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
303 lines
10 KiB
TypeScript
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 →
|
|
</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>
|
|
);
|
|
}
|