mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-10 03:28:26 +00:00
Reapply "fix(tui): paginate session history"
This reverts commit bf15c97e4b.
This commit is contained in:
parent
bf15c97e4b
commit
d32deb5541
5 changed files with 386 additions and 386 deletions
|
|
@ -1,60 +1,77 @@
|
|||
// 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 {
|
||||
AgentInfo,
|
||||
CommandInfo,
|
||||
AgentV2Info,
|
||||
CommandV2Info,
|
||||
FormFormInfo,
|
||||
FormUrlInfo,
|
||||
IntegrationInfo,
|
||||
LocationRef,
|
||||
McpServer,
|
||||
ModelInfo,
|
||||
ModelV2Info,
|
||||
PermissionSavedInfo,
|
||||
PermissionV2Request,
|
||||
ProviderV2Info,
|
||||
ReferenceInfo,
|
||||
SessionMessageInfo,
|
||||
SessionMessage,
|
||||
SessionMessageAssistant,
|
||||
SessionMessageAssistantReasoning,
|
||||
SessionMessageAssistantText,
|
||||
SessionMessageAssistantTool,
|
||||
SessionInfo,
|
||||
SessionV2Info,
|
||||
Shell,
|
||||
SkillInfo,
|
||||
SkillV2Info,
|
||||
V2Event,
|
||||
} from "@opencode-ai/sdk/v2"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useSDK } from "./sdk"
|
||||
import { createSignal, onCleanup } from "solid-js"
|
||||
import { batch, 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?: AgentInfo[]
|
||||
command?: CommandInfo[]
|
||||
agent?: AgentV2Info[]
|
||||
command?: CommandV2Info[]
|
||||
integration?: IntegrationInfo[]
|
||||
mcp?: McpServer[]
|
||||
model?: ModelInfo[]
|
||||
model?: ModelV2Info[]
|
||||
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?: SkillInfo[]
|
||||
skill?: SkillV2Info[]
|
||||
}
|
||||
|
||||
type Data = {
|
||||
session: {
|
||||
info: Record<string, SessionInfo>
|
||||
info: Record<string, SessionV2Info>
|
||||
// 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>
|
||||
message: Record<string, SessionMessageInfo[]>
|
||||
compaction: Partial<Record<string, string>>
|
||||
compactionReason: Partial<Record<string, "auto" | "manual">>
|
||||
message: Record<string, SessionMessages>
|
||||
input: Record<string, string[]>
|
||||
permission: Record<string, PermissionV2Request[]>
|
||||
// Pending forms keyed by session ID.
|
||||
|
|
@ -66,6 +83,10 @@ 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])
|
||||
}
|
||||
|
|
@ -90,6 +111,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
info: {},
|
||||
family: {},
|
||||
status: {},
|
||||
compaction: {},
|
||||
compactionReason: {},
|
||||
message: {},
|
||||
input: {},
|
||||
permission: {},
|
||||
|
|
@ -106,66 +129,44 @@ 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: SessionMessageInfo[], index: Map<string, number>) => void) {
|
||||
update(sessionID: string, fn: (messages: SessionMessage[], index: Map<string, number>) => void) {
|
||||
setStore(
|
||||
"session",
|
||||
"message",
|
||||
produce((draft) => {
|
||||
fn((draft[sessionID] ??= []), index(sessionID))
|
||||
fn((draft[sessionID] ??= emptyMessages()).items, index(sessionID))
|
||||
}),
|
||||
)
|
||||
},
|
||||
append(messages: SessionMessageInfo[], index: Map<string, number>, item: SessionMessageInfo) {
|
||||
append(messages: SessionMessage[], index: Map<string, number>, item: SessionMessage) {
|
||||
if (index.has(item.id)) return
|
||||
index.set(item.id, messages.length)
|
||||
messages.push(item)
|
||||
},
|
||||
activeAssistant(messages: SessionMessageInfo[]) {
|
||||
activeAssistant(messages: SessionMessage[]) {
|
||||
const item = messages.findLast((item) => item.type === "assistant" && !item.time.completed)
|
||||
return item?.type === "assistant" ? item : undefined
|
||||
},
|
||||
assistant(messages: SessionMessageInfo[], index: Map<string, number>, messageID: string) {
|
||||
assistant(messages: SessionMessage[], 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: SessionMessageInfo[], shellID: string) {
|
||||
const item = messages.findLast((item) => item.type === "shell" && item.shellID === shellID)
|
||||
shell(messages: SessionMessage[], shellID: string) {
|
||||
const item = messages.findLast((item) => item.type === "shell" && item.shell.id === shellID)
|
||||
return item?.type === "shell" ? item : undefined
|
||||
},
|
||||
compaction(messages: SessionMessageInfo[]) {
|
||||
const item = messages.findLast((item) => item.type === "compaction" && item.status === "running")
|
||||
compaction(messages: SessionMessage[]) {
|
||||
const item = messages.findLast(
|
||||
(item) => item.type === "compaction" && (item.status === "queued" || item.status === "running"),
|
||||
)
|
||||
return item?.type === "compaction" ? item : undefined
|
||||
},
|
||||
latestTool(assistant: SessionMessageAssistant | undefined, callID?: string) {
|
||||
|
|
@ -237,14 +238,13 @@ 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,7 +267,11 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
removeSession(event.data.sessionID)
|
||||
break
|
||||
case "session.usage.updated":
|
||||
updateSessionUsage(event.data.sessionID, event.data.cost, event.data.tokens)
|
||||
if (store.session.info[event.data.sessionID])
|
||||
setStore("session", "info", event.data.sessionID, {
|
||||
cost: event.data.cost,
|
||||
tokens: event.data.tokens,
|
||||
})
|
||||
break
|
||||
case "catalog.updated":
|
||||
void Promise.all([
|
||||
|
|
@ -374,7 +378,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
id: messageIDFromEvent(event.id),
|
||||
type: "system",
|
||||
text: event.data.text,
|
||||
metadata: event.metadata,
|
||||
time: { created: event.created },
|
||||
})
|
||||
})
|
||||
|
|
@ -384,9 +387,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 },
|
||||
})
|
||||
})
|
||||
|
|
@ -396,11 +399,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
message.append(draft, index, {
|
||||
id: messageIDFromEvent(event.id),
|
||||
type: "shell",
|
||||
shellID: event.data.shell.id,
|
||||
command: event.data.shell.command,
|
||||
status: event.data.shell.status,
|
||||
exit: event.data.shell.exit,
|
||||
metadata: event.metadata,
|
||||
shell: event.data.shell,
|
||||
time: { created: event.created },
|
||||
})
|
||||
})
|
||||
|
|
@ -409,8 +408,7 @@ 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.status = event.data.shell.status
|
||||
match.exit = event.data.shell.exit
|
||||
match.shell = event.data.shell
|
||||
match.output = event.data.output
|
||||
match.time.completed = event.created
|
||||
})
|
||||
|
|
@ -439,7 +437,6 @@ 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 },
|
||||
|
|
@ -500,7 +497,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
id: event.data.callID,
|
||||
name: event.data.name,
|
||||
time: { created: event.created },
|
||||
state: { status: "streaming", input: "" },
|
||||
state: { status: "pending", input: "" },
|
||||
})
|
||||
})
|
||||
break
|
||||
|
|
@ -510,7 +507,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
message.assistant(draft, index, event.data.assistantMessageID),
|
||||
event.data.callID,
|
||||
)
|
||||
if (match?.state.status === "streaming") match.state.input += event.data.delta
|
||||
if (match?.state.status === "pending") match.state.input += event.data.delta
|
||||
})
|
||||
break
|
||||
case "session.tool.input.ended":
|
||||
|
|
@ -519,7 +516,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
message.assistant(draft, index, event.data.assistantMessageID),
|
||||
event.data.callID,
|
||||
)
|
||||
if (match?.state.status === "streaming") match.state.input = event.data.text
|
||||
if (match?.state.status === "pending") match.state.input = event.data.text
|
||||
})
|
||||
break
|
||||
case "session.tool.called":
|
||||
|
|
@ -571,7 +568,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
message.assistant(draft, index, event.data.assistantMessageID),
|
||||
event.data.callID,
|
||||
)
|
||||
if (!match || (match.state.status !== "streaming" && match.state.status !== "running")) return
|
||||
if (!match || (match.state.status !== "pending" && match.state.status !== "running")) return
|
||||
match.state = {
|
||||
status: "error",
|
||||
error: event.data.error,
|
||||
|
|
@ -626,9 +623,29 @@ 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",
|
||||
|
|
@ -644,6 +661,10 @@ 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
|
||||
|
|
@ -674,26 +695,22 @@ 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?.status === "running") current.summary += event.data.text
|
||||
if (current) 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 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,
|
||||
}
|
||||
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
|
||||
return
|
||||
}
|
||||
message.append(draft, index, {
|
||||
|
|
@ -708,26 +725,11 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
})
|
||||
break
|
||||
case "session.compaction.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)
|
||||
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"
|
||||
})
|
||||
break
|
||||
case "permission.v2.asked":
|
||||
|
|
@ -845,50 +847,73 @@ 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) {
|
||||
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,
|
||||
)
|
||||
setStore("session", "info", sessionID, mutable(await sdk.api.session.get({ sessionID })))
|
||||
registerSession(sessionID)
|
||||
},
|
||||
message: {
|
||||
ids(sessionID: string) {
|
||||
return (store.session.message[sessionID] ?? []).map((message) => message.id)
|
||||
return (store.session.message[sessionID]?.items ?? []).map((message) => message.id)
|
||||
},
|
||||
list(sessionID: string) {
|
||||
return store.session.message[sessionID] ?? []
|
||||
return store.session.message[sessionID]?.items ?? []
|
||||
},
|
||||
get(sessionID: string, messageID: string) {
|
||||
const messages = store.session.message[sessionID]
|
||||
const messages = store.session.message[sessionID]?.items
|
||||
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) {
|
||||
const live = [...(store.session.message[sessionID] ?? [])]
|
||||
setStore("session", "message", sessionID, [])
|
||||
setStore("session", "message", sessionID, { ...emptyMessages(), loading: true })
|
||||
messageIndex.set(sessionID, new Map())
|
||||
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)
|
||||
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,
|
||||
})
|
||||
})
|
||||
},
|
||||
},
|
||||
permission: {
|
||||
|
|
@ -1031,8 +1056,6 @@ 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({
|
||||
|
|
@ -1046,15 +1069,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
"session",
|
||||
"info",
|
||||
produce((draft) => {
|
||||
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) draft[session.id] = mutable(session)
|
||||
}),
|
||||
)
|
||||
for (const session of response.data) registerSession(session.id)
|
||||
|
|
@ -1101,23 +1116,16 @@ 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) => {
|
||||
if (generation !== connectionGeneration) return
|
||||
const status: Record<string, DataSessionStatus> = Object.fromEntries(
|
||||
Object.keys(active).map((sessionID) => [sessionID, "running" as const]),
|
||||
setStore(
|
||||
"session",
|
||||
"status",
|
||||
reconcile(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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -114,24 +114,31 @@ test("refreshes resources into reactive getters", async () => {
|
|||
}
|
||||
})
|
||||
|
||||
test("applies absolute usage events without losing full session updates", async () => {
|
||||
test("pages older messages through nested message state", async () => {
|
||||
const events = createEventStream()
|
||||
const sessionID = "ses_usage_refresh"
|
||||
let resolveSessions!: (response: Response) => void
|
||||
const resolveSession: Array<(response: Response) => void> = []
|
||||
let sessionsRequested = false
|
||||
const sessionID = "ses_message_page"
|
||||
const pages: Array<{ limit?: string | null; order?: string | null; cursor?: string | null }> = []
|
||||
// Full first page (desc) so complete stays false until a short older page arrives.
|
||||
const first = Array.from({ length: 50 }, (_, index) => {
|
||||
const n = 51 - index
|
||||
return { id: `msg_${n}`, type: "user" as const, text: String(n), time: { created: n } }
|
||||
})
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === "/api/session") {
|
||||
sessionsRequested = true
|
||||
return new Promise<Response>((resolve) => {
|
||||
resolveSessions = resolve
|
||||
if (url.pathname !== `/api/session/${sessionID}/message`) return
|
||||
pages.push({
|
||||
limit: url.searchParams.get("limit"),
|
||||
order: url.searchParams.get("order"),
|
||||
cursor: url.searchParams.get("cursor"),
|
||||
})
|
||||
if (!url.searchParams.get("cursor"))
|
||||
return json({
|
||||
data: first,
|
||||
cursor: { next: "cursor-older" },
|
||||
})
|
||||
}
|
||||
if (url.pathname === `/api/session/${sessionID}`) {
|
||||
return new Promise<Response>((resolve) => {
|
||||
resolveSession.push(resolve)
|
||||
})
|
||||
}
|
||||
return json({
|
||||
data: [{ id: "msg_1", type: "user", text: "one", time: { created: 1 } }],
|
||||
cursor: {},
|
||||
})
|
||||
}, events)
|
||||
let data!: ReturnType<typeof useData>
|
||||
|
||||
|
|
@ -153,7 +160,68 @@ test("applies absolute usage events without losing full session updates", async
|
|||
))
|
||||
|
||||
try {
|
||||
await wait(() => sessionsRequested)
|
||||
await data.session.message.refresh(sessionID)
|
||||
expect(pages).toEqual([{ limit: "50", order: "desc", cursor: null }])
|
||||
expect(data.session.message.ids(sessionID)).toEqual(first.toReversed().map((message) => message.id))
|
||||
expect(data.session.message.cursor(sessionID)).toBe("cursor-older")
|
||||
expect(data.session.message.complete(sessionID)).toBe(false)
|
||||
expect(data.session.message.loading(sessionID)).toBe(false)
|
||||
|
||||
await data.session.message.more(sessionID)
|
||||
expect(pages).toEqual([
|
||||
{ limit: "50", order: "desc", cursor: null },
|
||||
{ limit: "50", order: null, cursor: "cursor-older" },
|
||||
])
|
||||
expect(data.session.message.ids(sessionID)[0]).toBe("msg_1")
|
||||
expect(data.session.message.ids(sessionID)).toHaveLength(51)
|
||||
expect(data.session.message.cursor(sessionID)).toBeUndefined()
|
||||
expect(data.session.message.complete(sessionID)).toBe(true)
|
||||
|
||||
await data.session.message.more(sessionID)
|
||||
expect(pages).toHaveLength(2)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("applies absolute usage events to session info", async () => {
|
||||
const events = createEventStream()
|
||||
const sessionID = "ses_usage_refresh"
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === `/api/session/${sessionID}`)
|
||||
return json({
|
||||
data: {
|
||||
id: sessionID,
|
||||
projectID: "proj_test",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 0, updated: 0 },
|
||||
title: "Usage",
|
||||
location: { directory },
|
||||
},
|
||||
})
|
||||
}, events)
|
||||
let data!: ReturnType<typeof useData>
|
||||
|
||||
function Probe() {
|
||||
data = useData()
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<TestTuiContexts>
|
||||
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
|
||||
<ProjectProvider>
|
||||
<DataProvider>
|
||||
<Probe />
|
||||
</DataProvider>
|
||||
</ProjectProvider>
|
||||
</SDKProvider>
|
||||
</TestTuiContexts>
|
||||
))
|
||||
|
||||
try {
|
||||
await data.session.refresh(sessionID)
|
||||
emitEvent(events, {
|
||||
id: "evt_usage_2",
|
||||
created: 2,
|
||||
|
|
@ -164,38 +232,6 @@ test("applies absolute usage events without losing full session updates", async
|
|||
tokens: { input: 5, output: 2, reasoning: 1, cache: { read: 1, write: 1 } },
|
||||
},
|
||||
})
|
||||
const initialRefresh = data.session.refresh(sessionID)
|
||||
await wait(() => resolveSession.length === 1)
|
||||
resolveSessions(
|
||||
json({
|
||||
data: [
|
||||
{
|
||||
id: sessionID,
|
||||
projectID: "proj_test",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 0, updated: 0 },
|
||||
title: "Stale usage",
|
||||
location: { directory },
|
||||
},
|
||||
],
|
||||
cursor: {},
|
||||
}),
|
||||
)
|
||||
resolveSession[0](
|
||||
json({
|
||||
data: {
|
||||
id: sessionID,
|
||||
projectID: "proj_test",
|
||||
cost: 0.5,
|
||||
tokens: { input: 5, output: 2, reasoning: 1, cache: { read: 1, write: 1 } },
|
||||
time: { created: 0, updated: 0 },
|
||||
title: "Current usage",
|
||||
location: { directory },
|
||||
},
|
||||
}),
|
||||
)
|
||||
await initialRefresh
|
||||
await wait(() => data.session.get(sessionID)?.cost === 0.5)
|
||||
expect(data.session.get(sessionID)?.tokens).toEqual({
|
||||
input: 5,
|
||||
|
|
@ -204,7 +240,6 @@ test("applies absolute usage events without losing full session updates", async
|
|||
cache: { read: 1, write: 1 },
|
||||
})
|
||||
|
||||
const fullRefresh = data.session.refresh(sessionID)
|
||||
emitEvent(events, {
|
||||
id: "evt_usage_3",
|
||||
created: 3,
|
||||
|
|
@ -216,57 +251,8 @@ test("applies absolute usage events without losing full session updates", async
|
|||
},
|
||||
})
|
||||
await wait(() => data.session.get(sessionID)?.cost === 1)
|
||||
resolveSession[1](
|
||||
json({
|
||||
data: {
|
||||
id: sessionID,
|
||||
projectID: "proj_test",
|
||||
cost: 0.75,
|
||||
tokens: { input: 8, output: 3, reasoning: 1, cache: { read: 1, write: 1 } },
|
||||
time: { created: 0, updated: 0 },
|
||||
title: "Older usage",
|
||||
location: { directory },
|
||||
},
|
||||
}),
|
||||
)
|
||||
await fullRefresh
|
||||
await Bun.sleep(20)
|
||||
expect(data.session.get(sessionID)?.cost).toBe(1)
|
||||
expect(data.session.get(sessionID)?.title).toBe("Older usage")
|
||||
expect(data.session.get(sessionID)?.title).toBe("Usage")
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_usage_6",
|
||||
created: 6,
|
||||
type: "session.usage.updated",
|
||||
data: {
|
||||
sessionID,
|
||||
cost: 1.25,
|
||||
tokens: { input: 12, output: 5, reasoning: 1, cache: { read: 1, write: 1 } },
|
||||
},
|
||||
})
|
||||
emitEvent(events, {
|
||||
id: "evt_usage_7",
|
||||
created: 7,
|
||||
type: "session.usage.updated",
|
||||
data: {
|
||||
sessionID,
|
||||
cost: 1.25,
|
||||
tokens: { input: 12, output: 5, reasoning: 1, cache: { read: 1, write: 1 } },
|
||||
},
|
||||
})
|
||||
await wait(() => data.session.get(sessionID)?.cost === 1.25)
|
||||
expect(data.session.get(sessionID)?.title).toBe("Older usage")
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_usage_8",
|
||||
created: 8,
|
||||
type: "session.usage.updated",
|
||||
data: {
|
||||
sessionID,
|
||||
cost: 1.5,
|
||||
tokens: { input: 14, output: 6, reasoning: 1, cache: { read: 1, write: 1 } },
|
||||
},
|
||||
})
|
||||
emitEvent(events, {
|
||||
id: "evt_usage_deleted",
|
||||
created: 9,
|
||||
|
|
@ -274,8 +260,7 @@ test("applies absolute usage events without losing full session updates", async
|
|||
durable: durable(sessionID, 9, 2),
|
||||
data: { sessionID },
|
||||
})
|
||||
await Bun.sleep(20)
|
||||
expect(data.session.get(sessionID)).toBeUndefined()
|
||||
await wait(() => data.session.get(sessionID) === undefined)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
|
|
@ -609,15 +594,7 @@ test("reconnects the event stream and bootstraps fresh data", async () => {
|
|||
expect(data.connection.error()).toBe("Event stream disconnected")
|
||||
|
||||
await wait(() => requests.active === 2 && data.connection.status() === "connected", 4000)
|
||||
emitEvent(events, {
|
||||
id: "evt_execution_started_after_reconnect",
|
||||
created: 1,
|
||||
type: "session.execution.started",
|
||||
durable: durable("session-new"),
|
||||
data: { sessionID: "session-new" },
|
||||
})
|
||||
await wait(() => data.session.status("session-new") === "running")
|
||||
resolveActive(json({ data: {} }))
|
||||
resolveActive(json({ data: { "session-new": { type: "running" } } }))
|
||||
|
||||
await wait(() => data.location.model.list()?.[0]?.id === "model-2", 4000)
|
||||
await wait(() => data.session.status("session-stale") === "idle")
|
||||
|
|
@ -631,16 +608,18 @@ test("reconnects the event stream and bootstraps fresh data", async () => {
|
|||
}
|
||||
})
|
||||
|
||||
test("completes exploration when a queued prompt is promoted", async () => {
|
||||
test("keeps pending prompts out of history rows until promoted", async () => {
|
||||
const events = createEventStream()
|
||||
const sessionID = "session-promotion"
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} })
|
||||
}, events)
|
||||
let rows!: ReturnType<typeof createSessionRows>
|
||||
let data!: ReturnType<typeof useData>
|
||||
|
||||
function Probe() {
|
||||
rows = createSessionRows(() => sessionID)
|
||||
data = useData()
|
||||
return <box />
|
||||
}
|
||||
|
||||
|
|
@ -695,7 +674,8 @@ test("completes exploration when a queued prompt is promoted", async () => {
|
|||
delivery: "steer",
|
||||
},
|
||||
})
|
||||
await wait(() => rows.at(-1)?.type === "message")
|
||||
await wait(() => data.session.input.has(sessionID, "message-user"))
|
||||
expect(rows.some((row) => row.type === "message" && row.messageID === "message-user")).toBe(false)
|
||||
expect(rows.find((row) => row.type === "group")?.completed).toBe(false)
|
||||
|
||||
emitEvent(events, {
|
||||
|
|
@ -705,7 +685,8 @@ test("completes exploration when a queued prompt is promoted", async () => {
|
|||
durable: durable(sessionID, 3),
|
||||
data: { sessionID, inputID: "message-user" },
|
||||
})
|
||||
await wait(() => rows.find((row) => row.type === "group")?.completed === true)
|
||||
await wait(() => rows.some((row) => row.type === "message" && row.messageID === "message-user"))
|
||||
expect(data.session.input.has(sessionID, "message-user")).toBe(false)
|
||||
expect(rows.at(-1)).toEqual({ type: "message", messageID: "message-user" })
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
|
|
|
|||
|
|
@ -207,31 +207,25 @@ test("renders a footer for a pre-output retry assistant after replay", () => {
|
|||
expect(reduceSessionRows([message])).toEqual([{ type: "assistant-footer", messageID: "assistant-retry" }])
|
||||
})
|
||||
|
||||
test("places a running compaction barrier before every queued user message", () => {
|
||||
const queued = (id: string, text: string, created: number): SessionMessageInfo => ({
|
||||
type: "user",
|
||||
id,
|
||||
text,
|
||||
time: { created },
|
||||
})
|
||||
test("history reduce keeps chronological order without pending reordering", () => {
|
||||
const messages: SessionMessageInfo[] = [
|
||||
queued("user-before", "Before", 1),
|
||||
{ type: "user", id: "user-1", text: "Before", time: { created: 1 } },
|
||||
{
|
||||
type: "compaction",
|
||||
id: "compaction",
|
||||
status: "running",
|
||||
status: "completed",
|
||||
reason: "manual",
|
||||
summary: "",
|
||||
summary: "done",
|
||||
recent: "",
|
||||
time: { created: 2 },
|
||||
},
|
||||
queued("user-after", "After", 3),
|
||||
{ type: "user", id: "user-2", text: "After", time: { created: 3 } },
|
||||
]
|
||||
|
||||
expect(reduceSessionRows(messages, new Set(["user-before", "user-after"]))).toEqual([
|
||||
expect(reduceSessionRows(messages)).toEqual([
|
||||
{ type: "message", messageID: "user-1" },
|
||||
{ type: "message", messageID: "compaction" },
|
||||
{ type: "message", messageID: "user-before" },
|
||||
{ type: "message", messageID: "user-after" },
|
||||
{ type: "message", messageID: "user-2" },
|
||||
])
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue