crewforth 3.0.3 → 3.1.0-rc.1

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.
Files changed (67) hide show
  1. package/CHANGELOG.md +211 -0
  2. package/README.md +3 -3
  3. package/VERSION +1 -1
  4. package/adopt.sh +86 -34
  5. package/kit/CLAUDE.md +1 -1
  6. package/kit/README.md +1 -1
  7. package/kit/agents/crew-commit-agent.md +7 -3
  8. package/kit/agents/crew-frontend-expert.md +4 -4
  9. package/kit/agents/crew-performance-expert.md +1 -1
  10. package/kit/agents/crew-review-agent.md +1 -1
  11. package/kit/eval/doctor.sh +57 -3
  12. package/kit/eval/golden-routing.txt +13 -0
  13. package/kit/eval/lib/git-bash.sh +11 -2
  14. package/kit/eval/parser-conformance.sh +1 -0
  15. package/kit/eval/routing-eval.sh +7 -3
  16. package/kit/eval/smoke-test.sh +2545 -214
  17. package/kit/hooks/commit-msg +1 -1
  18. package/kit/hooks/context-usage.sh +7 -1
  19. package/kit/hooks/guard-bash.sh +1608 -103
  20. package/kit/hooks/guard-commit-scan.sh +138 -19
  21. package/kit/hooks/guard-powershell.sh +89 -15
  22. package/kit/hooks/guard-write.sh +98 -22
  23. package/kit/hooks/no-bash-guard.ps1 +71 -0
  24. package/kit/hooks/pre-commit +5 -2
  25. package/kit/hooks/prompt-approval.sh +484 -0
  26. package/kit/hooks/route-hint.sh +13 -5
  27. package/kit/hooks/session-rehydrate.sh +18 -0
  28. package/kit/hooks/skill-trust.sh +17 -2
  29. package/kit/owned-blobs.tsv +43 -0
  30. package/kit/settings.json +20 -4
  31. package/kit/skills/a11y/SKILL.md +1 -0
  32. package/kit/skills/crew-doctor/SKILL.md +2 -2
  33. package/kit/skills/frontend/SKILL.md +3 -3
  34. package/kit/skills/frontend-flutter/SKILL.md +94 -0
  35. package/kit/skills/frontend-flutter/references/adaptive-and-accessible.md +55 -0
  36. package/kit/skills/frontend-flutter/references/platform-async-release.md +59 -0
  37. package/kit/skills/frontend-flutter/references/rebuilds-and-performance.md +36 -0
  38. package/kit/studio/server/hooks/studio-gate.sh +35 -1
  39. package/kit/studio/server/index.js +14 -4
  40. package/kit/studio/server/lib/graph.js +64 -3
  41. package/kit/studio/server/lib/kit.js +16 -2
  42. package/kit/studio/server/lib/permissions.js +77 -2
  43. package/kit/studio/server/lib/projects.js +60 -7
  44. package/kit/studio/server/lib/session.js +16 -2
  45. package/kit/studio/web/app.js +1765 -573
  46. package/kit/studio/web/approvals.js +124 -0
  47. package/kit/studio/web/canvas.js +932 -885
  48. package/kit/studio/web/chat.js +322 -145
  49. package/kit/studio/web/convo.js +174 -0
  50. package/kit/studio/web/dock.js +221 -0
  51. package/kit/studio/web/graph-plan.js +457 -0
  52. package/kit/studio/web/icons/builtin.svg +1 -0
  53. package/kit/studio/web/index.html +116 -50
  54. package/kit/studio/web/inspect.js +110 -0
  55. package/kit/studio/web/list-plan.js +112 -0
  56. package/kit/studio/web/list.js +157 -0
  57. package/kit/studio/web/liveness.js +66 -0
  58. package/kit/studio/web/nav.js +237 -0
  59. package/kit/studio/web/newsession.js +235 -0
  60. package/kit/studio/web/press.js +65 -0
  61. package/kit/studio/web/style.css +1281 -1014
  62. package/kit/studio/web/theme.js +43 -0
  63. package/kit/studio/web/timeline-plan.js +291 -0
  64. package/kit/studio/web/timeline.js +400 -0
  65. package/kit/studio/web/tokens.css +131 -0
  66. package/kit/studio/web/toolbar-fit.js +35 -0
  67. package/package.json +5 -4
@@ -1,103 +1,63 @@
1
1
  // The orchestration canvas.
2
2
  //
3
- // HTML nodes over an SVG edge layer — the same split n8n and React Flow use,
4
- // and for the same reason: cards are far easier to style as DOM, curves are far
5
- // easier to draw as paths. One shared transform keeps them registered.
3
+ // HTML cards over an SVG layer of wires — the same split n8n and React Flow use, and for the same reason: cards
4
+ // are far easier to style as DOM, curves are far easier to draw as paths. One shared transform keeps them
5
+ // registered.
6
6
  //
7
- // Layout is automatic until you touch it. The moment a node is dragged its
8
- // position is yours and is remembered per session; new agents still land on the
9
- // auto grid around whatever you have arranged.
10
-
11
- // Vertical flow: the session on top, everything it spawned beneath it. Depth
12
- // reads as distance down the page, which is how delegation actually feels —
13
- // work handed downward rather than sideways.
14
- const NODE_W = 236;
15
- const NODE_H = 104;
16
- const SIBLING_GAP = 26; // between nodes of the same kind
17
- const GROUP_GAP = 64; // between one kind and the next
18
- const DEPTH_GAP = 128; // between one depth and the next
19
- const MAX_PER_GROUP_ROW = 4;
20
-
21
- // Kinds that are not agents still need a colour, and the card and the edge into
22
- // it have to agree — a branch you can trace by colour stops working the moment
23
- // the two are computed in different places. This is that one place.
24
- const KIND_COLOR = { session: '#5b8cff', workflow: '#a874f5' };
25
-
26
- // Raw status strings, folded into the handful of things motion has to say.
27
- // `killed` and `stopped` come straight off the transcript and mean the same
28
- // thing to a reader as `failed`: this branch did not finish on its own terms.
29
- const FLOW_STATE = {
30
- running: 'live',
31
- starting: 'waking',
32
- done: 'done',
33
- failed: 'failed',
34
- killed: 'failed',
35
- stopped: 'failed',
36
- ended: 'quiet',
37
- stale: 'quiet',
38
- session: 'root',
39
- };
40
-
41
- // How long an arriving edge takes to draw itself toward its new node. Short on
42
- // purpose: this is a status panel, and anything a viewer has to wait through
43
- // is a cost they pay on every spawn.
7
+ // Where things go is not decided here. graph-plan.js turns the session's nodes into boxes and wires; this file
8
+ // draws them, keeps them drawn across polls, and answers the pointer. Three things it holds to:
9
+ //
10
+ // Colour says status and nothing else. Who an agent is, is said by the mark in its tile.
11
+ // A status is never colour alone: every dot has its word beside it.
12
+ // A poll that changed nothing writes nothing. Each element remembers what it last drew.
13
+ import {
14
+ plan, sequence, crowdFolds, fitZoom, stateOf, ZOOM, GROUPINGS, DENSITIES,
15
+ } from './graph-plan.js';
16
+ import { agentStatus } from './nav.js';
17
+
18
+ // How long an arriving wire takes to draw itself toward its new card. Short on purpose: this is a status panel,
19
+ // and anything a viewer has to wait through is a cost they pay on every spawn.
44
20
  const DRAW_MS = 300;
45
21
 
46
- // Above this many edges in motion at once the canvas stops moving them and
47
- // shows the same states standing still.
22
+ // Above this many wires in motion at once the canvas stops moving them and shows the same states standing still.
48
23
  //
49
- // Two reasons, and the second is the one that decided the number. A dash
50
- // travelling along a stroke is a paint-driven animation, not a composited one,
51
- // so its cost scales with how many strokes are in motion rather than with how
52
- // many exist. And 200 flowing lines carry less than 20 do — past a certain
53
- // density motion stops reading as direction and starts reading as noise, which
54
- // is the same reason the pulse is restrained rather than loud.
24
+ // Two reasons, and the second is the one that decided the number. A dash travelling along a stroke is a
25
+ // paint-driven animation, not a composited one, so its cost scales with how many strokes are in motion rather
26
+ // than with how many exist. And 200 flowing lines carry less than 20 do — past a certain density motion stops
27
+ // reading as direction and starts reading as noise.
55
28
  //
56
- // Profiled 2026-09-20, and it says this ceiling is the AESTHETIC one, not a
57
- // frame-budget one: 250 nodes with the budget full (60 strokes flowing) held a
58
- // median frame of 8.3 ms with nothing over 16.7 ms, and forcing the state past
59
- // the cap stayed flat at 150 and at 250 strokes. The knee was 400 strokes —
60
- // median 16.6 ms, 11 frames over budget. So the number is ~6x conservative for
61
- // performance on that machine, and it is kept for legibility.
62
- //
63
- // What that profile cost to get right, twice: rAF ran at a healthy 8.3 ms in a
64
- // HIDDEN pane while a deliberately expensive repaint changed the median by
65
- // nothing at all (ratio 1.00) — the pixels were never produced, so "paint is
66
- // free here" would have been a true number about a false world. Visible, the
67
- // same twin degraded 11x. And the first pass profiled `flow` with 9 strokes
68
- // moving, not 60: the observed case was not the legal worst case. Measure this
69
- // with both twins or do not quote it.
29
+ // Profiled 2026-09-20: 250 nodes with the budget full held a median frame of 8.3 ms with nothing over 16.7 ms,
30
+ // and the knee was 400 strokes. So the number is conservative for performance and kept for legibility. Measure
31
+ // it with packaging/studio-test/paint-profile.mjs, which refuses to quote a number from a pane that is not
32
+ // painting.
70
33
  const MOTION_BUDGET = 60;
34
+
35
+ // How long the pointer rests on a card before its details are shown.
36
+ const TIP_MS = 400;
37
+
71
38
  // Marks, so a card says what kind of thing it is before it is read.
72
39
  //
73
- // Crewforth's own is the ››› mark from assets/icon.svg, redrawn here rather than
74
- // fetched — one <img> per node would be a request per node, and the shape is
75
- // three strokes.
40
+ // Crewforth's own is the three chevrons, drawn here because the shape is three strokes and its colours are the
41
+ // chevron tokens. The session and the run are plain strokes in the text colour.
76
42
  //
77
- // Claude's built-in agents get a neutral burst, NOT Anthropic's logo. Shipping
78
- // a vendor's trademark inside an MIT repo, on cards the panel draws itself,
79
- // claims a relationship this project does not have. The shape reads as "not
80
- // ours" without borrowing anyone's mark.
81
- const MARKS = {
82
- // Crewforth's own mark, from assets/icon.svg: three chevrons, the 200-unit
83
- // artwork scaled by 0.08 into this 16-unit box (points and stroke widths
84
- // alike). Its own palette, not the node's, so it looks like the mark rather
85
- // than like the card.
86
- kit: '<g fill="none" stroke-linecap="round" stroke-linejoin="round">'
87
- + '<polyline points="3.52,4.64 6.88,8 3.52,11.36" stroke="#E5E7FB" stroke-width="1.68"/>'
88
- + '<polyline points="6.4,4.64 9.76,8 6.4,11.36" stroke="#B9BEF9" stroke-width="1.68"/>'
89
- + '<polyline points="9.28,4.64 12.64,8 9.28,11.36" stroke="#A78BFA" stroke-width="1.92"/></g>',
90
- // Claude's mark, in Claude's colour. Rays of uneven length and spacing —
91
- // the irregularity is what makes it read as the mark rather than as a star.
92
- builtin: '<g stroke="#D97757" stroke-width="1.45" stroke-linecap="round" fill="none"><path d="M8.00 6.50L8.00 1.10"/><path d="M7.21 6.73L5.03 3.25"/><path d="M6.65 7.34L2.07 5.11"/><path d="M6.51 8.16L2.83 8.54"/><path d="M6.82 8.92L2.56 12.25"/><path d="M7.49 9.41L6.15 13.07"/><path d="M8.31 9.47L9.37 14.46"/><path d="M9.04 9.08L11.61 11.74"/><path d="M9.46 8.36L14.70 9.67"/><path d="M9.43 7.54L13.33 6.27"/><path d="M8.88 6.79L11.76 2.82"/></g>',
93
- workflow: '<g fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">'
94
- + '<path d="M3 4.5h10M3 8h10M3 11.5h10"/></g>',
95
- session: '<g fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">'
96
- + '<path d="M3.5 4.5l3 3-3 3M8.5 11.5h4"/></g>',
43
+ // The mark for every other agent is NOT here. It is one file, web/icons/builtin.svg, handed in through
44
+ // setIcons(), so that changing it is changing that file and nothing else.
45
+ const MARK = {
46
+ kit: '<svg viewBox="0 0 16 16" aria-hidden="true"><g fill="none" stroke-linecap="round" stroke-linejoin="round">'
47
+ + '<polyline points="3.52,4.64 6.88,8 3.52,11.36" style="stroke:var(--chevron-1)" stroke-width="1.92"/>'
48
+ + '<polyline points="6.4,4.64 9.76,8 6.4,11.36" style="stroke:var(--chevron-2)" stroke-width="1.92"/>'
49
+ + '<polyline points="9.28,4.64 12.64,8 9.28,11.36" style="stroke:var(--chevron-3)" stroke-width="2.24"/></g></svg>',
50
+ session: '<svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><path d="M3 5l3 3-3 3M8 11h5"/></svg>',
51
+ run: '<svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 2l6 3-6 3-6-3 6-3z"/><path d="M2 8l6 3 6-3M2 11l6 3 6-3"/></svg>',
52
+ chevron: '<svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><path d="M6 4l4 4-4 4"/></svg>',
53
+ down: '<svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><path d="M4 6l4 4 4-4"/></svg>',
54
+ close: '<svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8"/></svg>',
97
55
  };
98
56
 
99
- /** Element helper. `el` is used as a local name for a node all over this file,
100
- * so the helper is named for what it does instead. */
57
+ const SVG = 'http://www.w3.org/2000/svg';
58
+
59
+ /** Element helper. `el` is used as a local name for a node all over this file, so the helper is named for what
60
+ * it does instead. */
101
61
  function mk(tag, cls, text) {
102
62
  const n = document.createElement(tag);
103
63
  if (cls) n.className = cls;
@@ -105,109 +65,127 @@ function mk(tag, cls, text) {
105
65
  return n;
106
66
  }
107
67
 
108
- const MIN_K = 0.06;
109
- const MAX_K = 2.5;
110
- // Padding fit() leaves around the graph, on each side. Named because the band
111
- // search below has to score candidates with the same arithmetic fit() uses; two
112
- // copies of the number is two numbers that drift.
113
- const FIT_PAD = 60;
114
- // The largest fit() will scale up to. Same reason.
115
- const FIT_MAX = 1.2;
116
-
117
- /* --------------------------------------------------------------- reading
118
- Three readings of the same picture, chosen by how much of a card would
119
- actually survive on screen.
120
-
121
- The idea is not "hide things when zoomed out" — hiding alone still leaves an
122
- unreadable title. What survives is redrawn at a constant *screen* size: its
123
- world font-size grows as the canvas shrinks. That costs one custom property
124
- and no per-node JS, which is why a band change is O(1) in node count. */
125
-
126
- // 13px .cv-desc clears 9px on screen at 9/13 = 0.692.
127
- const LOD_NEAR = 0.70;
128
- // A screen-constant 11px mono line stops fitting the card: the content box is
129
- // 204x80 world px (236-18-14, 104-12-12); six mono chars at a 6.6px advance
130
- // need 40 screen px, so 204*k >= 40 => k >= 0.196. The horizontal bound binds.
131
- const LOD_FAR = 0.20;
132
- // A trackpad tremor must not reband 250 cards.
133
- const LOD_HYST = 0.025;
134
- // Past this many drawn nodes, words are the noise floor. Count is the axis that
135
- // says "there are too many words on this canvas"; zoom is the axis that says
136
- // "the words are too small". Both have the same remedy, so both pick `far`.
137
- const LABEL_BUDGET = 40;
138
- // What the HUD calls each reading, so a viewer knows detail was traded rather
139
- // than lost and that one wheel notch brings it back.
140
- const LOD_WORD = { near: 'detail', mid: 'titles', far: 'marks' };
141
- const LOD_RANK = { far: 0, mid: 1, near: 2 };
142
-
143
- // Folding is driven by whether the graph fits, not by group size. A session
144
- // with one 6-agent workflow should show all six; a session with 250 should
145
- // arrive folded. Below this many nodes nothing is folded at all.
146
- const COMFORTABLE_NODES = 34;
68
+ const cap = (s) => (s ? s[0].toUpperCase() + s.slice(1) : s);
69
+ const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
147
70
 
148
71
  export class Canvas {
149
- constructor(root, { onSelect } = {}) {
72
+ constructor(root, { onSelect, onMenu, onChange } = {}) {
150
73
  this.root = root;
151
74
  this.onSelect = onSelect ?? (() => {});
152
- this.view = { x: 80, y: 80, k: 1 };
153
- this.nodes = new Map(); // id -> data
154
- this.pos = new Map(); // id -> {x, y}
155
- this.pinned = new Set(); // ids the user has placed by hand
156
- this.els = new Map(); // id -> element
157
- this.edgeEls = new Map(); // edge key -> <path>
158
- this.edges = [];
159
- // Ids that arrived on the last poll. An edge into one of these draws
160
- // itself; every other edge is left alone, so unfolding a group of 105 does
161
- // not set 105 animations running at once.
75
+ this.onMenu = onMenu ?? (() => {});
76
+ this.onChange = onChange ?? (() => {});
77
+ this.view = { x: 0, y: 0, k: 1 };
78
+ this.nodes = new Map(); // id -> the server's node
79
+ this.pos = new Map(); // drawn item id -> {x, y}
80
+ this.pinned = new Map(); // item id -> {x, y} the viewer set by hand
81
+ this.els = new Map(); // drawn item id -> element
82
+ this.cellEls = new Map(); // agent id -> its row or chip inside an open group
83
+ this.edgeEls = new Map(); // wire key -> <path>
84
+ this.mapRects = new Map(); // drawn item id -> its box on the minimap
85
+ this.seq = new Map(); // id -> arrival number (see graph-plan.js)
86
+ this.open = new Map(); // group id -> the viewer's own fold
87
+ // Ids that arrived on the last poll. A wire into one of these draws itself; every other wire is left alone,
88
+ // so opening a group of 105 does not set 105 animations running at once.
162
89
  this.newborn = new Set();
163
- this.palette = { map: {}, unknown: '#94a3c8' };
90
+ this.palette = { map: {} };
91
+ this.icons = {};
92
+ this.filter = null; // statuses the page asked to isolate
93
+ this.waiting = new Set(); // agents parked on an approval
94
+ this.sessionState = null; // { word, tone } for the session card's pill
95
+ this.focusId = null; // show only this branch
96
+ this.group = 'run';
97
+ this.density = readPref('crewforth-studio-density', DENSITIES, 'auto');
98
+ this.lastGraph = null;
99
+ this.lastPlan = null;
164
100
  this.sessionKey = null;
101
+ this.sessionY = null;
165
102
  this.selected = null;
166
103
  this.firstRender = true;
167
- this.collapsed = new Set();
168
- // How many nodes the last layout actually placed. The reading band is a
169
- // function of this as well as of zoom, and it is cached rather than
170
- // recounted because applyView() runs on every frame of a pan.
104
+ this.touched = false; // the viewer moved the view themselves
171
105
  this.drawn = 0;
172
- // Which way delegation reads. Down is the default because a session
173
- // handing work to agents feels like work moving downward; sideways suits
174
- // deep chains better, so it is a preference rather than a decision.
175
- this.flow = readFlow();
176
106
 
177
107
  this.#build();
178
108
  this.#wire();
179
- // #renderNode runs before the first fit() inside render(), so without this
180
- // there is one paint with --k unset, and every calc() that reads it drops.
181
109
  this.applyView();
182
- this.root.dataset.flow = this.flow;
183
- this.root.querySelector('[data-act="flow"]').textContent = this.flow === 'down' ? '⇅' : '⇄';
184
110
  }
185
111
 
186
112
  #build() {
187
113
  this.root.classList.add('cv-root');
188
- this.root.innerHTML = `
189
- <div class="cv-viewport">
190
- <svg class="cv-edges" aria-hidden="true"><defs></defs><g class="cv-edge-g"></g></svg>
191
- <div class="cv-nodes"></div>
192
- </div>
193
- <div class="cv-hud">
194
- <button class="cv-btn" data-act="expand" title="Expand every group">⊞</button>
195
- <button class="cv-btn" data-act="collapse" title="Fold every group">⊟</button>
196
- <button class="cv-btn cv-alarm" data-act="failed" title="Go to the next failed branch" hidden>⚠</button>
197
- <button class="cv-btn" data-act="flow" title="Switch layout direction">⇅</button>
198
- <button class="cv-btn" data-act="fit" title="Fit to view">⤢</button>
199
- <button class="cv-btn" data-act="relayout" title="Re-run auto layout">⟲</button>
200
- <span class="cv-zoom">100%</span>
201
- </div>
202
- <div class="cv-empty" hidden></div>`;
203
-
204
- this.viewport = this.root.querySelector('.cv-viewport');
205
- this.svg = this.root.querySelector('.cv-edges');
206
- this.edgeG = this.root.querySelector('.cv-edge-g');
207
- this.nodeLayer = this.root.querySelector('.cv-nodes');
208
- this.zoomLabel = this.root.querySelector('.cv-zoom');
209
- this.emptyEl = this.root.querySelector('.cv-empty');
210
- this.alarmBtn = this.root.querySelector('.cv-alarm');
114
+ this.root.replaceChildren();
115
+
116
+ this.viewport = mk('div', 'cv-viewport');
117
+ this.svg = document.createElementNS(SVG, 'svg');
118
+ this.svg.setAttribute('class', 'cv-edges');
119
+ this.svg.setAttribute('aria-hidden', 'true');
120
+ this.edgeG = document.createElementNS(SVG, 'g');
121
+ this.edgeG.setAttribute('class', 'cv-edge-g');
122
+ this.svg.append(this.edgeG);
123
+ this.nodeLayer = mk('div', 'cv-nodes');
124
+ this.viewport.append(this.svg, this.nodeLayer);
125
+
126
+ this.emptyEl = mk('div', 'cv-empty');
127
+ this.emptyEl.hidden = true;
128
+
129
+ // The whole graph, small. The frame is what the pane shows.
130
+ this.mapEl = mk('div', 'cv-minimap');
131
+ this.mapEl.setAttribute('aria-label', 'Minimap');
132
+ this.mapEl.hidden = true;
133
+ this.mapSvg = document.createElementNS(SVG, 'svg');
134
+ this.mapSvg.setAttribute('aria-hidden', 'true');
135
+ this.mapBoxes = document.createElementNS(SVG, 'g');
136
+ this.mapFrame = document.createElementNS(SVG, 'rect');
137
+ this.mapFrame.setAttribute('class', 'cv-map-frame');
138
+ this.mapSvg.append(this.mapBoxes, this.mapFrame);
139
+ this.mapEl.append(this.mapSvg);
140
+
141
+ this.legendEl = this.#legend();
142
+ this.tipEl = mk('div', 'cv-tip');
143
+ this.tipEl.setAttribute('role', 'tooltip');
144
+ this.tipEl.hidden = true;
145
+
146
+ this.root.append(this.viewport, this.emptyEl, this.mapEl, this.legendEl, this.tipEl);
147
+ }
148
+
149
+ /** What the lines and the two marks mean. Shown until it is closed, and then not again unless asked for. */
150
+ #legend() {
151
+ const box = mk('div', 'cv-legend');
152
+ box.setAttribute('aria-label', 'Legend');
153
+ const head = mk('div', 'cv-legend-head');
154
+ head.append(mk('span', 'cv-label', 'Legend'));
155
+ const close = mk('button', 'cv-x');
156
+ close.type = 'button';
157
+ close.setAttribute('aria-label', 'Close the legend');
158
+ close.innerHTML = MARK.close;
159
+ close.addEventListener('click', () => this.showLegend(false));
160
+ head.append(close);
161
+ box.append(head);
162
+ const line = (state, word) => {
163
+ const item = mk('span', 'cv-legend-item');
164
+ const sw = mk('span', 'cv-legend-line');
165
+ sw.dataset.state = state;
166
+ item.append(sw, mk('span', null, word));
167
+ return item;
168
+ };
169
+ const rows = mk('div', 'cv-legend-grid');
170
+ rows.append(line('live', 'working now'), line('waiting', 'needs you'), line('done', 'finished'), line('failed', 'failed'));
171
+ box.append(rows);
172
+ const marks = mk('div', 'cv-legend-grid');
173
+ this.legendBuiltin = mk('span', 'cv-tile cv-tile-sm');
174
+ const crew = mk('span', 'cv-tile cv-tile-sm');
175
+ crew.innerHTML = MARK.kit;
176
+ const a = mk('span', 'cv-legend-item');
177
+ a.append(this.legendBuiltin, mk('span', null, 'Claude Code built-in'));
178
+ const b = mk('span', 'cv-legend-item');
179
+ b.append(crew, mk('span', null, 'Crewforth'));
180
+ marks.append(a, b);
181
+ box.append(marks);
182
+ box.hidden = readFlag('crewforth-studio-legend') === 'closed';
183
+ return box;
184
+ }
185
+
186
+ showLegend(on) {
187
+ this.legendEl.hidden = !on;
188
+ writeFlag('crewforth-studio-legend', on ? 'open' : 'closed');
211
189
  }
212
190
 
213
191
  /* ------------------------------------------------------------ input */
@@ -217,7 +195,8 @@ export class Canvas {
217
195
 
218
196
  this.root.addEventListener('pointerdown', (e) => {
219
197
  if (e.button !== 0) return; // right/middle must not pan
220
- if (e.target.closest('.cv-node') || e.target.closest('.cv-hud')) return;
198
+ const t = e.target;
199
+ if (t.closest?.('.cv-node') || t.closest?.('.cv-minimap') || t.closest?.('.cv-legend')) return;
221
200
  panning = { px: e.clientX, py: e.clientY, x: this.view.x, y: this.view.y };
222
201
  this.root.setPointerCapture(e.pointerId);
223
202
  this.root.classList.add('cv-panning');
@@ -227,6 +206,7 @@ export class Canvas {
227
206
  if (!panning) return;
228
207
  this.view.x = panning.x + (e.clientX - panning.px);
229
208
  this.view.y = panning.y + (e.clientY - panning.py);
209
+ this.touched = true;
230
210
  this.applyView();
231
211
  });
232
212
 
@@ -240,659 +220,666 @@ export class Canvas {
240
220
  const mx = e.clientX - r.left;
241
221
  const my = e.clientY - r.top;
242
222
 
243
- // deltaMode 1 is lines and 2 is pages; Firefox reports ~3 where Chrome
244
- // reports ~100, which made zoom imperceptible and pan crawl.
223
+ // deltaMode 1 is lines and 2 is pages; Firefox reports ~3 where Chrome reports ~100, which made zoom
224
+ // imperceptible and pan crawl.
245
225
  const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? r.height : 1;
246
226
  const dY = e.deltaY * unit;
247
227
  const dX = e.deltaX * unit;
248
228
 
249
229
  if (e.ctrlKey || e.metaKey || Math.abs(dY) > Math.abs(dX)) {
250
- const k = clamp(this.view.k * Math.exp(-dY * 0.0016), MIN_K, MAX_K);
251
- // Zoom about the cursor: the world point under it must not move.
252
- this.view.x = mx - (mx - this.view.x) * (k / this.view.k);
253
- this.view.y = my - (my - this.view.y) * (k / this.view.k);
254
- this.view.k = k;
230
+ this.#zoomAbout(this.view.k * Math.exp(-dY * 0.0016), mx, my);
255
231
  } else {
256
232
  this.view.x -= dX;
257
233
  this.view.y -= dY;
258
234
  }
235
+ this.touched = true;
259
236
  this.applyView();
260
237
  }, { passive: false });
261
238
 
262
- this.root.querySelector('.cv-hud').addEventListener('click', (e) => {
263
- const act = e.target.closest('[data-act]')?.dataset.act;
264
- if (act === 'fit') this.fit();
265
- if (act === 'relayout') { this.pinned.clear(); this.#persist(); this.layout(true); this.fit(); }
266
- if (act === 'flow') this.setFlow(this.flow === 'down' ? 'right' : 'down');
267
- if (act === 'failed') this.gotoFailed();
268
- if (act === 'expand') { this.collapsed.clear(); this.#redraw(); }
269
- if (act === 'collapse') {
270
- for (const n of this.nodes.values()) if (this.hiddenCount(n.id) > 0) this.collapsed.add(n.id);
271
- this.#redraw();
272
- }
273
- });
239
+ // The minimap's frame is the pane: put the pointer somewhere on the map and the pane goes there.
240
+ let mapping = false;
241
+ const mapTo = (e) => {
242
+ const p = this.lastPlan;
243
+ if (!p) return;
244
+ const r = this.mapEl.getBoundingClientRect();
245
+ const s = this.#mapScale(p);
246
+ const wx = (e.clientX - r.left) / s;
247
+ const wy = (e.clientY - r.top) / s;
248
+ const pane = this.root.getBoundingClientRect();
249
+ this.view.x = pane.width / 2 - wx * this.view.k;
250
+ this.view.y = pane.height / 2 - wy * this.view.k;
251
+ this.touched = true;
252
+ this.applyView();
253
+ };
254
+ this.mapEl.addEventListener('pointerdown', (e) => { mapping = true; this.mapEl.setPointerCapture(e.pointerId); mapTo(e); });
255
+ this.mapEl.addEventListener('pointermove', (e) => { if (mapping) mapTo(e); });
256
+ this.mapEl.addEventListener('pointerup', () => { mapping = false; });
257
+ this.mapEl.addEventListener('pointercancel', () => { mapping = false; });
258
+ }
259
+
260
+ /** Zoom to `k`, keeping the world point under (mx, my) where it is. */
261
+ #zoomAbout(k, mx, my) {
262
+ const next = clamp(k, ZOOM.min, ZOOM.max);
263
+ this.view.x = mx - (mx - this.view.x) * (next / this.view.k);
264
+ this.view.y = my - (my - this.view.y) * (next / this.view.k);
265
+ this.view.k = next;
266
+ }
267
+
268
+ /** Step the zoom from the toolbar: about the middle of the pane. */
269
+ zoomBy(factor) {
270
+ const r = this.root.getBoundingClientRect();
271
+ this.#zoomAbout(this.view.k * factor, r.width / 2, r.height / 2);
272
+ this.touched = true;
273
+ this.applyView();
274
+ }
275
+
276
+ zoomTo(k) {
277
+ const r = this.root.getBoundingClientRect();
278
+ this.#zoomAbout(k, r.width / 2, r.height / 2);
279
+ this.touched = true;
280
+ this.applyView();
274
281
  }
275
282
 
276
283
  /**
277
- * Turn `view` into pixels. Public because it is the single place that does
278
- * so — the wheel, the pan, fit() and the constructor all come through here,
279
- * and so does anything that wants to drive the view without a pointer.
284
+ * Turn `view` into pixels. Public because it is the single place that does so — the wheel, the pan, fit() and
285
+ * the toolbar all come through here, and so does anything that wants to drive the view without a pointer.
280
286
  *
281
- * It publishes two things the stylesheet reads: the scale, so a surviving
282
- * label can be drawn at a constant *screen* size, and the reading band. A
283
- * band change never re-lays out, re-fits or re-renders a node — every card
284
- * responds through the cascade, which is what keeps this O(1) in node count
285
- * and makes it impossible for a band to move the world that decides it.
287
+ * It writes one transform. Nothing a card draws depends on the zoom, so a zoom restyles nothing: that was the
288
+ * cost of the old counter-scaled labels, 454 ms of style work per second at 250 nodes.
286
289
  */
287
290
  applyView() {
288
291
  const { x, y, k } = this.view;
289
292
  this.viewport.style.transform = `translate(${x}px, ${y}px) scale(${k})`;
290
- this.root.style.setProperty('--k', String(k));
291
- const band = this.#band(k);
292
- this.root.dataset.lod = band;
293
- // Say when it was the crowd rather than the zoom that traded the words
294
- // away, because the remedy for that one is folding, not scrolling.
295
- const crowded = band === 'far' && k >= LOD_FAR;
296
- this.zoomLabel.textContent = `${Math.round(k * 100)}% · ${LOD_WORD[band]}`
297
- + (crowded ? ` (${this.drawn})` : '');
293
+ this.#mapView();
294
+ this.onChange(this.state());
298
295
  }
299
296
 
300
- /** Which reading the canvas is in. Hysteresis is applied only on the way
301
- * *up*, so a jitter sitting on a boundary cannot reband 250 cards twice a
302
- * second; dropping detail is always immediate. */
303
- #band(k) {
304
- if (this.drawn > LABEL_BUDGET) return 'far';
305
- const now = LOD_RANK[this.root.dataset.lod] ?? LOD_RANK.near;
306
- const near = LOD_NEAR + (now < LOD_RANK.near ? LOD_HYST : 0);
307
- const mid = LOD_FAR + (now < LOD_RANK.mid ? LOD_HYST : 0);
308
- return k >= near ? 'near' : k >= mid ? 'mid' : 'far';
297
+ /** What the toolbar shows. */
298
+ state() {
299
+ return {
300
+ zoom: this.view.k,
301
+ group: this.group,
302
+ density: this.density,
303
+ drawnAs: this.lastPlan?.density ?? null,
304
+ stacked: Boolean(this.lastPlan?.stacked),
305
+ drawn: this.drawn,
306
+ focus: this.focusId ? (this.nodes.get(this.focusId)?.agentType ?? this.focusId) : null,
307
+ groups: (this.lastPlan?.items ?? []).filter((it) => it.kind === 'group').length,
308
+ };
309
309
  }
310
310
 
311
- /* ----------------------------------------------------------- layout */
311
+ /* ---------------------------------------------------------- choices */
312
312
 
313
- /**
314
- * Fold the fewest, largest groups needed to make the graph readable.
313
+ setPalette(p) { if (p?.map) { this.palette = p; this.#redraw(); } }
314
+
315
+ /** The marks that live in files. `builtin` is the markup of web/icons/builtin.svg. */
316
+ setIcons(icons) {
317
+ this.icons = { ...this.icons, ...icons };
318
+ if (this.legendBuiltin && this.icons.builtin) this.legendBuiltin.innerHTML = this.icons.builtin;
319
+ for (const el of this.els.values()) el.sig = null;
320
+ for (const el of this.cellEls.values()) el.sig = null;
321
+ this.#redraw();
322
+ }
323
+
324
+ setGroup(group) {
325
+ if (!GROUPINGS.includes(group) || group === this.group) return;
326
+ this.group = group;
327
+ // Positions set by hand and folds chosen for one grouping say nothing about another.
328
+ this.pinned.clear();
329
+ this.open.clear();
330
+ this.sessionY = null;
331
+ this.#crowdFold();
332
+ this.#persist();
333
+ this.#redraw({ fit: true });
334
+ }
335
+
336
+ setDensity(density) {
337
+ if (!DENSITIES.includes(density) || density === this.density) return;
338
+ this.density = density;
339
+ writeFlag('crewforth-studio-density', density);
340
+ this.sessionY = null;
341
+ this.#redraw({ fit: true });
342
+ }
343
+
344
+ /** Show some statuses and step the rest back. `null` shows everything.
315
345
  *
316
- * Runs once when a session opens. Small sessions are left fully open — the
317
- * point is to rescue the ones that do not fit, not to hide work by default.
318
- * Everything folded stays one click away and says how much it is holding.
319
- */
320
- #autoFold() {
321
- const total = this.nodes.size;
322
- if (total <= COMFORTABLE_NODES) return;
323
-
324
- const groups = [...this.nodes.values()]
325
- .filter((n) => n.kind === 'workflow' || (n.kind === 'agent' && this.hiddenCount(n.id) > 0))
326
- .map((n) => ({ id: n.id, size: this.hiddenCount(n.id) }))
327
- .filter((g) => g.size > 1)
328
- .sort((a, b) => b.size - a.size);
329
-
330
- let visible = total;
331
- for (const g of groups) {
332
- if (visible <= COMFORTABLE_NODES) break;
333
- this.collapsed.add(g.id);
334
- visible -= g.size;
335
- }
346
+ * Stepped back, not removed: the graph keeps its shape, so a card the viewer was looking at is where it was
347
+ * when the filter comes off. Only agents have the statuses the summary counts, so the session and the groups
348
+ * stay lit as the frame the agents hang from. */
349
+ setFilter(statuses, { keepWaiting = false } = {}) {
350
+ this.filter = statuses == null ? null : new Set(typeof statuses === 'string' ? [statuses] : statuses);
351
+ this.filterKeepsWaiting = keepWaiting;
352
+ this.#redraw();
336
353
  }
337
354
 
338
- /** Nodes that are actually drawn: everything except what a folded ancestor
339
- * is hiding. Hidden nodes keep their data — they are folded, not dropped. */
340
- visible() {
341
- const hiddenBy = (id) => {
342
- let cur = this.nodes.get(id);
343
- let guard = 0;
344
- while (cur && cur.parentId && guard++ < 24) {
345
- if (this.collapsed.has(cur.parentId)) return true;
346
- cur = this.nodes.get(cur.parentId);
347
- }
348
- return false;
349
- };
350
- return [...this.nodes.values()].filter((n) => !hiddenBy(n.id));
355
+ dimmed(n) {
356
+ if (!this.filter || n?.kind !== 'agent') return false;
357
+ if (this.filterKeepsWaiting && this.waiting.has(n.id)) return false;
358
+ return !this.filter.has(n.status);
351
359
  }
352
360
 
353
- /** How many nodes a folded node is holding back, transitively. */
354
- hiddenCount(id) {
355
- let n = 0;
356
- const walk = (pid) => {
357
- for (const c of this.nodes.values()) {
358
- if (c.parentId !== pid) continue;
359
- n += 1;
360
- walk(c.id);
361
- }
362
- };
363
- walk(id);
364
- return n;
361
+ /** Agents parked on an approval. The graph has no such status of its own; the page knows it. */
362
+ setWaiting(ids) {
363
+ this.waiting = new Set(ids ?? []);
364
+ this.#redraw();
365
365
  }
366
366
 
367
- // Depth downward, kind across. Eight agents in one undifferentiated fan told
368
- // the reader nothing about which were alike; grouped by type, with the type
369
- // named above its group, the same eight read as three Explores, a Plan, and
370
- // so on. Groups keep their own small grid so one large kind does not push
371
- // every other kind off the screen.
372
- //
373
- // A depth level then WRAPS. Laying every group of a depth in one unbounded
374
- // row made the world a ribbon — 8624x1246 at 250 nodes — and fit() is
375
- // width-bound at every size against a ribbon, so twelve agents already
376
- // arrived at 34% and the type line was 3.7px on screen. The level is packed
377
- // into bands instead, and how many groups go in a band is chosen by the same
378
- // arithmetic fit() uses, so the layout optimises the number the reader
379
- // actually feels.
380
- //
381
- // Nothing here reads this.view. Positions are never a function of zoom —
382
- // that invariant is what makes the reading bands oscillation-proof.
383
- layout(force = false) {
384
- const byDepth = new Map();
385
- let drawn = 0;
386
- for (const n of this.visible()) {
387
- const d = n.kind === 'session' ? 0 : (n.spawnDepth ?? 1);
388
- if (!byDepth.has(d)) byDepth.set(d, []);
389
- byDepth.get(d).push(n);
390
- drawn += 1;
367
+ setSessionState(state) {
368
+ this.sessionState = state ?? null;
369
+ this.#redraw();
370
+ }
371
+
372
+ /** The same mark a card carries, for a surface outside the canvas: one answer to "whose agent is this". */
373
+ tileFor(n) {
374
+ const tile = mk('span', 'cv-tile');
375
+ if (n?.kind === 'agent') this.#fillTile(tile, n);
376
+ else tile.innerHTML = MARK.session;
377
+ return tile;
378
+ }
379
+
380
+ /** The word and tone a node's status is drawn in on its card. */
381
+ statusOf(n) {
382
+ if (n?.kind === 'session') return this.sessionState ?? { word: null, tone: null, known: false };
383
+ return this.#statusOf(n);
384
+ }
385
+
386
+ expandAll() {
387
+ for (const it of this.lastPlan?.items ?? []) if (it.kind === 'group') this.open.set(it.id, true);
388
+ // Opening one level can reveal groups inside it; open those too.
389
+ for (let i = 0; i < 4; i += 1) {
390
+ const p = this.#plan();
391
+ let more = false;
392
+ for (const it of p.items) if (it.kind === 'group' && !it.open) { this.open.set(it.id, true); more = true; }
393
+ if (!more) break;
391
394
  }
392
- this.drawn = drawn;
395
+ this.sessionY = null;
396
+ this.#persist();
397
+ this.#redraw({ fit: true });
398
+ }
393
399
 
394
- const down = this.flow === 'down';
395
- const NODE_A = down ? NODE_W : NODE_H; // size across a band
396
- const NODE_B = down ? NODE_H : NODE_W; // size along the depth axis
400
+ foldAll() {
401
+ for (const it of this.lastPlan?.items ?? []) if (it.kind === 'group') this.open.set(it.id, false);
402
+ this.sessionY = null;
403
+ this.#persist();
404
+ this.#redraw({ fit: true });
405
+ }
397
406
 
398
- // Read once per call. The band search needs the pane, and asking for it per
399
- // group would be one forced layout per group on a 250-node graph.
400
- const r = this.root.getBoundingClientRect();
401
- const paneW = Math.max(2 * FIT_PAD + NODE_A, r.width || 800);
402
- const paneH = Math.max(2 * FIT_PAD + NODE_B, r.height || 600);
403
-
404
- const levels = [...byDepth.entries()].sort((a, b) => a[0] - b[0]).map(([, list]) => {
405
- // One group per kind. Workflows are their own kind; the session is alone
406
- // at its depth and needs no label.
407
- const groups = new Map();
408
- for (const n of list) {
409
- const key = n.kind === 'session' ? '\u0000session'
410
- : n.kind === 'workflow' ? 'workflow'
411
- : (n.agentType ?? 'unknown');
412
- if (!groups.has(key)) groups.set(key, []);
413
- groups.get(key).push(n);
414
- }
407
+ /** Show only one agent's branch: what led to it and what it spawned. `null` shows everything again. */
408
+ setFocus(id) {
409
+ this.focusId = id && this.nodes.has(id) ? id : null;
410
+ this.sessionY = null;
411
+ this.#redraw({ fit: true });
412
+ }
415
413
 
416
- // Biggest kinds first, then alphabetically, so the picture is stable
417
- // across refreshes rather than following arrival order.
418
- const ordered = [...groups.entries()].sort(
419
- (a, b) => b[1].length - a[1].length || a[0].localeCompare(b[0]),
420
- );
421
-
422
- // Measure first: every band is centred on its own width, so each group's
423
- // span has to be known before any node is placed.
424
- const measured = ordered.map(([key, members]) => {
425
- const cols = groupCols(members.length, NODE_A, NODE_B);
426
- const rows = Math.ceil(members.length / cols);
427
- return { key, members, cols, rows, span: cols * NODE_A + (cols - 1) * SIBLING_GAP };
428
- });
429
- return { measured, plans: bandPlans(measured, NODE_B) };
430
- });
414
+ /** Forget every hand-placed card and lay the graph out again. */
415
+ resetLayout() {
416
+ this.pinned.clear();
417
+ this.sessionY = null;
418
+ this.touched = false;
419
+ this.#persist();
420
+ this.#redraw({ fit: true });
421
+ }
431
422
 
432
- // Pick a packing per level against the fit of the WHOLE graph, not of the
433
- // level alone. Scored alone, a tall single-column level always wins — it is
434
- // narrow, and it is only the other levels stacked under it that make that a
435
- // bad trade. Levels are few and each has a handful of candidates, so a few
436
- // rounds of coordinate descent over the real objective is cheap and lands
437
- // on the same answer every time.
438
- const fitOf = (choice) => {
439
- let w = 0;
440
- let h = 0;
441
- for (let i = 0; i < levels.length; i += 1) {
442
- const p = levels[i].plans[choice[i]];
443
- w = Math.max(w, p.w);
444
- h += p.h;
445
- }
446
- h += Math.max(0, levels.length - 1) * DEPTH_GAP;
447
- return clamp(Math.min((paneW - 2 * FIT_PAD) / w, (paneH - 2 * FIT_PAD) / h, FIT_MAX), MIN_K, MAX_K);
448
- };
449
- const choice = levels.map((L) => {
450
- let best = 0;
451
- for (let i = 1; i < L.plans.length; i += 1) {
452
- if (soloFit(L.plans[i], paneW, paneH) > soloFit(L.plans[best], paneW, paneH) + 1e-9) best = i;
453
- }
454
- return best;
455
- });
456
- for (let round = 0; round < 4; round += 1) {
457
- let moved = false;
458
- for (let i = 0; i < levels.length; i += 1) {
459
- let bestJ = choice[i];
460
- let bestK = fitOf(choice);
461
- for (let j = 0; j < levels[i].plans.length; j += 1) {
462
- if (j === choice[i]) continue;
463
- const trial = choice.slice();
464
- trial[i] = j;
465
- const k = fitOf(trial);
466
- if (k > bestK + 1e-9) { bestK = k; bestJ = j; }
467
- }
468
- if (bestJ !== choice[i]) { choice[i] = bestJ; moved = true; }
469
- }
470
- if (!moved) break;
423
+ /* ----------------------------------------------------------- layout */
424
+
425
+ #crowdFold() {
426
+ const nodes = this.#visibleNodes();
427
+ for (const id of crowdFolds(nodes, { group: this.group, density: this.density, seq: this.seq })) {
428
+ if (!this.open.has(id)) this.open.set(id, false);
471
429
  }
430
+ }
472
431
 
473
- let cursor = 0; // position along the depth axis
474
- levels.forEach((L, li) => {
475
- const { per } = L.plans[choice[li]];
476
- let bandTop = cursor;
477
- for (let i = 0; i < L.measured.length; i += per) {
478
- const band = L.measured.slice(i, i + per);
479
- // Each band is centred on its own width rather than on the widest one,
480
- // so a level does not look ragged.
481
- let across = -(band.reduce((t, g) => t + g.span, 0) + (band.length - 1) * GROUP_GAP) / 2;
482
- let rows = 0;
483
- for (const g of band) {
484
- g.members.sort((a, b) => (a.startedAt ?? 0) - (b.startedAt ?? 0) || a.id.localeCompare(b.id));
485
- g.members.forEach((n, idx) => {
486
- const col = idx % g.cols;
487
- const row = Math.floor(idx / g.cols);
488
- const a = across + col * (NODE_A + SIBLING_GAP);
489
- const b = bandTop + row * (NODE_B + SIBLING_GAP);
490
- // Only a hand-placed node keeps its position. Freezing auto-placed
491
- // ones too was the bug: the row is re-centred as siblings arrive, so
492
- // nodes laid out against an older, shorter row overlapped the new
493
- // ones by half a card.
494
- if (!force && this.pinned.has(n.id)) return;
495
- this.pos.set(n.id, down ? { x: a, y: b } : { x: b, y: a });
496
- });
497
- across += g.span + GROUP_GAP;
498
- rows = Math.max(rows, g.rows);
499
- }
500
- bandTop += rows * (NODE_B + SIBLING_GAP) - SIBLING_GAP + GROUP_GAP;
501
- }
502
- // The next depth clears the whole stack of bands, not one row of it.
503
- cursor = bandTop - GROUP_GAP + DEPTH_GAP;
432
+ #visibleNodes() {
433
+ const all = [...this.nodes.values()];
434
+ if (!this.focusId) return all;
435
+ const keep = new Set();
436
+ for (let cur = this.nodes.get(this.focusId), guard = 0; cur && guard < 32; cur = this.nodes.get(cur.parentId), guard += 1) keep.add(cur.id);
437
+ const down = (id) => { for (const n of all) if (n.parentId === id && !keep.has(n.id)) { keep.add(n.id); down(n.id); } };
438
+ down(this.focusId);
439
+ for (const n of all) if (n.kind === 'session') keep.add(n.id);
440
+ return all.filter((n) => keep.has(n.id));
441
+ }
442
+
443
+ #plan() {
444
+ return plan(this.#visibleNodes(), {
445
+ group: this.group, density: this.density, seq: this.seq, open: this.open, pinned: this.pinned, sessionY: this.sessionY,
504
446
  });
505
447
  }
506
448
 
507
- /** Re-fit only while the layout is still automatic. Once nodes have been
508
- * placed by hand, moving the view under them is not helpful. */
449
+ /** Re-fit only while the view is still the canvas's own. Once the viewer has panned, zoomed or placed a card,
450
+ * moving the view under them is not helpful. */
509
451
  fitIfUntouched() {
510
- if (!this.pinned.size) this.fit();
452
+ if (!this.touched && !this.pinned.size) this.fit();
511
453
  }
512
454
 
513
455
  fit() {
514
- if (!this.pos.size) return;
515
- let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
516
- for (const p of this.pos.values()) {
517
- minX = Math.min(minX, p.x); minY = Math.min(minY, p.y);
518
- maxX = Math.max(maxX, p.x + NODE_W); maxY = Math.max(maxY, p.y + NODE_H);
519
- }
456
+ const p = this.lastPlan;
457
+ if (!p || !p.items.length) return;
520
458
  const r = this.root.getBoundingClientRect();
521
- const pad = FIT_PAD;
522
- const k = clamp(Math.min((r.width - pad * 2) / (maxX - minX), (r.height - pad * 2) / (maxY - minY), FIT_MAX), MIN_K, MAX_K);
459
+ const k = fitZoom(p.width, p.height, r.width, r.height);
523
460
  this.view.k = k;
524
- this.view.x = (r.width - (maxX - minX) * k) / 2 - minX * k;
525
- this.view.y = (r.height - (maxY - minY) * k) / 2 - minY * k;
461
+ this.view.x = Math.max(ZOOM.pad, (r.width - p.width * k) / 2);
462
+ this.view.y = Math.max(ZOOM.pad, (r.height - p.height * k) / 2);
463
+ this.touched = false;
526
464
  this.applyView();
527
465
  }
528
466
 
529
467
  /* -------------------------------------------------------- rendering */
530
468
 
531
- setPalette(p) { if (p?.map) this.palette = p; }
532
-
533
- /** The one colour a node answers to. The card paints itself with it and the
534
- * edge into the card is stroked with it, which is what makes a branch
535
- * traceable by colour alone. */
536
- nodeColor(n) {
537
- if (!n) return this.palette.unknown;
538
- return KIND_COLOR[n.kind] ?? this.palette.map[n.agentType]?.hex ?? this.palette.unknown;
539
- }
540
-
541
- /** Switch between top-down and left-to-right. Hand-placed nodes are cleared:
542
- * positions arranged for one direction are meaningless in the other. */
543
- setFlow(flow) {
544
- if (flow !== 'down' && flow !== 'right') return;
545
- this.flow = flow;
546
- writeFlow(flow);
547
- this.root.dataset.flow = flow;
548
- this.root.querySelector('[data-act="flow"]').textContent = flow === 'down' ? '⇅' : '⇄';
549
- this.pinned.clear();
550
- this.#persist();
551
- this.layout(true);
552
- const vis = new Set(this.visible().map((n) => n.id));
553
- for (const [id, el] of this.els) {
554
- if (!vis.has(id)) { el.remove(); this.els.delete(id); }
555
- }
556
- for (const n of this.visible()) this.#renderNode(n);
557
- this.#renderEdges();
558
- this.fit();
559
- this.#syncHud();
560
- }
561
-
562
469
  setSession(sessionId) {
563
470
  if (this.sessionKey === sessionId) return;
564
471
  this.sessionKey = sessionId;
565
- this.nodes.clear(); this.pos.clear(); this.pinned.clear(); this.collapsed.clear();
566
- this.els.clear(); this.nodeLayer.replaceChildren(); this.edgeG.replaceChildren();
472
+ this.nodes.clear(); this.pos.clear(); this.pinned.clear(); this.open.clear(); this.seq.clear();
473
+ this.els.clear(); this.cellEls.clear(); this.nodeLayer.replaceChildren(); this.edgeG.replaceChildren();
567
474
  this.edgeEls.clear(); this.newborn.clear();
475
+ this.mapRects.clear(); this.mapBoxes.replaceChildren();
568
476
  this.selected = null;
477
+ this.focusId = null;
478
+ this.lastGraph = null;
479
+ this.lastPlan = null;
480
+ this.sessionY = null;
481
+ this.sessionState = null;
482
+ this.waiting = new Set();
569
483
  this.firstRender = true;
484
+ this.touched = false;
485
+ this.group = 'run';
570
486
  this.#restore();
571
487
  }
572
488
 
573
489
  render(graph) {
490
+ this.lastGraph = graph;
574
491
  if (!graph?.nodes?.length) {
575
492
  this.emptyEl.hidden = false;
576
- this.emptyEl.innerHTML =
577
- '<strong>No agents yet</strong><div>This session has not delegated to a subagent. ' +
578
- 'The moment it does, a node appears here.</div>';
579
- // els must go with the DOM. Leaving stale elements behind made
580
- // #renderNode believe they were still mounted, so nodes never returned.
493
+ this.emptyEl.replaceChildren(mk('strong', null, 'No agents yet'),
494
+ mk('div', null, 'This session has not delegated to a subagent. The moment it does, a card appears here.'));
495
+ // els must go with the DOM. Leaving stale elements behind made the next render believe they were still
496
+ // mounted, so cards never returned.
581
497
  this.nodeLayer.replaceChildren(); this.edgeG.replaceChildren();
582
- this.nodes.clear(); this.els.clear(); this.pos.clear();
583
- this.edgeEls.clear(); this.newborn.clear();
498
+ this.nodes.clear(); this.els.clear(); this.cellEls.clear(); this.pos.clear();
499
+ this.edgeEls.clear(); this.newborn.clear(); this.seq.clear();
500
+ this.mapRects.clear(); this.mapBoxes.replaceChildren();
584
501
  this.selected = null;
585
502
  this.drawn = 0;
503
+ this.lastPlan = null;
504
+ this.mapEl.hidden = true;
586
505
  this.onSelect(null);
587
506
  this.applyView();
588
- this.#syncHud();
589
507
  return;
590
508
  }
591
509
  this.emptyEl.hidden = true;
592
510
 
593
511
  const seen = new Set();
594
512
  let born = false;
595
-
596
513
  for (const n of graph.nodes) {
597
514
  seen.add(n.id);
598
- const isNew = !this.nodes.has(n.id);
515
+ if (!this.nodes.has(n.id)) { born = true; this.newborn.add(n.id); }
599
516
  this.nodes.set(n.id, n);
600
- if (isNew) { born = true; this.newborn.add(n.id); }
601
517
  }
602
-
603
518
  for (const id of [...this.nodes.keys()]) {
604
- if (!seen.has(id)) {
605
- this.nodes.delete(id);
606
- this.els.get(id)?.remove();
607
- this.els.delete(id);
608
- this.pos.delete(id);
609
- this.pinned.delete(id);
610
- // The inspector must not keep describing a node that is gone.
611
- if (this.selected === id) { this.selected = null; this.onSelect(null); }
612
- }
519
+ if (seen.has(id)) continue;
520
+ this.nodes.delete(id);
521
+ this.pinned.delete(id);
522
+ // The inspector must not keep describing a node that is gone.
523
+ if (this.selected === id) { this.selected = null; this.onSelect(null); }
524
+ if (this.focusId === id) this.focusId = null;
613
525
  }
526
+ sequence(graph.nodes, this.seq);
614
527
 
615
- if (this.firstRender) this.#autoFold();
616
- this.layout();
617
- this.edges = graph.edges ?? [];
528
+ if (this.firstRender) this.#crowdFold();
529
+ // Fit once when a session opens, so the graph is never half off-screen. After that the view is the
530
+ // viewer's: re-fitting under them as agents appear would yank the canvas mid-read.
531
+ this.#redraw({ fit: this.firstRender || (born && !this.touched && !this.pinned.size) });
532
+ this.firstRender = false;
533
+ }
618
534
 
619
- const vis = new Set(this.visible().map((n) => n.id));
620
- for (const [id, el] of this.els) {
621
- if (!vis.has(id)) { el.remove(); this.els.delete(id); }
535
+ /** Lay out and draw whatever is known now. */
536
+ #redraw({ fit = false } = {}) {
537
+ if (!this.nodes.size) return;
538
+ const p = this.#plan();
539
+ this.lastPlan = p;
540
+ this.sessionY = p.sessionY;
541
+ this.drawn = p.drawn;
542
+ this.root.dataset.density = p.density;
543
+
544
+ const live = new Set();
545
+ const liveCells = new Set();
546
+ this.pos.clear();
547
+ for (const it of p.items) {
548
+ live.add(it.id);
549
+ this.pos.set(it.id, { x: it.x, y: it.y });
550
+ this.#drawItem(it, liveCells);
622
551
  }
623
- for (const n of this.visible()) this.#renderNode(n);
624
- this.#renderEdges();
625
-
626
- // Fit once when a session opens, so the graph is never half off-screen.
627
- // After that the view is the user's: re-fitting under them as agents
628
- // appear would yank the canvas mid-drag.
629
- if (this.firstRender) {
630
- this.firstRender = false;
631
- this.fit();
632
- } else if (born && !this.pinned.size) {
633
- this.fit();
634
- }
635
- // Neither branch above is guaranteed to run, and both the drawn count and
636
- // the failure count can have changed on any poll.
637
- this.applyView();
638
- this.#syncHud();
552
+ for (const [id, el] of this.els) if (!live.has(id)) { el.remove(); this.els.delete(id); }
553
+ for (const [id, el] of this.cellEls) if (!liveCells.has(id)) { el.remove(); this.cellEls.delete(id); }
554
+
555
+ this.#drawEdges(p);
556
+ this.#drawMap(p);
557
+ if (fit) this.fit(); else this.applyView();
639
558
  }
640
559
 
641
- #renderNode(n) {
642
- let el = this.els.get(n.id);
643
- const fresh = !el;
644
-
645
- if (fresh) {
646
- el = document.createElement('div');
647
- el.className = 'cv-node';
648
- el.dataset.id = n.id;
649
- el.innerHTML = `
650
- <span class="cv-port cv-in"></span>
651
- <span class="cv-port cv-out"></span>
652
- <div class="cv-accent"></div>
653
- <div class="cv-head">
654
- <button class="cv-fold" type="button" hidden></button>
655
- <svg class="cv-mark" viewBox="0 0 16 16" aria-hidden="true"></svg>
656
- <span class="cv-type"></span>
657
- <span class="cv-chip"></span>
658
- </div>
659
- <div class="cv-desc"></div>
660
- <div class="cv-foot"></div>`;
661
- // The card's children never change identity, so they are found once per
662
- // element instead of six times per element per poll — at 250 nodes on a
663
- // 2s poll that is 45,000 selector runs a minute for a fixed answer.
664
- el.parts = {
665
- fold: el.querySelector('.cv-fold'),
666
- mark: el.querySelector('.cv-mark'),
667
- type: el.querySelector('.cv-type'),
668
- chip: el.querySelector('.cv-chip'),
669
- desc: el.querySelector('.cv-desc'),
670
- foot: el.querySelector('.cv-foot'),
671
- };
560
+ /** The card's position and size, and whatever only changes with them. */
561
+ #place(el, it) {
562
+ const box = `${it.x},${it.y},${it.w},${it.h}`;
563
+ if (el.box === box) return;
564
+ el.box = box;
565
+ el.style.transform = `translate(${it.x}px, ${it.y}px)`;
566
+ el.style.width = `${it.w}px`;
567
+ el.style.height = `${it.h}px`;
568
+ }
569
+
570
+ #drawItem(it, liveCells) {
571
+ let el = this.els.get(it.id);
572
+ const shape = it.kind === 'group' ? `group:${it.open ? `open:${it.grid ? 'grid' : 'rows'}` : 'folded'}`
573
+ : `${it.kind}:${it.compact ? 'chip' : 'card'}`;
574
+ // A card that changes what it IS (an agent that becomes a chip, a group that opens) is rebuilt; one that only
575
+ // changes what it says is updated in place.
576
+ if (el && el.shape !== shape) {
577
+ el.remove();
578
+ this.els.delete(it.id);
579
+ if (it.kind === 'group') for (const m of it.members) { this.cellEls.get(m.id)?.remove(); this.cellEls.delete(m.id); }
580
+ el = null;
581
+ }
582
+ if (!el) {
583
+ el = mk('div', 'cv-node');
584
+ el.shape = shape;
585
+ el.dataset.id = it.id;
586
+ el.dataset.kind = it.kind === 'run-card' ? 'run' : it.kind;
587
+ el.tabIndex = 0;
588
+ this.#skeleton(el, it);
672
589
  this.nodeLayer.append(el);
673
- this.els.set(n.id, el);
674
- this.#makeDraggable(el, n.id);
675
- el.parts.fold.addEventListener('click', (ev) => {
676
- ev.stopPropagation();
677
- if (this.collapsed.has(n.id)) this.collapsed.delete(n.id); else this.collapsed.add(n.id);
678
- this.#redraw();
679
- });
680
- // A drag ends with a click on the same element. Without this guard every
681
- // reposition also toggled selection and fired a report fetch.
682
- el.addEventListener('click', () => {
683
- if (el.dataset.suppressClick === '1') { el.dataset.suppressClick = '0'; return; }
684
- // A container opens on click — the fold control is a 20px target and
685
- // the card is the obvious one, so a folded workflow used to look like a
686
- // dead end with its agents nowhere to be found.
687
- //
688
- // The session node is NOT one of those, even though it has children.
689
- // It is the conversation, and clicking it should open that; folding it
690
- // is what its caret is for. Treating every parent alike meant a click
691
- // on the session hid the whole graph.
692
- const node = this.nodes.get(n.id);
693
- if (node && node.kind !== 'session' && this.hiddenCount(n.id) > 0) {
694
- if (this.collapsed.has(n.id)) this.collapsed.delete(n.id); else this.collapsed.add(n.id);
695
- this.#redraw();
696
- return;
697
- }
698
- this.#select(n.id);
699
- });
700
- // Birth animation, only for nodes that actually just appeared.
701
- el.classList.add('cv-born');
702
- requestAnimationFrame(() => requestAnimationFrame(() => el.classList.remove('cv-born')));
590
+ this.els.set(it.id, el);
591
+ this.#interact(el, it.id);
592
+ // Birth, only for nodes that actually just appeared.
593
+ if (this.newborn.has(it.id) && !this.firstRender) {
594
+ el.classList.add('cv-born');
595
+ requestAnimationFrame(() => requestAnimationFrame(() => el.classList.remove('cv-born')));
596
+ }
703
597
  }
598
+ this.#place(el, it);
599
+ el.classList.toggle('cv-selected', this.selected === it.id);
600
+ el.classList.toggle('cv-pinned', Boolean(it.pinned));
601
+ if (it.kind === 'session') this.#fillSession(el, it);
602
+ else if (it.kind === 'group') this.#fillGroup(el, it, liveCells);
603
+ else this.#fillAgent(el, it.node, it.compact);
604
+ }
704
605
 
705
- const p = this.pos.get(n.id) ?? { x: 0, y: 0 };
706
- el.style.transform = `translate(${p.x}px, ${p.y}px)`;
707
- el.dataset.kind = n.kind;
708
- el.dataset.status = n.status ?? 'unknown';
709
- el.classList.toggle('cv-selected', this.selected === n.id);
710
-
711
- const known = n.kind !== 'agent' || Boolean(this.palette.map[n.agentType]);
712
- el.style.setProperty('--node-color', this.nodeColor(n));
713
- // What motion says about this node, separated from the raw status so that
714
- // `killed` and `stopped` read like the failures they are instead of like
715
- // two more words nothing has a rule for.
716
- el.dataset.state = FLOW_STATE[n.status] ?? 'unknown';
717
-
718
- const typeText = n.kind === 'session' ? 'SESSION'
719
- : n.kind === 'workflow' ? 'WORKFLOW'
720
- : (n.agentType ?? 'unknown agent');
721
- const typeEl = el.parts.type;
722
- typeEl.textContent = typeText;
723
- // Two names the stylesheet draws counter-scaled, so that what survives a
724
- // zoom-out is drawn at a constant size on the reader's screen rather than
725
- // shrunk into grey. The far one carries the running tool, because "what is
726
- // it doing" is exactly that string.
727
- typeEl.dataset.short = n.kind === 'agent' ? shortType(n.agentType ?? 'unknown') : typeText;
728
- typeEl.dataset.far = typeEl.dataset.short
729
- + (n.status === 'running' && n.lastTool ? ` \u25b8 ${n.lastTool}` : '');
730
-
731
- // Whose agent this is, at a glance. The palette says where a type was
732
- // declared; anything it does not know is drawn as unknown rather than
733
- // guessed into one camp or the other.
734
- const source = n.kind === 'session' ? 'session'
735
- : n.kind === 'workflow' ? 'workflow'
736
- : (this.palette.map[n.agentType]?.source ?? null);
737
- const markEl = el.parts.mark;
738
- markEl.innerHTML = MARKS[source === 'kit' ? 'kit' : source === 'builtin' ? 'builtin' : source] ?? MARKS.builtin;
739
- markEl.classList.toggle('cv-mark-unknown', n.kind === 'agent' && !source);
740
- markEl.setAttribute('aria-label', source === 'kit' ? 'Crewforth agent' : 'built-in agent');
741
- el.dataset.source = source ?? 'unknown';
742
- // An agent type Crewforth never declared is marked, not quietly coloured in.
743
- typeEl.classList.toggle('cv-unknown', !known);
744
-
745
- // Fold control, shown only where there is something to fold.
746
- const kids = [...this.nodes.values()].filter((c) => c.parentId === n.id).length;
747
- const fold = el.parts.fold;
748
- if (kids) {
749
- fold.hidden = false;
750
- const folded = this.collapsed.has(n.id);
751
- const hidden = folded ? this.hiddenCount(n.id) : 0;
752
- fold.textContent = folded ? `▸ ${hidden}` : '▾';
753
- fold.title = folded ? `Show ${hidden} hidden node(s)` : `Fold ${kids} child node(s)`;
754
- el.classList.toggle('cv-folded', folded);
755
- // Say what a click does, since for these nodes it is not selection.
756
- if (n.kind === 'session') {
757
- el.title = 'Click to open this conversation';
758
- el.classList.remove('cv-container');
606
+ /** The skeleton of a card: the parts it will fill. */
607
+ #skeleton(el, it) {
608
+ const p = {};
609
+ if (it.kind === 'session') {
610
+ el.classList.add('cv-session');
611
+ p.tile = mk('span', 'cv-tile');
612
+ p.tile.innerHTML = MARK.session;
613
+ p.pill = this.#pill();
614
+ const row = mk('div', 'cv-row');
615
+ row.append(p.tile, mk('span', 'cv-title', 'Session'), mk('span', 'cv-fill'), p.pill.el);
616
+ p.name = mk('div', 'cv-name cv-indent');
617
+ p.sub = mk('div', 'cv-sub cv-indent');
618
+ el.append(row, p.name, p.sub);
619
+ } else if (it.kind === 'group') {
620
+ el.classList.add('cv-group');
621
+ p.tile = mk('span', 'cv-tile');
622
+ p.name = mk('span', 'cv-name');
623
+ p.sub = mk('span', 'cv-sub');
624
+ p.fold = mk('button', 'cv-fold');
625
+ p.fold.type = 'button';
626
+ p.head = mk('div', 'cv-row cv-group-head');
627
+ p.head.append(p.tile, p.name, mk('span', 'cv-fill'), p.sub, p.fold);
628
+ p.bar = mk('div', 'cv-bar');
629
+ p.count = mk('span', 'cv-sub');
630
+ if (it.open) {
631
+ el.classList.add('cv-open');
632
+ el.append(p.head, p.bar);
759
633
  } else {
760
- el.title = folded ? `Click to show ${hidden} node(s) inside` : 'Click to fold';
761
- el.classList.add('cv-container');
634
+ const row = mk('div', 'cv-row cv-indent');
635
+ row.append(p.bar, p.count);
636
+ el.append(p.head, row);
762
637
  }
638
+ p.fold.addEventListener('click', (e) => { e.stopPropagation(); this.toggle(it.id); });
639
+ } else if (it.compact) {
640
+ el.classList.add('cv-chip');
641
+ p.dot = mk('span', 'dot');
642
+ p.name = mk('span', 'cv-name');
643
+ p.task = mk('span', 'cv-task');
644
+ p.word = mk('span', 'cv-word');
645
+ el.append(p.dot, p.name, p.task, p.word);
763
646
  } else {
764
- fold.hidden = true;
765
- el.classList.remove('cv-folded', 'cv-container');
766
- el.title = '';
647
+ p.tile = mk('span', 'cv-tile');
648
+ p.name = mk('span', 'cv-name');
649
+ p.pill = this.#pill();
650
+ const row = mk('div', 'cv-row');
651
+ row.append(p.tile, p.name, mk('span', 'cv-fill'), p.pill.el);
652
+ p.task = mk('div', 'cv-task cv-indent');
653
+ el.append(row, p.task);
767
654
  }
655
+ el.parts = p;
656
+ }
768
657
 
769
- const chip = el.parts.chip;
770
- chip.textContent = n.kind === 'session' ? `${n.turns ?? 0} turns`
771
- : n.kind === 'workflow' ? `${n.members ?? 0} agents`
772
- : (n.status ?? '?');
773
- chip.dataset.status = n.status ?? 'unknown';
774
-
775
- const desc = n.kind === 'session' ? (shortPath(n.cwd) || n.sessionId)
776
- : n.kind === 'workflow' ? (n.workflowId ?? 'workflow run')
777
- : (n.description ?? '—');
778
- el.parts.desc.textContent = desc;
779
-
780
- el.parts.foot.replaceChildren(...this.#footBits(n));
781
-
782
- // The card's whole name, at every reading. The two smaller readings drop
783
- // the type line to font-size:0 and redraw it from a pseudo-element, and
784
- // neither of those is reliably exposed to a screen reader — so without this
785
- // the far band would ship as an accessibility regression rather than as a
786
- // rendering change.
787
- el.setAttribute('aria-label', `${typeText}, ${n.status ?? 'unknown'}${desc ? `, ${desc}` : ''}`);
658
+ #pill() {
659
+ const el = mk('span', 'cv-pill');
660
+ const dot = mk('span', 'dot');
661
+ const word = mk('span', 'cv-pill-word');
662
+ el.append(dot, word);
663
+ return { el, dot, word };
788
664
  }
789
665
 
790
- #footBits(n) {
791
- const bits = [];
792
- const add = (text, cls) => {
793
- const s = document.createElement('span');
794
- s.className = `cv-bit${cls ? ` ${cls}` : ''}`;
795
- s.textContent = text;
796
- bits.push(s);
797
- };
666
+ /** The word and the colour an agent's status is drawn in. Waiting on the viewer outranks what the transcript
667
+ * says, because that is what the agent is doing now. */
668
+ #statusOf(n) {
669
+ if (this.waiting.has(n.id)) return { word: 'Needs you', tone: 'waiting', known: true, state: 'waiting' };
670
+ const s = agentStatus(n.status);
671
+ // A status this panel has no word for keeps its own, exactly as the transcript wrote it.
672
+ return { word: s.known ? cap(s.word) : s.word, tone: s.tone, known: s.known, state: stateOf(n.status) };
673
+ }
798
674
 
799
- if (n.kind === 'workflow') {
800
- // The breakdown is the point of a folded group: how it went, not just
801
- // how big it was.
802
- for (const [st, c] of Object.entries(n.byStatus ?? {})) add(`${c} ${st}`, st === 'running' ? 'cv-live' : null);
803
- if (n.tokens) add(fmtTokens(n.tokens));
804
- if (n.durationMs != null) add(fmtDuration(n.durationMs));
805
- return bits;
675
+ /**
676
+ * Whose agent this is. The palette says where a type was declared; a type it does not know is drawn as unknown
677
+ * rather than guessed into one camp or the other, and a palette that could not be read makes every agent
678
+ * unknown, with the reason.
679
+ */
680
+ #identity(n) {
681
+ if (this.palette.measured === false) {
682
+ return { source: 'unknown', title: `Agent identity not measured — ${this.palette.reason ?? 'no reason given'}` };
806
683
  }
684
+ const source = this.palette.map?.[n.agentType]?.source ?? null;
685
+ if (source === 'kit') return { source: 'kit', title: 'Crewforth agent' };
686
+ if (source === 'builtin') return { source: 'builtin', title: 'Claude Code built-in agent' };
687
+ return { source: 'unknown', title: 'Not declared by Crewforth or by Claude Code — an agent type this panel does not recognise' };
688
+ }
807
689
 
808
- if (n.kind === 'session') {
809
- if (n.model) add(n.model);
810
- if (n.tokens != null) add(`${fmtTokens(n.tokens)} ctx`);
811
- if (n.gitBranch) add(n.gitBranch, 'cv-branch');
812
- return bits;
690
+ #fillTile(tile, n) {
691
+ const who = this.#identity(n);
692
+ const sig = `${who.source}|${who.title}|${this.icons.builtin ? 1 : 0}`;
693
+ if (tile.sig === sig) return who;
694
+ tile.sig = sig;
695
+ tile.innerHTML = who.source === 'kit' ? MARK.kit : (this.icons.builtin ?? '');
696
+ tile.dataset.source = who.source;
697
+ tile.title = who.title;
698
+ return who;
699
+ }
700
+
701
+ #fillSession(el, it) {
702
+ const n = it.node;
703
+ const st = this.sessionState;
704
+ const sig = JSON.stringify([n.gitBranch, n.cwd, n.turns, n.tokens, st?.word, st?.tone]);
705
+ if (el.sig === sig) return;
706
+ el.sig = sig;
707
+ const p = el.parts;
708
+ el.dataset.state = 'root';
709
+ p.pill.el.hidden = !st?.word;
710
+ p.pill.dot.dataset.tone = st?.tone ?? 'none';
711
+ p.pill.word.textContent = st?.word ?? '';
712
+ p.name.textContent = n.gitBranch || shortPath(n.cwd) || n.sessionId || '';
713
+ const bits = [`${n.turns ?? 0} ${n.turns === 1 ? 'turn' : 'turns'}`];
714
+ if (n.tokens != null) bits.push(`${fmtTokens(n.tokens)} ctx`);
715
+ p.sub.textContent = bits.join(' · ');
716
+ el.title = 'Click to inspect this session';
717
+ el.setAttribute('aria-label', `Session, ${st?.word ?? 'state not measured'}, ${p.name.textContent}, ${p.sub.textContent}`);
718
+ }
719
+
720
+ #fillAgent(el, n, compact) {
721
+ const st = this.#statusOf(n);
722
+ const who = compact ? this.#identity(n) : this.#fillTile(el.parts.tile, n);
723
+ const type = n.kind === 'workflow' ? (n.workflowId ?? 'workflow run') : (n.agentType ?? 'unknown agent');
724
+ const task = n.kind === 'workflow' ? `${n.members ?? 0} agents` : (n.description ?? '');
725
+ const dim = this.dimmed(n);
726
+ const sig = JSON.stringify([type, task, st.word, st.tone, st.state, who.source, dim, n.status]);
727
+ if (el.sig === sig) return;
728
+ el.sig = sig;
729
+ const p = el.parts;
730
+ el.dataset.status = n.status ?? 'unknown';
731
+ // What motion and the frame say about this card, separated from the raw status so that `killed` and
732
+ // `stopped` read like the failures they are instead of like two more words nothing has a rule for.
733
+ el.dataset.state = st.state;
734
+ el.dataset.source = who.source;
735
+ el.classList.toggle('cv-dim', dim);
736
+ p.name.textContent = type;
737
+ p.task.textContent = task;
738
+ if (compact) {
739
+ p.dot.dataset.tone = st.tone ?? 'none';
740
+ p.word.textContent = st.word;
741
+ } else {
742
+ p.pill.dot.dataset.tone = st.tone ?? 'none';
743
+ p.pill.word.textContent = st.word;
744
+ p.pill.el.classList.toggle('cv-unknown', !st.known);
813
745
  }
746
+ // The card's whole name, whatever it is drawn as: a chip shows less than a card, and what it leaves out
747
+ // still has to reach a screen reader.
748
+ el.setAttribute('aria-label', `${type}, ${st.word}${task ? `, ${task}` : ''} (${who.title})`);
749
+ }
814
750
 
815
- if (n.status === 'running' && n.lastTool) add(`▸ ${n.lastTool}`, 'cv-live');
816
- else if (n.lastTool) add(n.lastTool);
817
- if (n.toolCount) add(`${n.toolCount} calls`);
818
- if (n.tokens != null) add(fmtTokens(n.tokens));
819
- if (n.durationMs != null) add(fmtDuration(n.durationMs));
820
- if (n.errors) add(`${n.errors} err`, 'cv-err');
821
- return bits;
751
+ #fillGroup(el, it, liveCells) {
752
+ const p = el.parts;
753
+ const counts = it.counts;
754
+ const says = Object.entries(counts).map(([s, c]) => `${c} ${agentStatus(s).word}`).join(' · ');
755
+ const sig = JSON.stringify([it.label, says, it.open, it.type, it.status]);
756
+ if (el.sig !== sig) {
757
+ el.sig = sig;
758
+ el.dataset.state = stateOf(it.status);
759
+ el.dataset.status = it.status;
760
+ el.dataset.group = it.type;
761
+ el.dataset.open = String(Boolean(it.open));
762
+ p.name.textContent = it.label;
763
+ if (it.type === 'run') { p.tile.innerHTML = MARK.run; p.tile.title = 'Workflow run'; }
764
+ else this.#fillTile(p.tile, it.type === 'type' ? { agentType: it.agentType } : (it.of ?? {}));
765
+ p.sub.textContent = it.type === 'type' ? '' : `${it.members.length} ${it.members.length === 1 ? 'agent' : 'agents'}`;
766
+ p.fold.innerHTML = it.open ? MARK.down : MARK.chevron;
767
+ p.fold.setAttribute('aria-label', `${it.open ? 'Fold' : 'Open'} ${it.label}`);
768
+ p.fold.setAttribute('aria-expanded', String(Boolean(it.open)));
769
+ // The bar: how the group went, in the order the eye wants it.
770
+ const order = ['done', 'running', 'starting', 'failed', 'killed', 'stopped', 'stale', 'ended'];
771
+ const keys = Object.keys(counts).sort((a, b) => (order.indexOf(a) + 99) % 99 - (order.indexOf(b) + 99) % 99);
772
+ p.bar.replaceChildren(...keys.map((s) => {
773
+ const seg = mk('span');
774
+ seg.style.flexGrow = String(counts[s]);
775
+ seg.dataset.tone = agentStatus(s).tone ?? 'none';
776
+ return seg;
777
+ }));
778
+ p.bar.title = says;
779
+ if (!it.open) p.count.textContent = says;
780
+ el.title = it.open ? '' : `Click to show ${it.members.length} agents`;
781
+ el.setAttribute('aria-label', `${it.label}, ${says}, ${it.open ? 'open' : 'folded'}`);
782
+ }
783
+ if (!it.open) return;
784
+
785
+ for (const cell of it.cells) {
786
+ liveCells.add(cell.id);
787
+ let c = this.cellEls.get(cell.id);
788
+ const shape = it.grid ? 'chip' : 'row';
789
+ if (c && (c.shape !== shape || c.host !== el)) { c.remove(); this.cellEls.delete(cell.id); c = null; }
790
+ if (!c) {
791
+ c = mk('div', `cv-member cv-member-${shape}`);
792
+ c.shape = shape;
793
+ c.host = el;
794
+ c.dataset.id = cell.id;
795
+ c.tabIndex = 0;
796
+ const q = {};
797
+ if (!it.grid) { q.tile = mk('span', 'cv-tile'); c.append(q.tile); } else { q.dot = mk('span', 'dot'); c.append(q.dot); }
798
+ q.name = mk('span', 'cv-name');
799
+ c.append(q.name);
800
+ if (!it.grid) { c.append(mk('span', 'cv-fill')); q.dot = mk('span', 'dot'); c.append(q.dot); }
801
+ q.word = mk('span', 'cv-word');
802
+ c.append(q.word);
803
+ c.parts = q;
804
+ el.append(c);
805
+ this.cellEls.set(cell.id, c);
806
+ this.#interactCell(c, cell.id);
807
+ }
808
+ const box = `${cell.x},${cell.y},${cell.w},${cell.h}`;
809
+ if (c.box !== box) {
810
+ c.box = box;
811
+ c.style.transform = `translate(${cell.x}px, ${cell.y}px)`;
812
+ c.style.width = `${cell.w}px`;
813
+ c.style.height = `${cell.h}px`;
814
+ }
815
+ const n = cell.node;
816
+ const st = this.#statusOf(n);
817
+ const who = it.grid ? this.#identity(n) : this.#fillTile(c.parts.tile, n);
818
+ // Inside a group of one type the type is the group's name, so a member is told apart by its task.
819
+ const text = it.type === 'type' ? (n.description || n.agentType || n.id) : (n.agentType ?? 'unknown agent');
820
+ const dim = this.dimmed(n);
821
+ const csig = JSON.stringify([text, st.word, st.tone, st.state, dim, this.selected === n.id]);
822
+ if (c.sig === csig) continue;
823
+ c.sig = csig;
824
+ c.dataset.state = st.state;
825
+ c.dataset.status = n.status ?? 'unknown';
826
+ c.classList.toggle('cv-dim', dim);
827
+ c.classList.toggle('cv-selected', this.selected === n.id);
828
+ c.parts.name.textContent = text;
829
+ c.parts.dot.dataset.tone = st.tone ?? 'none';
830
+ c.parts.word.textContent = st.word;
831
+ c.setAttribute('aria-label', `${n.agentType ?? 'unknown agent'}, ${st.word}${n.description ? `, ${n.description}` : ''} (${who.title})`);
832
+ }
822
833
  }
823
834
 
824
- // Edges are kept and updated, never rebuilt.
835
+ // Wires are kept and updated, never rebuilt.
825
836
  //
826
- // Replacing the whole layer each pass was the reason motion could not live
827
- // here: a CSS animation restarts when its element leaves the document, so a
828
- // travelling dash jumped back to its start on every 2s poll and on every
829
- // frame of a drag. Reusing the path element is what lets the stylesheet own
830
- // the animation and JS own nothing but geometry.
831
- #renderEdges() {
832
- const vis = new Set(this.visible().map((n) => n.id));
837
+ // Replacing the whole layer each pass was the reason motion could not live here: a CSS animation restarts when
838
+ // its element leaves the document, so a travelling dash jumped back to its start on every 2s poll and on every
839
+ // frame of a drag. Reusing the path element is what lets the stylesheet own the animation and JS own nothing
840
+ // but geometry.
841
+ #drawEdges(p) {
833
842
  const alive = new Set();
834
843
  let moving = 0;
844
+ const onPath = this.#pathTo(this.selected);
835
845
 
836
- for (const e of this.edges) {
837
- if (!vis.has(e.source) || !vis.has(e.target)) continue;
838
- const a = this.pos.get(e.source);
839
- const b = this.pos.get(e.target);
840
- if (!a || !b) continue;
841
-
842
- const key = `${e.source}\u0000${e.target}`;
843
- alive.add(key);
844
-
845
- // Out of the parent's outgoing port, into the child's incoming one.
846
- // Control points are pushed along the flow axis so siblings fan out
847
- // instead of stacking on one line.
848
- const down = this.flow === 'down';
849
- const x1 = down ? a.x + NODE_W / 2 : a.x + NODE_W;
850
- const y1 = down ? a.y + NODE_H : a.y + NODE_H / 2;
851
- const x2 = down ? b.x + NODE_W / 2 : b.x;
852
- const y2 = down ? b.y : b.y + NODE_H / 2;
853
- const d = Math.max(46, Math.abs((down ? y2 - y1 : x2 - x1)) * 0.55);
854
-
855
- let path = this.edgeEls.get(key);
846
+ for (const e of p.edges) {
847
+ alive.add(e.key);
848
+ let path = this.edgeEls.get(e.key);
856
849
  const fresh = !path;
857
850
  if (fresh) {
858
- path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
851
+ path = document.createElementNS(SVG, 'path');
859
852
  path.setAttribute('class', 'cv-edge');
860
- this.edgeEls.set(key, path);
853
+ this.edgeEls.set(e.key, path);
861
854
  this.edgeG.append(path);
862
855
  }
863
856
 
864
- path.setAttribute('d', down
865
- ? `M ${x1} ${y1} C ${x1} ${y1 + d}, ${x2} ${y2 - d}, ${x2} ${y2}`
866
- : `M ${x1} ${y1} C ${x1 + d} ${y1}, ${x2 - d} ${y2}, ${x2} ${y2}`);
867
-
868
- // The draw-in has to cover the whole curve without measuring it —
869
- // getTotalLength() forces a synchronous layout, once per edge. A cubic is
870
- // never longer than its control polygon, so that bound is computed from
871
- // the numbers already in hand and handed to CSS as a length.
872
- path.style.setProperty('--edge-len', `${Math.ceil(Math.hypot(x2 - x1, y2 - y1) + 2 * d)}px`);
857
+ // Out of the right edge of what spawned it, into the left edge of the card. The control points are pushed
858
+ // along the flow so siblings fan out instead of stacking on one line.
859
+ const { from, to } = e;
860
+ const c = Math.max(24, Math.abs(to.x - from.x) * 0.55);
861
+ const d = `M ${from.x} ${from.y} C ${from.x + c} ${from.y}, ${to.x - c} ${to.y}, ${to.x} ${to.y}`;
862
+ if (path.d !== d) {
863
+ path.d = d;
864
+ path.setAttribute('d', d);
865
+ // The draw-in has to cover the whole curve without measuring it — getTotalLength() forces a synchronous
866
+ // layout, once per wire. A cubic is never longer than its control polygon, so that bound is computed
867
+ // from the numbers already in hand and handed to CSS as a length.
868
+ path.style.setProperty('--edge-len', `${Math.ceil(Math.hypot(to.x - from.x, to.y - from.y) + 2 * c)}px`);
869
+ }
873
870
 
874
871
  const target = this.nodes.get(e.target);
875
- const parent = this.nodes.get(e.source);
876
- const state = FLOW_STATE[target?.status] ?? 'unknown';
877
- path.dataset.status = target?.status ?? 'unknown';
872
+ const waiting = target && this.waiting.has(target.id);
873
+ const state = waiting ? 'waiting' : e.state;
874
+ path.dataset.status = e.status;
878
875
  path.dataset.state = state;
879
-
880
- // A workflow's twelve members are one dispatch, not twelve unrelated
881
- // decisions. Blending each member's own colour toward the container's
882
- // pulls the whole bundle into one hue family while still leaving every
883
- // line traceable back to the card it feeds.
884
- const member = parent?.kind === 'workflow';
885
- path.dataset.group = member ? 'member' : 'spawn';
886
- const own = this.nodeColor(target);
887
- path.style.stroke = state === 'failed' ? 'var(--cv-fail)'
888
- : member ? mixHex(own, this.nodeColor(parent), 0.45)
889
- : own;
876
+ path.classList.toggle('cv-dim', Boolean(target) && this.dimmed(target));
877
+ path.classList.toggle('cv-path', onPath.has(e.target));
890
878
 
891
879
  if (state === 'live' || state === 'waking') moving += 1;
892
880
 
893
- // Only an edge into a node that just arrived draws itself. Unfolding a
894
- // group is not an arrival, so opening a 105-agent workflow reveals it
895
- // rather than performing it.
881
+ // Only a wire into a node that just arrived draws itself. Opening a group is not an arrival, so opening a
882
+ // 105-agent run reveals it rather than performing it.
896
883
  if (fresh && this.newborn.has(e.target)) {
897
884
  path.classList.add('cv-drawing');
898
885
  setTimeout(() => path.classList.remove('cv-drawing'), DRAW_MS);
@@ -906,156 +893,285 @@ export class Canvas {
906
893
  }
907
894
  this.newborn.clear();
908
895
 
909
- // Only travelling strokes are counted. The card pulse rides the same flag,
910
- // but it animates opacity on a pseudo-element and costs the compositor
911
- // almost nothing, so charging a running agent twice — once for its edge and
912
- // once for its card — would halve the budget for no reason anyone measured.
896
+ // Only travelling strokes are counted. The card pulse rides the same flag, but it animates opacity on a
897
+ // pseudo-element and costs the compositor almost nothing, so charging a running agent twice — once for its
898
+ // wire and once for its card — would halve the budget for no reason anyone measured.
913
899
  this.root.dataset.motion = moving > MOTION_BUDGET ? 'still' : 'flow';
914
900
 
915
- // The SVG plane must cover every node, including negative coordinates.
916
- let minX = 0, minY = 0, maxX = 0, maxY = 0;
917
- for (const p of this.pos.values()) {
918
- minX = Math.min(minX, p.x - 100); minY = Math.min(minY, p.y - 100);
919
- maxX = Math.max(maxX, p.x + NODE_W + 100); maxY = Math.max(maxY, p.y + NODE_H + 100);
901
+ const w = Math.max(1, Math.ceil(p.width + 200));
902
+ const h = Math.max(1, Math.ceil(p.height + 200));
903
+ if (this.svg.box !== `${w},${h}`) {
904
+ this.svg.box = `${w},${h}`;
905
+ this.svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
906
+ this.svg.style.width = `${w}px`;
907
+ this.svg.style.height = `${h}px`;
920
908
  }
921
- this.svg.setAttribute('viewBox', `${minX} ${minY} ${maxX - minX} ${maxY - minY}`);
922
- this.svg.style.left = `${minX}px`;
923
- this.svg.style.top = `${minY}px`;
924
- this.svg.style.width = `${maxX - minX}px`;
925
- this.svg.style.height = `${maxY - minY}px`;
926
909
  }
927
910
 
928
- /** Re-lay out and repaint after folding changed what is visible. */
929
- #redraw() {
930
- this.layout(true);
931
- const vis = new Set(this.visible().map((x) => x.id));
932
- for (const [id, el] of this.els) if (!vis.has(id)) { el.remove(); this.els.delete(id); }
933
- for (const v of this.visible()) this.#renderNode(v);
934
- this.#renderEdges();
935
- // Unfolding 105 agents puts most of them off-screen; pull the view back to
936
- // what was just revealed, unless the user has arranged things by hand.
937
- this.fitIfUntouched();
938
- // fitIfUntouched() is a no-op once a node has been dragged, and folding is
939
- // exactly the thing that changes the drawn count the reading band is a
940
- // function of. Republish it either way.
941
- this.applyView();
942
- this.#syncHud();
911
+ /** The drawn items on the way from the session to `id`, `id` included. */
912
+ #pathTo(id) {
913
+ const out = new Set();
914
+ if (!id) return out;
915
+ const byId = new Map((this.lastPlan?.items ?? []).map((it) => [it.id, it]));
916
+ let cur = byId.get(id);
917
+ if (!cur) {
918
+ // A member inside an open group: the wire that leads to it is the one into its group.
919
+ cur = (this.lastPlan?.items ?? []).find((it) => it.kind === 'group' && it.members.some((m) => m.id === id)) ?? null;
920
+ }
921
+ for (let guard = 0; cur && guard < 32; guard += 1) { out.add(cur.id); cur = cur.parent ? byId.get(cur.parent.id) : null; }
922
+ return out;
943
923
  }
944
924
 
945
- /** What the HUD can say only after a render: how many branches went wrong.
946
- * With three red marks among 250 you can still be panned away from them. */
947
- #syncHud() {
948
- const bad = this.#failedIds();
949
- this.alarmBtn.hidden = bad.length === 0;
950
- this.alarmBtn.textContent = `\u26a0 ${bad.length}`;
951
- this.alarmBtn.title = bad.length === 1
952
- ? 'Go to the failed branch'
953
- : `Go to the next of ${bad.length} failed branches`;
925
+ /* ---------------------------------------------------------- minimap */
926
+
927
+ #mapScale(p) { return Math.min(200 / Math.max(1, p.width), 88 / Math.max(1, p.height)); }
928
+
929
+ #drawMap(p) {
930
+ // A graph that is all one box needs no map of itself.
931
+ this.mapEl.hidden = p.items.length < 2;
932
+ const s = this.#mapScale(p);
933
+ const live = new Set();
934
+ for (const it of p.items) {
935
+ live.add(it.id);
936
+ // Kept and moved, like the wires: a map rebuilt on every poll is 250 elements made and thrown away.
937
+ let r = this.mapRects.get(it.id);
938
+ if (!r) {
939
+ r = document.createElementNS(SVG, 'rect');
940
+ r.setAttribute('class', 'cv-map-box');
941
+ r.setAttribute('rx', '1.5');
942
+ this.mapRects.set(it.id, r);
943
+ this.mapBoxes.append(r);
944
+ }
945
+ // What needs someone stays findable at any zoom: its box keeps its colour here.
946
+ const members = it.kind === 'group' ? it.members : (it.node ? [it.node] : []);
947
+ const tone = members.some((m) => this.waiting.has(m.id)) ? 'waiting'
948
+ : members.some((m) => stateOf(m.status) === 'failed') ? 'fail'
949
+ : (it.id === this.selected || members.some((m) => m.id === this.selected)) ? 'selected' : 'none';
950
+ const sig = `${(it.x * s).toFixed(1)},${(it.y * s).toFixed(1)},${Math.max(2, it.w * s).toFixed(1)},${Math.max(2, it.h * s).toFixed(1)},${tone}`;
951
+ if (r.sig === sig) continue;
952
+ r.sig = sig;
953
+ const [x, y, w, h] = sig.split(',');
954
+ r.setAttribute('x', x);
955
+ r.setAttribute('y', y);
956
+ r.setAttribute('width', w);
957
+ r.setAttribute('height', h);
958
+ r.dataset.tone = tone;
959
+ }
960
+ for (const [id, r] of this.mapRects) if (!live.has(id)) { r.remove(); this.mapRects.delete(id); }
961
+ if (!this.mapSvg.sized) {
962
+ this.mapSvg.sized = true;
963
+ this.mapSvg.setAttribute('width', '200');
964
+ this.mapSvg.setAttribute('height', '88');
965
+ }
954
966
  }
955
967
 
956
- /** Drawn nodes that did not finish on their own terms, in a stable order so
957
- * that stepping through them twice visits them in the same sequence. */
958
- #failedIds() {
959
- return this.visible()
960
- .filter((n) => FLOW_STATE[n.status] === 'failed')
961
- .map((n) => n.id)
962
- .sort((a, b) => a.localeCompare(b));
968
+ #mapView() {
969
+ const p = this.lastPlan;
970
+ if (!p || !this.mapFrame) return;
971
+ const s = this.#mapScale(p);
972
+ const r = this.root.getBoundingClientRect();
973
+ const { x, y, k } = this.view;
974
+ const fx = clamp((-x / k) * s, 0, 200);
975
+ const fy = clamp((-y / k) * s, 0, 88);
976
+ this.mapFrame.setAttribute('x', fx.toFixed(1));
977
+ this.mapFrame.setAttribute('y', fy.toFixed(1));
978
+ this.mapFrame.setAttribute('width', clamp((r.width / k) * s, 4, 200 - fx).toFixed(1));
979
+ this.mapFrame.setAttribute('height', clamp((r.height / k) * s, 4, 88 - fy).toFixed(1));
963
980
  }
964
981
 
965
- /**
966
- * Step to the next failed branch, wrapping. Public for the same reason
967
- * applyView() is: the HUD button is its only caller in the page, and it has
968
- * to be drivable without a pointer.
969
- *
970
- * Selection is set rather than toggled — #select() flips a node that is
971
- * already chosen, which would make the button a no-op the second time it is
972
- * pressed on a lone failure.
973
- */
974
- gotoFailed() {
975
- const bad = this.#failedIds();
976
- if (!bad.length) return;
977
- const at = bad.indexOf(this.selected);
978
- const id = bad[(at + 1) % bad.length];
979
- this.#centreOn(id);
980
- this.selected = id;
981
- for (const [nid, el] of this.els) el.classList.toggle('cv-selected', nid === id);
982
- this.onSelect(this.nodes.get(id) ?? null);
983
- }
982
+ /* -------------------------------------------------------- selection */
984
983
 
985
- /** Put one node in the middle of the pane without changing the zoom — the
986
- * reading band must not move because the view panned. */
987
- #centreOn(id) {
988
- const p = this.pos.get(id);
989
- if (!p) return;
990
- const r = this.root.getBoundingClientRect();
991
- this.view.x = r.width / 2 - (p.x + NODE_W / 2) * this.view.k;
992
- this.view.y = r.height / 2 - (p.y + NODE_H / 2) * this.view.k;
993
- this.applyView();
984
+ /** Fold or open one group. */
985
+ toggle(groupId) {
986
+ const it = this.lastPlan?.items.find((x) => x.id === groupId);
987
+ if (!it || it.kind !== 'group') return;
988
+ this.open.set(groupId, !it.open);
989
+ this.sessionY = null;
990
+ this.#persist();
991
+ this.#redraw();
994
992
  }
995
993
 
996
- /** Drop the selection without pretending a node was clicked. */
994
+ /** Drop the selection without pretending a card was clicked. */
997
995
  clearSelection() {
998
996
  this.selected = null;
999
- for (const el of this.els.values()) el.classList.remove('cv-selected');
997
+ this.#redraw();
1000
998
  }
1001
999
 
1002
1000
  #select(id) {
1003
1001
  this.selected = this.selected === id ? null : id;
1004
- for (const [nid, el] of this.els) el.classList.toggle('cv-selected', nid === this.selected);
1005
- this.onSelect(this.selected ? this.nodes.get(this.selected) : null);
1002
+ this.#redraw();
1003
+ this.onSelect(this.selected ? this.nodes.get(this.selected) ?? null : null);
1006
1004
  }
1007
1005
 
1008
- /* ---------------------------------------------------------- dragging */
1006
+ /**
1007
+ * Bring one agent in front of the viewer: open the group it is folded into, put it in the middle of the pane at
1008
+ * a size it can be read at, and select it. This is what an attention chip does.
1009
+ */
1010
+ focus(id) {
1011
+ if (!this.nodes.has(id)) return false;
1012
+ for (let i = 0; i < 4; i += 1) {
1013
+ const p = this.lastPlan ?? this.#plan();
1014
+ if (p.items.some((it) => it.id === id) || p.items.some((it) => it.kind === 'group' && it.open && it.members.some((m) => m.id === id))) break;
1015
+ // Not drawn: something it sits under is folded. Open the nearest folded group above it.
1016
+ const chain = [];
1017
+ for (let cur = this.nodes.get(id), g = 0; cur && g < 32; cur = this.nodes.get(cur.parentId), g += 1) chain.push(cur.id);
1018
+ const holder = p.items.find((it) => it.kind === 'group' && !it.open && it.members.some((m) => chain.includes(m.id)));
1019
+ if (!holder) break;
1020
+ this.open.set(holder.id, true);
1021
+ this.sessionY = null;
1022
+ this.lastPlan = this.#plan();
1023
+ }
1024
+ this.selected = id;
1025
+ this.#redraw();
1026
+ const p = this.lastPlan;
1027
+ const item = p.items.find((it) => it.id === id);
1028
+ const host = item ?? p.items.find((it) => it.kind === 'group' && it.members.some((m) => m.id === id));
1029
+ if (host) {
1030
+ const cell = item ? null : host.cells?.find((c) => c.id === id);
1031
+ const cx = host.x + (cell ? cell.x + cell.w / 2 : host.w / 2);
1032
+ const cy = host.y + (cell ? cell.y + cell.h / 2 : host.h / 2);
1033
+ const r = this.root.getBoundingClientRect();
1034
+ // Close enough to read, and no closer than the viewer already was.
1035
+ this.view.k = clamp(Math.max(this.view.k, 1), ZOOM.min, ZOOM.max);
1036
+ this.view.x = r.width / 2 - cx * this.view.k;
1037
+ this.view.y = r.height / 2 - cy * this.view.k;
1038
+ this.touched = true;
1039
+ this.applyView();
1040
+ }
1041
+ this.#persist();
1042
+ this.onSelect(this.nodes.get(id) ?? null);
1043
+ return true;
1044
+ }
1045
+
1046
+ /** Select a node without moving the view: for a choice made where the canvas is not on screen. */
1047
+ select(id) {
1048
+ if (!this.nodes.has(id)) return false;
1049
+ this.selected = id;
1050
+ this.#redraw();
1051
+ this.onSelect(this.nodes.get(id) ?? null);
1052
+ return true;
1053
+ }
1009
1054
 
1010
- #makeDraggable(el, id) {
1055
+ /* ------------------------------------------------------ interaction */
1056
+
1057
+ #interact(el, id) {
1011
1058
  let drag = null;
1012
1059
 
1013
1060
  el.addEventListener('pointerdown', (e) => {
1014
1061
  if (e.button !== 0) return;
1015
1062
  e.stopPropagation(); // do not pan the canvas too
1063
+ if (e.target.closest?.('.cv-fold')) return;
1016
1064
  const p = this.pos.get(id) ?? { x: 0, y: 0 };
1017
1065
  drag = { px: e.clientX, py: e.clientY, x: p.x, y: p.y, moved: false };
1018
1066
  el.setPointerCapture(e.pointerId);
1019
- el.classList.add('cv-dragging');
1020
1067
  });
1021
1068
 
1022
1069
  el.addEventListener('pointermove', (e) => {
1023
1070
  if (!drag) return;
1024
1071
  const dx = (e.clientX - drag.px) / this.view.k;
1025
1072
  const dy = (e.clientY - drag.py) / this.view.k;
1026
- if (Math.abs(dx) > 2 || Math.abs(dy) > 2) drag.moved = true;
1027
- this.pos.set(id, { x: drag.x + dx, y: drag.y + dy });
1028
- el.style.transform = `translate(${drag.x + dx}px, ${drag.y + dy}px)`;
1029
- this.#renderEdges();
1073
+ if (!drag.moved && Math.abs(dx) <= 3 && Math.abs(dy) <= 3) return;
1074
+ drag.moved = true;
1075
+ el.classList.add('cv-dragging');
1076
+ this.pinned.set(id, { x: Math.round(drag.x + dx), y: Math.round(drag.y + dy) });
1077
+ this.#redraw();
1030
1078
  });
1031
1079
 
1032
1080
  const stop = () => {
1033
1081
  if (!drag) return;
1034
- if (drag.moved) { this.pinned.add(id); this.#persist(); el.dataset.suppressClick = '1'; }
1082
+ // A drag ends with a click on the same element. Without this guard every reposition also toggled selection.
1083
+ if (drag.moved) { this.#persist(); el.dataset.suppressClick = '1'; }
1035
1084
  drag = null;
1036
1085
  el.classList.remove('cv-dragging');
1037
1086
  };
1038
1087
  el.addEventListener('pointerup', stop);
1039
1088
  el.addEventListener('pointercancel', stop);
1089
+
1090
+ const act = () => {
1091
+ const it = this.lastPlan?.items.find((x) => x.id === id);
1092
+ if (!it) return;
1093
+ // A group opens on click — the fold control is a small target and the card is the obvious one. The session
1094
+ // is not a group even though everything hangs off it: it is the conversation, and clicking it opens that.
1095
+ if (it.kind === 'group') { this.toggle(id); return; }
1096
+ this.#select(id);
1097
+ };
1098
+ el.addEventListener('click', (e) => {
1099
+ if (el.dataset.suppressClick === '1') { el.dataset.suppressClick = '0'; return; }
1100
+ if (e.target.closest?.('.cv-member')) return; // the member answers for itself
1101
+ act();
1102
+ });
1103
+ el.addEventListener('keydown', (e) => {
1104
+ if (e.target !== el) return;
1105
+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); act(); }
1106
+ });
1107
+ el.addEventListener('contextmenu', (e) => {
1108
+ if (e.target.closest?.('.cv-member')) return;
1109
+ e.preventDefault();
1110
+ this.onMenu(this.nodes.get(id) ?? null, { x: e.clientX, y: e.clientY, pinned: this.pinned.size > 0 });
1111
+ });
1112
+ this.#tip(el, () => this.nodes.get(id));
1113
+ }
1114
+
1115
+ #interactCell(c, id) {
1116
+ c.addEventListener('pointerdown', (e) => e.stopPropagation());
1117
+ c.addEventListener('click', (e) => { e.stopPropagation(); this.#select(id); });
1118
+ c.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); this.#select(id); } });
1119
+ c.addEventListener('contextmenu', (e) => {
1120
+ e.preventDefault();
1121
+ e.stopPropagation();
1122
+ this.onMenu(this.nodes.get(id) ?? null, { x: e.clientX, y: e.clientY, pinned: this.pinned.size > 0 });
1123
+ });
1124
+ this.#tip(c, () => this.nodes.get(id));
1125
+ }
1126
+
1127
+ /** The whole task and the numbers, after the pointer has rested on a card. */
1128
+ #tip(el, nodeOf) {
1129
+ let timer = null;
1130
+ const hide = () => { clearTimeout(timer); timer = null; this.tipEl.hidden = true; };
1131
+ el.addEventListener('pointerenter', (e) => {
1132
+ const n = nodeOf();
1133
+ if (!n || n.kind !== 'agent' || e.target !== el) return;
1134
+ clearTimeout(timer);
1135
+ timer = setTimeout(() => {
1136
+ const bits = [];
1137
+ // What it is doing this moment, for an agent that is doing something.
1138
+ if (n.status === 'running' && n.lastTool) bits.push(`\u25b8 ${n.lastTool}`);
1139
+ if (n.durationMs != null) bits.push(fmtDuration(n.durationMs));
1140
+ if (n.toolCount) bits.push(`${n.toolCount} ${n.toolCount === 1 ? 'call' : 'calls'}`);
1141
+ if (n.tokens != null) bits.push(`${fmtTokens(n.tokens)} tokens`);
1142
+ if (n.errors) bits.push(`${n.errors} ${n.errors === 1 ? 'error' : 'errors'}`);
1143
+ this.tipEl.replaceChildren(
1144
+ mk('strong', null, n.agentType ?? 'unknown agent'),
1145
+ mk('div', null, n.description || 'No task recorded'),
1146
+ mk('div', 'cv-sub', bits.length ? bits.join(' · ') : 'No numbers yet'),
1147
+ );
1148
+ const host = this.root.getBoundingClientRect();
1149
+ const r = el.getBoundingClientRect();
1150
+ this.tipEl.hidden = false;
1151
+ this.tipEl.style.left = `${clamp(r.left - host.left, 8, Math.max(8, host.width - 300))}px`;
1152
+ this.tipEl.style.top = `${clamp(r.bottom - host.top + 6, 8, Math.max(8, host.height - 96))}px`;
1153
+ }, TIP_MS);
1154
+ });
1155
+ el.addEventListener('pointerleave', hide);
1156
+ el.addEventListener('pointerdown', hide);
1040
1157
  }
1041
1158
 
1042
1159
  /* -------------------------------------------------------- persistence
1043
- Hand-placed positions are the user's work and outlive a refresh. Storage
1044
- can be unavailable (private windows, blocked site data), so every access
1045
- is guarded and the canvas falls back to auto layout. */
1160
+ Hand-placed positions, folds and the grouping are the viewer's work and outlive a refresh. Storage can be
1161
+ unavailable (private windows, blocked site data), so every access is guarded and the canvas falls back to
1162
+ its own layout. */
1046
1163
 
1047
- #key() { return `crewforth-studio-layout:${this.flow}:${this.sessionKey}`; }
1164
+ #key() { return `crewforth-studio-graph:${this.sessionKey}`; }
1048
1165
 
1049
1166
  #persist() {
1050
1167
  if (!this.sessionKey) return; // no session, nothing to key the layout to
1051
1168
  try {
1052
- const out = {};
1053
- for (const id of this.pinned) {
1054
- const p = this.pos.get(id);
1055
- if (p) out[id] = [Math.round(p.x), Math.round(p.y)];
1056
- }
1057
- localStorage.setItem(this.#key(), JSON.stringify(out));
1058
- } catch { /* positions stay for this page view only */ }
1169
+ localStorage.setItem(this.#key(), JSON.stringify({
1170
+ group: this.group,
1171
+ pins: Object.fromEntries([...this.pinned].map(([id, p]) => [id, [p.x, p.y]])),
1172
+ open: Object.fromEntries(this.open),
1173
+ }));
1174
+ } catch { /* kept for this page view only */ }
1059
1175
  }
1060
1176
 
1061
1177
  #restore() {
@@ -1063,96 +1179,27 @@ export class Canvas {
1063
1179
  try {
1064
1180
  const raw = localStorage.getItem(this.#key());
1065
1181
  if (!raw) return;
1066
- for (const [id, [x, y]] of Object.entries(JSON.parse(raw))) {
1067
- this.pos.set(id, { x, y });
1068
- this.pinned.add(id);
1069
- }
1070
- } catch { /* start from auto layout */ }
1182
+ const saved = JSON.parse(raw);
1183
+ if (GROUPINGS.includes(saved.group)) this.group = saved.group;
1184
+ for (const [id, [x, y]] of Object.entries(saved.pins ?? {})) this.pinned.set(id, { x, y });
1185
+ for (const [id, on] of Object.entries(saved.open ?? {})) this.open.set(id, Boolean(on));
1186
+ } catch { /* start from the canvas's own layout */ }
1071
1187
  }
1072
1188
  }
1073
1189
 
1074
1190
  /* -------------------------------------------------------------- helpers */
1075
1191
 
1076
- function clamp(v, lo, hi) { return Math.min(hi, Math.max(lo, v)); }
1077
-
1078
- /**
1079
- * How many columns one kind's own grid gets.
1080
- *
1081
- * Four until the group is big enough that four columns would make a tower —
1082
- * a session that spawned 250 agents of a single type is 63 rows deep at four
1083
- * across, and no amount of band packing rescues one group. Past ~36 members the
1084
- * count goes square-ish in the flow's own aspect instead. Below that this
1085
- * returns exactly what MAX_PER_GROUP_ROW always returned.
1086
- */
1087
- function groupCols(count, nodeA, nodeB) {
1088
- const square = Math.ceil(Math.sqrt(Math.max(1, count) * (nodeB / nodeA)));
1089
- return Math.min(count, Math.max(MAX_PER_GROUP_ROW, square));
1090
- }
1091
-
1092
- /**
1093
- * Every way a depth level's groups can be packed into bands, and the box each
1094
- * packing needs. `per` is groups per band; bands stack along the depth axis.
1095
- */
1096
- function bandPlans(measured, nodeB) {
1097
- const plans = [];
1098
- for (let per = 1; per <= Math.max(1, measured.length); per += 1) {
1099
- let w = 0;
1100
- let h = 0;
1101
- let bands = 0;
1102
- for (let i = 0; i < measured.length; i += per) {
1103
- const band = measured.slice(i, i + per);
1104
- w = Math.max(w, band.reduce((t, g) => t + g.span, 0) + (band.length - 1) * GROUP_GAP);
1105
- h += Math.max(...band.map((g) => g.rows)) * (nodeB + SIBLING_GAP) - SIBLING_GAP;
1106
- bands += 1;
1107
- }
1108
- plans.push({ per, w: Math.max(1, w), h: Math.max(1, h + (bands - 1) * GROUP_GAP) });
1109
- }
1110
- return plans;
1111
- }
1112
-
1113
- /** What a level would fit at with the pane to itself. Only a starting guess —
1114
- * the real objective is the whole graph, which layout() optimises. */
1115
- function soloFit(plan, paneW, paneH) {
1116
- return clamp(
1117
- Math.min((paneW - 2 * FIT_PAD) / plan.w, (paneH - 2 * FIT_PAD) / plan.h, FIT_MAX),
1118
- MIN_K, MAX_K,
1119
- );
1120
- }
1121
-
1122
- /** An agent's short name: the crew- prefix and the -expert / -agent suffix dropped. */
1123
- function shortType(t) {
1124
- return String(t ?? '').replace(/^crew-/, '').replace(/-(expert|agent)$/, '');
1125
- }
1126
-
1127
- /** `#rgb` and `#rrggbb` to three channels, or null for anything else — a
1128
- * palette entry that is not a plain hex is left alone rather than mangled. */
1129
- function rgb(h) {
1130
- const s = String(h ?? '').trim();
1131
- const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(s);
1132
- if (!m) return null;
1133
- const x = m[1].length === 3 ? m[1].replace(/./g, (c) => c + c) : m[1];
1134
- return [0, 2, 4].map((i) => parseInt(x.slice(i, i + 2), 16));
1135
- }
1136
-
1137
- /** `t` of the way from `a` toward `b`. Done here rather than with color-mix()
1138
- * because the result is also what the edge reports to anything reading it. */
1139
- function mixHex(a, b, t) {
1140
- const pa = rgb(a);
1141
- const pb = rgb(b);
1142
- if (!pa || !pb) return a;
1143
- const hex = pa.map((v, i) => Math.round(v + (pb[i] - v) * t).toString(16).padStart(2, '0'));
1144
- return `#${hex.join('')}`;
1192
+ function readFlag(key) {
1193
+ try { return localStorage.getItem(key); } catch { return null; }
1145
1194
  }
1146
1195
 
1147
- function readFlow() {
1148
- try {
1149
- const v = localStorage.getItem('crewforth-studio-flow');
1150
- return v === 'right' ? 'right' : 'down';
1151
- } catch { return 'down'; }
1196
+ function writeFlag(key, value) {
1197
+ try { localStorage.setItem(key, value); } catch { /* blocked storage */ }
1152
1198
  }
1153
1199
 
1154
- function writeFlow(v) {
1155
- try { localStorage.setItem('crewforth-studio-flow', v); } catch { /* blocked storage */ }
1200
+ function readPref(key, allowed, fallback) {
1201
+ const v = readFlag(key);
1202
+ return allowed.includes(v) ? v : fallback;
1156
1203
  }
1157
1204
 
1158
1205
  function fmtTokens(t) {
@@ -1169,7 +1216,7 @@ function fmtDuration(ms) {
1169
1216
  return m < 60 ? `${m}m ${s % 60}s` : `${Math.floor(m / 60)}h ${m % 60}m`;
1170
1217
  }
1171
1218
 
1172
- function shortPath(p, max = 40) {
1219
+ function shortPath(p, max = 28) {
1173
1220
  if (!p) return '';
1174
1221
  const home = p.match(/^\/(Users|home)\/[^/]+/);
1175
1222
  const s = home ? `~${p.slice(home[0].length)}` : p;