Spaces:
Running
Running
Speed up Reader first paint
Browse files- server/test/trace-window.test.mjs +13 -0
- web/package.json +1 -1
- web/src/App.tsx +24 -2
- web/src/api.ts +7 -4
- web/src/components/FilesPane.tsx +1 -1
- web/src/components/TerminalPane.tsx +9 -3
- web/src/components/TracePane.tsx +1 -1
- web/src/components/conversation/ConversationView.tsx +22 -4
- web/src/lib/traceWindows.ts +24 -12
- web/test/traceWindows.test.mjs +124 -0
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 |
-
|
| 124 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 455 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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',
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
/**
|
| 25 |
-
*
|
| 26 |
-
*
|
| 27 |
-
*
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 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');
|