Skip to content

Commit f1d8f7d

Browse files
committed
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
1 parent 472a6cc commit f1d8f7d

7 files changed

Lines changed: 176 additions & 1 deletion

File tree

packages/opencode/src/cli/cmd/tui/context/sync.tsx

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,58 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
419419
)
420420
fullSyncedSessions.add(sessionID)
421421
},
422+
async loadConversationHistory(sessionID: string) {
423+
const messages = store.message[sessionID]
424+
if (!messages || messages.length === 0) return
425+
426+
const earliest = messages[0]
427+
const result = await sdk.client.session.messages({
428+
sessionID,
429+
ts_before: earliest.time.created,
430+
breakpoint: true,
431+
})
432+
433+
if (!result.data || result.data.length === 0) {
434+
return 0
435+
}
436+
437+
setStore(
438+
produce((draft) => {
439+
const existing = draft.message[sessionID] ?? []
440+
draft.message[sessionID] = [...result.data!.map((x) => x.info), ...existing]
441+
for (const message of result.data!) {
442+
draft.part[message.info.id] = message.parts
443+
}
444+
}),
445+
)
446+
return result.data.length
447+
},
448+
async loadFullSessionHistory(sessionID: string) {
449+
const messages = store.message[sessionID]
450+
if (!messages || messages.length === 0) return
451+
452+
const earliest = messages[0]
453+
const result = await sdk.client.session.messages({
454+
sessionID,
455+
ts_before: earliest.time.created,
456+
// Omit breakpoint - undefined is falsy and won't stop at compaction
457+
})
458+
459+
if (!result.data || result.data.length === 0) {
460+
return 0
461+
}
462+
463+
setStore(
464+
produce((draft) => {
465+
const existing = draft.message[sessionID] ?? []
466+
draft.message[sessionID] = [...result.data!.map((x) => x.info), ...existing]
467+
for (const message of result.data!) {
468+
draft.part[message.info.id] = message.parts
469+
}
470+
}),
471+
)
472+
return result.data.length
473+
},
422474
},
423475
bootstrap,
424476
}

packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,22 @@ export function Session() {
119119
.toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
120120
})
121121
const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
122+
123+
const messagesDisplay = createMemo(() => {
124+
const msgs = messages()
125+
if (msgs.length >= 100) {
126+
const synthetic = {
127+
id: "__load_more__",
128+
sessionID: route.sessionID,
129+
role: "system" as const,
130+
time: { created: 0, updated: 0, completed: null },
131+
_synthetic: true,
132+
} as any
133+
return [synthetic, ...msgs]
134+
}
135+
return msgs
136+
})
137+
122138
const permissions = createMemo(() => {
123139
if (session()?.parentID) return []
124140
return children().flatMap((x) => sync.data.permission[x.id] ?? [])
@@ -926,9 +942,78 @@ export function Session() {
926942
flexGrow={1}
927943
scrollAcceleration={scrollAcceleration()}
928944
>
929-
<For each={messages()}>
945+
<For each={messagesDisplay()}>
930946
{(message, index) => (
931947
<Switch>
948+
<Match when={message.id === "__load_more__"}>
949+
{(function () {
950+
const [hoveredButton, setHoveredButton] = createSignal<"conversation" | "full" | null>(null)
951+
const [loading, setLoading] = createSignal(false)
952+
953+
const handleLoadConversation = async () => {
954+
if (loading()) return
955+
setLoading(true)
956+
try {
957+
const count = await sync.session.loadConversationHistory(route.sessionID)
958+
if (count === 0) {
959+
toast.show({ message: "No more messages loaded", variant: "info" })
960+
} else {
961+
toast.show({ message: `History loaded (${count} messages)`, variant: "success" })
962+
}
963+
} finally {
964+
setLoading(false)
965+
}
966+
}
967+
968+
const handleLoadFull = async () => {
969+
if (loading()) return
970+
setLoading(true)
971+
try {
972+
const count = await sync.session.loadFullSessionHistory(route.sessionID)
973+
if (count === 0) {
974+
toast.show({ message: "No more messages loaded", variant: "info" })
975+
} else {
976+
toast.show({ message: `History loaded (${count} messages)`, variant: "success" })
977+
}
978+
} finally {
979+
setLoading(false)
980+
}
981+
}
982+
983+
return (
984+
<box
985+
paddingLeft={2}
986+
paddingRight={2}
987+
paddingTop={1}
988+
paddingBottom={1}
989+
marginBottom={1}
990+
flexDirection="row"
991+
backgroundColor={theme.backgroundPanel}
992+
>
993+
<text fg={theme.textMuted}>Load more messages: </text>
994+
<box
995+
onMouseOver={() => setHoveredButton("conversation")}
996+
onMouseOut={() => setHoveredButton(null)}
997+
onMouseUp={handleLoadConversation}
998+
>
999+
<text fg={hoveredButton() === "conversation" ? theme.accent : theme.text}>
1000+
load conversation history
1001+
</text>
1002+
</box>
1003+
<text fg={theme.textMuted}> or </text>
1004+
<box
1005+
onMouseOver={() => setHoveredButton("full")}
1006+
onMouseOut={() => setHoveredButton(null)}
1007+
onMouseUp={handleLoadFull}
1008+
>
1009+
<text fg={hoveredButton() === "full" ? theme.accent : theme.text}>
1010+
load full session history
1011+
</text>
1012+
</box>
1013+
</box>
1014+
)
1015+
})()}
1016+
</Match>
9321017
<Match when={message.id === revert()?.messageID}>
9331018
{(function () {
9341019
const command = useCommandDialog()

packages/opencode/src/server/server.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1251,13 +1251,17 @@ export namespace Server {
12511251
"query",
12521252
z.object({
12531253
limit: z.coerce.number().optional(),
1254+
ts_before: z.coerce.number().optional(),
1255+
breakpoint: z.coerce.boolean().optional(),
12541256
}),
12551257
),
12561258
async (c) => {
12571259
const query = c.req.valid("query")
12581260
const messages = await Session.messages({
12591261
sessionID: c.req.valid("param").sessionID,
12601262
limit: query.limit,
1263+
ts_before: query.ts_before,
1264+
breakpoint: query.breakpoint,
12611265
})
12621266
return c.json(messages)
12631267
},

packages/opencode/src/session/index.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,12 +293,26 @@ export namespace Session {
293293
z.object({
294294
sessionID: Identifier.schema("session"),
295295
limit: z.number().optional(),
296+
ts_before: z.number().optional(),
297+
breakpoint: z.boolean().optional(),
296298
}),
297299
async (input) => {
298300
const result = [] as MessageV2.WithParts[]
301+
299302
for await (const msg of MessageV2.stream(input.sessionID)) {
303+
if (input.ts_before && msg.info.time.created >= input.ts_before) {
304+
continue
305+
}
306+
300307
if (input.limit && result.length >= input.limit) break
301308
result.push(msg)
309+
310+
if (input.ts_before && input.breakpoint) {
311+
const hasCompaction = msg.parts.some((p) => p.type === "compaction")
312+
if (hasCompaction) {
313+
break
314+
}
315+
}
302316
}
303317
result.reverse()
304318
return result

packages/sdk/js/src/v2/gen/sdk.gen.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1279,6 +1279,8 @@ export class Session extends HeyApiClient {
12791279
sessionID: string
12801280
directory?: string
12811281
limit?: number
1282+
ts_before?: number
1283+
breakpoint?: boolean
12821284
},
12831285
options?: Options<never, ThrowOnError>,
12841286
) {
@@ -1290,6 +1292,8 @@ export class Session extends HeyApiClient {
12901292
{ in: "path", key: "sessionID" },
12911293
{ in: "query", key: "directory" },
12921294
{ in: "query", key: "limit" },
1295+
{ in: "query", key: "ts_before" },
1296+
{ in: "query", key: "breakpoint" },
12931297
],
12941298
},
12951299
],

packages/sdk/js/src/v2/gen/types.gen.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3122,6 +3122,8 @@ export type SessionMessagesData = {
31223122
query?: {
31233123
directory?: string
31243124
limit?: number
3125+
ts_before?: number
3126+
breakpoint?: boolean
31253127
}
31263128
url: "/session/{sessionID}/message"
31273129
}

packages/sdk/openapi.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2008,6 +2008,20 @@
20082008
"schema": {
20092009
"type": "number"
20102010
}
2011+
},
2012+
{
2013+
"in": "query",
2014+
"name": "ts_before",
2015+
"schema": {
2016+
"type": "number"
2017+
}
2018+
},
2019+
{
2020+
"in": "query",
2021+
"name": "breakpoint",
2022+
"schema": {
2023+
"type": "boolean"
2024+
}
20112025
}
20122026
],
20132027
"summary": "Get session messages",

0 commit comments

Comments
 (0)