fix(tui): paginate session history
This commit is contained in:
parent
86e3828da6
commit
cc29f86cdc
5 changed files with 385 additions and 385 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue