feat(tui): preserve scroll position on submit

This commit is contained in:
Kit Langton 2026-07-31 14:30:09 +00:00 committed by OpenCode Agent
commit 6cc7ccd3c1

View file

@ -295,6 +295,7 @@ export function Session() {
let seeded = false let seeded = false
let sent = false let sent = false
let scroll: ScrollBoxRenderable let scroll: ScrollBoxRenderable
const [showJumpToBottom, setShowJumpToBottom] = createSignal(false)
const [prompt, setPrompt] = createSignal<PromptRef>() const [prompt, setPrompt] = createSignal<PromptRef>()
const bind = (r: PromptRef | undefined) => { const bind = (r: PromptRef | undefined) => {
setPrompt(r) 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 // 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 // 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 // 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<number>() const [hiddenRows, setHiddenRows] = createSignal<number>()
const hidden = createMemo(() => Math.max(0, Math.min(hiddenRows() ?? Infinity, rows.length - TRANSCRIPT_TAIL_ROWS))) 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 visibleRows = createMemo(() => (hidden() === 0 ? rows : rows.slice(hidden())))
createEffect(() => {
visibleRows().length
afterLayout(updateJumpToBottom)
})
createEffect(() => { createEffect(() => {
const current = hidden() const current = hidden()
if (current === 0) return if (current === 0) return
@ -409,6 +419,7 @@ export function Session() {
setTimeout(() => { setTimeout(() => {
if (!scroll || scroll.isDestroyed) return if (!scroll || scroll.isDestroyed) return
scroll.scrollTo(scroll.scrollHeight) scroll.scrollTo(scroll.scrollHeight)
updateJumpToBottom()
}, 50) }, 50)
} }
@ -421,6 +432,7 @@ export function Session() {
run: () => { run: () => {
clearMessageNavigation() clearMessageNavigation()
scroll.scrollBy(-scroll.height / 2) scroll.scrollBy(-scroll.height / 2)
updateJumpToBottom()
dialog.clear() dialog.clear()
}, },
}, },
@ -432,6 +444,7 @@ export function Session() {
run: () => { run: () => {
clearMessageNavigation() clearMessageNavigation()
scroll.scrollBy(scroll.height / 2) scroll.scrollBy(scroll.height / 2)
updateJumpToBottom()
dialog.clear() dialog.clear()
}, },
}, },
@ -443,6 +456,7 @@ export function Session() {
run: () => { run: () => {
clearMessageNavigation() clearMessageNavigation()
scroll.scrollBy(-1) scroll.scrollBy(-1)
updateJumpToBottom()
dialog.clear() dialog.clear()
}, },
}, },
@ -454,6 +468,7 @@ export function Session() {
run: () => { run: () => {
clearMessageNavigation() clearMessageNavigation()
scroll.scrollBy(1) scroll.scrollBy(1)
updateJumpToBottom()
dialog.clear() dialog.clear()
}, },
}, },
@ -465,6 +480,7 @@ export function Session() {
run: () => { run: () => {
clearMessageNavigation() clearMessageNavigation()
scroll.scrollBy(-scroll.height / 4) scroll.scrollBy(-scroll.height / 4)
updateJumpToBottom()
dialog.clear() dialog.clear()
}, },
}, },
@ -476,6 +492,7 @@ export function Session() {
run: () => { run: () => {
clearMessageNavigation() clearMessageNavigation()
scroll.scrollBy(scroll.height / 4) scroll.scrollBy(scroll.height / 4)
updateJumpToBottom()
dialog.clear() dialog.clear()
}, },
}, },
@ -490,6 +507,7 @@ export function Session() {
run: () => { run: () => {
clearMessageNavigation() clearMessageNavigation()
scroll.scrollTo(0) scroll.scrollTo(0)
updateJumpToBottom()
dialog.clear() dialog.clear()
}, },
}, },
@ -501,6 +519,7 @@ export function Session() {
run: () => { run: () => {
clearMessageNavigation() clearMessageNavigation()
scroll.scrollTo(scroll.scrollHeight) scroll.scrollTo(scroll.scrollHeight)
updateJumpToBottom()
dialog.clear() dialog.clear()
}, },
}, },
@ -960,48 +979,67 @@ export function Session() {
<box flexDirection="row" flexGrow={1} minHeight={0}> <box flexDirection="row" flexGrow={1} minHeight={0}>
<box flexGrow={1} minHeight={0} paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1}> <box flexGrow={1} minHeight={0} paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1}>
<Show when={session()}> <Show when={session()}>
<scrollbox <box flexGrow={1} minHeight={0}>
ref={(r) => (scroll = r)} <scrollbox
viewportOptions={{ id="session-transcript"
paddingRight: showScrollbar() ? 1 : 0, ref={(r) => (scroll = r)}
}} onMouseScroll={() => setTimeout(updateJumpToBottom, 0)}
verticalScrollbarOptions={{ viewportOptions={{
paddingLeft: 1, paddingRight: showScrollbar() ? 1 : 0,
visible: showScrollbar(), }}
trackOptions: { verticalScrollbarOptions={{
backgroundColor: theme.raise(theme.background.surface.offset), paddingLeft: 1,
foregroundColor: theme.border.default, visible: showScrollbar(),
}, trackOptions: {
}} backgroundColor: theme.raise(theme.background.surface.offset),
stickyScroll={!navigationMessage()} foregroundColor: theme.border.default,
stickyStart="bottom" },
flexGrow={1} }}
scrollAcceleration={scrollAcceleration()} stickyScroll={!navigationMessage()}
> stickyStart="bottom"
<For each={visibleRows()}> flexGrow={1}
{(row, index) => ( scrollAcceleration={scrollAcceleration()}
<SessionRowView >
row={row} <For each={visibleRows()}>
message={(messageID) => data.session.message.get(route.sessionID, messageID)} {(row, index) => (
boundaryID={boundaries()[index() + hidden()]} <SessionRowView
row={row}
message={(messageID) => data.session.message.get(route.sessionID, messageID)}
boundaryID={boundaries()[index() + hidden()]}
/>
)}
</For>
<BackgroundToolHint messages={messages()} />
<Show when={session()?.revert?.messageID}>
<RevertMessage
count={
messages().filter(
(message) => message.id >= session()!.revert!.messageID && message.type === "user",
).length
}
files={session()!.revert!.files ?? []}
/> />
)} </Show>
</For> <Show when={navigationSlack()}>
<BackgroundToolHint messages={messages()} /> {(height) => <box id={NAVIGATION_SLACK_ID} height={height()} flexShrink={0} />}
<Show when={session()?.revert?.messageID}> </Show>
<RevertMessage </scrollbox>
count={ <Show when={showJumpToBottom()}>
messages().filter( <box
(message) => message.id >= session()!.revert!.messageID && message.type === "user", id="session-jump-to-bottom"
).length position="absolute"
} zIndex={1000}
files={session()!.revert!.files ?? []} right={showScrollbar() ? 2 : 0}
/> bottom={0}
backgroundColor={theme.raise(theme.background.default)}
paddingLeft={1}
paddingRight={1}
onMouseUp={toBottom}
>
<text fg={theme.text.default}> jump to bottom</text>
</box>
</Show> </Show>
<Show when={navigationSlack()}> </box>
{(height) => <box id={NAVIGATION_SLACK_ID} height={height()} flexShrink={0} />}
</Show>
</scrollbox>
<box flexShrink={0}> <box flexShrink={0}>
<Composer <Composer
sessionID={route.sessionID} sessionID={route.sessionID}
@ -1034,9 +1072,6 @@ export function Session() {
visible={true} visible={true}
ref={bind} ref={bind}
disabled={false} disabled={false}
onSubmit={() => {
toBottom()
}}
sessionID={route.sessionID} sessionID={route.sessionID}
/> />
</Match> </Match>