Spaces:
Running
Reader mode pages back too — which is what was asked for
Browse filesThe 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 +65 -274
- web/src/components/conversation/ConversationView.tsx +79 -54
- web/src/conversation.css +4 -0
- web/src/lib/traceWindows.ts +273 -0
|
@@ -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,
|
|
|
|
| 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 |
-
|
| 212 |
-
|
| 213 |
-
|
| 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.
|
| 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 |
-
|
| 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 |
-
//
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 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 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
//
|
| 507 |
-
//
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
const
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
return
|
| 514 |
-
}, [
|
| 515 |
-
|
| 516 |
-
//
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
//
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
}
|
| 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,
|
| 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 (!
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 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 && !
|
| 735 |
|
| 736 |
-
{!error &&
|
| 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 &&
|
| 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
|
|
@@ -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 |
-
//
|
| 9 |
-
//
|
| 10 |
-
//
|
| 11 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
| 13 |
import * as api from '../../api';
|
| 14 |
-
import type {
|
|
|
|
| 15 |
import type { Session } from '../../types';
|
| 16 |
import { fmtTok, splitExchanges } from './exchanges';
|
| 17 |
import ExchangeView from './Exchange';
|
| 18 |
import { SendGlyph } from '../icons';
|
| 19 |
|
| 20 |
-
|
| 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
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 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 |
-
|
| 73 |
-
//
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
//
|
| 80 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
|
| 82 |
-
const turns:
|
| 83 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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
|
| 179 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
|
| 181 |
-
if (!
|
| 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 |
-
{
|
| 189 |
-
<span className="cxv-count" title={`${fmtNum(
|
| 190 |
{fmtNum(exchanges.length)} turn{exchanges.length === 1 ? '' : 's'}
|
|
|
|
| 191 |
</span>
|
| 192 |
-
{
|
| 193 |
-
<span className="cxv-tok" title={
|
| 194 |
-
{fmtUsage(
|
| 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 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
<ExchangeView
|
| 229 |
x={x}
|
| 230 |
n={exchanges.indexOf(x) + 1}
|
| 231 |
total={exchanges.length}
|
| 232 |
q={q || undefined}
|
| 233 |
-
baseModel={
|
| 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={
|
| 273 |
-
{
|
| 274 |
-
{
|
| 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 && (
|
|
@@ -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);
|
|
@@ -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 |
+
}
|