feat(agent-team): React/Vite status-dashboard SPA

New web/ SPA (React 18 + Vite 5 + TypeScript + React Flow + dagre, all pinned, no
CDN). Dark, three-pane layout: summary top bar, filterable task list, center
auto-laid pipeline map, and a right drawer showing a selected task's history
through each node (timeline with timestamps/duration/cost + Q&A/verdicts/plan).
Map nodes color by live state, loop-backs render dashed, clicking a node filters
the list, selecting a task highlights its path. Polls /api/state every 4s;
topology fetched once. dist/ is gitignored (built on the Mac, rsynced).
This commit is contained in:
Adam Moussa 2026-06-23 17:15:23 -04:00
parent 322a1f6922
commit 677f3d3c71
20 changed files with 5488 additions and 0 deletions

7
agent-team/web/.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
node_modules/
dist/
*.tsbuildinfo
.vite/
coverage/
playwright-report/
test-results/

13
agent-team/web/index.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" />
<title>agent-team · pipeline</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4290
agent-team/web/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,33 @@
{
"name": "agent-team-dashboard",
"private": true,
"version": "1.0.0",
"type": "module",
"description": "Read-only status dashboard SPA for the Sea Haven agent-team pipeline.",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc -b --noEmit"
},
"dependencies": {
"@dagrejs/dagre": "1.1.4",
"react": "18.3.1",
"react-dom": "18.3.1",
"reactflow": "11.11.4"
},
"devDependencies": {
"@testing-library/jest-dom": "6.4.8",
"@testing-library/react": "16.0.1",
"@types/node": "20.14.15",
"@types/react": "18.3.5",
"@types/react-dom": "18.3.0",
"@vitejs/plugin-react": "4.3.1",
"jsdom": "24.1.3",
"typescript": "5.5.4",
"vite": "5.4.8",
"vitest": "1.6.0"
}
}

129
agent-team/web/src/App.tsx Normal file
View file

@ -0,0 +1,129 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
fetchState,
fetchTask,
fetchTopology,
type StateResponse,
type TaskDetail,
type Topology,
} from "./api";
import { PipelineMap } from "./components/PipelineMap";
import { TaskDrawer } from "./components/TaskDrawer";
import { TaskList } from "./components/TaskList";
import { TopBar } from "./components/TopBar";
const POLL_MS = 4000;
export default function App() {
const [state, setState] = useState<StateResponse | null>(null);
const [topology, setTopology] = useState<Topology | null>(null);
const [connected, setConnected] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [detail, setDetail] = useState<TaskDetail | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [detailError, setDetailError] = useState<string | null>(null);
const [nodeFilter, setNodeFilter] = useState<string | null>(null);
// Topology is static for the life of the page — fetch once.
useEffect(() => {
fetchTopology()
.then(setTopology)
.catch(() => setTopology({ trees: [], nodes: [], edges: [] }));
}, []);
// Poll live state every 4s; the connection dot reflects the last fetch.
useEffect(() => {
let alive = true;
const tick = async () => {
try {
const s = await fetchState();
if (alive) {
setState(s);
setConnected(true);
}
} catch {
if (alive) setConnected(false);
}
};
tick();
const id = setInterval(tick, POLL_MS);
return () => {
alive = false;
clearInterval(id);
};
}, []);
const detailReq = useRef(0);
const selectTask = useCallback((threadId: string) => {
setSelectedId(threadId);
setDetailLoading(true);
setDetailError(null);
const req = ++detailReq.current;
fetchTask(threadId)
.then((d) => {
if (req === detailReq.current) setDetail(d);
})
.catch((e: unknown) => {
if (req === detailReq.current)
setDetailError(e instanceof Error ? e.message : "load failed");
})
.finally(() => {
if (req === detailReq.current) setDetailLoading(false);
});
}, []);
const closeDrawer = useCallback(() => {
setSelectedId(null);
setDetail(null);
setDetailError(null);
}, []);
const toggleNodeFilter = useCallback((nodeId: string) => {
setNodeFilter((cur) => (cur === nodeId ? null : nodeId));
}, []);
// Highlight the nodes a selected task has visited.
const pathNodeIds = useMemo(() => {
if (!selectedId || !detail?.ok) return null;
return new Set(detail.timeline.map((s) => s.to_phase));
}, [selectedId, detail]);
return (
<div className="app">
<TopBar
state={state}
connected={connected}
generatedAt={state?.generated_at ?? null}
/>
<div className="body">
<TaskList
state={state}
topology={topology}
selectedId={selectedId}
nodeFilter={nodeFilter}
onSelect={selectTask}
onClearNodeFilter={() => setNodeFilter(null)}
/>
<main className="center">
{topology ? (
<PipelineMap
topology={topology}
state={state}
pathNodeIds={pathNodeIds}
onNodeClick={toggleNodeFilter}
/>
) : (
<div className="loading">Loading pipeline…</div>
)}
</main>
<TaskDrawer
detail={detail}
loading={detailLoading}
error={detailError}
onClose={closeDrawer}
/>
</div>
</div>
);
}

102
agent-team/web/src/api.ts Normal file
View file

@ -0,0 +1,102 @@
// Typed client for the read-only dashboard API (agent_team/dashboard.py).
// All endpoints are GET + read-only; the SPA never mutates server state.
export type NodeState = "idle" | "active" | "awaiting_human" | "parked";
export interface TaskView {
thread_id: string;
short_id: string;
task: string;
current_phase: string;
status: string;
waiting: boolean;
waiting_since: string | null;
}
export interface StateResponse {
ok: boolean;
error: string | null;
generated_at: string;
db_path: string;
tasks: TaskView[];
waiting: TaskView[];
question_counts: Record<string, number>;
summary: {
active: number;
parked: number;
waiting: number;
open_questions: number;
total: number;
};
budget: {
available: boolean;
total_usd?: number | null;
recent?: Array<Record<string, unknown>>;
};
// Per-topology-node live state, keyed by node id. Absent nodes are idle.
nodes: Record<string, { state: NodeState; count: number }>;
}
export interface TopoNode {
id: string;
label: string;
agent: string;
tree: string;
kind: string;
gated: boolean;
}
export interface TopoEdge {
from: string;
to: string;
kind: "spine" | "branch" | "loopback";
}
export interface Topology {
trees: Array<{ id: string; label: string; root?: boolean }>;
nodes: TopoNode[];
edges: TopoEdge[];
}
export interface TimelineStep {
from_phase: string | null;
to_phase: string;
entered_at: string | null;
exited_at: string | null;
duration_s: number | null;
status: string | null;
note: string | null;
cost_usd: number;
}
export interface TaskDetail {
ok: boolean;
error?: string;
thread_id: string;
short_id: string;
task: string;
status: string;
current_phase: string;
partial: boolean;
timeline: TimelineStep[];
qa_history: unknown[];
review_verdicts: unknown[];
plan: Record<string, unknown> | null;
cost_by_stage: Record<string, number>;
total_usd: number;
}
async function getJSON<T>(url: string): Promise<T> {
const res = await fetch(url, { headers: { Accept: "application/json" } });
if (!res.ok) throw new Error(`${url} -> ${res.status}`);
return (await res.json()) as T;
}
export const fetchState = (): Promise<StateResponse> =>
getJSON<StateResponse>(`/api/state?ts=${Date.now()}`);
export const fetchTopology = (): Promise<Topology> =>
getJSON<Topology>("/api/topology");
export const fetchTask = (threadId: string): Promise<TaskDetail> =>
getJSON<TaskDetail>(`/api/task/${encodeURIComponent(threadId)}?ts=${Date.now()}`);

View file

@ -0,0 +1,48 @@
import { memo } from "react";
import { Handle, Position, type NodeProps } from "reactflow";
import type { RFData } from "../layout";
// One pipeline stage on the map. Color encodes live state; a badge shows the
// live task count; gated (P3/inert) nodes render dimmed with a tag. ARIA role +
// label keep the map reachable for screen readers / keyboard users.
function MapNodeImpl({ data }: NodeProps<RFData>) {
const { meta, state, count, dimmed, onPath } = data;
const cls = [
"node",
`state-${state}`,
meta.gated ? "gated" : "",
dimmed ? "dimmed" : "",
onPath ? "on-path" : "",
]
.filter(Boolean)
.join(" ");
return (
<div
className={cls}
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>
)}
</div>
<div className="node-agent">{meta.agent}</div>
{count > 0 && (
<span className="node-count" aria-hidden>
{count}
</span>
)}
{meta.gated && <span className="node-gated-tag">inert</span>}
<Handle type="source" position={Position.Right} />
</div>
);
}
export const MapNode = memo(MapNodeImpl);

View file

@ -0,0 +1,55 @@
import { useMemo } from "react";
import ReactFlow, {
Background,
Controls,
type NodeMouseHandler,
ReactFlowProvider,
} from "reactflow";
import "reactflow/dist/style.css";
import type { StateResponse, Topology } from "../api";
import { layout } from "../layout";
import { MapNode } from "./MapNode";
const nodeTypes = { pipeline: MapNode };
interface Props {
topology: Topology;
state: StateResponse | null;
pathNodeIds: Set<string> | null;
onNodeClick: (nodeId: string) => void;
}
// The center pipeline map. Nodes are dagre-laid from the topology; live state
// colors them; clicking a node filters the task list. Multiple trees (processes
// off intake) lay out as separate lanes automatically.
export function PipelineMap({ topology, state, pathNodeIds, onNodeClick }: Props) {
const { nodes, edges } = useMemo(
() => layout(topology, state?.nodes ?? {}, pathNodeIds),
[topology, state, pathNodeIds],
);
const handleNodeClick: NodeMouseHandler = (_evt, node) => onNodeClick(node.id);
return (
<div className="map-pane" aria-label="pipeline map">
<ReactFlowProvider>
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
fitView
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable
onNodeClick={handleNodeClick}
proOptions={{ hideAttribution: true }}
minZoom={0.3}
maxZoom={1.6}
>
<Background color="#222a36" gap={22} />
<Controls showInteractive={false} />
</ReactFlow>
</ReactFlowProvider>
</div>
);
}

View file

@ -0,0 +1,53 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { TaskDrawer } from "./TaskDrawer";
import type { TaskDetail } from "../api";
const DETAIL: TaskDetail = {
ok: true,
thread_id: "aaaa1111bbbb",
short_id: "aaaa1111",
task: "remediate CVE-2026-1",
status: "done",
current_phase: "done",
partial: false,
timeline: [
{ from_phase: null, to_phase: "intake", entered_at: "2026-06-23T00:00:00+00:00", exited_at: "2026-06-23T00:00:01+00:00", duration_s: 1, status: "active", note: null, cost_usd: 0 },
{ from_phase: "intake", to_phase: "plan", entered_at: "2026-06-23T00:00:01+00:00", exited_at: null, duration_s: null, status: "active", note: null, cost_usd: 0.12 },
],
qa_history: [{ turn: 0, answer: "scope it" }],
review_verdicts: [],
plan: { summary: "do the thing" },
cost_by_stage: { plan: 0.12 },
total_usd: 0.12,
};
describe("TaskDrawer", () => {
it("renders the timeline steps with the task description and spend", () => {
render(<TaskDrawer detail={DETAIL} loading={false} error={null} onClose={() => {}} />);
expect(screen.getByText("remediate CVE-2026-1")).toBeInTheDocument();
expect(screen.getByText("intake")).toBeInTheDocument();
expect(screen.getByText("plan")).toBeInTheDocument();
expect(screen.getByText("in progress")).toBeInTheDocument(); // open final step
expect(screen.getByText(/spend \$0\.12/)).toBeInTheDocument();
});
it("shows a partial-history banner when partial", () => {
render(
<TaskDrawer
detail={{ ...DETAIL, partial: true }}
loading={false}
error={null}
onClose={() => {}}
/>,
);
expect(screen.getByText(/Partial history/)).toBeInTheDocument();
});
it("renders nothing when idle (no detail, not loading)", () => {
const { container } = render(
<TaskDrawer detail={null} loading={false} error={null} onClose={() => {}} />,
);
expect(container).toBeEmptyDOMElement();
});
});

View file

@ -0,0 +1,109 @@
import type { TaskDetail } from "../api";
interface Props {
detail: TaskDetail | null;
loading: boolean;
error: string | null;
onClose: () => void;
}
function fmtDuration(s: number | null): string {
if (s == null) return "—";
if (s < 60) return `${s.toFixed(1)}s`;
if (s < 3600) return `${(s / 60).toFixed(1)}m`;
return `${(s / 3600).toFixed(1)}h`;
}
function fmtCost(usd: number): string {
return usd > 0 ? `$${usd.toFixed(4)}` : "—";
}
// Right-hand drawer: a task's journey through the pipeline (vertical timeline of
// task_transitions) plus Q&A, review verdicts, plan summary, and total spend.
export function TaskDrawer({ detail, loading, error, onClose }: Props) {
if (!detail && !loading && !error) return null;
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>
</div>
{loading && <div className="drawer-loading">Loading…</div>}
{error && <div className="drawer-error">{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>
<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>}
</div>
</div>
</li>
);
})}
{detail.timeline.length === 0 && (
<li className="empty">No transitions recorded.</li>
)}
</ol>
{detail.qa_history.length > 0 && (
<details className="drawer-section" open>
<summary>Q&amp;A ({detail.qa_history.length})</summary>
<pre className="json">{JSON.stringify(detail.qa_history, null, 2)}</pre>
</details>
)}
{detail.review_verdicts.length > 0 && (
<details className="drawer-section">
<summary>Review verdicts ({detail.review_verdicts.length})</summary>
<pre className="json">{JSON.stringify(detail.review_verdicts, null, 2)}</pre>
</details>
)}
{detail.plan && (
<details className="drawer-section">
<summary>Plan</summary>
<pre className="json">{JSON.stringify(detail.plan, null, 2)}</pre>
</details>
)}
</>
)}
{detail && !detail.ok && (
<div className="drawer-error">{detail.error ?? "Could not load task."}</div>
)}
</section>
);
}

View file

@ -0,0 +1,74 @@
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

@ -0,0 +1,145 @@
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

@ -0,0 +1,42 @@
import type { StateResponse } from "../api";
interface Props {
state: StateResponse | null;
connected: boolean;
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" },
];
// Top status bar: summary chips, a connection/health dot, and the snapshot clock.
export function TopBar({ state, connected, generatedAt }: Props) {
const summary = state?.summary;
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>
</div>
<div className="chips" role="list">
{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>
))}
</div>
<div className="health" title={connected ? "live" : "disconnected"}>
<span className={`health-dot ${connected ? "ok" : "down"}`} aria-hidden />
<span className="health-clock">{generatedAt ?? "—"}</span>
</div>
</header>
);
}

View file

@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import { layout } from "./layout";
import type { Topology } from "./api";
const TOPO: Topology = {
trees: [
{ id: "core", label: "Core", root: true },
{ id: "sdlc", label: "SDLC", root: false },
],
nodes: [
{ id: "intake", label: "Intake", agent: "coordinator", tree: "core", kind: "phase", gated: false },
{ id: "clarify", label: "Clarify", agent: "Claude", tree: "sdlc", kind: "gate", gated: false },
{ id: "plan", label: "Plan", agent: "Claude", tree: "sdlc", kind: "phase", gated: false },
{ id: "review", label: "Review", agent: "GPT-4.1", tree: "sdlc", kind: "phase", gated: false },
],
edges: [
{ from: "intake", to: "clarify", kind: "spine" },
{ from: "clarify", to: "plan", kind: "spine" },
{ from: "plan", to: "review", kind: "spine" },
{ from: "review", to: "plan", kind: "loopback" },
],
};
describe("layout", () => {
it("produces a laid-out node per topology node", () => {
const { nodes, edges } = layout(TOPO, {}, null);
expect(nodes).toHaveLength(4);
expect(edges).toHaveLength(4);
// every node has a numeric position assigned by dagre
for (const n of nodes) {
expect(typeof n.position.x).toBe("number");
expect(typeof n.position.y).toBe("number");
}
});
it("applies live state + count to the matching node", () => {
const { nodes } = layout(TOPO, { clarify: { state: "awaiting_human", count: 2 } }, null);
const clarify = nodes.find((n) => n.id === "clarify")!;
expect(clarify.data.state).toBe("awaiting_human");
expect(clarify.data.count).toBe(2);
const plan = nodes.find((n) => n.id === "plan")!;
expect(plan.data.state).toBe("idle");
});
it("marks the selected task's path and dims the rest", () => {
const path = new Set(["intake", "clarify"]);
const { nodes } = layout(TOPO, {}, path);
expect(nodes.find((n) => n.id === "intake")!.data.onPath).toBe(true);
expect(nodes.find((n) => n.id === "intake")!.data.dimmed).toBe(false);
expect(nodes.find((n) => n.id === "review")!.data.onPath).toBe(false);
expect(nodes.find((n) => n.id === "review")!.data.dimmed).toBe(true);
});
it("styles loopback edges distinctly (dashed)", () => {
const { edges } = layout(TOPO, {}, null);
const loop = edges.find((e) => e.id === "review->plan")!;
expect(loop.style?.strokeDasharray).toBeTruthy();
});
});

View file

@ -0,0 +1,71 @@
// Auto-layout the pipeline graph with dagre, so adding a node in the backend
// graph needs no manual coordinates here — the map redraws itself.
import dagre from "@dagrejs/dagre";
import type { Edge, Node } from "reactflow";
import type { Topology, TopoNode } from "./api";
export const NODE_W = 184;
export const NODE_H = 72;
export interface RFData {
meta: TopoNode;
state: "idle" | "active" | "awaiting_human" | "parked";
count: number;
dimmed: boolean;
onPath: boolean;
}
// Compute laid-out React Flow nodes + edges from the topology and live state.
// `onPath` marks the nodes a selected task has visited (highlighted); `dimmed`
// fades nodes outside the active task's path when one is selected.
export function layout(
topo: Topology,
liveNodes: Record<string, { state: RFData["state"]; count: number }>,
pathNodeIds: Set<string> | null,
): { nodes: Node<RFData>[]; edges: Edge[] } {
const g = new dagre.graphlib.Graph();
g.setGraph({ rankdir: "LR", nodesep: 36, ranksep: 72, marginx: 24, marginy: 24 });
g.setDefaultEdgeLabel(() => ({}));
for (const n of topo.nodes) g.setNode(n.id, { width: NODE_W, height: NODE_H });
for (const e of topo.edges) g.setEdge(e.from, e.to);
dagre.layout(g);
const nodes: Node<RFData>[] = topo.nodes.map((meta) => {
const pos = g.node(meta.id);
const live = liveNodes[meta.id];
const onPath = pathNodeIds ? pathNodeIds.has(meta.id) : false;
return {
id: meta.id,
type: "pipeline",
position: { x: pos.x - NODE_W / 2, y: pos.y - NODE_H / 2 },
data: {
meta,
state: live?.state ?? "idle",
count: live?.count ?? 0,
dimmed: pathNodeIds != null && !onPath,
onPath,
},
};
});
const edges: Edge[] = topo.edges.map((e) => {
const loop = e.kind === "loopback";
return {
id: `${e.from}->${e.to}`,
source: e.from,
target: e.to,
animated: false,
style: {
stroke: loop ? "#7a5cff" : e.kind === "branch" ? "#3f7fd8" : "#39414f",
strokeWidth: 1.5,
strokeDasharray: loop ? "5 4" : undefined,
},
label: loop ? "↺" : undefined,
labelStyle: { fill: "#9b8bff", fontWeight: 700 },
type: "smoothstep",
};
});
return { nodes, edges };
}

View file

@ -0,0 +1,10 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "./theme.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);

View file

@ -0,0 +1,19 @@
import "@testing-library/jest-dom/vitest";
// React Flow measures the DOM; jsdom lacks ResizeObserver + layout APIs, so
// stub the minimum the component touches during tests.
class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
}
globalThis.ResizeObserver =
globalThis.ResizeObserver ?? (ResizeObserverStub as typeof ResizeObserver);
if (!globalThis.DOMMatrixReadOnly) {
// @ts-expect-error - minimal stub for reactflow transforms in jsdom
globalThis.DOMMatrixReadOnly = class {
m22 = 1;
constructor() {}
};
}

View file

@ -0,0 +1,178 @@
: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; }

View file

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"types": ["node", "vitest/globals", "@testing-library/jest-dom"]
},
"include": ["src", "vite.config.ts"]
}

View file

@ -0,0 +1,30 @@
/// <reference types="vitest/config" />
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// The SPA is served by the FastAPI dashboard (agent_team/dashboard.py) from
// web/dist at LAN :8770. In dev, proxy /api to a locally running dashboard so
// `npm run dev` talks to real data without CORS. No CDN — everything bundles
// locally (the dashboard is offline/LAN-only).
export default defineConfig({
plugins: [react()],
build: {
outDir: "dist",
emptyOutDir: true,
sourcemap: false,
},
server: {
proxy: {
"/api": {
target: process.env.AGENT_TEAM_DASH || "http://127.0.0.1:8770",
changeOrigin: true,
},
},
},
test: {
globals: true,
environment: "jsdom",
setupFiles: ["./src/test/setup.ts"],
css: false,
},
});