Skip to content

Commit cb37a71

Browse files
authored
feat(tui): make session tab switching fast for long transcripts (#39568)
1 parent 488445a commit cb37a71

2 files changed

Lines changed: 88 additions & 38 deletions

File tree

packages/tui/src/routes/session/index.tsx

Lines changed: 76 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,13 @@ addDefaultParsers(parsers.parsers)
9898
// Exclude temporary bottom space when measuring the real transcript height.
9999
const NAVIGATION_SLACK_ID = "session-navigation-slack"
100100

101+
// Tail-first transcript mounting: rows mounted with the session, then backfill cadence.
102+
// The tail comfortably overfills a tall viewport; backfill drains a 200-message transcript
103+
// in a few hundred milliseconds without a perceptible pause.
104+
const TRANSCRIPT_TAIL_ROWS = 40
105+
const TRANSCRIPT_BACKFILL_CHUNK = 60
106+
const TRANSCRIPT_BACKFILL_DELAY = 120
107+
101108
const context = createContext<{
102109
width: number
103110
sessionID: string
@@ -296,6 +303,49 @@ export function Session() {
296303
r.set(route.prompt)
297304
}
298305

306+
/** Runs after layout has settled (two frames), unless the transcript was torn down. */
307+
const afterLayout = (continuation: () => void) => {
308+
requestAnimationFrame(() => {
309+
requestAnimationFrame(() => {
310+
if (!scroll || scroll.isDestroyed) return
311+
continuation()
312+
})
313+
})
314+
}
315+
316+
// Tail-first transcript mounting: only the newest rows mount when the session opens, and the
317+
// rest backfill in chunks shortly after, so switching to a long session costs the visible tail
318+
// instead of the whole transcript. Until backfill pins the count, the hidden span derives from
319+
// the row count, so it needs no effect ordering; the clamp keeps at least a tail visible when a
320+
// re-reduce shrinks the transcript. Streaming appends land at the end of the visible slice.
321+
const [hiddenRows, setHiddenRows] = createSignal<number>()
322+
const hidden = createMemo(() => Math.max(0, Math.min(hiddenRows() ?? Infinity, rows.length - TRANSCRIPT_TAIL_ROWS)))
323+
const visibleRows = createMemo(() => (hidden() === 0 ? rows : rows.slice(hidden())))
324+
createEffect(() => {
325+
const current = hidden()
326+
if (current === 0) return
327+
// Until the first chunk pins hiddenRows, appends change hidden() and reset this timer, so
328+
// backfill waits for a pause in streaming before starting. Once pinned, it drains on a fixed
329+
// cadence undisturbed by appends.
330+
const timer = setTimeout(() => {
331+
const before = scroll && !scroll.isDestroyed ? scroll.scrollHeight : undefined
332+
const viewportBottom = before === undefined ? 0 : scroll.scrollTop + scroll.viewport.height
333+
setHiddenRows(Math.max(0, current - TRANSCRIPT_BACKFILL_CHUNK))
334+
if (before === undefined) return
335+
// Sticky scroll holds bottom-anchored readers through the mount; compensation is only for
336+
// readers who have scrolled up.
337+
if (viewportBottom >= before - 1) return
338+
afterLayout(() => scroll.scrollBy(scroll.scrollHeight - before))
339+
}, TRANSCRIPT_BACKFILL_DELAY)
340+
onCleanup(() => clearTimeout(timer))
341+
})
342+
/** Message navigation needs the full transcript mounted before walking or jumping. */
343+
const ensureAllRows = (continuation: () => void) => {
344+
if (hidden() === 0) return continuation()
345+
setHiddenRows(0)
346+
afterLayout(continuation)
347+
}
348+
299349
createEffect(() => {
300350
const current = prompt()
301351
if (sent || !current || !synced() || !local.model.ready) return
@@ -322,41 +372,36 @@ export function Session() {
322372
currentSlack: scroll.getRenderable(NAVIGATION_SLACK_ID)?.height ?? 0,
323373
}),
324374
)
325-
requestAnimationFrame(() => {
326-
requestAnimationFrame(() => {
327-
if (scroll.isDestroyed || navigationMessage() !== messageID) return
328-
scroll.scrollTo(top)
329-
})
375+
afterLayout(() => {
376+
if (navigationMessage() !== messageID) return
377+
scroll.scrollTo(top)
330378
})
331379
}
332380

333-
const scrollToMessage = (direction: "next" | "prev", dialog: ReturnType<typeof useDialog>, userOnly = false) => {
334-
const target = findMessageBoundary({
335-
direction,
336-
children: scroll.getChildren(),
337-
messages: messages(),
338-
scrollTop: scroll.scrollTop,
339-
viewportY: scroll.viewport.y,
340-
currentID: navigationMessage(),
341-
userOnly,
342-
})
381+
const scrollToMessage = (direction: "next" | "prev", dialog: ReturnType<typeof useDialog>, userOnly = false) =>
382+
ensureAllRows(() => {
383+
const target = findMessageBoundary({
384+
direction,
385+
children: scroll.getChildren(),
386+
messages: messages(),
387+
scrollTop: scroll.scrollTop,
388+
viewportY: scroll.viewport.y,
389+
currentID: navigationMessage(),
390+
userOnly,
391+
})
343392

344-
if (!target) {
393+
if (target) alignMessage(target.id, target.top)
345394
dialog.clear()
346-
return
347-
}
348-
349-
alignMessage(target.id, target.top)
350-
dialog.clear()
351-
}
395+
})
352396

353-
const jumpToMessage = (messageID: string) => {
354-
const child = scroll.getRenderable(messageID)
355-
if (!child) return
356-
const y = scroll.scrollTop + child.y - scroll.viewport.y
357-
const message = data.session.message.get(route.sessionID, messageID)
358-
alignMessage(messageID, Math.max(0, y - (message?.type === "assistant" ? 1 : 0)))
359-
}
397+
const jumpToMessage = (messageID: string) =>
398+
ensureAllRows(() => {
399+
const child = scroll.getRenderable(messageID)
400+
if (!child) return
401+
const y = scroll.scrollTop + child.y - scroll.viewport.y
402+
const message = data.session.message.get(route.sessionID, messageID)
403+
alignMessage(messageID, Math.max(0, y - (message?.type === "assistant" ? 1 : 0)))
404+
})
360405

361406
function toBottom() {
362407
clearMessageNavigation()
@@ -932,12 +977,12 @@ export function Session() {
932977
flexGrow={1}
933978
scrollAcceleration={scrollAcceleration()}
934979
>
935-
<For each={rows}>
980+
<For each={visibleRows()}>
936981
{(row, index) => (
937982
<SessionRowView
938983
row={row}
939984
message={(messageID) => data.session.message.get(route.sessionID, messageID)}
940-
boundaryID={boundaries()[index()]}
985+
boundaryID={boundaries()[index() + hidden()]}
941986
/>
942987
)}
943988
</For>

packages/tui/src/routes/session/rows.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -97,11 +97,16 @@ export function createSessionRows(sessionID: Accessor<string>) {
9797
}),
9898
)
9999

100-
// Re-reduce when the revert boundary changes (stage/clear/commit).
100+
// Re-reduce when the revert boundary changes (stage/clear/commit). These reactions defer
101+
// their first run: the mount effect above has already reduced the same state.
101102
createEffect(
102-
on(revertBoundary, () => {
103-
setRows(reconcile(reduce()))
104-
}),
103+
on(
104+
revertBoundary,
105+
() => {
106+
setRows(reconcile(reduce()))
107+
},
108+
{ defer: true },
109+
),
105110
)
106111

107112
createEffect(
@@ -112,6 +117,7 @@ export function createSessionRows(sessionID: Accessor<string>) {
112117
.filter((item) => item.type === "compaction")
113118
.map((item) => item.id),
114119
() => setRows(reconcile(reduce())),
120+
{ defer: true },
115121
),
116122
)
117123

@@ -137,12 +143,11 @@ export function createSessionRows(sessionID: Accessor<string>) {
137143
: [],
138144
),
139145
() => setRows(reconcile(reduce())),
146+
{ defer: true },
140147
),
141148
)
142149

143-
createEffect(
144-
on(turnTokens, () => setRows(reconcile(reduce()))),
145-
)
150+
createEffect(on(turnTokens, () => setRows(reconcile(reduce())), { defer: true }))
146151

147152
const appendMessage = (messageID: string) =>
148153
setRows(

0 commit comments

Comments
 (0)