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:
@@ -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
1109
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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 “{activeSearch}”
|
||||
{displayedConclusions.length} result{displayedConclusions.length !== 1 ? "s" : ""} for
|
||||
“{activeSearch}”
|
||||
</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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 & memory</p>
|
||||
<p className="text-sm" style={{ color: "var(--text-2)" }}>
|
||||
Peer identity & 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" />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
149
src/components/shared/MarkdownRenderer.tsx
Normal file
149
src/components/shared/MarkdownRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
239
src/components/shared/PeerCardViewer.tsx
Normal file
239
src/components/shared/PeerCardViewer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
62
src/components/shared/TimestampChip.tsx
Normal file
62
src/components/shared/TimestampChip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
src/components/ui/badge.tsx
Normal file
29
src/components/ui/badge.tsx
Normal 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 };
|
||||
25
src/components/ui/card.tsx
Normal file
25
src/components/ui/card.tsx
Normal 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} />;
|
||||
}
|
||||
9
src/components/ui/collapsible.tsx
Normal file
9
src/components/ui/collapsible.tsx
Normal 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 };
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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
28
src/lib/constants.ts
Normal 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
16
src/lib/utils.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user