Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
9b03334
tui: show full session history in chat and timeline
vpetrigo May 10, 2026
06c68ff
api: add `after` cursor for forward pagination on /session/:id/message
vpetrigo May 10, 2026
13af011
tui: bidirectional message pagination with asymmetric windowing
vpetrigo May 10, 2026
6a67795
tui: scroll-driven loading + windowing in session view
vpetrigo May 10, 2026
c65fe8a
tests: refactor message pagination tests to use `withSession` helper
vpetrigo May 12, 2026
6c11870
Merge branch 'dev' into issue/7380
vpetrigo May 13, 2026
43c4728
Merge branch 'dev' into issue/7380
vpetrigo May 13, 2026
167c28f
Merge branch 'dev' into issue/7380
vpetrigo May 14, 2026
1f26910
Merge branch 'dev' into issue/7380
vpetrigo May 14, 2026
3ea1e3e
fix: Scrolling for long sessions
vpetrigo May 14, 2026
f6275a3
Merge branch 'dev' into issue/7380
vpetrigo May 14, 2026
e71d54f
Merge branch 'dev' into issue/7380
vpetrigo May 14, 2026
ce4258d
Merge branch 'dev' into issue/7380
vpetrigo May 15, 2026
42b6d06
Merge branch 'dev' into issue/7380
vpetrigo May 15, 2026
a771329
Merge branch 'dev' into issue/7380
vpetrigo May 15, 2026
19a7fb2
Merge branch 'dev' into issue/7380
vpetrigo May 15, 2026
e512612
Merge branch 'dev' into issue/7380
vpetrigo May 16, 2026
24a30e5
Merge branch 'dev' into issue/7380
vpetrigo May 16, 2026
080c9b2
Merge branch 'dev' into issue/7380
vpetrigo May 17, 2026
ccdfc4b
Merge branch 'dev' into issue/7380
vpetrigo May 18, 2026
54e08b2
Merge branch 'dev' into issue/7380
vpetrigo May 18, 2026
49f7895
Merge branch 'dev' into issue/7380
vpetrigo May 18, 2026
10399f3
Merge branch 'dev' into issue/7380
vpetrigo May 19, 2026
1953e68
Merge branch 'dev' into issue/7380
vpetrigo May 20, 2026
64d69bd
Merge branch 'dev' into issue/7380
vpetrigo May 21, 2026
345000f
Merge branch 'dev' into issue/7380
vpetrigo May 21, 2026
ad3ad6d
Merge branch 'dev' into issue/7380
vpetrigo May 21, 2026
80725ac
Merge branch 'dev' into issue/7380
vpetrigo May 21, 2026
16b198d
Merge branch 'dev' into issue/7380
vpetrigo May 22, 2026
65960a4
Merge branch 'dev' into issue/7380
vpetrigo May 22, 2026
2eaa2eb
Merge branch 'dev' into issue/7380
vpetrigo May 23, 2026
06d9ccc
Merge branch 'dev' into issue/7380
vpetrigo May 25, 2026
2eb04e5
Merge branch 'dev' into issue/7380
vpetrigo May 26, 2026
6aefccc
Merge branch 'dev' into issue/7380
vpetrigo May 26, 2026
27b79a3
Merge branch 'dev' into issue/7380
vpetrigo May 30, 2026
2402320
Merge remote-tracking branch 'origin/dev' into issue/7380
vpetrigo May 31, 2026
f9f88dc
merge: resolve conflicts with dev branch
vpetrigo Jun 4, 2026
c74191c
Merge branch 'dev' into issue/7380
vpetrigo Jun 4, 2026
68e5f55
Merge branch 'dev' into issue/7380
vpetrigo Jun 5, 2026
7321a7a
Merge branch 'dev' into issue/7380
vpetrigo Jun 6, 2026
3bc99a0
Merge remote-tracking branch 'origin/dev' into issue/7380
vpetrigo Jun 7, 2026
098f801
Merge branch 'dev' into issue/7380
vpetrigo Jun 8, 2026
66285b9
Merge remote-tracking branch 'origin/dev' into issue/7380
vpetrigo Jun 8, 2026
07a1287
tests: Clean up
vpetrigo Jun 8, 2026
ae9b31a
Merge branch 'dev' into issue/7380
vpetrigo Jun 8, 2026
82735b0
Merge branch 'dev' into issue/7380
vpetrigo Jun 8, 2026
3697ddc
Merge branch 'dev' into issue/7380
vpetrigo Jun 9, 2026
d59ba2a
Merge branch 'dev' into issue/7380
vpetrigo Jun 10, 2026
dabf066
Merge branch 'dev' into issue/7380
vpetrigo Jun 10, 2026
48fefc6
Merge branch 'dev' into issue/7380
vpetrigo Jun 10, 2026
9a1da89
Merge branch 'dev' into issue/7380
vpetrigo Jun 11, 2026
e832686
Merge branch 'dev' into issue/7380
vpetrigo Jun 11, 2026
7eed463
Merge branch 'dev' into issue/7380
vpetrigo Jun 12, 2026
3dd86e1
Merge branch 'dev' into issue/7380
vpetrigo Jun 12, 2026
2151dfe
Merge branch 'dev' into issue/7380
vpetrigo Jun 12, 2026
cfdb570
Merge branch 'dev' into issue/7380
vpetrigo Jun 14, 2026
c8f43cf
Merge branch 'dev' into issue/7380
vpetrigo Jun 15, 2026
eec662b
Merge branch 'dev' into issue/7380
vpetrigo Jun 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,14 +105,23 @@ 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({
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),
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 }))
Expand All @@ -123,6 +132,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
Expand All @@ -132,7 +142,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",
Expand Down
24 changes: 19 additions & 5 deletions packages/opencode/src/session/message-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,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"
Expand Down Expand Up @@ -106,6 +108,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(db: Database.Interface["db"], rows: (typeof MessageTable.$inferSelect)[]) {
const ids = rows.map((row) => row.id)
const partByMessage = new Map<string, Part[]>()
Expand Down Expand Up @@ -437,17 +442,26 @@ 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)
: after
? and(eq(MessageTable.session_id, input.sessionID), newer(after))
: eq(MessageTable.session_id, input.sessionID)
const rows = yield* 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()
.pipe(Effect.orDie)
Expand All @@ -468,12 +482,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 = yield* hydrate(db, 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,
}
})

Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/util/locale.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
export * from "@opencode-ai/tui/util/locale"
export { Locale } from "@opencode-ai/tui/util/locale"
export { Locale } from "@opencode-ai/tui/util/locale"
40 changes: 40 additions & 0 deletions packages/opencode/test/session/messages-pagination.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,46 @@ 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 = 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 = 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 = 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()
}),
),
)

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 })

const exit = yield* Effect.exit(
MessageV2.page({ sessionID, limit: 2, before: dummyCursor, after: dummyCursor }),
)
expect(exit._tag).toBe("Failure")
}),
),
)

it.instance("large limit returns all messages without cursor", () =>
withSession(({ sessionID }) =>
Effect.gen(function* () {
Expand Down
2 changes: 2 additions & 0 deletions packages/sdk/js/src/gen/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2556,6 +2556,8 @@ export type SessionMessagesData = {
query?: {
directory?: string
limit?: number
before?: string
after?: string
}
url: "/session/{id}/message"
}
Expand Down
2 changes: 2 additions & 0 deletions packages/sdk/js/src/v2/gen/sdk.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3648,6 +3648,7 @@ export class Session2 extends HeyApiClient {
workspace?: string
limit?: number
before?: string
after?: string
},
options?: Options<never, ThrowOnError>,
) {
Expand All @@ -3661,6 +3662,7 @@ export class Session2 extends HeyApiClient {
{ in: "query", key: "workspace" },
{ in: "query", key: "limit" },
{ in: "query", key: "before" },
{ in: "query", key: "after" },
],
},
],
Expand Down
1 change: 1 addition & 0 deletions packages/sdk/js/src/v2/gen/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7799,6 +7799,7 @@ export type SessionMessagesData = {
workspace?: string
limit?: number
before?: string
after?: string
}
url: "/session/{sessionID}/message"
}
Expand Down
8 changes: 8 additions & 0 deletions packages/sdk/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -6109,6 +6109,14 @@
"type": "string"
},
"required": false
},
{
"name": "after",
"in": "query",
"schema": {
"type": "string"
},
"required": false
}
],
"responses": {
Expand Down
Loading
Loading