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