From f1d8f7d888aa431f526b5170cb59a6f9e13bf7f3 Mon Sep 17 00:00:00 2001 From: ryan Date: Thu, 15 Jan 2026 00:45:44 -0700 Subject: [PATCH] feat(tui): add timestamp-based progressive message loading Implements progressive history loading with two distinct modes for accessing older messages in long-running sessions. ## Implementation **Server API Enhancement** Added two optional parameters to Session.messages(): - ts_before: Unix timestamp for loading messages older than a specific point - breakpoint: Boolean flag controlling whether to stop at compaction summaries **Client Load Functions** - loadConversationHistory(): Loads messages up to the next compaction summary - loadFullSessionHistory(): Loads entire remaining session history without stopping Both functions use the earliest loaded message timestamp as an anchor point, eliminating the need for index tracking or offset calculations. **UI Integration** Displays "Load more messages" when 100+ messages are present, offering two clickable options. Toast notifications show the count of messages loaded. Uses a synthetic message pattern for clean, reactive positioning. ## Design Decisions **Timestamp-Based Anchoring** Uses message timestamps as immutable reference points rather than maintaining counts or offsets. This eliminates state tracking complexity and race conditions. **Truthiness Pattern for Breakpoint** The breakpoint parameter uses standard boolean coercion (z.coerce.boolean). Omitting the parameter (undefined) is falsy and loads everything. Sending true stops at compaction summaries. This follows the established pattern used by other boolean parameters like 'roots'. **Two Loading Modes** - Conversation history: Stops at compaction summaries to show context - Full history: Loads all remaining messages for complete reconstruction **Non-Disruptive** All parameters are optional. Existing functionality remains unchanged. Zero breaking changes to any existing code paths. ## Technical Details The server iterates through messages in reverse chronological order, skipping messages newer than ts_before. When breakpoint is truthy and a compaction message is encountered, iteration stops. Results are reversed before returning to maintain chronological order. The client prepends loaded messages to the beginning of the existing array, maintaining sort order with oldest messages first. ## Files Changed - packages/opencode/src/session/index.ts - Core loading logic - packages/opencode/src/server/server.ts - HTTP endpoint parameters - packages/opencode/src/cli/cmd/tui/context/sync.tsx - Load functions - packages/opencode/src/cli/cmd/tui/routes/session/index.tsx - UI - packages/sdk/* - Generated SDK types Total: 176 lines added across 7 files --- .../opencode/src/cli/cmd/tui/context/sync.tsx | 52 +++++++++++ .../src/cli/cmd/tui/routes/session/index.tsx | 87 ++++++++++++++++++- packages/opencode/src/server/server.ts | 4 + packages/opencode/src/session/index.ts | 14 +++ packages/sdk/js/src/v2/gen/sdk.gen.ts | 4 + packages/sdk/js/src/v2/gen/types.gen.ts | 2 + packages/sdk/openapi.json | 14 +++ 7 files changed, 176 insertions(+), 1 deletion(-) 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",