Compare commits

...

2 Commits

Author SHA1 Message Date
Kit Langton f543a66be7 feat(tui): optimistic session creation
Seed new sessions locally with a client-generated session ID and create
them in the background, so submitting from the home screen navigates
into the session and renders the prompt echo immediately. The seed
marks initial reads complete until the session.created echo loads
server truth. Creation failure closes the optimistic tab, returns to
the home screen with the prompt restored, and drops the seed. Worktree-
backed creation and command, skill, and shell submissions keep awaiting
creation.
2026-08-15 14:38:28 -04:00
Kit Langton d4bd41c4ba feat(tui): optimistic prompt submission
Echo user prompts into the local data store with a client-generated
message ID before server admission. The editor clears and the message
renders immediately; admission runs in a per-session background queue
that preserves steer order. The session.inbox.enqueued echo replaces
the local copy with server truth, wholesale sync replaces re-append
unconfirmed echoes, and failures roll back the echo, restore the
captured prompt, and toast.
2026-08-15 14:32:38 -04:00
3 changed files with 748 additions and 110 deletions
+194 -89
View File
@@ -57,7 +57,12 @@ import {
type LocalAttachment, type LocalAttachment,
} from "./local-attachment" } from "./local-attachment"
import { useData } from "../../context/data" import { useData } from "../../context/data"
import { usePromptRef } from "../../context/prompt"
import { useLocation } from "../../context/location" import { useLocation } from "../../context/location"
import type { LocationRef, PromptFileAttachment, PromptSkillAttachment, SkillInfo } from "@opencode-ai/client"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { SessionID } from "@opencode-ai/schema/session-id"
import { useSessionTabs } from "../../context/session-tabs"
import { Keymap, type KeymapCommand } from "../../context/keymap" import { Keymap, type KeymapCommand } from "../../context/keymap"
import { abbreviateHome } from "../../runtime" import { abbreviateHome } from "../../runtime"
import { Slot } from "../../plugin/render" import { Slot } from "../../plugin/render"
@@ -100,6 +105,59 @@ export type PromptRef = {
const DRAFT_RETENTION_MIN_CHARS = 20 const DRAFT_RETENTION_MIN_CHARS = 20
// Serialize background prompt submissions per session so admission order matches
// the on-screen optimistic order, even across Prompt remounts and route changes.
const submitTails = new Map<string, Promise<void>>()
function enqueueSubmit(sessionID: string, task: () => Promise<void>) {
const tail = (submitTails.get(sessionID) ?? Promise.resolve()).then(task, task)
submitTails.set(sessionID, tail)
void tail.finally(() => {
if (submitTails.get(sessionID) === tail) submitTails.delete(sessionID)
})
return tail
}
// Approximate the server's materialized attachment shape for the local echo. Pasted
// data: URIs carry real content so images preview immediately; file references render
// as labels until the admission echo replaces them with server truth.
function optimisticFiles(files: PromptInfo["files"]): PromptFileAttachment[] | undefined {
if (!files?.length) return undefined
return files.map((file) => {
const match = /^data:([^;,]*);base64,(.*)$/.exec(file.uri)
if (match)
return {
data: match[2] ?? "",
mime: match[1] || "application/octet-stream",
source: { type: "inline" as const },
name: file.name,
description: file.description,
mention: file.mention,
}
return {
data: "",
mime: file.uri.endsWith("/") ? "application/x-directory" : "text/plain",
source: { type: "uri" as const, uri: file.uri },
name: file.name,
description: file.description,
mention: file.mention,
}
})
}
function optimisticSkills(
skills: PromptInfo["skills"],
available: SkillInfo[],
): PromptSkillAttachment[] | undefined {
if (!skills?.length) return undefined
return skills.map((attachment) => ({
id: attachment.id,
name: available.find((skill) => skill.id === attachment.id)?.name ?? attachment.id,
text: "",
mention: attachment.mention,
}))
}
function randomIndex(count: number) { function randomIndex(count: number) {
if (count <= 0) return 0 if (count <= 0) return 0
return Math.floor(Math.random() * count) return Math.floor(Math.random() * count)
@@ -201,6 +259,8 @@ export function Prompt(props: PromptProps) {
const editor = useEditorContext() const editor = useEditorContext()
const route = useRoute() const route = useRoute()
const data = useData() const data = useData()
const activePrompt = usePromptRef()
const sessionTabs = useSessionTabs()
const directoryRecents = useDirectoryRecents() const directoryRecents = useDirectoryRecents()
const keymapCommands = Keymap.useCommands() const keymapCommands = Keymap.useCommands()
const currentLocation = useLocation() const currentLocation = useLocation()
@@ -1070,6 +1130,20 @@ export function Prompt(props: PromptProps) {
} }
}) })
// Return a failed background submission to its author. When the target tab's
// editor is live and empty, restore directly; otherwise stash a draft for the
// next mount. A non-empty editor is never clobbered—prompt history retains the
// failed prompt either way.
function restorePrompt(sessionID: string, snapshot: PromptInfo) {
const active = route.data
if (active.type === "session" && active.sessionID === sessionID) {
const live = activePrompt.current
if (live && !live.current.text.trim()) live.set(snapshot)
return
}
saveDraft(sessionID, { prompt: snapshot, cursor: snapshot.text.length })
}
let submitting = false let submitting = false
async function submit(delivery: SessionInbox.Delivery = "steer") { async function submit(delivery: SessionInbox.Delivery = "steer") {
// Prevent overlapping invocations (e.g. a double-pressed Enter, or the // Prevent overlapping invocations (e.g. a double-pressed Enter, or the
@@ -1170,8 +1244,9 @@ export function Prompt(props: PromptProps) {
const variant = selection.variant const variant = selection.variant
let sessionID = props.sessionID let sessionID = props.sessionID
let session = sessionID ? data.session.get(sessionID) : undefined
let finishMoveProgress = false let finishMoveProgress = false
let createSession: (() => Promise<unknown>) | undefined
let createLocation: LocationRef | undefined
if (sessionID == null) { if (sessionID == null) {
const directory = await move.getDirectory() const directory = await move.getDirectory()
if (move.pending() && !directory) return false if (move.pending() && !directory) return false
@@ -1179,32 +1254,46 @@ export function Prompt(props: PromptProps) {
// The location context is where the next session is created: seeded by the home // The location context is where the next session is created: seeded by the home
// route (launch cwd, inherited session location, or picked project) and updated // route (launch cwd, inherited session location, or picked project) and updated
// by /cd before a session exists. // by /cd before a session exists.
const location = currentLocation.ref ?? data.location.default() const location = directory ? { directory } : (currentLocation.ref ?? data.location.default())
const model = { providerID: selection.providerID, id: selection.modelID, variant }
const created = await client.api.session // Seed the session locally with a client-generated ID and create it in the
.create({ // background so navigation and the prompt echo render immediately. Optimistic
location: directory ? { directory } : location, // creation covers the plain prompt path when the location's project is known.
// Worktree-backed creation keeps its progress flow, and command, skill, and
// shell submissions target the session right away, so those await creation.
const plainPrompt = store.mode !== "shell" && !(slashHead && isCommand) && !isSkill
const projectID = directory ? undefined : data.location.info(location)?.project.id
if (plainPrompt && projectID) {
const created = SessionID.create()
data.session.optimistic.create({
sessionID: created,
projectID,
location,
agent: agent.id, agent: agent.id,
model: { model,
providerID: selection.providerID,
id: selection.modelID,
variant,
},
}) })
.catch(() => undefined) sessionID = created
createLocation = location
if (!created) { createSession = () => client.api.session.create({ id: created, location, agent: agent.id, model })
if (finishMoveProgress) move.finishSubmit()
toast.show({
message: "Creating a session failed. Open console for more details.",
variant: "error",
})
return true
} }
sessionID = created.id if (sessionID == null) {
session = created const created = await client.api.session
.create({ location, agent: agent.id, model })
.catch(() => undefined)
if (!created) {
if (finishMoveProgress) move.finishSubmit()
toast.show({
message: "Creating a session failed. Open console for more details.",
variant: "error",
})
return true
}
sessionID = created.id
}
} }
// Capture mode before it gets reset // Capture mode before it gets reset
@@ -1245,70 +1334,87 @@ export function Prompt(props: PromptProps) {
}) })
} else { } else {
move.startSubmit() move.startSubmit()
if (!session) { // Echo the prompt locally and admit it in the background: the editor clears and
await data.session.sync(sessionID) // the message renders immediately, while a per-session queue preserves admission
session = data.session.get(sessionID) // order. Failure rolls the echo back and restores the captured prompt.
} const submitSessionID = sessionID
if (session?.agent !== agent.id) { const messageID = SessionMessage.ID.create()
await client.api.session.switchAgent({ sessionID, agent: agent.id }) const snapshot = structuredClone(unwrap(store.prompt))
} const editorContextText = pendingEditorSelection ? formatEditorContext(pendingEditorSelection) : undefined
if ( const targetAgent = agent.id
session?.model?.providerID !== selection.providerID || const model = { providerID: selection.providerID, id: selection.modelID, variant }
session.model.id !== selection.modelID || data.session.optimistic.prompt({
(session.model.variant ?? "default") !== (variant ?? "default") sessionID: submitSessionID,
) { messageID,
const model = { providerID: selection.providerID, id: selection.modelID, variant } delivery,
const cancelCommit = local.model.trackSessionCommit(sessionID, model) text: inputText,
await client.api.session.switchModel({ sessionID, model }).catch((error) => { files: optimisticFiles(snapshot.files),
cancelCommit() agents: snapshot.agents?.length ? snapshot.agents : undefined,
throw error skills: optimisticSkills(snapshot.skills, data.location.skill.list(currentLocation.ref) ?? []),
}) })
} // Mark the editor context sent with the echo so a rapid follow-up submit
if (session?.revert) { // does not re-attach the same selection while admission is in flight.
const error = await client.api.session.revert.commit({ sessionID }).then( if (editorContextText) editor.markSelectionSent()
() => undefined, let createFailed = false
(error) => error, void enqueueSubmit(submitSessionID, async () => {
) const error = await (async () => {
if (error) { if (createSession)
toast.show({ title: "Failed to commit revert", message: errorMessage(error), variant: "error" }) await createSession().catch((error) => {
return false createFailed = true
} throw error
} })
if (pendingEditorSelection) { let session = data.session.get(submitSessionID)
// Keep editor context hidden while admitting it before the corresponding user prompt. if (!session) {
const error = await client.api.session await data.session.sync(submitSessionID)
.synthetic({ session = data.session.get(submitSessionID)
sessionID, }
text: formatEditorContext(pendingEditorSelection), if (session?.agent !== targetAgent) {
resume: false, await client.api.session.switchAgent({ sessionID: submitSessionID, agent: targetAgent })
}
if (
session?.model?.providerID !== model.providerID ||
session.model.id !== model.id ||
(session.model.variant ?? "default") !== (model.variant ?? "default")
) {
const cancelCommit = local.model.trackSessionCommit(submitSessionID, model)
await client.api.session.switchModel({ sessionID: submitSessionID, model }).catch((error) => {
cancelCommit()
throw error
})
}
if (session?.revert) await client.api.session.revert.commit({ sessionID: submitSessionID })
// Keep editor context hidden while admitting it before the corresponding user prompt.
if (editorContextText)
await client.api.session.synthetic({ sessionID: submitSessionID, text: editorContextText, resume: false })
await client.api.session.prompt({
sessionID: submitSessionID,
id: messageID,
text: inputText,
files: snapshot.files,
agents: snapshot.agents,
skills: snapshot.skills?.length ? snapshot.skills : undefined,
delivery,
}) })
.then( })().then(
() => undefined,
(error) => error,
)
if (error) {
toast.show({ title: "Failed to send editor context", message: errorMessage(error), variant: "error" })
return false
}
}
const error = await client.api.session
.prompt({
sessionID,
text: inputText,
files: store.prompt.files,
agents: store.prompt.agents,
skills: store.prompt.skills?.length ? store.prompt.skills : undefined,
delivery,
})
.then(
() => undefined, () => undefined,
(error) => error, (error) => error,
) )
if (error) { if (error === undefined) return
data.session.optimistic.rollback(submitSessionID, messageID)
if (createFailed) {
// The session never existed server-side: leave the optimistic tab, return
// to the home screen with the prompt restored, and drop the seed.
if (route.data.type === "session" && route.data.sessionID === submitSessionID)
route.navigate({ type: "home", prompt: snapshot, location: createLocation })
else saveDraft(undefined, { prompt: snapshot, cursor: snapshot.text.length })
sessionTabs.close(submitSessionID)
data.session.optimistic.rollbackCreate(submitSessionID)
toast.show({ title: "Failed to create session", message: errorMessage(error), variant: "error" })
return
}
restorePrompt(submitSessionID, snapshot)
toast.show({ title: "Failed to send prompt", message: errorMessage(error), variant: "error" }) toast.show({ title: "Failed to send prompt", message: errorMessage(error), variant: "error" })
return false })
}
if (pendingEditorSelection) editor.markSelectionSent()
} }
history.append({ history.append({
...store.prompt, ...store.prompt,
@@ -1319,15 +1425,14 @@ export function Prompt(props: PromptProps) {
setStore("extmarkToPart", new Map()) setStore("extmarkToPart", new Map())
props.onSubmit?.() props.onSubmit?.()
// temporary hack to make sure the message is sent
if (!props.sessionID) { if (!props.sessionID) {
if (pendingEditorSelection) editor.preserveSelectionFromNewSession() if (pendingEditorSelection) editor.preserveSelectionFromNewSession()
setTimeout(() => { // The optimistic echo is already in the data store, so the session route
route.navigate({ // renders the prompt immediately; admission continues in the background.
type: "session", route.navigate({
sessionID, type: "session",
}) sessionID,
}, 50) })
} }
input.clear() input.clear()
if (finishMoveProgress) move.finishSubmit() if (finishMoveProgress) move.finishSubmit()
+176 -21
View File
@@ -20,6 +20,7 @@ import type {
ProviderInfo, ProviderInfo,
ReferenceInfo, ReferenceInfo,
SessionMessageInfo, SessionMessageInfo,
SessionMessageUser,
SessionMessageAssistant, SessionMessageAssistant,
SessionMessageAssistantReasoning, SessionMessageAssistantReasoning,
SessionMessageAssistantText, SessionMessageAssistantText,
@@ -40,7 +41,7 @@ import { useClient } from "./client"
import { nonEmptyToolContent } from "../util/tool-display" import { nonEmptyToolContent } from "../util/tool-display"
import type { SessionInbox } from "@opencode-ai/schema/session-inbox" import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
import { Worktree } from "@opencode-ai/schema/worktree" import { Worktree } from "@opencode-ai/schema/worktree"
import { createEffect, createSignal, onCleanup } from "solid-js" import { batch, createEffect, createSignal, onCleanup } from "solid-js"
export type DataSessionStatus = "idle" | "running" export type DataSessionStatus = "idle" | "running"
@@ -161,6 +162,25 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
const messageIndex = new Map<string, Map<string, number>>() const messageIndex = new Map<string, Map<string, number>>()
const sync = createSync() const sync = createSync()
// Optimistic prompt echoes: user prompts applied locally before server admission,
// keyed sessionID -> messageID. The session.inbox.enqueued echo carrying the same
// ID replaces the local copy with server truth (admission materializes file
// attachments and expands skills). Entries survive wholesale sync replaces until
// the server confirms them or the submitter rolls back.
type OptimisticPrompt = { message: SessionMessageInfo; pending: SessionInboxInfo }
const optimisticPrompts = new Map<string, Map<string, OptimisticPrompt>>()
function confirmOptimistic(sessionID: string, messageID: string) {
const entries = optimisticPrompts.get(sessionID)
if (!entries?.delete(messageID)) return false
if (entries.size === 0) optimisticPrompts.delete(sessionID)
return true
}
function optimisticMessages(sessionID: string) {
return [...(optimisticPrompts.get(sessionID)?.values() ?? [])]
}
function setSessionActive(sessionID: string, status: DataSessionStatus) { function setSessionActive(sessionID: string, status: DataSessionStatus) {
setStore("session", "active", sessionID, status) setStore("session", "active", sessionID, status)
} }
@@ -188,6 +208,17 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
) )
} }
function removeMessage(sessionID: string, messageID: string) {
if (!messageIndex.get(sessionID)?.has(messageID)) return
message.update(sessionID, (draft, index) => {
const position = index.get(messageID)
if (position === undefined) return
draft.splice(position, 1)
index.delete(messageID)
message.reindex(draft, index, position)
})
}
function removePermission(sessionID: string, requestID: string) { function removePermission(sessionID: string, requestID: string) {
const requests = store.session.permission[sessionID] const requests = store.session.permission[sessionID]
if (!requests?.some((request) => request.id === requestID)) return if (!requests?.some((request) => request.id === requestID)) return
@@ -313,7 +344,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
function removeSession(sessionID: string) { function removeSession(sessionID: string) {
messageIndex.delete(sessionID) messageIndex.delete(sessionID)
optimisticPrompts.delete(sessionID)
sync.invalidate(`session:${sessionID}`) sync.invalidate(`session:${sessionID}`)
sync.invalidate(`session.family:${sessionID}`)
sync.invalidate(`session.pending:${sessionID}`) sync.invalidate(`session.pending:${sessionID}`)
sync.invalidate(`session.message:${sessionID}`) sync.invalidate(`session.message:${sessionID}`)
sync.invalidate(`session.permission:${sessionID}`) sync.invalidate(`session.permission:${sessionID}`)
@@ -471,6 +504,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
break break
} }
case "session.inbox.delivered": { case "session.inbox.delivered": {
// Delivery implies the message is projected server-side, so future message
// fetches include it and the optimistic entry no longer needs re-appending.
confirmOptimistic(event.data.sessionID, event.data.inboxID)
const admitted = store.session.input[event.data.sessionID]?.includes(event.data.inboxID) ?? false const admitted = store.session.input[event.data.sessionID]?.includes(event.data.inboxID) ?? false
removePending(event.data.sessionID, event.data.inboxID) removePending(event.data.sessionID, event.data.inboxID)
message.update(event.data.sessionID, (draft, index) => { message.update(event.data.sessionID, (draft, index) => {
@@ -489,25 +525,31 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
updatePending(event.data.sessionID, event.data.inboxID, event.data.delivery) updatePending(event.data.sessionID, event.data.inboxID, event.data.delivery)
break break
case "session.inbox.cancelled": { case "session.inbox.cancelled": {
confirmOptimistic(event.data.sessionID, event.data.inboxID)
removePending(event.data.sessionID, event.data.inboxID) removePending(event.data.sessionID, event.data.inboxID)
if (messageIndex.get(event.data.sessionID)?.has(event.data.inboxID)) removeMessage(event.data.sessionID, event.data.inboxID)
message.update(event.data.sessionID, (draft, index) => {
const position = index.get(event.data.inboxID)
if (position === undefined) return
draft.splice(position, 1)
index.delete(event.data.inboxID)
message.reindex(draft, index, position)
})
break break
} }
case "session.inbox.enqueued": { case "session.inbox.enqueued": {
const item = event.data.item const item = event.data.item
addPending({ // The admission echo is authoritative for an optimistic local copy: it
// materializes file attachments and expands skills, so replace in place.
const confirmed = confirmOptimistic(event.data.sessionID, event.data.inboxID)
const pendingItem: SessionInboxInfo = {
id: event.data.inboxID, id: event.data.inboxID,
sessionID: event.data.sessionID, sessionID: event.data.sessionID,
timeCreated: event.created, timeCreated: event.created,
...item, ...item,
}) }
const pendingList = store.session.pending[event.data.sessionID]
if (confirmed && pendingList?.some((pending) => pending.id === event.data.inboxID))
setStore(
"session",
"pending",
event.data.sessionID,
pendingList.map((pending) => (pending.id === event.data.inboxID ? pendingItem : pending)),
)
else addPending(pendingItem)
if (!store.session.input[event.data.sessionID]?.includes(event.data.inboxID)) if (!store.session.input[event.data.sessionID]?.includes(event.data.inboxID))
setStore("session", "input", event.data.sessionID, [ setStore("session", "input", event.data.sessionID, [
...(store.session.input[event.data.sessionID] ?? []), ...(store.session.input[event.data.sessionID] ?? []),
@@ -515,9 +557,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
]) ])
if (item.type !== "user" && item.type !== "synthetic") break if (item.type !== "user" && item.type !== "synthetic") break
message.update(event.data.sessionID, (draft, index) => { message.update(event.data.sessionID, (draft, index) => {
message.append( const next: SessionMessageInfo =
draft,
index,
item.type === "user" item.type === "user"
? { ? {
id: event.data.inboxID, id: event.data.inboxID,
@@ -530,8 +570,13 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
type: "synthetic", type: "synthetic",
...item.payload, ...item.payload,
time: { created: event.created }, time: { created: event.created },
}, }
) const position = index.get(event.data.inboxID)
if (confirmed && position !== undefined) {
draft[position] = next
return
}
message.append(draft, index, next)
}) })
break break
} }
@@ -825,22 +870,30 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
if (store.session.info[event.data.sessionID]) if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "revert", undefined) setStore("session", "info", event.data.sessionID, "revert", undefined)
break break
case "session.revert.committed": case "session.revert.committed": {
if (store.session.info[event.data.sessionID]) { if (store.session.info[event.data.sessionID]) {
setStore("session", "info", event.data.sessionID, "revert", undefined) setStore("session", "info", event.data.sessionID, "revert", undefined)
} }
// Unconfirmed optimistic prompts postdate the revert boundary but were never
// part of the reverted history: the server admits them after the commit.
const local = optimisticPrompts.get(event.data.sessionID)
setStore( setStore(
"session", "session",
"input", "input",
event.data.sessionID, event.data.sessionID,
(store.session.input[event.data.sessionID] ?? []).filter((id) => id < event.data.to), (store.session.input[event.data.sessionID] ?? []).filter(
(id) => id < event.data.to || local?.has(id) === true,
),
) )
message.update(event.data.sessionID, (draft, index) => { message.update(event.data.sessionID, (draft, index) => {
const position = draft.findIndex((item) => item.id >= event.data.to) const position = draft.findIndex((item) => item.id >= event.data.to)
if (position === -1) return if (position === -1) return
for (const item of draft.splice(position)) index.delete(item.id) const dropped = draft.splice(position)
for (const item of dropped) index.delete(item.id)
for (const item of dropped) if (local?.has(item.id)) message.append(draft, index, item)
}) })
break break
}
case "session.compaction.delta": case "session.compaction.delta":
message.update(event.data.sessionID, (draft) => { message.update(event.data.sessionID, (draft) => {
const current = message.compaction(draft) const current = message.compaction(draft)
@@ -1013,13 +1066,109 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
return store.session.input[sessionID]?.includes(inboxID) ?? false return store.session.input[sessionID]?.includes(inboxID) ?? false
}, },
}, },
optimistic: {
// Seed a session locally before server creation so navigation and the first
// prompt echo render immediately. Reads are marked complete so the session
// route does not fetch a session the server does not know yet; the
// session.created echo invalidates the info read and loads server truth.
create(input: {
sessionID: string
projectID: string
location: LocationRef
agent?: string
model?: SessionInfo["model"]
}) {
const now = Date.now()
const info: SessionInfo = {
id: input.sessionID,
projectID: input.projectID,
location: input.location,
agent: input.agent,
model: input.model,
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: now, updated: now },
}
batch(() => {
setStore("session", "info", input.sessionID, info)
registerSession(input.sessionID)
})
sync.complete(`session:${input.sessionID}`)
sync.complete(`session.family:${input.sessionID}`)
sync.complete(`session.pending:${input.sessionID}`)
sync.complete(`session.message:${input.sessionID}`)
},
// Remove a seeded session after creation fails. Callers navigate away first.
rollbackCreate(sessionID: string) {
removeSession(sessionID)
},
// Locally echo a user prompt before server admission. The session.inbox.enqueued
// echo carrying the same message ID replaces the copy with server truth; rollback
// removes the echo when submission fails.
prompt(input: {
sessionID: string
messageID: string
delivery: SessionInbox.Delivery
text: string
files?: SessionMessageUser["files"]
agents?: SessionMessageUser["agents"]
skills?: SessionMessageUser["skills"]
}) {
const created = Date.now()
const pendingItem: SessionInboxInfo = {
id: input.messageID,
sessionID: input.sessionID,
timeCreated: created,
type: "user",
payload: { text: input.text, files: input.files, agents: input.agents, skills: input.skills },
delivery: input.delivery,
}
const messageItem: SessionMessageInfo = {
id: input.messageID,
type: "user",
text: input.text,
files: input.files,
agents: input.agents,
skills: input.skills,
time: { created },
}
const entries = optimisticPrompts.get(input.sessionID) ?? new Map<string, OptimisticPrompt>()
optimisticPrompts.set(input.sessionID, entries)
entries.set(input.messageID, { message: messageItem, pending: pendingItem })
batch(() => {
addPending(pendingItem)
if (!store.session.input[input.sessionID]?.includes(input.messageID))
setStore("session", "input", input.sessionID, [
...(store.session.input[input.sessionID] ?? []),
input.messageID,
])
message.update(input.sessionID, (draft, index) => message.append(draft, index, messageItem))
})
},
rollback(sessionID: string, messageID: string) {
if (!confirmOptimistic(sessionID, messageID)) return
batch(() => {
removePending(sessionID, messageID)
removeMessage(sessionID, messageID)
})
},
},
pending: { pending: {
list(sessionID: string) { list(sessionID: string) {
return store.session.pending[sessionID] ?? [] return store.session.pending[sessionID] ?? []
}, },
sync(sessionID: string) { sync(sessionID: string) {
return sync.run(`session.pending:${sessionID}`, async () => { return sync.run(`session.pending:${sessionID}`, async () => {
const pending = await client.api.session.inbox.list({ sessionID }) const fetched = await client.api.session.inbox.list({ sessionID })
// Keep unconfirmed optimistic prompts pending across the wholesale replace.
// Server presence here is not treated as confirmation: until the enqueued
// echo or a projected message arrives, the ledger still guards message.sync.
const pending = [
...fetched,
...optimisticMessages(sessionID)
.map((entry) => entry.pending)
.filter((entry) => !fetched.some((item) => item.id === entry.id)),
]
setStore("session", "pending", sessionID, reconcile(pending)) setStore("session", "pending", sessionID, reconcile(pending))
setStore( setStore(
"session", "session",
@@ -1069,9 +1218,15 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
}, },
sync(sessionID: string) { sync(sessionID: string) {
return sync.run(`session.message:${sessionID}`, async () => { return sync.run(`session.message:${sessionID}`, async () => {
const messages = ( const fetched = (
await client.api.message.list({ sessionID, limit: 200, order: "desc" }) await client.api.message.list({ sessionID, limit: 200, order: "desc" })
).data.toReversed() ).data.toReversed()
// A wholesale replace would drop optimistic prompts the server has not
// admitted yet. A fetched ID is server confirmation; the rest re-append.
for (const entry of optimisticMessages(sessionID))
if (fetched.some((item) => item.id === entry.message.id))
confirmOptimistic(sessionID, entry.message.id)
const messages = [...fetched, ...optimisticMessages(sessionID).map((entry) => entry.message)]
messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index]))) messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index])))
setStore("session", "message", sessionID, reconcile(messages)) setStore("session", "message", sessionID, reconcile(messages))
}) })
@@ -0,0 +1,378 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import { testRender } from "@opentui/solid"
import type { OpenCodeEvent, SessionMessageInfo } from "@opencode-ai/client"
import { createEffect, type ParentProps } from "solid-js"
import { ConfigProvider } from "../../../src/config"
import { ClientProvider, useClient } from "../../../src/context/client"
import { DataProvider as DataProviderBase, useData } from "../../../src/context/data"
import { LocationProvider, useLocation } from "../../../src/context/location"
import { createApi, createEventStream, createFetch, directory, json } from "../../fixture/tui-client"
import { TestTuiContexts } from "../../fixture/tui-environment"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
async function wait(fn: () => boolean, timeout = 2000) {
const start = Date.now()
while (!fn()) {
if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
await Bun.sleep(10)
}
}
function emitEvent(events: ReturnType<typeof createEventStream>, event: OpenCodeEvent) {
events.emit({ ...event, location: { directory } })
}
const config = createTuiResolvedConfig()
function DataProvider(props: ParentProps) {
return (
<ConfigProvider config={config}>
<DataProviderBase>
<LocationProvider>
<SyncLocation />
{props.children}
</LocationProvider>
</DataProviderBase>
</ConfigProvider>
)
}
function SyncLocation() {
const data = useData()
const location = useLocation()
createEffect(() => location.set(data.location.default()))
return null
}
function durable(sessionID: string, seq = 0): { aggregateID: string; seq: number; version: 1 } {
return { aggregateID: sessionID, seq, version: 1 }
}
type Harness = {
data: ReturnType<typeof useData>
client: ReturnType<typeof useClient>
}
async function renderData(fetch: ReturnType<typeof createFetch>["fetch"]) {
const harness = {} as Harness
function Probe() {
harness.client = useClient()
harness.data = useData()
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<ClientProvider api={createApi(fetch)}>
<DataProvider>
<Probe />
</DataProvider>
</ClientProvider>
</TestTuiContexts>
))
await wait(() => harness.client.connection.status() === "connected")
return { app, ...harness }
}
test("echoes an optimistic prompt and replaces it with the admission echo", async () => {
const events = createEventStream()
const sessionID = "session-optimistic-echo"
const calls = createFetch(undefined, events)
const { app, data } = await renderData(calls.fetch)
try {
data.session.optimistic.prompt({
sessionID,
messageID: "msg_optimistic",
delivery: "steer",
text: "Hello",
files: [{ data: "", mime: "text/plain", source: { type: "uri", uri: "file:///tmp/a.ts" }, name: "a.ts" }],
})
const echoed = data.session.message.get(sessionID, "msg_optimistic")
expect(echoed?.type === "user" && echoed.text).toBe("Hello")
expect(echoed?.type === "user" && echoed.files?.[0]?.data).toBe("")
expect(data.session.pending.list(sessionID).map((item) => item.id)).toEqual(["msg_optimistic"])
expect(data.session.input.has(sessionID, "msg_optimistic")).toBe(true)
// Admission materializes attachments, so the echo must be replaced in place.
emitEvent(events, {
id: "evt_admitted",
created: 9,
type: "session.inbox.enqueued",
durable: durable(sessionID),
data: {
sessionID,
inboxID: "msg_optimistic",
item: {
type: "user",
payload: {
text: "Hello",
files: [{ data: "QUJD", mime: "text/plain", source: { type: "uri", uri: "file:///tmp/a.ts" }, name: "a.ts" }],
},
delivery: "steer",
},
},
})
await wait(() => {
const message = data.session.message.get(sessionID, "msg_optimistic")
return message?.type === "user" && message.files?.[0]?.data === "QUJD"
})
expect(data.session.message.list(sessionID)).toHaveLength(1)
expect(data.session.pending.list(sessionID)).toHaveLength(1)
expect(data.session.pending.list(sessionID)[0]?.timeCreated).toBe(9)
} finally {
app.renderer.destroy()
}
})
test("rolls back a failed optimistic prompt", async () => {
const events = createEventStream()
const sessionID = "session-optimistic-rollback"
const calls = createFetch(undefined, events)
const { app, data } = await renderData(calls.fetch)
try {
data.session.optimistic.prompt({
sessionID,
messageID: "msg_failed",
delivery: "queue",
text: "Will fail",
})
expect(data.session.message.get(sessionID, "msg_failed")).toBeDefined()
data.session.optimistic.rollback(sessionID, "msg_failed")
expect(data.session.message.get(sessionID, "msg_failed")).toBeUndefined()
expect(data.session.pending.list(sessionID)).toHaveLength(0)
expect(data.session.input.has(sessionID, "msg_failed")).toBe(false)
// Rollback of an unknown or already-settled echo is a no-op.
data.session.optimistic.rollback(sessionID, "msg_failed")
expect(data.session.message.list(sessionID)).toHaveLength(0)
} finally {
app.renderer.destroy()
}
})
test("optimistic prompts survive sync replaces until the server confirms them", async () => {
const events = createEventStream()
const sessionID = "session-optimistic-sync"
let serverMessages: SessionMessageInfo[] = []
const calls = createFetch((url) => {
if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: serverMessages, cursor: {} })
if (url.pathname === `/api/session/${sessionID}/inbox`) return json({ data: [] })
}, events)
const { app, data } = await renderData(calls.fetch)
try {
data.session.optimistic.prompt({
sessionID,
messageID: "msg_pending",
delivery: "steer",
text: "Survive the sync",
})
// A wholesale replace from an empty server page keeps the unconfirmed echo.
await data.session.message.sync(sessionID)
expect(data.session.message.get(sessionID, "msg_pending")).toBeDefined()
await data.session.pending.sync(sessionID)
expect(data.session.pending.list(sessionID).map((item) => item.id)).toEqual(["msg_pending"])
expect(data.session.input.has(sessionID, "msg_pending")).toBe(true)
// A fetched page containing the ID is server confirmation: the projected copy
// wins and later rollback attempts become no-ops.
serverMessages = [{ id: "msg_pending", type: "user", text: "Survive the sync", time: { created: 5 } }]
data.session.message.invalidate(sessionID)
await data.session.message.sync(sessionID)
const confirmed = data.session.message.get(sessionID, "msg_pending")
expect(confirmed?.type === "user" && confirmed.time.created).toBe(5)
expect(data.session.message.list(sessionID)).toHaveLength(1)
data.session.optimistic.rollback(sessionID, "msg_pending")
expect(data.session.message.get(sessionID, "msg_pending")).toBeDefined()
} finally {
app.renderer.destroy()
}
})
test("cancellation clears the optimistic echo for good", async () => {
const events = createEventStream()
const sessionID = "session-optimistic-cancel"
const calls = createFetch((url) => {
if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} })
}, events)
const { app, data } = await renderData(calls.fetch)
try {
data.session.optimistic.prompt({
sessionID,
messageID: "msg_cancelled",
delivery: "queue",
text: "Cancel me",
})
emitEvent(events, {
id: "evt_cancelled",
created: 2,
type: "session.inbox.cancelled",
durable: durable(sessionID),
data: { sessionID, inboxID: "msg_cancelled" },
})
await wait(() => data.session.message.get(sessionID, "msg_cancelled") === undefined)
expect(data.session.pending.list(sessionID)).toHaveLength(0)
// The ledger entry is gone too: a sync replace must not resurrect the echo.
await data.session.message.sync(sessionID)
expect(data.session.message.get(sessionID, "msg_cancelled")).toBeUndefined()
} finally {
app.renderer.destroy()
}
})
test("optimistic session creation seeds info and suppresses initial reads", async () => {
const events = createEventStream()
const sessionID = "ses_optimistic"
const fetched: string[] = []
const calls = createFetch((url) => {
if (!url.pathname.startsWith(`/api/session/${sessionID}`)) return
fetched.push(url.pathname)
if (url.pathname === `/api/session/${sessionID}`)
return json({
data: {
id: sessionID,
projectID: "proj_test",
location: { directory },
agent: "build",
title: "Server title",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 1 },
},
})
}, events)
const { app, data } = await renderData(calls.fetch)
try {
data.session.optimistic.create({
sessionID,
projectID: "proj_test",
location: { directory },
agent: "build",
model: { providerID: "provider", id: "model" },
})
const seeded = data.session.get(sessionID)
expect(seeded?.projectID).toBe("proj_test")
expect(seeded?.agent).toBe("build")
// Seeded reads are marked complete: the server does not know the session yet.
await data.session.sync(sessionID, { children: true })
await data.session.message.sync(sessionID)
await data.session.pending.sync(sessionID)
expect(fetched).toEqual([])
// The session.created echo invalidates the info read and loads server truth.
emitEvent(events, {
id: "evt_created",
created: 2,
type: "session.created",
durable: durable(sessionID),
data: {
sessionID,
projectID: "proj_test",
location: { directory },
slug: "server-slug",
agent: "build",
version: "test",
},
})
await wait(() => data.session.get(sessionID)?.title === "Server title")
expect(fetched).toEqual([`/api/session/${sessionID}`])
} finally {
app.renderer.destroy()
}
})
test("rollbackCreate removes the seeded session and re-enables reads", async () => {
const events = createEventStream()
const sessionID = "ses_rollback"
const fetched: string[] = []
const calls = createFetch((url) => {
if (url.pathname !== `/api/session/${sessionID}/message`) return
fetched.push(url.pathname)
return json({ data: [], cursor: {} })
}, events)
const { app, data } = await renderData(calls.fetch)
try {
data.session.optimistic.create({
sessionID,
projectID: "proj_test",
location: { directory },
})
data.session.optimistic.prompt({
sessionID,
messageID: "msg_first",
delivery: "steer",
text: "First prompt",
})
expect(data.session.message.list(sessionID)).toHaveLength(1)
data.session.optimistic.rollback(sessionID, "msg_first")
data.session.optimistic.rollbackCreate(sessionID)
expect(data.session.get(sessionID)).toBeUndefined()
expect(data.session.message.list(sessionID)).toHaveLength(0)
expect(data.session.pending.list(sessionID)).toHaveLength(0)
// The seed's completed read markers are gone with it.
await data.session.message.sync(sessionID)
expect(fetched).toEqual([`/api/session/${sessionID}/message`])
} finally {
app.renderer.destroy()
}
})
test("revert commit preserves unconfirmed optimistic prompts", async () => {
const events = createEventStream()
const sessionID = "session-optimistic-revert"
const calls = createFetch(undefined, events)
const { app, data } = await renderData(calls.fetch)
try {
for (const [seq, id] of [
[0, "msg_1"],
[1, "msg_2"],
] as const) {
emitEvent(events, {
id: `evt_seed_${id}`,
created: seq + 1,
type: "session.inbox.enqueued",
durable: durable(sessionID, seq),
data: { sessionID, inboxID: id, item: { type: "user", payload: { text: id }, delivery: "steer" } },
})
}
await wait(() => data.session.message.list(sessionID).length === 2)
data.session.optimistic.prompt({
sessionID,
messageID: "msg_9",
delivery: "steer",
text: "After the revert boundary",
})
emitEvent(events, {
id: "evt_revert",
created: 4,
type: "session.revert.committed",
durable: durable(sessionID, 2),
data: { sessionID, to: "msg_2" },
})
await wait(() => data.session.message.get(sessionID, "msg_2") === undefined)
expect(data.session.message.get(sessionID, "msg_1")).toBeDefined()
expect(data.session.message.get(sessionID, "msg_9")).toBeDefined()
expect(data.session.input.has(sessionID, "msg_9")).toBe(true)
} finally {
app.renderer.destroy()
}
})