tui: preserve transcript window while scrolling (#44679)

This commit is contained in:
Simon Klee
2026-08-24 13:30:31 +02:00
committed by GitHub
parent 2e1d7c84ab
commit ea85118516
+37 -10
View File
@@ -282,11 +282,13 @@ export function Session(props: { verticalTabsWidth: number }) {
const sessionTabs = useSessionTabs()
const [awayFromBottom, setAwayFromBottom] = createSignal(false)
const [latestHovered, setLatestHovered] = createSignal(false)
let ensureAllRowsPending: (() => void)[] | undefined
createEffect(() => {
if (!awayFromBottom()) setLatestHovered(false)
})
const clearMessageNavigation = () => {
ensureAllRowsPending?.splice(0)
setNavigationSlack(0)
setNavigationMessage(undefined)
}
@@ -295,7 +297,9 @@ export function Session(props: { verticalTabsWidth: number }) {
on(
() => [dimensions().width, dimensions().height, props.verticalTabsWidth] as const,
(_, previous) => {
if (previous) clearMessageNavigation()
if (!previous) return
clearMessageNavigation()
if (scroll && !scroll.isDestroyed) updateAwayFromBottom()
},
),
)
@@ -355,6 +359,7 @@ export function Session(props: { verticalTabsWidth: number }) {
onCleanup(() => {
if (awayTimer) clearTimeout(awayTimer)
if (!scroll || scroll.isDestroyed) return
scroll.verticalScrollBar.off("change", updateAwayFromBottom)
saveScrollAnchor()
})
const [prompt, setPrompt] = createSignal<PromptRef>()
@@ -377,8 +382,8 @@ export function Session(props: { verticalTabsWidth: number }) {
}
// 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.
// mount on demand near the top, keeping inactive tabs cheap to tear down. While the reader stays
// at the bottom the hidden span follows appends; leaving the bottom pins it to preserve the viewport.
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)))
@@ -403,11 +408,12 @@ export function Session(props: { verticalTabsWidth: number }) {
if (current === 0) return prependHistory(scrollBy)
revealingOlderRows = true
const before = scroll.scrollHeight
scroll.stickyScroll = false
setHiddenRows(Math.max(0, current - TRANSCRIPT_BACKFILL_CHUNK))
afterLayout(() => {
revealingOlderRows = false
scroll.scrollBy(scroll.scrollHeight - before + scrollBy)
updateAwayFromBottom()
scroll.stickyScroll = !navigationMessage()
revealingOlderRows = false
})
return true
}
@@ -434,23 +440,40 @@ export function Session(props: { verticalTabsWidth: number }) {
}
/** Message navigation needs the full transcript mounted before walking or jumping. */
const ensureAllRows = (continuation: () => void) => {
if (hidden() === 0 && visibleEnd() === rows.length) return continuation()
if (!ensureAllRowsPending && hidden() === 0 && visibleEnd() === rows.length) return continuation()
if (ensureAllRowsPending) {
ensureAllRowsPending.push(continuation)
return
}
const pending = [continuation]
ensureAllRowsPending = pending
setHiddenRows(0)
setVisibleRowsEnd(undefined)
afterLayout(continuation)
afterLayout(() => {
if (ensureAllRowsPending === pending) ensureAllRowsPending = undefined
pending.forEach((continuation) => continuation())
updateAwayFromBottom()
})
}
function isAwayFromBottom() {
if (revealingOlderRows || revealingNewerRows || ensureAllRowsPending || navigationMessage()) return true
if (visibleEnd() < rows.length) return true
return scroll.scrollTop < Math.max(0, scroll.scrollHeight - scroll.viewport.height) - 1
}
function updateAwayFromBottom() {
const preserveWindow = revealingOlderRows || revealingNewerRows || !!ensureAllRowsPending
if (isAwayFromBottom()) setHiddenRows((current) => current ?? hidden())
if (awayTimer) clearTimeout(awayTimer)
awayTimer = setTimeout(() => {
awayTimer = undefined
if (!scroll || scroll.isDestroyed) return
const away = isAwayFromBottom()
const away = preserveWindow || isAwayFromBottom()
setAwayFromBottom(away)
if (!away) {
if (!renderer.getSelection()) setHiddenRows(undefined)
scroll.stickyScroll = true
}
saveScrollAnchor()
})
}
@@ -575,6 +598,7 @@ export function Session(props: { verticalTabsWidth: number }) {
const alignMessage = (messageID: string, top: number) => {
scroll.stickyScroll = false
setNavigationMessage(messageID)
updateAwayFromBottom()
setNavigationSlack(
messageNavigationSlack({
top,
@@ -624,6 +648,7 @@ export function Session(props: { verticalTabsWidth: number }) {
function toBottom() {
clearMessageNavigation()
ensureAllRowsPending = undefined
if (awayTimer) clearTimeout(awayTimer)
awayTimer = undefined
setAwayFromBottom(false)
@@ -712,7 +737,6 @@ export function Session(props: { verticalTabsWidth: number }) {
}
ensureAllRows(() => {
scroll.scrollTo(0)
updateAwayFromBottom()
})
}
first()
@@ -1193,7 +1217,10 @@ export function Session(props: { verticalTabsWidth: number }) {
<Show when={session()}>
<box flexGrow={1} minHeight={0} position="relative">
<scrollbox
ref={(r) => (scroll = r)}
ref={(r) => {
scroll = r
scroll.verticalScrollBar.on("change", updateAwayFromBottom)
}}
viewportOptions={{
paddingRight: showScrollbar() ? 1 : 0,
}}