Skip to content

Commit fdd508a

Browse files
committed
feat(tui): add load conversation and session history UI
Adds UI for loading older messages in long-running sessions: - 'Load more messages' banner appears when 100+ messages present - Two loading modes: conversation history (stops at compaction) and full session - Toast notifications show count of messages loaded Depends on: PR anomalyco#8996 (ts_before and breakpoint API params)
1 parent e55fcdf commit fdd508a

File tree

4 files changed

+210
-157
lines changed

4 files changed

+210
-157
lines changed

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

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,57 @@ 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+
})
457+
458+
if (!result.data || result.data.length === 0) {
459+
return 0
460+
}
461+
462+
setStore(
463+
produce((draft) => {
464+
const existing = draft.message[sessionID] ?? []
465+
draft.message[sessionID] = [...result.data!.map((x) => x.info), ...existing]
466+
for (const message of result.data!) {
467+
draft.part[message.info.id] = message.parts
468+
}
469+
}),
470+
)
471+
return result.data.length
472+
},
422473
},
423474
bootstrap,
424475
}

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] ?? [])
@@ -921,9 +937,78 @@ export function Session() {
921937
flexGrow={1}
922938
scrollAcceleration={scrollAcceleration()}
923939
>
924-
<For each={messages()}>
940+
<For each={messagesDisplay()}>
925941
{(message, index) => (
926942
<Switch>
943+
<Match when={message.id === "__load_more__"}>
944+
{(function () {
945+
const [hoveredButton, setHoveredButton] = createSignal<"conversation" | "full" | null>(null)
946+
const [loading, setLoading] = createSignal(false)
947+
948+
const handleLoadConversation = async () => {
949+
if (loading()) return
950+
setLoading(true)
951+
try {
952+
const count = await sync.session.loadConversationHistory(route.sessionID)
953+
if (count === 0) {
954+
toast.show({ message: "No more messages loaded", variant: "info" })
955+
} else {
956+
toast.show({ message: `History loaded (${count} messages)`, variant: "success" })
957+
}
958+
} finally {
959+
setLoading(false)
960+
}
961+
}
962+
963+
const handleLoadFull = async () => {
964+
if (loading()) return
965+
setLoading(true)
966+
try {
967+
const count = await sync.session.loadFullSessionHistory(route.sessionID)
968+
if (count === 0) {
969+
toast.show({ message: "No more messages loaded", variant: "info" })
970+
} else {
971+
toast.show({ message: `History loaded (${count} messages)`, variant: "success" })
972+
}
973+
} finally {
974+
setLoading(false)
975+
}
976+
}
977+
978+
return (
979+
<box
980+
paddingLeft={2}
981+
paddingRight={2}
982+
paddingTop={1}
983+
paddingBottom={1}
984+
marginBottom={1}
985+
flexDirection="row"
986+
backgroundColor={theme.backgroundPanel}
987+
>
988+
<text fg={theme.textMuted}>Load more messages: </text>
989+
<box
990+
onMouseOver={() => setHoveredButton("conversation")}
991+
onMouseOut={() => setHoveredButton(null)}
992+
onMouseUp={handleLoadConversation}
993+
>
994+
<text fg={hoveredButton() === "conversation" ? theme.accent : theme.text}>
995+
load conversation history
996+
</text>
997+
</box>
998+
<text fg={theme.textMuted}> or </text>
999+
<box
1000+
onMouseOver={() => setHoveredButton("full")}
1001+
onMouseOut={() => setHoveredButton(null)}
1002+
onMouseUp={handleLoadFull}
1003+
>
1004+
<text fg={hoveredButton() === "full" ? theme.accent : theme.text}>
1005+
load full session history
1006+
</text>
1007+
</box>
1008+
</box>
1009+
)
1010+
})()}
1011+
</Match>
9271012
<Match when={message.id === revert()?.messageID}>
9281013
{(function () {
9291014
const command = useCommandDialog()

0 commit comments

Comments
 (0)