feat(agent-team): redesign dashboard UI (Board + DAG tabs, Tailwind/shadcn)

Component fan-out + integration of the vibe-kanban-inspired redesign:
- Board.tsx + TaskCard.tsx: read-only Kanban (one column per backend stage,
  fallback grouping by phase), toolbar (search/status/tree filters + node-filter
  chip), gate columns flagged; filter logic lifted to taskFilters.ts (tested).
- MapNode.tsx + PipelineMap.tsx: DAG nodes restyled to Tailwind (Langflow-style
  state-driven borders, lucide icons, count pill, loop/gate affordances).
- TaskDrawer.tsx: shadcn rebuild + Magentic-style read-only gate panel
  ('Answer in Slack #agent-team', no submit controls).
- TopBar.tsx: restyled brand + status chips + health dot.
- Retire theme.css; port the .md markdown block into index.css on new tokens.
- Remove dead TaskList; add Board/taskFilters tests.

Typecheck clean, 36 tests pass, build clean. Read-only — no backend changes.
This commit is contained in:
Adam Moussa 2026-06-24 13:43:10 -04:00
parent 022befa796
commit 9b4bef1f4c
14 changed files with 1339 additions and 600 deletions

View file

@ -0,0 +1,275 @@
import { fireEvent, render, screen, within } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { ReactNode } from "react";
import { Board } from "./Board";
import { TooltipProvider } from "@/components/ui/tooltip";
import type { StateResponse, Topology } from "../api";
/** Wrap in TooltipProvider — the real app mounts this at the root. */
function renderWithProviders(ui: ReactNode) {
return render(<TooltipProvider>{ui}</TooltipProvider>);
}
const TOPO: Topology = {
trees: [
{ id: "core", label: "Core" },
{ id: "sdlc", label: "SDLC" },
],
nodes: [
{ id: "intake", label: "Intake", agent: "coordinator", tree: "core", kind: "phase", gated: false },
{ id: "clarify", label: "Clarify", agent: "coordinator", tree: "core", kind: "gate", gated: true },
{ id: "plan", label: "Plan", agent: "Claude", tree: "sdlc", kind: "phase", gated: false },
],
edges: [],
};
function makeState(): StateResponse {
return {
ok: true,
error: null,
generated_at: "now",
db_path: "x",
stages: [
{
key: "intake",
label: "Intake",
agent: "coordinator",
phases: ["intake"],
gated: false,
role_node: false,
state: "idle",
count: 1,
tasks: [
{
thread_id: "aaaa1111",
short_id: "aaaa1111",
task: "remediate CVE",
current_phase: "intake",
status: "active",
waiting: false,
waiting_since: null,
},
],
},
{
key: "clarify",
label: "Clarify",
agent: "coordinator",
phases: ["clarify"],
gated: true,
role_node: false,
state: "awaiting_human",
count: 1,
tasks: [
{
thread_id: "bbbb2222",
short_id: "bbbb2222",
task: "fix login bug",
current_phase: "clarify",
status: "waiting_human",
waiting: true,
waiting_since: "2024-01-01T00:00:00Z",
},
],
},
],
tasks: [
{
thread_id: "aaaa1111",
short_id: "aaaa1111",
task: "remediate CVE",
current_phase: "intake",
status: "active",
waiting: false,
waiting_since: null,
},
{
thread_id: "bbbb2222",
short_id: "bbbb2222",
task: "fix login bug",
current_phase: "clarify",
status: "waiting_human",
waiting: true,
waiting_since: "2024-01-01T00:00:00Z",
},
],
waiting: [],
question_counts: {},
summary: { active: 1, parked: 0, waiting: 1, open_questions: 0, total: 2 },
budget: { available: false },
nodes: {},
};
}
describe("Board", () => {
it("renders a column per stage with correct labels", () => {
renderWithProviders(
<Board
state={makeState()}
topology={TOPO}
selectedId={null}
nodeFilter={null}
onSelect={() => {}}
onClearNodeFilter={() => {}}
/>,
);
expect(screen.getByLabelText("Stage: Intake")).toBeInTheDocument();
expect(screen.getByLabelText("Stage: Clarify")).toBeInTheDocument();
});
it("shows task count badge in each column header", () => {
renderWithProviders(
<Board
state={makeState()}
topology={TOPO}
selectedId={null}
nodeFilter={null}
onSelect={() => {}}
onClearNodeFilter={() => {}}
/>,
);
const intakeCol = screen.getByLabelText("Stage: Intake");
// Both columns have 1 task each
expect(within(intakeCol).getByText("1")).toBeInTheDocument();
const clarifyCol = screen.getByLabelText("Stage: Clarify");
expect(within(clarifyCol).getByText("1")).toBeInTheDocument();
});
it("renders task descriptions inside columns", () => {
renderWithProviders(
<Board
state={makeState()}
topology={TOPO}
selectedId={null}
nodeFilter={null}
onSelect={() => {}}
onClearNodeFilter={() => {}}
/>,
);
expect(screen.getByText("remediate CVE")).toBeInTheDocument();
expect(screen.getByText("fix login bug")).toBeInTheDocument();
});
it("shows a waiting affordance (clock icon) for a waiting task", () => {
renderWithProviders(
<Board
state={makeState()}
topology={TOPO}
selectedId={null}
nodeFilter={null}
onSelect={() => {}}
onClearNodeFilter={() => {}}
/>,
);
// The Clock icon has aria-label="awaiting human"
expect(screen.getByLabelText("awaiting human")).toBeInTheDocument();
});
it("shows lock icon on a gated stage column", () => {
renderWithProviders(
<Board
state={makeState()}
topology={TOPO}
selectedId={null}
nodeFilter={null}
onSelect={() => {}}
onClearNodeFilter={() => {}}
/>,
);
expect(screen.getByLabelText("human gate")).toBeInTheDocument();
});
it("clicking a card fires onSelect with the thread_id", () => {
const onSelect = vi.fn();
renderWithProviders(
<Board
state={makeState()}
topology={TOPO}
selectedId={null}
nodeFilter={null}
onSelect={onSelect}
onClearNodeFilter={() => {}}
/>,
);
fireEvent.click(screen.getByText("remediate CVE"));
expect(onSelect).toHaveBeenCalledWith("aaaa1111");
});
it("selected card gets aria-selected=true", () => {
renderWithProviders(
<Board
state={makeState()}
topology={TOPO}
selectedId="aaaa1111"
nodeFilter={null}
onSelect={() => {}}
onClearNodeFilter={() => {}}
/>,
);
const cards = screen.getAllByRole("option", { selected: true });
expect(cards).toHaveLength(1);
});
it("shows node filter chip when nodeFilter is set", () => {
const onClearNodeFilter = vi.fn();
renderWithProviders(
<Board
state={makeState()}
topology={TOPO}
selectedId={null}
nodeFilter="intake"
onSelect={() => {}}
onClearNodeFilter={onClearNodeFilter}
/>,
);
const chip = screen.getByLabelText("Clear node filter: intake");
expect(chip).toBeInTheDocument();
fireEvent.click(chip);
expect(onClearNodeFilter).toHaveBeenCalled();
});
it("falls back to deriving stages from tasks when stages is undefined", () => {
const stateNoStages: StateResponse = {
...makeState(),
stages: undefined,
};
renderWithProviders(
<Board
state={stateNoStages}
topology={TOPO}
selectedId={null}
nodeFilter={null}
onSelect={() => {}}
onClearNodeFilter={() => {}}
/>,
);
// Should still show the tasks grouped by phase
expect(screen.getByText("remediate CVE")).toBeInTheDocument();
expect(screen.getByText("fix login bug")).toBeInTheDocument();
});
it("renders null state gracefully with loading message", () => {
renderWithProviders(
<Board
state={null}
topology={null}
selectedId={null}
nodeFilter={null}
onSelect={() => {}}
onClearNodeFilter={() => {}}
/>,
);
expect(screen.getByText("Loading…")).toBeInTheDocument();
});
});

View file

@ -1,6 +1,22 @@
import type { StateResponse, Topology } from "../api";
import { useMemo, useState } from "react";
import { Lock, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Button } from "@/components/ui/button";
import { TaskCard } from "./TaskCard";
import { buildTreeByNode, filterTasks, phaseToNode } from "../taskFilters";
import type { Stage, StateResponse, TaskView, Topology } from "../api";
// STUB — replaced in the component fan-out. Keeps the foundation compiling.
interface Props {
state: StateResponse | null;
topology: Topology | null;
@ -10,6 +26,245 @@ interface Props {
onClearNodeFilter: () => void;
}
export function Board(_props: Props) {
return <div className="p-4 text-sm text-muted-foreground">Board…</div>;
const STATUS_OPTIONS = [
{ value: "all", label: "All statuses" },
{ value: "active", label: "Active" },
{ value: "waiting_human", label: "Waiting human" },
{ value: "parked", label: "Parked" },
{ value: "done", label: "Done" },
{ value: "failed", label: "Failed" },
];
/** Derive ordered stages from topology node order when state.stages is absent. */
function deriveStages(tasks: TaskView[], topology: Topology | null): Stage[] {
// Build node order from topology; fall back to natural insertion order.
const nodeOrder: string[] = topology?.nodes.map((n) => n.id) ?? [];
const grouped = new Map<string, TaskView[]>();
for (const t of tasks) {
const node = phaseToNode(t.current_phase);
if (!grouped.has(node)) grouped.set(node, []);
grouped.get(node)!.push(t);
}
// Sort by topology order; unknown nodes go at end.
const nodeIds = [...grouped.keys()].sort((a, b) => {
const ai = nodeOrder.indexOf(a);
const bi = nodeOrder.indexOf(b);
if (ai === -1 && bi === -1) return 0;
if (ai === -1) return 1;
if (bi === -1) return -1;
return ai - bi;
});
return nodeIds.map((nodeId) => {
const topoNode = topology?.nodes.find((n) => n.id === nodeId);
const stageTasks = grouped.get(nodeId) ?? [];
return {
key: nodeId,
label: topoNode?.label ?? nodeId,
agent: topoNode?.agent ?? "",
phases: [nodeId],
gated: topoNode?.gated ?? false,
role_node: false,
state: "idle" as const,
count: stageTasks.length,
tasks: stageTasks,
};
});
}
/** Check if a stage is a human gate (stage.gated OR topology node kind === 'gate'). */
function isGate(stage: Stage, topology: Topology | null): boolean {
if (stage.gated) return true;
const topoNode = topology?.nodes.find((n) => n.id === stage.key);
return topoNode?.kind === "gate";
}
export function Board({
state,
topology,
selectedId,
nodeFilter,
onSelect,
onClearNodeFilter,
}: Props) {
const [query, setQuery] = useState("");
const [status, setStatus] = useState("all");
const [tree, setTree] = useState("all");
const treeByNode = useMemo(() => buildTreeByNode(topology), [topology]);
// Use backend-provided stages or fall back to derived grouping.
const allStages: Stage[] = useMemo(() => {
if (!state) return [];
if (state.stages && state.stages.length > 0) return state.stages;
return deriveStages(state.tasks ?? [], topology);
}, [state, topology]);
// Filter each stage's tasks by the toolbar + nodeFilter params.
const filteredStages = useMemo(() => {
return allStages.map((stage) => {
const filtered = filterTasks(stage.tasks, {
query,
status,
tree,
nodeFilter,
treeByNode,
});
return { ...stage, tasks: filtered, count: filtered.length };
});
}, [allStages, query, status, tree, nodeFilter, treeByNode]);
if (!state) {
return (
<div className="flex items-center justify-center h-32 text-sm text-muted-foreground">
Loading…
</div>
);
}
return (
<div className="flex flex-col gap-2 h-full min-h-0">
{/* Toolbar */}
<div
className="flex flex-wrap items-center gap-2 px-1"
role="toolbar"
aria-label="board filters"
>
<Input
type="search"
placeholder="Search tasks…"
aria-label="Search tasks"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="h-8 w-48 text-sm"
/>
<Select value={status} onValueChange={setStatus}>
<SelectTrigger className="h-8 w-40 text-sm" aria-label="Filter by status">
<SelectValue placeholder="All statuses" />
</SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value} className="text-sm">
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={tree} onValueChange={setTree}>
<SelectTrigger className="h-8 w-36 text-sm" aria-label="Filter by tree">
<SelectValue placeholder="All trees" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all" className="text-sm">All trees</SelectItem>
{(topology?.trees ?? []).map((t) => (
<SelectItem key={t.id} value={t.id} className="text-sm">
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Node filter chip */}
{nodeFilter && (
<Button
variant="secondary"
size="sm"
className="h-8 gap-1 text-xs"
onClick={onClearNodeFilter}
aria-label={`Clear node filter: ${nodeFilter}`}
>
node: {nodeFilter}
<X className="h-3 w-3" />
</Button>
)}
</div>
{/* Kanban columns — horizontal scroll */}
<div
className="flex gap-3 overflow-x-auto pb-2 flex-1 min-h-0 px-1"
role="listbox"
aria-label="pipeline stages"
aria-multiselectable="false"
>
{filteredStages.map((stage) => (
<KanbanColumn
key={stage.key}
stage={stage}
topology={topology}
selectedId={selectedId}
onSelect={onSelect}
/>
))}
{filteredStages.length === 0 && (
<div className="flex items-center justify-center flex-1 text-sm text-muted-foreground">
No stages available.
</div>
)}
</div>
</div>
);
}
interface ColumnProps {
stage: Stage;
topology: Topology | null;
selectedId: string | null;
onSelect: (threadId: string) => void;
}
function KanbanColumn({ stage, topology, selectedId, onSelect }: ColumnProps) {
const gate = isGate(stage, topology);
return (
<div
className={cn(
"flex flex-col gap-1.5 w-52 shrink-0 rounded-lg bg-muted/40 border border-border p-2",
)}
aria-label={`Stage: ${stage.label}`}
>
{/* Column header */}
<div className="flex items-center justify-between gap-1 mb-0.5">
<span className="text-xs font-semibold text-foreground truncate">{stage.label}</span>
<div className="flex items-center gap-1 shrink-0">
<Badge variant="outline" className="px-1.5 py-0 text-xs">
{stage.count}
</Badge>
{gate && (
<Tooltip>
<TooltipTrigger asChild>
<Lock
className="h-3 w-3 text-muted-foreground"
aria-label="human gate"
/>
</TooltipTrigger>
<TooltipContent>human gate</TooltipContent>
</Tooltip>
)}
</div>
</div>
{/* Task cards — scrollable vertically */}
<ScrollArea className="flex-1" style={{ maxHeight: "calc(100vh - 220px)" }}>
<div className="flex flex-col gap-1.5 pr-1">
{stage.tasks.length === 0 ? (
<p className="text-xs text-muted-foreground py-3 text-center">—</p>
) : (
stage.tasks.map((t) => (
<TaskCard
key={t.thread_id}
task={t}
selected={t.thread_id === selectedId}
onSelect={onSelect}
/>
))
)}
</div>
</ScrollArea>
</div>
);
}

View file

@ -1,5 +1,8 @@
import { memo } from "react";
import { Handle, Position, type NodeProps } from "reactflow";
import { Lock, RotateCcw } from "lucide-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import type { RFData } from "../layout";
// One pipeline stage on the map. Color encodes live state; a badge shows the
@ -7,49 +10,80 @@ import type { RFData } from "../layout";
// label keep the map reachable for screen readers / keyboard users.
function MapNodeImpl({ data }: NodeProps<RFData>) {
const { meta, state, count, dimmed, onPath, loopBackTo } = data;
const cls = [
"node",
`state-${state}`,
meta.gated ? "gated" : "",
dimmed ? "dimmed" : "",
onPath ? "on-path" : "",
]
.filter(Boolean)
.join(" ");
const canLoop = loopBackTo.length > 0;
return (
<div
className={cls}
className={cn(
// Base
"relative flex w-[184px] h-[72px] flex-col items-center justify-center gap-0.5 rounded-lg border bg-card px-3 py-2 text-center",
// State-driven border + tint
state === "active" && "border-status-active bg-status-active-bg",
state === "awaiting_human" && "border-status-waiting bg-status-waiting-bg",
state === "parked" && "border-status-parked bg-status-parked-bg",
state === "idle" && "border-border bg-card",
// Path highlight
onPath && "ring-2 ring-primary shadow-md shadow-primary/20",
// Dimmed when a task is selected and this node is not on its path
dimmed && "opacity-30",
// Gated / inert nodes
meta.gated && "opacity-60",
)}
role="group"
aria-label={`${meta.label} stage, ${state}, ${count} task${count === 1 ? "" : "s"}`}
tabIndex={0}
>
<Handle type="target" position={Position.Left} />
<div className="node-label">
{meta.label}
{meta.kind === "gate" && (
<span className="gate-pip" title="human gate" aria-hidden>
⛋
</span>
)}
{canLoop && (
<span
className="loop-chip"
title={`can send work back to ${loopBackTo.join(", ")}`}
aria-label={`can send work back to ${loopBackTo.join(", ")}`}
>
↺
</span>
)}
</div>
<div className="node-agent">{meta.agent}</div>
{/* Count badge — top-right corner */}
{count > 0 && (
<span className="node-count" aria-hidden>
<span
className="absolute -top-2 -right-2 flex h-5 min-w-5 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-semibold leading-none text-primary-foreground"
aria-hidden
>
{count}
</span>
)}
{meta.gated && <span className="node-gated-tag">inert</span>}
{/* Label row */}
<div className="flex items-center gap-1 font-semibold text-sm leading-tight text-foreground">
{meta.label}
{meta.kind === "gate" && (
<span className="inline-flex" title="human gate">
<Lock className="h-3 w-3 shrink-0 text-muted-foreground" aria-hidden />
</span>
)}
{canLoop && (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex items-center">
<RotateCcw
className="h-3 w-3 shrink-0 text-muted-foreground"
aria-label={`can send work back to ${loopBackTo.join(", ")}`}
/>
</span>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
can send work back to {loopBackTo.join(", ")}
</TooltipContent>
</Tooltip>
)}
</div>
{/* Agent name */}
<div className="text-xs text-muted-foreground leading-tight truncate max-w-full px-1">
{meta.agent}
</div>
{/* Inert tag — bottom-right corner */}
{meta.gated && (
<span className="absolute bottom-1 right-1.5 text-[9px] uppercase tracking-wide font-semibold text-muted-foreground">
inert
</span>
)}
<Handle type="source" position={Position.Right} />
</div>
);

View file

@ -39,7 +39,7 @@ export function PipelineMap({
const handleNodeClick: NodeMouseHandler = (_evt, node) => onNodeClick(node.id);
return (
<div className="map-pane" aria-label="pipeline map">
<div className="h-full w-full" aria-label="pipeline map">
<ReactFlowProvider>
<ReactFlow
nodes={nodes}

View file

@ -0,0 +1,82 @@
import { Clock } from "lucide-react";
import { cn } from "@/lib/utils";
import { Badge, statusVariant } from "@/components/ui/badge";
import type { TaskView } from "../api";
// Map a task status to a left-border accent class.
function statusBorderClass(status: string): string {
switch (status) {
case "active":
return "border-l-status-active";
case "waiting_human":
return "border-l-status-waiting";
case "parked":
return "border-l-status-parked";
case "done":
return "border-l-status-done";
case "failed":
return "border-l-status-failed";
default:
return "border-l-border";
}
}
interface TaskCardProps {
task: TaskView;
selected: boolean;
onSelect: (threadId: string) => void;
}
export function TaskCard({ task, selected, onSelect }: TaskCardProps) {
function handleClick() {
onSelect(task.thread_id);
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelect(task.thread_id);
}
}
return (
<div
role="option"
aria-selected={selected}
tabIndex={0}
onClick={handleClick}
onKeyDown={handleKeyDown}
className={cn(
// Base card
"relative cursor-pointer rounded-md bg-card border border-border border-l-4 p-2 text-sm",
// Left accent tinted by status
statusBorderClass(task.status),
// Selected ring
selected && "ring-2 ring-ring",
// Hover elevation
"hover:border-primary/50 hover:shadow-sm transition-shadow",
)}
>
{/* Top row: status badge + waiting clock */}
<div className="flex items-center gap-1.5 mb-1">
<Badge variant={statusVariant(task.status)}>
{task.status.replace("_", " ")}
</Badge>
{task.waiting && (
<Clock
className="h-3 w-3 text-status-waiting shrink-0"
aria-label="awaiting human"
/>
)}
</div>
{/* Task description — 2-line clamp */}
<p className="text-xs text-foreground leading-snug line-clamp-2 mb-1">
{task.task}
</p>
{/* Short ID in mono muted text */}
<p className="text-xs font-mono text-muted-foreground">{task.short_id}</p>
</div>
);
}

View file

@ -1,5 +1,17 @@
import { MessageSquare, X, ChevronDown } from "lucide-react";
import type { TaskDetail } from "../api";
import { Markdown } from "./Markdown";
import { Badge, statusVariant } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Collapsible,
CollapsibleTrigger,
CollapsibleContent,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
interface Props {
detail: TaskDetail | null;
@ -24,91 +36,246 @@ function fmtCost(usd: number): string {
export function TaskDrawer({ detail, loading, error, onClose }: Props) {
if (!detail && !loading && !error) return null;
const isWaiting = detail?.ok && detail.status === "waiting_human";
const isClarifyGate =
isWaiting && detail?.current_phase === "clarify";
return (
<section className="drawer" aria-label="task detail" role="complementary">
<div className="drawer-head">
<h2>Task history</h2>
<button className="drawer-close" aria-label="Close detail" onClick={onClose}>
✕
</button>
<section
className="flex flex-col w-[380px] h-full bg-card border-l border-border"
aria-label="task detail"
role="complementary"
>
{/* Sticky header */}
<div className="sticky top-0 z-10 flex items-center justify-between px-3 py-2 bg-card border-b border-border">
<h2 className="text-sm font-semibold text-foreground">Task history</h2>
<Button
variant="ghost"
size="icon"
aria-label="Close detail"
onClick={onClose}
className="h-7 w-7"
>
<X className="h-4 w-4" />
</Button>
</div>
{loading && <div className="drawer-loading">Loading…</div>}
{error && <div className="drawer-error">{error}</div>}
{loading && (
<div className="flex-1 flex items-center justify-center text-sm text-muted-foreground">
Loading…
</div>
)}
{error && (
<div className="m-3 rounded-md bg-status-failed-bg text-status-failed text-sm px-3 py-2">
{error}
</div>
)}
{detail && detail.ok && (
<>
<div className="drawer-summary">
<div className="drawer-task">{detail.task}</div>
<div className="drawer-meta">
<span className={`badge ${detail.status}`}>{detail.status}</span>
<span className="mono">{detail.short_id}</span>
<span className="spend">spend {fmtCost(detail.total_usd)}</span>
</div>
{detail.partial && (
<div className="partial-banner">
Partial history — recorded since the v3 ledger; earlier steps are
reconstructed.
</div>
)}
</div>
<ScrollArea className="flex-1">
<div className="p-3 space-y-3">
{/* Summary card */}
<Card className="bg-background">
<CardContent className="pt-3 pb-3 space-y-1.5">
<p className="text-sm font-medium text-foreground leading-snug">
{detail.task}
</p>
<div className="flex items-center gap-2 flex-wrap">
<Badge variant={statusVariant(detail.status)}>
{detail.status}
</Badge>
<code className="text-xs font-mono text-muted-foreground">
{detail.short_id}
</code>
<span className="text-xs text-muted-foreground">
spend {fmtCost(detail.total_usd)}
</span>
</div>
{detail.partial && (
<p className="text-xs text-muted-foreground bg-muted rounded px-2 py-1">
Partial history — recorded since the v3 ledger; earlier
steps are reconstructed.
</p>
)}
</CardContent>
</Card>
<ol className="timeline">
{detail.timeline.map((step, i) => {
const open = step.exited_at == null;
return (
<li className={`tl-step ${open ? "open" : ""}`} key={i}>
<span className="tl-dot" aria-hidden />
<div className="tl-body">
<div className="tl-node">{step.to_phase}</div>
<div className="tl-times">
<span>{step.entered_at ?? "—"}</span>
<span className="tl-dur">
{open ? "in progress" : fmtDuration(step.duration_s)}
</span>
</div>
<div className="tl-foot">
{step.status && <span className={`badge ${step.status}`}>{step.status}</span>}
<span className="tl-cost">{fmtCost(step.cost_usd)}</span>
{step.note && <span className="tl-note">{step.note}</span>}
{/* Gate panel — waiting_human informational only */}
{isWaiting && (
<Card className="bg-status-waiting-bg border border-status-waiting/30">
<CardContent className="pt-3 pb-3">
<div className="flex items-start gap-2">
<MessageSquare className="h-4 w-4 mt-0.5 shrink-0 text-status-waiting" />
<div className="space-y-1 min-w-0">
<p className="text-sm font-semibold text-status-waiting">
Input requested
</p>
<p className="text-xs text-status-waiting/80 leading-snug">
{isClarifyGate
? "The pipeline needs clarification before planning."
: "The pipeline is awaiting a human decision to proceed."}
</p>
<p className="text-xs font-medium text-status-waiting mt-1">
Answer in Slack{" "}
<span className="font-mono">#agent-team</span>
</p>
</div>
</div>
</li>
);
})}
{detail.timeline.length === 0 && (
<li className="empty">No transitions recorded.</li>
</CardContent>
</Card>
)}
</ol>
{detail.qa_history.length > 0 && (
<details className="drawer-section" open>
<summary>Q&amp;A ({detail.qa_history.length})</summary>
<QASection entries={detail.qa_history} />
</details>
)}
{detail.review_verdicts.length > 0 && (
<details className="drawer-section">
<summary>Review verdicts ({detail.review_verdicts.length})</summary>
<VerdictsSection entries={detail.review_verdicts} />
</details>
)}
{detail.plan && (
<details className="drawer-section">
<summary>Plan</summary>
<PlanSection plan={detail.plan} />
</details>
)}
</>
{/* Timeline */}
<div>
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2">
Timeline
</p>
<ol className="relative space-y-0">
{detail.timeline.map((step, i) => {
const open = step.exited_at == null;
const isLast = i === detail.timeline.length - 1;
return (
<li key={i} className="flex gap-2">
{/* Dot + connector */}
<div className="flex flex-col items-center">
<span
className={cn(
"mt-1 h-2 w-2 rounded-full shrink-0 border-2",
open
? "bg-status-active border-status-active"
: "bg-muted border-border",
)}
aria-hidden
/>
{!isLast && (
<span className="w-px flex-1 bg-border mt-0.5 mb-0.5 min-h-[12px]" />
)}
</div>
{/* Step body */}
<div className="pb-3 flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium text-foreground">
{step.to_phase}
</span>
<span
className={cn(
"text-xs shrink-0",
open
? "text-status-active font-medium"
: "text-muted-foreground",
)}
>
{open ? "in progress" : fmtDuration(step.duration_s)}
</span>
</div>
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
{step.entered_at && (
<span className="text-xs text-muted-foreground">
{step.entered_at}
</span>
)}
{step.status && (
<Badge variant={statusVariant(step.status)} className="text-[10px] py-0 px-1.5">
{step.status}
</Badge>
)}
{step.cost_usd > 0 && (
<span className="text-xs text-muted-foreground">
{fmtCost(step.cost_usd)}
</span>
)}
{step.note && (
<span className="text-xs text-muted-foreground italic">
{step.note}
</span>
)}
</div>
</div>
</li>
);
})}
{detail.timeline.length === 0 && (
<li className="text-sm text-muted-foreground py-2">
No transitions recorded.
</li>
)}
</ol>
</div>
{/* Q&A section */}
{detail.qa_history.length > 0 && (
<>
<Separator />
<CollapsibleSection
title={`Q&A (${detail.qa_history.length})`}
defaultOpen
>
<QASection entries={detail.qa_history} />
</CollapsibleSection>
</>
)}
{/* Review verdicts */}
{detail.review_verdicts.length > 0 && (
<>
<Separator />
<CollapsibleSection
title={`Review verdicts (${detail.review_verdicts.length})`}
defaultOpen={true}
>
<VerdictsSection entries={detail.review_verdicts} />
</CollapsibleSection>
</>
)}
{/* Plan */}
{detail.plan && (
<>
<Separator />
<CollapsibleSection title="Plan" defaultOpen={true}>
<PlanSection plan={detail.plan} />
</CollapsibleSection>
</>
)}
</div>
</ScrollArea>
)}
{detail && !detail.ok && (
<div className="drawer-error">{detail.error ?? "Could not load task."}</div>
<div className="m-3 rounded-md bg-status-failed-bg text-status-failed text-sm px-3 py-2">
{detail.error ?? "Could not load task."}
</div>
)}
</section>
);
}
// Collapsible section wrapper used for Q&A / Verdicts / Plan
function CollapsibleSection({
title,
defaultOpen,
children,
}: {
title: string;
defaultOpen: boolean;
children: React.ReactNode;
}) {
return (
<Collapsible defaultOpen={defaultOpen}>
<CollapsibleTrigger className="flex items-center justify-between w-full group">
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
{title}
</span>
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
</CollapsibleTrigger>
<CollapsibleContent>
<div className="mt-2">{children}</div>
</CollapsibleContent>
</Collapsible>
);
}
function asText(v: unknown): string {
return typeof v === "string" ? v : v == null ? "" : JSON.stringify(v, null, 2);
}
@ -118,17 +285,23 @@ function asText(v: unknown): string {
// as markdown so numbered sub-answers / bold render readably.
function QASection({ entries }: { entries: unknown[] }) {
return (
<div className="qa">
<div className="space-y-2">
{entries.map((entry, i) => {
const obj =
entry && typeof entry === "object" ? (entry as Record<string, unknown>) : null;
const turn = obj && "turn" in obj ? Number(obj.turn) : i;
const answer = obj && "answer" in obj ? asText(obj.answer) : asText(entry);
return (
<div className="qa-card" key={i}>
<div className="qa-turn">Turn {turn + 1}</div>
<Markdown>{answer}</Markdown>
</div>
<Card key={i} className="bg-background">
<CardHeader className="pb-1 pt-2 px-3">
<CardTitle className="text-xs font-semibold text-muted-foreground">
Turn {turn + 1}
</CardTitle>
</CardHeader>
<CardContent className="pb-2 px-3 text-sm prose-sm">
<Markdown>{answer}</Markdown>
</CardContent>
</Card>
);
})}
</div>
@ -139,7 +312,7 @@ function QASection({ entries }: { entries: unknown[] }) {
// verdict badge + round + outcome header and the findings rendered as markdown.
function VerdictsSection({ entries }: { entries: unknown[] }) {
return (
<div className="verdicts">
<div className="space-y-2">
{entries.map((entry, i) => {
const v =
entry && typeof entry === "object" ? (entry as Record<string, unknown>) : {};
@ -147,16 +320,29 @@ function VerdictsSection({ entries }: { entries: unknown[] }) {
const round = "round_index" in v ? Number(v.round_index) : i + 1;
const outcome = asText(v.outcome);
const findings = asText(v.findings);
const cls = verdict.replace(/[^a-z_]/gi, "_").toLowerCase();
return (
<div className="verdict-card" key={i}>
<div className="verdict-head">
<span className="verdict-round">Round {round}</span>
<span className={`badge ${cls}`}>{verdict.replace(/_/g, " ")}</span>
{outcome && <span className="verdict-outcome">{outcome.replace(/_/g, " ")}</span>}
</div>
{findings && <Markdown>{findings}</Markdown>}
</div>
<Card key={i} className="bg-background">
<CardHeader className="pb-1 pt-2 px-3">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-muted-foreground">
Round {round}
</span>
<Badge variant={statusVariant(verdict)}>
{verdict.replace(/_/g, " ")}
</Badge>
{outcome && (
<span className="text-xs text-muted-foreground">
{outcome.replace(/_/g, " ")}
</span>
)}
</div>
</CardHeader>
{findings && (
<CardContent className="pb-2 px-3 text-sm prose-sm">
<Markdown>{findings}</Markdown>
</CardContent>
)}
</Card>
);
})}
</div>
@ -176,27 +362,40 @@ function PlanSection({ plan }: { plan: Record<string, unknown> }) {
if (!summary && !phases) {
return (
<pre className="json">{JSON.stringify(plan, null, 2)}</pre>
<pre className="text-xs bg-muted rounded p-2 overflow-auto font-mono">
{JSON.stringify(plan, null, 2)}
</pre>
);
}
return (
<div className="plan">
{summary && <Markdown>{summary}</Markdown>}
<div className="space-y-2">
{summary && (
<div className="text-sm prose-sm">
<Markdown>{summary}</Markdown>
</div>
)}
{phases && (
<ol className="plan-phases">
<ol className="space-y-2">
{phases.map((p, i) => {
const ph =
p && typeof p === "object" ? (p as Record<string, unknown>) : {};
const name = asText(ph.name) || `Phase ${i + 1}`;
const steps = Array.isArray(ph.steps) ? ph.steps : [];
return (
<li className="plan-phase" key={i}>
<div className="plan-phase-name">{name}</div>
<li key={i}>
<p className="text-xs font-semibold text-foreground mb-1">
{name}
</p>
{steps.length > 0 && (
<ul className="plan-steps">
<ul className="space-y-0.5 pl-3">
{steps.map((s, j) => (
<li key={j}>{asText(s)}</li>
<li
key={j}
className="text-xs text-muted-foreground list-disc"
>
{asText(s)}
</li>
))}
</ul>
)}
@ -206,11 +405,11 @@ function PlanSection({ plan }: { plan: Record<string, unknown> }) {
</ol>
)}
{extra.length > 0 && (
<dl className="plan-extra">
<dl className="space-y-1">
{extra.map(([k, val]) => (
<div className="plan-extra-row" key={k}>
<dt>{k}</dt>
<dd>{asText(val)}</dd>
<div key={k} className="flex gap-2 text-xs">
<dt className="text-muted-foreground font-medium shrink-0">{k}</dt>
<dd className="text-foreground truncate">{asText(val)}</dd>
</div>
))}
</dl>

View file

@ -1,74 +0,0 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { TaskList } from "./TaskList";
import type { StateResponse, Topology } from "../api";
const TOPO: Topology = {
trees: [
{ id: "core", label: "Core" },
{ id: "sdlc", label: "SDLC" },
],
nodes: [
{ id: "intake", label: "Intake", agent: "coordinator", tree: "core", kind: "phase", gated: false },
{ id: "plan", label: "Plan", agent: "Claude", tree: "sdlc", kind: "phase", gated: false },
],
edges: [],
};
function makeState(): StateResponse {
return {
ok: true,
error: null,
generated_at: "now",
db_path: "x",
tasks: [
{ thread_id: "aaaa1111", short_id: "aaaa1111", task: "remediate CVE", current_phase: "plan", status: "active", waiting: false, waiting_since: null },
{ thread_id: "bbbb2222", short_id: "bbbb2222", task: "fix login bug", current_phase: "intake", status: "parked", waiting: false, waiting_since: null },
],
waiting: [],
question_counts: {},
summary: { active: 1, parked: 1, waiting: 0, open_questions: 0, total: 2 },
budget: { available: false },
nodes: {},
};
}
describe("TaskList", () => {
it("renders all tasks", () => {
render(
<TaskList state={makeState()} topology={TOPO} selectedId={null} nodeFilter={null}
onSelect={() => {}} onClearNodeFilter={() => {}} />,
);
expect(screen.getByText("remediate CVE")).toBeInTheDocument();
expect(screen.getByText("fix login bug")).toBeInTheDocument();
});
it("filters by status", () => {
render(
<TaskList state={makeState()} topology={TOPO} selectedId={null} nodeFilter={null}
onSelect={() => {}} onClearNodeFilter={() => {}} />,
);
fireEvent.change(screen.getByLabelText("Filter by status"), { target: { value: "parked" } });
expect(screen.queryByText("remediate CVE")).not.toBeInTheDocument();
expect(screen.getByText("fix login bug")).toBeInTheDocument();
});
it("honors a node filter (map click)", () => {
render(
<TaskList state={makeState()} topology={TOPO} selectedId={null} nodeFilter="plan"
onSelect={() => {}} onClearNodeFilter={() => {}} />,
);
expect(screen.getByText("remediate CVE")).toBeInTheDocument();
expect(screen.queryByText("fix login bug")).not.toBeInTheDocument();
});
it("fires onSelect when a row is clicked", () => {
const onSelect = vi.fn();
render(
<TaskList state={makeState()} topology={TOPO} selectedId={null} nodeFilter={null}
onSelect={onSelect} onClearNodeFilter={() => {}} />,
);
fireEvent.click(screen.getByText("remediate CVE"));
expect(onSelect).toHaveBeenCalledWith("aaaa1111");
});
});

View file

@ -1,145 +0,0 @@
import { useMemo, useState } from "react";
import type { StateResponse, TaskView, Topology } from "../api";
// Mirror of the backend phase->node mapping (topology.PHASE_TO_NODE) so the list
// can resolve a task's tree and honor a map-node filter. Identity except the P3
// build/verify vertices.
const PHASE_TO_NODE: Record<string, string> = {
build: "build_node",
verify: "verify_node",
};
const phaseToNode = (phase: string): string => PHASE_TO_NODE[phase] ?? phase;
interface Props {
state: StateResponse | null;
topology: Topology | null;
selectedId: string | null;
nodeFilter: string | null;
onSelect: (threadId: string) => void;
onClearNodeFilter: () => void;
}
const STATUS_OPTIONS = ["all", "active", "waiting_human", "parked", "done", "failed"];
export function TaskList({
state,
topology,
selectedId,
nodeFilter,
onSelect,
onClearNodeFilter,
}: Props) {
const [query, setQuery] = useState("");
const [status, setStatus] = useState("all");
const [tree, setTree] = useState("all");
const treeByNode = useMemo(() => {
const m: Record<string, string> = {};
for (const n of topology?.nodes ?? []) m[n.id] = n.tree;
return m;
}, [topology]);
const tasks = state?.tasks ?? [];
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return tasks.filter((t) => {
if (nodeFilter && phaseToNode(t.current_phase) !== nodeFilter) return false;
if (status !== "all" && t.status !== status) return false;
if (tree !== "all" && treeByNode[phaseToNode(t.current_phase)] !== tree)
return false;
if (q && !(`${t.task} ${t.short_id}`.toLowerCase().includes(q))) return false;
return true;
});
}, [tasks, query, status, tree, nodeFilter, treeByNode]);
return (
<aside className="tasklist" aria-label="task list">
<div className="tasklist-controls">
<input
className="search"
type="search"
placeholder="Search tasks…"
aria-label="Search tasks"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<div className="filters">
<select
aria-label="Filter by status"
value={status}
onChange={(e) => setStatus(e.target.value)}
>
{STATUS_OPTIONS.map((s) => (
<option key={s} value={s}>
{s === "all" ? "All statuses" : s.replace("_", " ")}
</option>
))}
</select>
<select
aria-label="Filter by tree"
value={tree}
onChange={(e) => setTree(e.target.value)}
>
<option value="all">All trees</option>
{(topology?.trees ?? []).map((t) => (
<option key={t.id} value={t.id}>
{t.label}
</option>
))}
</select>
</div>
{nodeFilter && (
<button className="filter-chip" onClick={onClearNodeFilter}>
node: {nodeFilter} ✕
</button>
)}
</div>
<ul className="tasks" role="listbox" aria-label="tasks">
{filtered.length === 0 && <li className="empty">No tasks match.</li>}
{filtered.map((t) => (
<TaskRow
key={t.thread_id}
task={t}
selected={t.thread_id === selectedId}
onSelect={onSelect}
/>
))}
</ul>
</aside>
);
}
function TaskRow({
task,
selected,
onSelect,
}: {
task: TaskView;
selected: boolean;
onSelect: (id: string) => void;
}) {
return (
<li
className={`task-row ${selected ? "selected" : ""}`}
role="option"
aria-selected={selected}
tabIndex={0}
onClick={() => onSelect(task.thread_id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelect(task.thread_id);
}
}}
>
<div className="task-row-top">
<span className={`badge ${task.status}`}>{task.status.replace("_", " ")}</span>
<span className="task-phase">{task.current_phase}</span>
{task.waiting && <span className="task-wait" title="awaiting human">⏳</span>}
</div>
<div className="task-desc">{task.task}</div>
<div className="task-id mono">{task.short_id}</div>
</li>
);
}

View file

@ -1,3 +1,4 @@
import { cn } from "@/lib/utils";
import type { StateResponse } from "../api";
interface Props {
@ -6,36 +7,118 @@ interface Props {
generatedAt: string | null;
}
const CHIPS: Array<{ key: keyof StateResponse["summary"]; label: string; cls: string }> =
[
{ key: "active", label: "Active", cls: "active" },
{ key: "waiting", label: "Awaiting human", cls: "waiting" },
{ key: "parked", label: "Parked", cls: "parked" },
{ key: "open_questions", label: "Open questions", cls: "open" },
{ key: "total", label: "Total", cls: "total" },
];
interface ChipDef {
key: keyof StateResponse["summary"];
label: string;
numCls: string;
bgCls: string;
}
// Top status bar: summary chips, a connection/health dot, and the snapshot clock.
const CHIPS: ChipDef[] = [
{
key: "active",
label: "ACTIVE",
numCls: "text-status-active",
bgCls: "bg-status-active-bg",
},
{
key: "waiting",
label: "AWAITING HUMAN",
numCls: "text-status-waiting",
bgCls: "bg-status-waiting-bg",
},
{
key: "parked",
label: "PARKED",
numCls: "text-status-parked",
bgCls: "bg-status-parked-bg",
},
{
key: "open_questions",
label: "OPEN QUESTIONS",
numCls: "text-status-waiting",
bgCls: "bg-status-waiting-bg",
},
{
key: "total",
label: "TOTAL",
numCls: "text-muted-foreground",
bgCls: "bg-muted",
},
];
// Top status bar: brand, summary chips, connection health, and snapshot clock.
export function TopBar({ state, connected, generatedAt }: Props) {
const summary = state?.summary;
const title = connected
? `Live — snapshot at ${generatedAt ?? "—"}`
: `Disconnected — last snapshot at ${generatedAt ?? "—"}`;
return (
<header className="topbar" role="banner">
<div className="brand">
<span className="brand-dot" aria-hidden />
<span className="brand-name">agent-team</span>
<span className="brand-sub">pipeline</span>
<header
className="flex items-center gap-3 py-2 px-3 bg-card border-b border-border"
role="banner"
>
{/* Brand */}
<div className="flex items-center gap-2 shrink-0">
<span
className="w-3 h-3 rounded-full bg-gradient-to-br from-primary to-primary/50 shadow-sm"
aria-hidden
/>
<span className="text-sm font-bold text-foreground tracking-tight leading-none">
agent-team
</span>
<span className="text-xs text-muted-foreground leading-none">
pipeline
</span>
</div>
<div className="chips" role="list">
{/* Divider */}
<div className="w-px h-5 bg-border shrink-0" aria-hidden />
{/* Summary chips */}
<div
className="flex items-center gap-2 flex-1 min-w-0 overflow-x-auto"
role="list"
aria-label="Pipeline summary"
>
{CHIPS.map((c) => (
<div className={`chip chip-${c.cls}`} role="listitem" key={c.key}>
<span className="chip-num">{summary ? summary[c.key] : "–"}</span>
<span className="chip-lbl">{c.label}</span>
<div
key={c.key}
role="listitem"
className={cn(
"flex flex-col items-center justify-center rounded-md px-2.5 py-1 shrink-0",
c.bgCls,
)}
>
<span className={cn("text-sm font-bold leading-none tabular-nums", c.numCls)}>
{summary ? summary[c.key] : "–"}
</span>
<span className="text-[9px] font-semibold tracking-widest text-muted-foreground leading-none mt-0.5 whitespace-nowrap">
{c.label}
</span>
</div>
))}
</div>
<div className="health" title={connected ? "live" : "disconnected"}>
<span className={`health-dot ${connected ? "ok" : "down"}`} aria-hidden />
<span className="health-clock">{generatedAt ?? "—"}</span>
{/* Health indicator */}
<div
className="flex items-center gap-2 shrink-0 ml-auto"
title={title}
aria-label={title}
>
<span
aria-hidden
className={cn(
"w-2.5 h-2.5 rounded-full",
connected
? "bg-green-500 shadow-[0_0_6px_2px_rgba(34,197,94,0.45)]"
: "bg-red-500",
)}
/>
<span className="text-xs text-muted-foreground tabular-nums">
{generatedAt ?? "—"}
</span>
</div>
</header>
);

View file

@ -92,3 +92,71 @@
.react-flow__attribution {
display: none;
}
/*
* Markdown content (Q&A answers / review findings / plan summaries), rendered by
* the XSS-safe <Markdown> component. Ported from theme.css onto the new tokens.
*/
.md {
font-size: 0.82rem;
color: hsl(var(--foreground));
overflow-wrap: anywhere;
}
.md > :first-child {
margin-top: 0;
}
.md > :last-child {
margin-bottom: 0;
}
.md p {
margin: 0.4rem 0;
}
.md ul,
.md ol {
margin: 0.4rem 0;
padding-left: 1.2rem;
}
.md li {
margin: 0.15rem 0;
}
.md strong {
color: hsl(var(--foreground));
font-weight: 700;
}
.md h1,
.md h2,
.md h3,
.md h4 {
font-size: 0.9rem;
margin: 0.6rem 0 0.3rem;
color: hsl(var(--status-done));
}
.md code {
background: hsl(var(--background));
border: 1px solid hsl(var(--border));
border-radius: 4px;
padding: 0 0.25rem;
font-size: 0.92em;
font-family: theme("fontFamily.mono");
}
.md pre {
background: hsl(var(--background));
border: 1px solid hsl(var(--border));
border-radius: 6px;
padding: 0.5rem;
overflow-x: auto;
}
.md pre code {
border: 0;
padding: 0;
background: none;
}
.md a {
color: hsl(var(--status-done));
}
.md blockquote {
margin: 0.4rem 0;
padding-left: 0.7rem;
border-left: 2px solid hsl(var(--input));
color: hsl(var(--muted-foreground));
}

View file

@ -2,10 +2,6 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "./index.css";
// theme.css is being retired as components migrate to Tailwind; kept until the
// last component is converted so intermediate builds still render (removed in
// the integration phase).
import "./theme.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>

View file

@ -0,0 +1,146 @@
import { describe, expect, it } from "vitest";
import { filterTasks, phaseToNode } from "./taskFilters";
import type { TaskView } from "./api";
const TREE_BY_NODE: Record<string, string> = {
intake: "core",
clarify: "core",
plan: "sdlc",
review: "sdlc",
build_node: "sdlc",
verify_node: "sdlc",
};
function task(overrides: Partial<TaskView> = {}): TaskView {
return {
thread_id: "aaaa1111",
short_id: "aaaa1111",
task: "remediate CVE-2024-1234",
current_phase: "plan",
status: "active",
waiting: false,
waiting_since: null,
...overrides,
};
}
const BASE_PARAMS = {
query: "",
status: "all",
tree: "all",
nodeFilter: null,
treeByNode: TREE_BY_NODE,
};
describe("phaseToNode", () => {
it("returns the phase itself for standard phases", () => {
expect(phaseToNode("intake")).toBe("intake");
expect(phaseToNode("plan")).toBe("plan");
expect(phaseToNode("clarify")).toBe("clarify");
});
it("maps build -> build_node and verify -> verify_node", () => {
expect(phaseToNode("build")).toBe("build_node");
expect(phaseToNode("verify")).toBe("verify_node");
});
});
describe("filterTasks", () => {
it("returns all tasks when no filters applied", () => {
const tasks = [task(), task({ thread_id: "bbbb2222", short_id: "bbbb2222" })];
expect(filterTasks(tasks, BASE_PARAMS)).toHaveLength(2);
});
it("filters by query matching task description", () => {
const tasks = [
task({ task: "remediate CVE" }),
task({ thread_id: "bbbb", short_id: "bbbb", task: "fix login bug" }),
];
const result = filterTasks(tasks, { ...BASE_PARAMS, query: "CVE" });
expect(result).toHaveLength(1);
expect(result[0].task).toBe("remediate CVE");
});
it("filters by query matching short_id", () => {
const tasks = [
task({ short_id: "abc123" }),
task({ thread_id: "bbbb", short_id: "xyz789", task: "other task" }),
];
const result = filterTasks(tasks, { ...BASE_PARAMS, query: "abc" });
expect(result).toHaveLength(1);
expect(result[0].short_id).toBe("abc123");
});
it("query matching is case-insensitive", () => {
const tasks = [task({ task: "Remediate CVE" })];
expect(filterTasks(tasks, { ...BASE_PARAMS, query: "remediate" })).toHaveLength(1);
});
it("filters by status", () => {
const tasks = [
task({ status: "active" }),
task({ thread_id: "bbbb", short_id: "bbbb", status: "parked" }),
];
const result = filterTasks(tasks, { ...BASE_PARAMS, status: "parked" });
expect(result).toHaveLength(1);
expect(result[0].status).toBe("parked");
});
it("status='all' does not filter by status", () => {
const tasks = [
task({ status: "active" }),
task({ thread_id: "bbbb", short_id: "bbbb", status: "done" }),
];
expect(filterTasks(tasks, { ...BASE_PARAMS, status: "all" })).toHaveLength(2);
});
it("filters by tree", () => {
const tasks = [
task({ current_phase: "intake" }), // core tree
task({ thread_id: "bbbb", short_id: "bbbb", current_phase: "plan" }), // sdlc tree
];
const result = filterTasks(tasks, { ...BASE_PARAMS, tree: "sdlc" });
expect(result).toHaveLength(1);
expect(result[0].current_phase).toBe("plan");
});
it("filters by nodeFilter (exact node match)", () => {
const tasks = [
task({ current_phase: "plan" }),
task({ thread_id: "bbbb", short_id: "bbbb", current_phase: "intake" }),
];
const result = filterTasks(tasks, { ...BASE_PARAMS, nodeFilter: "plan" });
expect(result).toHaveLength(1);
expect(result[0].current_phase).toBe("plan");
});
it("nodeFilter resolves build -> build_node", () => {
const tasks = [
task({ current_phase: "build" }),
task({ thread_id: "bbbb", short_id: "bbbb", current_phase: "plan" }),
];
const result = filterTasks(tasks, { ...BASE_PARAMS, nodeFilter: "build_node" });
expect(result).toHaveLength(1);
expect(result[0].current_phase).toBe("build");
});
it("combines query + status filters", () => {
const tasks = [
task({ task: "fix login", status: "active" }),
task({ thread_id: "b", short_id: "b", task: "fix login", status: "parked" }),
task({ thread_id: "c", short_id: "c", task: "other task", status: "active" }),
];
const result = filterTasks(tasks, { ...BASE_PARAMS, query: "fix login", status: "active" });
expect(result).toHaveLength(1);
expect(result[0].thread_id).toBe("aaaa1111");
});
it("returns empty array when nothing matches", () => {
const tasks = [task()];
expect(filterTasks(tasks, { ...BASE_PARAMS, query: "nonexistent" })).toHaveLength(0);
});
it("handles empty task list", () => {
expect(filterTasks([], BASE_PARAMS)).toHaveLength(0);
});
});

View file

@ -0,0 +1,47 @@
// Pure filter helpers — shared by Board and any future list views.
// Lifted from TaskList.tsx so logic is testable in isolation.
import type { TaskView, Topology } from "./api";
// Mirror of the backend phase->node mapping. Identity for most phases;
// build/verify are the only overrides (P3 build-pipeline vertices).
const PHASE_TO_NODE_MAP: Record<string, string> = {
build: "build_node",
verify: "verify_node",
};
export function phaseToNode(phase: string): string {
return PHASE_TO_NODE_MAP[phase] ?? phase;
}
/** Build a node-id -> tree-id lookup from topology. */
export function buildTreeByNode(topology: Topology | null): Record<string, string> {
const m: Record<string, string> = {};
for (const n of topology?.nodes ?? []) m[n.id] = n.tree;
return m;
}
export interface FilterParams {
query: string;
status: string; // "all" | task status value
tree: string; // "all" | tree id
nodeFilter: string | null;
treeByNode: Record<string, string>;
}
/** Filter an array of TaskView objects by the given params. */
export function filterTasks(tasks: TaskView[], params: FilterParams): TaskView[] {
const { query, status, tree, nodeFilter, treeByNode } = params;
const q = query.trim().toLowerCase();
return tasks.filter((t) => {
const node = phaseToNode(t.current_phase);
if (nodeFilter && node !== nodeFilter) return false;
if (status !== "all" && t.status !== status) return false;
if (tree !== "all" && treeByNode[node] !== tree) return false;
if (q && !`${t.task} ${t.short_id}`.toLowerCase().includes(q)) return false;
return true;
});
}

View file

@ -1,227 +0,0 @@
:root {
--bg: #0c0e13;
--bg-1: #12151c;
--bg-2: #1a1e26;
--border: #232a36;
--border-2: #2f3645;
--text: #e6e8ee;
--text-dim: #8a93a6;
--accent: #7a5cff;
--accent-2: #3f7fd8;
--ok: #2f8f5e;
--ok-bg: #163a2b;
--ok-fg: #6ee7a8;
--warn-bg: #3a3216;
--warn-fg: #f5d76e;
--park-bg: #3a1f16;
--park-fg: #f3a36e;
--done-bg: #1c2b3a;
--done-fg: #7fb3f5;
--fail-bg: #3a1620;
--fail-fg: #f57f9c;
color-scheme: dark;
}
* { box-sizing: border-box; }
html, body, #root { height: 100%; margin: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica,
Arial, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.45;
}
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.app { display: flex; flex-direction: column; height: 100%; }
.body { display: grid; grid-template-columns: 320px 1fr 360px; flex: 1; min-height: 0; }
/* --- Top bar -------------------------------------------------------------- */
.topbar {
display: flex; align-items: center; gap: 1.5rem;
padding: .6rem 1rem; background: var(--bg-1);
border-bottom: 1px solid var(--border);
}
.brand { display: flex; align-items: baseline; gap: .5rem; }
.brand-dot {
width: 10px; height: 10px; border-radius: 50%;
background: linear-gradient(135deg, var(--accent), var(--accent-2));
align-self: center;
}
.brand-name { font-weight: 700; letter-spacing: .01em; }
.brand-sub { color: var(--text-dim); font-size: .85rem; }
.chips { display: flex; gap: .6rem; flex: 1; }
.chip {
display: flex; flex-direction: column; align-items: center;
background: var(--bg-2); border: 1px solid var(--border);
border-radius: 8px; padding: .3rem .7rem; min-width: 4.5rem;
}
.chip-num { font-size: 1.25rem; font-weight: 700; }
.chip-lbl { font-size: .66rem; text-transform: uppercase; letter-spacing: .05em;
color: var(--text-dim); }
.chip-active .chip-num { color: var(--ok-fg); }
.chip-waiting .chip-num { color: var(--warn-fg); }
.chip-parked .chip-num { color: var(--park-fg); }
.health { display: flex; align-items: center; gap: .5rem; color: var(--text-dim);
font-size: .8rem; }
.health-dot { width: 8px; height: 8px; border-radius: 50%; }
.health-dot.ok { background: var(--ok-fg); box-shadow: 0 0 6px var(--ok); }
.health-dot.down { background: var(--fail-fg); }
/* --- Task list ------------------------------------------------------------ */
.tasklist { background: var(--bg-1); border-right: 1px solid var(--border);
display: flex; flex-direction: column; min-height: 0; }
.tasklist-controls { padding: .75rem; border-bottom: 1px solid var(--border);
display: flex; flex-direction: column; gap: .5rem; }
.search, select {
background: var(--bg-2); color: var(--text); border: 1px solid var(--border-2);
border-radius: 6px; padding: .4rem .55rem; font-size: .85rem; width: 100%;
}
.filters { display: flex; gap: .5rem; }
.filter-chip { background: var(--bg-2); color: var(--accent); cursor: pointer;
border: 1px solid var(--accent); border-radius: 999px; padding: .2rem .6rem;
font-size: .75rem; align-self: flex-start; }
.tasks { list-style: none; margin: 0; padding: .5rem; overflow-y: auto; flex: 1; }
.task-row { background: var(--bg-2); border: 1px solid var(--border);
border-radius: 8px; padding: .55rem .65rem; margin-bottom: .5rem; cursor: pointer;
transition: border-color .15s, transform .05s; }
.task-row:hover { border-color: var(--border-2); }
.task-row:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.task-row.selected { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
.task-row-top { display: flex; align-items: center; gap: .5rem; margin-bottom: .25rem; }
.task-phase { color: var(--text-dim); font-size: .75rem; }
.task-wait { margin-left: auto; }
.task-desc { font-size: .85rem; overflow: hidden; text-overflow: ellipsis;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.task-id { color: var(--text-dim); font-size: .72rem; margin-top: .25rem; }
.empty { color: var(--text-dim); font-style: italic; padding: .5rem; }
/* --- Badges -------------------------------------------------------------- */
.badge { display: inline-block; padding: .08rem .45rem; border-radius: 999px;
font-size: .72rem; font-weight: 600; }
.badge.active { background: var(--ok-bg); color: var(--ok-fg); }
.badge.waiting_human, .badge.waiting { background: var(--warn-bg); color: var(--warn-fg); }
.badge.parked { background: var(--park-bg); color: var(--park-fg); }
.badge.done { background: var(--done-bg); color: var(--done-fg); }
.badge.failed { background: var(--fail-bg); color: var(--fail-fg); }
.badge.unknown { background: var(--bg-2); color: var(--text-dim); }
/* --- Map ----------------------------------------------------------------- */
.center { min-height: 0; min-width: 0; position: relative; }
.map-pane { width: 100%; height: 100%; }
.loading { padding: 2rem; color: var(--text-dim); }
.react-flow__attribution { display: none; }
.node {
width: 184px; height: 72px; background: var(--bg-2);
border: 1.5px solid var(--border-2); border-radius: 10px;
padding: .5rem .6rem; display: flex; flex-direction: column; justify-content: center;
position: relative; transition: border-color .25s, background .25s, opacity .2s;
}
.node:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.node-label { font-weight: 700; font-size: .92rem; display: flex; gap: .3rem;
align-items: center; }
.gate-pip { color: var(--warn-fg); font-size: .8rem; }
.node-agent { color: var(--text-dim); font-size: .72rem; margin-top: .15rem; }
.node-count { position: absolute; top: -8px; right: -8px; min-width: 20px; height: 20px;
border-radius: 999px; background: var(--accent); color: #0c0e13; font-weight: 800;
font-size: .72rem; display: flex; align-items: center; justify-content: center;
padding: 0 5px; }
.node-gated-tag { position: absolute; bottom: 4px; right: 8px; font-size: .58rem;
letter-spacing: .08em; text-transform: uppercase; color: var(--text-dim); }
.node.state-active { border-color: var(--ok); background: var(--ok-bg); }
.node.state-awaiting_human { border-color: var(--warn-fg); background: var(--warn-bg); }
.node.state-parked { border-color: var(--park-fg); background: var(--park-bg); }
.node.gated { opacity: .62; }
.node.dimmed { opacity: .28; }
.node.on-path { box-shadow: 0 0 0 2px var(--accent), 0 0 14px rgba(122, 92, 255, .4); }
/* --- Drawer -------------------------------------------------------------- */
.drawer { background: var(--bg-1); border-left: 1px solid var(--border);
display: flex; flex-direction: column; min-height: 0; overflow-y: auto; }
.drawer-head { display: flex; align-items: center; justify-content: space-between;
padding: .75rem 1rem; border-bottom: 1px solid var(--border); position: sticky;
top: 0; background: var(--bg-1); }
.drawer-head h2 { font-size: 1rem; margin: 0; color: #9fb4d6; }
.drawer-close { background: none; border: none; color: var(--text-dim);
font-size: 1rem; cursor: pointer; }
.drawer-close:focus-visible { outline: 2px solid var(--accent); }
.drawer-loading, .drawer-error, .drawer-section { padding: 1rem; }
.drawer-error { color: var(--fail-fg); }
.drawer-summary { padding: 1rem; border-bottom: 1px solid var(--border); }
.drawer-task { font-size: .95rem; margin-bottom: .5rem; }
.drawer-meta { display: flex; gap: .6rem; align-items: center; flex-wrap: wrap;
font-size: .8rem; color: var(--text-dim); }
.spend { margin-left: auto; color: var(--text); }
.partial-banner { margin-top: .6rem; background: var(--warn-bg); color: var(--warn-fg);
border-radius: 6px; padding: .5rem .6rem; font-size: .76rem; }
.timeline { list-style: none; margin: 0; padding: 1rem 1rem 0; }
.tl-step { display: flex; gap: .7rem; padding-bottom: 1rem; position: relative; }
.tl-step:not(:last-child)::before { content: ""; position: absolute; left: 5px;
top: 14px; bottom: 0; width: 2px; background: var(--border-2); }
.tl-dot { width: 12px; height: 12px; border-radius: 50%; background: var(--accent);
margin-top: 2px; flex-shrink: 0; z-index: 1; }
.tl-step.open .tl-dot { background: var(--warn-fg); box-shadow: 0 0 6px var(--warn-fg); }
.tl-body { flex: 1; }
.tl-node { font-weight: 700; font-size: .88rem; }
.tl-times { display: flex; justify-content: space-between; color: var(--text-dim);
font-size: .72rem; margin: .15rem 0; }
.tl-dur { color: var(--text); }
.tl-foot { display: flex; gap: .5rem; align-items: center; font-size: .72rem; }
.tl-cost { color: var(--ok-fg); }
.tl-note { color: var(--text-dim); font-style: italic; }
.drawer-section summary { cursor: pointer; color: #9fb4d6; font-weight: 600;
font-size: .85rem; }
.json { background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
padding: .6rem; font-size: .72rem; overflow-x: auto; max-height: 16rem;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
/* --- Loop-back affordance on a node ------------------------------------- */
.loop-chip { margin-left: .35rem; color: var(--accent); font-weight: 700;
font-size: .9rem; cursor: help; }
.badge.request_changes { background: var(--park-bg); color: var(--park-fg); }
.badge.approve, .badge.approved, .badge.pass { background: var(--ok-bg); color: var(--ok-fg); }
/* --- Markdown content (Q&A / verdicts / plan) --------------------------- */
.md { font-size: .82rem; color: var(--text); overflow-wrap: anywhere; }
.md > :first-child { margin-top: 0; }
.md > :last-child { margin-bottom: 0; }
.md p { margin: .4rem 0; }
.md ul, .md ol { margin: .4rem 0; padding-left: 1.2rem; }
.md li { margin: .15rem 0; }
.md strong { color: #fff; font-weight: 700; }
.md h1, .md h2, .md h3, .md h4 { font-size: .9rem; margin: .6rem 0 .3rem;
color: #9fb4d6; }
.md code { background: var(--bg); border: 1px solid var(--border); border-radius: 4px;
padding: 0 .25rem; font-size: .92em;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.md pre { background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
padding: .5rem; overflow-x: auto; }
.md pre code { border: 0; padding: 0; background: none; }
.md a { color: var(--done-fg); }
.md blockquote { margin: .4rem 0; padding-left: .7rem; border-left: 2px solid var(--border-2);
color: var(--text-dim); }
/* --- Q&A cards --------------------------------------------------------- */
.qa-card, .verdict-card { background: var(--bg-2); border: 1px solid var(--border);
border-radius: 8px; padding: .55rem .65rem; margin-bottom: .6rem; }
.qa-turn { font-size: .72rem; font-weight: 700; color: var(--text-dim);
text-transform: uppercase; letter-spacing: .04em; margin-bottom: .3rem; }
/* --- Verdict cards ----------------------------------------------------- */
.verdict-head { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap;
margin-bottom: .35rem; }
.verdict-round { font-size: .72rem; font-weight: 700; color: var(--text-dim); }
.verdict-outcome { font-size: .72rem; color: var(--text-dim); }
/* --- Plan -------------------------------------------------------------- */
.plan-phases { margin: .5rem 0 0; padding-left: 1.1rem; }
.plan-phase { margin-bottom: .5rem; }
.plan-phase-name { font-weight: 700; font-size: .84rem; }
.plan-steps { margin: .2rem 0 0; padding-left: 1.1rem; }
.plan-steps li { font-size: .8rem; margin: .15rem 0; }
.plan-extra { margin: .6rem 0 0; font-size: .78rem; }
.plan-extra-row { display: flex; gap: .5rem; }
.plan-extra dt { color: var(--text-dim); min-width: 5rem; }
.plan-extra dd { margin: 0; }