omkit 0.8.15 → 0.8.17

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.
@@ -4,15 +4,15 @@
4
4
  (function () {
5
5
  "use strict";
6
6
 
7
+ const TIP_TAGS = 6; // tag chips a hover tooltip shows before "+N"
8
+ const TREE_TAGS = 4; // …and a timeline tree row
7
9
  const ROW_H = 42;
8
10
  const BAR_Y = 6;
9
11
  const BAR_H = 20;
10
- const FLAME_ROW = 26;
11
12
 
12
13
  // ── icons (24×24, stroked) ───────────────────────────────
13
14
  const ICON = {
14
15
  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
16
  flow: "M4 4h7v6H4zM13 14h7v6h-7zM7.5 10v4h9v0M16.5 14v-1",
17
17
  zoomIn: "M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM20 20l-4-4M11 8v6M8 11h6",
18
18
  zoomOut: "M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM20 20l-4-4M8 11h6",
@@ -43,7 +43,6 @@
43
43
  info: "M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18zM12 11v5M12 8h.01",
44
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
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
46
  external: "M14 4h6v6M20 4l-9 9M18 14v6H4V6h6",
48
47
  };
49
48
  const svgIcon = (name, extra) =>
@@ -64,6 +63,7 @@
64
63
  cancel: { color: "--cancel", icon: "stop", label: "Cancelled" },
65
64
  lifecycle: { color: "--c1", icon: "play", label: "Lifecycle" },
66
65
  run: { color: "--text-3", icon: "root", label: "Run" },
66
+ link: { color: "--text-3", icon: "flow", label: "Link" },
67
67
  };
68
68
 
69
69
  // ── formatting ───────────────────────────────────────────
@@ -78,10 +78,6 @@
78
78
  };
79
79
  const fmtRel = (ms) => `+${fmtDur(Math.max(0, ms)).replace(/^0ms$/, "0ms")}`;
80
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
81
  const fmtDate = (ts) => {
86
82
  const d = new Date(ts);
87
83
  return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`;
@@ -161,7 +157,7 @@
161
157
  items.push(item);
162
158
  if (item.node) {
163
159
  item.node.entries.push(item);
164
- if (kind !== "info" && kind !== "lifecycle") item.node.marks.push(item);
160
+ if (kind !== "info" && kind !== "lifecycle" && kind !== "link") item.node.marks.push(item);
165
161
  } else runEntries.push(item);
166
162
  }
167
163
 
@@ -191,6 +187,15 @@
191
187
  }
192
188
  }
193
189
 
190
+ // cross-node interactions, recorded on their target: who waited on, cancelled, or called into whom
191
+ const links = [];
192
+ for (const it of items) {
193
+ const l = it.kind === "link" && it.node && it.e.link;
194
+ const from = l && byId.get(l.from);
195
+ if (!from) continue;
196
+ links.push({ item: it, op: l.op, from, to: it.node, t: it.t, t0: it.t - (l.ms || 0), key: l.key || "", ms: l.ms, outcome: l.outcome || "", site: l.site || "" });
197
+ }
198
+
194
199
  const asserts = items.filter((i) => i.kind === "pass" || i.kind === "fail");
195
200
  const errors = collectErrors(items, nodes, run, tEnd - t0);
196
201
  const errorsBy = new Map();
@@ -214,6 +219,7 @@
214
219
  byId,
215
220
  root: nodes[0],
216
221
  items,
222
+ links,
217
223
  runEntries,
218
224
  gallery,
219
225
  asserts,
@@ -275,6 +281,7 @@
275
281
  return String(s).split("\n")[0];
276
282
  }
277
283
  function kindOf(e) {
284
+ if (e.level === "link") return "link";
278
285
  if (e.level === "assert") return e.message.startsWith("⊭") ? "fail" : "pass";
279
286
  if (e.level === "snapshot") return "snapshot";
280
287
  if (e.level === "artifact") return "artifact";
@@ -311,7 +318,7 @@
311
318
  selected: model.root.id,
312
319
  collapsed: new Set(),
313
320
  zoom: d3.zoomIdentity,
314
- detailTab: "overview",
321
+ detailOpen: new Set(["errors"]), // details-panel sections left open
315
322
  errFilter: "all",
316
323
  errHl: false,
317
324
  errOpen: new Set(),
@@ -332,7 +339,6 @@
332
339
  body: app.querySelector(".tl-body"),
333
340
  tree: app.querySelector(".tree"),
334
341
  chart: app.querySelector(".chart"),
335
- flame: app.querySelector(".flame-view"),
336
342
  flow: app.querySelector(".flow-view"),
337
343
  details: app.querySelector(".details"),
338
344
  lower: app.querySelector(".lower"),
@@ -345,10 +351,9 @@
345
351
  tip.hidden = true;
346
352
  document.body.appendChild(tip);
347
353
 
348
- let W = 600; // chart pixel width (timeline + flame share the x mapping)
354
+ let W = 600; // chart pixel width
349
355
  let baseX = d3.scaleLinear();
350
356
  const xz = () => state.zoom.rescaleX(baseX);
351
- let syncing = false;
352
357
 
353
358
  // ── header controls ──
354
359
  app.querySelectorAll("[data-view]").forEach((b) =>
@@ -371,6 +376,18 @@
371
376
  info.addEventListener("mousemove", (ev) => moveTip(ev));
372
377
  info.addEventListener("mouseleave", hideTip);
373
378
  info.addEventListener("click", () => select(model.root.id));
379
+ const legendBtn = app.querySelector("#legend-btn");
380
+ const legend = app.querySelector("#legend");
381
+ const toggleLegend = (open) => {
382
+ legend.hidden = !open;
383
+ legendBtn.setAttribute("aria-expanded", String(open));
384
+ legendBtn.classList.toggle("active", open);
385
+ };
386
+ legendBtn.addEventListener("click", () => toggleLegend(legend.hidden));
387
+ document.addEventListener("mousedown", (e) => {
388
+ if (!legend.hidden && !legend.contains(e.target) && !legendBtn.contains(e.target)) toggleLegend(false);
389
+ });
390
+ document.addEventListener("keydown", (e) => e.key === "Escape" && !legend.hidden && toggleLegend(false));
374
391
 
375
392
  // ── zoom behaviours ──
376
393
  const chartZoom = d3
@@ -378,28 +395,13 @@
378
395
  .scaleExtent([1, 5000])
379
396
  .filter((e) => (e.type === "wheel" ? e.ctrlKey || e.metaKey : !e.button && e.type !== "dblclick"))
380
397
  .on("zoom", (e) => onZoom(e.transform));
381
- const flameZoom = 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
398
  const flowZoom = d3.zoom().scaleExtent([0.1, 4]).on("zoom", (e) => flowG && flowG.attr("transform", e.transform));
387
399
 
388
400
  function activeZoom() {
389
- return state.view === "flame"
390
- ? { z: flameZoom, sel: d3.select(el.flame).select("svg") }
391
- : { z: chartZoom, sel: d3.select(el.chart).select("svg") };
401
+ return { z: chartZoom, sel: d3.select(el.chart).select("svg") };
392
402
  }
393
403
  function onZoom(t) {
394
404
  state.zoom = t;
395
- if (!syncing) {
396
- syncing = true;
397
- // keep the other x-view's internal transform in step
398
- const other = state.view === "flame" ? d3.select(el.chart).select("svg") : d3.select(el.flame).select("svg");
399
- const oz = state.view === "flame" ? chartZoom : flameZoom;
400
- if (!other.empty()) other.call(oz.transform, t);
401
- syncing = false;
402
- }
403
405
  redrawX();
404
406
  }
405
407
  function zoomBy(k) {
@@ -427,19 +429,18 @@
427
429
  function measure() {
428
430
  const sb = el.body.offsetWidth - el.body.clientWidth;
429
431
  el.head.style.paddingRight = `${sb}px`;
430
- W = Math.max(200, state.view === "flame" ? el.flame.clientWidth : el.chart.clientWidth);
432
+ W = Math.max(200, el.chart.clientWidth);
431
433
  const span = model.span;
432
434
  baseX = d3.scaleLinear().domain([-span * 0.01, span * 1.015]).range([0, W]);
433
- for (const z of [chartZoom, flameZoom]) z.translateExtent([[0, -1e6], [W, 1e6]]).extent([[0, 0], [W, 1]]);
435
+ chartZoom.translateExtent([[0, -1e6], [W, 1e6]]).extent([[0, 0], [W, 1]]);
434
436
  }
435
437
 
436
438
  function setView(v) {
437
439
  state.view = v;
438
440
  app.querySelectorAll("[data-view]").forEach((b) => b.classList.toggle("active", b.dataset.view === v));
439
- el.timeline.classList.toggle("no-tree", v === "flame");
440
441
  el.body.hidden = v !== "timeline";
441
442
  el.head.hidden = v === "flow";
442
- el.flame.hidden = v !== "flame";
443
+ el.timeline.classList.toggle("is-flow", v === "flow");
443
444
  el.flow.hidden = v !== "flow";
444
445
  el.windowSel.disabled = v === "flow";
445
446
  renderAll();
@@ -479,17 +480,17 @@
479
480
  ? `<button class="twist ${open ? "open" : ""}" title="${open ? "Collapse" : "Expand"}">${svgIcon("chevron")}</button>`
480
481
  : '<span class="twist"></span>';
481
482
  const color = colorOf(n);
482
- const rest = (n.v.tags || []).filter((t) => t !== displayName(n));
483
- const tags = rest.length ? `<span class="tagchip">${esc(rest.join(" · "))}</span>` : "";
483
+ const tags = chipsHtml(n.v.tags, TREE_TAGS, 18);
484
484
  return `<div class="row ${state.selected === n.id ? "selected" : ""}" data-id="${esc(n.id)}" title="${esc(n.v.path)}">
485
485
  <div class="name" style="padding-left:${n.depth ? 4 : 10}px">${guides}${twist}
486
486
  <span class="icon" style="color:${color};background:${alpha(color, 0.16)}">${svgIcon(iconOf(n))}</span>
487
- <span class="label">${esc(displayName(n))}</span>${tags}${
488
- model.errorsBy.get(n) ? `<span class="errbadge" title="${model.errorsBy.get(n)} error${model.errorsBy.get(n) > 1 ? "s" : ""}">${model.errorsBy.get(n)}</span>` : ""
489
- }
487
+ <span class="lines">
488
+ <span class="l1"><span class="label">${labelHtml(n)}</span>${
489
+ model.errorsBy.get(n) ? `<span class="errbadge" title="${model.errorsBy.get(n)} error${model.errorsBy.get(n) > 1 ? "s" : ""}">${model.errorsBy.get(n)}</span>` : ""
490
+ }</span>
491
+ <span class="l2">${tags}<span class="meta"><span class="st"><span class="dot ${n.status}"></span>${esc(n.status)}</span><span class="sep">·</span><span class="du">${fmtDur(n.duration)}</span></span></span>
492
+ </span>
490
493
  </div>
491
- <div class="status"><span class="dot ${n.status}"></span>${esc(n.status)}</div>
492
- <div class="dur">${fmtDur(n.duration)}</div>
493
494
  </div>`;
494
495
  }
495
496
  function toggle(n) {
@@ -514,7 +515,21 @@
514
515
  .on("mouseleave", () => moveCursor(null));
515
516
  chartSvg.append("g").attr("class", "grid");
516
517
  chartSvg.append("g").attr("class", "links");
518
+ chartSvg
519
+ .append("defs")
520
+ .append("marker")
521
+ .attr("id", "xl-arrow")
522
+ .attr("viewBox", "0 0 10 10")
523
+ .attr("refX", 9)
524
+ .attr("refY", 5)
525
+ .attr("markerWidth", 6)
526
+ .attr("markerHeight", 6)
527
+ .attr("orient", "auto")
528
+ .append("path")
529
+ .attr("d", "M0,1 L10,5 L0,9 z");
517
530
  chartSvg.append("g").attr("class", "rows");
531
+ // over the rows: their hover targets would otherwise swallow the link strips
532
+ chartSvg.append("g").attr("class", "xlinks");
518
533
  chartSvg.append("line").attr("class", "pin").attr("stroke", css("--accent")).attr("stroke-width", 1.5).attr("y1", 0).attr("y2", H);
519
534
  chartSvg.append("line").attr("class", "cursor").attr("y1", 0).attr("y2", H).attr("visibility", "hidden");
520
535
  chartSvg.call(chartZoom).call(chartZoom.transform, state.zoom);
@@ -527,7 +542,6 @@
527
542
  updateWindowLabel(x);
528
543
  updateOverviewBrush(x);
529
544
  if (state.view === "timeline" && chartSvg) drawRows(x);
530
- if (state.view === "flame") drawFlame(x);
531
545
  }
532
546
 
533
547
  function drawRows(x) {
@@ -575,9 +589,6 @@
575
589
  const cb = r.append("g").attr("class", "code-btn src");
576
590
  cb.append("circle").attr("r", 9);
577
591
  cb.append("text").attr("text-anchor", "middle").attr("y", 3.5).text("<>");
578
- const rb = r.append("g").attr("class", "code-btn ret");
579
- rb.append("circle").attr("r", 9);
580
- rb.append("path").attr("d", ICON.ret).attr("transform", "translate(-6,-6) scale(0.5)");
581
592
  return r;
582
593
  })
583
594
  .attr("transform", (_n, i) => `translate(0,${i * ROW_H})`)
@@ -598,7 +609,7 @@
598
609
  const t = d3.select(this);
599
610
  const x0 = x(n.start);
600
611
  const w = x(n.end) - x0;
601
- const text = `${displayName(n)} (${fmtDur(n.duration)})`;
612
+ const text = `${boxLabel(n)} (${fmtDur(n.duration)})`;
602
613
  const need = text.length * 6.6 + 14;
603
614
  const inside = w >= need;
604
615
  const clipX = Math.max(x0, 0);
@@ -617,43 +628,115 @@
617
628
  // …and, for a block spanning the chart, inside it right after its label
618
629
  const cx =
619
630
  right + 8 <= W - 10 ? right + 8 : left - 8 >= 10 ? left - 8 : Math.min(W - 12, (flip ? x0 : lx + tw) + 14);
620
- // buttons stack away from the block: rightward when they sit after it, leftward before it
621
- const dir = right + 8 <= W - 10 || left - 8 < 10 ? 1 : -1;
622
- const src = hasCode(n);
623
- const row = d3.select(this.parentNode);
624
- row
631
+ d3.select(this.parentNode)
625
632
  .select("g.code-btn.src")
626
- .style("display", src ? null : "none")
633
+ .style("display", hasPopup(n) ? null : "none")
627
634
  .attr("transform", `translate(${cx},${BAR_Y + BAR_H / 2})`);
628
- row
629
- .select("g.code-btn.ret")
630
- .style("display", hasValue(n) ? null : "none")
631
- .attr("transform", `translate(${src ? cx + dir * 21 : cx},${BAR_Y + BAR_H / 2})`);
632
635
  });
633
636
  g.select("g.code-btn.src")
634
637
  .on("click", (ev, n) => {
635
638
  ev.stopPropagation();
636
639
  toggleCode(n, ev);
637
640
  })
638
- .on("mouseenter", (ev) => showTip(ev, "Show source"))
639
- .on("mouseleave", hideTip);
640
- g.select("g.code-btn.ret")
641
- .on("click", (ev, n) => {
642
- ev.stopPropagation();
643
- toggleCode(n, ev, "value", true);
644
- })
645
- .on("mouseenter", (ev, n) => showTip(ev, `Returned: <span class="mono">${esc(valuePreview(n, 80))}</span>`))
641
+ .on("mouseenter", (ev, n) => showTip(ev, popupTip(n)))
646
642
  .on("mouseleave", hideTip);
647
643
  g.selectAll("rect.hit, text.bar-label").call(bindNode);
648
644
 
649
645
  g.select("g.marks").each(function (n) {
650
646
  drawMarks(d3.select(this), n, x);
651
647
  });
648
+ drawLinks(x, index);
652
649
 
653
650
  chartSvg.select("line.pin").attr("x1", state.pin != null ? x(state.pin) : -10).attr("x2", state.pin != null ? x(state.pin) : -10);
654
651
  if (app.classList.contains("hl-on") || state.errHl) applyHl();
655
652
  }
656
653
 
654
+ /** The visible row standing in for `n`: its own, or its nearest expanded ancestor's. */
655
+ function rowIndexOf(n, index) {
656
+ for (let p = n; p; p = p.parent) if (index.has(p.id)) return index.get(p.id);
657
+ return -1;
658
+ }
659
+ /**
660
+ * Cross-node links on the timeline. A strip under the caller's bar holds its waits
661
+ * (`once`, dashed) and its calls on another action's ref (solid), in the target's colour;
662
+ * a dashed connector joins the two rows where a wait ended (target → waiter) and where a
663
+ * cancel landed (canceller → target).
664
+ */
665
+ function drawLinks(x, index) {
666
+ const STRIP_Y = BAR_Y + BAR_H + 3;
667
+ const spans = [];
668
+ const wires = [];
669
+ for (const l of model.links) {
670
+ const fi = rowIndexOf(l.from, index);
671
+ const ti = rowIndexOf(l.to, index);
672
+ if (fi < 0) continue;
673
+ if (l.op !== "cancel") spans.push({ l, fi });
674
+ if (l.op !== "ref" && ti >= 0 && ti !== fi) wires.push({ l, fi, ti });
675
+ }
676
+ const layer = chartSvg.select(".xlinks");
677
+ layer
678
+ .selectAll("line.xl-span")
679
+ .data(spans)
680
+ .join("line")
681
+ .attr("class", (d) => `xl-span ${d.l.op}`)
682
+ .attr("x1", (d) => x(d.l.t0))
683
+ .attr("x2", (d) => Math.max(x(d.l.t), x(d.l.t0) + 2))
684
+ .attr("y1", (d) => d.fi * ROW_H + STRIP_Y)
685
+ .attr("y2", (d) => d.fi * ROW_H + STRIP_Y)
686
+ .attr("stroke", (d) => colorOf(d.l.to))
687
+ .call(bindLink);
688
+ const wirePath = (d) => {
689
+ const px = x(d.l.t);
690
+ const fy = d.fi * ROW_H + STRIP_Y;
691
+ const ty = d.ti * ROW_H + BAR_Y + BAR_H / 2;
692
+ const [y1, y2] = d.l.op === "once" ? [ty, fy] : [fy, ty];
693
+ return `M${px},${y1} C${px + 10},${(2 * y1 + y2) / 3} ${px + 10},${(y1 + 2 * y2) / 3} ${px},${y2}`;
694
+ };
695
+ layer
696
+ .selectAll("path.xl-wire")
697
+ .data(wires)
698
+ .join("path")
699
+ .attr("class", (d) => `xl-wire ${d.l.op}`)
700
+ .attr("d", wirePath)
701
+ .attr("stroke", (d) => (d.l.op === "cancel" ? css("--cancel") : colorOf(d.l.to)))
702
+ .attr("marker-end", "url(#xl-arrow)");
703
+ // a wide transparent twin over each wire, so a 1px dashed line is easy to hover
704
+ layer.selectAll("path.xl-hit").data(wires).join("path").attr("class", "xl-hit").attr("d", wirePath).call(bindLink);
705
+ }
706
+ function bindLink(sel) {
707
+ sel
708
+ .on("click", (ev, d) => {
709
+ ev.stopPropagation();
710
+ setPin(d.l.t);
711
+ select(d.l.from.id, "links");
712
+ })
713
+ .on("mouseenter", (ev, d) => showTip(ev, linkTip(d.l), true))
714
+ .on("mousemove", (ev) => moveTip(ev))
715
+ .on("mouseleave", hideTip);
716
+ }
717
+ /** A link in words: who did what to whom, how it ended, and the line that did it. */
718
+ function linkTip(l, count) {
719
+ const from = esc(displayName(l.from));
720
+ const to = esc(displayName(l.to));
721
+ const title = l.op === "once" ? `once · ${esc(l.key)}` : l.op === "cancel" ? "cancel" : `ref · ${esc(l.key)}()`;
722
+ const what =
723
+ l.op === "once"
724
+ ? `<b>${from}</b> waited for <b>${esc(l.key)}</b> on <b>${to}</b>`
725
+ : l.op === "cancel"
726
+ ? `<b>${from}</b> cancelled <b>${to}</b>`
727
+ : `<b>${from}</b> called <b>${esc(l.key)}()</b> on <b>${to}</b>'s ref`;
728
+ const meta = [
729
+ count > 1 ? `×${count}` : "",
730
+ l.outcome ? esc(l.outcome) : "",
731
+ l.ms != null ? (l.op === "once" ? `after ${fmtDur(l.ms)}` : fmtDur(l.ms)) : "",
732
+ fmtRel(l.t),
733
+ ].filter(Boolean);
734
+ const line = (/:(\d+)$/.exec(l.site) || [])[1];
735
+ return `<div class="tt">${title}</div><div class="summary">${what}</div>
736
+ <div class="tmeta">${meta.join('<span class="sep">·</span>')}</div>
737
+ ${l.site ? `<div class="mono small" style="margin-top:6px">${esc(relOf(l.site))}${line ? ":" + line : ""}</div>` : ""}`;
738
+ }
739
+
657
740
  function drawMarks(g, n, x) {
658
741
  const cy = BAR_Y + BAR_H / 2;
659
742
  // cluster marks closer than 4px so a chatty node draws a readable line of dots
@@ -721,7 +804,7 @@
721
804
  function updateAxis(x) {
722
805
  const svg = d3.select(el.axis);
723
806
  if (state.view === "flow") return;
724
- const w = state.view === "flame" ? W : el.chart.clientWidth || W;
807
+ const w = el.chart.clientWidth || W;
725
808
  svg.attr("viewBox", null);
726
809
  const g = svg.selectAll("g.axis").data([0]).join("g").attr("class", "axis").attr("transform", "translate(0,33)");
727
810
  g.call(d3.axisTop(x).ticks(Math.max(2, Math.floor(w / 90))).tickFormat(fmtTick).tickSizeOuter(0));
@@ -729,9 +812,8 @@
729
812
  svg.selectAll("text.cursor-t").data([0]).join("text").attr("class", "cursor-t").attr("y", 12).attr("fill", css("--accent")).attr("font-size", 11);
730
813
  }
731
814
  function moveCursor(px) {
732
- const svgs = [chartSvg, flameSvg].filter(Boolean);
733
- for (const s of svgs) {
734
- s.select("line.cursor").attr("visibility", px == null ? "hidden" : "visible").attr("x1", px).attr("x2", px);
815
+ if (chartSvg) {
816
+ chartSvg.select("line.cursor").attr("visibility", px == null ? "hidden" : "visible").attr("x1", px).attr("x2", px);
735
817
  }
736
818
  const label = d3.select(el.axis).select("text.cursor-t");
737
819
  if (px == null) label.text("");
@@ -748,91 +830,6 @@
748
830
  el.windowSel.value = "0";
749
831
  }
750
832
 
751
- // ── flame view ──
752
- let flameSvg = null;
753
- /**
754
- * A row per node, packed: a node goes on the first row below its parent's that nothing
755
- * already placed there overlaps in time. Plain depth rows hid every step that ran beside a
756
- * long-lived sibling (a daemon, a server) — they shared a row and were drawn over.
757
- */
758
- let flameRows = null;
759
- function packFlame() {
760
- const rows = []; // rows[r] = [[start, end], …] already taken
761
- const out = new Map();
762
- const free = (r, a, b) => !(rows[r] || []).some(([s, e]) => a < e && s < b);
763
- (function place(n, parentRow) {
764
- let r = parentRow + 1;
765
- // a zero-length node still needs a sliver of time to claim, or it would stack on anything
766
- const a = n.start;
767
- const b = Math.max(n.end, n.start + model.span / 2000);
768
- while (!free(r, a, b)) r++;
769
- (rows[r] = rows[r] || []).push([a, b]);
770
- out.set(n.id, r);
771
- [...n.children].sort((p, q) => p.start - q.start).forEach((c) => place(c, r));
772
- })(model.root, -1);
773
- return { rowOf: out, count: rows.length };
774
- }
775
- function renderFlame() {
776
- d3.select(el.flame).selectAll("svg").remove();
777
- flameRows = flameRows || packFlame();
778
- flameSvg = d3
779
- .select(el.flame)
780
- .append("svg")
781
- .style("height", `${Math.max(el.flame.clientHeight, 16 + flameRows.count * FLAME_ROW)}px`)
782
- .on("mousemove", (ev) => moveCursor(d3.pointer(ev)[0]))
783
- .on("mouseleave", () => moveCursor(null));
784
- flameSvg.append("g").attr("class", "grid");
785
- flameSvg.append("g").attr("class", "cells");
786
- flameSvg.append("line").attr("class", "pin").attr("stroke", css("--accent")).attr("stroke-width", 1.5).attr("y1", 0).attr("y2", 4000);
787
- flameSvg.append("line").attr("class", "cursor").attr("y1", 0).attr("y2", 4000).attr("visibility", "hidden");
788
- flameSvg.call(flameZoom).call(flameZoom.transform, state.zoom);
789
- redrawX();
790
- }
791
- function drawFlame(x) {
792
- if (!flameSvg) return;
793
- const px = state.pin != null ? x(state.pin) : -10;
794
- queueMicrotask(() => (app.classList.contains("hl-on") || state.errHl) && applyHl());
795
- flameSvg.select("line.pin").attr("x1", px).attr("x2", px);
796
- const H = el.flame.clientHeight;
797
- const ticks = x.ticks(Math.max(2, Math.floor(W / 90)));
798
- flameSvg
799
- .select(".grid")
800
- .selectAll("line")
801
- .data(ticks)
802
- .join("line")
803
- .attr("x1", (d) => x(d))
804
- .attr("x2", (d) => x(d))
805
- .attr("y1", 0)
806
- .attr("y2", H);
807
- const cells = flameSvg
808
- .select(".cells")
809
- .selectAll("g.cell")
810
- .data(model.nodes, (n) => n.id)
811
- .join((enter) => {
812
- const g = enter.append("g").attr("class", "cell");
813
- g.append("rect").attr("class", "flame-rect").attr("rx", 3).attr("height", FLAME_ROW - 2);
814
- g.append("text").attr("class", "flame-text").attr("y", FLAME_ROW / 2 + 3);
815
- return g;
816
- })
817
- .attr("transform", (n) => `translate(0,${8 + flameRows.rowOf.get(n.id) * FLAME_ROW})`);
818
- cells
819
- .select("rect")
820
- .attr("x", (n) => x(n.start))
821
- .attr("width", (n) => Math.max(1, x(n.end) - x(n.start)))
822
- .attr("fill", (n) => colorOf(n))
823
- .attr("fill-opacity", (n) => (n.status === "cancelled" ? 0.55 : 0.92))
824
- .classed("selected", (n) => n.id === state.selected)
825
- .call(bindNode);
826
- cells
827
- .select("text")
828
- .attr("x", (n) => Math.max(x(n.start), 0) + 5)
829
- .text((n) => {
830
- const w = x(n.end) - Math.max(x(n.start), 0);
831
- return w < 34 ? "" : truncate(`${displayName(n)} ${fmtDur(n.duration)}`, (w - 10) / 6.2);
832
- })
833
- .call(bindNode);
834
- }
835
-
836
833
  // ── flow view ──
837
834
  //
838
835
  // Each node's children form one *lane*: a column to its right, in start order, joined by
@@ -842,7 +839,19 @@
842
839
  let flowG = null;
843
840
  let flowPos = new Map();
844
841
  let flowBounds = null;
845
- const FLOW = { CW: 250, CH: 58, GX: 64, GY: 26, LANE_DX: 22, TW: 46, TH: 28 };
842
+ const FLOW = { CW: 250, CH: 58, TAGS_H: 18, GX: 64, GY: 26, LANE_DX: 22, TW: 46, TH: 28 };
843
+ /** A tag chip in SVG, matching the HTML `.chip`; returns its group and width. */
844
+ function svgChip(parent, x, label, color) {
845
+ const g = parent.append("g").attr("class", "tchip").attr("transform", `translate(${x},0)`);
846
+ const rect = g.append("rect").attr("height", 16).attr("rx", 4);
847
+ const text = g.append("text").attr("x", 6).attr("y", 11.5).text(label);
848
+ const w = (text.node().getComputedTextLength() || label.length * 6) + 12;
849
+ rect
850
+ .attr("width", w)
851
+ .attr("fill", color ? alpha(color, 0.14) : "none")
852
+ .attr("stroke", color ? alpha(color, 0.45) : css("--line"));
853
+ return { g, w };
854
+ }
846
855
  function renderFlow() {
847
856
  d3.select(el.flow).selectAll("svg").remove();
848
857
  const svg = d3.select(el.flow).append("svg");
@@ -860,7 +869,9 @@
860
869
  .attr("class", "flow-arrowhead")
861
870
  .attr("d", "M0,1 L10,5 L0,9 z");
862
871
  flowG = svg.append("g");
863
- const { CW, CH, GX, GY, LANE_DX, TW, TH } = FLOW;
872
+ const { CW, GX, GY, LANE_DX, TW, TH } = FLOW;
873
+ // every card gets the tag row when any is tagged, so lanes stay on one rhythm
874
+ const CH = FLOW.CH + (model.nodes.some((n) => n.v.tags && n.v.tags.length) ? FLOW.TAGS_H : 0);
864
875
 
865
876
  const shotsBy = new Map();
866
877
  for (const g of model.gallery) {
@@ -954,6 +965,70 @@
954
965
  }
955
966
 
956
967
  const nodes = [...pos.keys()].map((id) => model.byId.get(id));
968
+ // cross-node links: one curve per (caller, target, kind), arcing out past the cards' right
969
+ // edges; drawn first so the cards sit on top of their ends
970
+ const agg = new Map();
971
+ for (const l of model.links) {
972
+ if (!pos.has(l.from.id) || !pos.has(l.to.id) || l.from === l.to) continue;
973
+ const k = `${l.from.id}\0${l.to.id}\0${l.op}`;
974
+ if (!agg.has(k)) agg.set(k, { op: l.op, from: l.from, to: l.to, list: [] });
975
+ agg.get(k).list.push(l);
976
+ }
977
+ const xl = flowG.append("g").attr("class", "flow-xlinks");
978
+ const fan = new Map(); // edges between the same two cards fan out instead of overlapping
979
+ for (const a of agg.values()) {
980
+ const f = pos.get(a.from.id);
981
+ const t = pos.get(a.to.id);
982
+ const [s, e] = a.op === "once" ? [t, f] : [f, t];
983
+ const pair = [a.from.id, a.to.id].sort().join("");
984
+ const k = fan.get(pair) || 0;
985
+ fan.set(pair, k + 1);
986
+ const off = (k % 2 ? 1 : -1) * Math.ceil(k / 2) * 10;
987
+ let x1, y1, x2, y2, path, lx, ly;
988
+ if (Math.abs(s.x - e.x) < CW) {
989
+ // same column: arc out past the right edges
990
+ x1 = s.x + CW;
991
+ x2 = e.x + CW;
992
+ y1 = s.y + CH / 2 + off;
993
+ y2 = e.y + CH / 2 + off;
994
+ const bulge = 36 + Math.min(140, Math.abs(y2 - y1) * 0.2) + Math.abs(off);
995
+ path = `M${x1},${y1} C${x1 + bulge},${y1} ${x2 + bulge},${y2} ${x2 + 2},${y2}`;
996
+ lx = (x1 + x2) / 2 + bulge * 0.75 + 4;
997
+ ly = (y1 + y2) / 2 + 4;
998
+ } else {
999
+ // different columns: facing sides, an S-curve between them, below the tree's own links
1000
+ const rightward = e.x > s.x;
1001
+ x1 = rightward ? s.x + CW : s.x;
1002
+ x2 = rightward ? e.x : e.x + CW;
1003
+ y1 = s.y + CH * 0.72 + off;
1004
+ y2 = e.y + CH * 0.72 + off;
1005
+ const mx = (x1 + x2) / 2;
1006
+ path = `M${x1},${y1} C${mx},${y1} ${mx},${y2} ${x2 + (rightward ? -2 : 2)},${y2}`;
1007
+ lx = mx + 4;
1008
+ ly = (y1 + y2) / 2 - 4;
1009
+ }
1010
+ const keys = [...new Set(a.list.map((l) => l.key))];
1011
+ const label =
1012
+ a.op === "cancel" ? "cancel" : a.op === "once" ? `once ${keys.join(", ")}` : keys.length === 1 ? `${keys[0]}() ×${a.list.length}` : `ref ×${a.list.length}`;
1013
+ const g = xl
1014
+ .append("g")
1015
+ .attr("class", `flow-xlink ${a.op}`)
1016
+ .on("click", (ev) => {
1017
+ ev.stopPropagation();
1018
+ select(a.from.id, "links");
1019
+ })
1020
+ .on("mouseenter", (ev) => showTip(ev, linkTip(a.list[a.list.length - 1], a.list.length), true))
1021
+ .on("mousemove", (ev) => moveTip(ev))
1022
+ .on("mouseleave", hideTip);
1023
+ g.append("path")
1024
+ .attr("d", path)
1025
+ .attr("stroke", a.op === "cancel" ? css("--cancel") : colorOf(a.to))
1026
+ .attr("marker-end", "url(#flow-arrow)");
1027
+ g.append("path").attr("class", "hit").attr("d", path);
1028
+ // between neighbouring columns there is no room for words — the tooltip carries them
1029
+ if (Math.abs(x2 - x1) >= 90) g.append("text").attr("x", lx).attr("y", ly).text(truncate(label, 28));
1030
+ }
1031
+
957
1032
  const card = flowG
958
1033
  .append("g")
959
1034
  .selectAll("g.flow-card")
@@ -988,7 +1063,7 @@
988
1063
  .attr("color", (n) => colorOf(n))
989
1064
  .html((n) => `<path d="${ICON[iconOf(n)]}" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>`);
990
1065
  card.each(function (n) {
991
- const lines = wrapName(displayName(n), 31);
1066
+ const lines = wrapName(boxLabel(n), 31);
992
1067
  const t = d3.select(this);
993
1068
  lines.forEach((line, i) =>
994
1069
  t
@@ -1003,6 +1078,21 @@
1003
1078
  .attr("x", 42)
1004
1079
  .attr("y", lines.length > 1 ? 49 : 41)
1005
1080
  .text(`${nodeKind(n)} · ${n.status} · ${fmtDur(n.duration)} · +${fmtTick(n.start)}`);
1081
+ const tags = n.v.tags || [];
1082
+ if (!tags.length) return;
1083
+ const row = t.append("g").attr("class", "tchips").attr("transform", `translate(42,${lines.length > 1 ? 56 : 51})`);
1084
+ const room = CW - 42 - 10;
1085
+ let x = 0;
1086
+ for (let i = 0; i < tags.length; i++) {
1087
+ const c = svgChip(row, x, truncate(tags[i], 22), tagColor(tags[i]));
1088
+ // keep room for a "+N" after it, unless it is the last
1089
+ if (i > 0 && x + c.w > room - (i < tags.length - 1 ? 30 : 0)) {
1090
+ c.g.remove();
1091
+ svgChip(row, x, `+${tags.length - i}`, null).g.classed("more", true);
1092
+ break;
1093
+ }
1094
+ x += c.w + 4;
1095
+ }
1006
1096
  });
1007
1097
  card
1008
1098
  .append("circle")
@@ -1011,7 +1101,7 @@
1011
1101
  .attr("r", 4)
1012
1102
  .attr("fill", (n) => css(statusVar(n.status)));
1013
1103
  const fcb = card
1014
- .filter((n) => hasCode(n))
1104
+ .filter((n) => hasPopup(n))
1015
1105
  .append("g")
1016
1106
  .attr("class", "code-btn")
1017
1107
  .attr("transform", `translate(${CW / 2},0)`)
@@ -1019,27 +1109,12 @@
1019
1109
  ev.stopPropagation();
1020
1110
  toggleCode(n, ev);
1021
1111
  })
1022
- .on("mouseenter", (ev) => {
1112
+ .on("mouseenter", (ev, n) => {
1023
1113
  ev.stopPropagation();
1024
- showTip(ev, "Show source");
1114
+ showTip(ev, popupTip(n));
1025
1115
  });
1026
1116
  fcb.append("circle").attr("r", 9);
1027
1117
  fcb.append("text").attr("text-anchor", "middle").attr("y", 3.5).text("<>");
1028
- const frb = card
1029
- .filter((n) => hasValue(n))
1030
- .append("g")
1031
- .attr("class", "code-btn ret")
1032
- .attr("transform", (n) => `translate(${CW / 2 + (hasCode(n) ? 22 : 0)},0)`)
1033
- .on("click", (ev, n) => {
1034
- ev.stopPropagation();
1035
- toggleCode(n, ev, "value", true);
1036
- })
1037
- .on("mouseenter", (ev, n) => {
1038
- ev.stopPropagation();
1039
- showTip(ev, `Returned: <span class="mono">${esc(valuePreview(n, 80))}</span>`);
1040
- });
1041
- frb.append("circle").attr("r", 9);
1042
- frb.append("path").attr("d", ICON.ret).attr("transform", "translate(-6,-6) scale(0.5)");
1043
1118
 
1044
1119
  // snapshot chips — on lane arrows, and in strips under leaf cards
1045
1120
  const chipLayer = flowG.append("g").attr("class", "flow-shots");
@@ -1182,7 +1257,7 @@
1182
1257
  // ── selection + details ──
1183
1258
  function select(id, tab) {
1184
1259
  state.selected = id;
1185
- if (tab) state.detailTab = tab;
1260
+ if (tab) state.detailOpen.add(tab);
1186
1261
  state.details = true;
1187
1262
  el.main.classList.remove("no-details");
1188
1263
  el.tree.querySelectorAll(".row").forEach((r) => r.classList.toggle("selected", r.dataset.id === id));
@@ -1190,7 +1265,6 @@
1190
1265
  chartSvg.selectAll("rect.bar").classed("selected", (n) => n.id === id);
1191
1266
  chartSvg.selectAll("g.r").classed("sel", (n) => n.id === id);
1192
1267
  }
1193
- if (flameSvg) flameSvg.selectAll("rect.flame-rect").classed("selected", (n) => n.id === id);
1194
1268
  d3.select(el.flow).selectAll("g.flow-card").classed("selected", (n) => n.id === id);
1195
1269
  renderDetails();
1196
1270
  }
@@ -1240,10 +1314,9 @@
1240
1314
  flash(matchMark);
1241
1315
  }
1242
1316
  function flash(matchMark) {
1243
- const svgs = [chartSvg, flameSvg].filter(Boolean);
1244
- for (const s of svgs) {
1245
- s.selectAll("line.pin").classed("flash", false).node()?.getBoundingClientRect();
1246
- s.selectAll("line.pin").classed("flash", true);
1317
+ if (chartSvg) {
1318
+ chartSvg.selectAll("line.pin").classed("flash", false).node()?.getBoundingClientRect();
1319
+ chartSvg.selectAll("line.pin").classed("flash", true);
1247
1320
  }
1248
1321
  if (chartSvg) {
1249
1322
  const hit = chartSvg
@@ -1266,15 +1339,7 @@
1266
1339
  function renderDetails() {
1267
1340
  const n = model.byId.get(state.selected) || model.root;
1268
1341
  const color = colorOf(n);
1269
- const tabs = [
1270
- ["overview", "Overview"],
1271
- ["args", "Args"],
1272
- ...(hasValue(n) ? [["value", "Return"]] : []),
1273
- ...(model.errorsBy.get(n) ? [["errors", `Errors (${model.errorsBy.get(n)})`]] : []),
1274
- ["events", `Events`],
1275
- ["logs", "Logs"],
1276
- ["snapshots", "Snapshots"],
1277
- ];
1342
+ const call = callOf(n);
1278
1343
  el.details.innerHTML = `
1279
1344
  <div class="dh">Action details <button class="x" title="Close">${svgIcon("close", ' width="16" height="16"')}</button></div>
1280
1345
  <div class="who">
@@ -1283,25 +1348,30 @@
1283
1348
  <span class="pill"><span class="dot ${n.status}"></span>${esc(n.status)}</span>
1284
1349
  </div>
1285
1350
  <div class="scroll">
1351
+ ${call ? `<div class="tcall who-call">${callHtml(n, call)}</div>` : ""}
1286
1352
  <div class="kv">
1287
1353
  <div class="k">Duration</div><div class="v">${fmtDur(n.duration)}</div>
1288
- <div class="k">Start</div><div class="v">${fmtRel(n.start)} (${fmtClock(n.startAbs)})</div>
1289
- <div class="k">End</div><div class="v">${fmtRel(n.end)} (${fmtClock(n.endAbs)})</div>
1290
1354
  ${n.v.description ? `<div class="k">Summary</div><div class="v">${esc(n.v.description.summary)}</div>` : ""}
1355
+ ${n.v.tags.length ? `<div class="k">Tags</div><div class="v">${chipsHtml(n.v.tags, Infinity, 40)}</div>` : ""}
1291
1356
  ${n.v.mcp ? `<div class="k">MCP</div><div class="v"><span class="badge-inline">${esc(n.v.mcp.mode)}</span></div>` : ""}
1292
- <div class="k">Path</div><div class="v">${esc(n.v.path)}</div>
1357
+ <div class="k">Id</div><div class="v mono-sm">${esc(n.id)}</div>
1293
1358
  ${userSite(n) ? `<div class="k">${siteLabel(n)}</div><div class="v defined">
1294
1359
  <button class="icon-btn" data-copy-source aria-label="Copy relative path">${svgIcon("copy")}</button>
1295
- ${hasCode(n) ? `<button class="icon-btn" data-show-code aria-label="Show source">${svgIcon("code")}</button>` : ""}
1296
- ${hasValue(n) ? `<button class="icon-btn" data-show-value aria-label="Show returned value">${svgIcon("ret")}</button>` : ""}
1360
+ ${hasPopup(n) ? `<button class="icon-btn" data-show-code aria-label="Show source">${svgIcon("code")}</button>` : ""}
1297
1361
  <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>
1298
1362
  <span class="defined-name" >${esc(basename(sourceOf(n)))}</span>
1299
1363
  </div>` : ""}
1300
1364
  </div>
1301
- <div class="tabs">${tabs
1302
- .map(([k, l]) => `<button class="tab ${state.detailTab === k ? "active" : ""}" data-tab="${k}">${l}</button>`)
1365
+ <div class="acc">${detailSections(n)
1366
+ .map((sec) =>
1367
+ sec.empty
1368
+ ? `<div class="acc-sec empty"><div class="acc-h">${svgIcon("chevron")}<span>${esc(sec.title)}</span><span class="acc-n">0</span></div></div>`
1369
+ : `<details class="acc-sec ${sec.key}" data-sec="${sec.key}" ${state.detailOpen.has(sec.key) ? "open" : ""}>
1370
+ <summary class="acc-h">${svgIcon("chevron")}<span>${esc(sec.title)}</span>${sec.count != null ? `<span class="acc-n">${sec.count}</span>` : ""}</summary>
1371
+ <div class="acc-b">${sec.body()}</div>
1372
+ </details>`
1373
+ )
1303
1374
  .join("")}</div>
1304
- <div class="tabbody">${detailTabHtml(n)}</div>
1305
1375
  </div>`;
1306
1376
  const copySrc = el.details.querySelector("[data-copy-source]");
1307
1377
  if (copySrc) {
@@ -1312,17 +1382,11 @@
1312
1382
  copySrc.addEventListener("mousemove", (ev) => moveTip(ev));
1313
1383
  copySrc.addEventListener("mouseleave", hideTip);
1314
1384
  }
1315
- const showValue = el.details.querySelector("[data-show-value]");
1316
- if (showValue) {
1317
- showValue.addEventListener("click", (ev) => toggleCode(n, ev, "value", true));
1318
- showValue.addEventListener("mouseenter", (ev) => showTip(ev, `Returned: <span class="mono">${esc(valuePreview(n, 80))}</span>`));
1319
- showValue.addEventListener("mouseleave", hideTip);
1320
- }
1321
1385
  wireValue(el.details, n);
1322
1386
  const showCode = el.details.querySelector("[data-show-code]");
1323
1387
  if (showCode) {
1324
1388
  showCode.addEventListener("click", (ev) => toggleCode(n, ev));
1325
- showCode.addEventListener("mouseenter", (ev) => showTip(ev, "Show source"));
1389
+ showCode.addEventListener("mouseenter", (ev) => showTip(ev, popupTip(n)));
1326
1390
  showCode.addEventListener("mouseleave", hideTip);
1327
1391
  }
1328
1392
  el.details.querySelectorAll("[data-tip-text]").forEach((a) => {
@@ -1334,67 +1398,140 @@
1334
1398
  el.main.classList.add("no-details");
1335
1399
  relayout();
1336
1400
  });
1337
- el.details.querySelectorAll(".tab").forEach((b) =>
1338
- b.addEventListener("click", () => {
1339
- state.detailTab = b.dataset.tab;
1340
- renderDetails();
1401
+ // which sections are open is a preference: it carries over from action to action
1402
+ el.details.querySelectorAll("details[data-sec]").forEach((d) =>
1403
+ d.addEventListener("toggle", () => {
1404
+ if (d.open) state.detailOpen.add(d.dataset.sec);
1405
+ else state.detailOpen.delete(d.dataset.sec);
1341
1406
  })
1342
1407
  );
1343
1408
  wireItems(el.details);
1344
1409
  }
1345
1410
 
1346
- function detailTabHtml(n) {
1347
- const tab = state.detailTab;
1348
- if (tab === "errors") {
1349
- const list = model.errors.filter((e) => e.node === n);
1350
- if (!list.length) return '<div class="muted">No errors.</div>';
1351
- return list
1352
- .map((e) => {
1353
- const kind = ERR_TYPES.find(([k]) => k === e.type)[1];
1354
- 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>`;
1355
- })
1356
- .join("");
1357
- }
1358
- if (tab === "value") {
1359
- return hasValue(n) ? `<div class="code-box">${valueHtml(n)}</div>` : '<div class="muted">Returned nothing.</div>';
1360
- }
1361
- if (tab === "args") {
1362
- const params = schemaFields(n.v.inputSchema);
1363
- const schema = params.length ? `<div class="section-title">Schema</div><div class="params">${params.map(paramHtml).join("")}</div><div class="section-title">Values</div>` : "";
1364
- 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>');
1365
- }
1366
- if (tab === "events") {
1367
- const evs = n.entries.filter((i) => i.kind !== "info");
1368
- return evs.length ? `<ul class="ev-list">${evs.map((i) => evLi(i, n)).join("")}</ul>` : '<div class="muted">No events.</div>';
1369
- }
1370
- if (tab === "logs") {
1371
- const logs = n.entries.filter((i) => i.kind === "info" || i.kind === "error");
1372
- return logs.length
1373
- ? `<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>`
1374
- : '<div class="muted">No log output.</div>';
1375
- }
1376
- if (tab === "snapshots") {
1377
- const g = model.gallery.filter((x) => x.node === n);
1378
- return g.length ? `<div class="thumbs">${g.map(thumbHtml).join("")}</div>` : '<div class="muted">No snapshots or files.</div>';
1379
- }
1380
- // overview
1381
- const fails = model.run.failures.filter((f) => f.action === n.id || f.action === n.name || f.action === n.v.path);
1411
+ /** The details panel's accordion: one section per facet of `n`, empty ones shown but shut. */
1412
+ function detailSections(n) {
1413
+ const errs = model.errors.filter((e) => e.node === n);
1414
+ const params = schemaFields(n.v.inputSchema);
1415
+ const evs = n.entries.filter((i) => i.kind !== "info" && i.kind !== "link");
1416
+ const logs = n.entries.filter((i) => i.kind === "info" || i.kind === "error");
1417
+ const lks = model.links.filter((l) => l.from === n || l.to === n);
1418
+ const shots = model.gallery.filter((x) => x.node === n);
1382
1419
  const asserts = n.entries.filter((i) => i.kind === "pass" || i.kind === "fail");
1383
- const g = model.gallery.filter((x) => x.node === n);
1384
- return `
1385
- <div class="kv">
1386
- <div class="k">Id</div><div class="v">${esc(n.id)}</div>
1387
- ${n.name !== displayName(n) ? `<div class="k">Name</div><div class="v">${esc(n.name)}</div>` : ""}
1388
- <div class="k">Tags</div><div class="v">${n.v.tags.length ? esc(n.v.tags.join(", ")) : '<span class="muted">none</span>'}</div>
1389
- <div class="k">Children</div><div class="v">${n.children.length}</div>
1390
- <div class="k">Events</div><div class="v">${n.marks.length}</div>
1391
- </div>
1392
- ${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("")}` : ""}
1393
- ${g.length ? `<div class="section-title">Snapshots (${g.length})</div><div class="thumbs">${g.slice(0, 6).map(thumbHtml).join("")}</div>` : ""}
1394
- <div class="section-title">Related logs</div>
1395
- <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>
1396
- <div class="section-title">Assertions (${asserts.length})</div>
1397
- ${asserts.length ? `<ul class="ev-list">${asserts.map((i) => evLi(i, n)).join("")}</ul>` : '<div class="muted">No assertions in this action.</div>'}`;
1420
+ 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>`;
1421
+ return [
1422
+ {
1423
+ key: "errors",
1424
+ title: "Errors",
1425
+ count: errs.length,
1426
+ empty: !errs.length,
1427
+ body: () =>
1428
+ errs
1429
+ .map((e) => {
1430
+ const kind = ERR_TYPES.find(([k]) => k === e.type)[1];
1431
+ 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>`;
1432
+ })
1433
+ .join(""),
1434
+ },
1435
+ {
1436
+ key: "args",
1437
+ title: "Args",
1438
+ empty: !hasArgs(n) && !params.length,
1439
+ body: () =>
1440
+ (params.length ? `<div class="params">${params.map(paramHtml).join("")}</div>` : "") +
1441
+ (hasArgs(n) ? `<div class="code-box">${argsHtml(n)}</div>` : '<div class="muted">No arguments.</div>'),
1442
+ },
1443
+ { key: "value", title: "Returned", empty: !hasValue(n), body: () => `<div class="code-box">${valueHtml(n)}</div>` },
1444
+ {
1445
+ key: "snapshots",
1446
+ title: "Snapshots & files",
1447
+ count: shots.length,
1448
+ empty: !shots.length,
1449
+ body: () => `<div class="shots">${shots.map((g) => shotHtml(g, n)).join("")}</div>`,
1450
+ },
1451
+ {
1452
+ key: "events",
1453
+ title: "Events",
1454
+ count: evs.length,
1455
+ empty: !evs.length,
1456
+ body: () => `<ul class="ev-list">${evs.map((i) => evLi(i, n)).join("")}</ul>`,
1457
+ },
1458
+ {
1459
+ key: "links",
1460
+ title: "Links",
1461
+ count: lks.length,
1462
+ empty: !lks.length,
1463
+ body: () => linksHtml(n, lks),
1464
+ },
1465
+ {
1466
+ key: "logs",
1467
+ title: "Logs",
1468
+ count: logs.length,
1469
+ empty: !logs.length,
1470
+ body: () =>
1471
+ `${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>`,
1472
+ },
1473
+ {
1474
+ key: "assertions",
1475
+ title: "Assertions",
1476
+ count: asserts.length,
1477
+ empty: !asserts.length,
1478
+ body: () => `<ul class="ev-list">${asserts.map((i) => evLi(i, n)).join("")}</ul>`,
1479
+ },
1480
+ ];
1481
+ }
1482
+
1483
+ /**
1484
+ * The details panel's links: what `n` did to other actions and what they did to it. Ref
1485
+ * calls fold into one row per (other action, method), with a count and their total time.
1486
+ */
1487
+ function linksHtml(n, lks) {
1488
+ const rows = [];
1489
+ const refs = new Map();
1490
+ for (const l of lks) {
1491
+ if (l.op === "ref") {
1492
+ const k = `${l.from.id}\0${l.to.id}\0${l.key}`;
1493
+ if (!refs.has(k)) {
1494
+ refs.set(k, { l, n: 0, ms: 0 });
1495
+ rows.push(refs.get(k));
1496
+ }
1497
+ const r = refs.get(k);
1498
+ r.n++;
1499
+ r.ms += l.ms || 0;
1500
+ } else rows.push({ l, n: 1, ms: l.ms || 0 });
1501
+ }
1502
+ const name = (x) => `<b>${esc(displayName(x))}</b>`;
1503
+ return `<ul class="ev-list">${rows
1504
+ .map(({ l, n: count, ms }) => {
1505
+ const out = l.from === n;
1506
+ const text =
1507
+ l.op === "once"
1508
+ ? out
1509
+ ? `waited for ${esc(l.key)} on ${name(l.to)} · ${esc(l.outcome)} after ${fmtDur(ms)}`
1510
+ : `${name(l.from)} waited for ${esc(l.key)} · ${esc(l.outcome)} after ${fmtDur(ms)}`
1511
+ : l.op === "cancel"
1512
+ ? out
1513
+ ? `cancelled ${name(l.to)}`
1514
+ : `cancelled by ${name(l.from)}`
1515
+ : out
1516
+ ? `${name(l.to)}.${esc(l.key)}() ×${count} · ${fmtDur(ms)}`
1517
+ : `${name(l.from)} called ${esc(l.key)}() ×${count} · ${fmtDur(ms)}`;
1518
+ const color = l.op === "cancel" ? css("--cancel") : colorOf(out ? l.to : l.from);
1519
+ return `<li data-link="${model.links.indexOf(l)}" style="cursor:pointer">
1520
+ <span class="t">${fmtRel(l.t - n.start)}</span>
1521
+ <span class="ev-ico" style="background:${alpha(color, 0.2)};color:${color}">${svgIcon(l.op === "cancel" ? "stop" : l.op === "once" ? "pulse" : "code")}</span>
1522
+ <span class="m">${out ? "→ " : "← "}${text}</span></li>`;
1523
+ })
1524
+ .join("")}</ul>`;
1525
+ }
1526
+
1527
+ /** A snapshot or file in the details panel: a small thumb with its name and when it landed. */
1528
+ function shotHtml(g, n) {
1529
+ const i = model.gallery.indexOf(g);
1530
+ return `<div class="shot" data-gallery="${i}" title="${esc(g.name)}">
1531
+ ${thumbHtml(g)}
1532
+ <span class="nm">${esc(g.name)}</span>
1533
+ ${g.t != null ? `<span class="t">${fmtRel(g.t - n.start)}</span>` : ""}
1534
+ </div>`;
1398
1535
  }
1399
1536
 
1400
1537
  function evLi(i, n) {
@@ -1421,6 +1558,9 @@
1421
1558
  root.querySelectorAll("[data-gallery]").forEach((t) =>
1422
1559
  t.addEventListener("click", () => openGallery(model.gallery[+t.dataset.gallery]))
1423
1560
  );
1561
+ root.querySelectorAll("[data-link]").forEach((t) =>
1562
+ t.addEventListener("click", () => setPin(model.links[+t.dataset.link].t))
1563
+ );
1424
1564
  root.querySelectorAll("[data-item]").forEach((t) =>
1425
1565
  t.addEventListener("click", () => openItem(model.items[+t.dataset.item]))
1426
1566
  );
@@ -1561,7 +1701,6 @@
1561
1701
  const mark = (sel, idOf) =>
1562
1702
  sel.classed("err", (d) => !!set && set.has(idOf(d))).classed("err-path", (d) => !!set && path.has(idOf(d)));
1563
1703
  if (chartSvg) mark(chartSvg.selectAll("g.r"), (n) => n.id);
1564
- if (flameSvg) mark(flameSvg.selectAll("g.cell"), (n) => n.id);
1565
1704
  mark(d3.select(el.flow).selectAll("g.flow-card"), (n) => n.id);
1566
1705
  el.tree.querySelectorAll(".row").forEach((r) => {
1567
1706
  r.classList.toggle("err", !!set && set.has(r.dataset.id));
@@ -1641,7 +1780,7 @@
1641
1780
  ? (i) => i.kind === "info" || i.kind === "error"
1642
1781
  : tab === "asserts"
1643
1782
  ? (i) => i.kind === "pass" || i.kind === "fail"
1644
- : (i) => i.kind !== "info";
1783
+ : (i) => i.kind !== "info" && !(i.kind === "link" && i.e.link && i.e.link.op === "ref");
1645
1784
  const rows = [];
1646
1785
  for (let idx = 0; idx < model.items.length; idx++) {
1647
1786
  const i = model.items[idx];
@@ -1762,40 +1901,20 @@
1762
1901
  hovered = n; // the node `C` copies while its tooltip is up
1763
1902
  const v = n.v;
1764
1903
  const shown = n.depth === 0 ? model.data.name : displayName(n);
1765
- const badges = [
1766
- `<span class="badge"><span class="dot ${n.status}"></span>${esc(n.status)}</span>`,
1767
- v.mcp ? `<span class="badge mcp" title="Exposed over MCP">MCP · ${esc(v.mcp.mode)}</span>` : "",
1768
- n.depth === 0 ? '<span class="badge">om</span>' : "",
1769
- n.v.kind === "step" ? '<span class="badge" title="An inline step(…), not a reusable action">step</span>' : "",
1770
- ].join("");
1771
- const rows = [
1772
- ["Duration", fmtDur(n.duration)],
1773
- ["When", `${fmtRel(n.start)} → ${fmtRel(n.end)}`],
1774
- v.tags.length ? ["Tags", esc(v.tags.join(" · "))] : null,
1775
- userSite(n) ? [siteLabel(n) === "Called at" ? "Called" : "Defined", `<span class="mono">${esc(sourceOf(n))}</span>`] : null,
1776
- hasValue(n) ? ["Returns", `<span class="mono">${esc(valuePreview(n, 90))}</span>`] : null,
1777
- n.children.length ? ["Children", String(n.children.length)] : null,
1778
- n.marks.length ? ["Events", String(n.marks.length)] : null,
1904
+ const call = callOf(n);
1905
+ const meta = [
1906
+ `<span class="dot ${n.status}"></span>${esc(n.status)}`,
1907
+ esc(fmtDur(n.duration)),
1908
+ n.depth === 0 ? "om" : v.kind === "step" ? "step" : "",
1909
+ v.mcp ? `<span class="mcp">MCP · ${esc(v.mcp.mode)}</span>` : "",
1779
1910
  ].filter(Boolean);
1780
- const args = !promptOf(n) && v.args && v.args.length ? truncate(oneLine(v.args.length === 1 ? v.args[0] : v.args), 220) : "";
1781
- const params = schemaFields(v.inputSchema);
1782
- const pr = promptOf(n);
1783
- const promptRows = pr
1784
- ? `<div class="sec">Prompt</div><div class="tprompt">${esc(truncate(pr.message || "", 200))}</div>${
1785
- 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>` : ""
1786
- }`
1787
- : "";
1788
1911
  const fails = model.run.failures.filter((f) => f.action === n.id || f.action === n.name || f.action === v.path);
1789
1912
  return `<div class="tt">${esc(shown)}</div>
1790
- ${shown !== n.name ? `<div class="ts mono">${esc(truncate(n.name, 140))}</div>` : ""}
1791
- <div class="badges">${badges}</div>
1792
- ${v.description ? `<div class="summary">${esc(v.description.summary)}</div>` : '<div class="summary muted">No description.</div>'}
1793
- <div class="tkv">${rows.map(([k, val]) => `<span class="k">${k}</span><span>${val}</span>`).join("")}</div>
1794
- ${promptRows}
1795
- ${params.length ? `<div class="sec">Args schema</div><div class="params">${params.map(paramHtml).join("")}</div>` : ""}
1796
- ${args ? `<div class="sec">Args</div><div class="mono small">${esc(args)}</div>` : ""}
1797
- ${v.definedAt ? '<div class="hint">Press <kbd>C</kbd> to copy the path</div>' : ""}
1798
- ${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>` : ""}`;
1913
+ ${call ? `<div class="tcall">${callHtml(n, call)}</div>` : ""}
1914
+ <div class="tmeta">${meta.join('<span class="sep">·</span>')}</div>
1915
+ ${v.description ? `<div class="summary">${esc(truncate(v.description.summary, 240))}</div>` : ""}
1916
+ ${chipsHtml(v.tags, TIP_TAGS, 28)}
1917
+ ${fails.length ? `<div class="tfail">${esc(truncate(String(fails[0].error).split(String.fromCharCode(10))[0], 160))}</div>` : ""}`;
1799
1918
  }
1800
1919
  function galleryTip(x) {
1801
1920
  const img = x.image ? `<img src="${model.asset(x.f.asset)}" alt="">` : "";
@@ -1844,19 +1963,89 @@
1844
1963
  function codeOf(site) {
1845
1964
  return site && model.data.code ? model.data.code[site] : undefined;
1846
1965
  }
1847
- /** The popover's tabs for `n`, most useful first. */
1966
+ /**
1967
+ * How the user's code launched `n`, read off the source at its call site: the variable it
1968
+ * was bound to and the callee — `page = chromePage(…)`. Sites carry a line but no column,
1969
+ * so this takes the first call on that line; null when the site is library source.
1970
+ */
1971
+ function callExpr(n) {
1972
+ const site = n.v.calledAt;
1973
+ const c = site && !isLib(site) && codeOf(site);
1974
+ if (!c) return null;
1975
+ const at = Number((/:(\d+)$/.exec(site) || [])[1]);
1976
+ const lines = c.text.split("\n");
1977
+ const line = lines[at - c.start];
1978
+ if (line == null) return null;
1979
+ let call = null;
1980
+ for (const m of line.matchAll(/(?:^|[^\w$.])([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)\s*\(/g)) {
1981
+ if (!/^(if|for|while|switch|return|catch|function|await|typeof|new|async)$/.test(m[1])) {
1982
+ call = { callee: m[1], index: m.index };
1983
+ break;
1984
+ }
1985
+ }
1986
+ if (!call) return null;
1987
+ const DECL = /\b(?:const|let|var)\s+([\w$]+|\{[^}]*\}|\[[^\]]*\])/;
1988
+ const prefix = line.slice(0, call.index);
1989
+ const prev = lines[at - c.start - 1] || "";
1990
+ const m =
1991
+ DECL.exec(prefix) ||
1992
+ /^\s*([\w$.]+)\s*=(?!=)/.exec(prefix) ||
1993
+ (/^\s*(?:await\s+)?$/.test(prefix) && /=\s*(?:await\s*)?$/.test(prev) ? DECL.exec(prev) : null);
1994
+ return { callee: call.callee, bind: m ? m[1].replace(/\s+/g, " ") : "" };
1995
+ }
1996
+ /** The popover's tabs for `n`: what it is (prompt, source), then what went in and came out. */
1848
1997
  function codeTabs(n) {
1849
1998
  const tabs = [];
1850
1999
  if (promptOf(n)) tabs.push({ key: "prompt", label: "Prompt" });
1851
- if (hasValue(n)) tabs.push({ key: "value", label: "Returned" });
1852
2000
  const mine = userSite(n);
1853
2001
  if (codeOf(mine)) tabs.push({ key: "user", label: siteLabel(n) === "Called at" ? "Call site" : "Source", site: mine });
1854
2002
  const def = n.v.definedAt;
1855
2003
  if (def && def !== mine && codeOf(def)) tabs.push({ key: "def", label: isLib(def) ? "Definition (omkit)" : "Definition", site: def });
2004
+ if (hasArgs(n)) tabs.push({ key: "args", label: "Args" });
2005
+ if (hasValue(n)) tabs.push({ key: "value", label: "Returned" });
1856
2006
  return tabs;
1857
2007
  }
1858
2008
  function hasCode(n) {
1859
- return codeTabs(n).some((t) => t.key !== "value");
2009
+ return codeTabs(n).some((t) => t.key !== "value" && t.key !== "args");
2010
+ }
2011
+ const callMemo = new Map();
2012
+ /** {@link callExpr}, once per node — bar labels ask on every redraw. */
2013
+ function callOf(n) {
2014
+ if (!callMemo.has(n.id)) callMemo.set(n.id, n.depth === 0 ? null : callExpr(n));
2015
+ return callMemo.get(n.id);
2016
+ }
2017
+ /**
2018
+ * An action box's label: the call that launched it, as the tooltip shows it —
2019
+ * `watchBuildDirectory = watchDir(…)` — then omkit's name when the callee does not already
2020
+ * say it (`command(…) · npm run dev`). Just the name when the call is unknown.
2021
+ */
2022
+ function boxLabel(n) {
2023
+ const c = callOf(n);
2024
+ if (!c) return displayName(n);
2025
+ const call = `${c.bind ? `${c.bind} = ` : ""}${c.callee}(${hasArgs(n) ? "…" : ""})`;
2026
+ return c.callee === displayName(n) ? call : `${call} · ${displayName(n)}`;
2027
+ }
2028
+ function labelHtml(n) {
2029
+ const c = callOf(n);
2030
+ if (!c) return esc(displayName(n));
2031
+ const name = c.callee === displayName(n) ? "" : `<span class="sep"> · </span>${esc(displayName(n))}`;
2032
+ return `<span class="tcall">${callHtml(n, c)}</span>${name}`;
2033
+ }
2034
+ /** `page = chromePage(…)`, from {@link callExpr}. */
2035
+ function callHtml(n, call) {
2036
+ return `${call.bind ? `<span class="b">${esc(call.bind)}</span> = ` : ""}<span class="f">${esc(call.callee)}</span>(${hasArgs(n) ? "…" : ""})`;
2037
+ }
2038
+ function hasPopup(n) {
2039
+ return codeTabs(n).length > 0;
2040
+ }
2041
+ function popupTip(n) {
2042
+ return codeTabs(n).map((t) => esc(t.label)).join(" · ");
2043
+ }
2044
+ function hasArgs(n) {
2045
+ return !!(n.v.args && n.v.args.length);
2046
+ }
2047
+ function argsOf(n) {
2048
+ return n.v.args.length === 1 ? n.v.args[0] : n.v.args;
1860
2049
  }
1861
2050
  function relOf(site) {
1862
2051
  return (model.data.sources && model.data.sources[site]) || String(site).replace(/:\d+(:\d+)?$/, "");
@@ -1864,27 +2053,32 @@
1864
2053
  function hasValue(n) {
1865
2054
  return n.v.value !== undefined || !!n.v.valueNote;
1866
2055
  }
1867
- function valuePreview(n, max) {
1868
- if (n.v.value === undefined) return n.v.valueNote || "";
1869
- return truncate(oneLine(n.v.value), max);
1870
- }
1871
- /** The returned value in the code view: pretty JSON, highlighted, numbered, copyable. */
1872
- function valueHtml(n) {
1873
- if (n.v.value === undefined) {
1874
- return `<div class="pp"><div class="muted">${esc(n.v.valueNote || "Returned nothing.")}</div></div>`;
1875
- }
1876
- const text = json(n.v.value);
2056
+ /** A value in the code view: pretty JSON, highlighted, numbered, copyable. */
2057
+ function jsonHtml(value, what) {
2058
+ const text = json(value);
1877
2059
  const lines = highlight(text);
1878
- 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;
2060
+ const kind = Array.isArray(value) ? `array · ${value.length} items` : value === null ? "null" : typeof value === "object" ? `object · ${Object.keys(value).length} keys` : typeof value;
1879
2061
  return `<div class="cp-tools"><span class="muted">${esc(kind)} · ${lines.length} line${lines.length > 1 ? "s" : ""} · ${fmtSize(text.length)}</span>
1880
- <button class="btn solo small" data-a="copy-json">${svgIcon("copy")}Copy JSON</button></div>
2062
+ <button class="btn solo small" data-a="copy-json" data-what="${what}">${svgIcon("copy")}Copy JSON</button></div>
1881
2063
  <div class="cp-code">${lines
1882
2064
  .map((html, i) => `<div class="cl"><span class="ln">${i + 1}</span><span class="lc">${html || " "}</span></div>`)
1883
2065
  .join("")}</div>`;
1884
2066
  }
2067
+ function valueHtml(n) {
2068
+ if (n.v.value === undefined) {
2069
+ return `<div class="pp"><div class="muted">${esc(n.v.valueNote || "Returned nothing.")}</div></div>`;
2070
+ }
2071
+ return jsonHtml(n.v.value, "value");
2072
+ }
2073
+ function argsHtml(n) {
2074
+ return hasArgs(n) ? jsonHtml(argsOf(n), "args") : '<div class="pp"><div class="muted">No arguments.</div></div>';
2075
+ }
1885
2076
  function wireValue(root, n) {
1886
- const b = root.querySelector('[data-a="copy-json"]');
1887
- if (b) b.addEventListener("click", () => copy(json(n.v.value), "Copied the returned value"));
2077
+ root.querySelectorAll('[data-a="copy-json"]').forEach((b) =>
2078
+ b.addEventListener("click", () =>
2079
+ b.dataset.what === "args" ? copy(json(argsOf(n)), "Copied the args") : copy(json(n.v.value), "Copied the returned value")
2080
+ )
2081
+ );
1888
2082
  }
1889
2083
  function codeHtml(site) {
1890
2084
  const c = codeOf(site);
@@ -1927,7 +2121,7 @@
1927
2121
  if (codeFor === n.id && (!want || (toggle && codeTab === want))) return closeCode();
1928
2122
  const tabs = codeTabs(n);
1929
2123
  if (!tabs.length) return;
1930
- const tab = tabs.find((t) => t.key === want) || tabs.find((t) => t.key !== "value") || tabs[0];
2124
+ const tab = tabs.find((t) => t.key === want) || tabs.find((t) => t.key !== "value" && t.key !== "args") || tabs[0];
1931
2125
  codeTab = tab.key;
1932
2126
  hideTip();
1933
2127
  const reopen = codeFor !== n.id;
@@ -1943,7 +2137,9 @@
1943
2137
  <button class="icon-btn" data-a="close" aria-label="Close">${svgIcon("close")}</button>
1944
2138
  </div>
1945
2139
  ${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>` : ""}
1946
- <div class="cp-body">${tab.key === "prompt" ? promptHtml(promptOf(n)) : tab.key === "value" ? valueHtml(n) : codeHtml(site)}</div>`;
2140
+ <div class="cp-body">${
2141
+ tab.key === "prompt" ? promptHtml(promptOf(n)) : tab.key === "value" ? valueHtml(n) : tab.key === "args" ? argsHtml(n) : codeHtml(site)
2142
+ }</div>`;
1947
2143
  wireValue(codeEl, n);
1948
2144
  const cp = codeEl.querySelector('[data-a="copy"]');
1949
2145
  if (cp) cp.addEventListener("click", () => copy(rel, `Copied ${rel}`));
@@ -1964,7 +2160,7 @@
1964
2160
  if (at) at.scrollIntoView({ block: "center" });
1965
2161
  if (codeAway) document.removeEventListener("mousedown", codeAway, true);
1966
2162
  codeAway = (e) => {
1967
- if (!codeEl.contains(e.target) && !e.target.closest(".code-btn, [data-show-code], [data-show-value]")) closeCode();
2163
+ if (!codeEl.contains(e.target) && !e.target.closest(".code-btn, [data-show-code]")) closeCode();
1968
2164
  };
1969
2165
  setTimeout(() => codeAway && document.addEventListener("mousedown", codeAway, true), 0);
1970
2166
  }
@@ -2020,7 +2216,6 @@
2020
2216
  function renderAll() {
2021
2217
  measure();
2022
2218
  if (state.view === "timeline") renderTimeline();
2023
- if (state.view === "flame") renderFlame();
2024
2219
  if (state.view === "flow") renderFlow();
2025
2220
  renderOverview();
2026
2221
  }
@@ -2094,7 +2289,6 @@
2094
2289
  </div>
2095
2290
  <div class="btn-group">
2096
2291
  <button class="btn active" data-view="timeline">${svgIcon("timeline")}Timeline + Graph</button>
2097
- <button class="btn" data-view="flame">${svgIcon("flame")}Flame Graph</button>
2098
2292
  <button class="btn" data-view="flow">${svgIcon("flow")}Flow</button>
2099
2293
  </div>
2100
2294
  <div class="btn-group">
@@ -2108,20 +2302,15 @@
2108
2302
  <button class="btn solo" id="more" title="More">${svgIcon("more")}</button>
2109
2303
  </header>
2110
2304
  <div class="main">
2111
- <section class="card timeline" style="--tree-w:420px;--row-h:${ROW_H}px;position:relative">
2305
+ <section class="card timeline" style="--tree-w:360px;--row-h:${ROW_H}px;position:relative">
2112
2306
  <div class="tl-head">
2113
- <div class="cols"><div>Action / Event</div><div>Status</div><div>Duration</div></div>
2307
+ <div class="cols"><div>Action / Event</div></div>
2114
2308
  <svg></svg>
2115
2309
  </div>
2116
2310
  <div class="tl-body"><div class="tree"></div><div class="chart"></div></div>
2117
- <div class="flame-view" hidden style="top:34px"></div>
2118
2311
  <div class="flow-view" hidden></div>
2119
- </section>
2120
- <aside class="card details"></aside>
2121
- </div>
2122
- <div class="bottom">
2123
- <section class="card lower"></section>
2124
- <section class="card legend">
2312
+ <button class="info-btn legend-btn" id="legend-btn" aria-label="Legend" aria-expanded="false" title="Legend">${svgIcon("info")}</button>
2313
+ <div class="legend legend-pop" id="legend" hidden>
2125
2314
  <h3>Legend</h3>
2126
2315
  <div class="grid2">
2127
2316
  ${legendItem("--c1", "Action run", "bar")}
@@ -2132,9 +2321,16 @@
2132
2321
  ${legendItem("--fail", "Failure / error", "dot")}
2133
2322
  ${legendItem("--cancel", "Cancelled", "dash")}
2134
2323
  ${legendItem("--accent", "Pinned time", "line")}
2324
+ ${legendItem("--text-2", "Waits for an event", "dash")}
2325
+ ${legendItem("--text-2", "Call on a ref", "tick")}
2135
2326
  </div>
2136
2327
  <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>
2328
+ </div>
2137
2329
  </section>
2330
+ <aside class="card details"></aside>
2331
+ </div>
2332
+ <div class="bottom">
2333
+ <section class="card lower"></section>
2138
2334
  <section class="card overview"><h3>Overview</h3><div class="range"></div><svg></svg></section>
2139
2335
  </div>`;
2140
2336
  }
@@ -2143,7 +2339,9 @@
2143
2339
  const sw =
2144
2340
  shape === "bar"
2145
2341
  ? `<span style="width:16px;height:9px;border-radius:3px;background:${c}"></span>`
2146
- : shape === "dash"
2342
+ : shape === "tick"
2343
+ ? `<span style="width:16px;height:3px;border-radius:2px;background:${c}"></span>`
2344
+ : shape === "dash"
2147
2345
  ? `<span style="width:16px;height:0;border-top:2px dashed ${c}"></span>`
2148
2346
  : shape === "line"
2149
2347
  ? `<span style="width:2px;height:14px;background:${c};margin:0 7px"></span>`
@@ -2285,6 +2483,23 @@
2285
2483
  n = Math.max(1, Math.floor(n));
2286
2484
  return s.length > n ? `${s.slice(0, Math.max(1, n - 1))}…` : s;
2287
2485
  }
2486
+ /** A tag's colour: hashed from its text, so a tag reads the same in every view. */
2487
+ function tagColor(t) {
2488
+ let h = 0;
2489
+ for (let i = 0; i < t.length; i++) h = (h * 31 + t.charCodeAt(i)) | 0;
2490
+ return css(PALETTE[Math.abs(h) % PALETTE.length]);
2491
+ }
2492
+ /** One tag as a chip — the tree, tooltips and details share it; the flow draws the same in SVG. */
2493
+ function chipHtml(t, max) {
2494
+ const c = tagColor(t);
2495
+ return `<span class="chip" style="background:${alpha(c, 0.14)};border-color:${alpha(c, 0.45)}" title="${esc(t)}">${esc(truncate(t, max))}</span>`;
2496
+ }
2497
+ /** Up to `limit` tag chips, then a "+N" chip holding the rest. */
2498
+ function chipsHtml(tags, limit, max) {
2499
+ if (!tags || !tags.length) return "";
2500
+ const more = tags.length > limit ? `<span class="chip more" title="${esc(tags.slice(limit).join(", "))}">+${tags.length - limit}</span>` : "";
2501
+ return `<span class="chips">${tags.slice(0, limit).map((t) => chipHtml(t, max)).join("")}${more}</span>`;
2502
+ }
2288
2503
  function displayName(n) {
2289
2504
  if (n.depth === 0) return "main";
2290
2505
  const p = promptOf(n);