thomwolf HF Staff commited on
Commit
af2a0da
·
1 Parent(s): 9162969

Speed up Reader first paint

Browse files
server/test/trace-window.test.mjs CHANGED
@@ -44,6 +44,19 @@ assert.equal(tail.firstTs, 0);
44
  assert.equal(tail.usage, null);
45
  assert.equal(tail.total, null);
46
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  // ---- paging back reproduces the conversation exactly ----
48
  let page = tail;
49
  let stitched = [];
 
44
  assert.equal(tail.usage, null);
45
  assert.equal(tail.total, null);
46
 
47
+ // The reader's FIRST paint asks for a strict, small floor. A sparse tail must
48
+ // stop as soon as it has enough to render instead of growing to the ordinary
49
+ // twelve-message page and putting megabytes in front of the first pixel.
50
+ const sparse = path.join(TMP, 'sparse.jsonl');
51
+ fs.writeFileSync(sparse, `${Array.from({ length: 20 }, (_, i) => claudeLine(i, 50_000)).join('\n')}\n`);
52
+ const firstPaint = await readTraceByPath(sparse, { window: { at: 'tail', bytes: 32 * 1024, min: 2 } });
53
+ const ordinary = await readTraceByPath(sparse, { window: { at: 'tail', bytes: 32 * 1024 } });
54
+ assert.ok(firstPaint.turns.length >= 2, 'the strict tail still has enough messages to paint');
55
+ assert.ok(firstPaint.turns.length < ordinary.turns.length,
56
+ `the first paint stops before an ordinary page (${firstPaint.turns.length} vs ${ordinary.turns.length})`);
57
+ assert.ok(firstPaint.window.end - firstPaint.window.start < ordinary.window.end - ordinary.window.start,
58
+ 'the strict tail reads a smaller byte range');
59
+
60
  // ---- paging back reproduces the conversation exactly ----
61
  let page = tail;
62
  let stitched = [];
web/package.json CHANGED
@@ -13,7 +13,7 @@
13
  "dev": "vite",
14
  "build": "tsc --noEmit && vite build",
15
  "typecheck": "tsc --noEmit",
16
- "test": "node test/exchanges.test.mjs && node test/sessionTitle.test.mjs && node test/overviewSort.test.mjs && node test/drafts.test.mjs",
17
  "preview": "vite preview"
18
  },
19
  "dependencies": {
 
13
  "dev": "vite",
14
  "build": "tsc --noEmit && vite build",
15
  "typecheck": "tsc --noEmit",
16
+ "test": "node test/exchanges.test.mjs && node test/sessionTitle.test.mjs && node test/overviewSort.test.mjs && node test/drafts.test.mjs && node test/traceWindows.test.mjs",
17
  "preview": "vite preview"
18
  },
19
  "dependencies": {
web/src/App.tsx CHANGED
@@ -120,8 +120,20 @@ export default function App() {
120
  // How every pane is read — the terminal itself, or reader mode over the same
121
  // session. App-wide, like zoom, and remembered the same way.
122
  const [paneMode, setPaneMode] = useState(readPaneMode);
123
- useEffect(() => onPaneMode(setPaneMode), []);
124
- const showPaneMode = (m: 'terminal' | 'reader') => { setPaneMode(m); writePaneMode(m); };
 
 
 
 
 
 
 
 
 
 
 
 
125
  const [zoom, setZoom] = useState<number>(() => {
126
  const z = parseInt(localStorage.getItem('am-zoom') || '100', 10);
127
  return Number.isFinite(z) ? z : 100;
@@ -515,6 +527,13 @@ export default function App() {
515
  .filter((s) => !isPassive(s.cli) && !isRemote(s.cli))
516
  .map((s) => s.id);
517
  const visibleTerminalKey = visibleTerminalIds.join(',');
 
 
 
 
 
 
 
518
  const sessionIdsKey = tree.sessions.map((s) => s.id).join(',');
519
  const [warmTerminalIds, setWarmTerminalIds] = useState<string[]>([]);
520
  useEffect(() => {
@@ -807,6 +826,9 @@ export default function App() {
807
  theme={theme}
808
  zoom={zoom}
809
  mode={paneMode}
 
 
 
810
  groupName={groupNameOf[s.id]}
811
  focused={shown && sessions.length > 1 && s.id === focusedId}
812
  visible={shown && deckVisible}
 
120
  // How every pane is read — the terminal itself, or reader mode over the same
121
  // session. App-wide, like zoom, and remembered the same way.
122
  const [paneMode, setPaneMode] = useState(readPaneMode);
123
+ // Which visible batch has let its focused reader paint. Reset before entering
124
+ // reader mode; a batch key below also prevents readiness leaking across pages.
125
+ const [readerReadyFor, setReaderReadyFor] = useState('');
126
+ const paneModeRef = useRef(paneMode);
127
+ paneModeRef.current = paneMode;
128
+ useEffect(() => onPaneMode((m) => {
129
+ if (m === 'reader' && paneModeRef.current !== 'reader') setReaderReadyFor('');
130
+ paneModeRef.current = m;
131
+ setPaneMode(m);
132
+ }), []);
133
+ const showPaneMode = (m: 'terminal' | 'reader') => {
134
+ if (m === 'reader' && paneMode !== 'reader') setReaderReadyFor('');
135
+ setPaneMode(m); writePaneMode(m);
136
+ };
137
  const [zoom, setZoom] = useState<number>(() => {
138
  const z = parseInt(localStorage.getItem('am-zoom') || '100', 10);
139
  return Number.isFinite(z) ? z : 100;
 
527
  .filter((s) => !isPassive(s.cli) && !isRemote(s.cli))
528
  .map((s) => s.id);
529
  const visibleTerminalKey = visibleTerminalIds.join(',');
530
+ const readerLeadId = focusedId && visibleTerminalIds.includes(focusedId)
531
+ ? focusedId : visibleTerminalIds[0] || null;
532
+ // Focus can move after the batch is ready; that must not tear down and
533
+ // re-fetch every sibling reader. A different visible page/group is a new
534
+ // batch, while focus only chooses which member gets its critical path.
535
+ const readerBatch = visibleTerminalKey;
536
+ const readerFollowersReady = readerReadyFor === readerBatch;
537
  const sessionIdsKey = tree.sessions.map((s) => s.id).join(',');
538
  const [warmTerminalIds, setWarmTerminalIds] = useState<string[]>([]);
539
  useEffect(() => {
 
826
  theme={theme}
827
  zoom={zoom}
828
  mode={paneMode}
829
+ readerEnabled={shown && deckVisible && (id === readerLeadId || readerFollowersReady)}
830
+ onReaderReady={id === readerLeadId ? () => setReaderReadyFor(readerBatch) : undefined}
831
+ readerReadyKey={readerBatch}
832
  groupName={groupNameOf[s.id]}
833
  focused={shown && sessions.length > 1 && s.id === focusedId}
834
  visible={shown && deckVisible}
web/src/api.ts CHANGED
@@ -451,14 +451,17 @@ const traceFetch = async <T>(url: string): Promise<T> => {
451
  return r.json();
452
  };
453
 
454
- export const getTraceWindow = (id: string, req: TraceReq, bytes?: number): Promise<TraceWindow> =>
455
- traceFetch(`/api/trace/${id}?${traceRange(req)}${bytes ? `&bytes=${bytes}` : ''}`);
 
 
 
456
 
457
  export const getTraceSummary = (id: string): Promise<TraceSummary> =>
458
  traceFetch(`/api/trace/${id}?summary=1`);
459
 
460
- export const getFileTraceWindow = (id: string, p: string, req: TraceReq, bytes?: number): Promise<TraceWindow> =>
461
- traceFetch(`/api/files/${id}/trace?path=${encodeURIComponent(p)}&${traceRange(req)}${bytes ? `&bytes=${bytes}` : ''}`);
462
 
463
  export const getFileTraceSummary = (id: string, p: string): Promise<TraceSummary> =>
464
  traceFetch(`/api/files/${id}/trace?path=${encodeURIComponent(p)}&summary=1`);
 
451
  return r.json();
452
  };
453
 
454
+ const windowSize = (bytes?: number, min?: number) =>
455
+ `${bytes ? `&bytes=${bytes}` : ''}${min ? `&min=${min}` : ''}`;
456
+
457
+ export const getTraceWindow = (id: string, req: TraceReq, bytes?: number, min?: number): Promise<TraceWindow> =>
458
+ traceFetch(`/api/trace/${id}?${traceRange(req)}${windowSize(bytes, min)}`);
459
 
460
  export const getTraceSummary = (id: string): Promise<TraceSummary> =>
461
  traceFetch(`/api/trace/${id}?summary=1`);
462
 
463
+ export const getFileTraceWindow = (id: string, p: string, req: TraceReq, bytes?: number, min?: number): Promise<TraceWindow> =>
464
+ traceFetch(`/api/files/${id}/trace?path=${encodeURIComponent(p)}&${traceRange(req)}${windowSize(bytes, min)}`);
465
 
466
  export const getFileTraceSummary = (id: string, p: string): Promise<TraceSummary> =>
467
  traceFetch(`/api/files/${id}/trace?path=${encodeURIComponent(p)}&summary=1`);
web/src/components/FilesPane.tsx CHANGED
@@ -766,7 +766,7 @@ function FileView({ sessionId, path, zoom, raw, scripts, onInfo, onSaved }: {
766
  // The reader opens on the tail of the transcript and pages backwards from
767
  // there; the summary is the one call that reads all of it.
768
  const traceSrc = useMemo<TraceSource>(() => ({
769
- window: (req, bytes) => api.getFileTraceWindow(sessionId, path, req, bytes),
770
  summary: () => api.getFileTraceSummary(sessionId, path),
771
  }), [sessionId, path]);
772
 
 
766
  // The reader opens on the tail of the transcript and pages backwards from
767
  // there; the summary is the one call that reads all of it.
768
  const traceSrc = useMemo<TraceSource>(() => ({
769
+ window: (req, bytes, min) => api.getFileTraceWindow(sessionId, path, req, bytes, min),
770
  summary: () => api.getFileTraceSummary(sessionId, path),
771
  }), [sessionId, path]);
772
 
web/src/components/TerminalPane.tsx CHANGED
@@ -181,7 +181,8 @@ if (typeof window !== 'undefined') {
181
  }
182
 
183
  export default function TerminalPane({
184
- session, cli, theme, focused, visible, active, zoom = 100, mode = 'terminal', dragId, isMobile, groupName, onDragActive, onFocus, onRename, onClose,
 
185
  }: {
186
  session: Session;
187
  cli?: Cli;
@@ -192,6 +193,9 @@ export default function TerminalPane({
192
  active?: boolean;
193
  zoom?: number;
194
  mode?: PaneMode; // app-wide reading mode, from the bottom bar
 
 
 
195
  dragId?: string; // set when the pane can be rearranged (group view)
196
  isMobile?: boolean; // show the on-screen control-key bar
197
  onDragActive?: (dragging: boolean) => void;
@@ -892,9 +896,11 @@ export default function TerminalPane({
892
  {/* Reader mode draws OVER the terminal rather than replacing it: xterm needs
893
  layout to fit, and detaching tmux costs a repaint and can trip the
894
  handoff path. The terminal stays mounted and connected underneath. */}
895
- {reading && (
896
  <div className="pane-reader" onMouseDown={(e) => e.stopPropagation()}>
897
- <ConversationView session={session} paused={visible === false} isMobile={isMobile} />
 
 
898
  </div>
899
  )}
900
  </div>
 
181
  }
182
 
183
  export default function TerminalPane({
184
+ session, cli, theme, focused, visible, active, zoom = 100, mode = 'terminal', readerEnabled,
185
+ readerReadyKey, onReaderReady, dragId, isMobile, groupName, onDragActive, onFocus, onRename, onClose,
186
  }: {
187
  session: Session;
188
  cli?: Cli;
 
193
  active?: boolean;
194
  zoom?: number;
195
  mode?: PaneMode; // app-wide reading mode, from the bottom bar
196
+ readerEnabled?: boolean; // focused reader paints before visible followers
197
+ readerReadyKey?: string; // visible batch whose first paint is being awaited
198
+ onReaderReady?: () => void;
199
  dragId?: string; // set when the pane can be rearranged (group view)
200
  isMobile?: boolean; // show the on-screen control-key bar
201
  onDragActive?: (dragging: boolean) => void;
 
896
  {/* Reader mode draws OVER the terminal rather than replacing it: xterm needs
897
  layout to fit, and detaching tmux costs a repaint and can trip the
898
  handoff path. The terminal stays mounted and connected underneath. */}
899
+ {reading && visible !== false && (
900
  <div className="pane-reader" onMouseDown={(e) => e.stopPropagation()}>
901
+ {readerEnabled === false
902
+ ? <div className="cxv-empty mono">reading the trace…</div>
903
+ : <ConversationView session={session} isMobile={isMobile} onReady={onReaderReady} readyKey={readerReadyKey} />}
904
  </div>
905
  )}
906
  </div>
web/src/components/TracePane.tsx CHANGED
@@ -586,7 +586,7 @@ export default function TracePane({
586
  const nav = useRef<((dir: -1 | 1) => void) | null>(null);
587
  const onNav = useCallback((go: (dir: -1 | 1) => void) => { nav.current = go; }, []);
588
  const src = useMemo<TraceSource>(() => ({
589
- window: (req, bytes) => api.getTraceWindow(session.id, req, bytes),
590
  summary: () => api.getTraceSummary(session.id),
591
  }), [session.id]);
592
 
 
586
  const nav = useRef<((dir: -1 | 1) => void) | null>(null);
587
  const onNav = useCallback((go: (dir: -1 | 1) => void) => { nav.current = go; }, []);
588
  const src = useMemo<TraceSource>(() => ({
589
+ window: (req, bytes, min) => api.getTraceWindow(session.id, req, bytes, min),
590
  summary: () => api.getTraceSummary(session.id),
591
  }), [session.id]);
592
 
web/src/components/conversation/ConversationView.tsx CHANGED
@@ -30,7 +30,7 @@ const fmtNum = (n: number) => n.toLocaleString();
30
  const fmtUsage = (u?: { in: number; out: number } | null) =>
31
  (u ? `${fmtTok(u.in)}↓ ${fmtTok(u.out)}↑` : '');
32
 
33
- export default function ConversationView({ session, paused, isMobile, readOnly, onHandover }: {
34
  session: Session;
35
  /** The pane is off-screen: stop asking the server for a trace nobody sees. */
36
  paused?: boolean;
@@ -38,6 +38,10 @@ export default function ConversationView({ session, paused, isMobile, readOnly,
38
  /** A trace with no agent behind it — a shared file, an import. Read-only. */
39
  readOnly?: boolean;
40
  onHandover?: () => void;
 
 
 
 
41
  }) {
42
  const [query, setQuery] = useState('');
43
  const [hits, setHits] = useState(0);
@@ -62,7 +66,7 @@ export default function ConversationView({ session, paused, isMobile, readOnly,
62
  const live = session.state === 'working' && !paused;
63
 
64
  const src = useMemo<TraceSource>(() => ({
65
- window: (req, bytes) => api.getTraceWindow(session.id, req, bytes),
66
  summary: () => api.getTraceSummary(session.id),
67
  }), [session.id]);
68
 
@@ -96,6 +100,20 @@ export default function ConversationView({ session, paused, isMobile, readOnly,
96
  const exchanges = useMemo(() => splitExchanges(turns), [version, turns]); // eslint-disable-line react-hooks/exhaustive-deps
97
  const last = exchanges[exchanges.length - 1];
98
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
  // The optimistic echo stands until the transcript catches up: a CLI writes it,
100
  // the reader picks it up, the poll lands — seconds, during which a card that
101
  // showed nothing would read as "did that get lost?".
@@ -200,12 +218,12 @@ export default function ConversationView({ session, paused, isMobile, readOnly,
200
  const fills = useRef(0);
201
  useEffect(() => {
202
  const el = scroller.current;
203
- if (!el || !head || atStart || blocked) return;
204
  if (el.scrollHeight > el.clientHeight) { fills.current = 0; return; }
205
  if (fills.current >= 2) return;
206
  fills.current += 1;
207
  loadOlder();
208
- }, [version, head, atStart, blocked, loadOlder]);
209
 
210
  useLayoutEffect(() => {
211
  const el = scroller.current;
 
30
  const fmtUsage = (u?: { in: number; out: number } | null) =>
31
  (u ? `${fmtTok(u.in)}↓ ${fmtTok(u.out)}↑` : '');
32
 
33
+ export default function ConversationView({ session, paused, isMobile, readOnly, onHandover, onReady, readyKey }: {
34
  session: Session;
35
  /** The pane is off-screen: stop asking the server for a trace nobody sees. */
36
  paused?: boolean;
 
38
  /** A trace with no agent behind it — a shared file, an import. Read-only. */
39
  readOnly?: boolean;
40
  onHandover?: () => void;
41
+ /** Called after the first tail page (or its terminal error) has painted. */
42
+ onReady?: () => void;
43
+ /** The visible batch this paint should release. */
44
+ readyKey?: string;
45
  }) {
46
  const [query, setQuery] = useState('');
47
  const [hits, setHits] = useState(0);
 
66
  const live = session.state === 'working' && !paused;
67
 
68
  const src = useMemo<TraceSource>(() => ({
69
+ window: (req, bytes, min) => api.getTraceWindow(session.id, req, bytes, min),
70
  summary: () => api.getTraceSummary(session.id),
71
  }), [session.id]);
72
 
 
100
  const exchanges = useMemo(() => splitExchanges(turns), [version, turns]); // eslint-disable-line react-hooks/exhaustive-deps
101
  const last = exchanges[exchanges.length - 1];
102
 
103
+ // Group reader mode gives the focused pane the critical path. Its siblings
104
+ // wait for this signal before mounting their own readers, so one click cannot
105
+ // turn twelve retained panes into twelve competing tail reads. useEffect runs
106
+ // after the head/error render commits: this is a paint barrier, not a timer.
107
+ const readySentFor = useRef('');
108
+ const ready = useRef(onReady);
109
+ ready.current = onReady;
110
+ useEffect(() => {
111
+ const key = `${session.id}:${readyKey || ''}`;
112
+ if (readySentFor.current === key || (!head && !error)) return;
113
+ readySentFor.current = key;
114
+ ready.current?.();
115
+ }, [head, error, readyKey, session.id]);
116
+
117
  // The optimistic echo stands until the transcript catches up: a CLI writes it,
118
  // the reader picks it up, the poll lands — seconds, during which a card that
119
  // showed nothing would read as "did that get lost?".
 
218
  const fills = useRef(0);
219
  useEffect(() => {
220
  const el = scroller.current;
221
+ if (paused || !el || !head || atStart || blocked) return;
222
  if (el.scrollHeight > el.clientHeight) { fills.current = 0; return; }
223
  if (fills.current >= 2) return;
224
  fills.current += 1;
225
  loadOlder();
226
+ }, [version, head, atStart, blocked, loadOlder, paused]);
227
 
228
  useLayoutEffect(() => {
229
  const el = scroller.current;
web/src/lib/traceWindows.ts CHANGED
@@ -21,10 +21,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
21
  import * as api from '../api';
22
  import type { TraceCursor, TraceSummary, TraceTurn, TraceWindow } from '../api';
23
 
24
- /** How much transcript the first request asks for. Measured on the longest trace
25
- * on this box (19 MB / 1,420 turns): 384 KB is ~60 turns, ~84 KB of JSON and
26
- * ~6 ms of server time, and renders in a frame the smallest window that still
27
- * opens on a complete-looking conversation rather than a stub. */
 
 
 
28
  export const WINDOW_BYTES = 384 * 1024;
29
  // Following a trace that is still being written. Nothing in here knows whether
30
  // an agent is running — but a transcript whose newest turn is seconds old is one
@@ -36,7 +39,7 @@ const FRESH_MS = 120_000;
36
  const SUMMARY_DELAY_MS = 400; // let the first paint happen before the whole-file read
37
 
38
  export interface TraceSource {
39
- window: (req: api.TraceReq, bytes?: number) => Promise<TraceWindow>;
40
  summary: () => Promise<TraceSummary>;
41
  }
42
 
@@ -112,10 +115,13 @@ export function useTraceWindows(src: TraceSource, srcKey: string, opts: {
112
  cb.current = opts;
113
 
114
  const loadTail = useCallback(async () => {
 
115
  loading.current = true;
116
  const mine = gen.current;
117
  try {
118
- const { turns: got, window: win, ...m } = await src.window({ at: 'tail' }, WINDOW_BYTES);
 
 
119
  if (mine !== gen.current) return;
120
  turns.current = got;
121
  cursor.current = win;
@@ -142,7 +148,7 @@ export function useTraceWindows(src: TraceSource, srcKey: string, opts: {
142
  /** Fetch the window before the oldest turn held. Returns how many arrived. */
143
  const loadOlder = useCallback(async () => {
144
  const cur = cursor.current;
145
- if (loading.current || !cur || cur.atStart || cur.blocked) return 0;
146
  loading.current = true;
147
  const mine = gen.current;
148
  try {
@@ -184,7 +190,7 @@ export function useTraceWindows(src: TraceSource, srcKey: string, opts: {
184
  /** Whatever the agent has written since we last looked. */
185
  const loadNewer = useCallback(async () => {
186
  const cur = cursor.current;
187
- if (loading.current || !cur) return 0;
188
  loading.current = true;
189
  const mine = gen.current;
190
  try {
@@ -215,6 +221,8 @@ export function useTraceWindows(src: TraceSource, srcKey: string, opts: {
215
  }
216
  }, [src, bump]);
217
 
 
 
218
  useEffect(() => {
219
  gen.current += 1;
220
  loading.current = false;
@@ -224,8 +232,8 @@ export function useTraceWindows(src: TraceSource, srcKey: string, opts: {
224
  setMeta(null);
225
  setSummary(null);
226
  setError(null);
227
- loadTail();
228
- }, [srcKey, loadTail]);
229
 
230
  // The one read that touches the whole file, fired AFTER the first paint: it
231
  // buys the header a real turn count, the session's token total, the date the
@@ -233,12 +241,17 @@ export function useTraceWindows(src: TraceSource, srcKey: string, opts: {
233
  // a window can know. If it fails or is slow, the header simply says how much
234
  // is loaded.
235
  useEffect(() => {
 
 
 
 
 
236
  let dead = false;
237
  const h = window.setTimeout(() => {
238
  src.summary().then((s) => { if (!dead) setSummary(s); }).catch(() => {});
239
  }, SUMMARY_DELAY_MS);
240
  return () => { dead = true; window.clearTimeout(h); };
241
- }, [src, srcKey]);
242
 
243
  // The transcript may still be being written — see LIVE_MS above. Except when
244
  // "a window" costs a whole-file read: the SQLite harnesses have no byte
@@ -246,7 +259,6 @@ export function useTraceWindows(src: TraceSource, srcKey: string, opts: {
246
  // evict the Overview's memo doing it). They are not polled at all.
247
  const lastTs = meta ? meta.lastTs : 0;
248
  const seekable = cursor.current ? cursor.current.mode === 'bytes' : true;
249
- const paused = !!opts.paused;
250
  const live = opts.live;
251
  const [hidden, setHidden] = useState(() => (typeof document === 'undefined' ? false : document.hidden));
252
  useEffect(() => {
 
21
  import * as api from '../api';
22
  import type { TraceCursor, TraceSummary, TraceTurn, TraceWindow } from '../api';
23
 
24
+ /** The first paint is deliberately smaller than an ordinary page. A turn floor
25
+ * made a nominal 384 KB tail grow to 1.5 MB / 737 KB of JSON on a real Codex
26
+ * trace before the reader could show anything. Two messages are enough to paint
27
+ * the latest exchange; the host can fill above it after that first paint. */
28
+ export const INITIAL_WINDOW_BYTES = 128 * 1024;
29
+ export const INITIAL_WINDOW_TURNS = 2;
30
+ /** Once something is on screen, larger pages make scrolling back efficient. */
31
  export const WINDOW_BYTES = 384 * 1024;
32
  // Following a trace that is still being written. Nothing in here knows whether
33
  // an agent is running — but a transcript whose newest turn is seconds old is one
 
39
  const SUMMARY_DELAY_MS = 400; // let the first paint happen before the whole-file read
40
 
41
  export interface TraceSource {
42
+ window: (req: api.TraceReq, bytes?: number, min?: number) => Promise<TraceWindow>;
43
  summary: () => Promise<TraceSummary>;
44
  }
45
 
 
115
  cb.current = opts;
116
 
117
  const loadTail = useCallback(async () => {
118
+ if (cb.current.paused) return;
119
  loading.current = true;
120
  const mine = gen.current;
121
  try {
122
+ const { turns: got, window: win, ...m } = await src.window(
123
+ { at: 'tail' }, INITIAL_WINDOW_BYTES, INITIAL_WINDOW_TURNS,
124
+ );
125
  if (mine !== gen.current) return;
126
  turns.current = got;
127
  cursor.current = win;
 
148
  /** Fetch the window before the oldest turn held. Returns how many arrived. */
149
  const loadOlder = useCallback(async () => {
150
  const cur = cursor.current;
151
+ if (cb.current.paused || loading.current || !cur || cur.atStart || cur.blocked) return 0;
152
  loading.current = true;
153
  const mine = gen.current;
154
  try {
 
190
  /** Whatever the agent has written since we last looked. */
191
  const loadNewer = useCallback(async () => {
192
  const cur = cursor.current;
193
+ if (cb.current.paused || loading.current || !cur) return 0;
194
  loading.current = true;
195
  const mine = gen.current;
196
  try {
 
221
  }
222
  }, [src, bump]);
223
 
224
+ const paused = !!opts.paused;
225
+
226
  useEffect(() => {
227
  gen.current += 1;
228
  loading.current = false;
 
232
  setMeta(null);
233
  setSummary(null);
234
  setError(null);
235
+ if (!paused) loadTail();
236
+ }, [srcKey, loadTail, paused]);
237
 
238
  // The one read that touches the whole file, fired AFTER the first paint: it
239
  // buys the header a real turn count, the session's token total, the date the
 
241
  // a window can know. If it fails or is slow, the header simply says how much
242
  // is loaded.
243
  useEffect(() => {
244
+ // `meta` is set by the tail response and this effect runs after that render
245
+ // commits. Starting the clock on mount let a slow tail lose a race to every
246
+ // pane's full-file summary — exactly the work the delay meant to keep away
247
+ // from first paint. A paused/hidden reader does no summary work at all.
248
+ if (paused || !meta || summary) return undefined;
249
  let dead = false;
250
  const h = window.setTimeout(() => {
251
  src.summary().then((s) => { if (!dead) setSummary(s); }).catch(() => {});
252
  }, SUMMARY_DELAY_MS);
253
  return () => { dead = true; window.clearTimeout(h); };
254
+ }, [src, srcKey, paused, meta, summary]);
255
 
256
  // The transcript may still be being written — see LIVE_MS above. Except when
257
  // "a window" costs a whole-file read: the SQLite harnesses have no byte
 
259
  // evict the Overview's memo doing it). They are not polled at all.
260
  const lastTs = meta ? meta.lastTs : 0;
261
  const seekable = cursor.current ? cursor.current.mode === 'bytes' : true;
 
262
  const live = opts.live;
263
  const [hidden, setHidden] = useState(() => (typeof document === 'undefined' ? false : document.hidden));
264
  useEffect(() => {
web/test/traceWindows.test.mjs ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Critical-path scheduling for the windowed reader, in a real React/browser
2
+ // lifecycle: hidden readers stay silent, the first request is deliberately
3
+ // small, and the whole-file summary clock starts only after the tail paints.
4
+ import assert from 'node:assert/strict';
5
+ import fs from 'node:fs';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { build } from 'esbuild';
10
+ import { chromium } from 'playwright';
11
+
12
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
13
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'trace-windows-web-'));
14
+ const bundle = path.join(tmp, 'harness.js');
15
+
16
+ await build({
17
+ stdin: {
18
+ resolveDir: path.join(HERE, '..'),
19
+ loader: 'tsx',
20
+ contents: `
21
+ import React, { useEffect } from 'react';
22
+ import { createRoot } from 'react-dom/client';
23
+ import { useTraceWindows } from './src/lib/traceWindows.ts';
24
+
25
+ const calls = [];
26
+ const tails = [];
27
+ const source = {
28
+ window(req, bytes, min) {
29
+ calls.push({ kind: 'window', req, bytes, min, at: performance.now() });
30
+ return new Promise((resolve) => tails.push(resolve));
31
+ },
32
+ async summary() {
33
+ calls.push({ kind: 'summary', at: performance.now() });
34
+ return { total: 2, userTurns: [0] };
35
+ },
36
+ };
37
+
38
+ function Probe({ paused }) {
39
+ const { head } = useTraceWindows(source, 'session-a', { paused, live: false });
40
+ useEffect(() => { window.__traceHead = head; }, [head]);
41
+ return <div id="head">{head ? String(head.loaded) : ''}</div>;
42
+ }
43
+
44
+ const root = createRoot(document.getElementById('root'));
45
+ let paused = true;
46
+ const render = () => root.render(<Probe paused={paused} />);
47
+ window.__traceHarness = {
48
+ calls,
49
+ setPaused(next) { paused = next; render(); },
50
+ resolveTail() {
51
+ tails.shift()?.({
52
+ harness: 'claude', harnessLabel: 'Claude Code', sessionId: 's',
53
+ title: '', model: null, cwd: null, firstTs: 0, lastTs: Date.now(),
54
+ usage: null, source: null, sharedBy: null, note: null, truncated: false,
55
+ total: null, userTurns: null,
56
+ turns: [
57
+ { role: 'user', ts: 1, blocks: [{ type: 'text', text: 'ask' }] },
58
+ { role: 'assistant', ts: 2, blocks: [{ type: 'text', text: 'answer' }] },
59
+ ],
60
+ window: { mode: 'bytes', start: 10, end: 20, atStart: false, atEnd: true },
61
+ });
62
+ },
63
+ };
64
+ render();
65
+ `,
66
+ },
67
+ outfile: bundle,
68
+ bundle: true,
69
+ format: 'iife',
70
+ platform: 'browser',
71
+ logLevel: 'error',
72
+ });
73
+
74
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
75
+ // CI normally uses Playwright's matching download. The development Space keeps
76
+ // a shared Chromium revision instead, so allow that executable to be supplied
77
+ // without baking an environment-specific path into the test.
78
+ const browser = await chromium.launch({
79
+ headless: true,
80
+ executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH,
81
+ });
82
+ try {
83
+ const page = await browser.newPage();
84
+ await page.setContent('<div id="root"></div>');
85
+ await page.addScriptTag({ path: bundle });
86
+ await page.waitForFunction(() => !!window.__traceHarness);
87
+
88
+ await sleep(100);
89
+ assert.deepEqual(await page.evaluate(() => window.__traceHarness.calls), [],
90
+ 'a paused reader performs no initial or summary request');
91
+
92
+ await page.evaluate(() => window.__traceHarness.setPaused(false));
93
+ await page.waitForFunction(() => window.__traceHarness.calls.length === 1);
94
+ const first = await page.evaluate(() => window.__traceHarness.calls[0]);
95
+ assert.deepEqual(first.req, { at: 'tail' });
96
+ assert.equal(first.bytes, 128 * 1024, 'the first byte window is strict and small');
97
+ assert.equal(first.min, 2, 'the first window stops after one displayable exchange');
98
+
99
+ // The old mount-based timer would have fired by now even though no tail page
100
+ // exists. The summary must not compete with the request that removes loading.
101
+ await sleep(500);
102
+ assert.equal(await page.evaluate(() => window.__traceHarness.calls.filter((c) => c.kind === 'summary').length), 0,
103
+ 'the summary clock has not started while the tail is pending');
104
+
105
+ const resolvedAt = Date.now();
106
+ await page.evaluate(() => window.__traceHarness.resolveTail());
107
+ await page.waitForFunction(() => document.getElementById('head').textContent === '2');
108
+ await sleep(250);
109
+ assert.equal(await page.evaluate(() => window.__traceHarness.calls.filter((c) => c.kind === 'summary').length), 0,
110
+ 'the summary remains deferred after the first page paints');
111
+ await page.waitForFunction(() => window.__traceHarness.calls.some((c) => c.kind === 'summary'), { timeout: 1500 });
112
+ assert.ok(Date.now() - resolvedAt >= 350, 'the delay is measured from the painted tail, not mount');
113
+
114
+ const beforePause = await page.evaluate(() => window.__traceHarness.calls.length);
115
+ await page.evaluate(() => window.__traceHarness.setPaused(true));
116
+ await sleep(500);
117
+ assert.equal(await page.evaluate(() => window.__traceHarness.calls.length), beforePause,
118
+ 'pausing an already-mounted reader starts no further work');
119
+ } finally {
120
+ await browser.close();
121
+ fs.rmSync(tmp, { recursive: true, force: true });
122
+ }
123
+
124
+ console.log('trace-windows: ok');