Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
52 changes: 50 additions & 2 deletions packages/opencode/src/cli/cmd/tui/context/sync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
}
Expand Down Expand Up @@ -84,6 +90,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
session_diff: {},
todo: {},
message: {},
message_page: {},
part: {},
lsp: [],
mcp: {},
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -350,17 +356,19 @@ 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)
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 ?? []
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
}
Expand All @@ -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,
}
Expand Down
24 changes: 24 additions & 0 deletions packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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] ?? [])
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -908,6 +925,8 @@ export function Session() {
</Show>
<scrollbox
ref={(r) => (scroll = r)}
onMouseScroll={() => loadMore()}
onKeyDown={() => setTimeout(loadMore, 1)}
viewportOptions={{
paddingRight: showScrollbar() ? 1 : 0,
}}
Expand All @@ -924,6 +943,11 @@ export function Session() {
flexGrow={1}
scrollAcceleration={scrollAcceleration()}
>
<Show when={paging()?.loading}>
<box flexShrink={0} paddingLeft={1}>
<text fg={theme.textMuted}>Loading more...</text>
</box>
</Show>
<For each={messages()}>
{(message, index) => (
<Switch>
Expand Down
26 changes: 22 additions & 4 deletions packages/opencode/src/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
33 changes: 30 additions & 3 deletions packages/opencode/src/session/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
64 changes: 64 additions & 0 deletions packages/opencode/test/session/messages-pagination.test.ts
Original file line number Diff line number Diff line change
@@ -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)
},
})
})
})

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 @@ -1191,6 +1191,7 @@ export class Session extends HeyApiClient {
sessionID: string
directory?: string
limit?: number
before?: string
},
options?: Options<never, ThrowOnError>,
) {
Expand All @@ -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" },
],
},
],
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 @@ -2915,6 +2915,7 @@ export type SessionMessagesData = {
query?: {
directory?: string
limit?: number
before?: string
}
url: "/session/{sessionID}/message"
}
Expand Down
7 changes: 7 additions & 0 deletions packages/sdk/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -1887,6 +1887,13 @@
"schema": {
"type": "number"
}
},
{
"in": "query",
"name": "before",
"schema": {
"type": "string"
}
}
],
"summary": "Get session messages",
Expand Down