From 74c2a182d53feef57c62896df321b78413cbbe0b Mon Sep 17 00:00:00 2001 From: Vladimir Petrigo Date: Sun, 10 May 2026 16:50:33 +0200 Subject: [PATCH 01/12] 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. --- packages/opencode/src/util/locale.ts | 98 +++++++++++++++- packages/tui/src/context/sync.tsx | 111 ++++-------------- .../src/routes/session/dialog-timeline.tsx | 2 +- 3 files changed, 119 insertions(+), 92 deletions(-) diff --git a/packages/opencode/src/util/locale.ts b/packages/opencode/src/util/locale.ts index a08213380939..344cad85aeaf 100644 --- a/packages/opencode/src/util/locale.ts +++ b/packages/opencode/src/util/locale.ts @@ -1,2 +1,96 @@ -export * from "@opencode-ai/tui/util/locale" -export { Locale } from "@opencode-ai/tui/util/locale" +export function titlecase(str: string) { + return str.replace(/\b\w/g, (c) => c.toUpperCase()) +} + +export function time(input: number): string { + const date = new Date(input) + return date.toLocaleTimeString(undefined, { timeStyle: "short" }) +} + +export function datetime(input: number): string { + const date = new Date(input) + const localTime = time(input) + const localDate = date.toLocaleDateString() + 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() + const isToday = + date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth() && date.getDate() === now.getDate() + + if (isToday) { + return time(input) + } else { + return datetime(input) + } +} + +export function number(num: number): string { + if (num >= 1000000) { + return (num / 1000000).toFixed(1) + "M" + } else if (num >= 1000) { + return (num / 1000).toFixed(1) + "K" + } + return num.toString() +} + +export function duration(input: number) { + if (input < 1000) { + return `${input}ms` + } + if (input < 60000) { + return `${(input / 1000).toFixed(1)}s` + } + if (input < 3600000) { + const minutes = Math.floor(input / 60000) + const seconds = Math.floor((input % 60000) / 1000) + return `${minutes}m ${seconds}s` + } + if (input < 86400000) { + const hours = Math.floor(input / 3600000) + const minutes = Math.floor((input % 3600000) / 60000) + return `${hours}h ${minutes}m` + } + const hours = Math.floor(input / 3600000) + const days = Math.floor((input % 3600000) / 86400000) + return `${days}d ${hours}h` +} + +export function truncate(str: string, len: number): string { + if (str.length <= len) return str + return str.slice(0, len - 1) + "…" +} + +export function truncateLeft(str: string, len: number): string { + if (str.length <= len) return str + return "…" + str.slice(-(len - 1)) +} + +export function truncateMiddle(str: string, maxLength: number = 35): string { + if (str.length <= maxLength) return str + + const ellipsis = "…" + const keepStart = Math.ceil((maxLength - ellipsis.length) / 2) + const keepEnd = Math.floor((maxLength - ellipsis.length) / 2) + + return str.slice(0, keepStart) + ellipsis + str.slice(-keepEnd) +} + +export function pluralize(count: number, singular: string, plural: string): string { + const template = count === 1 ? singular : plural + return template.replace("{}", count.toString()) +} + +export * as Locale from "./locale" diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx index d0511c5183e4..c43deb3602e2 100644 --- a/packages/tui/src/context/sync.tsx +++ b/packages/tui/src/context/sync.tsx @@ -331,25 +331,6 @@ export const { 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": { @@ -587,76 +568,28 @@ export const { }, async sync(sessionID: string) { if (fullSyncedSessions.has(sessionID)) return - const syncing = syncingSessions.get(sessionID) - if (syncing) return syncing - const tracker = { messages: new Set(), parts: new Set() } - hydratingSessions.set(sessionID, tracker) - const task = (async () => { - 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.todo({ sessionID }), - sdk.client.session.diff({ sessionID }), - ]) - setStore( - produce((draft) => { - const match = search(draft.session, sessionID, (s) => s.id) - if (match.found) draft.session[match.index] = session.data! - if (!match.found) draft.session.splice(match.index, 0, session.data!) - draft.todo[sessionID] = todo.data ?? [] - const currentMessages = draft.message[sessionID] ?? [] - const infos = (messages.data ?? []).flatMap((message) => { - if (!tracker.messages.has(message.info.id)) return [message.info] - const current = currentMessages.find((item) => item.id === message.info.id) - return current ? [current] : [] - }) - infos.push( - ...currentMessages.filter( - (message) => tracker.messages.has(message.id) && !infos.some((item) => item.id === message.id), - ), - ) - const removed = infos.slice(0, -100) - const visible = infos.slice(-100) - const visibleIDs = new Set(visible.map((message) => message.id)) - for (const message of messages.data ?? []) { - if (!visibleIDs.has(message.info.id)) { - delete draft.part[message.info.id] - continue - } - const currentParts = draft.part[message.info.id] ?? [] - const parts = message.parts.flatMap((part) => { - const current = currentParts.find((item) => item.id === part.id) - if (tracker.parts.has(part.id)) return current ? [current] : [] - if ( - current && - (part.type === "text" || part.type === "reasoning") && - (current.type === "text" || current.type === "reasoning") && - part.text.length === 0 && - current.text.length > 0 - ) { - return [current] - } - return [part] - }) - parts.push( - ...currentParts.filter( - (part) => tracker.parts.has(part.id) && !parts.some((item) => item.id === part.id), - ), - ) - draft.part[message.info.id] = parts - } - for (const message of removed) delete draft.part[message.id] - draft.message[sessionID] = visible - draft.session_diff[sessionID] = diff.data ?? [] - }), - ) - fullSyncedSessions.add(sessionID) - })().finally(() => { - syncingSessions.delete(sessionID) - hydratingSessions.delete(sessionID) - }) - syncingSessions.set(sessionID, task) - return task + const [session, messages, todo, diff] = await Promise.all([ + sdk.client.session.get({ sessionID }, { throwOnError: true }), + sdk.client.session.messages({ sessionID }), + sdk.client.session.todo({ sessionID }), + sdk.client.session.diff({ sessionID }), + ]) + setStore( + produce((draft) => { + const match = Binary.search(draft.session, sessionID, (s) => s.id) + if (match.found) draft.session[match.index] = session.data! + if (!match.found) draft.session.splice(match.index, 0, session.data!) + draft.todo[sessionID] = todo.data ?? [] + const infos: (typeof draft.message)[string] = [] + for (const message of messages.data ?? []) { + infos.push(message.info) + draft.part[message.info.id] = message.parts + } + draft.message[sessionID] = infos + draft.session_diff[sessionID] = diff.data ?? [] + }), + ) + fullSyncedSessions.add(sessionID) }, }, bootstrap, diff --git a/packages/tui/src/routes/session/dialog-timeline.tsx b/packages/tui/src/routes/session/dialog-timeline.tsx index bda87119d4b5..6a52900e5756 100644 --- a/packages/tui/src/routes/session/dialog-timeline.tsx +++ b/packages/tui/src/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(() => ( From 7f3c18cc045dd63673b003a116a4c16c08cc4f5d Mon Sep 17 00:00:00 2001 From: Vladimir Petrigo Date: Sun, 10 May 2026 16:57:20 +0200 Subject: [PATCH 02/12] 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 | 44 +++++++++++------- .../test/session/messages-pagination.test.ts | 45 +++++++++++++++++++ 4 files changed, 88 insertions(+), 17 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 959a303dc964..a6d06f51c686 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts @@ -44,6 +44,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 662585020a64..ad9382452475 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -107,7 +107,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({ @@ -115,6 +117,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 })) @@ -125,6 +134,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 @@ -134,7 +144,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 1bea9f52c3ec..2bfcfe0adb2f 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -26,6 +26,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 "@opencode-ai/core/session/sql" import { ProviderError } from "@/provider/error" @@ -95,7 +97,10 @@ 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))) -function hydrate(db: Database.Interface["db"], rows: (typeof MessageTable.$inferSelect)[]) { +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() return Effect.gen(function* () { @@ -426,20 +431,29 @@ export const page = Effect.fn("MessageV2.page")(function* (input: { sessionID: SessionID limit: number before?: string + after?: string }) { - const { db } = yield* Database.Service + 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) - const rows = yield* db - .select() - .from(MessageTable) - .where(where) - .orderBy(desc(MessageTable.time_created), desc(MessageTable.id)) - .limit(input.limit + 1) - .all() - .pipe(Effect.orDie) + : 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( + after ? asc(MessageTable.time_created) : desc(MessageTable.time_created), + after ? asc(MessageTable.id) : desc(MessageTable.id), + ) + .limit(input.limit + 1) + .all(), + ) if (rows.length === 0) { const row = yield* db .select({ id: SessionTable.id }) @@ -456,13 +470,13 @@ 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 = yield* hydrate(db, slice) - items.reverse() - const tail = slice.at(-1) + const items = hydrate(slice) + 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 b67c982ebcd3..f4a1e10fe239 100644 --- a/packages/opencode/test/session/messages-pagination.test.ts +++ b/packages/opencode/test/session/messages-pagination.test.ts @@ -291,6 +291,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 bbba042f8547564631c4327ce1c9a67a2c07607a Mon Sep 17 00:00:00 2001 From: Vladimir Petrigo Date: Sun, 10 May 2026 17:04:29 +0200 Subject: [PATCH 03/12] 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. --- 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 ++ packages/tui/src/context/sync.tsx | 180 ++++++++++++++++++++++-- 5 files changed, 181 insertions(+), 12 deletions(-) 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 9ed0084aac84..672628d995ce 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -3710,6 +3710,7 @@ export class Session2 extends HeyApiClient { workspace?: string limit?: number before?: string + after?: string }, options?: Options, ) { @@ -3723,6 +3724,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 42d224780d32..6511262f7f3c 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -9758,6 +9758,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 b300754bc859..6b989e1a31ce 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -6132,6 +6132,14 @@ "type": "string" }, "required": false + }, + { + "name": "after", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx index c43deb3602e2..6dfe89d1f1f0 100644 --- a/packages/tui/src/context/sync.tsx +++ b/packages/tui/src/context/sync.tsx @@ -105,6 +105,18 @@ export const { } 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: [], @@ -135,6 +147,10 @@ export const { mcp_resource: {}, formatter: [], vcs: undefined, + messageOlderCursor: {}, + messageNewerCursor: {}, + messageOlderLoading: {}, + messageNewerLoading: {}, }) const event = useEvent() @@ -313,20 +329,33 @@ export const { } case "message.updated": { - touchMessage(event.properties.info.sessionID, event.properties.info.id) - 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 = 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) }), @@ -349,20 +378,30 @@ export const { break } case "message.part.updated": { - touchPart(event.properties.part.sessionID, event.properties.part.id) - 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 = 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) }), @@ -570,10 +609,11 @@ export const { 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) @@ -587,9 +627,118 @@ export const { } 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, @@ -597,3 +746,10 @@ export const { 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") +} From 1d9a00d5692c87304c26062742e4f89778b6feba Mon Sep 17 00:00:00 2001 From: Vladimir Petrigo Date: Sun, 10 May 2026 17:07:46 +0200 Subject: [PATCH 04/12] 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. --- .../src/routes/session/dialog-timeline.tsx | 1 + packages/tui/src/routes/session/index.tsx | 78 +++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/packages/tui/src/routes/session/dialog-timeline.tsx b/packages/tui/src/routes/session/dialog-timeline.tsx index 6a52900e5756..11ba58d9a34f 100644 --- a/packages/tui/src/routes/session/dialog-timeline.tsx +++ b/packages/tui/src/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/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 6d77b0ea58fd..18f0df343e4f 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -420,6 +420,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 enterChild(sessionID: string) { @@ -749,6 +804,7 @@ export function Session() { hidden: true, run: () => { scroll.scrollBy(-scroll.height / 2) + maybeLoadAdjacent() dialog.clear() }, }, @@ -759,6 +815,7 @@ export function Session() { hidden: true, run: () => { scroll.scrollBy(scroll.height / 2) + maybeLoadAdjacent() dialog.clear() }, }, @@ -769,6 +826,7 @@ export function Session() { hidden: true, run: () => { scroll.scrollBy(-1) + maybeLoadAdjacent() dialog.clear() }, }, @@ -779,6 +837,7 @@ export function Session() { hidden: true, run: () => { scroll.scrollBy(1) + maybeLoadAdjacent() dialog.clear() }, }, @@ -789,6 +848,7 @@ export function Session() { hidden: true, run: () => { scroll.scrollBy(-scroll.height / 4) + maybeLoadAdjacent() dialog.clear() }, }, @@ -799,6 +859,7 @@ export function Session() { hidden: true, run: () => { scroll.scrollBy(scroll.height / 4) + maybeLoadAdjacent() dialog.clear() }, }, @@ -809,6 +870,7 @@ export function Session() { hidden: true, run: () => { scroll.scrollTo(0) + maybeLoadAdjacent() dialog.clear() }, }, @@ -819,6 +881,7 @@ export function Session() { hidden: true, run: () => { scroll.scrollTo(scroll.scrollHeight) + maybeLoadAdjacent() dialog.clear() }, }, @@ -1182,7 +1245,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) => ( @@ -1278,6 +1351,11 @@ export function Session() { )} + + + Loading newer messages… + + 0}> From be4fccb10801491791688c9804f26774c0eef646 Mon Sep 17 00:00:00 2001 From: Vladimir Petrigo Date: Tue, 12 May 2026 11:22:46 +0200 Subject: [PATCH 05/12] 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 f4a1e10fe239..38ef1bdafc5b 100644 --- a/packages/opencode/test/session/messages-pagination.test.ts +++ b/packages/opencode/test/session/messages-pagination.test.ts @@ -291,50 +291,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 b4574bdcf2ae2f7681645314e2cc96922cefd976 Mon Sep 17 00:00:00 2001 From: Vladimir Petrigo Date: Thu, 14 May 2026 15:16:31 +0200 Subject: [PATCH 06/12] 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` --- packages/tui/src/context/sync.tsx | 6 +-- packages/tui/src/routes/session/index.tsx | 49 +++++++++++++++++------ 2 files changed, 39 insertions(+), 16 deletions(-) diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx index 6dfe89d1f1f0..192fad8d896d 100644 --- a/packages/tui/src/context/sync.tsx +++ b/packages/tui/src/context/sync.tsx @@ -613,7 +613,7 @@ export const { 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) @@ -639,7 +639,7 @@ export const { 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] ?? [] @@ -663,7 +663,7 @@ export const { 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/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 18f0df343e4f..85f511088d6e 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -429,20 +429,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) } @@ -450,23 +463,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) } From b8f377f16aea64838a0fe51f0b7dee8fadc04155 Mon Sep 17 00:00:00 2001 From: Vladimir Petrigo Date: Mon, 8 Jun 2026 10:57:34 +0200 Subject: [PATCH 07/12] tests: Clean up --- .../opencode/test/session/messages-pagination.test.ts | 11 ++++++----- packages/tui/src/context/sync.tsx | 2 +- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/opencode/test/session/messages-pagination.test.ts b/packages/opencode/test/session/messages-pagination.test.ts index 38ef1bdafc5b..9376db400815 100644 --- a/packages/opencode/test/session/messages-pagination.test.ts +++ b/packages/opencode/test/session/messages-pagination.test.ts @@ -299,17 +299,17 @@ describe("MessageV2.page", () => { // 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, limit: 2, after: anchor }) + const a = yield* 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, limit: 2, after: a.cursor! }) + const b = yield* 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, limit: 2, after: b.cursor! }) + const c = yield* 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() @@ -323,9 +323,10 @@ describe("MessageV2.page", () => { yield* fill(sessionID, 2) const dummyCursor = MessageV2.cursor.encode({ id: MessageID.ascending(), time: 0 }) - expect(() => + const exit = yield* Effect.exit( MessageV2.page({ sessionID, limit: 2, before: dummyCursor, after: dummyCursor }), - ).toThrow() + ) + expect(exit._tag).toBe("Failure") }), ), ) diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx index 192fad8d896d..f1d8dc42dcde 100644 --- a/packages/tui/src/context/sync.tsx +++ b/packages/tui/src/context/sync.tsx @@ -387,7 +387,7 @@ export const { const inWindow = (() => { const messages = store.message[sessionID] if (!messages) return true - return Binary.search(messages, messageID, (m) => m.id).found + return search(messages, messageID, (m) => m.id).found })() if (!parts) { if (!inWindow && store.messageNewerCursor[sessionID]) break From 85118576f0c34da537c462b6a50d07219ed6b712 Mon Sep 17 00:00:00 2001 From: Vladimir Petrigo Date: Fri, 19 Jun 2026 21:58:28 +0200 Subject: [PATCH 08/12] refactor(tui): simplify scroll-anchor restoration and cursor validation - Extract restoreScrollAnchor helper to deduplicate the 12-line setTimeout scroll-restoration pattern in maybeLoadOlder/NewerMessages - Inline single-use NEAR_TOP/BOTTOM_THRESHOLD constants (literal 5) - Inline inWindow IIFE in sync.tsx as a simple boolean expression - Collapse duplicate before/after cursor validation blocks in session handler into a single ?? check No behavior change. Net -21 lines. --- .../instance/httpapi/handlers/session.ts | 13 ++----- packages/tui/src/context/sync.tsx | 7 +--- packages/tui/src/routes/session/index.tsx | 37 +++++++------------ 3 files changed, 18 insertions(+), 39 deletions(-) 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 ad9382452475..0cd083ab52e8 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -110,17 +110,10 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", 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 + const cursor = ctx.query.before ?? ctx.query.after + if (cursor) { yield* Effect.try({ - try: () => MessageV2.cursor.decode(before), - catch: () => new HttpApiError.BadRequest({}), - }) - } - if (ctx.query.after) { - const after = ctx.query.after - yield* Effect.try({ - try: () => MessageV2.cursor.decode(after), + try: () => MessageV2.cursor.decode(cursor), catch: () => new HttpApiError.BadRequest({}), }) } diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx index f1d8dc42dcde..ba906fe09690 100644 --- a/packages/tui/src/context/sync.tsx +++ b/packages/tui/src/context/sync.tsx @@ -384,11 +384,8 @@ export const { // 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 search(messages, messageID, (m) => m.id).found - })() + const messages = store.message[sessionID] + const inWindow = !messages || search(messages, messageID, (m) => m.id).found if (!parts) { if (!inWindow && store.messageNewerCursor[sessionID]) break setStore("part", messageID, [event.properties.part]) diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 85f511088d6e..9527550c0bc1 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -422,14 +422,12 @@ export function Session() { // 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.scrollTop > NEAR_TOP_THRESHOLD) return + if (scroll.scrollTop > 5) 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 @@ -445,18 +443,7 @@ export function Session() { 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 - 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) + restoreScrollAnchor(anchorId, anchorOffset) } async function maybeLoadNewerMessages() { @@ -464,7 +451,7 @@ export function Session() { if (!sync.data.messageNewerCursor[route.sessionID]) return if (sync.data.messageNewerLoading[route.sessionID]) return const distanceFromBottom = scroll.scrollHeight - scroll.height - scroll.scrollTop - if (distanceFromBottom > NEAR_BOTTOM_THRESHOLD) return + if (distanceFromBottom > 5) 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 @@ -479,17 +466,19 @@ export function Session() { if (messages.length > WINDOW_CAP && scroll.scrollTop > scroll.height * 4) { sync.session.trimOlderMessages(route.sessionID, WINDOW_CAP) } + restoreScrollAnchor(anchorId, anchorOffset) + } + + // Anchor-based scroll restoration: after content changes at either end, + // reposition the viewport so the previously-visible anchor child stays + // in place. child.y includes the scroll offset, so child.y - scroll.y + // gives the offset from the viewport top regardless of scroll position. + function restoreScrollAnchor(anchorId?: string, anchorOffset?: number) { setTimeout(() => { if (!scroll || scroll.isDestroyed) return 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) - } + const child = scroll.getChildren().find((c) => c.id === anchorId) + if (child) scroll.scrollBy(child.y - scroll.y - anchorOffset) }, 0) } From 8d942a35bbb693bebd19347a062baf5fe8803692 Mon Sep 17 00:00:00 2001 From: Vladimir Petrigo Date: Wed, 1 Jul 2026 16:55:08 +0200 Subject: [PATCH 09/12] fix(tui): resolve replay conflicts on dev --- packages/tui/src/context/sync.tsx | 2 +- packages/tui/src/util/locale.ts | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx index ba906fe09690..8fef901f1556 100644 --- a/packages/tui/src/context/sync.tsx +++ b/packages/tui/src/context/sync.tsx @@ -613,7 +613,7 @@ export const { 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) + const match = search(draft.session, sessionID, (s) => s.id) if (match.found) draft.session[match.index] = session.data! if (!match.found) draft.session.splice(match.index, 0, session.data!) draft.todo[sessionID] = todo.data ?? [] diff --git a/packages/tui/src/util/locale.ts b/packages/tui/src/util/locale.ts index ddcb973e7439..8a0ec1269853 100644 --- a/packages/tui/src/util/locale.ts +++ b/packages/tui/src/util/locale.ts @@ -14,6 +14,14 @@ export function datetime(input: number): string { return `${localTime} · ${localDate}` } +export function datetimeFull(input: number): string { + const date = new Date(input) + return date.toLocaleString(undefined, { + dateStyle: "medium", + timeStyle: "medium", + }) +} + export function todayTimeOrDateTime(input: number): string { const date = new Date(input) const now = new Date() From 9d7d96b5949fd02e3cc0ff0dd13c432b364bde2e Mon Sep 17 00:00:00 2001 From: Vladimir Petrigo Date: Wed, 1 Jul 2026 16:55:09 +0200 Subject: [PATCH 10/12] fix(session): await paginated message queries --- packages/opencode/src/session/message-v2.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 2bfcfe0adb2f..b69d8b2e2867 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -442,7 +442,7 @@ export const page = Effect.fn("MessageV2.page")(function* (input: { : after ? and(eq(MessageTable.session_id, input.sessionID), newer(after)) : eq(MessageTable.session_id, input.sessionID) - const rows = Database.use((db) => + const rows = yield* Database.use((db) => db .select() .from(MessageTable) @@ -455,12 +455,9 @@ export const page = Effect.fn("MessageV2.page")(function* (input: { .all(), ) if (rows.length === 0) { - const row = yield* db - .select({ id: SessionTable.id }) - .from(SessionTable) - .where(eq(SessionTable.id, input.sessionID)) - .get() - .pipe(Effect.orDie) + const row = yield* Database.use((db) => + db.select({ id: SessionTable.id }).from(SessionTable).where(eq(SessionTable.id, input.sessionID)).get(), + ) if (!row) return yield* new NotFoundError({ message: `Session not found: ${input.sessionID}` }) return { items: [] as WithParts[], @@ -470,7 +467,7 @@ 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) + const items = yield* hydrate(slice) if (!after) items.reverse() const cursorRow = slice.at(-1) return { From 646f3f1bbee51f95c22ce4bd1fcdeb8c9137e2ad Mon Sep 17 00:00:00 2001 From: Vladimir Petrigo Date: Wed, 1 Jul 2026 17:48:59 +0200 Subject: [PATCH 11/12] fix(session): restore message pagination hydration --- .../instance/httpapi/handlers/session.ts | 4 +- packages/opencode/src/session/message-v2.ts | 34 +++++---- packages/tui/src/context/sync.tsx | 75 ++++++++++++------- 3 files changed, 72 insertions(+), 41 deletions(-) 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 0cd083ab52e8..deefa60e804c 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -1,4 +1,5 @@ import { PermissionV1 } from "@opencode-ai/core/v1/permission" +import { Database } from "@opencode-ai/core/database/database" import { Agent } from "@/agent/agent" import { SessionV1 } from "@opencode-ai/core/v1/session" import { EventV2Bridge } from "@/event-v2-bridge" @@ -48,6 +49,7 @@ const tryParseJson = (text: string) => export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", (handlers) => Effect.gen(function* () { const session = yield* Session.Service + const database = yield* Database.Service const shareSvc = yield* SessionShare.Service const promptSvc = yield* SessionPrompt.Service const revertSvc = yield* SessionRevert.Service @@ -128,7 +130,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", limit: ctx.query.limit, before: ctx.query.before, after: ctx.query.after, - }), + }).pipe(Effect.provideService(Database.Service, database)), ) if (!page.cursor) return page.items diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index b69d8b2e2867..5cdf01247636 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -105,6 +105,7 @@ function hydrate(rows: (typeof MessageTable.$inferSelect)[]) { const partByMessage = new Map() return Effect.gen(function* () { if (ids.length > 0) { + const { db } = yield* Database.Service const partRows = yield* db .select() .from(PartTable) @@ -442,22 +443,25 @@ export const page = Effect.fn("MessageV2.page")(function* (input: { : after ? and(eq(MessageTable.session_id, input.sessionID), newer(after)) : eq(MessageTable.session_id, input.sessionID) - const rows = yield* Database.use((db) => - db - .select() - .from(MessageTable) - .where(where) - .orderBy( - after ? asc(MessageTable.time_created) : desc(MessageTable.time_created), - after ? asc(MessageTable.id) : desc(MessageTable.id), - ) - .limit(input.limit + 1) - .all(), - ) - if (rows.length === 0) { - const row = yield* Database.use((db) => - db.select({ id: SessionTable.id }).from(SessionTable).where(eq(SessionTable.id, input.sessionID)).get(), + const { db } = yield* Database.Service + const rows = yield* db + .select() + .from(MessageTable) + .where(where) + .orderBy( + after ? asc(MessageTable.time_created) : desc(MessageTable.time_created), + after ? asc(MessageTable.id) : desc(MessageTable.id), ) + .limit(input.limit + 1) + .all() + .pipe(Effect.orDie) + if (rows.length === 0) { + const row = yield* db + .select({ id: SessionTable.id }) + .from(SessionTable) + .where(eq(SessionTable.id, input.sessionID)) + .get() + .pipe(Effect.orDie) if (!row) return yield* new NotFoundError({ message: `Session not found: ${input.sessionID}` }) return { items: [] as WithParts[], diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx index 8fef901f1556..e2401d6b0b1e 100644 --- a/packages/tui/src/context/sync.tsx +++ b/packages/tui/src/context/sync.tsx @@ -604,31 +604,56 @@ export const { }, async sync(sessionID: string) { 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: 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 = search(draft.session, sessionID, (s) => s.id) - if (match.found) draft.session[match.index] = session.data! - if (!match.found) draft.session.splice(match.index, 0, session.data!) - draft.todo[sessionID] = todo.data ?? [] - const infos: (typeof draft.message)[string] = [] - for (const message of messages.data ?? []) { - infos.push(message.info) - draft.part[message.info.id] = message.parts - } - draft.message[sessionID] = infos - draft.session_diff[sessionID] = diff.data ?? [] - draft.messageOlderCursor[sessionID] = olderCursor - draft.messageNewerCursor[sessionID] = null - }), - ) - if (!olderCursor) fullSyncedSessions.add(sessionID) + const hydration = { messages: new Set(), parts: new Set() } + hydratingSessions.set(sessionID, hydration) + try { + const [session, messages, todo, diff] = await Promise.all([ + sdk.client.session.get({ sessionID }, { throwOnError: true }), + 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 = search(draft.session, sessionID, (s) => s.id) + if (match.found) draft.session[match.index] = session.data! + if (!match.found) draft.session.splice(match.index, 0, session.data!) + draft.todo[sessionID] = todo.data ?? [] + if (messages.data) { + const existing = draft.message[sessionID] ?? [] + const infos: (typeof draft.message)[string] = [] + const hydratedIDs = new Set() + for (const message of messages.data) { + hydratedIDs.add(message.info.id) + const current = existing.find((item) => item.id === message.info.id) + if (current) { + infos.push(current) + continue + } + if (hydration.messages.has(message.info.id)) continue + infos.push(message.info) + draft.part[message.info.id] = message.parts + } + for (const message of existing) { + if (hydratedIDs.has(message.id)) continue + infos.push(message) + } + while (infos.length > INITIAL_PAGE_SIZE) { + const evicted = infos.shift() + if (evicted) delete draft.part[evicted.id] + } + draft.message[sessionID] = infos + draft.messageOlderCursor[sessionID] = olderCursor + draft.messageNewerCursor[sessionID] = null + } + draft.session_diff[sessionID] = diff.data ?? [] + }), + ) + if (!olderCursor) fullSyncedSessions.add(sessionID) + } finally { + hydratingSessions.delete(sessionID) + } }, async loadOlderMessages(sessionID: string) { const cursor = store.messageOlderCursor[sessionID] From 05a2bd554c319850138681b9caf0c612017e90a3 Mon Sep 17 00:00:00 2001 From: Vladimir Petrigo Date: Thu, 2 Jul 2026 12:23:27 +0200 Subject: [PATCH 12/12] tui: Remove duplication of locale specific functions --- packages/opencode/src/util/locale.ts | 98 +---------------------- packages/tui/src/routes/session/index.tsx | 2 +- packages/tui/src/util/locale.ts | 5 +- 3 files changed, 4 insertions(+), 101 deletions(-) diff --git a/packages/opencode/src/util/locale.ts b/packages/opencode/src/util/locale.ts index 344cad85aeaf..a08213380939 100644 --- a/packages/opencode/src/util/locale.ts +++ b/packages/opencode/src/util/locale.ts @@ -1,96 +1,2 @@ -export function titlecase(str: string) { - return str.replace(/\b\w/g, (c) => c.toUpperCase()) -} - -export function time(input: number): string { - const date = new Date(input) - return date.toLocaleTimeString(undefined, { timeStyle: "short" }) -} - -export function datetime(input: number): string { - const date = new Date(input) - const localTime = time(input) - const localDate = date.toLocaleDateString() - 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() - const isToday = - date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth() && date.getDate() === now.getDate() - - if (isToday) { - return time(input) - } else { - return datetime(input) - } -} - -export function number(num: number): string { - if (num >= 1000000) { - return (num / 1000000).toFixed(1) + "M" - } else if (num >= 1000) { - return (num / 1000).toFixed(1) + "K" - } - return num.toString() -} - -export function duration(input: number) { - if (input < 1000) { - return `${input}ms` - } - if (input < 60000) { - return `${(input / 1000).toFixed(1)}s` - } - if (input < 3600000) { - const minutes = Math.floor(input / 60000) - const seconds = Math.floor((input % 60000) / 1000) - return `${minutes}m ${seconds}s` - } - if (input < 86400000) { - const hours = Math.floor(input / 3600000) - const minutes = Math.floor((input % 3600000) / 60000) - return `${hours}h ${minutes}m` - } - const hours = Math.floor(input / 3600000) - const days = Math.floor((input % 3600000) / 86400000) - return `${days}d ${hours}h` -} - -export function truncate(str: string, len: number): string { - if (str.length <= len) return str - return str.slice(0, len - 1) + "…" -} - -export function truncateLeft(str: string, len: number): string { - if (str.length <= len) return str - return "…" + str.slice(-(len - 1)) -} - -export function truncateMiddle(str: string, maxLength: number = 35): string { - if (str.length <= maxLength) return str - - const ellipsis = "…" - const keepStart = Math.ceil((maxLength - ellipsis.length) / 2) - const keepEnd = Math.floor((maxLength - ellipsis.length) / 2) - - return str.slice(0, keepStart) + ellipsis + str.slice(-keepEnd) -} - -export function pluralize(count: number, singular: string, plural: string): string { - const template = count === 1 ? singular : plural - return template.replace("{}", count.toString()) -} - -export * as Locale from "./locale" +export * from "@opencode-ai/tui/util/locale" +export { Locale } from "@opencode-ai/tui/util/locale" diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 9527550c0bc1..6f354b2c1be7 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -1516,7 +1516,7 @@ function UserMessage(props: { - {Locale.todayTimeOrDateTime(props.message.time.created)} + {Locale.datetimeFull(props.message.time.created)} diff --git a/packages/tui/src/util/locale.ts b/packages/tui/src/util/locale.ts index 8a0ec1269853..5938cf323612 100644 --- a/packages/tui/src/util/locale.ts +++ b/packages/tui/src/util/locale.ts @@ -16,10 +16,7 @@ export function datetime(input: number): string { export function datetimeFull(input: number): string { const date = new Date(input) - return date.toLocaleString(undefined, { - dateStyle: "medium", - timeStyle: "medium", - }) + return `${String(date.getDate()).padStart(2, "0")}/${String(date.getMonth() + 1).padStart(2, "0")}/${date.getFullYear()} ${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}` } export function todayTimeOrDateTime(input: number): string {