agent-manager-template / web /src /lib /traceWindows.ts
Agent Manager
Re-arm the whole-file summary when the tab comes back too
ca62b3a
Raw
History Blame Contribute Delete
19.8 kB
// Reading a trace as WINDOWS, for whichever surface is doing the reading.
//
// The Trace pane and reader mode show the same conversation very differently β€”
// one as rows of turns, the other as exchanges with a composer under them β€” but
// they fetch it identically: open on the end, page backwards a window at a time,
// follow the end while it is being written. That fetching is subtle in ways two
// review rounds found the hard way (a load outliving its source, a second load
// racing the first, a pointer to a window nobody read), so it lives here once
// rather than twice.
//
// `src` MUST be memoized by the caller (all three call sites use useMemo on the
// session id or path): the reset effect depends on it, so a fresh object per
// render would refetch the tail forever.
//
// What stays with the caller is everything about presentation: how a row is
// measured, what is virtualized, and how the reading position is anchored when
// older turns arrive. The hook says WHEN turns are about to be prepended
// (`onPrepend`) and when everything has been replaced (`onReset`); the caller
// decides what that means for its own scroller.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import * as api from '../api';
import type { TraceCursor, TraceSummary, TraceTurn, TraceWindow } from '../api';
/** The first paint is deliberately smaller than an ordinary page. A turn floor
* made a nominal 384 KB tail grow to 1.5 MB / 737 KB of JSON on a real Codex
* trace before the reader could show anything. Two messages are enough to paint
* the latest exchange; the host can fill above it after that first paint. */
export const INITIAL_WINDOW_BYTES = 128 * 1024;
export const INITIAL_WINDOW_TURNS = 2;
/** Once something is on screen, larger pages make scrolling back efficient. */
export const WINDOW_BYTES = 384 * 1024;
// Following a trace that is still being written. Nothing in here knows whether
// an agent is running β€” but a transcript whose newest turn is seconds old is one
// being written, so the cadence follows the trace itself: quick while it moves,
// slow once it has gone quiet, which is also how it notices movement resuming.
const LIVE_MS = 3_000;
const IDLE_MS = 10_000;
const FRESH_MS = 120_000;
const SUMMARY_DELAY_MS = 400; // let the first paint happen before the whole-file read
export interface TraceSource {
window: (req: api.TraceReq, bytes?: number, min?: number, signal?: AbortSignal) => Promise<TraceWindow>;
summary: (signal?: AbortSignal) => Promise<TraceSummary>;
}
export type Meta = Omit<TraceWindow, 'turns' | 'window'>;
/** What a host's chrome needs. `total` is null until the summary lands. */
export type TraceHeadInfo = Meta & {
/** turns the reader is holding right now */
loaded: number;
/** the first turn of the conversation is loaded β€” there is nothing above */
atStart: boolean;
/** one line is bigger than a window: nothing older can be reached */
blocked: boolean;
};
// Keep whichever value actually says something: a window that doesn't reach the
// start of the trace reports no session start and no session cost, and must not
// blank out what an earlier response already told us. Identity is preserved when
// no SCALAR changed, which is what keeps a poll that learns nothing from looking
// like new state; an object-valued field (`usage`, `source`) compares by
// identity and would churn, so those only ever arrive once per source today.
export const mergeMeta = (prev: Meta | null, next: Meta): Meta => {
if (!prev) return next;
const out = { ...prev } as Record<string, unknown>;
let changed = false;
for (const [k, v] of Object.entries(next)) {
// A window OLDER than what we hold describes an older stretch of the same
// conversation. `lastTs` must not travel backwards β€” it drives how often we
// look for new turns, and scrolling back would otherwise slow the live tail
// to a crawl. `model` is simply first-one-wins: the tail is read first, so
// that is the model the session is running now, and an older window's model
// does not replace it.
if ((k === 'lastTs' || k === 'model') && out[k] && (k !== 'lastTs' || (v as number) <= (out[k] as number))) continue;
if ((v || !(k in out)) && out[k] !== v) { out[k] = v; changed = true; }
}
return changed ? (out as Meta) : prev;
};
export function useTraceWindows(src: TraceSource, srcKey: string, opts: {
/** About to prepend `count` older turns β€” capture the reading position now. */
onPrepend?: (count: number) => void;
/** About to append `count` new turns at the end. */
onAppend?: (count: number) => void;
/** Everything replaced: a new source, or a gap too big to splice. */
onReset?: () => void;
/** The surface is off-screen: stop asking for turns nobody is looking at. */
paused?: boolean;
/** The host knows the session is running. `false` means it is not, and a trace
* that has also been quiet for a while is then left alone entirely β€” which is
* what reader mode did before it had windows. Omit to decide on recency only. */
live?: boolean;
} = {}) {
const [meta, setMeta] = useState<Meta | null>(null);
const [summary, setSummary] = useState<TraceSummary | null>(null);
const [error, setError] = useState<string | null>(null);
const turns = useRef<TraceTurn[]>([]);
const cursor = useRef<TraceCursor | null>(null);
// ONE request at a time. Flicking the wheel at the top of a long trace fires
// scroll events by the dozen, and each one would otherwise start its own load;
// they would arrive out of order and prepend the same turns twice.
const loading = useRef(false);
// Which source the turns in hand belong to. Every load reads this before its
// await and checks it after: switch files in the Files pane while a window is
// in flight and it would otherwise be prepended to the NEW file's turns, with
// the old file's byte cursors and header β€” a conversation spliced out of two
// different transcripts.
const gen = useRef(0);
const windowAbort = useRef<AbortController | null>(null);
const summaryAbort = useRef<AbortController | null>(null);
const summaryStarted = useRef(false);
const [version, setVersion] = useState(0);
const bump = useCallback(() => setVersion((n) => n + 1), []);
const cb = useRef(opts);
cb.current = opts;
const loadTail = useCallback(async () => {
if (cb.current.paused) return;
loading.current = true;
const mine = gen.current;
const abort = new AbortController();
windowAbort.current = abort;
try {
const { turns: got, window: win, ...m } = await src.window(
{ at: 'tail' }, INITIAL_WINDOW_BYTES, INITIAL_WINDOW_TURNS, abort.signal,
);
if (mine !== gen.current) return;
turns.current = got;
cursor.current = win;
cb.current.onReset?.();
setMeta(m);
setError(null);
bump();
// Nothing to render means nothing to measure, so no measurement will ever
// arrive to unblock the paging: walk back until there is something. After
// this call returns, so the one-request-at-a-time guard still holds.
if (!got.length && !win.atStart) window.setTimeout(() => loadOlderRef.current(), 0);
} catch (e) {
if (mine !== gen.current) return;
// The server distinguishes "nothing to show yet" from a real failure and
// says which β€” pass its own words through rather than inventing a reason.
setError(e instanceof api.TraceUnavailable ? e.message : 'could not read the trace');
} finally {
if (windowAbort.current === abort) windowAbort.current = null;
if (mine === gen.current) loading.current = false;
}
}, [src, bump]);
const loadOlderRef = useRef<() => Promise<number>>(async () => 0);
/** Fetch the window before the oldest turn held. Returns how many arrived. */
const loadOlder = useCallback(async () => {
const cur = cursor.current;
if (cb.current.paused || loading.current || !cur || cur.atStart || cur.blocked) return 0;
loading.current = true;
const mine = gen.current;
const abort = new AbortController();
windowAbort.current = abort;
try {
let from = cur.start;
let atStart = false;
let blocked = false;
let got: TraceTurn[] = [];
let meta2: Meta | null = null;
// A window can legitimately hold no turns at all (a stretch of file-history
// lines, a run of harness metadata). Keep walking back until it holds
// something, the file starts, or the cursor stops moving.
for (let hop = 0; hop < 8 && !got.length && !atStart; hop++) {
const { turns: page, window: win, ...m } = await src.window(
{ at: 'before', cursor: from }, WINDOW_BYTES, undefined, abort.signal,
);
if (mine !== gen.current) return 0;
got = page;
meta2 = m;
// `blocked` is a line too big for any window β€” the server cannot get
// past it, so neither can we, and this is NOT the start of the trace.
blocked = !!win.blocked;
atStart = win.atStart || (!blocked && win.start >= from);
from = win.start;
if (blocked) break;
}
if (got.length) cb.current.onPrepend?.(got.length);
turns.current = [...got, ...turns.current];
cursor.current = { ...cur, start: from, atStart, blocked };
if (meta2) setMeta((p) => mergeMeta(p, meta2 as Meta));
bump();
return got.length;
} catch {
// Keep what is on screen; the next scroll retries.
return 0;
} finally {
if (windowAbort.current === abort) windowAbort.current = null;
if (mine === gen.current) loading.current = false;
}
}, [src, bump]);
loadOlderRef.current = loadOlder;
/** Whatever the agent has written since we last looked. */
const loadNewer = useCallback(async () => {
const cur = cursor.current;
if (cb.current.paused || loading.current || !cur) return 0;
loading.current = true;
const mine = gen.current;
const abort = new AbortController();
windowAbort.current = abort;
try {
const { turns: got, window: win, ...m } = await src.window(
{ at: 'after', cursor: cur.end }, undefined, undefined, abort.signal,
);
if (mine !== gen.current) return 0;
if (win.gap) {
// More was written than one window can carry. Splicing it in would leave
// a hole in the middle of the conversation with nothing to say so β€”
// start again from the new tail instead.
turns.current = got;
cursor.current = win;
cb.current.onReset?.();
} else {
if (got.length) {
cb.current.onAppend?.(got.length);
turns.current = [...turns.current, ...got];
}
cursor.current = { ...cur, end: win.end, atEnd: win.atEnd };
}
setMeta((p) => mergeMeta(p, m));
if (got.length) bump();
return got.length;
} catch {
// A poll that fails must not throw away the conversation on screen.
return 0;
} finally {
if (windowAbort.current === abort) windowAbort.current = null;
if (mine === gen.current) loading.current = false;
}
}, [src, bump]);
const paused = !!opts.paused;
useEffect(() => {
gen.current += 1;
windowAbort.current?.abort();
windowAbort.current = null;
summaryAbort.current?.abort();
summaryAbort.current = null;
summaryStarted.current = false;
loading.current = false;
turns.current = [];
cursor.current = null;
cb.current.onReset?.();
setMeta(null);
setSummary(null);
setError(null);
if (!paused) loadTail();
return () => {
gen.current += 1;
windowAbort.current?.abort();
windowAbort.current = null;
summaryAbort.current?.abort();
summaryAbort.current = null;
};
}, [srcKey, loadTail, paused]);
// The one read that touches the whole file, fired AFTER the first paint: it
// buys the header a real turn count, the session's token total, the date the
// conversation started and the disclosures a window cannot see β€” none of which
// a window can know. If it fails or is slow, the header simply says how much
// is loaded.
const summaryReady = !!meta;
// Bumped when a return to the foreground abandons a summary that was in
// flight. `summaryStarted` is a ref, so clearing it alone would not re-run the
// effect below β€” nothing else in its deps changes when a tab comes back.
const [summaryEpoch, setSummaryEpoch] = useState(0);
useEffect(() => {
// `meta` is set by the tail response and this effect runs after that render
// commits. Starting the clock on mount let a slow tail lose a race to every
// pane's full-file summary β€” exactly the work the delay meant to keep away
// from first paint. A paused/hidden reader does no summary work at all.
if (paused || !summaryReady || summary || summaryStarted.current) return undefined;
let dead = false;
const h = window.setTimeout(() => {
if (summaryStarted.current) return;
summaryStarted.current = true;
const abort = new AbortController();
summaryAbort.current = abort;
src.summary(abort.signal)
.then((s) => { if (!dead) setSummary(s); })
.catch(() => {})
.finally(() => { if (summaryAbort.current === abort) summaryAbort.current = null; });
}, SUMMARY_DELAY_MS);
return () => { dead = true; window.clearTimeout(h); };
}, [src, srcKey, paused, summaryReady, summary, summaryEpoch]);
// The transcript may still be being written β€” see LIVE_MS above. Except when
// "a window" costs a whole-file read: the SQLite harnesses have no byte
// offsets to seek, so every poll would re-parse the entire conversation (and
// evict the Overview's memo doing it). They are not polled at all.
const lastTs = meta ? meta.lastTs : 0;
const seekable = cursor.current ? cursor.current.mode === 'bytes' : true;
const live = opts.live;
const [hidden, setHidden] = useState(() => (typeof document === 'undefined' ? false : document.hidden));
useEffect(() => {
const onVis = () => setHidden(document.hidden);
document.addEventListener('visibilitychange', onVis);
return () => document.removeEventListener('visibilitychange', onVis);
}, []);
useEffect(() => {
const fresh = !!lastTs && Date.now() - lastTs < FRESH_MS;
// Nothing to follow: the host says the session is not running and the trace
// has not moved in a while. A pane behind another browser tab is the same
// case β€” the reader is not looking, so nobody is waiting for the turn.
if (!seekable || paused || hidden || (live === false && !fresh)) return undefined;
const h = window.setInterval(loadNewer, fresh ? LIVE_MS : IDLE_MS);
return () => window.clearInterval(h);
}, [loadNewer, lastTs, seekable, paused, hidden, live]);
// Coming back from another app has to show what happened while away, and none
// of the loop above survives the trip. The interval is cleared while hidden,
// and the effect that would restore it schedules NOTHING when the host says
// the session stopped and the trace has been quiet β€” so a reader left on a
// finished session shows the turns from before the operator switched away,
// for as long as they keep looking at it. Even when a poll is scheduled, an
// interval's first tick is one whole interval late.
//
// A frozen request makes it worse. iOS Safari suspends a backgrounded tab, so
// a window request that was in flight when the operator left may never settle
// β€” and `loading` is released only in that request's `finally`, so every later
// read returns early against a latch nothing will ever release. That is a
// reader which stays stale even once polling resumes.
//
// So a return reads immediately: abandon whatever was outstanding (bumping the
// generation, which is what makes its response and its `finally` no longer
// ours), release the latch, and ask for the turns written while away.
useEffect(() => {
const resync = () => {
if (document.hidden || cb.current.paused) return;
const outstanding = windowAbort.current;
if (outstanding) {
gen.current += 1;
windowAbort.current = null;
outstanding.abort();
}
loading.current = false;
// The whole-file summary can be the frozen request instead of a window.
// It is single-flight through `summaryStarted`, which nothing else clears,
// so the header would keep its "loaded so far" counts β€” no turn total, no
// user turns, no session cost β€” for the life of this source, while the
// turns below it updated normally on every return.
const outstandingSummary = summaryAbort.current;
if (outstandingSummary) {
summaryAbort.current = null;
summaryStarted.current = false;
outstandingSummary.abort();
setSummaryEpoch((epoch) => epoch + 1);
}
void (cursor.current ? loadNewer() : loadTail());
};
const onVisible = () => { if (!document.hidden) resync(); };
// Restoring a page from the back/forward cache fires no visibilitychange:
// the tab was never hidden, the whole page was frozen and thawed. Safari
// leans on this hard β€” a swipe back, or an app switch that outlived the
// renderer, comes back this way.
const onShow = (e: PageTransitionEvent) => { if (e.persisted) resync(); };
document.addEventListener('visibilitychange', onVisible);
window.addEventListener('pageshow', onShow);
return () => {
document.removeEventListener('visibilitychange', onVisible);
window.removeEventListener('pageshow', onShow);
};
}, [loadNewer, loadTail]);
const atStart = !!cursor.current?.atStart;
const blocked = !!cursor.current?.blocked;
// MEMOIZED, and it has to be: a host that lifts this into its own state β€” the
// Trace pane does, `onHead={setHead}` β€” turns a fresh object per render into
// effect β†’ setState β†’ render β†’ fresh object, a loop that never trips React's
// update-depth guard because it goes through an effect. It just spins: measured
// at 98% of a core with an untouched pane open.
// What the pane knows about the trace: whatever this window could tell us,
// filled in from the summary for everything a window cannot know. That is not
// only the counts β€” a window of a codex rollout cannot count the session's
// encrypted reasoning steps (`note`), and a window of an STS file never sees
// the `{type:'session'}` first line that carries the title, the harness and
// the session id.
const head: TraceHeadInfo | null = useMemo(() => (meta ? {
...meta,
total: summary ? summary.total : null,
userTurns: summary ? summary.userTurns : null,
usage: meta.usage || (summary ? summary.usage : null),
firstTs: meta.firstTs || (summary ? summary.firstTs : 0),
truncated: meta.truncated || !!(summary && summary.truncated),
note: meta.note || (summary ? summary.note : null),
title: meta.title || (summary ? summary.title : ''),
harnessLabel: meta.harnessLabel || (summary ? summary.harnessLabel : ''),
sessionId: meta.sessionId || (summary ? summary.sessionId : null),
model: meta.model || (summary ? summary.model : null),
cwd: meta.cwd || (summary ? summary.cwd : null),
source: meta.source || (summary ? summary.source : null),
sharedBy: meta.sharedBy || (summary ? summary.sharedBy : null),
loaded: turns.current.length,
atStart,
blocked,
} : null), [meta, summary, version, atStart, blocked]); // eslint-disable-line react-hooks/exhaustive-deps
return { turns, head, meta, error, version, atStart, blocked, loadOlder, loadNewer, reload: loadTail };
}