glassbox-trace 0.3.0 → 0.4.1

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.
package/src/viewer.html CHANGED
@@ -1,5 +1,5 @@
1
1
  <title>Glassbox</title>
2
- <meta name="description" content="The flight recorder viewer for Claude Code and Agent SDK sessions. Drop a session .jsonl; get a timeline, token and cost burn, and auto-diagnostics. Nothing leaves your browser.">
2
+ <meta name="description" content="Glassbox tells you what went wrong in a Claude Code or Agent SDK session. Drop a session .jsonl; get a timeline, token and cost burn, auto-diagnostics, session compare and live tail. Nothing leaves your browser.">
3
3
  <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 64 64%27%3E%3Crect width=%2764%27 height=%2764%27 rx=%2712%27 fill=%27%23161C24%27/%3E%3Crect x=%2712%27 y=%2728%27 width=%2740%27 height=%278%27 rx=%272%27 fill=%27%23FF8A4D%27/%3E%3Crect x=%2712%27 y=%2716%27 width=%2724%27 height=%278%27 rx=%272%27 fill=%27%237DB0F0%27/%3E%3Crect x=%2724%27 y=%2740%27 width=%2728%27 height=%278%27 rx=%272%27 fill=%27%234FD0BB%27/%3E%3C/svg%3E">
4
4
  <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+Condensed:wght@500;600&family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap">
5
5
  <style>
@@ -12,6 +12,7 @@
12
12
  --err:#C4342A;--warn:#8A5F00;--info:#2A63B8;--ok:#177A6B;
13
13
  --err-soft:rgba(196,52,42,.12);--warn-soft:rgba(138,95,0,.14);--info-soft:rgba(42,99,184,.12);
14
14
  --focus:#B8420A;
15
+ --who-a:#2A63B8;--who-b:#5B3FB8;
15
16
  --shadow:0 10px 30px rgba(22,28,36,.14);
16
17
  --sans:"IBM Plex Sans",system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
17
18
  --cond:"IBM Plex Sans Condensed","Arial Narrow",var(--sans);
@@ -28,6 +29,7 @@
28
29
  --err:#F27C72;--warn:#E9B63A;--info:#7FB2F0;--ok:#4FD0BB;
29
30
  --err-soft:rgba(242,124,114,.16);--warn-soft:rgba(233,182,58,.16);--info-soft:rgba(127,178,240,.16);
30
31
  --focus:#FF8A4D;
32
+ --who-a:#7DB0F0;--who-b:#B39CF5;
31
33
  --shadow:0 10px 30px rgba(0,0,0,.5);
32
34
  }
33
35
  }
@@ -40,6 +42,7 @@
40
42
  --err:#F27C72;--warn:#E9B63A;--info:#7FB2F0;--ok:#4FD0BB;
41
43
  --err-soft:rgba(242,124,114,.16);--warn-soft:rgba(233,182,58,.16);--info-soft:rgba(127,178,240,.16);
42
44
  --focus:#FF8A4D;
45
+ --who-a:#7DB0F0;--who-b:#B39CF5;
43
46
  --shadow:0 10px 30px rgba(0,0,0,.5);
44
47
  }
45
48
  :root[data-density="compact"]{--fs:12.5px;--pad:8px;--rowh:15px}
@@ -76,6 +79,33 @@ a{color:var(--accent)}
76
79
  .banner p{margin:0;flex:1}
77
80
  .banner code{font-family:var(--mono);font-size:12.5px}
78
81
 
82
+ /* live pill + compare */
83
+ .live{display:inline-flex;align-items:center;gap:6px;font:500 11px/1 var(--cond);letter-spacing:.08em;text-transform:uppercase;color:var(--ok);border:1px solid var(--ok);border-radius:999px;padding:4px 9px;margin-left:8px}
84
+ .live i{width:7px;height:7px;border-radius:50%;background:var(--ok);animation:pulse 1.6s ease-in-out infinite}
85
+ .live.off{color:var(--warn);border-color:var(--warn)}.live.off i{background:var(--warn);animation:none}
86
+ @keyframes pulse{0%,100%{opacity:1}50%{opacity:.25}}
87
+ @media (prefers-reduced-motion:reduce){.live i{animation:none}}
88
+ .cmp{padding:var(--pad)}
89
+ .cmp .verdict{display:flex;gap:18px;flex-wrap:wrap;font-size:13px;margin:0 0 10px;color:var(--muted)}
90
+ .cmp .verdict b{color:var(--ink)}
91
+ .cmp .who{display:inline-block;padding:1px 7px;border-radius:999px;font:500 11px var(--cond);letter-spacing:.06em;text-transform:uppercase;background:var(--panel-2);border:1px solid var(--line);color:var(--ink-2)}
92
+ .cmp .who.a{border-color:var(--who-a);color:var(--who-a)}.cmp .who.b{border-color:var(--who-b);color:var(--who-b)}
93
+ .cmp .grid{display:grid;grid-template-columns:minmax(0,3fr) minmax(0,2fr);gap:16px}
94
+ @media (max-width:900px){.cmp .grid{grid-template-columns:1fr}}
95
+ .cmp table{font-size:13px}
96
+ .cmp td.d{min-width:140px}
97
+ .cmp .dbar{display:flex;align-items:center;gap:8px}
98
+ .cmp .dbar .track{position:relative;flex:1;height:8px;background:var(--panel-2);border-radius:4px;overflow:hidden;border:1px solid var(--line-2)}
99
+ .cmp .dbar .fill{position:absolute;top:0;bottom:0;background:var(--faint)}
100
+ .cmp .dbar .fill.good{background:var(--ok)}.cmp .dbar .fill.bad{background:var(--err)}
101
+ .cmp .dbar .ch{font-family:var(--mono);font-size:12px;min-width:56px;text-align:right;color:var(--muted)}
102
+ .cmp .dbar .ch.good{color:var(--ok)}.cmp .dbar .ch.bad{color:var(--err)}
103
+ .cmp h3{font:600 11px var(--cond);letter-spacing:.1em;text-transform:uppercase;color:var(--muted);margin:14px 0 6px}
104
+ .cmp .fl{margin:0;padding:0;list-style:none;font-size:13px}
105
+ .cmp .fl li{padding:4px 0;border-top:1px solid var(--line-2)}
106
+ .cmp .fl .id{font:11px var(--mono);color:var(--faint);margin-left:6px}
107
+ .cmp .empty{padding:8px 0}
108
+
79
109
  /* session line */
80
110
  .session{display:flex;gap:18px;flex-wrap:wrap;align-items:baseline;padding:10px 0 0;color:var(--muted);font-size:13px}
81
111
  .session .title{color:var(--ink);font-weight:500;font-size:15px}
@@ -228,7 +258,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
228
258
 
229
259
  <div class="app">
230
260
  <header class="mast">
231
- <div class="brand"><h1>Glass<span>box</span></h1><span class="tag">flight recorder for Claude Code sessions</span></div>
261
+ <div class="brand"><h1>Glass<span>box</span></h1><span class="tag">what went wrong in your Claude Code session</span><span class="live" id="live" hidden role="status" aria-live="polite"><i aria-hidden="true"></i><span id="live-txt">LIVE</span></span></div>
232
262
  <label class="drop" id="drop" tabindex="0" aria-label="Drop a session .jsonl file or folder here, or press Enter to choose files">
233
263
  <input type="file" id="file" multiple accept=".jsonl,.json,.txt" class="sr-only">
234
264
  <span class="hint"><b>Drop a session .jsonl</b> (or the whole session folder) — parsed in your browser, never uploaded.</span>
@@ -238,6 +268,8 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
238
268
  <button class="btn" id="open-folder" hidden title="Browse ~/.claude/projects and pick a session">Open folder…</button>
239
269
  <button class="btn" id="recent" hidden title="Sessions from the folder you opened before">Recent</button>
240
270
  <button class="btn" id="demo">Load demo</button>
271
+ <button class="btn" id="compare" title="Load a second session and compare it with this one">Compare…</button>
272
+ <input type="file" id="file-cmp" multiple accept=".jsonl,.json,.txt" class="sr-only" aria-label="Choose the session to compare with">
241
273
  <button class="btn" id="share" aria-pressed="false" title="Blank every prompt, input and result so the trace can be shared">Share mode</button>
242
274
  <button class="btn" id="export" disabled title="Download a structure-only copy of the transcript">Export redacted</button>
243
275
  <button class="btn" id="rates" title="Edit the per-model rate card">Rates</button>
@@ -254,9 +286,15 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
254
286
  <div id="problems" class="problems" hidden role="alert"></div>
255
287
  <dl class="strip" id="strip"></dl>
256
288
 
289
+ <section class="sec" id="cmp-sec" hidden aria-labelledby="h-cmp">
290
+ <div class="sec-h"><h2 id="h-cmp">Compare</h2><div class="tools"><span class="aux" id="cmp-aux"></span><button class="btn sm" id="cmp-swap" title="Put the other session on the timeline">Swap</button><button class="btn sm" id="cmp-copy">Copy as Markdown</button><button class="btn sm" id="cmp-close" aria-label="Close compare">Close</button></div></div>
291
+ <div class="panel cmp" id="cmp"></div>
292
+ </section>
293
+
257
294
  <section class="sec" aria-labelledby="h-tl">
258
295
  <div class="sec-h"><h2 id="h-tl">Timeline</h2>
259
296
  <div class="tools">
297
+ <button class="btn" id="follow" hidden aria-pressed="true" title="Keep the right edge of the timeline on now">Follow</button>
260
298
  <div class="search"><label class="sr-only" for="q">Search prompts, tool inputs and results</label><input id="q" type="search" placeholder="Search…"><span class="n" id="q-n" aria-live="polite"></span></div>
261
299
  <label class="sr-only" for="fit">Fit to turn</label><select id="fit" class="btn"></select>
262
300
  <button class="btn" id="z-out" aria-label="Zoom out">−</button><button class="btn" id="z-in" aria-label="Zoom in">+</button><button class="btn" id="z-reset" aria-label="Reset zoom">⟲</button>
@@ -315,6 +353,15 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
315
353
  <div class="db"><p class="note" style="margin:0 0 8px">The clipboard isn't available here — the text is selected below, press <kbd>Ctrl</kbd>/<kbd>⌘</kbd>+<kbd>C</kbd>.</p><label class="sr-only" for="copy-text">Text to copy</label><textarea id="copy-text" style="width:100%;height:240px;font:12px/1.45 var(--mono);background:var(--panel-2);color:var(--ink);border:1px solid var(--line);border-radius:4px;padding:8px"></textarea></div>
316
354
  </dialog>
317
355
 
356
+ <dialog id="cmp-dlg" aria-labelledby="cmp-h">
357
+ <div class="dh"><h3 id="cmp-h">Compare with another session</h3><button class="x" id="cmp-dlg-close" aria-label="Close">×</button></div>
358
+ <div class="db" style="font-size:13.5px;line-height:1.55">
359
+ <p style="margin:0 0 12px">Same task, two sessions: Glassbox lines up time, tokens, cost, tool use and findings side by side. The session already loaded stays on the timeline as <b>A</b>; the one you pick becomes <b>B</b>.</p>
360
+ <div class="controls"><button class="btn primary" id="cmp-pick">Choose files…</button><button class="btn" id="cmp-folder" hidden>From folder…</button><button class="btn" id="cmp-demo">Use the demo</button></div>
361
+ <p class="note" style="margin:12px 0 0">Or drop the second session's <code class="mono">.jsonl</code> anywhere on the page while this dialog is open. From the CLI: <code class="mono">glassbox compare A B --out compare.html</code>.</p>
362
+ </div>
363
+ </dialog>
364
+
318
365
  <dialog id="help-dlg" aria-labelledby="help-h">
319
366
  <div class="dh"><h3 id="help-h">How to read Glassbox</h3><button class="x" id="help-close" aria-label="Close">×</button></div>
320
367
  <div class="db" style="font-size:13.5px;line-height:1.55" tabindex="0">
@@ -322,6 +369,8 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
322
369
  <p><b>Context &amp; cost.</b> One bar per API request: the size of the prompt the model saw, split into cached reads (light), cache writes (mid) and uncached input (dark). The line is cumulative estimated cost.</p>
323
370
  <p><b>Findings.</b> Mechanical rules, no AI: identical calls repeated in a turn, tool error rates, results over 20k characters, context over 120k tokens, mid-session cache invalidation, responses that took over a minute and produced little, truncated outputs, hook and API errors, compaction events. Select one to highlight its evidence on the timeline; copy one or all as Markdown to paste into an issue or back into the agent.</p>
324
371
  <p><b>Tokens.</b> Assistant messages are stored one record per content block with the usage repeated on each; Glassbox counts usage once per API request. Thinking tokens are part of output tokens, shown as a share, never double counted. Cost is an estimate from the rate card unless the file carries a reported <code class="mono">total_cost_usd</code>.</p>
372
+ <p><b>Compare.</b> Load a second session (<b>Compare…</b>) and Glassbox lines the two up: every metric with a change bar, tool use side by side, and which findings appear in only one of them. <b>Swap</b> puts the other session on the timeline.</p>
373
+ <p><b>Live.</b> <code class="mono">glassbox watch</code> serves this page from your own machine and streams the transcript as Claude Code writes it. <b>Follow</b> keeps the right edge on now; pan or zoom and it lets go.</p>
325
374
  <p><b>Share mode.</b> Blanks every prompt, tool input, result and assistant text in the view; <b>Export redacted</b> downloads a structure-only <code class="mono">.jsonl</code> that keeps timestamps, usage and tool names — safe to post publicly.</p>
326
375
  <p><b>Keyboard.</b></p>
327
376
  <div class="keys">
@@ -354,7 +403,8 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
354
403
  const say = (msg) => { const s = $('#status'); s.textContent = ''; setTimeout(() => { s.textContent = msg; }, 30); };
355
404
 
356
405
  // ------------------------------------------------------------ state
357
- const S = { files: [], records: [], trace: null, findings: [], cost: null, rates: loadRates(), share: false, sel: null, hl: new Set(), toolFilter: null, view: null, query: '', matches: new Set(), focusId: null, lastFocus: null, isDemo: false };
406
+ const S = { files: [], records: [], trace: null, findings: [], cost: null, rates: loadRates(), share: false, sel: null, hl: new Set(), toolFilter: null, view: null, query: '', matches: new Set(), focusId: null, lastFocus: null, isDemo: false, other: null, labels: { a: 'A', b: 'B' }, cmp: null, follow: true, live: null };
407
+ const pickingForCompare = () => $('#cmp-dlg').open;
358
408
  function loadRates() { try { const r = JSON.parse(store.get('rates') || 'null'); if (r && typeof r === 'object') return r; } catch (e) { } return JSON.parse(JSON.stringify(C.RATES)); }
359
409
  const R = (s) => S.share ? '«' + String(s == null ? '' : s).length + ' chars»' : s;
360
410
 
@@ -379,9 +429,10 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
379
429
  if (!usedEntries) for (const f of Array.from(dt.files || [])) out.push({ name: f.name, file: f });
380
430
  await loadFiles(out);
381
431
  }
382
- async function loadFiles(list) { const files = []; for (const { name, file } of list) files.push({ name: (file.webkitRelativePath || name), text: await file.text() }); if (files.length) load(files, false); else say('No .jsonl files found in what was dropped.'); }
383
- function load(files, isDemo) {
432
+ async function loadFiles(list, forCompare) { const files = []; for (const { name, file } of list) files.push({ name: (file.webkitRelativePath || name), text: await file.text() }); if (!files.length) { say('No .jsonl files found in what was dropped.'); return; } if (forCompare || pickingForCompare()) loadOther(files); else load(files, false); }
433
+ function load(files, isDemo, keepOther) {
384
434
  S.files = files; S.isDemo = !!isDemo;
435
+ if (!keepOther) { S.other = null; S.cmp = null; $('#cmp-sec').hidden = true; }
385
436
  S.records = files.filter((f) => !/\.meta\.json$/.test(f.name)).flatMap((f) => C.parseLines(f.text, f.name).records);
386
437
  S.trace = C.parseTrace(files);
387
438
  S.findings = C.diagnose(S.trace);
@@ -389,6 +440,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
389
440
  S.sel = null; S.hl = new Set(); S.toolFilter = null; S.view = null; S.query = ''; S.matches = new Set(); S.focusId = null; $('#q').value = ''; $('#q-n').textContent = '';
390
441
  $('#main').hidden = false; $('#banner').hidden = !isDemo;
391
442
  renderAll();
443
+ if (S.other) renderCompare();
392
444
  const T = S.trace.totals;
393
445
  if (!S.trace.requests.length && !S.trace.toolCalls.length) { const p = $('#problems'); p.hidden = false; p.textContent = 'No API requests or tool calls were found — this does not look like a Claude Code / Agent SDK transcript. Expected a .jsonl with "type":"assistant" and "type":"user" records.'; }
394
446
  say(`Loaded ${isDemo ? 'the demo session' : files.length + ' file' + (files.length > 1 ? 's' : '')}: ${T.turns} turns, ${T.toolCalls} tool calls, ${S.findings.length} findings${S.trace.problems.length ? ', ' + S.trace.problems.length + ' unreadable lines skipped' : ''}.`);
@@ -608,7 +660,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
608
660
  function hideTip() { tip.style.display = 'none'; }
609
661
  document.addEventListener('mousemove', (e) => { if (tip.style.display === 'block' && !(document.activeElement && document.activeElement.dataset && document.activeElement.dataset.kind)) moveTip(e.clientX, e.clientY); });
610
662
  function cum(i) { let s = 0; for (let k = 0; k <= i; k++) s += S.cost.byRequest[k] || 0; return s; }
611
- function inputSummary(c) { const i = c.input || {}; if (i.command) return i.command; if (i.file_path) return i.file_path + (i.pattern ? ' ' + i.pattern : ''); if (i.pattern) return i.pattern; if (i.query) return i.query; if (i.description) return i.description; if (i.url) return i.url; if (i.prompt) return i.prompt; if (i.path) return i.path; const s = JSON.stringify(i); return s === '{}' ? '' : s; }
663
+ const inputSummary = C.inputSummary;
612
664
 
613
665
  // ------------------------------------------------------------ burn chart
614
666
  function renderBurn() {
@@ -648,13 +700,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
648
700
 
649
701
  // ------------------------------------------------------------ findings
650
702
  function findingMd(f) { return `- **${f.severity.toUpperCase()}** \`${f.id}\` — ${f.title}\n ${f.detail}`; }
651
- function reportMd() {
652
- const t = S.trace, T = t.totals, m = t.meta;
653
- const head = `# Glassbox report — ${R(m.title || 'session')}\n\n` + `- session: ${m.sessionId || '—'} · model: ${m.models.join(', ') || '—'} · started: ${m.start ? new Date(m.start).toISOString() : '—'}\n` + `- wall ${fmtDur(T.wallMs)} (active ${fmtDur(T.activeMs)}) · ${T.turns} turns · ${T.requests} requests · ${T.toolCalls} tool calls (${T.toolErrors} failed)\n` + `- tokens: ${fmtInt(T.usage.input + T.usage.cacheRead + T.usage.cacheWrite)} context served (${pct(T.cacheHitRatio)} cached), ${fmtInt(T.usage.output)} output (${fmtInt(T.usage.thinking)} thinking) · est. cost ${S.cost.total != null ? fmtUSD(S.cost.total) : '—'}\n\n`;
654
- const fs = S.findings.length ? `## Findings (${S.findings.length})\n\n` + S.findings.map(findingMd).join('\n') + '\n' : '## Findings\n\nNothing flagged.\n';
655
- const tools = '\n## Tools\n\n| tool | calls | errors | total time |\n|---|---|---|---|\n' + toolRows().map((r) => `| ${r.name} | ${r.calls} | ${r.errors} | ${fmtDur(r.time)} |`).join('\n') + '\n';
656
- return head + fs + tools + '\n_Generated by Glassbox._\n';
657
- }
703
+ function reportMd() { return C.reportMarkdown(S.trace, S.findings, S.cost, { redact: S.share }); }
658
704
  async function copyText(text, what) { try { await navigator.clipboard.writeText(text); say(what + ' copied to clipboard.'); return true; } catch (e) { const dlg = $('#copy-dlg'); $('#copy-text').value = text; dlg.showModal(); $('#copy-text').select(); say('Clipboard blocked — text shown for manual copy.'); return false; } }
659
705
  $('#copy-close').onclick = () => $('#copy-dlg').close();
660
706
  function renderFindings() {
@@ -782,6 +828,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
782
828
  function setHash(h) { hashLock = true; try { history.replaceState(null, '', '#' + h); } catch (e) { } setTimeout(() => { hashLock = false; }, 0); }
783
829
  function applyHash() {
784
830
  const h = location.hash.slice(1); if (!h || !S.trace) return;
831
+ if (h === 'compare') { if (S.other) $('#cmp-sec').scrollIntoView(); return; }
785
832
  const eq = h.indexOf('='); if (eq < 0) return; const k = h.slice(0, eq), v = decodeURIComponent(h.slice(eq + 1)); if (!v) return;
786
833
  if (k === 'req') { const r = S.trace.requests[+v - 1]; if (r) select({ kind: 'req', ref: r }); }
787
834
  else if (k === 'tool') { const c = S.trace.toolCalls.find((x) => x.id === v); if (c) select({ kind: 'tool', ref: c }); }
@@ -813,7 +860,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
813
860
  const idb = { open() { return new Promise((res, rej) => { const r = indexedDB.open('glassbox', 1); r.onupgradeneeded = () => r.result.createObjectStore('kv'); r.onsuccess = () => res(r.result); r.onerror = () => rej(r.error); }); },
814
861
  async get(k) { try { const db = await this.open(); return await new Promise((res) => { const t = db.transaction('kv').objectStore('kv').get(k); t.onsuccess = () => res(t.result); t.onerror = () => res(null); }); } catch (e) { return null; } },
815
862
  async set(k, v) { try { const db = await this.open(); await new Promise((res) => { const t = db.transaction('kv', 'readwrite').objectStore('kv').put(v, k); t.onsuccess = res; t.onerror = res; }); } catch (e) { } } };
816
- const decodeProject = (n) => n.replace(/^-/, '/').replace(/-/g, '/');
863
+ const decodeProject = (n) => { const m = n.match(/^([A-Za-z])--(.*)$/); if (m) return m[1] + ':/' + m[2].replace(/-/g, '/'); return n.replace(/^-/, '/').replace(/-/g, '/'); };
817
864
  async function scanProjects(dir) {
818
865
  const sessions = [];
819
866
  for await (const [pname, ph] of dir.entries()) {
@@ -835,7 +882,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
835
882
  async function loadSession(s) {
836
883
  const files = [{ name: s.id + '.jsonl', text: await (await s.handle.getFile()).text() }];
837
884
  try { const sdir = await (await s.pdir.getDirectoryHandle(s.id)).getDirectoryHandle('subagents'); for await (const [n, h] of sdir.entries()) if (h.kind === 'file' && /\.(jsonl|json)$/.test(n)) files.push({ name: s.id + '/subagents/' + n, text: await (await h.getFile()).text() }); } catch (e) { }
838
- $('#sessions-dlg').close(); load(files, false);
885
+ $('#sessions-dlg').close(); if (S.pickOther) { S.pickOther = false; loadOther(files); } else load(files, false);
839
886
  }
840
887
  async function showSessions(dir) {
841
888
  const dlg = $('#sessions-dlg'); $('#sessions-note').textContent = 'Scanning ' + dir.name + '…'; $('#sessions-tbl').innerHTML = ''; dlg.showModal();
@@ -853,6 +900,99 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
853
900
  }
854
901
  $('#sessions-close').onclick = () => $('#sessions-dlg').close();
855
902
 
903
+ // ------------------------------------------------------------ compare
904
+ function analyseFiles(files) { const trace = C.parseTrace(files); return { files, trace, findings: C.diagnose(trace), cost: C.estimateCost(trace, S.rates) }; }
905
+ function loadOther(files) {
906
+ if (!S.trace) { say('Load a session first, then compare it with another.'); return; }
907
+ S.other = analyseFiles(files);
908
+ try { $('#cmp-dlg').close(); } catch (e) { }
909
+ renderCompare();
910
+ const m = S.cmp.metrics.find((x) => x.key === 'cost');
911
+ say(`Comparing: ${S.labels.a} vs ${S.labels.b}. ${S.other.trace.totals.toolCalls} tool calls in ${S.labels.b}${m && m.better ? ', ' + (m.better === 'a' ? S.labels.a : S.labels.b) + ' is cheaper' : ''}.`);
912
+ $('#cmp-sec').scrollIntoView({ behavior: 'smooth', block: 'start' });
913
+ setHash('compare');
914
+ }
915
+ function sessionLabel(res, fallback) { const m = res.trace.meta; return m.title ? m.title.slice(0, 40) : m.sessionId ? m.sessionId.slice(0, 8) : fallback; }
916
+ function renderCompare() {
917
+ if (!S.other) return;
918
+ const A = { trace: S.trace, findings: S.findings, cost: S.cost }, B = S.other;
919
+ const c = S.cmp = C.compare(A, B);
920
+ const la = S.labels.a, lb = S.labels.b;
921
+ const who = (x) => x === 'a' ? `<span class="who a">${esc(la)}</span>` : x === 'b' ? `<span class="who b">${esc(lb)}</span>` : '<span class="who">tie</span>';
922
+ $('#cmp-aux').innerHTML = `<span class="who a">${esc(la)}</span> ${esc(R(sessionLabel(A, 'this session')))} · <span class="who b">${esc(lb)}</span> ${esc(R(sessionLabel(B, 'other session')))} — ${esc(la)} is on the timeline`;
923
+ const rows = c.metrics.map((m) => {
924
+ let bar = '<span class="ch">—</span>';
925
+ if (m.delta != null) {
926
+ const good = m.better === 'a' ? 'bad' : m.better === 'b' ? 'good' : '';
927
+ const ch = C.fmtChange(m);
928
+ const r = m.ratio != null && m.a ? m.ratio : (m.delta === 0 ? 1 : 2);
929
+ const f = Math.min(1, Math.abs(Math.log2(Math.max(r, 1e-6))) / 3);
930
+ const left = r < 1, w = (f * 50).toFixed(1);
931
+ bar = `<div class="dbar" aria-hidden="true"><div class="track"><div class="fill ${good}" style="left:${left ? 50 - w : 50}%;width:${w}%"></div></div><span class="ch ${good}">${ch}</span></div>`;
932
+ }
933
+ const sr = m.delta == null ? 'not comparable' : m.better ? `${m.better === 'a' ? la : lb} better` : 'same';
934
+ return `<tr><td>${esc(m.label)}</td><td class="n">${m.aText}</td><td class="n">${m.bText}</td><td class="d">${bar}<span class="sr-only">${esc(sr)}</span></td></tr>`;
935
+ }).join('');
936
+ const tools = c.tools.slice(0, 14).map((t) => `<tr><td>${esc(t.name)}</td><td class="n">${t.a.calls}${t.a.errors ? ` <span class="errpill">${t.a.errors}</span>` : ''}</td><td class="n">${t.b.calls}${t.b.errors ? ` <span class="errpill">${t.b.errors}</span>` : ''}</td><td class="n">${t.deltaCalls > 0 ? '+' : ''}${t.deltaCalls}</td></tr>`).join('');
937
+ const fl = (list) => list.length ? `<ul class="fl">${list.map((f) => `<li><span class="sev ${f.severity}"></span> ${esc(f.title)}<span class="id">${f.id}</span></li>`).join('')}</ul>` : '<div class="empty">none</div>';
938
+ $('#cmp').innerHTML = `<div class="verdict"><span>cheaper ${who(c.verdict.cheaper)}</span><span>faster ${who(c.verdict.faster)}</span><span>cleaner ${who(c.verdict.cleaner)}</span></div>
939
+ <div class="grid"><div><table><caption class="sr-only">Metrics side by side</caption><thead><tr><th scope="col">Metric</th><th class="n" scope="col">${esc(la)}</th><th class="n" scope="col">${esc(lb)}</th><th scope="col">Change (${esc(lb)} vs ${esc(la)})</th></tr></thead><tbody>${rows}</tbody></table></div>
940
+ <div><h3 style="margin-top:0">Tools</h3><table><caption class="sr-only">Tool calls per session</caption><thead><tr><th scope="col">Tool</th><th class="n" scope="col">${esc(la)}</th><th class="n" scope="col">${esc(lb)}</th><th class="n" scope="col">Δ</th></tr></thead><tbody>${tools || '<tr><td colspan="4" class="empty">no tool calls</td></tr>'}</tbody></table>${c.tools.length > 14 ? `<p class="note">… ${c.tools.length - 14} more tools</p>` : ''}
941
+ <h3>Only in ${esc(la)}</h3>${fl(c.findings.onlyA)}<h3>Only in ${esc(lb)}</h3>${fl(c.findings.onlyB)}${c.findings.both.length ? `<h3>In both</h3><ul class="fl">${c.findings.both.map((f) => `<li><span class="id" style="margin:0">${f.id}</span> — ${esc(la)}: ${f.a.length}, ${esc(lb)}: ${f.b.length}</li>`).join('')}</ul>` : ''}</div></div>`;
942
+ $('#cmp-sec').hidden = false;
943
+ }
944
+ $('#compare').onclick = () => { if (!S.trace) { say('Load a session first.'); return; } $('#cmp-folder').hidden = !FSA; $('#cmp-dlg').showModal(); };
945
+ $('#cmp-dlg-close').onclick = () => $('#cmp-dlg').close();
946
+ $('#cmp-folder').onclick = async () => { $('#cmp-dlg').close(); S.pickOther = true; let h = await idb.get('projects'); try { if (h) { let p = await h.queryPermission({ mode: 'read' }); if (p !== 'granted') p = await h.requestPermission({ mode: 'read' }); if (p !== 'granted') h = null; } if (!h) { h = await window.showDirectoryPicker({ id: 'claude-projects', mode: 'read' }); await idb.set('projects', h); $('#recent').hidden = false; } await showSessions(h); } catch (e) { S.pickOther = false; if (e && e.name !== 'AbortError') say('Could not open the folder: ' + e.message); } };
947
+ $('#sessions-dlg').addEventListener('close', () => { setTimeout(() => { S.pickOther = false; }, 0); });
948
+ $('#cmp-pick').onclick = () => $('#file-cmp').click();
949
+ $('#file-cmp').addEventListener('change', (e) => { loadFiles(Array.from(e.target.files).map((f) => ({ name: f.name, file: f })), true); e.target.value = ''; });
950
+ $('#cmp-demo').onclick = () => { if (!DEMO) { say('No demo embedded in this build.'); return; } loadOther(DEMO.map((f) => ({ name: f.name, text: f.text }))); };
951
+ $('#cmp-close').onclick = () => { S.other = null; S.cmp = null; $('#cmp-sec').hidden = true; setHash(''); say('Compare closed.'); $('#compare').focus(); };
952
+ $('#cmp-swap').onclick = () => { if (!S.other) return; const cur = { files: S.files, isDemo: S.isDemo }; const nxt = S.other; S.labels = { a: S.labels.b, b: S.labels.a }; S.other = analyseFiles(cur.files); load(nxt.files, false, true); say(`${S.labels.a} is now on the timeline.`); };
953
+ $('#cmp-copy').onclick = () => { if (!S.cmp) return; copyText(C.compareMarkdown(S.cmp, { labelA: S.labels.a, labelB: S.labels.b }), 'Comparison (Markdown)'); };
954
+
955
+ // ------------------------------------------------------------ live tail
956
+ let liveTimer = null;
957
+ function applyLiveFiles(ev) {
958
+ const f = S.files.find((x) => x.name === ev.name);
959
+ if (ev.type === 'append') { if (f) f.text += ev.text; else S.files.push({ name: ev.name, text: ev.text }); }
960
+ else if (ev.type === 'replace') { if (f) f.text = ev.text; else S.files.push({ name: ev.name, text: ev.text }); }
961
+ else if (ev.type === 'hello') { S.files = ev.files.map((x) => ({ name: x.name, text: x.text })); }
962
+ if (liveTimer) return; liveTimer = setTimeout(() => { liveTimer = null; refresh(); }, 150);
963
+ }
964
+ // Re-parse the current files in place: selection, filter, search and zoom survive; Follow keeps the right edge on now.
965
+ function refresh() {
966
+ const wasWhole = !S.view || (TL.total && S.view.t1 >= TL.total - 1 && S.view.t0 <= 1);
967
+ const oldSpan = S.view ? S.view.t1 - S.view.t0 : null;
968
+ const selId = S.sel ? S.sel.ref.id : null, selKind = S.sel ? S.sel.kind : null;
969
+ S.records = S.files.filter((f) => !/\.meta\.json$/.test(f.name)).flatMap((f) => C.parseLines(f.text, f.name).records);
970
+ S.trace = C.parseTrace(S.files); S.findings = C.diagnose(S.trace); S.cost = C.estimateCost(S.trace, S.rates);
971
+ if (selId) { const ref = selKind === 'tool' ? S.trace.toolCalls.find((c) => c.id === selId) : S.trace.requests.find((r) => r.id === selId); S.sel = ref ? { kind: selKind, ref } : null; }
972
+ if (S.toolFilter && !S.trace.toolCalls.some((c) => c.name === S.toolFilter)) S.toolFilter = null;
973
+ buildLayout();
974
+ if (S.follow) { if (wasWhole || oldSpan == null || oldSpan >= TL.total) S.view = null; else S.view = { t0: Math.max(0, TL.total - oldSpan), t1: TL.total }; }
975
+ renderSession(); renderStrip(); renderFit(); renderTimeline(); renderBurn(); renderFindings(); renderTools(); renderTurns();
976
+ if (S.query) runSearch();
977
+ if (S.sel && !$('#drawer').hidden) select(S.sel, true);
978
+ if (S.other) renderCompare();
979
+ const t = new Date(); $('#live-txt').textContent = 'LIVE · ' + t.toTimeString().slice(0, 8);
980
+ }
981
+ function startLive(cfg) {
982
+ const pill = $('#live'); pill.hidden = false; $('#follow').hidden = false;
983
+ $('#follow').onclick = () => { S.follow = !S.follow; $('#follow').setAttribute('aria-pressed', String(S.follow)); say(S.follow ? 'Following: the timeline keeps up with the session.' : 'Follow off: the view stays where it is.'); if (S.follow) refresh(); };
984
+ const es = new EventSource(cfg.url); S.live = es;
985
+ es.addEventListener('hello', (e) => { pill.classList.remove('off'); $('#live-txt').textContent = 'LIVE'; applyLiveFiles(Object.assign({ type: 'hello' }, JSON.parse(e.data))); });
986
+ es.addEventListener('append', (e) => applyLiveFiles(JSON.parse(e.data)));
987
+ es.addEventListener('replace', (e) => applyLiveFiles(JSON.parse(e.data)));
988
+ es.onerror = () => { pill.classList.add('off'); $('#live-txt').textContent = 'reconnecting'; };
989
+ // any manual zoom or pan lets go of Follow
990
+ const letGo = () => { if (S.follow) { S.follow = false; $('#follow').setAttribute('aria-pressed', 'false'); } };
991
+ $('#tl').addEventListener('wheel', letGo, { passive: true }); $('#tl').addEventListener('pointerdown', letGo);
992
+ $('#z-in').addEventListener('click', letGo); $('#z-out').addEventListener('click', letGo); $('#fit').addEventListener('change', letGo);
993
+ $('#banner').hidden = true;
994
+ }
995
+
856
996
  // ------------------------------------------------------------ help + inputs
857
997
  $('#help').onclick = () => $('#help-dlg').showModal(); $('#help-close').onclick = () => $('#help-dlg').close();
858
998
  $('#banner-x').onclick = () => { $('#banner').hidden = true; };
@@ -862,6 +1002,7 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
862
1002
  drop.addEventListener('drop', (e) => { e.preventDefault(); drop.classList.remove('over'); handleDataTransfer(e.dataTransfer); });
863
1003
  document.addEventListener('dragover', (e) => { e.preventDefault(); });
864
1004
  document.addEventListener('drop', (e) => { if (!drop.contains(e.target)) { e.preventDefault(); handleDataTransfer(e.dataTransfer); } });
1005
+ $('#cmp-dlg').addEventListener('dragover', (e) => e.preventDefault()); $('#cmp-dlg').addEventListener('drop', (e) => { e.preventDefault(); e.stopPropagation(); handleDataTransfer(e.dataTransfer); });
865
1006
  $('#file').addEventListener('change', (e) => loadFiles(Array.from(e.target.files).map((f) => ({ name: f.name, file: f }))));
866
1007
  drop.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); $('#file').click(); } });
867
1008
 
@@ -869,7 +1010,13 @@ kbd{font:11px var(--mono);border:1px solid var(--line);border-bottom-width:2px;b
869
1010
  const EMBED = /*__EMBED__*/null;
870
1011
  function loadDemo(e) { if (e) e.preventDefault(); if (!DEMO) { say('No demo embedded in this build.'); return; } load(DEMO.map((f) => ({ name: f.name, text: f.text })), true); }
871
1012
  $('#demo').onclick = loadDemo;
872
- if (EMBED && EMBED.length) load(EMBED.map((f) => ({ name: f.name, text: f.text })), false);
1013
+ const LIVE = /*__LIVE__*/null;
1014
+ const embedFiles = EMBED && !Array.isArray(EMBED) ? EMBED.files : EMBED;
1015
+ if (embedFiles && embedFiles.length) {
1016
+ load(embedFiles.map((f) => ({ name: f.name, text: f.text })), false);
1017
+ if (EMBED && !Array.isArray(EMBED) && EMBED.compare && EMBED.compare.length) { if (EMBED.labels) S.labels = EMBED.labels; loadOther(EMBED.compare.map((f) => ({ name: f.name, text: f.text }))); }
1018
+ }
873
1019
  else if (DEMO) loadDemo();
1020
+ if (LIVE && LIVE.url && 'EventSource' in window) startLive(LIVE);
874
1021
  })();
875
1022
  </script>