diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 893cc10ad9b..3f9a714981f 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -55,6 +55,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ message: { [sessionID: string]: Message[] } + message_page: { + [sessionID: string]: { + hasMore: boolean + loading: boolean + } + } part: { [messageID: string]: Part[] } @@ -84,6 +90,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ session_diff: {}, todo: {}, message: {}, + message_page: {}, part: {}, lsp: [], mcp: {}, @@ -193,7 +200,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ event.properties.info.sessionID, produce((draft) => { draft.splice(result.index, 0, event.properties.info) - if (draft.length > 100) draft.shift() }), ) break @@ -350,10 +356,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ if (fullSyncedSessions.has(sessionID)) return const [session, messages, todo, diff] = await Promise.all([ sdk.client.session.get({ sessionID }, { throwOnError: true }), - sdk.client.session.messages({ sessionID, limit: 100 }), + sdk.client.session.messages({ sessionID, limit: 100 }, { throwOnError: true }), sdk.client.session.todo({ sessionID }), sdk.client.session.diff({ sessionID }), ]) + const hasMore = !!messages.response.headers.get("link")?.includes('rel="next"') setStore( produce((draft) => { const match = Binary.search(draft.session, sessionID, (s) => s.id) @@ -361,6 +368,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ if (!match.found) draft.session.splice(match.index, 0, session.data!) draft.todo[sessionID] = todo.data ?? [] draft.message[sessionID] = messages.data!.map((x) => x.info) + draft.message_page[sessionID] = { hasMore, loading: false } for (const message of messages.data!) { draft.part[message.info.id] = message.parts } @@ -369,6 +377,46 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ ) fullSyncedSessions.add(sessionID) }, + loadMore(sessionID: string) { + const page = store.message_page[sessionID] + if (page?.loading) return Promise.resolve() + if (page && !page.hasMore) return Promise.resolve() + + const messages = store.message[sessionID] ?? [] + const oldest = messages.at(0) + if (!oldest) return Promise.resolve() + + setStore("message_page", sessionID, { + hasMore: page?.hasMore ?? true, + loading: true, + }) + + return sdk.client.session + .messages({ sessionID, before: oldest.id, limit: 100 }, { throwOnError: true }) + .then((res) => { + const hasMore = !!res.response.headers.get("link")?.includes('rel="next"') + setStore( + produce((draft) => { + const existing = draft.message[sessionID] ?? [] + for (const msg of res.data ?? []) { + const match = Binary.search(existing, msg.info.id, (m) => m.id) + if (match.found) { + existing[match.index] = msg.info + draft.part[msg.info.id] = msg.parts + continue + } + existing.splice(match.index, 0, msg.info) + draft.part[msg.info.id] = msg.parts + } + draft.message[sessionID] = existing + draft.message_page[sessionID] = { hasMore, loading: false } + }), + ) + }) + .catch(() => { + setStore("message_page", sessionID, "loading", false) + }) + }, }, bootstrap, } 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 d049ec4373c..7809ff3bdc0 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -115,6 +115,7 @@ export function Session() { .toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) }) const messages = createMemo(() => sync.data.message[route.sessionID] ?? []) + const paging = createMemo(() => sync.data.message_page[route.sessionID]) const permissions = createMemo(() => { if (session().parentID) return sync.data.permission[route.sessionID] ?? [] return children().flatMap((x) => sync.data.permission[x.id] ?? []) @@ -191,6 +192,22 @@ export function Session() { let prompt: PromptRef const keybind = useKeybind() + const loadMore = () => { + const page = paging() + if (!page?.hasMore) return + if (page.loading) return + if (!scroll) return + if (scroll.y > 5) return + const height = scroll.scrollHeight + const y = scroll.y + sync.session.loadMore(route.sessionID).then(() => { + setTimeout(() => { + const delta = scroll.scrollHeight - height + if (delta > 0) scroll.scrollTo(y + delta) + }, 1) + }) + } + // Helper: Find next visible message boundary in direction const findNextVisibleMessage = (direction: "next" | "prev"): string | null => { const children = scroll.getChildren() @@ -908,6 +925,8 @@ export function Session() { (scroll = r)} + onMouseScroll={() => loadMore()} + onKeyDown={() => setTimeout(loadMore, 1)} viewportOptions={{ paddingRight: showScrollbar() ? 1 : 0, }} @@ -924,6 +943,11 @@ export function Session() { flexGrow={1} scrollAcceleration={scrollAcceleration()} > + + + Loading more... + + {(message, index) => ( diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 4c6dac415bb..d3f0fe01720 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -1162,15 +1162,33 @@ export namespace Server { "query", z.object({ limit: z.coerce.number().optional(), + before: z.string().optional(), }), ), async (c) => { const query = c.req.valid("query") - const messages = await Session.messages({ - sessionID: c.req.valid("param").sessionID, - limit: query.limit, + const limit = query.limit + const sessionID = c.req.valid("param").sessionID + if (limit !== undefined && limit <= 0) return c.json([]) + const page = await Session.messages({ + sessionID, + limit: limit === undefined ? undefined : limit + 1, + before: query.before, }) - return c.json(messages) + + if (limit !== undefined && page.length > limit) { + const messages = page.slice(1) + const first = messages.at(0) + const url = new URL(c.req.url) + url.searchParams.set("limit", limit.toString()) + if (first) { + url.searchParams.set("before", first.info.id) + c.header("Link", `<${url.toString()}>; rel="next"`) + } + return c.json(messages) + } + + return c.json(page) }, ) .get( diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 0776590d6a9..5f6e4da9e17 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -274,12 +274,39 @@ export namespace Session { z.object({ sessionID: Identifier.schema("session"), limit: z.number().optional(), + before: Identifier.schema("message").optional(), }), async (input) => { const result = [] as MessageV2.WithParts[] - for await (const msg of MessageV2.stream(input.sessionID)) { - if (input.limit && result.length >= input.limit) break - result.push(msg) + const list = await Storage.list(["message", input.sessionID]) + const ids = list.map((x) => x[2]).filter((x): x is string => typeof x === "string") + + let start = ids.length - 1 + if (input.before) { + let lo = 0 + let hi = ids.length - 1 + let pos = -1 + while (lo <= hi) { + const mid = (lo + hi) >> 1 + const id = ids[mid] + if (id < input.before) { + pos = mid + lo = mid + 1 + continue + } + hi = mid - 1 + } + start = pos + } + + for (let i = start; i >= 0; i--) { + if (input.limit !== undefined && result.length >= input.limit) break + result.push( + await MessageV2.get({ + sessionID: input.sessionID, + messageID: ids[i], + }), + ) } result.reverse() return result diff --git a/packages/opencode/test/session/messages-pagination.test.ts b/packages/opencode/test/session/messages-pagination.test.ts new file mode 100644 index 00000000000..af92582f1b2 --- /dev/null +++ b/packages/opencode/test/session/messages-pagination.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import { Identifier } from "../../src/id/id" +import { Instance } from "../../src/project/instance" +import { Session } from "../../src/session" +import { Log } from "../../src/util/log" + +const projectRoot = path.join(__dirname, "../..") +Log.init({ print: false }) + +describe("session.messages pagination", () => { + test("supports before cursor", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const session = await Session.create({}) + const ids: string[] = [] + + for (let i = 0; i < 10; i++) { + const id = Identifier.ascending("message") + ids.push(id) + await Session.updateMessage({ + id, + sessionID: session.id, + role: "user", + time: { created: Date.now() }, + agent: "test", + model: { providerID: "opencode", modelID: "test" }, + }) + } + + const page1 = await Session.messages({ sessionID: session.id, limit: 3 }) + expect(page1.map((x) => x.info.id)).toEqual(ids.slice(-3)) + + const cursor1 = page1.at(0)?.info.id + expect(cursor1).toBe(ids.at(-3)) + + const page2 = await Session.messages({ sessionID: session.id, limit: 3, before: cursor1! }) + expect(page2.map((x) => x.info.id)).toEqual(ids.slice(-6, -3)) + + const cursor2 = page2.at(0)?.info.id + expect(cursor2).toBe(ids.at(-6)) + + const page3 = await Session.messages({ sessionID: session.id, limit: 3, before: cursor2! }) + expect(page3.map((x) => x.info.id)).toEqual(ids.slice(-9, -6)) + + const cursor3 = page3.at(0)?.info.id + expect(cursor3).toBe(ids.at(-9)) + + const page4 = await Session.messages({ sessionID: session.id, limit: 3, before: cursor3! }) + expect(page4.map((x) => x.info.id)).toEqual(ids.slice(0, 1)) + + const cursor4 = page4.at(0)?.info.id + expect(cursor4).toBe(ids.at(0)) + + const page5 = await Session.messages({ sessionID: session.id, limit: 3, before: cursor4! }) + expect(page5).toEqual([]) + + await Session.remove(session.id) + }, + }) + }) +}) + diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index f56e8367795..b8ed4a81055 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -1191,6 +1191,7 @@ export class Session extends HeyApiClient { sessionID: string directory?: string limit?: number + before?: string }, options?: Options, ) { @@ -1202,6 +1203,7 @@ export class Session extends HeyApiClient { { in: "path", key: "sessionID" }, { in: "query", key: "directory" }, { in: "query", key: "limit" }, + { in: "query", key: "before" }, ], }, ], diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 10764bebee8..59c69ae835d 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -2915,6 +2915,7 @@ export type SessionMessagesData = { query?: { directory?: string limit?: number + before?: string } url: "/session/{sessionID}/message" } diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 3393d1c8618..5aa491983b7 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -1887,6 +1887,13 @@ "schema": { "type": "number" } + }, + { + "in": "query", + "name": "before", + "schema": { + "type": "string" + } } ], "summary": "Get session messages",