From 6cc7ccd3c10b0f48ec59da65f17ee02368eb6e5c Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 31 Jul 2026 14:30:09 +0000 Subject: [PATCH] feat(tui): preserve scroll position on submit --- packages/tui/src/routes/session/index.tsx | 121 ++++++++++++++-------- 1 file changed, 78 insertions(+), 43 deletions(-) diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 79c36ee4c8..b26991686b 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -295,6 +295,7 @@ export function Session() { let seeded = false let sent = false let scroll: ScrollBoxRenderable + const [showJumpToBottom, setShowJumpToBottom] = createSignal(false) const [prompt, setPrompt] = createSignal() const bind = (r: PromptRef | undefined) => { setPrompt(r) @@ -314,6 +315,11 @@ export function Session() { }) } + const updateJumpToBottom = () => { + if (!scroll || scroll.isDestroyed) return + setShowJumpToBottom(scroll.scrollTop + scroll.viewport.height < scroll.scrollHeight - 1) + } + // Tail-first transcript mounting: only the newest rows mount when the session opens, and the // rest backfill in chunks shortly after, so switching to a long session costs the visible tail // instead of the whole transcript. Until backfill pins the count, the hidden span derives from @@ -322,6 +328,10 @@ export function Session() { const [hiddenRows, setHiddenRows] = 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()))) + createEffect(() => { + visibleRows().length + afterLayout(updateJumpToBottom) + }) createEffect(() => { const current = hidden() if (current === 0) return @@ -409,6 +419,7 @@ export function Session() { setTimeout(() => { if (!scroll || scroll.isDestroyed) return scroll.scrollTo(scroll.scrollHeight) + updateJumpToBottom() }, 50) } @@ -421,6 +432,7 @@ export function Session() { run: () => { clearMessageNavigation() scroll.scrollBy(-scroll.height / 2) + updateJumpToBottom() dialog.clear() }, }, @@ -432,6 +444,7 @@ export function Session() { run: () => { clearMessageNavigation() scroll.scrollBy(scroll.height / 2) + updateJumpToBottom() dialog.clear() }, }, @@ -443,6 +456,7 @@ export function Session() { run: () => { clearMessageNavigation() scroll.scrollBy(-1) + updateJumpToBottom() dialog.clear() }, }, @@ -454,6 +468,7 @@ export function Session() { run: () => { clearMessageNavigation() scroll.scrollBy(1) + updateJumpToBottom() dialog.clear() }, }, @@ -465,6 +480,7 @@ export function Session() { run: () => { clearMessageNavigation() scroll.scrollBy(-scroll.height / 4) + updateJumpToBottom() dialog.clear() }, }, @@ -476,6 +492,7 @@ export function Session() { run: () => { clearMessageNavigation() scroll.scrollBy(scroll.height / 4) + updateJumpToBottom() dialog.clear() }, }, @@ -490,6 +507,7 @@ export function Session() { run: () => { clearMessageNavigation() scroll.scrollTo(0) + updateJumpToBottom() dialog.clear() }, }, @@ -501,6 +519,7 @@ export function Session() { run: () => { clearMessageNavigation() scroll.scrollTo(scroll.scrollHeight) + updateJumpToBottom() dialog.clear() }, }, @@ -960,48 +979,67 @@ export function Session() { - (scroll = r)} - viewportOptions={{ - paddingRight: showScrollbar() ? 1 : 0, - }} - verticalScrollbarOptions={{ - paddingLeft: 1, - visible: showScrollbar(), - trackOptions: { - backgroundColor: theme.raise(theme.background.surface.offset), - foregroundColor: theme.border.default, - }, - }} - stickyScroll={!navigationMessage()} - stickyStart="bottom" - flexGrow={1} - scrollAcceleration={scrollAcceleration()} - > - - {(row, index) => ( - data.session.message.get(route.sessionID, messageID)} - boundaryID={boundaries()[index() + hidden()]} + + (scroll = r)} + onMouseScroll={() => setTimeout(updateJumpToBottom, 0)} + viewportOptions={{ + paddingRight: showScrollbar() ? 1 : 0, + }} + verticalScrollbarOptions={{ + paddingLeft: 1, + visible: showScrollbar(), + trackOptions: { + backgroundColor: theme.raise(theme.background.surface.offset), + foregroundColor: theme.border.default, + }, + }} + stickyScroll={!navigationMessage()} + stickyStart="bottom" + flexGrow={1} + scrollAcceleration={scrollAcceleration()} + > + + {(row, index) => ( + data.session.message.get(route.sessionID, messageID)} + boundaryID={boundaries()[index() + hidden()]} + /> + )} + + + + message.id >= session()!.revert!.messageID && message.type === "user", + ).length + } + files={session()!.revert!.files ?? []} /> - )} - - - - message.id >= session()!.revert!.messageID && message.type === "user", - ).length - } - files={session()!.revert!.files ?? []} - /> + + + {(height) => } + + + + + ↓ jump to bottom + - - {(height) => } - - + { - toBottom() - }} sessionID={route.sessionID} />