diff --git a/packages/opencode/src/server/routes/global.ts b/packages/opencode/src/server/routes/global.ts index 2024d2fa5e..f7b965ef85 100644 --- a/packages/opencode/src/server/routes/global.ts +++ b/packages/opencode/src/server/routes/global.ts @@ -135,6 +135,10 @@ export const GlobalRoutes = lazy(() => .number() .optional() .meta({ description: "Filter sessions updated on or after this timestamp (milliseconds since epoch)" }), + cursor: z.coerce + .number() + .optional() + .meta({ description: "Return sessions updated before this timestamp (milliseconds since epoch)" }), search: z.string().optional().meta({ description: "Filter sessions by title (case-insensitive)" }), limit: z.coerce.number().optional().meta({ description: "Maximum number of sessions to return" }), archived: z.coerce.boolean().optional().meta({ description: "Include archived sessions (default false)" }), @@ -142,18 +146,25 @@ export const GlobalRoutes = lazy(() => ), async (c) => { const query = c.req.valid("query") + const limit = query.limit ?? 100 const sessions: Session.GlobalInfo[] = [] for await (const session of Session.listGlobal({ directory: query.directory, roots: query.roots, start: query.start, + cursor: query.cursor, search: query.search, - limit: query.limit, + limit: limit + 1, archived: query.archived, })) { sessions.push(session) } - return c.json(sessions) + const hasMore = sessions.length > limit + const list = hasMore ? sessions.slice(0, limit) : sessions + if (hasMore && list.length > 0) { + c.header("x-next-cursor", String(list[list.length - 1].time.updated)) + } + return c.json(list) }, ) .get( diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 556672f14a..f8e59b6d91 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -10,7 +10,7 @@ import { Flag } from "../flag/flag" import { Identifier } from "../id/id" import { Installation } from "../installation" -import { Database, NotFoundError, eq, and, or, gte, isNull, desc, like, inArray } from "../storage/db" +import { Database, NotFoundError, eq, and, or, gte, isNull, desc, like, inArray, lt } from "../storage/db" import type { SQL } from "../storage/db" import { SessionTable, MessageTable, PartTable } from "./session.sql" import { ProjectTable } from "../project/project.sql" @@ -568,6 +568,7 @@ export namespace Session { directory?: string roots?: boolean start?: number + cursor?: number search?: string limit?: number archived?: boolean @@ -583,6 +584,9 @@ export namespace Session { if (input?.start) { conditions.push(gte(SessionTable.time_updated, input.start)) } + if (input?.cursor) { + conditions.push(lt(SessionTable.time_updated, input.cursor)) + } if (input?.search) { conditions.push(like(SessionTable.title, `%${input.search}%`)) } diff --git a/packages/opencode/test/server/global-session-list.test.ts b/packages/opencode/test/server/global-session-list.test.ts index fe2f1ccea1..05d6de04b1 100644 --- a/packages/opencode/test/server/global-session-list.test.ts +++ b/packages/opencode/test/server/global-session-list.test.ts @@ -62,4 +62,28 @@ describe("Session.listGlobal", () => { expect(allIds).toContain(archived.id) }) + + test("supports cursor pagination", async () => { + await using tmp = await tmpdir({ git: true }) + + const first = await Instance.provide({ + directory: tmp.path, + fn: async () => Session.create({ title: "page-one" }), + }) + await new Promise((resolve) => setTimeout(resolve, 5)) + const second = await Instance.provide({ + directory: tmp.path, + fn: async () => Session.create({ title: "page-two" }), + }) + + const page = [...Session.listGlobal({ directory: tmp.path, limit: 1 })] + expect(page.length).toBe(1) + expect(page[0].id).toBe(second.id) + + const next = [...Session.listGlobal({ directory: tmp.path, limit: 10, cursor: page[0].time.updated })] + const ids = next.map((session) => session.id) + + expect(ids).toContain(first.id) + expect(ids).not.toContain(second.id) + }) })