Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
9b03334
tui: show full session history in chat and timeline
vpetrigo May 10, 2026
06c68ff
api: add `after` cursor for forward pagination on /session/:id/message
vpetrigo May 10, 2026
13af011
tui: bidirectional message pagination with asymmetric windowing
vpetrigo May 10, 2026
6a67795
tui: scroll-driven loading + windowing in session view
vpetrigo May 10, 2026
c65fe8a
tests: refactor message pagination tests to use `withSession` helper
vpetrigo May 12, 2026
6c11870
Merge branch 'dev' into issue/7380
vpetrigo May 13, 2026
43c4728
Merge branch 'dev' into issue/7380
vpetrigo May 13, 2026
167c28f
Merge branch 'dev' into issue/7380
vpetrigo May 14, 2026
1f26910
Merge branch 'dev' into issue/7380
vpetrigo May 14, 2026
3ea1e3e
fix: Scrolling for long sessions
vpetrigo May 14, 2026
f6275a3
Merge branch 'dev' into issue/7380
vpetrigo May 14, 2026
e71d54f
Merge branch 'dev' into issue/7380
vpetrigo May 14, 2026
ce4258d
Merge branch 'dev' into issue/7380
vpetrigo May 15, 2026
42b6d06
Merge branch 'dev' into issue/7380
vpetrigo May 15, 2026
a771329
Merge branch 'dev' into issue/7380
vpetrigo May 15, 2026
19a7fb2
Merge branch 'dev' into issue/7380
vpetrigo May 15, 2026
e512612
Merge branch 'dev' into issue/7380
vpetrigo May 16, 2026
24a30e5
Merge branch 'dev' into issue/7380
vpetrigo May 16, 2026
080c9b2
Merge branch 'dev' into issue/7380
vpetrigo May 17, 2026
ccdfc4b
Merge branch 'dev' into issue/7380
vpetrigo May 18, 2026
54e08b2
Merge branch 'dev' into issue/7380
vpetrigo May 18, 2026
49f7895
Merge branch 'dev' into issue/7380
vpetrigo May 18, 2026
10399f3
Merge branch 'dev' into issue/7380
vpetrigo May 19, 2026
1953e68
Merge branch 'dev' into issue/7380
vpetrigo May 20, 2026
64d69bd
Merge branch 'dev' into issue/7380
vpetrigo May 21, 2026
345000f
Merge branch 'dev' into issue/7380
vpetrigo May 21, 2026
ad3ad6d
Merge branch 'dev' into issue/7380
vpetrigo May 21, 2026
80725ac
Merge branch 'dev' into issue/7380
vpetrigo May 21, 2026
16b198d
Merge branch 'dev' into issue/7380
vpetrigo May 22, 2026
65960a4
Merge branch 'dev' into issue/7380
vpetrigo May 22, 2026
2eaa2eb
Merge branch 'dev' into issue/7380
vpetrigo May 23, 2026
06d9ccc
Merge branch 'dev' into issue/7380
vpetrigo May 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
197 changes: 168 additions & 29 deletions packages/opencode/src/cli/cmd/tui/context/sync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,18 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
}
formatter: FormatterStatus[]
vcs: VcsInfo | undefined
messageOlderCursor: {
[sessionID: string]: string | null
}
messageNewerCursor: {
[sessionID: string]: string | null
}
messageOlderLoading: {
[sessionID: string]: boolean
}
messageNewerLoading: {
[sessionID: string]: boolean
}
}>({
provider_next: {
all: [],
Expand Down Expand Up @@ -105,6 +117,10 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
mcp_resource: {},
formatter: [],
vcs: undefined,
messageOlderCursor: {},
messageNewerCursor: {},
messageOlderLoading: {},
messageNewerLoading: {},
})

const event = useEvent()
Expand Down Expand Up @@ -251,42 +267,37 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
}

case "message.updated": {
const messages = store.message[event.properties.info.sessionID]
const sessionID = event.properties.info.sessionID
const messages = store.message[sessionID]
if (!messages) {
setStore("message", event.properties.info.sessionID, [event.properties.info])
setStore("message", sessionID, [event.properties.info])
break
}
const result = Binary.search(messages, event.properties.info.id, (m) => m.id)
if (result.found) {
setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info))
setStore("message", sessionID, result.index, reconcile(event.properties.info))
break
}
// If the bottom of the window has been evicted (messageNewerCursor
// is set), drop messages that arrive past our visible tail. They
// will be loaded on demand when the user scrolls back down.
if (store.messageNewerCursor[sessionID]) {
const last = messages[messages.length - 1]
if (last) {
const incoming = event.properties.info
const isPastTail =
incoming.time.created > last.time.created ||
(incoming.time.created === last.time.created && incoming.id > last.id)
if (isPastTail) break
}
}
setStore(
"message",
event.properties.info.sessionID,
sessionID,
produce((draft) => {
draft.splice(result.index, 0, event.properties.info)
}),
)
const updated = store.message[event.properties.info.sessionID]
if (updated.length > 100) {
const oldest = updated[0]
batch(() => {
setStore(
"message",
event.properties.info.sessionID,
produce((draft) => {
draft.shift()
}),
)
setStore(
"part",
produce((draft) => {
delete draft[oldest.id]
}),
)
})
}
break
}
case "message.removed": {
Expand All @@ -304,19 +315,30 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
break
}
case "message.part.updated": {
const parts = store.part[event.properties.part.messageID]
const sessionID = event.properties.part.sessionID
const messageID = event.properties.part.messageID
const parts = store.part[messageID]
// If the parent message isn't in our window AND the window's
// bottom has been evicted, drop the part - it would otherwise
// be orphaned in store.part with no message to attach to.
const inWindow = (() => {
const messages = store.message[sessionID]
if (!messages) return true
return Binary.search(messages, messageID, (m) => m.id).found
})()
if (!parts) {
setStore("part", event.properties.part.messageID, [event.properties.part])
if (!inWindow && store.messageNewerCursor[sessionID]) break
setStore("part", messageID, [event.properties.part])
break
}
const result = Binary.search(parts, event.properties.part.id, (p) => p.id)
if (result.found) {
setStore("part", event.properties.part.messageID, result.index, reconcile(event.properties.part))
setStore("part", messageID, result.index, reconcile(event.properties.part))
break
}
setStore(
"part",
event.properties.part.messageID,
messageID,
produce((draft) => {
draft.splice(result.index, 0, event.properties.part)
}),
Expand Down Expand Up @@ -522,10 +544,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
if (fullSyncedSessions.has(sessionID)) return
const [session, messages, todo, diff] = await Promise.all([
sdk.client.session.get({ sessionID }, { throwOnError: true }),
sdk.client.session.messages({ sessionID, limit: 100 }),
sdk.client.session.messages({ sessionID, limit: INITIAL_PAGE_SIZE }),
sdk.client.session.todo({ sessionID }),
sdk.client.session.diff({ sessionID }),
])
const olderCursor = (messages.response?.headers.get("x-next-cursor") as string | null | undefined) ?? null
setStore(
produce((draft) => {
const match = Binary.search(draft.session, sessionID, (s) => s.id)
Expand All @@ -539,13 +562,129 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
}
draft.message[sessionID] = infos
draft.session_diff[sessionID] = diff.data ?? []
draft.messageOlderCursor[sessionID] = olderCursor
draft.messageNewerCursor[sessionID] = null
}),
)
fullSyncedSessions.add(sessionID)
if (!olderCursor) fullSyncedSessions.add(sessionID)
},
async loadOlderMessages(sessionID: string) {
const cursor = store.messageOlderCursor[sessionID]
if (!cursor || store.messageOlderLoading[sessionID]) return
setStore("messageOlderLoading", sessionID, true)
try {
const res = await sdk.client.session.messages({ sessionID, limit: PAGE_SIZE, before: cursor })
const nextCursor = (res.response?.headers.get("x-next-cursor") as string | null | undefined) ?? null
setStore(
produce((draft) => {
const existing = draft.message[sessionID] ?? []
const prepend: Message[] = []
for (const m of res.data ?? []) {
draft.part[m.info.id] = m.parts
prepend.push(m.info)
}
draft.message[sessionID] = [...prepend, ...existing]
draft.messageOlderCursor[sessionID] = nextCursor
}),
)
if (!nextCursor && !store.messageNewerCursor[sessionID]) fullSyncedSessions.add(sessionID)
} finally {
setStore("messageOlderLoading", sessionID, false)
}
},
async loadNewerMessages(sessionID: string) {
const cursor = store.messageNewerCursor[sessionID]
if (!cursor || store.messageNewerLoading[sessionID]) return
setStore("messageNewerLoading", sessionID, true)
try {
const res = await sdk.client.session.messages({ sessionID, limit: PAGE_SIZE, after: cursor })
const nextCursor = (res.response?.headers.get("x-next-cursor") as string | null | undefined) ?? null
setStore(
produce((draft) => {
const existing = draft.message[sessionID] ?? []
const append: Message[] = []
for (const m of res.data ?? []) {
draft.part[m.info.id] = m.parts
append.push(m.info)
}
draft.message[sessionID] = [...existing, ...append]
draft.messageNewerCursor[sessionID] = nextCursor
}),
)
if (!nextCursor && !store.messageOlderCursor[sessionID]) fullSyncedSessions.add(sessionID)
} finally {
setStore("messageNewerLoading", sessionID, false)
}
},
trimNewerMessages(sessionID: string, cap: number) {
const messages = store.message[sessionID]
if (!messages || messages.length <= cap) return
// Find the largest "safe" prefix length we can keep without
// discarding a message that's still in flight (assistants
// currently streaming) - those need to remain pinned so live
// events can update them.
let target = cap
while (target < messages.length) {
const tail = messages.slice(target)
const hasInflight = tail.some(
(m) => m.role === "assistant" && !m.time?.completed,
)
if (!hasInflight) break
target++
}
if (target >= messages.length) return
const evicted = messages.slice(target)
const newLast = messages[target - 1]
if (!newLast) return
const cursorVal = encodeMessageCursor({ id: newLast.id, time: newLast.time.created })
setStore(
produce((draft) => {
const arr = draft.message[sessionID]
for (const ev of evicted) delete draft.part[ev.id]
arr.length = target
draft.messageNewerCursor[sessionID] = cursorVal
}),
)
fullSyncedSessions.delete(sessionID)
},
trimOlderMessages(sessionID: string, cap: number) {
const messages = store.message[sessionID]
if (!messages || messages.length <= cap) return
const drop = messages.length - cap
const evicted = messages.slice(0, drop)
const newFirst = messages[drop]
if (!newFirst) return
const cursorVal = encodeMessageCursor({ id: newFirst.id, time: newFirst.time.created })
setStore(
produce((draft) => {
const arr = draft.message[sessionID]
for (const ev of evicted) delete draft.part[ev.id]
arr.splice(0, drop)
draft.messageOlderCursor[sessionID] = cursorVal
}),
)
fullSyncedSessions.delete(sessionID)
},
async loadAllMessages(sessionID: string) {
// Page through both directions until exhausted. Used by the
// Timeline dialog so it can render every prompt in the session.
while (store.messageOlderCursor[sessionID]) {
await result.session.loadOlderMessages(sessionID)
}
while (store.messageNewerCursor[sessionID]) {
await result.session.loadNewerMessages(sessionID)
}
},
},
bootstrap,
}
return result
},
})

const INITIAL_PAGE_SIZE = 100
const PAGE_SIZE = 50

function encodeMessageCursor(input: { id: string; time: number }): string {
return Buffer.from(JSON.stringify(input)).toString("base64url")
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export function DialogTimeline(props: {

onMount(() => {
dialog.setSize("large")
void sync.session.loadAllMessages(props.sessionID)
})

const options = createMemo((): DialogSelectOption<string>[] => {
Expand All @@ -31,7 +32,7 @@ export function DialogTimeline(props: {
result.push({
title: part.text.replace(/\n/g, " "),
value: message.id,
footer: Locale.time(message.time.created),
footer: Locale.datetimeFull(message.time.created),
onSelect: (dialog) => {
dialog.replace(() => (
<DialogMessage messageID={message.id} sessionID={props.sessionID} setPrompt={props.setPrompt} />
Expand Down
Loading
Loading