import { StrictMode, useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { KeyboardEvent as ReactKeyboardEvent } from "react"; import { createRoot } from "react-dom/client"; import { Background, BackgroundVariant, Controls, Handle, MarkerType, MiniMap, Position, ReactFlow, ReactFlowProvider, useEdgesState, useNodesState, useReactFlow, } from "@xyflow/react"; import type { Edge, Node, NodeProps } from "@xyflow/react"; import { computePositions, excerptText, NODE_W } from "./canvas-layout.js"; /* ---------- payload types (mirror canvas.ts) ---------- */ interface CanvasNodeView { id: string; type: string; task: string; status: string; model: string; effort?: string; turns: number; tokens: number; cost_usd_estimate: number; status_note?: string; produced_outputs: string[]; outputs: Array<{ path: string; kind: string; required: boolean }>; depends_on: string[]; iterate: boolean; worktree: boolean; } interface CanvasPayload { fleet_name: string; status: string; created_at: string; iteration: number; lgtm_streak: number; paused: boolean; cost_usd_estimate: number; demo?: boolean; empty?: boolean; loop?: { gate: string; max_iterations: number; lgtm_count: number }; config: { max_concurrent: number; model?: string; effort?: string; warn_cost_usd?: number }; nodes: CanvasNodeView[]; edges: Array<{ from: string; to: string }>; iterations: Array<{ n: number; verdict: string | null; cost: number; tokens: number; duration_ms: number }>; generated_at: string; } interface FleetInfo { name: string; status: string } interface SessionEntry { role: string; text: string } interface ActionView { type: "tool_call" | "tool_result" | "model_change" | "thinking_level_change" | "complete"; name?: string; toolName?: string; arguments?: Record; provider?: string; modelId?: string; thinkingLevel?: string; stopReason?: string; isError?: boolean; timestamp?: string; } type TimelineEvent = | { type: "message"; role: string; text: string; timestamp?: string } | { type: "tool_call"; name: string; arguments?: Record; timestamp?: string } | { type: "tool_result"; toolName?: string; isError?: boolean; text?: string; timestamp?: string } | { type: "model_change"; provider: string; modelId: string; timestamp?: string } | { type: "thinking_level_change"; thinkingLevel: string; timestamp?: string } | { type: "complete"; stopReason: string; timestamp?: string }; interface SessionResp { entries: SessionEntry[]; actions: ActionView[]; events: TimelineEvent[]; task?: string } /* ---------- helpers ---------- */ function statusClass(s: string): string { return "st-" + s.replace(/\s+/g, "_"); } function cssVar(name: string): string { return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || "#8b949e"; } function minimapColor(status: string): string { if (status === "completed") return cssVar("--ok"); if (status === "running") return cssVar("--accent"); if (status === "failed" || status === "contract_failed") return cssVar("--bad"); if (status === "killed" || status === "blocked") return cssVar("--wire"); return cssVar("--line"); } function shortModel(model: string): string { return model.split("/").pop()?.replace(/^deepseek-/, "") || model; } function j(u: string): Promise { return fetch(u).then((r) => { if (!r.ok) throw new Error(String(r.status)); return r.json() as Promise; }); } /* ---------- custom node ---------- */ type FleetNodeData = { view: CanvasNodeView; selected: boolean; demo: boolean; gate: boolean; nodeRole: "start" | "end" | "gate" | "normal"; fleet: string | null; onOpen: (id: string) => void; }; function FleetNode({ data }: NodeProps>) { const { view: n, selected } = data; const running = n.status === "running"; const roleLabel = data.nodeRole === "start" ? "START" : data.nodeRole === "end" ? "END" : data.nodeRole === "gate" ? "GATE" : null; const flags: Array<{ label: string; title: string }> = []; if (data.gate) flags.push({ label: "⟳ loop gate", title: "Reviewer gate: its verdict decides whether the fleet iterates again" }); if (n.iterate === false) flags.push({ label: "once", title: "Runs once; not re-run on loop iterations" }); if (n.worktree) flags.push({ label: "worktree", title: "Runs in an isolated git worktree" }); const activate = () => { data.onOpen(n.id); }; const isFailed = n.status === "failed" || n.status === "contract_failed"; const missingRequired = n.outputs.filter((o) => o.required && !n.produced_outputs.includes(o.path)).map((o) => o.path); const failReason = isFailed && !n.status_note ? (missingRequired.length ? `missing required output: ${missingRequired.join(", ")}` : "worker did not complete — open for details") : ""; const ariaLabel = `${n.id}, ${n.type}, ${n.status}, ${n.turns} turns, ${(Number(n.tokens || 0) / 1000).toFixed(1)}k tokens, ` + `$${Number(n.cost_usd_estimate || 0).toFixed(2)}${n.status_note ? `, ${n.status_note}` : failReason ? `, ${failReason}` : ""}`; return (
{ if (e.key === "Enter" || e.key === " ") { e.preventDefault(); activate(); } }} >
{running && } {n.status} {n.effort && <>·{n.effort}} ·{shortModel(n.model)}
{(n.turns | 0)} turns · {(Number(n.tokens || 0) / 1000).toFixed(1)}k tok · ${Number(n.cost_usd_estimate || 0).toFixed(2)}
{n.outputs?.length > 0 && (
`${o.path} · ${o.kind}`).join("\n")}>{n.outputs.length} output{n.outputs.length === 1 ? "" : "s"} {n.outputs[0].kind}
)} {flags.length > 0 && (
{flags.map((f, i) => ( {i > 0 ? " · " : ""}{f.label} ))}
)} {n.status_note &&
{n.status_note}
} {failReason &&
{failReason}
}
); } const nodeTypes = { fleet: FleetNode }; /* ---------- side panel ---------- */ function CollapsiblePrompt({ title, text }: { title: string; text: string }) { const [open, setOpen] = useState(false); if (!text) return null; return (
{text}
); } function formatActionDetail(a: { arguments?: Record }): string { const args = a.arguments || {}; if (typeof args.path === "string") return args.path; if (typeof args.command === "string") return args.command; if (Array.isArray(args.queries)) return String(args.queries[0]); const keys = Object.keys(args); if (keys[0]) return `${keys[0]}: ${JSON.stringify(args[keys[0]]).slice(0, 40)}`; return ""; } function TimelineItem({ event }: { event: TimelineEvent }) { const [open, setOpen] = useState(event.isError ? true : false); const [expanded, setExpanded] = useState(false); const ts = event.timestamp ? new Date(event.timestamp).toLocaleTimeString([], { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" }) : ""; if (event.type === "message") { const { excerpt, truncated } = excerptText(event.text, 240); const text = truncated && !expanded ? excerpt : event.text; return (
{event.role} {ts && {ts}}
{text}
{truncated && ( )}
); } if (event.type === "tool_call") { const detail = formatActionDetail(event); const hasArgs = !!event.arguments && Object.keys(event.arguments).length > 0; return (
call {event.name} {detail && {detail}} {ts && {ts}}
{open && hasArgs && (
{JSON.stringify(event.arguments, null, 2)}
)}
); } if (event.type === "tool_result") { const hasText = !!event.text && event.text.length > 0; const expanded = open; return (
{event.isError ? "err" : "ok"} {event.toolName || "result"} {event.text && {event.isError ? "Error: " : ""}{event.text}} {ts && {ts}}
{expanded && hasText && (
{event.isError ? Error: : null} {event.text}
)}
); } if (event.type === "model_change") { return (
mdl model {event.provider}/{event.modelId} {ts && {ts}}
); } if (event.type === "thinking_level_change") { return (
think thinking {event.thinkingLevel} {ts && {ts}}
); } if (event.type === "complete") { return (
done done {event.stopReason !== "complete" && event.stopReason !== "stop" && {event.stopReason}} {ts && {ts}}
); } return null; } function Timeline({ events }: { events: TimelineEvent[] }) { const visible = events.filter((e) => e.type !== "model_change" && e.type !== "thinking_level_change"); if (!visible.length) return
No session data yet.
; return (
{visible.map((e, i) => )}
); } function SidePanel({ fleet, demo, selected, task, onClose }: { fleet: string | null; demo: boolean; selected: string | null; task: string | null; onClose: () => void }) { const [resp, setResp] = useState(null); const boxRef = useRef(null); // move focus into the panel on open (silently, no visible ring); restore to the node on close useEffect(() => { if (selected) boxRef.current?.focus(); }, [selected]); const closeAndRestore = () => { const id = selected; onClose(); if (id) (document.querySelector(`.node[data-node-id="${id}"]`) as HTMLElement | null)?.focus(); }; useEffect(() => { if (!selected) { setResp(null); return; } let alive = true; const load = () => { const q = fleet ? "&fleet=" + encodeURIComponent(fleet) : ""; const demoQ = demo ? "&demo=1" : ""; j("/api/session/" + selected + "?tail=30" + q + demoQ).then((r) => alive && setResp(r)).catch(() => {}); }; load(); const t = setInterval(load, 2000); return () => { alive = false; clearInterval(t); }; }, [selected, demo, fleet]); // close on Escape while open (and restore focus to the node) useEffect(() => { if (!selected) return; const onKey = (e: KeyboardEvent) => { if (e.key !== "Escape") return; const id = selected; onClose(); if (id) (document.querySelector(`.node[data-node-id="${id}"]`) as HTMLElement | null)?.focus(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [selected, onClose]); // follow the latest transcript turn when the operator is already near the bottom useEffect(() => { const el = boxRef.current; if (!el) return; const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 120; if (nearBottom) el.scrollTop = el.scrollHeight; }, [resp]); const latestModel = resp?.events?.slice().reverse().find((e): e is TimelineEvent & { type: "model_change" } => e.type === "model_change"); const latestThinking = resp?.events?.slice().reverse().find((e): e is TimelineEvent & { type: "thinking_level_change" } => e.type === "thinking_level_change"); if (!selected) return null; return ( ); } /* ---------- fleet picker (custom dropdown) ---------- */ type FpOption = { value: string | null; name: string; status: string }; function FleetPicker({ fleets, value, onChange }: { fleets: FleetInfo[]; value: string | null; onChange: (v: string | null) => void }) { const [open, setOpen] = useState(false); const [q, setQ] = useState(""); const [active, setActive] = useState(0); const ref = useRef(null); const searchRef = useRef(null); const listRef = useRef(null); const triggerRef = useRef(null); useEffect(() => { if (!open) return; const onDoc = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); }; document.addEventListener("mousedown", onDoc); setTimeout(() => searchRef.current?.focus(), 0); return () => document.removeEventListener("mousedown", onDoc); }, [open]); const filtered = useMemo( () => fleets.filter((f) => f.name.toLowerCase().includes(q.toLowerCase())), [fleets, q], ); const options: FpOption[] = useMemo( () => [{ value: null, name: "live fleet", status: "live" }, ...filtered.map((f) => ({ value: f.name, name: f.name, status: f.status }))], [filtered], ); const current = fleets.find((f) => f.name === value); const pick = (v: string | null) => { onChange(v); setOpen(false); setQ(""); triggerRef.current?.focus(); }; // keep the active option in range and scrolled into view useEffect(() => { setActive((a) => Math.min(Math.max(a, 0), Math.max(options.length - 1, 0))); }, [options.length]); useEffect(() => { if (!open) return; listRef.current?.querySelector(`[data-i="${active}"]`)?.scrollIntoView({ block: "nearest" }); }, [active, open]); const onKey = (e: ReactKeyboardEvent) => { if (e.key === "ArrowDown") { e.preventDefault(); setActive((a) => Math.min(a + 1, options.length - 1)); } else if (e.key === "ArrowUp") { e.preventDefault(); setActive((a) => Math.max(a - 1, 0)); } else if (e.key === "Home") { e.preventDefault(); setActive(0); } else if (e.key === "End") { e.preventDefault(); setActive(options.length - 1); } else if (e.key === "Enter") { e.preventDefault(); if (options[active]) pick(options[active].value); } else if (e.key === "Escape") { e.preventDefault(); setOpen(false); triggerRef.current?.focus(); } }; const listId = "fp-listbox"; return (
{open && (
{ setQ(e.target.value); setActive(0); }} onKeyDown={onKey} role="combobox" aria-expanded="true" aria-controls={listId} aria-activedescendant={`fp-opt-${active}`} aria-autocomplete="list" aria-label="Filter fleets" />
{options.map((o, i) => (
setActive(i)} onClick={() => pick(o.value)} > {o.value === null ?
))} {options.length === 1 && q &&
no match
}
)}
); } /* ---------- theme ---------- */ function currentTheme(): string { return document.documentElement.getAttribute("data-theme") || "dark"; } function applyTheme(t: string) { document.documentElement.setAttribute("data-theme", t); document.body.className = t; try { localStorage.setItem("fleet-canvas-theme", t); } catch { /* ignore */ } } /* ---------- flow ---------- */ function Flow() { const qs = new URLSearchParams(location.search); const [demo, setDemo] = useState(qs.get("demo") === "1"); const [fleet, setFleet] = useState(qs.get("fleet")); const [fleets, setFleets] = useState([]); const [payload, setPayload] = useState(null); const [selected, setSelected] = useState(qs.get("node")); const [conn, setConn] = useState(""); const [legendOpen, setLegendOpen] = useState(false); const [nonce, setNonce] = useState(0); const [demoFallback, setDemoFallback] = useState(false); const { fitView } = useReactFlow(); const resetView = useCallback(() => fitView({ padding: 0.2, duration: 300 }), [fitView]); // keyboard: F = fit graph to view, R = reset (same), ignoring typing in inputs useEffect(() => { const onKey = (e: KeyboardEvent) => { const t = e.target as HTMLElement; if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return; if (e.key === "f" || e.key === "F" || e.key === "r" || e.key === "R") { e.preventDefault(); resetView(); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [resetView]); useEffect(() => { j<{ fleets: FleetInfo[] }>("/api/fleets").then((r) => setFleets(r.fleets)).catch(() => {}); }, []); useEffect(() => { let alive = true; const loadDemo = () => j("/api/demo") .then((d) => { if (!alive) return; setConn(""); setPayload(d); setDemoFallback(!demo); }) .catch(() => { if (!alive) return; setPayload(null); setDemoFallback(false); }); const tick = () => { if (demo) { loadDemo(); return; } j("/api/state" + (fleet ? "?fleet=" + encodeURIComponent(fleet) : "")) .then((s) => { if (!alive) return; setConn(""); // nothing live and no specific past fleet chosen -> show the baked sample fleet if (s.empty && !fleet) { loadDemo(); return; } setDemoFallback(false); setPayload(s.empty ? null : s); }) .catch(() => { if (!alive) return; setConn(fleet ? "fleet unavailable" : "connection lost"); }); }; tick(); const t = setInterval(tick, 1500); return () => { alive = false; clearInterval(t); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [demo, fleet, nonce]); const onOpen = useCallback((id: string) => setSelected(id), []); const [nodes, setNodes, onNodesChange] = useNodesState>([]); const [edges, setEdges] = useEdgesState([]); const idsKey = payload ? payload.nodes.map((n) => n.id).sort().join(",") : ""; // Rebuild topology only when the node set changes; otherwise patch data in place // so drag positions and measured sizes (needed by the minimap) survive polling. useEffect(() => { if (!payload) { setNodes([]); return; } const pos = computePositions(payload.nodes, payload.edges); const gateId = payload.loop ? (payload.nodes.find((n) => n.id === payload.loop!.gate) ?? payload.nodes.find((n) => n.type === payload.loop!.gate))?.id : undefined; const hasOutgoing = new Set(payload.edges.map((e) => e.from)); setNodes((prev) => { const byId: Record> = {}; prev.forEach((n) => { byId[n.id] = n; }); return payload.nodes.map((v) => { const existing = byId[v.id]; const nodeRole: FleetNodeData["nodeRole"] = v.id === gateId ? "gate" : v.depends_on.length === 0 ? "start" : !hasOutgoing.has(v.id) ? "end" : "normal"; const data: FleetNodeData = { view: v, selected: selected === v.id, demo, gate: v.id === gateId, nodeRole, fleet, onOpen }; return existing ? { ...existing, data } : { id: v.id, type: "fleet", position: pos[v.id] ?? { x: 0, y: 0 }, data, width: NODE_W }; }); }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [idsKey, payload, selected, demo, fleet, onOpen, setNodes]); useEffect(() => { if (!payload) { setEdges([]); return; } const forward: Edge[] = payload.edges.map((e, i) => ({ id: "e" + i, source: e.from, target: e.to, type: "smoothstep", pathOptions: { borderRadius: 18 }, animated: payload.nodes.find((n) => n.id === e.to)?.status === "running", style: { strokeWidth: 1.6 }, markerEnd: { type: MarkerType.ArrowClosed, color: "var(--edge)" } as Edge["markerEnd"], })); // feedback loop: the gate node re-triggers the iterate roots each iteration const loop = payload.loop; const gate = loop ? (payload.nodes.find((n) => n.id === loop.gate) ?? payload.nodes.find((n) => n.type === loop.gate)) : undefined; const roots = payload.nodes.filter((n) => n.iterate && n.depends_on.length === 0); const looping = payload.status === "running" && !!loop && payload.iteration < loop.max_iterations && payload.lgtm_streak < loop.lgtm_count; const loopEdges: Edge[] = gate && loop ? roots.map((r, i) => ({ id: "loop" + i, source: gate.id, target: r.id, sourceHandle: "loop", targetHandle: "loopIn", type: "smoothstep", pathOptions: { borderRadius: 12 }, animated: looping, zIndex: 0, label: i === 0 ? `iterate ${payload.iteration}/${loop.max_iterations}` : undefined, labelStyle: { fill: "var(--warn)", fontSize: 11, fontWeight: 600 }, labelBgStyle: { fill: "var(--bg)", fillOpacity: 0.9 }, labelBgPadding: [4, 2] as [number, number], labelBgBorderRadius: 4, style: { stroke: "var(--warn)", strokeDasharray: "5 4", strokeWidth: 1.5, opacity: 0.65 }, markerEnd: { type: MarkerType.ArrowClosed, color: "var(--warn)" } as Edge["markerEnd"], })) : []; setEdges([...loopEdges, ...forward]); }, [payload, setEdges]); const done = payload ? payload.nodes.filter((n) => n.status === "completed").length : 0; const failed = payload ? payload.nodes.filter((n) => n.status === "failed" || n.status === "contract_failed") : []; const running = payload ? payload.nodes.filter((n) => n.status === "running").length : 0; const cycleFailed = () => { if (!failed.length) return; const cur = failed.findIndex((f) => f.id === selected); setSelected(failed[(cur + 1) % failed.length].id); }; return ( <>
fleet canvas { setFleet(v); setSelected(null); try { localStorage.setItem("fleet-canvas-fleet", v ?? ""); } catch { /* ignore */ } }} /> {conn ? ( {conn === "connection lost" ? "Canvas server unreachable" : "Fleet unavailable"} ) : payload ? ( <> {payload.fleet_name} {demoFallback ? sample : payload.demo && demo} {payload.status} {payload.paused && paused} {running > 0 && } {done}/{payload.nodes.length} done {failed.length > 0 && ( )} ${payload.cost_usd_estimate.toFixed(2)} {payload.loop && ( iter {payload.iteration}/{payload.loop.max_iterations} · streak {payload.lgtm_streak}/{payload.loop.lgtm_count} )} ) : no live fleet}
minimapColor((n.data as FleetNodeData).view.status)} /> {legendOpen && setLegendOpen(false)} />} {!payload && !conn && !demo && 0} onDemo={() => setDemo(true)} />}
n.id === selected)?.task ?? null} onClose={() => setSelected(null)} />
); } /* ---------- legend ---------- */ const LEGEND_ROWS: Array<{ status: string; label: string }> = [ { status: "running", label: "running" }, { status: "completed", label: "completed" }, { status: "failed", label: "failed / contract failed" }, { status: "blocked", label: "blocked / killed" }, { status: "pending", label: "pending / ready" }, ]; function Legend({ hasLoop, onClose }: { hasLoop: boolean; onClose: () => void }) { return (
status
{LEGEND_ROWS.map((r) => (
))} {hasLoop && (
)}
); } /* ---------- empty state ---------- */ function EmptyState({ hasFleets, onDemo }: { hasFleets: boolean; onDemo: () => void }) { return (
No fleet running

This canvas shows a live DAG of agent workers — status, tokens, cost, and reviewer-gated iteration loops — as a fleet runs.

  • Start a fleet from pi with /fleet, then it appears here automatically.
  • {hasFleets ?
  • Or open a past run from the fleet selector at the top left.
  • :
  • Past runs will be listed in the fleet selector once you have some.
  • }
); } /* ---------- boot ---------- */ (function initTheme() { const qs = new URLSearchParams(location.search); let t = qs.get("theme"); if (t !== "light" && t !== "dark") { try { t = localStorage.getItem("fleet-canvas-theme"); } catch { /* ignore */ } if (t !== "light" && t !== "dark" && window.matchMedia && matchMedia("(prefers-color-scheme: light)").matches) t = "light"; } applyTheme(t === "light" || t === "dark" ? t : "dark"); })(); const root = createRoot(document.getElementById("root")!); root.render( , );