diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 4edc4c53fa0..d61de794f4c 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -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() @@ -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() const [visibleRowsEnd, setVisibleRowsEnd] = createSignal() 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 }) { (scroll = r)} + ref={(r) => { + scroll = r + scroll.verticalScrollBar.on("change", updateAwayFromBottom) + }} viewportOptions={{ paddingRight: showScrollbar() ? 1 : 0, }}