From 9b033347e7b13c39c069fccf28ba7eca047e4724 Mon Sep 17 00:00:00 2001 From: Vladimir Petrigo Date: Sun, 10 May 2026 16:50:33 +0200 Subject: [PATCH 1/6] tui: show full session history in chat and timeline Long sessions previously dropped older messages from the in-memory store: the initial sync was capped at 100 and a per-event eviction removed the oldest message whenever new ones arrived, leaving the chat starting mid-conversation and the Timeline / Jump-to-Message features unable to reach the missing entries. - Remove the 100-message limit from the initial session sync so all historical messages are loaded. - Remove the eviction block from the message.updated handler so older messages are no longer purged when new ones arrive. - Add Locale.datetimeFull (DD/MM/YYYY HH:MM) and use it for the Timeline footer so each entry shows full date and time instead of just time. --- .../opencode/src/cli/cmd/tui/context/sync.tsx | 21 +------------------ .../tui/routes/session/dialog-timeline.tsx | 2 +- packages/opencode/src/util/locale.ts | 10 +++++++++ 3 files changed, 12 insertions(+), 21 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 9f8a384f777f..587c79f8e5a4 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -268,25 +268,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ 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": { @@ -522,7 +503,7 @@ 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 }), sdk.client.session.todo({ sessionID }), sdk.client.session.diff({ sessionID }), ]) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx index 87248a6a8ba6..4084b38d7743 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx @@ -31,7 +31,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(() => ( diff --git a/packages/opencode/src/util/locale.ts b/packages/opencode/src/util/locale.ts index ec900b441679..344cad85aeaf 100644 --- a/packages/opencode/src/util/locale.ts +++ b/packages/opencode/src/util/locale.ts @@ -14,6 +14,16 @@ export function datetime(input: number): string { return `${localTime} · ${localDate}` } +export function datetimeFull(input: number): string { + const date = new Date(input) + const dd = String(date.getDate()).padStart(2, "0") + const mm = String(date.getMonth() + 1).padStart(2, "0") + const yyyy = date.getFullYear() + const hh = String(date.getHours()).padStart(2, "0") + const min = String(date.getMinutes()).padStart(2, "0") + return `${dd}/${mm}/${yyyy} ${hh}:${min}` +} + export function todayTimeOrDateTime(input: number): string { const date = new Date(input) const now = new Date() From 06c68ff6ab928cf713e57c041f9aff3e71ac8d65 Mon Sep 17 00:00:00 2001 From: Vladimir Petrigo Date: Sun, 10 May 2026 16:57:20 +0200 Subject: [PATCH 2/6] api: add `after` cursor for forward pagination on /session/:id/message The v1 messages endpoint previously only supported backward pagination via the `before` cursor. Adding an `after` cursor enables the TUI to recover messages that have been evicted from the in-memory window when the user scrolls back toward the live tail. - MessageV2.page accepts an optional `after` cursor (mutually exclusive with `before`); when set, it returns the next page in ascending chronological order using a `newer()` predicate. - Handler validates that only one of `before`/`after` is supplied, decodes the cursor, and emits the `Link` / `X-Next-Cursor` headers with the correct direction parameter. - MessagesQuery schema gains an optional `after` field. - Tests cover forward pagination across multiple pages and the before+after-together rejection path. --- .../routes/instance/httpapi/groups/session.ts | 1 + .../instance/httpapi/handlers/session.ts | 15 ++++++- packages/opencode/src/session/message-v2.ts | 24 +++++++--- .../test/session/messages-pagination.test.ts | 45 +++++++++++++++++++ 4 files changed, 78 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts index b8c8a142be6e..a1fa8aa125a8 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts @@ -41,6 +41,7 @@ export const MessagesQuery = Schema.Struct({ ...WorkspaceRoutingQueryFields, limit: Schema.optional(Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0))), before: Schema.optional(Schema.String), + after: Schema.optional(Schema.String), }) export const StatusMap = Schema.Record(Schema.String, SessionStatus.Info) export const UpdatePayload = Schema.Struct({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index b12be2cfc2dc..3f1a2b5af475 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -97,7 +97,9 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", params: { sessionID: SessionID } query: typeof MessagesQuery.Type }) { - if (ctx.query.before && ctx.query.limit === undefined) return yield* new HttpApiError.BadRequest({}) + if ((ctx.query.before || ctx.query.after) && ctx.query.limit === undefined) + return yield* new HttpApiError.BadRequest({}) + if (ctx.query.before && ctx.query.after) return yield* new HttpApiError.BadRequest({}) if (ctx.query.before) { const before = ctx.query.before yield* Effect.try({ @@ -105,6 +107,13 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", catch: () => new HttpApiError.BadRequest({}), }) } + if (ctx.query.after) { + const after = ctx.query.after + yield* Effect.try({ + try: () => MessageV2.cursor.decode(after), + catch: () => new HttpApiError.BadRequest({}), + }) + } yield* requireSession(ctx.params.sessionID) if (ctx.query.limit === undefined || ctx.query.limit === 0) { return yield* SessionError.mapStorageNotFound(session.messages({ sessionID: ctx.params.sessionID })) @@ -115,6 +124,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", sessionID: ctx.params.sessionID, limit: ctx.query.limit, before: ctx.query.before, + after: ctx.query.after, }), ) if (!page.cursor) return page.items @@ -124,7 +134,8 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", // header echoes the real origin instead of a hard-coded localhost. const url = Option.getOrElse(HttpServerRequest.toURL(request), () => new URL(request.url, "http://localhost")) url.searchParams.set("limit", ctx.query.limit.toString()) - url.searchParams.set("before", page.cursor) + const direction = ctx.query.after ? "after" : "before" + url.searchParams.set(direction, page.cursor) return HttpServerResponse.jsonUnsafe(page.items, { headers: { "Access-Control-Expose-Headers": "Link, X-Next-Cursor", diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 869ef979f278..a084b583a868 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -12,6 +12,8 @@ import { desc } from "drizzle-orm" import { eq } from "drizzle-orm" import { inArray } from "drizzle-orm" import { lt } from "drizzle-orm" +import { gt } from "drizzle-orm" +import { asc } from "drizzle-orm" import { or } from "drizzle-orm" import { MessageTable, PartTable, SessionTable } from "./session.sql" import * as ProviderError from "@/provider/error" @@ -595,6 +597,9 @@ const part = (row: typeof PartTable.$inferSelect) => const older = (row: Cursor) => or(lt(MessageTable.time_created, row.time), and(eq(MessageTable.time_created, row.time), lt(MessageTable.id, row.id))) +const newer = (row: Cursor) => + or(gt(MessageTable.time_created, row.time), and(eq(MessageTable.time_created, row.time), gt(MessageTable.id, row.id))) + function hydrate(rows: (typeof MessageTable.$inferSelect)[]) { const ids = rows.map((row) => row.id) const partByMessage = new Map() @@ -923,17 +928,26 @@ export const page = Effect.fn("MessageV2.page")(function* (input: { sessionID: SessionID limit: number before?: string + after?: string }) { + if (input.before && input.after) + throw new Error("page: only one of `before` or `after` may be provided") const before = input.before ? cursor.decode(input.before) : undefined + const after = input.after ? cursor.decode(input.after) : undefined const where = before ? and(eq(MessageTable.session_id, input.sessionID), older(before)) - : eq(MessageTable.session_id, input.sessionID) + : after + ? and(eq(MessageTable.session_id, input.sessionID), newer(after)) + : eq(MessageTable.session_id, input.sessionID) const rows = Database.use((db) => db .select() .from(MessageTable) .where(where) - .orderBy(desc(MessageTable.time_created), desc(MessageTable.id)) + .orderBy( + after ? asc(MessageTable.time_created) : desc(MessageTable.time_created), + after ? asc(MessageTable.id) : desc(MessageTable.id), + ) .limit(input.limit + 1) .all(), ) @@ -951,12 +965,12 @@ export const page = Effect.fn("MessageV2.page")(function* (input: { const more = rows.length > input.limit const slice = more ? rows.slice(0, input.limit) : rows const items = hydrate(slice) - items.reverse() - const tail = slice.at(-1) + if (!after) items.reverse() + const cursorRow = slice.at(-1) return { items, more, - cursor: more && tail ? cursor.encode({ id: tail.id, time: tail.time_created }) : undefined, + cursor: more && cursorRow ? cursor.encode({ id: cursorRow.id, time: cursorRow.time_created }) : undefined, } }) diff --git a/packages/opencode/test/session/messages-pagination.test.ts b/packages/opencode/test/session/messages-pagination.test.ts index e558d07b500f..2b756c59eda7 100644 --- a/packages/opencode/test/session/messages-pagination.test.ts +++ b/packages/opencode/test/session/messages-pagination.test.ts @@ -289,6 +289,51 @@ describe("MessageV2.page", () => { }), ) + test("pages forward with after cursor", async () => { + await WithInstance.provide({ + directory: root, + fn: async () => { + const session = await svc.create({}) + const ids = await fill(session.id, 6) + + // Anchor at "before everything": all messages are newer than time 0 + const anchor = MessageV2.cursor.encode({ id: MessageID.ascending(), time: 0 }) + + const a = MessageV2.page({ sessionID: session.id, limit: 2, after: anchor }) + expect(a.items.map((item) => item.info.id)).toEqual(ids.slice(0, 2)) + expect(a.more).toBe(true) + expect(a.cursor).toBeTruthy() + + const b = MessageV2.page({ sessionID: session.id, limit: 2, after: a.cursor! }) + expect(b.items.map((item) => item.info.id)).toEqual(ids.slice(2, 4)) + expect(b.more).toBe(true) + expect(b.cursor).toBeTruthy() + + const c = MessageV2.page({ sessionID: session.id, limit: 2, after: b.cursor! }) + expect(c.items.map((item) => item.info.id)).toEqual(ids.slice(4, 6)) + expect(c.more).toBe(false) + expect(c.cursor).toBeUndefined() + + await svc.remove(session.id) + }, + }) + }) + + test("rejects requests with both before and after", async () => { + await WithInstance.provide({ + directory: root, + fn: async () => { + const session = await svc.create({}) + await fill(session.id, 2) + const dummyCursor = MessageV2.cursor.encode({ id: MessageID.ascending(), time: 0 }) + expect(() => + MessageV2.page({ sessionID: session.id, limit: 2, before: dummyCursor, after: dummyCursor }), + ).toThrow() + await svc.remove(session.id) + }, + }) + }) + it.instance("large limit returns all messages without cursor", () => withSession(({ sessionID }) => Effect.gen(function* () { From 13af01150fa2569f1a0d467ebd236c6695a863c5 Mon Sep 17 00:00:00 2001 From: Vladimir Petrigo Date: Sun, 10 May 2026 17:04:29 +0200 Subject: [PATCH 3/6] 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. --- .../opencode/src/cli/cmd/tui/context/sync.tsx | 178 +++++++++++++++++- packages/sdk/js/src/gen/types.gen.ts | 2 + packages/sdk/js/src/v2/gen/sdk.gen.ts | 2 + packages/sdk/js/src/v2/gen/types.gen.ts | 1 + packages/sdk/openapi.json | 8 + 5 files changed, 181 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 587c79f8e5a4..c7b25949d8fd 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -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: [], @@ -105,6 +117,10 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ mcp_resource: {}, formatter: [], vcs: undefined, + messageOlderCursor: {}, + messageNewerCursor: {}, + messageOlderLoading: {}, + messageNewerLoading: {}, }) const event = useEvent() @@ -251,19 +267,33 @@ 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) }), @@ -285,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) }), @@ -503,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 }), + 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) @@ -520,9 +562,118 @@ 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 + }), + ) + 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.add(sessionID) + 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, @@ -530,3 +681,10 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ 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") +} diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index 5e4fd8906155..ac72368ec7e2 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -2556,6 +2556,8 @@ export type SessionMessagesData = { query?: { directory?: string limit?: number + before?: string + after?: string } url: "/session/{id}/message" } diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 37b938574399..ac8f86c3eac5 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -3294,6 +3294,7 @@ export class Session2 extends HeyApiClient { workspace?: string limit?: number before?: string + after?: string }, options?: Options, ) { @@ -3307,6 +3308,7 @@ export class Session2 extends HeyApiClient { { in: "query", key: "workspace" }, { in: "query", key: "limit" }, { in: "query", key: "before" }, + { in: "query", key: "after" }, ], }, ], diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 014a5fbabe2b..1b251b8d42ae 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -5689,6 +5689,7 @@ export type SessionMessagesData = { workspace?: string limit?: number before?: string + after?: string } url: "/session/{sessionID}/message" } diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 114db9cd743c..01e2ee4a5330 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -5113,6 +5113,14 @@ "type": "string" }, "required": false + }, + { + "name": "after", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { From 6a67795163f94dcbb033145e2e09ab645addc454 Mon Sep 17 00:00:00 2001 From: Vladimir Petrigo Date: Sun, 10 May 2026 17:07:46 +0200 Subject: [PATCH 4/6] tui: scroll-driven loading + windowing in session view Wires the new pagination helpers into the chat surface: - maybeLoadOlderMessages / maybeLoadNewerMessages run after every scroll input (key bindings: page/half-page/line up & down, first & last; mouse wheel via onMouseScroll). They fire only when the viewport is within five rows of the corresponding edge and a cursor is available. - After a successful prepend, the view restores the previous logical scroll position by adjusting for the height delta so the user does not jump. - When the in-memory window grows past 200 messages and the user is far from the opposite edge (>4 viewports away), the matching trim helper evicts the now-distant side; the streaming guard inside trimNewerMessages prevents dropping an in-flight assistant message. - A loader spinner is shown at the top of the scrollbox while older messages are being fetched, and at the bottom while newer ones are being recovered after eviction. Timeline dialog kicks off `loadAllMessages` on mount so every prompt in the session becomes selectable, even ones that were never within the live window. --- .../tui/routes/session/dialog-timeline.tsx | 1 + .../src/cli/cmd/tui/routes/session/index.tsx | 78 +++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx index 4084b38d7743..8275abd6ed3f 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx @@ -17,6 +17,7 @@ export function DialogTimeline(props: { onMount(() => { dialog.setSize("large") + void sync.session.loadAllMessages(props.sessionID) }) const options = createMemo((): DialogSelectOption[] => { diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index b5e8e10283e3..b4020cafb3b3 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -404,6 +404,61 @@ export function Session() { }, 50) } + // Pagination + asymmetric windowing + const WINDOW_CAP = 200 + const NEAR_TOP_THRESHOLD = 5 + const NEAR_BOTTOM_THRESHOLD = 5 + + async function maybeLoadOlderMessages() { + if (!scroll || scroll.isDestroyed) return + if (!sync.data.messageOlderCursor[route.sessionID]) return + if (sync.data.messageOlderLoading[route.sessionID]) return + if (scroll.y > NEAR_TOP_THRESHOLD) return + const prevScrollHeight = scroll.scrollHeight + const prevY = scroll.y + await sync.session.loadOlderMessages(route.sessionID) + // Trim from the bottom if the user is well above it - only safe when + // there's room above the live tail and no message there is still + // streaming. trimNewerMessages itself enforces the streaming guard. + const messages = sync.data.message[route.sessionID] ?? [] + if (messages.length > WINDOW_CAP && scroll.scrollHeight - prevY > scroll.height * 4) { + sync.session.trimNewerMessages(route.sessionID, WINDOW_CAP) + } + setTimeout(() => { + if (!scroll || scroll.isDestroyed) return + scroll.scrollTo(prevY + (scroll.scrollHeight - prevScrollHeight)) + }, 0) + } + + async function maybeLoadNewerMessages() { + if (!scroll || scroll.isDestroyed) return + if (!sync.data.messageNewerCursor[route.sessionID]) return + if (sync.data.messageNewerLoading[route.sessionID]) return + const distanceFromBottom = scroll.scrollHeight - scroll.height - scroll.y + if (distanceFromBottom > NEAR_BOTTOM_THRESHOLD) return + const prevScrollHeight = scroll.scrollHeight + const prevY = scroll.y + await sync.session.loadNewerMessages(route.sessionID) + // Trim from the top - older messages can always be re-fetched via the + // older cursor, no streaming concern. + const messages = sync.data.message[route.sessionID] ?? [] + if (messages.length > WINDOW_CAP && prevY > scroll.height * 4) { + sync.session.trimOlderMessages(route.sessionID, WINDOW_CAP) + } + setTimeout(() => { + if (!scroll || scroll.isDestroyed) return + // After append, scroll position relative to top is unchanged. After + // top-trim, content above shrinks - keep the same logical position. + const heightDelta = scroll.scrollHeight - prevScrollHeight + if (heightDelta < 0) scroll.scrollTo(Math.max(0, prevY + heightDelta)) + }, 0) + } + + function maybeLoadAdjacent() { + void maybeLoadOlderMessages() + void maybeLoadNewerMessages() + } + const local = useLocal() function moveFirstChild() { @@ -729,6 +784,7 @@ export function Session() { hidden: true, run: () => { scroll.scrollBy(-scroll.height / 2) + maybeLoadAdjacent() dialog.clear() }, }, @@ -739,6 +795,7 @@ export function Session() { hidden: true, run: () => { scroll.scrollBy(scroll.height / 2) + maybeLoadAdjacent() dialog.clear() }, }, @@ -749,6 +806,7 @@ export function Session() { hidden: true, run: () => { scroll.scrollBy(-1) + maybeLoadAdjacent() dialog.clear() }, }, @@ -759,6 +817,7 @@ export function Session() { hidden: true, run: () => { scroll.scrollBy(1) + maybeLoadAdjacent() dialog.clear() }, }, @@ -769,6 +828,7 @@ export function Session() { hidden: true, run: () => { scroll.scrollBy(-scroll.height / 4) + maybeLoadAdjacent() dialog.clear() }, }, @@ -779,6 +839,7 @@ export function Session() { hidden: true, run: () => { scroll.scrollBy(scroll.height / 4) + maybeLoadAdjacent() dialog.clear() }, }, @@ -789,6 +850,7 @@ export function Session() { hidden: true, run: () => { scroll.scrollTo(0) + maybeLoadAdjacent() dialog.clear() }, }, @@ -799,6 +861,7 @@ export function Session() { hidden: true, run: () => { scroll.scrollTo(scroll.scrollHeight) + maybeLoadAdjacent() dialog.clear() }, }, @@ -1116,7 +1179,17 @@ export function Session() { stickyStart="bottom" flexGrow={1} scrollAcceleration={scrollAcceleration()} + onMouseScroll={() => { + // Defer until after the scrollbox has applied the scroll + // delta so scroll.y reflects the post-event position. + setTimeout(() => maybeLoadAdjacent(), 0) + }} > + + + Loading older messages… + + {(message, index) => ( @@ -1213,6 +1286,11 @@ export function Session() { )} + + + Loading newer messages… + + 0}> From c65fe8a013e91f164a3d907f9c1d6d9a8e64d071 Mon Sep 17 00:00:00 2001 From: Vladimir Petrigo Date: Tue, 12 May 2026 11:22:46 +0200 Subject: [PATCH 5/6] tests: refactor message pagination tests to use `withSession` helper --- .../test/session/messages-pagination.test.ts | 44 ++++++++----------- 1 file changed, 19 insertions(+), 25 deletions(-) diff --git a/packages/opencode/test/session/messages-pagination.test.ts b/packages/opencode/test/session/messages-pagination.test.ts index 2b756c59eda7..06bf260cdc93 100644 --- a/packages/opencode/test/session/messages-pagination.test.ts +++ b/packages/opencode/test/session/messages-pagination.test.ts @@ -289,50 +289,44 @@ describe("MessageV2.page", () => { }), ) - test("pages forward with after cursor", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) - const ids = await fill(session.id, 6) + it.instance("pages forward with after cursor", () => + withSession(({ sessionID }) => + Effect.gen(function* () { + const ids = yield* fill(sessionID, 6) // Anchor at "before everything": all messages are newer than time 0 const anchor = MessageV2.cursor.encode({ id: MessageID.ascending(), time: 0 }) - const a = MessageV2.page({ sessionID: session.id, limit: 2, after: anchor }) + const a = MessageV2.page({ sessionID, limit: 2, after: anchor }) expect(a.items.map((item) => item.info.id)).toEqual(ids.slice(0, 2)) expect(a.more).toBe(true) expect(a.cursor).toBeTruthy() - const b = MessageV2.page({ sessionID: session.id, limit: 2, after: a.cursor! }) + const b = MessageV2.page({ sessionID, limit: 2, after: a.cursor! }) expect(b.items.map((item) => item.info.id)).toEqual(ids.slice(2, 4)) expect(b.more).toBe(true) expect(b.cursor).toBeTruthy() - const c = MessageV2.page({ sessionID: session.id, limit: 2, after: b.cursor! }) + const c = MessageV2.page({ sessionID, limit: 2, after: b.cursor! }) expect(c.items.map((item) => item.info.id)).toEqual(ids.slice(4, 6)) expect(c.more).toBe(false) expect(c.cursor).toBeUndefined() + }), + ), + ) - await svc.remove(session.id) - }, - }) - }) - - test("rejects requests with both before and after", async () => { - await WithInstance.provide({ - directory: root, - fn: async () => { - const session = await svc.create({}) - await fill(session.id, 2) + it.instance("rejects requests with both before and after", () => + withSession(({ sessionID }) => + Effect.gen(function* () { + yield* fill(sessionID, 2) const dummyCursor = MessageV2.cursor.encode({ id: MessageID.ascending(), time: 0 }) + expect(() => - MessageV2.page({ sessionID: session.id, limit: 2, before: dummyCursor, after: dummyCursor }), + MessageV2.page({ sessionID, limit: 2, before: dummyCursor, after: dummyCursor }), ).toThrow() - await svc.remove(session.id) - }, - }) - }) + }), + ), + ) it.instance("large limit returns all messages without cursor", () => withSession(({ sessionID }) => From 3ea1e3e0b6a1b846868887ea73452a1e6a254798 Mon Sep 17 00:00:00 2001 From: Vladimir Petrigo Date: Thu, 14 May 2026 15:16:31 +0200 Subject: [PATCH 6/6] fix: Scrolling for long sessions - Changed all 3 occurrences of "X-Next-Cursor" "x-next-cursor" for case-insensitive header lookup reliability - Fix scrolling in the `maybeLoadOlderMessages` and `maybeLoadNewerMessages` --- .../opencode/src/cli/cmd/tui/context/sync.tsx | 6 +-- .../src/cli/cmd/tui/routes/session/index.tsx | 49 ++++++++++++++----- 2 files changed, 39 insertions(+), 16 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index c7b25949d8fd..a8759a60db20 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -548,7 +548,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ sdk.client.session.todo({ sessionID }), sdk.client.session.diff({ sessionID }), ]) - const olderCursor = (messages.response?.headers.get("X-Next-Cursor") as string | null | undefined) ?? null + 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) @@ -574,7 +574,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ 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 + const nextCursor = (res.response?.headers.get("x-next-cursor") as string | null | undefined) ?? null setStore( produce((draft) => { const existing = draft.message[sessionID] ?? [] @@ -598,7 +598,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ 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 + const nextCursor = (res.response?.headers.get("x-next-cursor") as string | null | undefined) ?? null setStore( produce((draft) => { const existing = draft.message[sessionID] ?? [] diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index e32100c838e6..0b6bd2abd5a9 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -413,20 +413,33 @@ export function Session() { if (!scroll || scroll.isDestroyed) return if (!sync.data.messageOlderCursor[route.sessionID]) return if (sync.data.messageOlderLoading[route.sessionID]) return - if (scroll.y > NEAR_TOP_THRESHOLD) return - const prevScrollHeight = scroll.scrollHeight - const prevY = scroll.y + if (scroll.scrollTop > NEAR_TOP_THRESHOLD) return + // Anchor-based scroll restoration: identify the first visible child + // so we can restore its position after content changes at either end. + // Note: child.y includes the scroll offset, so child.y - scroll.y + // gives the offset from the viewport top regardless of scroll position. + const anchor = scroll.getChildren().find((c) => c.id && c.y >= scroll.y) + const anchorId = anchor?.id + const anchorOffset = anchor ? anchor.y - scroll.y : undefined await sync.session.loadOlderMessages(route.sessionID) // Trim from the bottom if the user is well above it - only safe when // there's room above the live tail and no message there is still // streaming. trimNewerMessages itself enforces the streaming guard. const messages = sync.data.message[route.sessionID] ?? [] - if (messages.length > WINDOW_CAP && scroll.scrollHeight - prevY > scroll.height * 4) { + if (messages.length > WINDOW_CAP && scroll.scrollHeight - scroll.scrollTop > scroll.height * 4) { sync.session.trimNewerMessages(route.sessionID, WINDOW_CAP) } setTimeout(() => { if (!scroll || scroll.isDestroyed) return - scroll.scrollTo(prevY + (scroll.scrollHeight - prevScrollHeight)) + if (anchorId === undefined || anchorOffset === undefined) return + const anchorChild = scroll.getChildren().find((c) => c.id === anchorId) + if (anchorChild) { + // Use scrollBy with the delta (anchorChild.y - scroll.y - anchorOffset) + // rather than scrollTo with an absolute position. The child.y values + // include the scroll offset, so child.y - scroll.y cancels it out, + // giving a correct delta regardless of scroll.y vs scrollTop. + scroll.scrollBy(anchorChild.y - scroll.y - anchorOffset) + } }, 0) } @@ -434,23 +447,33 @@ export function Session() { if (!scroll || scroll.isDestroyed) return if (!sync.data.messageNewerCursor[route.sessionID]) return if (sync.data.messageNewerLoading[route.sessionID]) return - const distanceFromBottom = scroll.scrollHeight - scroll.height - scroll.y + const distanceFromBottom = scroll.scrollHeight - scroll.height - scroll.scrollTop if (distanceFromBottom > NEAR_BOTTOM_THRESHOLD) return - const prevScrollHeight = scroll.scrollHeight - const prevY = scroll.y + // Anchor-based scroll restoration: identify the first visible child + // so we can restore its position after content changes at either end. + // Note: child.y includes the scroll offset, so child.y - scroll.y + // gives the offset from the viewport top regardless of scroll position. + const anchor = scroll.getChildren().find((c) => c.id && c.y >= scroll.y) + const anchorId = anchor?.id + const anchorOffset = anchor ? anchor.y - scroll.y : undefined await sync.session.loadNewerMessages(route.sessionID) // Trim from the top - older messages can always be re-fetched via the // older cursor, no streaming concern. const messages = sync.data.message[route.sessionID] ?? [] - if (messages.length > WINDOW_CAP && prevY > scroll.height * 4) { + if (messages.length > WINDOW_CAP && scroll.scrollTop > scroll.height * 4) { sync.session.trimOlderMessages(route.sessionID, WINDOW_CAP) } setTimeout(() => { if (!scroll || scroll.isDestroyed) return - // After append, scroll position relative to top is unchanged. After - // top-trim, content above shrinks - keep the same logical position. - const heightDelta = scroll.scrollHeight - prevScrollHeight - if (heightDelta < 0) scroll.scrollTo(Math.max(0, prevY + heightDelta)) + if (anchorId === undefined || anchorOffset === undefined) return + const anchorChild = scroll.getChildren().find((c) => c.id === anchorId) + if (anchorChild) { + // Use scrollBy with the delta (anchorChild.y - scroll.y - anchorOffset) + // rather than scrollTo with an absolute position. The child.y values + // include the scroll offset, so child.y - scroll.y cancels it out, + // giving a correct delta regardless of scroll.y vs scrollTop. + scroll.scrollBy(anchorChild.y - scroll.y - anchorOffset) + } }, 0) }