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": {
|
"dependencies": {
|
||||||
"@fontsource/dm-mono": "^5.2.7",
|
"@fontsource/dm-mono": "^5.2.7",
|
||||||
"@fontsource/dm-sans": "^5.2.8",
|
"@fontsource/dm-sans": "^5.2.8",
|
||||||
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"@tailwindcss/vite": "^4.2.4",
|
"@tailwindcss/vite": "^4.2.4",
|
||||||
"@tanstack/react-query": "^5.74.4",
|
"@tanstack/react-query": "^5.74.4",
|
||||||
"@tanstack/react-router": "^1.120.3",
|
"@tanstack/react-router": "^1.120.3",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
"framer-motion": "^12.38.0",
|
"framer-motion": "^12.38.0",
|
||||||
"lucide-react": "^1.11.0",
|
"lucide-react": "^1.11.0",
|
||||||
|
"luxon": "^3.7.2",
|
||||||
"openapi-fetch": "^0.13.5",
|
"openapi-fetch": "^0.13.5",
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.5",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss": "^4.2.4",
|
"tailwindcss": "^4.2.4",
|
||||||
"zod": "^3.24.3"
|
"zod": "^3.24.3"
|
||||||
},
|
},
|
||||||
@@ -34,6 +41,7 @@
|
|||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
|
"@types/luxon": "^3.7.1",
|
||||||
"@types/node": "^25.6.0",
|
"@types/node": "^25.6.0",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
|||||||
1109
pnpm-lock.yaml
generated
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 {
|
import {
|
||||||
useConclusions,
|
useConclusions,
|
||||||
useQueryConclusions,
|
|
||||||
useCreateConclusion,
|
useCreateConclusion,
|
||||||
useDeleteConclusion,
|
useDeleteConclusion,
|
||||||
|
useQueryConclusions,
|
||||||
} from "@/api/queries";
|
} from "@/api/queries";
|
||||||
import { PageLoader } from "@/components/shared/LoadingSpinner";
|
|
||||||
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
|
||||||
import { Pagination } from "@/components/shared/Pagination";
|
|
||||||
import { EmptyState } from "@/components/shared/EmptyState";
|
|
||||||
import { FormModal } from "@/components/shared/FormModal";
|
|
||||||
import { ConfirmDialog } from "@/components/shared/ConfirmDialog";
|
|
||||||
import type { components } from "@/api/schema.d.ts";
|
import type { components } from "@/api/schema.d.ts";
|
||||||
|
import { ConfirmDialog } from "@/components/shared/ConfirmDialog";
|
||||||
|
import { EmptyState } from "@/components/shared/EmptyState";
|
||||||
|
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
||||||
|
import { FormModal } from "@/components/shared/FormModal";
|
||||||
|
import { PageLoader } from "@/components/shared/LoadingSpinner";
|
||||||
|
import { Pagination } from "@/components/shared/Pagination";
|
||||||
|
import { Link, useParams } from "@tanstack/react-router";
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import { ArrowLeft, Clock, Eye, Lightbulb, Plus, Search, Trash2, X } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
type Conclusion = components["schemas"]["Conclusion"];
|
type Conclusion = components["schemas"]["Conclusion"];
|
||||||
|
|
||||||
@@ -58,7 +58,9 @@ export function ConclusionBrowser() {
|
|||||||
const total = (data as { total?: number } | undefined)?.total ?? 0;
|
const total = (data as { total?: number } | undefined)?.total ?? 0;
|
||||||
|
|
||||||
const displayedConclusions: Conclusion[] = activeSearch
|
const displayedConclusions: Conclusion[] = activeSearch
|
||||||
? Array.isArray(searchResults) ? searchResults : []
|
? Array.isArray(searchResults)
|
||||||
|
? searchResults
|
||||||
|
: []
|
||||||
: conclusions;
|
: conclusions;
|
||||||
|
|
||||||
function handleSearch(e: React.SyntheticEvent<HTMLFormElement>) {
|
function handleSearch(e: React.SyntheticEvent<HTMLFormElement>) {
|
||||||
@@ -144,7 +146,10 @@ export function ConclusionBrowser() {
|
|||||||
initial={{ opacity: 0, scale: 0.8 }}
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
exit={{ opacity: 0, scale: 0.8 }}
|
exit={{ opacity: 0, scale: 0.8 }}
|
||||||
onClick={() => { setActiveSearch(""); setSearchQuery(""); }}
|
onClick={() => {
|
||||||
|
setActiveSearch("");
|
||||||
|
setSearchQuery("");
|
||||||
|
}}
|
||||||
className="px-3 py-2.5 rounded-xl text-sm transition-all"
|
className="px-3 py-2.5 rounded-xl text-sm transition-all"
|
||||||
style={{
|
style={{
|
||||||
background: "var(--surface)",
|
background: "var(--surface)",
|
||||||
@@ -182,8 +187,8 @@ export function ConclusionBrowser() {
|
|||||||
className="text-xs font-mono mb-3"
|
className="text-xs font-mono mb-3"
|
||||||
style={{ color: "var(--text-4)" }}
|
style={{ color: "var(--text-4)" }}
|
||||||
>
|
>
|
||||||
{displayedConclusions.length} result{displayedConclusions.length !== 1 ? "s" : ""}{" "}
|
{displayedConclusions.length} result{displayedConclusions.length !== 1 ? "s" : ""} for
|
||||||
for “{activeSearch}”
|
“{activeSearch}”
|
||||||
</motion.p>
|
</motion.p>
|
||||||
)}
|
)}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -201,7 +206,10 @@ export function ConclusionBrowser() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<p className="text-sm leading-relaxed whitespace-pre-wrap flex-1" style={{ color: "var(--text-2)" }}>
|
<p
|
||||||
|
className="text-sm leading-relaxed whitespace-pre-wrap flex-1"
|
||||||
|
style={{ color: "var(--text-2)" }}
|
||||||
|
>
|
||||||
{c.content}
|
{c.content}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
@@ -224,15 +232,32 @@ export function ConclusionBrowser() {
|
|||||||
</div>
|
</div>
|
||||||
{c.observed_id && (
|
{c.observed_id && (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<span className="text-xs" style={{ color: "var(--text-4)" }}>→</span>
|
<span className="text-xs" style={{ color: "var(--text-4)" }}>
|
||||||
|
→
|
||||||
|
</span>
|
||||||
<span className="text-xs font-mono" style={{ color: "var(--text-4)" }}>
|
<span className="text-xs font-mono" style={{ color: "var(--text-4)" }}>
|
||||||
{c.observed_id}
|
{c.observed_id}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{c.session_id && (
|
||||||
|
<Link
|
||||||
|
to={"/workspaces/$workspaceId/sessions/$sessionId" as never}
|
||||||
|
params={{ workspaceId, sessionId: c.session_id } as never}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="flex items-center gap-1 text-xs font-mono hover:underline"
|
||||||
|
style={{ color: "var(--accent-text)" }}
|
||||||
|
>
|
||||||
|
{c.session_id}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
{c.created_at && (
|
{c.created_at && (
|
||||||
<div className="flex items-center gap-1 ml-auto">
|
<div className="flex items-center gap-1 ml-auto">
|
||||||
<Clock className="w-3 h-3" style={{ color: "var(--text-4)" }} strokeWidth={1.5} />
|
<Clock
|
||||||
|
className="w-3 h-3"
|
||||||
|
style={{ color: "var(--text-4)" }}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
<span className="text-xs font-mono" style={{ color: "var(--text-4)" }}>
|
<span className="text-xs font-mono" style={{ color: "var(--text-4)" }}>
|
||||||
{new Date(c.created_at).toLocaleString()}
|
{new Date(c.created_at).toLocaleString()}
|
||||||
</span>
|
</span>
|
||||||
@@ -284,15 +309,26 @@ function CreateConclusionModal({
|
|||||||
}: {
|
}: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSubmit: (v: { observer_id: string; observed_id: string; content: string; session_id?: string | null }) => Promise<void>;
|
onSubmit: (v: {
|
||||||
|
observer_id: string;
|
||||||
|
observed_id: string;
|
||||||
|
content: string;
|
||||||
|
session_id?: string | null;
|
||||||
|
}) => Promise<void>;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
}) {
|
}) {
|
||||||
const [fields, setFields] = useState({ observer_id: "", observed_id: "", content: "", session_id: "" });
|
const [fields, setFields] = useState({
|
||||||
|
observer_id: "",
|
||||||
|
observed_id: "",
|
||||||
|
content: "",
|
||||||
|
session_id: "",
|
||||||
|
});
|
||||||
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
|
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
const set = (k: keyof typeof fields) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
|
const set =
|
||||||
setFields((f) => ({ ...f, [k]: e.target.value }));
|
(k: keyof typeof fields) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
|
||||||
|
setFields((f) => ({ ...f, [k]: e.target.value }));
|
||||||
|
|
||||||
const handleSubmit = async (e: React.SyntheticEvent<HTMLFormElement>) => {
|
const handleSubmit = async (e: React.SyntheticEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -327,7 +363,9 @@ function CreateConclusionModal({
|
|||||||
className="theme-input w-full text-sm px-3 py-2 rounded-lg"
|
className="theme-input w-full text-sm px-3 py-2 rounded-lg"
|
||||||
/>
|
/>
|
||||||
{validationErrors[field] && (
|
{validationErrors[field] && (
|
||||||
<p className="text-xs mt-1" style={{ color: "#f87171" }}>{validationErrors[field]}</p>
|
<p className="text-xs mt-1" style={{ color: "#f87171" }}>
|
||||||
|
{validationErrors[field]}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -343,7 +381,9 @@ function CreateConclusionModal({
|
|||||||
className="theme-input w-full text-sm px-3 py-2 rounded-lg resize-y"
|
className="theme-input w-full text-sm px-3 py-2 rounded-lg resize-y"
|
||||||
/>
|
/>
|
||||||
{validationErrors.content && (
|
{validationErrors.content && (
|
||||||
<p className="text-xs mt-1" style={{ color: "#f87171" }}>{validationErrors.content}</p>
|
<p className="text-xs mt-1" style={{ color: "#f87171" }}>
|
||||||
|
{validationErrors.content}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -357,13 +397,21 @@ function CreateConclusionModal({
|
|||||||
className="theme-input w-full text-sm px-3 py-2 rounded-lg"
|
className="theme-input w-full text-sm px-3 py-2 rounded-lg"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{error && <p className="text-xs" style={{ color: "#f87171" }}>{error}</p>}
|
{error && (
|
||||||
|
<p className="text-xs" style={{ color: "#f87171" }}>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="px-3 py-1.5 text-sm rounded-lg"
|
className="px-3 py-1.5 text-sm rounded-lg"
|
||||||
style={{ background: "var(--surface)", border: "1px solid var(--border)", color: "var(--text-2)" }}
|
style={{
|
||||||
|
background: "var(--surface)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
color: "var(--text-2)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
@@ -371,7 +419,11 @@ function CreateConclusionModal({
|
|||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="px-3 py-1.5 text-sm rounded-lg font-medium disabled:opacity-50"
|
className="px-3 py-1.5 text-sm rounded-lg font-medium disabled:opacity-50"
|
||||||
style={{ background: "var(--accent-dim)", border: "1px solid var(--accent-border)", color: "var(--accent-text)" }}
|
style={{
|
||||||
|
background: "var(--accent-dim)",
|
||||||
|
border: "1px solid var(--accent-border)",
|
||||||
|
color: "var(--accent-text)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{loading ? "Creating..." : "Create"}
|
{loading ? "Creating..." : "Create"}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,63 +1,158 @@
|
|||||||
import { useState } from "react";
|
import { useQueueStatus, useWorkspaces } from "@/api/queries";
|
||||||
|
import type { components } from "@/api/schema.d.ts";
|
||||||
|
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
||||||
|
import { PageLoader } from "@/components/shared/LoadingSpinner";
|
||||||
|
import { COLOR } from "@/lib/constants";
|
||||||
|
import { formatCount } from "@/lib/utils";
|
||||||
import { Link } from "@tanstack/react-router";
|
import { Link } from "@tanstack/react-router";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { Boxes, Activity, LayoutDashboard } from "lucide-react";
|
import { Activity, Boxes, ChevronRight, CircleDot, LayoutDashboard } from "lucide-react";
|
||||||
import { useWorkspaces, useQueueStatus } from "@/api/queries";
|
import { useState } from "react";
|
||||||
import { PageLoader } from "@/components/shared/LoadingSpinner";
|
|
||||||
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
|
||||||
|
|
||||||
function QueueCard({ workspaceId }: { workspaceId: string }) {
|
type QueueStatus = components["schemas"]["QueueStatus"];
|
||||||
|
|
||||||
|
// ─── Per-workspace queue row ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function WorkspaceQueueRow({ workspaceId }: { workspaceId: string }) {
|
||||||
const { data, isLoading } = useQueueStatus(workspaceId);
|
const { data, isLoading } = useQueueStatus(workspaceId);
|
||||||
|
|
||||||
if (isLoading)
|
const pending = data?.pending_work_units ?? 0;
|
||||||
return (
|
const active = data?.in_progress_work_units ?? 0;
|
||||||
<div className="rounded-xl p-5 theme-card">
|
const done = data?.completed_work_units ?? 0;
|
||||||
<PageLoader />
|
const total = data?.total_work_units ?? 0;
|
||||||
</div>
|
const isActive = active > 0 || pending > 0;
|
||||||
);
|
|
||||||
if (!data) return null;
|
|
||||||
|
|
||||||
const pending = data.pending_work_units;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl p-5 theme-card">
|
<tr
|
||||||
<div className="flex items-center justify-between mb-4">
|
style={{
|
||||||
<div className="flex items-center gap-2">
|
borderTop: "1px solid var(--border)",
|
||||||
<Activity className="w-4 h-4" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
|
background: isActive ? COLOR.warningDim : undefined,
|
||||||
<h3 className="text-sm font-medium" style={{ color: "var(--text-1)" }}>Queue Status</h3>
|
}}
|
||||||
</div>
|
>
|
||||||
<span
|
<td className="py-2 px-4">
|
||||||
className="text-xs font-mono px-2 py-0.5 rounded-full"
|
<Link
|
||||||
style={{
|
to="/workspaces/$workspaceId"
|
||||||
background: pending === 0 ? "rgba(52,211,153,0.1)" : "rgba(245,158,11,0.1)",
|
params={{ workspaceId } as never}
|
||||||
color: pending === 0 ? "#34d399" : "#f59e0b",
|
className="flex items-center gap-2 group"
|
||||||
border: `1px solid ${pending === 0 ? "rgba(52,211,153,0.2)" : "rgba(245,158,11,0.2)"}`,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{pending === 0 ? "Idle" : "Active"}
|
<span
|
||||||
</span>
|
className="font-mono text-xs truncate max-w-[200px] group-hover:underline"
|
||||||
</div>
|
style={{ color: "var(--accent-text)" }}
|
||||||
<div className="space-y-2">
|
>
|
||||||
{(["total_work_units", "completed_work_units", "in_progress_work_units", "pending_work_units"] as const).map((key) => (
|
{workspaceId}
|
||||||
<div key={key} className="flex justify-between text-xs">
|
</span>
|
||||||
<span className="capitalize" style={{ color: "var(--text-3)" }}>
|
<ChevronRight
|
||||||
{key.replace(/_work_units$/, "").replace(/_/g, " ")}
|
className="w-3 h-3 opacity-0 group-hover:opacity-60 transition-opacity flex-shrink-0"
|
||||||
</span>
|
style={{ color: "var(--accent)" }}
|
||||||
<span className="font-mono font-medium" style={{ color: "var(--text-1)" }}>
|
strokeWidth={2}
|
||||||
{data[key]}
|
/>
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="py-2 px-4 text-right">
|
||||||
|
{isLoading ? (
|
||||||
|
<span className="text-xs font-mono" style={{ color: "var(--text-4)" }}>
|
||||||
|
…
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-end gap-1.5">
|
||||||
|
{isActive ? (
|
||||||
|
<motion.div
|
||||||
|
animate={{ opacity: [0.5, 1, 0.5] }}
|
||||||
|
transition={{ duration: 1.5, repeat: Number.POSITIVE_INFINITY }}
|
||||||
|
>
|
||||||
|
<CircleDot className="w-3 h-3" style={{ color: COLOR.warning }} strokeWidth={2} />
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
<CircleDot className="w-3 h-3" style={{ color: COLOR.success }} strokeWidth={2} />
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className="text-xs font-medium"
|
||||||
|
style={{ color: isActive ? COLOR.warning : COLOR.success }}
|
||||||
|
>
|
||||||
|
{isActive ? `${formatCount(pending + active)} pending` : "Idle"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)}
|
||||||
</div>
|
</td>
|
||||||
|
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
{ val: total, color: "var(--text-2)" },
|
||||||
|
{ val: done, color: COLOR.success },
|
||||||
|
{ val: active, color: COLOR.warning },
|
||||||
|
{ val: pending, color: "var(--text-3)" },
|
||||||
|
] as Array<{ val: number; color: string }>
|
||||||
|
).map(({ val, color }, i) => (
|
||||||
|
<td
|
||||||
|
// biome-ignore lint/suspicious/noArrayIndexKey: static positional columns
|
||||||
|
key={i}
|
||||||
|
className="py-2 px-4 text-right font-mono text-xs"
|
||||||
|
style={{ color: isLoading ? "var(--text-4)" : color }}
|
||||||
|
>
|
||||||
|
{isLoading ? "—" : formatCount(val)}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Aggregate banner ─────────────────────────────────────────────────────────
|
||||||
|
// Each workspace row already called useQueueStatus — TanStack Query deduplicates
|
||||||
|
// the fetches so calling the same hooks here just reads from cache.
|
||||||
|
|
||||||
|
function GlobalQueueBanner({ workspaces }: { workspaces: Array<{ id: string }> }) {
|
||||||
|
const statuses = workspaces.map((ws) => {
|
||||||
|
// biome-ignore lint/correctness/useHookAtTopLevel: intentional map over stable list
|
||||||
|
const { data } = useQueueStatus(ws.id);
|
||||||
|
return data as QueueStatus | undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalPending = statuses.reduce((s, d) => s + (d?.pending_work_units ?? 0), 0);
|
||||||
|
const totalActive = statuses.reduce((s, d) => s + (d?.in_progress_work_units ?? 0), 0);
|
||||||
|
const totalDone = statuses.reduce((s, d) => s + (d?.completed_work_units ?? 0), 0);
|
||||||
|
const allLoaded = statuses.every((d) => d !== undefined);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
{ label: "Workspaces", value: workspaces.length, color: "var(--text-1)", always: true },
|
||||||
|
{ label: "Total done", value: totalDone, color: COLOR.success, always: false },
|
||||||
|
{ label: "Active", value: totalActive, color: COLOR.warning, always: false },
|
||||||
|
{
|
||||||
|
label: "Pending",
|
||||||
|
value: totalPending,
|
||||||
|
color: totalPending > 0 ? COLOR.warning : "var(--text-3)",
|
||||||
|
always: false,
|
||||||
|
},
|
||||||
|
] as Array<{ label: string; value: number; color: string; always: boolean }>
|
||||||
|
).map(({ label, value, color, always }) => (
|
||||||
|
<div key={label} className="rounded-xl p-4 theme-card">
|
||||||
|
<div
|
||||||
|
className="text-2xl font-semibold font-mono"
|
||||||
|
style={{ color: allLoaded || always ? color : "var(--text-4)" }}
|
||||||
|
>
|
||||||
|
{allLoaded || always ? formatCount(value) : "—"}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs mt-0.5" style={{ color: "var(--text-3)" }}>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Main dashboard ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function Dashboard() {
|
export function Dashboard() {
|
||||||
const [page] = useState(1);
|
const [page] = useState(1);
|
||||||
const { data, isLoading, error } = useWorkspaces(page, 6);
|
const { data, isLoading, error } = useWorkspaces(page, 50);
|
||||||
|
|
||||||
const workspaces = (data as { items?: Array<{ id: string; created_at?: string }> } | undefined)?.items ?? [];
|
const workspaces = (
|
||||||
|
data as { items?: Array<{ id: string; created_at?: string }> } | undefined
|
||||||
|
)?.items ?? [];
|
||||||
const total = (data as { total?: number } | undefined)?.total ?? 0;
|
const total = (data as { total?: number } | undefined)?.total ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -68,10 +163,29 @@ export function Dashboard() {
|
|||||||
className="mb-8"
|
className="mb-8"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<LayoutDashboard className="w-5 h-5" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
|
<LayoutDashboard
|
||||||
<h1 className="text-xl font-semibold tracking-tight" style={{ color: "var(--text-1)" }}>
|
className="w-5 h-5"
|
||||||
|
style={{ color: "var(--accent)" }}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
|
<h1
|
||||||
|
className="text-xl font-semibold tracking-tight"
|
||||||
|
style={{ color: "var(--text-1)" }}
|
||||||
|
>
|
||||||
Dashboard
|
Dashboard
|
||||||
</h1>
|
</h1>
|
||||||
|
{total > 0 && (
|
||||||
|
<span
|
||||||
|
className="ml-1 text-xs font-mono px-2 py-0.5 rounded-full"
|
||||||
|
style={{
|
||||||
|
background: COLOR.accentSubtle,
|
||||||
|
color: COLOR.accentText,
|
||||||
|
border: `1px solid ${COLOR.accentBorder}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{total} workspace{total !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm" style={{ color: "var(--text-2)" }}>
|
<p className="text-sm" style={{ color: "var(--text-2)" }}>
|
||||||
Overview of your Honcho instance
|
Overview of your Honcho instance
|
||||||
@@ -81,98 +195,90 @@ export function Dashboard() {
|
|||||||
<ErrorAlert error={error instanceof Error ? error : null} />
|
<ErrorAlert error={error instanceof Error ? error : null} />
|
||||||
{isLoading && <PageLoader />}
|
{isLoading && <PageLoader />}
|
||||||
|
|
||||||
{!isLoading && (
|
{!isLoading && workspaces.length > 0 && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Stat row */}
|
{/* Aggregate stat row */}
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 8 }}
|
initial={{ opacity: 0, y: 8 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ delay: 0.05 }}
|
transition={{ delay: 0.05 }}
|
||||||
className="grid grid-cols-1 sm:grid-cols-3 gap-3"
|
|
||||||
>
|
>
|
||||||
{[
|
<GlobalQueueBanner workspaces={workspaces} />
|
||||||
{ label: "Workspaces", value: total, icon: Boxes },
|
|
||||||
].map((stat) => {
|
|
||||||
const Icon = stat.icon;
|
|
||||||
return (
|
|
||||||
<div key={stat.label} className="rounded-xl p-5 theme-card">
|
|
||||||
<Icon className="w-5 h-5 mb-3" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
|
|
||||||
<div className="text-3xl font-semibold font-mono" style={{ color: "var(--text-1)" }}>
|
|
||||||
{stat.value}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs mt-1" style={{ color: "var(--text-3)" }}>
|
|
||||||
{stat.label}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
{/* Per-workspace queue table */}
|
||||||
{/* Workspace list */}
|
<motion.div
|
||||||
<motion.div
|
initial={{ opacity: 0, y: 8 }}
|
||||||
initial={{ opacity: 0, y: 8 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
transition={{ delay: 0.12 }}
|
||||||
transition={{ delay: 0.1 }}
|
className="rounded-xl theme-card overflow-hidden"
|
||||||
className="rounded-xl p-5 theme-card"
|
>
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 px-4 py-3"
|
||||||
|
style={{ borderBottom: "1px solid var(--border)" }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<Activity
|
||||||
<div className="flex items-center gap-2">
|
className="w-4 h-4"
|
||||||
<Boxes className="w-4 h-4" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
|
style={{ color: "var(--accent)" }}
|
||||||
<h2 className="text-sm font-medium" style={{ color: "var(--text-1)" }}>
|
strokeWidth={1.5}
|
||||||
Recent Workspaces
|
/>
|
||||||
</h2>
|
<h2 className="text-sm font-medium" style={{ color: "var(--text-1)" }}>
|
||||||
</div>
|
Queue Status
|
||||||
<Link
|
</h2>
|
||||||
to="/workspaces"
|
<span className="text-xs ml-1" style={{ color: "var(--text-4)" }}>
|
||||||
className="text-xs transition-colors"
|
all workspaces · updates every 10s
|
||||||
style={{ color: "var(--accent-text)" }}
|
</span>
|
||||||
>
|
</div>
|
||||||
View all →
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{workspaces.length === 0 ? (
|
<div className="overflow-x-auto">
|
||||||
<p className="text-sm" style={{ color: "var(--text-3)" }}>No workspaces found.</p>
|
<table className="w-full text-xs">
|
||||||
) : (
|
<thead>
|
||||||
<div className="space-y-1">
|
<tr style={{ background: "var(--bg-3)" }}>
|
||||||
|
{["Workspace", "Status", "Total", "Done", "Active", "Pending"].map((h) => (
|
||||||
|
<th
|
||||||
|
key={h}
|
||||||
|
className={`py-2 px-4 font-medium text-left ${h !== "Workspace" && h !== "Status" ? "text-right" : ""}`}
|
||||||
|
style={{ color: "var(--text-3)" }}
|
||||||
|
>
|
||||||
|
{h}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
{workspaces.map((ws) => (
|
{workspaces.map((ws) => (
|
||||||
<Link
|
<WorkspaceQueueRow key={ws.id} workspaceId={ws.id} />
|
||||||
key={ws.id}
|
|
||||||
to="/workspaces/$workspaceId"
|
|
||||||
params={{ workspaceId: ws.id } as never}
|
|
||||||
className="flex items-center justify-between py-2 px-3 rounded-lg transition-all group"
|
|
||||||
style={{ color: "var(--text-2)" }}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="font-mono text-xs truncate"
|
|
||||||
style={{ color: "var(--accent-text)" }}
|
|
||||||
>
|
|
||||||
{ws.id}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className="text-xs opacity-0 group-hover:opacity-100 transition-opacity"
|
|
||||||
style={{ color: "var(--text-4)" }}
|
|
||||||
>
|
|
||||||
→
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</tbody>
|
||||||
)}
|
</table>
|
||||||
</motion.div>
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
{/* Queue for first workspace */}
|
{total > workspaces.length && (
|
||||||
{workspaces[0] && (
|
<p className="text-xs text-center" style={{ color: "var(--text-4)" }}>
|
||||||
<motion.div
|
Showing {workspaces.length} of {total} workspaces.{" "}
|
||||||
initial={{ opacity: 0, y: 8 }}
|
<Link
|
||||||
animate={{ opacity: 1, y: 0 }}
|
to="/workspaces"
|
||||||
transition={{ delay: 0.15 }}
|
className="hover:underline"
|
||||||
|
style={{ color: "var(--accent-text)" }}
|
||||||
>
|
>
|
||||||
<QueueCard workspaceId={workspaces[0].id} />
|
View all →
|
||||||
</motion.div>
|
</Link>
|
||||||
)}
|
</p>
|
||||||
</div>
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && workspaces.length === 0 && (
|
||||||
|
<div className="rounded-xl p-10 text-center theme-card">
|
||||||
|
<Boxes
|
||||||
|
className="w-8 h-8 mx-auto mb-3"
|
||||||
|
style={{ color: "var(--text-4)" }}
|
||||||
|
strokeWidth={1}
|
||||||
|
/>
|
||||||
|
<p className="text-sm" style={{ color: "var(--text-3)" }}>
|
||||||
|
No workspaces found.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { Link, useNavigate, useParams } from "@tanstack/react-router";
|
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
|
||||||
import { User, MessageCircle, Search, Save, X } from "lucide-react";
|
|
||||||
import {
|
import {
|
||||||
usePeer,
|
usePeer,
|
||||||
usePeerCard,
|
usePeerCard,
|
||||||
usePeerContext,
|
usePeerContext,
|
||||||
usePeerRepresentation,
|
usePeerRepresentation,
|
||||||
useSetPeerCard,
|
|
||||||
useSearchPeer,
|
useSearchPeer,
|
||||||
|
useSetPeerCard,
|
||||||
} from "@/api/queries";
|
} from "@/api/queries";
|
||||||
import { PageLoader } from "@/components/shared/LoadingSpinner";
|
import { Badge } from "@/components/shared/Badge";
|
||||||
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
||||||
import { JsonViewer } from "@/components/shared/JsonViewer";
|
import { JsonViewer } from "@/components/shared/JsonViewer";
|
||||||
import { Badge } from "@/components/shared/Badge";
|
import { PageLoader } from "@/components/shared/LoadingSpinner";
|
||||||
|
import { MarkdownRenderer } from "@/components/shared/MarkdownRenderer";
|
||||||
|
import { PeerCardViewer } from "@/components/shared/PeerCardViewer";
|
||||||
|
import { Link, useNavigate, useParams } from "@tanstack/react-router";
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import { MessageCircle, Save, Search, User, X } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
type Tab = "context" | "card" | "representation" | "metadata" | "search";
|
type Tab = "context" | "card" | "representation" | "metadata" | "search";
|
||||||
|
|
||||||
@@ -28,7 +30,10 @@ export function PeerDetail() {
|
|||||||
const { data: peer, isLoading, error } = usePeer(workspaceId, peerId);
|
const { data: peer, isLoading, error } = usePeer(workspaceId, peerId);
|
||||||
const { data: card, isLoading: cardLoading } = usePeerCard(workspaceId, peerId);
|
const { data: card, isLoading: cardLoading } = usePeerCard(workspaceId, peerId);
|
||||||
const { data: context, isLoading: contextLoading } = usePeerContext(workspaceId, peerId);
|
const { data: context, isLoading: contextLoading } = usePeerContext(workspaceId, peerId);
|
||||||
const { data: representation, isLoading: repLoading } = usePeerRepresentation(workspaceId, peerId);
|
const { data: representation, isLoading: repLoading } = usePeerRepresentation(
|
||||||
|
workspaceId,
|
||||||
|
peerId,
|
||||||
|
);
|
||||||
|
|
||||||
const setPeerCard = useSetPeerCard(workspaceId, peerId);
|
const setPeerCard = useSetPeerCard(workspaceId, peerId);
|
||||||
const searchPeer = useSearchPeer(workspaceId, peerId);
|
const searchPeer = useSearchPeer(workspaceId, peerId);
|
||||||
@@ -36,12 +41,11 @@ export function PeerDetail() {
|
|||||||
const [cardDraft, setCardDraft] = useState<string | null>(null);
|
const [cardDraft, setCardDraft] = useState<string | null>(null);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
|
||||||
const cardLines: string[] =
|
const cardLines: string[] = Array.isArray((card as { peer_card?: unknown })?.peer_card)
|
||||||
Array.isArray((card as { peer_card?: unknown })?.peer_card)
|
? (card as { peer_card: string[] }).peer_card
|
||||||
? ((card as { peer_card: string[] }).peer_card)
|
: typeof card === "string"
|
||||||
: typeof card === "string"
|
? [card]
|
||||||
? [card]
|
: [];
|
||||||
: [];
|
|
||||||
|
|
||||||
const tabs: Array<{ id: Tab; label: string }> = [
|
const tabs: Array<{ id: Tab; label: string }> = [
|
||||||
{ id: "context", label: "Context" },
|
{ id: "context", label: "Context" },
|
||||||
@@ -55,13 +59,23 @@ export function PeerDetail() {
|
|||||||
<div className="page-container">
|
<div className="page-container">
|
||||||
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }}>
|
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }}>
|
||||||
<div className="flex items-center gap-2 text-xs mb-4" style={{ color: "var(--text-3)" }}>
|
<div className="flex items-center gap-2 text-xs mb-4" style={{ color: "var(--text-3)" }}>
|
||||||
<Link to="/workspaces" className="hover:underline">Workspaces</Link>
|
<Link to="/workspaces" className="hover:underline">
|
||||||
|
Workspaces
|
||||||
|
</Link>
|
||||||
<span>/</span>
|
<span>/</span>
|
||||||
<Link to="/workspaces/$workspaceId" params={{ workspaceId } as never} className="hover:underline font-mono">
|
<Link
|
||||||
|
to="/workspaces/$workspaceId"
|
||||||
|
params={{ workspaceId } as never}
|
||||||
|
className="hover:underline font-mono"
|
||||||
|
>
|
||||||
{workspaceId}
|
{workspaceId}
|
||||||
</Link>
|
</Link>
|
||||||
<span>/</span>
|
<span>/</span>
|
||||||
<Link to="/workspaces/$workspaceId/peers" params={{ workspaceId } as never} className="hover:underline">
|
<Link
|
||||||
|
to="/workspaces/$workspaceId/peers"
|
||||||
|
params={{ workspaceId } as never}
|
||||||
|
className="hover:underline"
|
||||||
|
>
|
||||||
Peers
|
Peers
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -77,7 +91,9 @@ export function PeerDetail() {
|
|||||||
{peerId}
|
{peerId}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm" style={{ color: "var(--text-2)" }}>Peer identity & memory</p>
|
<p className="text-sm" style={{ color: "var(--text-2)" }}>
|
||||||
|
Peer identity & memory
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
@@ -132,29 +148,45 @@ export function PeerDetail() {
|
|||||||
transition={{ duration: 0.2 }}
|
transition={{ duration: 0.2 }}
|
||||||
className="rounded-xl p-5 theme-card"
|
className="rounded-xl p-5 theme-card"
|
||||||
>
|
>
|
||||||
{tab === "context" && (
|
{tab === "context" &&
|
||||||
contextLoading ? <PageLoader /> : (
|
(contextLoading ? (
|
||||||
|
<PageLoader />
|
||||||
|
) : (
|
||||||
<>
|
<>
|
||||||
<h2 className="text-sm font-medium mb-3" style={{ color: "var(--text-1)" }}>Peer Context</h2>
|
<h2 className="text-sm font-medium mb-3" style={{ color: "var(--text-1)" }}>
|
||||||
|
Peer Context
|
||||||
|
</h2>
|
||||||
{typeof context === "string" ? (
|
{typeof context === "string" ? (
|
||||||
<p className="text-sm whitespace-pre-wrap leading-relaxed" style={{ color: "var(--text-2)" }}>{context}</p>
|
<p
|
||||||
|
className="text-sm whitespace-pre-wrap leading-relaxed"
|
||||||
|
style={{ color: "var(--text-2)" }}
|
||||||
|
>
|
||||||
|
{context}
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<JsonViewer data={context} />
|
<JsonViewer data={context} />
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
))}
|
||||||
)}
|
|
||||||
|
|
||||||
{tab === "card" && (
|
{tab === "card" &&
|
||||||
cardLoading ? <PageLoader /> : (
|
(cardLoading ? (
|
||||||
|
<PageLoader />
|
||||||
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h2 className="text-sm font-medium" style={{ color: "var(--text-1)" }}>Peer Card</h2>
|
<h2 className="text-sm font-medium" style={{ color: "var(--text-1)" }}>
|
||||||
|
Peer Card
|
||||||
|
</h2>
|
||||||
{cardDraft === null ? (
|
{cardDraft === null ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => setCardDraft(cardLines.join("\n"))}
|
onClick={() => setCardDraft(cardLines.join("\n"))}
|
||||||
className="text-xs px-2 py-1 rounded-lg transition-colors"
|
className="text-xs px-2 py-1 rounded-lg transition-colors"
|
||||||
style={{ background: "var(--accent-dim)", border: "1px solid var(--accent-border)", color: "var(--accent-text)" }}
|
style={{
|
||||||
|
background: "var(--accent-dim)",
|
||||||
|
border: "1px solid var(--accent-border)",
|
||||||
|
color: "var(--accent-text)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
@@ -167,7 +199,11 @@ export function PeerDetail() {
|
|||||||
}}
|
}}
|
||||||
disabled={setPeerCard.isPending}
|
disabled={setPeerCard.isPending}
|
||||||
className="flex items-center gap-1 text-xs px-2 py-1 rounded-lg disabled:opacity-50"
|
className="flex items-center gap-1 text-xs px-2 py-1 rounded-lg disabled:opacity-50"
|
||||||
style={{ background: "var(--accent-dim)", border: "1px solid var(--accent-border)", color: "var(--accent-text)" }}
|
style={{
|
||||||
|
background: "var(--accent-dim)",
|
||||||
|
border: "1px solid var(--accent-border)",
|
||||||
|
color: "var(--accent-text)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Save className="w-3 h-3" strokeWidth={2} />
|
<Save className="w-3 h-3" strokeWidth={2} />
|
||||||
Save
|
Save
|
||||||
@@ -175,7 +211,11 @@ export function PeerDetail() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setCardDraft(null)}
|
onClick={() => setCardDraft(null)}
|
||||||
className="flex items-center gap-1 text-xs px-2 py-1 rounded-lg"
|
className="flex items-center gap-1 text-xs px-2 py-1 rounded-lg"
|
||||||
style={{ background: "var(--surface)", border: "1px solid var(--border)", color: "var(--text-3)" }}
|
style={{
|
||||||
|
background: "var(--surface)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
color: "var(--text-3)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<X className="w-3 h-3" strokeWidth={2} />
|
<X className="w-3 h-3" strokeWidth={2} />
|
||||||
</button>
|
</button>
|
||||||
@@ -196,34 +236,32 @@ export function PeerDetail() {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<motion.div key="view" initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
|
<motion.div key="view" initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
|
||||||
{cardLines.length > 0 ? (
|
<PeerCardViewer lines={cardLines} />
|
||||||
<p className="text-sm whitespace-pre-wrap" style={{ color: "var(--text-2)" }}>
|
|
||||||
{cardLines.join("\n")}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm" style={{ color: "var(--text-4)" }}>No card set.</p>
|
|
||||||
)}
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</>
|
</>
|
||||||
)
|
))}
|
||||||
)}
|
|
||||||
|
|
||||||
{tab === "representation" && (
|
{tab === "representation" &&
|
||||||
repLoading ? <PageLoader /> : (
|
(repLoading ? (
|
||||||
|
<PageLoader />
|
||||||
|
) : (
|
||||||
<>
|
<>
|
||||||
<h2 className="text-sm font-medium mb-3" style={{ color: "var(--text-1)" }}>Memory Representation</h2>
|
<h2 className="text-sm font-medium mb-3" style={{ color: "var(--text-1)" }}>
|
||||||
{representation && typeof (representation as { representation?: unknown }).representation === "string" ? (
|
Memory Representation
|
||||||
<p className="text-sm whitespace-pre-wrap leading-relaxed" style={{ color: "var(--text-2)" }}>
|
</h2>
|
||||||
{(representation as { representation: string }).representation}
|
{representation &&
|
||||||
</p>
|
typeof (representation as { representation?: unknown }).representation ===
|
||||||
|
"string" ? (
|
||||||
|
<MarkdownRenderer
|
||||||
|
content={(representation as { representation: string }).representation}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<JsonViewer data={representation} maxHeight="400px" />
|
<JsonViewer data={representation} maxHeight="400px" />
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
))}
|
||||||
)}
|
|
||||||
|
|
||||||
{tab === "search" && (
|
{tab === "search" && (
|
||||||
<>
|
<>
|
||||||
@@ -249,21 +287,44 @@ export function PeerDetail() {
|
|||||||
type="submit"
|
type="submit"
|
||||||
disabled={searchPeer.isPending}
|
disabled={searchPeer.isPending}
|
||||||
className="px-3 py-2 text-sm rounded-lg font-medium"
|
className="px-3 py-2 text-sm rounded-lg font-medium"
|
||||||
style={{ background: "var(--accent-dim)", border: "1px solid var(--accent-border)", color: "var(--accent-text)" }}
|
style={{
|
||||||
|
background: "var(--accent-dim)",
|
||||||
|
border: "1px solid var(--accent-border)",
|
||||||
|
color: "var(--accent-text)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{searchPeer.isPending ? "…" : "Search"}
|
{searchPeer.isPending ? "…" : "Search"}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{searchPeer.data && (
|
{searchPeer.data && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{(searchPeer.data as Array<{ id: string; content: string; peer_id?: string; created_at?: string }>).length === 0 ? (
|
{(
|
||||||
<p className="text-sm" style={{ color: "var(--text-3)" }}>No results.</p>
|
searchPeer.data as Array<{
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
peer_id?: string;
|
||||||
|
created_at?: string;
|
||||||
|
}>
|
||||||
|
).length === 0 ? (
|
||||||
|
<p className="text-sm" style={{ color: "var(--text-3)" }}>
|
||||||
|
No results.
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
(searchPeer.data as Array<{ id: string; content: string; peer_id?: string; created_at?: string }>).map((r) => (
|
(
|
||||||
|
searchPeer.data as Array<{
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
peer_id?: string;
|
||||||
|
created_at?: string;
|
||||||
|
}>
|
||||||
|
).map((r) => (
|
||||||
<div
|
<div
|
||||||
key={r.id}
|
key={r.id}
|
||||||
className="py-3 px-4 rounded-lg"
|
className="py-3 px-4 rounded-lg"
|
||||||
style={{ background: "var(--surface)", border: "1px solid var(--border)" }}
|
style={{
|
||||||
|
background: "var(--surface)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 mb-1.5">
|
<div className="flex items-center gap-2 mb-1.5">
|
||||||
<Badge variant="blue">{r.peer_id ?? peerId}</Badge>
|
<Badge variant="blue">{r.peer_id ?? peerId}</Badge>
|
||||||
@@ -273,7 +334,12 @@ export function PeerDetail() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm whitespace-pre-wrap" style={{ color: "var(--text-2)" }}>{r.content}</p>
|
<p
|
||||||
|
className="text-sm whitespace-pre-wrap"
|
||||||
|
style={{ color: "var(--text-2)" }}
|
||||||
|
>
|
||||||
|
{r.content}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
@@ -284,7 +350,9 @@ export function PeerDetail() {
|
|||||||
|
|
||||||
{tab === "metadata" && (
|
{tab === "metadata" && (
|
||||||
<>
|
<>
|
||||||
<h2 className="text-sm font-medium mb-3" style={{ color: "var(--text-1)" }}>Peer Metadata</h2>
|
<h2 className="text-sm font-medium mb-3" style={{ color: "var(--text-1)" }}>
|
||||||
|
Peer Metadata
|
||||||
|
</h2>
|
||||||
<JsonViewer data={peer.metadata} maxHeight="400px" />
|
<JsonViewer data={peer.metadata} maxHeight="400px" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { Link, useNavigate, useParams } from "@tanstack/react-router";
|
|
||||||
import { motion, type Variants } from "framer-motion";
|
|
||||||
import { Users, ChevronRight, Clock, ArrowLeft } from "lucide-react";
|
|
||||||
import { usePeers } from "@/api/queries";
|
import { usePeers } from "@/api/queries";
|
||||||
import { PageLoader } from "@/components/shared/LoadingSpinner";
|
|
||||||
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
|
||||||
import { Pagination } from "@/components/shared/Pagination";
|
|
||||||
import { EmptyState } from "@/components/shared/EmptyState";
|
|
||||||
import type { components } from "@/api/schema.d.ts";
|
import type { components } from "@/api/schema.d.ts";
|
||||||
|
import { EmptyState } from "@/components/shared/EmptyState";
|
||||||
|
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
||||||
|
import { PageLoader } from "@/components/shared/LoadingSpinner";
|
||||||
|
import { Pagination } from "@/components/shared/Pagination";
|
||||||
|
import { Link, useNavigate, useParams } from "@tanstack/react-router";
|
||||||
|
import { type Variants, motion } from "framer-motion";
|
||||||
|
import { ArrowLeft, ChevronRight, Clock, Eye, Users } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
type Peer = components["schemas"]["Peer"];
|
type Peer = components["schemas"]["Peer"];
|
||||||
|
|
||||||
@@ -32,11 +32,7 @@ export function PeerList() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8 max-w-3xl mx-auto">
|
<div className="p-8 max-w-3xl mx-auto">
|
||||||
<motion.div
|
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }} className="mb-8">
|
||||||
initial={{ opacity: 0, y: -8 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
className="mb-8"
|
|
||||||
>
|
|
||||||
<Link
|
<Link
|
||||||
to="/workspaces/$workspaceId"
|
to="/workspaces/$workspaceId"
|
||||||
params={{ workspaceId } as never}
|
params={{ workspaceId } as never}
|
||||||
@@ -121,14 +117,28 @@ export function PeerList() {
|
|||||||
strokeWidth={1.5}
|
strokeWidth={1.5}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{peer.created_at && (
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<div className="flex items-center gap-1">
|
{(peer.configuration as { observe_me?: boolean } | null)?.observe_me && (
|
||||||
<Clock className="w-3 h-3" style={{ color: "rgba(148,163,184,0.3)" }} strokeWidth={1.5} />
|
<div className="flex items-center gap-1">
|
||||||
<p className="text-xs font-mono" style={{ color: "rgba(148,163,184,0.3)" }}>
|
<Eye className="w-3 h-3" style={{ color: "#818cf8" }} strokeWidth={1.5} />
|
||||||
{new Date(peer.created_at).toLocaleString()}
|
<span className="text-xs" style={{ color: "#818cf8" }}>
|
||||||
</p>
|
observed
|
||||||
</div>
|
</span>
|
||||||
)}
|
</div>
|
||||||
|
)}
|
||||||
|
{peer.created_at && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Clock
|
||||||
|
className="w-3 h-3"
|
||||||
|
style={{ color: "rgba(148,163,184,0.3)" }}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
|
<p className="text-xs font-mono" style={{ color: "rgba(148,163,184,0.3)" }}>
|
||||||
|
{new Date(peer.created_at).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</motion.button>
|
</motion.button>
|
||||||
))}
|
))}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -1,27 +1,29 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { Link, useParams, useNavigate } from "@tanstack/react-router";
|
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
|
||||||
import { MessageSquare, Trash2, Copy, Search, Users, X } from "lucide-react";
|
|
||||||
import {
|
import {
|
||||||
useSessionMessages,
|
|
||||||
useSessionSummaries,
|
|
||||||
useSessionContext,
|
|
||||||
useSessionPeers,
|
|
||||||
useDeleteSession,
|
|
||||||
useCloneSession,
|
|
||||||
useSearchSession,
|
|
||||||
useRemovePeersFromSession,
|
|
||||||
usePeers,
|
|
||||||
useAddPeersToSession,
|
useAddPeersToSession,
|
||||||
|
useCloneSession,
|
||||||
|
useDeleteSession,
|
||||||
|
usePeers,
|
||||||
|
useRemovePeersFromSession,
|
||||||
|
useSearchSession,
|
||||||
|
useSessionContext,
|
||||||
|
useSessionMessages,
|
||||||
|
useSessionPeers,
|
||||||
|
useSessionSummaries,
|
||||||
} from "@/api/queries";
|
} from "@/api/queries";
|
||||||
|
import type { components } from "@/api/schema.d.ts";
|
||||||
|
import { Badge } from "@/components/shared/Badge";
|
||||||
|
import { ConfirmDialog } from "@/components/shared/ConfirmDialog";
|
||||||
|
import { JsonViewer } from "@/components/shared/JsonViewer";
|
||||||
import { PageLoader } from "@/components/shared/LoadingSpinner";
|
import { PageLoader } from "@/components/shared/LoadingSpinner";
|
||||||
import { Pagination } from "@/components/shared/Pagination";
|
import { Pagination } from "@/components/shared/Pagination";
|
||||||
import { Badge } from "@/components/shared/Badge";
|
import { Link, useNavigate, useParams } from "@tanstack/react-router";
|
||||||
import { JsonViewer } from "@/components/shared/JsonViewer";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
import { ConfirmDialog } from "@/components/shared/ConfirmDialog";
|
import { AlignLeft, Clock, Copy, MessageSquare, Search, Trash2, Users, X } from "lucide-react";
|
||||||
import type { components } from "@/api/schema.d.ts";
|
import { useState } from "react";
|
||||||
|
|
||||||
type Message = components["schemas"]["Message"];
|
type Message = components["schemas"]["Message"];
|
||||||
|
type SessionSummaries = components["schemas"]["SessionSummaries"];
|
||||||
|
type Summary = components["schemas"]["Summary"];
|
||||||
type Tab = "messages" | "summaries" | "context" | "peers";
|
type Tab = "messages" | "summaries" | "context" | "peers";
|
||||||
|
|
||||||
export function SessionDetail() {
|
export function SessionDetail() {
|
||||||
@@ -37,8 +39,15 @@ export function SessionDetail() {
|
|||||||
const [searchActive, setSearchActive] = useState(false);
|
const [searchActive, setSearchActive] = useState(false);
|
||||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
|
|
||||||
const { data: msgData, isLoading: msgsLoading } = useSessionMessages(workspaceId, sessionId, page);
|
const { data: msgData, isLoading: msgsLoading } = useSessionMessages(
|
||||||
const { data: summaries, isLoading: summariesLoading } = useSessionSummaries(workspaceId, sessionId);
|
workspaceId,
|
||||||
|
sessionId,
|
||||||
|
page,
|
||||||
|
);
|
||||||
|
const { data: summaries, isLoading: summariesLoading } = useSessionSummaries(
|
||||||
|
workspaceId,
|
||||||
|
sessionId,
|
||||||
|
);
|
||||||
const { data: context, isLoading: contextLoading } = useSessionContext(workspaceId, sessionId);
|
const { data: context, isLoading: contextLoading } = useSessionContext(workspaceId, sessionId);
|
||||||
const { data: sessionPeers, isLoading: peersLoading } = useSessionPeers(workspaceId, sessionId);
|
const { data: sessionPeers, isLoading: peersLoading } = useSessionPeers(workspaceId, sessionId);
|
||||||
const { data: allPeers } = usePeers(workspaceId, 1, 100);
|
const { data: allPeers } = usePeers(workspaceId, 1, 100);
|
||||||
@@ -52,11 +61,11 @@ export function SessionDetail() {
|
|||||||
const messages: Message[] = (msgData as { items?: Message[] } | undefined)?.items ?? [];
|
const messages: Message[] = (msgData as { items?: Message[] } | undefined)?.items ?? [];
|
||||||
const totalPages = (msgData as { pages?: number } | undefined)?.pages ?? 1;
|
const totalPages = (msgData as { pages?: number } | undefined)?.pages ?? 1;
|
||||||
|
|
||||||
const memberPeerIds = new Set(
|
const sessionPeerItems = (
|
||||||
(sessionPeers as Array<{ id?: string; peer_id?: string }> | undefined)?.map(
|
sessionPeers as { items?: Array<{ id?: string; peer_id?: string }> } | undefined
|
||||||
(p) => p.id ?? p.peer_id ?? "",
|
)?.items ?? [];
|
||||||
) ?? [],
|
|
||||||
);
|
const memberPeerIds = new Set(sessionPeerItems.map((p) => p.id ?? p.peer_id ?? ""));
|
||||||
|
|
||||||
const availablePeers = (
|
const availablePeers = (
|
||||||
(allPeers as { items?: Array<{ id: string }> } | undefined)?.items ?? []
|
(allPeers as { items?: Array<{ id: string }> } | undefined)?.items ?? []
|
||||||
@@ -71,7 +80,10 @@ export function SessionDetail() {
|
|||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
await deleteSession.mutateAsync(sessionId);
|
await deleteSession.mutateAsync(sessionId);
|
||||||
navigate({ to: "/workspaces/$workspaceId/sessions" as never, params: { workspaceId } as never });
|
navigate({
|
||||||
|
to: "/workspaces/$workspaceId/sessions" as never,
|
||||||
|
params: { workspaceId } as never,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClone = async () => {
|
const handleClone = async () => {
|
||||||
@@ -88,19 +100,34 @@ export function SessionDetail() {
|
|||||||
<div className="page-container">
|
<div className="page-container">
|
||||||
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }}>
|
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }}>
|
||||||
<div className="flex items-center gap-2 text-xs mb-4" style={{ color: "var(--text-3)" }}>
|
<div className="flex items-center gap-2 text-xs mb-4" style={{ color: "var(--text-3)" }}>
|
||||||
<Link to="/workspaces/$workspaceId" params={{ workspaceId } as never} className="hover:underline font-mono">
|
<Link
|
||||||
|
to="/workspaces/$workspaceId"
|
||||||
|
params={{ workspaceId } as never}
|
||||||
|
className="hover:underline font-mono"
|
||||||
|
>
|
||||||
{workspaceId}
|
{workspaceId}
|
||||||
</Link>
|
</Link>
|
||||||
<span>/</span>
|
<span>/</span>
|
||||||
<Link to="/workspaces/$workspaceId/sessions" params={{ workspaceId } as never} className="hover:underline">
|
<Link
|
||||||
|
to="/workspaces/$workspaceId/sessions"
|
||||||
|
params={{ workspaceId } as never}
|
||||||
|
className="hover:underline"
|
||||||
|
>
|
||||||
Sessions
|
Sessions
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-start justify-between gap-4 mb-1">
|
<div className="flex items-start justify-between gap-4 mb-1">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<MessageSquare className="w-5 h-5 flex-shrink-0" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
|
<MessageSquare
|
||||||
<h1 className="text-xl font-semibold font-mono break-all tracking-tight" style={{ color: "var(--text-1)" }}>
|
className="w-5 h-5 flex-shrink-0"
|
||||||
|
style={{ color: "var(--accent)" }}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
|
<h1
|
||||||
|
className="text-xl font-semibold font-mono break-all tracking-tight"
|
||||||
|
style={{ color: "var(--text-1)" }}
|
||||||
|
>
|
||||||
{sessionId}
|
{sessionId}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
@@ -141,7 +168,9 @@ export function SessionDetail() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm" style={{ color: "var(--text-2)" }}>Session detail</p>
|
<p className="text-sm" style={{ color: "var(--text-2)" }}>
|
||||||
|
Session detail
|
||||||
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Inline search bar */}
|
{/* Inline search bar */}
|
||||||
@@ -171,19 +200,32 @@ export function SessionDetail() {
|
|||||||
type="submit"
|
type="submit"
|
||||||
disabled={searchSession.isPending}
|
disabled={searchSession.isPending}
|
||||||
className="px-3 py-2 text-sm rounded-lg font-medium"
|
className="px-3 py-2 text-sm rounded-lg font-medium"
|
||||||
style={{ background: "var(--accent-dim)", border: "1px solid var(--accent-border)", color: "var(--accent-text)" }}
|
style={{
|
||||||
|
background: "var(--accent-dim)",
|
||||||
|
border: "1px solid var(--accent-border)",
|
||||||
|
color: "var(--accent-text)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{searchSession.isPending ? "…" : "Search"}
|
{searchSession.isPending ? "…" : "Search"}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{searchSession.data && (
|
{searchSession.data && (
|
||||||
<div className="mt-3 rounded-xl p-4 theme-card space-y-2">
|
<div className="mt-3 rounded-xl p-4 theme-card space-y-2">
|
||||||
{(searchSession.data as Array<{ id: string; content: string; peer_id?: string }>).length === 0 ? (
|
{(searchSession.data as Array<{ id: string; content: string; peer_id?: string }>)
|
||||||
<p className="text-sm" style={{ color: "var(--text-3)" }}>No results.</p>
|
.length === 0 ? (
|
||||||
|
<p className="text-sm" style={{ color: "var(--text-3)" }}>
|
||||||
|
No results.
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
(searchSession.data as Array<{ id: string; content: string; peer_id?: string }>).map((r) => (
|
(
|
||||||
<div key={r.id} className="text-sm py-2" style={{ borderBottom: "1px solid var(--border)", color: "var(--text-2)" }}>
|
searchSession.data as Array<{ id: string; content: string; peer_id?: string }>
|
||||||
{r.peer_id && <Badge variant="blue" >{r.peer_id}</Badge>}
|
).map((r) => (
|
||||||
|
<div
|
||||||
|
key={r.id}
|
||||||
|
className="text-sm py-2"
|
||||||
|
style={{ borderBottom: "1px solid var(--border)", color: "var(--text-2)" }}
|
||||||
|
>
|
||||||
|
{r.peer_id && <Badge variant="blue">{r.peer_id}</Badge>}
|
||||||
<p className="mt-1 whitespace-pre-wrap">{r.content}</p>
|
<p className="mt-1 whitespace-pre-wrap">{r.content}</p>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
@@ -227,15 +269,23 @@ export function SessionDetail() {
|
|||||||
transition={{ duration: 0.2 }}
|
transition={{ duration: 0.2 }}
|
||||||
className="rounded-xl p-5 theme-card"
|
className="rounded-xl p-5 theme-card"
|
||||||
>
|
>
|
||||||
{tab === "messages" && (
|
{tab === "messages" &&
|
||||||
msgsLoading ? <PageLoader /> : (
|
(msgsLoading ? (
|
||||||
|
<PageLoader />
|
||||||
|
) : (
|
||||||
<div>
|
<div>
|
||||||
{messages.length === 0 ? (
|
{messages.length === 0 ? (
|
||||||
<p className="text-sm" style={{ color: "var(--text-3)" }}>No messages.</p>
|
<p className="text-sm" style={{ color: "var(--text-3)" }}>
|
||||||
|
No messages.
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{messages.map((msg) => (
|
{messages.map((msg) => (
|
||||||
<div key={msg.id} className="pb-4" style={{ borderBottom: "1px solid var(--border)" }}>
|
<div
|
||||||
|
key={msg.id}
|
||||||
|
className="pb-4"
|
||||||
|
style={{ borderBottom: "1px solid var(--border)" }}
|
||||||
|
>
|
||||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||||
<Badge variant={msg.peer_id ? "blue" : "default"}>
|
<Badge variant={msg.peer_id ? "blue" : "default"}>
|
||||||
{msg.peer_id ?? "system"}
|
{msg.peer_id ?? "system"}
|
||||||
@@ -251,7 +301,10 @@ export function SessionDetail() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm whitespace-pre-wrap leading-relaxed" style={{ color: "var(--text-2)" }}>
|
<p
|
||||||
|
className="text-sm whitespace-pre-wrap leading-relaxed"
|
||||||
|
style={{ color: "var(--text-2)" }}
|
||||||
|
>
|
||||||
{msg.content}
|
{msg.content}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -260,45 +313,45 @@ export function SessionDetail() {
|
|||||||
)}
|
)}
|
||||||
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
|
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
|
||||||
</div>
|
</div>
|
||||||
)
|
))}
|
||||||
)}
|
|
||||||
|
|
||||||
{tab === "summaries" && (
|
{tab === "summaries" &&
|
||||||
summariesLoading ? <PageLoader /> : (
|
(summariesLoading ? <PageLoader /> : <SummariesDisplay summaries={summaries} />)}
|
||||||
<>
|
|
||||||
<h2 className="text-sm font-medium mb-3" style={{ color: "var(--text-1)" }}>Session Summaries</h2>
|
|
||||||
<JsonViewer data={summaries} maxHeight="500px" />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tab === "context" && (
|
{tab === "context" &&
|
||||||
contextLoading ? <PageLoader /> : (
|
(contextLoading ? (
|
||||||
|
<PageLoader />
|
||||||
|
) : (
|
||||||
<>
|
<>
|
||||||
<h2 className="text-sm font-medium mb-3" style={{ color: "var(--text-1)" }}>Session Context</h2>
|
<h2 className="text-sm font-medium mb-3" style={{ color: "var(--text-1)" }}>
|
||||||
|
Session Context
|
||||||
|
</h2>
|
||||||
{typeof context === "string" ? (
|
{typeof context === "string" ? (
|
||||||
<p className="text-sm whitespace-pre-wrap leading-relaxed" style={{ color: "var(--text-2)" }}>
|
<p
|
||||||
|
className="text-sm whitespace-pre-wrap leading-relaxed"
|
||||||
|
style={{ color: "var(--text-2)" }}
|
||||||
|
>
|
||||||
{context}
|
{context}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<JsonViewer data={context} maxHeight="500px" />
|
<JsonViewer data={context} maxHeight="500px" />
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
))}
|
||||||
)}
|
|
||||||
|
|
||||||
{tab === "peers" && (
|
{tab === "peers" &&
|
||||||
peersLoading ? <PageLoader /> : (
|
(peersLoading ? (
|
||||||
|
<PageLoader />
|
||||||
|
) : (
|
||||||
<SessionPeersTab
|
<SessionPeersTab
|
||||||
members={sessionPeers as Array<{ id?: string; peer_id?: string }> | undefined}
|
members={sessionPeerItems}
|
||||||
available={availablePeers}
|
available={availablePeers}
|
||||||
onRemove={(id) => removePeers.mutate([id])}
|
onRemove={(id) => removePeers.mutate([id])}
|
||||||
onAdd={(id) => addPeers.mutate({ [id]: {} })}
|
onAdd={(id) => addPeers.mutate({ [id]: {} })}
|
||||||
removing={removePeers.isPending}
|
removing={removePeers.isPending}
|
||||||
adding={addPeers.isPending}
|
adding={addPeers.isPending}
|
||||||
/>
|
/>
|
||||||
)
|
))}
|
||||||
)}
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -340,7 +393,9 @@ function SessionPeersTab({
|
|||||||
Session members ({list.length})
|
Session members ({list.length})
|
||||||
</h2>
|
</h2>
|
||||||
{list.length === 0 ? (
|
{list.length === 0 ? (
|
||||||
<p className="text-sm" style={{ color: "var(--text-3)" }}>No peers in this session.</p>
|
<p className="text-sm" style={{ color: "var(--text-3)" }}>
|
||||||
|
No peers in this session.
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{list.map((p) => {
|
{list.map((p) => {
|
||||||
@@ -351,7 +406,9 @@ function SessionPeersTab({
|
|||||||
className="flex items-center justify-between py-1.5 px-3 rounded-lg"
|
className="flex items-center justify-between py-1.5 px-3 rounded-lg"
|
||||||
style={{ background: "var(--surface)", border: "1px solid var(--border)" }}
|
style={{ background: "var(--surface)", border: "1px solid var(--border)" }}
|
||||||
>
|
>
|
||||||
<span className="text-xs font-mono" style={{ color: "var(--accent-text)" }}>{id}</span>
|
<span className="text-xs font-mono" style={{ color: "var(--accent-text)" }}>
|
||||||
|
{id}
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => onRemove(id)}
|
onClick={() => onRemove(id)}
|
||||||
disabled={removing}
|
disabled={removing}
|
||||||
@@ -394,3 +451,68 @@ function SessionPeersTab({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SummaryCard({ label, summary }: { label: string; summary: Summary }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="rounded-xl p-4"
|
||||||
|
style={{ background: "var(--surface)", border: "1px solid var(--border)" }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<AlignLeft className="w-3.5 h-3.5" style={{ color: "var(--text-4)" }} strokeWidth={1.5} />
|
||||||
|
<span className="text-xs font-medium" style={{ color: "var(--text-2)" }}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{summary.token_count != null && (
|
||||||
|
<span className="text-xs font-mono" style={{ color: "var(--text-4)" }}>
|
||||||
|
{summary.token_count} tok
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{summary.created_at && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3" style={{ color: "var(--text-4)" }} strokeWidth={1.5} />
|
||||||
|
<span className="text-xs font-mono" style={{ color: "var(--text-4)" }}>
|
||||||
|
{new Date(summary.created_at).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm leading-relaxed whitespace-pre-wrap" style={{ color: "var(--text-2)" }}>
|
||||||
|
{summary.content}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SummariesDisplay({ summaries }: { summaries: unknown }) {
|
||||||
|
const data = summaries as SessionSummaries | null | undefined;
|
||||||
|
|
||||||
|
if (!data || (!data.short_summary && !data.long_summary)) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h2 className="text-sm font-medium mb-3" style={{ color: "var(--text-1)" }}>
|
||||||
|
Session Summaries
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm" style={{ color: "var(--text-4)" }}>
|
||||||
|
No summaries available yet.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h2 className="text-sm font-medium mb-3" style={{ color: "var(--text-1)" }}>
|
||||||
|
Session Summaries
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{data.short_summary && <SummaryCard label="Short summary" summary={data.short_summary} />}
|
||||||
|
{data.long_summary && <SummaryCard label="Long summary" summary={data.long_summary} />}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { Link, useNavigate, useParams } from "@tanstack/react-router";
|
|
||||||
import { motion, type Variants } from "framer-motion";
|
|
||||||
import { MessageSquare, ChevronRight, Clock, CircleDot, ArrowLeft } from "lucide-react";
|
|
||||||
import { useSessions } from "@/api/queries";
|
import { useSessions } from "@/api/queries";
|
||||||
import { PageLoader } from "@/components/shared/LoadingSpinner";
|
|
||||||
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
|
||||||
import { Pagination } from "@/components/shared/Pagination";
|
|
||||||
import { EmptyState } from "@/components/shared/EmptyState";
|
|
||||||
import type { components } from "@/api/schema.d.ts";
|
import type { components } from "@/api/schema.d.ts";
|
||||||
|
import { EmptyState } from "@/components/shared/EmptyState";
|
||||||
|
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
||||||
|
import { PageLoader } from "@/components/shared/LoadingSpinner";
|
||||||
|
import { Pagination } from "@/components/shared/Pagination";
|
||||||
|
import { Link, useNavigate, useParams } from "@tanstack/react-router";
|
||||||
|
import { type Variants, motion } from "framer-motion";
|
||||||
|
import { ArrowLeft, ChevronRight, CircleDot, Clock, MessageSquare } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
type Session = components["schemas"]["Session"];
|
type Session = components["schemas"]["Session"];
|
||||||
|
|
||||||
@@ -32,11 +32,7 @@ export function SessionList() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8 max-w-3xl mx-auto">
|
<div className="p-8 max-w-3xl mx-auto">
|
||||||
<motion.div
|
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }} className="mb-8">
|
||||||
initial={{ opacity: 0, y: -8 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
className="mb-8"
|
|
||||||
>
|
|
||||||
<Link
|
<Link
|
||||||
to="/workspaces/$workspaceId"
|
to="/workspaces/$workspaceId"
|
||||||
params={{ workspaceId } as never}
|
params={{ workspaceId } as never}
|
||||||
@@ -105,7 +101,10 @@ export function SessionList() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="font-mono text-sm font-medium truncate" style={{ color: "#c7d2fe" }}>
|
<span
|
||||||
|
className="font-mono text-sm font-medium truncate"
|
||||||
|
style={{ color: "#c7d2fe" }}
|
||||||
|
>
|
||||||
{session.id}
|
{session.id}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-2 shrink-0 ml-2">
|
<div className="flex items-center gap-2 shrink-0 ml-2">
|
||||||
@@ -113,11 +112,17 @@ export function SessionList() {
|
|||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<motion.div
|
<motion.div
|
||||||
animate={{ opacity: [0.5, 1, 0.5] }}
|
animate={{ opacity: [0.5, 1, 0.5] }}
|
||||||
transition={{ duration: 2, repeat: Infinity }}
|
transition={{ duration: 2, repeat: Number.POSITIVE_INFINITY }}
|
||||||
>
|
>
|
||||||
<CircleDot className="w-3 h-3" style={{ color: "#34d399" }} strokeWidth={2} />
|
<CircleDot
|
||||||
|
className="w-3 h-3"
|
||||||
|
style={{ color: "#34d399" }}
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
<span className="text-xs" style={{ color: "#34d399" }}>Active</span>
|
<span className="text-xs" style={{ color: "#34d399" }}>
|
||||||
|
Active
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<ChevronRight
|
<ChevronRight
|
||||||
@@ -127,14 +132,32 @@ export function SessionList() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{session.created_at && (
|
<div className="flex items-center gap-2 mt-2">
|
||||||
<div className="flex items-center gap-1.5 mt-2">
|
{session.created_at && (
|
||||||
<Clock className="w-3 h-3" style={{ color: "rgba(148,163,184,0.3)" }} strokeWidth={1.5} />
|
<div className="flex items-center gap-1.5">
|
||||||
<p className="text-xs font-mono" style={{ color: "rgba(148,163,184,0.3)" }}>
|
<Clock
|
||||||
{new Date(session.created_at).toLocaleString()}
|
className="w-3 h-3"
|
||||||
</p>
|
style={{ color: "rgba(148,163,184,0.3)" }}
|
||||||
</div>
|
strokeWidth={1.5}
|
||||||
)}
|
/>
|
||||||
|
<p className="text-xs font-mono" style={{ color: "rgba(148,163,184,0.3)" }}>
|
||||||
|
{new Date(session.created_at).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(session.metadata as Record<string, string> | null)?.source && (
|
||||||
|
<span
|
||||||
|
className="text-xs font-mono px-1.5 py-0.5 rounded"
|
||||||
|
style={{
|
||||||
|
background: "rgba(99,102,241,0.08)",
|
||||||
|
border: "1px solid rgba(99,102,241,0.15)",
|
||||||
|
color: "rgba(148,163,184,0.6)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(session.metadata as Record<string, string>).source}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</motion.button>
|
</motion.button>
|
||||||
))}
|
))}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
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 { useDeleteWorkspace, useQueueStatus, useScheduleDream, useWorkspace } from "@/api/queries";
|
||||||
import { Link, useParams, useNavigate } from "@tanstack/react-router";
|
import { ConfirmDialog } from "@/components/shared/ConfirmDialog";
|
||||||
import { motion } from "framer-motion";
|
|
||||||
import { Boxes, Users, MessageSquare, Lightbulb, ArrowLeft, CircleDot, Trash2, Zap, Webhook } from "lucide-react";
|
|
||||||
import {
|
|
||||||
useWorkspace,
|
|
||||||
useQueueStatus,
|
|
||||||
useDeleteWorkspace,
|
|
||||||
useScheduleDream,
|
|
||||||
} from "@/api/queries";
|
|
||||||
import { PageLoader } from "@/components/shared/LoadingSpinner";
|
|
||||||
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
||||||
import { JsonViewer } from "@/components/shared/JsonViewer";
|
import { JsonViewer } from "@/components/shared/JsonViewer";
|
||||||
import { ConfirmDialog } from "@/components/shared/ConfirmDialog";
|
import { PageLoader } from "@/components/shared/LoadingSpinner";
|
||||||
import { ScheduleDreamModal } from "@/components/workspaces/ScheduleDreamModal";
|
import { ScheduleDreamModal } from "@/components/workspaces/ScheduleDreamModal";
|
||||||
|
import { Link, useNavigate, useParams } from "@tanstack/react-router";
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Boxes,
|
||||||
|
ChevronDown,
|
||||||
|
CircleDot,
|
||||||
|
Lightbulb,
|
||||||
|
MessageSquare,
|
||||||
|
Trash2,
|
||||||
|
Users,
|
||||||
|
Webhook,
|
||||||
|
Zap,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
const NAV_SECTIONS = [
|
const NAV_SECTIONS = [
|
||||||
{ label: "Peers", icon: Users, to: "peers" as const, description: "Browse peer identities and memory" },
|
{
|
||||||
{ label: "Sessions", icon: MessageSquare, to: "sessions" as const, description: "View conversation sessions" },
|
label: "Peers",
|
||||||
{ label: "Conclusions", icon: Lightbulb, to: "conclusions" as const, description: "Browse memory conclusions" },
|
icon: Users,
|
||||||
{ label: "Webhooks", icon: Webhook, to: "webhooks" as const, description: "Manage event webhooks" },
|
to: "peers" as const,
|
||||||
|
description: "Browse peer identities and memory",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Sessions",
|
||||||
|
icon: MessageSquare,
|
||||||
|
to: "sessions" as const,
|
||||||
|
description: "View conversation sessions",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Conclusions",
|
||||||
|
icon: Lightbulb,
|
||||||
|
to: "conclusions" as const,
|
||||||
|
description: "Browse memory conclusions",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Webhooks",
|
||||||
|
icon: Webhook,
|
||||||
|
to: "webhooks" as const,
|
||||||
|
description: "Manage event webhooks",
|
||||||
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export function WorkspaceDetail() {
|
export function WorkspaceDetail() {
|
||||||
@@ -33,6 +59,7 @@ export function WorkspaceDetail() {
|
|||||||
|
|
||||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
const [dreamOpen, setDreamOpen] = useState(false);
|
const [dreamOpen, setDreamOpen] = useState(false);
|
||||||
|
const [sessionsExpanded, setSessionsExpanded] = useState(false);
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
await deleteWorkspace.mutateAsync(workspaceId);
|
await deleteWorkspace.mutateAsync(workspaceId);
|
||||||
@@ -52,7 +79,11 @@ export function WorkspaceDetail() {
|
|||||||
</Link>
|
</Link>
|
||||||
<div className="flex items-start justify-between gap-4 mb-1">
|
<div className="flex items-start justify-between gap-4 mb-1">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<Boxes className="w-5 h-5 flex-shrink-0" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
|
<Boxes
|
||||||
|
className="w-5 h-5 flex-shrink-0"
|
||||||
|
style={{ color: "var(--accent)" }}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
<h1
|
<h1
|
||||||
className="text-xl font-semibold font-mono break-all tracking-tight"
|
className="text-xl font-semibold font-mono break-all tracking-tight"
|
||||||
style={{ color: "var(--text-1)" }}
|
style={{ color: "var(--text-1)" }}
|
||||||
@@ -87,7 +118,9 @@ export function WorkspaceDetail() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm" style={{ color: "var(--text-2)" }}>Workspace overview</p>
|
<p className="text-sm" style={{ color: "var(--text-2)" }}>
|
||||||
|
Workspace overview
|
||||||
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
@@ -112,7 +145,11 @@ export function WorkspaceDetail() {
|
|||||||
params={{ workspaceId } as never}
|
params={{ workspaceId } as never}
|
||||||
className="block rounded-xl p-5 group transition-all theme-card"
|
className="block rounded-xl p-5 group transition-all theme-card"
|
||||||
>
|
>
|
||||||
<Icon className="w-5 h-5 mb-3" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
|
<Icon
|
||||||
|
className="w-5 h-5 mb-3"
|
||||||
|
style={{ color: "var(--accent)" }}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
<h2 className="text-sm font-medium mb-0.5" style={{ color: "var(--text-1)" }}>
|
<h2 className="text-sm font-medium mb-0.5" style={{ color: "var(--text-1)" }}>
|
||||||
{s.label}
|
{s.label}
|
||||||
</h2>
|
</h2>
|
||||||
@@ -139,24 +176,47 @@ export function WorkspaceDetail() {
|
|||||||
</h2>
|
</h2>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{queue.pending_work_units > 0 ? (
|
{queue.pending_work_units > 0 ? (
|
||||||
<motion.div animate={{ opacity: [0.5, 1, 0.5] }} transition={{ duration: 1.5, repeat: Infinity }}>
|
<motion.div
|
||||||
<CircleDot className="w-3.5 h-3.5" style={{ color: "#f59e0b" }} strokeWidth={2} />
|
animate={{ opacity: [0.5, 1, 0.5] }}
|
||||||
|
transition={{ duration: 1.5, repeat: Number.POSITIVE_INFINITY }}
|
||||||
|
>
|
||||||
|
<CircleDot
|
||||||
|
className="w-3.5 h-3.5"
|
||||||
|
style={{ color: "#f59e0b" }}
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
) : (
|
) : (
|
||||||
<CircleDot className="w-3.5 h-3.5" style={{ color: "#34d399" }} strokeWidth={2} />
|
<CircleDot
|
||||||
|
className="w-3.5 h-3.5"
|
||||||
|
style={{ color: "#34d399" }}
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
<span
|
<span
|
||||||
className="text-xs font-medium"
|
className="text-xs font-medium"
|
||||||
style={{ color: queue.pending_work_units > 0 ? "#f59e0b" : "#34d399" }}
|
style={{ color: queue.pending_work_units > 0 ? "#f59e0b" : "#34d399" }}
|
||||||
>
|
>
|
||||||
{queue.pending_work_units === 0 ? "Idle" : `${queue.pending_work_units} pending`}
|
{queue.pending_work_units === 0
|
||||||
|
? "Idle"
|
||||||
|
: `${queue.pending_work_units} pending`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-4">
|
||||||
{(["total_work_units", "completed_work_units", "in_progress_work_units", "pending_work_units"] as const).map((key) => (
|
{(
|
||||||
|
[
|
||||||
|
"total_work_units",
|
||||||
|
"completed_work_units",
|
||||||
|
"in_progress_work_units",
|
||||||
|
"pending_work_units",
|
||||||
|
] as const
|
||||||
|
).map((key) => (
|
||||||
<div key={key}>
|
<div key={key}>
|
||||||
<div className="text-2xl font-semibold font-mono" style={{ color: "var(--text-1)" }}>
|
<div
|
||||||
|
className="text-2xl font-semibold font-mono"
|
||||||
|
style={{ color: "var(--text-1)" }}
|
||||||
|
>
|
||||||
{queue[key]}
|
{queue[key]}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs capitalize mt-0.5" style={{ color: "var(--text-3)" }}>
|
<div className="text-xs capitalize mt-0.5" style={{ color: "var(--text-3)" }}>
|
||||||
@@ -165,6 +225,108 @@ export function WorkspaceDetail() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Per-session breakdown */}
|
||||||
|
{queue.sessions && Object.keys(queue.sessions).length > 0 && (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => setSessionsExpanded((v) => !v)}
|
||||||
|
className="flex items-center gap-1.5 text-xs font-medium w-full text-left"
|
||||||
|
style={{ color: "var(--text-3)" }}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
animate={{ rotate: sessionsExpanded ? 0 : -90 }}
|
||||||
|
transition={{ duration: 0.15 }}
|
||||||
|
>
|
||||||
|
<ChevronDown className="w-3.5 h-3.5" strokeWidth={2} />
|
||||||
|
</motion.div>
|
||||||
|
{Object.keys(queue.sessions).length} session
|
||||||
|
{Object.keys(queue.sessions).length !== 1 ? "s" : ""}
|
||||||
|
</button>
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{sessionsExpanded && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: "auto", opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="overflow-hidden"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="mt-3 rounded-lg overflow-hidden"
|
||||||
|
style={{ border: "1px solid var(--border)" }}
|
||||||
|
>
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-3)",
|
||||||
|
borderBottom: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{["Session", "Total", "Done", "Active", "Pending"].map((h) => (
|
||||||
|
<th
|
||||||
|
key={h}
|
||||||
|
className={`py-2 px-3 font-medium text-left ${h !== "Session" ? "text-right" : ""}`}
|
||||||
|
style={{ color: "var(--text-3)" }}
|
||||||
|
>
|
||||||
|
{h}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{Object.entries(queue.sessions).map(([sid, s], i) => (
|
||||||
|
<tr
|
||||||
|
key={sid}
|
||||||
|
style={{
|
||||||
|
borderTop: i > 0 ? "1px solid var(--border)" : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<td className="py-1.5 px-3">
|
||||||
|
<Link
|
||||||
|
to={"/workspaces/$workspaceId/sessions/$sessionId" as never}
|
||||||
|
params={{ workspaceId, sessionId: sid } as never}
|
||||||
|
className="font-mono truncate block max-w-[180px] hover:underline"
|
||||||
|
style={{ color: "var(--accent-text)" }}
|
||||||
|
>
|
||||||
|
{sid}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className="py-1.5 px-3 text-right font-mono"
|
||||||
|
style={{ color: "var(--text-2)" }}
|
||||||
|
>
|
||||||
|
{s.total_work_units}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className="py-1.5 px-3 text-right font-mono"
|
||||||
|
style={{ color: "#34d399" }}
|
||||||
|
>
|
||||||
|
{s.completed_work_units}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className="py-1.5 px-3 text-right font-mono"
|
||||||
|
style={{ color: "#f59e0b" }}
|
||||||
|
>
|
||||||
|
{s.in_progress_work_units}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className="py-1.5 px-3 text-right font-mono"
|
||||||
|
style={{ color: "var(--text-3)" }}
|
||||||
|
>
|
||||||
|
{s.pending_work_units}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -5,50 +5,70 @@
|
|||||||
@import "@fontsource/dm-sans/500.css";
|
@import "@fontsource/dm-sans/500.css";
|
||||||
@import "@fontsource/dm-sans/600.css";
|
@import "@fontsource/dm-sans/600.css";
|
||||||
|
|
||||||
|
/* ─── Tailwind v4 theme bridge ─── */
|
||||||
|
@theme inline {
|
||||||
|
--color-background: var(--bg);
|
||||||
|
--color-foreground: var(--text-1);
|
||||||
|
--color-card: var(--bg-2);
|
||||||
|
--color-card-foreground: var(--text-1);
|
||||||
|
--color-muted: var(--bg-3);
|
||||||
|
--color-muted-foreground: var(--text-3);
|
||||||
|
--color-primary: var(--accent);
|
||||||
|
--color-primary-foreground: #ffffff;
|
||||||
|
--color-secondary: var(--bg-3);
|
||||||
|
--color-secondary-foreground: var(--text-2);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-ring: var(--accent);
|
||||||
|
--radius-sm: 6px;
|
||||||
|
--radius-md: 8px;
|
||||||
|
--radius-lg: 12px;
|
||||||
|
--radius-xl: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── Theme tokens ─── */
|
/* ─── Theme tokens ─── */
|
||||||
:root,
|
:root,
|
||||||
[data-theme="dark"] {
|
[data-theme="dark"] {
|
||||||
--bg: #0c0c10;
|
--bg: #0c0c10;
|
||||||
--bg-2: #111118;
|
--bg-2: #111118;
|
||||||
--bg-3: #16161f;
|
--bg-3: #1a1a24;
|
||||||
--surface: rgba(255, 255, 255, 0.02);
|
--surface: rgba(255, 255, 255, 0.03);
|
||||||
--border: rgba(255, 255, 255, 0.06);
|
--border: rgba(255, 255, 255, 0.08);
|
||||||
--border-2: rgba(255, 255, 255, 0.10);
|
--border-2: rgba(255, 255, 255, 0.13);
|
||||||
--text-1: #e4e4f0;
|
--text-1: #e8e8f4;
|
||||||
--text-2: rgba(148, 163, 184, 0.75);
|
--text-2: #94a3b8;
|
||||||
--text-3: rgba(148, 163, 184, 0.40);
|
--text-3: #64748b;
|
||||||
--text-4: rgba(148, 163, 184, 0.25);
|
--text-4: #475569;
|
||||||
--accent: #6366f1;
|
--accent: #6366f1;
|
||||||
--accent-dim: rgba(99, 102, 241, 0.12);
|
--accent-dim: rgba(99, 102, 241, 0.15);
|
||||||
--accent-border: rgba(99, 102, 241, 0.30);
|
--accent-border: rgba(99, 102, 241, 0.35);
|
||||||
--accent-text: #a5b4fc;
|
--accent-text: #a5b4fc;
|
||||||
--sidebar-bg: linear-gradient(180deg, #111118 0%, #0e0e15 100%);
|
--sidebar-bg: linear-gradient(180deg, #111118 0%, #0e0e15 100%);
|
||||||
--grid-line: rgba(99, 102, 241, 0.03);
|
--grid-line: rgba(99, 102, 241, 0.03);
|
||||||
--glow: rgba(79, 70, 229, 0.08);
|
--glow: rgba(79, 70, 229, 0.08);
|
||||||
--scrollbar: rgba(99, 102, 241, 0.2);
|
--scrollbar: rgba(99, 102, 241, 0.2);
|
||||||
--card-hover: rgba(99, 102, 241, 0.06);
|
--card-hover: rgba(99, 102, 241, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="light"] {
|
[data-theme="light"] {
|
||||||
--bg: #f8f8fc;
|
--bg: #f8f8fc;
|
||||||
--bg-2: #ffffff;
|
--bg-2: #ffffff;
|
||||||
--bg-3: #f0f0f8;
|
--bg-3: #f0f0f8;
|
||||||
--surface: rgba(0, 0, 0, 0.02);
|
--surface: rgba(0, 0, 0, 0.02);
|
||||||
--border: rgba(0, 0, 0, 0.07);
|
--border: rgba(0, 0, 0, 0.08);
|
||||||
--border-2: rgba(0, 0, 0, 0.12);
|
--border-2: rgba(0, 0, 0, 0.14);
|
||||||
--text-1: #1a1a2e;
|
--text-1: #1a1a2e;
|
||||||
--text-2: rgba(30, 30, 60, 0.65);
|
--text-2: #374151;
|
||||||
--text-3: rgba(30, 30, 60, 0.40);
|
--text-3: #6b7280;
|
||||||
--text-4: rgba(30, 30, 60, 0.25);
|
--text-4: #9ca3af;
|
||||||
--accent: #4f46e5;
|
--accent: #4f46e5;
|
||||||
--accent-dim: rgba(79, 70, 229, 0.08);
|
--accent-dim: rgba(79, 70, 229, 0.08);
|
||||||
--accent-border: rgba(79, 70, 229, 0.25);
|
--accent-border: rgba(79, 70, 229, 0.25);
|
||||||
--accent-text: #4f46e5;
|
--accent-text: #4f46e5;
|
||||||
--sidebar-bg: linear-gradient(180deg, #ffffff 0%, #f4f4fc 100%);
|
--sidebar-bg: linear-gradient(180deg, #ffffff 0%, #f4f4fc 100%);
|
||||||
--grid-line: rgba(79, 70, 229, 0.04);
|
--grid-line: rgba(79, 70, 229, 0.04);
|
||||||
--glow: rgba(79, 70, 229, 0.06);
|
--glow: rgba(79, 70, 229, 0.06);
|
||||||
--scrollbar: rgba(79, 70, 229, 0.2);
|
--scrollbar: rgba(79, 70, 229, 0.2);
|
||||||
--card-hover: rgba(79, 70, 229, 0.04);
|
--card-hover: rgba(79, 70, 229, 0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Base ─── */
|
/* ─── Base ─── */
|
||||||
|
|||||||
28
src/lib/constants.ts
Normal file
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