Files
Codewalkers/apps/web/src/routes/agents.tsx
Lukas May 04c212da92 feat: Implement v2 design system with indigo brand, dark mode, and status tokens
Complete frontend design overhaul replacing achromatic shadcn/ui defaults with
an indigo-branded (#6366F1), status-aware, dark-mode-enabled token system.

Phase 1 — Theme Foundation:
- Replace all CSS tokens in index.css with v2 light/dark mode values
- Add 24 status tokens (6 statuses × 4 variants), 22 terminal tokens,
  7 diff tokens, 5 shadow tokens, 9 transition/animation tokens,
  10 z-index tokens, 10-step extended indigo scale
- Install Geist Sans/Mono variable fonts (public/fonts/)
- Extend tailwind.config.ts with all new token utilities
- Add dark mode flash-prevention script in index.html
- Add status-pulse and shimmer keyframe animations
- Add global focus-visible styles and reduced-motion media query

Phase 2 — ThemeProvider + Toggle:
- ThemeProvider context with system preference listener
- 3-state ThemeToggle (Sun/Monitor/Moon)
- Radix tooltip primitive for tooltips
- localStorage persistence with 'cw-theme' key

Phase 3 — Shared Components + Token Migration:
- StatusDot: mapEntityStatus() maps raw statuses to 6 semantic variants
- StatusBadge: uses status token bg/fg/border classes
- Badge: 6 new status variants + xs size
- EmptyState, ErrorState, SaveIndicator shared patterns
- CommandPalette: Cmd+K search with fuzzy matching, keyboard nav
- Skeleton with shimmer animation + SkeletonCard composite layouts
- KeyboardShortcutHint, NavBadge, enhanced Sonner config
- Migrate ALL hardcoded Tailwind colors to token classes across
  AgentOutputViewer, review/*, ProgressBar, AccountCard,
  InitiativeHeader, DependencyIndicator, PipelineTaskCard,
  PreviewPanel, ChangeSetBanner, MessageCard, PhaseDetailPanel

Phase 4 — App Layout Overhaul:
- Single 48px row header with CW logo, nav with NavBadge counts,
  Cmd+K search button, ThemeToggle, HealthDot
- Remove max-w-7xl from header/main; pages control own widths
- ConnectionBanner for offline/reconnecting states
- BrowserTitleUpdater with running/questions counts
- useGlobalKeyboard (1-4 nav, Cmd+K), useConnectionStatus hooks
- Per-page width wrappers (initiatives max-w-6xl, settings max-w-4xl)

Phase 5 — Page-Level Token Migration:
- ReviewSidebar: all hardcoded green/orange/red → status/diff tokens
- CommentThread: resolved state → status-success tokens
- Settings health: green → status-success-dot
2026-03-03 11:43:09 +01:00

303 lines
10 KiB
TypeScript

import { useState } from "react";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { AlertCircle, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/Skeleton";
import { toast } from "sonner";
import { trpc } from "@/lib/trpc";
import { AgentOutputViewer } from "@/components/AgentOutputViewer";
import { AgentActions } from "@/components/AgentActions";
import { formatRelativeTime } from "@/lib/utils";
import { cn } from "@/lib/utils";
import { modeLabel } from "@/lib/labels";
import { StatusDot } from "@/components/StatusDot";
import { useLiveUpdates } from "@/hooks";
export const Route = createFileRoute("/agents")({
component: AgentsPage,
});
type StatusFilter = "all" | "running" | "questions" | "exited" | "dismissed";
function AgentsPage() {
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
const [filter, setFilter] = useState<StatusFilter>("all");
const navigate = useNavigate();
// Live updates
const utils = trpc.useUtils();
useLiveUpdates([
{ prefix: 'agent:', invalidate: ['listAgents'] },
]);
// Data
const agentsQuery = trpc.listAgents.useQuery();
// Mutations
const stopMutation = trpc.stopAgent.useMutation({
onSuccess: (data) => {
toast.success(`Stopped ${data.name}`);
},
onError: (err) => toast.error(`Failed to stop: ${err.message}`),
});
const deleteMutation = trpc.deleteAgent.useMutation({
onSuccess: (data) => {
if (selectedAgentId) {
const deleted = agents.find(
(a) => a.name === data.name || a.id === selectedAgentId
);
if (deleted) setSelectedAgentId(null);
}
toast.success(`Deleted ${data.name}`);
},
onError: (err) => toast.error(`Failed to delete: ${err.message}`),
});
const dismissMutation = trpc.dismissAgent.useMutation({
onSuccess: (data) => {
toast.success(`Dismissed ${data.name}`);
},
onError: (err) => toast.error(`Failed to dismiss: ${err.message}`),
});
// Handlers
function handleRefresh() {
void utils.listAgents.invalidate();
}
function handleStop(id: string) {
stopMutation.mutate({ id });
}
function handleDelete(id: string) {
if (!window.confirm("Delete this agent? This cannot be undone.")) return;
setSelectedAgentId((prev) => (prev === id ? null : prev));
deleteMutation.mutate({ id });
}
function handleDismiss(id: string) {
dismissMutation.mutate({ id });
}
function handleGoToInbox() {
void navigate({ to: "/inbox" });
}
// Loading state
if (agentsQuery.isLoading) {
return (
<div className="flex h-full flex-col gap-4">
<div className="flex items-center justify-between shrink-0">
<div className="flex items-center gap-2">
<Skeleton className="h-6 w-20" />
<Skeleton className="h-5 w-8 rounded-full" />
</div>
<Skeleton className="h-8 w-20" />
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[320px_1fr] min-h-0 flex-1">
<div className="space-y-2 overflow-y-auto min-h-0">
{Array.from({ length: 5 }).map((_, i) => (
<Card key={i} className="p-3">
<div className="flex items-center gap-3">
<Skeleton className="h-2 w-2 rounded-full" />
<Skeleton className="h-4 w-24" />
</div>
</Card>
))}
</div>
<Skeleton className="h-full rounded-lg" />
</div>
</div>
);
}
// Error state
if (agentsQuery.isError) {
return (
<div className="flex flex-col items-center justify-center gap-4 py-12">
<AlertCircle className="h-8 w-8 text-destructive" />
<p className="text-sm text-destructive">
Failed to load agents:{" "}
{agentsQuery.error?.message ?? "Unknown error"}
</p>
<Button variant="outline" size="sm" onClick={handleRefresh}>
Retry
</Button>
</div>
);
}
const agents = agentsQuery.data ?? [];
const selectedAgent = selectedAgentId
? agents.find((a) => a.id === selectedAgentId)
: null;
// Filter counts
const counts = {
all: agents.filter((a) => !a.userDismissedAt).length,
running: agents.filter((a) => a.status === "running").length,
questions: agents.filter((a) => a.status === "waiting_for_input").length,
exited: agents.filter((a) =>
["stopped", "crashed", "idle"].includes(a.status)
).length,
dismissed: agents.filter((a) => a.userDismissedAt).length,
};
// Filter + sort
const filtered = agents
.filter((agent) => {
switch (filter) {
case "all":
return !agent.userDismissedAt;
case "running":
return agent.status === "running";
case "questions":
return agent.status === "waiting_for_input";
case "exited":
return ["stopped", "crashed", "idle"].includes(agent.status);
case "dismissed":
return !!agent.userDismissedAt;
}
})
.sort(
(a, b) =>
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
);
const filterOptions: { value: StatusFilter; label: string }[] = [
{ value: "all", label: "All" },
{ value: "running", label: "Running" },
{ value: "questions", label: "Questions" },
{ value: "exited", label: "Exited" },
{ value: "dismissed", label: "Dismissed" },
];
return (
<div className="flex h-full flex-col gap-4">
{/* Header + Filters */}
<div className="shrink-0 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h1 className="text-lg font-semibold">Agents</h1>
<Badge variant="secondary">{agents.length}</Badge>
</div>
<Button variant="outline" size="sm" onClick={handleRefresh}>
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
Refresh
</Button>
</div>
<div className="flex items-center gap-1">
{filterOptions.map((opt) => (
<Button
key={opt.value}
variant={filter === opt.value ? "default" : "outline"}
size="sm"
className="h-7 px-2 text-xs"
onClick={() => setFilter(opt.value)}
>
{opt.label}
<Badge
variant="secondary"
className="ml-1.5 h-4 min-w-4 px-1 text-[10px]"
>
{counts[opt.value]}
</Badge>
</Button>
))}
</div>
</div>
{/* Two-panel layout */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[320px_1fr] min-h-0 flex-1">
{/* Left: Agent List */}
<div className="overflow-y-auto min-h-0 space-y-2">
{filtered.length === 0 ? (
<div className="rounded-lg border border-dashed p-8 text-center">
<p className="text-sm text-muted-foreground">
No agents match this filter
</p>
</div>
) : (
filtered.map((agent) => (
<Card
key={agent.id}
className={cn(
"cursor-pointer p-3 transition-colors hover:bg-muted/50",
selectedAgentId === agent.id && "bg-muted"
)}
onClick={() => setSelectedAgentId(agent.id)}
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<StatusDot status={agent.status} size="sm" />
<span className="truncate text-sm font-medium">
{agent.name}
</span>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<Badge variant="outline" className="text-xs">
{agent.provider}
</Badge>
<Badge variant="secondary" className="text-xs">
{modeLabel(agent.mode)}
</Badge>
{/* Action dropdown */}
<div onClick={(e) => e.stopPropagation()}>
<AgentActions
agentId={agent.id}
status={agent.status}
isDismissed={!!agent.userDismissedAt}
onStop={handleStop}
onDelete={handleDelete}
onDismiss={handleDismiss}
onGoToInbox={handleGoToInbox}
/>
</div>
</div>
</div>
<div className="mt-1 flex items-center justify-between">
<span className="text-xs text-muted-foreground">
{formatRelativeTime(String(agent.updatedAt))}
</span>
{agent.status === "waiting_for_input" && (
<span
className="text-xs text-status-warning-fg hover:underline cursor-pointer"
onClick={(e) => {
e.stopPropagation();
handleGoToInbox();
}}
>
Answer questions &rarr;
</span>
)}
</div>
</Card>
))
)}
</div>
{/* Right: Output Viewer */}
<div className="min-h-0 overflow-hidden">
{selectedAgent ? (
<AgentOutputViewer
agentId={selectedAgent.id}
agentName={selectedAgent.name}
status={selectedAgent.status}
onStop={handleStop}
/>
) : (
<div className="flex h-full items-center justify-center rounded-lg border border-dashed">
<p className="text-sm text-muted-foreground">
Select an agent to view output
</p>
</div>
)}
</div>
</div>
</div>
);
}