Skip to content

Commit 538f3cb

Browse files
committed
tui: bidirectional message pagination with asymmetric windowing
Replaces the unbounded "load all messages on session sync" path with a windowed loader that keeps memory bounded for long sessions while still letting the user reach any message via scrolling or the Timeline. sync.tsx (TUI sync context): - Initial sync now fetches the most-recent 100 messages and captures the `X-Next-Cursor` response header into `messageOlderCursor`. - New helpers: - loadOlderMessages: fetches a 50-message page using the older cursor and prepends. - loadNewerMessages: fetches a 50-message page using the newer cursor and appends. Used after eviction to recover the live tail. - trimNewerMessages / trimOlderMessages: cap the in-memory window by dropping from the tail/head and recording a cursor for re-fetch. Eviction skips assistant messages still streaming (`time.completed` unset) so live output is never lost mid-turn. - loadAllMessages: paginate exhaustively in both directions (consumed by the Timeline dialog). - Live event handling honours the eviction state: - `message.updated` for an ID past the windowed tail is dropped when `messageNewerCursor` is set; insertions for IDs already in the window still update normally. - `message.part.updated` no longer creates orphan entries for evicted messages. SDK v2 client: - SessionMessagesData and OpencodeClient.messages now accept the new `after` query parameter. - openapi.json mirrors the schema change.
1 parent fb486a0 commit 538f3cb

5 files changed

Lines changed: 181 additions & 10 deletions

File tree

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

Lines changed: 168 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,18 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
7878
}
7979
formatter: FormatterStatus[]
8080
vcs: VcsInfo | undefined
81+
messageOlderCursor: {
82+
[sessionID: string]: string | null
83+
}
84+
messageNewerCursor: {
85+
[sessionID: string]: string | null
86+
}
87+
messageOlderLoading: {
88+
[sessionID: string]: boolean
89+
}
90+
messageNewerLoading: {
91+
[sessionID: string]: boolean
92+
}
8193
}>({
8294
provider_next: {
8395
all: [],
@@ -105,6 +117,10 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
105117
mcp_resource: {},
106118
formatter: [],
107119
vcs: undefined,
120+
messageOlderCursor: {},
121+
messageNewerCursor: {},
122+
messageOlderLoading: {},
123+
messageNewerLoading: {},
108124
})
109125

110126
const event = useEvent()
@@ -251,19 +267,33 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
251267
}
252268

253269
case "message.updated": {
254-
const messages = store.message[event.properties.info.sessionID]
270+
const sessionID = event.properties.info.sessionID
271+
const messages = store.message[sessionID]
255272
if (!messages) {
256-
setStore("message", event.properties.info.sessionID, [event.properties.info])
273+
setStore("message", sessionID, [event.properties.info])
257274
break
258275
}
259276
const result = Binary.search(messages, event.properties.info.id, (m) => m.id)
260277
if (result.found) {
261-
setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info))
278+
setStore("message", sessionID, result.index, reconcile(event.properties.info))
262279
break
263280
}
281+
// If the bottom of the window has been evicted (messageNewerCursor
282+
// is set), drop messages that arrive past our visible tail. They
283+
// will be loaded on demand when the user scrolls back down.
284+
if (store.messageNewerCursor[sessionID]) {
285+
const last = messages[messages.length - 1]
286+
if (last) {
287+
const incoming = event.properties.info
288+
const isPastTail =
289+
incoming.time.created > last.time.created ||
290+
(incoming.time.created === last.time.created && incoming.id > last.id)
291+
if (isPastTail) break
292+
}
293+
}
264294
setStore(
265295
"message",
266-
event.properties.info.sessionID,
296+
sessionID,
267297
produce((draft) => {
268298
draft.splice(result.index, 0, event.properties.info)
269299
}),
@@ -285,19 +315,30 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
285315
break
286316
}
287317
case "message.part.updated": {
288-
const parts = store.part[event.properties.part.messageID]
318+
const sessionID = event.properties.part.sessionID
319+
const messageID = event.properties.part.messageID
320+
const parts = store.part[messageID]
321+
// If the parent message isn't in our window AND the window's
322+
// bottom has been evicted, drop the part - it would otherwise
323+
// be orphaned in store.part with no message to attach to.
324+
const inWindow = (() => {
325+
const messages = store.message[sessionID]
326+
if (!messages) return true
327+
return Binary.search(messages, messageID, (m) => m.id).found
328+
})()
289329
if (!parts) {
290-
setStore("part", event.properties.part.messageID, [event.properties.part])
330+
if (!inWindow && store.messageNewerCursor[sessionID]) break
331+
setStore("part", messageID, [event.properties.part])
291332
break
292333
}
293334
const result = Binary.search(parts, event.properties.part.id, (p) => p.id)
294335
if (result.found) {
295-
setStore("part", event.properties.part.messageID, result.index, reconcile(event.properties.part))
336+
setStore("part", messageID, result.index, reconcile(event.properties.part))
296337
break
297338
}
298339
setStore(
299340
"part",
300-
event.properties.part.messageID,
341+
messageID,
301342
produce((draft) => {
302343
draft.splice(result.index, 0, event.properties.part)
303344
}),
@@ -503,10 +544,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
503544
if (fullSyncedSessions.has(sessionID)) return
504545
const [session, messages, todo, diff] = await Promise.all([
505546
sdk.client.session.get({ sessionID }, { throwOnError: true }),
506-
sdk.client.session.messages({ sessionID }),
547+
sdk.client.session.messages({ sessionID, limit: INITIAL_PAGE_SIZE }),
507548
sdk.client.session.todo({ sessionID }),
508549
sdk.client.session.diff({ sessionID }),
509550
])
551+
const olderCursor = (messages.response?.headers.get("X-Next-Cursor") as string | null | undefined) ?? null
510552
setStore(
511553
produce((draft) => {
512554
const match = Binary.search(draft.session, sessionID, (s) => s.id)
@@ -520,13 +562,129 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
520562
}
521563
draft.message[sessionID] = infos
522564
draft.session_diff[sessionID] = diff.data ?? []
565+
draft.messageOlderCursor[sessionID] = olderCursor
566+
draft.messageNewerCursor[sessionID] = null
567+
}),
568+
)
569+
if (!olderCursor) fullSyncedSessions.add(sessionID)
570+
},
571+
async loadOlderMessages(sessionID: string) {
572+
const cursor = store.messageOlderCursor[sessionID]
573+
if (!cursor || store.messageOlderLoading[sessionID]) return
574+
setStore("messageOlderLoading", sessionID, true)
575+
try {
576+
const res = await sdk.client.session.messages({ sessionID, limit: PAGE_SIZE, before: cursor })
577+
const nextCursor = (res.response?.headers.get("X-Next-Cursor") as string | null | undefined) ?? null
578+
setStore(
579+
produce((draft) => {
580+
const existing = draft.message[sessionID] ?? []
581+
const prepend: Message[] = []
582+
for (const m of res.data ?? []) {
583+
draft.part[m.info.id] = m.parts
584+
prepend.push(m.info)
585+
}
586+
draft.message[sessionID] = [...prepend, ...existing]
587+
draft.messageOlderCursor[sessionID] = nextCursor
588+
}),
589+
)
590+
if (!nextCursor && !store.messageNewerCursor[sessionID]) fullSyncedSessions.add(sessionID)
591+
} finally {
592+
setStore("messageOlderLoading", sessionID, false)
593+
}
594+
},
595+
async loadNewerMessages(sessionID: string) {
596+
const cursor = store.messageNewerCursor[sessionID]
597+
if (!cursor || store.messageNewerLoading[sessionID]) return
598+
setStore("messageNewerLoading", sessionID, true)
599+
try {
600+
const res = await sdk.client.session.messages({ sessionID, limit: PAGE_SIZE, after: cursor })
601+
const nextCursor = (res.response?.headers.get("X-Next-Cursor") as string | null | undefined) ?? null
602+
setStore(
603+
produce((draft) => {
604+
const existing = draft.message[sessionID] ?? []
605+
const append: Message[] = []
606+
for (const m of res.data ?? []) {
607+
draft.part[m.info.id] = m.parts
608+
append.push(m.info)
609+
}
610+
draft.message[sessionID] = [...existing, ...append]
611+
draft.messageNewerCursor[sessionID] = nextCursor
612+
}),
613+
)
614+
if (!nextCursor && !store.messageOlderCursor[sessionID]) fullSyncedSessions.add(sessionID)
615+
} finally {
616+
setStore("messageNewerLoading", sessionID, false)
617+
}
618+
},
619+
trimNewerMessages(sessionID: string, cap: number) {
620+
const messages = store.message[sessionID]
621+
if (!messages || messages.length <= cap) return
622+
// Find the largest "safe" prefix length we can keep without
623+
// discarding a message that's still in flight (assistants
624+
// currently streaming) - those need to remain pinned so live
625+
// events can update them.
626+
let target = cap
627+
while (target < messages.length) {
628+
const tail = messages.slice(target)
629+
const hasInflight = tail.some(
630+
(m) => m.role === "assistant" && !m.time?.completed,
631+
)
632+
if (!hasInflight) break
633+
target++
634+
}
635+
if (target >= messages.length) return
636+
const evicted = messages.slice(target)
637+
const newLast = messages[target - 1]
638+
if (!newLast) return
639+
const cursorVal = encodeMessageCursor({ id: newLast.id, time: newLast.time.created })
640+
setStore(
641+
produce((draft) => {
642+
const arr = draft.message[sessionID]
643+
for (const ev of evicted) delete draft.part[ev.id]
644+
arr.length = target
645+
draft.messageNewerCursor[sessionID] = cursorVal
523646
}),
524647
)
525-
fullSyncedSessions.add(sessionID)
648+
fullSyncedSessions.delete(sessionID)
649+
},
650+
trimOlderMessages(sessionID: string, cap: number) {
651+
const messages = store.message[sessionID]
652+
if (!messages || messages.length <= cap) return
653+
const drop = messages.length - cap
654+
const evicted = messages.slice(0, drop)
655+
const newFirst = messages[drop]
656+
if (!newFirst) return
657+
const cursorVal = encodeMessageCursor({ id: newFirst.id, time: newFirst.time.created })
658+
setStore(
659+
produce((draft) => {
660+
const arr = draft.message[sessionID]
661+
for (const ev of evicted) delete draft.part[ev.id]
662+
arr.splice(0, drop)
663+
draft.messageOlderCursor[sessionID] = cursorVal
664+
}),
665+
)
666+
fullSyncedSessions.delete(sessionID)
667+
},
668+
async loadAllMessages(sessionID: string) {
669+
// Page through both directions until exhausted. Used by the
670+
// Timeline dialog so it can render every prompt in the session.
671+
while (store.messageOlderCursor[sessionID]) {
672+
await result.session.loadOlderMessages(sessionID)
673+
}
674+
while (store.messageNewerCursor[sessionID]) {
675+
await result.session.loadNewerMessages(sessionID)
676+
}
526677
},
527678
},
528679
bootstrap,
529680
}
530681
return result
531682
},
532683
})
684+
685+
const INITIAL_PAGE_SIZE = 100
686+
const PAGE_SIZE = 50
687+
688+
function encodeMessageCursor(input: { id: string; time: number }): string {
689+
return Buffer.from(JSON.stringify(input)).toString("base64url")
690+
}

packages/sdk/js/src/gen/types.gen.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2556,6 +2556,8 @@ export type SessionMessagesData = {
25562556
query?: {
25572557
directory?: string
25582558
limit?: number
2559+
before?: string
2560+
after?: string
25592561
}
25602562
url: "/session/{id}/message"
25612563
}

packages/sdk/js/src/v2/gen/sdk.gen.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3289,6 +3289,7 @@ export class Session2 extends HeyApiClient {
32893289
workspace?: string
32903290
limit?: number
32913291
before?: string
3292+
after?: string
32923293
},
32933294
options?: Options<never, ThrowOnError>,
32943295
) {
@@ -3302,6 +3303,7 @@ export class Session2 extends HeyApiClient {
33023303
{ in: "query", key: "workspace" },
33033304
{ in: "query", key: "limit" },
33043305
{ in: "query", key: "before" },
3306+
{ in: "query", key: "after" },
33053307
],
33063308
},
33073309
],

packages/sdk/js/src/v2/gen/types.gen.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5474,6 +5474,7 @@ export type SessionMessagesData = {
54745474
workspace?: string
54755475
limit?: number
54765476
before?: string
5477+
after?: string
54775478
}
54785479
url: "/session/{sessionID}/message"
54795480
}

packages/sdk/openapi.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5103,6 +5103,14 @@
51035103
"type": "string"
51045104
},
51055105
"required": false
5106+
},
5107+
{
5108+
"name": "after",
5109+
"in": "query",
5110+
"schema": {
5111+
"type": "string"
5112+
},
5113+
"required": false
51065114
}
51075115
],
51085116
"responses": {

0 commit comments

Comments
 (0)