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.
- {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/PKG-INFO +29 -5
- {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/README.md +28 -4
- {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/pyproject.toml +1 -1
- {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/src/transcript_viewer/page.html +306 -91
- {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/tests/page.test.js +163 -1
- {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/uv.lock +1 -1
- {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/.coverage +0 -0
- {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/.github/workflows/publish.yml +0 -0
- {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/.github/workflows/test.yml +0 -0
- {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/.gitignore +0 -0
- {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/design/Anchor.dc.html +0 -0
- {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/design/Dense.dc.html +0 -0
- {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/design/Hover.dc.html +0 -0
- {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/design/Main.dc.html +0 -0
- {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/design/Quiet.dc.html +0 -0
- {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/design/canvas.json +0 -0
- {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/design/delegation-timeline.html +0 -0
- {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/src/transcript_viewer/__init__.py +0 -0
- {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/src/transcript_viewer/ai.py +0 -0
- {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/src/transcript_viewer/cli.py +0 -0
- {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/src/transcript_viewer/config.py +0 -0
- {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/src/transcript_viewer/corpus.py +0 -0
- {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/src/transcript_viewer/fetch.py +0 -0
- {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/src/transcript_viewer/library.py +0 -0
- {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/src/transcript_viewer/store.py +0 -0
- {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/src/transcript_viewer/viewer.py +0 -0
- {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/tests/fixtures/big-command.json +0 -0
- {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/tests/test_ai.py +0 -0
- {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/tests/test_cli.py +0 -0
- {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/tests/test_config.py +0 -0
- {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/tests/test_corpus.py +0 -0
- {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/tests/test_fetch.py +0 -0
- {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/tests/test_library.py +0 -0
- {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/tests/test_page.py +0 -0
- {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/tests/test_readme.py +0 -0
- {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/tests/test_store.py +0 -0
- {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/tests/test_stream.py +0 -0
- {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/tests/test_style.py +0 -0
- {transcript_viewer-0.12.0 → transcript_viewer-0.14.0}/tests/test_tidiness.py +0 -0
- {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.
|
|
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
|
|
183
|
-
|
|
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
|
-
|
|
192
|
-
|
|
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
|
|
170
|
-
|
|
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
|
-
|
|
179
|
-
|
|
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
|
|
@@ -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:
|
|
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:
|
|
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:
|
|
414
|
-
.lane-axis.tight{grid-template-columns:
|
|
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:
|
|
422
|
-
.lane.tight ~ .bandlayer,.lanes-tracks:has(.lane.tight) .bandlayer{left:
|
|
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();
|
|
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
|
-
|
|
2275
|
-
|
|
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
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
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
|
-
|
|
2616
|
-
const
|
|
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
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
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
|
-
|
|
2644
|
-
|
|
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
|
-
|
|
2657
|
-
|
|
2658
|
-
|
|
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
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
const
|
|
2665
|
-
|
|
2666
|
-
|
|
2667
|
-
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
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" : ""}
|
|
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
|
-
|
|
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
|
+
});
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|