diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 9f8a384f777f..a8759a60db20 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,42 +267,37 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ } case "message.updated": { - const messages = store.message[event.properties.info.sessionID] + const sessionID = event.properties.info.sessionID + const messages = store.message[sessionID] if (!messages) { - setStore("message", event.properties.info.sessionID, [event.properties.info]) + setStore("message", sessionID, [event.properties.info]) break } const result = Binary.search(messages, event.properties.info.id, (m) => m.id) if (result.found) { - setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info)) + setStore("message", sessionID, result.index, reconcile(event.properties.info)) break } + // If the bottom of the window has been evicted (messageNewerCursor + // is set), drop messages that arrive past our visible tail. They + // will be loaded on demand when the user scrolls back down. + if (store.messageNewerCursor[sessionID]) { + const last = messages[messages.length - 1] + if (last) { + const incoming = event.properties.info + const isPastTail = + incoming.time.created > last.time.created || + (incoming.time.created === last.time.created && incoming.id > last.id) + if (isPastTail) break + } + } setStore( "message", - event.properties.info.sessionID, + sessionID, produce((draft) => { draft.splice(result.index, 0, event.properties.info) }), ) - const updated = store.message[event.properties.info.sessionID] - if (updated.length > 100) { - const oldest = updated[0] - batch(() => { - setStore( - "message", - event.properties.info.sessionID, - produce((draft) => { - draft.shift() - }), - ) - setStore( - "part", - produce((draft) => { - delete draft[oldest.id] - }), - ) - }) - } break } case "message.removed": { @@ -304,19 +315,30 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ break } case "message.part.updated": { - const parts = store.part[event.properties.part.messageID] + const sessionID = event.properties.part.sessionID + const messageID = event.properties.part.messageID + const parts = store.part[messageID] + // If the parent message isn't in our window AND the window's + // bottom has been evicted, drop the part - it would otherwise + // be orphaned in store.part with no message to attach to. + const inWindow = (() => { + const messages = store.message[sessionID] + if (!messages) return true + return Binary.search(messages, messageID, (m) => m.id).found + })() if (!parts) { - setStore("part", event.properties.part.messageID, [event.properties.part]) + if (!inWindow && store.messageNewerCursor[sessionID]) break + setStore("part", messageID, [event.properties.part]) break } const result = Binary.search(parts, event.properties.part.id, (p) => p.id) if (result.found) { - setStore("part", event.properties.part.messageID, result.index, reconcile(event.properties.part)) + setStore("part", messageID, result.index, reconcile(event.properties.part)) break } setStore( "part", - event.properties.part.messageID, + messageID, produce((draft) => { draft.splice(result.index, 0, event.properties.part) }), @@ -522,10 +544,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ if (fullSyncedSessions.has(sessionID)) return const [session, messages, todo, diff] = await Promise.all([ sdk.client.session.get({ sessionID }, { throwOnError: true }), - sdk.client.session.messages({ sessionID, limit: 100 }), + sdk.client.session.messages({ sessionID, limit: INITIAL_PAGE_SIZE }), sdk.client.session.todo({ sessionID }), sdk.client.session.diff({ sessionID }), ]) + const olderCursor = (messages.response?.headers.get("x-next-cursor") as string | null | undefined) ?? null setStore( produce((draft) => { const match = Binary.search(draft.session, sessionID, (s) => s.id) @@ -539,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 }), ) - 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, @@ -549,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/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx index 87248a6a8ba6..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[] => { @@ -31,7 +32,7 @@ export function DialogTimeline(props: { result.push({ title: part.text.replace(/\n/g, " "), value: message.id, - footer: Locale.time(message.time.created), + footer: Locale.datetimeFull(message.time.created), onSelect: (dialog) => { dialog.replace(() => ( 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 270c11049e0f..c465ec43fad1 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -407,6 +407,84 @@ 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.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 - 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) + } + + 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.scrollTop + if (distanceFromBottom > NEAR_BOTTOM_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.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 && scroll.scrollTop > scroll.height * 4) { + sync.session.trimOlderMessages(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) + } + + function maybeLoadAdjacent() { + void maybeLoadOlderMessages() + void maybeLoadNewerMessages() + } + const local = useLocal() function moveFirstChild() { @@ -736,6 +814,7 @@ export function Session() { hidden: true, run: () => { scroll.scrollBy(-scroll.height / 2) + maybeLoadAdjacent() dialog.clear() }, }, @@ -746,6 +825,7 @@ export function Session() { hidden: true, run: () => { scroll.scrollBy(scroll.height / 2) + maybeLoadAdjacent() dialog.clear() }, }, @@ -756,6 +836,7 @@ export function Session() { hidden: true, run: () => { scroll.scrollBy(-1) + maybeLoadAdjacent() dialog.clear() }, }, @@ -766,6 +847,7 @@ export function Session() { hidden: true, run: () => { scroll.scrollBy(1) + maybeLoadAdjacent() dialog.clear() }, }, @@ -776,6 +858,7 @@ export function Session() { hidden: true, run: () => { scroll.scrollBy(-scroll.height / 4) + maybeLoadAdjacent() dialog.clear() }, }, @@ -786,6 +869,7 @@ export function Session() { hidden: true, run: () => { scroll.scrollBy(scroll.height / 4) + maybeLoadAdjacent() dialog.clear() }, }, @@ -796,6 +880,7 @@ export function Session() { hidden: true, run: () => { scroll.scrollTo(0) + maybeLoadAdjacent() dialog.clear() }, }, @@ -806,6 +891,7 @@ export function Session() { hidden: true, run: () => { scroll.scrollTo(scroll.scrollHeight) + maybeLoadAdjacent() dialog.clear() }, }, @@ -1132,7 +1218,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) => ( @@ -1228,6 +1324,11 @@ export function Session() { )} + + + Loading newer messages… + + 0}> 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 cd2f3be19c81..78cb6c9a7dc5 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 4d4cce367b41..758900a58491 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -104,7 +104,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({ @@ -112,6 +114,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 })) @@ -122,6 +131,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 @@ -131,7 +141,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 2745ff4f45d7..1a3a59eca810 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() @@ -924,17 +929,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(), ) @@ -952,12 +966,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/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() diff --git a/packages/opencode/test/session/messages-pagination.test.ts b/packages/opencode/test/session/messages-pagination.test.ts index e558d07b500f..06bf260cdc93 100644 --- a/packages/opencode/test/session/messages-pagination.test.ts +++ b/packages/opencode/test/session/messages-pagination.test.ts @@ -289,6 +289,45 @@ describe("MessageV2.page", () => { }), ) + 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, 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! }) + 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! }) + expect(c.items.map((item) => item.info.id)).toEqual(ids.slice(4, 6)) + expect(c.more).toBe(false) + expect(c.cursor).toBeUndefined() + }), + ), + ) + + 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, limit: 2, before: dummyCursor, after: dummyCursor }), + ).toThrow() + }), + ), + ) + it.instance("large limit returns all messages without cursor", () => withSession(({ sessionID }) => Effect.gen(function* () { 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 cd17e70fdf0e..a1be627546d6 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -3379,6 +3379,7 @@ export class Session2 extends HeyApiClient { workspace?: string limit?: number before?: string + after?: string }, options?: Options, ) { @@ -3392,6 +3393,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 aae1b06ad320..6b8fe25c0dee 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -6339,6 +6339,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 877d9ba7e6b1..abee704df22f 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -5826,6 +5826,14 @@ "type": "string" }, "required": false + }, + { + "name": "after", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": {