From cb37a7166af2046d5676d86f22ad4f31db1b7b9a Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 29 Jul 2026 22:23:10 -0400 Subject: [PATCH] feat(tui): make session tab switching fast for long transcripts (#39568) --- packages/tui/src/routes/session/index.tsx | 111 +++++++++++++++------- packages/tui/src/routes/session/rows.ts | 19 ++-- 2 files changed, 90 insertions(+), 40 deletions(-) diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index eb09929ad3..07dd461a1f 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -98,6 +98,13 @@ addDefaultParsers(parsers.parsers) // Exclude temporary bottom space when measuring the real transcript height. const NAVIGATION_SLACK_ID = "session-navigation-slack" +// 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. +const TRANSCRIPT_TAIL_ROWS = 40 +const TRANSCRIPT_BACKFILL_CHUNK = 60 +const TRANSCRIPT_BACKFILL_DELAY = 120 + const context = createContext<{ width: number sessionID: string @@ -296,6 +303,49 @@ export function Session() { r.set(route.prompt) } + /** Runs after layout has settled (two frames), unless the transcript was torn down. */ + const afterLayout = (continuation: () => void) => { + requestAnimationFrame(() => { + requestAnimationFrame(() => { + if (!scroll || scroll.isDestroyed) return + continuation() + }) + }) + } + + // 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. + 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(() => { + 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)) + }) + /** Message navigation needs the full transcript mounted before walking or jumping. */ + const ensureAllRows = (continuation: () => void) => { + if (hidden() === 0) return continuation() + setHiddenRows(0) + afterLayout(continuation) + } + createEffect(() => { const current = prompt() if (sent || !current || !synced() || !local.model.ready) return @@ -322,41 +372,36 @@ export function Session() { currentSlack: scroll.getRenderable(NAVIGATION_SLACK_ID)?.height ?? 0, }), ) - requestAnimationFrame(() => { - requestAnimationFrame(() => { - if (scroll.isDestroyed || navigationMessage() !== messageID) return - scroll.scrollTo(top) + afterLayout(() => { + if (navigationMessage() !== messageID) return + scroll.scrollTo(top) + }) + } + + const scrollToMessage = (direction: "next" | "prev", dialog: ReturnType, userOnly = false) => + ensureAllRows(() => { + const target = findMessageBoundary({ + direction, + children: scroll.getChildren(), + messages: messages(), + scrollTop: scroll.scrollTop, + viewportY: scroll.viewport.y, + currentID: navigationMessage(), + userOnly, }) - }) - } - const scrollToMessage = (direction: "next" | "prev", dialog: ReturnType, userOnly = false) => { - const target = findMessageBoundary({ - direction, - children: scroll.getChildren(), - messages: messages(), - scrollTop: scroll.scrollTop, - viewportY: scroll.viewport.y, - currentID: navigationMessage(), - userOnly, - }) - - if (!target) { + if (target) alignMessage(target.id, target.top) dialog.clear() - return - } + }) - alignMessage(target.id, target.top) - dialog.clear() - } - - const jumpToMessage = (messageID: string) => { - const child = scroll.getRenderable(messageID) - if (!child) return - const y = scroll.scrollTop + child.y - scroll.viewport.y - const message = data.session.message.get(route.sessionID, messageID) - alignMessage(messageID, Math.max(0, y - (message?.type === "assistant" ? 1 : 0))) - } + const jumpToMessage = (messageID: string) => + ensureAllRows(() => { + const child = scroll.getRenderable(messageID) + if (!child) return + const y = scroll.scrollTop + child.y - scroll.viewport.y + const message = data.session.message.get(route.sessionID, messageID) + alignMessage(messageID, Math.max(0, y - (message?.type === "assistant" ? 1 : 0))) + }) function toBottom() { clearMessageNavigation() @@ -932,12 +977,12 @@ export function Session() { flexGrow={1} scrollAcceleration={scrollAcceleration()} > - + {(row, index) => ( data.session.message.get(route.sessionID, messageID)} - boundaryID={boundaries()[index()]} + boundaryID={boundaries()[index() + hidden()]} /> )} diff --git a/packages/tui/src/routes/session/rows.ts b/packages/tui/src/routes/session/rows.ts index 1f8e814793..928a9fe01a 100644 --- a/packages/tui/src/routes/session/rows.ts +++ b/packages/tui/src/routes/session/rows.ts @@ -97,11 +97,16 @@ export function createSessionRows(sessionID: Accessor) { }), ) - // Re-reduce when the revert boundary changes (stage/clear/commit). + // Re-reduce when the revert boundary changes (stage/clear/commit). These reactions defer + // their first run: the mount effect above has already reduced the same state. createEffect( - on(revertBoundary, () => { - setRows(reconcile(reduce())) - }), + on( + revertBoundary, + () => { + setRows(reconcile(reduce())) + }, + { defer: true }, + ), ) createEffect( @@ -112,6 +117,7 @@ export function createSessionRows(sessionID: Accessor) { .filter((item) => item.type === "compaction") .map((item) => item.id), () => setRows(reconcile(reduce())), + { defer: true }, ), ) @@ -137,12 +143,11 @@ export function createSessionRows(sessionID: Accessor) { : [], ), () => setRows(reconcile(reduce())), + { defer: true }, ), ) - createEffect( - on(turnTokens, () => setRows(reconcile(reduce()))), - ) + createEffect(on(turnTokens, () => setRows(reconcile(reduce())), { defer: true })) const appendMessage = (messageID: string) => setRows(