Skip to content

Commit 9cc6b92

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 538f3cb commit 9cc6b92

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
@@ -405,6 +405,61 @@ export function Session() {
405405
}, 50)
406406
}
407407

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

410465
function moveFirstChild() {
@@ -730,6 +785,7 @@ export function Session() {
730785
hidden: true,
731786
run: () => {
732787
scroll.scrollBy(-scroll.height / 2)
788+
maybeLoadAdjacent()
733789
dialog.clear()
734790
},
735791
},
@@ -740,6 +796,7 @@ export function Session() {
740796
hidden: true,
741797
run: () => {
742798
scroll.scrollBy(scroll.height / 2)
799+
maybeLoadAdjacent()
743800
dialog.clear()
744801
},
745802
},
@@ -750,6 +807,7 @@ export function Session() {
750807
hidden: true,
751808
run: () => {
752809
scroll.scrollBy(-1)
810+
maybeLoadAdjacent()
753811
dialog.clear()
754812
},
755813
},
@@ -760,6 +818,7 @@ export function Session() {
760818
hidden: true,
761819
run: () => {
762820
scroll.scrollBy(1)
821+
maybeLoadAdjacent()
763822
dialog.clear()
764823
},
765824
},
@@ -770,6 +829,7 @@ export function Session() {
770829
hidden: true,
771830
run: () => {
772831
scroll.scrollBy(-scroll.height / 4)
832+
maybeLoadAdjacent()
773833
dialog.clear()
774834
},
775835
},
@@ -780,6 +840,7 @@ export function Session() {
780840
hidden: true,
781841
run: () => {
782842
scroll.scrollBy(scroll.height / 4)
843+
maybeLoadAdjacent()
783844
dialog.clear()
784845
},
785846
},
@@ -790,6 +851,7 @@ export function Session() {
790851
hidden: true,
791852
run: () => {
792853
scroll.scrollTo(0)
854+
maybeLoadAdjacent()
793855
dialog.clear()
794856
},
795857
},
@@ -800,6 +862,7 @@ export function Session() {
800862
hidden: true,
801863
run: () => {
802864
scroll.scrollTo(scroll.scrollHeight)
865+
maybeLoadAdjacent()
803866
dialog.clear()
804867
},
805868
},
@@ -1117,7 +1180,17 @@ export function Session() {
11171180
stickyStart="bottom"
11181181
flexGrow={1}
11191182
scrollAcceleration={scrollAcceleration()}
1183+
onMouseScroll={() => {
1184+
// Defer until after the scrollbox has applied the scroll
1185+
// delta so scroll.y reflects the post-event position.
1186+
setTimeout(() => maybeLoadAdjacent(), 0)
1187+
}}
11201188
>
1189+
<Show when={sync.data.messageOlderLoading[route.sessionID]}>
1190+
<box paddingLeft={3} flexShrink={0}>
1191+
<Spinner color={theme.textMuted}>Loading older messages…</Spinner>
1192+
</box>
1193+
</Show>
11211194
<box height={1} />
11221195
<For each={messages()}>
11231196
{(message, index) => (
@@ -1214,6 +1287,11 @@ export function Session() {
12141287
</Switch>
12151288
)}
12161289
</For>
1290+
<Show when={sync.data.messageNewerLoading[route.sessionID]}>
1291+
<box paddingLeft={3} flexShrink={0}>
1292+
<Spinner color={theme.textMuted}>Loading newer messages…</Spinner>
1293+
</box>
1294+
</Show>
12171295
</scrollbox>
12181296
<box flexShrink={0}>
12191297
<Show when={permissions().length > 0}>

0 commit comments

Comments
 (0)