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:
parent
322a1f6922
commit
677f3d3c71
20 changed files with 5488 additions and 0 deletions
7
agent-team/web/.gitignore
vendored
Normal file
7
agent-team/web/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
.vite/
|
||||
coverage/
|
||||
playwright-report/
|
||||
test-results/
|
||||
13
agent-team/web/index.html
Normal file
13
agent-team/web/index.html
Normal 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
4290
agent-team/web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
33
agent-team/web/package.json
Normal file
33
agent-team/web/package.json
Normal 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
129
agent-team/web/src/App.tsx
Normal 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
102
agent-team/web/src/api.ts
Normal 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()}`);
|
||||
48
agent-team/web/src/components/MapNode.tsx
Normal file
48
agent-team/web/src/components/MapNode.tsx
Normal 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);
|
||||
55
agent-team/web/src/components/PipelineMap.tsx
Normal file
55
agent-team/web/src/components/PipelineMap.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
agent-team/web/src/components/TaskDrawer.test.tsx
Normal file
53
agent-team/web/src/components/TaskDrawer.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
109
agent-team/web/src/components/TaskDrawer.tsx
Normal file
109
agent-team/web/src/components/TaskDrawer.tsx
Normal 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&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>
|
||||
);
|
||||
}
|
||||
74
agent-team/web/src/components/TaskList.test.tsx
Normal file
74
agent-team/web/src/components/TaskList.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
145
agent-team/web/src/components/TaskList.tsx
Normal file
145
agent-team/web/src/components/TaskList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
42
agent-team/web/src/components/TopBar.tsx
Normal file
42
agent-team/web/src/components/TopBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
59
agent-team/web/src/layout.test.ts
Normal file
59
agent-team/web/src/layout.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
71
agent-team/web/src/layout.ts
Normal file
71
agent-team/web/src/layout.ts
Normal 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 };
|
||||
}
|
||||
10
agent-team/web/src/main.tsx
Normal file
10
agent-team/web/src/main.tsx
Normal 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>,
|
||||
);
|
||||
19
agent-team/web/src/test/setup.ts
Normal file
19
agent-team/web/src/test/setup.ts
Normal 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() {}
|
||||
};
|
||||
}
|
||||
178
agent-team/web/src/theme.css
Normal file
178
agent-team/web/src/theme.css
Normal 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; }
|
||||
21
agent-team/web/tsconfig.json
Normal file
21
agent-team/web/tsconfig.json
Normal 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"]
|
||||
}
|
||||
30
agent-team/web/vite.config.ts
Normal file
30
agent-team/web/vite.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
Reference in a new issue