feat: component library, markdown renderer, multi-workspace dashboard

- Add PeerCardViewer with collapsible ALL_CAPS sections and title-case metadata table
- Add MarkdownRenderer (react-markdown + remark-gfm) with TimestampChip (Luxon)
- Add Badge, Card, Collapsible shadcn/ui components
- Rebuild Dashboard with per-workspace queue table and aggregate stats
- Add metadata.source chip to SessionList, observe_me badge to PeerList
- Make session_id in ConclusionBrowser a clickable link
- Fix sessionPeers pagination crash (unwrap .items from Page_Peer_ response)
- Add COLOR constants (src/lib/constants.ts) and localized formatCount (Intl)
- Fix dark mode contrast: text-2/3/4 vars, global font-size baseline
This commit is contained in:
Offending Commit
2026-04-24 13:47:22 -05:00
parent 45e0183439
commit 91c78915e5
18 changed files with 2614 additions and 381 deletions

View File

@@ -17,14 +17,21 @@
"dependencies": { "dependencies": {
"@fontsource/dm-mono": "^5.2.7", "@fontsource/dm-mono": "^5.2.7",
"@fontsource/dm-sans": "^5.2.8", "@fontsource/dm-sans": "^5.2.8",
"@radix-ui/react-collapsible": "^1.1.12",
"@tailwindcss/vite": "^4.2.4", "@tailwindcss/vite": "^4.2.4",
"@tanstack/react-query": "^5.74.4", "@tanstack/react-query": "^5.74.4",
"@tanstack/react-router": "^1.120.3", "@tanstack/react-router": "^1.120.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.38.0", "framer-motion": "^12.38.0",
"lucide-react": "^1.11.0", "lucide-react": "^1.11.0",
"luxon": "^3.7.2",
"openapi-fetch": "^0.13.5", "openapi-fetch": "^0.13.5",
"react": "^19.2.5", "react": "^19.2.5",
"react-dom": "^19.2.5", "react-dom": "^19.2.5",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.4", "tailwindcss": "^4.2.4",
"zod": "^3.24.3" "zod": "^3.24.3"
}, },
@@ -34,6 +41,7 @@
"@testing-library/jest-dom": "^6.6.3", "@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
"@types/luxon": "^3.7.1",
"@types/node": "^25.6.0", "@types/node": "^25.6.0",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",

1109
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,21 +1,21 @@
import { useState } from "react";
import { Link, useParams } from "@tanstack/react-router";
import { motion, AnimatePresence } from "framer-motion";
import { z } from "zod";
import { Lightbulb, Search, X, Clock, ArrowLeft, Eye, Plus, Trash2 } from "lucide-react";
import { import {
useConclusions, useConclusions,
useQueryConclusions,
useCreateConclusion, useCreateConclusion,
useDeleteConclusion, useDeleteConclusion,
useQueryConclusions,
} from "@/api/queries"; } from "@/api/queries";
import { PageLoader } from "@/components/shared/LoadingSpinner";
import { ErrorAlert } from "@/components/shared/ErrorAlert";
import { Pagination } from "@/components/shared/Pagination";
import { EmptyState } from "@/components/shared/EmptyState";
import { FormModal } from "@/components/shared/FormModal";
import { ConfirmDialog } from "@/components/shared/ConfirmDialog";
import type { components } from "@/api/schema.d.ts"; import type { components } from "@/api/schema.d.ts";
import { ConfirmDialog } from "@/components/shared/ConfirmDialog";
import { EmptyState } from "@/components/shared/EmptyState";
import { ErrorAlert } from "@/components/shared/ErrorAlert";
import { FormModal } from "@/components/shared/FormModal";
import { PageLoader } from "@/components/shared/LoadingSpinner";
import { Pagination } from "@/components/shared/Pagination";
import { Link, useParams } from "@tanstack/react-router";
import { AnimatePresence, motion } from "framer-motion";
import { ArrowLeft, Clock, Eye, Lightbulb, Plus, Search, Trash2, X } from "lucide-react";
import { useState } from "react";
import { z } from "zod";
type Conclusion = components["schemas"]["Conclusion"]; type Conclusion = components["schemas"]["Conclusion"];
@@ -58,7 +58,9 @@ export function ConclusionBrowser() {
const total = (data as { total?: number } | undefined)?.total ?? 0; const total = (data as { total?: number } | undefined)?.total ?? 0;
const displayedConclusions: Conclusion[] = activeSearch const displayedConclusions: Conclusion[] = activeSearch
? Array.isArray(searchResults) ? searchResults : [] ? Array.isArray(searchResults)
? searchResults
: []
: conclusions; : conclusions;
function handleSearch(e: React.SyntheticEvent<HTMLFormElement>) { function handleSearch(e: React.SyntheticEvent<HTMLFormElement>) {
@@ -144,7 +146,10 @@ export function ConclusionBrowser() {
initial={{ opacity: 0, scale: 0.8 }} initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }} exit={{ opacity: 0, scale: 0.8 }}
onClick={() => { setActiveSearch(""); setSearchQuery(""); }} onClick={() => {
setActiveSearch("");
setSearchQuery("");
}}
className="px-3 py-2.5 rounded-xl text-sm transition-all" className="px-3 py-2.5 rounded-xl text-sm transition-all"
style={{ style={{
background: "var(--surface)", background: "var(--surface)",
@@ -182,8 +187,8 @@ export function ConclusionBrowser() {
className="text-xs font-mono mb-3" className="text-xs font-mono mb-3"
style={{ color: "var(--text-4)" }} style={{ color: "var(--text-4)" }}
> >
{displayedConclusions.length} result{displayedConclusions.length !== 1 ? "s" : ""}{" "} {displayedConclusions.length} result{displayedConclusions.length !== 1 ? "s" : ""} for
for &ldquo;{activeSearch}&rdquo; &ldquo;{activeSearch}&rdquo;
</motion.p> </motion.p>
)} )}
<div className="space-y-3"> <div className="space-y-3">
@@ -201,7 +206,10 @@ export function ConclusionBrowser() {
}} }}
> >
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<p className="text-sm leading-relaxed whitespace-pre-wrap flex-1" style={{ color: "var(--text-2)" }}> <p
className="text-sm leading-relaxed whitespace-pre-wrap flex-1"
style={{ color: "var(--text-2)" }}
>
{c.content} {c.content}
</p> </p>
<button <button
@@ -224,15 +232,32 @@ export function ConclusionBrowser() {
</div> </div>
{c.observed_id && ( {c.observed_id && (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span className="text-xs" style={{ color: "var(--text-4)" }}></span> <span className="text-xs" style={{ color: "var(--text-4)" }}>
</span>
<span className="text-xs font-mono" style={{ color: "var(--text-4)" }}> <span className="text-xs font-mono" style={{ color: "var(--text-4)" }}>
{c.observed_id} {c.observed_id}
</span> </span>
</div> </div>
)} )}
{c.session_id && (
<Link
to={"/workspaces/$workspaceId/sessions/$sessionId" as never}
params={{ workspaceId, sessionId: c.session_id } as never}
onClick={(e) => e.stopPropagation()}
className="flex items-center gap-1 text-xs font-mono hover:underline"
style={{ color: "var(--accent-text)" }}
>
{c.session_id}
</Link>
)}
{c.created_at && ( {c.created_at && (
<div className="flex items-center gap-1 ml-auto"> <div className="flex items-center gap-1 ml-auto">
<Clock className="w-3 h-3" style={{ color: "var(--text-4)" }} strokeWidth={1.5} /> <Clock
className="w-3 h-3"
style={{ color: "var(--text-4)" }}
strokeWidth={1.5}
/>
<span className="text-xs font-mono" style={{ color: "var(--text-4)" }}> <span className="text-xs font-mono" style={{ color: "var(--text-4)" }}>
{new Date(c.created_at).toLocaleString()} {new Date(c.created_at).toLocaleString()}
</span> </span>
@@ -284,15 +309,26 @@ function CreateConclusionModal({
}: { }: {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
onSubmit: (v: { observer_id: string; observed_id: string; content: string; session_id?: string | null }) => Promise<void>; onSubmit: (v: {
observer_id: string;
observed_id: string;
content: string;
session_id?: string | null;
}) => Promise<void>;
loading: boolean; loading: boolean;
error?: string; error?: string;
}) { }) {
const [fields, setFields] = useState({ observer_id: "", observed_id: "", content: "", session_id: "" }); const [fields, setFields] = useState({
observer_id: "",
observed_id: "",
content: "",
session_id: "",
});
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({}); const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
const set = (k: keyof typeof fields) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => const set =
setFields((f) => ({ ...f, [k]: e.target.value })); (k: keyof typeof fields) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
setFields((f) => ({ ...f, [k]: e.target.value }));
const handleSubmit = async (e: React.SyntheticEvent<HTMLFormElement>) => { const handleSubmit = async (e: React.SyntheticEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
@@ -327,7 +363,9 @@ function CreateConclusionModal({
className="theme-input w-full text-sm px-3 py-2 rounded-lg" className="theme-input w-full text-sm px-3 py-2 rounded-lg"
/> />
{validationErrors[field] && ( {validationErrors[field] && (
<p className="text-xs mt-1" style={{ color: "#f87171" }}>{validationErrors[field]}</p> <p className="text-xs mt-1" style={{ color: "#f87171" }}>
{validationErrors[field]}
</p>
)} )}
</div> </div>
))} ))}
@@ -343,7 +381,9 @@ function CreateConclusionModal({
className="theme-input w-full text-sm px-3 py-2 rounded-lg resize-y" className="theme-input w-full text-sm px-3 py-2 rounded-lg resize-y"
/> />
{validationErrors.content && ( {validationErrors.content && (
<p className="text-xs mt-1" style={{ color: "#f87171" }}>{validationErrors.content}</p> <p className="text-xs mt-1" style={{ color: "#f87171" }}>
{validationErrors.content}
</p>
)} )}
</div> </div>
<div> <div>
@@ -357,13 +397,21 @@ function CreateConclusionModal({
className="theme-input w-full text-sm px-3 py-2 rounded-lg" className="theme-input w-full text-sm px-3 py-2 rounded-lg"
/> />
</div> </div>
{error && <p className="text-xs" style={{ color: "#f87171" }}>{error}</p>} {error && (
<p className="text-xs" style={{ color: "#f87171" }}>
{error}
</p>
)}
<div className="flex justify-end gap-2 pt-2"> <div className="flex justify-end gap-2 pt-2">
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
className="px-3 py-1.5 text-sm rounded-lg" className="px-3 py-1.5 text-sm rounded-lg"
style={{ background: "var(--surface)", border: "1px solid var(--border)", color: "var(--text-2)" }} style={{
background: "var(--surface)",
border: "1px solid var(--border)",
color: "var(--text-2)",
}}
> >
Cancel Cancel
</button> </button>
@@ -371,7 +419,11 @@ function CreateConclusionModal({
type="submit" type="submit"
disabled={loading} disabled={loading}
className="px-3 py-1.5 text-sm rounded-lg font-medium disabled:opacity-50" className="px-3 py-1.5 text-sm rounded-lg font-medium disabled:opacity-50"
style={{ background: "var(--accent-dim)", border: "1px solid var(--accent-border)", color: "var(--accent-text)" }} style={{
background: "var(--accent-dim)",
border: "1px solid var(--accent-border)",
color: "var(--accent-text)",
}}
> >
{loading ? "Creating..." : "Create"} {loading ? "Creating..." : "Create"}
</button> </button>

View File

@@ -1,63 +1,158 @@
import { useState } from "react"; import { useQueueStatus, useWorkspaces } from "@/api/queries";
import type { components } from "@/api/schema.d.ts";
import { ErrorAlert } from "@/components/shared/ErrorAlert";
import { PageLoader } from "@/components/shared/LoadingSpinner";
import { COLOR } from "@/lib/constants";
import { formatCount } from "@/lib/utils";
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { Boxes, Activity, LayoutDashboard } from "lucide-react"; import { Activity, Boxes, ChevronRight, CircleDot, LayoutDashboard } from "lucide-react";
import { useWorkspaces, useQueueStatus } from "@/api/queries"; import { useState } from "react";
import { PageLoader } from "@/components/shared/LoadingSpinner";
import { ErrorAlert } from "@/components/shared/ErrorAlert";
function QueueCard({ workspaceId }: { workspaceId: string }) { type QueueStatus = components["schemas"]["QueueStatus"];
// ─── Per-workspace queue row ─────────────────────────────────────────────────
function WorkspaceQueueRow({ workspaceId }: { workspaceId: string }) {
const { data, isLoading } = useQueueStatus(workspaceId); const { data, isLoading } = useQueueStatus(workspaceId);
if (isLoading) const pending = data?.pending_work_units ?? 0;
return ( const active = data?.in_progress_work_units ?? 0;
<div className="rounded-xl p-5 theme-card"> const done = data?.completed_work_units ?? 0;
<PageLoader /> const total = data?.total_work_units ?? 0;
</div> const isActive = active > 0 || pending > 0;
);
if (!data) return null;
const pending = data.pending_work_units;
return ( return (
<div className="rounded-xl p-5 theme-card"> <tr
<div className="flex items-center justify-between mb-4"> style={{
<div className="flex items-center gap-2"> borderTop: "1px solid var(--border)",
<Activity className="w-4 h-4" style={{ color: "var(--accent)" }} strokeWidth={1.5} /> background: isActive ? COLOR.warningDim : undefined,
<h3 className="text-sm font-medium" style={{ color: "var(--text-1)" }}>Queue Status</h3> }}
</div> >
<span <td className="py-2 px-4">
className="text-xs font-mono px-2 py-0.5 rounded-full" <Link
style={{ to="/workspaces/$workspaceId"
background: pending === 0 ? "rgba(52,211,153,0.1)" : "rgba(245,158,11,0.1)", params={{ workspaceId } as never}
color: pending === 0 ? "#34d399" : "#f59e0b", className="flex items-center gap-2 group"
border: `1px solid ${pending === 0 ? "rgba(52,211,153,0.2)" : "rgba(245,158,11,0.2)"}`,
}}
> >
{pending === 0 ? "Idle" : "Active"} <span
</span> className="font-mono text-xs truncate max-w-[200px] group-hover:underline"
</div> style={{ color: "var(--accent-text)" }}
<div className="space-y-2"> >
{(["total_work_units", "completed_work_units", "in_progress_work_units", "pending_work_units"] as const).map((key) => ( {workspaceId}
<div key={key} className="flex justify-between text-xs"> </span>
<span className="capitalize" style={{ color: "var(--text-3)" }}> <ChevronRight
{key.replace(/_work_units$/, "").replace(/_/g, " ")} className="w-3 h-3 opacity-0 group-hover:opacity-60 transition-opacity flex-shrink-0"
</span> style={{ color: "var(--accent)" }}
<span className="font-mono font-medium" style={{ color: "var(--text-1)" }}> strokeWidth={2}
{data[key]} />
</Link>
</td>
<td className="py-2 px-4 text-right">
{isLoading ? (
<span className="text-xs font-mono" style={{ color: "var(--text-4)" }}>
</span>
) : (
<div className="flex items-center justify-end gap-1.5">
{isActive ? (
<motion.div
animate={{ opacity: [0.5, 1, 0.5] }}
transition={{ duration: 1.5, repeat: Number.POSITIVE_INFINITY }}
>
<CircleDot className="w-3 h-3" style={{ color: COLOR.warning }} strokeWidth={2} />
</motion.div>
) : (
<CircleDot className="w-3 h-3" style={{ color: COLOR.success }} strokeWidth={2} />
)}
<span
className="text-xs font-medium"
style={{ color: isActive ? COLOR.warning : COLOR.success }}
>
{isActive ? `${formatCount(pending + active)} pending` : "Idle"}
</span> </span>
</div> </div>
))} )}
</div> </td>
{(
[
{ val: total, color: "var(--text-2)" },
{ val: done, color: COLOR.success },
{ val: active, color: COLOR.warning },
{ val: pending, color: "var(--text-3)" },
] as Array<{ val: number; color: string }>
).map(({ val, color }, i) => (
<td
// biome-ignore lint/suspicious/noArrayIndexKey: static positional columns
key={i}
className="py-2 px-4 text-right font-mono text-xs"
style={{ color: isLoading ? "var(--text-4)" : color }}
>
{isLoading ? "—" : formatCount(val)}
</td>
))}
</tr>
);
}
// ─── Aggregate banner ─────────────────────────────────────────────────────────
// Each workspace row already called useQueueStatus — TanStack Query deduplicates
// the fetches so calling the same hooks here just reads from cache.
function GlobalQueueBanner({ workspaces }: { workspaces: Array<{ id: string }> }) {
const statuses = workspaces.map((ws) => {
// biome-ignore lint/correctness/useHookAtTopLevel: intentional map over stable list
const { data } = useQueueStatus(ws.id);
return data as QueueStatus | undefined;
});
const totalPending = statuses.reduce((s, d) => s + (d?.pending_work_units ?? 0), 0);
const totalActive = statuses.reduce((s, d) => s + (d?.in_progress_work_units ?? 0), 0);
const totalDone = statuses.reduce((s, d) => s + (d?.completed_work_units ?? 0), 0);
const allLoaded = statuses.every((d) => d !== undefined);
return (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{(
[
{ label: "Workspaces", value: workspaces.length, color: "var(--text-1)", always: true },
{ label: "Total done", value: totalDone, color: COLOR.success, always: false },
{ label: "Active", value: totalActive, color: COLOR.warning, always: false },
{
label: "Pending",
value: totalPending,
color: totalPending > 0 ? COLOR.warning : "var(--text-3)",
always: false,
},
] as Array<{ label: string; value: number; color: string; always: boolean }>
).map(({ label, value, color, always }) => (
<div key={label} className="rounded-xl p-4 theme-card">
<div
className="text-2xl font-semibold font-mono"
style={{ color: allLoaded || always ? color : "var(--text-4)" }}
>
{allLoaded || always ? formatCount(value) : "—"}
</div>
<div className="text-xs mt-0.5" style={{ color: "var(--text-3)" }}>
{label}
</div>
</div>
))}
</div> </div>
); );
} }
// ─── Main dashboard ───────────────────────────────────────────────────────────
export function Dashboard() { export function Dashboard() {
const [page] = useState(1); const [page] = useState(1);
const { data, isLoading, error } = useWorkspaces(page, 6); const { data, isLoading, error } = useWorkspaces(page, 50);
const workspaces = (data as { items?: Array<{ id: string; created_at?: string }> } | undefined)?.items ?? []; const workspaces = (
data as { items?: Array<{ id: string; created_at?: string }> } | undefined
)?.items ?? [];
const total = (data as { total?: number } | undefined)?.total ?? 0; const total = (data as { total?: number } | undefined)?.total ?? 0;
return ( return (
@@ -68,10 +163,29 @@ export function Dashboard() {
className="mb-8" className="mb-8"
> >
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<LayoutDashboard className="w-5 h-5" style={{ color: "var(--accent)" }} strokeWidth={1.5} /> <LayoutDashboard
<h1 className="text-xl font-semibold tracking-tight" style={{ color: "var(--text-1)" }}> className="w-5 h-5"
style={{ color: "var(--accent)" }}
strokeWidth={1.5}
/>
<h1
className="text-xl font-semibold tracking-tight"
style={{ color: "var(--text-1)" }}
>
Dashboard Dashboard
</h1> </h1>
{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} workspace{total !== 1 ? "s" : ""}
</span>
)}
</div> </div>
<p className="text-sm" style={{ color: "var(--text-2)" }}> <p className="text-sm" style={{ color: "var(--text-2)" }}>
Overview of your Honcho instance Overview of your Honcho instance
@@ -81,98 +195,90 @@ export function Dashboard() {
<ErrorAlert error={error instanceof Error ? error : null} /> <ErrorAlert error={error instanceof Error ? error : null} />
{isLoading && <PageLoader />} {isLoading && <PageLoader />}
{!isLoading && ( {!isLoading && workspaces.length > 0 && (
<div className="space-y-4"> <div className="space-y-4">
{/* Stat row */} {/* Aggregate stat row */}
<motion.div <motion.div
initial={{ opacity: 0, y: 8 }} initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.05 }} transition={{ delay: 0.05 }}
className="grid grid-cols-1 sm:grid-cols-3 gap-3"
> >
{[ <GlobalQueueBanner workspaces={workspaces} />
{ label: "Workspaces", value: total, icon: Boxes },
].map((stat) => {
const Icon = stat.icon;
return (
<div key={stat.label} className="rounded-xl p-5 theme-card">
<Icon className="w-5 h-5 mb-3" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
<div className="text-3xl font-semibold font-mono" style={{ color: "var(--text-1)" }}>
{stat.value}
</div>
<div className="text-xs mt-1" style={{ color: "var(--text-3)" }}>
{stat.label}
</div>
</div>
);
})}
</motion.div> </motion.div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4"> {/* Per-workspace queue table */}
{/* Workspace list */} <motion.div
<motion.div initial={{ opacity: 0, y: 8 }}
initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }}
animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.12 }}
transition={{ delay: 0.1 }} className="rounded-xl theme-card overflow-hidden"
className="rounded-xl p-5 theme-card" >
<div
className="flex items-center gap-2 px-4 py-3"
style={{ borderBottom: "1px solid var(--border)" }}
> >
<div className="flex items-center justify-between mb-4"> <Activity
<div className="flex items-center gap-2"> className="w-4 h-4"
<Boxes className="w-4 h-4" style={{ color: "var(--accent)" }} strokeWidth={1.5} /> style={{ color: "var(--accent)" }}
<h2 className="text-sm font-medium" style={{ color: "var(--text-1)" }}> strokeWidth={1.5}
Recent Workspaces />
</h2> <h2 className="text-sm font-medium" style={{ color: "var(--text-1)" }}>
</div> Queue Status
<Link </h2>
to="/workspaces" <span className="text-xs ml-1" style={{ color: "var(--text-4)" }}>
className="text-xs transition-colors" all workspaces · updates every 10s
style={{ color: "var(--accent-text)" }} </span>
> </div>
View all
</Link>
</div>
{workspaces.length === 0 ? ( <div className="overflow-x-auto">
<p className="text-sm" style={{ color: "var(--text-3)" }}>No workspaces found.</p> <table className="w-full text-xs">
) : ( <thead>
<div className="space-y-1"> <tr style={{ background: "var(--bg-3)" }}>
{["Workspace", "Status", "Total", "Done", "Active", "Pending"].map((h) => (
<th
key={h}
className={`py-2 px-4 font-medium text-left ${h !== "Workspace" && h !== "Status" ? "text-right" : ""}`}
style={{ color: "var(--text-3)" }}
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{workspaces.map((ws) => ( {workspaces.map((ws) => (
<Link <WorkspaceQueueRow key={ws.id} workspaceId={ws.id} />
key={ws.id}
to="/workspaces/$workspaceId"
params={{ workspaceId: ws.id } as never}
className="flex items-center justify-between py-2 px-3 rounded-lg transition-all group"
style={{ color: "var(--text-2)" }}
>
<span
className="font-mono text-xs truncate"
style={{ color: "var(--accent-text)" }}
>
{ws.id}
</span>
<span
className="text-xs opacity-0 group-hover:opacity-100 transition-opacity"
style={{ color: "var(--text-4)" }}
>
</span>
</Link>
))} ))}
</div> </tbody>
)} </table>
</motion.div> </div>
</motion.div>
{/* Queue for first workspace */} {total > workspaces.length && (
{workspaces[0] && ( <p className="text-xs text-center" style={{ color: "var(--text-4)" }}>
<motion.div Showing {workspaces.length} of {total} workspaces.{" "}
initial={{ opacity: 0, y: 8 }} <Link
animate={{ opacity: 1, y: 0 }} to="/workspaces"
transition={{ delay: 0.15 }} className="hover:underline"
style={{ color: "var(--accent-text)" }}
> >
<QueueCard workspaceId={workspaces[0].id} /> View all
</motion.div> </Link>
)} </p>
</div> )}
</div>
)}
{!isLoading && workspaces.length === 0 && (
<div className="rounded-xl p-10 text-center theme-card">
<Boxes
className="w-8 h-8 mx-auto mb-3"
style={{ color: "var(--text-4)" }}
strokeWidth={1}
/>
<p className="text-sm" style={{ color: "var(--text-3)" }}>
No workspaces found.
</p>
</div> </div>
)} )}
</div> </div>

View File

@@ -1,19 +1,21 @@
import { useState } from "react";
import { Link, useNavigate, useParams } from "@tanstack/react-router";
import { motion, AnimatePresence } from "framer-motion";
import { User, MessageCircle, Search, Save, X } from "lucide-react";
import { import {
usePeer, usePeer,
usePeerCard, usePeerCard,
usePeerContext, usePeerContext,
usePeerRepresentation, usePeerRepresentation,
useSetPeerCard,
useSearchPeer, useSearchPeer,
useSetPeerCard,
} from "@/api/queries"; } from "@/api/queries";
import { PageLoader } from "@/components/shared/LoadingSpinner"; import { Badge } from "@/components/shared/Badge";
import { ErrorAlert } from "@/components/shared/ErrorAlert"; import { ErrorAlert } from "@/components/shared/ErrorAlert";
import { JsonViewer } from "@/components/shared/JsonViewer"; import { JsonViewer } from "@/components/shared/JsonViewer";
import { Badge } from "@/components/shared/Badge"; import { PageLoader } from "@/components/shared/LoadingSpinner";
import { MarkdownRenderer } from "@/components/shared/MarkdownRenderer";
import { PeerCardViewer } from "@/components/shared/PeerCardViewer";
import { Link, useNavigate, useParams } from "@tanstack/react-router";
import { AnimatePresence, motion } from "framer-motion";
import { MessageCircle, Save, Search, User, X } from "lucide-react";
import { useState } from "react";
type Tab = "context" | "card" | "representation" | "metadata" | "search"; type Tab = "context" | "card" | "representation" | "metadata" | "search";
@@ -28,7 +30,10 @@ export function PeerDetail() {
const { data: peer, isLoading, error } = usePeer(workspaceId, peerId); const { data: peer, isLoading, error } = usePeer(workspaceId, peerId);
const { data: card, isLoading: cardLoading } = usePeerCard(workspaceId, peerId); const { data: card, isLoading: cardLoading } = usePeerCard(workspaceId, peerId);
const { data: context, isLoading: contextLoading } = usePeerContext(workspaceId, peerId); const { data: context, isLoading: contextLoading } = usePeerContext(workspaceId, peerId);
const { data: representation, isLoading: repLoading } = usePeerRepresentation(workspaceId, peerId); const { data: representation, isLoading: repLoading } = usePeerRepresentation(
workspaceId,
peerId,
);
const setPeerCard = useSetPeerCard(workspaceId, peerId); const setPeerCard = useSetPeerCard(workspaceId, peerId);
const searchPeer = useSearchPeer(workspaceId, peerId); const searchPeer = useSearchPeer(workspaceId, peerId);
@@ -36,12 +41,11 @@ export function PeerDetail() {
const [cardDraft, setCardDraft] = useState<string | null>(null); const [cardDraft, setCardDraft] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const cardLines: string[] = const cardLines: string[] = Array.isArray((card as { peer_card?: unknown })?.peer_card)
Array.isArray((card as { peer_card?: unknown })?.peer_card) ? (card as { peer_card: string[] }).peer_card
? ((card as { peer_card: string[] }).peer_card) : typeof card === "string"
: typeof card === "string" ? [card]
? [card] : [];
: [];
const tabs: Array<{ id: Tab; label: string }> = [ const tabs: Array<{ id: Tab; label: string }> = [
{ id: "context", label: "Context" }, { id: "context", label: "Context" },
@@ -55,13 +59,23 @@ export function PeerDetail() {
<div className="page-container"> <div className="page-container">
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }}> <motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }}>
<div className="flex items-center gap-2 text-xs mb-4" style={{ color: "var(--text-3)" }}> <div className="flex items-center gap-2 text-xs mb-4" style={{ color: "var(--text-3)" }}>
<Link to="/workspaces" className="hover:underline">Workspaces</Link> <Link to="/workspaces" className="hover:underline">
Workspaces
</Link>
<span>/</span> <span>/</span>
<Link to="/workspaces/$workspaceId" params={{ workspaceId } as never} className="hover:underline font-mono"> <Link
to="/workspaces/$workspaceId"
params={{ workspaceId } as never}
className="hover:underline font-mono"
>
{workspaceId} {workspaceId}
</Link> </Link>
<span>/</span> <span>/</span>
<Link to="/workspaces/$workspaceId/peers" params={{ workspaceId } as never} className="hover:underline"> <Link
to="/workspaces/$workspaceId/peers"
params={{ workspaceId } as never}
className="hover:underline"
>
Peers Peers
</Link> </Link>
</div> </div>
@@ -77,7 +91,9 @@ export function PeerDetail() {
{peerId} {peerId}
</h1> </h1>
</div> </div>
<p className="text-sm" style={{ color: "var(--text-2)" }}>Peer identity &amp; memory</p> <p className="text-sm" style={{ color: "var(--text-2)" }}>
Peer identity &amp; memory
</p>
</div> </div>
<button <button
onClick={() => onClick={() =>
@@ -132,29 +148,45 @@ export function PeerDetail() {
transition={{ duration: 0.2 }} transition={{ duration: 0.2 }}
className="rounded-xl p-5 theme-card" className="rounded-xl p-5 theme-card"
> >
{tab === "context" && ( {tab === "context" &&
contextLoading ? <PageLoader /> : ( (contextLoading ? (
<PageLoader />
) : (
<> <>
<h2 className="text-sm font-medium mb-3" style={{ color: "var(--text-1)" }}>Peer Context</h2> <h2 className="text-sm font-medium mb-3" style={{ color: "var(--text-1)" }}>
Peer Context
</h2>
{typeof context === "string" ? ( {typeof context === "string" ? (
<p className="text-sm whitespace-pre-wrap leading-relaxed" style={{ color: "var(--text-2)" }}>{context}</p> <p
className="text-sm whitespace-pre-wrap leading-relaxed"
style={{ color: "var(--text-2)" }}
>
{context}
</p>
) : ( ) : (
<JsonViewer data={context} /> <JsonViewer data={context} />
)} )}
</> </>
) ))}
)}
{tab === "card" && ( {tab === "card" &&
cardLoading ? <PageLoader /> : ( (cardLoading ? (
<PageLoader />
) : (
<> <>
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-medium" style={{ color: "var(--text-1)" }}>Peer Card</h2> <h2 className="text-sm font-medium" style={{ color: "var(--text-1)" }}>
Peer Card
</h2>
{cardDraft === null ? ( {cardDraft === null ? (
<button <button
onClick={() => setCardDraft(cardLines.join("\n"))} onClick={() => setCardDraft(cardLines.join("\n"))}
className="text-xs px-2 py-1 rounded-lg transition-colors" className="text-xs px-2 py-1 rounded-lg transition-colors"
style={{ background: "var(--accent-dim)", border: "1px solid var(--accent-border)", color: "var(--accent-text)" }} style={{
background: "var(--accent-dim)",
border: "1px solid var(--accent-border)",
color: "var(--accent-text)",
}}
> >
Edit Edit
</button> </button>
@@ -167,7 +199,11 @@ export function PeerDetail() {
}} }}
disabled={setPeerCard.isPending} disabled={setPeerCard.isPending}
className="flex items-center gap-1 text-xs px-2 py-1 rounded-lg disabled:opacity-50" className="flex items-center gap-1 text-xs px-2 py-1 rounded-lg disabled:opacity-50"
style={{ background: "var(--accent-dim)", border: "1px solid var(--accent-border)", color: "var(--accent-text)" }} style={{
background: "var(--accent-dim)",
border: "1px solid var(--accent-border)",
color: "var(--accent-text)",
}}
> >
<Save className="w-3 h-3" strokeWidth={2} /> <Save className="w-3 h-3" strokeWidth={2} />
Save Save
@@ -175,7 +211,11 @@ export function PeerDetail() {
<button <button
onClick={() => setCardDraft(null)} onClick={() => setCardDraft(null)}
className="flex items-center gap-1 text-xs px-2 py-1 rounded-lg" className="flex items-center gap-1 text-xs px-2 py-1 rounded-lg"
style={{ background: "var(--surface)", border: "1px solid var(--border)", color: "var(--text-3)" }} style={{
background: "var(--surface)",
border: "1px solid var(--border)",
color: "var(--text-3)",
}}
> >
<X className="w-3 h-3" strokeWidth={2} /> <X className="w-3 h-3" strokeWidth={2} />
</button> </button>
@@ -196,34 +236,32 @@ export function PeerDetail() {
/> />
) : ( ) : (
<motion.div key="view" initial={{ opacity: 0 }} animate={{ opacity: 1 }}> <motion.div key="view" initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
{cardLines.length > 0 ? ( <PeerCardViewer lines={cardLines} />
<p className="text-sm whitespace-pre-wrap" style={{ color: "var(--text-2)" }}>
{cardLines.join("\n")}
</p>
) : (
<p className="text-sm" style={{ color: "var(--text-4)" }}>No card set.</p>
)}
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
</> </>
) ))}
)}
{tab === "representation" && ( {tab === "representation" &&
repLoading ? <PageLoader /> : ( (repLoading ? (
<PageLoader />
) : (
<> <>
<h2 className="text-sm font-medium mb-3" style={{ color: "var(--text-1)" }}>Memory Representation</h2> <h2 className="text-sm font-medium mb-3" style={{ color: "var(--text-1)" }}>
{representation && typeof (representation as { representation?: unknown }).representation === "string" ? ( Memory Representation
<p className="text-sm whitespace-pre-wrap leading-relaxed" style={{ color: "var(--text-2)" }}> </h2>
{(representation as { representation: string }).representation} {representation &&
</p> typeof (representation as { representation?: unknown }).representation ===
"string" ? (
<MarkdownRenderer
content={(representation as { representation: string }).representation}
/>
) : ( ) : (
<JsonViewer data={representation} maxHeight="400px" /> <JsonViewer data={representation} maxHeight="400px" />
)} )}
</> </>
) ))}
)}
{tab === "search" && ( {tab === "search" && (
<> <>
@@ -249,21 +287,44 @@ export function PeerDetail() {
type="submit" type="submit"
disabled={searchPeer.isPending} disabled={searchPeer.isPending}
className="px-3 py-2 text-sm rounded-lg font-medium" className="px-3 py-2 text-sm rounded-lg font-medium"
style={{ background: "var(--accent-dim)", border: "1px solid var(--accent-border)", color: "var(--accent-text)" }} style={{
background: "var(--accent-dim)",
border: "1px solid var(--accent-border)",
color: "var(--accent-text)",
}}
> >
{searchPeer.isPending ? "…" : "Search"} {searchPeer.isPending ? "…" : "Search"}
</button> </button>
</form> </form>
{searchPeer.data && ( {searchPeer.data && (
<div className="space-y-3"> <div className="space-y-3">
{(searchPeer.data as Array<{ id: string; content: string; peer_id?: string; created_at?: string }>).length === 0 ? ( {(
<p className="text-sm" style={{ color: "var(--text-3)" }}>No results.</p> searchPeer.data as Array<{
id: string;
content: string;
peer_id?: string;
created_at?: string;
}>
).length === 0 ? (
<p className="text-sm" style={{ color: "var(--text-3)" }}>
No results.
</p>
) : ( ) : (
(searchPeer.data as Array<{ id: string; content: string; peer_id?: string; created_at?: string }>).map((r) => ( (
searchPeer.data as Array<{
id: string;
content: string;
peer_id?: string;
created_at?: string;
}>
).map((r) => (
<div <div
key={r.id} key={r.id}
className="py-3 px-4 rounded-lg" className="py-3 px-4 rounded-lg"
style={{ background: "var(--surface)", border: "1px solid var(--border)" }} style={{
background: "var(--surface)",
border: "1px solid var(--border)",
}}
> >
<div className="flex items-center gap-2 mb-1.5"> <div className="flex items-center gap-2 mb-1.5">
<Badge variant="blue">{r.peer_id ?? peerId}</Badge> <Badge variant="blue">{r.peer_id ?? peerId}</Badge>
@@ -273,7 +334,12 @@ export function PeerDetail() {
</span> </span>
)} )}
</div> </div>
<p className="text-sm whitespace-pre-wrap" style={{ color: "var(--text-2)" }}>{r.content}</p> <p
className="text-sm whitespace-pre-wrap"
style={{ color: "var(--text-2)" }}
>
{r.content}
</p>
</div> </div>
)) ))
)} )}
@@ -284,7 +350,9 @@ export function PeerDetail() {
{tab === "metadata" && ( {tab === "metadata" && (
<> <>
<h2 className="text-sm font-medium mb-3" style={{ color: "var(--text-1)" }}>Peer Metadata</h2> <h2 className="text-sm font-medium mb-3" style={{ color: "var(--text-1)" }}>
Peer Metadata
</h2>
<JsonViewer data={peer.metadata} maxHeight="400px" /> <JsonViewer data={peer.metadata} maxHeight="400px" />
</> </>
)} )}

View File

@@ -1,13 +1,13 @@
import { useState } from "react";
import { Link, useNavigate, useParams } from "@tanstack/react-router";
import { motion, type Variants } from "framer-motion";
import { Users, ChevronRight, Clock, ArrowLeft } from "lucide-react";
import { usePeers } from "@/api/queries"; import { usePeers } from "@/api/queries";
import { PageLoader } from "@/components/shared/LoadingSpinner";
import { ErrorAlert } from "@/components/shared/ErrorAlert";
import { Pagination } from "@/components/shared/Pagination";
import { EmptyState } from "@/components/shared/EmptyState";
import type { components } from "@/api/schema.d.ts"; 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 { Link, useNavigate, useParams } from "@tanstack/react-router";
import { type Variants, motion } from "framer-motion";
import { ArrowLeft, ChevronRight, Clock, Eye, Users } from "lucide-react";
import { useState } from "react";
type Peer = components["schemas"]["Peer"]; type Peer = components["schemas"]["Peer"];
@@ -32,11 +32,7 @@ export function PeerList() {
return ( return (
<div className="p-8 max-w-3xl mx-auto"> <div className="p-8 max-w-3xl mx-auto">
<motion.div <motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }} className="mb-8">
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
className="mb-8"
>
<Link <Link
to="/workspaces/$workspaceId" to="/workspaces/$workspaceId"
params={{ workspaceId } as never} params={{ workspaceId } as never}
@@ -121,14 +117,28 @@ export function PeerList() {
strokeWidth={1.5} strokeWidth={1.5}
/> />
</div> </div>
{peer.created_at && ( <div className="flex items-center gap-2 flex-wrap">
<div className="flex items-center gap-1"> {(peer.configuration as { observe_me?: boolean } | null)?.observe_me && (
<Clock className="w-3 h-3" style={{ color: "rgba(148,163,184,0.3)" }} strokeWidth={1.5} /> <div className="flex items-center gap-1">
<p className="text-xs font-mono" style={{ color: "rgba(148,163,184,0.3)" }}> <Eye className="w-3 h-3" style={{ color: "#818cf8" }} strokeWidth={1.5} />
{new Date(peer.created_at).toLocaleString()} <span className="text-xs" style={{ color: "#818cf8" }}>
</p> observed
</div> </span>
)} </div>
)}
{peer.created_at && (
<div className="flex items-center gap-1">
<Clock
className="w-3 h-3"
style={{ color: "rgba(148,163,184,0.3)" }}
strokeWidth={1.5}
/>
<p className="text-xs font-mono" style={{ color: "rgba(148,163,184,0.3)" }}>
{new Date(peer.created_at).toLocaleString()}
</p>
</div>
)}
</div>
</motion.button> </motion.button>
))} ))}
</motion.div> </motion.div>

View File

@@ -1,27 +1,29 @@
import { useState } from "react";
import { Link, useParams, useNavigate } from "@tanstack/react-router";
import { motion, AnimatePresence } from "framer-motion";
import { MessageSquare, Trash2, Copy, Search, Users, X } from "lucide-react";
import { import {
useSessionMessages,
useSessionSummaries,
useSessionContext,
useSessionPeers,
useDeleteSession,
useCloneSession,
useSearchSession,
useRemovePeersFromSession,
usePeers,
useAddPeersToSession, useAddPeersToSession,
useCloneSession,
useDeleteSession,
usePeers,
useRemovePeersFromSession,
useSearchSession,
useSessionContext,
useSessionMessages,
useSessionPeers,
useSessionSummaries,
} from "@/api/queries"; } from "@/api/queries";
import type { components } from "@/api/schema.d.ts";
import { Badge } from "@/components/shared/Badge";
import { ConfirmDialog } from "@/components/shared/ConfirmDialog";
import { JsonViewer } from "@/components/shared/JsonViewer";
import { PageLoader } from "@/components/shared/LoadingSpinner"; import { PageLoader } from "@/components/shared/LoadingSpinner";
import { Pagination } from "@/components/shared/Pagination"; import { Pagination } from "@/components/shared/Pagination";
import { Badge } from "@/components/shared/Badge"; import { Link, useNavigate, useParams } from "@tanstack/react-router";
import { JsonViewer } from "@/components/shared/JsonViewer"; import { AnimatePresence, motion } from "framer-motion";
import { ConfirmDialog } from "@/components/shared/ConfirmDialog"; import { AlignLeft, Clock, Copy, MessageSquare, Search, Trash2, Users, X } from "lucide-react";
import type { components } from "@/api/schema.d.ts"; import { useState } from "react";
type Message = components["schemas"]["Message"]; type Message = components["schemas"]["Message"];
type SessionSummaries = components["schemas"]["SessionSummaries"];
type Summary = components["schemas"]["Summary"];
type Tab = "messages" | "summaries" | "context" | "peers"; type Tab = "messages" | "summaries" | "context" | "peers";
export function SessionDetail() { export function SessionDetail() {
@@ -37,8 +39,15 @@ export function SessionDetail() {
const [searchActive, setSearchActive] = useState(false); const [searchActive, setSearchActive] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false);
const { data: msgData, isLoading: msgsLoading } = useSessionMessages(workspaceId, sessionId, page); const { data: msgData, isLoading: msgsLoading } = useSessionMessages(
const { data: summaries, isLoading: summariesLoading } = useSessionSummaries(workspaceId, sessionId); workspaceId,
sessionId,
page,
);
const { data: summaries, isLoading: summariesLoading } = useSessionSummaries(
workspaceId,
sessionId,
);
const { data: context, isLoading: contextLoading } = useSessionContext(workspaceId, sessionId); const { data: context, isLoading: contextLoading } = useSessionContext(workspaceId, sessionId);
const { data: sessionPeers, isLoading: peersLoading } = useSessionPeers(workspaceId, sessionId); const { data: sessionPeers, isLoading: peersLoading } = useSessionPeers(workspaceId, sessionId);
const { data: allPeers } = usePeers(workspaceId, 1, 100); const { data: allPeers } = usePeers(workspaceId, 1, 100);
@@ -52,11 +61,11 @@ export function SessionDetail() {
const messages: Message[] = (msgData as { items?: Message[] } | undefined)?.items ?? []; const messages: Message[] = (msgData as { items?: Message[] } | undefined)?.items ?? [];
const totalPages = (msgData as { pages?: number } | undefined)?.pages ?? 1; const totalPages = (msgData as { pages?: number } | undefined)?.pages ?? 1;
const memberPeerIds = new Set( const sessionPeerItems = (
(sessionPeers as Array<{ id?: string; peer_id?: string }> | undefined)?.map( sessionPeers as { items?: Array<{ id?: string; peer_id?: string }> } | undefined
(p) => p.id ?? p.peer_id ?? "", )?.items ?? [];
) ?? [],
); const memberPeerIds = new Set(sessionPeerItems.map((p) => p.id ?? p.peer_id ?? ""));
const availablePeers = ( const availablePeers = (
(allPeers as { items?: Array<{ id: string }> } | undefined)?.items ?? [] (allPeers as { items?: Array<{ id: string }> } | undefined)?.items ?? []
@@ -71,7 +80,10 @@ export function SessionDetail() {
const handleDelete = async () => { const handleDelete = async () => {
await deleteSession.mutateAsync(sessionId); await deleteSession.mutateAsync(sessionId);
navigate({ to: "/workspaces/$workspaceId/sessions" as never, params: { workspaceId } as never }); navigate({
to: "/workspaces/$workspaceId/sessions" as never,
params: { workspaceId } as never,
});
}; };
const handleClone = async () => { const handleClone = async () => {
@@ -88,19 +100,34 @@ export function SessionDetail() {
<div className="page-container"> <div className="page-container">
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }}> <motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }}>
<div className="flex items-center gap-2 text-xs mb-4" style={{ color: "var(--text-3)" }}> <div className="flex items-center gap-2 text-xs mb-4" style={{ color: "var(--text-3)" }}>
<Link to="/workspaces/$workspaceId" params={{ workspaceId } as never} className="hover:underline font-mono"> <Link
to="/workspaces/$workspaceId"
params={{ workspaceId } as never}
className="hover:underline font-mono"
>
{workspaceId} {workspaceId}
</Link> </Link>
<span>/</span> <span>/</span>
<Link to="/workspaces/$workspaceId/sessions" params={{ workspaceId } as never} className="hover:underline"> <Link
to="/workspaces/$workspaceId/sessions"
params={{ workspaceId } as never}
className="hover:underline"
>
Sessions Sessions
</Link> </Link>
</div> </div>
<div className="flex items-start justify-between gap-4 mb-1"> <div className="flex items-start justify-between gap-4 mb-1">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<MessageSquare className="w-5 h-5 flex-shrink-0" style={{ color: "var(--accent)" }} strokeWidth={1.5} /> <MessageSquare
<h1 className="text-xl font-semibold font-mono break-all tracking-tight" style={{ color: "var(--text-1)" }}> className="w-5 h-5 flex-shrink-0"
style={{ color: "var(--accent)" }}
strokeWidth={1.5}
/>
<h1
className="text-xl font-semibold font-mono break-all tracking-tight"
style={{ color: "var(--text-1)" }}
>
{sessionId} {sessionId}
</h1> </h1>
</div> </div>
@@ -141,7 +168,9 @@ export function SessionDetail() {
</button> </button>
</div> </div>
</div> </div>
<p className="text-sm" style={{ color: "var(--text-2)" }}>Session detail</p> <p className="text-sm" style={{ color: "var(--text-2)" }}>
Session detail
</p>
</motion.div> </motion.div>
{/* Inline search bar */} {/* Inline search bar */}
@@ -171,19 +200,32 @@ export function SessionDetail() {
type="submit" type="submit"
disabled={searchSession.isPending} disabled={searchSession.isPending}
className="px-3 py-2 text-sm rounded-lg font-medium" className="px-3 py-2 text-sm rounded-lg font-medium"
style={{ background: "var(--accent-dim)", border: "1px solid var(--accent-border)", color: "var(--accent-text)" }} style={{
background: "var(--accent-dim)",
border: "1px solid var(--accent-border)",
color: "var(--accent-text)",
}}
> >
{searchSession.isPending ? "…" : "Search"} {searchSession.isPending ? "…" : "Search"}
</button> </button>
</form> </form>
{searchSession.data && ( {searchSession.data && (
<div className="mt-3 rounded-xl p-4 theme-card space-y-2"> <div className="mt-3 rounded-xl p-4 theme-card space-y-2">
{(searchSession.data as Array<{ id: string; content: string; peer_id?: string }>).length === 0 ? ( {(searchSession.data as Array<{ id: string; content: string; peer_id?: string }>)
<p className="text-sm" style={{ color: "var(--text-3)" }}>No results.</p> .length === 0 ? (
<p className="text-sm" style={{ color: "var(--text-3)" }}>
No results.
</p>
) : ( ) : (
(searchSession.data as Array<{ id: string; content: string; peer_id?: string }>).map((r) => ( (
<div key={r.id} className="text-sm py-2" style={{ borderBottom: "1px solid var(--border)", color: "var(--text-2)" }}> searchSession.data as Array<{ id: string; content: string; peer_id?: string }>
{r.peer_id && <Badge variant="blue" >{r.peer_id}</Badge>} ).map((r) => (
<div
key={r.id}
className="text-sm py-2"
style={{ borderBottom: "1px solid var(--border)", color: "var(--text-2)" }}
>
{r.peer_id && <Badge variant="blue">{r.peer_id}</Badge>}
<p className="mt-1 whitespace-pre-wrap">{r.content}</p> <p className="mt-1 whitespace-pre-wrap">{r.content}</p>
</div> </div>
)) ))
@@ -227,15 +269,23 @@ export function SessionDetail() {
transition={{ duration: 0.2 }} transition={{ duration: 0.2 }}
className="rounded-xl p-5 theme-card" className="rounded-xl p-5 theme-card"
> >
{tab === "messages" && ( {tab === "messages" &&
msgsLoading ? <PageLoader /> : ( (msgsLoading ? (
<PageLoader />
) : (
<div> <div>
{messages.length === 0 ? ( {messages.length === 0 ? (
<p className="text-sm" style={{ color: "var(--text-3)" }}>No messages.</p> <p className="text-sm" style={{ color: "var(--text-3)" }}>
No messages.
</p>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
{messages.map((msg) => ( {messages.map((msg) => (
<div key={msg.id} className="pb-4" style={{ borderBottom: "1px solid var(--border)" }}> <div
key={msg.id}
className="pb-4"
style={{ borderBottom: "1px solid var(--border)" }}
>
<div className="flex items-center gap-2 mb-2 flex-wrap"> <div className="flex items-center gap-2 mb-2 flex-wrap">
<Badge variant={msg.peer_id ? "blue" : "default"}> <Badge variant={msg.peer_id ? "blue" : "default"}>
{msg.peer_id ?? "system"} {msg.peer_id ?? "system"}
@@ -251,7 +301,10 @@ export function SessionDetail() {
</span> </span>
)} )}
</div> </div>
<p className="text-sm whitespace-pre-wrap leading-relaxed" style={{ color: "var(--text-2)" }}> <p
className="text-sm whitespace-pre-wrap leading-relaxed"
style={{ color: "var(--text-2)" }}
>
{msg.content} {msg.content}
</p> </p>
</div> </div>
@@ -260,45 +313,45 @@ export function SessionDetail() {
)} )}
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} /> <Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
</div> </div>
) ))}
)}
{tab === "summaries" && ( {tab === "summaries" &&
summariesLoading ? <PageLoader /> : ( (summariesLoading ? <PageLoader /> : <SummariesDisplay summaries={summaries} />)}
<>
<h2 className="text-sm font-medium mb-3" style={{ color: "var(--text-1)" }}>Session Summaries</h2>
<JsonViewer data={summaries} maxHeight="500px" />
</>
)
)}
{tab === "context" && ( {tab === "context" &&
contextLoading ? <PageLoader /> : ( (contextLoading ? (
<PageLoader />
) : (
<> <>
<h2 className="text-sm font-medium mb-3" style={{ color: "var(--text-1)" }}>Session Context</h2> <h2 className="text-sm font-medium mb-3" style={{ color: "var(--text-1)" }}>
Session Context
</h2>
{typeof context === "string" ? ( {typeof context === "string" ? (
<p className="text-sm whitespace-pre-wrap leading-relaxed" style={{ color: "var(--text-2)" }}> <p
className="text-sm whitespace-pre-wrap leading-relaxed"
style={{ color: "var(--text-2)" }}
>
{context} {context}
</p> </p>
) : ( ) : (
<JsonViewer data={context} maxHeight="500px" /> <JsonViewer data={context} maxHeight="500px" />
)} )}
</> </>
) ))}
)}
{tab === "peers" && ( {tab === "peers" &&
peersLoading ? <PageLoader /> : ( (peersLoading ? (
<PageLoader />
) : (
<SessionPeersTab <SessionPeersTab
members={sessionPeers as Array<{ id?: string; peer_id?: string }> | undefined} members={sessionPeerItems}
available={availablePeers} available={availablePeers}
onRemove={(id) => removePeers.mutate([id])} onRemove={(id) => removePeers.mutate([id])}
onAdd={(id) => addPeers.mutate({ [id]: {} })} onAdd={(id) => addPeers.mutate({ [id]: {} })}
removing={removePeers.isPending} removing={removePeers.isPending}
adding={addPeers.isPending} adding={addPeers.isPending}
/> />
) ))}
)}
</motion.div> </motion.div>
</div> </div>
@@ -340,7 +393,9 @@ function SessionPeersTab({
Session members ({list.length}) Session members ({list.length})
</h2> </h2>
{list.length === 0 ? ( {list.length === 0 ? (
<p className="text-sm" style={{ color: "var(--text-3)" }}>No peers in this session.</p> <p className="text-sm" style={{ color: "var(--text-3)" }}>
No peers in this session.
</p>
) : ( ) : (
<div className="space-y-1"> <div className="space-y-1">
{list.map((p) => { {list.map((p) => {
@@ -351,7 +406,9 @@ function SessionPeersTab({
className="flex items-center justify-between py-1.5 px-3 rounded-lg" className="flex items-center justify-between py-1.5 px-3 rounded-lg"
style={{ background: "var(--surface)", border: "1px solid var(--border)" }} style={{ background: "var(--surface)", border: "1px solid var(--border)" }}
> >
<span className="text-xs font-mono" style={{ color: "var(--accent-text)" }}>{id}</span> <span className="text-xs font-mono" style={{ color: "var(--accent-text)" }}>
{id}
</span>
<button <button
onClick={() => onRemove(id)} onClick={() => onRemove(id)}
disabled={removing} disabled={removing}
@@ -394,3 +451,68 @@ function SessionPeersTab({
</div> </div>
); );
} }
function SummaryCard({ label, summary }: { label: string; summary: Summary }) {
return (
<div
className="rounded-xl p-4"
style={{ background: "var(--surface)", border: "1px solid var(--border)" }}
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-1.5">
<AlignLeft className="w-3.5 h-3.5" style={{ color: "var(--text-4)" }} strokeWidth={1.5} />
<span className="text-xs font-medium" style={{ color: "var(--text-2)" }}>
{label}
</span>
</div>
<div className="flex items-center gap-3">
{summary.token_count != null && (
<span className="text-xs font-mono" style={{ color: "var(--text-4)" }}>
{summary.token_count} tok
</span>
)}
{summary.created_at && (
<div className="flex items-center gap-1">
<Clock className="w-3 h-3" style={{ color: "var(--text-4)" }} strokeWidth={1.5} />
<span className="text-xs font-mono" style={{ color: "var(--text-4)" }}>
{new Date(summary.created_at).toLocaleString()}
</span>
</div>
)}
</div>
</div>
<p className="text-sm leading-relaxed whitespace-pre-wrap" style={{ color: "var(--text-2)" }}>
{summary.content}
</p>
</div>
);
}
function SummariesDisplay({ summaries }: { summaries: unknown }) {
const data = summaries as SessionSummaries | null | undefined;
if (!data || (!data.short_summary && !data.long_summary)) {
return (
<>
<h2 className="text-sm font-medium mb-3" style={{ color: "var(--text-1)" }}>
Session Summaries
</h2>
<p className="text-sm" style={{ color: "var(--text-4)" }}>
No summaries available yet.
</p>
</>
);
}
return (
<>
<h2 className="text-sm font-medium mb-3" style={{ color: "var(--text-1)" }}>
Session Summaries
</h2>
<div className="space-y-3">
{data.short_summary && <SummaryCard label="Short summary" summary={data.short_summary} />}
{data.long_summary && <SummaryCard label="Long summary" summary={data.long_summary} />}
</div>
</>
);
}

View File

@@ -1,13 +1,13 @@
import { useState } from "react";
import { Link, useNavigate, useParams } from "@tanstack/react-router";
import { motion, type Variants } from "framer-motion";
import { MessageSquare, ChevronRight, Clock, CircleDot, ArrowLeft } from "lucide-react";
import { useSessions } from "@/api/queries"; import { useSessions } from "@/api/queries";
import { PageLoader } from "@/components/shared/LoadingSpinner";
import { ErrorAlert } from "@/components/shared/ErrorAlert";
import { Pagination } from "@/components/shared/Pagination";
import { EmptyState } from "@/components/shared/EmptyState";
import type { components } from "@/api/schema.d.ts"; 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 { Link, useNavigate, useParams } from "@tanstack/react-router";
import { type Variants, motion } from "framer-motion";
import { ArrowLeft, ChevronRight, CircleDot, Clock, MessageSquare } from "lucide-react";
import { useState } from "react";
type Session = components["schemas"]["Session"]; type Session = components["schemas"]["Session"];
@@ -32,11 +32,7 @@ export function SessionList() {
return ( return (
<div className="p-8 max-w-3xl mx-auto"> <div className="p-8 max-w-3xl mx-auto">
<motion.div <motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }} className="mb-8">
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
className="mb-8"
>
<Link <Link
to="/workspaces/$workspaceId" to="/workspaces/$workspaceId"
params={{ workspaceId } as never} params={{ workspaceId } as never}
@@ -105,7 +101,10 @@ export function SessionList() {
}} }}
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="font-mono text-sm font-medium truncate" style={{ color: "#c7d2fe" }}> <span
className="font-mono text-sm font-medium truncate"
style={{ color: "#c7d2fe" }}
>
{session.id} {session.id}
</span> </span>
<div className="flex items-center gap-2 shrink-0 ml-2"> <div className="flex items-center gap-2 shrink-0 ml-2">
@@ -113,11 +112,17 @@ export function SessionList() {
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<motion.div <motion.div
animate={{ opacity: [0.5, 1, 0.5] }} animate={{ opacity: [0.5, 1, 0.5] }}
transition={{ duration: 2, repeat: Infinity }} transition={{ duration: 2, repeat: Number.POSITIVE_INFINITY }}
> >
<CircleDot className="w-3 h-3" style={{ color: "#34d399" }} strokeWidth={2} /> <CircleDot
className="w-3 h-3"
style={{ color: "#34d399" }}
strokeWidth={2}
/>
</motion.div> </motion.div>
<span className="text-xs" style={{ color: "#34d399" }}>Active</span> <span className="text-xs" style={{ color: "#34d399" }}>
Active
</span>
</div> </div>
)} )}
<ChevronRight <ChevronRight
@@ -127,14 +132,32 @@ export function SessionList() {
/> />
</div> </div>
</div> </div>
{session.created_at && ( <div className="flex items-center gap-2 mt-2">
<div className="flex items-center gap-1.5 mt-2"> {session.created_at && (
<Clock className="w-3 h-3" style={{ color: "rgba(148,163,184,0.3)" }} strokeWidth={1.5} /> <div className="flex items-center gap-1.5">
<p className="text-xs font-mono" style={{ color: "rgba(148,163,184,0.3)" }}> <Clock
{new Date(session.created_at).toLocaleString()} className="w-3 h-3"
</p> style={{ color: "rgba(148,163,184,0.3)" }}
</div> strokeWidth={1.5}
)} />
<p className="text-xs font-mono" style={{ color: "rgba(148,163,184,0.3)" }}>
{new Date(session.created_at).toLocaleString()}
</p>
</div>
)}
{(session.metadata as Record<string, string> | null)?.source && (
<span
className="text-xs font-mono px-1.5 py-0.5 rounded"
style={{
background: "rgba(99,102,241,0.08)",
border: "1px solid rgba(99,102,241,0.15)",
color: "rgba(148,163,184,0.6)",
}}
>
{(session.metadata as Record<string, string>).source}
</span>
)}
</div>
</motion.button> </motion.button>
))} ))}
</motion.div> </motion.div>

View File

@@ -0,0 +1,149 @@
import { TimestampChip } from "@/components/shared/TimestampChip";
import ReactMarkdown, { type Components } from "react-markdown";
import remarkGfm from "remark-gfm";
// [2026-04-24 18:18:48] rest of line
const TIMESTAMP_LINE_RE = /^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\]\s*(.*)/s;
function flattenChildren(children: React.ReactNode): string {
if (typeof children === "string") return children;
if (Array.isArray(children)) return children.map(flattenChildren).join("");
if (children && typeof children === "object" && "props" in (children as object)) {
return flattenChildren((children as { props: { children?: React.ReactNode } }).props.children);
}
return "";
}
/** Paragraph renderer: detects blocks of timestamp lines and renders them specially. */
function Paragraph({ children }: { children?: React.ReactNode }) {
const text = flattenChildren(children);
const lines = text.split("\n").filter(Boolean);
// If every line in this paragraph matches the timestamp pattern, render as timestamp list
if (lines.length > 0 && lines.every((l) => TIMESTAMP_LINE_RE.test(l))) {
return (
<div className="space-y-0.5 my-2">
{lines.map((line, i) => {
const m = TIMESTAMP_LINE_RE.exec(line)!;
return (
<div
key={i}
className="flex items-start gap-3 py-1 px-1 rounded-sm group"
style={{ borderBottom: "1px solid var(--border)" }}
>
<TimestampChip value={m[1]} className="mt-0.5" />
<span
className="text-sm leading-relaxed flex-1 min-w-0"
style={{ color: "var(--text-2)" }}
>
{m[2]}
</span>
</div>
);
})}
</div>
);
}
return (
<p className="text-sm leading-relaxed mb-3" style={{ color: "var(--text-2)" }}>
{children}
</p>
);
}
const COMPONENTS: Components = {
h1: ({ children }) => (
<h1
className="text-base font-semibold mt-4 mb-2 pb-1"
style={{ color: "var(--text-1)", borderBottom: "1px solid var(--border)" }}
>
{children}
</h1>
),
h2: ({ children }) => (
<h2
className="text-sm font-semibold mt-4 mb-2 pb-1 uppercase tracking-wider"
style={{ color: "var(--accent-text)", borderBottom: "1px solid var(--border)" }}
>
{children}
</h2>
),
h3: ({ children }) => (
<h3 className="text-sm font-medium mt-3 mb-1.5" style={{ color: "var(--text-1)" }}>
{children}
</h3>
),
p: Paragraph,
ul: ({ children }) => (
<ul className="text-sm space-y-1 mb-3 pl-4 list-disc" style={{ color: "var(--text-2)" }}>
{children}
</ul>
),
ol: ({ children }) => (
<ol className="text-sm space-y-1 mb-3 pl-4 list-decimal" style={{ color: "var(--text-2)" }}>
{children}
</ol>
),
li: ({ children }) => <li className="leading-relaxed">{children}</li>,
code: ({ children, className }) => {
const isBlock = className?.includes("language-");
if (isBlock) {
return (
<pre
className="text-xs font-mono rounded-lg p-3 overflow-x-auto my-3"
style={{
background: "var(--bg-3)",
border: "1px solid var(--border)",
color: "var(--text-2)",
}}
>
<code>{children}</code>
</pre>
);
}
return (
<code
className="text-xs font-mono px-1.5 py-0.5 rounded"
style={{
background: "var(--bg-3)",
color: "var(--accent-text)",
border: "1px solid var(--border)",
}}
>
{children}
</code>
);
},
blockquote: ({ children }) => (
<blockquote
className="text-sm pl-3 my-3 italic"
style={{
borderLeft: "3px solid var(--accent-border)",
color: "var(--text-3)",
}}
>
{children}
</blockquote>
),
hr: () => (
<hr style={{ border: "none", borderTop: "1px solid var(--border)" }} className="my-4" />
),
strong: ({ children }) => (
<strong className="font-semibold" style={{ color: "var(--text-1)" }}>
{children}
</strong>
),
};
interface Props {
content: string;
}
export function MarkdownRenderer({ content }: Props) {
return (
<ReactMarkdown remarkPlugins={[remarkGfm]} components={COMPONENTS}>
{content}
</ReactMarkdown>
);
}

View File

@@ -0,0 +1,239 @@
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
import { ChevronDown } from "lucide-react";
import { useState } from "react";
interface Props {
lines: string[];
}
// ALL_CAPS_WORD: — no lowercase letters in key
const CAPS_RE = /^([A-Z][A-Z0-9_]+):\s*([\s\S]*)/;
// Title Case Word: — starts capital, must contain at least one lowercase
const TITLE_RE = /^([A-Z][a-zA-Z0-9][a-zA-Z0-9 ]*):\s*([\s\S]*)/;
type ParsedLine =
| { kind: "fact"; text: string }
| { kind: "caps"; key: string; value: string }
| { kind: "title"; key: string; value: string };
const PALETTE: Array<{ bg: string; text: string; border: string; dot: string }> = [
{ bg: "rgba(52,211,153,0.08)", text: "#34d399", border: "rgba(52,211,153,0.25)", dot: "#34d399" },
{ bg: "rgba(245,158,11,0.08)", text: "#f59e0b", border: "rgba(245,158,11,0.25)", dot: "#f59e0b" },
{ bg: "rgba(14,165,233,0.08)", text: "#38bdf8", border: "rgba(14,165,233,0.25)", dot: "#38bdf8" },
{ bg: "rgba(236,72,153,0.08)", text: "#f472b6", border: "rgba(236,72,153,0.25)", dot: "#f472b6" },
{ bg: "rgba(168,85,247,0.08)", text: "#c084fc", border: "rgba(168,85,247,0.25)", dot: "#c084fc" },
{ bg: "rgba(239,68,68,0.08)", text: "#f87171", border: "rgba(239,68,68,0.25)", dot: "#f87171" },
{ bg: "rgba(34,197,94,0.08)", text: "#4ade80", border: "rgba(34,197,94,0.25)", dot: "#4ade80" },
{ bg: "rgba(251,146,60,0.08)", text: "#fb923c", border: "rgba(251,146,60,0.25)", dot: "#fb923c" },
];
function hashPalette(word: string): number {
let h = 5381;
for (let i = 0; i < word.length; i++) h = ((h * 33) ^ word.charCodeAt(i)) >>> 0;
return h % PALETTE.length;
}
function toLabel(key: string): string {
const s = key.toLowerCase().replace(/_/g, " ");
return s.charAt(0).toUpperCase() + s.slice(1);
}
function parseLine(line: string): ParsedLine {
const caps = CAPS_RE.exec(line);
if (caps) return { kind: "caps", key: caps[1], value: caps[2].trim() };
const title = TITLE_RE.exec(line);
if (title && /[a-z]/.test(title[1])) {
return { kind: "title", key: title[1], value: title[2].trim() };
}
return { kind: "fact", text: line };
}
interface CapsGroup {
key: string;
items: string[];
}
interface Parsed {
titlePairs: Array<{ key: string; value: string }>;
facts: string[];
capsGroups: CapsGroup[];
}
function parse(lines: string[]): Parsed {
const titlePairs: Array<{ key: string; value: string }> = [];
const facts: string[] = [];
const capsMap = new Map<string, string[]>();
const capsOrder: string[] = [];
for (const line of lines) {
const p = parseLine(line);
if (p.kind === "title") {
titlePairs.push({ key: p.key, value: p.value });
} else if (p.kind === "caps") {
if (!capsMap.has(p.key)) {
capsMap.set(p.key, []);
capsOrder.push(p.key);
}
capsMap.get(p.key)!.push(p.value);
} else {
facts.push(p.text);
}
}
return {
titlePairs,
facts,
capsGroups: capsOrder.map((k) => ({ key: k, items: capsMap.get(k)! })),
};
}
// ─── Metadata table (Title Case: pairs) ──────────────────────────────────────
function MetadataCard({ pairs }: { pairs: Array<{ key: string; value: string }> }) {
if (pairs.length === 0) return null;
return (
<div className="rounded-lg overflow-hidden" style={{ border: "1px solid var(--border-2)" }}>
<dl className="divide-y" style={{ "--tw-divide-opacity": 1 } as React.CSSProperties}>
{pairs.map(({ key, value }, i) => (
<div
key={`${key}-${i}`}
className="grid grid-cols-[9rem_1fr] gap-3 px-4 py-2.5 text-sm"
style={{ background: i % 2 === 0 ? "var(--surface)" : "var(--bg-3)" }}
>
<dt className="font-medium truncate" style={{ color: "var(--text-3)" }}>
{key}
</dt>
<dd className="min-w-0 break-words" style={{ color: "var(--text-1)" }}>
{value || <span style={{ color: "var(--text-4)" }}></span>}
</dd>
</div>
))}
</dl>
</div>
);
}
// ─── Collapsible section (ALL_CAPS: groups + Facts) ───────────────────────────
interface SectionStyle {
bg: string;
text: string;
border: string;
}
const FACTS_STYLE: SectionStyle = {
bg: "rgba(99,102,241,0.08)",
text: "#a5b4fc",
border: "rgba(99,102,241,0.2)",
};
function CollapsibleSection({
label,
count,
style,
children,
}: {
label: string;
count: number;
style: SectionStyle;
children: React.ReactNode;
}) {
const [open, setOpen] = useState(true);
return (
<Collapsible open={open} onOpenChange={setOpen}>
<div className="rounded-lg overflow-hidden" style={{ border: `1px solid ${style.border}` }}>
<CollapsibleTrigger asChild>
<button
type="button"
className={cn(
"w-full flex items-center justify-between px-4 py-2.5 text-sm font-medium",
"transition-opacity hover:opacity-80",
)}
style={{ background: style.bg, color: style.text }}
>
<span className="flex items-center gap-2">
{label}
<span
className="text-xs font-mono px-1.5 py-0.5 rounded"
style={{
background: "rgba(0,0,0,0.2)",
color: style.text,
opacity: 0.75,
}}
>
{count}
</span>
</span>
<ChevronDown
className="w-4 h-4 transition-transform duration-200"
style={{ transform: open ? "rotate(0deg)" : "rotate(-90deg)" }}
strokeWidth={2}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent>{children}</CollapsibleContent>
</div>
</Collapsible>
);
}
function ItemList({ items }: { items: string[] }) {
return (
<ul>
{items.map((item, i) => (
<li
key={item}
className="px-4 py-2.5 text-sm leading-relaxed"
style={{
color: "var(--text-2)",
borderTop: i > 0 ? "1px solid var(--border)" : "1px solid var(--border)",
}}
>
{item}
</li>
))}
</ul>
);
}
// ─── Export ───────────────────────────────────────────────────────────────────
export function PeerCardViewer({ lines }: Props) {
if (!lines || lines.length === 0) {
return (
<p className="text-sm" style={{ color: "var(--text-4)" }}>
No card set.
</p>
);
}
const { titlePairs, facts, capsGroups } = parse(lines);
return (
<div className="space-y-2">
<MetadataCard pairs={titlePairs} />
{facts.length > 0 && (
<CollapsibleSection label="Facts" count={facts.length} style={FACTS_STYLE}>
<ItemList items={facts} />
</CollapsibleSection>
)}
{capsGroups.map((g) => {
const p = PALETTE[hashPalette(g.key)];
return (
<CollapsibleSection
key={g.key}
label={toLabel(g.key)}
count={g.items.length}
style={{ bg: p.bg, text: p.text, border: p.border }}
>
<ItemList items={g.items} />
</CollapsibleSection>
);
})}
</div>
);
}

View File

@@ -0,0 +1,62 @@
import { cn } from "@/lib/utils";
import { DateTime } from "luxon";
interface Props {
/** ISO-like string: "2026-04-24 18:18:48" or any Luxon-parseable string */
value: string;
className?: string;
}
function parseTimestamp(value: string): DateTime {
// Try bracket format first: "2026-04-24 18:18:48"
const dt = DateTime.fromFormat(value, "yyyy-MM-dd HH:mm:ss");
if (dt.isValid) return dt;
// Fall back to ISO
return DateTime.fromISO(value);
}
function formatDisplay(dt: DateTime): string {
const now = DateTime.now();
const diffMs = Math.abs(now.diff(dt, "milliseconds").milliseconds);
// Same calendar day → show time only
if (dt.hasSame(now, "day")) return dt.toFormat("HH:mm:ss");
// Within the past year → month + day + time
if (diffMs < 365 * 24 * 3600 * 1000) return dt.toFormat("MMM d HH:mm");
// Older → full date
return dt.toFormat("yyyy-MM-dd HH:mm");
}
export function TimestampChip({ value, className }: Props) {
const dt = parseTimestamp(value);
if (!dt.isValid) {
return (
<span className={cn("font-mono text-xs", className)} style={{ color: "var(--text-4)" }}>
{value}
</span>
);
}
const display = formatDisplay(dt);
const full = dt.toFormat("yyyy-MM-dd HH:mm:ss ZZZZ");
const relative = dt.toRelative() ?? "";
return (
<time
dateTime={dt.toISO() ?? value}
title={`${full} · ${relative}`}
className={cn(
"inline-flex items-center shrink-0 font-mono text-xs px-1.5 py-0.5 rounded",
"select-none cursor-default",
className,
)}
style={{
background: "rgba(99,102,241,0.1)",
color: "var(--accent-text)",
border: "1px solid rgba(99,102,241,0.2)",
}}
>
{display}
</time>
);
}

View File

@@ -0,0 +1,29 @@
import { cn } from "@/lib/utils";
import { type VariantProps, cva } from "class-variance-authority";
import type { HTMLAttributes } from "react";
const badgeVariants = cva(
"inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-xs font-medium transition-colors",
{
variants: {
variant: {
default: "border-transparent bg-primary/15 text-primary",
secondary: "border-transparent bg-secondary text-muted-foreground",
outline: "border-border text-muted-foreground",
destructive: "border-transparent bg-red-500/15 text-red-400",
success: "border-transparent bg-emerald-500/15 text-emerald-400",
warning: "border-transparent bg-amber-500/15 text-amber-400",
blue: "border-transparent bg-sky-500/15 text-sky-400",
},
},
defaultVariants: { variant: "default" },
},
);
interface BadgeProps extends HTMLAttributes<HTMLSpanElement>, VariantProps<typeof badgeVariants> {}
export function Badge({ className, variant, ...props }: BadgeProps) {
return <span className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { badgeVariants };

View File

@@ -0,0 +1,25 @@
import { cn } from "@/lib/utils";
import type { HTMLAttributes } from "react";
export function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("rounded-xl border border-border bg-card text-card-foreground", className)}
{...props}
/>
);
}
export function CardHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn("flex flex-col gap-1 px-5 py-4", className)} {...props} />;
}
export function CardTitle({ className, ...props }: HTMLAttributes<HTMLHeadingElement>) {
return (
<h3 className={cn("text-sm font-semibold leading-none tracking-tight", className)} {...props} />
);
}
export function CardContent({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn("px-5 pb-5", className)} {...props} />;
}

View File

@@ -0,0 +1,9 @@
"use client";
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
const Collapsible = CollapsiblePrimitive.Root;
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
export { Collapsible, CollapsibleTrigger, CollapsibleContent };

View File

@@ -1,24 +1,50 @@
import { useState } from "react"; import { useDeleteWorkspace, useQueueStatus, useScheduleDream, useWorkspace } from "@/api/queries";
import { Link, useParams, useNavigate } from "@tanstack/react-router"; import { ConfirmDialog } from "@/components/shared/ConfirmDialog";
import { motion } from "framer-motion";
import { Boxes, Users, MessageSquare, Lightbulb, ArrowLeft, CircleDot, Trash2, Zap, Webhook } from "lucide-react";
import {
useWorkspace,
useQueueStatus,
useDeleteWorkspace,
useScheduleDream,
} from "@/api/queries";
import { PageLoader } from "@/components/shared/LoadingSpinner";
import { ErrorAlert } from "@/components/shared/ErrorAlert"; import { ErrorAlert } from "@/components/shared/ErrorAlert";
import { JsonViewer } from "@/components/shared/JsonViewer"; import { JsonViewer } from "@/components/shared/JsonViewer";
import { ConfirmDialog } from "@/components/shared/ConfirmDialog"; import { PageLoader } from "@/components/shared/LoadingSpinner";
import { ScheduleDreamModal } from "@/components/workspaces/ScheduleDreamModal"; import { ScheduleDreamModal } from "@/components/workspaces/ScheduleDreamModal";
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 = [ 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: "Peers",
{ label: "Conclusions", icon: Lightbulb, to: "conclusions" as const, description: "Browse memory conclusions" }, icon: Users,
{ label: "Webhooks", icon: Webhook, to: "webhooks" as const, description: "Manage event webhooks" }, 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; ] as const;
export function WorkspaceDetail() { export function WorkspaceDetail() {
@@ -33,6 +59,7 @@ export function WorkspaceDetail() {
const [confirmDelete, setConfirmDelete] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false);
const [dreamOpen, setDreamOpen] = useState(false); const [dreamOpen, setDreamOpen] = useState(false);
const [sessionsExpanded, setSessionsExpanded] = useState(false);
const handleDelete = async () => { const handleDelete = async () => {
await deleteWorkspace.mutateAsync(workspaceId); await deleteWorkspace.mutateAsync(workspaceId);
@@ -52,7 +79,11 @@ export function WorkspaceDetail() {
</Link> </Link>
<div className="flex items-start justify-between gap-4 mb-1"> <div className="flex items-start justify-between gap-4 mb-1">
<div className="flex items-center gap-2 min-w-0"> <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} /> <Boxes
className="w-5 h-5 flex-shrink-0"
style={{ color: "var(--accent)" }}
strokeWidth={1.5}
/>
<h1 <h1
className="text-xl font-semibold font-mono break-all tracking-tight" className="text-xl font-semibold font-mono break-all tracking-tight"
style={{ color: "var(--text-1)" }} style={{ color: "var(--text-1)" }}
@@ -87,7 +118,9 @@ export function WorkspaceDetail() {
</button> </button>
</div> </div>
</div> </div>
<p className="text-sm" style={{ color: "var(--text-2)" }}>Workspace overview</p> <p className="text-sm" style={{ color: "var(--text-2)" }}>
Workspace overview
</p>
</motion.div> </motion.div>
<div className="mt-8"> <div className="mt-8">
@@ -112,7 +145,11 @@ export function WorkspaceDetail() {
params={{ workspaceId } as never} params={{ workspaceId } as never}
className="block rounded-xl p-5 group transition-all theme-card" 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} /> <Icon
className="w-5 h-5 mb-3"
style={{ color: "var(--accent)" }}
strokeWidth={1.5}
/>
<h2 className="text-sm font-medium mb-0.5" style={{ color: "var(--text-1)" }}> <h2 className="text-sm font-medium mb-0.5" style={{ color: "var(--text-1)" }}>
{s.label} {s.label}
</h2> </h2>
@@ -139,24 +176,47 @@ export function WorkspaceDetail() {
</h2> </h2>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
{queue.pending_work_units > 0 ? ( {queue.pending_work_units > 0 ? (
<motion.div animate={{ opacity: [0.5, 1, 0.5] }} transition={{ duration: 1.5, repeat: Infinity }}> <motion.div
<CircleDot className="w-3.5 h-3.5" style={{ color: "#f59e0b" }} strokeWidth={2} /> 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: "#f59e0b" }}
strokeWidth={2}
/>
</motion.div> </motion.div>
) : ( ) : (
<CircleDot className="w-3.5 h-3.5" style={{ color: "#34d399" }} strokeWidth={2} /> <CircleDot
className="w-3.5 h-3.5"
style={{ color: "#34d399" }}
strokeWidth={2}
/>
)} )}
<span <span
className="text-xs font-medium" className="text-xs font-medium"
style={{ color: queue.pending_work_units > 0 ? "#f59e0b" : "#34d399" }} style={{ color: queue.pending_work_units > 0 ? "#f59e0b" : "#34d399" }}
> >
{queue.pending_work_units === 0 ? "Idle" : `${queue.pending_work_units} pending`} {queue.pending_work_units === 0
? "Idle"
: `${queue.pending_work_units} pending`}
</span> </span>
</div> </div>
</div> </div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4"> <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) => ( {(
[
"total_work_units",
"completed_work_units",
"in_progress_work_units",
"pending_work_units",
] as const
).map((key) => (
<div key={key}> <div key={key}>
<div className="text-2xl font-semibold font-mono" style={{ color: "var(--text-1)" }}> <div
className="text-2xl font-semibold font-mono"
style={{ color: "var(--text-1)" }}
>
{queue[key]} {queue[key]}
</div> </div>
<div className="text-xs capitalize mt-0.5" style={{ color: "var(--text-3)" }}> <div className="text-xs capitalize mt-0.5" style={{ color: "var(--text-3)" }}>
@@ -165,6 +225,108 @@ export function WorkspaceDetail() {
</div> </div>
))} ))}
</div> </div>
{/* Per-session breakdown */}
{queue.sessions && Object.keys(queue.sessions).length > 0 && (
<div>
<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: "#34d399" }}
>
{s.completed_work_units}
</td>
<td
className="py-1.5 px-3 text-right font-mono"
style={{ color: "#f59e0b" }}
>
{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> </motion.div>
)} )}

View File

@@ -5,50 +5,70 @@
@import "@fontsource/dm-sans/500.css"; @import "@fontsource/dm-sans/500.css";
@import "@fontsource/dm-sans/600.css"; @import "@fontsource/dm-sans/600.css";
/* ─── Tailwind v4 theme bridge ─── */
@theme inline {
--color-background: var(--bg);
--color-foreground: var(--text-1);
--color-card: var(--bg-2);
--color-card-foreground: var(--text-1);
--color-muted: var(--bg-3);
--color-muted-foreground: var(--text-3);
--color-primary: var(--accent);
--color-primary-foreground: #ffffff;
--color-secondary: var(--bg-3);
--color-secondary-foreground: var(--text-2);
--color-border: var(--border);
--color-ring: var(--accent);
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
}
/* ─── Theme tokens ─── */ /* ─── Theme tokens ─── */
:root, :root,
[data-theme="dark"] { [data-theme="dark"] {
--bg: #0c0c10; --bg: #0c0c10;
--bg-2: #111118; --bg-2: #111118;
--bg-3: #16161f; --bg-3: #1a1a24;
--surface: rgba(255, 255, 255, 0.02); --surface: rgba(255, 255, 255, 0.03);
--border: rgba(255, 255, 255, 0.06); --border: rgba(255, 255, 255, 0.08);
--border-2: rgba(255, 255, 255, 0.10); --border-2: rgba(255, 255, 255, 0.13);
--text-1: #e4e4f0; --text-1: #e8e8f4;
--text-2: rgba(148, 163, 184, 0.75); --text-2: #94a3b8;
--text-3: rgba(148, 163, 184, 0.40); --text-3: #64748b;
--text-4: rgba(148, 163, 184, 0.25); --text-4: #475569;
--accent: #6366f1; --accent: #6366f1;
--accent-dim: rgba(99, 102, 241, 0.12); --accent-dim: rgba(99, 102, 241, 0.15);
--accent-border: rgba(99, 102, 241, 0.30); --accent-border: rgba(99, 102, 241, 0.35);
--accent-text: #a5b4fc; --accent-text: #a5b4fc;
--sidebar-bg: linear-gradient(180deg, #111118 0%, #0e0e15 100%); --sidebar-bg: linear-gradient(180deg, #111118 0%, #0e0e15 100%);
--grid-line: rgba(99, 102, 241, 0.03); --grid-line: rgba(99, 102, 241, 0.03);
--glow: rgba(79, 70, 229, 0.08); --glow: rgba(79, 70, 229, 0.08);
--scrollbar: rgba(99, 102, 241, 0.2); --scrollbar: rgba(99, 102, 241, 0.2);
--card-hover: rgba(99, 102, 241, 0.06); --card-hover: rgba(99, 102, 241, 0.06);
} }
[data-theme="light"] { [data-theme="light"] {
--bg: #f8f8fc; --bg: #f8f8fc;
--bg-2: #ffffff; --bg-2: #ffffff;
--bg-3: #f0f0f8; --bg-3: #f0f0f8;
--surface: rgba(0, 0, 0, 0.02); --surface: rgba(0, 0, 0, 0.02);
--border: rgba(0, 0, 0, 0.07); --border: rgba(0, 0, 0, 0.08);
--border-2: rgba(0, 0, 0, 0.12); --border-2: rgba(0, 0, 0, 0.14);
--text-1: #1a1a2e; --text-1: #1a1a2e;
--text-2: rgba(30, 30, 60, 0.65); --text-2: #374151;
--text-3: rgba(30, 30, 60, 0.40); --text-3: #6b7280;
--text-4: rgba(30, 30, 60, 0.25); --text-4: #9ca3af;
--accent: #4f46e5; --accent: #4f46e5;
--accent-dim: rgba(79, 70, 229, 0.08); --accent-dim: rgba(79, 70, 229, 0.08);
--accent-border: rgba(79, 70, 229, 0.25); --accent-border: rgba(79, 70, 229, 0.25);
--accent-text: #4f46e5; --accent-text: #4f46e5;
--sidebar-bg: linear-gradient(180deg, #ffffff 0%, #f4f4fc 100%); --sidebar-bg: linear-gradient(180deg, #ffffff 0%, #f4f4fc 100%);
--grid-line: rgba(79, 70, 229, 0.04); --grid-line: rgba(79, 70, 229, 0.04);
--glow: rgba(79, 70, 229, 0.06); --glow: rgba(79, 70, 229, 0.06);
--scrollbar: rgba(79, 70, 229, 0.2); --scrollbar: rgba(79, 70, 229, 0.2);
--card-hover: rgba(79, 70, 229, 0.04); --card-hover: rgba(79, 70, 229, 0.04);
} }
/* ─── Base ─── */ /* ─── Base ─── */

28
src/lib/constants.ts Normal file
View File

@@ -0,0 +1,28 @@
// Semantic color tokens for inline styles.
// CSS variables (var(--text-1) etc.) handle theme-aware colors.
// These constants are for fixed semantic states that don't invert with theme.
export const COLOR = {
// Status
success: "#34d399",
successDim: "rgba(52,211,153,0.08)",
successBorder: "rgba(52,211,153,0.2)",
warning: "#f59e0b",
warningDim: "rgba(245,158,11,0.08)",
warningBorder: "rgba(245,158,11,0.2)",
destructive: "#f87171",
destructiveDim: "rgba(239,68,68,0.08)",
destructiveBorder: "rgba(239,68,68,0.2)",
// Accent (indigo — matches --accent CSS var)
accent: "#6366f1",
accentText: "#818cf8",
accentSoft: "#c7d2fe",
accentDim: "rgba(99,102,241,0.08)",
accentDimHover: "rgba(99,102,241,0.06)",
accentSubtle: "rgba(99,102,241,0.1)",
accentBorder: "rgba(99,102,241,0.2)",
accentBorderStrong: "rgba(99,102,241,0.15)",
} as const;

16
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,16 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
const _compact = new Intl.NumberFormat(undefined, {
notation: "compact",
maximumFractionDigits: 1,
});
export function formatCount(n: number): string {
if (n < 1_000) return String(n);
return _compact.format(n);
}