thomwolf HF Staff Claude Opus 5 (1M context) commited on
Commit
c145ecd
·
1 Parent(s): b5d584e

Reader mode pages back too — which is what was asked for

Browse files

The windowing went into the Trace pane, and the Trace pane is not where anyone
reads a conversation. Reader mode is, and it still fetched the last 400 turns in
one request and stopped there: 702 KB on a 19 MB session, "1,020 earlier
messages are not shown", and no way to reach them. It also re-fetched all 400
every three seconds while the agent worked, each one a full re-parse of the
transcript on the server.

Reader mode now opens on a window of the end, pages backwards when you scroll to
the top, and follows the end with `after=`. Measured on the 19 MB / 1,420-turn
session: the open drops 702 KB → 84 KB, and the poll while an agent works drops
702 KB + a whole-file parse → 359 bytes and a bounded read. Paging back reaches
the first turn in 34 windows.

The paging itself moved to lib/traceWindows.ts and both surfaces use it. Writing
it twice was not an option — the cursor handling, the single-flight guard, the
generation stamp that stops a load outliving its source, the gap and blocked
cases, are all things a review round found rather than things that were obvious.
What stays with each surface is presentation: the Trace pane measures rows and
virtualizes; reader mode renders exchanges and does not.

Anchoring had to differ, and that is the interesting part. The Trace pane pins a
row by its own geometry. Reader mode cannot: its unit is an exchange, and an
exchange at the top of the list is a fragment whose prompt was in the window we
had not read yet — when that window arrives the two become ONE exchange, so the
element the reader was looking at ceases to exist. What does not change is that
everything new is added above, so it restores the distance to the bottom
instead. Verified by what is under the middle of the pane rather than by element
identity: same text before and after.

No virtualization in reader mode yet, because measurement says it is not needed:
the whole 1,420-turn conversation paged in is 2,719 DOM nodes scrolling at a
16.6 ms median frame (worst 19.3 ms). Windowing by exchange (spec §10.7) is
still the right next step for a session an order of magnitude longer.

Two bugs this shook out, both mine, both caught by the browser checks rather than
by reasoning:

- the shared hook has to say whether turns arrived at the FRONT or the BACK. A
single "the array grew" path prepended height slots for an append too, which
silently misaligned the Trace pane's height model on every live poll.
- a prepend's scroll anchor must outrank a measurement's. The ResizeObserver
captures an anchor to keep the view still across a re-measure; landing between
a prepend's capture and its restore, it overwrote the anchor with the OLD
indexing, and the view was restored a whole window away from where the reader
was — which then triggered another load, and another.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

web/src/components/TracePane.tsx CHANGED
@@ -17,28 +17,16 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr
17
  import type { ReactNode } from 'react';
18
  import type { Session } from '../types';
19
  import * as api from '../api';
20
- import type { TraceBlock, TraceCursor, TraceSummary, TraceTurn, TraceWindow } from '../api';
 
21
  import { renderMarkdown } from '../lib/markdown';
22
  import Logo from './Logo';
23
  import { CloseGlyph } from './icons';
24
 
25
- // How much transcript the first request asks for. Measured on the longest trace
26
- // on this box (19 MB / 1,395 turns): 384 KB is ~60 turns, 95 KB of JSON and
27
- // ~35 ms of server time, and renders in a frame — the smallest window that still
28
- // opens on a complete-looking conversation rather than a stub. See the PR.
29
- const WINDOW_BYTES = 384 * 1024;
30
  const ROW_EST = 44; // unmeasured row height, collapsed
31
  const OVERSCAN_PX = 600;
32
  const NEAR_TOP_PX = 400; // start fetching older turns before the reader arrives
33
  const STICK_PX = 24; // "at the bottom" tolerance for following a live trace
34
- // Following a trace that is still being written. Nothing in here knows whether
35
- // an agent is running — but a transcript whose newest turn is seconds old is one
36
- // being written, so the cadence follows the trace itself: quick while it moves,
37
- // slow once it has gone quiet, which is also how it notices movement resuming.
38
- const LIVE_MS = 3_000;
39
- const IDLE_MS = 10_000;
40
- const FRESH_MS = 120_000;
41
- const SUMMARY_DELAY_MS = 400; // let the first paint happen before the whole-file read
42
 
43
  const fmtTs = (ms?: number) => (ms ? new Date(ms).toLocaleTimeString() : '');
44
  const fmtNum = (n: number) => n.toLocaleString();
@@ -208,39 +196,9 @@ function Row({ turn, index }: { turn: TraceTurn; index: number }) {
208
  // turns arrive one window at a time as you scroll back into them. The only read
209
  // that touches the whole trace is the summary, which buys the header an honest
210
  // turn count and is asked for after the first paint.
211
- export interface TraceSource {
212
- window: (req: api.TraceReq, bytes?: number) => Promise<TraceWindow>;
213
- summary: () => Promise<TraceSummary>;
214
- }
215
-
216
- /** What a host pane's toolbar needs. `total` is null until the summary lands. */
217
- export type TraceHeadInfo = Omit<TraceWindow, 'turns' | 'window'> & {
218
- /** turns the reader is holding right now */
219
- loaded: number;
220
- /** the first turn of the conversation is loaded — there is nothing above */
221
- atStart: boolean;
222
- };
223
-
224
- type Meta = Omit<TraceWindow, 'turns' | 'window'>;
225
-
226
- // Keep whichever value actually says something: a window that doesn't reach the
227
- // start of the trace reports no session start and no session cost, and must not
228
- // blank out what an earlier response already told us. Identity is preserved when
229
- // nothing changed — a poll that learns nothing must not look like new state.
230
- const mergeMeta = (prev: Meta | null, next: Meta): Meta => {
231
- if (!prev) return next;
232
- const out = { ...prev } as Record<string, unknown>;
233
- let changed = false;
234
- for (const [k, v] of Object.entries(next)) {
235
- // A window OLDER than what we hold describes an older stretch of the same
236
- // conversation. Two of its facts must not travel backwards: `lastTs` drives
237
- // how often we look for new turns (scrolling back would otherwise slow the
238
- // live tail to a crawl), and `model` is the one the session is running now.
239
- if ((k === 'lastTs' || k === 'model') && out[k] && (k !== 'lastTs' || (v as number) <= (out[k] as number))) continue;
240
- if ((v || !(k in out)) && out[k] !== v) { out[k] = v; changed = true; }
241
- }
242
- return changed ? (out as Meta) : prev;
243
- };
244
 
245
  export function TraceView({ src, srcKey, zoom = 100, query = '', onHead, onNav }: {
246
  src: TraceSource;
@@ -251,25 +209,6 @@ export function TraceView({ src, srcKey, zoom = 100, query = '', onHead, onNav }
251
  onHead?: (head: TraceHeadInfo | null) => void;
252
  onNav?: (go: (dir: -1 | 1) => void) => void;
253
  }) {
254
- const [meta, setMeta] = useState<Meta | null>(null);
255
- const [summary, setSummary] = useState<TraceSummary | null>(null);
256
- const [error, setError] = useState<string | null>(null);
257
-
258
- const turns = useRef<TraceTurn[]>([]);
259
- const cursor = useRef<TraceCursor | null>(null);
260
- // ONE request at a time. Flicking the wheel at the top of a long trace fires
261
- // scroll events by the dozen, and each one would otherwise start its own load;
262
- // they would arrive out of order and prepend the same turns twice.
263
- const loading = useRef(false);
264
- // Which source the turns in hand belong to. Every load reads this before its
265
- // await and checks it after: switch files in the Files pane while a window is
266
- // in flight and it would otherwise be prepended to the NEW file's turns, with
267
- // the old file's byte cursors and header — a conversation spliced out of two
268
- // different transcripts.
269
- const gen = useRef(0);
270
- const [tick, setTick] = useState(0);
271
- const bump = useCallback(() => setTick((n) => n + 1), []);
272
-
273
  const scroller = useRef<HTMLDivElement | null>(null);
274
  const heights = useRef<number[]>([]);
275
  // Bumped whenever a row is measured: the values live in a ref (so the
@@ -307,26 +246,11 @@ export function TraceView({ src, srcKey, zoom = 100, query = '', onHead, onNav }
307
  // before the first layout it is 0 because nothing has been placed, and before
308
  // the first measurement the whole list is 44 px per row, so a window of 19
309
  // dense turns looks 800 px tall when it is really 3,600 — near enough to the
310
- // top to fetch a window of older turns nobody asked for. (Seen on a codex
311
- // rollout on the Space; a Claude transcript's 60-turn window hid it.)
312
  const positioned = useRef(false);
313
  const measured = useRef(false);
314
-
315
- // ---- windowing ----
316
- // Prefix sums over measured (or estimated) row heights. n is bounded by what
317
- // the reader has actually loaded, so a full recompute is cheap and happens
318
- // only when a row is measured, turns arrive, or the window moves.
319
- const offsets = useMemo(() => {
320
- const n = turns.current.length;
321
- const acc = new Float64Array(n + 1);
322
- for (let i = 0; i < n; i++) acc[i + 1] = acc[i] + (heights.current[i] || ROW_EST);
323
- return acc;
324
- }, [range, tick, heightsVersion]); // eslint-disable-line react-hooks/exhaustive-deps
325
- // The loaders need the offsets as they are NOW, not as they were when the
326
- // callback was made.
327
- const offsetsRef = useRef(offsets);
328
- offsetsRef.current = offsets;
329
-
330
  // Rendered rows, by index — the ResizeObserver measures through these, and the
331
  // anchor reads its geometry from them.
332
  const rowRefs = useRef(new Map<number, HTMLElement>());
@@ -358,173 +282,58 @@ export function TraceView({ src, srcKey, zoom = 100, query = '', onHead, onNav }
358
  return true;
359
  };
360
 
361
- // ---- loading ----
362
- const loadTail = useCallback(async () => {
363
- loading.current = true;
364
- const mine = gen.current;
365
- try {
366
- const { turns: got, window: win, ...m } = await src.window({ at: 'tail' }, WINDOW_BYTES);
367
- if (mine !== gen.current) return;
368
- turns.current = got;
369
- heights.current = new Array(got.length);
370
- cursor.current = win;
371
- anchor.current = null;
372
- stick.current = true;
373
- positioned.current = false;
374
- measured.current = false;
375
- setMeta(m);
376
- setError(null);
377
- setRange({ start: 0, end: Math.min(got.length, 40) });
378
- bump();
379
- // Nothing to render means nothing to measure, so no measurement will ever
380
- // arrive to unblock the paging: walk back until there is something. After
381
- // this call returns, so the one-request-at-a-time guard still holds.
382
- if (!got.length && !win.atStart) window.setTimeout(() => loadOlderRef.current(), 0);
383
- } catch (e) {
384
- if (mine !== gen.current) return;
385
- // The server distinguishes "nothing to show yet" from a real failure and
386
- // says which — pass its own words through rather than inventing a reason.
387
- setError(e instanceof api.TraceUnavailable ? e.message : 'could not read the trace');
388
- } finally {
389
- if (mine === gen.current) loading.current = false;
390
- }
391
- }, [src, bump]);
392
-
393
- // loadTail may need loadOlder before it is declared; the ref keeps that honest
394
- // without reordering the two.
395
- const loadOlderRef = useRef<() => Promise<number>>(async () => 0);
396
-
397
- /** Fetch the window before the oldest turn held. Returns how many arrived. */
398
- const loadOlder = useCallback(async () => {
399
- const cur = cursor.current;
400
- if (loading.current || !cur || cur.atStart || cur.blocked) return 0;
401
- loading.current = true;
402
- const mine = gen.current;
403
- try {
404
- let from = cur.start;
405
- let atStart = false;
406
- let got: TraceTurn[] = [];
407
- let meta2: Meta | null = null;
408
- let blocked = false;
409
- // A window can legitimately hold no turns at all (a stretch of file-history
410
- // lines, a run of harness metadata). Keep walking back until it holds
411
- // something, the file starts, or the cursor stops moving.
412
- for (let hop = 0; hop < 8 && !got.length && !atStart; hop++) {
413
- const { turns: page, window: win, ...m } = await src.window({ at: 'before', cursor: from }, WINDOW_BYTES);
414
- if (mine !== gen.current) return 0;
415
- got = page;
416
- meta2 = m;
417
- // `blocked` is a line too big for any window — the server cannot get
418
- // past it, so neither can we, and this is NOT the start of the trace.
419
- blocked = !!win.blocked;
420
- atStart = win.atStart || (!blocked && win.start >= from);
421
- from = win.start;
422
- if (blocked) break;
423
- }
424
- // Same row, `got.length` places further down the list — see `anchor`.
425
- if (got.length && captureAnchor(got.length)) stick.current = false;
426
- turns.current = [...got, ...turns.current];
427
- heights.current = [...new Array(got.length), ...heights.current];
428
- // Everything that names a row BY INDEX moves with the prepend: the keys,
429
- // a pending jump, and an anchor captured before this landed. Missing the
430
- // jump was worth a runaway — `wanted` kept addressing turn N of the newly
431
- // prepended window, which sits a whole window before the turn asked for,
432
- // and the restored scroll position then triggered another load.
433
- keyBase.current -= got.length;
434
- if (wanted.current != null) wanted.current += got.length;
435
- // NOT the anchor: captureAnchor() above was already given this shift, and
436
- // shifting it a second time threw the view a whole window forward.
437
- cursor.current = { ...cur, start: from, atStart, blocked };
438
- if (meta2) setMeta((p) => mergeMeta(p, meta2 as Meta));
439
- if (got.length) setRange((r) => ({ start: r.start + got.length, end: r.end + got.length }));
440
- bump();
441
- return got.length;
442
- } catch {
443
- // Keep what is on screen; the next scroll retries.
444
- return 0;
445
- } finally {
446
- if (mine === gen.current) loading.current = false;
447
- }
448
- }, [src, bump]);
449
- loadOlderRef.current = loadOlder;
450
-
451
- /** Whatever the agent has written since we last looked. */
452
- const loadNewer = useCallback(async () => {
453
- const cur = cursor.current;
454
- if (loading.current || !cur) return;
455
- loading.current = true;
456
- const mine = gen.current;
457
- try {
458
- const { turns: got, window: win, ...m } = await src.window({ at: 'after', cursor: cur.end });
459
- if (mine !== gen.current) return;
460
- if (win.gap) {
461
- // More was written than one window can carry. Splicing it in would leave
462
- // a hole in the middle of the conversation with nothing to say so —
463
- // start again from the new tail instead.
464
- turns.current = got;
465
- heights.current = new Array(got.length);
466
- cursor.current = win;
467
- anchor.current = null;
468
- stick.current = true;
469
- positioned.current = false;
470
- measured.current = false;
471
- setRange({ start: 0, end: Math.min(got.length, 40) });
472
- } else {
473
- if (got.length) {
474
- turns.current = [...turns.current, ...got];
475
- heights.current = [...heights.current, ...new Array(got.length)];
476
- }
477
- cursor.current = { ...cur, end: win.end, atEnd: win.atEnd };
478
- }
479
- setMeta((p) => mergeMeta(p, m));
480
- if (got.length) bump();
481
- } catch {
482
- // A poll that fails must not throw away the conversation on screen.
483
- } finally {
484
- if (mine === gen.current) loading.current = false;
485
- }
486
- }, [src, bump]);
487
-
488
- useEffect(() => {
489
- gen.current += 1;
490
- loading.current = false;
491
- turns.current = [];
492
  heights.current = [];
493
- cursor.current = null;
494
  anchor.current = null;
495
  stick.current = true;
496
  positioned.current = false;
497
  measured.current = false;
498
- setMeta(null);
499
- setSummary(null);
500
  setRange({ start: 0, end: 40 });
501
- loadTail();
502
- }, [srcKey, loadTail]);
503
-
504
- // The one read that touches the whole file, fired AFTER the first paint: it
505
- // buys the header a real turn count, the session's token total and the date
506
- // the conversation started none of which a window can know. If it fails or
507
- // is slow, the header simply says how much is loaded.
508
- useEffect(() => {
509
- let dead = false;
510
- const h = window.setTimeout(() => {
511
- src.summary().then((s) => { if (!dead) setSummary(s); }).catch(() => {});
512
- }, SUMMARY_DELAY_MS);
513
- return () => { dead = true; window.clearTimeout(h); };
514
- }, [src, srcKey]);
515
-
516
- // The transcript may still be being written — see LIVE_MS above. Except when
517
- // "a window" costs a whole-file read: the SQLite harnesses have no byte
518
- // offsets to seek, so every poll would re-parse the entire conversation (and
519
- // evict the Overview's memo doing it). They are not polled at all, which is
520
- // what this pane did for every harness before windows existed.
521
- const lastTs = meta ? meta.lastTs : 0;
522
- const seekable = cursor.current ? cursor.current.mode === 'bytes' : true;
523
- useEffect(() => {
524
- if (!seekable) return undefined;
525
- const h = window.setInterval(loadNewer, lastTs && Date.now() - lastTs < FRESH_MS ? LIVE_MS : IDLE_MS);
526
- return () => window.clearInterval(h);
527
- }, [loadNewer, lastTs, seekable]);
528
 
529
  const recompute = useCallback(() => {
530
  const el = scroller.current;
@@ -545,13 +354,11 @@ export function TraceView({ src, srcKey, zoom = 100, query = '', onHead, onNav }
545
  if (positioned.current && wanted.current == null && el.scrollTop < NEAR_TOP_PX) loadOlder();
546
  }, [offsets, range.start, range.end, loadOlder]);
547
 
548
- useEffect(() => { recompute(); }, [recompute, meta]);
549
 
550
  // ---- keeping the view still ----
551
  // Every layout change lands here: a jump to a prompt, a prepended window, a
552
  // row that just measured itself, a new turn while pinned to the bottom.
553
- const wanted = useRef<number | null>(null);
554
- const tries = useRef(0);
555
  useLayoutEffect(() => {
556
  const el = scroller.current;
557
  if (!el) return;
@@ -627,7 +434,7 @@ export function TraceView({ src, srcKey, zoom = 100, query = '', onHead, onNav }
627
  }
628
  // Let the prepend commit before looking again: the recursion would
629
  // otherwise read the pre-prepend prompt list and fetch another window.
630
- if (!cursor.current?.atStart && await loadOlder()) {
631
  await new Promise((r) => window.setTimeout(r, 0));
632
  goRef.current(dir);
633
  return;
@@ -649,7 +456,11 @@ export function TraceView({ src, srcKey, zoom = 100, query = '', onHead, onNav }
649
  measured.current = true;
650
  // Measuring changes the offsets of everything below — and of everything
651
  // above, if a row above the viewport grew. Pin the row being read.
652
- if (!stick.current && wanted.current == null) captureAnchor(0);
 
 
 
 
653
  setHeightsVersion((v) => v + 1);
654
  });
655
  for (const el of rowRefs.current.values()) ro.observe(el);
@@ -658,7 +469,7 @@ export function TraceView({ src, srcKey, zoom = 100, query = '', onHead, onNav }
658
  // moving) get measured too — otherwise they keep their 44px estimate and the
659
  // scroll height stays wrong. NOT heightsVersion: that's what the observer
660
  // sets, and re-attaching on it would churn on every measurement.
661
- }, [range, tick, meta]);
662
 
663
  const setRowRef = (i: number) => (el: HTMLDivElement | null) => {
664
  if (el) rowRefs.current.set(i, el); else rowRefs.current.delete(i);
@@ -676,8 +487,6 @@ export function TraceView({ src, srcKey, zoom = 100, query = '', onHead, onNav }
676
  }, [query, tick, heightsVersion]); // eslint-disable-line react-hooks/exhaustive-deps
677
 
678
  const n = turns.current.length;
679
- const atStart = !!cursor.current?.atStart;
680
- const blocked = !!cursor.current?.blocked;
681
  const rows: ReactNode[] = [];
682
  for (let i = range.start; i < Math.min(range.end, n); i++) {
683
  rows.push(
@@ -697,24 +506,6 @@ export function TraceView({ src, srcKey, zoom = 100, query = '', onHead, onNav }
697
  // the session id. Taking only the numbers left a reader with no idea the
698
  // model's reasoning had been withheld, which is the one thing §12 of the spec
699
  // says must never be left implied.
700
- const head = useMemo<TraceHeadInfo | null>(() => (meta ? {
701
- ...meta,
702
- total: summary ? summary.total : null,
703
- userTurns: summary ? summary.userTurns : null,
704
- usage: meta.usage || (summary ? summary.usage : null),
705
- firstTs: meta.firstTs || (summary ? summary.firstTs : 0),
706
- truncated: meta.truncated || !!(summary && summary.truncated),
707
- note: meta.note || (summary ? summary.note : null),
708
- title: meta.title || (summary ? summary.title : ''),
709
- harnessLabel: meta.harnessLabel || (summary ? summary.harnessLabel : ''),
710
- sessionId: meta.sessionId || (summary ? summary.sessionId : null),
711
- model: meta.model || (summary ? summary.model : null),
712
- cwd: meta.cwd || (summary ? summary.cwd : null),
713
- source: meta.source || (summary ? summary.source : null),
714
- sharedBy: meta.sharedBy || (summary ? summary.sharedBy : null),
715
- loaded: turns.current.length,
716
- atStart,
717
- } : null), [meta, summary, tick, atStart]); // eslint-disable-line react-hooks/exhaustive-deps
718
  useEffect(() => { onNav?.((d: -1 | 1) => goRef.current(d)); }, [onNav]);
719
  useEffect(() => { onHead?.(head); }, [head, onHead]);
720
 
@@ -731,9 +522,9 @@ export function TraceView({ src, srcKey, zoom = 100, query = '', onHead, onNav }
731
  style={{ fontSize: `${(13 * zoom) / 100}px` }}
732
  >
733
  {error && <div className="tv-msg">{error}</div>}
734
- {!error && !meta && <div className="tv-msg">reading…</div>}
735
 
736
- {!error && meta && matches && (
737
  <div className="tv-matches">
738
  <div className="tv-msg">
739
  {matches.length} match{matches.length === 1 ? '' : 'es'} in the {fmtNum(n)} turns
@@ -743,7 +534,7 @@ export function TraceView({ src, srcKey, zoom = 100, query = '', onHead, onNav }
743
  </div>
744
  )}
745
 
746
- {!error && meta && !matches && (
747
  <>
748
  {/* Fixed height whether it is loading, done, or at the beginning:
749
  this line sits above every offset in the list, so changing its
 
17
  import type { ReactNode } from 'react';
18
  import type { Session } from '../types';
19
  import * as api from '../api';
20
+ import type { TraceBlock, TraceTurn } from '../api';
21
+ import { useTraceWindows, type TraceHeadInfo, type TraceSource } from '../lib/traceWindows';
22
  import { renderMarkdown } from '../lib/markdown';
23
  import Logo from './Logo';
24
  import { CloseGlyph } from './icons';
25
 
 
 
 
 
 
26
  const ROW_EST = 44; // unmeasured row height, collapsed
27
  const OVERSCAN_PX = 600;
28
  const NEAR_TOP_PX = 400; // start fetching older turns before the reader arrives
29
  const STICK_PX = 24; // "at the bottom" tolerance for following a live trace
 
 
 
 
 
 
 
 
30
 
31
  const fmtTs = (ms?: number) => (ms ? new Date(ms).toLocaleTimeString() : '');
32
  const fmtNum = (n: number) => n.toLocaleString();
 
196
  // turns arrive one window at a time as you scroll back into them. The only read
197
  // that touches the whole trace is the summary, which buys the header an honest
198
  // turn count and is asked for after the first paint.
199
+ // The source and the head shape now live with the paging itself, so reader mode
200
+ // can use the same ones — see lib/traceWindows.ts.
201
+ export type { TraceSource, TraceHeadInfo } from '../lib/traceWindows';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
202
 
203
  export function TraceView({ src, srcKey, zoom = 100, query = '', onHead, onNav }: {
204
  src: TraceSource;
 
209
  onHead?: (head: TraceHeadInfo | null) => void;
210
  onNav?: (go: (dir: -1 | 1) => void) => void;
211
  }) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
212
  const scroller = useRef<HTMLDivElement | null>(null);
213
  const heights = useRef<number[]>([]);
214
  // Bumped whenever a row is measured: the values live in a ref (so the
 
246
  // before the first layout it is 0 because nothing has been placed, and before
247
  // the first measurement the whole list is 44 px per row, so a window of 19
248
  // dense turns looks 800 px tall when it is really 3,600 — near enough to the
249
+ // top to fetch a window of older turns nobody asked for.
 
250
  const positioned = useRef(false);
251
  const measured = useRef(false);
252
+ const wanted = useRef<number | null>(null);
253
+ const tries = useRef(0);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
254
  // Rendered rows, by index — the ResizeObserver measures through these, and the
255
  // anchor reads its geometry from them.
256
  const rowRefs = useRef(new Map<number, HTMLElement>());
 
282
  return true;
283
  };
284
 
285
+ // Everything that names a row BY INDEX moves with a prepend: the keys, a
286
+ // pending jump, and the anchor (which captureAnchor is given the shift for).
287
+ const onPrepend = useCallback((count: number) => {
288
+ // Everything that names a row BY INDEX moves with the prepend: the heights,
289
+ // the keys, the rendered window, and a pending jump. (The anchor is given
290
+ // the shift by captureAnchor rather than moved afterwards shifting it
291
+ // twice throws the view a whole window forward.)
292
+ heights.current = [...new Array(count), ...heights.current];
293
+ keyBase.current -= count;
294
+ if (wanted.current != null) wanted.current += count;
295
+ if (captureAnchor(count)) stick.current = false;
296
+ setRange((r) => ({ start: r.start + count, end: r.end + count }));
297
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
298
+
299
+ const onAppend = useCallback((count: number) => {
300
+ heights.current = [...heights.current, ...new Array(count)];
301
+ }, []);
302
+
303
+ const onReset = useCallback(() => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
304
  heights.current = [];
 
305
  anchor.current = null;
306
  stick.current = true;
307
  positioned.current = false;
308
  measured.current = false;
 
 
309
  setRange({ start: 0, end: 40 });
310
+ }, []);
311
+
312
+ const { turns, head, error, version: tick, atStart, blocked, loadOlder } =
313
+ useTraceWindows(src, srcKey, { onPrepend, onReset });
314
+
315
+ // Prefix sums over measured (or estimated) row heights. n is bounded by what
316
+ // the reader has actually loaded, so a full recompute is cheap and happens
317
+ // only when a row is measured, turns arrive, or the window moves.
318
+ const offsets = useMemo(() => {
319
+ const n = turns.current.length;
320
+ const acc = new Float64Array(n + 1);
321
+ for (let i = 0; i < n; i++) acc[i + 1] = acc[i] + (heights.current[i] || ROW_EST);
322
+ return acc;
323
+ }, [range, tick, heightsVersion]); // eslint-disable-line react-hooks/exhaustive-deps
324
+ // The loaders need the offsets as they are NOW, not as they were when the
325
+ // callback was made.
326
+ const offsetsRef = useRef(offsets);
327
+ offsetsRef.current = offsets;
328
+
329
+ // heights are index-aligned with turns; the hook only ever prepends or appends,
330
+ // so re-derive the array length from it rather than tracking every mutation.
331
+ if (heights.current.length !== turns.current.length) {
332
+ const grew = turns.current.length - heights.current.length;
333
+ heights.current = grew > 0 && heights.current.length
334
+ ? [...new Array(grew), ...heights.current] // older turns arrived on top
335
+ : new Array(turns.current.length);
336
+ }
337
 
338
  const recompute = useCallback(() => {
339
  const el = scroller.current;
 
354
  if (positioned.current && wanted.current == null && el.scrollTop < NEAR_TOP_PX) loadOlder();
355
  }, [offsets, range.start, range.end, loadOlder]);
356
 
357
+ useEffect(() => { recompute(); }, [recompute, head]);
358
 
359
  // ---- keeping the view still ----
360
  // Every layout change lands here: a jump to a prompt, a prepended window, a
361
  // row that just measured itself, a new turn while pinned to the bottom.
 
 
362
  useLayoutEffect(() => {
363
  const el = scroller.current;
364
  if (!el) return;
 
434
  }
435
  // Let the prepend commit before looking again: the recursion would
436
  // otherwise read the pre-prepend prompt list and fetch another window.
437
+ if (!atStart && await loadOlder()) {
438
  await new Promise((r) => window.setTimeout(r, 0));
439
  goRef.current(dir);
440
  return;
 
456
  measured.current = true;
457
  // Measuring changes the offsets of everything below — and of everything
458
  // above, if a row above the viewport grew. Pin the row being read.
459
+ // Not if an anchor is already waiting: a prepend captured that one against
460
+ // the indices it is about to shift, and a measurement landing in between
461
+ // would overwrite it with the OLD indexing — restoring to a row a whole
462
+ // window away from the one the reader was looking at.
463
+ if (!stick.current && wanted.current == null && !anchor.current) captureAnchor(0);
464
  setHeightsVersion((v) => v + 1);
465
  });
466
  for (const el of rowRefs.current.values()) ro.observe(el);
 
469
  // moving) get measured too — otherwise they keep their 44px estimate and the
470
  // scroll height stays wrong. NOT heightsVersion: that's what the observer
471
  // sets, and re-attaching on it would churn on every measurement.
472
+ }, [range, tick, head]);
473
 
474
  const setRowRef = (i: number) => (el: HTMLDivElement | null) => {
475
  if (el) rowRefs.current.set(i, el); else rowRefs.current.delete(i);
 
487
  }, [query, tick, heightsVersion]); // eslint-disable-line react-hooks/exhaustive-deps
488
 
489
  const n = turns.current.length;
 
 
490
  const rows: ReactNode[] = [];
491
  for (let i = range.start; i < Math.min(range.end, n); i++) {
492
  rows.push(
 
506
  // the session id. Taking only the numbers left a reader with no idea the
507
  // model's reasoning had been withheld, which is the one thing §12 of the spec
508
  // says must never be left implied.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
509
  useEffect(() => { onNav?.((d: -1 | 1) => goRef.current(d)); }, [onNav]);
510
  useEffect(() => { onHead?.(head); }, [head, onHead]);
511
 
 
522
  style={{ fontSize: `${(13 * zoom) / 100}px` }}
523
  >
524
  {error && <div className="tv-msg">{error}</div>}
525
+ {!error && !head && <div className="tv-msg">reading…</div>}
526
 
527
+ {!error && head && matches && (
528
  <div className="tv-matches">
529
  <div className="tv-msg">
530
  {matches.length} match{matches.length === 1 ? '' : 'es'} in the {fmtNum(n)} turns
 
534
  </div>
535
  )}
536
 
537
+ {!error && head && !matches && (
538
  <>
539
  {/* Fixed height whether it is loading, done, or at the beginning:
540
  this line sits above every offset in the list, so changing its
web/src/components/conversation/ConversationView.tsx CHANGED
@@ -5,21 +5,24 @@
5
  // reader's own: a line of session facts, search, turn navigation, and the same
6
  // ExchangeView the Overview card shows one of.
7
  //
8
- // Draft scope: this reads the tail of the trace in one request and renders every
9
- // turn in it. Windowing by exchange (§10.7) is the next step a collapsed turn
10
- // is 2–3 rows, so the DOM stays small, but the measured-height machinery in
11
- // TraceView is what makes it survive a 5,000-turn session.
 
 
 
 
12
  import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
13
  import * as api from '../../api';
14
- import type { TracePage, TraceTurn } from '../../api';
 
15
  import type { Session } from '../../types';
16
  import { fmtTok, splitExchanges } from './exchanges';
17
  import ExchangeView from './Exchange';
18
  import { SendGlyph } from '../icons';
19
 
20
- /** How much of the tail RENDER mode reads. The server caps a page at 500. */
21
- export const RENDER_TAIL = 400;
22
- const POLL_MS = 3_000;
23
 
24
  const fmtNum = (n: number) => n.toLocaleString();
25
  const fmtUsage = (u?: { in: number; out: number } | null) =>
@@ -34,8 +37,6 @@ export default function ConversationView({ session, paused, isMobile, readOnly,
34
  readOnly?: boolean;
35
  onHandover?: () => void;
36
  }) {
37
- const [page, setPage] = useState<TracePage | null>(null);
38
- const [error, setError] = useState<string>('');
39
  const [query, setQuery] = useState('');
40
  const [hits, setHits] = useState(0);
41
  const [hit, setHit] = useState(0);
@@ -55,32 +56,39 @@ export default function ConversationView({ session, paused, isMobile, readOnly,
55
 
56
  const live = session.state === 'working' && !paused;
57
 
58
- const load = useCallback(async () => {
59
- try {
60
- // A negative offset reads from the end (server/src/traces.js pageOf).
61
- const p = await api.getTracePage(session.id, -RENDER_TAIL, RENDER_TAIL);
62
- setPage(p);
63
- setError('');
64
- } catch (e) {
65
- // A failed REFRESH must not throw away the conversation on screen: this
66
- // mount answers EIO now and then, and blanking mid-read is worse than
67
- // going stale for three seconds.
68
- setError(e instanceof Error ? e.message : 'could not read this trace');
69
- }
70
- }, [session.id]);
71
 
72
- useEffect(() => { setPage(null); setError(''); load(); }, [load]);
73
- // While the agent works, the trace is still being written.
74
- useEffect(() => {
75
- if (!live) return undefined;
76
- const h = window.setInterval(load, POLL_MS);
77
- return () => window.clearInterval(h);
78
- }, [live, load]);
79
- // Coming back into view, catch up at once rather than waiting for a tick.
80
- useEffect(() => { if (!paused && page) load(); }, [paused]); // eslint-disable-line react-hooks/exhaustive-deps
 
 
 
 
 
 
 
 
 
 
 
 
 
81
 
82
- const turns: TraceTurn[] = useMemo(() => page?.turns || [], [page]);
83
- const exchanges = useMemo(() => splitExchanges(turns), [turns]);
 
 
 
84
  const last = exchanges[exchanges.length - 1];
85
 
86
  // The optimistic echo stands until the transcript catches up: a CLI writes it,
@@ -99,7 +107,7 @@ export default function ConversationView({ session, paused, isMobile, readOnly,
99
  setDraft(''); setSent({ text, at: Date.now() });
100
  if (inputRef.current) { inputRef.current.style.height = 'auto'; inputRef.current.blur(); }
101
  stick.current = true;
102
- load();
103
  } catch { setFailed(true); window.setTimeout(() => setFailed(false), 4000); }
104
  setSending(false);
105
  };
@@ -175,23 +183,31 @@ export default function ConversationView({ session, paused, isMobile, readOnly,
175
  // never fights a decision to read further back.
176
  useLayoutEffect(() => {
177
  const el = scroller.current;
178
- if (el && stick.current) el.scrollTop = el.scrollHeight;
179
- }, [turns, live]);
 
 
 
 
 
 
 
180
 
181
- if (!page) return <div className="cxv-empty mono">{error || 'reading the trace…'}</div>;
182
 
183
  return (
184
  <div className="cxv">
185
  {/* The reader's own controls, on their own row: on a phone the pane
186
  header above has no spare width. */}
187
  <div className="cxv-bar mono">
188
- {page.model && <span className="cxv-chip">{page.model}</span>}
189
- <span className="cxv-count" title={`${fmtNum(page.total)} messages`}>
190
  {fmtNum(exchanges.length)} turn{exchanges.length === 1 ? '' : 's'}
 
191
  </span>
192
- {page.usage && (
193
- <span className="cxv-tok" title={page.usage.cacheRead ? `${fmtNum(page.usage.cacheRead)} cached` : undefined}>
194
- {fmtUsage(page.usage)}
195
  </span>
196
  )}
197
  <span className="spacer" />
@@ -208,15 +224,20 @@ export default function ConversationView({ session, paused, isMobile, readOnly,
208
  onScroll={(e) => {
209
  const el = e.currentTarget;
210
  stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 48;
 
 
 
211
  }}>
212
  <div className="cxv-col">
213
  {error && <div className="cxv-msg bad mono">{error} · showing the last read</div>}
214
- {(page.truncated || page.offset > 0) && (
215
- <div className="cxv-msg mono">
216
- {page.offset > 0 ? `${fmtNum(page.offset)} earlier messages are not shown` : 'earlier turns are not shown'}
217
- </div>
218
- )}
219
- {page.note && <div className="cxv-msg mono">{page.note}</div>}
 
 
220
  {q && (
221
  <div className="cxv-msg mono">
222
  {shown.length} of {exchanges.length} turns match “{query}”
@@ -224,13 +245,17 @@ export default function ConversationView({ session, paused, isMobile, readOnly,
224
  </div>
225
  )}
226
  {shown.map((x, i) => (
227
- <div key={x.key} ref={(el) => { if (el) rows.current.set(i, el); else rows.current.delete(i); }}>
 
 
 
 
228
  <ExchangeView
229
  x={x}
230
  n={exchanges.indexOf(x) + 1}
231
  total={exchanges.length}
232
  q={q || undefined}
233
- baseModel={page.model || undefined}
234
  running={live && x === exchanges[exchanges.length - 1]}
235
  />
236
  </div>
@@ -269,9 +294,9 @@ export default function ConversationView({ session, paused, isMobile, readOnly,
269
  {failed && <div className="ov-note cxv-note">failed to reach the agent</div>}
270
 
271
  <div className="cxv-foot mono">
272
- <span className="cxv-path" title={page.cwd || undefined}>
273
- {page.cwd}
274
- {page.firstTs ? ` · ${new Date(page.firstTs).toLocaleDateString()}` : ''}
275
  </span>
276
  <span className="spacer" />
277
  {onHandover && (
 
5
  // reader's own: a line of session facts, search, turn navigation, and the same
6
  // ExchangeView the Overview card shows one of.
7
  //
8
+ // It opens on the END of the conversation and pages backwards: one window of the
9
+ // transcript to start, another when you scroll to the top, anchored so the text
10
+ // you are reading does not move under you. Before that it read the last 400
11
+ // turns in a single request and stopped there — 702 KB on a 19 MB session, with
12
+ // "1,020 earlier messages are not shown" and no way to reach them — and it
13
+ // re-fetched all 400 every three seconds while the agent worked, each one a full
14
+ // re-parse of the transcript on the server. The paging itself lives in
15
+ // lib/traceWindows.ts, shared with the Trace pane.
16
  import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
17
  import * as api from '../../api';
18
+ import type { TraceTurn } from '../../api';
19
+ import { useTraceWindows, type TraceSource } from '../../lib/traceWindows';
20
  import type { Session } from '../../types';
21
  import { fmtTok, splitExchanges } from './exchanges';
22
  import ExchangeView from './Exchange';
23
  import { SendGlyph } from '../icons';
24
 
25
+ const NEAR_TOP_PX = 300; // start fetching older turns before the reader arrives
 
 
26
 
27
  const fmtNum = (n: number) => n.toLocaleString();
28
  const fmtUsage = (u?: { in: number; out: number } | null) =>
 
37
  readOnly?: boolean;
38
  onHandover?: () => void;
39
  }) {
 
 
40
  const [query, setQuery] = useState('');
41
  const [hits, setHits] = useState(0);
42
  const [hit, setHit] = useState(0);
 
56
 
57
  const live = session.state === 'working' && !paused;
58
 
59
+ const src = useMemo<TraceSource>(() => ({
60
+ window: (req, bytes) => api.getTraceWindow(session.id, req, bytes),
61
+ summary: () => api.getTraceSummary(session.id),
62
+ }), [session.id]);
 
 
 
 
 
 
 
 
 
63
 
64
+ // React keys for the exchanges, and the reading position across a prepend.
65
+ // The keys are indices into a list that grows at the FRONT, so without a base
66
+ // that moves with it every exchange remounts when older turns arrive.
67
+ const keyBase = useRef(0);
68
+ // The anchor is the scroll height, not an element. Turns regroup here: an
69
+ // exchange at the top of the list is a fragment whose prompt was in the window
70
+ // we had not read yet, and when that window arrives the two become ONE
71
+ // exchange so the element the reader was looking at can cease to exist as an
72
+ // element. What does not change is that everything new is added ABOVE, so
73
+ // keeping the distance to the bottom constant keeps the same text under the
74
+ // reader's eyes regardless of how the pieces were regrouped.
75
+ const anchor = useRef<number | null>(null);
76
+
77
+ const onPrepend = useCallback((count: number) => {
78
+ keyBase.current -= count;
79
+ const el = scroller.current;
80
+ if (!el) return;
81
+ anchor.current = el.scrollHeight - el.scrollTop;
82
+ stick.current = false;
83
+ }, []);
84
+
85
+ const onReset = useCallback(() => { anchor.current = null; stick.current = true; }, []);
86
 
87
+ const { turns: turnsRef, head, error, version, atStart, blocked, loadOlder, loadNewer } =
88
+ useTraceWindows(src, session.id, { onPrepend, onReset, paused });
89
+
90
+ const turns: TraceTurn[] = turnsRef.current;
91
+ const exchanges = useMemo(() => splitExchanges(turns), [version, turns]); // eslint-disable-line react-hooks/exhaustive-deps
92
  const last = exchanges[exchanges.length - 1];
93
 
94
  // The optimistic echo stands until the transcript catches up: a CLI writes it,
 
107
  setDraft(''); setSent({ text, at: Date.now() });
108
  if (inputRef.current) { inputRef.current.style.height = 'auto'; inputRef.current.blur(); }
109
  stick.current = true;
110
+ loadNewer();
111
  } catch { setFailed(true); window.setTimeout(() => setFailed(false), 4000); }
112
  setSending(false);
113
  };
 
183
  // never fights a decision to read further back.
184
  useLayoutEffect(() => {
185
  const el = scroller.current;
186
+ if (!el) return;
187
+ if (stick.current) { el.scrollTop = el.scrollHeight; anchor.current = null; return; }
188
+ // Older turns just arrived above the reader: restore the distance to the
189
+ // bottom, which is the same text on screen however the exchanges regrouped.
190
+ const a = anchor.current;
191
+ if (a == null) return;
192
+ el.scrollTop = el.scrollHeight - a;
193
+ anchor.current = null;
194
+ }, [version, live]);
195
 
196
+ if (!head) return <div className="cxv-empty mono">{error || 'reading the trace…'}</div>;
197
 
198
  return (
199
  <div className="cxv">
200
  {/* The reader's own controls, on their own row: on a phone the pane
201
  header above has no spare width. */}
202
  <div className="cxv-bar mono">
203
+ {head.model && <span className="cxv-chip">{head.model}</span>}
204
+ <span className="cxv-count" title={head.total != null ? `${fmtNum(head.total)} messages in this conversation` : `${fmtNum(head.loaded)} messages loaded`}>
205
  {fmtNum(exchanges.length)} turn{exchanges.length === 1 ? '' : 's'}
206
+ {head.total != null && head.loaded < head.total ? ` of ${fmtNum(head.total)} messages` : ''}
207
  </span>
208
+ {head.usage && (
209
+ <span className="cxv-tok" title={head.usage.cacheRead ? `${fmtNum(head.usage.cacheRead)} cached` : undefined}>
210
+ {fmtUsage(head.usage)}
211
  </span>
212
  )}
213
  <span className="spacer" />
 
224
  onScroll={(e) => {
225
  const el = e.currentTarget;
226
  stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 48;
227
+ // Reading back into the conversation: fetch the stretch in front of
228
+ // what we hold before the reader arrives at it.
229
+ if (el.scrollTop < NEAR_TOP_PX) loadOlder();
230
  }}>
231
  <div className="cxv-col">
232
  {error && <div className="cxv-msg bad mono">{error} · showing the last read</div>}
233
+ <div className="cxv-msg mono cxv-top">
234
+ {blocked
235
+ ? 'earlier turns can’t be read one line here is larger than the reader’s window'
236
+ : atStart
237
+ ? 'beginning of the conversation'
238
+ : 'earlier turns load as you scroll up…'}
239
+ </div>
240
+ {head.note && <div className="cxv-msg mono">{head.note}</div>}
241
  {q && (
242
  <div className="cxv-msg mono">
243
  {shown.length} of {exchanges.length} turns match “{query}”
 
245
  </div>
246
  )}
247
  {shown.map((x, i) => (
248
+ <div
249
+ key={keyBase.current + x.at}
250
+ data-x={String(keyBase.current + x.at)}
251
+ ref={(el) => { if (el) rows.current.set(i, el); else rows.current.delete(i); }}
252
+ >
253
  <ExchangeView
254
  x={x}
255
  n={exchanges.indexOf(x) + 1}
256
  total={exchanges.length}
257
  q={q || undefined}
258
+ baseModel={head.model || undefined}
259
  running={live && x === exchanges[exchanges.length - 1]}
260
  />
261
  </div>
 
294
  {failed && <div className="ov-note cxv-note">failed to reach the agent</div>}
295
 
296
  <div className="cxv-foot mono">
297
+ <span className="cxv-path" title={head.cwd || undefined}>
298
+ {head.cwd}
299
+ {head.firstTs ? ` · ${new Date(head.firstTs).toLocaleDateString()}` : ''}
300
  </span>
301
  <span className="spacer" />
302
  {onHandover && (
web/src/conversation.css CHANGED
@@ -166,6 +166,10 @@ mark.cx-hit.on { background: var(--accent); color: var(--panel); }
166
  .cxv-body { flex: 1; min-height: 0; overflow-y: auto; background: var(--panel); padding: 4px 14px 30px; }
167
  .cxv-col { display: flex; flex-direction: column; min-width: 0; }
168
  .cxv-msg { font-size: 11px; color: var(--muted); padding: 6px 0; }
 
 
 
 
169
  .cxv-foot {
170
  display: flex; align-items: center; gap: 8px; flex: none;
171
  padding: 5px 10px; border-top: 1px solid var(--border); background: var(--panel);
 
166
  .cxv-body { flex: 1; min-height: 0; overflow-y: auto; background: var(--panel); padding: 4px 14px 30px; }
167
  .cxv-col { display: flex; flex-direction: column; min-width: 0; }
168
  .cxv-msg { font-size: 11px; color: var(--muted); padding: 6px 0; }
169
+ /* The line above the oldest loaded exchange. Fixed height on purpose: it sits
170
+ above everything in the column, so a line that grew or vanished when the
171
+ reader reached the start would shove the whole conversation. */
172
+ .cxv-top { height: 1.5em; line-height: 1.5em; padding: 0; text-align: center; opacity: 0.75; }
173
  .cxv-foot {
174
  display: flex; align-items: center; gap: 8px; flex: none;
175
  padding: 5px 10px; border-top: 1px solid var(--border); background: var(--panel);
web/src/lib/traceWindows.ts ADDED
@@ -0,0 +1,273 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Reading a trace as WINDOWS, for whichever surface is doing the reading.
2
+ //
3
+ // The Trace pane and reader mode show the same conversation very differently —
4
+ // one as rows of turns, the other as exchanges with a composer under them — but
5
+ // they fetch it identically: open on the end, page backwards a window at a time,
6
+ // follow the end while it is being written. That fetching is subtle in ways two
7
+ // review rounds found the hard way (a load outliving its source, a second load
8
+ // racing the first, a pointer to a window nobody read), so it lives here once
9
+ // rather than twice.
10
+ //
11
+ // What stays with the caller is everything about presentation: how a row is
12
+ // measured, what is virtualized, and how the reading position is anchored when
13
+ // older turns arrive. The hook says WHEN turns are about to be prepended
14
+ // (`onPrepend`) and when everything has been replaced (`onReset`); the caller
15
+ // decides what that means for its own scroller.
16
+ import { useCallback, useEffect, useRef, useState } from 'react';
17
+ import * as api from '../api';
18
+ import type { TraceCursor, TraceSummary, TraceTurn, TraceWindow } from '../api';
19
+
20
+ /** How much transcript the first request asks for. Measured on the longest trace
21
+ * on this box (19 MB / 1,420 turns): 384 KB is ~60 turns, ~84 KB of JSON and
22
+ * ~6 ms of server time, and renders in a frame — the smallest window that still
23
+ * opens on a complete-looking conversation rather than a stub. */
24
+ export const WINDOW_BYTES = 384 * 1024;
25
+ // Following a trace that is still being written. Nothing in here knows whether
26
+ // an agent is running — but a transcript whose newest turn is seconds old is one
27
+ // being written, so the cadence follows the trace itself: quick while it moves,
28
+ // slow once it has gone quiet, which is also how it notices movement resuming.
29
+ const LIVE_MS = 3_000;
30
+ const IDLE_MS = 10_000;
31
+ const FRESH_MS = 120_000;
32
+ const SUMMARY_DELAY_MS = 400; // let the first paint happen before the whole-file read
33
+
34
+ export interface TraceSource {
35
+ window: (req: api.TraceReq, bytes?: number) => Promise<TraceWindow>;
36
+ summary: () => Promise<TraceSummary>;
37
+ }
38
+
39
+ export type Meta = Omit<TraceWindow, 'turns' | 'window'>;
40
+
41
+ /** What a host's chrome needs. `total` is null until the summary lands. */
42
+ export type TraceHeadInfo = Meta & {
43
+ /** turns the reader is holding right now */
44
+ loaded: number;
45
+ /** the first turn of the conversation is loaded — there is nothing above */
46
+ atStart: boolean;
47
+ /** one line is bigger than a window: nothing older can be reached */
48
+ blocked: boolean;
49
+ };
50
+
51
+ // Keep whichever value actually says something: a window that doesn't reach the
52
+ // start of the trace reports no session start and no session cost, and must not
53
+ // blank out what an earlier response already told us. Identity is preserved when
54
+ // nothing changed — a poll that learns nothing must not look like new state.
55
+ export const mergeMeta = (prev: Meta | null, next: Meta): Meta => {
56
+ if (!prev) return next;
57
+ const out = { ...prev } as Record<string, unknown>;
58
+ let changed = false;
59
+ for (const [k, v] of Object.entries(next)) {
60
+ // A window OLDER than what we hold describes an older stretch of the same
61
+ // conversation. Two of its facts must not travel backwards: `lastTs` drives
62
+ // how often we look for new turns (scrolling back would otherwise slow the
63
+ // live tail to a crawl), and `model` is the one the session is running now.
64
+ if ((k === 'lastTs' || k === 'model') && out[k] && (k !== 'lastTs' || (v as number) <= (out[k] as number))) continue;
65
+ if ((v || !(k in out)) && out[k] !== v) { out[k] = v; changed = true; }
66
+ }
67
+ return changed ? (out as Meta) : prev;
68
+ };
69
+
70
+ export function useTraceWindows(src: TraceSource, srcKey: string, opts: {
71
+ /** About to prepend `count` older turns — capture the reading position now. */
72
+ onPrepend?: (count: number) => void;
73
+ /** About to append `count` new turns at the end. */
74
+ onAppend?: (count: number) => void;
75
+ /** Everything replaced: a new source, or a gap too big to splice. */
76
+ onReset?: () => void;
77
+ /** The surface is off-screen: stop asking for turns nobody is looking at. */
78
+ paused?: boolean;
79
+ } = {}) {
80
+ const [meta, setMeta] = useState<Meta | null>(null);
81
+ const [summary, setSummary] = useState<TraceSummary | null>(null);
82
+ const [error, setError] = useState<string | null>(null);
83
+
84
+ const turns = useRef<TraceTurn[]>([]);
85
+ const cursor = useRef<TraceCursor | null>(null);
86
+ // ONE request at a time. Flicking the wheel at the top of a long trace fires
87
+ // scroll events by the dozen, and each one would otherwise start its own load;
88
+ // they would arrive out of order and prepend the same turns twice.
89
+ const loading = useRef(false);
90
+ // Which source the turns in hand belong to. Every load reads this before its
91
+ // await and checks it after: switch files in the Files pane while a window is
92
+ // in flight and it would otherwise be prepended to the NEW file's turns, with
93
+ // the old file's byte cursors and header — a conversation spliced out of two
94
+ // different transcripts.
95
+ const gen = useRef(0);
96
+ const [version, setVersion] = useState(0);
97
+ const bump = useCallback(() => setVersion((n) => n + 1), []);
98
+
99
+ const cb = useRef(opts);
100
+ cb.current = opts;
101
+
102
+ const loadTail = useCallback(async () => {
103
+ loading.current = true;
104
+ const mine = gen.current;
105
+ try {
106
+ const { turns: got, window: win, ...m } = await src.window({ at: 'tail' }, WINDOW_BYTES);
107
+ if (mine !== gen.current) return;
108
+ turns.current = got;
109
+ cursor.current = win;
110
+ cb.current.onReset?.();
111
+ setMeta(m);
112
+ setError(null);
113
+ bump();
114
+ // Nothing to render means nothing to measure, so no measurement will ever
115
+ // arrive to unblock the paging: walk back until there is something. After
116
+ // this call returns, so the one-request-at-a-time guard still holds.
117
+ if (!got.length && !win.atStart) window.setTimeout(() => loadOlderRef.current(), 0);
118
+ } catch (e) {
119
+ if (mine !== gen.current) return;
120
+ // The server distinguishes "nothing to show yet" from a real failure and
121
+ // says which — pass its own words through rather than inventing a reason.
122
+ setError(e instanceof api.TraceUnavailable ? e.message : 'could not read the trace');
123
+ } finally {
124
+ if (mine === gen.current) loading.current = false;
125
+ }
126
+ }, [src, bump]);
127
+
128
+ const loadOlderRef = useRef<() => Promise<number>>(async () => 0);
129
+
130
+ /** Fetch the window before the oldest turn held. Returns how many arrived. */
131
+ const loadOlder = useCallback(async () => {
132
+ const cur = cursor.current;
133
+ if (loading.current || !cur || cur.atStart || cur.blocked) return 0;
134
+ loading.current = true;
135
+ const mine = gen.current;
136
+ try {
137
+ let from = cur.start;
138
+ let atStart = false;
139
+ let blocked = false;
140
+ let got: TraceTurn[] = [];
141
+ let meta2: Meta | null = null;
142
+ // A window can legitimately hold no turns at all (a stretch of file-history
143
+ // lines, a run of harness metadata). Keep walking back until it holds
144
+ // something, the file starts, or the cursor stops moving.
145
+ for (let hop = 0; hop < 8 && !got.length && !atStart; hop++) {
146
+ const { turns: page, window: win, ...m } = await src.window({ at: 'before', cursor: from }, WINDOW_BYTES);
147
+ if (mine !== gen.current) return 0;
148
+ got = page;
149
+ meta2 = m;
150
+ // `blocked` is a line too big for any window — the server cannot get
151
+ // past it, so neither can we, and this is NOT the start of the trace.
152
+ blocked = !!win.blocked;
153
+ atStart = win.atStart || (!blocked && win.start >= from);
154
+ from = win.start;
155
+ if (blocked) break;
156
+ }
157
+ if (got.length) cb.current.onPrepend?.(got.length);
158
+ turns.current = [...got, ...turns.current];
159
+ cursor.current = { ...cur, start: from, atStart, blocked };
160
+ if (meta2) setMeta((p) => mergeMeta(p, meta2 as Meta));
161
+ bump();
162
+ return got.length;
163
+ } catch {
164
+ // Keep what is on screen; the next scroll retries.
165
+ return 0;
166
+ } finally {
167
+ if (mine === gen.current) loading.current = false;
168
+ }
169
+ }, [src, bump]);
170
+ loadOlderRef.current = loadOlder;
171
+
172
+ /** Whatever the agent has written since we last looked. */
173
+ const loadNewer = useCallback(async () => {
174
+ const cur = cursor.current;
175
+ if (loading.current || !cur) return 0;
176
+ loading.current = true;
177
+ const mine = gen.current;
178
+ try {
179
+ const { turns: got, window: win, ...m } = await src.window({ at: 'after', cursor: cur.end });
180
+ if (mine !== gen.current) return 0;
181
+ if (win.gap) {
182
+ // More was written than one window can carry. Splicing it in would leave
183
+ // a hole in the middle of the conversation with nothing to say so —
184
+ // start again from the new tail instead.
185
+ turns.current = got;
186
+ cursor.current = win;
187
+ cb.current.onReset?.();
188
+ } else {
189
+ if (got.length) {
190
+ cb.current.onAppend?.(got.length);
191
+ turns.current = [...turns.current, ...got];
192
+ }
193
+ cursor.current = { ...cur, end: win.end, atEnd: win.atEnd };
194
+ }
195
+ setMeta((p) => mergeMeta(p, m));
196
+ if (got.length) bump();
197
+ return got.length;
198
+ } catch {
199
+ // A poll that fails must not throw away the conversation on screen.
200
+ return 0;
201
+ } finally {
202
+ if (mine === gen.current) loading.current = false;
203
+ }
204
+ }, [src, bump]);
205
+
206
+ useEffect(() => {
207
+ gen.current += 1;
208
+ loading.current = false;
209
+ turns.current = [];
210
+ cursor.current = null;
211
+ cb.current.onReset?.();
212
+ setMeta(null);
213
+ setSummary(null);
214
+ setError(null);
215
+ loadTail();
216
+ }, [srcKey, loadTail]);
217
+
218
+ // The one read that touches the whole file, fired AFTER the first paint: it
219
+ // buys the header a real turn count, the session's token total, the date the
220
+ // conversation started and the disclosures a window cannot see — none of which
221
+ // a window can know. If it fails or is slow, the header simply says how much
222
+ // is loaded.
223
+ useEffect(() => {
224
+ let dead = false;
225
+ const h = window.setTimeout(() => {
226
+ src.summary().then((s) => { if (!dead) setSummary(s); }).catch(() => {});
227
+ }, SUMMARY_DELAY_MS);
228
+ return () => { dead = true; window.clearTimeout(h); };
229
+ }, [src, srcKey]);
230
+
231
+ // The transcript may still be being written — see LIVE_MS above. Except when
232
+ // "a window" costs a whole-file read: the SQLite harnesses have no byte
233
+ // offsets to seek, so every poll would re-parse the entire conversation (and
234
+ // evict the Overview's memo doing it). They are not polled at all.
235
+ const lastTs = meta ? meta.lastTs : 0;
236
+ const seekable = cursor.current ? cursor.current.mode === 'bytes' : true;
237
+ const paused = !!opts.paused;
238
+ useEffect(() => {
239
+ if (!seekable || paused) return undefined;
240
+ const h = window.setInterval(loadNewer, lastTs && Date.now() - lastTs < FRESH_MS ? LIVE_MS : IDLE_MS);
241
+ return () => window.clearInterval(h);
242
+ }, [loadNewer, lastTs, seekable, paused]);
243
+
244
+ const atStart = !!cursor.current?.atStart;
245
+ const blocked = !!cursor.current?.blocked;
246
+ // What the pane knows about the trace: whatever this window could tell us,
247
+ // filled in from the summary for everything a window cannot know. That is not
248
+ // only the counts — a window of a codex rollout cannot count the session's
249
+ // encrypted reasoning steps (`note`), and a window of an STS file never sees
250
+ // the `{type:'session'}` first line that carries the title, the harness and
251
+ // the session id.
252
+ const head: TraceHeadInfo | null = meta ? {
253
+ ...meta,
254
+ total: summary ? summary.total : null,
255
+ userTurns: summary ? summary.userTurns : null,
256
+ usage: meta.usage || (summary ? summary.usage : null),
257
+ firstTs: meta.firstTs || (summary ? summary.firstTs : 0),
258
+ truncated: meta.truncated || !!(summary && summary.truncated),
259
+ note: meta.note || (summary ? summary.note : null),
260
+ title: meta.title || (summary ? summary.title : ''),
261
+ harnessLabel: meta.harnessLabel || (summary ? summary.harnessLabel : ''),
262
+ sessionId: meta.sessionId || (summary ? summary.sessionId : null),
263
+ model: meta.model || (summary ? summary.model : null),
264
+ cwd: meta.cwd || (summary ? summary.cwd : null),
265
+ source: meta.source || (summary ? summary.source : null),
266
+ sharedBy: meta.sharedBy || (summary ? summary.sharedBy : null),
267
+ loaded: turns.current.length,
268
+ atStart,
269
+ blocked,
270
+ } : null;
271
+
272
+ return { turns, head, meta, error, version, atStart, blocked, loadOlder, loadNewer, reload: loadTail };
273
+ }