fix(tui): isolate tab scroll state (#42456)

This commit is contained in:
Kit Langton 2026-08-13 22:04:40 -04:00 committed by GitHub
parent 82b96eb495
commit 71caa7d406
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 138 additions and 32 deletions

View file

@ -38,6 +38,11 @@ type PersistedState = {
cwd: Record<string, TabsState>
}
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<string, number>()
const scrollAnchors = new Map<string, ScrollAnchor>()
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

View file

@ -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<string>()
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<typeof setTimeout> | 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<PromptRef>()
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<number>()
const [visibleRowsEnd, setVisibleRowsEnd] = createSignal<number>()
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() {
</box>
<box height={1} flexShrink={0} flexDirection="row" justifyContent="flex-end">
<Show when={config.experimental?.tab_scroll === true && awayFromBottom()}>
<text fg={theme.text.subdued} onMouseUp={toBottom}>
<text
fg={latestHovered() ? theme.text.default : theme.text.subdued}
onMouseOver={() => setLatestHovered(true)}
onMouseOut={() => setLatestHovered(false)}
onMouseUp={toBottom}
>
Latest
</text>
</Show>

View file

@ -35,7 +35,7 @@ export type SessionRow =
| { type: "assistant-footer"; messageID: string }
| { type: "turn-usage"; messageIDs: string[]; previousCache?: CacheUsage }
export function createSessionRows(sessionID: Accessor<string>) {
export function createSessionRows(sessionID: Accessor<string>, onSynced?: (sessionID: string) => void) {
const data = useData()
const client = useClient()
const config = useConfig()
@ -95,6 +95,7 @@ export function createSessionRows(sessionID: Accessor<string>) {
() => {
if (sessionID() !== id) return
setRows(reconcile(reduce()))
onSynced?.(id)
},
() => undefined,
)