Skip to content

Commit 6fc343e

Browse files
CasualDeveloperNamedIdentity
authored andcommitted
feat(session): implement bi-directional cursor pagination across API and TUI
Add cursor-based message retrieval (before/after/oldest) in session and API layers with RFC 8288 Link-header navigation while preserving response compatibility. Implement TUI pagination state, boundary-triggered loading for mouse and command paths, robust non-Error pagination error rendering, and revert-marker-safe oldest/latest jumps under bounded in-memory windows. Include regression coverage for session/server/link-header/TUI pagination flows and regenerate SDK/OpenAPI artifacts to match the updated contract.
1 parent ecacaf2 commit 6fc343e

16 files changed

Lines changed: 1817 additions & 105 deletions

File tree

packages/opencode/src/cli/cmd/tui/context/sync.tsx

Lines changed: 428 additions & 11 deletions
Large diffs are not rendered by default.

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

Lines changed: 187 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ import { formatTranscript } from "../../util/transcript"
8181
import { UI } from "@/cli/ui.ts"
8282
import { useTuiConfig } from "../../context/tui-config"
8383
import { DialogSessionTree } from "./dialog-session-tree"
84+
import { edgeHints, olderScrollTarget, queueBoundaryLoad } from "@tui/util/pagination"
8485

8586
addDefaultParsers(parsers.parsers)
8687

@@ -164,13 +165,75 @@ export function Session() {
164165
}
165166
return ids
166167
})
168+
const paging = createMemo(() => sync.data.message_page[route.sessionID])
167169
const permissions = createMemo(() => {
168170
return descendants().flatMap((id) => sync.data.permission[id] ?? [])
169171
})
170172
const questions = createMemo(() => {
171173
return descendants().flatMap((id) => sync.data.question[id] ?? [])
172174
})
173175

176+
const LOAD_MORE_THRESHOLD = 5
177+
178+
const loadOlder = () => {
179+
const page = paging()
180+
if (!page?.hasOlder || page.loading || !scroll) return
181+
if (scroll.scrollTop > LOAD_MORE_THRESHOLD) return
182+
183+
const anchor = (() => {
184+
const scrollTop = scroll.scrollTop
185+
const children = scroll.getChildren()
186+
for (const child of children) {
187+
if (!child.id) continue
188+
if (child.y + child.height > scrollTop) {
189+
return { id: child.id, offset: scrollTop - child.y }
190+
}
191+
}
192+
return undefined
193+
})()
194+
195+
const height = scroll.scrollHeight
196+
const scrollTop = scroll.scrollTop
197+
sync.session.loadOlder(route.sessionID).then(() => {
198+
queueMicrotask(() => {
199+
requestAnimationFrame(() => {
200+
if (!scroll || scroll.isDestroyed) return
201+
const nextTop = olderScrollTarget(scroll.getChildren(), scroll.scrollHeight, height, scrollTop, anchor)
202+
if (nextTop !== undefined) scroll.scrollTo(nextTop)
203+
refreshEdges()
204+
})
205+
})
206+
})
207+
}
208+
209+
const loadNewer = () => {
210+
const page = paging()
211+
if (!page?.hasNewer || page.loading || !scroll) return
212+
const bottomDistance = scroll.scrollHeight - scroll.scrollTop - scroll.viewport.height
213+
if (bottomDistance > LOAD_MORE_THRESHOLD) return
214+
sync.session.loadNewer(route.sessionID).then(() => {
215+
queueMicrotask(() => {
216+
requestAnimationFrame(() => {
217+
refreshEdges()
218+
})
219+
})
220+
})
221+
}
222+
223+
const refreshEdges = () => {
224+
if (!scroll || scroll.isDestroyed) return
225+
const edges = edgeHints(scroll.scrollTop, scroll.scrollHeight, scroll.viewport.height, HINT_THRESHOLD)
226+
setNearTop(edges.nearTop)
227+
setNearBottom(edges.nearBottom)
228+
}
229+
230+
const scrollMove = (delta: number) => {
231+
if (!scroll || scroll.isDestroyed) return
232+
scroll.scrollBy(delta)
233+
refreshEdges()
234+
queueBoundaryLoad(delta, loadOlder, loadNewer)
235+
}
236+
174237
const pending = createMemo(() => {
175238
return messages().findLast((x) => x.role === "assistant" && !x.time.completed)?.id
176239
})
@@ -192,6 +255,9 @@ export function Session() {
192255
const [diffWrapMode] = kv.signal<"word" | "none">("diff_wrap_mode", "word")
193256
const [animationsEnabled, setAnimationsEnabled] = kv.signal("animations_enabled", true)
194257
const [showGenericToolOutput, setShowGenericToolOutput] = kv.signal("generic_tool_output_visibility", false)
258+
const [nearTop, setNearTop] = createSignal(false)
259+
const [nearBottom, setNearBottom] = createSignal(false)
260+
const HINT_THRESHOLD = 20
195261

196262
const wide = createMemo(() => dimensions().width > 120)
197263
const sidebarVisible = createMemo(() => {
@@ -219,7 +285,9 @@ export function Session() {
219285
await sync.session
220286
.sync(route.sessionID)
221287
.then(() => {
222-
if (scroll) scroll.scrollBy(100_000)
288+
if (!scroll || scroll.isDestroyed) return
289+
scroll.scrollBy(100_000)
290+
refreshEdges()
223291
})
224292
.catch((e) => {
225293
console.error(e)
@@ -232,6 +300,16 @@ export function Session() {
232300
sync.session.syncTree(route.sessionID).catch(() => {})
233301
})
234302

303+
createEffect(() => {
304+
if (!scroll || scroll.isDestroyed) return
305+
messages()
306+
queueMicrotask(() => {
307+
requestAnimationFrame(() => {
308+
refreshEdges()
309+
})
310+
})
311+
})
312+
235313
const toast = useToast()
236314
const sdk = useSDK()
237315

@@ -299,7 +377,7 @@ export function Session() {
299377
const findNextVisibleMessage = (direction: "next" | "prev"): string | null => {
300378
const children = scroll.getChildren()
301379
const messagesList = messages()
302-
const scrollTop = scroll.y
380+
const scrollTop = scroll.scrollTop
303381

304382
// Get visible messages sorted by position, filtering for valid non-synthetic, non-ignored content
305383
const visibleMessages = children
@@ -331,20 +409,26 @@ export function Session() {
331409
const targetID = findNextVisibleMessage(direction)
332410

333411
if (!targetID) {
334-
scroll.scrollBy(direction === "next" ? scroll.height : -scroll.height)
412+
scrollMove(direction === "next" ? scroll.height : -scroll.height)
335413
dialog.clear()
336414
return
337415
}
338416

339417
const child = scroll.getChildren().find((c) => c.id === targetID)
340-
if (child) scroll.scrollBy(child.y - scroll.y - 1)
418+
if (child) {
419+
scroll.scrollBy(child.y - scroll.scrollTop - 1)
420+
refreshEdges()
421+
}
341422
dialog.clear()
342423
}
343424

344425
function toBottom() {
345426
setTimeout(() => {
346427
if (!scroll || scroll.isDestroyed) return
347428
scroll.scrollTo(scroll.scrollHeight)
429+
requestAnimationFrame(() => {
430+
refreshEdges()
431+
})
348432
}, 50)
349433
}
350434

@@ -454,7 +538,10 @@ export function Session() {
454538
const child = scroll.getChildren().find((child) => {
455539
return child.id === messageID
456540
})
457-
if (child) scroll.scrollBy(child.y - scroll.y - 1)
541+
if (child) {
542+
scroll.scrollBy(child.y - scroll.scrollTop - 1)
543+
refreshEdges()
544+
}
458545
}}
459546
sessionID={route.sessionID}
460547
setPrompt={(promptInfo) => prompt.set(promptInfo)}
@@ -477,7 +564,10 @@ export function Session() {
477564
const child = scroll.getChildren().find((child) => {
478565
return child.id === messageID
479566
})
480-
if (child) scroll.scrollBy(child.y - scroll.y - 1)
567+
if (child) {
568+
scroll.scrollBy(child.y - scroll.scrollTop - 1)
569+
refreshEdges()
570+
}
481571
}}
482572
sessionID={route.sessionID}
483573
/>
@@ -691,7 +781,7 @@ export function Session() {
691781
category: "Session",
692782
hidden: true,
693783
onSelect: (dialog) => {
694-
scroll.scrollBy(-scroll.height / 2)
784+
scrollMove(-scroll.height / 2)
695785
dialog.clear()
696786
},
697787
},
@@ -702,7 +792,7 @@ export function Session() {
702792
category: "Session",
703793
hidden: true,
704794
onSelect: (dialog) => {
705-
scroll.scrollBy(scroll.height / 2)
795+
scrollMove(scroll.height / 2)
706796
dialog.clear()
707797
},
708798
},
@@ -713,7 +803,7 @@ export function Session() {
713803
category: "Session",
714804
disabled: true,
715805
onSelect: (dialog) => {
716-
scroll.scrollBy(-1)
806+
scrollMove(-1)
717807
dialog.clear()
718808
},
719809
},
@@ -724,7 +814,7 @@ export function Session() {
724814
category: "Session",
725815
disabled: true,
726816
onSelect: (dialog) => {
727-
scroll.scrollBy(1)
817+
scrollMove(1)
728818
dialog.clear()
729819
},
730820
},
@@ -735,7 +825,7 @@ export function Session() {
735825
category: "Session",
736826
hidden: true,
737827
onSelect: (dialog) => {
738-
scroll.scrollBy(-scroll.height / 4)
828+
scrollMove(-scroll.height / 4)
739829
dialog.clear()
740830
},
741831
},
@@ -746,7 +836,7 @@ export function Session() {
746836
category: "Session",
747837
hidden: true,
748838
onSelect: (dialog) => {
749-
scroll.scrollBy(scroll.height / 4)
839+
scrollMove(scroll.height / 4)
750840
dialog.clear()
751841
},
752842
},
@@ -757,7 +847,23 @@ export function Session() {
757847
category: "Session",
758848
hidden: true,
759849
onSelect: (dialog) => {
760-
scroll.scrollTo(0)
850+
const page = paging()
851+
if (page?.hasOlder && !page.loading) {
852+
sync.session.jumpToOldest(route.sessionID).then(() => {
853+
requestAnimationFrame(() => {
854+
if (!scroll || scroll.isDestroyed) return
855+
scroll.scrollTo(0)
856+
refreshEdges()
857+
})
858+
})
859+
} else {
860+
if (!scroll || scroll.isDestroyed) {
861+
dialog.clear()
862+
return
863+
}
864+
scroll.scrollTo(0)
865+
refreshEdges()
866+
}
761867
dialog.clear()
762868
},
763869
},
@@ -768,7 +874,23 @@ export function Session() {
768874
category: "Session",
769875
hidden: true,
770876
onSelect: (dialog) => {
771-
scroll.scrollTo(scroll.scrollHeight)
877+
const page = paging()
878+
if (page?.hasNewer && !page.loading) {
879+
sync.session.jumpToLatest(route.sessionID).then(() => {
880+
requestAnimationFrame(() => {
881+
if (!scroll || scroll.isDestroyed) return
882+
scroll.scrollTo(scroll.scrollHeight)
883+
refreshEdges()
884+
})
885+
})
886+
} else {
887+
if (!scroll || scroll.isDestroyed) {
888+
dialog.clear()
889+
return
890+
}
891+
scroll.scrollTo(scroll.scrollHeight)
892+
refreshEdges()
893+
}
772894
dialog.clear()
773895
},
774896
},
@@ -798,7 +920,10 @@ export function Session() {
798920
const child = scroll.getChildren().find((child) => {
799921
return child.id === message.id
800922
})
801-
if (child) scroll.scrollBy(child.y - scroll.y - 1)
923+
if (child) {
924+
scroll.scrollBy(child.y - scroll.scrollTop - 1)
925+
refreshEdges()
926+
}
802927
break
803928
}
804929
}
@@ -1098,8 +1223,45 @@ export function Session() {
10981223
<Show when={showHeader() && (!sidebarVisible() || !wide())}>
10991224
<Header />
11001225
</Show>
1226+
<Show when={paging()?.loading && paging()?.loadingDirection === "older"}>
1227+
<box flexShrink={0} paddingLeft={1}>
1228+
<text fg={theme.textMuted}>Loading older messages...</text>
1229+
</box>
1230+
</Show>
1231+
<Show when={!paging()?.loading && paging()?.hasOlder && nearTop()}>
1232+
<box flexShrink={0} paddingLeft={1}>
1233+
<text fg={theme.textMuted}>(scroll up for more)</text>
1234+
</box>
1235+
</Show>
1236+
<Show when={paging()?.error}>
1237+
<box flexShrink={0} paddingLeft={1}>
1238+
<text fg={theme.error}>Failed to load: {paging()?.error}</text>
1239+
<text fg={theme.textMuted}> (scroll to retry)</text>
1240+
</box>
1241+
</Show>
11011242
<scrollbox
11021243
ref={(r) => (scroll = r)}
1244+
onMouseScroll={() => {
1245+
refreshEdges()
1246+
loadOlder()
1247+
loadNewer()
1248+
}}
1249+
onKeyDown={(e) => {
1250+
// Standard scroll triggers incremental load
1251+
if (["up", "pageup", "home"].includes(e.name)) {
1252+
setTimeout(() => {
1253+
refreshEdges()
1254+
loadOlder()
1255+
}, 0)
1256+
}
1257+
if (["down", "pagedown", "end"].includes(e.name)) {
1258+
setTimeout(() => {
1259+
refreshEdges()
1260+
loadNewer()
1261+
}, 0)
1262+
}
1263+
}}
1264+
viewportCulling={true}
11031265
viewportOptions={{
11041266
paddingRight: showScrollbar() ? 1 : 0,
11051267
}}
@@ -1212,6 +1374,16 @@ export function Session() {
12121374
)}
12131375
</For>
12141376
</scrollbox>
1377+
<Show when={paging()?.loading && paging()?.loadingDirection === "newer"}>
1378+
<box flexShrink={0} paddingLeft={1}>
1379+
<text fg={theme.textMuted}>Loading newer messages...</text>
1380+
</box>
1381+
</Show>
1382+
<Show when={!paging()?.loading && paging()?.hasNewer && nearBottom()}>
1383+
<box flexShrink={0} paddingLeft={1}>
1384+
<text fg={theme.textMuted}>(scroll down for more)</text>
1385+
</box>
1386+
</Show>
12151387
<box flexShrink={0}>
12161388
<Show when={permissions().length > 0}>
12171389
<PermissionPrompt request={permissions()[0]} />

0 commit comments

Comments
 (0)