omkit 0.8.14 → 0.8.16

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.
@@ -1,1981 +1,2366 @@
1
- /* omkit run report — the viewer. Runs in the browser against the JSON block the writer
2
- * inlined (`#omkit-data`) and the global `d3`. Plain script, no build step: the writer
3
- * reads this file and pastes it into report.html as-is. */
4
- (function () {
5
- "use strict";
6
-
7
- const ROW_H = 42;
8
- const BAR_Y = 6;
9
- const BAR_H = 20;
10
- const FLAME_ROW = 26;
11
-
12
- // ── icons (24×24, stroked) ───────────────────────────────
13
- const ICON = {
14
- timeline: "M3 17l5-5 4 4 8-8M3 21h18",
15
- flame: "M12 3c1 4 6 6 6 11a6 6 0 0 1-12 0c0-3 2-4 2-7 2 1 3 3 3 5 1-2 1-6 1-9z",
16
- flow: "M4 4h7v6H4zM13 14h7v6h-7zM7.5 10v4h9v0M16.5 14v-1",
17
- zoomIn: "M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM20 20l-4-4M11 8v6M8 11h6",
18
- zoomOut: "M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM20 20l-4-4M8 11h6",
19
- more: "M5 12h.01M12 12h.01M19 12h.01",
20
- close: "M6 6l12 12M18 6L6 18",
21
- chevron: "M9 6l6 6-6 6",
22
- play: "M8 5v14l11-7z",
23
- terminal: "M4 5h16v14H4zM7 9l3 3-3 3M12 15h5",
24
- globe: "M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18zM3 12h18M12 3c3 3 3 15 0 18M12 3c-3 3-3 15 0 18",
25
- window: "M3 5h18v14H3zM3 9h18M6 7h.01M9 7h.01",
26
- pulse: "M3 12h4l2-5 4 10 2-5h6",
27
- cursor: "M5 3l14 7-6 2-2 6z",
28
- box: "M12 3l8 4.5v9L12 21l-8-4.5v-9zM4 7.5l8 4.5 8-4.5M12 12v9",
29
- camera: "M4 8h3l2-3h6l2 3h3v11H4zM12 10a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7z",
30
- file: "M6 3h8l4 4v14H6zM14 3v4h4",
31
- check: "M5 12l4 4 10-10",
32
- x: "M6 6l12 12M18 6L6 18",
33
- alert: "M12 4l9 16H3zM12 10v4M12 17h.01",
34
- root: "M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8zM12 2v3M12 19v3M2 12h3M19 12h3",
35
- chat: "M4 5h16v11H9l-5 4z",
36
- flask: "M9 3h6M10 3v6l-5 9a2 2 0 0 0 2 3h10a2 2 0 0 0 2-3l-5-9V3",
37
- braces: "M8 4c-2 0-3 1-3 3v2c0 1-1 2-2 3 1 1 2 2 2 3v2c0 2 1 3 3 3M16 4c2 0 3 1 3 3v2c0 1 1 2 2 3-1 1-2 2-2 3v2c0 2-1 3-3 3",
38
- image: "M4 5h16v14H4zM4 16l5-5 4 4 2-2 5 5M15 9h.01",
39
- sun: "M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8zM12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4",
40
- download: "M12 4v11M7 10l5 5 5-5M5 20h14",
41
- copy: "M9 9h11v11H9zM5 15V4h11",
42
- stop: "M6 6h12v12H6z",
43
- info: "M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18zM12 11v5M12 8h.01",
44
- locate: "M12 5a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM12 2v3M12 19v3M2 12h3M19 12h3M12 10.5a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3z",
45
- code: "M8 7l-5 5 5 5M16 7l5 5-5 5",
46
- ret: "M9 10l-4 4 4 4M5 14h11a4 4 0 0 0 4-4V5",
47
- external: "M14 4h6v6M20 4l-9 9M18 14v6H4V6h6",
48
- };
49
- const svgIcon = (name, extra) =>
50
- `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"${
51
- extra || ""
52
- }><path d="${ICON[name]}"/></svg>`;
53
-
54
- const PALETTE = ["--c1", "--c2", "--c3", "--c4", "--c5", "--c6", "--c7", "--c8"];
55
- const css = (v) => getComputedStyle(document.documentElement).getPropertyValue(v).trim();
56
-
57
- const MARK = {
58
- event: { color: "--c3", icon: "check", label: "Process event" },
59
- snapshot: { color: "--c2", icon: "camera", label: "Snapshot" },
60
- artifact: { color: "--c6", icon: "file", label: "Artifact" },
61
- pass: { color: "--ok", icon: "check", label: "Assertion passed" },
62
- fail: { color: "--fail", icon: "alert", label: "Assertion failed" },
63
- error: { color: "--fail", icon: "x", label: "Error" },
64
- cancel: { color: "--cancel", icon: "stop", label: "Cancelled" },
65
- lifecycle: { color: "--c1", icon: "play", label: "Lifecycle" },
66
- run: { color: "--text-3", icon: "root", label: "Run" },
67
- };
68
-
69
- // ── formatting ───────────────────────────────────────────
70
- const fmtDur = (ms) => {
71
- if (!isFinite(ms)) return "–";
72
- if (ms < 1000) return `${Math.round(ms)}ms`;
73
- if (ms < 10000) return `${(ms / 1000).toFixed(1)}s`;
74
- if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
75
- const m = Math.floor(ms / 60000);
76
- const s = Math.round((ms % 60000) / 1000);
77
- return `${m}m ${String(s).padStart(2, "0")}s`;
78
- };
79
- const fmtRel = (ms) => `+${fmtDur(Math.max(0, ms)).replace(/^0ms$/, "0ms")}`;
80
- const pad2 = (n) => String(n).padStart(2, "0");
81
- const fmtClock = (ts) => {
82
- const d = new Date(ts);
83
- return `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}.${String(d.getMilliseconds()).padStart(3, "0")}`;
84
- };
85
- const fmtDate = (ts) => {
86
- const d = new Date(ts);
87
- return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`;
88
- };
89
- const fmtSize = (b) =>
90
- b < 1024 ? `${b} B` : b < 1048576 ? `${Math.round(b / 1024)} KB` : `${(b / 1048576).toFixed(1)} MB`;
91
- const fmtTick = (ms) => {
92
- if (ms === 0) return "0s";
93
- const a = Math.abs(ms);
94
- if (a < 1000) return `${Math.round(ms)}ms`;
95
- if (a < 60000) return `${+(ms / 1000).toFixed(a < 10000 ? 2 : 1)}s`;
96
- return `${Math.floor(ms / 60000)}m${pad2(Math.round((ms % 60000) / 1000))}s`;
97
- };
98
- const esc = (s) =>
99
- String(s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
100
- const basename = (p) => String(p).split(/[\\/]/).pop();
101
- const fileUrl = (p) => "file:///" + String(p).replace(/\\/g, "/").replace(/^\/+/, "").split("/").map(encodeURIComponent).join("/").replace(/^([A-Za-z])%3A/, "$1:");
102
- const editorUrl = (site) => {
103
- const m = /^(.*):(\d+)$/.exec(site);
104
- const file = (m ? m[1] : site).replace(/\\/g, "/");
105
- return `vscode://file/${file.replace(/^\/+/, "")}${m ? ":" + m[2] : ""}`;
106
- };
107
- const json = (v) => {
108
- try {
109
- return JSON.stringify(v, null, 2);
110
- } catch {
111
- return String(v);
112
- }
113
- };
114
-
115
- // ── model ────────────────────────────────────────────────
116
- function buildModel(data) {
117
- const run = data.run;
118
- const t0 = run.startedAt;
119
- const nodes = [];
120
- const byId = new Map();
121
- let tEnd = run.endedAt || t0;
122
- let topIndex = 0;
123
- (function walk(v, parent, depth, branch) {
124
- const isTop = depth === 1;
125
- const b = depth === 0 ? 0 : isTop ? topIndex++ % PALETTE.length : branch;
126
- const n = {
127
- v,
128
- id: v.id,
129
- name: v.name,
130
- parent,
131
- depth,
132
- branch: depth === 0 ? -1 : b,
133
- children: [],
134
- status: ["ok", "failed", "cancelled"].includes(v.status) ? v.status : v.endedAt ? v.status : "running",
135
- startAbs: v.startedAt || t0,
136
- endAbs: v.endedAt || 0,
137
- entries: [],
138
- marks: [],
139
- };
140
- nodes.push(n);
141
- byId.set(n.id, n);
142
- if (parent) parent.children.push(n);
143
- if (n.endAbs > tEnd) tEnd = n.endAbs;
144
- for (const c of v.children || []) walk(c, n, depth + 1, b);
145
- })(run.root, null, 0, 0);
146
-
147
- for (const e of data.entries) if (e.ts > tEnd) tEnd = e.ts;
148
- for (const n of nodes) {
149
- if (!n.endAbs) n.endAbs = tEnd;
150
- n.start = n.startAbs - t0;
151
- n.end = Math.max(n.start, n.endAbs - t0);
152
- n.duration = n.v.duration || n.end - n.start;
153
- }
154
-
155
- const items = [];
156
- const runEntries = [];
157
- for (const e of data.entries) {
158
- if (e.level === "child" || e.level === "tag") continue;
159
- const kind = kindOf(e);
160
- const item = { idx: items.length, e, kind, t: e.ts - t0, label: labelOf(e, kind), node: byId.get(e.nodeId) || null };
161
- items.push(item);
162
- if (item.node) {
163
- item.node.entries.push(item);
164
- if (kind !== "info" && kind !== "lifecycle") item.node.marks.push(item);
165
- } else runEntries.push(item);
166
- }
167
-
168
- const assets = new Map();
169
- const gallery = [];
170
- for (const f of data.files) {
171
- gallery.push({
172
- type: "file",
173
- f,
174
- name: f.name,
175
- t: f.ts ? f.ts - t0 : null,
176
- node: f.nodeId ? byId.get(f.nodeId) || null : null,
177
- image: f.mime.startsWith("image/") && !!f.asset,
178
- });
179
- }
180
- for (const s of data.snapshots) {
181
- gallery.push({ type: "snapshot", s, name: s.name, t: s.ts - t0, node: byId.get(s.nodeId) || null, image: false });
182
- }
183
- gallery.sort((a, b) => (a.t ?? Infinity) - (b.t ?? Infinity));
184
- // Let a mark find its gallery item (snapshot by file, artifact by file).
185
- const galleryByFile = new Map();
186
- for (const g of gallery) galleryByFile.set(String(g.type === "file" ? g.f.file : g.s.file).toLowerCase(), g);
187
- for (const it of items) {
188
- if (it.kind === "snapshot" || it.kind === "artifact") {
189
- const i = it.e.message.lastIndexOf(" → ");
190
- if (i >= 0) it.gallery = galleryByFile.get(it.e.message.slice(i + 3).toLowerCase()) || null;
191
- }
192
- }
193
-
194
- const asserts = items.filter((i) => i.kind === "pass" || i.kind === "fail");
195
- return {
196
- data,
197
- run,
198
- t0,
199
- span: Math.max(1, tEnd - t0),
200
- nodes,
201
- byId,
202
- root: nodes[0],
203
- items,
204
- runEntries,
205
- gallery,
206
- asserts,
207
- assets,
208
- asset(id) {
209
- if (!assets.has(id)) {
210
- const el = document.getElementById(id);
211
- assets.set(id, el ? `data:${el.dataset.mime};base64,${el.textContent.trim()}` : "");
212
- }
213
- return assets.get(id);
214
- },
215
- };
216
- }
217
-
218
- function kindOf(e) {
219
- if (e.level === "assert") return e.message.startsWith("⊭") ? "fail" : "pass";
220
- if (e.level === "snapshot") return "snapshot";
221
- if (e.level === "artifact") return "artifact";
222
- if (e.level === "error") return "error";
223
- if (e.level === "run") return "run";
224
- if (e.level === "event" && e.source === "cancel") return "cancel";
225
- if (e.level === "event" && e.source === "lifecycle") return "lifecycle";
226
- if (e.level === "event") return "event";
227
- return "info";
228
- }
229
-
230
- function labelOf(e, kind) {
231
- const m = e.message;
232
- if (kind === "snapshot" || kind === "artifact") {
233
- const i = m.lastIndexOf(" → ");
234
- return i >= 0 ? m.slice(0, i) : m;
235
- }
236
- if (kind === "pass" || kind === "fail") return m.replace(/^[⊨⊭]\s*(pass|FAIL)\s*·\s*/, "");
237
- if (kind === "error") return m.split("\n")[0];
238
- if (kind === "event") {
239
- // Command events repeat their source as a prefix — drop it.
240
- let s = m.startsWith(e.source + " · ") ? m.slice(e.source.length + 3) : m;
241
- s = s.split(" · ")[0];
242
- return s;
243
- }
244
- return m;
245
- }
246
-
247
- // ── app ──────────────────────────────────────────────────
248
- function App(model) {
249
- const app = document.getElementById("app");
250
- const state = {
251
- view: "timeline",
252
- selected: model.root.id,
253
- collapsed: new Set(),
254
- zoom: d3.zoomIdentity,
255
- detailTab: "overview",
256
- lowerTab: "snapshots",
257
- filter: "",
258
- pin: null,
259
- details: true,
260
- };
261
- // Deep, wide runs start collapsed below the second level so the first screen stays readable.
262
- if (model.nodes.length > 60) for (const n of model.nodes) if (n.depth >= 2 && n.children.length) state.collapsed.add(n.id);
263
-
264
- app.innerHTML = shellHtml(model);
265
- const el = {
266
- main: app.querySelector(".main"),
267
- timeline: app.querySelector(".timeline"),
268
- head: app.querySelector(".tl-head"),
269
- axis: app.querySelector(".tl-head svg"),
270
- body: app.querySelector(".tl-body"),
271
- tree: app.querySelector(".tree"),
272
- chart: app.querySelector(".chart"),
273
- flame: app.querySelector(".flame-view"),
274
- flow: app.querySelector(".flow-view"),
275
- details: app.querySelector(".details"),
276
- lower: app.querySelector(".lower"),
277
- overview: app.querySelector(".overview svg"),
278
- range: app.querySelector(".overview .range"),
279
- windowSel: app.querySelector("#window"),
280
- };
281
- const tip = document.createElement("div");
282
- tip.className = "tip";
283
- tip.hidden = true;
284
- document.body.appendChild(tip);
285
-
286
- let W = 600; // chart pixel width (timeline + flame share the x mapping)
287
- let baseX = d3.scaleLinear();
288
- const xz = () => state.zoom.rescaleX(baseX);
289
- let syncing = false;
290
-
291
- // ── header controls ──
292
- app.querySelectorAll("[data-view]").forEach((b) =>
293
- b.addEventListener("click", () => setView(b.dataset.view))
294
- );
295
- app.querySelector("#zin").addEventListener("click", () => zoomBy(1.6));
296
- app.querySelector("#zout").addEventListener("click", () => zoomBy(1 / 1.6));
297
- app.querySelector("#fit").addEventListener("click", fit);
298
- el.windowSel.addEventListener("change", () => {
299
- const ms = +el.windowSel.value;
300
- if (!ms) return fit();
301
- const [d0] = xz().domain();
302
- zoomToDomain(Math.max(0, d0), Math.max(0, d0) + ms);
303
- });
304
- app.querySelector("#more").addEventListener("click", (ev) => openMenu(ev.currentTarget));
305
- const info = app.querySelector("#om-info");
306
- info.addEventListener("mouseenter", (ev) => showTip(ev, omTip(), true));
307
- info.addEventListener("mousemove", (ev) => moveTip(ev));
308
- info.addEventListener("mouseleave", hideTip);
309
- info.addEventListener("click", () => select(model.root.id));
310
-
311
- // ── zoom behaviours ──
312
- const chartZoom = d3
313
- .zoom()
314
- .scaleExtent([1, 5000])
315
- .filter((e) => (e.type === "wheel" ? e.ctrlKey || e.metaKey : !e.button && e.type !== "dblclick"))
316
- .on("zoom", (e) => onZoom(e.transform));
317
- const flameZoom = d3
318
- .zoom()
319
- .scaleExtent([1, 5000])
320
- .filter((e) => !e.button && e.type !== "dblclick")
321
- .on("zoom", (e) => onZoom(e.transform));
322
- const flowZoom = d3.zoom().scaleExtent([0.1, 4]).on("zoom", (e) => flowG && flowG.attr("transform", e.transform));
323
-
324
- function activeZoom() {
325
- return state.view === "flame"
326
- ? { z: flameZoom, sel: d3.select(el.flame).select("svg") }
327
- : { z: chartZoom, sel: d3.select(el.chart).select("svg") };
328
- }
329
- function onZoom(t) {
330
- state.zoom = t;
331
- if (!syncing) {
332
- syncing = true;
333
- // keep the other x-view's internal transform in step
334
- const other = state.view === "flame" ? d3.select(el.chart).select("svg") : d3.select(el.flame).select("svg");
335
- const oz = state.view === "flame" ? chartZoom : flameZoom;
336
- if (!other.empty()) other.call(oz.transform, t);
337
- syncing = false;
338
- }
339
- redrawX();
340
- }
341
- function zoomBy(k) {
342
- if (state.view === "flow") return d3.select(el.flow).select("svg").transition().duration(200).call(flowZoom.scaleBy, k);
343
- const { z, sel } = activeZoom();
344
- const [cx] = state.pin != null ? [xz()(state.pin)] : [W / 2];
345
- sel.transition().duration(200).call(z.scaleBy, k, [cx, 0]);
346
- }
347
- function fit() {
348
- if (state.view === "flow") return fitFlow();
349
- const { z, sel } = activeZoom();
350
- sel.transition().duration(250).call(z.transform, d3.zoomIdentity);
351
- }
352
- function zoomToDomain(a, b) {
353
- const x0 = baseX(a);
354
- const x1 = baseX(b);
355
- if (!(x1 > x0)) return;
356
- const k = Math.max(1, W / (x1 - x0));
357
- const t = d3.zoomIdentity.scale(k).translate(-x0, 0);
358
- const { z, sel } = activeZoom();
359
- sel.call(z.transform, t);
360
- }
361
-
362
- // ── layout ──
363
- function measure() {
364
- const sb = el.body.offsetWidth - el.body.clientWidth;
365
- el.head.style.paddingRight = `${sb}px`;
366
- W = Math.max(200, state.view === "flame" ? el.flame.clientWidth : el.chart.clientWidth);
367
- const span = model.span;
368
- baseX = d3.scaleLinear().domain([-span * 0.01, span * 1.015]).range([0, W]);
369
- for (const z of [chartZoom, flameZoom]) z.translateExtent([[0, -1e6], [W, 1e6]]).extent([[0, 0], [W, 1]]);
370
- }
371
-
372
- function setView(v) {
373
- state.view = v;
374
- app.querySelectorAll("[data-view]").forEach((b) => b.classList.toggle("active", b.dataset.view === v));
375
- el.timeline.classList.toggle("no-tree", v === "flame");
376
- el.body.hidden = v !== "timeline";
377
- el.head.hidden = v === "flow";
378
- el.flame.hidden = v !== "flame";
379
- el.flow.hidden = v !== "flow";
380
- el.windowSel.disabled = v === "flow";
381
- renderAll();
382
- }
383
-
384
- // ── tree + timeline ──
385
- function visible() {
386
- const out = [];
387
- (function walk(n) {
388
- out.push(n);
389
- if (!state.collapsed.has(n.id)) n.children.forEach(walk);
390
- })(model.root);
391
- return out;
392
- }
393
-
394
- function renderTree(rows) {
395
- el.tree.innerHTML = rows.map((n) => treeRowHtml(n)).join("");
396
- el.tree.querySelectorAll(".row").forEach((r) => {
397
- const n = model.byId.get(r.dataset.id);
398
- r.addEventListener("click", (ev) => {
399
- if (ev.target.closest(".twist")) {
400
- toggle(n);
401
- return;
402
- }
403
- select(n.id);
404
- });
405
- r.addEventListener("dblclick", () => focusNode(n));
406
- r.addEventListener("mouseenter", (ev) => showTip(ev, nodeTip(n), true, 280));
407
- r.addEventListener("mousemove", (ev) => moveTip(ev));
408
- r.addEventListener("mouseleave", hideTip);
409
- });
410
- }
411
- function treeRowHtml(n) {
412
- const guides = Array.from({ length: n.depth }, () => '<span class="guide"></span>').join("");
413
- const open = !state.collapsed.has(n.id);
414
- const twist = n.children.length
415
- ? `<button class="twist ${open ? "open" : ""}" title="${open ? "Collapse" : "Expand"}">${svgIcon("chevron")}</button>`
416
- : '<span class="twist"></span>';
417
- const color = colorOf(n);
418
- const rest = (n.v.tags || []).filter((t) => t !== displayName(n));
419
- const tags = rest.length ? `<span class="tagchip">${esc(rest.join(" · "))}</span>` : "";
420
- return `<div class="row ${state.selected === n.id ? "selected" : ""}" data-id="${esc(n.id)}" title="${esc(n.v.path)}">
421
- <div class="name" style="padding-left:${n.depth ? 4 : 10}px">${guides}${twist}
422
- <span class="icon" style="color:${color};background:${alpha(color, 0.16)}">${svgIcon(iconOf(n))}</span>
423
- <span class="label">${esc(displayName(n))}</span>${tags}
424
- </div>
425
- <div class="status"><span class="dot ${n.status}"></span>${esc(n.status)}</div>
426
- <div class="dur">${fmtDur(n.duration)}</div>
427
- </div>`;
428
- }
429
- function toggle(n) {
430
- if (state.collapsed.has(n.id)) state.collapsed.delete(n.id);
431
- else state.collapsed.add(n.id);
432
- renderTimeline();
433
- }
434
-
435
- let chartSvg = null;
436
- let rowsNow = [];
437
- function renderTimeline() {
438
- rowsNow = visible();
439
- renderTree(rowsNow);
440
- const H = rowsNow.length * ROW_H + 8;
441
- d3.select(el.chart).selectAll("svg").remove();
442
- chartSvg = d3
443
- .select(el.chart)
444
- .append("svg")
445
- .attr("width", W)
446
- .attr("height", H)
447
- .on("mousemove", (ev) => moveCursor(d3.pointer(ev)[0]))
448
- .on("mouseleave", () => moveCursor(null));
449
- chartSvg.append("g").attr("class", "grid");
450
- chartSvg.append("g").attr("class", "links");
451
- chartSvg.append("g").attr("class", "rows");
452
- chartSvg.append("line").attr("class", "pin").attr("stroke", css("--accent")).attr("stroke-width", 1.5).attr("y1", 0).attr("y2", H);
453
- chartSvg.append("line").attr("class", "cursor").attr("y1", 0).attr("y2", H).attr("visibility", "hidden");
454
- chartSvg.call(chartZoom).call(chartZoom.transform, state.zoom);
455
- redrawX();
456
- }
457
-
458
- function redrawX() {
459
- const x = xz();
460
- updateAxis(x);
461
- updateWindowLabel(x);
462
- updateOverviewBrush(x);
463
- if (state.view === "timeline" && chartSvg) drawRows(x);
464
- if (state.view === "flame") drawFlame(x);
465
- }
466
-
467
- function drawRows(x) {
468
- const H = rowsNow.length * ROW_H + 8;
469
- const ticks = x.ticks(Math.max(2, Math.floor(W / 90)));
470
- chartSvg
471
- .select(".grid")
472
- .selectAll("line")
473
- .data(ticks)
474
- .join("line")
475
- .attr("x1", (d) => x(d))
476
- .attr("x2", (d) => x(d))
477
- .attr("y1", 0)
478
- .attr("y2", H);
479
- const index = new Map(rowsNow.map((n, i) => [n.id, i]));
480
- // parent → child elbows
481
- const links = rowsNow.filter((n) => n.parent && index.has(n.parent.id));
482
- chartSvg
483
- .select(".links")
484
- .selectAll("path")
485
- .data(links, (n) => n.id)
486
- .join("path")
487
- .attr("class", "link")
488
- .attr("d", (n) => {
489
- const pi = index.get(n.parent.id);
490
- const ci = index.get(n.id);
491
- const cx = x(n.start);
492
- const px = Math.max(x(n.parent.start) + 6, cx - 12);
493
- const py = pi * ROW_H + BAR_Y + BAR_H;
494
- const cy = ci * ROW_H + BAR_Y + BAR_H / 2;
495
- return `M${px},${py}V${cy - 4}Q${px},${cy} ${px + 4},${cy}H${cx}`;
496
- });
497
-
498
- const g = chartSvg
499
- .select(".rows")
500
- .selectAll("g.r")
501
- .data(rowsNow, (n) => n.id)
502
- .join((enter) => {
503
- const r = enter.append("g").attr("class", "r");
504
- // A transparent target spanning the bar *and* its label, so a 2px bar is still hoverable.
505
- r.append("rect").attr("class", "hit").attr("y", BAR_Y - 4).attr("height", BAR_H + 8);
506
- r.append("rect").attr("class", "bar").attr("rx", 5).attr("y", BAR_Y).attr("height", BAR_H);
507
- r.append("text").attr("class", "bar-label").attr("y", BAR_Y + BAR_H / 2 + 4);
508
- r.append("g").attr("class", "marks");
509
- const cb = r.append("g").attr("class", "code-btn src");
510
- cb.append("circle").attr("r", 9);
511
- cb.append("text").attr("text-anchor", "middle").attr("y", 3.5).text("<>");
512
- const rb = r.append("g").attr("class", "code-btn ret");
513
- rb.append("circle").attr("r", 9);
514
- rb.append("path").attr("d", ICON.ret).attr("transform", "translate(-6,-6) scale(0.5)");
515
- return r;
516
- })
517
- .attr("transform", (_n, i) => `translate(0,${i * ROW_H})`)
518
- .classed("sel", (n) => n.id === state.selected);
519
-
520
- g.select("rect.bar")
521
- .attr("x", (n) => x(n.start))
522
- .attr("width", (n) => Math.max(3, x(n.end) - x(n.start)))
523
- .attr("fill", (n) => colorOf(n))
524
- .attr("fill-opacity", (n) => (n.status === "cancelled" ? 0.55 : 0.92))
525
- .attr("stroke", (n) => (n.status === "failed" ? css("--fail") : null))
526
- .attr("stroke-width", (n) => (n.status === "failed" ? 2 : null))
527
- .attr("stroke-dasharray", (n) => (n.status === "cancelled" ? "4 3" : null))
528
- .classed("selected", (n) => n.id === state.selected)
529
- .call(bindNode);
530
-
531
- g.select("text.bar-label").each(function (n) {
532
- const t = d3.select(this);
533
- const x0 = x(n.start);
534
- const w = x(n.end) - x0;
535
- const text = `${displayName(n)} (${fmtDur(n.duration)})`;
536
- const need = text.length * 6.6 + 14;
537
- const inside = w >= need;
538
- const clipX = Math.max(x0, 0);
539
- const outside = !inside && w <= 60;
540
- const shown = inside || outside ? text : truncate(text, (w - 14) / 6.6);
541
- const tw = shown.length * 6.6;
542
- // an outside label that would run off the chart hangs left of its bar instead
543
- const flip = outside && x0 + Math.max(w, 3) + 6 + tw > W;
544
- const lx = flip ? x0 - 6 : outside ? x0 + Math.max(w, 3) + 6 : Math.min(clipX + 8, x0 + w - 20);
545
- t.text(shown).classed("outside", outside).attr("x", lx).attr("text-anchor", flip ? "end" : "start");
546
- // the hit target covers bar + label, padded so a sliver of a bar is still easy to reach
547
- const left = Math.min(x0, flip ? lx - tw : lx) - 6;
548
- const right = Math.max(x0 + Math.max(w, 3), flip ? x0 : lx + tw) + 6;
549
- d3.select(this.parentNode).select("rect.hit").attr("x", left).attr("width", Math.max(16, right - left));
550
- // the <> sits just past the block (or just before it, when that would leave the chart)
551
- // …and, for a block spanning the chart, inside it right after its label
552
- const cx =
553
- right + 8 <= W - 10 ? right + 8 : left - 8 >= 10 ? left - 8 : Math.min(W - 12, (flip ? x0 : lx + tw) + 14);
554
- // buttons stack away from the block: rightward when they sit after it, leftward before it
555
- const dir = right + 8 <= W - 10 || left - 8 < 10 ? 1 : -1;
556
- const src = hasCode(n);
557
- const row = d3.select(this.parentNode);
558
- row
559
- .select("g.code-btn.src")
560
- .style("display", src ? null : "none")
561
- .attr("transform", `translate(${cx},${BAR_Y + BAR_H / 2})`);
562
- row
563
- .select("g.code-btn.ret")
564
- .style("display", hasValue(n) ? null : "none")
565
- .attr("transform", `translate(${src ? cx + dir * 21 : cx},${BAR_Y + BAR_H / 2})`);
566
- });
567
- g.select("g.code-btn.src")
568
- .on("click", (ev, n) => {
569
- ev.stopPropagation();
570
- toggleCode(n, ev);
571
- })
572
- .on("mouseenter", (ev) => showTip(ev, "Show source"))
573
- .on("mouseleave", hideTip);
574
- g.select("g.code-btn.ret")
575
- .on("click", (ev, n) => {
576
- ev.stopPropagation();
577
- toggleCode(n, ev, "value", true);
578
- })
579
- .on("mouseenter", (ev, n) => showTip(ev, `Returned: <span class="mono">${esc(valuePreview(n, 80))}</span>`))
580
- .on("mouseleave", hideTip);
581
- g.selectAll("rect.hit, text.bar-label").call(bindNode);
582
-
583
- g.select("g.marks").each(function (n) {
584
- drawMarks(d3.select(this), n, x);
585
- });
586
-
587
- chartSvg.select("line.pin").attr("x1", state.pin != null ? x(state.pin) : -10).attr("x2", state.pin != null ? x(state.pin) : -10);
588
- }
589
-
590
- function drawMarks(g, n, x) {
591
- const cy = BAR_Y + BAR_H / 2;
592
- // cluster marks closer than 4px so a chatty node draws a readable line of dots
593
- const marks = [];
594
- let last = -Infinity;
595
- for (const m of n.marks) {
596
- const px = x(m.t);
597
- if (px - last < 4 && marks.length) {
598
- marks[marks.length - 1].more.push(m);
599
- continue;
600
- }
601
- marks.push({ m, px, more: [] });
602
- last = px;
603
- }
604
- g.selectAll("circle.mark")
605
- .data(marks)
606
- .join("circle")
607
- .attr("class", "mark")
608
- .attr("cx", (d) => d.px)
609
- .attr("cy", cy)
610
- .attr("r", (d) => (d.more.length ? 6 : 5))
611
- .attr("fill", (d) => css(MARK[worst(d)].color))
612
- .on("click", (ev, d) => {
613
- ev.stopPropagation();
614
- openItem(d.m);
615
- })
616
- .on("mouseenter", (ev, d) => showTip(ev, markTip(d)))
617
- .on("mousemove", (ev) => moveTip(ev))
618
- .on("mouseleave", hideTip);
619
- // labels under the bar, skipped where they would collide
620
- const labels = [];
621
- let edge = -Infinity;
622
- for (const d of marks) {
623
- const text = truncate(d.more.length ? `${d.m.label} +${d.more.length}` : d.m.label, 22);
624
- const w = text.length * 6 + 8;
625
- if (d.px - 6 < edge) continue;
626
- labels.push({ d, text });
627
- edge = d.px + w;
628
- }
629
- // a label that would run off the right edge hangs left of its mark instead
630
- const flip = (l) => l.d.px - 4 + l.text.length * 6 > W;
631
- g.selectAll("text.mark-label")
632
- .data(labels)
633
- .join("text")
634
- .attr("class", "mark-label")
635
- .attr("x", (l) => (flip(l) ? l.d.px + 4 : l.d.px - 4))
636
- .attr("text-anchor", (l) => (flip(l) ? "end" : "start"))
637
- .attr("y", BAR_Y + BAR_H + 13)
638
- .text((l) => l.text)
639
- .on("click", (ev, l) => {
640
- ev.stopPropagation();
641
- openItem(l.d.m);
642
- })
643
- .on("mouseenter", (ev, l) => showTip(ev, markTip(l.d)))
644
- .on("mousemove", (ev) => moveTip(ev))
645
- .on("mouseleave", hideTip);
646
- }
647
- function worst(d) {
648
- const all = [d.m, ...d.more].map((m) => m.kind);
649
- for (const k of ["fail", "error", "cancel", "snapshot", "artifact", "pass", "event"]) if (all.includes(k)) return k;
650
- return all[0];
651
- }
652
-
653
- // ── axis + cursor ──
654
- function updateAxis(x) {
655
- const svg = d3.select(el.axis);
656
- if (state.view === "flow") return;
657
- const w = state.view === "flame" ? W : el.chart.clientWidth || W;
658
- svg.attr("viewBox", null);
659
- const g = svg.selectAll("g.axis").data([0]).join("g").attr("class", "axis").attr("transform", "translate(0,33)");
660
- g.call(d3.axisTop(x).ticks(Math.max(2, Math.floor(w / 90))).tickFormat(fmtTick).tickSizeOuter(0));
661
- g.selectAll(".tick text").attr("dy", "-0.5em");
662
- svg.selectAll("text.cursor-t").data([0]).join("text").attr("class", "cursor-t").attr("y", 12).attr("fill", css("--accent")).attr("font-size", 11);
663
- }
664
- function moveCursor(px) {
665
- const svgs = [chartSvg, flameSvg].filter(Boolean);
666
- for (const s of svgs) {
667
- s.select("line.cursor").attr("visibility", px == null ? "hidden" : "visible").attr("x1", px).attr("x2", px);
668
- }
669
- const label = d3.select(el.axis).select("text.cursor-t");
670
- if (px == null) label.text("");
671
- else {
672
- const t = xz().invert(px);
673
- label.attr("x", px + 4).text(fmtTick(Math.max(0, t)));
674
- }
675
- }
676
- function updateWindowLabel(x) {
677
- const [a, b] = x.domain();
678
- const span = b - a;
679
- const opt = el.windowSel.querySelector("option[value='0']");
680
- opt.textContent = `${fmtTick(span)} window`;
681
- el.windowSel.value = "0";
682
- }
683
-
684
- // ── flame view ──
685
- let flameSvg = null;
686
- function renderFlame() {
687
- d3.select(el.flame).selectAll("svg").remove();
688
- flameSvg = d3
689
- .select(el.flame)
690
- .append("svg")
691
- .on("mousemove", (ev) => moveCursor(d3.pointer(ev)[0]))
692
- .on("mouseleave", () => moveCursor(null));
693
- flameSvg.append("g").attr("class", "grid");
694
- flameSvg.append("g").attr("class", "cells");
695
- flameSvg.append("line").attr("class", "pin").attr("stroke", css("--accent")).attr("stroke-width", 1.5).attr("y1", 0).attr("y2", 4000);
696
- flameSvg.append("line").attr("class", "cursor").attr("y1", 0).attr("y2", 4000).attr("visibility", "hidden");
697
- flameSvg.call(flameZoom).call(flameZoom.transform, state.zoom);
698
- redrawX();
699
- }
700
- function drawFlame(x) {
701
- if (!flameSvg) return;
702
- const px = state.pin != null ? x(state.pin) : -10;
703
- flameSvg.select("line.pin").attr("x1", px).attr("x2", px);
704
- const H = el.flame.clientHeight;
705
- const ticks = x.ticks(Math.max(2, Math.floor(W / 90)));
706
- flameSvg
707
- .select(".grid")
708
- .selectAll("line")
709
- .data(ticks)
710
- .join("line")
711
- .attr("x1", (d) => x(d))
712
- .attr("x2", (d) => x(d))
713
- .attr("y1", 0)
714
- .attr("y2", H);
715
- const cells = flameSvg
716
- .select(".cells")
717
- .selectAll("g.cell")
718
- .data(model.nodes, (n) => n.id)
719
- .join((enter) => {
720
- const g = enter.append("g").attr("class", "cell");
721
- g.append("rect").attr("class", "flame-rect").attr("rx", 3).attr("height", FLAME_ROW - 2);
722
- g.append("text").attr("class", "flame-text").attr("y", FLAME_ROW / 2 + 3);
723
- return g;
724
- })
725
- .attr("transform", (n) => `translate(0,${8 + n.depth * FLAME_ROW})`);
726
- cells
727
- .select("rect")
728
- .attr("x", (n) => x(n.start))
729
- .attr("width", (n) => Math.max(1, x(n.end) - x(n.start)))
730
- .attr("fill", (n) => colorOf(n))
731
- .attr("fill-opacity", (n) => (n.status === "cancelled" ? 0.55 : 0.92))
732
- .classed("selected", (n) => n.id === state.selected)
733
- .call(bindNode);
734
- cells
735
- .select("text")
736
- .attr("x", (n) => Math.max(x(n.start), 0) + 5)
737
- .text((n) => {
738
- const w = x(n.end) - Math.max(x(n.start), 0);
739
- return w < 34 ? "" : truncate(`${displayName(n)} ${fmtDur(n.duration)}`, (w - 10) / 6.2);
740
- })
741
- .call(bindNode);
742
- }
743
-
744
- // ── flow view ──
745
- let flowG = null;
746
- let flowPos = new Map();
747
- let flowBounds = null;
748
- function renderFlow() {
749
- d3.select(el.flow).selectAll("svg").remove();
750
- const svg = d3.select(el.flow).append("svg");
751
- flowG = svg.append("g");
752
- const h = d3.hierarchy(model.root, (n) => n.children);
753
- const CW = 210;
754
- const CH = 46;
755
- // cards that took snapshots carry a strip of them underneath, so leave room for it
756
- const shotsOf = (n) => model.gallery.filter((g) => g.node === n);
757
- const SH = model.gallery.some((g) => g.node) ? 36 : 0;
758
- d3.tree().nodeSize([CH + 18 + SH, CW + 70])(h);
759
- let minX = Infinity;
760
- let maxX = -Infinity;
761
- let maxY = 0;
762
- h.each((d) => {
763
- minX = Math.min(minX, d.x);
764
- maxX = Math.max(maxX, d.x);
765
- maxY = Math.max(maxY, d.y);
766
- });
767
- flowBounds = { x0: -20, y0: minX - CH, x1: maxY + CW + 20, y1: maxX + CH };
768
- flowG
769
- .append("g")
770
- .selectAll("path")
771
- .data(h.links())
772
- .join("path")
773
- .attr("class", "flow-link")
774
- .attr("d", (l) => {
775
- const sx = l.source.y + CW;
776
- const sy = l.source.x;
777
- const tx = l.target.y;
778
- const ty = l.target.x;
779
- const mx = (sx + tx) / 2;
780
- return `M${sx},${sy}C${mx},${sy} ${mx},${ty} ${tx},${ty}`;
781
- });
782
- const card = flowG
783
- .append("g")
784
- .selectAll("g")
785
- .data(h.descendants())
786
- .join("g")
787
- .attr("class", (d) => `flow-card ${d.data.id === state.selected ? "selected" : ""}`)
788
- .attr("transform", (d) => `translate(${d.y},${d.x - CH / 2})`)
789
- .style("cursor", "pointer")
790
- .on("click", (ev, d) => select(d.data.id))
791
- .on("mouseenter", (ev, d) => showTip(ev, nodeTip(d.data)))
792
- .on("mousemove", (ev) => moveTip(ev))
793
- .on("mouseleave", hideTip);
794
- card.append("rect").attr("class", "box").attr("width", CW).attr("height", CH).attr("rx", 8);
795
- card.append("rect").attr("width", 5).attr("height", CH - 12).attr("x", 6).attr("y", 6).attr("rx", 2).attr("fill", (d) => colorOf(d.data));
796
- card.append("text").attr("x", 20).attr("y", 19).text((d) => truncate(displayName(d.data), 27));
797
- card
798
- .append("text")
799
- .attr("class", "sub")
800
- .attr("x", 20)
801
- .attr("y", 35)
802
- .text((d) => `${d.data.status} · ${fmtDur(d.data.duration)} · +${fmtTick(d.data.start)}`);
803
- card
804
- .append("circle")
805
- .attr("cx", CW - 14)
806
- .attr("cy", 14)
807
- .attr("r", 4)
808
- .attr("fill", (d) => css(statusVar(d.data.status)));
809
- const fcb = card
810
- .filter((d) => hasCode(d.data))
811
- .append("g")
812
- .attr("class", "code-btn")
813
- .attr("transform", `translate(${CW / 2},0)`)
814
- .on("click", (ev, d) => {
815
- ev.stopPropagation();
816
- toggleCode(d.data, ev);
817
- })
818
- .on("mouseenter", (ev) => {
819
- ev.stopPropagation();
820
- showTip(ev, "Show source");
821
- });
822
- fcb.append("circle").attr("r", 9);
823
- fcb.append("text").attr("text-anchor", "middle").attr("y", 3.5).text("<>");
824
- const frb = card
825
- .filter((d) => hasValue(d.data))
826
- .append("g")
827
- .attr("class", "code-btn ret")
828
- .attr("transform", (d) => `translate(${CW / 2 + (hasCode(d.data) ? 22 : 0)},0)`)
829
- .on("click", (ev, d) => {
830
- ev.stopPropagation();
831
- toggleCode(d.data, ev, "value", true);
832
- })
833
- .on("mouseenter", (ev, d) => {
834
- ev.stopPropagation();
835
- showTip(ev, `Returned: <span class="mono">${esc(valuePreview(d.data, 80))}</span>`);
836
- });
837
- frb.append("circle").attr("r", 9);
838
- frb.append("path").attr("d", ICON.ret).attr("transform", "translate(-6,-6) scale(0.5)");
839
- const TW = 46;
840
- const TH = 28;
841
- const strip = card
842
- .filter((d) => shotsOf(d.data).length > 0)
843
- .append("g")
844
- .attr("class", "flow-shots")
845
- .attr("transform", `translate(6,${CH + 4})`);
846
- strip.each(function (d) {
847
- const shots = shotsOf(d.data);
848
- const fit = Math.floor((CW - 12) / (TW + 4));
849
- const shown = shots.length > fit ? shots.slice(0, fit - 1) : shots;
850
- const g = d3.select(this);
851
- const cell = g
852
- .selectAll("g.shot")
853
- .data(shown)
854
- .join("g")
855
- .attr("class", "shot")
856
- .attr("transform", (_x, i) => `translate(${i * (TW + 4)},0)`)
857
- .on("click", (ev, x) => {
858
- ev.stopPropagation();
859
- openGallery(x);
860
- })
861
- .on("mouseenter", (ev, x) => {
862
- ev.stopPropagation();
863
- showTip(ev, galleryTip(x));
864
- })
865
- .on("mousemove", (ev) => moveTip(ev))
866
- .on("mouseleave", hideTip);
867
- cell.append("rect").attr("width", TW).attr("height", TH).attr("rx", 4);
868
- cell
869
- .filter((x) => x.image)
870
- .append("image")
871
- .attr("href", (x) => model.asset(x.f.asset))
872
- .attr("x", 1)
873
- .attr("y", 1)
874
- .attr("width", TW - 2)
875
- .attr("height", TH - 2)
876
- .attr("preserveAspectRatio", "xMidYMid slice");
877
- cell
878
- .filter((x) => !x.image)
879
- .append("text")
880
- .attr("x", TW / 2)
881
- .attr("y", TH / 2 + 3.5)
882
- .attr("text-anchor", "middle")
883
- .text((x) => (x.type === "snapshot" ? "{ }" : "file"));
884
- if (shots.length > shown.length) {
885
- const more = g.append("g").attr("class", "shot more").attr("transform", `translate(${shown.length * (TW + 4)},0)`)
886
- .on("click", (ev) => {
887
- ev.stopPropagation();
888
- select(d.data.id, "snapshots");
889
- });
890
- more.append("rect").attr("width", TW).attr("height", TH).attr("rx", 4);
891
- more.append("text").attr("x", TW / 2).attr("y", TH / 2 + 3.5).attr("text-anchor", "middle").text(`+${shots.length - shown.length}`);
892
- }
893
- });
894
- flowPos = new Map(h.descendants().map((d) => [d.data.id, { x: d.y + CW / 2, y: d.x }]));
895
- svg.call(flowZoom);
896
- fitFlow(true);
897
- }
898
- function fitFlow(instant) {
899
- const svg = d3.select(el.flow).select("svg");
900
- if (svg.empty() || !flowBounds) return;
901
- const w = el.flow.clientWidth;
902
- const h = el.flow.clientHeight;
903
- const b = flowBounds;
904
- const k = Math.min(1.2, Math.min(w / (b.x1 - b.x0), h / (b.y1 - b.y0)) * 0.95);
905
- const t = d3.zoomIdentity
906
- .translate(w / 2 - (k * (b.x0 + b.x1)) / 2, h / 2 - (k * (b.y0 + b.y1)) / 2)
907
- .scale(k);
908
- (instant ? svg : svg.transition().duration(250)).call(flowZoom.transform, t);
909
- }
910
-
911
- // ── overview minimap ──
912
- let ovBrush = null;
913
- let ovX = null;
914
- let brushing = false;
915
- function renderOverview() {
916
- const svg = d3.select(el.overview);
917
- svg.selectAll("*").remove();
918
- const w = el.overview.clientWidth || 220;
919
- const h = el.overview.clientHeight || 120;
920
- ovX = d3.scaleLinear().domain(baseX.domain()).range([0, w]);
921
- const rowH = Math.max(1.5, Math.min(7, (h - 6) / model.nodes.length));
922
- svg
923
- .append("g")
924
- .selectAll("rect")
925
- .data(model.nodes)
926
- .join("rect")
927
- .attr("x", (n) => ovX(n.start))
928
- .attr("width", (n) => Math.max(1, ovX(n.end) - ovX(n.start)))
929
- .attr("y", (_n, i) => 3 + i * rowH)
930
- .attr("height", Math.max(1, rowH - 1))
931
- .attr("rx", Math.min(2, rowH / 2))
932
- .attr("fill", (n) => colorOf(n));
933
- ovBrush = d3
934
- .brushX()
935
- .extent([[0, 0], [w, h]])
936
- .on("start", (ev) => {
937
- if (ev.sourceEvent) brushing = true;
938
- })
939
- .on("brush end", (ev) => {
940
- if (ev.type === "end") brushing = false;
941
- if (!ev.sourceEvent || !ev.selection) return;
942
- const [a, b] = ev.selection.map(ovX.invert);
943
- zoomToDomain(a, b);
944
- });
945
- svg.append("g").attr("class", "brush").call(ovBrush);
946
- el.range.textContent = `0s – ${fmtTick(model.span)} (${fmtDur(model.root.duration)} body)`;
947
- updateOverviewBrush(xz());
948
- }
949
- function updateOverviewBrush(x) {
950
- if (!ovBrush || !ovX || brushing) return;
951
- const [a, b] = x.domain();
952
- const s = [Math.max(0, ovX(a)), Math.min(ovX.range()[1], ovX(b))];
953
- d3.select(el.overview).select("g.brush").call(ovBrush.move, s);
954
- }
955
-
956
- // ── selection + details ──
957
- function select(id, tab) {
958
- state.selected = id;
959
- if (tab) state.detailTab = tab;
960
- state.details = true;
961
- el.main.classList.remove("no-details");
962
- el.tree.querySelectorAll(".row").forEach((r) => r.classList.toggle("selected", r.dataset.id === id));
963
- if (chartSvg) {
964
- chartSvg.selectAll("rect.bar").classed("selected", (n) => n.id === id);
965
- chartSvg.selectAll("g.r").classed("sel", (n) => n.id === id);
966
- }
967
- if (flameSvg) flameSvg.selectAll("rect.flame-rect").classed("selected", (n) => n.id === id);
968
- d3.select(el.flow).selectAll("g.flow-card").classed("selected", (d) => d.data.id === id);
969
- renderDetails();
970
- }
971
- function focusNode(n) {
972
- const pad = Math.max(n.end - n.start, 50) * 0.08;
973
- zoomToDomain(n.start - pad, n.end + pad);
974
- }
975
- function revealRow(n, block) {
976
- let p = n.parent;
977
- let changed = false;
978
- while (p) {
979
- if (state.collapsed.delete(p.id)) changed = true;
980
- p = p.parent;
981
- }
982
- if (changed && state.view === "timeline") renderTimeline();
983
- const row = el.tree.querySelector(`.row[data-id="${CSS.escape(n.id)}"]`);
984
- if (row) row.scrollIntoView({ block: block || "nearest" });
985
- }
986
-
987
- /**
988
- * Bring a gallery item into view in whichever graph is showing: select its node, pin its
989
- * time, pan (keeping the zoom) so that time is on screen, and pulse its mark.
990
- */
991
- function locate(g) {
992
- if (!g) return;
993
- if (g.node) select(g.node.id, "snapshots");
994
- if (state.view === "flow") {
995
- if (g.node) centerFlowOn(g.node);
996
- return;
997
- }
998
- if (g.t != null) {
999
- state.pin = g.t;
1000
- const [a, b] = xz().domain();
1001
- if (g.t < a || g.t > b) {
1002
- const k = state.zoom.k;
1003
- const t = d3.zoomIdentity.translate(W / 2 - k * baseX(g.t), 0).scale(k);
1004
- const { z, sel } = activeZoom();
1005
- sel.call(z.transform, t);
1006
- } else redrawX();
1007
- }
1008
- if (g.node && state.view === "timeline") revealRow(g.node, "center");
1009
- flash(g);
1010
- }
1011
- function flash(g) {
1012
- const svgs = [chartSvg, flameSvg].filter(Boolean);
1013
- for (const s of svgs) {
1014
- s.selectAll("line.pin").classed("flash", false).node()?.getBoundingClientRect();
1015
- s.selectAll("line.pin").classed("flash", true);
1016
- }
1017
- if (chartSvg) {
1018
- const hit = chartSvg
1019
- .selectAll("circle.mark")
1020
- .filter((d) => d.m.gallery === g || d.more.some((m) => m.gallery === g));
1021
- hit.classed("flash", false).node()?.getBoundingClientRect();
1022
- hit.classed("flash", true);
1023
- }
1024
- setTimeout(() => d3.selectAll(".flash").classed("flash", false), 1800);
1025
- }
1026
- function centerFlowOn(n) {
1027
- const svg = d3.select(el.flow).select("svg");
1028
- const p = flowPos.get(n.id);
1029
- if (svg.empty() || !p) return;
1030
- const k = Math.max(0.8, d3.zoomTransform(svg.node()).k);
1031
- const t = d3.zoomIdentity.translate(el.flow.clientWidth / 2 - k * p.x, el.flow.clientHeight / 2 - k * p.y).scale(k);
1032
- svg.transition().duration(300).call(flowZoom.transform, t);
1033
- }
1034
-
1035
- function renderDetails() {
1036
- const n = model.byId.get(state.selected) || model.root;
1037
- const color = colorOf(n);
1038
- const tabs = [
1039
- ["overview", "Overview"],
1040
- ["args", "Args"],
1041
- ...(hasValue(n) ? [["value", "Return"]] : []),
1042
- ["events", `Events`],
1043
- ["logs", "Logs"],
1044
- ["snapshots", "Snapshots"],
1045
- ];
1046
- el.details.innerHTML = `
1047
- <div class="dh">Action details <button class="x" title="Close">${svgIcon("close", ' width="16" height="16"')}</button></div>
1048
- <div class="who">
1049
- <span class="icon" style="color:${color};background:${alpha(color, 0.16)}">${svgIcon(iconOf(n))}</span>
1050
- <span class="nm" title="${esc(n.name)}">${esc(displayName(n))}</span>
1051
- <span class="pill"><span class="dot ${n.status}"></span>${esc(n.status)}</span>
1052
- </div>
1053
- <div class="scroll">
1054
- <div class="kv">
1055
- <div class="k">Duration</div><div class="v">${fmtDur(n.duration)}</div>
1056
- <div class="k">Start</div><div class="v">${fmtRel(n.start)} (${fmtClock(n.startAbs)})</div>
1057
- <div class="k">End</div><div class="v">${fmtRel(n.end)} (${fmtClock(n.endAbs)})</div>
1058
- ${n.v.description ? `<div class="k">Summary</div><div class="v">${esc(n.v.description.summary)}</div>` : ""}
1059
- ${n.v.mcp ? `<div class="k">MCP</div><div class="v"><span class="badge-inline">${esc(n.v.mcp.mode)}</span></div>` : ""}
1060
- <div class="k">Path</div><div class="v">${esc(n.v.path)}</div>
1061
- ${userSite(n) ? `<div class="k">${siteLabel(n)}</div><div class="v defined">
1062
- <button class="icon-btn" data-copy-source aria-label="Copy relative path">${svgIcon("copy")}</button>
1063
- ${hasCode(n) ? `<button class="icon-btn" data-show-code aria-label="Show source">${svgIcon("code")}</button>` : ""}
1064
- ${hasValue(n) ? `<button class="icon-btn" data-show-value aria-label="Show returned value">${svgIcon("ret")}</button>` : ""}
1065
- <a class="icon-btn" href="${esc(editorUrl(userSite(n)))}" aria-label="Open in VS Code" data-tip-text="Open in VS Code">${svgIcon("external")}</a>
1066
- <span class="defined-name" >${esc(basename(sourceOf(n)))}</span>
1067
- </div>` : ""}
1068
- </div>
1069
- <div class="tabs">${tabs
1070
- .map(([k, l]) => `<button class="tab ${state.detailTab === k ? "active" : ""}" data-tab="${k}">${l}</button>`)
1071
- .join("")}</div>
1072
- <div class="tabbody">${detailTabHtml(n)}</div>
1073
- </div>`;
1074
- const copySrc = el.details.querySelector("[data-copy-source]");
1075
- if (copySrc) {
1076
- copySrc.addEventListener("click", () => copySource(n));
1077
- copySrc.addEventListener("mouseenter", (ev) =>
1078
- showTip(ev, `<div class="mono">${esc(sourceOf(n))}</div><div class="hint">Click (or press <kbd>C</kbd>) to copy</div>`)
1079
- );
1080
- copySrc.addEventListener("mousemove", (ev) => moveTip(ev));
1081
- copySrc.addEventListener("mouseleave", hideTip);
1082
- }
1083
- const showValue = el.details.querySelector("[data-show-value]");
1084
- if (showValue) {
1085
- showValue.addEventListener("click", (ev) => toggleCode(n, ev, "value", true));
1086
- showValue.addEventListener("mouseenter", (ev) => showTip(ev, `Returned: <span class="mono">${esc(valuePreview(n, 80))}</span>`));
1087
- showValue.addEventListener("mouseleave", hideTip);
1088
- }
1089
- wireValue(el.details, n);
1090
- const showCode = el.details.querySelector("[data-show-code]");
1091
- if (showCode) {
1092
- showCode.addEventListener("click", (ev) => toggleCode(n, ev));
1093
- showCode.addEventListener("mouseenter", (ev) => showTip(ev, "Show source"));
1094
- showCode.addEventListener("mouseleave", hideTip);
1095
- }
1096
- el.details.querySelectorAll("[data-tip-text]").forEach((a) => {
1097
- a.addEventListener("mouseenter", (ev) => showTip(ev, esc(a.dataset.tipText)));
1098
- a.addEventListener("mouseleave", hideTip);
1099
- });
1100
- el.details.querySelector(".x").addEventListener("click", () => {
1101
- state.details = false;
1102
- el.main.classList.add("no-details");
1103
- relayout();
1104
- });
1105
- el.details.querySelectorAll(".tab").forEach((b) =>
1106
- b.addEventListener("click", () => {
1107
- state.detailTab = b.dataset.tab;
1108
- renderDetails();
1109
- })
1110
- );
1111
- wireItems(el.details);
1112
- }
1113
-
1114
- function detailTabHtml(n) {
1115
- const tab = state.detailTab;
1116
- if (tab === "value") {
1117
- return hasValue(n) ? `<div class="code-box">${valueHtml(n)}</div>` : '<div class="muted">Returned nothing.</div>';
1118
- }
1119
- if (tab === "args") {
1120
- const params = schemaFields(n.v.inputSchema);
1121
- const schema = params.length ? `<div class="section-title">Schema</div><div class="params">${params.map(paramHtml).join("")}</div><div class="section-title">Values</div>` : "";
1122
- return schema + (n.v.args && n.v.args.length ? `<pre class="code">${esc(json(n.v.args.length === 1 ? n.v.args[0] : n.v.args))}</pre>` : '<div class="muted">No arguments.</div>');
1123
- }
1124
- if (tab === "events") {
1125
- const evs = n.entries.filter((i) => i.kind !== "info");
1126
- return evs.length ? `<ul class="ev-list">${evs.map((i) => evLi(i, n)).join("")}</ul>` : '<div class="muted">No events.</div>';
1127
- }
1128
- if (tab === "logs") {
1129
- const logs = n.entries.filter((i) => i.kind === "info" || i.kind === "error");
1130
- return logs.length
1131
- ? `<pre class="code">${logs.map((i) => `<span class="muted">${esc(fmtRel(i.t - n.start).padEnd(8))}</span>${esc(i.e.message)}`).join("\n")}</pre>`
1132
- : '<div class="muted">No log output.</div>';
1133
- }
1134
- if (tab === "snapshots") {
1135
- const g = model.gallery.filter((x) => x.node === n);
1136
- return g.length ? `<div class="thumbs">${g.map(thumbHtml).join("")}</div>` : '<div class="muted">No snapshots or files.</div>';
1137
- }
1138
- // overview
1139
- const fails = model.run.failures.filter((f) => f.action === n.id || f.action === n.name || f.action === n.v.path);
1140
- const asserts = n.entries.filter((i) => i.kind === "pass" || i.kind === "fail");
1141
- const g = model.gallery.filter((x) => x.node === n);
1142
- return `
1143
- <div class="kv">
1144
- <div class="k">Id</div><div class="v">${esc(n.id)}</div>
1145
- ${n.name !== displayName(n) ? `<div class="k">Name</div><div class="v">${esc(n.name)}</div>` : ""}
1146
- <div class="k">Tags</div><div class="v">${n.v.tags.length ? esc(n.v.tags.join(", ")) : '<span class="muted">none</span>'}</div>
1147
- <div class="k">Children</div><div class="v">${n.children.length}</div>
1148
- <div class="k">Events</div><div class="v">${n.marks.length}</div>
1149
- </div>
1150
- ${fails.length ? `<div class="section-title" style="color:var(--fail)">Failures (${fails.length})</div>${fails.map((f) => `<pre class="code">${esc(f.error)}</pre>`).join("")}` : ""}
1151
- ${g.length ? `<div class="section-title">Snapshots (${g.length})</div><div class="thumbs">${g.slice(0, 6).map(thumbHtml).join("")}</div>` : ""}
1152
- <div class="section-title">Related logs</div>
1153
- <div class="kv"><div class="k">${svgIcon("file", ' width="14" height="14"')}</div><div class="v"><a href="${esc(fileUrl(n.v.logFile))}" title="${esc(n.v.logFile)}">${esc(basename(n.v.logFile))}</a></div></div>
1154
- <div class="section-title">Assertions (${asserts.length})</div>
1155
- ${asserts.length ? `<ul class="ev-list">${asserts.map((i) => evLi(i, n)).join("")}</ul>` : '<div class="muted">No assertions in this action.</div>'}`;
1156
- }
1157
-
1158
- function evLi(i, n) {
1159
- const mk = MARK[i.kind] || MARK.event;
1160
- const color = css(mk.color);
1161
- const text = i.kind === "lifecycle" ? i.e.message : i.label;
1162
- return `<li data-item="${i.idx}" style="cursor:pointer">
1163
- <span class="t">${fmtRel(i.t - (n ? n.start : 0))}</span>
1164
- <span class="ev-ico" style="background:${alpha(color, 0.2)};color:${color}">${svgIcon(mk.icon)}</span>
1165
- <span class="m" title="${esc(i.e.message)}">${esc(truncate(text, 140))}</span></li>`;
1166
- }
1167
-
1168
- function thumbHtml(g) {
1169
- const i = model.gallery.indexOf(g);
1170
- if (g.image) return `<div class="thumb" data-gallery="${i}" title="${esc(g.name)}"><img alt="${esc(g.name)}" data-asset="${esc(g.f.asset)}"></div>`;
1171
- const label = g.type === "snapshot" ? "{ }" : g.f.mime.split("/")[1] || "file";
1172
- return `<div class="thumb" data-gallery="${i}" title="${esc(g.name)}"><span class="json-ico">${esc(label)}</span></div>`;
1173
- }
1174
-
1175
- function wireItems(root) {
1176
- root.querySelectorAll("img[data-asset]").forEach((img) => {
1177
- if (!img.src) img.src = model.asset(img.dataset.asset);
1178
- });
1179
- root.querySelectorAll("[data-gallery]").forEach((t) =>
1180
- t.addEventListener("click", () => openGallery(model.gallery[+t.dataset.gallery]))
1181
- );
1182
- root.querySelectorAll("[data-item]").forEach((t) =>
1183
- t.addEventListener("click", () => openItem(model.items[+t.dataset.item]))
1184
- );
1185
- }
1186
-
1187
- function openItem(item) {
1188
- if (!item) return;
1189
- setPin(item.t);
1190
- if (item.node && item.node.id !== state.selected) select(item.node.id, "events");
1191
- if (item.gallery) openGallery(item.gallery);
1192
- }
1193
- function setPin(t) {
1194
- state.pin = t;
1195
- redrawX();
1196
- }
1197
-
1198
- // ── lower panel ──
1199
- function renderLower() {
1200
- const tabs = [
1201
- ["snapshots", `Snapshots (${model.gallery.length})`],
1202
- ["logs", "Logs"],
1203
- ["asserts", `Assertions (${model.asserts.length})`],
1204
- ["events", "Timeline events"],
1205
- ];
1206
- el.lower.innerHTML = `<div class="tabs">${tabs
1207
- .map(([k, l]) => `<button class="tab ${state.lowerTab === k ? "active" : ""}" data-tab="${k}">${l}</button>`)
1208
- .join("")}<span class="spacer"></span><input class="filter" placeholder="Filter…" value="${esc(state.filter)}"></div>
1209
- <div class="body">${lowerBodyHtml()}</div>`;
1210
- el.lower.querySelectorAll(".tab").forEach((b) =>
1211
- b.addEventListener("click", () => {
1212
- state.lowerTab = b.dataset.tab;
1213
- renderLower();
1214
- })
1215
- );
1216
- const input = el.lower.querySelector(".filter");
1217
- input.addEventListener("input", () => {
1218
- state.filter = input.value;
1219
- el.lower.querySelector(".body").innerHTML = lowerBodyHtml();
1220
- wireLower();
1221
- });
1222
- wireLower();
1223
- }
1224
- function wireLower() {
1225
- const body = el.lower.querySelector(".body");
1226
- wireItems(body);
1227
- body.querySelectorAll("tr[data-row]").forEach((tr) =>
1228
- tr.addEventListener("click", () => openItem(model.items[+tr.dataset.row]))
1229
- );
1230
- body.querySelectorAll("[data-locate]").forEach((b) => {
1231
- b.addEventListener("click", (ev) => {
1232
- ev.stopPropagation();
1233
- locate(model.gallery[+b.dataset.locate]);
1234
- });
1235
- b.addEventListener("mouseenter", (ev) => showTip(ev, `Show in the ${state.view === "flow" ? "flow" : "graph"}`));
1236
- b.addEventListener("mouseleave", hideTip);
1237
- });
1238
- }
1239
- function matches(...parts) {
1240
- const q = state.filter.trim().toLowerCase();
1241
- return !q || parts.some((p) => String(p || "").toLowerCase().includes(q));
1242
- }
1243
- function lowerBodyHtml() {
1244
- const tab = state.lowerTab;
1245
- if (tab === "snapshots") {
1246
- const g = model.gallery.filter((x) => matches(x.name, x.node && x.node.v.path));
1247
- if (!g.length) return '<div class="muted">No snapshots, artifacts, or images in this run.</div>';
1248
- return `<div class="gallery">${g
1249
- .map((x) => {
1250
- const sub = x.type === "snapshot" ? `snapshot · ${x.node ? esc(displayName(x.node)) : ""}` : `${esc(x.f.mime)} · ${fmtSize(x.f.size)}${x.f.omitted ? " · not embedded" : ""}`;
1251
- const at = x.t != null || x.node ? `<button class="icon-btn locate" data-locate="${model.gallery.indexOf(x)}" aria-label="Show in graph">${svgIcon("locate")}</button>` : "";
1252
- return `<div class="gal-item">${thumbHtml(x)}<div class="cap"><span class="t">${x.t == null ? "–" : fmtTick(x.t)}</span><span class="n" title="${esc(x.name)}">${esc(x.name)}</span>${at}</div><div class="sz">${sub}</div></div>`;
1253
- })
1254
- .join("")}</div>`;
1255
- }
1256
- const pick =
1257
- tab === "logs"
1258
- ? (i) => i.kind === "info" || i.kind === "error"
1259
- : tab === "asserts"
1260
- ? (i) => i.kind === "pass" || i.kind === "fail"
1261
- : (i) => i.kind !== "info";
1262
- const rows = [];
1263
- for (let idx = 0; idx < model.items.length; idx++) {
1264
- const i = model.items[idx];
1265
- if (!pick(i)) continue;
1266
- if (!matches(i.e.message, i.e.path, i.e.source)) continue;
1267
- rows.push({ i, idx });
1268
- if (rows.length >= 3000) break;
1269
- }
1270
- if (!rows.length) return `<div class="muted">${tab === "asserts" ? "No assertions in this run." : "Nothing matches."}</div>`;
1271
- return `<table class="list"><tbody>${rows
1272
- .map(({ i, idx }) => {
1273
- const mk = MARK[i.kind];
1274
- const ico = mk
1275
- ? `<span class="ev-ico" style="background:${alpha(css(mk.color), 0.2)};color:${css(mk.color)}">${svgIcon(mk.icon)}</span>`
1276
- : "";
1277
- return `<tr data-row="${idx}" class="${i.kind === "error" || i.kind === "fail" ? "err" : ""}"><td class="t">${fmtTick(i.t)}</td><td>${ico}</td><td class="p" title="${esc(i.e.path)}">${esc(i.node ? displayName(i.node) : i.e.path)}</td><td class="m">${esc(i.e.message)}</td></tr>`;
1278
- })
1279
- .join("")}</tbody></table>${rows.length >= 3000 ? '<div class="muted">First 3000 rows — narrow with the filter.</div>' : ""}`;
1280
- }
1281
-
1282
- // ── modal ──
1283
- function openGallery(g) {
1284
- if (!g) return;
1285
- if (g.t != null) setPin(g.t);
1286
- let body;
1287
- let actions = "";
1288
- const pathOf = g.type === "file" ? g.f.file : g.s.file;
1289
- if (g.type === "file" && g.image) {
1290
- const src = model.asset(g.f.asset);
1291
- body = `<img alt="${esc(g.name)}" src="${src}">`;
1292
- actions = `<a class="btn solo" download="${esc(basename(g.f.file))}" href="${src}">${svgIcon("download")}Download</a>`;
1293
- } else if (g.type === "file") {
1294
- body = g.f.text != null ? `<pre class="code">${esc(g.f.text)}</pre>` : `<div class="muted">Not embedded (${esc(g.f.omitted || "binary")}). Open it from the run folder.</div>`;
1295
- } else {
1296
- body = `<pre class="code">${esc(g.s.value !== undefined ? json(g.s.value) : g.s.text)}</pre>${g.s.truncated ? '<div class="muted">Truncated — the snapshot file holds the rest.</div>' : ""}`;
1297
- }
1298
- const sub = [g.t != null ? fmtRel(g.t) : null, g.node ? g.node.v.path : null, g.type === "file" ? fmtSize(g.f.size) : "snapshot"]
1299
- .filter(Boolean)
1300
- .join(" · ");
1301
- const m = document.createElement("div");
1302
- m.className = "modal";
1303
- m.innerHTML = `<div class="box"><div class="mh"><span class="nm">${esc(g.name)} <span class="muted" style="font-weight:400">${esc(sub)}</span></span>
1304
- ${actions}
1305
- <a class="btn solo" href="${esc(fileUrl(pathOf))}">${svgIcon("file")}Open</a>
1306
- <button class="btn solo" data-copy>${svgIcon("copy")}Copy path</button>
1307
- <button class="btn solo" data-close title="Close">${svgIcon("close")}</button></div>
1308
- <div class="mb">${body}</div></div>`;
1309
- const close = () => {
1310
- m.remove();
1311
- document.removeEventListener("keydown", onKey);
1312
- };
1313
- const onKey = (e) => e.key === "Escape" && close();
1314
- m.addEventListener("click", (e) => e.target === m && close());
1315
- m.querySelector("[data-close]").addEventListener("click", close);
1316
- m.querySelector("[data-copy]").addEventListener("click", () => copy(pathOf));
1317
- document.addEventListener("keydown", onKey);
1318
- document.body.appendChild(m);
1319
- if (g.node && g.node.id !== state.selected) select(g.node.id);
1320
- }
1321
-
1322
- function openMenu(anchor) {
1323
- const r = anchor.getBoundingClientRect();
1324
- const m = document.createElement("div");
1325
- m.className = "menu";
1326
- m.style.top = `${r.bottom + 6}px`;
1327
- m.style.right = `${window.innerWidth - r.right}px`;
1328
- m.innerHTML = `<button data-a="theme">${svgIcon("sun", ' width="14" height="14"')} Toggle light / dark</button>
1329
- <button data-a="data">${svgIcon("download", ' width="14" height="14"')} Download run data (JSON)</button>
1330
- <button data-a="folder">${svgIcon("copy", ' width="14" height="14"')} Copy run folder path</button>
1331
- <button data-a="expand">Expand all</button>
1332
- <button data-a="collapse">Collapse all</button>`;
1333
- const close = () => {
1334
- m.remove();
1335
- document.removeEventListener("mousedown", away, true);
1336
- };
1337
- const away = (e) => !m.contains(e.target) && close();
1338
- m.addEventListener("click", (e) => {
1339
- const a = e.target.closest("button")?.dataset.a;
1340
- close();
1341
- if (a === "theme") {
1342
- toggleTheme();
1343
- renderAll();
1344
- renderDetails();
1345
- renderLower();
1346
- }
1347
- if (a === "data") download(`${model.data.name}-run.json`, JSON.stringify(model.data, null, 2), "application/json");
1348
- if (a === "folder") copy(model.data.folder);
1349
- if (a === "expand") {
1350
- state.collapsed.clear();
1351
- renderAll();
1352
- }
1353
- if (a === "collapse") {
1354
- for (const n of model.nodes) if (n.depth >= 1 && n.children.length) state.collapsed.add(n.id);
1355
- renderAll();
1356
- }
1357
- });
1358
- document.addEventListener("mousedown", away, true);
1359
- document.body.appendChild(m);
1360
- }
1361
-
1362
- // ── tooltips ──
1363
- /** Click selects, double-click focuses, hover explains — the same on every node mark. */
1364
- function bindNode(sel) {
1365
- sel
1366
- .on("click", (ev, n) => {
1367
- ev.stopPropagation();
1368
- select(n.id);
1369
- })
1370
- .on("dblclick", (ev, n) => {
1371
- ev.stopPropagation();
1372
- focusNode(n);
1373
- })
1374
- .on("mouseenter", (ev, n) => showTip(ev, nodeTip(n), true))
1375
- .on("mousemove", (ev) => moveTip(ev))
1376
- .on("mouseleave", hideTip);
1377
- }
1378
- function nodeTip(n) {
1379
- hovered = n; // the node `C` copies while its tooltip is up
1380
- const v = n.v;
1381
- const shown = n.depth === 0 ? model.data.name : displayName(n);
1382
- const badges = [
1383
- `<span class="badge"><span class="dot ${n.status}"></span>${esc(n.status)}</span>`,
1384
- v.mcp ? `<span class="badge mcp" title="Exposed over MCP">MCP · ${esc(v.mcp.mode)}</span>` : "",
1385
- n.depth === 0 ? '<span class="badge">om</span>' : "",
1386
- ].join("");
1387
- const rows = [
1388
- ["Duration", fmtDur(n.duration)],
1389
- ["When", `${fmtRel(n.start)} → ${fmtRel(n.end)}`],
1390
- v.tags.length ? ["Tags", esc(v.tags.join(" · "))] : null,
1391
- userSite(n) ? [siteLabel(n) === "Called at" ? "Called" : "Defined", `<span class="mono">${esc(sourceOf(n))}</span>`] : null,
1392
- hasValue(n) ? ["Returns", `<span class="mono">${esc(valuePreview(n, 90))}</span>`] : null,
1393
- n.children.length ? ["Children", String(n.children.length)] : null,
1394
- n.marks.length ? ["Events", String(n.marks.length)] : null,
1395
- ].filter(Boolean);
1396
- const args = !promptOf(n) && v.args && v.args.length ? truncate(oneLine(v.args.length === 1 ? v.args[0] : v.args), 220) : "";
1397
- const params = schemaFields(v.inputSchema);
1398
- const pr = promptOf(n);
1399
- const promptRows = pr
1400
- ? `<div class="sec">Prompt</div><div class="tprompt">${esc(truncate(pr.message || "", 200))}</div>${
1401
- pr.answer ? `<div class="tkv"><span class="k">Answer</span><span class="mono">${esc(truncate(typeof pr.answer.value === "string" ? pr.answer.value : oneLine(pr.answer.value), 120))}</span>${pr.answer.via ? `<span class="k">Via</span><span>${esc(pr.answer.via)}</span>` : ""}</div>` : ""
1402
- }`
1403
- : "";
1404
- const fails = model.run.failures.filter((f) => f.action === n.id || f.action === n.name || f.action === v.path);
1405
- return `<div class="tt">${esc(shown)}</div>
1406
- ${shown !== n.name ? `<div class="ts mono">${esc(truncate(n.name, 140))}</div>` : ""}
1407
- <div class="badges">${badges}</div>
1408
- ${v.description ? `<div class="summary">${esc(v.description.summary)}</div>` : '<div class="summary muted">No description.</div>'}
1409
- <div class="tkv">${rows.map(([k, val]) => `<span class="k">${k}</span><span>${val}</span>`).join("")}</div>
1410
- ${promptRows}
1411
- ${params.length ? `<div class="sec">Args schema</div><div class="params">${params.map(paramHtml).join("")}</div>` : ""}
1412
- ${args ? `<div class="sec">Args</div><div class="mono small">${esc(args)}</div>` : ""}
1413
- ${v.definedAt ? '<div class="hint">Press <kbd>C</kbd> to copy the path</div>' : ""}
1414
- ${fails.length ? `<div class="sec fail">Failed</div><div class="mono small">${esc(truncate(String(fails[0].error).split(String.fromCharCode(10))[0], 200))}</div>` : ""}`;
1415
- }
1416
- function galleryTip(x) {
1417
- const img = x.image ? `<img src="${model.asset(x.f.asset)}" alt="">` : "";
1418
- const body = x.type === "snapshot"
1419
- ? `<div class="mono small">${esc(truncate(x.s.value !== undefined ? oneLine(x.s.value) : x.s.text || "", 200))}</div>`
1420
- : `<div class="ts">${esc(x.f.mime)} · ${fmtSize(x.f.size)}</div>`;
1421
- return `<div class="tt">${esc(x.name)}${x.t != null ? ` · ${fmtRel(x.t)}` : ""}</div>${img}${body}`;
1422
- }
1423
- /** The om itself: its node tooltip, plus where the run's record lives. */
1424
- function omTip() {
1425
- return `${nodeTip(model.root)}<div class="sec">Run folder</div><div class="mono small">${esc(model.data.folder)}</div>`;
1426
- }
1427
- function markTip(d) {
1428
- const m = d.m;
1429
- const mk = MARK[m.kind] || MARK.event;
1430
- const img = m.gallery && m.gallery.image ? `<img src="${model.asset(m.gallery.f.asset)}" alt="">` : "";
1431
- const more = d.more.length ? `<div class="ts">+${d.more.length} more within a few ms</div>` : "";
1432
- const extra = m.gallery && m.gallery.image ? `<div class="ts">${esc(m.gallery.f.rel)} · ${fmtSize(m.gallery.f.size)}</div>` : "";
1433
- return `<div class="tt">${esc(mk.label)} · ${fmtRel(m.t)}</div>${img}<div>${esc(truncate(m.kind === "snapshot" || m.kind === "artifact" ? m.label : m.e.message, 260))}</div>${extra}${more}`;
1434
- }
1435
- // ── source popover (the <> buttons) — click-toggled, like the tskb explorer's ──
1436
- const codeEl = document.createElement("div");
1437
- codeEl.className = "code-pop";
1438
- codeEl.hidden = true;
1439
- document.body.appendChild(codeEl);
1440
- let codeFor = null;
1441
- let codeTab = null;
1442
- let codeAway = null;
1443
- /** A site inside omkit or an installed package — library source, not the user's. */
1444
- function isLib(site) {
1445
- return !!(site && model.data.library && model.data.library.includes(site));
1446
- }
1447
- /**
1448
- * The site worth showing for `n`: where it is defined, unless that is library source (a
1449
- * battery, a prompt), in which case the user's call that launched it.
1450
- */
1451
- function userSite(n) {
1452
- const { definedAt, calledAt } = n.v;
1453
- if (definedAt && !isLib(definedAt)) return definedAt;
1454
- if (calledAt && !isLib(calledAt)) return calledAt;
1455
- return definedAt || calledAt;
1456
- }
1457
- function siteLabel(n) {
1458
- return userSite(n) === n.v.definedAt ? "Defined at" : "Called at";
1459
- }
1460
- function codeOf(site) {
1461
- return site && model.data.code ? model.data.code[site] : undefined;
1462
- }
1463
- /** The popover's tabs for `n`, most useful first. */
1464
- function codeTabs(n) {
1465
- const tabs = [];
1466
- if (promptOf(n)) tabs.push({ key: "prompt", label: "Prompt" });
1467
- if (hasValue(n)) tabs.push({ key: "value", label: "Returned" });
1468
- const mine = userSite(n);
1469
- if (codeOf(mine)) tabs.push({ key: "user", label: siteLabel(n) === "Called at" ? "Call site" : "Source", site: mine });
1470
- const def = n.v.definedAt;
1471
- if (def && def !== mine && codeOf(def)) tabs.push({ key: "def", label: isLib(def) ? "Definition (omkit)" : "Definition", site: def });
1472
- return tabs;
1473
- }
1474
- function hasCode(n) {
1475
- return codeTabs(n).some((t) => t.key !== "value");
1476
- }
1477
- function relOf(site) {
1478
- return (model.data.sources && model.data.sources[site]) || String(site).replace(/:\d+(:\d+)?$/, "");
1479
- }
1480
- function hasValue(n) {
1481
- return n.v.value !== undefined || !!n.v.valueNote;
1482
- }
1483
- function valuePreview(n, max) {
1484
- if (n.v.value === undefined) return n.v.valueNote || "";
1485
- return truncate(oneLine(n.v.value), max);
1486
- }
1487
- /** The returned value in the code view: pretty JSON, highlighted, numbered, copyable. */
1488
- function valueHtml(n) {
1489
- if (n.v.value === undefined) {
1490
- return `<div class="pp"><div class="muted">${esc(n.v.valueNote || "Returned nothing.")}</div></div>`;
1491
- }
1492
- const text = json(n.v.value);
1493
- const lines = highlight(text);
1494
- const kind = Array.isArray(n.v.value) ? `array · ${n.v.value.length} items` : n.v.value === null ? "null" : typeof n.v.value === "object" ? `object · ${Object.keys(n.v.value).length} keys` : typeof n.v.value;
1495
- return `<div class="cp-tools"><span class="muted">${esc(kind)} · ${lines.length} line${lines.length > 1 ? "s" : ""} · ${fmtSize(text.length)}</span>
1496
- <button class="btn solo small" data-a="copy-json">${svgIcon("copy")}Copy JSON</button></div>
1497
- <div class="cp-code">${lines
1498
- .map((html, i) => `<div class="cl"><span class="ln">${i + 1}</span><span class="lc">${html || " "}</span></div>`)
1499
- .join("")}</div>`;
1500
- }
1501
- function wireValue(root, n) {
1502
- const b = root.querySelector('[data-a="copy-json"]');
1503
- if (b) b.addEventListener("click", () => copy(json(n.v.value), "Copied the returned value"));
1504
- }
1505
- function codeHtml(site) {
1506
- const c = codeOf(site);
1507
- const line = Number((/:(\d+)(?::\d+)?$/.exec(site) || [])[1]);
1508
- const lines = highlight(c.text).map(
1509
- (html, i) => `<div class="cl${c.start + i === line ? " at" : ""}"><span class="ln">${c.start + i}</span><span class="lc">${html || " "}</span></div>`
1510
- );
1511
- return `<div class="cp-code">${lines.join("")}</div>${c.truncated ? '<div class="muted cp-more">Cut short — open the file for the rest.</div>' : ""}`;
1512
- }
1513
- function promptHtml(p) {
1514
- const choices = p.choices && p.choices.length
1515
- ? `<div class="pp-k">Choices</div><div class="pp-v"><ol class="pp-choices">${p.choices
1516
- .map((c) => {
1517
- const value = typeof c === "string" ? c : c.value;
1518
- const label = typeof c === "string" ? c : c.label;
1519
- const picked = p.answer && p.answer.value === value;
1520
- return `<li class="${picked ? "picked" : ""}">${esc(label)}${label !== value ? ` <span class="muted">(${esc(value)})</span>` : ""}</li>`;
1521
- })
1522
- .join("")}</ol></div>`
1523
- : "";
1524
- const answer = p.answer
1525
- ? `<div class="pp-answer"><span class="pp-k">Answer</span><pre class="code">${esc(typeof p.answer.value === "string" ? p.answer.value : json(p.answer.value))}</pre>${
1526
- p.answer.via ? `<span class="badge-inline">via ${esc(p.answer.via)}</span>` : ""
1527
- }</div>`
1528
- : '<div class="muted">No answer recorded.</div>';
1529
- return `<div class="pp">
1530
- <div class="pp-q">${esc(p.message || "(no message)")}</div>
1531
- ${p.hint ? `<div class="mono small">${esc(p.hint)}</div>` : ""}
1532
- <div class="pp-grid">
1533
- <div class="pp-k">Kind</div><div class="pp-v">${esc(p.kind)}</div>
1534
- ${p.def !== undefined ? `<div class="pp-k">Default</div><div class="pp-v mono">${esc(oneLine(p.def))}</div>` : ""}
1535
- ${p.timeoutMs !== undefined ? `<div class="pp-k">Timeout</div><div class="pp-v">${p.timeoutMs > 0 && isFinite(p.timeoutMs) ? fmtDur(p.timeoutMs) : "none"}</div>` : ""}
1536
- ${choices}
1537
- </div>
1538
- ${answer}
1539
- </div>`;
1540
- }
1541
- function toggleCode(n, ev, want, toggle) {
1542
- // the <> closes whatever is open on its node; the return button closes only its own tab
1543
- if (codeFor === n.id && (!want || (toggle && codeTab === want))) return closeCode();
1544
- const tabs = codeTabs(n);
1545
- if (!tabs.length) return;
1546
- const tab = tabs.find((t) => t.key === want) || tabs.find((t) => t.key !== "value") || tabs[0];
1547
- codeTab = tab.key;
1548
- hideTip();
1549
- const reopen = codeFor !== n.id;
1550
- codeFor = n.id;
1551
- const site = tab.site || userSite(n);
1552
- const rel = site ? relOf(site) : "";
1553
- const line = site ? (/:(\d+)(?::\d+)?$/.exec(site) || [])[1] : "";
1554
- codeEl.innerHTML = `<div class="cp-head">
1555
- ${site ? `<span class="cp-path" title="${esc(site)}">${esc(rel)}<span class="muted">${line ? ":" + line : ""}</span></span>
1556
- <button class="icon-btn" data-a="copy" aria-label="Copy path">${svgIcon("copy")}</button>
1557
- <a class="icon-btn" href="${esc(editorUrl(site))}" aria-label="Open in VS Code">${svgIcon("external")}</a>` : ""}
1558
- <span class="cp-name">${esc(displayName(n))}</span>
1559
- <button class="icon-btn" data-a="close" aria-label="Close">${svgIcon("close")}</button>
1560
- </div>
1561
- ${tabs.length > 1 ? `<div class="cp-tabs">${tabs.map((t) => `<button class="tab ${t === tab ? "active" : ""}" data-tab="${t.key}">${esc(t.label)}</button>`).join("")}</div>` : ""}
1562
- <div class="cp-body">${tab.key === "prompt" ? promptHtml(promptOf(n)) : tab.key === "value" ? valueHtml(n) : codeHtml(site)}</div>`;
1563
- wireValue(codeEl, n);
1564
- const cp = codeEl.querySelector('[data-a="copy"]');
1565
- if (cp) cp.addEventListener("click", () => copy(rel, `Copied ${rel}`));
1566
- codeEl.querySelector('[data-a="close"]').addEventListener("click", closeCode);
1567
- codeEl.querySelectorAll(".cp-tabs .tab").forEach((b) => b.addEventListener("click", () => toggleCode(n, null, b.dataset.tab)));
1568
- codeEl.hidden = false;
1569
- if (reopen) {
1570
- const x = ev && ev.clientX != null ? ev.clientX : window.innerWidth / 2;
1571
- const y = ev && ev.clientY != null ? ev.clientY : window.innerHeight / 3;
1572
- const w = codeEl.offsetWidth;
1573
- const h = Math.min(codeEl.offsetHeight, window.innerHeight * 0.7);
1574
- const left = x + 14 + w > window.innerWidth - 8 ? x - w - 14 : x + 14;
1575
- const top = y + 14 + h > window.innerHeight - 8 ? y - h - 14 : y + 14;
1576
- codeEl.style.left = `${Math.max(8, left)}px`;
1577
- codeEl.style.top = `${Math.max(8, top)}px`;
1578
- }
1579
- const at = codeEl.querySelector(".cl.at");
1580
- if (at) at.scrollIntoView({ block: "center" });
1581
- if (codeAway) document.removeEventListener("mousedown", codeAway, true);
1582
- codeAway = (e) => {
1583
- if (!codeEl.contains(e.target) && !e.target.closest(".code-btn, [data-show-code], [data-show-value]")) closeCode();
1584
- };
1585
- setTimeout(() => codeAway && document.addEventListener("mousedown", codeAway, true), 0);
1586
- }
1587
- function closeCode() {
1588
- codeFor = null;
1589
- codeEl.hidden = true;
1590
- if (codeAway) document.removeEventListener("mousedown", codeAway, true);
1591
- codeAway = null;
1592
- }
1593
- let tipTimer = 0;
1594
- let hovered = null;
1595
- /** The file worth opening for `n` (see {@link userSite}), relative to the project root — no line. */
1596
- function sourceOf(n) {
1597
- const site = userSite(n);
1598
- return site ? relOf(site) : "";
1599
- }
1600
- function copySource(n) {
1601
- const src = n && sourceOf(n);
1602
- if (src) copy(src, `Copied ${src}`);
1603
- }
1604
- let lastEv = null;
1605
- function showTip(ev, html, wide, delay) {
1606
- clearTimeout(tipTimer);
1607
- lastEv = ev;
1608
- const open = () => {
1609
- tip.innerHTML = html;
1610
- tip.classList.toggle("wide", !!wide);
1611
- tip.hidden = false;
1612
- moveTip(lastEv);
1613
- };
1614
- if (delay && tip.hidden) tipTimer = setTimeout(open, delay);
1615
- else open();
1616
- }
1617
- function moveTip(ev) {
1618
- lastEv = ev;
1619
- if (tip.hidden) return;
1620
- const pad = 14;
1621
- const r = tip.getBoundingClientRect();
1622
- let x = ev.clientX + pad;
1623
- let y = ev.clientY + pad;
1624
- if (x + r.width > window.innerWidth - 8) x = ev.clientX - r.width - pad;
1625
- if (y + r.height > window.innerHeight - 8) y = ev.clientY - r.height - pad;
1626
- tip.style.left = `${x}px`;
1627
- tip.style.top = `${y}px`;
1628
- }
1629
- function hideTip() {
1630
- hovered = null;
1631
- clearTimeout(tipTimer);
1632
- tip.hidden = true;
1633
- }
1634
-
1635
- // ── render orchestration ──
1636
- function renderAll() {
1637
- measure();
1638
- if (state.view === "timeline") renderTimeline();
1639
- if (state.view === "flame") renderFlame();
1640
- if (state.view === "flow") renderFlow();
1641
- renderOverview();
1642
- }
1643
- function relayout() {
1644
- const keep = state.zoom;
1645
- measure();
1646
- state.zoom = keep;
1647
- renderAll();
1648
- }
1649
- let resizeTimer = 0;
1650
- window.addEventListener("resize", () => {
1651
- clearTimeout(resizeTimer);
1652
- resizeTimer = setTimeout(relayout, 120);
1653
- });
1654
- document.addEventListener("keydown", (e) => {
1655
- if (e.key === "Escape" && codeFor) return closeCode();
1656
- if (e.target.closest && e.target.closest("input")) return;
1657
- if (e.key === "+" || e.key === "=") zoomBy(1.6);
1658
- else if (e.key === "-") zoomBy(1 / 1.6);
1659
- else if (e.key === "0") fit();
1660
- else if ((e.key === "c" || e.key === "C") && !e.ctrlKey && !e.metaKey && !e.altKey) {
1661
- copySource(hovered || model.byId.get(state.selected));
1662
- }
1663
- else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
1664
- const rows = visible();
1665
- const i = rows.findIndex((n) => n.id === state.selected);
1666
- const next = rows[Math.max(0, Math.min(rows.length - 1, i + (e.key === "ArrowDown" ? 1 : -1)))];
1667
- if (next) {
1668
- e.preventDefault();
1669
- select(next.id);
1670
- revealRow(next);
1671
- }
1672
- }
1673
- });
1674
-
1675
- setView("timeline");
1676
- renderDetails();
1677
- renderLower();
1678
- }
1679
-
1680
- // ── shell markup ─────────────────────────────────────────
1681
- function shellHtml(model) {
1682
- const run = model.run;
1683
- const ok = run.ok;
1684
- const a = run.assertions;
1685
- const total = a.passed + a.failed;
1686
- const assertText = total ? `${a.passed} / ${total} assertions passed` : "no assertions";
1687
- const fails = run.failures.length ? `<span class="sep">•</span><span style="color:var(--fail)">${run.failures.length} failure${run.failures.length > 1 ? "s" : ""}</span>` : "";
1688
- const windows = [0, 100, 500, 1000, 5000, 10000, 30000, 60000, 300000].filter((w) => w === 0 || w < model.span);
1689
- return `
1690
- <header class="top">
1691
- <div class="titles">
1692
- <div class="title-row">
1693
- <h1 title="${esc(model.data.folder)}">Run: ${esc(model.data.name)}</h1>
1694
- <button class="info-btn" id="om-info" aria-label="About this om">${svgIcon("info")}</button>
1695
- </div>
1696
- <div class="meta"><span class="dot ${ok ? "ok" : "failed"}"></span><span>${ok ? "passed" : "failed"}</span>
1697
- <span class="sep">•</span><span>${fmtDate(run.startedAt)}</span>
1698
- <span class="sep">•</span><span title="body ${fmtDur(run.duration)} · record ${fmtDur(model.span)}">${fmtDur(run.duration)}</span>
1699
- <span class="sep">•</span><span>${model.nodes.length} action${model.nodes.length > 1 ? "s" : ""}</span>
1700
- <span class="sep">•</span><span>${assertText}</span>${fails}
1701
- </div>
1702
- </div>
1703
- <div class="btn-group">
1704
- <button class="btn active" data-view="timeline">${svgIcon("timeline")}Timeline + Graph</button>
1705
- <button class="btn" data-view="flame">${svgIcon("flame")}Flame Graph</button>
1706
- <button class="btn" data-view="flow">${svgIcon("flow")}Flow</button>
1707
- </div>
1708
- <div class="btn-group">
1709
- <button class="btn" id="zout" title="Zoom out (-)">${svgIcon("zoomOut")}</button>
1710
- <button class="btn" id="zin" title="Zoom in (+)">${svgIcon("zoomIn")}</button>
1711
- <button class="btn" id="fit" title="Fit (0)">Fit</button>
1712
- </div>
1713
- <select id="window" class="btn solo" title="Visible window">
1714
- ${windows.map((w) => `<option value="${w}">${w ? fmtTick(w) : "window"}</option>`).join("")}
1715
- </select>
1716
- <button class="btn solo" id="more" title="More">${svgIcon("more")}</button>
1717
- </header>
1718
- <div class="main">
1719
- <section class="card timeline" style="--tree-w:420px;--row-h:${ROW_H}px;position:relative">
1720
- <div class="tl-head">
1721
- <div class="cols"><div>Action / Event</div><div>Status</div><div>Duration</div></div>
1722
- <svg></svg>
1723
- </div>
1724
- <div class="tl-body"><div class="tree"></div><div class="chart"></div></div>
1725
- <div class="flame-view" hidden style="top:34px"></div>
1726
- <div class="flow-view" hidden></div>
1727
- </section>
1728
- <aside class="card details"></aside>
1729
- </div>
1730
- <div class="bottom">
1731
- <section class="card lower"></section>
1732
- <section class="card legend">
1733
- <h3>Legend</h3>
1734
- <div class="grid2">
1735
- ${legendItem("--c1", "Action run", "bar")}
1736
- ${legendItem("--c2", "Snapshot", "dot")}
1737
- ${legendItem("--c3", "Process event", "dot")}
1738
- ${legendItem("--c6", "Artifact", "dot")}
1739
- ${legendItem("--ok", "Assert passed", "dot")}
1740
- ${legendItem("--fail", "Failure / error", "dot")}
1741
- ${legendItem("--cancel", "Cancelled", "dash")}
1742
- ${legendItem("--accent", "Pinned time", "line")}
1743
- </div>
1744
- <div class="muted" style="margin-top:12px;font-size:11px">Ctrl + wheel to zoom · drag to pan · double-click a bar to focus · ↑/↓ to step</div>
1745
- </section>
1746
- <section class="card overview"><h3>Overview</h3><div class="range"></div><svg></svg></section>
1747
- </div>`;
1748
- }
1749
- function legendItem(color, label, shape) {
1750
- const c = `var(${color})`;
1751
- const sw =
1752
- shape === "bar"
1753
- ? `<span style="width:16px;height:9px;border-radius:3px;background:${c}"></span>`
1754
- : shape === "dash"
1755
- ? `<span style="width:16px;height:0;border-top:2px dashed ${c}"></span>`
1756
- : shape === "line"
1757
- ? `<span style="width:2px;height:14px;background:${c};margin:0 7px"></span>`
1758
- : `<span class="dot" style="background:${c};width:11px;height:11px"></span>`;
1759
- return `<div class="li">${sw}${esc(label)}</div>`;
1760
- }
1761
-
1762
- // ── helpers ──────────────────────────────────────────────
1763
- function colorOf(n) {
1764
- return n.branch < 0 ? css("--c1") : css(PALETTE[n.branch]);
1765
- }
1766
- function statusVar(s) {
1767
- return s === "ok" ? "--ok" : s === "failed" ? "--fail" : s === "cancelled" ? "--cancel" : "--running";
1768
- }
1769
- function alpha(color, a) {
1770
- const c = d3.color(color);
1771
- if (!c) return color;
1772
- c.opacity = a;
1773
- return c.formatRgb();
1774
- }
1775
- const KEYWORDS = new Set(
1776
- "abstract as async await break case catch class const continue default delete do else enum export extends false finally for from function get if implements import in instanceof interface keyof let new null of private protected public readonly return satisfies set static super switch this throw true try type typeof undefined var void while yield".split(" ")
1777
- );
1778
- const TOKEN =
1779
- /(\/\/[^\n]*|\/\*[\s\S]*?\*\/)|(`(?:\\[\s\S]|[^`\\])*`|"(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*')|(\b\d[\d_]*(?:\.\d+)?(?:[eE][+-]?\d+)?n?\b)|([A-Za-z_$][\w$]*)/g;
1780
- /** Highlighted HTML per source line. Tokens that span lines are closed and reopened at each break. */
1781
- function highlight(text) {
1782
- const parts = [];
1783
- let last = 0;
1784
- let m;
1785
- TOKEN.lastIndex = 0;
1786
- while ((m = TOKEN.exec(text))) {
1787
- if (m.index > last) parts.push([null, text.slice(last, m.index)]);
1788
- let cls = null;
1789
- if (m[1]) cls = "tk-c";
1790
- else if (m[2]) cls = /^\s*:/.test(text.slice(TOKEN.lastIndex, TOKEN.lastIndex + 8)) ? "tk-p" : "tk-s";
1791
- else if (m[3]) cls = "tk-n";
1792
- else if (m[4]) {
1793
- if (KEYWORDS.has(m[4])) cls = "tk-k";
1794
- else if (/^\s*\(/.test(text.slice(TOKEN.lastIndex, TOKEN.lastIndex + 40))) cls = "tk-f";
1795
- else if (/^[A-Z]/.test(m[4])) cls = "tk-t";
1796
- }
1797
- parts.push([cls, m[0]]);
1798
- last = TOKEN.lastIndex;
1799
- }
1800
- if (last < text.length) parts.push([null, text.slice(last)]);
1801
- const lines = [""];
1802
- for (const [cls, chunk] of parts) {
1803
- chunk.split("\n").forEach((piece, i) => {
1804
- if (i > 0) lines.push("");
1805
- if (piece) lines[lines.length - 1] += cls ? `<span class="${cls}">${esc(piece)}</span>` : esc(piece);
1806
- });
1807
- }
1808
- return lines;
1809
- }
1810
- function oneLine(v) {
1811
- try {
1812
- return typeof v === "string" ? v : JSON.stringify(v);
1813
- } catch {
1814
- return String(v);
1815
- }
1816
- }
1817
- /** Top-level fields of a JSON Schema object: name, type, required, default, description. */
1818
- function schemaFields(schema) {
1819
- if (!schema || typeof schema !== "object" || !schema.properties) return [];
1820
- const req = new Set(schema.required || []);
1821
- return Object.entries(schema.properties).map(([name, p]) => ({
1822
- name,
1823
- type: typeOf(p),
1824
- required: req.has(name) && !(p && "default" in p),
1825
- def: p && "default" in p ? oneLine(p.default) : undefined,
1826
- desc: p && p.description,
1827
- }));
1828
- }
1829
- function typeOf(p) {
1830
- if (!p || typeof p !== "object") return "any";
1831
- if (p.enum) return p.enum.map((e) => JSON.stringify(e)).join(" | ");
1832
- if (p.const !== undefined) return JSON.stringify(p.const);
1833
- if (Array.isArray(p.type)) return p.type.join(" | ");
1834
- if (p.type === "array") return `${typeOf(p.items)}[]`;
1835
- if (p.type) return p.type;
1836
- if (p.anyOf || p.oneOf) return (p.anyOf || p.oneOf).map(typeOf).join(" | ");
1837
- return "any";
1838
- }
1839
- function paramHtml(f) {
1840
- return `<div class="param"><span class="pn">${esc(f.name)}${f.required ? "" : "?"}</span><span class="pt">${esc(truncate(f.type, 40))}</span>${
1841
- f.def !== undefined ? `<span class="pd">= ${esc(truncate(f.def, 24))}</span>` : ""
1842
- }${f.desc ? `<div class="pdesc">${esc(f.desc)}</div>` : ""}</div>`;
1843
- }
1844
- /**
1845
- * What a `prompt` battery node asked and got back: its options (the node's one argument) plus
1846
- * the `prompt` / `answer` events it emitted. `null` for anything that is not a prompt.
1847
- */
1848
- function promptOf(n) {
1849
- if (n._prompt !== undefined) return n._prompt;
1850
- n._prompt = null;
1851
- if (n.name !== "prompt" || n.depth === 0) return null;
1852
- const opts = (n.v.args && n.v.args[0] && typeof n.v.args[0] === "object" ? n.v.args[0] : {}) || {};
1853
- let message = opts.message;
1854
- let answer = null;
1855
- for (const it of n.entries) {
1856
- if (it.e.level !== "event") continue;
1857
- if (it.e.message.startsWith("prompt · ")) message = it.e.message.slice("prompt · ".length);
1858
- else if (it.e.message.startsWith("answer · ")) {
1859
- try {
1860
- answer = JSON.parse(it.e.message.slice("answer · ".length));
1861
- } catch {
1862
- answer = { value: it.e.message.slice("answer · ".length) };
1863
- }
1864
- }
1865
- }
1866
- if (message === undefined && !answer) return null;
1867
- n._prompt = {
1868
- message,
1869
- kind: opts.kind || "input",
1870
- hint: opts.hint,
1871
- choices: opts.choices,
1872
- def: opts.default,
1873
- timeoutMs: opts.timeoutMs,
1874
- answer,
1875
- };
1876
- return n._prompt;
1877
- }
1878
- function truncate(s, n) {
1879
- n = Math.max(1, Math.floor(n));
1880
- return s.length > n ? `${s.slice(0, Math.max(1, n - 1))}…` : s;
1881
- }
1882
- function displayName(n) {
1883
- if (n.depth === 0) return "main";
1884
- const p = promptOf(n);
1885
- if (p && p.message) return `prompt: ${truncate(p.message, 48)}`;
1886
- const tag = n.v.tags && n.v.tags[0];
1887
- // Battery steps are named after their kind (`command`, `chromePage`); their first tag
1888
- // is what the om called them, which is the more useful label.
1889
- return tag && tag !== n.name && n.name.length > 28 ? tag : n.name;
1890
- }
1891
- function iconOf(n) {
1892
- if (n.depth === 0) return "root";
1893
- const s = `${n.name} ${(n.v.tags || []).join(" ")}`.toLowerCase();
1894
- if (/^(npm|npx|node|pnpm|yarn|tsc|git|bun)\b/.test(n.name) || /command|shell|exec/.test(s)) return "terminal";
1895
- if (/chromepage|page:|tab/.test(s)) return "window";
1896
- if (/browser|chrome/.test(s)) return "globe";
1897
- if (/health|ready|gate|wait|port/.test(s)) return "pulse";
1898
- if (/drive|inspect|click/.test(s)) return "cursor";
1899
- if (/test|assert|validate/.test(s)) return "flask";
1900
- if (/snapshot|screenshot/.test(s)) return "camera";
1901
- if (/prompt|ask/.test(s)) return "chat";
1902
- if (/build|compile|bundle/.test(s)) return "box";
1903
- return "play";
1904
- }
1905
- function copy(text, message) {
1906
- const done = () => toast(message || "Copied");
1907
- if (navigator.clipboard && window.isSecureContext) navigator.clipboard.writeText(text).then(done, () => fallbackCopy(text, message));
1908
- else fallbackCopy(text, message);
1909
- }
1910
- function fallbackCopy(text, message) {
1911
- const t = document.createElement("textarea");
1912
- t.value = text;
1913
- document.body.appendChild(t);
1914
- t.select();
1915
- try {
1916
- document.execCommand("copy");
1917
- toast(message || "Copied");
1918
- } catch {
1919
- toast(text);
1920
- }
1921
- t.remove();
1922
- }
1923
- function toast(text) {
1924
- const t = document.createElement("div");
1925
- t.className = "toast";
1926
- t.textContent = text;
1927
- document.body.appendChild(t);
1928
- setTimeout(() => t.remove(), 1400);
1929
- }
1930
- function download(name, text, mime) {
1931
- const a = document.createElement("a");
1932
- a.href = URL.createObjectURL(new Blob([text], { type: mime }));
1933
- a.download = name;
1934
- a.click();
1935
- setTimeout(() => URL.revokeObjectURL(a.href), 1000);
1936
- }
1937
- function toggleTheme() {
1938
- const root = document.documentElement;
1939
- const dark = root.dataset.theme ? root.dataset.theme === "dark" : !matchMedia("(prefers-color-scheme: light)").matches;
1940
- root.dataset.theme = dark ? "light" : "dark";
1941
- try {
1942
- localStorage.setItem("omkit-report-theme", root.dataset.theme);
1943
- } catch {
1944
- /* storage may be unavailable on file:// */
1945
- }
1946
- }
1947
-
1948
- // ── boot ─────────────────────────────────────────────────
1949
- const frame = () => new Promise((r) => requestAnimationFrame(() => setTimeout(r, 0)));
1950
- const step = (t) => {
1951
- const s = document.getElementById("loader-step");
1952
- if (s) s.textContent = t;
1953
- };
1954
- async function boot() {
1955
- try {
1956
- const saved = localStorage.getItem("omkit-report-theme");
1957
- if (saved) document.documentElement.dataset.theme = saved;
1958
- } catch {
1959
- /* ignore */
1960
- }
1961
- await frame();
1962
- step("Parsing the run record…");
1963
- await frame();
1964
- const data = JSON.parse(document.getElementById("omkit-data").textContent);
1965
- step(`Building ${data.run ? "the timeline" : "the view"}…`);
1966
- await frame();
1967
- const model = buildModel(data);
1968
- const app = document.getElementById("app");
1969
- app.hidden = false;
1970
- App(model);
1971
- await frame();
1972
- const loader = document.getElementById("loader");
1973
- loader.style.transition = "opacity 0.2s";
1974
- loader.style.opacity = "0";
1975
- setTimeout(() => loader.remove(), 220);
1976
- }
1977
- boot().catch((err) => {
1978
- step(`Could not render this run: ${err && err.message ? err.message : err}`);
1979
- console.error(err);
1980
- });
1981
- })();
1
+ /* omkit run report — the viewer. Runs in the browser against the JSON block the writer
2
+ * inlined (`#omkit-data`) and the global `d3`. Plain script, no build step: the writer
3
+ * reads this file and pastes it into report.html as-is. */
4
+ (function () {
5
+ "use strict";
6
+
7
+ const TIP_TAGS = 6; // tag chips a hover tooltip shows before "+N"
8
+ const TREE_TAGS = 4; // …and a timeline tree row
9
+ const ROW_H = 42;
10
+ const BAR_Y = 6;
11
+ const BAR_H = 20;
12
+
13
+ // ── icons (24×24, stroked) ───────────────────────────────
14
+ const ICON = {
15
+ timeline: "M3 17l5-5 4 4 8-8M3 21h18",
16
+ flow: "M4 4h7v6H4zM13 14h7v6h-7zM7.5 10v4h9v0M16.5 14v-1",
17
+ zoomIn: "M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM20 20l-4-4M11 8v6M8 11h6",
18
+ zoomOut: "M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM20 20l-4-4M8 11h6",
19
+ more: "M5 12h.01M12 12h.01M19 12h.01",
20
+ close: "M6 6l12 12M18 6L6 18",
21
+ chevron: "M9 6l6 6-6 6",
22
+ play: "M8 5v14l11-7z",
23
+ terminal: "M4 5h16v14H4zM7 9l3 3-3 3M12 15h5",
24
+ globe: "M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18zM3 12h18M12 3c3 3 3 15 0 18M12 3c-3 3-3 15 0 18",
25
+ window: "M3 5h18v14H3zM3 9h18M6 7h.01M9 7h.01",
26
+ pulse: "M3 12h4l2-5 4 10 2-5h6",
27
+ cursor: "M5 3l14 7-6 2-2 6z",
28
+ box: "M12 3l8 4.5v9L12 21l-8-4.5v-9zM4 7.5l8 4.5 8-4.5M12 12v9",
29
+ camera: "M4 8h3l2-3h6l2 3h3v11H4zM12 10a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7z",
30
+ file: "M6 3h8l4 4v14H6zM14 3v4h4",
31
+ check: "M5 12l4 4 10-10",
32
+ x: "M6 6l12 12M18 6L6 18",
33
+ alert: "M12 4l9 16H3zM12 10v4M12 17h.01",
34
+ root: "M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8zM12 2v3M12 19v3M2 12h3M19 12h3",
35
+ chat: "M4 5h16v11H9l-5 4z",
36
+ flask: "M9 3h6M10 3v6l-5 9a2 2 0 0 0 2 3h10a2 2 0 0 0 2-3l-5-9V3",
37
+ braces: "M8 4c-2 0-3 1-3 3v2c0 1-1 2-2 3 1 1 2 2 2 3v2c0 2 1 3 3 3M16 4c2 0 3 1 3 3v2c0 1 1 2 2 3-1 1-2 2-2 3v2c0 2-1 3-3 3",
38
+ image: "M4 5h16v14H4zM4 16l5-5 4 4 2-2 5 5M15 9h.01",
39
+ sun: "M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8zM12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4",
40
+ download: "M12 4v11M7 10l5 5 5-5M5 20h14",
41
+ copy: "M9 9h11v11H9zM5 15V4h11",
42
+ stop: "M6 6h12v12H6z",
43
+ info: "M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18zM12 11v5M12 8h.01",
44
+ locate: "M12 5a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM12 2v3M12 19v3M2 12h3M19 12h3M12 10.5a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3z",
45
+ code: "M8 7l-5 5 5 5M16 7l5 5-5 5",
46
+ external: "M14 4h6v6M20 4l-9 9M18 14v6H4V6h6",
47
+ };
48
+ const svgIcon = (name, extra) =>
49
+ `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"${
50
+ extra || ""
51
+ }><path d="${ICON[name]}"/></svg>`;
52
+
53
+ const PALETTE = ["--c1", "--c2", "--c3", "--c4", "--c5", "--c6", "--c7", "--c8"];
54
+ const css = (v) => getComputedStyle(document.documentElement).getPropertyValue(v).trim();
55
+
56
+ const MARK = {
57
+ event: { color: "--c3", icon: "check", label: "Process event" },
58
+ snapshot: { color: "--c2", icon: "camera", label: "Snapshot" },
59
+ artifact: { color: "--c6", icon: "file", label: "Artifact" },
60
+ pass: { color: "--ok", icon: "check", label: "Assertion passed" },
61
+ fail: { color: "--fail", icon: "alert", label: "Assertion failed" },
62
+ error: { color: "--fail", icon: "x", label: "Error" },
63
+ cancel: { color: "--cancel", icon: "stop", label: "Cancelled" },
64
+ lifecycle: { color: "--c1", icon: "play", label: "Lifecycle" },
65
+ run: { color: "--text-3", icon: "root", label: "Run" },
66
+ };
67
+
68
+ // ── formatting ───────────────────────────────────────────
69
+ const fmtDur = (ms) => {
70
+ if (!isFinite(ms)) return "–";
71
+ if (ms < 1000) return `${Math.round(ms)}ms`;
72
+ if (ms < 10000) return `${(ms / 1000).toFixed(1)}s`;
73
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
74
+ const m = Math.floor(ms / 60000);
75
+ const s = Math.round((ms % 60000) / 1000);
76
+ return `${m}m ${String(s).padStart(2, "0")}s`;
77
+ };
78
+ const fmtRel = (ms) => `+${fmtDur(Math.max(0, ms)).replace(/^0ms$/, "0ms")}`;
79
+ const pad2 = (n) => String(n).padStart(2, "0");
80
+ const fmtDate = (ts) => {
81
+ const d = new Date(ts);
82
+ return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`;
83
+ };
84
+ const fmtSize = (b) =>
85
+ b < 1024 ? `${b} B` : b < 1048576 ? `${Math.round(b / 1024)} KB` : `${(b / 1048576).toFixed(1)} MB`;
86
+ const fmtTick = (ms) => {
87
+ if (ms === 0) return "0s";
88
+ const a = Math.abs(ms);
89
+ if (a < 1000) return `${Math.round(ms)}ms`;
90
+ if (a < 60000) return `${+(ms / 1000).toFixed(a < 10000 ? 2 : 1)}s`;
91
+ return `${Math.floor(ms / 60000)}m${pad2(Math.round((ms % 60000) / 1000))}s`;
92
+ };
93
+ const esc = (s) =>
94
+ String(s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
95
+ const basename = (p) => String(p).split(/[\\/]/).pop();
96
+ const fileUrl = (p) => "file:///" + String(p).replace(/\\/g, "/").replace(/^\/+/, "").split("/").map(encodeURIComponent).join("/").replace(/^([A-Za-z])%3A/, "$1:");
97
+ const editorUrl = (site) => {
98
+ const m = /^(.*):(\d+)$/.exec(site);
99
+ const file = (m ? m[1] : site).replace(/\\/g, "/");
100
+ return `vscode://file/${file.replace(/^\/+/, "")}${m ? ":" + m[2] : ""}`;
101
+ };
102
+ const json = (v) => {
103
+ try {
104
+ return JSON.stringify(v, null, 2);
105
+ } catch {
106
+ return String(v);
107
+ }
108
+ };
109
+
110
+ // ── model ────────────────────────────────────────────────
111
+ function buildModel(data) {
112
+ const run = data.run;
113
+ const t0 = run.startedAt;
114
+ const nodes = [];
115
+ const byId = new Map();
116
+ let tEnd = run.endedAt || t0;
117
+ let topIndex = 0;
118
+ (function walk(v, parent, depth, branch) {
119
+ const isTop = depth === 1;
120
+ const b = depth === 0 ? 0 : isTop ? topIndex++ % PALETTE.length : branch;
121
+ const n = {
122
+ v,
123
+ id: v.id,
124
+ name: v.name,
125
+ parent,
126
+ depth,
127
+ branch: depth === 0 ? -1 : b,
128
+ children: [],
129
+ status: ["ok", "failed", "cancelled"].includes(v.status) ? v.status : v.endedAt ? v.status : "running",
130
+ startAbs: v.startedAt || t0,
131
+ endAbs: v.endedAt || 0,
132
+ entries: [],
133
+ marks: [],
134
+ };
135
+ nodes.push(n);
136
+ byId.set(n.id, n);
137
+ if (parent) parent.children.push(n);
138
+ if (n.endAbs > tEnd) tEnd = n.endAbs;
139
+ for (const c of v.children || []) walk(c, n, depth + 1, b);
140
+ })(run.root, null, 0, 0);
141
+
142
+ for (const e of data.entries) if (e.ts > tEnd) tEnd = e.ts;
143
+ for (const n of nodes) {
144
+ if (!n.endAbs) n.endAbs = tEnd;
145
+ n.start = n.startAbs - t0;
146
+ n.end = Math.max(n.start, n.endAbs - t0);
147
+ n.duration = n.v.duration || n.end - n.start;
148
+ }
149
+
150
+ const items = [];
151
+ const runEntries = [];
152
+ for (const e of data.entries) {
153
+ if (e.level === "child" || e.level === "tag") continue;
154
+ const kind = kindOf(e);
155
+ const item = { idx: items.length, e, kind, t: e.ts - t0, label: labelOf(e, kind), node: byId.get(e.nodeId) || null };
156
+ items.push(item);
157
+ if (item.node) {
158
+ item.node.entries.push(item);
159
+ if (kind !== "info" && kind !== "lifecycle") item.node.marks.push(item);
160
+ } else runEntries.push(item);
161
+ }
162
+
163
+ const assets = new Map();
164
+ const gallery = [];
165
+ for (const f of data.files) {
166
+ gallery.push({
167
+ type: "file",
168
+ f,
169
+ name: f.name,
170
+ t: f.ts ? f.ts - t0 : null,
171
+ node: f.nodeId ? byId.get(f.nodeId) || null : null,
172
+ image: f.mime.startsWith("image/") && !!f.asset,
173
+ });
174
+ }
175
+ for (const s of data.snapshots) {
176
+ gallery.push({ type: "snapshot", s, name: s.name, t: s.ts - t0, node: byId.get(s.nodeId) || null, image: false });
177
+ }
178
+ gallery.sort((a, b) => (a.t ?? Infinity) - (b.t ?? Infinity));
179
+ // Let a mark find its gallery item (snapshot by file, artifact by file).
180
+ const galleryByFile = new Map();
181
+ for (const g of gallery) galleryByFile.set(String(g.type === "file" ? g.f.file : g.s.file).toLowerCase(), g);
182
+ for (const it of items) {
183
+ if (it.kind === "snapshot" || it.kind === "artifact") {
184
+ const i = it.e.message.lastIndexOf(" → ");
185
+ if (i >= 0) it.gallery = galleryByFile.get(it.e.message.slice(i + 3).toLowerCase()) || null;
186
+ }
187
+ }
188
+
189
+ const asserts = items.filter((i) => i.kind === "pass" || i.kind === "fail");
190
+ const errors = collectErrors(items, nodes, run, tEnd - t0);
191
+ const errorsBy = new Map();
192
+ const errPath = new Set();
193
+ for (const e of errors) {
194
+ if (!e.node) continue;
195
+ errorsBy.set(e.node, (errorsBy.get(e.node) || 0) + 1);
196
+ for (let p = e.node.parent; p; p = p.parent) errPath.add(p.id);
197
+ }
198
+ const errNodes = new Set([...errorsBy.keys()].map((n) => n.id));
199
+ return {
200
+ errors,
201
+ errorsBy,
202
+ errNodes,
203
+ errPath,
204
+ data,
205
+ run,
206
+ t0,
207
+ span: Math.max(1, tEnd - t0),
208
+ nodes,
209
+ byId,
210
+ root: nodes[0],
211
+ items,
212
+ runEntries,
213
+ gallery,
214
+ asserts,
215
+ assets,
216
+ asset(id) {
217
+ if (!assets.has(id)) {
218
+ const el = document.getElementById(id);
219
+ assets.set(id, el ? `data:${el.dataset.mime};base64,${el.textContent.trim()}` : "");
220
+ }
221
+ return assets.get(id);
222
+ },
223
+ };
224
+ }
225
+
226
+ /**
227
+ * Everything that went wrong, in time order, as three kinds:
228
+ * - `failure` — an action threw: its error (with stack), one per failed node;
229
+ * - `assert` — a failed `assert(…)`;
230
+ * - `output` — error output (stderr, console.error). A process's consecutive lines arrive as
231
+ * separate entries; lines from one source within a short gap are one block, so a stack
232
+ * trace reads as one error rather than thirty.
233
+ */
234
+ function collectErrors(items, nodes, run, span) {
235
+ const out = [];
236
+ const open = new Map(); // `${nodeId}\0${source}` → the output block still accepting lines
237
+ for (const it of items) {
238
+ if (it.kind === "fail") {
239
+ out.push({ type: "assert", node: it.node, t: it.t, title: it.label, text: it.e.message, items: [it] });
240
+ } else if (it.kind === "error" && it.e.source === "error") {
241
+ out.push({ type: "failure", node: it.node, t: it.t, title: firstLine(it.e.message), text: it.e.message, items: [it] });
242
+ } else if (it.kind === "error") {
243
+ const key = `${it.e.nodeId}\0${it.e.source}`;
244
+ const block = open.get(key);
245
+ if (block && it.t - block.tEnd <= 300) {
246
+ block.lines.push(it.e.message);
247
+ block.items.push(it);
248
+ block.tEnd = it.t;
249
+ block.text = block.lines.join("\n");
250
+ continue;
251
+ }
252
+ const fresh = { type: "output", node: it.node, t: it.t, tEnd: it.t, source: it.e.source, title: firstLine(it.e.message), lines: [it.e.message], text: it.e.message, items: [it] };
253
+ open.set(key, fresh);
254
+ out.push(fresh);
255
+ }
256
+ }
257
+ // a failure the verdict holds but no node logged (e.g. the body threw outside any action)
258
+ for (const f of run.failures || []) {
259
+ const n = nodes.find((x) => x.v.path === f.action || x.id === f.action || x.name === f.action) || null;
260
+ if (out.some((e) => e.type === "failure" && e.node === n)) continue;
261
+ // a failed assert is already its own entry; the verdict just lists it again as a fault
262
+ if (out.some((e) => e.type === "assert" && e.node === n && String(f.error).includes(e.title))) continue;
263
+ out.push({ type: "failure", node: n, t: n ? n.end : span, title: firstLine(f.error), text: f.error, items: [] });
264
+ }
265
+ out.sort((a, b) => a.t - b.t);
266
+ out.forEach((e, i) => (e.idx = i));
267
+ return out;
268
+ }
269
+ function firstLine(s) {
270
+ return String(s).split("\n")[0];
271
+ }
272
+ function kindOf(e) {
273
+ if (e.level === "assert") return e.message.startsWith("⊭") ? "fail" : "pass";
274
+ if (e.level === "snapshot") return "snapshot";
275
+ if (e.level === "artifact") return "artifact";
276
+ if (e.level === "error") return "error";
277
+ if (e.level === "run") return "run";
278
+ if (e.level === "event" && e.source === "cancel") return "cancel";
279
+ if (e.level === "event" && e.source === "lifecycle") return "lifecycle";
280
+ if (e.level === "event") return "event";
281
+ return "info";
282
+ }
283
+
284
+ function labelOf(e, kind) {
285
+ const m = e.message;
286
+ if (kind === "snapshot" || kind === "artifact") {
287
+ const i = m.lastIndexOf(" → ");
288
+ return i >= 0 ? m.slice(0, i) : m;
289
+ }
290
+ if (kind === "pass" || kind === "fail") return m.replace(/^[⊨⊭]\s*(pass|FAIL)\s*·\s*/, "");
291
+ if (kind === "error") return m.split("\n")[0];
292
+ if (kind === "event") {
293
+ // Command events repeat their source as a prefix — drop it.
294
+ let s = m.startsWith(e.source + " · ") ? m.slice(e.source.length + 3) : m;
295
+ s = s.split(" · ")[0];
296
+ return s;
297
+ }
298
+ return m;
299
+ }
300
+
301
+ // ── app ──────────────────────────────────────────────────
302
+ function App(model) {
303
+ const app = document.getElementById("app");
304
+ const state = {
305
+ view: "timeline",
306
+ selected: model.root.id,
307
+ collapsed: new Set(),
308
+ zoom: d3.zoomIdentity,
309
+ detailOpen: new Set(["errors"]), // details-panel sections left open
310
+ errFilter: "all",
311
+ errHl: false,
312
+ errOpen: new Set(),
313
+ lowerTab: "snapshots",
314
+ filter: "",
315
+ pin: null,
316
+ details: true,
317
+ };
318
+ // Deep, wide runs start collapsed below the second level so the first screen stays readable.
319
+ if (model.nodes.length > 60) for (const n of model.nodes) if (n.depth >= 2 && n.children.length) state.collapsed.add(n.id);
320
+
321
+ app.innerHTML = shellHtml(model);
322
+ const el = {
323
+ main: app.querySelector(".main"),
324
+ timeline: app.querySelector(".timeline"),
325
+ head: app.querySelector(".tl-head"),
326
+ axis: app.querySelector(".tl-head svg"),
327
+ body: app.querySelector(".tl-body"),
328
+ tree: app.querySelector(".tree"),
329
+ chart: app.querySelector(".chart"),
330
+ flow: app.querySelector(".flow-view"),
331
+ details: app.querySelector(".details"),
332
+ lower: app.querySelector(".lower"),
333
+ overview: app.querySelector(".overview svg"),
334
+ range: app.querySelector(".overview .range"),
335
+ windowSel: app.querySelector("#window"),
336
+ };
337
+ const tip = document.createElement("div");
338
+ tip.className = "tip";
339
+ tip.hidden = true;
340
+ document.body.appendChild(tip);
341
+
342
+ let W = 600; // chart pixel width
343
+ let baseX = d3.scaleLinear();
344
+ const xz = () => state.zoom.rescaleX(baseX);
345
+
346
+ // ── header controls ──
347
+ app.querySelectorAll("[data-view]").forEach((b) =>
348
+ b.addEventListener("click", () => setView(b.dataset.view))
349
+ );
350
+ app.querySelector("#zin").addEventListener("click", () => zoomBy(1.6));
351
+ app.querySelector("#zout").addEventListener("click", () => zoomBy(1 / 1.6));
352
+ app.querySelector("#fit").addEventListener("click", fit);
353
+ el.windowSel.addEventListener("change", () => {
354
+ const ms = +el.windowSel.value;
355
+ if (!ms) return fit();
356
+ const [d0] = xz().domain();
357
+ zoomToDomain(Math.max(0, d0), Math.max(0, d0) + ms);
358
+ });
359
+ app.querySelector("#more").addEventListener("click", (ev) => openMenu(ev.currentTarget));
360
+ const goErrors = app.querySelector("#go-errors");
361
+ if (goErrors) goErrors.addEventListener("click", () => openErrors(true));
362
+ const info = app.querySelector("#om-info");
363
+ info.addEventListener("mouseenter", (ev) => showTip(ev, omTip(), true));
364
+ info.addEventListener("mousemove", (ev) => moveTip(ev));
365
+ info.addEventListener("mouseleave", hideTip);
366
+ info.addEventListener("click", () => select(model.root.id));
367
+ const legendBtn = app.querySelector("#legend-btn");
368
+ const legend = app.querySelector("#legend");
369
+ const toggleLegend = (open) => {
370
+ legend.hidden = !open;
371
+ legendBtn.setAttribute("aria-expanded", String(open));
372
+ legendBtn.classList.toggle("active", open);
373
+ };
374
+ legendBtn.addEventListener("click", () => toggleLegend(legend.hidden));
375
+ document.addEventListener("mousedown", (e) => {
376
+ if (!legend.hidden && !legend.contains(e.target) && !legendBtn.contains(e.target)) toggleLegend(false);
377
+ });
378
+ document.addEventListener("keydown", (e) => e.key === "Escape" && !legend.hidden && toggleLegend(false));
379
+
380
+ // ── zoom behaviours ──
381
+ const chartZoom = d3
382
+ .zoom()
383
+ .scaleExtent([1, 5000])
384
+ .filter((e) => (e.type === "wheel" ? e.ctrlKey || e.metaKey : !e.button && e.type !== "dblclick"))
385
+ .on("zoom", (e) => onZoom(e.transform));
386
+ const flowZoom = d3.zoom().scaleExtent([0.1, 4]).on("zoom", (e) => flowG && flowG.attr("transform", e.transform));
387
+
388
+ function activeZoom() {
389
+ return { z: chartZoom, sel: d3.select(el.chart).select("svg") };
390
+ }
391
+ function onZoom(t) {
392
+ state.zoom = t;
393
+ redrawX();
394
+ }
395
+ function zoomBy(k) {
396
+ if (state.view === "flow") return d3.select(el.flow).select("svg").transition().duration(200).call(flowZoom.scaleBy, k);
397
+ const { z, sel } = activeZoom();
398
+ const [cx] = state.pin != null ? [xz()(state.pin)] : [W / 2];
399
+ sel.transition().duration(200).call(z.scaleBy, k, [cx, 0]);
400
+ }
401
+ function fit() {
402
+ if (state.view === "flow") return fitFlow();
403
+ const { z, sel } = activeZoom();
404
+ sel.transition().duration(250).call(z.transform, d3.zoomIdentity);
405
+ }
406
+ function zoomToDomain(a, b) {
407
+ const x0 = baseX(a);
408
+ const x1 = baseX(b);
409
+ if (!(x1 > x0)) return;
410
+ const k = Math.max(1, W / (x1 - x0));
411
+ const t = d3.zoomIdentity.scale(k).translate(-x0, 0);
412
+ const { z, sel } = activeZoom();
413
+ sel.call(z.transform, t);
414
+ }
415
+
416
+ // ── layout ──
417
+ function measure() {
418
+ const sb = el.body.offsetWidth - el.body.clientWidth;
419
+ el.head.style.paddingRight = `${sb}px`;
420
+ W = Math.max(200, el.chart.clientWidth);
421
+ const span = model.span;
422
+ baseX = d3.scaleLinear().domain([-span * 0.01, span * 1.015]).range([0, W]);
423
+ chartZoom.translateExtent([[0, -1e6], [W, 1e6]]).extent([[0, 0], [W, 1]]);
424
+ }
425
+
426
+ function setView(v) {
427
+ state.view = v;
428
+ app.querySelectorAll("[data-view]").forEach((b) => b.classList.toggle("active", b.dataset.view === v));
429
+ el.body.hidden = v !== "timeline";
430
+ el.head.hidden = v === "flow";
431
+ el.timeline.classList.toggle("is-flow", v === "flow");
432
+ el.flow.hidden = v !== "flow";
433
+ el.windowSel.disabled = v === "flow";
434
+ renderAll();
435
+ }
436
+
437
+ // ── tree + timeline ──
438
+ function visible() {
439
+ const out = [];
440
+ (function walk(n) {
441
+ out.push(n);
442
+ if (!state.collapsed.has(n.id)) n.children.forEach(walk);
443
+ })(model.root);
444
+ return out;
445
+ }
446
+
447
+ function renderTree(rows) {
448
+ el.tree.innerHTML = rows.map((n) => treeRowHtml(n)).join("");
449
+ el.tree.querySelectorAll(".row").forEach((r) => {
450
+ const n = model.byId.get(r.dataset.id);
451
+ r.addEventListener("click", (ev) => {
452
+ if (ev.target.closest(".twist")) {
453
+ toggle(n);
454
+ return;
455
+ }
456
+ select(n.id);
457
+ });
458
+ r.addEventListener("dblclick", () => focusNode(n));
459
+ r.addEventListener("mouseenter", (ev) => showTip(ev, nodeTip(n), true, 280));
460
+ r.addEventListener("mousemove", (ev) => moveTip(ev));
461
+ r.addEventListener("mouseleave", hideTip);
462
+ });
463
+ }
464
+ function treeRowHtml(n) {
465
+ const guides = Array.from({ length: n.depth }, () => '<span class="guide"></span>').join("");
466
+ const open = !state.collapsed.has(n.id);
467
+ const twist = n.children.length
468
+ ? `<button class="twist ${open ? "open" : ""}" title="${open ? "Collapse" : "Expand"}">${svgIcon("chevron")}</button>`
469
+ : '<span class="twist"></span>';
470
+ const color = colorOf(n);
471
+ const tags = chipsHtml(n.v.tags, TREE_TAGS, 18);
472
+ return `<div class="row ${state.selected === n.id ? "selected" : ""}" data-id="${esc(n.id)}" title="${esc(n.v.path)}">
473
+ <div class="name" style="padding-left:${n.depth ? 4 : 10}px">${guides}${twist}
474
+ <span class="icon" style="color:${color};background:${alpha(color, 0.16)}">${svgIcon(iconOf(n))}</span>
475
+ <span class="lines">
476
+ <span class="l1"><span class="label">${labelHtml(n)}</span>${
477
+ model.errorsBy.get(n) ? `<span class="errbadge" title="${model.errorsBy.get(n)} error${model.errorsBy.get(n) > 1 ? "s" : ""}">${model.errorsBy.get(n)}</span>` : ""
478
+ }</span>
479
+ <span class="l2"><span class="st"><span class="dot ${n.status}"></span>${esc(n.status)}</span><span class="sep">·</span><span class="du">${fmtDur(n.duration)}</span>${tags}</span>
480
+ </span>
481
+ </div>
482
+ </div>`;
483
+ }
484
+ function toggle(n) {
485
+ if (state.collapsed.has(n.id)) state.collapsed.delete(n.id);
486
+ else state.collapsed.add(n.id);
487
+ renderTimeline();
488
+ }
489
+
490
+ let chartSvg = null;
491
+ let rowsNow = [];
492
+ function renderTimeline() {
493
+ rowsNow = visible();
494
+ renderTree(rowsNow);
495
+ const H = rowsNow.length * ROW_H + 8;
496
+ d3.select(el.chart).selectAll("svg").remove();
497
+ chartSvg = d3
498
+ .select(el.chart)
499
+ .append("svg")
500
+ .attr("width", W)
501
+ .attr("height", H)
502
+ .on("mousemove", (ev) => moveCursor(d3.pointer(ev)[0]))
503
+ .on("mouseleave", () => moveCursor(null));
504
+ chartSvg.append("g").attr("class", "grid");
505
+ chartSvg.append("g").attr("class", "links");
506
+ chartSvg.append("g").attr("class", "rows");
507
+ chartSvg.append("line").attr("class", "pin").attr("stroke", css("--accent")).attr("stroke-width", 1.5).attr("y1", 0).attr("y2", H);
508
+ chartSvg.append("line").attr("class", "cursor").attr("y1", 0).attr("y2", H).attr("visibility", "hidden");
509
+ chartSvg.call(chartZoom).call(chartZoom.transform, state.zoom);
510
+ redrawX();
511
+ }
512
+
513
+ function redrawX() {
514
+ const x = xz();
515
+ updateAxis(x);
516
+ updateWindowLabel(x);
517
+ updateOverviewBrush(x);
518
+ if (state.view === "timeline" && chartSvg) drawRows(x);
519
+ }
520
+
521
+ function drawRows(x) {
522
+ const H = rowsNow.length * ROW_H + 8;
523
+ const ticks = x.ticks(Math.max(2, Math.floor(W / 90)));
524
+ chartSvg
525
+ .select(".grid")
526
+ .selectAll("line")
527
+ .data(ticks)
528
+ .join("line")
529
+ .attr("x1", (d) => x(d))
530
+ .attr("x2", (d) => x(d))
531
+ .attr("y1", 0)
532
+ .attr("y2", H);
533
+ const index = new Map(rowsNow.map((n, i) => [n.id, i]));
534
+ // parent → child elbows
535
+ const links = rowsNow.filter((n) => n.parent && index.has(n.parent.id));
536
+ chartSvg
537
+ .select(".links")
538
+ .selectAll("path")
539
+ .data(links, (n) => n.id)
540
+ .join("path")
541
+ .attr("class", "link")
542
+ .attr("d", (n) => {
543
+ const pi = index.get(n.parent.id);
544
+ const ci = index.get(n.id);
545
+ const cx = x(n.start);
546
+ const px = Math.max(x(n.parent.start) + 6, cx - 12);
547
+ const py = pi * ROW_H + BAR_Y + BAR_H;
548
+ const cy = ci * ROW_H + BAR_Y + BAR_H / 2;
549
+ return `M${px},${py}V${cy - 4}Q${px},${cy} ${px + 4},${cy}H${cx}`;
550
+ });
551
+
552
+ const g = chartSvg
553
+ .select(".rows")
554
+ .selectAll("g.r")
555
+ .data(rowsNow, (n) => n.id)
556
+ .join((enter) => {
557
+ const r = enter.append("g").attr("class", "r");
558
+ // A transparent target spanning the bar *and* its label, so a 2px bar is still hoverable.
559
+ r.append("rect").attr("class", "hit").attr("y", BAR_Y - 4).attr("height", BAR_H + 8);
560
+ r.append("rect").attr("class", "bar").attr("rx", 5).attr("y", BAR_Y).attr("height", BAR_H);
561
+ r.append("text").attr("class", "bar-label").attr("y", BAR_Y + BAR_H / 2 + 4);
562
+ r.append("g").attr("class", "marks");
563
+ const cb = r.append("g").attr("class", "code-btn src");
564
+ cb.append("circle").attr("r", 9);
565
+ cb.append("text").attr("text-anchor", "middle").attr("y", 3.5).text("<>");
566
+ return r;
567
+ })
568
+ .attr("transform", (_n, i) => `translate(0,${i * ROW_H})`)
569
+ .classed("sel", (n) => n.id === state.selected);
570
+
571
+ g.select("rect.bar")
572
+ .attr("x", (n) => x(n.start))
573
+ .attr("width", (n) => Math.max(3, x(n.end) - x(n.start)))
574
+ .attr("fill", (n) => colorOf(n))
575
+ .attr("fill-opacity", (n) => (n.status === "cancelled" ? 0.55 : 0.92))
576
+ .attr("stroke", (n) => (n.status === "failed" ? css("--fail") : null))
577
+ .attr("stroke-width", (n) => (n.status === "failed" ? 2 : null))
578
+ .attr("stroke-dasharray", (n) => (n.status === "cancelled" ? "4 3" : null))
579
+ .classed("selected", (n) => n.id === state.selected)
580
+ .call(bindNode);
581
+
582
+ g.select("text.bar-label").each(function (n) {
583
+ const t = d3.select(this);
584
+ const x0 = x(n.start);
585
+ const w = x(n.end) - x0;
586
+ const text = `${boxLabel(n)} (${fmtDur(n.duration)})`;
587
+ const need = text.length * 6.6 + 14;
588
+ const inside = w >= need;
589
+ const clipX = Math.max(x0, 0);
590
+ const outside = !inside && w <= 60;
591
+ const shown = inside || outside ? text : truncate(text, (w - 14) / 6.6);
592
+ const tw = shown.length * 6.6;
593
+ // an outside label that would run off the chart hangs left of its bar instead
594
+ const flip = outside && x0 + Math.max(w, 3) + 6 + tw > W;
595
+ const lx = flip ? x0 - 6 : outside ? x0 + Math.max(w, 3) + 6 : Math.min(clipX + 8, x0 + w - 20);
596
+ t.text(shown).classed("outside", outside).attr("x", lx).attr("text-anchor", flip ? "end" : "start");
597
+ // the hit target covers bar + label, padded so a sliver of a bar is still easy to reach
598
+ const left = Math.min(x0, flip ? lx - tw : lx) - 6;
599
+ const right = Math.max(x0 + Math.max(w, 3), flip ? x0 : lx + tw) + 6;
600
+ d3.select(this.parentNode).select("rect.hit").attr("x", left).attr("width", Math.max(16, right - left));
601
+ // the <> sits just past the block (or just before it, when that would leave the chart)
602
+ // …and, for a block spanning the chart, inside it right after its label
603
+ const cx =
604
+ right + 8 <= W - 10 ? right + 8 : left - 8 >= 10 ? left - 8 : Math.min(W - 12, (flip ? x0 : lx + tw) + 14);
605
+ d3.select(this.parentNode)
606
+ .select("g.code-btn.src")
607
+ .style("display", hasPopup(n) ? null : "none")
608
+ .attr("transform", `translate(${cx},${BAR_Y + BAR_H / 2})`);
609
+ });
610
+ g.select("g.code-btn.src")
611
+ .on("click", (ev, n) => {
612
+ ev.stopPropagation();
613
+ toggleCode(n, ev);
614
+ })
615
+ .on("mouseenter", (ev, n) => showTip(ev, popupTip(n)))
616
+ .on("mouseleave", hideTip);
617
+ g.selectAll("rect.hit, text.bar-label").call(bindNode);
618
+
619
+ g.select("g.marks").each(function (n) {
620
+ drawMarks(d3.select(this), n, x);
621
+ });
622
+
623
+ chartSvg.select("line.pin").attr("x1", state.pin != null ? x(state.pin) : -10).attr("x2", state.pin != null ? x(state.pin) : -10);
624
+ if (app.classList.contains("hl-on") || state.errHl) applyHl();
625
+ }
626
+
627
+ function drawMarks(g, n, x) {
628
+ const cy = BAR_Y + BAR_H / 2;
629
+ // cluster marks closer than 4px so a chatty node draws a readable line of dots
630
+ const marks = [];
631
+ let last = -Infinity;
632
+ for (const m of n.marks) {
633
+ const px = x(m.t);
634
+ if (px - last < 4 && marks.length) {
635
+ marks[marks.length - 1].more.push(m);
636
+ continue;
637
+ }
638
+ marks.push({ m, px, more: [] });
639
+ last = px;
640
+ }
641
+ g.selectAll("circle.mark")
642
+ .data(marks)
643
+ .join("circle")
644
+ .attr("class", "mark")
645
+ .attr("cx", (d) => d.px)
646
+ .attr("cy", cy)
647
+ .attr("r", (d) => (d.more.length ? 6 : 5))
648
+ .attr("fill", (d) => css(MARK[worst(d)].color))
649
+ .on("click", (ev, d) => {
650
+ ev.stopPropagation();
651
+ openItem(d.m);
652
+ })
653
+ .on("mouseenter", (ev, d) => showTip(ev, markTip(d)))
654
+ .on("mousemove", (ev) => moveTip(ev))
655
+ .on("mouseleave", hideTip);
656
+ // labels under the bar, skipped where they would collide
657
+ const labels = [];
658
+ let edge = -Infinity;
659
+ for (const d of marks) {
660
+ const text = truncate(d.more.length ? `${d.m.label} +${d.more.length}` : d.m.label, 22);
661
+ const w = text.length * 6 + 8;
662
+ if (d.px - 6 < edge) continue;
663
+ labels.push({ d, text });
664
+ edge = d.px + w;
665
+ }
666
+ // a label that would run off the right edge hangs left of its mark instead
667
+ const flip = (l) => l.d.px - 4 + l.text.length * 6 > W;
668
+ g.selectAll("text.mark-label")
669
+ .data(labels)
670
+ .join("text")
671
+ .attr("class", "mark-label")
672
+ .attr("x", (l) => (flip(l) ? l.d.px + 4 : l.d.px - 4))
673
+ .attr("text-anchor", (l) => (flip(l) ? "end" : "start"))
674
+ .attr("y", BAR_Y + BAR_H + 13)
675
+ .text((l) => l.text)
676
+ .on("click", (ev, l) => {
677
+ ev.stopPropagation();
678
+ openItem(l.d.m);
679
+ })
680
+ .on("mouseenter", (ev, l) => showTip(ev, markTip(l.d)))
681
+ .on("mousemove", (ev) => moveTip(ev))
682
+ .on("mouseleave", hideTip);
683
+ }
684
+ function worst(d) {
685
+ const all = [d.m, ...d.more].map((m) => m.kind);
686
+ for (const k of ["fail", "error", "cancel", "snapshot", "artifact", "pass", "event"]) if (all.includes(k)) return k;
687
+ return all[0];
688
+ }
689
+
690
+ // ── axis + cursor ──
691
+ function updateAxis(x) {
692
+ const svg = d3.select(el.axis);
693
+ if (state.view === "flow") return;
694
+ const w = el.chart.clientWidth || W;
695
+ svg.attr("viewBox", null);
696
+ const g = svg.selectAll("g.axis").data([0]).join("g").attr("class", "axis").attr("transform", "translate(0,33)");
697
+ g.call(d3.axisTop(x).ticks(Math.max(2, Math.floor(w / 90))).tickFormat(fmtTick).tickSizeOuter(0));
698
+ g.selectAll(".tick text").attr("dy", "-0.5em");
699
+ svg.selectAll("text.cursor-t").data([0]).join("text").attr("class", "cursor-t").attr("y", 12).attr("fill", css("--accent")).attr("font-size", 11);
700
+ }
701
+ function moveCursor(px) {
702
+ if (chartSvg) {
703
+ chartSvg.select("line.cursor").attr("visibility", px == null ? "hidden" : "visible").attr("x1", px).attr("x2", px);
704
+ }
705
+ const label = d3.select(el.axis).select("text.cursor-t");
706
+ if (px == null) label.text("");
707
+ else {
708
+ const t = xz().invert(px);
709
+ label.attr("x", px + 4).text(fmtTick(Math.max(0, t)));
710
+ }
711
+ }
712
+ function updateWindowLabel(x) {
713
+ const [a, b] = x.domain();
714
+ const span = b - a;
715
+ const opt = el.windowSel.querySelector("option[value='0']");
716
+ opt.textContent = `${fmtTick(span)} window`;
717
+ el.windowSel.value = "0";
718
+ }
719
+
720
+ // ── flow view ──
721
+ //
722
+ // Each node's children form one *lane*: a column to its right, in start order, joined by
723
+ // dashed down-arrows — the direction the run went. The node's own snapshots sit on those
724
+ // arrows, between the children they happened between, so "when, relative to what" reads
725
+ // off the picture. A leaf has no lane; its snapshots sit in a strip under its card.
726
+ let flowG = null;
727
+ let flowPos = new Map();
728
+ let flowBounds = null;
729
+ const FLOW = { CW: 250, CH: 58, TAGS_H: 18, GX: 64, GY: 26, LANE_DX: 22, TW: 46, TH: 28 };
730
+ /** A tag chip in SVG, matching the HTML `.chip`; returns its group and width. */
731
+ function svgChip(parent, x, label, color) {
732
+ const g = parent.append("g").attr("class", "tchip").attr("transform", `translate(${x},0)`);
733
+ const rect = g.append("rect").attr("height", 16).attr("rx", 4);
734
+ const text = g.append("text").attr("x", 6).attr("y", 11.5).text(label);
735
+ const w = (text.node().getComputedTextLength() || label.length * 6) + 12;
736
+ rect
737
+ .attr("width", w)
738
+ .attr("fill", color ? alpha(color, 0.14) : "none")
739
+ .attr("stroke", color ? alpha(color, 0.45) : css("--line"));
740
+ return { g, w };
741
+ }
742
+ function renderFlow() {
743
+ d3.select(el.flow).selectAll("svg").remove();
744
+ const svg = d3.select(el.flow).append("svg");
745
+ svg
746
+ .append("defs")
747
+ .append("marker")
748
+ .attr("id", "flow-arrow")
749
+ .attr("viewBox", "0 0 10 10")
750
+ .attr("refX", 9)
751
+ .attr("refY", 5)
752
+ .attr("markerWidth", 7)
753
+ .attr("markerHeight", 7)
754
+ .attr("orient", "auto")
755
+ .append("path")
756
+ .attr("class", "flow-arrowhead")
757
+ .attr("d", "M0,1 L10,5 L0,9 z");
758
+ flowG = svg.append("g");
759
+ const { CW, GX, GY, LANE_DX, TW, TH } = FLOW;
760
+ // every card gets the tag row when any is tagged, so lanes stay on one rhythm
761
+ const CH = FLOW.CH + (model.nodes.some((n) => n.v.tags && n.v.tags.length) ? FLOW.TAGS_H : 0);
762
+
763
+ const shotsBy = new Map();
764
+ for (const g of model.gallery) {
765
+ if (!g.node) continue;
766
+ if (!shotsBy.has(g.node)) shotsBy.set(g.node, []);
767
+ shotsBy.get(g.node).push(g);
768
+ }
769
+ const byTime = (a, b) => (a.t ?? Infinity) - (b.t ?? Infinity);
770
+ const pos = new Map();
771
+ const lanes = [];
772
+ const chipRows = [];
773
+ const strips = [];
774
+ const STRIP_H = 6 + TH;
775
+ const cardBottom = (n, y) => y + CH + (!n.children.length && shotsBy.has(n) ? STRIP_H : 0);
776
+
777
+ // Lays `n` out with its card's top-left at (x, y); returns the bottom of its whole subtree.
778
+ const lay = (n, x, y) => {
779
+ pos.set(n.id, { x, y });
780
+ const shots = [...(shotsBy.get(n) || [])].sort(byTime);
781
+ if (!n.children.length) {
782
+ if (shots.length) strips.push({ n, x: x + 6, y: y + CH + 6, shots });
783
+ return cardBottom(n, y);
784
+ }
785
+ const cx = x + CW + GX;
786
+ const kids = [...n.children].sort((a, b) => a.start - b.start);
787
+ const seq = [];
788
+ let si = 0;
789
+ for (const k of kids) {
790
+ const before = [];
791
+ while (si < shots.length && (shots[si].t ?? Infinity) < k.start) before.push(shots[si++]);
792
+ if (before.length) seq.push({ shots: before });
793
+ seq.push({ node: k });
794
+ }
795
+ if (si < shots.length) seq.push({ shots: shots.slice(si) });
796
+ const items = [];
797
+ let cy = y;
798
+ for (const it of seq) {
799
+ if (it.shots) {
800
+ const row = { x: cx + LANE_DX - TW / 2, y: cy, shots: it.shots };
801
+ chipRows.push(row);
802
+ items.push({ top: cy, bottom: cy + TH, row });
803
+ cy += TH + GY;
804
+ } else {
805
+ const bottom = lay(it.node, cx, cy);
806
+ items.push({ top: cy, bottom: cardBottom(it.node, cy), node: it.node });
807
+ cy = bottom + GY;
808
+ }
809
+ }
810
+ lanes.push({ parent: n, x: cx + LANE_DX, cardX: cx, items });
811
+ return Math.max(cardBottom(n, y), cy - GY);
812
+ };
813
+ const total = lay(model.root, 0, 0);
814
+
815
+ let maxX = CW;
816
+ for (const p of pos.values()) maxX = Math.max(maxX, p.x + CW);
817
+ for (const r of chipRows) maxX = Math.max(maxX, r.x + r.shots.length * (TW + 4) + 60);
818
+ flowBounds = { x0: -24, y0: -24, x1: maxX + 24, y1: total + 24 };
819
+
820
+ // parent → its lane: across from the card, then down into the first item
821
+ const links = flowG.append("g").attr("class", "flow-links");
822
+ for (const lane of lanes) {
823
+ const p = pos.get(lane.parent.id);
824
+ const first = lane.items[0];
825
+ const mid = p.y + CH / 2;
826
+ const d =
827
+ first.node && first.top === p.y
828
+ ? `M${p.x + CW},${mid}H${lane.cardX}`
829
+ : `M${p.x + CW},${mid}H${lane.x - 8}Q${lane.x},${mid} ${lane.x},${mid + 8}V${first.top - 2}`;
830
+ links.append("path").attr("class", "flow-link").attr("d", d);
831
+ // sibling → next sibling: the flow direction. Overlapping siblings ran in parallel.
832
+ let prevNode = null;
833
+ for (let i = 0; i + 1 < lane.items.length; i++) {
834
+ const a = lane.items[i];
835
+ const b = lane.items[i + 1];
836
+ if (a.node) prevNode = a.node;
837
+ const parallel = !!(b.node && prevNode && b.node.start < prevNode.end - 1);
838
+ links
839
+ .append("path")
840
+ .attr("class", `flow-seq${parallel ? " parallel" : ""}`)
841
+ .attr("d", `M${lane.x},${a.bottom + 2}V${b.top - 3}`)
842
+ .attr("marker-end", parallel ? null : "url(#flow-arrow)");
843
+ if (parallel) {
844
+ links
845
+ .append("text")
846
+ .attr("class", "flow-par")
847
+ .attr("x", lane.x + 6)
848
+ .attr("y", (a.bottom + b.top) / 2 + 3)
849
+ .text("∥ parallel");
850
+ }
851
+ }
852
+ }
853
+
854
+ const nodes = [...pos.keys()].map((id) => model.byId.get(id));
855
+ const card = flowG
856
+ .append("g")
857
+ .selectAll("g.flow-card")
858
+ .data(nodes, (n) => n.id)
859
+ .join("g")
860
+ .attr("class", (n) => `flow-card ${n.id === state.selected ? "selected" : ""}`)
861
+ .attr("transform", (n) => `translate(${pos.get(n.id).x},${pos.get(n.id).y})`)
862
+ .style("cursor", "pointer")
863
+ .on("click", (ev, n) => select(n.id))
864
+ .on("dblclick", (ev, n) => {
865
+ ev.stopPropagation();
866
+ setView("timeline");
867
+ focusNode(n);
868
+ })
869
+ .on("mouseenter", (ev, n) => showTip(ev, nodeTip(n), true))
870
+ .on("mousemove", (ev) => moveTip(ev))
871
+ .on("mouseleave", hideTip);
872
+ card.append("rect").attr("class", "box").attr("width", CW).attr("height", CH).attr("rx", 9);
873
+ card
874
+ .append("rect")
875
+ .attr("class", "icon-bg")
876
+ .attr("x", 10)
877
+ .attr("y", 10)
878
+ .attr("width", 24)
879
+ .attr("height", 24)
880
+ .attr("rx", 6)
881
+ .attr("fill", (n) => alpha(colorOf(n), 0.16));
882
+ card
883
+ .append("g")
884
+ .attr("class", "flow-icon")
885
+ .attr("transform", "translate(15,15) scale(0.58)")
886
+ .attr("color", (n) => colorOf(n))
887
+ .html((n) => `<path d="${ICON[iconOf(n)]}" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>`);
888
+ card.each(function (n) {
889
+ const lines = wrapName(boxLabel(n), 31);
890
+ const t = d3.select(this);
891
+ lines.forEach((line, i) =>
892
+ t
893
+ .append("text")
894
+ .attr("class", "nm")
895
+ .attr("x", 42)
896
+ .attr("y", (lines.length > 1 ? 17 : 22) + i * 14)
897
+ .text(line)
898
+ );
899
+ t.append("text")
900
+ .attr("class", "sub")
901
+ .attr("x", 42)
902
+ .attr("y", lines.length > 1 ? 49 : 41)
903
+ .text(`${nodeKind(n)} · ${n.status} · ${fmtDur(n.duration)} · +${fmtTick(n.start)}`);
904
+ const tags = n.v.tags || [];
905
+ if (!tags.length) return;
906
+ const row = t.append("g").attr("class", "tchips").attr("transform", `translate(42,${lines.length > 1 ? 56 : 51})`);
907
+ const room = CW - 42 - 10;
908
+ let x = 0;
909
+ for (let i = 0; i < tags.length; i++) {
910
+ const c = svgChip(row, x, truncate(tags[i], 22), tagColor(tags[i]));
911
+ // keep room for a "+N" after it, unless it is the last
912
+ if (i > 0 && x + c.w > room - (i < tags.length - 1 ? 30 : 0)) {
913
+ c.g.remove();
914
+ svgChip(row, x, `+${tags.length - i}`, null).g.classed("more", true);
915
+ break;
916
+ }
917
+ x += c.w + 4;
918
+ }
919
+ });
920
+ card
921
+ .append("circle")
922
+ .attr("cx", CW - 12)
923
+ .attr("cy", 12)
924
+ .attr("r", 4)
925
+ .attr("fill", (n) => css(statusVar(n.status)));
926
+ const fcb = card
927
+ .filter((n) => hasPopup(n))
928
+ .append("g")
929
+ .attr("class", "code-btn")
930
+ .attr("transform", `translate(${CW / 2},0)`)
931
+ .on("click", (ev, n) => {
932
+ ev.stopPropagation();
933
+ toggleCode(n, ev);
934
+ })
935
+ .on("mouseenter", (ev, n) => {
936
+ ev.stopPropagation();
937
+ showTip(ev, popupTip(n));
938
+ });
939
+ fcb.append("circle").attr("r", 9);
940
+ fcb.append("text").attr("text-anchor", "middle").attr("y", 3.5).text("<>");
941
+
942
+ // snapshot chips — on lane arrows, and in strips under leaf cards
943
+ const chipLayer = flowG.append("g").attr("class", "flow-shots");
944
+ const drawChips = (x, y, shots, max, owner) => {
945
+ const shown = shots.length > max ? shots.slice(0, max - 1) : shots;
946
+ const g = chipLayer.append("g").attr("transform", `translate(${x},${y})`);
947
+ const cell = g
948
+ .selectAll("g.shot")
949
+ .data(shown)
950
+ .join("g")
951
+ .attr("class", "shot")
952
+ .attr("transform", (_x, i) => `translate(${i * (TW + 4)},0)`)
953
+ .on("click", (ev, x2) => {
954
+ ev.stopPropagation();
955
+ openGallery(x2);
956
+ })
957
+ .on("mouseenter", (ev, x2) => showTip(ev, galleryTip(x2)))
958
+ .on("mousemove", (ev) => moveTip(ev))
959
+ .on("mouseleave", hideTip);
960
+ cell.append("rect").attr("width", TW).attr("height", TH).attr("rx", 5);
961
+ cell
962
+ .filter((x2) => x2.image)
963
+ .append("image")
964
+ .attr("href", (x2) => model.asset(x2.f.asset))
965
+ .attr("x", 1)
966
+ .attr("y", 1)
967
+ .attr("width", TW - 2)
968
+ .attr("height", TH - 2)
969
+ .attr("preserveAspectRatio", "xMidYMid slice");
970
+ cell
971
+ .filter((x2) => !x2.image)
972
+ .append("text")
973
+ .attr("x", TW / 2)
974
+ .attr("y", TH / 2 + 3.5)
975
+ .attr("text-anchor", "middle")
976
+ .text((x2) => (x2.type === "snapshot" ? "{ }" : "file"));
977
+ let end = shown.length * (TW + 4);
978
+ if (shots.length > shown.length) {
979
+ const more = g
980
+ .append("g")
981
+ .attr("class", "shot more")
982
+ .attr("transform", `translate(${end},0)`)
983
+ .on("click", (ev) => {
984
+ ev.stopPropagation();
985
+ select(owner.id, "snapshots");
986
+ });
987
+ more.append("rect").attr("width", TW).attr("height", TH).attr("rx", 5);
988
+ more
989
+ .append("text")
990
+ .attr("x", TW / 2)
991
+ .attr("y", TH / 2 + 3.5)
992
+ .attr("text-anchor", "middle")
993
+ .text(`+${shots.length - shown.length}`);
994
+ end += TW + 4;
995
+ }
996
+ const t = shots[0].t;
997
+ if (t != null) {
998
+ g.append("text")
999
+ .attr("class", "flow-shot-t")
1000
+ .attr("x", end + 2)
1001
+ .attr("y", TH / 2 + 4)
1002
+ .text(`${fmtRel(t)} · ${shots.length > 1 ? `${shots.length} snapshots` : esc(truncate(shots[0].name, 22))}`);
1003
+ }
1004
+ };
1005
+ for (const r of chipRows) {
1006
+ const owner = lanes.find((l) => l.items.some((i) => i.row === r)).parent;
1007
+ drawChips(r.x, r.y, r.shots, 6, owner);
1008
+ }
1009
+ for (const s of strips) drawChips(s.x, s.y, s.shots, Math.floor((CW - 12) / (TW + 4)), s.n);
1010
+
1011
+ flowPos = new Map([...pos].map(([id, p]) => [id, { x: p.x + CW / 2, y: p.y + CH / 2 }]));
1012
+ svg.call(flowZoom).on("dblclick.zoom", null);
1013
+ fitFlow(true, "start");
1014
+ if (state.errHl) applyHl();
1015
+ }
1016
+ /**
1017
+ * `"start"` (the first view): a readable zoom, anchored at the run's beginning — a large run
1018
+ * fit whole is a field of unreadable dots. Otherwise (the Fit button): everything on screen.
1019
+ */
1020
+ function fitFlow(instant, mode) {
1021
+ const svg = d3.select(el.flow).select("svg");
1022
+ if (svg.empty() || !flowBounds) return;
1023
+ const w = el.flow.clientWidth;
1024
+ const h = el.flow.clientHeight;
1025
+ const b = flowBounds;
1026
+ const fitK = Math.min(w / (b.x1 - b.x0), h / (b.y1 - b.y0)) * 0.95;
1027
+ const k = mode === "start" ? Math.max(0.62, Math.min(1, fitK)) : Math.min(1.2, fitK);
1028
+ const fits = (b.x1 - b.x0) * k <= w && (b.y1 - b.y0) * k <= h;
1029
+ const t = fits
1030
+ ? d3.zoomIdentity.translate(w / 2 - (k * (b.x0 + b.x1)) / 2, h / 2 - (k * (b.y0 + b.y1)) / 2).scale(k)
1031
+ : d3.zoomIdentity.translate(-k * b.x0, -k * b.y0).scale(k);
1032
+ (instant ? svg : svg.transition().duration(250)).call(flowZoom.transform, t);
1033
+ }
1034
+
1035
+ // ── overview minimap ──
1036
+ let ovBrush = null;
1037
+ let ovX = null;
1038
+ let brushing = false;
1039
+ function renderOverview() {
1040
+ const svg = d3.select(el.overview);
1041
+ svg.selectAll("*").remove();
1042
+ const w = el.overview.clientWidth || 220;
1043
+ const h = el.overview.clientHeight || 120;
1044
+ ovX = d3.scaleLinear().domain(baseX.domain()).range([0, w]);
1045
+ const rowH = Math.max(1.5, Math.min(7, (h - 6) / model.nodes.length));
1046
+ svg
1047
+ .append("g")
1048
+ .selectAll("rect")
1049
+ .data(model.nodes)
1050
+ .join("rect")
1051
+ .attr("x", (n) => ovX(n.start))
1052
+ .attr("width", (n) => Math.max(1, ovX(n.end) - ovX(n.start)))
1053
+ .attr("y", (_n, i) => 3 + i * rowH)
1054
+ .attr("height", Math.max(1, rowH - 1))
1055
+ .attr("rx", Math.min(2, rowH / 2))
1056
+ .attr("fill", (n) => colorOf(n));
1057
+ ovBrush = d3
1058
+ .brushX()
1059
+ .extent([[0, 0], [w, h]])
1060
+ .on("start", (ev) => {
1061
+ if (ev.sourceEvent) brushing = true;
1062
+ })
1063
+ .on("brush end", (ev) => {
1064
+ if (ev.type === "end") brushing = false;
1065
+ if (!ev.sourceEvent || !ev.selection) return;
1066
+ const [a, b] = ev.selection.map(ovX.invert);
1067
+ zoomToDomain(a, b);
1068
+ });
1069
+ svg.append("g").attr("class", "brush").call(ovBrush);
1070
+ el.range.textContent = `0s – ${fmtTick(model.span)} (${fmtDur(model.root.duration)} body)`;
1071
+ updateOverviewBrush(xz());
1072
+ }
1073
+ function updateOverviewBrush(x) {
1074
+ if (!ovBrush || !ovX || brushing) return;
1075
+ const [a, b] = x.domain();
1076
+ const s = [Math.max(0, ovX(a)), Math.min(ovX.range()[1], ovX(b))];
1077
+ d3.select(el.overview).select("g.brush").call(ovBrush.move, s);
1078
+ }
1079
+
1080
+ // ── selection + details ──
1081
+ function select(id, tab) {
1082
+ state.selected = id;
1083
+ if (tab) state.detailOpen.add(tab);
1084
+ state.details = true;
1085
+ el.main.classList.remove("no-details");
1086
+ el.tree.querySelectorAll(".row").forEach((r) => r.classList.toggle("selected", r.dataset.id === id));
1087
+ if (chartSvg) {
1088
+ chartSvg.selectAll("rect.bar").classed("selected", (n) => n.id === id);
1089
+ chartSvg.selectAll("g.r").classed("sel", (n) => n.id === id);
1090
+ }
1091
+ d3.select(el.flow).selectAll("g.flow-card").classed("selected", (n) => n.id === id);
1092
+ renderDetails();
1093
+ }
1094
+ function focusNode(n) {
1095
+ const pad = Math.max(n.end - n.start, 50) * 0.08;
1096
+ zoomToDomain(n.start - pad, n.end + pad);
1097
+ }
1098
+ function revealRow(n, block) {
1099
+ let p = n.parent;
1100
+ let changed = false;
1101
+ while (p) {
1102
+ if (state.collapsed.delete(p.id)) changed = true;
1103
+ p = p.parent;
1104
+ }
1105
+ if (changed && state.view === "timeline") renderTimeline();
1106
+ const row = el.tree.querySelector(`.row[data-id="${CSS.escape(n.id)}"]`);
1107
+ if (row) row.scrollIntoView({ block: block || "nearest" });
1108
+ }
1109
+
1110
+ /**
1111
+ * Bring a gallery item into view in whichever graph is showing: select its node, pin its
1112
+ * time, pan (keeping the zoom) so that time is on screen, and pulse its mark.
1113
+ */
1114
+ function locate(g) {
1115
+ if (!g) return;
1116
+ locateAt(g.node, g.t, (m) => m.gallery === g, "snapshots");
1117
+ }
1118
+ /** Select `node` (on `tab`), pin `t` and pan to it, then pulse the marks `matchMark` accepts. */
1119
+ function locateAt(node, t0, matchMark, tab) {
1120
+ const g = { node, t: t0 };
1121
+ if (g.node) select(g.node.id, tab);
1122
+ if (state.view === "flow") {
1123
+ if (g.node) centerFlowOn(g.node);
1124
+ return;
1125
+ }
1126
+ if (g.t != null) {
1127
+ state.pin = g.t;
1128
+ const [a, b] = xz().domain();
1129
+ if (g.t < a || g.t > b) {
1130
+ const k = state.zoom.k;
1131
+ const t = d3.zoomIdentity.translate(W / 2 - k * baseX(g.t), 0).scale(k);
1132
+ const { z, sel } = activeZoom();
1133
+ sel.call(z.transform, t);
1134
+ } else redrawX();
1135
+ }
1136
+ if (g.node && state.view === "timeline") revealRow(g.node, "center");
1137
+ flash(matchMark);
1138
+ }
1139
+ function flash(matchMark) {
1140
+ if (chartSvg) {
1141
+ chartSvg.selectAll("line.pin").classed("flash", false).node()?.getBoundingClientRect();
1142
+ chartSvg.selectAll("line.pin").classed("flash", true);
1143
+ }
1144
+ if (chartSvg) {
1145
+ const hit = chartSvg
1146
+ .selectAll("circle.mark")
1147
+ .filter((d) => matchMark(d.m) || d.more.some(matchMark));
1148
+ hit.classed("flash", false).node()?.getBoundingClientRect();
1149
+ hit.classed("flash", true);
1150
+ }
1151
+ setTimeout(() => d3.selectAll(".flash").classed("flash", false), 1800);
1152
+ }
1153
+ function centerFlowOn(n) {
1154
+ const svg = d3.select(el.flow).select("svg");
1155
+ const p = flowPos.get(n.id);
1156
+ if (svg.empty() || !p) return;
1157
+ const k = Math.max(0.8, d3.zoomTransform(svg.node()).k);
1158
+ const t = d3.zoomIdentity.translate(el.flow.clientWidth / 2 - k * p.x, el.flow.clientHeight / 2 - k * p.y).scale(k);
1159
+ svg.transition().duration(300).call(flowZoom.transform, t);
1160
+ }
1161
+
1162
+ function renderDetails() {
1163
+ const n = model.byId.get(state.selected) || model.root;
1164
+ const color = colorOf(n);
1165
+ const call = callOf(n);
1166
+ el.details.innerHTML = `
1167
+ <div class="dh">Action details <button class="x" title="Close">${svgIcon("close", ' width="16" height="16"')}</button></div>
1168
+ <div class="who">
1169
+ <span class="icon" style="color:${color};background:${alpha(color, 0.16)}">${svgIcon(iconOf(n))}</span>
1170
+ <span class="nm" title="${esc(n.name)}">${esc(displayName(n))}</span>
1171
+ <span class="pill"><span class="dot ${n.status}"></span>${esc(n.status)}</span>
1172
+ </div>
1173
+ <div class="scroll">
1174
+ ${call ? `<div class="tcall who-call">${callHtml(n, call)}</div>` : ""}
1175
+ <div class="kv">
1176
+ <div class="k">Duration</div><div class="v">${fmtDur(n.duration)}</div>
1177
+ ${n.v.description ? `<div class="k">Summary</div><div class="v">${esc(n.v.description.summary)}</div>` : ""}
1178
+ ${n.v.tags.length ? `<div class="k">Tags</div><div class="v">${chipsHtml(n.v.tags, Infinity, 40)}</div>` : ""}
1179
+ ${n.v.mcp ? `<div class="k">MCP</div><div class="v"><span class="badge-inline">${esc(n.v.mcp.mode)}</span></div>` : ""}
1180
+ <div class="k">Id</div><div class="v mono-sm">${esc(n.id)}</div>
1181
+ ${userSite(n) ? `<div class="k">${siteLabel(n)}</div><div class="v defined">
1182
+ <button class="icon-btn" data-copy-source aria-label="Copy relative path">${svgIcon("copy")}</button>
1183
+ ${hasPopup(n) ? `<button class="icon-btn" data-show-code aria-label="Show source">${svgIcon("code")}</button>` : ""}
1184
+ <a class="icon-btn" href="${esc(editorUrl(userSite(n)))}" aria-label="Open in VS Code" data-tip-text="Open in VS Code">${svgIcon("external")}</a>
1185
+ <span class="defined-name" >${esc(basename(sourceOf(n)))}</span>
1186
+ </div>` : ""}
1187
+ </div>
1188
+ <div class="acc">${detailSections(n)
1189
+ .map((sec) =>
1190
+ sec.empty
1191
+ ? `<div class="acc-sec empty"><div class="acc-h">${svgIcon("chevron")}<span>${esc(sec.title)}</span><span class="acc-n">0</span></div></div>`
1192
+ : `<details class="acc-sec ${sec.key}" data-sec="${sec.key}" ${state.detailOpen.has(sec.key) ? "open" : ""}>
1193
+ <summary class="acc-h">${svgIcon("chevron")}<span>${esc(sec.title)}</span>${sec.count != null ? `<span class="acc-n">${sec.count}</span>` : ""}</summary>
1194
+ <div class="acc-b">${sec.body()}</div>
1195
+ </details>`
1196
+ )
1197
+ .join("")}</div>
1198
+ </div>`;
1199
+ const copySrc = el.details.querySelector("[data-copy-source]");
1200
+ if (copySrc) {
1201
+ copySrc.addEventListener("click", () => copySource(n));
1202
+ copySrc.addEventListener("mouseenter", (ev) =>
1203
+ showTip(ev, `<div class="mono">${esc(sourceOf(n))}</div><div class="hint">Click (or press <kbd>C</kbd>) to copy</div>`)
1204
+ );
1205
+ copySrc.addEventListener("mousemove", (ev) => moveTip(ev));
1206
+ copySrc.addEventListener("mouseleave", hideTip);
1207
+ }
1208
+ wireValue(el.details, n);
1209
+ const showCode = el.details.querySelector("[data-show-code]");
1210
+ if (showCode) {
1211
+ showCode.addEventListener("click", (ev) => toggleCode(n, ev));
1212
+ showCode.addEventListener("mouseenter", (ev) => showTip(ev, popupTip(n)));
1213
+ showCode.addEventListener("mouseleave", hideTip);
1214
+ }
1215
+ el.details.querySelectorAll("[data-tip-text]").forEach((a) => {
1216
+ a.addEventListener("mouseenter", (ev) => showTip(ev, esc(a.dataset.tipText)));
1217
+ a.addEventListener("mouseleave", hideTip);
1218
+ });
1219
+ el.details.querySelector(".x").addEventListener("click", () => {
1220
+ state.details = false;
1221
+ el.main.classList.add("no-details");
1222
+ relayout();
1223
+ });
1224
+ // which sections are open is a preference: it carries over from action to action
1225
+ el.details.querySelectorAll("details[data-sec]").forEach((d) =>
1226
+ d.addEventListener("toggle", () => {
1227
+ if (d.open) state.detailOpen.add(d.dataset.sec);
1228
+ else state.detailOpen.delete(d.dataset.sec);
1229
+ })
1230
+ );
1231
+ wireItems(el.details);
1232
+ }
1233
+
1234
+ /** The details panel's accordion: one section per facet of `n`, empty ones shown but shut. */
1235
+ function detailSections(n) {
1236
+ const errs = model.errors.filter((e) => e.node === n);
1237
+ const params = schemaFields(n.v.inputSchema);
1238
+ const evs = n.entries.filter((i) => i.kind !== "info");
1239
+ const logs = n.entries.filter((i) => i.kind === "info" || i.kind === "error");
1240
+ const shots = model.gallery.filter((x) => x.node === n);
1241
+ const asserts = n.entries.filter((i) => i.kind === "pass" || i.kind === "fail");
1242
+ const logLink = `<div class="acc-file">${svgIcon("file", ' width="14" height="14"')}<a href="${esc(fileUrl(n.v.logFile))}" title="${esc(n.v.logFile)}">${esc(basename(n.v.logFile))}</a></div>`;
1243
+ return [
1244
+ {
1245
+ key: "errors",
1246
+ title: "Errors",
1247
+ count: errs.length,
1248
+ empty: !errs.length,
1249
+ body: () =>
1250
+ errs
1251
+ .map((e) => {
1252
+ const kind = ERR_TYPES.find(([k]) => k === e.type)[1];
1253
+ return `<div class="section-title err-kind ${e.type}">${esc(kind)} <span class="muted">${fmtRel(e.t - n.start)}</span></div><pre class="code err-text">${esc(e.text)}</pre>`;
1254
+ })
1255
+ .join(""),
1256
+ },
1257
+ {
1258
+ key: "args",
1259
+ title: "Args",
1260
+ empty: !hasArgs(n) && !params.length,
1261
+ body: () =>
1262
+ (params.length ? `<div class="params">${params.map(paramHtml).join("")}</div>` : "") +
1263
+ (hasArgs(n) ? `<div class="code-box">${argsHtml(n)}</div>` : '<div class="muted">No arguments.</div>'),
1264
+ },
1265
+ { key: "value", title: "Returned", empty: !hasValue(n), body: () => `<div class="code-box">${valueHtml(n)}</div>` },
1266
+ {
1267
+ key: "snapshots",
1268
+ title: "Snapshots & files",
1269
+ count: shots.length,
1270
+ empty: !shots.length,
1271
+ body: () => `<div class="shots">${shots.map((g) => shotHtml(g, n)).join("")}</div>`,
1272
+ },
1273
+ {
1274
+ key: "events",
1275
+ title: "Events",
1276
+ count: evs.length,
1277
+ empty: !evs.length,
1278
+ body: () => `<ul class="ev-list">${evs.map((i) => evLi(i, n)).join("")}</ul>`,
1279
+ },
1280
+ {
1281
+ key: "logs",
1282
+ title: "Logs",
1283
+ count: logs.length,
1284
+ empty: !logs.length,
1285
+ body: () =>
1286
+ `${logLink}<pre class="code">${logs.map((i) => `<span class="muted">${esc(fmtRel(i.t - n.start).padEnd(8))}</span>${esc(i.e.message)}`).join("\n")}</pre>`,
1287
+ },
1288
+ {
1289
+ key: "assertions",
1290
+ title: "Assertions",
1291
+ count: asserts.length,
1292
+ empty: !asserts.length,
1293
+ body: () => `<ul class="ev-list">${asserts.map((i) => evLi(i, n)).join("")}</ul>`,
1294
+ },
1295
+ ];
1296
+ }
1297
+
1298
+ /** A snapshot or file in the details panel: a small thumb with its name and when it landed. */
1299
+ function shotHtml(g, n) {
1300
+ const i = model.gallery.indexOf(g);
1301
+ return `<div class="shot" data-gallery="${i}" title="${esc(g.name)}">
1302
+ ${thumbHtml(g)}
1303
+ <span class="nm">${esc(g.name)}</span>
1304
+ ${g.t != null ? `<span class="t">${fmtRel(g.t - n.start)}</span>` : ""}
1305
+ </div>`;
1306
+ }
1307
+
1308
+ function evLi(i, n) {
1309
+ const mk = MARK[i.kind] || MARK.event;
1310
+ const color = css(mk.color);
1311
+ const text = i.kind === "lifecycle" ? i.e.message : i.label;
1312
+ return `<li data-item="${i.idx}" style="cursor:pointer">
1313
+ <span class="t">${fmtRel(i.t - (n ? n.start : 0))}</span>
1314
+ <span class="ev-ico" style="background:${alpha(color, 0.2)};color:${color}">${svgIcon(mk.icon)}</span>
1315
+ <span class="m" title="${esc(i.e.message)}">${esc(truncate(text, 140))}</span></li>`;
1316
+ }
1317
+
1318
+ function thumbHtml(g) {
1319
+ const i = model.gallery.indexOf(g);
1320
+ if (g.image) return `<div class="thumb" data-gallery="${i}" title="${esc(g.name)}"><img alt="${esc(g.name)}" data-asset="${esc(g.f.asset)}"></div>`;
1321
+ const label = g.type === "snapshot" ? "{ }" : g.f.mime.split("/")[1] || "file";
1322
+ return `<div class="thumb" data-gallery="${i}" title="${esc(g.name)}"><span class="json-ico">${esc(label)}</span></div>`;
1323
+ }
1324
+
1325
+ function wireItems(root) {
1326
+ root.querySelectorAll("img[data-asset]").forEach((img) => {
1327
+ if (!img.src) img.src = model.asset(img.dataset.asset);
1328
+ });
1329
+ root.querySelectorAll("[data-gallery]").forEach((t) =>
1330
+ t.addEventListener("click", () => openGallery(model.gallery[+t.dataset.gallery]))
1331
+ );
1332
+ root.querySelectorAll("[data-item]").forEach((t) =>
1333
+ t.addEventListener("click", () => openItem(model.items[+t.dataset.item]))
1334
+ );
1335
+ }
1336
+
1337
+ function openItem(item) {
1338
+ if (!item) return;
1339
+ setPin(item.t);
1340
+ if (item.node && item.node.id !== state.selected) select(item.node.id, "events");
1341
+ if (item.gallery) openGallery(item.gallery);
1342
+ }
1343
+ function setPin(t) {
1344
+ state.pin = t;
1345
+ redrawX();
1346
+ }
1347
+
1348
+
1349
+ // ── errors: the tab, and highlighting them in the graphs ──
1350
+ const ERR_TYPES = [
1351
+ ["failure", "Failures", "x"],
1352
+ ["assert", "Failed assertions", "alert"],
1353
+ ["output", "Error output", "terminal"],
1354
+ ];
1355
+ /** Failures and failed assertions — what makes a run wrong. Error output alone may be noise. */
1356
+ function seriousCount() {
1357
+ return model.errors.filter((e) => e.type !== "output").length;
1358
+ }
1359
+ function errorsShown() {
1360
+ return model.errors.filter(
1361
+ (e) =>
1362
+ (state.errFilter === "all" || e.type === state.errFilter) &&
1363
+ matches(e.text, e.node && e.node.v.path, e.source)
1364
+ );
1365
+ }
1366
+ function errorsHtml() {
1367
+ if (!model.errors.length) return '<div class="muted">No errors: nothing failed, no assertion failed, and nothing wrote error output.</div>';
1368
+ const count = (t) => model.errors.filter((e) => t === "all" || e.type === t).length;
1369
+ const seg = [["all", "All"], ...ERR_TYPES.map(([k, l]) => [k, l])]
1370
+ .filter(([k]) => k === "all" || count(k))
1371
+ .map(([k, l]) => `<button class="seg ${state.errFilter === k ? "active" : ""}" data-err-filter="${k}">${l} <span class="muted">${count(k)}</span></button>`)
1372
+ .join("");
1373
+ const shown = errorsShown();
1374
+ const sections = ERR_TYPES.map(([type, label, icon]) => {
1375
+ const list = shown.filter((e) => e.type === type);
1376
+ if (!list.length) return "";
1377
+ return `<div class="err-sec"><div class="err-sec-h">${svgIcon(icon)}${label} <span class="muted">${list.length}</span></div>${list.map(errEntryHtml).join("")}</div>`;
1378
+ }).join("");
1379
+ return `<div class="err-bar"><div class="segs">${seg}</div>
1380
+ <button class="btn solo small ${state.errHl ? "active" : ""}" data-err-hl>${svgIcon("locate")}Highlight in graphs</button></div>
1381
+ ${sections || '<div class="muted">Nothing matches.</div>'}`;
1382
+ }
1383
+ function errEntryHtml(e) {
1384
+ const open = state.errOpen.has(e.idx);
1385
+ const who = e.node ? displayName(e.node) : "run";
1386
+ const extra = e.type === "output" && e.lines.length > 1 ? `<span class="err-n">${e.lines.length} lines</span>` : "";
1387
+ const src = e.type === "output" && e.source ? `<span class="err-src">${esc(truncate(e.source, 40))}</span>` : "";
1388
+ return `<div class="err-entry ${e.type}${open ? " open" : ""}" data-err="${e.idx}">
1389
+ <div class="err-row">
1390
+ <span class="twist ${open ? "open" : ""}">${svgIcon("chevron")}</span>
1391
+ <span class="t">${fmtTick(e.t)}</span>
1392
+ <span class="who" title="${esc(e.node ? e.node.v.path : "run")}">${esc(truncate(who, 40))}</span>
1393
+ ${src}
1394
+ <span class="msg" title="${esc(e.title)}">${esc(e.title)}</span>
1395
+ ${extra}
1396
+ <button class="icon-btn" data-err-locate="${e.idx}" aria-label="Show in graph">${svgIcon("locate")}</button>
1397
+ <button class="icon-btn" data-err-copy="${e.idx}" aria-label="Copy">${svgIcon("copy")}</button>
1398
+ </div>
1399
+ ${open ? `<pre class="code err-text">${esc(e.text)}</pre>` : ""}
1400
+ </div>`;
1401
+ }
1402
+ function wireErrors(body) {
1403
+ body.querySelectorAll("[data-err-filter]").forEach((b) =>
1404
+ b.addEventListener("click", () => {
1405
+ state.errFilter = b.dataset.errFilter;
1406
+ refreshErrors();
1407
+ })
1408
+ );
1409
+ const hl = body.querySelector("[data-err-hl]");
1410
+ if (hl) hl.addEventListener("click", () => setErrHl(!state.errHl));
1411
+ body.querySelectorAll(".err-entry").forEach((row) => {
1412
+ const e = model.errors[+row.dataset.err];
1413
+ row.querySelector(".err-row").addEventListener("click", (ev) => {
1414
+ if (ev.target.closest("button")) return;
1415
+ if (state.errOpen.has(e.idx)) state.errOpen.delete(e.idx);
1416
+ else state.errOpen.add(e.idx);
1417
+ refreshErrors();
1418
+ });
1419
+ row.querySelector(".err-row").addEventListener("dblclick", () => locateError(e));
1420
+ // hovering an entry previews just its action in the graphs
1421
+ row.addEventListener("mouseenter", () => e.node && applyHl(new Set([e.node.id])));
1422
+ row.addEventListener("mouseleave", () => applyHl());
1423
+ });
1424
+ body.querySelectorAll("[data-err-locate]").forEach((b) => {
1425
+ b.addEventListener("click", () => locateError(model.errors[+b.dataset.errLocate]));
1426
+ b.addEventListener("mouseenter", (ev) => showTip(ev, "Show in graph"));
1427
+ b.addEventListener("mouseleave", hideTip);
1428
+ });
1429
+ body.querySelectorAll("[data-err-copy]").forEach((b) =>
1430
+ b.addEventListener("click", () => copy(model.errors[+b.dataset.errCopy].text, "Copied the error"))
1431
+ );
1432
+ }
1433
+ function refreshErrors() {
1434
+ if (state.lowerTab !== "errors") return;
1435
+ const body = el.lower.querySelector(".body");
1436
+ const top = body.scrollTop;
1437
+ body.innerHTML = errorsHtml();
1438
+ wireLower();
1439
+ body.scrollTop = top;
1440
+ }
1441
+ function setErrHl(on) {
1442
+ state.errHl = on;
1443
+ refreshErrors();
1444
+ applyHl();
1445
+ }
1446
+ function openErrors(highlight) {
1447
+ state.lowerTab = "errors";
1448
+ renderLower();
1449
+ if (highlight) setErrHl(true);
1450
+ }
1451
+ function locateError(e) {
1452
+ if (!e) return;
1453
+ const items = new Set(e.items);
1454
+ locateAt(e.node, e.t, (m) => items.has(m), "errors");
1455
+ if (e.node) applyHl(new Set([e.node.id]), 1600);
1456
+ }
1457
+ /**
1458
+ * Mark error nodes in every graph: red on the nodes that errored, a lighter mark on their
1459
+ * ancestors (the path to them), everything else dimmed. With no `only`, the highlight
1460
+ * follows the toggle; `only` previews a set (an entry being hovered), and `ms` makes that
1461
+ * preview lapse back to the toggle's state.
1462
+ */
1463
+ let hlTimer = 0;
1464
+ function applyHl(only, ms) {
1465
+ clearTimeout(hlTimer);
1466
+ const set = only || (state.errHl ? model.errNodes : null);
1467
+ const path = only ? pathOf(only) : model.errPath;
1468
+ app.classList.toggle("hl-on", !!set);
1469
+ const mark = (sel, idOf) =>
1470
+ sel.classed("err", (d) => !!set && set.has(idOf(d))).classed("err-path", (d) => !!set && path.has(idOf(d)));
1471
+ if (chartSvg) mark(chartSvg.selectAll("g.r"), (n) => n.id);
1472
+ mark(d3.select(el.flow).selectAll("g.flow-card"), (n) => n.id);
1473
+ el.tree.querySelectorAll(".row").forEach((r) => {
1474
+ r.classList.toggle("err", !!set && set.has(r.dataset.id));
1475
+ r.classList.toggle("err-path", !!set && path.has(r.dataset.id));
1476
+ });
1477
+ if (ms) hlTimer = setTimeout(() => applyHl(), ms);
1478
+ }
1479
+ function pathOf(ids) {
1480
+ const out = new Set();
1481
+ for (const id of ids) for (let p = model.byId.get(id)?.parent; p; p = p.parent) out.add(p.id);
1482
+ return out;
1483
+ }
1484
+
1485
+ // ── lower panel ──
1486
+ function renderLower() {
1487
+ const tabs = [
1488
+ ["snapshots", `Snapshots (${model.gallery.length})`],
1489
+ ["errors", `Errors <span class="tab-count${seriousCount() ? " bad" : model.errors.length ? " warn" : ""}">${model.errors.length}</span>`],
1490
+ ["logs", "Logs"],
1491
+ ["asserts", `Assertions (${model.asserts.length})`],
1492
+ ["events", "Timeline events"],
1493
+ ];
1494
+ el.lower.innerHTML = `<div class="tabs">${tabs
1495
+ .map(([k, l]) => `<button class="tab ${state.lowerTab === k ? "active" : ""}" data-tab="${k}">${l}</button>`)
1496
+ .join("")}<span class="spacer"></span><input class="filter" placeholder="Filter…" value="${esc(state.filter)}"></div>
1497
+ <div class="body">${lowerBodyHtml()}</div>`;
1498
+ el.lower.querySelectorAll(".tab").forEach((b) =>
1499
+ b.addEventListener("click", () => {
1500
+ state.lowerTab = b.dataset.tab;
1501
+ renderLower();
1502
+ })
1503
+ );
1504
+ const input = el.lower.querySelector(".filter");
1505
+ input.addEventListener("input", () => {
1506
+ state.filter = input.value;
1507
+ el.lower.querySelector(".body").innerHTML = lowerBodyHtml();
1508
+ wireLower();
1509
+ });
1510
+ wireLower();
1511
+ }
1512
+ function wireLower() {
1513
+ const body = el.lower.querySelector(".body");
1514
+ if (state.lowerTab === "errors") wireErrors(body);
1515
+ wireItems(body);
1516
+ body.querySelectorAll("tr[data-row]").forEach((tr) =>
1517
+ tr.addEventListener("click", () => openItem(model.items[+tr.dataset.row]))
1518
+ );
1519
+ body.querySelectorAll("[data-locate]").forEach((b) => {
1520
+ b.addEventListener("click", (ev) => {
1521
+ ev.stopPropagation();
1522
+ locate(model.gallery[+b.dataset.locate]);
1523
+ });
1524
+ b.addEventListener("mouseenter", (ev) => showTip(ev, `Show in the ${state.view === "flow" ? "flow" : "graph"}`));
1525
+ b.addEventListener("mouseleave", hideTip);
1526
+ });
1527
+ }
1528
+ function matches(...parts) {
1529
+ const q = state.filter.trim().toLowerCase();
1530
+ return !q || parts.some((p) => String(p || "").toLowerCase().includes(q));
1531
+ }
1532
+ function lowerBodyHtml() {
1533
+ const tab = state.lowerTab;
1534
+ if (tab === "errors") return errorsHtml();
1535
+ if (tab === "snapshots") {
1536
+ const g = model.gallery.filter((x) => matches(x.name, x.node && x.node.v.path));
1537
+ if (!g.length) return '<div class="muted">No snapshots, artifacts, or images in this run.</div>';
1538
+ return `<div class="gallery">${g
1539
+ .map((x) => {
1540
+ const sub = x.type === "snapshot" ? `snapshot · ${x.node ? esc(displayName(x.node)) : ""}` : `${esc(x.f.mime)} · ${fmtSize(x.f.size)}${x.f.omitted ? " · not embedded" : ""}`;
1541
+ const at = x.t != null || x.node ? `<button class="icon-btn locate" data-locate="${model.gallery.indexOf(x)}" aria-label="Show in graph">${svgIcon("locate")}</button>` : "";
1542
+ return `<div class="gal-item">${thumbHtml(x)}<div class="cap"><span class="t">${x.t == null ? "–" : fmtTick(x.t)}</span><span class="n" title="${esc(x.name)}">${esc(x.name)}</span>${at}</div><div class="sz">${sub}</div></div>`;
1543
+ })
1544
+ .join("")}</div>`;
1545
+ }
1546
+ const pick =
1547
+ tab === "logs"
1548
+ ? (i) => i.kind === "info" || i.kind === "error"
1549
+ : tab === "asserts"
1550
+ ? (i) => i.kind === "pass" || i.kind === "fail"
1551
+ : (i) => i.kind !== "info";
1552
+ const rows = [];
1553
+ for (let idx = 0; idx < model.items.length; idx++) {
1554
+ const i = model.items[idx];
1555
+ if (!pick(i)) continue;
1556
+ if (!matches(i.e.message, i.e.path, i.e.source)) continue;
1557
+ rows.push({ i, idx });
1558
+ if (rows.length >= 3000) break;
1559
+ }
1560
+ if (!rows.length) return `<div class="muted">${tab === "asserts" ? "No assertions in this run." : "Nothing matches."}</div>`;
1561
+ return `<table class="list"><tbody>${rows
1562
+ .map(({ i, idx }) => {
1563
+ const mk = MARK[i.kind];
1564
+ const ico = mk
1565
+ ? `<span class="ev-ico" style="background:${alpha(css(mk.color), 0.2)};color:${css(mk.color)}">${svgIcon(mk.icon)}</span>`
1566
+ : "";
1567
+ return `<tr data-row="${idx}" class="${i.kind === "error" || i.kind === "fail" ? "err" : ""}"><td class="t">${fmtTick(i.t)}</td><td>${ico}</td><td class="p" title="${esc(i.e.path)}">${esc(i.node ? displayName(i.node) : i.e.path)}</td><td class="m">${esc(i.e.message)}</td></tr>`;
1568
+ })
1569
+ .join("")}</tbody></table>${rows.length >= 3000 ? '<div class="muted">First 3000 rows — narrow with the filter.</div>' : ""}`;
1570
+ }
1571
+
1572
+ // ── modal ──
1573
+ function openGallery(g) {
1574
+ if (!g) return;
1575
+ if (g.t != null) setPin(g.t);
1576
+ let body;
1577
+ let actions = "";
1578
+ const pathOf = g.type === "file" ? g.f.file : g.s.file;
1579
+ if (g.type === "file" && g.image) {
1580
+ const src = model.asset(g.f.asset);
1581
+ body = `<img alt="${esc(g.name)}" src="${src}">`;
1582
+ actions = `<a class="btn solo" download="${esc(basename(g.f.file))}" href="${src}">${svgIcon("download")}Download</a>`;
1583
+ } else if (g.type === "file") {
1584
+ body = g.f.text != null ? `<pre class="code">${esc(g.f.text)}</pre>` : `<div class="muted">Not embedded (${esc(g.f.omitted || "binary")}). Open it from the run folder.</div>`;
1585
+ } else {
1586
+ body = `<pre class="code">${esc(g.s.value !== undefined ? json(g.s.value) : g.s.text)}</pre>${g.s.truncated ? '<div class="muted">Truncated — the snapshot file holds the rest.</div>' : ""}`;
1587
+ }
1588
+ const sub = [g.t != null ? fmtRel(g.t) : null, g.node ? g.node.v.path : null, g.type === "file" ? fmtSize(g.f.size) : "snapshot"]
1589
+ .filter(Boolean)
1590
+ .join(" · ");
1591
+ const m = document.createElement("div");
1592
+ m.className = "modal";
1593
+ m.innerHTML = `<div class="box"><div class="mh"><span class="nm">${esc(g.name)} <span class="muted" style="font-weight:400">${esc(sub)}</span></span>
1594
+ ${actions}
1595
+ <a class="btn solo" href="${esc(fileUrl(pathOf))}">${svgIcon("file")}Open</a>
1596
+ <button class="btn solo" data-copy>${svgIcon("copy")}Copy path</button>
1597
+ <button class="btn solo" data-close title="Close">${svgIcon("close")}</button></div>
1598
+ <div class="mb">${body}</div></div>`;
1599
+ const close = () => {
1600
+ m.remove();
1601
+ document.removeEventListener("keydown", onKey);
1602
+ };
1603
+ const onKey = (e) => e.key === "Escape" && close();
1604
+ m.addEventListener("click", (e) => e.target === m && close());
1605
+ m.querySelector("[data-close]").addEventListener("click", close);
1606
+ m.querySelector("[data-copy]").addEventListener("click", () => copy(pathOf));
1607
+ document.addEventListener("keydown", onKey);
1608
+ document.body.appendChild(m);
1609
+ if (g.node && g.node.id !== state.selected) select(g.node.id);
1610
+ }
1611
+
1612
+ function openMenu(anchor) {
1613
+ const r = anchor.getBoundingClientRect();
1614
+ const m = document.createElement("div");
1615
+ m.className = "menu";
1616
+ m.style.top = `${r.bottom + 6}px`;
1617
+ m.style.right = `${window.innerWidth - r.right}px`;
1618
+ m.innerHTML = `<button data-a="theme">${svgIcon("sun", ' width="14" height="14"')} Toggle light / dark</button>
1619
+ <button data-a="data">${svgIcon("download", ' width="14" height="14"')} Download run data (JSON)</button>
1620
+ <button data-a="folder">${svgIcon("copy", ' width="14" height="14"')} Copy run folder path</button>
1621
+ <button data-a="expand">Expand all</button>
1622
+ <button data-a="collapse">Collapse all</button>`;
1623
+ const close = () => {
1624
+ m.remove();
1625
+ document.removeEventListener("mousedown", away, true);
1626
+ };
1627
+ const away = (e) => !m.contains(e.target) && close();
1628
+ m.addEventListener("click", (e) => {
1629
+ const a = e.target.closest("button")?.dataset.a;
1630
+ close();
1631
+ if (a === "theme") {
1632
+ toggleTheme();
1633
+ renderAll();
1634
+ renderDetails();
1635
+ renderLower();
1636
+ }
1637
+ if (a === "data") download(`${model.data.name}-run.json`, JSON.stringify(model.data, null, 2), "application/json");
1638
+ if (a === "folder") copy(model.data.folder);
1639
+ if (a === "expand") {
1640
+ state.collapsed.clear();
1641
+ renderAll();
1642
+ }
1643
+ if (a === "collapse") {
1644
+ for (const n of model.nodes) if (n.depth >= 1 && n.children.length) state.collapsed.add(n.id);
1645
+ renderAll();
1646
+ }
1647
+ });
1648
+ document.addEventListener("mousedown", away, true);
1649
+ document.body.appendChild(m);
1650
+ }
1651
+
1652
+ // ── tooltips ──
1653
+ /** Click selects, double-click focuses, hover explains — the same on every node mark. */
1654
+ function bindNode(sel) {
1655
+ sel
1656
+ .on("click", (ev, n) => {
1657
+ ev.stopPropagation();
1658
+ select(n.id);
1659
+ })
1660
+ .on("dblclick", (ev, n) => {
1661
+ ev.stopPropagation();
1662
+ focusNode(n);
1663
+ })
1664
+ .on("mouseenter", (ev, n) => showTip(ev, nodeTip(n), true))
1665
+ .on("mousemove", (ev) => moveTip(ev))
1666
+ .on("mouseleave", hideTip);
1667
+ }
1668
+ function nodeTip(n) {
1669
+ hovered = n; // the node `C` copies while its tooltip is up
1670
+ const v = n.v;
1671
+ const shown = n.depth === 0 ? model.data.name : displayName(n);
1672
+ const call = callOf(n);
1673
+ const meta = [
1674
+ `<span class="dot ${n.status}"></span>${esc(n.status)}`,
1675
+ esc(fmtDur(n.duration)),
1676
+ n.depth === 0 ? "om" : v.kind === "step" ? "step" : "",
1677
+ v.mcp ? `<span class="mcp">MCP · ${esc(v.mcp.mode)}</span>` : "",
1678
+ ].filter(Boolean);
1679
+ const fails = model.run.failures.filter((f) => f.action === n.id || f.action === n.name || f.action === v.path);
1680
+ return `<div class="tt">${esc(shown)}</div>
1681
+ ${call ? `<div class="tcall">${callHtml(n, call)}</div>` : ""}
1682
+ <div class="tmeta">${meta.join('<span class="sep">·</span>')}</div>
1683
+ ${v.description ? `<div class="summary">${esc(truncate(v.description.summary, 240))}</div>` : ""}
1684
+ ${chipsHtml(v.tags, TIP_TAGS, 28)}
1685
+ ${fails.length ? `<div class="tfail">${esc(truncate(String(fails[0].error).split(String.fromCharCode(10))[0], 160))}</div>` : ""}`;
1686
+ }
1687
+ function galleryTip(x) {
1688
+ const img = x.image ? `<img src="${model.asset(x.f.asset)}" alt="">` : "";
1689
+ const body = x.type === "snapshot"
1690
+ ? `<div class="mono small">${esc(truncate(x.s.value !== undefined ? oneLine(x.s.value) : x.s.text || "", 200))}</div>`
1691
+ : `<div class="ts">${esc(x.f.mime)} · ${fmtSize(x.f.size)}</div>`;
1692
+ return `<div class="tt">${esc(x.name)}${x.t != null ? ` · ${fmtRel(x.t)}` : ""}</div>${img}${body}`;
1693
+ }
1694
+ /** The om itself: its node tooltip, plus where the run's record lives. */
1695
+ function omTip() {
1696
+ return `${nodeTip(model.root)}<div class="sec">Run folder</div><div class="mono small">${esc(model.data.folder)}</div>`;
1697
+ }
1698
+ function markTip(d) {
1699
+ const m = d.m;
1700
+ const mk = MARK[m.kind] || MARK.event;
1701
+ const img = m.gallery && m.gallery.image ? `<img src="${model.asset(m.gallery.f.asset)}" alt="">` : "";
1702
+ const more = d.more.length ? `<div class="ts">+${d.more.length} more within a few ms</div>` : "";
1703
+ const extra = m.gallery && m.gallery.image ? `<div class="ts">${esc(m.gallery.f.rel)} · ${fmtSize(m.gallery.f.size)}</div>` : "";
1704
+ return `<div class="tt">${esc(mk.label)} · ${fmtRel(m.t)}</div>${img}<div>${esc(truncate(m.kind === "snapshot" || m.kind === "artifact" ? m.label : m.e.message, 260))}</div>${extra}${more}`;
1705
+ }
1706
+ // ── source popover (the <> buttons) — click-toggled, like the tskb explorer's ──
1707
+ const codeEl = document.createElement("div");
1708
+ codeEl.className = "code-pop";
1709
+ codeEl.hidden = true;
1710
+ document.body.appendChild(codeEl);
1711
+ let codeFor = null;
1712
+ let codeTab = null;
1713
+ let codeAway = null;
1714
+ /** A site inside omkit or an installed package — library source, not the user's. */
1715
+ function isLib(site) {
1716
+ return !!(site && model.data.library && model.data.library.includes(site));
1717
+ }
1718
+ /**
1719
+ * The site worth showing for `n`: where it is defined, unless that is library source (a
1720
+ * battery, a prompt), in which case the user's call that launched it.
1721
+ */
1722
+ function userSite(n) {
1723
+ const { definedAt, calledAt } = n.v;
1724
+ if (definedAt && !isLib(definedAt)) return definedAt;
1725
+ if (calledAt && !isLib(calledAt)) return calledAt;
1726
+ return definedAt || calledAt;
1727
+ }
1728
+ function siteLabel(n) {
1729
+ return userSite(n) === n.v.definedAt ? "Defined at" : "Called at";
1730
+ }
1731
+ function codeOf(site) {
1732
+ return site && model.data.code ? model.data.code[site] : undefined;
1733
+ }
1734
+ /**
1735
+ * How the user's code launched `n`, read off the source at its call site: the variable it
1736
+ * was bound to and the callee — `page = chromePage(…)`. Sites carry a line but no column,
1737
+ * so this takes the first call on that line; null when the site is library source.
1738
+ */
1739
+ function callExpr(n) {
1740
+ const site = n.v.calledAt;
1741
+ const c = site && !isLib(site) && codeOf(site);
1742
+ if (!c) return null;
1743
+ const at = Number((/:(\d+)$/.exec(site) || [])[1]);
1744
+ const lines = c.text.split("\n");
1745
+ const line = lines[at - c.start];
1746
+ if (line == null) return null;
1747
+ let call = null;
1748
+ for (const m of line.matchAll(/(?:^|[^\w$.])([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)\s*\(/g)) {
1749
+ if (!/^(if|for|while|switch|return|catch|function|await|typeof|new|async)$/.test(m[1])) {
1750
+ call = { callee: m[1], index: m.index };
1751
+ break;
1752
+ }
1753
+ }
1754
+ if (!call) return null;
1755
+ const DECL = /\b(?:const|let|var)\s+([\w$]+|\{[^}]*\}|\[[^\]]*\])/;
1756
+ const prefix = line.slice(0, call.index);
1757
+ const prev = lines[at - c.start - 1] || "";
1758
+ const m =
1759
+ DECL.exec(prefix) ||
1760
+ /^\s*([\w$.]+)\s*=(?!=)/.exec(prefix) ||
1761
+ (/^\s*(?:await\s+)?$/.test(prefix) && /=\s*(?:await\s*)?$/.test(prev) ? DECL.exec(prev) : null);
1762
+ return { callee: call.callee, bind: m ? m[1].replace(/\s+/g, " ") : "" };
1763
+ }
1764
+ /** The popover's tabs for `n`: what it is (prompt, source), then what went in and came out. */
1765
+ function codeTabs(n) {
1766
+ const tabs = [];
1767
+ if (promptOf(n)) tabs.push({ key: "prompt", label: "Prompt" });
1768
+ const mine = userSite(n);
1769
+ if (codeOf(mine)) tabs.push({ key: "user", label: siteLabel(n) === "Called at" ? "Call site" : "Source", site: mine });
1770
+ const def = n.v.definedAt;
1771
+ if (def && def !== mine && codeOf(def)) tabs.push({ key: "def", label: isLib(def) ? "Definition (omkit)" : "Definition", site: def });
1772
+ if (hasArgs(n)) tabs.push({ key: "args", label: "Args" });
1773
+ if (hasValue(n)) tabs.push({ key: "value", label: "Returned" });
1774
+ return tabs;
1775
+ }
1776
+ function hasCode(n) {
1777
+ return codeTabs(n).some((t) => t.key !== "value" && t.key !== "args");
1778
+ }
1779
+ const callMemo = new Map();
1780
+ /** {@link callExpr}, once per node — bar labels ask on every redraw. */
1781
+ function callOf(n) {
1782
+ if (!callMemo.has(n.id)) callMemo.set(n.id, n.depth === 0 ? null : callExpr(n));
1783
+ return callMemo.get(n.id);
1784
+ }
1785
+ /**
1786
+ * An action box's label: the call that launched it, as the tooltip shows it —
1787
+ * `watchBuildDirectory = watchDir(…)` — then omkit's name when the callee does not already
1788
+ * say it (`command(…) · npm run dev`). Just the name when the call is unknown.
1789
+ */
1790
+ function boxLabel(n) {
1791
+ const c = callOf(n);
1792
+ if (!c) return displayName(n);
1793
+ const call = `${c.bind ? `${c.bind} = ` : ""}${c.callee}(${hasArgs(n) ? "…" : ""})`;
1794
+ return c.callee === displayName(n) ? call : `${call} · ${displayName(n)}`;
1795
+ }
1796
+ function labelHtml(n) {
1797
+ const c = callOf(n);
1798
+ if (!c) return esc(displayName(n));
1799
+ const name = c.callee === displayName(n) ? "" : `<span class="sep"> · </span>${esc(displayName(n))}`;
1800
+ return `<span class="tcall">${callHtml(n, c)}</span>${name}`;
1801
+ }
1802
+ /** `page = chromePage(…)`, from {@link callExpr}. */
1803
+ function callHtml(n, call) {
1804
+ return `${call.bind ? `<span class="b">${esc(call.bind)}</span> = ` : ""}<span class="f">${esc(call.callee)}</span>(${hasArgs(n) ? "…" : ""})`;
1805
+ }
1806
+ function hasPopup(n) {
1807
+ return codeTabs(n).length > 0;
1808
+ }
1809
+ function popupTip(n) {
1810
+ return codeTabs(n).map((t) => esc(t.label)).join(" · ");
1811
+ }
1812
+ function hasArgs(n) {
1813
+ return !!(n.v.args && n.v.args.length);
1814
+ }
1815
+ function argsOf(n) {
1816
+ return n.v.args.length === 1 ? n.v.args[0] : n.v.args;
1817
+ }
1818
+ function relOf(site) {
1819
+ return (model.data.sources && model.data.sources[site]) || String(site).replace(/:\d+(:\d+)?$/, "");
1820
+ }
1821
+ function hasValue(n) {
1822
+ return n.v.value !== undefined || !!n.v.valueNote;
1823
+ }
1824
+ /** A value in the code view: pretty JSON, highlighted, numbered, copyable. */
1825
+ function jsonHtml(value, what) {
1826
+ const text = json(value);
1827
+ const lines = highlight(text);
1828
+ const kind = Array.isArray(value) ? `array · ${value.length} items` : value === null ? "null" : typeof value === "object" ? `object · ${Object.keys(value).length} keys` : typeof value;
1829
+ return `<div class="cp-tools"><span class="muted">${esc(kind)} · ${lines.length} line${lines.length > 1 ? "s" : ""} · ${fmtSize(text.length)}</span>
1830
+ <button class="btn solo small" data-a="copy-json" data-what="${what}">${svgIcon("copy")}Copy JSON</button></div>
1831
+ <div class="cp-code">${lines
1832
+ .map((html, i) => `<div class="cl"><span class="ln">${i + 1}</span><span class="lc">${html || " "}</span></div>`)
1833
+ .join("")}</div>`;
1834
+ }
1835
+ function valueHtml(n) {
1836
+ if (n.v.value === undefined) {
1837
+ return `<div class="pp"><div class="muted">${esc(n.v.valueNote || "Returned nothing.")}</div></div>`;
1838
+ }
1839
+ return jsonHtml(n.v.value, "value");
1840
+ }
1841
+ function argsHtml(n) {
1842
+ return hasArgs(n) ? jsonHtml(argsOf(n), "args") : '<div class="pp"><div class="muted">No arguments.</div></div>';
1843
+ }
1844
+ function wireValue(root, n) {
1845
+ root.querySelectorAll('[data-a="copy-json"]').forEach((b) =>
1846
+ b.addEventListener("click", () =>
1847
+ b.dataset.what === "args" ? copy(json(argsOf(n)), "Copied the args") : copy(json(n.v.value), "Copied the returned value")
1848
+ )
1849
+ );
1850
+ }
1851
+ function codeHtml(site) {
1852
+ const c = codeOf(site);
1853
+ const line = Number((/:(\d+)(?::\d+)?$/.exec(site) || [])[1]);
1854
+ const lines = highlight(c.text).map(
1855
+ (html, i) => `<div class="cl${c.start + i === line ? " at" : ""}"><span class="ln">${c.start + i}</span><span class="lc">${html || " "}</span></div>`
1856
+ );
1857
+ return `<div class="cp-code">${lines.join("")}</div>${c.truncated ? '<div class="muted cp-more">Cut short — open the file for the rest.</div>' : ""}`;
1858
+ }
1859
+ function promptHtml(p) {
1860
+ const choices = p.choices && p.choices.length
1861
+ ? `<div class="pp-k">Choices</div><div class="pp-v"><ol class="pp-choices">${p.choices
1862
+ .map((c) => {
1863
+ const value = typeof c === "string" ? c : c.value;
1864
+ const label = typeof c === "string" ? c : c.label;
1865
+ const picked = p.answer && p.answer.value === value;
1866
+ return `<li class="${picked ? "picked" : ""}">${esc(label)}${label !== value ? ` <span class="muted">(${esc(value)})</span>` : ""}</li>`;
1867
+ })
1868
+ .join("")}</ol></div>`
1869
+ : "";
1870
+ const answer = p.answer
1871
+ ? `<div class="pp-answer"><span class="pp-k">Answer</span><pre class="code">${esc(typeof p.answer.value === "string" ? p.answer.value : json(p.answer.value))}</pre>${
1872
+ p.answer.via ? `<span class="badge-inline">via ${esc(p.answer.via)}</span>` : ""
1873
+ }</div>`
1874
+ : '<div class="muted">No answer recorded.</div>';
1875
+ return `<div class="pp">
1876
+ <div class="pp-q">${esc(p.message || "(no message)")}</div>
1877
+ ${p.hint ? `<div class="mono small">${esc(p.hint)}</div>` : ""}
1878
+ <div class="pp-grid">
1879
+ <div class="pp-k">Kind</div><div class="pp-v">${esc(p.kind)}</div>
1880
+ ${p.def !== undefined ? `<div class="pp-k">Default</div><div class="pp-v mono">${esc(oneLine(p.def))}</div>` : ""}
1881
+ ${p.timeoutMs !== undefined ? `<div class="pp-k">Timeout</div><div class="pp-v">${p.timeoutMs > 0 && isFinite(p.timeoutMs) ? fmtDur(p.timeoutMs) : "none"}</div>` : ""}
1882
+ ${choices}
1883
+ </div>
1884
+ ${answer}
1885
+ </div>`;
1886
+ }
1887
+ function toggleCode(n, ev, want, toggle) {
1888
+ // the <> closes whatever is open on its node; the return button closes only its own tab
1889
+ if (codeFor === n.id && (!want || (toggle && codeTab === want))) return closeCode();
1890
+ const tabs = codeTabs(n);
1891
+ if (!tabs.length) return;
1892
+ const tab = tabs.find((t) => t.key === want) || tabs.find((t) => t.key !== "value" && t.key !== "args") || tabs[0];
1893
+ codeTab = tab.key;
1894
+ hideTip();
1895
+ const reopen = codeFor !== n.id;
1896
+ codeFor = n.id;
1897
+ const site = tab.site || userSite(n);
1898
+ const rel = site ? relOf(site) : "";
1899
+ const line = site ? (/:(\d+)(?::\d+)?$/.exec(site) || [])[1] : "";
1900
+ codeEl.innerHTML = `<div class="cp-head">
1901
+ ${site ? `<span class="cp-path" title="${esc(site)}">${esc(rel)}<span class="muted">${line ? ":" + line : ""}</span></span>
1902
+ <button class="icon-btn" data-a="copy" aria-label="Copy path">${svgIcon("copy")}</button>
1903
+ <a class="icon-btn" href="${esc(editorUrl(site))}" aria-label="Open in VS Code">${svgIcon("external")}</a>` : ""}
1904
+ <span class="cp-name">${esc(displayName(n))}</span>
1905
+ <button class="icon-btn" data-a="close" aria-label="Close">${svgIcon("close")}</button>
1906
+ </div>
1907
+ ${tabs.length > 1 ? `<div class="cp-tabs">${tabs.map((t) => `<button class="tab ${t === tab ? "active" : ""}" data-tab="${t.key}">${esc(t.label)}</button>`).join("")}</div>` : ""}
1908
+ <div class="cp-body">${
1909
+ tab.key === "prompt" ? promptHtml(promptOf(n)) : tab.key === "value" ? valueHtml(n) : tab.key === "args" ? argsHtml(n) : codeHtml(site)
1910
+ }</div>`;
1911
+ wireValue(codeEl, n);
1912
+ const cp = codeEl.querySelector('[data-a="copy"]');
1913
+ if (cp) cp.addEventListener("click", () => copy(rel, `Copied ${rel}`));
1914
+ codeEl.querySelector('[data-a="close"]').addEventListener("click", closeCode);
1915
+ codeEl.querySelectorAll(".cp-tabs .tab").forEach((b) => b.addEventListener("click", () => toggleCode(n, null, b.dataset.tab)));
1916
+ codeEl.hidden = false;
1917
+ if (reopen) {
1918
+ const x = ev && ev.clientX != null ? ev.clientX : window.innerWidth / 2;
1919
+ const y = ev && ev.clientY != null ? ev.clientY : window.innerHeight / 3;
1920
+ const w = codeEl.offsetWidth;
1921
+ const h = Math.min(codeEl.offsetHeight, window.innerHeight * 0.7);
1922
+ const left = x + 14 + w > window.innerWidth - 8 ? x - w - 14 : x + 14;
1923
+ const top = y + 14 + h > window.innerHeight - 8 ? y - h - 14 : y + 14;
1924
+ codeEl.style.left = `${Math.max(8, left)}px`;
1925
+ codeEl.style.top = `${Math.max(8, top)}px`;
1926
+ }
1927
+ const at = codeEl.querySelector(".cl.at");
1928
+ if (at) at.scrollIntoView({ block: "center" });
1929
+ if (codeAway) document.removeEventListener("mousedown", codeAway, true);
1930
+ codeAway = (e) => {
1931
+ if (!codeEl.contains(e.target) && !e.target.closest(".code-btn, [data-show-code]")) closeCode();
1932
+ };
1933
+ setTimeout(() => codeAway && document.addEventListener("mousedown", codeAway, true), 0);
1934
+ }
1935
+ function closeCode() {
1936
+ codeFor = null;
1937
+ codeEl.hidden = true;
1938
+ if (codeAway) document.removeEventListener("mousedown", codeAway, true);
1939
+ codeAway = null;
1940
+ }
1941
+ let tipTimer = 0;
1942
+ let hovered = null;
1943
+ /** The file worth opening for `n` (see {@link userSite}), relative to the project root — no line. */
1944
+ function sourceOf(n) {
1945
+ const site = userSite(n);
1946
+ return site ? relOf(site) : "";
1947
+ }
1948
+ function copySource(n) {
1949
+ const src = n && sourceOf(n);
1950
+ if (src) copy(src, `Copied ${src}`);
1951
+ }
1952
+ let lastEv = null;
1953
+ function showTip(ev, html, wide, delay) {
1954
+ clearTimeout(tipTimer);
1955
+ lastEv = ev;
1956
+ const open = () => {
1957
+ tip.innerHTML = html;
1958
+ tip.classList.toggle("wide", !!wide);
1959
+ tip.hidden = false;
1960
+ moveTip(lastEv);
1961
+ };
1962
+ if (delay && tip.hidden) tipTimer = setTimeout(open, delay);
1963
+ else open();
1964
+ }
1965
+ function moveTip(ev) {
1966
+ lastEv = ev;
1967
+ if (tip.hidden) return;
1968
+ const pad = 14;
1969
+ const r = tip.getBoundingClientRect();
1970
+ let x = ev.clientX + pad;
1971
+ let y = ev.clientY + pad;
1972
+ if (x + r.width > window.innerWidth - 8) x = ev.clientX - r.width - pad;
1973
+ if (y + r.height > window.innerHeight - 8) y = ev.clientY - r.height - pad;
1974
+ tip.style.left = `${x}px`;
1975
+ tip.style.top = `${y}px`;
1976
+ }
1977
+ function hideTip() {
1978
+ hovered = null;
1979
+ clearTimeout(tipTimer);
1980
+ tip.hidden = true;
1981
+ }
1982
+
1983
+ // ── render orchestration ──
1984
+ function renderAll() {
1985
+ measure();
1986
+ if (state.view === "timeline") renderTimeline();
1987
+ if (state.view === "flow") renderFlow();
1988
+ renderOverview();
1989
+ }
1990
+ function relayout() {
1991
+ const keep = state.zoom;
1992
+ measure();
1993
+ state.zoom = keep;
1994
+ renderAll();
1995
+ }
1996
+ let resizeTimer = 0;
1997
+ window.addEventListener("resize", () => {
1998
+ clearTimeout(resizeTimer);
1999
+ resizeTimer = setTimeout(relayout, 120);
2000
+ });
2001
+ document.addEventListener("keydown", (e) => {
2002
+ if (e.key === "Escape" && codeFor) return closeCode();
2003
+ if (e.target.closest && e.target.closest("input")) return;
2004
+ if (e.key === "+" || e.key === "=") zoomBy(1.6);
2005
+ else if (e.key === "-") zoomBy(1 / 1.6);
2006
+ else if (e.key === "0") fit();
2007
+ else if ((e.key === "c" || e.key === "C") && !e.ctrlKey && !e.metaKey && !e.altKey) {
2008
+ copySource(hovered || model.byId.get(state.selected));
2009
+ }
2010
+ else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
2011
+ const rows = visible();
2012
+ const i = rows.findIndex((n) => n.id === state.selected);
2013
+ const next = rows[Math.max(0, Math.min(rows.length - 1, i + (e.key === "ArrowDown" ? 1 : -1)))];
2014
+ if (next) {
2015
+ e.preventDefault();
2016
+ select(next.id);
2017
+ revealRow(next);
2018
+ }
2019
+ }
2020
+ });
2021
+
2022
+ setView("timeline");
2023
+ renderDetails();
2024
+ renderLower();
2025
+ }
2026
+
2027
+ // ── shell markup ─────────────────────────────────────────
2028
+ function shellHtml(model) {
2029
+ const run = model.run;
2030
+ const ok = run.ok;
2031
+ const a = run.assertions;
2032
+ const total = a.passed + a.failed;
2033
+ const assertText = total ? `${a.passed} / ${total} assertions passed` : "no assertions";
2034
+ const serious = model.errors.filter((e) => e.type !== "output").length;
2035
+ const output = model.errors.length - serious;
2036
+ const parts = [
2037
+ serious ? `${serious} error${serious > 1 ? "s" : ""}` : "",
2038
+ output ? `${output} error output` : "",
2039
+ ].filter(Boolean);
2040
+ const fails = parts.length
2041
+ ? `<span class="sep">•</span><button class="linkish ${serious ? "bad" : "warn"}" id="go-errors" title="Open the Errors tab and highlight them in the graphs">${parts.join(" · ")}</button>`
2042
+ : "";
2043
+ const windows = [0, 100, 500, 1000, 5000, 10000, 30000, 60000, 300000].filter((w) => w === 0 || w < model.span);
2044
+ return `
2045
+ <header class="top">
2046
+ <div class="titles">
2047
+ <div class="title-row">
2048
+ <h1 title="${esc(model.data.folder)}">Run: ${esc(model.data.name)}</h1>
2049
+ <button class="info-btn" id="om-info" aria-label="About this om">${svgIcon("info")}</button>
2050
+ </div>
2051
+ <div class="meta"><span class="dot ${ok ? "ok" : "failed"}"></span><span>${ok ? "passed" : "failed"}</span>
2052
+ <span class="sep">•</span><span>${fmtDate(run.startedAt)}</span>
2053
+ <span class="sep">•</span><span title="body ${fmtDur(run.duration)} · record ${fmtDur(model.span)}">${fmtDur(run.duration)}</span>
2054
+ <span class="sep">•</span><span>${model.nodes.length} action${model.nodes.length > 1 ? "s" : ""}</span>
2055
+ <span class="sep">•</span><span>${assertText}</span>${fails}
2056
+ </div>
2057
+ </div>
2058
+ <div class="btn-group">
2059
+ <button class="btn active" data-view="timeline">${svgIcon("timeline")}Timeline + Graph</button>
2060
+ <button class="btn" data-view="flow">${svgIcon("flow")}Flow</button>
2061
+ </div>
2062
+ <div class="btn-group">
2063
+ <button class="btn" id="zout" title="Zoom out (-)">${svgIcon("zoomOut")}</button>
2064
+ <button class="btn" id="zin" title="Zoom in (+)">${svgIcon("zoomIn")}</button>
2065
+ <button class="btn" id="fit" title="Fit (0)">Fit</button>
2066
+ </div>
2067
+ <select id="window" class="btn solo" title="Visible window">
2068
+ ${windows.map((w) => `<option value="${w}">${w ? fmtTick(w) : "window"}</option>`).join("")}
2069
+ </select>
2070
+ <button class="btn solo" id="more" title="More">${svgIcon("more")}</button>
2071
+ </header>
2072
+ <div class="main">
2073
+ <section class="card timeline" style="--tree-w:360px;--row-h:${ROW_H}px;position:relative">
2074
+ <div class="tl-head">
2075
+ <div class="cols"><div>Action / Event</div></div>
2076
+ <svg></svg>
2077
+ </div>
2078
+ <div class="tl-body"><div class="tree"></div><div class="chart"></div></div>
2079
+ <div class="flow-view" hidden></div>
2080
+ <button class="info-btn legend-btn" id="legend-btn" aria-label="Legend" aria-expanded="false" title="Legend">${svgIcon("info")}</button>
2081
+ <div class="legend legend-pop" id="legend" hidden>
2082
+ <h3>Legend</h3>
2083
+ <div class="grid2">
2084
+ ${legendItem("--c1", "Action run", "bar")}
2085
+ ${legendItem("--c2", "Snapshot", "dot")}
2086
+ ${legendItem("--c3", "Process event", "dot")}
2087
+ ${legendItem("--c6", "Artifact", "dot")}
2088
+ ${legendItem("--ok", "Assert passed", "dot")}
2089
+ ${legendItem("--fail", "Failure / error", "dot")}
2090
+ ${legendItem("--cancel", "Cancelled", "dash")}
2091
+ ${legendItem("--accent", "Pinned time", "line")}
2092
+ </div>
2093
+ <div class="muted" style="margin-top:12px;font-size:11px">Ctrl + wheel to zoom · drag to pan · double-click a bar to focus · ↑/↓ to step</div>
2094
+ </div>
2095
+ </section>
2096
+ <aside class="card details"></aside>
2097
+ </div>
2098
+ <div class="bottom">
2099
+ <section class="card lower"></section>
2100
+ <section class="card overview"><h3>Overview</h3><div class="range"></div><svg></svg></section>
2101
+ </div>`;
2102
+ }
2103
+ function legendItem(color, label, shape) {
2104
+ const c = `var(${color})`;
2105
+ const sw =
2106
+ shape === "bar"
2107
+ ? `<span style="width:16px;height:9px;border-radius:3px;background:${c}"></span>`
2108
+ : shape === "dash"
2109
+ ? `<span style="width:16px;height:0;border-top:2px dashed ${c}"></span>`
2110
+ : shape === "line"
2111
+ ? `<span style="width:2px;height:14px;background:${c};margin:0 7px"></span>`
2112
+ : `<span class="dot" style="background:${c};width:11px;height:11px"></span>`;
2113
+ return `<div class="li">${sw}${esc(label)}</div>`;
2114
+ }
2115
+
2116
+ // ── helpers ──────────────────────────────────────────────
2117
+ function colorOf(n) {
2118
+ return n.branch < 0 ? css("--c1") : css(PALETTE[n.branch]);
2119
+ }
2120
+ function statusVar(s) {
2121
+ return s === "ok" ? "--ok" : s === "failed" ? "--fail" : s === "cancelled" ? "--cancel" : "--running";
2122
+ }
2123
+ function alpha(color, a) {
2124
+ const c = d3.color(color);
2125
+ if (!c) return color;
2126
+ c.opacity = a;
2127
+ return c.formatRgb();
2128
+ }
2129
+ const KEYWORDS = new Set(
2130
+ "abstract as async await break case catch class const continue default delete do else enum export extends false finally for from function get if implements import in instanceof interface keyof let new null of private protected public readonly return satisfies set static super switch this throw true try type typeof undefined var void while yield".split(" ")
2131
+ );
2132
+ const TOKEN =
2133
+ /(\/\/[^\n]*|\/\*[\s\S]*?\*\/)|(`(?:\\[\s\S]|[^`\\])*`|"(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*')|(\b\d[\d_]*(?:\.\d+)?(?:[eE][+-]?\d+)?n?\b)|([A-Za-z_$][\w$]*)/g;
2134
+ /** Highlighted HTML per source line. Tokens that span lines are closed and reopened at each break. */
2135
+ function highlight(text) {
2136
+ const parts = [];
2137
+ let last = 0;
2138
+ let m;
2139
+ TOKEN.lastIndex = 0;
2140
+ while ((m = TOKEN.exec(text))) {
2141
+ if (m.index > last) parts.push([null, text.slice(last, m.index)]);
2142
+ let cls = null;
2143
+ if (m[1]) cls = "tk-c";
2144
+ else if (m[2]) cls = /^\s*:/.test(text.slice(TOKEN.lastIndex, TOKEN.lastIndex + 8)) ? "tk-p" : "tk-s";
2145
+ else if (m[3]) cls = "tk-n";
2146
+ else if (m[4]) {
2147
+ if (KEYWORDS.has(m[4])) cls = "tk-k";
2148
+ else if (/^\s*\(/.test(text.slice(TOKEN.lastIndex, TOKEN.lastIndex + 40))) cls = "tk-f";
2149
+ else if (/^[A-Z]/.test(m[4])) cls = "tk-t";
2150
+ }
2151
+ parts.push([cls, m[0]]);
2152
+ last = TOKEN.lastIndex;
2153
+ }
2154
+ if (last < text.length) parts.push([null, text.slice(last)]);
2155
+ const lines = [""];
2156
+ for (const [cls, chunk] of parts) {
2157
+ chunk.split("\n").forEach((piece, i) => {
2158
+ if (i > 0) lines.push("");
2159
+ if (piece) lines[lines.length - 1] += cls ? `<span class="${cls}">${esc(piece)}</span>` : esc(piece);
2160
+ });
2161
+ }
2162
+ return lines;
2163
+ }
2164
+ function oneLine(v) {
2165
+ try {
2166
+ return typeof v === "string" ? v : JSON.stringify(v);
2167
+ } catch {
2168
+ return String(v);
2169
+ }
2170
+ }
2171
+ /** Top-level fields of a JSON Schema object: name, type, required, default, description. */
2172
+ function schemaFields(schema) {
2173
+ if (!schema || typeof schema !== "object" || !schema.properties) return [];
2174
+ const req = new Set(schema.required || []);
2175
+ return Object.entries(schema.properties).map(([name, p]) => ({
2176
+ name,
2177
+ type: typeOf(p),
2178
+ required: req.has(name) && !(p && "default" in p),
2179
+ def: p && "default" in p ? oneLine(p.default) : undefined,
2180
+ desc: p && p.description,
2181
+ }));
2182
+ }
2183
+ function typeOf(p) {
2184
+ if (!p || typeof p !== "object") return "any";
2185
+ if (p.enum) return p.enum.map((e) => JSON.stringify(e)).join(" | ");
2186
+ if (p.const !== undefined) return JSON.stringify(p.const);
2187
+ if (Array.isArray(p.type)) return p.type.join(" | ");
2188
+ if (p.type === "array") return `${typeOf(p.items)}[]`;
2189
+ if (p.type) return p.type;
2190
+ if (p.anyOf || p.oneOf) return (p.anyOf || p.oneOf).map(typeOf).join(" | ");
2191
+ return "any";
2192
+ }
2193
+ function paramHtml(f) {
2194
+ return `<div class="param"><span class="pn">${esc(f.name)}${f.required ? "" : "?"}</span><span class="pt">${esc(truncate(f.type, 40))}</span>${
2195
+ f.def !== undefined ? `<span class="pd">= ${esc(truncate(f.def, 24))}</span>` : ""
2196
+ }${f.desc ? `<div class="pdesc">${esc(f.desc)}</div>` : ""}</div>`;
2197
+ }
2198
+ /**
2199
+ * What a `prompt` battery node asked and got back: its options (the node's one argument) plus
2200
+ * the `prompt` / `answer` events it emitted. `null` for anything that is not a prompt.
2201
+ */
2202
+ function promptOf(n) {
2203
+ if (n._prompt !== undefined) return n._prompt;
2204
+ n._prompt = null;
2205
+ if (n.name !== "prompt" || n.depth === 0) return null;
2206
+ const opts = (n.v.args && n.v.args[0] && typeof n.v.args[0] === "object" ? n.v.args[0] : {}) || {};
2207
+ let message = opts.message;
2208
+ let answer = null;
2209
+ for (const it of n.entries) {
2210
+ if (it.e.level !== "event") continue;
2211
+ if (it.e.message.startsWith("prompt · ")) message = it.e.message.slice("prompt · ".length);
2212
+ else if (it.e.message.startsWith("answer · ")) {
2213
+ try {
2214
+ answer = JSON.parse(it.e.message.slice("answer · ".length));
2215
+ } catch {
2216
+ answer = { value: it.e.message.slice("answer · ".length) };
2217
+ }
2218
+ }
2219
+ }
2220
+ if (message === undefined && !answer) return null;
2221
+ n._prompt = {
2222
+ message,
2223
+ kind: opts.kind || "input",
2224
+ hint: opts.hint,
2225
+ choices: opts.choices,
2226
+ def: opts.default,
2227
+ timeoutMs: opts.timeoutMs,
2228
+ answer,
2229
+ };
2230
+ return n._prompt;
2231
+ }
2232
+ /** What a node is, for a card's subtitle: the om, an inline step, a prompt, or an action. */
2233
+ function nodeKind(n) {
2234
+ if (n.depth === 0) return "om";
2235
+ if (promptOf(n)) return "prompt";
2236
+ return n.v.kind === "step" ? "step" : "action";
2237
+ }
2238
+ /** A name broken into at most two lines of `max` characters, at word boundaries when it can. */
2239
+ function wrapName(name, max) {
2240
+ if (name.length <= max) return [name];
2241
+ let cut = name.lastIndexOf(" ", max);
2242
+ if (cut < max * 0.5) cut = max;
2243
+ const first = name.slice(0, cut).trimEnd();
2244
+ return [first, truncate(name.slice(cut).trimStart(), max)];
2245
+ }
2246
+ function truncate(s, n) {
2247
+ n = Math.max(1, Math.floor(n));
2248
+ return s.length > n ? `${s.slice(0, Math.max(1, n - 1))}…` : s;
2249
+ }
2250
+ /** A tag's colour: hashed from its text, so a tag reads the same in every view. */
2251
+ function tagColor(t) {
2252
+ let h = 0;
2253
+ for (let i = 0; i < t.length; i++) h = (h * 31 + t.charCodeAt(i)) | 0;
2254
+ return css(PALETTE[Math.abs(h) % PALETTE.length]);
2255
+ }
2256
+ /** One tag as a chip — the tree, tooltips and details share it; the flow draws the same in SVG. */
2257
+ function chipHtml(t, max) {
2258
+ const c = tagColor(t);
2259
+ return `<span class="chip" style="background:${alpha(c, 0.14)};border-color:${alpha(c, 0.45)}" title="${esc(t)}">${esc(truncate(t, max))}</span>`;
2260
+ }
2261
+ /** Up to `limit` tag chips, then a "+N" chip holding the rest. */
2262
+ function chipsHtml(tags, limit, max) {
2263
+ if (!tags || !tags.length) return "";
2264
+ const more = tags.length > limit ? `<span class="chip more" title="${esc(tags.slice(limit).join(", "))}">+${tags.length - limit}</span>` : "";
2265
+ return `<span class="chips">${tags.slice(0, limit).map((t) => chipHtml(t, max)).join("")}${more}</span>`;
2266
+ }
2267
+ function displayName(n) {
2268
+ if (n.depth === 0) return "main";
2269
+ const p = promptOf(n);
2270
+ if (p && p.message) return `prompt: ${truncate(p.message, 48)}`;
2271
+ const tag = n.v.tags && n.v.tags[0];
2272
+ // Battery steps are named after their kind (`command`, `chromePage`); their first tag
2273
+ // is what the om called them, which is the more useful label.
2274
+ return tag && tag !== n.name && n.name.length > 28 ? tag : n.name;
2275
+ }
2276
+ function iconOf(n) {
2277
+ if (n.depth === 0) return "root";
2278
+ const s = `${n.name} ${(n.v.tags || []).join(" ")}`.toLowerCase();
2279
+ if (/^(npm|npx|node|pnpm|yarn|tsc|git|bun)\b/.test(n.name) || /command|shell|exec/.test(s)) return "terminal";
2280
+ if (/chromepage|page:|tab/.test(s)) return "window";
2281
+ if (/browser|chrome/.test(s)) return "globe";
2282
+ if (/health|ready|gate|wait|port/.test(s)) return "pulse";
2283
+ if (/drive|inspect|click/.test(s)) return "cursor";
2284
+ if (/test|assert|validate/.test(s)) return "flask";
2285
+ if (/snapshot|screenshot/.test(s)) return "camera";
2286
+ if (/prompt|ask/.test(s)) return "chat";
2287
+ if (/build|compile|bundle/.test(s)) return "box";
2288
+ return "play";
2289
+ }
2290
+ function copy(text, message) {
2291
+ const done = () => toast(message || "Copied");
2292
+ if (navigator.clipboard && window.isSecureContext) navigator.clipboard.writeText(text).then(done, () => fallbackCopy(text, message));
2293
+ else fallbackCopy(text, message);
2294
+ }
2295
+ function fallbackCopy(text, message) {
2296
+ const t = document.createElement("textarea");
2297
+ t.value = text;
2298
+ document.body.appendChild(t);
2299
+ t.select();
2300
+ try {
2301
+ document.execCommand("copy");
2302
+ toast(message || "Copied");
2303
+ } catch {
2304
+ toast(text);
2305
+ }
2306
+ t.remove();
2307
+ }
2308
+ function toast(text) {
2309
+ const t = document.createElement("div");
2310
+ t.className = "toast";
2311
+ t.textContent = text;
2312
+ document.body.appendChild(t);
2313
+ setTimeout(() => t.remove(), 1400);
2314
+ }
2315
+ function download(name, text, mime) {
2316
+ const a = document.createElement("a");
2317
+ a.href = URL.createObjectURL(new Blob([text], { type: mime }));
2318
+ a.download = name;
2319
+ a.click();
2320
+ setTimeout(() => URL.revokeObjectURL(a.href), 1000);
2321
+ }
2322
+ function toggleTheme() {
2323
+ const root = document.documentElement;
2324
+ const dark = root.dataset.theme ? root.dataset.theme === "dark" : !matchMedia("(prefers-color-scheme: light)").matches;
2325
+ root.dataset.theme = dark ? "light" : "dark";
2326
+ try {
2327
+ localStorage.setItem("omkit-report-theme", root.dataset.theme);
2328
+ } catch {
2329
+ /* storage may be unavailable on file:// */
2330
+ }
2331
+ }
2332
+
2333
+ // ── boot ─────────────────────────────────────────────────
2334
+ const frame = () => new Promise((r) => requestAnimationFrame(() => setTimeout(r, 0)));
2335
+ const step = (t) => {
2336
+ const s = document.getElementById("loader-step");
2337
+ if (s) s.textContent = t;
2338
+ };
2339
+ async function boot() {
2340
+ try {
2341
+ const saved = localStorage.getItem("omkit-report-theme");
2342
+ if (saved) document.documentElement.dataset.theme = saved;
2343
+ } catch {
2344
+ /* ignore */
2345
+ }
2346
+ await frame();
2347
+ step("Parsing the run record…");
2348
+ await frame();
2349
+ const data = JSON.parse(document.getElementById("omkit-data").textContent);
2350
+ step(`Building ${data.run ? "the timeline" : "the view"}…`);
2351
+ await frame();
2352
+ const model = buildModel(data);
2353
+ const app = document.getElementById("app");
2354
+ app.hidden = false;
2355
+ App(model);
2356
+ await frame();
2357
+ const loader = document.getElementById("loader");
2358
+ loader.style.transition = "opacity 0.2s";
2359
+ loader.style.opacity = "0";
2360
+ setTimeout(() => loader.remove(), 220);
2361
+ }
2362
+ boot().catch((err) => {
2363
+ step(`Could not render this run: ${err && err.message ? err.message : err}`);
2364
+ console.error(err);
2365
+ });
2366
+ })();