Skip to content

Commit 6a67795

Browse files
committed
tui: scroll-driven loading + windowing in session view
Wires the new pagination helpers into the chat surface: - maybeLoadOlderMessages / maybeLoadNewerMessages run after every scroll input (key bindings: page/half-page/line up & down, first & last; mouse wheel via onMouseScroll). They fire only when the viewport is within five rows of the corresponding edge and a cursor is available. - After a successful prepend, the view restores the previous logical scroll position by adjusting for the height delta so the user does not jump. - When the in-memory window grows past 200 messages and the user is far from the opposite edge (>4 viewports away), the matching trim helper evicts the now-distant side; the streaming guard inside trimNewerMessages prevents dropping an in-flight assistant message. - A loader spinner is shown at the top of the scrollbox while older messages are being fetched, and at the bottom while newer ones are being recovered after eviction. Timeline dialog kicks off `loadAllMessages` on mount so every prompt in the session becomes selectable, even ones that were never within the live window.
1 parent 13af011 commit 6a67795

2 files changed

Lines changed: 79 additions & 0 deletions

File tree

packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export function DialogTimeline(props: {
1717

1818
onMount(() => {
1919
dialog.setSize("large")
20+
void sync.session.loadAllMessages(props.sessionID)
2021
})
2122

2223
const options = createMemo((): DialogSelectOption<string>[] => {

packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,61 @@ export function Session() {
404404
}, 50)
405405
}
406406

407+
// Pagination + asymmetric windowing
408+
const WINDOW_CAP = 200
409+
const NEAR_TOP_THRESHOLD = 5
410+
const NEAR_BOTTOM_THRESHOLD = 5
411+
412+
async function maybeLoadOlderMessages() {
413+
if (!scroll || scroll.isDestroyed) return
414+
if (!sync.data.messageOlderCursor[route.sessionID]) return
415+
if (sync.data.messageOlderLoading[route.sessionID]) return
416+
if (scroll.y > NEAR_TOP_THRESHOLD) return
417+
const prevScrollHeight = scroll.scrollHeight
418+
const prevY = scroll.y
419+
await sync.session.loadOlderMessages(route.sessionID)
420+
// Trim from the bottom if the user is well above it - only safe when
421+
// there's room above the live tail and no message there is still
422+
// streaming. trimNewerMessages itself enforces the streaming guard.
423+
const messages = sync.data.message[route.sessionID] ?? []
424+
if (messages.length > WINDOW_CAP && scroll.scrollHeight - prevY > scroll.height * 4) {
425+
sync.session.trimNewerMessages(route.sessionID, WINDOW_CAP)
426+
}
427+
setTimeout(() => {
428+
if (!scroll || scroll.isDestroyed) return
429+
scroll.scrollTo(prevY + (scroll.scrollHeight - prevScrollHeight))
430+
}, 0)
431+
}
432+
433+
async function maybeLoadNewerMessages() {
434+
if (!scroll || scroll.isDestroyed) return
435+
if (!sync.data.messageNewerCursor[route.sessionID]) return
436+
if (sync.data.messageNewerLoading[route.sessionID]) return
437+
const distanceFromBottom = scroll.scrollHeight - scroll.height - scroll.y
438+
if (distanceFromBottom > NEAR_BOTTOM_THRESHOLD) return
439+
const prevScrollHeight = scroll.scrollHeight
440+
const prevY = scroll.y
441+
await sync.session.loadNewerMessages(route.sessionID)
442+
// Trim from the top - older messages can always be re-fetched via the
443+
// older cursor, no streaming concern.
444+
const messages = sync.data.message[route.sessionID] ?? []
445+
if (messages.length > WINDOW_CAP && prevY > scroll.height * 4) {
446+
sync.session.trimOlderMessages(route.sessionID, WINDOW_CAP)
447+
}
448+
setTimeout(() => {
449+
if (!scroll || scroll.isDestroyed) return
450+
// After append, scroll position relative to top is unchanged. After
451+
// top-trim, content above shrinks - keep the same logical position.
452+
const heightDelta = scroll.scrollHeight - prevScrollHeight
453+
if (heightDelta < 0) scroll.scrollTo(Math.max(0, prevY + heightDelta))
454+
}, 0)
455+
}
456+
457+
function maybeLoadAdjacent() {
458+
void maybeLoadOlderMessages()
459+
void maybeLoadNewerMessages()
460+
}
461+
407462
const local = useLocal()
408463

409464
function moveFirstChild() {
@@ -729,6 +784,7 @@ export function Session() {
729784
hidden: true,
730785
run: () => {
731786
scroll.scrollBy(-scroll.height / 2)
787+
maybeLoadAdjacent()
732788
dialog.clear()
733789
},
734790
},
@@ -739,6 +795,7 @@ export function Session() {
739795
hidden: true,
740796
run: () => {
741797
scroll.scrollBy(scroll.height / 2)
798+
maybeLoadAdjacent()
742799
dialog.clear()
743800
},
744801
},
@@ -749,6 +806,7 @@ export function Session() {
749806
hidden: true,
750807
run: () => {
751808
scroll.scrollBy(-1)
809+
maybeLoadAdjacent()
752810
dialog.clear()
753811
},
754812
},
@@ -759,6 +817,7 @@ export function Session() {
759817
hidden: true,
760818
run: () => {
761819
scroll.scrollBy(1)
820+
maybeLoadAdjacent()
762821
dialog.clear()
763822
},
764823
},
@@ -769,6 +828,7 @@ export function Session() {
769828
hidden: true,
770829
run: () => {
771830
scroll.scrollBy(-scroll.height / 4)
831+
maybeLoadAdjacent()
772832
dialog.clear()
773833
},
774834
},
@@ -779,6 +839,7 @@ export function Session() {
779839
hidden: true,
780840
run: () => {
781841
scroll.scrollBy(scroll.height / 4)
842+
maybeLoadAdjacent()
782843
dialog.clear()
783844
},
784845
},
@@ -789,6 +850,7 @@ export function Session() {
789850
hidden: true,
790851
run: () => {
791852
scroll.scrollTo(0)
853+
maybeLoadAdjacent()
792854
dialog.clear()
793855
},
794856
},
@@ -799,6 +861,7 @@ export function Session() {
799861
hidden: true,
800862
run: () => {
801863
scroll.scrollTo(scroll.scrollHeight)
864+
maybeLoadAdjacent()
802865
dialog.clear()
803866
},
804867
},
@@ -1116,7 +1179,17 @@ export function Session() {
11161179
stickyStart="bottom"
11171180
flexGrow={1}
11181181
scrollAcceleration={scrollAcceleration()}
1182+
onMouseScroll={() => {
1183+
// Defer until after the scrollbox has applied the scroll
1184+
// delta so scroll.y reflects the post-event position.
1185+
setTimeout(() => maybeLoadAdjacent(), 0)
1186+
}}
11191187
>
1188+
<Show when={sync.data.messageOlderLoading[route.sessionID]}>
1189+
<box paddingLeft={3} flexShrink={0}>
1190+
<Spinner color={theme.textMuted}>Loading older messages…</Spinner>
1191+
</box>
1192+
</Show>
11201193
<box height={1} />
11211194
<For each={messages()}>
11221195
{(message, index) => (
@@ -1213,6 +1286,11 @@ export function Session() {
12131286
</Switch>
12141287
)}
12151288
</For>
1289+
<Show when={sync.data.messageNewerLoading[route.sessionID]}>
1290+
<box paddingLeft={3} flexShrink={0}>
1291+
<Spinner color={theme.textMuted}>Loading newer messages…</Spinner>
1292+
</box>
1293+
</Show>
12161294
</scrollbox>
12171295
<box flexShrink={0}>
12181296
<Show when={permissions().length > 0}>

0 commit comments

Comments
 (0)