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.
- package/CHANGELOG.md +211 -0
- package/README.md +3 -3
- package/VERSION +1 -1
- package/adopt.sh +86 -34
- package/kit/CLAUDE.md +1 -1
- package/kit/README.md +1 -1
- package/kit/agents/crew-commit-agent.md +7 -3
- package/kit/agents/crew-frontend-expert.md +4 -4
- package/kit/agents/crew-performance-expert.md +1 -1
- package/kit/agents/crew-review-agent.md +1 -1
- package/kit/eval/doctor.sh +57 -3
- package/kit/eval/golden-routing.txt +13 -0
- package/kit/eval/lib/git-bash.sh +11 -2
- package/kit/eval/parser-conformance.sh +1 -0
- package/kit/eval/routing-eval.sh +7 -3
- package/kit/eval/smoke-test.sh +2545 -214
- package/kit/hooks/commit-msg +1 -1
- package/kit/hooks/context-usage.sh +7 -1
- package/kit/hooks/guard-bash.sh +1608 -103
- package/kit/hooks/guard-commit-scan.sh +138 -19
- package/kit/hooks/guard-powershell.sh +89 -15
- package/kit/hooks/guard-write.sh +98 -22
- package/kit/hooks/no-bash-guard.ps1 +71 -0
- package/kit/hooks/pre-commit +5 -2
- package/kit/hooks/prompt-approval.sh +484 -0
- package/kit/hooks/route-hint.sh +13 -5
- package/kit/hooks/session-rehydrate.sh +18 -0
- package/kit/hooks/skill-trust.sh +17 -2
- package/kit/owned-blobs.tsv +43 -0
- package/kit/settings.json +20 -4
- package/kit/skills/a11y/SKILL.md +1 -0
- package/kit/skills/crew-doctor/SKILL.md +2 -2
- package/kit/skills/frontend/SKILL.md +3 -3
- package/kit/skills/frontend-flutter/SKILL.md +94 -0
- package/kit/skills/frontend-flutter/references/adaptive-and-accessible.md +55 -0
- package/kit/skills/frontend-flutter/references/platform-async-release.md +59 -0
- package/kit/skills/frontend-flutter/references/rebuilds-and-performance.md +36 -0
- package/kit/studio/server/hooks/studio-gate.sh +35 -1
- package/kit/studio/server/index.js +14 -4
- package/kit/studio/server/lib/graph.js +64 -3
- package/kit/studio/server/lib/kit.js +16 -2
- package/kit/studio/server/lib/permissions.js +77 -2
- package/kit/studio/server/lib/projects.js +60 -7
- package/kit/studio/server/lib/session.js +16 -2
- package/kit/studio/web/app.js +1765 -573
- package/kit/studio/web/approvals.js +124 -0
- package/kit/studio/web/canvas.js +932 -885
- package/kit/studio/web/chat.js +322 -145
- package/kit/studio/web/convo.js +174 -0
- package/kit/studio/web/dock.js +221 -0
- package/kit/studio/web/graph-plan.js +457 -0
- package/kit/studio/web/icons/builtin.svg +1 -0
- package/kit/studio/web/index.html +116 -50
- package/kit/studio/web/inspect.js +110 -0
- package/kit/studio/web/list-plan.js +112 -0
- package/kit/studio/web/list.js +157 -0
- package/kit/studio/web/liveness.js +66 -0
- package/kit/studio/web/nav.js +237 -0
- package/kit/studio/web/newsession.js +235 -0
- package/kit/studio/web/press.js +65 -0
- package/kit/studio/web/style.css +1281 -1014
- package/kit/studio/web/theme.js +43 -0
- package/kit/studio/web/timeline-plan.js +291 -0
- package/kit/studio/web/timeline.js +400 -0
- package/kit/studio/web/tokens.css +131 -0
- package/kit/studio/web/toolbar-fit.js +35 -0
- package/package.json +5 -4
package/kit/studio/web/canvas.js
CHANGED
|
@@ -1,103 +1,63 @@
|
|
|
1
1
|
// The orchestration canvas.
|
|
2
2
|
//
|
|
3
|
-
// HTML
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
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
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
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
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
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
|
|
74
|
-
//
|
|
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
|
-
//
|
|
78
|
-
//
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
100
|
-
|
|
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
|
|
109
|
-
const
|
|
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.
|
|
153
|
-
this.
|
|
154
|
-
this.
|
|
155
|
-
this.
|
|
156
|
-
this.
|
|
157
|
-
this.
|
|
158
|
-
this.
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
//
|
|
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: {}
|
|
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.
|
|
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.
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
this.
|
|
205
|
-
this.
|
|
206
|
-
this.
|
|
207
|
-
this.
|
|
208
|
-
this.
|
|
209
|
-
this.
|
|
210
|
-
this.
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
if (
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
*
|
|
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
|
|
282
|
-
*
|
|
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
|
|
291
|
-
|
|
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
|
-
/**
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
/*
|
|
311
|
+
/* ---------------------------------------------------------- choices */
|
|
312
312
|
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
*
|
|
317
|
-
*
|
|
318
|
-
*
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
/**
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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.
|
|
395
|
+
this.sessionY = null;
|
|
396
|
+
this.#persist();
|
|
397
|
+
this.#redraw({ fit: true });
|
|
398
|
+
}
|
|
393
399
|
|
|
394
|
-
|
|
395
|
-
const
|
|
396
|
-
|
|
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
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
|
508
|
-
*
|
|
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
|
-
|
|
515
|
-
|
|
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
|
|
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 -
|
|
525
|
-
this.view.y = (r.height -
|
|
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.
|
|
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.
|
|
577
|
-
'
|
|
578
|
-
|
|
579
|
-
//
|
|
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
|
-
|
|
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 (
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
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.#
|
|
616
|
-
|
|
617
|
-
|
|
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
|
-
|
|
620
|
-
|
|
621
|
-
|
|
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
|
|
624
|
-
this
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
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
|
-
|
|
642
|
-
|
|
643
|
-
const
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
el
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
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(
|
|
674
|
-
this.#
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
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
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
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
|
-
|
|
761
|
-
|
|
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
|
-
|
|
765
|
-
|
|
766
|
-
|
|
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
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
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
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
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
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
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
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
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
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
if (
|
|
821
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
828
|
-
//
|
|
829
|
-
//
|
|
830
|
-
|
|
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
|
|
837
|
-
|
|
838
|
-
|
|
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(
|
|
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
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
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
|
|
876
|
-
const state =
|
|
877
|
-
path.dataset.status =
|
|
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
|
-
|
|
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
|
|
894
|
-
//
|
|
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
|
-
//
|
|
911
|
-
//
|
|
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
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
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
|
-
/**
|
|
929
|
-
#
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
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
|
-
|
|
946
|
-
|
|
947
|
-
#
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
this.
|
|
952
|
-
|
|
953
|
-
|
|
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
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
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
|
-
/**
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
this
|
|
992
|
-
this
|
|
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
|
|
994
|
+
/** Drop the selection without pretending a card was clicked. */
|
|
997
995
|
clearSelection() {
|
|
998
996
|
this.selected = null;
|
|
999
|
-
|
|
997
|
+
this.#redraw();
|
|
1000
998
|
}
|
|
1001
999
|
|
|
1002
1000
|
#select(id) {
|
|
1003
1001
|
this.selected = this.selected === id ? null : id;
|
|
1004
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
1027
|
-
|
|
1028
|
-
el.
|
|
1029
|
-
this
|
|
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
|
-
|
|
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
|
|
1044
|
-
|
|
1045
|
-
|
|
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-
|
|
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
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
}
|
|
1057
|
-
|
|
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
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
}
|
|
1070
|
-
} catch { /* start from
|
|
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
|
|
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
|
|
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
|
|
1155
|
-
|
|
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 =
|
|
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;
|