diff --git a/prototype/gen-mockup.js b/prototype/gen-mockup.js
new file mode 100644
index 0000000..2d5b8ae
--- /dev/null
+++ b/prototype/gen-mockup.js
@@ -0,0 +1,92 @@
+'use strict';
+// Drives public/workflow-graph.js with a realistic synthetic Claude Code session
+// (orchestrator spawns two parallel subagents, then fans in) and writes a static
+// SVG + an HTML wrapper. No browser / deps needed.
+
+const fs = require('fs');
+const path = require('path');
+const { buildWorkflowGraph, renderWorkflowSVG } = require('../public/workflow-graph.js');
+
+const T0 = Date.parse('2026-06-17T10:00:00Z');
+const s = ms => T0 + ms;
+
+// Helper: an assistant message carrying tool_use blocks (so extractSpawnCalls works).
+const asst = (...blocks) => ({ role: 'assistant', content: blocks });
+const agentCall = (id, description) => ({ type: 'tool_use', id, name: 'Agent', input: { description } });
+
+const entries = [
+ // ── orchestrator (explicit session) ──
+ {
+ id: 'main-1', sessionId: 'sess-main', receivedAt: s(0), elapsed: 2600,
+ agent: 'claude', displayNum: 1, title: 'Refactor auth + add tests',
+ toolCalls: { Read: 1, Grep: 2 },
+ req: { messages: [ asst({ type: 'tool_use', id: 't1', name: 'Read', input: {} }) ] },
+ },
+ {
+ id: 'main-2', sessionId: 'sess-main', receivedAt: s(3200), elapsed: 800,
+ agent: 'claude', displayNum: 2,
+ toolCalls: { Agent: 2 },
+ req: { messages: [ asst(
+ agentCall('a1', 'explore auth module structure'),
+ agentCall('a2', 'survey existing test coverage'),
+ ) ] },
+ },
+ {
+ id: 'main-3', sessionId: 'sess-main', receivedAt: s(8400), elapsed: 4200,
+ agent: 'claude', displayNum: 3,
+ toolCalls: { Edit: 2, Write: 1 },
+ req: { messages: [ asst({ type: 'tool_use', id: 't9', name: 'Edit', input: {} }) ] },
+ },
+ {
+ id: 'main-4', sessionId: 'sess-main', receivedAt: s(13200), elapsed: 5100,
+ agent: 'claude', displayNum: 4,
+ toolCalls: { Bash: 1 },
+ req: { messages: [ asst({ type: 'tool_use', id: 't12', name: 'Bash', input: {} }) ] },
+ },
+
+ // ── subagent A: explore auth (inferred session) ──
+ {
+ id: 'subA-1', sessionId: 'sess-subA', receivedAt: s(4200), elapsed: 1500,
+ agent: 'claude', displayNum: 1, isSubagent: true, sessionInferred: true,
+ title: 'explore auth', toolCalls: { Glob: 1, Read: 2 },
+ req: { messages: [] },
+ },
+ {
+ id: 'subA-2', sessionId: 'sess-subA', receivedAt: s(6000), elapsed: 1400,
+ agent: 'claude', displayNum: 2, isSubagent: true, sessionInferred: true,
+ toolCalls: { Grep: 3 }, req: { messages: [] },
+ },
+
+ // ── subagent B: survey tests (inferred session, runs in parallel with A) ──
+ {
+ id: 'subB-1', sessionId: 'sess-subB', receivedAt: s(4500), elapsed: 2900,
+ agent: 'claude', displayNum: 1, isSubagent: true, sessionInferred: true,
+ title: 'survey tests', toolCalls: { Bash: 1, Read: 1 }, toolFail: true,
+ req: { messages: [] },
+ },
+];
+
+const graph = buildWorkflowGraph(entries);
+
+// ── sanity: did inference recover the intended spawn structure? ──
+const spawns = graph.edges.filter(e => e.type === 'spawn');
+const fanins = graph.edges.filter(e => e.type === 'fanin');
+console.log('lanes :', graph.lanes.map(l => `${l.sessionId}(${l.kind})`).join(', '));
+console.log('spawn edges:', spawns.map(e => `${e.from}→${e.to} [${e.label}]`).join(' '));
+console.log('fanin edges:', fanins.map(e => `${e.from}→${e.to}`).join(' '));
+const ok = spawns.length === 2
+ && spawns.every(e => e.from === 'main-2')
+ && spawns.some(e => e.to === 'subA-1') && spawns.some(e => e.to === 'subB-1')
+ && fanins.length === 2 && fanins.every(e => e.to === 'main-3');
+console.log(ok ? '✓ spawn/fan-in inference reproduced the intended graph' : '✗ inference MISMATCH');
+
+const svg = renderWorkflowSVG(graph, { pxPerSec: 40 });
+const outDir = __dirname;
+fs.writeFileSync(path.join(outDir, 'workflow-swimlane.svg'), svg);
+fs.writeFileSync(path.join(outDir, 'workflow-swimlane.html'),
+`
ccxray · Workflow Swimlane prototype
+
+${svg}
+`);
+console.log('wrote', path.join(outDir, 'workflow-swimlane.svg'));
+if (!ok) process.exit(1);
diff --git a/prototype/workflow-swimlane.html b/prototype/workflow-swimlane.html
new file mode 100644
index 0000000..5a6bf33
--- /dev/null
+++ b/prototype/workflow-swimlane.html
@@ -0,0 +1,4 @@
+ccxray · Workflow Swimlane prototype
+
+
+
\ No newline at end of file
diff --git a/prototype/workflow-swimlane.svg b/prototype/workflow-swimlane.svg
new file mode 100644
index 0000000..c9aa2ac
--- /dev/null
+++ b/prototype/workflow-swimlane.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/public/workflow-graph.js b/public/workflow-graph.js
new file mode 100644
index 0000000..1814381
--- /dev/null
+++ b/public/workflow-graph.js
@@ -0,0 +1,286 @@
+// ── Workflow Graph (prototype) ───────────────────────────────────────────────
+// Turns a flat list of turn-entries into a swimlane workflow graph that exposes
+// the structure a linear timeline flattens away: subagent spawn (fan-out),
+// parallel lanes, and fan-in back to the orchestrator.
+//
+// Isomorphic: runs in the browser (attaches to window) and in Node (module.exports)
+// so it can be unit-driven / rendered to a static SVG without a headless browser.
+//
+// INPUT — entries: loaded turn objects shaped like the dashboard's allEntries[]:
+// { id, sessionId, receivedAt, elapsed, agent, title, displayNum,
+// isSubagent, sessionInferred, toolCalls:{name:count}, req:{messages:[...]} }
+// OUTPUT — { lanes, nodes, edges, t0, t1 }
+//
+// Edge inference here mirrors what server/store.js already knows (inferParentSession:
+// inflight + 30s window). The real integration would read an explicit
+// entry.spawnedBy / entry.parentEntryId the server can stamp; the time-window
+// inference below is the zero-backend-change fallback.
+
+(function (root, factory) {
+ const api = factory();
+ if (typeof module === 'object' && module.exports) module.exports = api;
+ if (root) Object.assign(root, api);
+})(typeof window !== 'undefined' ? window : null, function () {
+ 'use strict';
+
+ const SPAWN_WINDOW_MS = 60000; // a subagent's first turn must start within 60s of the spawning Agent call
+
+ function agentToolCount(entry) {
+ const tc = entry.toolCalls || {};
+ return (tc.Agent || 0) + (tc.Task || 0) + (tc.TaskCreate || 0);
+ }
+
+ // Pull the Agent/Task tool_use blocks (with their descriptions) out of a turn's
+ // own output. We look at assistant messages and keep spawn calls in order.
+ function extractSpawnCalls(entry) {
+ const out = [];
+ const msgs = (entry.req && entry.req.messages) || [];
+ for (const m of msgs) {
+ if (m.role !== 'assistant' || !Array.isArray(m.content)) continue;
+ for (const b of m.content) {
+ if (b.type === 'tool_use' && (b.name === 'Agent' || b.name === 'Task' || b.name === 'TaskCreate')) {
+ const inp = b.input || {};
+ out.push({ id: b.id, name: b.name, label: (inp.description || inp.subject || inp.prompt || '').slice(0, 48) });
+ }
+ }
+ }
+ // Fallback: we know the count but not the text (summary-only entries).
+ if (!out.length && agentToolCount(entry)) {
+ for (let i = 0; i < agentToolCount(entry); i++) out.push({ id: null, name: 'Agent', label: '' });
+ }
+ return out;
+ }
+
+ // Per-turn tool chips for display (name + count), skipping spawn tools which
+ // are drawn as edges instead.
+ function turnToolChips(entry) {
+ const tc = entry.toolCalls || {};
+ const chips = [];
+ for (const k of Object.keys(tc)) {
+ if (k === 'Agent' || k === 'Task' || k === 'TaskCreate') continue;
+ chips.push(tc[k] > 1 ? k + '×' + tc[k] : k);
+ }
+ return chips;
+ }
+
+ function buildWorkflowGraph(entries, opts) {
+ opts = opts || {};
+ const list = (entries || []).filter(Boolean).slice().sort((a, b) => (a.receivedAt || 0) - (b.receivedAt || 0));
+ if (!list.length) return { lanes: [], nodes: [], edges: [], t0: 0, t1: 0 };
+
+ // 1. Partition into lanes by session. Main (explicit) session leads; each
+ // inferred/subagent session is its own lane, ordered by first activity.
+ const bySession = new Map();
+ for (const e of list) {
+ const sid = e.sessionId || 'unknown';
+ if (!bySession.has(sid)) bySession.set(sid, []);
+ bySession.get(sid).push(e);
+ }
+
+ const laneMeta = [];
+ for (const [sid, turns] of bySession) {
+ const isSub = turns.some(t => t.isSubagent || t.sessionInferred);
+ laneMeta.push({
+ sessionId: sid,
+ kind: isSub ? 'subagent' : 'main',
+ turns,
+ t0: turns[0].receivedAt || 0,
+ t1: turns[turns.length - 1].receivedAt || 0,
+ label: turns.find(t => t.title)?.title || (isSub ? 'subagent' : 'main') ,
+ });
+ }
+ // main lanes first, then subagents by first-activity time
+ laneMeta.sort((a, b) => {
+ if (a.kind !== b.kind) return a.kind === 'main' ? -1 : 1;
+ return a.t0 - b.t0;
+ });
+ laneMeta.forEach((l, i) => { l.idx = i; });
+ const laneBySession = new Map(laneMeta.map(l => [l.sessionId, l]));
+
+ // 2. Nodes — one per turn, on its lane.
+ const nodes = [];
+ for (const lane of laneMeta) {
+ lane.turns.forEach((e, ti) => {
+ nodes.push({
+ id: e.id,
+ laneIdx: lane.idx,
+ sessionId: lane.sessionId,
+ t: e.receivedAt || 0,
+ dur: e.elapsed || 0,
+ turnIndexInLane: ti,
+ displayNum: e.displayNum || (ti + 1),
+ agent: e.agent || 'claude',
+ title: e.title || '',
+ chips: turnToolChips(e),
+ spawns: extractSpawnCalls(e),
+ fail: !!e.toolFail,
+ kind: lane.kind,
+ });
+ });
+ }
+ const nodeById = new Map(nodes.map(n => [n.id, n]));
+
+ // 3. Sequence edges — consecutive turns within a lane.
+ const edges = [];
+ for (const lane of laneMeta) {
+ for (let i = 1; i < lane.turns.length; i++) {
+ edges.push({ type: 'seq', from: lane.turns[i - 1].id, to: lane.turns[i].id });
+ }
+ }
+
+ // 4. Spawn edges (fan-out) — match each subagent lane to the spawning main turn.
+ // A subagent lane is spawned by the latest main turn that (a) issued an
+ // Agent/Task call and (b) started no more than SPAWN_WINDOW_MS before the
+ // subagent's first turn. Each spawn-call slot is consumed once.
+ const mainSpawnSlots = [];
+ for (const lane of laneMeta) {
+ if (lane.kind !== 'main') continue;
+ for (const e of lane.turns) {
+ for (const s of extractSpawnCalls(e)) {
+ mainSpawnSlots.push({ turnId: e.id, t: e.receivedAt || 0, label: s.label, used: false });
+ }
+ }
+ }
+ mainSpawnSlots.sort((a, b) => a.t - b.t);
+
+ const subLanes = laneMeta.filter(l => l.kind === 'subagent').sort((a, b) => a.t0 - b.t0);
+ for (const sub of subLanes) {
+ const firstTurnId = sub.turns[0].id;
+ let best = null;
+ for (const slot of mainSpawnSlots) {
+ if (slot.used) continue;
+ if (slot.t > sub.t0) continue;
+ if (sub.t0 - slot.t > SPAWN_WINDOW_MS) continue;
+ if (!best || slot.t > best.t) best = slot;
+ }
+ if (best) {
+ best.used = true;
+ sub.spawnedBy = best.turnId;
+ edges.push({ type: 'spawn', from: best.turnId, to: firstTurnId, label: best.label || sub.label });
+ }
+ }
+
+ // 5. Fan-in edges — a subagent lane's last turn returns into the next main
+ // turn that starts after it (the turn that consumes the tool_result).
+ for (const sub of subLanes) {
+ const lastTurn = sub.turns[sub.turns.length - 1];
+ const lastEnd = (lastTurn.receivedAt || 0) + (lastTurn.elapsed || 0);
+ let target = null;
+ for (const lane of laneMeta) {
+ if (lane.kind !== 'main') continue;
+ for (const e of lane.turns) {
+ if ((e.receivedAt || 0) >= lastEnd - 1000) { // small slack
+ if (!target || (e.receivedAt || 0) < target.t) target = { id: e.id, t: e.receivedAt || 0 };
+ break;
+ }
+ }
+ }
+ if (target) edges.push({ type: 'fanin', from: lastTurn.id, to: target.id });
+ }
+
+ const t0 = Math.min(...nodes.map(n => n.t));
+ const t1 = Math.max(...nodes.map(n => n.t + n.dur));
+ return { lanes: laneMeta, nodes, edges, nodeById, laneBySession, t0, t1 };
+ }
+
+ // ── SVG renderer ───────────────────────────────────────────────────────────
+ const C = {
+ bg: '#0d1117', surface: '#161b22', border: '#30363d', text: '#e6edf3', dim: '#8b949e',
+ accent: '#58a6ff', green: '#3fb950', red: '#f85149', yellow: '#d29922',
+ spawn: '#ff8a65', fanin: '#4dd0e1',
+ };
+
+ function esc(s) { return String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); }
+
+ function renderWorkflowSVG(graph, opts) {
+ opts = opts || {};
+ const padL = 150; // lane-label gutter
+ const padR = 40, padT = 56, padB = 30;
+ const laneH = 92;
+ const minBoxW = 96, maxBoxW = 240;
+ const pxPerSec = opts.pxPerSec || 36;
+
+ const span = Math.max(1, (graph.t1 - graph.t0) / 1000);
+ const plotW = Math.max(620, span * pxPerSec);
+ const W = padL + plotW + padR;
+ const H = padT + graph.lanes.length * laneH + padB;
+ const xOf = t => padL + ((t - graph.t0) / 1000) * pxPerSec;
+ const laneY = i => padT + i * laneH + laneH / 2;
+ const boxH = 46;
+
+ let s = '';
+ s += ``;
+ return s;
+ }
+
+ return { buildWorkflowGraph, renderWorkflowSVG, _SPAWN_WINDOW_MS: SPAWN_WINDOW_MS };
+});