diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 0edc911344c..db6112f3147 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -419,6 +419,58 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ ) fullSyncedSessions.add(sessionID) }, + async loadConversationHistory(sessionID: string) { + const messages = store.message[sessionID] + if (!messages || messages.length === 0) return + + const earliest = messages[0] + const result = await sdk.client.session.messages({ + sessionID, + ts_before: earliest.time.created, + breakpoint: true, + }) + + if (!result.data || result.data.length === 0) { + return 0 + } + + setStore( + produce((draft) => { + const existing = draft.message[sessionID] ?? [] + draft.message[sessionID] = [...result.data!.map((x) => x.info), ...existing] + for (const message of result.data!) { + draft.part[message.info.id] = message.parts + } + }), + ) + return result.data.length + }, + async loadFullSessionHistory(sessionID: string) { + const messages = store.message[sessionID] + if (!messages || messages.length === 0) return + + const earliest = messages[0] + const result = await sdk.client.session.messages({ + sessionID, + ts_before: earliest.time.created, + // Omit breakpoint - undefined is falsy and won't stop at compaction + }) + + if (!result.data || result.data.length === 0) { + return 0 + } + + setStore( + produce((draft) => { + const existing = draft.message[sessionID] ?? [] + draft.message[sessionID] = [...result.data!.map((x) => x.info), ...existing] + for (const message of result.data!) { + draft.part[message.info.id] = message.parts + } + }), + ) + return result.data.length + }, }, 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 d91363954a1..cbe6bc2db5f 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -119,6 +119,22 @@ 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 messagesDisplay = createMemo(() => { + const msgs = messages() + if (msgs.length >= 100) { + const synthetic = { + id: "__load_more__", + sessionID: route.sessionID, + role: "system" as const, + time: { created: 0, updated: 0, completed: null }, + _synthetic: true, + } as any + return [synthetic, ...msgs] + } + return msgs + }) + const permissions = createMemo(() => { if (session()?.parentID) return [] return children().flatMap((x) => sync.data.permission[x.id] ?? []) @@ -926,9 +942,78 @@ export function Session() { flexGrow={1} scrollAcceleration={scrollAcceleration()} > - + {(message, index) => ( + + {(function () { + const [hoveredButton, setHoveredButton] = createSignal<"conversation" | "full" | null>(null) + const [loading, setLoading] = createSignal(false) + + const handleLoadConversation = async () => { + if (loading()) return + setLoading(true) + try { + const count = await sync.session.loadConversationHistory(route.sessionID) + if (count === 0) { + toast.show({ message: "No more messages loaded", variant: "info" }) + } else { + toast.show({ message: `History loaded (${count} messages)`, variant: "success" }) + } + } finally { + setLoading(false) + } + } + + const handleLoadFull = async () => { + if (loading()) return + setLoading(true) + try { + const count = await sync.session.loadFullSessionHistory(route.sessionID) + if (count === 0) { + toast.show({ message: "No more messages loaded", variant: "info" }) + } else { + toast.show({ message: `History loaded (${count} messages)`, variant: "success" }) + } + } finally { + setLoading(false) + } + } + + return ( + + Load more messages: + setHoveredButton("conversation")} + onMouseOut={() => setHoveredButton(null)} + onMouseUp={handleLoadConversation} + > + + load conversation history + + + or + setHoveredButton("full")} + onMouseOut={() => setHoveredButton(null)} + onMouseUp={handleLoadFull} + > + + load full session history + + + + ) + })()} + {(function () { const command = useCommandDialog() diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 7015c818822..38608aad207 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -1251,6 +1251,8 @@ export namespace Server { "query", z.object({ limit: z.coerce.number().optional(), + ts_before: z.coerce.number().optional(), + breakpoint: z.coerce.boolean().optional(), }), ), async (c) => { @@ -1258,6 +1260,8 @@ export namespace Server { const messages = await Session.messages({ sessionID: c.req.valid("param").sessionID, limit: query.limit, + ts_before: query.ts_before, + breakpoint: query.breakpoint, }) return c.json(messages) }, diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 3fcdab5238c..0318d5763c3 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -293,12 +293,26 @@ export namespace Session { z.object({ sessionID: Identifier.schema("session"), limit: z.number().optional(), + ts_before: z.number().optional(), + breakpoint: z.boolean().optional(), }), async (input) => { const result = [] as MessageV2.WithParts[] + for await (const msg of MessageV2.stream(input.sessionID)) { + if (input.ts_before && msg.info.time.created >= input.ts_before) { + continue + } + if (input.limit && result.length >= input.limit) break result.push(msg) + + if (input.ts_before && input.breakpoint) { + const hasCompaction = msg.parts.some((p) => p.type === "compaction") + if (hasCompaction) { + break + } + } } result.reverse() return result diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 697dac7eefe..0297c1df57c 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -1279,6 +1279,8 @@ export class Session extends HeyApiClient { sessionID: string directory?: string limit?: number + ts_before?: number + breakpoint?: boolean }, options?: Options, ) { @@ -1290,6 +1292,8 @@ export class Session extends HeyApiClient { { in: "path", key: "sessionID" }, { in: "query", key: "directory" }, { in: "query", key: "limit" }, + { in: "query", key: "ts_before" }, + { in: "query", key: "breakpoint" }, ], }, ], diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 9c4f0e50d12..19c953e2932 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -3122,6 +3122,8 @@ export type SessionMessagesData = { query?: { directory?: string limit?: number + ts_before?: number + breakpoint?: boolean } url: "/session/{sessionID}/message" } diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 20b1029fc96..f9e14033dd2 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -2008,6 +2008,20 @@ "schema": { "type": "number" } + }, + { + "in": "query", + "name": "ts_before", + "schema": { + "type": "number" + } + }, + { + "in": "query", + "name": "breakpoint", + "schema": { + "type": "boolean" + } } ], "summary": "Get session messages",