From e5d79ae187fe4ab34f628c3db80043d5d2168a94 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 13 Aug 2026 15:52:39 -0400 Subject: [PATCH] fix(tui): load transcript history on demand (#42388) --- packages/tui/src/routes/session/index.tsx | 112 +++++++++------------- 1 file changed, 47 insertions(+), 65 deletions(-) diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 397a46cf035..886a5736e21 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -115,12 +115,9 @@ addDefaultParsers(parsers.parsers) const NAVIGATION_SLACK_ID = "session-navigation-slack" const BACKGROUND_TOOL_HINT_DELAY = 1_000 -// Tail-first transcript mounting: rows mounted with the session, then backfill cadence. -// The tail comfortably overfills a tall viewport; backfill drains a 200-message transcript -// in a few hundred milliseconds without a perceptible pause. +// The tail comfortably overfills a tall viewport; older rows mount as the reader approaches them. const TRANSCRIPT_TAIL_ROWS = 40 const TRANSCRIPT_BACKFILL_CHUNK = 60 -const TRANSCRIPT_BACKFILL_DELAY = 120 type PendingAction = "steer" | "queue" | "cancel" const context = createContext<{ @@ -362,32 +359,33 @@ export function Session() { }) } - // Tail-first transcript mounting: only the newest rows mount when the session opens, and the - // rest backfill in chunks shortly after, so switching to a long session costs the visible tail - // instead of the whole transcript. Until backfill pins the count, the hidden span derives from - // the row count, so it needs no effect ordering; the clamp keeps at least a tail visible when a - // re-reduce shrinks the transcript. Streaming appends land at the end of the visible slice. + // Tail-first transcript mounting: only the newest rows mount when the session opens. Older rows + // mount on demand near the top, keeping inactive tabs cheap to tear down. Until the first chunk + // pins the count, the hidden span derives from the row count, so streaming appends remain visible. const [hiddenRows, setHiddenRows] = createSignal() const hidden = createMemo(() => Math.max(0, Math.min(hiddenRows() ?? Infinity, rows.length - TRANSCRIPT_TAIL_ROWS))) const visibleRows = createMemo(() => (hidden() === 0 ? rows : rows.slice(hidden()))) - createEffect(() => { + let revealingOlderRows = false + const revealOlderRows = (scrollBy = 0) => { const current = hidden() - if (current === 0) return - // Until the first chunk pins hiddenRows, appends change hidden() and reset this timer, so - // backfill waits for a pause in streaming before starting. Once pinned, it drains on a fixed - // cadence undisturbed by appends. - const timer = setTimeout(() => { - const before = scroll && !scroll.isDestroyed ? scroll.scrollHeight : undefined - const viewportBottom = before === undefined ? 0 : scroll.scrollTop + scroll.viewport.height - setHiddenRows(Math.max(0, current - TRANSCRIPT_BACKFILL_CHUNK)) - if (before === undefined) return - // Sticky scroll holds bottom-anchored readers through the mount; compensation is only for - // readers who have scrolled up. - if (viewportBottom >= before - 1) return - afterLayout(() => scroll.scrollBy(scroll.scrollHeight - before)) - }, TRANSCRIPT_BACKFILL_DELAY) - onCleanup(() => clearTimeout(timer)) - }) + if ( + revealingOlderRows || + current === 0 || + !scroll || + scroll.isDestroyed || + scroll.scrollTop > scroll.viewport.height + ) + return false + revealingOlderRows = true + const before = scroll.scrollHeight + setHiddenRows(Math.max(0, current - TRANSCRIPT_BACKFILL_CHUNK)) + afterLayout(() => { + revealingOlderRows = false + scroll.scrollBy(scroll.scrollHeight - before + scrollBy) + updateAwayFromBottom() + }) + return true + } /** Message navigation needs the full transcript mounted before walking or jumping. */ const ensureAllRows = (continuation: () => void) => { if (hidden() === 0) return continuation() @@ -536,78 +534,57 @@ export function Session() { }, 50) } + function moveTranscript(delta: number) { + clearMessageNavigation() + if (delta >= 0 || !revealOlderRows(delta)) { + scroll.scrollBy(delta) + updateAwayFromBottom() + } + dialog.clear() + } + const globalCommands = [ { id: "session.page.up", title: "Page up", group: "Session", palette: undefined, - run: () => { - clearMessageNavigation() - scroll.scrollBy(-scroll.height / 2) - updateAwayFromBottom() - dialog.clear() - }, + run: () => moveTranscript(-scroll.height / 2), }, { id: "session.page.down", title: "Page down", group: "Session", palette: undefined, - run: () => { - clearMessageNavigation() - scroll.scrollBy(scroll.height / 2) - updateAwayFromBottom() - dialog.clear() - }, + run: () => moveTranscript(scroll.height / 2), }, { id: "session.line.up", title: "Line up", group: "Session", palette: undefined, - run: () => { - clearMessageNavigation() - scroll.scrollBy(-1) - updateAwayFromBottom() - dialog.clear() - }, + run: () => moveTranscript(-1), }, { id: "session.line.down", title: "Line down", group: "Session", palette: undefined, - run: () => { - clearMessageNavigation() - scroll.scrollBy(1) - updateAwayFromBottom() - dialog.clear() - }, + run: () => moveTranscript(1), }, { id: "session.half.page.up", title: "Half page up", group: "Session", palette: undefined, - run: () => { - clearMessageNavigation() - scroll.scrollBy(-scroll.height / 4) - updateAwayFromBottom() - dialog.clear() - }, + run: () => moveTranscript(-scroll.height / 4), }, { id: "session.half.page.down", title: "Half page down", group: "Session", palette: undefined, - run: () => { - clearMessageNavigation() - scroll.scrollBy(scroll.height / 4) - updateAwayFromBottom() - dialog.clear() - }, + run: () => moveTranscript(scroll.height / 4), }, ] @@ -619,8 +596,10 @@ export function Session() { palette: undefined, run: () => { clearMessageNavigation() - scroll.scrollTo(0) - updateAwayFromBottom() + ensureAllRows(() => { + scroll.scrollTo(0) + updateAwayFromBottom() + }) dialog.clear() }, }, @@ -1102,7 +1081,10 @@ export function Session() { stickyStart="bottom" flexGrow={1} scrollAcceleration={scrollAcceleration()} - onMouseScroll={updateAwayFromBottom} + onMouseScroll={(event) => { + if (event.scroll?.direction === "up" && revealOlderRows()) return + updateAwayFromBottom() + }} > {(row, index) => (