From 71caa7d40681a5272055b31dffe17b86b09ef3b6 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 13 Aug 2026 22:04:40 -0400 Subject: [PATCH] fix(tui): isolate tab scroll state (#42456) --- packages/tui/src/context/session-tabs.tsx | 25 ++-- packages/tui/src/routes/session/index.tsx | 142 ++++++++++++++++++---- packages/tui/src/routes/session/rows.ts | 3 +- 3 files changed, 138 insertions(+), 32 deletions(-) diff --git a/packages/tui/src/context/session-tabs.tsx b/packages/tui/src/context/session-tabs.tsx index 0708c6d7ec0..c55b7c831d1 100644 --- a/packages/tui/src/context/session-tabs.tsx +++ b/packages/tui/src/context/session-tabs.tsx @@ -38,6 +38,11 @@ type PersistedState = { cwd: Record } +type ScrollAnchor = { + messageID: string + screenY: number +} + const empty = (): TabsState => ({ tabs: [], unread: {} }) // Deliberately after connect settles: the visible session's mount syncs win the first slots. @@ -71,7 +76,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp let history: SessionTabHistory = { entries: [], index: -1 } // User-closed tabs eligible for reopening; in-memory like history, deleted sessions pruned. let closedTabs: ClosedSessionTab[] = [] - const scrollPositions = new Map() + const scrollAnchors = new Map() const onFocus = () => setFocused(true) const onBlur = () => setFocused(false) @@ -84,7 +89,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp createEffect(() => { if (config.experimental?.tab_scroll === true) return - scrollPositions.clear() + scrollAnchors.clear() }) function state() { @@ -260,7 +265,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp function remove(sessionID: string, navigate: boolean) { const target = root(sessionID) - scrollPositions.delete(target) + scrollAnchors.delete(target) const closed = closeSessionTab(state().tabs, target) const selected = navigate && current() === target if (closed.tabs === state().tabs && !selected) return @@ -292,18 +297,20 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp }, current, status, - scrollPosition(sessionID: string) { + scrollAnchor(sessionID: string) { const target = root(sessionID) if (!state().tabs.some((tab) => tab.sessionID === target)) return - return scrollPositions.get(target) + return scrollAnchors.get(target) }, - setScrollPosition(sessionID: string, position: number | undefined) { + setScrollAnchor(sessionID: string, anchor: ScrollAnchor | undefined) { const target = root(sessionID) - if (position === undefined || !state().tabs.some((tab) => tab.sessionID === target)) { - scrollPositions.delete(target) + if (anchor === undefined || !state().tabs.some((tab) => tab.sessionID === target)) { + scrollAnchors.delete(target) return } - scrollPositions.set(target, position) + const current = scrollAnchors.get(target) + if (current?.messageID === anchor.messageID && current.screenY === anchor.screenY) return + scrollAnchors.set(target, anchor) }, select(sessionID: string) { if (!enabled()) return diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 46628c15e1b..e90da6f0830 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -149,6 +149,7 @@ export function Session() { await writeFile(file, content) } const route = useRouteData("session") + const sessionID = route.sessionID const { navigate } = useRoute() const data = useData() const local = useLocal() @@ -266,13 +267,21 @@ export function Session() { }) }) const editor = useEditorContext() - const rows = createSessionRows(() => route.sessionID) + const [rowsSynced, setRowsSynced] = createSignal(false) + const rows = createSessionRows( + () => route.sessionID, + (id) => { + if (id === sessionID) setRowsSynced(true) + }, + ) const boundaries = createMemo(() => messageBoundaryIDs(rows, messages())) + const boundaryIDs = createMemo(() => new Set(boundaries().filter((id) => id !== undefined))) const [navigationMessage, setNavigationMessage] = createSignal() const [navigationSlack, setNavigationSlack] = createSignal(0) const [synced, setSynced] = createSignal(false) const sessionTabs = useSessionTabs() const [awayFromBottom, setAwayFromBottom] = createSignal(false) + const [latestHovered, setLatestHovered] = createSignal(false) const clearMessageNavigation = () => { setNavigationSlack(0) @@ -318,7 +327,6 @@ export function Session() { return } editor.reconnect(info.location.directory) - if (route.sessionID === sessionID && scroll) restoreScrollPosition(sessionID) setSynced(true) })().catch((error) => { if (route.sessionID !== sessionID) return @@ -333,13 +341,18 @@ export function Session() { let seeded = false let sent = false + let restored = false let scroll: ScrollBoxRenderable + createEffect(() => { + if (restored || !synced() || !rowsSynced() || !scroll || scroll.isDestroyed) return + restored = true + restoreScrollPosition() + }) + let awayTimer: ReturnType | undefined onCleanup(() => { + if (awayTimer) clearTimeout(awayTimer) if (!scroll || scroll.isDestroyed) return - sessionTabs.setScrollPosition( - route.sessionID, - config.experimental?.tab_scroll === true && isAwayFromBottom() ? scroll.scrollTop : undefined, - ) + saveScrollAnchor() }) const [prompt, setPrompt] = createSignal() const bind = (r: PromptRef | undefined) => { @@ -364,8 +377,10 @@ export function Session() { // 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 [visibleRowsEnd, setVisibleRowsEnd] = 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()))) + const visibleEnd = createMemo(() => Math.max(hidden(), Math.min(visibleRowsEnd() ?? rows.length, rows.length))) + const visibleRows = createMemo(() => rows.slice(hidden(), visibleEnd())) let revealingOlderRows = false const revealOlderRows = (scrollBy = 0) => { const current = hidden() @@ -387,36 +402,102 @@ export function Session() { }) return true } + let revealingNewerRows = false + const revealNewerRows = (scrollBy = 0) => { + const current = visibleEnd() + if ( + revealingNewerRows || + current === rows.length || + !scroll || + scroll.isDestroyed || + scroll.scrollTop + scroll.viewport.height < scroll.scrollHeight - scroll.viewport.height + ) + return false + revealingNewerRows = true + const next = Math.min(rows.length, current + TRANSCRIPT_BACKFILL_CHUNK) + setVisibleRowsEnd(next === rows.length ? undefined : next) + afterLayout(() => { + revealingNewerRows = false + scroll.scrollBy(scrollBy) + updateAwayFromBottom() + }) + return true + } /** Message navigation needs the full transcript mounted before walking or jumping. */ const ensureAllRows = (continuation: () => void) => { - if (hidden() === 0) return continuation() + if (hidden() === 0 && visibleEnd() === rows.length) return continuation() setHiddenRows(0) + setVisibleRowsEnd(undefined) afterLayout(continuation) } function isAwayFromBottom() { + if (visibleEnd() < rows.length) return true return scroll.scrollTop < Math.max(0, scroll.scrollHeight - scroll.viewport.height) - 1 } function updateAwayFromBottom() { if (config.experimental?.tab_scroll !== true) return - setTimeout(() => { + if (awayTimer) clearTimeout(awayTimer) + awayTimer = setTimeout(() => { + awayTimer = undefined if (!scroll || scroll.isDestroyed) return const away = isAwayFromBottom() setAwayFromBottom(away) - if (!away) sessionTabs.setScrollPosition(route.sessionID, undefined) + saveScrollAnchor() }) } - function restoreScrollPosition(sessionID: string) { - const position = config.experimental?.tab_scroll === true ? sessionTabs.scrollPosition(sessionID) : undefined - if (position === undefined) { + function saveScrollAnchor() { + if (config.experimental?.tab_scroll !== true || !isAwayFromBottom()) { + sessionTabs.setScrollAnchor(sessionID, undefined) + return + } + let first: { messageID: string; screenY: number } | undefined + let anchor: { messageID: string; screenY: number } | undefined + for (const child of scroll.getChildren()) { + if (!child.id || !boundaryIDs().has(child.id)) continue + const item = { messageID: child.id, screenY: child.y - scroll.viewport.y } + first ??= item + if (item.screenY <= 0) anchor = item + } + anchor ??= first + if (anchor) sessionTabs.setScrollAnchor(sessionID, anchor) + else sessionTabs.setScrollAnchor(sessionID, undefined) + } + function restoreScrollPosition() { + const anchor = config.experimental?.tab_scroll === true ? sessionTabs.scrollAnchor(sessionID) : undefined + const index = anchor ? boundaries().indexOf(anchor.messageID) : -1 + if (!anchor || index === -1) { scroll.scrollTo(scroll.scrollHeight) setAwayFromBottom(false) return } - ensureAllRows(() => { - scroll.scrollTo(position) - updateAwayFromBottom() - }) + setHiddenRows(Math.max(0, index - TRANSCRIPT_BACKFILL_CHUNK)) + const end = Math.min(rows.length, index + TRANSCRIPT_BACKFILL_CHUNK) + setVisibleRowsEnd(end === rows.length ? undefined : end) + scroll.stickyScroll = false + const restore = () => + afterLayout(() => { + const boundary = scroll.getRenderable(anchor.messageID) + if (!boundary) { + sessionTabs.setScrollAnchor(sessionID, undefined) + scroll.stickyScroll = true + scroll.scrollTo(scroll.scrollHeight) + setAwayFromBottom(false) + return + } + const contentY = scroll.scrollTop + boundary.y - scroll.viewport.y + const target = contentY - anchor.screenY + const maximum = Math.max(0, scroll.scrollHeight - scroll.viewport.height) + if (target > maximum && visibleEnd() < rows.length) { + const next = Math.min(rows.length, visibleEnd() + TRANSCRIPT_BACKFILL_CHUNK) + setVisibleRowsEnd(next === rows.length ? undefined : next) + restore() + return + } + scroll.scrollTo(target) + updateAwayFromBottom() + }) + restore() } createEffect(() => { @@ -527,20 +608,31 @@ export function Session() { function toBottom() { clearMessageNavigation() + if (awayTimer) clearTimeout(awayTimer) + awayTimer = undefined setAwayFromBottom(false) - sessionTabs.setScrollPosition(route.sessionID, undefined) + sessionTabs.setScrollAnchor(route.sessionID, undefined) + setHiddenRows(undefined) + setVisibleRowsEnd(undefined) setTimeout(() => { if (!scroll || scroll.isDestroyed) return + scroll.stickyScroll = true scroll.scrollTo(scroll.scrollHeight) }, 50) } function moveTranscript(delta: number) { clearMessageNavigation() - if (delta >= 0 || !revealOlderRows(delta)) { - scroll.scrollBy(delta) - updateAwayFromBottom() + if (delta < 0 && revealOlderRows(delta)) { + dialog.clear() + return } + if (delta > 0 && revealNewerRows(delta)) { + dialog.clear() + return + } + scroll.scrollBy(delta) + updateAwayFromBottom() dialog.clear() } @@ -1084,6 +1176,7 @@ export function Session() { scrollAcceleration={scrollAcceleration()} onMouseScroll={(event) => { if (event.scroll?.direction === "up" && revealOlderRows()) return + if (event.scroll?.direction === "down" && revealNewerRows()) return updateAwayFromBottom() }} > @@ -1110,7 +1203,12 @@ export function Session() { - + setLatestHovered(true)} + onMouseOut={() => setLatestHovered(false)} + onMouseUp={toBottom} + > Latest ↓ diff --git a/packages/tui/src/routes/session/rows.ts b/packages/tui/src/routes/session/rows.ts index cd04ba74122..e1e0efe78a2 100644 --- a/packages/tui/src/routes/session/rows.ts +++ b/packages/tui/src/routes/session/rows.ts @@ -35,7 +35,7 @@ export type SessionRow = | { type: "assistant-footer"; messageID: string } | { type: "turn-usage"; messageIDs: string[]; previousCache?: CacheUsage } -export function createSessionRows(sessionID: Accessor) { +export function createSessionRows(sessionID: Accessor, onSynced?: (sessionID: string) => void) { const data = useData() const client = useClient() const config = useConfig() @@ -95,6 +95,7 @@ export function createSessionRows(sessionID: Accessor) { () => { if (sessionID() !== id) return setRows(reconcile(reduce())) + onSynced?.(id) }, () => undefined, )