From 4e6fd49509a7d9fd008108cc425fd3f3fd1d3afc Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Tue, 21 Jul 2026 14:32:11 +1200 Subject: [PATCH] Add live run graph: animate per-target status without relayout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3, control side. The control can now track a running build: each status update animates in place instead of re-rendering from scratch. - GraphControl splits layout from status. Layout is cached on the graph structure (names + relations + flags); a status-only change reuses the existing positions and just patches node data + edge animation. Edges feeding a running target animate ("flow into active work"). - mountLive(el, graph, { subscribe, onRunTarget }) keeps the graph in sync with a source and returns a dispose fn. A source is any subscribe(push) that pushes a { target: status } patch or a whole graph. Two adapters ship: pollStatus(url) (fits the extension file-watcher / a status JSON) and sseStatus(url) (a live server stream). - `npm run live` builds a self-contained looping demo driven by a scripted run — the stand-in until the BuildManager status producer (framework side) exists. Verified in a browser: statuses stream in, running nodes spin, edges flow, no relayout. Next: the C# BuildManager producer + the extension bridge. Co-Authored-By: Claude Opus 4.8 (1M context) --- poc/graph-control/README.md | 23 +++++++ poc/graph-control/package.json | 1 + poc/graph-control/scripts/live-demo.mjs | 86 ++++++++++++++++++++++++ poc/graph-control/src/GraphControl.tsx | 56 ++++++++++++---- poc/graph-control/src/mount.tsx | 87 +++++++++++++++++++++++++ 5 files changed, 242 insertions(+), 11 deletions(-) create mode 100644 poc/graph-control/scripts/live-demo.mjs diff --git a/poc/graph-control/README.md b/poc/graph-control/README.md index 89ffffe7..5125ea6d 100644 --- a/poc/graph-control/README.md +++ b/poc/graph-control/README.md @@ -18,6 +18,7 @@ npm run dev # dev server, hot reload — opens the demo fixture npm run build # → dist/index.html, a single self-contained file you can double-click npm run build:lib # → dist-lib/fallout-graph-control.js, an IIFE for host embedding npm run report # → dist-lib/report.html, a self-contained static build-graph report +npm run live # → dist-lib/live.html, a self-contained looping live-run demo ``` ## Static HTML report @@ -47,6 +48,28 @@ Re-calling `mount` on the same element reconciles in place — that's how the VS Code extension does its live refresh. The runtime ` + + +
+ + + + +`; + +writeFileSync(resolve(outArg), html); +console.log('[live-demo] wrote ' + resolve(outArg) + ' (' + Math.round(Buffer.byteLength(html) / 1024) + ' KB, self-contained)'); diff --git a/poc/graph-control/src/GraphControl.tsx b/poc/graph-control/src/GraphControl.tsx index 9e9bda9d..bddc0658 100644 --- a/poc/graph-control/src/GraphControl.tsx +++ b/poc/graph-control/src/GraphControl.tsx @@ -23,30 +23,64 @@ export interface GraphControlProps { onRunTarget?: (target: string) => void; } +// Structural signature: names + relations + flags, but NOT status. Layout depends +// only on structure, so a status-only change (the live case) reuses the existing +// positions instead of triggering an async relayout — the key to smooth animation. +function structureSignature(graph: BuildGraph): string { + return graph.targets + .map((t) => `${t.name}|${t.dependsOn}|${t.after}|${t.triggers}|${t.triggeredBy}|${t.default}|${t.listed}`) + .join('\n'); +} + /** * The reusable Fallout graph control. Same component drives the VS Code webview, - * the static --plan HTML report, and (later) the live CI run graph — only the - * data source and the onRunTarget handler change. + * the static --plan HTML report, and the live CI run graph — only the data source + * and the onRunTarget handler change. Layout (positions) is computed from the + * structure; per-target status is patched in on top, so a live run animates + * without re-laying-out. */ export function GraphControl({ graph, onRunTarget }: GraphControlProps) { - const [nodes, setNodes] = useState[]>([]); - const [edges, setEdges] = useState([]); - const [ready, setReady] = useState(false); + // Base layout — positions + edges — recomputed only when the structure changes. + const [base, setBase] = useState<{ nodes: Node[]; edges: Edge[] } | null>(null); + const structureKey = useMemo(() => structureSignature(graph), [graph]); useEffect(() => { let cancelled = false; - setReady(false); void layoutGraph(graph).then((laid) => { - if (cancelled) return; - setNodes(laid.nodes); - setEdges(laid.edges); - setReady(true); + if (!cancelled) setBase(laid); }); return () => { cancelled = true; }; + // graph is read for its structure only; structureKey gates the relayout. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [structureKey]); + + const statusByName = useMemo(() => { + const m = new Map(); + for (const t of graph.targets) m.set(t.name, t.status); + return m; }, [graph]); + // Patch current status onto the laid-out nodes (new object only when it changed, + // so unchanged nodes keep referential identity). + const nodes = useMemo[]>(() => { + if (!base) return []; + return base.nodes.map((n) => { + const status = statusByName.get(n.id); + return status === n.data.status ? n : { ...n, data: { ...n.data, status } }; + }); + }, [base, statusByName]); + + // Animate edges feeding a currently-running target (the "flow" into active work). + const edges = useMemo(() => { + if (!base) return []; + return base.edges.map((e) => { + const animated = statusByName.get(e.target) === 'running'; + return animated === e.animated ? e : { ...e, animated }; + }); + }, [base, statusByName]); + const onNodeClick = useMemo( () => (_event, node) => onRunTarget?.(node.id), [onRunTarget], @@ -63,7 +97,7 @@ export function GraphControl({ graph, onRunTarget }: GraphControlProps) { {graph.targets.length} targets
- {ready && ( + {base && ( | BuildGraph; + +/** Wires a status source. Called once with a `push` callback; returns a teardown. */ +export type Subscribe = (push: (update: StatusUpdate) => void) => (() => void) | void; + +export interface LiveOptions { + onRunTarget?: (target: string) => void; + /** The status source — e.g. `pollStatus(url)` or `sseStatus(url)`, or a custom fn. */ + subscribe: Subscribe; +} + +function isGraph(update: StatusUpdate): update is BuildGraph { + return Array.isArray((update as BuildGraph).targets); +} + +/** + * Renders the graph and keeps it live: each update from `subscribe` patches + * per-target status (or replaces the whole graph) and re-renders. Because layout + * is cached on structure, a status patch only animates the affected nodes/edges. + * Returns a dispose function that tears down the source and unmounts. + */ +export function mountLive(el: HTMLElement, initialGraph: BuildGraph, options: LiveOptions): () => void { + let graph = initialGraph; + const render = () => mount(el, graph, { onRunTarget: options.onRunTarget }); + render(); + + const apply = (update: StatusUpdate) => { + if (isGraph(update)) { + graph = update; + } else { + graph = { + ...graph, + targets: graph.targets.map((t) => + update[t.name] ? { ...t, status: update[t.name] } : t, + ), + }; + } + render(); + }; + + const teardown = options.subscribe(apply); + return () => { + teardown?.(); + unmount(el); + }; +} + +/** Status source that polls a JSON URL (a status map or a full graph) on an interval. */ +export function pollStatus(url: string, intervalMs = 1000): Subscribe { + return (push) => { + let stopped = false; + const tick = async () => { + if (stopped) return; + try { + const res = await fetch(url, { cache: 'no-store' }); + if (res.ok) push(await res.json()); + } catch { + // transient — the next tick retries + } + }; + const id = setInterval(tick, intervalMs); + void tick(); + return () => { + stopped = true; + clearInterval(id); + }; + }; +} + +/** Status source backed by Server-Sent Events; each event's data is a JSON update. */ +export function sseStatus(url: string): Subscribe { + return (push) => { + const source = new EventSource(url); + source.onmessage = (event) => { + try { + push(JSON.parse(event.data)); + } catch { + // ignore malformed frames + } + }; + return () => source.close(); + }; +}