Compare commits

...

1 Commits

Author SHA1 Message Date
Dax Raad e689829315 feat(tui): hydrate pending session work 2026-07-09 22:43:01 +00:00
3 changed files with 175 additions and 4 deletions
+70 -4
View File
@@ -22,6 +22,7 @@ import type {
SessionMessageAssistantText,
SessionMessageAssistantTool,
SessionInfo,
SessionPendingInfo,
Shell,
SkillInfo,
} from "@opencode-ai/sdk/v2"
@@ -145,6 +146,31 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
const item = messages.findLast((item) => item.type === "compaction" && item.status === "running")
return item?.type === "compaction" ? item : undefined
},
fromPending(item: SessionPendingInfo): SessionMessageInfo {
if (item.type === "user")
return {
id: item.id,
type: "user",
...item.data,
time: { created: item.timeCreated },
}
if (item.type === "synthetic")
return {
id: item.id,
type: "synthetic",
...item.data,
time: { created: item.timeCreated },
}
return {
id: item.id,
type: "compaction",
status: "running",
reason: "manual",
summary: "",
recent: "",
time: { created: item.timeCreated },
}
},
latestTool(assistant: SessionMessageAssistant | undefined, callID?: string) {
return assistant?.content.findLast(
(item): item is SessionMessageAssistantTool =>
@@ -612,11 +638,38 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
setSessionStatus(event.data.sessionID, "running")
break
case "session.compaction.admitted":
message.update(event.data.sessionID, (draft, index) => {
message.append(draft, index, {
id: event.data.inputID,
type: "compaction",
status: "running",
reason: "manual",
summary: "",
recent: "",
time: { created: event.created },
})
})
break
case "session.compaction.started":
message.update(event.data.sessionID, (draft, index) => {
const id = event.data.inputID ?? messageIDFromEvent(event.id)
const position = index.get(id)
const existing = position === undefined ? undefined : draft[position]
if (position !== undefined && existing?.type === "compaction") {
draft[position] = {
id,
type: "compaction",
status: "running",
reason: event.data.reason,
summary: existing.status === "running" ? existing.summary : "",
recent: event.data.recent,
metadata: existing.metadata,
time: existing.time,
}
return
}
message.append(draft, index, {
id: event.data.inputID ?? messageIDFromEvent(event.id),
id,
type: "compaction",
status: "running",
reason: event.data.reason,
@@ -836,9 +889,22 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
return position === undefined ? undefined : messages?.[position]
},
async refresh(sessionID: string) {
const messages = (await sdk.api.message.list({ sessionID, limit: 200, order: "desc" })).data.toReversed()
messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index])))
setStore("session", "message", sessionID, reconcile(messages))
const [projected, pending] = await Promise.all([
sdk.api.message.list({ sessionID, limit: 200, order: "desc" }),
sdk.api.session.pending.list({ sessionID }),
])
const messages = projected.data.toReversed()
const projectedIDs = new Set(messages.map((message) => message.id))
const pendingMessages = pending.filter((item) => !projectedIDs.has(item.id)).map(message.fromPending)
const next = [...messages, ...pendingMessages]
messageIndex.set(sessionID, new Map(next.map((message, index) => [message.id, index])))
setStore(
"session",
"input",
sessionID,
pendingMessages.flatMap((item) => (item.type === "user" || item.type === "synthetic" ? [item.id] : [])),
)
setStore("session", "message", sessionID, reconcile(next))
},
},
permission: {
+104
View File
@@ -2091,6 +2091,110 @@ test("renders admitted prompts immediately and tracks them until promoted", asyn
}
})
test("refreshes pending session work into message state", async () => {
const events = createEventStream()
const sessionID = "session-pending"
const calls = createFetch((url) => {
if (url.pathname === `/api/session/${sessionID}/message`)
return json({
data: [{ id: "msg_projected", type: "user", text: "already visible", time: { created: 1 } }],
cursor: {},
})
if (url.pathname === `/api/session/${sessionID}/pending`)
return json({
data: [
{
admittedSeq: 1,
id: "msg_projected",
sessionID,
timeCreated: 1,
type: "user",
data: { text: "already visible" },
delivery: "steer",
},
{
admittedSeq: 2,
id: "msg_pending_user",
sessionID,
timeCreated: 2,
type: "user",
data: { text: "queued user", metadata: { source: "test" } },
delivery: "queue",
},
{
admittedSeq: 3,
id: "msg_pending_synthetic",
sessionID,
timeCreated: 3,
type: "synthetic",
data: { text: "internal", description: "Queued synthetic" },
delivery: "steer",
},
{
admittedSeq: 4,
id: "msg_pending_compaction",
sessionID,
timeCreated: 4,
type: "compaction",
},
],
})
}, 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.message.refresh(sessionID)
expect(data.session.message.ids(sessionID)).toEqual([
"msg_projected",
"msg_pending_user",
"msg_pending_synthetic",
"msg_pending_compaction",
])
expect(data.session.input.list(sessionID)).toEqual(["msg_pending_user", "msg_pending_synthetic"])
expect(data.session.message.get(sessionID, "msg_pending_user")).toMatchObject({
id: "msg_pending_user",
type: "user",
text: "queued user",
metadata: { source: "test" },
time: { created: 2 },
})
expect(data.session.message.get(sessionID, "msg_pending_synthetic")).toMatchObject({
id: "msg_pending_synthetic",
type: "synthetic",
text: "internal",
description: "Queued synthetic",
time: { created: 3 },
})
expect(data.session.message.get(sessionID, "msg_pending_compaction")).toMatchObject({
id: "msg_pending_compaction",
type: "compaction",
status: "running",
summary: "",
recent: "",
time: { created: 4 },
})
} finally {
app.renderer.destroy()
}
})
test("projects live instruction updates with their message ID", async () => {
const events = createEventStream()
const calls = createFetch(undefined, events)
+1
View File
@@ -102,6 +102,7 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
return json({ location: { directory, project: { id: "proj_test", directory: worktree } }, data: [] })
if (url.pathname === "/api/session") return json({ data: [], cursor: {} })
if (url.pathname === "/api/session/active") return json({ data: {} })
if (/^\/api\/session\/[^/]+\/pending$/.test(url.pathname)) return json({ data: [] })
if (url.pathname === "/api/permission/request")
return json({ location: { directory, project: { id: "proj_test", directory: worktree } }, data: [] })
if (url.pathname === "/api/form/request")