Skip to content

Commit 2c70c15

Browse files
feat(session): add bi-directional cursor-based pagination with Home/End navigation
1 parent 85ab979 commit 2c70c15

File tree

13 files changed

+1229
-26
lines changed

13 files changed

+1229
-26
lines changed

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

Lines changed: 286 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@ import { useArgs } from "./args"
2828
import { batch, onMount } from "solid-js"
2929
import { Log } from "@/util/log"
3030
import type { Path } from "@opencode-ai/sdk"
31+
import { parseLinkHeader } from "@/util/link-header"
32+
33+
/** Maximum messages kept in memory per session */
34+
const MAX_LOADED_MESSAGES = 500
35+
/** Chunk size for eviction when limit exceeded */
36+
const EVICTION_CHUNK_SIZE = 50
3137

3238
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
3339
name: "Sync",
@@ -48,6 +54,15 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
4854
}
4955
config: Config
5056
session: Session[]
57+
message_page: {
58+
[sessionID: string]: {
59+
hasOlder: boolean
60+
hasNewer: boolean
61+
loading: boolean
62+
loadingDirection?: "older" | "newer"
63+
error?: string
64+
}
65+
}
5166
session_status: {
5267
[sessionID: string]: SessionStatus
5368
}
@@ -89,6 +104,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
89104
provider: [],
90105
provider_default: {},
91106
session: [],
107+
message_page: {},
92108
session_status: {},
93109
session_diff: {},
94110
todo: {},
@@ -226,22 +242,26 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
226242
}
227243

228244
case "message.updated": {
229-
const messages = store.message[event.properties.info.sessionID]
245+
const sessionID = event.properties.info.sessionID
246+
const page = store.message_page[sessionID]
247+
const messages = store.message[sessionID]
230248
if (!messages) {
231-
setStore("message", event.properties.info.sessionID, [event.properties.info])
249+
setStore("message", sessionID, [event.properties.info])
232250
break
233251
}
234252
const result = Binary.search(messages, event.properties.info.id, (m) => m.id)
235253
if (result.found) {
236-
setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info))
254+
setStore("message", sessionID, result.index, reconcile(event.properties.info))
255+
break
256+
}
257+
if (page?.hasNewer) {
237258
break
238259
}
239260
setStore(
240261
"message",
241-
event.properties.info.sessionID,
262+
sessionID,
242263
produce((draft) => {
243264
draft.splice(result.index, 0, event.properties.info)
244-
if (draft.length > 100) draft.shift()
245265
}),
246266
)
247267
break
@@ -261,6 +281,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
261281
break
262282
}
263283
case "message.part.updated": {
284+
const sessionID = event.properties.part.sessionID
285+
const page = store.message_page[sessionID]
286+
const messages = store.message[sessionID]
287+
const messageExists = messages?.some((m) => m.id === event.properties.part.messageID)
288+
if (page?.hasNewer && !messageExists) {
289+
break
290+
}
264291
const parts = store.part[event.properties.part.messageID]
265292
if (!parts) {
266293
setStore("part", event.properties.part.messageID, [event.properties.part])
@@ -371,6 +398,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
371398
})
372399

373400
const fullSyncedSessions = new Set<string>()
401+
const loadingGuard = new Set<string>()
374402
const result = {
375403
data: store,
376404
set: setStore,
@@ -404,6 +432,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
404432
sdk.client.session.todo({ sessionID }),
405433
sdk.client.session.diff({ sessionID }),
406434
])
435+
const link = messages.response.headers.get("link") ?? ""
436+
const hasOlder = parseLinkHeader(link).prev !== undefined
407437
setStore(
408438
produce((draft) => {
409439
const match = Binary.search(draft.session, sessionID, (s) => s.id)
@@ -415,10 +445,261 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
415445
draft.part[message.info.id] = message.parts
416446
}
417447
draft.session_diff[sessionID] = diff.data ?? []
448+
draft.message_page[sessionID] = { hasOlder, hasNewer: false, loading: false, error: undefined }
418449
}),
419450
)
420451
fullSyncedSessions.add(sessionID)
421452
},
453+
async loadOlder(sessionID: string) {
454+
const page = store.message_page[sessionID]
455+
if (page?.loading || !page?.hasOlder) return
456+
const messages = store.message[sessionID] ?? []
457+
const oldest = messages.at(0)
458+
if (!oldest) return
459+
if (loadingGuard.has(sessionID)) return
460+
loadingGuard.add(sessionID)
461+
try {
462+
setStore("message_page", sessionID, { ...page, loading: true, loadingDirection: "older", error: undefined })
463+
464+
const res = await sdk.client.session.messages(
465+
{ sessionID, before: oldest.id, limit: 100 },
466+
{ throwOnError: true },
467+
)
468+
const link = res.response.headers.get("link") ?? ""
469+
const hasOlder = parseLinkHeader(link).prev !== undefined
470+
setStore(
471+
produce((draft) => {
472+
const existing = draft.message[sessionID] ?? []
473+
for (const msg of res.data ?? []) {
474+
const match = Binary.search(existing, msg.info.id, (m) => m.id)
475+
if (!match.found) {
476+
existing.splice(match.index, 0, msg.info)
477+
draft.part[msg.info.id] = msg.parts
478+
}
479+
}
480+
if (existing.length > MAX_LOADED_MESSAGES + EVICTION_CHUNK_SIZE) {
481+
const evicted = existing.splice(-(existing.length - MAX_LOADED_MESSAGES))
482+
for (const msg of evicted) delete draft.part[msg.id]
483+
draft.message_page[sessionID] = { hasOlder, hasNewer: true, loading: false, error: undefined }
484+
} else {
485+
draft.message_page[sessionID] = {
486+
hasOlder,
487+
hasNewer: draft.message_page[sessionID]?.hasNewer ?? false,
488+
loading: false,
489+
error: undefined,
490+
}
491+
}
492+
}),
493+
)
494+
} catch (e) {
495+
const page = store.message_page[sessionID]
496+
setStore("message_page", sessionID, {
497+
hasOlder: page?.hasOlder ?? false,
498+
hasNewer: page?.hasNewer ?? false,
499+
loading: false,
500+
error: e instanceof Error ? e.message : String(e),
501+
})
502+
} finally {
503+
loadingGuard.delete(sessionID)
504+
}
505+
},
506+
async loadNewer(sessionID: string) {
507+
const page = store.message_page[sessionID]
508+
if (page?.loading || !page?.hasNewer) return
509+
const messages = store.message[sessionID] ?? []
510+
const newest = messages.at(-1)
511+
if (!newest) return
512+
if (loadingGuard.has(sessionID)) return
513+
loadingGuard.add(sessionID)
514+
try {
515+
setStore("message_page", sessionID, { ...page, loading: true, loadingDirection: "newer", error: undefined })
516+
517+
const res = await sdk.client.session.messages(
518+
{ sessionID, after: newest.id, limit: 100 },
519+
{ throwOnError: true },
520+
)
521+
const link = res.response.headers.get("link") ?? ""
522+
const hasNewer = parseLinkHeader(link).next !== undefined
523+
setStore(
524+
produce((draft) => {
525+
const existing = draft.message[sessionID] ?? []
526+
for (const msg of res.data ?? []) {
527+
const match = Binary.search(existing, msg.info.id, (m) => m.id)
528+
if (!match.found) {
529+
existing.splice(match.index, 0, msg.info)
530+
draft.part[msg.info.id] = msg.parts
531+
}
532+
}
533+
if (existing.length > MAX_LOADED_MESSAGES + EVICTION_CHUNK_SIZE) {
534+
const evicted = existing.splice(0, existing.length - MAX_LOADED_MESSAGES)
535+
for (const msg of evicted) delete draft.part[msg.id]
536+
draft.message_page[sessionID] = { hasOlder: true, hasNewer, loading: false, error: undefined }
537+
} else {
538+
draft.message_page[sessionID] = {
539+
hasOlder: draft.message_page[sessionID]?.hasOlder ?? false,
540+
hasNewer,
541+
loading: false,
542+
error: undefined,
543+
}
544+
}
545+
}),
546+
)
547+
} catch (e) {
548+
const page = store.message_page[sessionID]
549+
setStore("message_page", sessionID, {
550+
hasOlder: page?.hasOlder ?? false,
551+
hasNewer: page?.hasNewer ?? false,
552+
loading: false,
553+
error: e instanceof Error ? e.message : String(e),
554+
})
555+
} finally {
556+
loadingGuard.delete(sessionID)
557+
}
558+
},
559+
async jumpToLatest(sessionID: string) {
560+
const page = store.message_page[sessionID]
561+
if (page?.loading || !page?.hasNewer) return
562+
if (loadingGuard.has(sessionID)) return
563+
loadingGuard.add(sessionID)
564+
565+
try {
566+
// Check for revert state
567+
const session = store.session.find((s) => s.id === sessionID)
568+
const revertMessageID = session?.revert?.messageID
569+
570+
setStore("message_page", sessionID, {
571+
...page,
572+
loading: true,
573+
loadingDirection: "newer",
574+
error: undefined,
575+
})
576+
577+
// Fetch newest page (no cursor = newest)
578+
const res = await sdk.client.session.messages({ sessionID, limit: 100 }, { throwOnError: true })
579+
580+
let messages = res.data ?? []
581+
const link = res.response.headers.get("link") ?? ""
582+
const hasOlder = parseLinkHeader(link).prev !== undefined
583+
584+
// Revert-aware: If in revert state and marker not in results, fetch it
585+
if (revertMessageID && !messages.some((m) => m.info.id === revertMessageID)) {
586+
try {
587+
const revertResult = await sdk.client.session.message(
588+
{ sessionID, messageID: revertMessageID },
589+
{ throwOnError: true },
590+
)
591+
if (revertResult.data) {
592+
// Prepend revert message (it's older than newest page)
593+
messages = [revertResult.data, ...messages]
594+
}
595+
} catch (e) {
596+
// Revert message may have been deleted, continue without it
597+
Log.Default.info("Revert marker fetch failed (may be deleted)", {
598+
messageID: revertMessageID,
599+
error: e,
600+
})
601+
}
602+
}
603+
604+
setStore(
605+
produce((draft) => {
606+
// Clean up parts only for messages not in new results
607+
const oldMessages = draft.message[sessionID] ?? []
608+
const newIds = new Set(messages.map((m) => m.info.id))
609+
for (const msg of oldMessages) {
610+
if (!newIds.has(msg.id)) {
611+
delete draft.part[msg.id]
612+
}
613+
}
614+
615+
// Store new messages
616+
draft.message[sessionID] = messages.map((m) => m.info)
617+
for (const msg of messages) {
618+
draft.part[msg.info.id] = msg.parts
619+
}
620+
draft.message_page[sessionID] = {
621+
hasOlder,
622+
hasNewer: false,
623+
loading: false,
624+
error: undefined,
625+
}
626+
}),
627+
)
628+
} catch (e) {
629+
setStore(
630+
produce((draft) => {
631+
const p = draft.message_page[sessionID]
632+
if (p) {
633+
p.loading = false
634+
p.error = e instanceof Error ? e.message : String(e)
635+
}
636+
}),
637+
)
638+
} finally {
639+
loadingGuard.delete(sessionID)
640+
}
641+
},
642+
async jumpToOldest(sessionID: string) {
643+
const page = store.message_page[sessionID]
644+
if (page?.loading || !page?.hasOlder) return
645+
if (loadingGuard.has(sessionID)) return
646+
loadingGuard.add(sessionID)
647+
648+
try {
649+
setStore("message_page", sessionID, {
650+
...page,
651+
loading: true,
652+
loadingDirection: "older",
653+
error: undefined,
654+
})
655+
656+
const res = await sdk.client.session.messages(
657+
{ sessionID, oldest: true, limit: 100 },
658+
{ throwOnError: true },
659+
)
660+
661+
const messages = res.data ?? []
662+
const link = res.response.headers.get("link") ?? ""
663+
const hasNewer = parseLinkHeader(link).next !== undefined
664+
665+
setStore(
666+
produce((draft) => {
667+
// Clean up parts only for messages not in new results
668+
const oldMessages = draft.message[sessionID] ?? []
669+
const newIds = new Set(messages.map((m) => m.info.id))
670+
for (const msg of oldMessages) {
671+
if (!newIds.has(msg.id)) {
672+
delete draft.part[msg.id]
673+
}
674+
}
675+
676+
// Store new messages
677+
draft.message[sessionID] = messages.map((m) => m.info)
678+
for (const msg of messages) {
679+
draft.part[msg.info.id] = msg.parts
680+
}
681+
draft.message_page[sessionID] = {
682+
hasOlder: false,
683+
hasNewer,
684+
loading: false,
685+
error: undefined,
686+
}
687+
}),
688+
)
689+
} catch (e) {
690+
setStore(
691+
produce((draft) => {
692+
const p = draft.message_page[sessionID]
693+
if (p) {
694+
p.loading = false
695+
p.error = e instanceof Error ? e.message : String(e)
696+
}
697+
}),
698+
)
699+
} finally {
700+
loadingGuard.delete(sessionID)
701+
}
702+
},
422703
},
423704
bootstrap,
424705
}

0 commit comments

Comments
 (0)