feat: restructure as pnpm monorepo with Tauri desktop shell

- Migrate to packages/web + packages/desktop workspace layout via git mv
- Add Tauri v2 desktop shell with @tauri-apps/plugin-http for CORS bypass
- Configure Turborepo with package-level dependsOn build graph
- Add semantic-release with exec plugin for GHA output and disabled PR comments
- Fix http:default capability scope to allow all HTTP/HTTPS origins
- Add Vite Tauri integration (clearScreen, TAURI_DEV_HOST, target, envPrefix)
- Add semantic-release.yml and release.yml GitHub Actions workflows
- Fix all Biome lint errors (noArrayIndexKey, noNonNullAssertion, button types)
This commit is contained in:
Offending Commit
2026-04-24 16:52:40 -05:00
parent 9a74182f97
commit 92c4dfd3dd
152 changed files with 14088 additions and 4774 deletions

View File

@@ -0,0 +1,134 @@
import { FormModal } from "@/components/shared/FormModal";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Caption } from "@/components/ui/typography";
import { COLOR } from "@/lib/constants";
import type { UseMutationResult } from "@tanstack/react-query";
import { useState } from "react";
import { z } from "zod";
const schema = z.object({
observer: z.string().min(1, "Observer peer ID is required"),
observed: z.string().optional(),
session_id: z.string().optional(),
});
interface Props {
open: boolean;
workspaceId: string;
onClose: () => void;
mutation: UseMutationResult<
void,
Error,
{ observer: string; observed?: string | null; dream_type: "omni"; session_id?: string | null }
>;
}
export function ScheduleDreamModal({ open, onClose, mutation }: Props) {
const [observer, setObserver] = useState("");
const [observed, setObserved] = useState("");
const [sessionId, setSessionId] = useState("");
const [validationError, setValidationError] = useState("");
const reset = () => {
setObserver("");
setObserved("");
setSessionId("");
setValidationError("");
};
const handleSubmit = async (e: React.SyntheticEvent<HTMLFormElement>) => {
e.preventDefault();
const result = schema.safeParse({
observer,
observed: observed || undefined,
session_id: sessionId || undefined,
});
if (!result.success) {
setValidationError(result.error.errors[0].message);
return;
}
await mutation.mutateAsync({
observer: result.data.observer,
observed: result.data.observed ?? null,
dream_type: "omni",
session_id: result.data.session_id ?? null,
});
reset();
onClose();
};
return (
<FormModal
open={open}
title="Schedule Dream"
onClose={() => {
reset();
onClose();
}}
>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label className="mb-1.5">
Observer peer ID <span style={{ color: COLOR.destructive }}>*</span>
</Label>
<Input
value={observer}
onChange={(e) => {
setObserver(e.target.value);
setValidationError("");
}}
placeholder="peer_id"
/>
</div>
<div>
<Label className="mb-1.5">
Observed peer ID <Caption as="span"> (optional, defaults to observer)</Caption>
</Label>
<Input
value={observed}
onChange={(e) => setObserved(e.target.value)}
placeholder="peer_id"
/>
</div>
<div>
<Label className="mb-1.5">
Session ID <Caption as="span"> (optional)</Caption>
</Label>
<Input
value={sessionId}
onChange={(e) => setSessionId(e.target.value)}
placeholder="session_id"
/>
</div>
{validationError && (
<Caption as="p" style={{ color: COLOR.destructive }}>
{validationError}
</Caption>
)}
{mutation.error && (
<Caption as="p" style={{ color: COLOR.destructive }}>
{mutation.error.message}
</Caption>
)}
<div className="flex justify-end gap-2 pt-2">
<Button
type="button"
variant="surface"
size="sm"
onClick={() => {
reset();
onClose();
}}
>
Cancel
</Button>
<Button type="submit" variant="accent" size="sm" disabled={mutation.isPending}>
{mutation.isPending ? "Scheduling..." : "Schedule"}
</Button>
</div>
</form>
</FormModal>
);
}

View File

@@ -0,0 +1,217 @@
import { useCreateWebhook, useDeleteWebhook, useTestWebhook, useWebhooks } from "@/api/queries";
import { ConfirmDialog } from "@/components/shared/ConfirmDialog";
import { ErrorAlert } from "@/components/shared/ErrorAlert";
import { PageLoader } from "@/components/shared/LoadingSpinner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Body, Muted, PageTitle, SectionHeading } from "@/components/ui/typography";
import { COLOR } from "@/lib/constants";
import { Link } from "@tanstack/react-router";
import { open } from "@tauri-apps/plugin-shell";
import { AnimatePresence, motion } from "framer-motion";
import { ArrowLeft, ExternalLink, Plus, Trash2, Webhook, Zap } from "lucide-react";
import { useState } from "react";
import { z } from "zod";
const urlSchema = z.string().url("Must be a valid URL");
interface Props {
workspaceId: string;
}
export function WebhookManager({ workspaceId }: Props) {
const { data: webhooks, isLoading, error } = useWebhooks(workspaceId);
const createWebhook = useCreateWebhook(workspaceId);
const deleteWebhook = useDeleteWebhook(workspaceId);
const testWebhook = useTestWebhook(workspaceId);
const [url, setUrl] = useState("");
const [urlError, setUrlError] = useState("");
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
const [testResult, setTestResult] = useState<string | null>(null);
const handleCreate = async (e: React.SyntheticEvent<HTMLFormElement>) => {
e.preventDefault();
const result = urlSchema.safeParse(url);
if (!result.success) {
setUrlError(result.error.errors[0].message);
return;
}
await createWebhook.mutateAsync(url);
setUrl("");
setUrlError("");
};
const handleTest = async () => {
const data = await testWebhook.mutateAsync();
setTestResult(JSON.stringify(data, null, 2));
setTimeout(() => setTestResult(null), 5000);
};
const list = Array.isArray(webhooks) ? webhooks : [];
return (
<div className="page-container">
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }}>
<Link
to="/workspaces/$workspaceId"
params={{ workspaceId } as never}
className="inline-flex items-center gap-1.5 text-xs mb-4 transition-colors"
style={{ color: "var(--text-3)" }}
>
<ArrowLeft className="w-3 h-3" strokeWidth={1.5} />
{workspaceId}
</Link>
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<Webhook className="w-5 h-5" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
<PageTitle>Webhooks</PageTitle>
</div>
<Button variant="accent" size="sm" onClick={handleTest} disabled={testWebhook.isPending}>
<Zap className="w-3.5 h-3.5" strokeWidth={2} />
{testWebhook.isPending ? "Firing..." : "Test emit"}
</Button>
</div>
<Body className="leading-none">Event webhook endpoints for this workspace</Body>
</motion.div>
<div className="mt-8 space-y-4">
<ErrorAlert error={error instanceof Error ? error : null} />
{isLoading && <PageLoader />}
{/* Add webhook form */}
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
className="rounded-xl p-5 theme-card"
>
<SectionHeading>
<Plus className="w-3.5 h-3.5 inline mr-1.5" strokeWidth={2} />
Add endpoint
</SectionHeading>
<form onSubmit={handleCreate} className="flex gap-2">
<div className="flex-1">
<Input
value={url}
onChange={(e) => {
setUrl(e.target.value);
setUrlError("");
}}
placeholder="https://your-server.com/webhook"
/>
{urlError && (
<p className="text-xs mt-1" style={{ color: COLOR.destructive }}>
{urlError}
</p>
)}
</div>
<Button type="submit" variant="accent" disabled={createWebhook.isPending}>
{createWebhook.isPending ? "Adding..." : "Add"}
</Button>
</form>
</motion.div>
{/* Test result */}
<AnimatePresence>
{testResult && (
<motion.div
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
className="rounded-xl p-4 text-xs font-mono overflow-auto"
style={{
background: COLOR.successDim,
border: `1px solid ${COLOR.successBorder}`,
color: COLOR.success,
}}
>
{testResult}
</motion.div>
)}
</AnimatePresence>
{/* Webhook list */}
{!isLoading && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="rounded-xl theme-card overflow-hidden"
>
{list.length === 0 ? (
<div className="p-8 text-center">
<Webhook
className="w-8 h-8 mx-auto mb-2 opacity-20"
style={{ color: "var(--text-3)" }}
strokeWidth={1.5}
/>
<Muted>No webhook endpoints yet.</Muted>
</div>
) : (
<div className="divide-y" style={{ borderColor: "var(--border)" }}>
{list.map((wh, i) => (
<motion.div
key={(wh as { id: string }).id}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: i * 0.04 }}
className="flex items-center justify-between px-5 py-3 gap-4"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span
className="text-xs font-mono truncate"
style={{ color: "var(--accent-text)" }}
>
{(wh as { url: string }).url}
</span>
<button
type="button"
onClick={() => void open((wh as { url: string }).url)}
className="flex-shrink-0"
style={{
color: "var(--text-4)",
background: "none",
border: "none",
cursor: "pointer",
padding: 0,
}}
>
<ExternalLink className="w-3 h-3" strokeWidth={1.5} />
</button>
</div>
<span className="text-xs font-mono" style={{ color: "var(--text-4)" }}>
{(wh as { id: string }).id}
</span>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => setDeleteTarget((wh as { id: string }).id)}
aria-label="Delete webhook"
>
<Trash2 className="w-3.5 h-3.5" strokeWidth={1.5} />
</Button>
</motion.div>
))}
</div>
)}
</motion.div>
)}
</div>
<ConfirmDialog
open={Boolean(deleteTarget)}
title="Delete webhook"
description="This endpoint will stop receiving events immediately."
confirmLabel="Delete"
onConfirm={async () => {
if (deleteTarget) await deleteWebhook.mutateAsync(deleteTarget);
setDeleteTarget(null);
}}
onCancel={() => setDeleteTarget(null)}
loading={deleteWebhook.isPending}
/>
</div>
);
}

View File

@@ -0,0 +1,342 @@
import { useDeleteWorkspace, useQueueStatus, useScheduleDream, useWorkspace } from "@/api/queries";
import { ConfirmDialog } from "@/components/shared/ConfirmDialog";
import { ErrorAlert } from "@/components/shared/ErrorAlert";
import { JsonViewer } from "@/components/shared/JsonViewer";
import { PageLoader } from "@/components/shared/LoadingSpinner";
import { Button } from "@/components/ui/button";
import { Body, Caption, PageTitle, SectionHeading } from "@/components/ui/typography";
import { ScheduleDreamModal } from "@/components/workspaces/ScheduleDreamModal";
import { COLOR } from "@/lib/constants";
import { Link, useNavigate, useParams } from "@tanstack/react-router";
import { AnimatePresence, motion } from "framer-motion";
import {
ArrowLeft,
Boxes,
ChevronDown,
CircleDot,
Lightbulb,
MessageSquare,
Trash2,
Users,
Webhook,
Zap,
} from "lucide-react";
import { useState } from "react";
const NAV_SECTIONS = [
{
label: "Peers",
icon: Users,
to: "peers" as const,
description: "Browse peer identities and memory",
},
{
label: "Sessions",
icon: MessageSquare,
to: "sessions" as const,
description: "View conversation sessions",
},
{
label: "Conclusions",
icon: Lightbulb,
to: "conclusions" as const,
description: "Browse memory conclusions",
},
{
label: "Webhooks",
icon: Webhook,
to: "webhooks" as const,
description: "Manage event webhooks",
},
] as const;
export function WorkspaceDetail() {
const { workspaceId } = useParams({ strict: false }) as { workspaceId: string };
const navigate = useNavigate();
const { data: workspace, isLoading, error } = useWorkspace(workspaceId);
const { data: queue } = useQueueStatus(workspaceId);
const deleteWorkspace = useDeleteWorkspace();
const scheduleDream = useScheduleDream(workspaceId);
const [confirmDelete, setConfirmDelete] = useState(false);
const [dreamOpen, setDreamOpen] = useState(false);
const [sessionsExpanded, setSessionsExpanded] = useState(false);
const handleDelete = async () => {
await deleteWorkspace.mutateAsync(workspaceId);
navigate({ to: "/workspaces" as never });
};
return (
<div className="page-container page-container--wide">
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }}>
<Link
to="/workspaces"
className="inline-flex items-center gap-1.5 text-xs mb-4 transition-colors"
style={{ color: "var(--text-3)" }}
>
<ArrowLeft className="w-3 h-3" strokeWidth={1.5} />
Workspaces
</Link>
<div className="flex items-start justify-between gap-4 mb-1">
<div className="flex items-center gap-2 min-w-0">
<Boxes
className="w-5 h-5 flex-shrink-0"
style={{ color: "var(--accent)" }}
strokeWidth={1.5}
/>
<PageTitle className="font-mono break-all">{workspaceId}</PageTitle>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<Button variant="accent" size="sm" onClick={() => setDreamOpen(true)}>
<Zap className="w-3.5 h-3.5" strokeWidth={2} />
Schedule Dream
</Button>
<Button variant="destructive" size="sm" onClick={() => setConfirmDelete(true)}>
<Trash2 className="w-3.5 h-3.5" strokeWidth={2} />
Delete
</Button>
</div>
</div>
<Body className="leading-none">Workspace overview</Body>
</motion.div>
<div className="mt-8">
<ErrorAlert error={error instanceof Error ? error : null} />
{isLoading && <PageLoader />}
{!isLoading && workspace && (
<div className="space-y-4">
{/* Nav cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
{NAV_SECTIONS.map((s, i) => {
const Icon = s.icon;
return (
<motion.div
key={s.to}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.06, type: "spring", stiffness: 300, damping: 25 }}
>
<Link
to={`/workspaces/$workspaceId/${s.to}` as never}
params={{ workspaceId } as never}
className="block rounded-xl p-5 group transition-all theme-card"
>
<Icon
className="w-5 h-5 mb-3"
style={{ color: "var(--accent)" }}
strokeWidth={1.5}
/>
<SectionHeading className="mb-0.5">{s.label}</SectionHeading>
<Caption as="p">{s.description}</Caption>
</Link>
</motion.div>
);
})}
</div>
{/* Queue status */}
{queue && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.28 }}
className="rounded-xl p-5 theme-card"
>
<div className="flex items-center justify-between mb-4">
<SectionHeading className="mb-0">Queue Status</SectionHeading>
<div className="flex items-center gap-1.5">
{queue.pending_work_units > 0 ? (
<motion.div
animate={{ opacity: [0.5, 1, 0.5] }}
transition={{ duration: 1.5, repeat: Number.POSITIVE_INFINITY }}
>
<CircleDot
className="w-3.5 h-3.5"
style={{ color: COLOR.warning }}
strokeWidth={2}
/>
</motion.div>
) : (
<CircleDot
className="w-3.5 h-3.5"
style={{ color: COLOR.success }}
strokeWidth={2}
/>
)}
<span
className="text-xs font-medium"
style={{
color: queue.pending_work_units > 0 ? COLOR.warning : COLOR.success,
}}
>
{queue.pending_work_units === 0
? "Idle"
: `${queue.pending_work_units} pending`}
</span>
</div>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-4">
{(
[
"total_work_units",
"completed_work_units",
"in_progress_work_units",
"pending_work_units",
] as const
).map((key) => (
<div key={key}>
<div
className="text-2xl font-semibold font-mono"
style={{ color: "var(--text-1)" }}
>
{queue[key]}
</div>
<div className="text-xs capitalize mt-0.5" style={{ color: "var(--text-3)" }}>
{key.replace(/_work_units$/, "").replace(/_/g, " ")}
</div>
</div>
))}
</div>
{/* Per-session breakdown */}
{queue.sessions && Object.keys(queue.sessions).length > 0 && (
<div>
<button
type="button"
onClick={() => setSessionsExpanded((v) => !v)}
className="flex items-center gap-1.5 text-xs font-medium w-full text-left"
style={{ color: "var(--text-3)" }}
>
<motion.div
animate={{ rotate: sessionsExpanded ? 0 : -90 }}
transition={{ duration: 0.15 }}
>
<ChevronDown className="w-3.5 h-3.5" strokeWidth={2} />
</motion.div>
{Object.keys(queue.sessions).length} session
{Object.keys(queue.sessions).length !== 1 ? "s" : ""}
</button>
<AnimatePresence initial={false}>
{sessionsExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div
className="mt-3 rounded-lg overflow-hidden"
style={{ border: "1px solid var(--border)" }}
>
<table className="w-full text-xs">
<thead>
<tr
style={{
background: "var(--bg-3)",
borderBottom: "1px solid var(--border)",
}}
>
{["Session", "Total", "Done", "Active", "Pending"].map((h) => (
<th
key={h}
className={`py-2 px-3 font-medium text-left ${h !== "Session" ? "text-right" : ""}`}
style={{ color: "var(--text-3)" }}
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{Object.entries(queue.sessions).map(([sid, s], i) => (
<tr
key={sid}
style={{
borderTop: i > 0 ? "1px solid var(--border)" : undefined,
}}
>
<td className="py-1.5 px-3">
<Link
to={"/workspaces/$workspaceId/sessions/$sessionId" as never}
params={{ workspaceId, sessionId: sid } as never}
className="font-mono truncate block max-w-[180px] hover:underline"
style={{ color: "var(--accent-text)" }}
>
{sid}
</Link>
</td>
<td
className="py-1.5 px-3 text-right font-mono"
style={{ color: "var(--text-2)" }}
>
{s.total_work_units}
</td>
<td
className="py-1.5 px-3 text-right font-mono"
style={{ color: COLOR.success }}
>
{s.completed_work_units}
</td>
<td
className="py-1.5 px-3 text-right font-mono"
style={{ color: COLOR.warning }}
>
{s.in_progress_work_units}
</td>
<td
className="py-1.5 px-3 text-right font-mono"
style={{ color: "var(--text-3)" }}
>
{s.pending_work_units}
</td>
</tr>
))}
</tbody>
</table>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)}
</motion.div>
)}
{/* Metadata */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.38 }}
className="rounded-xl p-5 theme-card"
>
<SectionHeading>Metadata</SectionHeading>
<JsonViewer data={workspace.metadata} />
</motion.div>
</div>
)}
</div>
<ConfirmDialog
open={confirmDelete}
title="Delete workspace"
description={`This will permanently delete workspace "${workspaceId}" and all its data. This cannot be undone.`}
confirmLabel="Delete workspace"
onConfirm={handleDelete}
onCancel={() => setConfirmDelete(false)}
loading={deleteWorkspace.isPending}
/>
<ScheduleDreamModal
open={dreamOpen}
workspaceId={workspaceId}
onClose={() => setDreamOpen(false)}
mutation={scheduleDream}
/>
</div>
);
}

View File

@@ -0,0 +1,156 @@
import { useWorkspaces } from "@/api/queries";
import type { components } from "@/api/schema.d.ts";
import { EmptyState } from "@/components/shared/EmptyState";
import { ErrorAlert } from "@/components/shared/ErrorAlert";
import { PageLoader } from "@/components/shared/LoadingSpinner";
import { Pagination } from "@/components/shared/Pagination";
import { SortControl, type SortDir } from "@/components/shared/SortControl";
import { MonoCaption, Muted, PageTitle } from "@/components/ui/typography";
import { COLOR } from "@/lib/constants";
import { useNavigate } from "@tanstack/react-router";
import { type Variants, motion } from "framer-motion";
import { Boxes, ChevronRight, Clock } from "lucide-react";
import { useMemo, useState } from "react";
type Workspace = components["schemas"]["Workspace"];
const SORT_OPTIONS = [
{ value: "created_at", label: "Newest" },
{ value: "id", label: "ID" },
];
const container: Variants = {
hidden: { opacity: 0 },
show: { opacity: 1, transition: { staggerChildren: 0.06 } },
};
const item: Variants = {
hidden: { opacity: 0, y: 12 },
show: { opacity: 1, y: 0, transition: { type: "spring", stiffness: 300, damping: 25 } },
};
export function WorkspaceList() {
const [page, setPage] = useState(1);
const [sortField, setSortField] = useState("created_at");
const [sortDir, setSortDir] = useState<SortDir>("desc");
const navigate = useNavigate();
const { data, isLoading, error } = useWorkspaces(page);
const workspaces: Workspace[] = (data as { items?: Workspace[] } | undefined)?.items ?? [];
const totalPages = (data as { pages?: number } | undefined)?.pages ?? 1;
const total = (data as { total?: number } | undefined)?.total ?? 0;
const sorted = useMemo(() => {
return [...workspaces].sort((a, b) => {
let cmp = 0;
if (sortField === "created_at") {
cmp = new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
} else if (sortField === "id") {
cmp = a.id.localeCompare(b.id);
}
return sortDir === "asc" ? cmp : -cmp;
});
}, [workspaces, sortField, sortDir]);
function handleSort(field: string, dir: SortDir) {
setSortField(field);
setSortDir(dir);
}
return (
<div className="page-container">
<motion.div
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.35 }}
className="mb-6"
>
<div className="flex items-center gap-2 mb-1">
<Boxes className="w-5 h-5" style={{ color: COLOR.accent }} strokeWidth={1.5} />
<PageTitle>Workspaces</PageTitle>
{total > 0 && (
<span
className="ml-1 text-xs font-mono px-2 py-0.5 rounded-full"
style={{
background: COLOR.accentSubtle,
color: COLOR.accentText,
border: `1px solid ${COLOR.accentBorder}`,
}}
>
{total}
</span>
)}
<div className="ml-auto">
<SortControl
options={SORT_OPTIONS}
field={sortField}
dir={sortDir}
onChange={handleSort}
/>
</div>
</div>
<Muted>All workspaces in your Honcho instance</Muted>
</motion.div>
<ErrorAlert error={error instanceof Error ? error : null} />
{isLoading && <PageLoader />}
{!isLoading && workspaces.length === 0 && (
<EmptyState
icon={Boxes}
title="No workspaces found"
description="No workspaces exist yet in this Honcho instance."
/>
)}
{!isLoading && sorted.length > 0 && (
<>
<motion.div variants={container} initial="hidden" animate="show" className="space-y-2">
{sorted.map((ws) => (
<motion.button
key={ws.id}
variants={item}
onClick={() =>
navigate({
to: "/workspaces/$workspaceId",
params: { workspaceId: ws.id } as never,
})
}
className="w-full text-left rounded-xl px-5 py-4 group transition-all"
style={{
background: COLOR.cardBaseBg,
border: `1px solid ${COLOR.cardBaseBorder}`,
}}
whileHover={{
background: COLOR.accentDimHover,
borderColor: COLOR.accentBorder,
x: 2,
}}
>
<div className="flex items-center justify-between">
<span
className="font-mono text-sm font-medium"
style={{ color: COLOR.accentSoft }}
>
{ws.id}
</span>
<ChevronRight
className="w-4 h-4 opacity-30 group-hover:opacity-70 transition-opacity"
style={{ color: COLOR.accent }}
strokeWidth={1.5}
/>
</div>
{ws.created_at && (
<div className="flex items-center gap-1.5 mt-2">
<Clock className="w-3 h-3" style={{ color: COLOR.dimIcon }} strokeWidth={1.5} />
<MonoCaption>{new Date(ws.created_at).toLocaleString()}</MonoCaption>
</div>
)}
</motion.button>
))}
</motion.div>
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
</>
)}
</div>
);
}