transcript-viewer 0.11.0__tar.gz → 0.11.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/PKG-INFO +9 -3
  2. {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/README.md +8 -2
  3. {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/pyproject.toml +1 -1
  4. {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/src/transcript_viewer/page.html +171 -25
  5. {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/tests/page.test.js +179 -5
  6. {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/uv.lock +1 -1
  7. {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/.coverage +0 -0
  8. {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/.github/workflows/publish.yml +0 -0
  9. {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/.github/workflows/test.yml +0 -0
  10. {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/.gitignore +0 -0
  11. {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/design/Anchor.dc.html +0 -0
  12. {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/design/Dense.dc.html +0 -0
  13. {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/design/Hover.dc.html +0 -0
  14. {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/design/Main.dc.html +0 -0
  15. {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/design/Quiet.dc.html +0 -0
  16. {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/design/canvas.json +0 -0
  17. {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/design/delegation-timeline.html +0 -0
  18. {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/src/transcript_viewer/__init__.py +0 -0
  19. {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/src/transcript_viewer/ai.py +0 -0
  20. {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/src/transcript_viewer/cli.py +0 -0
  21. {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/src/transcript_viewer/config.py +0 -0
  22. {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/src/transcript_viewer/corpus.py +0 -0
  23. {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/src/transcript_viewer/fetch.py +0 -0
  24. {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/src/transcript_viewer/library.py +0 -0
  25. {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/src/transcript_viewer/store.py +0 -0
  26. {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/src/transcript_viewer/viewer.py +0 -0
  27. {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/tests/fixtures/big-command.json +0 -0
  28. {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/tests/test_ai.py +0 -0
  29. {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/tests/test_cli.py +0 -0
  30. {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/tests/test_config.py +0 -0
  31. {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/tests/test_corpus.py +0 -0
  32. {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/tests/test_fetch.py +0 -0
  33. {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/tests/test_library.py +0 -0
  34. {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/tests/test_page.py +0 -0
  35. {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/tests/test_readme.py +0 -0
  36. {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/tests/test_store.py +0 -0
  37. {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/tests/test_stream.py +0 -0
  38. {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/tests/test_style.py +0 -0
  39. {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/tests/test_tidiness.py +0 -0
  40. {transcript_viewer-0.11.0 → transcript_viewer-0.11.2}/tests/test_viewer.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: transcript-viewer
3
- Version: 0.11.0
3
+ Version: 0.11.2
4
4
  Summary: Browse agent transcripts in a local, dependency-free web viewer.
5
5
  License: MIT
6
6
  Requires-Python: >=3.12
@@ -182,8 +182,14 @@ so. Nothing is left behind and nothing is copied twice.
182
182
  A run that delegated shows a **timeline** above its steps: one lane per
183
183
  subagent, laid out on the clock. It exists for the one thing a nested box
184
184
  cannot say — that two subagents were running at the same moment, which 25 pairs
185
- in the corpus this was built against were. Clicking a lane opens the box the
186
- transcript already has for it rather than showing the same thing twice.
185
+ in the corpus this was built against were. Two bars in the same column of the
186
+ chart is what says it; overlap gets no colour of its own, since a second
187
+ encoding of the same fact is noise.
188
+
189
+ Clicking a lane opens the box the transcript already has for it rather than
190
+ showing the same thing twice, drawing the run out far enough to reach it first:
191
+ a long run paints 250 steps at a time, and in one real session every delegation
192
+ happens after step 1,231.
187
193
 
188
194
  Where a session spans days but holds hours of work — one runs 10.3 days and
189
195
  contains 5.3 hours, with 31 gaps over an hour — the quiet stretches are skipped
@@ -169,8 +169,14 @@ so. Nothing is left behind and nothing is copied twice.
169
169
  A run that delegated shows a **timeline** above its steps: one lane per
170
170
  subagent, laid out on the clock. It exists for the one thing a nested box
171
171
  cannot say — that two subagents were running at the same moment, which 25 pairs
172
- in the corpus this was built against were. Clicking a lane opens the box the
173
- transcript already has for it rather than showing the same thing twice.
172
+ in the corpus this was built against were. Two bars in the same column of the
173
+ chart is what says it; overlap gets no colour of its own, since a second
174
+ encoding of the same fact is noise.
175
+
176
+ Clicking a lane opens the box the transcript already has for it rather than
177
+ showing the same thing twice, drawing the run out far enough to reach it first:
178
+ a long run paints 250 steps at a time, and in one real session every delegation
179
+ happens after step 1,231.
174
180
 
175
181
  Where a session spans days but holds hours of work — one runs 10.3 days and
176
182
  contains 5.3 hours, with 31 gaps over an hour — the quiet stretches are skipped
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "transcript-viewer"
3
- version = "0.11.0"
3
+ version = "0.11.2"
4
4
  description = "Browse agent transcripts in a local, dependency-free web viewer."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -371,6 +371,8 @@ pre{background:var(--sunk);border-radius:7px;padding:10px;overflow:auto;font-siz
371
371
  .branch .inner{padding:6px 14px 4px 22px;border-top:1px solid var(--line);background:var(--sunk)}
372
372
  .branch .inner .step{border-left-color:var(--line)}
373
373
  .miss{padding:9px 12px;color:var(--dim);font-size:12.5px}
374
+ /* Where a lane just sent you. Long enough to notice, short enough to forget. */
375
+ .branch.found{box-shadow:0 0 0 2px var(--accent)}
374
376
 
375
377
  /* ---- delegation timeline ----
376
378
  Nested boxes cannot show that two subagents ran at once, and 25 pairs in the
@@ -396,7 +398,6 @@ pre{background:var(--sunk);border-radius:7px;padding:10px;overflow:auto;font-siz
396
398
  .lane.tight .track{height:13px}
397
399
  .track .rule{position:absolute;left:0;right:0;top:50%;height:1px;background:var(--line)}
398
400
  .track .tick{position:absolute;top:0;bottom:0;width:1px;background:var(--line);opacity:.45}
399
- .track .band{position:absolute;top:0;bottom:0;background:var(--hl)}
400
401
  /* A floor, so a four-minute subagent in a thirty-hour run is still a target.
401
402
  Where a bar is at the floor its width is not its duration — the card and
402
403
  the tooltip carry the real one. */
@@ -420,6 +421,9 @@ pre{background:var(--sunk);border-radius:7px;padding:10px;overflow:auto;font-siz
420
421
  .gapmark:after{content:"";position:absolute;left:-2px;top:0;bottom:0;width:5px;
421
422
  background:var(--bg);opacity:.9;border-left:1px dashed var(--muted);
422
423
  border-right:1px dashed var(--muted);opacity:.35}
424
+ /* Conversations that belong to the record rather than to any step. */
425
+ .loose{margin-top:18px;padding-top:12px;border-top:1px solid var(--line)}
426
+ .loose-head{font:11px var(--font-mono);color:var(--muted);margin-bottom:8px}
423
427
  .lanes-legend{display:flex;align-items:center;gap:14px;margin-top:10px;padding-top:9px;
424
428
  border-top:1px solid var(--line);font-size:11px;color:var(--muted);flex-wrap:wrap}
425
429
  .lanes-legend i{display:inline-block;width:16px;height:8px;border-radius:2px;
@@ -2366,6 +2370,9 @@ function haystack(s){
2366
2370
  Where they do not, the same lanes are laid out by step position and the axis
2367
2371
  says so, rather than inventing a duration. */
2368
2372
 
2373
+ /* "1 subagent" and "2 subagents", said once rather than at each call site. */
2374
+ const many = (n, one, more) => `${num(n)} ${n === 1 ? one : more || one + "s"}`;
2375
+
2369
2376
  const at = (s) => {
2370
2377
  const t = s && s.timestamp;
2371
2378
  if (!t) return null;
@@ -2419,6 +2426,36 @@ function laneIndex(t) {
2419
2426
  });
2420
2427
  };
2421
2428
  walk(t, 0);
2429
+
2430
+ // A subagent that no call refers to. ATIF links delegation from the result of
2431
+ // the call that spawned it, but not every format has a call to link from:
2432
+ // MALT's records carry an advisor, an agent and its raters as trajectories in
2433
+ // their own right, spawned by nothing. Walking only the references missed all
2434
+ // of them, and a record with ten subagents drew no timeline at all.
2435
+ const linked = new Set(lanes.map((l) => l.id));
2436
+ const loose = (parent, depth) => {
2437
+ for (const sub of parent.subagent_trajectories || []) {
2438
+ if (!linked.has(sub.trajectory_id)) {
2439
+ const x = sub.extra || {};
2440
+ lanes.push({
2441
+ id: sub.trajectory_id,
2442
+ kind: x.agentType || sub.agent?.name || "subagent",
2443
+ said: x.description || "",
2444
+ steps: (sub.steps || []).length,
2445
+ tools: [...new Set((sub.steps || []).flatMap(
2446
+ (s) => (s.tool_calls || []).map((c) => c.function_name)))],
2447
+ span: spanMs(sub.steps),
2448
+ fromStep: null,
2449
+ fromIndex: 0,
2450
+ depth,
2451
+ });
2452
+ linked.add(sub.trajectory_id);
2453
+ }
2454
+ loose(sub, depth + 1);
2455
+ }
2456
+ };
2457
+ loose(t, 0);
2458
+
2422
2459
  return lanes;
2423
2460
  }
2424
2461
 
@@ -2529,10 +2566,17 @@ function drawLanes(t) {
2529
2566
  width:${Math.max(100 / Math.max(1, steps), 0.6)}%" title="${esc(lane.kind)}"
2530
2567
  onmouseenter="laneCard(event,'${esc(lane.id)}')" onmouseleave="laneCard(null)"
2531
2568
  onclick="openLane('${esc(lane.id)}')"></button>`)).join("");
2532
- return laneShell(LANES.length, "", rows,
2533
- laneRow({ kind: "main thread", steps, main: true }, tight,
2569
+ return laneShell({
2570
+ count: LANES.length,
2571
+ rows,
2572
+ mainRow: laneRow({ kind: "main thread", steps, main: true }, tight,
2534
2573
  `<span class="bar main" style="left:0;width:100%"></span>`),
2535
- "", "no timestamps — laid out by step");
2574
+ marks: "",
2575
+ // No clock to lay anything against, so no duration is claimed and no
2576
+ // overlap either: without times, two bars sitting side by side would
2577
+ // say something the log never recorded.
2578
+ note: "laid out by step · these steps carry no times",
2579
+ });
2536
2580
  }
2537
2581
 
2538
2582
  // Everything the run touched, parent and children alike.
@@ -2547,11 +2591,6 @@ function drawLanes(t) {
2547
2591
  const scale = timeScale(lo, hi, busy);
2548
2592
  const bands = together(timed);
2549
2593
 
2550
- const bandLayer = bands.map(([a, b]) => {
2551
- const left = scale.posOf(a);
2552
- return `<span class="band" style="left:${left}%;width:${Math.max(scale.posOf(b) - left, 0.3)}%"></span>`;
2553
- }).join("");
2554
-
2555
2594
  const breaks = scale.gaps.map((g) =>
2556
2595
  `<span class="gapmark" style="left:${g.at}%" title="nothing running for ${esc(g.took)}"></span>`
2557
2596
  ).join("");
@@ -2579,13 +2618,24 @@ function drawLanes(t) {
2579
2618
  return { pos: (m.pos / 100) * scale.room, label: m.label };
2580
2619
  });
2581
2620
 
2582
- const note = scale.gaps.length
2583
- ? `${num(scale.gaps.length)} quiet stretch${scale.gaps.length === 1 ? "" : "es"} skipped · ${duration(scale.skipped)} idle`
2584
- : "on the clock · click a bar to open it";
2585
-
2586
- return laneShell(LANES.length, duration(scale.kept), rows, mainRow,
2587
- marks.map((m) => `<span style="left:${m.pos}%">${esc(m.label)}</span>`).join(""),
2588
- note, bandLayer + breaks, bands.length);
2621
+ // What the axis leaves out, in the reader's terms. How many separate gaps
2622
+ // were collapsed is a fact about the drawing, not about the run, so it is not
2623
+ // said; how much time is missing from the picture very much is.
2624
+ const hidden = scale.gaps.length ? `${duration(scale.skipped)} of waiting hidden` : "";
2625
+ const note = [hidden, "click a bar to open it"].filter(Boolean).join(" · ");
2626
+
2627
+ const [shownAs, wholeAs] = durationPair(scale.kept, hi - lo);
2628
+ return laneShell({
2629
+ count: LANES.length,
2630
+ shown: shownAs,
2631
+ whole: wholeAs,
2632
+ rows,
2633
+ mainRow,
2634
+ marks: marks.map((m) => `<span style="left:${m.pos}%">${esc(m.label)}</span>`).join(""),
2635
+ note,
2636
+ layer: breaks,
2637
+ overlaps: bands.length,
2638
+ });
2589
2639
  }
2590
2640
 
2591
2641
  /* One row: who, how many steps, and the track it sits on. */
@@ -2602,14 +2652,35 @@ function laneRow(lane, tight, inner) {
2602
2652
  }
2603
2653
 
2604
2654
  /* The frame around the lanes: what it is, the axis, and what was left out. */
2605
- function laneShell(count, span, rows, mainRow, marks, note, layer, overlaps) {
2655
+ /* What the header says about a run. Every clause is computed, and each is true
2656
+ of any transcript rather than of the one it was written against:
2657
+
2658
+ - the count of subagents;
2659
+ - how much time the axis is showing, and — only when they differ — the whole
2660
+ stretch it was drawn from. Saying "31h" of a run that ran ten days would be
2661
+ wrong, and it is the axis that was shortened, not the run;
2662
+ - how many times two or more were going at once, which is what the view is
2663
+ for. Called "overlapped" rather than "stretches", because the skipped
2664
+ stretches below are the opposite thing and the word was doing both jobs.
2665
+ */
2666
+ function laneHeader({ count, shown, whole, overlaps }) {
2667
+ const parts = [many(count, "subagent")];
2668
+ if (shown) parts.push(whole && whole !== shown ? `${shown} shown of ${whole}` : shown);
2669
+ if (overlaps) {
2670
+ parts.push(
2671
+ `<em>overlapped ${overlaps === 1 ? "once" : many(overlaps, "time")}</em>`,
2672
+ );
2673
+ }
2674
+ return parts;
2675
+ }
2676
+
2677
+ function laneShell({ count, shown, whole, rows, mainRow, marks, note, layer, overlaps }) {
2678
+ const parts = laneHeader({ count, shown, whole, overlaps });
2606
2679
  return `
2607
2680
  <details id="lanes" open>
2608
2681
  <summary>
2609
2682
  <b>Delegation</b>
2610
- <span class="fine">${num(count)} subagent${count === 1 ? "" : "s"}${span ? " · " + span : ""}${
2611
- overlaps ? ` · <em>${num(overlaps)} stretch${overlaps === 1 ? "" : "es"} with two or more at once</em>` : ""
2612
- }</span>
2683
+ <span class="fine">${parts.join(" · ")}</span>
2613
2684
  </summary>
2614
2685
  <div class="lanes-body">
2615
2686
  <div class="lane-axis${rows.includes("lane tight") ? " tight" : ""}">
@@ -2623,7 +2694,6 @@ function laneShell(count, span, rows, mainRow, marks, note, layer, overlaps) {
2623
2694
  <div class="lanes-legend">
2624
2695
  <span><i style="background:var(--ink);opacity:.85"></i>main thread</span>
2625
2696
  <span><i style="background:var(--accent);opacity:.85"></i>subagent</span>
2626
- ${overlaps ? `<span><i style="background:var(--hl)"></i>two or more at once</span>` : ""}
2627
2697
  <span class="lanes-note">${esc(note)}</span>
2628
2698
  </div>
2629
2699
  </div>
@@ -2671,16 +2741,73 @@ function laneCard(ev, id) {
2671
2741
  /* Open the subagent a lane stands for: scroll to the step that called it and
2672
2742
  unfold the box that is already there, rather than inventing a second place
2673
2743
  for the same thing to live. */
2744
+ /* Open the subagent a lane stands for: reach the step that called it, unfold
2745
+ the box already there, and go to it.
2746
+
2747
+ A long run is drawn 250 steps at a time. In one real session all thirty
2748
+ delegations happen after step 1,231, so the box a lane pointed at had never
2749
+ been painted and the click did nothing at all. The run is drawn far enough
2750
+ first. A lane from a record where nothing spawned it is found in the block
2751
+ after the run instead — which only exists in the plain view, so a lens or a
2752
+ search is cleared to get back to it rather than failing quietly. */
2674
2753
  function openLane(id) {
2675
2754
  laneCard(null);
2676
- const box = document.querySelector(`details.branch[data-lane="${CSS.escape(id)}"]`);
2677
- if (!box) return;
2755
+ const lane = LANES.find((l) => l.id === id);
2756
+ if (!lane) return;
2757
+
2758
+ const find = () =>
2759
+ document.querySelector(`details.branch[data-lane="${CSS.escape(id)}"]`);
2760
+
2761
+ let box = find();
2762
+ if (!box) {
2763
+ let repaint = false;
2764
+ if (lane.fromStep === null && (lens !== "all" || query)) {
2765
+ lens = "all";
2766
+ query = "";
2767
+ repaint = true;
2768
+ }
2769
+ if (lane.fromIndex >= limit) {
2770
+ // Far enough to include it, and a little beyond so it is not the last row.
2771
+ limit = lane.fromIndex + PAGE_SIZE;
2772
+ repaint = true;
2773
+ }
2774
+ if (repaint) render();
2775
+ box = find();
2776
+ }
2777
+ if (!box) {
2778
+ note("That subagent is not in this view.");
2779
+ return;
2780
+ }
2781
+
2678
2782
  box.open = true;
2679
- box.scrollIntoView({ behavior: "smooth", block: "center" });
2680
2783
  box.classList.add("found");
2784
+ // After the fold, not before: opening changes the height, and scrolling to
2785
+ // where the box used to end leaves the reader above what they asked for.
2786
+ requestAnimationFrame(() =>
2787
+ box.scrollIntoView({ behavior: "smooth", block: "center" }),
2788
+ );
2681
2789
  setTimeout(() => box.classList.remove("found"), 1400);
2682
2790
  }
2683
2791
 
2792
+ /* Subagents that no call refers to. They are drawn after the run rather than
2793
+ inside it, because there is no step they belong to: MALT records an advisor,
2794
+ an agent and its raters as trajectories spawned by nothing, and until now the
2795
+ viewer held them without ever showing them. The timeline made that visible by
2796
+ pointing at boxes that were not there. */
2797
+ function looseSubagents(t){
2798
+ const linked=new Set();
2799
+ const mark=p=>{
2800
+ for(const s of p.steps||[]){
2801
+ for(const r of s.observation?.results||[]){
2802
+ for(const ref of r.subagent_trajectory_ref||[])linked.add(ref.trajectory_id);
2803
+ }
2804
+ }
2805
+ for(const sub of p.subagent_trajectories||[])mark(sub);
2806
+ };
2807
+ mark(t);
2808
+ return (t.subagent_trajectories||[]).filter(s=>!linked.has(s.trajectory_id));
2809
+ }
2810
+
2684
2811
  function branchIndex(t){
2685
2812
  const out=[];
2686
2813
  t.steps.forEach((s,i)=>{
@@ -2787,7 +2914,16 @@ function trajectoryTab(t,e,branches,branchSteps,counts,q){
2787
2914
  ${shown.map(({s,i})=>step(s,t,0,i,"")).join("")
2788
2915
  ||`<div class="empty">Nothing matches.</div>`}
2789
2916
  ${total>shown.length?`<div class="more"><button onclick="more()">Show more —
2790
- ${num(shown.length)} of ${num(total)} steps</button></div>`:""}`;
2917
+ ${num(shown.length)} of ${num(total)} steps</button></div>`:""}
2918
+ ${(()=>{
2919
+ // Only in the plain view: a lens or a search is a question about the
2920
+ // steps of this run, and answering it with whole other conversations
2921
+ // would be answering something else.
2922
+ if(lens!=="all"||q)return "";
2923
+ const loose=looseSubagents(t);
2924
+ return loose.length?`<div class="loose"><div class="loose-head">
2925
+ ${many(loose.length,"other conversation")} in this record — no step spawned them</div>
2926
+ ${loose.map(sub=>branch({trajectory_id:sub.trajectory_id},t,0)).join("")}</div>`:""})()}`;
2791
2927
  }
2792
2928
 
2793
2929
  /* The switch stays visible when AI is off for a transcript — otherwise there
@@ -2936,6 +3072,16 @@ function spanOf(steps){
2936
3072
  return ms>0?ms:null;
2937
3073
  }
2938
3074
 
3075
+ /* Two durations that will be read against each other, in one unit, so "75m
3076
+ shown of 1.6h" cannot happen — the pair is scaled by the larger of them. */
3077
+ function durationPair(small, large){
3078
+ if(small==null||large==null)return [duration(small),duration(large)];
3079
+ const unit=large/1000<90?1000:large/60000<90?60000:large/3600000<48?3600000:86400000;
3080
+ const suffix=unit===1000?"s":unit===60000?"m":unit===3600000?"h":"d";
3081
+ const show=v=>{const n=v/unit;return (n<10?n.toFixed(1).replace(/\.0$/,""):String(Math.round(n)))+suffix};
3082
+ return [show(small),show(large)];
3083
+ }
3084
+
2939
3085
  function duration(ms){
2940
3086
  if(ms==null)return null;
2941
3087
  // A whole number reads better without the decimal: 2h, not 2.0h.
@@ -66,8 +66,31 @@ for (const id of [
66
66
  const SAFE_FETCH = () =>
67
67
  Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
68
68
 
69
+ /* Lookup by id, as the page expects. Reset with fetch on every run: a test that
70
+ stubs it to watch one element used to leave the next test's render() holding
71
+ an object with no focus(), which fails somewhere else entirely. */
72
+ const SAFE_BY_ID = () => null;
73
+
74
+ /* Looking for an element by selector, against what was actually painted. The
75
+ stub DOM holds markup as a string rather than a tree, so this answers the one
76
+ question the page asks — is this box on the page — from that string. */
77
+ const SAFE_QUERY = (sel) => {
78
+ const m = /\[data-lane="([^"]+)"\]/.exec(sel);
79
+ if (!m) return null;
80
+ const html = (global.main && global.main.innerHTML) || "";
81
+ return html.includes(`data-lane="${m[1]}"`)
82
+ ? { open: false, classList: { add() {}, remove() {} }, scrollIntoView() {} }
83
+ : null;
84
+ };
85
+
86
+ /* The page escapes an id before putting it in a selector; the stub DOM has no
87
+ CSS object, so provide the one method that is used. */
88
+ global.CSS = { escape: (v) => String(v).replace(/["\\]/g, "\\$&") };
89
+
69
90
  const run = (body) => {
70
91
  global.fetch = SAFE_FETCH;
92
+ global.document.getElementById = SAFE_BY_ID;
93
+ global.document.querySelector = SAFE_QUERY;
71
94
  return eval(script + "\n;(() => {" + body + "})()");
72
95
  };
73
96
 
@@ -1840,8 +1863,30 @@ test("the timeline says how many ran at once", () => {
1840
1863
  const html = run(`return drawLanes(${JSON.stringify(TWO_AT_ONCE)})`);
1841
1864
  assert.match(html, /id="lanes"/);
1842
1865
  assert.match(html, /2 subagents/);
1843
- assert.match(html, /two or more at once/);
1844
- assert.match(html, /class="band"/, "the overlap is drawn, not only counted");
1866
+ assert.match(html, /overlapped once/, "one overlap reads as once, not '1 times'");
1867
+ // Overlap is not given a colour of its own: two bars in the same column of
1868
+ // the chart already say it, and a second encoding of the same fact is noise.
1869
+ assert.ok(!/class="band"/.test(html), "the lanes are the encoding");
1870
+ });
1871
+
1872
+ test("the header counts, and counts in English", () => {
1873
+ const one = run(`return laneHeader({count: 1, shown: "5m", overlaps: 0}).join(" · ")`);
1874
+ assert.strictEqual(one, "1 subagent · 5m");
1875
+
1876
+ const some = run(`return laneHeader({count: 12, shown: "2h", overlaps: 4}).join(" · ")`);
1877
+ assert.match(some, /^12 subagents · 2h · /);
1878
+ assert.match(some, /overlapped 4 times/);
1879
+ });
1880
+
1881
+ test("the header never passes off the drawn time as the whole run", () => {
1882
+ // The axis skips the waiting, so a run of ten days can show thirty-one hours.
1883
+ // Saying "31h" alone would be false about the run.
1884
+ const elided = run(`return laneHeader({count: 3, shown: "31h", whole: "10d"}).join(" · ")`);
1885
+ assert.match(elided, /31h shown of 10d/);
1886
+
1887
+ // Where nothing was skipped the two are the same and it says it once.
1888
+ const straight = run(`return laneHeader({count: 3, shown: "20m", whole: "20m"}).join(" · ")`);
1889
+ assert.strictEqual(straight, "3 subagents · 20m");
1845
1890
  });
1846
1891
 
1847
1892
  test("a transcript that delegated nothing gets no timeline at all", () => {
@@ -1856,8 +1901,10 @@ test("without timestamps the lanes fall back to step position and say so", () =>
1856
1901
  for (const s of sub.steps) delete s.timestamp;
1857
1902
  }
1858
1903
  const html = run(`return drawLanes(${JSON.stringify(untimed)})`);
1859
- assert.match(html, /no timestamps/, "it must not imply a duration it does not have");
1904
+ assert.match(html, /carry no times/, "it must not imply a duration it does not have");
1860
1905
  assert.ok(!/class="band"/.test(html), "and must not claim overlap it cannot know");
1906
+ assert.ok(!/overlapped/.test(html), "nor say two things ran together");
1907
+ assert.ok(!/hidden/.test(html), "nor that anything was left out");
1861
1908
  });
1862
1909
 
1863
1910
  test("the axis is read in whole units, not seven of them", () => {
@@ -1946,7 +1993,134 @@ test("what was skipped is stated, not swallowed", () => {
1946
1993
  ] }],
1947
1994
  };
1948
1995
  const html = run(`return drawLanes(${JSON.stringify(t)})`);
1949
- assert.match(html, /quiet stretch/, "a reader must be told time was left out");
1950
- assert.match(html, /idle/);
1996
+ assert.match(html, /of waiting hidden/, "a reader must be told time was left out");
1997
+ assert.match(html, /shown of/, "and that the axis is shorter than the run");
1951
1998
  assert.match(html, /class="gapmark"/, "and see where");
1999
+ // How many gaps were collapsed is a fact about the drawing, not the run.
2000
+ assert.ok(!/\d+ quiet stretch/.test(html), "that is not the reader's business");
2001
+ });
2002
+
2003
+ test("every phrase in the timeline is computed, none of it written per run", () => {
2004
+ // The header and note are built from counts and durations alone: the same run
2005
+ // renders the same words, and a different one renders different ones for the
2006
+ // same reason. Nothing here is specific to any transcript.
2007
+ const twice = [1, 2].map(() =>
2008
+ run(`return drawLanes(${JSON.stringify(TWO_AT_ONCE)})`),
2009
+ );
2010
+ assert.strictEqual(twice[0], twice[1], "the same run must read the same way");
2011
+
2012
+ const bigger = JSON.parse(JSON.stringify(TWO_AT_ONCE));
2013
+ bigger.subagent_trajectories[1].steps = [
2014
+ { step_id: 1, source: "agent", timestamp: "2026-01-01T10:30:00Z" },
2015
+ { step_id: 2, source: "agent", timestamp: "2026-01-01T10:40:00Z" },
2016
+ ];
2017
+ const changed = run(`return drawLanes(${JSON.stringify(bigger)})`);
2018
+ assert.notStrictEqual(changed, twice[0], "a different run must read differently");
2019
+ assert.ok(!/overlapped/.test(changed), "and this one no longer overlaps");
2020
+ });
2021
+
2022
+ test("a subagent no call spawned still gets a lane and a box", () => {
2023
+ // MALT records an advisor, an agent and its raters as trajectories in their
2024
+ // own right. Walking only the call references found none of them, so a record
2025
+ // with ten subagents drew no timeline and showed none of them anywhere.
2026
+ const t = {
2027
+ steps: [{ step_id: 1, source: "user", message: "do the thing",
2028
+ timestamp: "2026-01-01T10:00:00Z" }],
2029
+ subagent_trajectories: [
2030
+ { trajectory_id: "advisor", agent: { name: "metr-malt" }, extra: {},
2031
+ steps: [{ step_id: 1, source: "agent", timestamp: "2026-01-01T10:00:05Z" },
2032
+ { step_id: 2, source: "agent", timestamp: "2026-01-01T10:02:00Z" }] },
2033
+ { trajectory_id: "rater", agent: { name: "metr-malt" }, extra: {},
2034
+ steps: [{ step_id: 1, source: "agent", timestamp: "2026-01-01T10:01:00Z" },
2035
+ { step_id: 2, source: "agent", timestamp: "2026-01-01T10:03:00Z" }] },
2036
+ ],
2037
+ };
2038
+
2039
+ const lanes = run(`return laneIndex(${JSON.stringify(t)})`);
2040
+ assert.strictEqual(lanes.length, 2, "both belong on the timeline");
2041
+ assert.deepStrictEqual(lanes.map((l) => l.fromStep), [null, null],
2042
+ "and neither claims a step spawned it");
2043
+
2044
+ const loose = run(`return looseSubagents(${JSON.stringify(t)}).map(s=>s.trajectory_id)`);
2045
+ assert.deepStrictEqual(loose, ["advisor", "rater"],
2046
+ "the transcript must show them somewhere, or the lanes point at nothing");
2047
+ });
2048
+
2049
+ test("a subagent a call did spawn is not also shown loose", () => {
2050
+ const t = {
2051
+ steps: [{ step_id: 1, source: "agent",
2052
+ tool_calls: [{ tool_call_id: "c1", function_name: "Task" }],
2053
+ observation: { results: [{ source_call_id: "c1",
2054
+ subagent_trajectory_ref: [{ trajectory_id: "a" }] }] } }],
2055
+ subagent_trajectories: [{ trajectory_id: "a", agent: { name: "claude-code" },
2056
+ extra: {}, steps: [] }],
2057
+ };
2058
+ const loose = run(`return looseSubagents(${JSON.stringify(t)})`);
2059
+ assert.deepStrictEqual(loose, [], "it already has a home in the run");
2060
+ });
2061
+
2062
+ test("two durations read against each other share a unit", () => {
2063
+ // "75m shown of 1.6h" is two scales in one sentence.
2064
+ const pair = run(`return durationPair(75*60000, 96*60000)`);
2065
+ assert.deepStrictEqual(pair, ["1.3h", "1.6h"]);
2066
+
2067
+ const wide = run(`return durationPair(31*3600000, 10*86400000)`);
2068
+ assert.deepStrictEqual(wide, ["1.3d", "10d"]);
2069
+ });
2070
+
2071
+ test("a lane reaches its box even when the run has not been drawn that far", () => {
2072
+ // The bug: a long run paints 250 steps at a time, and in one real session all
2073
+ // thirty delegations happen after step 1,231 — so the box a lane pointed at
2074
+ // had never been painted and clicking did nothing whatever.
2075
+ const steps = [];
2076
+ for (let i = 0; i < 400; i++) {
2077
+ steps.push({ step_id: i + 1, source: "agent", message: "working" });
2078
+ }
2079
+ steps[300] = {
2080
+ step_id: 301, source: "agent",
2081
+ tool_calls: [{ tool_call_id: "c1", function_name: "Task" }],
2082
+ observation: { results: [{ source_call_id: "c1",
2083
+ subagent_trajectory_ref: [{ trajectory_id: "far" }] }] },
2084
+ };
2085
+ const t = {
2086
+ steps,
2087
+ subagent_trajectories: [{ trajectory_id: "far", agent: { name: "claude-code" },
2088
+ extra: { agentType: "Explore" }, steps: [] }],
2089
+ };
2090
+
2091
+ const out = run(
2092
+ setup({ ...ROW, key: "k" }) + `
2093
+ traj = ${JSON.stringify(t)};
2094
+ cur = "k"; lens = "all"; query = ""; limit = 250;
2095
+ render();
2096
+ const before = !!document.querySelector('details.branch[data-lane="far"]');
2097
+ LANES = laneIndex(traj);
2098
+ openLane("far");
2099
+ const after = !!document.querySelector('details.branch[data-lane="far"]');
2100
+ return { before, after, limit };
2101
+ `);
2102
+
2103
+ assert.strictEqual(out.before, false, "the box starts undrawn — this is the bug");
2104
+ assert.strictEqual(out.after, true, "and the click has to reach it");
2105
+ assert.ok(out.limit > 300, `the run was drawn far enough (limit ${out.limit})`);
2106
+ });
2107
+
2108
+ test("a lane whose box is filtered away brings the view back rather than failing", () => {
2109
+ const t = {
2110
+ steps: [{ step_id: 1, source: "user", message: "go" }],
2111
+ subagent_trajectories: [{ trajectory_id: "loose", agent: { name: "metr-malt" },
2112
+ extra: {}, steps: [] }],
2113
+ };
2114
+ const out = run(
2115
+ setup({ ...ROW, key: "k" }) + `
2116
+ traj = ${JSON.stringify(t)};
2117
+ cur = "k"; lens = "tools"; query = "nothing matches this"; limit = 250;
2118
+ render();
2119
+ LANES = laneIndex(traj);
2120
+ openLane("loose");
2121
+ return { lens, query, found: !!document.querySelector('details.branch[data-lane="loose"]') };
2122
+ `);
2123
+ assert.strictEqual(out.lens, "all", "the lens is cleared to reach it");
2124
+ assert.strictEqual(out.query, "");
2125
+ assert.strictEqual(out.found, true);
1952
2126
  });
@@ -388,7 +388,7 @@ wheels = [
388
388
 
389
389
  [[package]]
390
390
  name = "transcript-viewer"
391
- version = "0.11.0"
391
+ version = "0.11.2"
392
392
  source = { editable = "." }
393
393
  dependencies = [
394
394
  { name = "atif-make" },