transcript-viewer 0.13.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.13.0/README.md → transcript_viewer-0.14.0/PKG-INFO +26 -3
- transcript_viewer-0.13.0/PKG-INFO → transcript_viewer-0.14.0/README.md +13 -16
- {transcript_viewer-0.13.0 → transcript_viewer-0.14.0}/pyproject.toml +1 -1
- {transcript_viewer-0.13.0 → transcript_viewer-0.14.0}/src/transcript_viewer/page.html +245 -80
- {transcript_viewer-0.13.0 → transcript_viewer-0.14.0}/tests/page.test.js +94 -1
- {transcript_viewer-0.13.0 → transcript_viewer-0.14.0}/uv.lock +1 -1
- {transcript_viewer-0.13.0 → transcript_viewer-0.14.0}/.coverage +0 -0
- {transcript_viewer-0.13.0 → transcript_viewer-0.14.0}/.github/workflows/publish.yml +0 -0
- {transcript_viewer-0.13.0 → transcript_viewer-0.14.0}/.github/workflows/test.yml +0 -0
- {transcript_viewer-0.13.0 → transcript_viewer-0.14.0}/.gitignore +0 -0
- {transcript_viewer-0.13.0 → transcript_viewer-0.14.0}/design/Anchor.dc.html +0 -0
- {transcript_viewer-0.13.0 → transcript_viewer-0.14.0}/design/Dense.dc.html +0 -0
- {transcript_viewer-0.13.0 → transcript_viewer-0.14.0}/design/Hover.dc.html +0 -0
- {transcript_viewer-0.13.0 → transcript_viewer-0.14.0}/design/Main.dc.html +0 -0
- {transcript_viewer-0.13.0 → transcript_viewer-0.14.0}/design/Quiet.dc.html +0 -0
- {transcript_viewer-0.13.0 → transcript_viewer-0.14.0}/design/canvas.json +0 -0
- {transcript_viewer-0.13.0 → transcript_viewer-0.14.0}/design/delegation-timeline.html +0 -0
- {transcript_viewer-0.13.0 → transcript_viewer-0.14.0}/src/transcript_viewer/__init__.py +0 -0
- {transcript_viewer-0.13.0 → transcript_viewer-0.14.0}/src/transcript_viewer/ai.py +0 -0
- {transcript_viewer-0.13.0 → transcript_viewer-0.14.0}/src/transcript_viewer/cli.py +0 -0
- {transcript_viewer-0.13.0 → transcript_viewer-0.14.0}/src/transcript_viewer/config.py +0 -0
- {transcript_viewer-0.13.0 → transcript_viewer-0.14.0}/src/transcript_viewer/corpus.py +0 -0
- {transcript_viewer-0.13.0 → transcript_viewer-0.14.0}/src/transcript_viewer/fetch.py +0 -0
- {transcript_viewer-0.13.0 → transcript_viewer-0.14.0}/src/transcript_viewer/library.py +0 -0
- {transcript_viewer-0.13.0 → transcript_viewer-0.14.0}/src/transcript_viewer/store.py +0 -0
- {transcript_viewer-0.13.0 → transcript_viewer-0.14.0}/src/transcript_viewer/viewer.py +0 -0
- {transcript_viewer-0.13.0 → transcript_viewer-0.14.0}/tests/fixtures/big-command.json +0 -0
- {transcript_viewer-0.13.0 → transcript_viewer-0.14.0}/tests/test_ai.py +0 -0
- {transcript_viewer-0.13.0 → transcript_viewer-0.14.0}/tests/test_cli.py +0 -0
- {transcript_viewer-0.13.0 → transcript_viewer-0.14.0}/tests/test_config.py +0 -0
- {transcript_viewer-0.13.0 → transcript_viewer-0.14.0}/tests/test_corpus.py +0 -0
- {transcript_viewer-0.13.0 → transcript_viewer-0.14.0}/tests/test_fetch.py +0 -0
- {transcript_viewer-0.13.0 → transcript_viewer-0.14.0}/tests/test_library.py +0 -0
- {transcript_viewer-0.13.0 → transcript_viewer-0.14.0}/tests/test_page.py +0 -0
- {transcript_viewer-0.13.0 → transcript_viewer-0.14.0}/tests/test_readme.py +0 -0
- {transcript_viewer-0.13.0 → transcript_viewer-0.14.0}/tests/test_store.py +0 -0
- {transcript_viewer-0.13.0 → transcript_viewer-0.14.0}/tests/test_stream.py +0 -0
- {transcript_viewer-0.13.0 → transcript_viewer-0.14.0}/tests/test_style.py +0 -0
- {transcript_viewer-0.13.0 → transcript_viewer-0.14.0}/tests/test_tidiness.py +0 -0
- {transcript_viewer-0.13.0 → transcript_viewer-0.14.0}/tests/test_viewer.py +0 -0
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: transcript-viewer
|
|
3
|
+
Version: 0.14.0
|
|
4
|
+
Summary: Browse agent transcripts in a local, dependency-free web viewer.
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.12
|
|
7
|
+
Requires-Dist: atif-make>=0.7.1
|
|
8
|
+
Provides-Extra: ai
|
|
9
|
+
Requires-Dist: anthropic>=0.40; extra == 'ai'
|
|
10
|
+
Provides-Extra: parquet
|
|
11
|
+
Requires-Dist: atif-make[parquet]>=0.5.0; extra == 'parquet'
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
1
14
|
# transcript-viewer
|
|
2
15
|
|
|
3
16
|
Browse agent transcripts in a local web viewer — what Claude Code, Codex and
|
|
@@ -166,11 +179,21 @@ If that directory is still there, the first run moves it to
|
|
|
166
179
|
`~/.transcript-viewer` — index, annotations and stored keys together — and says
|
|
167
180
|
so. Nothing is left behind and nothing is copied twice.
|
|
168
181
|
|
|
169
|
-
A run that delegated shows
|
|
170
|
-
|
|
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
|
|
171
193
|
do — "Review backend for bugs and DRY" rather than "general-purpose" — wherever
|
|
172
194
|
that was written down, which is 50 of the 53 Claude Code subagents here; the
|
|
173
|
-
kind is the fallback and
|
|
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
|
|
174
197
|
cannot say — that two subagents were running at the same moment, which 25 pairs
|
|
175
198
|
in the corpus this was built against were. Two bars in the same column of the
|
|
176
199
|
chart is what says it; overlap gets no colour of its own, since a second
|
|
@@ -1,16 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.5
|
|
2
|
-
Name: transcript-viewer
|
|
3
|
-
Version: 0.13.0
|
|
4
|
-
Summary: Browse agent transcripts in a local, dependency-free web viewer.
|
|
5
|
-
License: MIT
|
|
6
|
-
Requires-Python: >=3.12
|
|
7
|
-
Requires-Dist: atif-make>=0.7.1
|
|
8
|
-
Provides-Extra: ai
|
|
9
|
-
Requires-Dist: anthropic>=0.40; extra == 'ai'
|
|
10
|
-
Provides-Extra: parquet
|
|
11
|
-
Requires-Dist: atif-make[parquet]>=0.5.0; extra == 'parquet'
|
|
12
|
-
Description-Content-Type: text/markdown
|
|
13
|
-
|
|
14
1
|
# transcript-viewer
|
|
15
2
|
|
|
16
3
|
Browse agent transcripts in a local web viewer — what Claude Code, Codex and
|
|
@@ -179,11 +166,21 @@ If that directory is still there, the first run moves it to
|
|
|
179
166
|
`~/.transcript-viewer` — index, annotations and stored keys together — and says
|
|
180
167
|
so. Nothing is left behind and nothing is copied twice.
|
|
181
168
|
|
|
182
|
-
A run that delegated shows
|
|
183
|
-
|
|
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
|
|
184
180
|
do — "Review backend for bugs and DRY" rather than "general-purpose" — wherever
|
|
185
181
|
that was written down, which is 50 of the 53 Claude Code subagents here; the
|
|
186
|
-
kind is the fallback and
|
|
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
|
|
187
184
|
cannot say — that two subagents were running at the same moment, which 25 pairs
|
|
188
185
|
in the corpus this was built against were. Two bars in the same column of the
|
|
189
186
|
chart is what says it; overlap gets no colour of its own, since a second
|
|
@@ -431,6 +431,41 @@ pre{background:var(--sunk);border-radius:7px;padding:10px;overflow:auto;font-siz
|
|
|
431
431
|
/* Conversations that belong to the record rather than to any step. */
|
|
432
432
|
.loose{margin-top:18px;padding-top:12px;border-top:1px solid var(--line)}
|
|
433
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}
|
|
434
469
|
.lanes-legend{display:flex;align-items:center;gap:14px;margin-top:10px;padding-top:9px;
|
|
435
470
|
border-top:1px solid var(--line);font-size:11px;color:var(--muted);flex-wrap:wrap}
|
|
436
471
|
.lanes-legend i{display:inline-block;width:16px;height:8px;border-radius:2px;
|
|
@@ -2538,6 +2573,31 @@ function laneIndex(t) {
|
|
|
2538
2573
|
return lanes;
|
|
2539
2574
|
}
|
|
2540
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
|
+
|
|
2541
2601
|
/* Stretches of time where more than one subagent was running. */
|
|
2542
2602
|
function together(lanes) {
|
|
2543
2603
|
const edges = [];
|
|
@@ -2626,6 +2686,23 @@ function axisMarks(lo, hi) {
|
|
|
2626
2686
|
|
|
2627
2687
|
let LANES = [];
|
|
2628
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
|
+
|
|
2629
2706
|
function drawLanes(t) {
|
|
2630
2707
|
LANES = laneIndex(t);
|
|
2631
2708
|
if (!LANES.length) return "";
|
|
@@ -2633,95 +2710,183 @@ function drawLanes(t) {
|
|
|
2633
2710
|
const timed = LANES.filter((l) => l.span);
|
|
2634
2711
|
const parent = spanMs(t.steps);
|
|
2635
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
|
+
};
|
|
2636
2756
|
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
onclick="openLane('${esc(lane.id)}')"></button>`)).join("");
|
|
2648
|
-
return laneShell({
|
|
2649
|
-
count: LANES.length,
|
|
2650
|
-
rows,
|
|
2651
|
-
mainRow: laneRow({ kind: "main thread", steps, main: true }, tight,
|
|
2652
|
-
`<span class="bar main" style="left:0;width:100%"></span>`),
|
|
2653
|
-
marks: "",
|
|
2654
|
-
// No clock to lay anything against, so no duration is claimed and no
|
|
2655
|
-
// overlap either: without times, two bars sitting side by side would
|
|
2656
|
-
// say something the log never recorded.
|
|
2657
|
-
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
|
+
};
|
|
2658
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` : "";
|
|
2659
2791
|
}
|
|
2660
2792
|
|
|
2661
|
-
|
|
2662
|
-
const
|
|
2663
|
-
const hi = Math.max(parent[1], ...timed.map((l) => l.span[1]));
|
|
2664
|
-
|
|
2665
|
-
// What was actually happening, so the quiet stretches can be skipped.
|
|
2666
|
-
const busy = timed.map((l) => l.span);
|
|
2667
|
-
const points = (t.steps || []).map(at).filter((v) => v !== null).sort((a, b) => a - b);
|
|
2668
|
-
for (const v of points) busy.push([v, v]);
|
|
2669
|
-
|
|
2670
|
-
const scale = timeScale(lo, hi, busy);
|
|
2671
|
-
const bands = together(timed);
|
|
2672
|
-
|
|
2673
|
-
// Every quiet stretch is skipped; only the large ones are marked. Drawing a
|
|
2674
|
-
// line for all 152 of one session's turned the chart into vertical stripes,
|
|
2675
|
-
// and a mark every few pixels tells a reader nothing they can act on.
|
|
2676
|
-
const worth = scale.gaps
|
|
2677
|
-
.slice()
|
|
2678
|
-
.sort((a, b) => b.ms - a.ms)
|
|
2679
|
-
.slice(0, 8);
|
|
2680
|
-
const breaks = worth.map((g) =>
|
|
2681
|
-
`<span class="gapmark" style="left:${g.at}%" title="nothing running for ${esc(g.took)}"></span>`
|
|
2682
|
-
).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";
|
|
2683
2795
|
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
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
|
+
}
|
|
2688
2820
|
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
`<span class="bar main" style="left:${mainAt.left}%;width:${mainAt.width}%"></span>`);
|
|
2692
|
-
|
|
2693
|
-
const rows = LANES.map((lane) => {
|
|
2694
|
-
const at = bar(lane.span);
|
|
2695
|
-
return laneRow(lane, tight,
|
|
2696
|
-
`<button class="bar" style="left:${at.left}%;width:${at.width}%"
|
|
2697
|
-
title="${esc(lane.kind + (lane.said ? " — " + lane.said : ""))} · ${esc(duration(lane.span[1] - lane.span[0]))}"
|
|
2698
|
-
onmouseenter="laneCard(event,'${esc(lane.id)}')" onmouseleave="laneCard(null)"
|
|
2699
|
-
onclick="openLane('${esc(lane.id)}')"></button>`);
|
|
2700
|
-
}).join("");
|
|
2821
|
+
/* What was farmed out, and what came back. Sorted as the reader asks. */
|
|
2822
|
+
let LANESORT = "start";
|
|
2701
2823
|
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2824
|
+
function setLaneSort(k) {
|
|
2825
|
+
LANESORT = LANESORT === k ? k + "-" : k;
|
|
2826
|
+
const at = main.scrollTop;
|
|
2827
|
+
render();
|
|
2828
|
+
main.scrollTop = at;
|
|
2829
|
+
}
|
|
2706
2830
|
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
const
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
mainRow,
|
|
2720
|
-
marks: marks.map((m) => `<span style="left:${m.pos}%">${esc(m.label)}</span>`).join(""),
|
|
2721
|
-
note,
|
|
2722
|
-
layer: breaks,
|
|
2723
|
-
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;
|
|
2724
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>`;
|
|
2725
2890
|
}
|
|
2726
2891
|
|
|
2727
2892
|
/* One row: who, how many steps, and the track it sits on. */
|
|
@@ -2001,7 +2001,8 @@ test("what was skipped is stated, not swallowed", () => {
|
|
|
2001
2001
|
{ step_id: 2, source: "agent", timestamp: "2026-01-01T10:02:00Z" },
|
|
2002
2002
|
] }],
|
|
2003
2003
|
};
|
|
2004
|
-
|
|
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)})`);
|
|
2005
2006
|
assert.match(html, /of waiting hidden/, "a reader must be told time was left out");
|
|
2006
2007
|
assert.match(html, /shown of/, "and that the axis is shorter than the run");
|
|
2007
2008
|
assert.match(html, /class="gapmark"/, "and see where");
|
|
@@ -2290,3 +2291,95 @@ test("an empty address shows the library rather than a blank pane", () => {
|
|
|
2290
2291
|
`);
|
|
2291
2292
|
assert.strictEqual(out, null, "no address means no transcript");
|
|
2292
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
|