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": {
"@fontsource/dm-mono": "^5.2.7",
"@fontsource/dm-sans": "^5.2.8",
"@radix-ui/react-collapsible": "^1.1.12",
"@tailwindcss/vite": "^4.2.4",
"@tanstack/react-query": "^5.74.4",
"@tanstack/react-router": "^1.120.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.38.0",
"lucide-react": "^1.11.0",
"luxon": "^3.7.2",
"openapi-fetch": "^0.13.5",
"react": "^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",
"zod": "^3.24.3"
},
@@ -34,6 +41,7 @@
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/luxon": "^3.7.1",
"@types/node": "^25.6.0",
"@types/react": "^19.2.14",
"@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 {
useConclusions,
useQueryConclusions,
useCreateConclusion,
useDeleteConclusion,
useQueryConclusions,
} 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 { 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"];
@@ -58,7 +58,9 @@ export function ConclusionBrowser() {
const total = (data as { total?: number } | undefined)?.total ?? 0;
const displayedConclusions: Conclusion[] = activeSearch
? Array.isArray(searchResults) ? searchResults : []
? Array.isArray(searchResults)
? searchResults
: []
: conclusions;
function handleSearch(e: React.SyntheticEvent<HTMLFormElement>) {
@@ -144,7 +146,10 @@ export function ConclusionBrowser() {
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
onClick={() => { setActiveSearch(""); setSearchQuery(""); }}
onClick={() => {
setActiveSearch("");
setSearchQuery("");
}}
className="px-3 py-2.5 rounded-xl text-sm transition-all"
style={{
background: "var(--surface)",
@@ -182,8 +187,8 @@ export function ConclusionBrowser() {
className="text-xs font-mono mb-3"
style={{ color: "var(--text-4)" }}
>
{displayedConclusions.length} result{displayedConclusions.length !== 1 ? "s" : ""}{" "}
for &ldquo;{activeSearch}&rdquo;
{displayedConclusions.length} result{displayedConclusions.length !== 1 ? "s" : ""} for
&ldquo;{activeSearch}&rdquo;
</motion.p>
)}
<div className="space-y-3">
@@ -201,7 +206,10 @@ export function ConclusionBrowser() {
}}
>
<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}
</p>
<button
@@ -224,15 +232,32 @@ export function ConclusionBrowser() {
</div>
{c.observed_id && (
<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)" }}>
{c.observed_id}
</span>
</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 && (
<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)" }}>
{new Date(c.created_at).toLocaleString()}
</span>
@@ -284,15 +309,26 @@ function CreateConclusionModal({
}: {
open: boolean;
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;
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 set = (k: keyof typeof fields) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
setFields((f) => ({ ...f, [k]: e.target.value }));
const set =
(k: keyof typeof fields) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
setFields((f) => ({ ...f, [k]: e.target.value }));
const handleSubmit = async (e: React.SyntheticEvent<HTMLFormElement>) => {
e.preventDefault();
@@ -327,7 +363,9 @@ function CreateConclusionModal({
className="theme-input w-full text-sm px-3 py-2 rounded-lg"
/>
{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>
))}
@@ -343,7 +381,9 @@ function CreateConclusionModal({
className="theme-input w-full text-sm px-3 py-2 rounded-lg resize-y"
/>
{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>
@@ -357,13 +397,21 @@ function CreateConclusionModal({
className="theme-input w-full text-sm px-3 py-2 rounded-lg"
/>
</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">
<button
type="button"
onClick={onClose}
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
</button>
@@ -371,7 +419,11 @@ function CreateConclusionModal({
type="submit"
disabled={loading}
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"}
</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 { motion } from "framer-motion";
import { Boxes, Activity, LayoutDashboard } from "lucide-react";
import { useWorkspaces, useQueueStatus } from "@/api/queries";
import { PageLoader } from "@/components/shared/LoadingSpinner";
import { ErrorAlert } from "@/components/shared/ErrorAlert";
import { Activity, Boxes, ChevronRight, CircleDot, LayoutDashboard } from "lucide-react";
import { useState } from "react";
function QueueCard({ workspaceId }: { workspaceId: string }) {
type QueueStatus = components["schemas"]["QueueStatus"];
// ─── Per-workspace queue row ─────────────────────────────────────────────────
function WorkspaceQueueRow({ workspaceId }: { workspaceId: string }) {
const { data, isLoading } = useQueueStatus(workspaceId);
if (isLoading)
return (
<div className="rounded-xl p-5 theme-card">
<PageLoader />
</div>
);
if (!data) return null;
const pending = data.pending_work_units;
const pending = data?.pending_work_units ?? 0;
const active = data?.in_progress_work_units ?? 0;
const done = data?.completed_work_units ?? 0;
const total = data?.total_work_units ?? 0;
const isActive = active > 0 || pending > 0;
return (
<div className="rounded-xl p-5 theme-card">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Activity className="w-4 h-4" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
<h3 className="text-sm font-medium" style={{ color: "var(--text-1)" }}>Queue Status</h3>
</div>
<span
className="text-xs font-mono px-2 py-0.5 rounded-full"
style={{
background: pending === 0 ? "rgba(52,211,153,0.1)" : "rgba(245,158,11,0.1)",
color: pending === 0 ? "#34d399" : "#f59e0b",
border: `1px solid ${pending === 0 ? "rgba(52,211,153,0.2)" : "rgba(245,158,11,0.2)"}`,
}}
<tr
style={{
borderTop: "1px solid var(--border)",
background: isActive ? COLOR.warningDim : undefined,
}}
>
<td className="py-2 px-4">
<Link
to="/workspaces/$workspaceId"
params={{ workspaceId } as never}
className="flex items-center gap-2 group"
>
{pending === 0 ? "Idle" : "Active"}
</span>
</div>
<div className="space-y-2">
{(["total_work_units", "completed_work_units", "in_progress_work_units", "pending_work_units"] as const).map((key) => (
<div key={key} className="flex justify-between text-xs">
<span className="capitalize" style={{ color: "var(--text-3)" }}>
{key.replace(/_work_units$/, "").replace(/_/g, " ")}
</span>
<span className="font-mono font-medium" style={{ color: "var(--text-1)" }}>
{data[key]}
<span
className="font-mono text-xs truncate max-w-[200px] group-hover:underline"
style={{ color: "var(--accent-text)" }}
>
{workspaceId}
</span>
<ChevronRight
className="w-3 h-3 opacity-0 group-hover:opacity-60 transition-opacity flex-shrink-0"
style={{ color: "var(--accent)" }}
strokeWidth={2}
/>
</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>
</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>
);
}
// ─── Main dashboard ───────────────────────────────────────────────────────────
export function Dashboard() {
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;
return (
@@ -68,10 +163,29 @@ export function Dashboard() {
className="mb-8"
>
<div className="flex items-center gap-2 mb-1">
<LayoutDashboard 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)" }}>
<LayoutDashboard
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
</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>
<p className="text-sm" style={{ color: "var(--text-2)" }}>
Overview of your Honcho instance
@@ -81,98 +195,90 @@ export function Dashboard() {
<ErrorAlert error={error instanceof Error ? error : null} />
{isLoading && <PageLoader />}
{!isLoading && (
{!isLoading && workspaces.length > 0 && (
<div className="space-y-4">
{/* Stat row */}
{/* Aggregate stat row */}
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.05 }}
className="grid grid-cols-1 sm:grid-cols-3 gap-3"
>
{[
{ 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>
);
})}
<GlobalQueueBanner workspaces={workspaces} />
</motion.div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Workspace list */}
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="rounded-xl p-5 theme-card"
{/* Per-workspace queue table */}
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.12 }}
className="rounded-xl theme-card overflow-hidden"
>
<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">
<div className="flex items-center gap-2">
<Boxes className="w-4 h-4" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
<h2 className="text-sm font-medium" style={{ color: "var(--text-1)" }}>
Recent Workspaces
</h2>
</div>
<Link
to="/workspaces"
className="text-xs transition-colors"
style={{ color: "var(--accent-text)" }}
>
View all
</Link>
</div>
<Activity
className="w-4 h-4"
style={{ color: "var(--accent)" }}
strokeWidth={1.5}
/>
<h2 className="text-sm font-medium" style={{ color: "var(--text-1)" }}>
Queue Status
</h2>
<span className="text-xs ml-1" style={{ color: "var(--text-4)" }}>
all workspaces · updates every 10s
</span>
</div>
{workspaces.length === 0 ? (
<p className="text-sm" style={{ color: "var(--text-3)" }}>No workspaces found.</p>
) : (
<div className="space-y-1">
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<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) => (
<Link
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>
<WorkspaceQueueRow key={ws.id} workspaceId={ws.id} />
))}
</div>
)}
</motion.div>
</tbody>
</table>
</div>
</motion.div>
{/* Queue for first workspace */}
{workspaces[0] && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.15 }}
{total > workspaces.length && (
<p className="text-xs text-center" style={{ color: "var(--text-4)" }}>
Showing {workspaces.length} of {total} workspaces.{" "}
<Link
to="/workspaces"
className="hover:underline"
style={{ color: "var(--accent-text)" }}
>
<QueueCard workspaceId={workspaces[0].id} />
</motion.div>
)}
</div>
View all
</Link>
</p>
)}
</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>

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 {
usePeer,
usePeerCard,
usePeerContext,
usePeerRepresentation,
useSetPeerCard,
useSearchPeer,
useSetPeerCard,
} from "@/api/queries";
import { PageLoader } from "@/components/shared/LoadingSpinner";
import { Badge } from "@/components/shared/Badge";
import { ErrorAlert } from "@/components/shared/ErrorAlert";
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";
@@ -28,7 +30,10 @@ export function PeerDetail() {
const { data: peer, isLoading, error } = usePeer(workspaceId, peerId);
const { data: card, isLoading: cardLoading } = usePeerCard(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 searchPeer = useSearchPeer(workspaceId, peerId);
@@ -36,12 +41,11 @@ export function PeerDetail() {
const [cardDraft, setCardDraft] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const cardLines: string[] =
Array.isArray((card as { peer_card?: unknown })?.peer_card)
? ((card as { peer_card: string[] }).peer_card)
: typeof card === "string"
? [card]
: [];
const cardLines: string[] = Array.isArray((card as { peer_card?: unknown })?.peer_card)
? (card as { peer_card: string[] }).peer_card
: typeof card === "string"
? [card]
: [];
const tabs: Array<{ id: Tab; label: string }> = [
{ id: "context", label: "Context" },
@@ -55,13 +59,23 @@ export function PeerDetail() {
<div className="page-container">
<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)" }}>
<Link to="/workspaces" className="hover:underline">Workspaces</Link>
<Link to="/workspaces" className="hover:underline">
Workspaces
</Link>
<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}
</Link>
<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
</Link>
</div>
@@ -77,7 +91,9 @@ export function PeerDetail() {
{peerId}
</h1>
</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>
<button
onClick={() =>
@@ -132,29 +148,45 @@ export function PeerDetail() {
transition={{ duration: 0.2 }}
className="rounded-xl p-5 theme-card"
>
{tab === "context" && (
contextLoading ? <PageLoader /> : (
{tab === "context" &&
(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" ? (
<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} />
)}
</>
)
)}
))}
{tab === "card" && (
cardLoading ? <PageLoader /> : (
{tab === "card" &&
(cardLoading ? (
<PageLoader />
) : (
<>
<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 ? (
<button
onClick={() => setCardDraft(cardLines.join("\n"))}
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
</button>
@@ -167,7 +199,11 @@ export function PeerDetail() {
}}
disabled={setPeerCard.isPending}
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
@@ -175,7 +211,11 @@ export function PeerDetail() {
<button
onClick={() => setCardDraft(null)}
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} />
</button>
@@ -196,34 +236,32 @@ export function PeerDetail() {
/>
) : (
<motion.div key="view" initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
{cardLines.length > 0 ? (
<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>
)}
<PeerCardViewer lines={cardLines} />
</motion.div>
)}
</AnimatePresence>
</>
)
)}
))}
{tab === "representation" && (
repLoading ? <PageLoader /> : (
{tab === "representation" &&
(repLoading ? (
<PageLoader />
) : (
<>
<h2 className="text-sm font-medium mb-3" style={{ color: "var(--text-1)" }}>Memory Representation</h2>
{representation && typeof (representation as { representation?: unknown }).representation === "string" ? (
<p className="text-sm whitespace-pre-wrap leading-relaxed" style={{ color: "var(--text-2)" }}>
{(representation as { representation: string }).representation}
</p>
<h2 className="text-sm font-medium mb-3" style={{ color: "var(--text-1)" }}>
Memory Representation
</h2>
{representation &&
typeof (representation as { representation?: unknown }).representation ===
"string" ? (
<MarkdownRenderer
content={(representation as { representation: string }).representation}
/>
) : (
<JsonViewer data={representation} maxHeight="400px" />
)}
</>
)
)}
))}
{tab === "search" && (
<>
@@ -249,21 +287,44 @@ export function PeerDetail() {
type="submit"
disabled={searchPeer.isPending}
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"}
</button>
</form>
{searchPeer.data && (
<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
key={r.id}
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">
<Badge variant="blue">{r.peer_id ?? peerId}</Badge>
@@ -273,7 +334,12 @@ export function PeerDetail() {
</span>
)}
</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>
))
)}
@@ -284,7 +350,9 @@ export function PeerDetail() {
{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" />
</>
)}

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 { 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 { 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"];
@@ -32,11 +32,7 @@ export function PeerList() {
return (
<div className="p-8 max-w-3xl mx-auto">
<motion.div
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
className="mb-8"
>
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }} className="mb-8">
<Link
to="/workspaces/$workspaceId"
params={{ workspaceId } as never}
@@ -121,14 +117,28 @@ export function PeerList() {
strokeWidth={1.5}
/>
</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 className="flex items-center gap-2 flex-wrap">
{(peer.configuration as { observe_me?: boolean } | null)?.observe_me && (
<div className="flex items-center gap-1">
<Eye className="w-3 h-3" style={{ color: "#818cf8" }} strokeWidth={1.5} />
<span className="text-xs" style={{ color: "#818cf8" }}>
observed
</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.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 {
useSessionMessages,
useSessionSummaries,
useSessionContext,
useSessionPeers,
useDeleteSession,
useCloneSession,
useSearchSession,
useRemovePeersFromSession,
usePeers,
useAddPeersToSession,
useCloneSession,
useDeleteSession,
usePeers,
useRemovePeersFromSession,
useSearchSession,
useSessionContext,
useSessionMessages,
useSessionPeers,
useSessionSummaries,
} 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 { Pagination } from "@/components/shared/Pagination";
import { Badge } from "@/components/shared/Badge";
import { JsonViewer } from "@/components/shared/JsonViewer";
import { ConfirmDialog } from "@/components/shared/ConfirmDialog";
import type { components } from "@/api/schema.d.ts";
import { Link, useNavigate, useParams } from "@tanstack/react-router";
import { AnimatePresence, motion } from "framer-motion";
import { AlignLeft, Clock, Copy, MessageSquare, Search, Trash2, Users, X } from "lucide-react";
import { useState } from "react";
type Message = components["schemas"]["Message"];
type SessionSummaries = components["schemas"]["SessionSummaries"];
type Summary = components["schemas"]["Summary"];
type Tab = "messages" | "summaries" | "context" | "peers";
export function SessionDetail() {
@@ -37,8 +39,15 @@ export function SessionDetail() {
const [searchActive, setSearchActive] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const { data: msgData, isLoading: msgsLoading } = useSessionMessages(workspaceId, sessionId, page);
const { data: summaries, isLoading: summariesLoading } = useSessionSummaries(workspaceId, sessionId);
const { data: msgData, isLoading: msgsLoading } = useSessionMessages(
workspaceId,
sessionId,
page,
);
const { data: summaries, isLoading: summariesLoading } = useSessionSummaries(
workspaceId,
sessionId,
);
const { data: context, isLoading: contextLoading } = useSessionContext(workspaceId, sessionId);
const { data: sessionPeers, isLoading: peersLoading } = useSessionPeers(workspaceId, sessionId);
const { data: allPeers } = usePeers(workspaceId, 1, 100);
@@ -52,11 +61,11 @@ export function SessionDetail() {
const messages: Message[] = (msgData as { items?: Message[] } | undefined)?.items ?? [];
const totalPages = (msgData as { pages?: number } | undefined)?.pages ?? 1;
const memberPeerIds = new Set(
(sessionPeers as Array<{ id?: string; peer_id?: string }> | undefined)?.map(
(p) => p.id ?? p.peer_id ?? "",
) ?? [],
);
const sessionPeerItems = (
sessionPeers as { items?: Array<{ id?: string; peer_id?: string }> } | undefined
)?.items ?? [];
const memberPeerIds = new Set(sessionPeerItems.map((p) => p.id ?? p.peer_id ?? ""));
const availablePeers = (
(allPeers as { items?: Array<{ id: string }> } | undefined)?.items ?? []
@@ -71,7 +80,10 @@ export function SessionDetail() {
const handleDelete = async () => {
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 () => {
@@ -88,19 +100,34 @@ export function SessionDetail() {
<div className="page-container">
<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)" }}>
<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}
</Link>
<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
</Link>
</div>
<div className="flex items-start justify-between gap-4 mb-1">
<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} />
<h1 className="text-xl font-semibold font-mono break-all tracking-tight" style={{ color: "var(--text-1)" }}>
<MessageSquare
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}
</h1>
</div>
@@ -141,7 +168,9 @@ export function SessionDetail() {
</button>
</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>
{/* Inline search bar */}
@@ -171,19 +200,32 @@ export function SessionDetail() {
type="submit"
disabled={searchSession.isPending}
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"}
</button>
</form>
{searchSession.data && (
<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 ? (
<p className="text-sm" style={{ color: "var(--text-3)" }}>No results.</p>
{(searchSession.data as Array<{ id: string; content: string; peer_id?: string }>)
.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)" }}>
{r.peer_id && <Badge variant="blue" >{r.peer_id}</Badge>}
(
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)" }}
>
{r.peer_id && <Badge variant="blue">{r.peer_id}</Badge>}
<p className="mt-1 whitespace-pre-wrap">{r.content}</p>
</div>
))
@@ -227,15 +269,23 @@ export function SessionDetail() {
transition={{ duration: 0.2 }}
className="rounded-xl p-5 theme-card"
>
{tab === "messages" && (
msgsLoading ? <PageLoader /> : (
{tab === "messages" &&
(msgsLoading ? (
<PageLoader />
) : (
<div>
{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">
{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">
<Badge variant={msg.peer_id ? "blue" : "default"}>
{msg.peer_id ?? "system"}
@@ -251,7 +301,10 @@ export function SessionDetail() {
</span>
)}
</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}
</p>
</div>
@@ -260,45 +313,45 @@ export function SessionDetail() {
)}
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
</div>
)
)}
))}
{tab === "summaries" && (
summariesLoading ? <PageLoader /> : (
<>
<h2 className="text-sm font-medium mb-3" style={{ color: "var(--text-1)" }}>Session Summaries</h2>
<JsonViewer data={summaries} maxHeight="500px" />
</>
)
)}
{tab === "summaries" &&
(summariesLoading ? <PageLoader /> : <SummariesDisplay summaries={summaries} />)}
{tab === "context" && (
contextLoading ? <PageLoader /> : (
{tab === "context" &&
(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" ? (
<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}
</p>
) : (
<JsonViewer data={context} maxHeight="500px" />
)}
</>
)
)}
))}
{tab === "peers" && (
peersLoading ? <PageLoader /> : (
{tab === "peers" &&
(peersLoading ? (
<PageLoader />
) : (
<SessionPeersTab
members={sessionPeers as Array<{ id?: string; peer_id?: string }> | undefined}
members={sessionPeerItems}
available={availablePeers}
onRemove={(id) => removePeers.mutate([id])}
onAdd={(id) => addPeers.mutate({ [id]: {} })}
removing={removePeers.isPending}
adding={addPeers.isPending}
/>
)
)}
))}
</motion.div>
</div>
@@ -340,7 +393,9 @@ function SessionPeersTab({
Session members ({list.length})
</h2>
{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">
{list.map((p) => {
@@ -351,7 +406,9 @@ function SessionPeersTab({
className="flex items-center justify-between py-1.5 px-3 rounded-lg"
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
onClick={() => onRemove(id)}
disabled={removing}
@@ -394,3 +451,68 @@ function SessionPeersTab({
</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 { 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 { 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"];
@@ -32,11 +32,7 @@ export function SessionList() {
return (
<div className="p-8 max-w-3xl mx-auto">
<motion.div
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
className="mb-8"
>
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }} className="mb-8">
<Link
to="/workspaces/$workspaceId"
params={{ workspaceId } as never}
@@ -105,7 +101,10 @@ export function SessionList() {
}}
>
<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}
</span>
<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">
<motion.div
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>
<span className="text-xs" style={{ color: "#34d399" }}>Active</span>
<span className="text-xs" style={{ color: "#34d399" }}>
Active
</span>
</div>
)}
<ChevronRight
@@ -127,14 +132,32 @@ export function SessionList() {
/>
</div>
</div>
{session.created_at && (
<div className="flex items-center gap-1.5 mt-2">
<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(session.created_at).toLocaleString()}
</p>
</div>
)}
<div className="flex items-center gap-2 mt-2">
{session.created_at && (
<div className="flex items-center gap-1.5">
<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(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.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 { Link, useParams, useNavigate } from "@tanstack/react-router";
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 { 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 { ConfirmDialog } from "@/components/shared/ConfirmDialog";
import { PageLoader } from "@/components/shared/LoadingSpinner";
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 = [
{ 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" },
{
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() {
@@ -33,6 +59,7 @@ export function WorkspaceDetail() {
const [confirmDelete, setConfirmDelete] = useState(false);
const [dreamOpen, setDreamOpen] = useState(false);
const [sessionsExpanded, setSessionsExpanded] = useState(false);
const handleDelete = async () => {
await deleteWorkspace.mutateAsync(workspaceId);
@@ -52,7 +79,11 @@ export function WorkspaceDetail() {
</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} />
<Boxes
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)" }}
@@ -87,7 +118,9 @@ export function WorkspaceDetail() {
</button>
</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>
<div className="mt-8">
@@ -112,7 +145,11 @@ export function WorkspaceDetail() {
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} />
<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)" }}>
{s.label}
</h2>
@@ -139,24 +176,47 @@ export function WorkspaceDetail() {
</h2>
<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: Infinity }}>
<CircleDot className="w-3.5 h-3.5" style={{ color: "#f59e0b" }} strokeWidth={2} />
<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: "#f59e0b" }}
strokeWidth={2}
/>
</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
className="text-xs font-medium"
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>
</div>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
{(["total_work_units", "completed_work_units", "in_progress_work_units", "pending_work_units"] as const).map((key) => (
<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)" }}>
<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)" }}>
@@ -165,6 +225,108 @@ export function WorkspaceDetail() {
</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>
)}

View File

@@ -5,50 +5,70 @@
@import "@fontsource/dm-sans/500.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 ─── */
:root,
[data-theme="dark"] {
--bg: #0c0c10;
--bg-2: #111118;
--bg-3: #16161f;
--surface: rgba(255, 255, 255, 0.02);
--border: rgba(255, 255, 255, 0.06);
--border-2: rgba(255, 255, 255, 0.10);
--text-1: #e4e4f0;
--text-2: rgba(148, 163, 184, 0.75);
--text-3: rgba(148, 163, 184, 0.40);
--text-4: rgba(148, 163, 184, 0.25);
--accent: #6366f1;
--accent-dim: rgba(99, 102, 241, 0.12);
--accent-border: rgba(99, 102, 241, 0.30);
--accent-text: #a5b4fc;
--sidebar-bg: linear-gradient(180deg, #111118 0%, #0e0e15 100%);
--grid-line: rgba(99, 102, 241, 0.03);
--glow: rgba(79, 70, 229, 0.08);
--scrollbar: rgba(99, 102, 241, 0.2);
--card-hover: rgba(99, 102, 241, 0.06);
--bg: #0c0c10;
--bg-2: #111118;
--bg-3: #1a1a24;
--surface: rgba(255, 255, 255, 0.03);
--border: rgba(255, 255, 255, 0.08);
--border-2: rgba(255, 255, 255, 0.13);
--text-1: #e8e8f4;
--text-2: #94a3b8;
--text-3: #64748b;
--text-4: #475569;
--accent: #6366f1;
--accent-dim: rgba(99, 102, 241, 0.15);
--accent-border: rgba(99, 102, 241, 0.35);
--accent-text: #a5b4fc;
--sidebar-bg: linear-gradient(180deg, #111118 0%, #0e0e15 100%);
--grid-line: rgba(99, 102, 241, 0.03);
--glow: rgba(79, 70, 229, 0.08);
--scrollbar: rgba(99, 102, 241, 0.2);
--card-hover: rgba(99, 102, 241, 0.06);
}
[data-theme="light"] {
--bg: #f8f8fc;
--bg-2: #ffffff;
--bg-3: #f0f0f8;
--surface: rgba(0, 0, 0, 0.02);
--border: rgba(0, 0, 0, 0.07);
--border-2: rgba(0, 0, 0, 0.12);
--text-1: #1a1a2e;
--text-2: rgba(30, 30, 60, 0.65);
--text-3: rgba(30, 30, 60, 0.40);
--text-4: rgba(30, 30, 60, 0.25);
--accent: #4f46e5;
--accent-dim: rgba(79, 70, 229, 0.08);
--bg: #f8f8fc;
--bg-2: #ffffff;
--bg-3: #f0f0f8;
--surface: rgba(0, 0, 0, 0.02);
--border: rgba(0, 0, 0, 0.08);
--border-2: rgba(0, 0, 0, 0.14);
--text-1: #1a1a2e;
--text-2: #374151;
--text-3: #6b7280;
--text-4: #9ca3af;
--accent: #4f46e5;
--accent-dim: rgba(79, 70, 229, 0.08);
--accent-border: rgba(79, 70, 229, 0.25);
--accent-text: #4f46e5;
--sidebar-bg: linear-gradient(180deg, #ffffff 0%, #f4f4fc 100%);
--grid-line: rgba(79, 70, 229, 0.04);
--glow: rgba(79, 70, 229, 0.06);
--scrollbar: rgba(79, 70, 229, 0.2);
--card-hover: rgba(79, 70, 229, 0.04);
--accent-text: #4f46e5;
--sidebar-bg: linear-gradient(180deg, #ffffff 0%, #f4f4fc 100%);
--grid-line: rgba(79, 70, 229, 0.04);
--glow: rgba(79, 70, 229, 0.06);
--scrollbar: rgba(79, 70, 229, 0.2);
--card-hover: rgba(79, 70, 229, 0.04);
}
/* ─── 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);
}