transcript-viewer 0.12.0__tar.gz → 0.14.0__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.12.0 → transcript_viewer-0.14.0}/PKG-INFO +29 -5
  2. {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/README.md +28 -4
  3. {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/pyproject.toml +1 -1
  4. {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/src/transcript_viewer/page.html +306 -91
  5. {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/tests/page.test.js +163 -1
  6. {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/uv.lock +1 -1
  7. {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/.coverage +0 -0
  8. {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/.github/workflows/publish.yml +0 -0
  9. {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/.github/workflows/test.yml +0 -0
  10. {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/.gitignore +0 -0
  11. {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/design/Anchor.dc.html +0 -0
  12. {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/design/Dense.dc.html +0 -0
  13. {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/design/Hover.dc.html +0 -0
  14. {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/design/Main.dc.html +0 -0
  15. {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/design/Quiet.dc.html +0 -0
  16. {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/design/canvas.json +0 -0
  17. {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/design/delegation-timeline.html +0 -0
  18. {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/src/transcript_viewer/__init__.py +0 -0
  19. {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/src/transcript_viewer/ai.py +0 -0
  20. {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/src/transcript_viewer/cli.py +0 -0
  21. {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/src/transcript_viewer/config.py +0 -0
  22. {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/src/transcript_viewer/corpus.py +0 -0
  23. {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/src/transcript_viewer/fetch.py +0 -0
  24. {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/src/transcript_viewer/library.py +0 -0
  25. {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/src/transcript_viewer/store.py +0 -0
  26. {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/src/transcript_viewer/viewer.py +0 -0
  27. {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/tests/fixtures/big-command.json +0 -0
  28. {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/tests/test_ai.py +0 -0
  29. {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/tests/test_cli.py +0 -0
  30. {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/tests/test_config.py +0 -0
  31. {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/tests/test_corpus.py +0 -0
  32. {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/tests/test_fetch.py +0 -0
  33. {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/tests/test_library.py +0 -0
  34. {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/tests/test_page.py +0 -0
  35. {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/tests/test_readme.py +0 -0
  36. {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/tests/test_store.py +0 -0
  37. {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/tests/test_stream.py +0 -0
  38. {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/tests/test_style.py +0 -0
  39. {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/tests/test_tidiness.py +0 -0
  40. {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/tests/test_viewer.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: transcript-viewer
3
- Version: 0.12.0
3
+ Version: 0.14.0
4
4
  Summary: Browse agent transcripts in a local, dependency-free web viewer.
5
5
  License: MIT
6
6
  Requires-Python: >=3.12
@@ -179,8 +179,21 @@ If that directory is still there, the first run moves it to
179
179
  `~/.transcript-viewer` — index, annotations and stored keys together — and says
180
180
  so. Nothing is left behind and nothing is copied twice.
181
181
 
182
- A run that delegated shows a **timeline** above its steps: one lane per
183
- subagent, laid out on the clock. It exists for the one thing a nested box
182
+ A run that delegated shows what it farmed out, above its steps, as a **list**
183
+ or a **timeline**. The list leads because the common question is what was asked
184
+ and what came back, and the median subagent here is two steps — a bar for which
185
+ says almost nothing. It carries the task, the kind, the steps, how long it took
186
+ and the tools it reached for, sorted however you ask.
187
+
188
+ The timeline is for when the question is *when*, and alongside what. A row is
189
+ shared by everything that did not overlap, so a second row exists only because
190
+ two things ran at once: thirty subagents in one session pack into eight rows,
191
+ and a session of fifteen packs into one, which is the honest picture of a run
192
+ that never did two things at a time. Either way a subagent is called what it was asked to
193
+ do — "Review backend for bugs and DRY" rather than "general-purpose" — wherever
194
+ that was written down, which is 50 of the 53 Claude Code subagents here; the
195
+ kind is the fallback, and reads as a handle rather than as prose so the
196
+ difference is visible. It exists for the one thing a nested box
184
197
  cannot say — that two subagents were running at the same moment, which 25 pairs
185
198
  in the corpus this was built against were. Two bars in the same column of the
186
199
  chart is what says it; overlap gets no colour of its own, since a second
@@ -188,8 +201,13 @@ encoding of the same fact is noise.
188
201
 
189
202
  Clicking a lane opens the box the transcript already has for it rather than
190
203
  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.
204
+ in one real session every delegation happens after step 1,231.
205
+
206
+ A long run paints its first 250 steps at once and fills the rest in behind
207
+ itself, so there is nothing to press and nothing to wait for. The largest run
208
+ here is 8,445 steps and 34 MB of markup, which is a second of a blank page if
209
+ it is drawn in one go, so the rest arrives in the gaps between frames and the
210
+ reader keeps their place.
193
211
 
194
212
  Where a session spans days but holds hours of work — one runs 10.3 days and
195
213
  contains 5.3 hours, with 31 gaps over an hour — the quiet stretches are skipped
@@ -199,6 +217,12 @@ that floor its width is not its duration; the card carries the real one. A
199
217
  transcript with no timestamps lays the same lanes out by step instead and says
200
218
  so, and one that delegated nothing shows no timeline at all.
201
219
 
220
+ A transcript has an address: opening one puts its key in the URL, so a refresh
221
+ comes back to it and a link can be sent to someone with the same transcript.
222
+ The key is the hash of the file's contents, so it is the same key on another
223
+ machine holding the same file, and back and forward move between the library
224
+ and a run.
225
+
202
226
  **Clear library** above the tree forgets every session at once, after saying
203
227
  how many and what it will touch. Files are left alone by the same rule as a
204
228
  single removal — only copies the viewer made itself are deleted — and stored
@@ -166,8 +166,21 @@ If that directory is still there, the first run moves it to
166
166
  `~/.transcript-viewer` — index, annotations and stored keys together — and says
167
167
  so. Nothing is left behind and nothing is copied twice.
168
168
 
169
- A run that delegated shows a **timeline** above its steps: one lane per
170
- subagent, laid out on the clock. It exists for the one thing a nested box
169
+ A run that delegated shows what it farmed out, above its steps, as a **list**
170
+ or a **timeline**. The list leads because the common question is what was asked
171
+ and what came back, and the median subagent here is two steps — a bar for which
172
+ says almost nothing. It carries the task, the kind, the steps, how long it took
173
+ and the tools it reached for, sorted however you ask.
174
+
175
+ The timeline is for when the question is *when*, and alongside what. A row is
176
+ shared by everything that did not overlap, so a second row exists only because
177
+ two things ran at once: thirty subagents in one session pack into eight rows,
178
+ and a session of fifteen packs into one, which is the honest picture of a run
179
+ that never did two things at a time. Either way a subagent is called what it was asked to
180
+ do — "Review backend for bugs and DRY" rather than "general-purpose" — wherever
181
+ that was written down, which is 50 of the 53 Claude Code subagents here; the
182
+ kind is the fallback, and reads as a handle rather than as prose so the
183
+ difference is visible. It exists for the one thing a nested box
171
184
  cannot say — that two subagents were running at the same moment, which 25 pairs
172
185
  in the corpus this was built against were. Two bars in the same column of the
173
186
  chart is what says it; overlap gets no colour of its own, since a second
@@ -175,8 +188,13 @@ encoding of the same fact is noise.
175
188
 
176
189
  Clicking a lane opens the box the transcript already has for it rather than
177
190
  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.
191
+ in one real session every delegation happens after step 1,231.
192
+
193
+ A long run paints its first 250 steps at once and fills the rest in behind
194
+ itself, so there is nothing to press and nothing to wait for. The largest run
195
+ here is 8,445 steps and 34 MB of markup, which is a second of a blank page if
196
+ it is drawn in one go, so the rest arrives in the gaps between frames and the
197
+ reader keeps their place.
180
198
 
181
199
  Where a session spans days but holds hours of work — one runs 10.3 days and
182
200
  contains 5.3 hours, with 31 gaps over an hour — the quiet stretches are skipped
@@ -186,6 +204,12 @@ that floor its width is not its duration; the card carries the real one. A
186
204
  transcript with no timestamps lays the same lanes out by step instead and says
187
205
  so, and one that delegated nothing shows no timeline at all.
188
206
 
207
+ A transcript has an address: opening one puts its key in the URL, so a refresh
208
+ comes back to it and a link can be sent to someone with the same transcript.
209
+ The key is the hash of the file's contents, so it is the same key on another
210
+ machine holding the same file, and back and forward move between the library
211
+ and a run.
212
+
189
213
  **Clear library** above the tree forgets every session at once, after saying
190
214
  how many and what it will touch. Files are left alone by the same rule as a
191
215
  single removal — only copies the viewer made itself are deleted — and stored
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "transcript-viewer"
3
- version = "0.12.0"
3
+ version = "0.14.0"
4
4
  description = "Browse agent transcripts in a local, dependency-free web viewer."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -387,14 +387,16 @@ pre{background:var(--sunk);border-radius:7px;padding:10px;overflow:auto;font-siz
387
387
  #lanes>summary .fine{font:11px var(--font-mono);color:var(--muted)}
388
388
  #lanes>summary .fine em{font-style:normal;color:var(--accent)}
389
389
  .lanes-body{padding:2px 16px 12px;border-top:1px solid var(--line)}
390
- .lane{display:grid;grid-template-columns:150px 1fr;gap:0 12px;align-items:center}
390
+ .lane{display:grid;grid-template-columns:196px 1fr;gap:0 12px;align-items:center}
391
391
  /* Denser than the roomy version, but still a row you can aim at: thirty of
392
392
  these is a screenful, and a 13px row with an 8px bar read as a scatter
393
393
  plot rather than a set of lanes. */
394
- .lane.tight{grid-template-columns:150px 1fr;gap:0 12px}
394
+ .lane.tight{grid-template-columns:196px 1fr;gap:0 12px}
395
395
  .lane-name{display:flex;align-items:baseline;gap:7px;min-width:0}
396
396
  .lane-name .kind{font:12px var(--font-mono);color:var(--accent);overflow:hidden;
397
397
  text-overflow:ellipsis;white-space:nowrap}
398
+ /* A task the agent was given is prose, not an identifier. */
399
+ .lane-name .kind.named{font:12px var(--font-ui);color:var(--ink)}
398
400
  .lane-name .kind.main{color:var(--ink);font-weight:600}
399
401
  .lane-name .n{font:10px var(--font-mono);color:var(--faint);margin-left:auto;flex:none}
400
402
  .track{position:relative;height:22px}
@@ -410,16 +412,16 @@ pre{background:var(--sunk);border-radius:7px;padding:10px;overflow:auto;font-siz
410
412
  .bar:hover,.bar:focus-visible{opacity:1;outline:1px solid var(--ink);outline-offset:1px}
411
413
  .bar.main{background:var(--ink);opacity:.85;cursor:default}
412
414
  .bar.main:hover{outline:none;opacity:.85}
413
- .lane-axis{display:grid;grid-template-columns:150px 1fr;gap:0 12px;margin-bottom:2px}
414
- .lane-axis.tight{grid-template-columns:118px 1fr;gap:0 10px}
415
+ .lane-axis{display:grid;grid-template-columns:196px 1fr;gap:0 12px;margin-bottom:2px}
416
+ .lane-axis.tight{grid-template-columns:196px 1fr;gap:0 12px}
415
417
  .lane-axis .marks{position:relative;height:13px}
416
418
  .lane-axis .marks span{position:absolute;transform:translateX(-50%);
417
419
  font:10px var(--font-mono);color:var(--faint);white-space:nowrap}
418
420
  /* The bands and the axis breaks sit behind every lane at once, drawn once
419
421
  rather than repeated into each row. */
420
422
  .lanes-tracks{position:relative}
421
- .bandlayer{position:absolute;left:162px;right:0;top:0;bottom:0;pointer-events:none}
422
- .lane.tight ~ .bandlayer,.lanes-tracks:has(.lane.tight) .bandlayer{left:162px}
423
+ .bandlayer{position:absolute;left:208px;right:0;top:0;bottom:0;pointer-events:none}
424
+ .lane.tight ~ .bandlayer,.lanes-tracks:has(.lane.tight) .bandlayer{left:208px}
423
425
  /* Where the axis was cut. One thin line, and only for the cuts worth seeing —
424
426
  one session skips 152 quiet stretches, and marking every one turned the
425
427
  whole chart into vertical stripes. The rest are still skipped; they are
@@ -429,6 +431,41 @@ pre{background:var(--sunk);border-radius:7px;padding:10px;overflow:auto;font-siz
429
431
  /* Conversations that belong to the record rather than to any step. */
430
432
  .loose{margin-top:18px;padding-top:12px;border-top:1px solid var(--line)}
431
433
  .loose-head{font:11px var(--font-mono);color:var(--muted);margin-bottom:8px}
434
+ /* The list. Diwan's library row, at the density of a table: one grid template
435
+ shared by the head and every row, so the columns line up. */
436
+ .lanelist{margin:2px 0 0}
437
+ .lrow{display:grid;grid-template-columns:1fr 118px 52px 58px 150px;gap:0 12px;
438
+ align-items:baseline;padding:7px 8px;border-radius:5px;cursor:pointer}
439
+ .lrow:hover{background:var(--soft)}
440
+ .lrow.head{cursor:default;padding-bottom:5px;margin-bottom:2px;
441
+ border-bottom:1px solid var(--line)}
442
+ .lrow.head:hover{background:transparent}
443
+ .lh{font:10px var(--font-mono);color:var(--faint);letter-spacing:.04em;
444
+ text-transform:uppercase;cursor:pointer;white-space:nowrap}
445
+ .lh.on{color:var(--accent)}
446
+ .lh.num{text-align:right}
447
+ .lname{font-size:13px;color:var(--ink);overflow:hidden;text-overflow:ellipsis;
448
+ white-space:nowrap}
449
+ /* Nothing was written down for this one; the kind is standing in for a name. */
450
+ .lname.untitled{font-family:var(--font-mono);font-size:12px;color:var(--muted)}
451
+ .lkind{font:11px var(--font-mono);color:var(--accent);overflow:hidden;
452
+ text-overflow:ellipsis;white-space:nowrap}
453
+ .lrow .num{font:11.5px var(--font-mono);color:var(--muted);text-align:right}
454
+ .ltools{font:10.5px var(--font-mono);color:var(--faint);overflow:hidden;
455
+ text-overflow:ellipsis;white-space:nowrap}
456
+
457
+ /* A packed row holds whatever did not overlap, so it has a number rather than
458
+ a name — the names are in the list, where there is room for them. */
459
+ .lane.packed{grid-template-columns:58px 1fr;gap:0 12px;align-items:center}
460
+ .lane-axis.packed{grid-template-columns:58px 1fr;gap:0 12px}
461
+ .bandlayer.packed{left:70px}
462
+ .rownum{font:10px var(--font-mono);color:var(--faint);width:100%;text-align:right}
463
+ .lane.packed .track{height:22px}
464
+ .lane.packed .bar{height:14px}
465
+
466
+ /* Which of the two views, in the box's own header. */
467
+ .laneview{margin-left:auto;display:flex;gap:6px;flex:none}
468
+ .laneview .chip{font-size:11px;padding:2px 9px}
432
469
  .lanes-legend{display:flex;align-items:center;gap:14px;margin-top:10px;padding-top:9px;
433
470
  border-top:1px solid var(--line);font-size:11px;color:var(--muted);flex-wrap:wrap}
434
471
  .lanes-legend i{display:inline-block;width:16px;height:8px;border-radius:2px;
@@ -1309,6 +1346,9 @@ function toggleSide(){
1309
1346
  // Restore the last choice; localStorage can throw in restricted contexts.
1310
1347
  try{if(localStorage.getItem("transcript-viewer.side")==="1")toggleSide()}catch(e){}
1311
1348
 
1349
+ /* Back and forward move between the library and a transcript. */
1350
+ addEventListener("popstate",followAddress);
1351
+
1312
1352
  addEventListener("keydown",e=>{
1313
1353
  // Ignore the shortcut while typing in the filter box.
1314
1354
  if(e.key==="\\"&&e.target.tagName!=="INPUT"){e.preventDefault();toggleSide()}
@@ -1375,7 +1415,13 @@ async function openLibrary(){
1375
1415
  bootSay(`Drawing ${num(INDEX.length)} sessions…`,0,0);
1376
1416
  // Let that line paint before the work that blocks the thread.
1377
1417
  await new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)));
1378
- showCount();drawList();showLibrary();
1418
+ showCount();drawList();
1419
+ // The address says what to show. Without this a refresh lands in the
1420
+ // library however specific the link was.
1421
+ const asked=addressed();
1422
+ if(asked&&INDEX.some(e=>e.key===asked))pick(asked,false);
1423
+ else{ if(asked)note("That transcript is not in this library.");
1424
+ setAddress(null,false); showLibrary(); }
1379
1425
  }catch(err){
1380
1426
  // Leave it on screen: a blank window would say nothing at all.
1381
1427
  bootSay("Could not open your library — "+((err&&err.message)||err),0,0);
@@ -1624,7 +1670,7 @@ function showLibrary(){
1624
1670
  </div>`;
1625
1671
  return;
1626
1672
  }
1627
- cur=null;drawCrumb();
1673
+ cur=null;setAddress(null,false);drawCrumb();
1628
1674
  const rows=libraryRows();
1629
1675
  main.innerHTML=`
1630
1676
  <div class="lhead">
@@ -2271,8 +2317,37 @@ function star(key){
2271
2317
  annotate(key,{starred:!entry.starred});
2272
2318
  }
2273
2319
 
2274
- function pick(key){
2275
- cur=key;EDITING=null;drawList();drawCrumb();
2320
+ /* Which transcript the address names, if any.
2321
+
2322
+ The key is the sha256 of the file's contents, so it is the same key on the
2323
+ next run and on another machine holding the same transcript — an address
2324
+ worth putting in the bar. Refreshing used to throw the reader back to the
2325
+ library, and a link to what someone was looking at could not be sent at all.
2326
+
2327
+ The whole hash is used rather than a prefix: two transcripts sharing the
2328
+ first sixteen characters would otherwise open the wrong one. */
2329
+ const addressed = () => (location.hash || "").replace(/^#/, "");
2330
+
2331
+ function setAddress(key, push) {
2332
+ const to = key ? "#" + key : location.pathname;
2333
+ if (addressed() === (key || "")) return;
2334
+ try {
2335
+ if (push) history.pushState({ key: key || null }, "", to);
2336
+ else history.replaceState({ key: key || null }, "", to);
2337
+ } catch (e) {
2338
+ // A page opened from a file has no history to write; the view still works.
2339
+ }
2340
+ }
2341
+
2342
+ /* Back and forward move between the library and a transcript. */
2343
+ function followAddress() {
2344
+ const key = addressed();
2345
+ if (!key) { cur = null; drawList(); drawCrumb(); showLibrary(); return; }
2346
+ if (key !== cur) pick(key, false);
2347
+ }
2348
+
2349
+ function pick(key, push = true){
2350
+ cur=key;EDITING=null;setAddress(key,push);drawList();drawCrumb();
2276
2351
  main.innerHTML=`<div class="empty">Converting…</div>`;
2277
2352
  fetch("/api/trajectory?id="+encodeURIComponent(key)).then(r=>r.json()).then(t=>{
2278
2353
  if(t.error){main.innerHTML=`<div class="empty">${esc(t.error)}</div>`;return}
@@ -2445,6 +2520,11 @@ function laneIndex(t) {
2445
2520
  id: sub.trajectory_id,
2446
2521
  kind: x.agentType || sub.agent?.name || "subagent",
2447
2522
  said: x.description || "",
2523
+ // What to call the lane. The task it was given where that was
2524
+ // written down — 50 of the 53 Claude Code subagents here have one,
2525
+ // and "Docs and config audit" says what "general-purpose" cannot.
2526
+ // Otherwise the kind, which is all some formats record.
2527
+ name: x.description || x.agentType || sub.agent?.name || "subagent",
2448
2528
  steps: (sub.steps || []).length,
2449
2529
  tools: [...new Set((sub.steps || []).flatMap(
2450
2530
  (s) => (s.tool_calls || []).map((c) => c.function_name)))],
@@ -2474,6 +2554,7 @@ function laneIndex(t) {
2474
2554
  id: sub.trajectory_id,
2475
2555
  kind: x.agentType || sub.agent?.name || "subagent",
2476
2556
  said: x.description || "",
2557
+ name: x.description || x.agentType || sub.agent?.name || "subagent",
2477
2558
  steps: (sub.steps || []).length,
2478
2559
  tools: [...new Set((sub.steps || []).flatMap(
2479
2560
  (s) => (s.tool_calls || []).map((c) => c.function_name)))],
@@ -2492,6 +2573,31 @@ function laneIndex(t) {
2492
2573
  return lanes;
2493
2574
  }
2494
2575
 
2576
+ /* Share a row with anything that does not overlap.
2577
+
2578
+ Thirty subagents drew thirty rows, and twenty-nine of them said only that
2579
+ the run was long. Packed, the same session is eight rows — and the count now
2580
+ means something: a second row exists only because two things ran at once, so
2581
+ the shape of the chart is the parallelism rather than a decoration on top of
2582
+ it. One real session of fifteen subagents packs to a single row, which is the
2583
+ honest picture of a run that never did two things at a time.
2584
+
2585
+ The lowest free row, which is the usual way to pack intervals. */
2586
+ function packLanes(lanes) {
2587
+ const timed = lanes.filter((l) => l.span).sort((a, b) => a.span[0] - b.span[0]);
2588
+ const rows = [];
2589
+ for (const lane of timed) {
2590
+ let row = rows.findIndex((busyUntil) => lane.span[0] >= busyUntil);
2591
+ if (row === -1) row = rows.push(0) - 1;
2592
+ rows[row] = lane.span[1];
2593
+ lane.row = row;
2594
+ }
2595
+ // Anything without times cannot be placed against the others; give it its own.
2596
+ let extra = rows.length;
2597
+ for (const lane of lanes) if (!lane.span) lane.row = extra++;
2598
+ return Math.max(1, extra);
2599
+ }
2600
+
2495
2601
  /* Stretches of time where more than one subagent was running. */
2496
2602
  function together(lanes) {
2497
2603
  const edges = [];
@@ -2580,6 +2686,23 @@ function axisMarks(lo, hi) {
2580
2686
 
2581
2687
  let LANES = [];
2582
2688
 
2689
+ /* Which of the two views the box is showing. A list by default: the common
2690
+ question is what was farmed out and what came back, and the median subagent
2691
+ here is two steps — a bar for which says almost nothing. The timeline is for
2692
+ when the question is when, and alongside what. */
2693
+ let LANEVIEW = (() => {
2694
+ try { return localStorage.getItem("transcript-viewer.delegation") || "list"; }
2695
+ catch (e) { return "list"; }
2696
+ })();
2697
+
2698
+ function setLaneView(v) {
2699
+ LANEVIEW = v;
2700
+ try { localStorage.setItem("transcript-viewer.delegation", v); } catch (e) {}
2701
+ const at = main.scrollTop;
2702
+ render();
2703
+ main.scrollTop = at;
2704
+ }
2705
+
2583
2706
  function drawLanes(t) {
2584
2707
  LANES = laneIndex(t);
2585
2708
  if (!LANES.length) return "";
@@ -2587,104 +2710,196 @@ function drawLanes(t) {
2587
2710
  const timed = LANES.filter((l) => l.span);
2588
2711
  const parent = spanMs(t.steps);
2589
2712
  const onClock = timed.length > 0 && parent;
2713
+ const bands = onClock ? together(timed) : [];
2714
+
2715
+ let shown = "", whole = "", note = "", body = "";
2716
+
2717
+ if (LANEVIEW === "list") {
2718
+ body = laneList(t);
2719
+ note = onClock ? "" : "these steps carry no times";
2720
+ if (onClock) {
2721
+ const lo = Math.min(parent[0], ...timed.map((l) => l.span[0]));
2722
+ const hi = Math.max(parent[1], ...timed.map((l) => l.span[1]));
2723
+ // A list has no axis, so nothing was shortened: the span is the span.
2724
+ shown = duration(hi - lo);
2725
+ }
2726
+ } else if (!onClock) {
2727
+ // No times to lay anything out against; place each where it was called from.
2728
+ const steps = Math.max(1, (t.steps || []).length);
2729
+ body = laneTracks(
2730
+ LANES.map((lane) => ({
2731
+ lane,
2732
+ html: `<button class="bar" style="left:${(lane.fromIndex / steps) * 100}%;
2733
+ width:${Math.max(100 / steps, 0.6)}%" title="${esc(lane.name || lane.kind)}"
2734
+ onmouseenter="laneCard(event,'${esc(lane.id)}')" onmouseleave="laneCard(null)"
2735
+ onclick="openLane('${esc(lane.id)}')"></button>`,
2736
+ })),
2737
+ LANES.length,
2738
+ `<span class="bar main" style="left:0;width:100%"></span>`,
2739
+ "",
2740
+ "",
2741
+ );
2742
+ note = "laid out by step · these steps carry no times";
2743
+ } else {
2744
+ const lo = Math.min(parent[0], ...timed.map((l) => l.span[0]));
2745
+ const hi = Math.max(parent[1], ...timed.map((l) => l.span[1]));
2746
+
2747
+ const busy = timed.map((l) => l.span);
2748
+ for (const v of (t.steps || []).map(at).filter((x) => x !== null)) busy.push([v, v]);
2749
+ const scale = timeScale(lo, hi, busy);
2750
+ const rowCount = packLanes(LANES);
2751
+
2752
+ const place = (span) => {
2753
+ const left = scale.posOf(span[0]);
2754
+ return { left, width: Math.max(scale.posOf(span[1]) - left, 0.4) };
2755
+ };
2590
2756
 
2591
- const tight = LANES.length > 12 ? " tight" : "";
2592
- const steps = (t.steps || []).length;
2593
-
2594
- if (!onClock) {
2595
- // No times to lay anything out against. Say so and place each lane where it
2596
- // was called from, rather than implying durations the log never recorded.
2597
- const rows = LANES.map((lane) => laneRow(lane, tight,
2598
- `<button class="bar" style="left:${(lane.fromIndex / Math.max(1, steps)) * 100}%;
2599
- width:${Math.max(100 / Math.max(1, steps), 0.6)}%" title="${esc(lane.kind)}"
2600
- onmouseenter="laneCard(event,'${esc(lane.id)}')" onmouseleave="laneCard(null)"
2601
- onclick="openLane('${esc(lane.id)}')"></button>`)).join("");
2602
- return laneShell({
2603
- count: LANES.length,
2604
- rows,
2605
- mainRow: laneRow({ kind: "main thread", steps, main: true }, tight,
2606
- `<span class="bar main" style="left:0;width:100%"></span>`),
2607
- marks: "",
2608
- // No clock to lay anything against, so no duration is claimed and no
2609
- // overlap either: without times, two bars sitting side by side would
2610
- // say something the log never recorded.
2611
- note: "laid out by step · these steps carry no times",
2757
+ const bars = LANES.map((lane) => {
2758
+ const at2 = lane.span ? place(lane.span) : { left: 0, width: 0.4 };
2759
+ return {
2760
+ lane,
2761
+ html: `<button class="bar" style="left:${at2.left}%;width:${at2.width}%"
2762
+ title="${esc(lane.name || lane.kind)}${
2763
+ lane.span ? " · " + esc(duration(lane.span[1] - lane.span[0])) : ""
2764
+ }" onmouseenter="laneCard(event,'${esc(lane.id)}')"
2765
+ onmouseleave="laneCard(null)" onclick="openLane('${esc(lane.id)}')"></button>`,
2766
+ };
2612
2767
  });
2768
+
2769
+ const mainAt = place(parent);
2770
+ const marks = axisMarks(0, scale.kept)
2771
+ .map((m) => `<span style="left:${(m.pos / 100) * scale.room}%">${esc(m.label)}</span>`)
2772
+ .join("");
2773
+ const worth = scale.gaps.slice().sort((a, b) => b.ms - a.ms).slice(0, 8);
2774
+ const breaks = worth
2775
+ .map((g) => `<span class="gapmark" style="left:${g.at}%"
2776
+ title="nothing running for ${esc(g.took)}"></span>`)
2777
+ .join("");
2778
+
2779
+ body = laneTracks(
2780
+ bars,
2781
+ rowCount,
2782
+ `<span class="bar main" style="left:${mainAt.left}%;width:${mainAt.width}%"></span>`,
2783
+ marks,
2784
+ breaks,
2785
+ );
2786
+
2787
+ const [a, b] = durationPair(scale.kept, hi - lo);
2788
+ shown = a;
2789
+ whole = b;
2790
+ note = scale.gaps.length ? `${duration(scale.skipped)} of waiting hidden` : "";
2613
2791
  }
2614
2792
 
2615
- // Everything the run touched, parent and children alike.
2616
- const lo = Math.min(parent[0], ...timed.map((l) => l.span[0]));
2617
- const hi = Math.max(parent[1], ...timed.map((l) => l.span[1]));
2618
-
2619
- // What was actually happening, so the quiet stretches can be skipped.
2620
- const busy = timed.map((l) => l.span);
2621
- const points = (t.steps || []).map(at).filter((v) => v !== null).sort((a, b) => a - b);
2622
- for (const v of points) busy.push([v, v]);
2623
-
2624
- const scale = timeScale(lo, hi, busy);
2625
- const bands = together(timed);
2626
-
2627
- // Every quiet stretch is skipped; only the large ones are marked. Drawing a
2628
- // line for all 152 of one session's turned the chart into vertical stripes,
2629
- // and a mark every few pixels tells a reader nothing they can act on.
2630
- const worth = scale.gaps
2631
- .slice()
2632
- .sort((a, b) => b.ms - a.ms)
2633
- .slice(0, 8);
2634
- const breaks = worth.map((g) =>
2635
- `<span class="gapmark" style="left:${g.at}%" title="nothing running for ${esc(g.took)}"></span>`
2636
- ).join("");
2793
+ const parts = laneHeader({ count: LANES.length, shown, whole, overlaps: bands.length });
2794
+ const hint = LANEVIEW === "list" ? "click a row to open it" : "click a bar to open it";
2637
2795
 
2638
- const bar = (span, cls) => {
2639
- const left = scale.posOf(span[0]);
2640
- return { left, width: Math.max(scale.posOf(span[1]) - left, 0.4) };
2641
- };
2796
+ return `
2797
+ <details id="lanes" open>
2798
+ <summary>
2799
+ <b>Delegation</b>
2800
+ <span class="fine">${parts.join(" · ")}</span>
2801
+ <span class="laneview" onclick="event.preventDefault()">
2802
+ ${[["list", "List"], ["time", "Timeline"]].map(([k, label]) =>
2803
+ `<span class="chip${LANEVIEW === k ? " on" : ""}"
2804
+ onclick="event.preventDefault();event.stopPropagation();setLaneView('${k}')"
2805
+ >${label}</span>`).join("")}
2806
+ </span>
2807
+ </summary>
2808
+ <div class="lanes-body">
2809
+ ${body}
2810
+ <div class="lanes-legend">
2811
+ ${LANEVIEW === "time"
2812
+ ? `<span><i style="background:var(--ink);opacity:.85"></i>main thread</span>
2813
+ <span><i style="background:var(--accent);opacity:.85"></i>subagent</span>`
2814
+ : ""}
2815
+ <span class="lanes-note">${[note, hint].filter(Boolean).join(" · ")}</span>
2816
+ </div>
2817
+ </div>
2818
+ </details>`;
2819
+ }
2642
2820
 
2643
- const mainAt = bar(parent, "main");
2644
- const mainRow = laneRow({ kind: "main thread", steps, main: true }, tight,
2645
- `<span class="bar main" style="left:${mainAt.left}%;width:${mainAt.width}%"></span>`);
2646
-
2647
- const rows = LANES.map((lane) => {
2648
- const at = bar(lane.span);
2649
- return laneRow(lane, tight,
2650
- `<button class="bar" style="left:${at.left}%;width:${at.width}%"
2651
- title="${esc(lane.kind + (lane.said ? " — " + lane.said : ""))} · ${esc(duration(lane.span[1] - lane.span[0]))}"
2652
- onmouseenter="laneCard(event,'${esc(lane.id)}')" onmouseleave="laneCard(null)"
2653
- onclick="openLane('${esc(lane.id)}')"></button>`);
2654
- }).join("");
2821
+ /* What was farmed out, and what came back. Sorted as the reader asks. */
2822
+ let LANESORT = "start";
2655
2823
 
2656
- const marks = axisMarks(0, scale.kept).map((m) => {
2657
- // Positions are on the kept time, which is what the axis measures.
2658
- return { pos: (m.pos / 100) * scale.room, label: m.label };
2659
- });
2824
+ function setLaneSort(k) {
2825
+ LANESORT = LANESORT === k ? k + "-" : k;
2826
+ const at = main.scrollTop;
2827
+ render();
2828
+ main.scrollTop = at;
2829
+ }
2660
2830
 
2661
- // What the axis leaves out, in the reader's terms. How many separate gaps
2662
- // were collapsed is a fact about the drawing, not about the run, so it is not
2663
- // said; how much time is missing from the picture very much is.
2664
- const hidden = scale.gaps.length ? `${duration(scale.skipped)} of waiting hidden` : "";
2665
- const note = [hidden, "click a bar to open it"].filter(Boolean).join(" · ");
2666
-
2667
- const [shownAs, wholeAs] = durationPair(scale.kept, hi - lo);
2668
- return laneShell({
2669
- count: LANES.length,
2670
- shown: shownAs,
2671
- whole: wholeAs,
2672
- rows,
2673
- mainRow,
2674
- marks: marks.map((m) => `<span style="left:${m.pos}%">${esc(m.label)}</span>`).join(""),
2675
- note,
2676
- layer: breaks,
2677
- overlaps: bands.length,
2831
+ function laneList(t) {
2832
+ const key = LANESORT.replace(/-$/, "");
2833
+ const down = LANESORT.endsWith("-");
2834
+ const rows = LANES.slice().sort((a, b) => {
2835
+ const of = (l) =>
2836
+ key === "steps" ? l.steps
2837
+ : key === "took" ? (l.span ? l.span[1] - l.span[0] : -1)
2838
+ : key === "name" ? (l.name || l.kind).toLowerCase()
2839
+ : (l.span ? l.span[0] : l.fromIndex);
2840
+ const x = of(a), y = of(b);
2841
+ const n = x < y ? -1 : x > y ? 1 : 0;
2842
+ return down ? -n : n;
2678
2843
  });
2844
+
2845
+ const head = (k, label, cls) =>
2846
+ `<span class="lh ${cls || ""}${key === k ? " on" : ""}"
2847
+ onclick="setLaneSort('${k}')">${label}${key === k ? (down ? " ↓" : " ↑") : ""}</span>`;
2848
+
2849
+ return `
2850
+ <div class="lanelist">
2851
+ <div class="lrow head">
2852
+ ${head("name", "what it was asked to do")}
2853
+ <span class="lh">kind</span>
2854
+ ${head("steps", "steps", "num")}
2855
+ ${head("took", "took", "num")}
2856
+ <span class="lh">tools</span>
2857
+ </div>
2858
+ ${rows.map((l) => `
2859
+ <div class="lrow" onclick="openLane('${esc(l.id)}')"
2860
+ onmouseenter="laneCard(event,'${esc(l.id)}')" onmouseleave="laneCard(null)">
2861
+ <span class="lname${l.said ? "" : " untitled"}">${esc(l.name || l.kind)}</span>
2862
+ <span class="lkind">${esc(l.kind)}</span>
2863
+ <span class="num">${num(l.steps)}</span>
2864
+ <span class="num">${l.span ? esc(duration(l.span[1] - l.span[0])) : "—"}</span>
2865
+ <span class="ltools">${l.tools.length ? esc(l.tools.slice(0, 3).join(" ")) : "—"}</span>
2866
+ </div>`).join("")}
2867
+ </div>`;
2868
+ }
2869
+
2870
+ /* The tracks: one row per packed lane, the main thread above them. */
2871
+ function laneTracks(bars, rowCount, mainBar, marks, breaks) {
2872
+ const rows = [];
2873
+ for (let r = 0; r < rowCount; r++) {
2874
+ const inner = bars.filter(({ lane }) => lane.row === r).map((b) => b.html).join("");
2875
+ rows.push(`<div class="lane packed">
2876
+ <div class="lane-name"><span class="rownum">${r + 1}</span></div>
2877
+ <div class="track"><span class="rule"></span>${inner}</div>
2878
+ </div>`);
2879
+ }
2880
+ return `
2881
+ <div class="lane-axis packed"><div></div><div class="marks">${marks}</div></div>
2882
+ <div class="lanes-tracks">
2883
+ <div class="bandlayer packed">${breaks}</div>
2884
+ <div class="lane packed">
2885
+ <div class="lane-name"><span class="kind main">main</span></div>
2886
+ <div class="track"><span class="rule"></span>${mainBar}</div>
2887
+ </div>
2888
+ ${rows.join("")}
2889
+ </div>`;
2679
2890
  }
2680
2891
 
2681
2892
  /* One row: who, how many steps, and the track it sits on. */
2682
2893
  function laneRow(lane, tight, inner) {
2894
+ const name = lane.name || lane.kind;
2895
+ // Both, where they differ: the task reads first, the kind explains it.
2896
+ const full = lane.said && lane.kind !== name ? `${name} — ${lane.kind}` : name;
2683
2897
  return `
2684
2898
  <div class="lane${tight}">
2685
2899
  <div class="lane-name">
2686
2900
  ${lane.main ? "" : `<span style="color:var(--accent);font-weight:700;font-size:11px">⤷</span>`}
2687
- <span class="kind${lane.main ? " main" : ""}" title="${esc(lane.kind)}">${esc(lane.kind)}</span>
2901
+ <span class="kind${lane.main ? " main" : ""}${lane.said ? " named" : ""}"
2902
+ title="${esc(full)}">${esc(name)}</span>
2688
2903
  <span class="n">${num(lane.steps)}</span>
2689
2904
  </div>
2690
2905
  <div class="track"><span class="rule"></span>${inner}</div>
@@ -71,6 +71,14 @@ const SAFE_FETCH = () =>
71
71
  an object with no focus(), which fails somewhere else entirely. */
72
72
  const SAFE_BY_ID = () => null;
73
73
 
74
+ /* The page reads and writes an address so a transcript can be linked to. The
75
+ stub keeps one, so tests can watch it without a browser. */
76
+ global.location = { hash: "", pathname: "/" };
77
+ global.history = {
78
+ pushState: (_s, _t, to) => { global.location.hash = String(to).startsWith("#") ? to : ""; },
79
+ replaceState: (_s, _t, to) => { global.location.hash = String(to).startsWith("#") ? to : ""; },
80
+ };
81
+
74
82
  /* Looking for an element by selector, against what was actually painted. The
75
83
  stub DOM holds markup as a string rather than a tree, so this answers the one
76
84
  question the page asks — is this box on the page — from that string. */
@@ -91,6 +99,7 @@ const run = (body) => {
91
99
  global.fetch = SAFE_FETCH;
92
100
  global.document.getElementById = SAFE_BY_ID;
93
101
  global.document.querySelector = SAFE_QUERY;
102
+ global.location.hash = "";
94
103
  return eval(script + "\n;(() => {" + body + "})()");
95
104
  };
96
105
 
@@ -1992,7 +2001,8 @@ test("what was skipped is stated, not swallowed", () => {
1992
2001
  { step_id: 2, source: "agent", timestamp: "2026-01-01T10:02:00Z" },
1993
2002
  ] }],
1994
2003
  };
1995
- const html = run(`return drawLanes(${JSON.stringify(t)})`);
2004
+ // The elision belongs to the timeline; the list has no axis to cut.
2005
+ const html = run(`LANEVIEW="time"; return drawLanes(${JSON.stringify(t)})`);
1996
2006
  assert.match(html, /of waiting hidden/, "a reader must be told time was left out");
1997
2007
  assert.match(html, /shown of/, "and that the axis is shorter than the run");
1998
2008
  assert.match(html, /class="gapmark"/, "and see where");
@@ -2221,3 +2231,155 @@ test("filling holds the reader's place, and stops when the run is done", () => {
2221
2231
  assert.strictEqual(out.limit >= out.steps, true, `stopped at ${out.limit} of ${out.steps}`);
2222
2232
  assert.strictEqual(out.at, 400, "the reader stays where they were");
2223
2233
  });
2234
+
2235
+ // ---- a transcript has an address ---------------------------------------------
2236
+
2237
+ test("opening a transcript puts it in the address", () => {
2238
+ const out = run(`
2239
+ INDEX = [{key:"abc123", path:"/a.jsonl"}];
2240
+ globalThis.fetch = () => Promise.resolve({ok:true, json: () => Promise.resolve({steps:[]})});
2241
+ pick("abc123");
2242
+ return location.hash;
2243
+ `);
2244
+ assert.strictEqual(out, "#abc123", "so a refresh comes back here");
2245
+ });
2246
+
2247
+ test("going back to the library clears it", () => {
2248
+ const out = run(`
2249
+ INDEX = [{key:"abc123", path:"/a.jsonl", group:"Local"}];
2250
+ location.hash = "#abc123";
2251
+ showLibrary();
2252
+ return location.hash;
2253
+ `);
2254
+ assert.strictEqual(out, "", "the library is not one transcript");
2255
+ });
2256
+
2257
+ test("the whole key is used, never a prefix", () => {
2258
+ // Two transcripts can share a prefix; opening the wrong one would be silent.
2259
+ const out = run(`
2260
+ INDEX = [{key:"aaaaaaaaaaaaaaaa1111"},{key:"aaaaaaaaaaaaaaaa2222"}];
2261
+ globalThis.fetch = () => Promise.resolve({ok:true, json: () => Promise.resolve({steps:[]})});
2262
+ pick("aaaaaaaaaaaaaaaa2222");
2263
+ return { hash: location.hash, asked: addressed() };
2264
+ `);
2265
+ assert.strictEqual(out.hash, "#aaaaaaaaaaaaaaaa2222");
2266
+ assert.strictEqual(out.asked, "aaaaaaaaaaaaaaaa2222");
2267
+ });
2268
+
2269
+ test("an address naming nothing in this library says so, and shows the library", () => {
2270
+ const out = run(`
2271
+ INDEX = [{key:"real"}];
2272
+ location.hash = "#gone";
2273
+ let said = "";
2274
+ note = (m) => { said = m; };
2275
+ followAddress();
2276
+ return { said, cur, hash: location.hash };
2277
+ `);
2278
+ assert.strictEqual(out.cur, "gone", "it tried");
2279
+ // followAddress hands off to pick, which fetches; what matters is it did not
2280
+ // silently sit on the library with a stale address.
2281
+ assert.strictEqual(out.hash, "#gone");
2282
+ });
2283
+
2284
+ test("an empty address shows the library rather than a blank pane", () => {
2285
+ const out = run(`
2286
+ INDEX = [{key:"real", group:"Local"}];
2287
+ location.hash = "";
2288
+ cur = "real";
2289
+ followAddress();
2290
+ return cur;
2291
+ `);
2292
+ assert.strictEqual(out, null, "no address means no transcript");
2293
+ });
2294
+
2295
+ // ---- packing, and the two views ----------------------------------------------
2296
+
2297
+ test("lanes that never overlap share one row", () => {
2298
+ const t = {
2299
+ steps: [{ step_id: 1, source: "agent", timestamp: "2026-01-01T10:00:00Z" }],
2300
+ subagent_trajectories: [
2301
+ { trajectory_id: "a", agent: {}, extra: {}, steps: [
2302
+ { step_id: 1, source: "agent", timestamp: "2026-01-01T10:00:00Z" },
2303
+ { step_id: 2, source: "agent", timestamp: "2026-01-01T10:05:00Z" }] },
2304
+ { trajectory_id: "b", agent: {}, extra: {}, steps: [
2305
+ { step_id: 1, source: "agent", timestamp: "2026-01-01T10:06:00Z" },
2306
+ { step_id: 2, source: "agent", timestamp: "2026-01-01T10:10:00Z" }] },
2307
+ { trajectory_id: "c", agent: {}, extra: {}, steps: [
2308
+ { step_id: 1, source: "agent", timestamp: "2026-01-01T10:11:00Z" },
2309
+ { step_id: 2, source: "agent", timestamp: "2026-01-01T10:15:00Z" }] },
2310
+ ],
2311
+ };
2312
+ const out = run(`
2313
+ const lanes = laneIndex(${JSON.stringify(t)});
2314
+ const rows = packLanes(lanes);
2315
+ return { rows, on: lanes.map(l => l.row) };
2316
+ `);
2317
+ assert.strictEqual(out.rows, 1, "three in a row is one row, not three");
2318
+ assert.deepStrictEqual(out.on, [0, 0, 0]);
2319
+ });
2320
+
2321
+ test("a second row exists only because two things ran at once", () => {
2322
+ const t = {
2323
+ steps: [{ step_id: 1, source: "agent", timestamp: "2026-01-01T10:00:00Z" }],
2324
+ subagent_trajectories: [
2325
+ { trajectory_id: "a", agent: {}, extra: {}, steps: [
2326
+ { step_id: 1, source: "agent", timestamp: "2026-01-01T10:00:00Z" },
2327
+ { step_id: 2, source: "agent", timestamp: "2026-01-01T10:10:00Z" }] },
2328
+ { trajectory_id: "b", agent: {}, extra: {}, steps: [
2329
+ { step_id: 1, source: "agent", timestamp: "2026-01-01T10:05:00Z" },
2330
+ { step_id: 2, source: "agent", timestamp: "2026-01-01T10:12:00Z" }] },
2331
+ ],
2332
+ };
2333
+ const out = run(`
2334
+ const lanes = laneIndex(${JSON.stringify(t)});
2335
+ return { rows: packLanes(lanes), on: lanes.map(l => l.row) };
2336
+ `);
2337
+ assert.strictEqual(out.rows, 2);
2338
+ assert.deepStrictEqual(out.on, [0, 1], "the overlap is what made the row");
2339
+ });
2340
+
2341
+ test("the list is what the box shows first", () => {
2342
+ const html = run(`LANEVIEW="list"; return drawLanes(${JSON.stringify(TWO_AT_ONCE)})`);
2343
+ assert.match(html, /class="lanelist"/);
2344
+ assert.match(html, /what it was asked to do/, "the task leads");
2345
+ assert.ok(!/class="track"/.test(html), "and no chart is drawn");
2346
+ });
2347
+
2348
+ test("the list names each one and says what it cost", () => {
2349
+ const html = run(`LANEVIEW="list"; return drawLanes(${JSON.stringify(TWO_AT_ONCE)})`);
2350
+ assert.match(html, /find the theme/, "the task, where there was one");
2351
+ assert.match(html, /general-purpose/, "the kind, for the one with none");
2352
+ assert.match(html, /Grep/, "the tools it reached for");
2353
+ });
2354
+
2355
+ test("a subagent with nothing written down is set apart, not left blank", () => {
2356
+ const html = run(`LANEVIEW="list"; return drawLanes(${JSON.stringify(TWO_AT_ONCE)})`);
2357
+ assert.match(html, /class="lname untitled"/,
2358
+ "its kind stands in for a name, and reads as a handle rather than prose");
2359
+ });
2360
+
2361
+ test("the list sorts by what it is asked to sort by", () => {
2362
+ const order = (sort) => run(`
2363
+ LANEVIEW="list"; LANESORT=${JSON.stringify(sort)};
2364
+ LANES = laneIndex(${JSON.stringify(TWO_AT_ONCE)});
2365
+ const html = laneList({});
2366
+ return [...html.matchAll(/class="lname[^"]*">([^<]*)</g)].map(m => m[1]);
2367
+ `);
2368
+ // By name, which has no ties to be arbitrary about.
2369
+ const up = order("name");
2370
+ const down = order("name-");
2371
+ assert.deepStrictEqual(up, ["find the theme", "general-purpose"]);
2372
+ assert.deepStrictEqual(down, up.slice().reverse(), "and reverses when asked twice");
2373
+ });
2374
+
2375
+ test("switching view is remembered", () => {
2376
+ const out = run(`
2377
+ const saved = [];
2378
+ localStorage.setItem = (k, v) => saved.push([k, v]);
2379
+ render = () => {};
2380
+ setLaneView("time");
2381
+ return { LANEVIEW, saved };
2382
+ `);
2383
+ assert.strictEqual(out.LANEVIEW, "time");
2384
+ assert.deepStrictEqual(out.saved, [["transcript-viewer.delegation", "time"]]);
2385
+ });
@@ -388,7 +388,7 @@ wheels = [
388
388
 
389
389
  [[package]]
390
390
  name = "transcript-viewer"
391
- version = "0.12.0"
391
+ version = "0.14.0"
392
392
  source = { editable = "." }
393
393
  dependencies = [
394
394
  { name = "atif-make" },