Compare commits

...

1 Commits

Author SHA1 Message Date
Kit Langton 6cc7ccd3c1 feat(tui): preserve scroll position on submit 2026-07-31 14:37:47 +00:00
+78 -43
View File
@@ -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<PromptRef>()
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<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())))
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() {
<box flexDirection="row" flexGrow={1} minHeight={0}>
<box flexGrow={1} minHeight={0} paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1}>
<Show when={session()}>
<scrollbox
ref={(r) => (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()}
>
<For each={visibleRows()}>
{(row, index) => (
<SessionRowView
row={row}
message={(messageID) => data.session.message.get(route.sessionID, messageID)}
boundaryID={boundaries()[index() + hidden()]}
<box flexGrow={1} minHeight={0}>
<scrollbox
id="session-transcript"
ref={(r) => (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()}
>
<For each={visibleRows()}>
{(row, index) => (
<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 ?? []}
/>
)}
</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>
<Show when={navigationSlack()}>
{(height) => <box id={NAVIGATION_SLACK_ID} height={height()} flexShrink={0} />}
</Show>
</scrollbox>
<Show when={showJumpToBottom()}>
<box
id="session-jump-to-bottom"
position="absolute"
zIndex={1000}
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 when={navigationSlack()}>
{(height) => <box id={NAVIGATION_SLACK_ID} height={height()} flexShrink={0} />}
</Show>
</scrollbox>
</box>
<box flexShrink={0}>
<Composer
sessionID={route.sessionID}
@@ -1034,9 +1072,6 @@ export function Session() {
visible={true}
ref={bind}
disabled={false}
onSubmit={() => {
toBottom()
}}
sessionID={route.sessionID}
/>
</Match>