Revert "fix(tui): paginate session history"

This reverts commit cc29f86cdc.
This commit is contained in:
𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 2026-07-08 13:45:20 +00:00
commit bf15c97e4b
5 changed files with 385 additions and 385 deletions

View file

@ -1,77 +1,60 @@
// Client data layer: apply server events and cache API reads into a Solid store.
// Prefer straightforward projection. Do not add generation counters, stale-response
// merges, live/history overlays, or other race machinery here—last write wins.
// Reconnect may re-bootstrap; that is enough. UI and the server own ordering concerns.
import type {
AgentV2Info,
CommandV2Info,
AgentInfo,
CommandInfo,
FormFormInfo,
FormUrlInfo,
IntegrationInfo,
LocationRef,
McpServer,
ModelV2Info,
ModelInfo,
PermissionSavedInfo,
PermissionV2Request,
ProviderV2Info,
ReferenceInfo,
SessionMessage,
SessionMessageInfo,
SessionMessageAssistant,
SessionMessageAssistantReasoning,
SessionMessageAssistantText,
SessionMessageAssistantTool,
SessionV2Info,
SessionInfo,
Shell,
SkillV2Info,
SkillInfo,
V2Event,
} from "@opencode-ai/sdk/v2"
import { createStore, produce, reconcile } from "solid-js/store"
import { createSimpleContext } from "./helper"
import { useSDK } from "./sdk"
import { batch, createSignal, onCleanup } from "solid-js"
import { createSignal, onCleanup } from "solid-js"
export type DataSessionStatus = "idle" | "running"
const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_")
const MESSAGE_PAGE_SIZE = 25
export type FormInfo = FormFormInfo | FormUrlInfo
// Per-session message timeline plus older-history paging. `items` is ascending;
// `cursor` is the opaque server cursor for the next older page after a desc first load.
type SessionMessages = {
items: SessionMessage[]
cursor?: string
complete: boolean
loading: boolean
}
type LocationData = {
agent?: AgentV2Info[]
command?: CommandV2Info[]
agent?: AgentInfo[]
command?: CommandInfo[]
integration?: IntegrationInfo[]
mcp?: McpServer[]
model?: ModelV2Info[]
model?: ModelInfo[]
provider?: ProviderV2Info[]
reference?: ReferenceInfo[]
// Currently running shell commands for this location, keyed by shell id. Entries are removed
// once the command exits or is deleted, so this only ever holds in-flight shells.
shell?: Record<string, Shell>
skill?: SkillV2Info[]
skill?: SkillInfo[]
}
type Data = {
session: {
info: Record<string, SessionV2Info>
info: Record<string, SessionInfo>
// Family index keyed by a family's root (or furthest-known-ancestor when the
// true root is not yet loaded). The value is a flat deduplicated list of every
// session ID in that family, including the key itself once its info arrives.
family: Record<string, string[]>
status: Record<string, DataSessionStatus>
compaction: Partial<Record<string, string>>
compactionReason: Partial<Record<string, "auto" | "manual">>
message: Record<string, SessionMessages>
message: Record<string, SessionMessageInfo[]>
input: Record<string, string[]>
permission: Record<string, PermissionV2Request[]>
// Pending forms keyed by session ID.
@ -83,10 +66,6 @@ type Data = {
location: Record<string, LocationData>
}
function emptyMessages(): SessionMessages {
return { items: [], complete: false, loading: false }
}
function locationKey(location: LocationRef) {
return JSON.stringify([location.directory, location.workspaceID])
}
@ -111,8 +90,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
info: {},
family: {},
status: {},
compaction: {},
compactionReason: {},
message: {},
input: {},
permission: {},
@ -129,44 +106,66 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
directory: process.cwd(),
})
const messageIndex = new Map<string, Map<string, number>>()
const sessionRefreshGeneration = new Map<string, number>()
const sessionRefreshApplied = new Map<string, number>()
const sessionUsage = new Map<string, { generation: number; cost: number; tokens: SessionInfo["tokens"] }>()
let connectionGeneration = 0
let statusChanges: Set<string> | undefined
let bootstrapping: Promise<void> | undefined
function setSessionStatus(sessionID: string, status: DataSessionStatus) {
statusChanges?.add(sessionID)
setStore("session", "status", sessionID, status)
}
function nextSessionRefresh(sessionID: string) {
const generation = (sessionRefreshGeneration.get(sessionID) ?? 0) + 1
sessionRefreshGeneration.set(sessionID, generation)
return generation
}
function applySessionRefresh(sessionID: string, generation: number) {
if ((sessionRefreshApplied.get(sessionID) ?? 0) > generation) return false
sessionRefreshApplied.set(sessionID, generation)
return true
}
function updateSessionUsage(sessionID: string, cost: number, tokens: SessionInfo["tokens"]) {
sessionUsage.set(sessionID, { generation: (sessionUsage.get(sessionID)?.generation ?? 0) + 1, cost, tokens })
if (!store.session.info[sessionID]) return
setStore("session", "info", sessionID, { cost, tokens })
}
const message = {
update(sessionID: string, fn: (messages: SessionMessage[], index: Map<string, number>) => void) {
update(sessionID: string, fn: (messages: SessionMessageInfo[], index: Map<string, number>) => void) {
setStore(
"session",
"message",
produce((draft) => {
fn((draft[sessionID] ??= emptyMessages()).items, index(sessionID))
fn((draft[sessionID] ??= []), index(sessionID))
}),
)
},
append(messages: SessionMessage[], index: Map<string, number>, item: SessionMessage) {
append(messages: SessionMessageInfo[], index: Map<string, number>, item: SessionMessageInfo) {
if (index.has(item.id)) return
index.set(item.id, messages.length)
messages.push(item)
},
activeAssistant(messages: SessionMessage[]) {
activeAssistant(messages: SessionMessageInfo[]) {
const item = messages.findLast((item) => item.type === "assistant" && !item.time.completed)
return item?.type === "assistant" ? item : undefined
},
assistant(messages: SessionMessage[], index: Map<string, number>, messageID: string) {
assistant(messages: SessionMessageInfo[], index: Map<string, number>, messageID: string) {
const position = index.get(messageID)
const item = position === undefined ? undefined : messages[position]
return item?.type === "assistant" ? item : undefined
},
shell(messages: SessionMessage[], shellID: string) {
const item = messages.findLast((item) => item.type === "shell" && item.shell.id === shellID)
shell(messages: SessionMessageInfo[], shellID: string) {
const item = messages.findLast((item) => item.type === "shell" && item.shellID === shellID)
return item?.type === "shell" ? item : undefined
},
compaction(messages: SessionMessage[]) {
const item = messages.findLast(
(item) => item.type === "compaction" && (item.status === "queued" || item.status === "running"),
)
compaction(messages: SessionMessageInfo[]) {
const item = messages.findLast((item) => item.type === "compaction" && item.status === "running")
return item?.type === "compaction" ? item : undefined
},
latestTool(assistant: SessionMessageAssistant | undefined, callID?: string) {
@ -238,13 +237,14 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
}
function removeSession(sessionID: string) {
sessionRefreshApplied.set(sessionID, nextSessionRefresh(sessionID))
sessionUsage.delete(sessionID)
messageIndex.delete(sessionID)
setStore(
"session",
produce((draft) => {
delete draft.info[sessionID]
delete draft.status[sessionID]
delete draft.compaction[sessionID]
delete draft.message[sessionID]
delete draft.input[sessionID]
delete draft.permission[sessionID]
@ -267,11 +267,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
removeSession(event.data.sessionID)
break
case "session.usage.updated":
if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, {
cost: event.data.cost,
tokens: event.data.tokens,
})
updateSessionUsage(event.data.sessionID, event.data.cost, event.data.tokens)
break
case "catalog.updated":
void Promise.all([
@ -378,6 +374,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
id: messageIDFromEvent(event.id),
type: "system",
text: event.data.text,
metadata: event.metadata,
time: { created: event.created },
})
})
@ -387,9 +384,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
message.append(draft, index, {
id: messageIDFromEvent(event.id),
type: "synthetic",
sessionID: event.data.sessionID,
text: event.data.text,
description: event.data.description,
metadata: event.data.metadata,
time: { created: event.created },
})
})
@ -399,7 +396,11 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
message.append(draft, index, {
id: messageIDFromEvent(event.id),
type: "shell",
shell: event.data.shell,
shellID: event.data.shell.id,
command: event.data.shell.command,
status: event.data.shell.status,
exit: event.data.shell.exit,
metadata: event.metadata,
time: { created: event.created },
})
})
@ -408,7 +409,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
message.update(event.data.sessionID, (draft) => {
const match = message.shell(draft, event.data.shell.id)
if (!match) return
match.shell = event.data.shell
match.status = event.data.shell.status
match.exit = event.data.shell.exit
match.output = event.data.output
match.time.completed = event.created
})
@ -437,6 +439,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
type: "assistant",
agent: event.data.agent,
model: event.data.model,
metadata: event.metadata,
content: [],
snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined,
time: { created: event.created },
@ -497,7 +500,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
id: event.data.callID,
name: event.data.name,
time: { created: event.created },
state: { status: "pending", input: "" },
state: { status: "streaming", input: "" },
})
})
break
@ -507,7 +510,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
message.assistant(draft, index, event.data.assistantMessageID),
event.data.callID,
)
if (match?.state.status === "pending") match.state.input += event.data.delta
if (match?.state.status === "streaming") match.state.input += event.data.delta
})
break
case "session.tool.input.ended":
@ -516,7 +519,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
message.assistant(draft, index, event.data.assistantMessageID),
event.data.callID,
)
if (match?.state.status === "pending") match.state.input = event.data.text
if (match?.state.status === "streaming") match.state.input = event.data.text
})
break
case "session.tool.called":
@ -568,7 +571,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
message.assistant(draft, index, event.data.assistantMessageID),
event.data.callID,
)
if (!match || (match.state.status !== "pending" && match.state.status !== "running")) return
if (!match || (match.state.status !== "streaming" && match.state.status !== "running")) return
match.state = {
status: "error",
error: event.data.error,
@ -623,29 +626,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
setSessionStatus(event.data.sessionID, "running")
break
case "session.compaction.admitted":
message.update(event.data.sessionID, (draft, index) => {
if (message.compaction(draft)) return
message.append(draft, index, {
id: event.data.inputID,
type: "compaction",
status: "queued",
reason: "manual",
summary: "",
recent: "",
time: { created: event.created },
})
})
break
case "session.compaction.started":
setStore("session", "compaction", event.data.sessionID, "")
setStore("session", "compactionReason", event.data.sessionID, event.data.reason)
message.update(event.data.sessionID, (draft, index) => {
const current = message.compaction(draft)
if (current) {
current.status = "running"
current.reason = event.data.reason
return
}
message.append(draft, index, {
id: event.data.inputID ?? messageIDFromEvent(event.id),
type: "compaction",
@ -661,10 +644,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
case "session.execution.failed":
case "session.execution.interrupted":
setSessionStatus(event.data.sessionID, "idle")
if (store.session.compaction[event.data.sessionID] !== undefined)
setStore("session", "compaction", event.data.sessionID, undefined)
if (store.session.compactionReason[event.data.sessionID] !== undefined)
setStore("session", "compactionReason", event.data.sessionID, undefined)
message.update(event.data.sessionID, (draft) => {
const currentAssistant = message.activeAssistant(draft)
if (currentAssistant) currentAssistant.retry = undefined
@ -695,22 +674,26 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
})
break
case "session.compaction.delta":
setStore("session", "compaction", event.data.sessionID, (text) => (text ?? "") + event.data.text)
message.update(event.data.sessionID, (draft) => {
const current = message.compaction(draft)
if (current) current.summary += event.data.text
if (current?.status === "running") current.summary += event.data.text
})
break
case "session.compaction.ended":
setStore("session", "compaction", event.data.sessionID, undefined)
setStore("session", "compactionReason", event.data.sessionID, undefined)
message.update(event.data.sessionID, (draft, index) => {
const current = message.compaction(draft)
if (current) {
current.status = "completed"
current.reason = event.data.reason
current.summary = event.data.text
current.recent = event.data.recent
const position = draft.findLastIndex((item) => item.type === "compaction" && item.status === "running")
const current = draft[position]
if (current?.type === "compaction") {
draft[position] = {
id: current.id,
type: "compaction",
status: "completed",
reason: event.data.reason,
summary: event.data.text,
recent: event.data.recent,
metadata: current.metadata,
time: current.time,
}
return
}
message.append(draft, index, {
@ -725,11 +708,26 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
})
break
case "session.compaction.failed":
setStore("session", "compaction", event.data.sessionID, undefined)
setStore("session", "compactionReason", event.data.sessionID, undefined)
message.update(event.data.sessionID, (draft) => {
const current = message.compaction(draft)
if (current) current.status = "failed"
message.update(event.data.sessionID, (draft, index) => {
const position = draft.findLastIndex((item) => item.type === "compaction" && item.status === "running")
const current = draft[position]
const failed: Extract<SessionMessageInfo, { type: "compaction"; status: "failed" }> = {
id: current?.id ?? event.data.inputID ?? messageIDFromEvent(event.id),
type: "compaction",
status: "failed",
reason: event.data.reason ?? "manual",
error: event.data.error ?? {
type: "compaction.failed",
message: "Compaction failed before recording an error",
},
metadata: current?.type === "compaction" ? current.metadata : event.metadata,
time: current?.type === "compaction" ? current.time : { created: event.created },
}
if (current?.type === "compaction") {
draft[position] = failed
return
}
message.append(draft, index, failed)
})
break
case "permission.v2.asked":
@ -847,73 +845,50 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
return store.session.input[sessionID]?.includes(inputID) ?? false
},
},
compaction(sessionID: string) {
return store.session.compaction[sessionID]
},
async refresh(sessionID: string) {
setStore("session", "info", sessionID, mutable(await sdk.api.session.get({ sessionID })))
const generation = nextSessionRefresh(sessionID)
const usageGeneration = sessionUsage.get(sessionID)?.generation ?? 0
const info = mutable(await sdk.api.session.get({ sessionID }))
if (!applySessionRefresh(sessionID, generation)) return
const usage = sessionUsage.get(sessionID)
setStore(
"session",
"info",
sessionID,
usage && usage.generation !== usageGeneration ? { ...info, cost: usage.cost, tokens: usage.tokens } : info,
)
registerSession(sessionID)
},
message: {
ids(sessionID: string) {
return (store.session.message[sessionID]?.items ?? []).map((message) => message.id)
return (store.session.message[sessionID] ?? []).map((message) => message.id)
},
list(sessionID: string) {
return store.session.message[sessionID]?.items ?? []
return store.session.message[sessionID] ?? []
},
get(sessionID: string, messageID: string) {
const messages = store.session.message[sessionID]?.items
const messages = store.session.message[sessionID]
const position = messageIndex.get(sessionID)?.get(messageID)
return position === undefined ? undefined : messages?.[position]
},
cursor(sessionID: string) {
return store.session.message[sessionID]?.cursor
},
complete(sessionID: string) {
return store.session.message[sessionID]?.complete ?? false
},
loading(sessionID: string) {
return store.session.message[sessionID]?.loading ?? false
},
async refresh(sessionID: string) {
setStore("session", "message", sessionID, { ...emptyMessages(), loading: true })
const live = [...(store.session.message[sessionID] ?? [])]
setStore("session", "message", sessionID, [])
messageIndex.set(sessionID, new Map())
const response = await sdk.api.message.list({ sessionID, limit: MESSAGE_PAGE_SIZE, order: "desc" })
const items = mutable(response.data).toReversed()
messageIndex.set(sessionID, new Map(items.map((message, index) => [message.id, index])))
setStore("session", "message", sessionID, {
items,
cursor: response.cursor.next ?? undefined,
complete: response.data.length < MESSAGE_PAGE_SIZE,
loading: false,
})
const running = items.find((message) => message.type === "compaction" && message.status === "running")
setStore("session", "compaction", sessionID, running?.type === "compaction" ? running.summary : undefined)
setStore(
"session",
"compactionReason",
sessionID,
running?.type === "compaction" ? running.reason : undefined,
)
},
async more(sessionID: string) {
const current = store.session.message[sessionID]
if (!current || current.loading || current.complete || !current.cursor) return
const cursor = current.cursor
setStore("session", "message", sessionID, "loading", true)
const response = await sdk.api.message.list({ sessionID, limit: MESSAGE_PAGE_SIZE, cursor })
const older = mutable(response.data).toReversed()
const prepend = older.filter((item) => !messageIndex.get(sessionID)?.has(item.id))
const items = [...prepend, ...current.items]
messageIndex.set(sessionID, new Map(items.map((item, position) => [item.id, position])))
batch(() => {
setStore("session", "message", sessionID, "items", items)
setStore("session", "message", sessionID, {
cursor: response.cursor.next ?? undefined,
complete: response.data.length < MESSAGE_PAGE_SIZE,
loading: false,
})
})
const loaded = mutable(
(await sdk.api.message.list({ sessionID, limit: 200, order: "desc" })).data,
).toReversed()
const loadedIDs = new Set(loaded.map((message) => message.id))
const liveByID = new Map(live.map((message) => [message.id, message]))
const messages = [
...loaded.map((message) => {
if (message.type === "user") return message
return liveByID.get(message.id) ?? message
}),
...live.filter((message) => !loadedIDs.has(message.id)),
].toSorted((a, b) => a.time.created - b.time.created)
messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index])))
setStore("session", "message", sessionID, messages)
},
},
permission: {
@ -1056,6 +1031,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
async function bootstrap() {
if (bootstrapping) return bootstrapping
const generation = new Map(sessionRefreshApplied)
const usageGeneration = new Map(Array.from(sessionUsage, ([id, usage]) => [id, usage.generation]))
bootstrapping = Promise.allSettled([
sdk.api.session
.list({
@ -1069,7 +1046,15 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
"session",
"info",
produce((draft) => {
for (const session of response.data) draft[session.id] = mutable(session)
for (const session of response.data) {
if ((sessionRefreshApplied.get(session.id) ?? 0) !== (generation.get(session.id) ?? 0)) continue
const usage = sessionUsage.get(session.id)
draft[session.id] = mutable(
usage && usage.generation !== (usageGeneration.get(session.id) ?? 0)
? { ...session, cost: usage.cost, tokens: usage.tokens }
: session,
)
}
}),
)
for (const session of response.data) registerSession(session.id)
@ -1116,16 +1101,23 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
}
function refreshActive() {
const generation = ++connectionGeneration
const changed = new Set<string>()
statusChanges = changed
void sdk.api.session
.active()
.then((active) => {
setStore(
"session",
"status",
reconcile(Object.fromEntries(Object.keys(active).map((sessionID) => [sessionID, "running" as const]))),
if (generation !== connectionGeneration) return
const status: Record<string, DataSessionStatus> = Object.fromEntries(
Object.keys(active).map((sessionID) => [sessionID, "running" as const]),
)
for (const sessionID of changed) status[sessionID] = store.session.status[sessionID]
setStore("session", "status", reconcile(status))
})
.catch(() => undefined)
.finally(() => {
if (statusChanges === changed) statusChanges = undefined
})
}
onCleanup(