pi-agent-fleet 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,669 @@
1
+ import { StrictMode, useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import type { KeyboardEvent as ReactKeyboardEvent } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import {
5
+ Background,
6
+ BackgroundVariant,
7
+ Controls,
8
+ Handle,
9
+ MarkerType,
10
+ MiniMap,
11
+ Position,
12
+ ReactFlow,
13
+ ReactFlowProvider,
14
+ useEdgesState,
15
+ useNodesState,
16
+ useReactFlow,
17
+ } from "@xyflow/react";
18
+ import type { Edge, Node, NodeProps } from "@xyflow/react";
19
+
20
+ /* ---------- payload types (mirror canvas.ts) ---------- */
21
+ interface CanvasNodeView {
22
+ id: string;
23
+ type: string;
24
+ task: string;
25
+ status: string;
26
+ model: string;
27
+ effort?: string;
28
+ turns: number;
29
+ tokens: number;
30
+ cost_usd_estimate: number;
31
+ status_note?: string;
32
+ produced_outputs: string[];
33
+ outputs: Array<{ path: string; kind: string; required: boolean }>;
34
+ depends_on: string[];
35
+ iterate: boolean;
36
+ worktree: boolean;
37
+ }
38
+ interface CanvasPayload {
39
+ fleet_name: string;
40
+ status: string;
41
+ created_at: string;
42
+ iteration: number;
43
+ lgtm_streak: number;
44
+ paused: boolean;
45
+ cost_usd_estimate: number;
46
+ demo?: boolean;
47
+ empty?: boolean;
48
+ loop?: { gate: string; max_iterations: number; lgtm_count: number };
49
+ config: { max_concurrent: number; model?: string; effort?: string; warn_cost_usd?: number };
50
+ nodes: CanvasNodeView[];
51
+ edges: Array<{ from: string; to: string }>;
52
+ iterations: Array<{ n: number; verdict: string | null; cost: number; tokens: number; duration_ms: number }>;
53
+ generated_at: string;
54
+ }
55
+ interface FleetInfo { name: string; status: string }
56
+ interface SessionEntry { role: string; text: string }
57
+ interface SessionResp { entries: SessionEntry[]; task?: string }
58
+
59
+ /* ---------- helpers ---------- */
60
+ const NODE_W = 284;
61
+ function statusClass(s: string): string {
62
+ return "st-" + s.replace(/\s+/g, "_");
63
+ }
64
+ function cssVar(name: string): string {
65
+ return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || "#8b949e";
66
+ }
67
+ function minimapColor(status: string): string {
68
+ if (status === "completed") return cssVar("--ok");
69
+ if (status === "running") return cssVar("--accent");
70
+ if (status === "failed" || status === "contract_failed") return cssVar("--bad");
71
+ if (status === "killed" || status === "blocked") return cssVar("--wire");
72
+ return cssVar("--line");
73
+ }
74
+ function j<T>(u: string): Promise<T> {
75
+ return fetch(u).then((r) => {
76
+ if (!r.ok) throw new Error(String(r.status));
77
+ return r.json() as Promise<T>;
78
+ });
79
+ }
80
+
81
+ /* topo-layer layout, ported from the legacy canvas */
82
+ function topoLayers(nodes: CanvasNodeView[], edges: Array<{ from: string; to: string }>): string[][] {
83
+ const ids = nodes.map((n) => n.id);
84
+ const indeg: Record<string, number> = {};
85
+ const rev: Record<string, string[]> = {};
86
+ ids.forEach((i) => { indeg[i] = 0; rev[i] = []; });
87
+ edges.forEach((e) => { if (e.from in indeg) { indeg[e.to]++; rev[e.from].push(e.to); } });
88
+ const layers: string[][] = [];
89
+ let cur = ids.filter((i) => indeg[i] === 0);
90
+ const seen: Record<string, boolean> = {};
91
+ while (cur.length) {
92
+ layers.push(cur);
93
+ cur.forEach((i) => { seen[i] = true; });
94
+ const next: string[] = [];
95
+ cur.forEach((i) => rev[i].forEach((m) => { if (--indeg[m] === 0) next.push(m); }));
96
+ cur = next;
97
+ }
98
+ ids.forEach((i) => { if (!seen[i]) { layers.push([i]); seen[i] = true; } });
99
+ return layers;
100
+ }
101
+ function median(arr: number[]): number {
102
+ const s = arr.slice().sort((a, b) => a - b);
103
+ const m = Math.floor(s.length / 2);
104
+ return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
105
+ }
106
+ function reduceCrossings(layers: string[][], edges: Array<{ from: string; to: string }>): string[][] {
107
+ for (let li = 1; li < layers.length; li++) {
108
+ const prevPos: Record<string, number> = {};
109
+ layers[li - 1].forEach((id, i) => { prevPos[id] = i; });
110
+ const key = (id: string) => median(edges.filter((e) => e.to === id).map((e) => prevPos[e.from] ?? 0));
111
+ layers[li].sort((a, b) => key(a) - key(b));
112
+ }
113
+ return layers;
114
+ }
115
+ function computePositions(p: CanvasPayload): Record<string, { x: number; y: number }> {
116
+ const layers = reduceCrossings(topoLayers(p.nodes, p.edges), p.edges);
117
+ const pos: Record<string, { x: number; y: number }> = {};
118
+ layers.forEach((layer, li) => layer.forEach((id, ni) => { pos[id] = { x: li * 360, y: ni * 170 }; }));
119
+ return pos;
120
+ }
121
+
122
+ /* ---------- custom node ---------- */
123
+ type FleetNodeData = {
124
+ view: CanvasNodeView;
125
+ selected: boolean;
126
+ demo: boolean;
127
+ gate: boolean;
128
+ fleet: string | null;
129
+ onOpen: (id: string) => void;
130
+ };
131
+
132
+ function FleetNode({ data }: NodeProps<Node<FleetNodeData>>) {
133
+ const { view: n, selected } = data;
134
+ const running = n.status === "running";
135
+
136
+ const flags: Array<{ label: string; title: string }> = [];
137
+ if (data.gate) flags.push({ label: "⟳ loop gate", title: "Reviewer gate: its verdict decides whether the fleet iterates again" });
138
+ if (n.iterate === false) flags.push({ label: "once", title: "Runs once; not re-run on loop iterations" });
139
+ if (n.worktree) flags.push({ label: "worktree", title: "Runs in an isolated git worktree" });
140
+
141
+ const activate = () => { data.onOpen(n.id); };
142
+
143
+ const isFailed = n.status === "failed" || n.status === "contract_failed";
144
+ const missingRequired = n.outputs.filter((o) => o.required && !n.produced_outputs.includes(o.path)).map((o) => o.path);
145
+ const failReason = isFailed && !n.status_note
146
+ ? (missingRequired.length ? `missing required output: ${missingRequired.join(", ")}` : "worker did not complete — open for details")
147
+ : "";
148
+ const ariaLabel =
149
+ `${n.id}, ${n.type}, ${n.status}, ${n.turns} turns, ${(Number(n.tokens || 0) / 1000).toFixed(1)}k tokens, ` +
150
+ `$${Number(n.cost_usd_estimate || 0).toFixed(2)}${n.status_note ? `, ${n.status_note}` : failReason ? `, ${failReason}` : ""}`;
151
+
152
+ return (
153
+ <div
154
+ className={"node " + statusClass(n.status) + (selected ? " sel" : "")}
155
+ data-node-id={n.id}
156
+ role="button"
157
+ tabIndex={0}
158
+ aria-pressed={selected}
159
+ aria-label={ariaLabel}
160
+ onClick={activate}
161
+ onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); activate(); } }}
162
+ >
163
+ <Handle type="target" position={Position.Left} />
164
+ <Handle type="target" position={Position.Bottom} id="loopIn" />
165
+ <div className="card-body">
166
+ <div className="node-header">
167
+ <span className={"node-dot" + (running ? " pulse" : "")} style={{ background: minimapColor(n.status) }} aria-hidden="true" />
168
+ <span className="id" title={n.id}>{n.id}</span>
169
+ <span className="badge">{n.type}</span>
170
+ </div>
171
+ <div className="status-row">
172
+ {running && <span className="spinner" />}
173
+ <span className="st-word">{n.status}</span>
174
+ {n.effort && <><span>·</span><span>{n.effort}</span></>}
175
+ <span>·</span><span>{n.model}</span>
176
+ </div>
177
+ <div className="stats">
178
+ {(n.turns | 0)} turns · {(Number(n.tokens || 0) / 1000).toFixed(1)}k tok · ${Number(n.cost_usd_estimate || 0).toFixed(2)}
179
+ </div>
180
+ {n.outputs?.length > 0 && (
181
+ <div className="outputs">
182
+ {n.outputs.map((o, i) => (
183
+ <span className="out-chip" key={i}>{o.path} · {o.kind}</span>
184
+ ))}
185
+ </div>
186
+ )}
187
+ {flags.length > 0 && (
188
+ <div className="flags">
189
+ {flags.map((f, i) => (
190
+ <span key={i} title={f.title}>{i > 0 ? " · " : ""}{f.label}</span>
191
+ ))}
192
+ </div>
193
+ )}
194
+ {n.status_note && <div className="note">{n.status_note}</div>}
195
+ {failReason && <div className="fail-reason">{failReason}</div>}
196
+ </div>
197
+ <Handle type="source" position={Position.Right} />
198
+ <Handle type="source" position={Position.Bottom} id="loop" />
199
+ </div>
200
+ );
201
+ }
202
+ const nodeTypes = { fleet: FleetNode };
203
+
204
+ /* ---------- side panel ---------- */
205
+ function SidePanel({ fleet, demo, selected, task, onClose }: { fleet: string | null; demo: boolean; selected: string | null; task: string | null; onClose: () => void }) {
206
+ const [resp, setResp] = useState<SessionResp | null>(null);
207
+ const boxRef = useRef<HTMLDivElement>(null);
208
+ // move focus into the panel on open (silently, no visible ring); restore to the node on close
209
+ useEffect(() => { if (selected) boxRef.current?.focus(); }, [selected]);
210
+ const closeAndRestore = () => {
211
+ const id = selected;
212
+ onClose();
213
+ if (id) (document.querySelector(`.node[data-node-id="${id}"]`) as HTMLElement | null)?.focus();
214
+ };
215
+
216
+ useEffect(() => {
217
+ if (!selected || demo) { setResp(null); return; }
218
+ let alive = true;
219
+ const load = () => {
220
+ const q = fleet ? "&fleet=" + encodeURIComponent(fleet) : "";
221
+ j<SessionResp>("/api/session/" + selected + "?tail=30" + q).then((r) => alive && setResp(r)).catch(() => {});
222
+ };
223
+ load();
224
+ const t = setInterval(load, 2000);
225
+ return () => { alive = false; clearInterval(t); };
226
+ }, [selected, demo, fleet]);
227
+
228
+ // close on Escape while open (and restore focus to the node)
229
+ useEffect(() => {
230
+ if (!selected) return;
231
+ const onKey = (e: KeyboardEvent) => {
232
+ if (e.key !== "Escape") return;
233
+ const id = selected;
234
+ onClose();
235
+ if (id) (document.querySelector(`.node[data-node-id="${id}"]`) as HTMLElement | null)?.focus();
236
+ };
237
+ window.addEventListener("keydown", onKey);
238
+ return () => window.removeEventListener("keydown", onKey);
239
+ }, [selected, onClose]);
240
+
241
+ // follow the latest transcript turn when the operator is already near the bottom
242
+ useEffect(() => {
243
+ const el = boxRef.current;
244
+ if (!el) return;
245
+ const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 120;
246
+ if (nearBottom) el.scrollTop = el.scrollHeight;
247
+ }, [resp]);
248
+
249
+ if (!selected) return null;
250
+ return (
251
+ <div id="side" className="open" ref={boxRef} tabIndex={-1} role="complementary" aria-label={`${selected} session`}>
252
+ <div className="side-head">
253
+ <span className="meta"># {selected} — session</span>
254
+ <button className="icon-btn" onClick={closeAndRestore} aria-label="Close session panel" title="Close (Esc)">×</button>
255
+ </div>
256
+ {(resp?.task || task) && (
257
+ <div className="taskbox-side">
258
+ <div className="taskbox-side-label">task</div>
259
+ {resp?.task || task}
260
+ </div>
261
+ )}
262
+ {demo && <div className="msg">Session transcripts hidden in demo mode.</div>}
263
+ {(resp?.entries ?? []).map((e, i) => (
264
+ <div className="msg" key={i}>
265
+ <div className={"role role-" + e.role}>{e.role}</div>
266
+ {e.text}
267
+ </div>
268
+ ))}
269
+ </div>
270
+ );
271
+ }
272
+
273
+ /* ---------- fleet picker (custom dropdown) ---------- */
274
+ type FpOption = { value: string | null; name: string; status: string };
275
+
276
+ function FleetPicker({ fleets, value, onChange }: { fleets: FleetInfo[]; value: string | null; onChange: (v: string | null) => void }) {
277
+ const [open, setOpen] = useState(false);
278
+ const [q, setQ] = useState("");
279
+ const [active, setActive] = useState(0);
280
+ const ref = useRef<HTMLDivElement>(null);
281
+ const searchRef = useRef<HTMLInputElement>(null);
282
+ const listRef = useRef<HTMLDivElement>(null);
283
+ const triggerRef = useRef<HTMLButtonElement>(null);
284
+
285
+ useEffect(() => {
286
+ if (!open) return;
287
+ const onDoc = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); };
288
+ document.addEventListener("mousedown", onDoc);
289
+ setTimeout(() => searchRef.current?.focus(), 0);
290
+ return () => document.removeEventListener("mousedown", onDoc);
291
+ }, [open]);
292
+
293
+ const filtered = useMemo(
294
+ () => fleets.filter((f) => f.name.toLowerCase().includes(q.toLowerCase())),
295
+ [fleets, q],
296
+ );
297
+ const options: FpOption[] = useMemo(
298
+ () => [{ value: null, name: "live fleet", status: "live" }, ...filtered.map((f) => ({ value: f.name, name: f.name, status: f.status }))],
299
+ [filtered],
300
+ );
301
+ const current = fleets.find((f) => f.name === value);
302
+ const pick = (v: string | null) => { onChange(v); setOpen(false); setQ(""); triggerRef.current?.focus(); };
303
+
304
+ // keep the active option in range and scrolled into view
305
+ useEffect(() => { setActive((a) => Math.min(Math.max(a, 0), Math.max(options.length - 1, 0))); }, [options.length]);
306
+ useEffect(() => {
307
+ if (!open) return;
308
+ listRef.current?.querySelector<HTMLElement>(`[data-i="${active}"]`)?.scrollIntoView({ block: "nearest" });
309
+ }, [active, open]);
310
+
311
+ const onKey = (e: ReactKeyboardEvent) => {
312
+ if (e.key === "ArrowDown") { e.preventDefault(); setActive((a) => Math.min(a + 1, options.length - 1)); }
313
+ else if (e.key === "ArrowUp") { e.preventDefault(); setActive((a) => Math.max(a - 1, 0)); }
314
+ else if (e.key === "Home") { e.preventDefault(); setActive(0); }
315
+ else if (e.key === "End") { e.preventDefault(); setActive(options.length - 1); }
316
+ else if (e.key === "Enter") { e.preventDefault(); if (options[active]) pick(options[active].value); }
317
+ else if (e.key === "Escape") { e.preventDefault(); setOpen(false); triggerRef.current?.focus(); }
318
+ };
319
+
320
+ const listId = "fp-listbox";
321
+ return (
322
+ <div className="fp" ref={ref}>
323
+ <button
324
+ ref={triggerRef}
325
+ className="fp-trigger"
326
+ onClick={() => setOpen((v) => !v)}
327
+ aria-haspopup="listbox"
328
+ aria-controls={listId}
329
+ aria-expanded={open}
330
+ aria-label={`Fleet: ${current ? `${current.name}, ${current.status}` : "live fleet"}. Change fleet`}
331
+ >
332
+ {current ? <span className="dot" style={{ background: minimapColor(current.status) }} aria-hidden="true" /> : <span className="dot live" aria-hidden="true" />}
333
+ <span className="fp-label">{current ? current.name : "live fleet"}</span>
334
+ {current && <span className="fp-trigger-status">{current.status}</span>}
335
+ <span className="fp-caret" aria-hidden="true">▾</span>
336
+ </button>
337
+ {open && (
338
+ <div className="fp-menu">
339
+ <input
340
+ ref={searchRef}
341
+ className="fp-search"
342
+ placeholder="filter fleets…"
343
+ value={q}
344
+ onChange={(e) => { setQ(e.target.value); setActive(0); }}
345
+ onKeyDown={onKey}
346
+ role="combobox"
347
+ aria-expanded="true"
348
+ aria-controls={listId}
349
+ aria-activedescendant={`fp-opt-${active}`}
350
+ aria-autocomplete="list"
351
+ aria-label="Filter fleets"
352
+ />
353
+ <div className="fp-list" id={listId} role="listbox" ref={listRef} aria-label="Fleets">
354
+ {options.map((o, i) => (
355
+ <div
356
+ key={o.value ?? "__live"}
357
+ id={`fp-opt-${i}`}
358
+ data-i={i}
359
+ role="option"
360
+ aria-selected={value === o.value}
361
+ className={"fp-item" + (value === o.value ? " selected" : "") + (i === active ? " active" : "")}
362
+ onMouseEnter={() => setActive(i)}
363
+ onClick={() => pick(o.value)}
364
+ >
365
+ {o.value === null
366
+ ? <span className="dot live" aria-hidden="true" />
367
+ : <span className="dot" style={{ background: minimapColor(o.status) }} aria-hidden="true" />}
368
+ <span className="fp-name" title={o.name}>{o.name}</span>
369
+ {o.value !== null && <span className="fp-status">{o.status}</span>}
370
+ </div>
371
+ ))}
372
+ {options.length === 1 && q && <div className="fp-empty">no match</div>}
373
+ </div>
374
+ </div>
375
+ )}
376
+ </div>
377
+ );
378
+ }
379
+
380
+ /* ---------- theme ---------- */
381
+ function currentTheme(): string {
382
+ return document.documentElement.getAttribute("data-theme") || "dark";
383
+ }
384
+ function applyTheme(t: string) {
385
+ document.documentElement.setAttribute("data-theme", t);
386
+ document.body.className = t;
387
+ try { localStorage.setItem("fleet-canvas-theme", t); } catch { /* ignore */ }
388
+ }
389
+
390
+ /* ---------- flow ---------- */
391
+ function Flow() {
392
+ const qs = new URLSearchParams(location.search);
393
+ const [demo, setDemo] = useState(qs.get("demo") === "1");
394
+ const [fleet, setFleet] = useState<string | null>(qs.get("fleet"));
395
+ const [fleets, setFleets] = useState<FleetInfo[]>([]);
396
+ const [payload, setPayload] = useState<CanvasPayload | null>(null);
397
+ const [selected, setSelected] = useState<string | null>(qs.get("node"));
398
+ const [conn, setConn] = useState<string>("");
399
+ const [legendOpen, setLegendOpen] = useState(false);
400
+ const [nonce, setNonce] = useState(0);
401
+ const [demoFallback, setDemoFallback] = useState(false);
402
+ const { fitView } = useReactFlow();
403
+ const resetView = useCallback(() => fitView({ padding: 0.2, duration: 300 }), [fitView]);
404
+
405
+ // keyboard: F = fit graph to view, R = reset (same), ignoring typing in inputs
406
+ useEffect(() => {
407
+ const onKey = (e: KeyboardEvent) => {
408
+ const t = e.target as HTMLElement;
409
+ if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;
410
+ if (e.key === "f" || e.key === "F" || e.key === "r" || e.key === "R") { e.preventDefault(); resetView(); }
411
+ };
412
+ window.addEventListener("keydown", onKey);
413
+ return () => window.removeEventListener("keydown", onKey);
414
+ }, [resetView]);
415
+
416
+ useEffect(() => {
417
+ j<{ fleets: FleetInfo[] }>("/api/fleets").then((r) => setFleets(r.fleets)).catch(() => {});
418
+ }, []);
419
+
420
+ useEffect(() => {
421
+ let alive = true;
422
+ const loadDemo = () => j<CanvasPayload>("/api/demo")
423
+ .then((d) => { if (!alive) return; setConn(""); setPayload(d); setDemoFallback(!demo); })
424
+ .catch(() => { if (!alive) return; setPayload(null); setDemoFallback(false); });
425
+ const tick = () => {
426
+ if (demo) { loadDemo(); return; }
427
+ j<CanvasPayload>("/api/state" + (fleet ? "?fleet=" + encodeURIComponent(fleet) : ""))
428
+ .then((s) => {
429
+ if (!alive) return;
430
+ setConn("");
431
+ // nothing live and no specific past fleet chosen -> show the baked sample fleet
432
+ if (s.empty && !fleet) { loadDemo(); return; }
433
+ setDemoFallback(false);
434
+ setPayload(s.empty ? null : s);
435
+ })
436
+ .catch(() => { if (!alive) return; setConn(fleet ? "fleet unavailable" : "connection lost"); });
437
+ };
438
+ tick();
439
+ const t = setInterval(tick, 1500);
440
+ return () => { alive = false; clearInterval(t); };
441
+ // eslint-disable-next-line react-hooks/exhaustive-deps
442
+ }, [demo, fleet, nonce]);
443
+
444
+ const onOpen = useCallback((id: string) => setSelected(id), []);
445
+
446
+ const [nodes, setNodes, onNodesChange] = useNodesState<Node<FleetNodeData>>([]);
447
+ const [edges, setEdges] = useEdgesState<Edge>([]);
448
+ const idsKey = payload ? payload.nodes.map((n) => n.id).sort().join(",") : "";
449
+
450
+ // Rebuild topology only when the node set changes; otherwise patch data in place
451
+ // so drag positions and measured sizes (needed by the minimap) survive polling.
452
+ useEffect(() => {
453
+ if (!payload) { setNodes([]); return; }
454
+ const pos = computePositions(payload);
455
+ const gateId = payload.loop
456
+ ? (payload.nodes.find((n) => n.id === payload.loop!.gate) ?? payload.nodes.find((n) => n.type === payload.loop!.gate))?.id
457
+ : undefined;
458
+ setNodes((prev) => {
459
+ const byId: Record<string, Node<FleetNodeData>> = {};
460
+ prev.forEach((n) => { byId[n.id] = n; });
461
+ return payload.nodes.map((v) => {
462
+ const existing = byId[v.id];
463
+ const data: FleetNodeData = { view: v, selected: selected === v.id, demo, gate: v.id === gateId, fleet, onOpen };
464
+ return existing
465
+ ? { ...existing, data }
466
+ : { id: v.id, type: "fleet", position: pos[v.id] ?? { x: 0, y: 0 }, data, width: NODE_W };
467
+ });
468
+ });
469
+ // eslint-disable-next-line react-hooks/exhaustive-deps
470
+ }, [idsKey, payload, selected, demo, fleet, onOpen, setNodes]);
471
+
472
+ useEffect(() => {
473
+ if (!payload) { setEdges([]); return; }
474
+ const forward: Edge[] = payload.edges.map((e, i) => ({
475
+ id: "e" + i,
476
+ source: e.from,
477
+ target: e.to,
478
+ animated: payload.nodes.find((n) => n.id === e.to)?.status === "running",
479
+ }));
480
+
481
+ // feedback loop: the gate node re-triggers the iterate roots each iteration
482
+ const loop = payload.loop;
483
+ const gate = loop
484
+ ? (payload.nodes.find((n) => n.id === loop.gate) ?? payload.nodes.find((n) => n.type === loop.gate))
485
+ : undefined;
486
+ const roots = payload.nodes.filter((n) => n.iterate && n.depends_on.length === 0);
487
+ const looping = payload.status === "running" && !!loop && payload.iteration < loop.max_iterations && payload.lgtm_streak < loop.lgtm_count;
488
+ const loopEdges: Edge[] = gate && loop
489
+ ? roots.map((r, i) => ({
490
+ id: "loop" + i,
491
+ source: gate.id,
492
+ target: r.id,
493
+ sourceHandle: "loop",
494
+ targetHandle: "loopIn",
495
+ type: "smoothstep",
496
+ pathOptions: { borderRadius: 12 },
497
+ animated: looping,
498
+ zIndex: 0,
499
+ label: i === 0 ? `iterate ${payload.iteration}/${loop.max_iterations}` : undefined,
500
+ labelStyle: { fill: "var(--warn)", fontSize: 11, fontWeight: 600 },
501
+ labelBgStyle: { fill: "var(--bg)", fillOpacity: 0.9 },
502
+ labelBgPadding: [4, 2] as [number, number],
503
+ labelBgBorderRadius: 4,
504
+ style: { stroke: "var(--warn)", strokeDasharray: "5 4", strokeWidth: 1.5, opacity: 0.65 },
505
+ markerEnd: { type: MarkerType.ArrowClosed, color: "var(--warn)" } as Edge["markerEnd"],
506
+ }))
507
+ : [];
508
+ setEdges([...loopEdges, ...forward]);
509
+ }, [payload, setEdges]);
510
+
511
+ const done = payload ? payload.nodes.filter((n) => n.status === "completed").length : 0;
512
+ const failed = payload ? payload.nodes.filter((n) => n.status === "failed" || n.status === "contract_failed") : [];
513
+ const running = payload ? payload.nodes.filter((n) => n.status === "running").length : 0;
514
+ const cycleFailed = () => {
515
+ if (!failed.length) return;
516
+ const cur = failed.findIndex((f) => f.id === selected);
517
+ setSelected(failed[(cur + 1) % failed.length].id);
518
+ };
519
+
520
+ return (
521
+ <>
522
+ <header>
523
+ <span className="name">fleet canvas</span>
524
+ <FleetPicker
525
+ fleets={fleets}
526
+ value={fleet}
527
+ onChange={(v) => { setFleet(v); setSelected(null); try { localStorage.setItem("fleet-canvas-fleet", v ?? ""); } catch { /* ignore */ } }}
528
+ />
529
+ <span id="hdr">
530
+ {conn ? (
531
+ <span className="conn">
532
+ <span className="pill pill-bad">{conn === "connection lost" ? "Canvas server unreachable" : "Fleet unavailable"}</span>
533
+ <button className="link-btn" onClick={() => { setConn(""); setNonce((n) => n + 1); }}>Retry</button>
534
+ </span>
535
+ ) : payload ? (
536
+ <>
537
+ <span className="fleet-title">{payload.fleet_name}</span>
538
+ {demoFallback
539
+ ? <span className="pill" title="No fleet is live — showing a sample fleet">sample</span>
540
+ : payload.demo && <span className="pill">demo</span>}
541
+ <span className={"pill status-" + payload.status}>{payload.status}</span>
542
+ {payload.paused && <span className="pill">paused</span>}
543
+ {running > 0 && <span className="stat"><span className="dot dot-run" aria-hidden="true" />{running} running</span>}
544
+ <span className="stat">{done}/{payload.nodes.length} done</span>
545
+ {failed.length > 0 && (
546
+ <button
547
+ className="pill pill-bad pill-btn"
548
+ onClick={cycleFailed}
549
+ title={failed.length > 1 ? "Jump to next failed worker" : "Jump to the failed worker"}
550
+ >⚠ {failed.length} failed</button>
551
+ )}
552
+ <span className="stat" title="Estimated spend so far">${payload.cost_usd_estimate.toFixed(2)}</span>
553
+ {payload.loop && (
554
+ <span className="stat" title={`Reviewer-gated loop: re-runs up to ${payload.loop.max_iterations}× until ${payload.loop.lgtm_count} consecutive LGTM verdicts`}>
555
+ iter {payload.iteration}/{payload.loop.max_iterations} · streak {payload.lgtm_streak}/{payload.loop.lgtm_count}
556
+ </span>
557
+ )}
558
+ </>
559
+ ) : <span className="stat meta">no live fleet</span>}
560
+ </span>
561
+ <span className="spacer" />
562
+ <button onClick={resetView} title="Fit graph to view (F)">reset view</button>
563
+ <button className={legendOpen ? "toggled" : ""} aria-pressed={legendOpen} onClick={() => setLegendOpen((v) => !v)}>legend</button>
564
+ <button aria-pressed={demo} onClick={() => { setDemo((v) => !v); setSelected(null); }}>{demo ? "live" : "demo"}</button>
565
+ <button onClick={() => applyTheme(currentTheme() === "light" ? "dark" : "light")}>theme</button>
566
+ </header>
567
+ <main>
568
+ <div id="stage">
569
+ <ReactFlow
570
+ key={(demo ? "demo" : "live") + ":" + (fleet ?? "") + ":" + (demoFallback ? "s" : "")}
571
+ nodes={nodes}
572
+ edges={edges}
573
+ onNodesChange={onNodesChange}
574
+ nodeTypes={nodeTypes}
575
+ fitView
576
+ fitViewOptions={{ padding: 0.2 }}
577
+ minZoom={0.2}
578
+ maxZoom={2.5}
579
+ proOptions={{ hideAttribution: true }}
580
+ defaultEdgeOptions={{ type: "smoothstep", pathOptions: { borderRadius: 18 } }}
581
+ >
582
+ <Background variant={BackgroundVariant.Dots} gap={22} size={1} />
583
+ <Controls showInteractive={false} />
584
+ <MiniMap pannable zoomable nodeColor={(n) => minimapColor((n.data as FleetNodeData).view.status)} />
585
+ </ReactFlow>
586
+ {legendOpen && <Legend hasLoop={!!payload?.loop} onClose={() => setLegendOpen(false)} />}
587
+ {!payload && !conn && !demo && <EmptyState hasFleets={fleets.length > 0} onDemo={() => setDemo(true)} />}
588
+ </div>
589
+ <SidePanel
590
+ fleet={fleet}
591
+ demo={demo || demoFallback}
592
+ selected={selected}
593
+ task={payload?.nodes.find((n) => n.id === selected)?.task ?? null}
594
+ onClose={() => setSelected(null)}
595
+ />
596
+ </main>
597
+ </>
598
+ );
599
+ }
600
+
601
+ /* ---------- legend ---------- */
602
+ const LEGEND_ROWS: Array<{ status: string; label: string }> = [
603
+ { status: "running", label: "running" },
604
+ { status: "completed", label: "completed" },
605
+ { status: "failed", label: "failed / contract failed" },
606
+ { status: "blocked", label: "blocked / killed" },
607
+ { status: "pending", label: "pending / ready" },
608
+ ];
609
+ function Legend({ hasLoop, onClose }: { hasLoop: boolean; onClose: () => void }) {
610
+ return (
611
+ <div className="legend" role="region" aria-label="Status legend">
612
+ <div className="legend-head">
613
+ <span>status</span>
614
+ <button className="icon-btn sm" onClick={onClose} aria-label="Close legend" title="Close">×</button>
615
+ </div>
616
+ {LEGEND_ROWS.map((r) => (
617
+ <div className="legend-row" key={r.status}>
618
+ <span className={"swatch " + statusClass(r.status)} aria-hidden="true" />
619
+ <span>{r.label}</span>
620
+ </div>
621
+ ))}
622
+ {hasLoop && (
623
+ <div className="legend-row legend-loop">
624
+ <span className="swatch-line" aria-hidden="true" />
625
+ <span>iteration loop (gate → roots)</span>
626
+ </div>
627
+ )}
628
+ </div>
629
+ );
630
+ }
631
+
632
+ /* ---------- empty state ---------- */
633
+ function EmptyState({ hasFleets, onDemo }: { hasFleets: boolean; onDemo: () => void }) {
634
+ return (
635
+ <div className="empty">
636
+ <div className="empty-title">No fleet running</div>
637
+ <p className="empty-body">
638
+ This canvas shows a live DAG of agent workers — status, tokens, cost, and reviewer-gated iteration loops — as a fleet runs.
639
+ </p>
640
+ <ul className="empty-steps">
641
+ <li>Start a fleet from pi with <code>/fleet</code>, then it appears here automatically.</li>
642
+ {hasFleets
643
+ ? <li>Or open a past run from the <strong>fleet selector</strong> at the top left.</li>
644
+ : <li>Past runs will be listed in the <strong>fleet selector</strong> once you have some.</li>}
645
+ </ul>
646
+ <button className="empty-cta" onClick={onDemo}>Explore a demo fleet</button>
647
+ </div>
648
+ );
649
+ }
650
+
651
+ /* ---------- boot ---------- */
652
+ (function initTheme() {
653
+ const qs = new URLSearchParams(location.search);
654
+ let t = qs.get("theme");
655
+ if (t !== "light" && t !== "dark") {
656
+ try { t = localStorage.getItem("fleet-canvas-theme"); } catch { /* ignore */ }
657
+ if (t !== "light" && t !== "dark" && window.matchMedia && matchMedia("(prefers-color-scheme: light)").matches) t = "light";
658
+ }
659
+ applyTheme(t === "light" || t === "dark" ? t : "dark");
660
+ })();
661
+
662
+ const root = createRoot(document.getElementById("root")!);
663
+ root.render(
664
+ <StrictMode>
665
+ <ReactFlowProvider>
666
+ <Flow />
667
+ </ReactFlowProvider>
668
+ </StrictMode>,
669
+ );