fix(tui): paginate session history

This commit is contained in:
Dax Raad 2026-07-08 01:34:43 -04:00
commit cc29f86cdc
5 changed files with 385 additions and 385 deletions

View file

@ -198,6 +198,16 @@ export function Session() {
?.id
})
// Admitted inputs and in-flight manual compaction sit after history, not in rows.
const pendingMessages = createMemo(() => {
const boundary = session()?.revert?.messageID
return messages().filter((message) => {
if (boundary && message.id >= boundary) return false
if (data.session.input.has(route.sessionID, message.id)) return true
return message.type === "compaction" && (message.status === "queued" || message.status === "running")
})
})
const lastAssistant = createMemo(() => {
return messages().findLast((x) => x.type === "assistant")
})
@ -241,37 +251,44 @@ export function Session() {
}),
)
createEffect(() => {
const sessionID = route.sessionID
void (async () => {
await Promise.all([
data.session.refresh(sessionID),
data.session.permission.refresh(sessionID),
data.session.form.refresh(sessionID),
])
const info = data.session.get(sessionID)
if (!info) {
toast.show({
message: `Session not found: ${sessionID}`,
variant: "error",
duration: 5000,
createEffect(
on(
() => route.sessionID,
(sessionID) => {
void (async () => {
if (data.session.message.list(sessionID).length === 0) {
await Promise.all([
data.session.refresh(sessionID),
data.session.message.refresh(sessionID),
data.session.permission.refresh(sessionID),
data.session.form.refresh(sessionID),
])
}
const info = data.session.get(sessionID)
if (!info) {
toast.show({
message: `Session not found: ${sessionID}`,
variant: "error",
duration: 5000,
})
navigate({ type: "home" })
return
}
project.workspace.set(info.location.workspaceID)
editor.reconnect(info.location.directory)
if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000)
})().catch((error) => {
if (route.sessionID !== sessionID) return
toast.show({
message: errorMessage(error),
variant: "error",
duration: 5000,
})
navigate({ type: "home" })
})
navigate({ type: "home" })
return
}
project.workspace.set(info.location.workspaceID)
editor.reconnect(info.location.directory)
if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000)
})().catch((error) => {
if (route.sessionID !== sessionID) return
toast.show({
message: errorMessage(error),
variant: "error",
duration: 5000,
})
navigate({ type: "home" })
})
})
},
),
)
let seeded = false
let scroll: ScrollBoxRenderable
@ -327,12 +344,14 @@ export function Session() {
if (!targetID) {
scroll.scrollBy(direction === "next" ? scroll.height : -scroll.height)
if (direction === "prev") loadOlder()
dialog.clear()
return
}
const child = scroll.getChildren().find((c) => c.id === targetID)
if (child) scroll.scrollBy(child.y - scroll.y - 1)
if (direction === "prev") loadOlder()
dialog.clear()
}
@ -343,6 +362,24 @@ export function Session() {
}, 50)
}
let loadingOlder = false
function loadOlder() {
if (loadingOlder || scroll.scrollTop > 2) return
loadingOlder = true
const before = scroll.scrollHeight
void data.session.message.more(route.sessionID).then(
() => {
setTimeout(() => {
if (!scroll.isDestroyed) scroll.scrollBy(scroll.scrollHeight - before)
loadingOlder = false
}, 50)
},
() => {
loadingOlder = false
},
)
}
const sessionCommandList = createMemo(() => [
{
title: "Share session",
@ -548,6 +585,7 @@ export function Session() {
hidden: true,
run: () => {
scroll.scrollBy(-scroll.height / 2)
loadOlder()
dialog.clear()
},
},
@ -568,6 +606,7 @@ export function Session() {
hidden: true,
run: () => {
scroll.scrollBy(-1)
loadOlder()
dialog.clear()
},
},
@ -588,6 +627,7 @@ export function Session() {
hidden: true,
run: () => {
scroll.scrollBy(-scroll.height / 4)
loadOlder()
dialog.clear()
},
},
@ -608,6 +648,7 @@ export function Session() {
hidden: true,
run: () => {
scroll.scrollTo(0)
loadOlder()
dialog.clear()
},
},
@ -917,6 +958,9 @@ export function Session() {
stickyStart="bottom"
flexGrow={1}
scrollAcceleration={scrollAcceleration()}
onMouseScroll={(event) => {
if (event.scroll?.direction === "up") void loadOlder()
}}
>
<For each={rows}>
{(row) => (
@ -926,6 +970,13 @@ export function Session() {
/>
)}
</For>
<For each={pendingMessages()}>
{(message) => (
<box marginTop={1} flexShrink={0}>
<SessionMessageView message={message} />
</box>
)}
</For>
<BackgroundToolHint messages={messages()} />
<Show when={session()?.revert?.messageID}>
<RevertMessage

View file

@ -1,6 +1,6 @@
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/sdk/v2"
import type { SessionMessage, SessionMessageAssistant } from "@opencode-ai/sdk/v2"
import { createEffect, on, onCleanup, type Accessor } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
import { createStore, produce } from "solid-js/store"
import { useData } from "../../context/data"
export type PartRef = {
@ -25,11 +25,22 @@ export function createSessionRows(sessionID: Accessor<string>) {
const [rows, setRows] = createStore<SessionRow[]>([])
const revertBoundary = () => data.session.get(sessionID())?.revert?.messageID
function pendingIDs() {
const inputs = data.session.input.list(sessionID())
const pending = new Set(inputs)
for (const message of data.session.message.list(sessionID())) {
if (message.type === "compaction" && (message.status === "queued" || message.status === "running"))
pending.add(message.id)
}
return pending
}
function reduce() {
const messages = data.session.message.list(sessionID())
const inputs = new Set(data.session.input.list(sessionID()))
const boundary = revertBoundary()
const rows = reduceSessionRows(boundary ? messages.filter((message) => message.id < boundary) : messages, inputs)
const visible = boundary ? messages.filter((message) => message.id < boundary) : messages
const pending = pendingIDs()
const rows = reduceSessionRows(visible.filter((message) => !pending.has(message.id)))
partitionPending(rows, pendingPermissions())
return rows
}
@ -52,48 +63,30 @@ export function createSessionRows(sessionID: Accessor<string>) {
})
createEffect(
on(sessionID, (id) => {
setRows(reconcile(reduce()))
void data.session.message.refresh(id).then(
() => {
if (sessionID() !== id) return
setRows(reconcile(reduce()))
},
() => undefined,
)
on(sessionID, () => {
setRows(reduce())
}),
)
// Re-reduce when the revert boundary changes (stage/clear/commit).
createEffect(
on(revertBoundary, () => {
setRows(reconcile(reduce()))
setRows(reduce())
}),
)
// Pending inputs and compaction leaving the pending set change history membership.
createEffect(
on(
() =>
data.session.message.list(sessionID()).flatMap((message) =>
message.type === "user"
? [
{
id: message.id,
created: message.time.created,
input: data.session.input.has(sessionID(), message.id),
},
]
: message.type === "compaction"
? [
{
id: message.id,
created: message.time.created,
input: message.status === "running",
},
]
: [],
),
() => setRows(reconcile(reduce())),
() => {
const messages = data.session.message.list(sessionID())
const pending = data.session.input.list(sessionID()).join("\0")
const compaction = messages
.filter((message) => message.type === "compaction")
.map((message) => `${message.id}:${message.status}`)
.join("\0")
return `${pending}\u0001${compaction}`
},
() => setRows(reduce()),
),
)
@ -101,12 +94,9 @@ export function createSessionRows(sessionID: Accessor<string>) {
setRows(
produce((draft) => {
if (draft.some((row) => row.type === "message" && row.messageID === messageID)) return
const pending = isPending(messageID)
const message = data.session.message.get(sessionID(), messageID)
const index =
message?.type === "compaction" && pending ? queuedStart(draft) : pending ? draft.length : queuedStart(draft)
if (!pending) completePrevious(draft, index)
draft.splice(index, 0, { type: "message", messageID })
if (pendingIDs().has(messageID)) return
completePrevious(draft)
draft.push({ type: "message", messageID })
}),
)
@ -114,15 +104,14 @@ export function createSessionRows(sessionID: Accessor<string>) {
setRows(
produce((draft) => {
if (hasPart(draft, ref)) return
const index = queuedStart(draft)
if (name && exploration(name)) {
const previous = draft[index - 1]
const previous = draft.at(-1)
if (previous?.type === "group" && previous.kind === "exploration") {
previous.refs.push(ref)
return
}
completePrevious(draft, index)
draft.splice(index, 0, {
completePrevious(draft)
draft.push({
type: "group",
kind: "exploration",
refs: [ref],
@ -131,8 +120,8 @@ export function createSessionRows(sessionID: Accessor<string>) {
})
return
}
completePrevious(draft, index)
draft.splice(index, 0, { type: "part", ref })
completePrevious(draft)
draft.push({ type: "part", ref })
}),
)
@ -140,9 +129,8 @@ export function createSessionRows(sessionID: Accessor<string>) {
setRows(
produce((draft) => {
if (draft.some((row) => row.type === "assistant-footer" && row.messageID === messageID)) return
const index = queuedStart(draft)
completePrevious(draft, index)
draft.splice(index, 0, { type: "assistant-footer", messageID })
completePrevious(draft)
draft.push({ type: "assistant-footer", messageID })
}),
)
@ -154,26 +142,13 @@ export function createSessionRows(sessionID: Accessor<string>) {
}),
)
const isPending = (messageID: string) => {
const message = data.session.message.get(sessionID(), messageID)
if (message?.type === "user") return data.session.input.has(sessionID(), messageID)
return message?.type === "compaction" && message.status === "running"
}
const queuedStart = (rows: SessionRow[]) => {
const index = rows.findIndex((row) => row.type === "message" && isPending(row.messageID))
return index === -1 ? rows.length : index
}
const message = (event: { id: string; data: { sessionID: string } }) => {
if (event.data.sessionID === sessionID()) appendMessage(event.id.replace(/^evt_/, "msg_"))
}
const input = (event: { data: { sessionID: string; inputID: string } }) => {
if (event.data.sessionID === sessionID()) appendMessage(event.data.inputID)
}
const subscriptions = [
data.on("session.prompt.admitted", input),
data.on("session.compaction.started", message),
data.on("session.prompt.promoted", (event) => {
if (event.data.sessionID === sessionID()) appendMessage(event.data.inputID)
}),
data.on("session.instructions.updated", message),
data.on("session.synthetic", (event) => {
if (event.data.sessionID === sessionID() && event.data.description?.trim())
@ -182,9 +157,7 @@ export function createSessionRows(sessionID: Accessor<string>) {
data.on("session.shell.started", message),
data.on("session.agent.selected", message),
data.on("session.model.selected", message),
data.on("session.compaction.ended", (event) => {
if (event.data.reason !== "manual") message(event)
}),
data.on("session.text.delta", (event) => {
if (event.data.sessionID === sessionID())
appendPart({ messageID: event.data.assistantMessageID, partID: `text:${event.data.ordinal}` })
@ -224,18 +197,11 @@ export function createSessionRows(sessionID: Accessor<string>) {
return rows
}
export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new Set<string>()) {
const isInput = (message: SessionMessageInfo) => inputs.has(message.id)
const pendingCompactions = messages.filter((message) => message.type === "compaction" && message.status === "running")
const pending = new Set([...pendingCompactions.map((message) => message.id), ...inputs])
return [
...messages.filter((message) => !pending.has(message.id)),
...pendingCompactions,
...messages.filter(isInput),
].reduce<SessionRow[]>((rows, message) => {
export function reduceSessionRows(messages: SessionMessage[]) {
return messages.reduce<SessionRow[]>((rows, message) => {
if (message.type !== "assistant") {
if (message.type === "synthetic" && !message.description?.trim()) return rows
if (!pending.has(message.id)) completePrevious(rows)
completePrevious(rows)
rows.push({ type: "message", messageID: message.id })
return rows
}