Skip to content

Commit c0cd9d3

Browse files
feat(session): add bi-directional cursor-based pagination with Home/End navigation
Implements cursor-based pagination for message loading to handle long sessions without memory explosion with absolute navigation via Home/End keys. API changes: - Add 'before' cursor param: fetch messages older than cursor (newest first) - Add 'after' cursor param: fetch messages newer than cursor (oldest first) - Add 'oldest' param: start from oldest messages (for jumpToOldest) - Link headers with rel="prev"/"next" for cursor discovery (RFC 5005) TUI changes: - loadOlder/loadNewer actions with sliding window eviction (500 msg limit) - jumpToOldest (Home): fetches oldest page via ?oldest=true - jumpToLatest (End): fetches newest page, preserves revert marker - Detached mode: ignores SSE when viewing history to prevent gaps Implementation: - Binary.lowerBound for efficient cursor lookup - parseLinkHeader utility for RFC 5988 parsing - Message.stream() reverse option for ascending order - Smart parts cleanup: only deletes parts for evicted messages Tests: - Unit tests for pagination logic and cursor handling - API tests for before/after/oldest params and Link headers Resolves: #6548
1 parent b3901ac commit c0cd9d3

File tree

13 files changed

+1342
-38
lines changed

13 files changed

+1342
-38
lines changed

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

Lines changed: 385 additions & 10 deletions
Large diffs are not rendered by default.

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

Lines changed: 112 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ export function Session() {
119119
.toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
120120
})
121121
const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
122+
const paging = createMemo(() => sync.data.message_page[route.sessionID])
122123
const permissions = createMemo(() => {
123124
if (session()?.parentID) return []
124125
return children().flatMap((x) => sync.data.permission[x.id] ?? [])
@@ -128,6 +129,52 @@ export function Session() {
128129
return children().flatMap((x) => sync.data.question[x.id] ?? [])
129130
})
130131

132+
const LOAD_MORE_THRESHOLD = 5
133+
134+
const loadOlder = () => {
135+
const page = paging()
136+
if (!page?.hasOlder || page.loading || !scroll) return
137+
if (scroll.scrollTop > LOAD_MORE_THRESHOLD) return
138+
139+
const anchor = (() => {
140+
const scrollTop = scroll.scrollTop
141+
const children = scroll.getChildren()
142+
for (const child of children) {
143+
if (child.y + child.height > scrollTop) {
144+
return { id: child.id, offset: scrollTop - child.y }
145+
}
146+
}
147+
return undefined
148+
})()
149+
150+
const height = scroll.scrollHeight
151+
const scrollTop = scroll.scrollTop
152+
sync.session.loadOlder(route.sessionID).then(() => {
153+
queueMicrotask(() => {
154+
requestAnimationFrame(() => {
155+
if (anchor) {
156+
const child = scroll.getChildren().find((item) => item.id === anchor.id)
157+
if (child) {
158+
scroll.scrollTo(child.y + anchor.offset)
159+
return
160+
}
161+
}
162+
163+
const delta = scroll.scrollHeight - height
164+
if (delta > 0) scroll.scrollTo(scrollTop + delta)
165+
})
166+
})
167+
})
168+
}
169+
170+
const loadNewer = () => {
171+
const page = paging()
172+
if (!page?.hasNewer || page.loading || !scroll) return
173+
const bottomDistance = scroll.scrollHeight - scroll.scrollTop - scroll.viewport.height
174+
if (bottomDistance > LOAD_MORE_THRESHOLD) return
175+
sync.session.loadNewer(route.sessionID)
176+
}
177+
131178
const pending = createMemo(() => {
132179
return messages().findLast((x) => x.role === "assistant" && !x.time.completed)?.id
133180
})
@@ -230,7 +277,7 @@ export function Session() {
230277
const findNextVisibleMessage = (direction: "next" | "prev"): string | null => {
231278
const children = scroll.getChildren()
232279
const messagesList = messages()
233-
const scrollTop = scroll.y
280+
const scrollTop = scroll.scrollTop
234281

235282
// Get visible messages sorted by position, filtering for valid non-synthetic, non-ignored content
236283
const visibleMessages = children
@@ -268,7 +315,7 @@ export function Session() {
268315
}
269316

270317
const child = scroll.getChildren().find((c) => c.id === targetID)
271-
if (child) scroll.scrollBy(child.y - scroll.y - 1)
318+
if (child) scroll.scrollBy(child.y - scroll.scrollTop - 1)
272319
dialog.clear()
273320
}
274321

@@ -347,7 +394,7 @@ export function Session() {
347394
const child = scroll.getChildren().find((child) => {
348395
return child.id === messageID
349396
})
350-
if (child) scroll.scrollBy(child.y - scroll.y - 1)
397+
if (child) scroll.scrollBy(child.y - scroll.scrollTop - 1)
351398
}}
352399
sessionID={route.sessionID}
353400
setPrompt={(promptInfo) => prompt.set(promptInfo)}
@@ -370,7 +417,7 @@ export function Session() {
370417
const child = scroll.getChildren().find((child) => {
371418
return child.id === messageID
372419
})
373-
if (child) scroll.scrollBy(child.y - scroll.y - 1)
420+
if (child) scroll.scrollBy(child.y - scroll.scrollTop - 1)
374421
}}
375422
sessionID={route.sessionID}
376423
/>
@@ -652,7 +699,16 @@ export function Session() {
652699
category: "Session",
653700
hidden: true,
654701
onSelect: (dialog) => {
655-
scroll.scrollTo(0)
702+
const page = paging()
703+
if (page?.hasOlder && !page.loading) {
704+
sync.session.jumpToOldest(route.sessionID).then(() => {
705+
requestAnimationFrame(() => {
706+
if (scroll) scroll.scrollTo(0)
707+
})
708+
})
709+
} else {
710+
scroll.scrollTo(0)
711+
}
656712
dialog.clear()
657713
},
658714
},
@@ -663,7 +719,16 @@ export function Session() {
663719
category: "Session",
664720
hidden: true,
665721
onSelect: (dialog) => {
666-
scroll.scrollTo(scroll.scrollHeight)
722+
const page = paging()
723+
if (page?.hasNewer && !page.loading) {
724+
sync.session.jumpToLatest(route.sessionID).then(() => {
725+
requestAnimationFrame(() => {
726+
if (scroll) scroll.scrollTo(scroll.scrollHeight)
727+
})
728+
})
729+
} else {
730+
scroll.scrollTo(scroll.scrollHeight)
731+
}
667732
dialog.clear()
668733
},
669734
},
@@ -693,7 +758,7 @@ export function Session() {
693758
const child = scroll.getChildren().find((child) => {
694759
return child.id === message.id
695760
})
696-
if (child) scroll.scrollBy(child.y - scroll.y - 1)
761+
if (child) scroll.scrollBy(child.y - scroll.scrollTop - 1)
697762
break
698763
}
699764
}
@@ -963,8 +1028,38 @@ export function Session() {
9631028
<Show when={!sidebarVisible() || !wide()}>
9641029
<Header />
9651030
</Show>
1031+
<Show when={paging()?.loading && paging()?.loadingDirection === "older"}>
1032+
<box flexShrink={0} paddingLeft={1}>
1033+
<text fg={theme.textMuted}>Loading older messages...</text>
1034+
</box>
1035+
</Show>
1036+
<Show when={!paging()?.loading && paging()?.hasOlder}>
1037+
<box flexShrink={0} paddingLeft={1}>
1038+
<text fg={theme.textMuted}>(scroll up for more)</text>
1039+
</box>
1040+
</Show>
1041+
<Show when={paging()?.error}>
1042+
<box flexShrink={0} paddingLeft={1}>
1043+
<text fg={theme.error}>Failed to load: {paging()?.error}</text>
1044+
<text fg={theme.textMuted}> (scroll to retry)</text>
1045+
</box>
1046+
</Show>
9661047
<scrollbox
9671048
ref={(r) => (scroll = r)}
1049+
onMouseScroll={() => {
1050+
loadOlder()
1051+
loadNewer()
1052+
}}
1053+
onKeyDown={(e) => {
1054+
// Standard scroll triggers incremental load
1055+
if (["up", "pageup", "home"].includes(e.name)) {
1056+
setTimeout(loadOlder, 0)
1057+
}
1058+
if (["down", "pagedown", "end"].includes(e.name)) {
1059+
setTimeout(loadNewer, 0)
1060+
}
1061+
}}
1062+
viewportCulling={true}
9681063
viewportOptions={{
9691064
paddingRight: showScrollbar() ? 1 : 0,
9701065
}}
@@ -1077,6 +1172,16 @@ export function Session() {
10771172
)}
10781173
</For>
10791174
</scrollbox>
1175+
<Show when={paging()?.loading && paging()?.loadingDirection === "newer"}>
1176+
<box flexShrink={0} paddingLeft={1}>
1177+
<text fg={theme.textMuted}>Loading newer messages...</text>
1178+
</box>
1179+
</Show>
1180+
<Show when={!paging()?.loading && paging()?.hasNewer}>
1181+
<box flexShrink={0} paddingLeft={1}>
1182+
<text fg={theme.textMuted}>(scroll down for more)</text>
1183+
</box>
1184+
</Show>
10801185
<box flexShrink={0}>
10811186
<Show when={permissions().length > 0}>
10821187
<PermissionPrompt request={permissions()[0]} />

packages/opencode/src/server/routes/session.ts

Lines changed: 87 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { describeRoute, validator, resolver } from "hono-openapi"
44
import z from "zod"
55
import { Session } from "../../session"
66
import { MessageV2 } from "../../session/message-v2"
7+
import { Identifier } from "../../id/id"
78
import { SessionPrompt } from "../../session/prompt"
89
import { SessionCompaction } from "../../session/compaction"
910
import { SessionRevert } from "../../session/revert"
@@ -570,16 +571,97 @@ export const SessionRoutes = lazy(() =>
570571
validator(
571572
"query",
572573
z.object({
573-
limit: z.coerce.number().optional(),
574+
limit: z.coerce.number().int().min(0).optional(),
575+
before: Identifier.schema("message").optional(),
576+
after: Identifier.schema("message").optional(),
577+
oldest: z.coerce.boolean().optional(),
574578
}),
575579
),
576580
async (c) => {
577581
const query = c.req.valid("query")
578-
const messages = await Session.messages({
579-
sessionID: c.req.valid("param").sessionID,
580-
limit: query.limit,
582+
if (query.before && query.after) {
583+
return c.json({ error: "Cannot specify both 'before' and 'after'" }, 400)
584+
}
585+
if (query.oldest && (query.before || query.after)) {
586+
return c.json({ error: "Cannot use 'oldest' with 'before' or 'after'" }, 400)
587+
}
588+
589+
const rawLimit = query.limit === 0 ? undefined : query.limit
590+
const sessionID = c.req.valid("param").sessionID
591+
592+
const usesCursor = !!(query.oldest || query.after || query.before)
593+
594+
if (!usesCursor && rawLimit === undefined) {
595+
const messages = await Session.messages({ sessionID })
596+
return c.json(messages)
597+
}
598+
599+
const pageLimit = usesCursor ? (rawLimit ? Math.min(rawLimit, 100) : 100) : (rawLimit ?? 100)
600+
601+
if (query.oldest) {
602+
const page = await Session.messages({
603+
sessionID,
604+
limit: pageLimit + 1,
605+
oldest: true,
606+
})
607+
608+
if (page.length > pageLimit) {
609+
const messages = page.slice(0, -1)
610+
const last = messages.at(-1)
611+
if (last) {
612+
const url = new URL(c.req.url)
613+
url.searchParams.delete("oldest")
614+
url.searchParams.set("limit", pageLimit.toString())
615+
url.searchParams.set("after", last.info.id)
616+
c.header("Link", `<${url.toString()}>; rel=\"next\"`)
617+
}
618+
return c.json(messages)
619+
}
620+
621+
return c.json(page)
622+
}
623+
624+
if (query.after) {
625+
const page = await Session.messages({
626+
sessionID,
627+
limit: pageLimit + 1,
628+
after: query.after,
629+
})
630+
631+
if (page.length > pageLimit) {
632+
const messages = page.slice(0, -1)
633+
const last = messages.at(-1)
634+
if (last) {
635+
const url = new URL(c.req.url)
636+
url.searchParams.set("limit", pageLimit.toString())
637+
url.searchParams.set("after", last.info.id)
638+
c.header("Link", `<${url.toString()}>; rel=\"next\"`)
639+
}
640+
return c.json(messages)
641+
}
642+
643+
return c.json(page)
644+
}
645+
646+
const page = await Session.messages({
647+
sessionID,
648+
limit: pageLimit + 1,
649+
before: query.before,
581650
})
582-
return c.json(messages)
651+
652+
if (page.length > pageLimit) {
653+
const messages = page.slice(1)
654+
const first = messages.at(0)
655+
if (first) {
656+
const url = new URL(c.req.url)
657+
url.searchParams.set("limit", pageLimit.toString())
658+
url.searchParams.set("before", first.info.id)
659+
c.header("Link", `<${url.toString()}>; rel=\"prev\"`)
660+
}
661+
return c.json(messages)
662+
}
663+
664+
return c.json(page)
583665
},
584666
)
585667
.get(

0 commit comments

Comments
 (0)