From 2e5ec616d286a78021cafa0e2e6554bd3239cd63 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 21 Aug 2026 14:21:31 -0400 Subject: [PATCH] feat(tui): optimistic session creation on first prompt (#43687) --- packages/client/src/solid/data.ts | 136 ++++++++-- packages/tui/src/component/prompt/index.tsx | 266 ++++++++++++-------- packages/tui/src/context/session-tabs.tsx | 11 + 3 files changed, 288 insertions(+), 125 deletions(-) diff --git a/packages/client/src/solid/data.ts b/packages/client/src/solid/data.ts index 03c4b27e68..39c15cc108 100644 --- a/packages/client/src/solid/data.ts +++ b/packages/client/src/solid/data.ts @@ -13,6 +13,7 @@ import type { McpResource, McpServer, ModelInfo, + ModelRef, PermissionSavedInfo, PermissionRequest, PermissionReplyInput, @@ -34,6 +35,7 @@ import type { WebSearchProvider, } from "../promise" import { Worktree } from "@opencode-ai/schema/worktree" +import { SessionID } from "@opencode-ai/schema/session-id" import { SessionMessage } from "@opencode-ai/schema/session-message" import { isPermissionNotFoundError, type SessionPromptInput } from "../promise" import { createStore, produce, reconcile } from "solid-js/store" @@ -226,6 +228,33 @@ export function createData(config: CreateDataInput) { // rollback — not on POST success, which typically precedes the echo. const outbox = new Set() + // Session IDs of optimistic create admissions still awaiting acknowledgement + // (the session.created echo or the create response itself). A failed create + // only rolls back a session the server never acknowledged. Unlike + // `creating`, this clears on the echo rather than request settlement. + const sessionOutbox = new Set() + + // In-flight optimistic creates by session ID. prompt() gates its POST on + // this so a prompt sent to a still-creating session waits for the session + // to exist server-side instead of failing with "not found". + const creating = new Map>() + + // Per-session send chain: prompts must be admitted in submission order, + // and HTTP gives no ordering across concurrent POSTs. Each prompt waits + // for the previous prompt's POST (settled, so one failure does not block + // the next) before sending its own. + const sending = new Map>() + + // Register `promise` under `key` until it settles. A later registration + // replaces an earlier one; settlement only clears its own entry. + function track(map: Map>, key: string, promise: Promise) { + map.set(key, promise) + const settle = () => { + if (map.get(key) === promise) map.delete(key) + } + void promise.then(settle, settle) + } + // Upsert an admitted inbox item into pending, input, and (for user and // synthetic items) the visible transcript. Used by the inbox.enqueued // handler and by optimistic prompt admission; the upsert is what reconciles @@ -385,6 +414,7 @@ export function createData(config: CreateDataInput) { store.session.pending[sessionID]?.forEach((item) => outbox.delete(item.id)) messageIndex.delete(sessionID) sync.invalidate(`session:${sessionID}`) + sync.invalidate(`session.family:${sessionID}`) sync.invalidate(`session.pending:${sessionID}`) sync.invalidate(`session.message:${sessionID}`) sync.invalidate(`session.permission:${sessionID}`) @@ -434,6 +464,7 @@ export function createData(config: CreateDataInput) { void result.project.sync().catch((error) => console.error("Failed to preload projects", error)) return case "session.created": + sessionOutbox.delete(event.data.sessionID) result.session.invalidate(event.data.sessionID) void result.session.sync(event.data.sessionID) // Band-aid: a newly created session starts empty, so live events can be its source of truth. @@ -1110,46 +1141,117 @@ export function createData(config: CreateDataInput) { sync.invalidate(`session.pending:${sessionID}`) }, }, + // Optimistic session creation: admit a local record under a + // client-minted ID so a session view can mount immediately, then create + // the session on the server. The session.created echo re-syncs the + // record by ID, so the durable payload replaces the client's guess. + // Returns the ID synchronously along with the in-flight request: + // callers gate session-dependent sends on the request (prompt() gates + // itself on any in-flight create of its session automatically). + create(input: { + id?: string + title?: string + agent?: string + model?: ModelRef + location?: LocationRef + projectID?: string + }) { + const { projectID, ...payload } = input + const id = payload.id ?? SessionID.create() + const location = payload.location ?? defaultLocation() + const fresh = !store.session.info[id] + if (fresh) { + const now = Date.now() + sessionOutbox.add(id) + result.session.remember({ + id, + projectID: projectID ?? store.location[locationKey(location)]?.info?.project.id ?? "", + agent: payload.agent, + model: payload.model, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: now, updated: now }, + title: payload.title, + location, + }) + // A mounted optimistic session must not fetch its empty collections + // before creation settles. The session.created echo re-syncs info. + sync.complete(`session.family:${id}`) + sync.complete(`session.pending:${id}`) + sync.complete(`session.message:${id}`) + } + // Wrapped so even a synchronous client failure reaches the rollback. + const request = Promise.resolve() + .then(() => api().session.create({ ...payload, id, location })) + .then((info) => { + sessionOutbox.delete(id) + result.session.remember(info) + return info + }) + .catch((error) => { + // Roll back only a record this call admitted and neither the echo + // nor the response has acknowledged: anything else is server state. + if (fresh && sessionOutbox.delete(id)) removeSession(id) + throw error + }) + if (fresh) track(creating, id, request) + return { id, request } + }, // Optimistic prompt admission: render the prompt immediately under a // client-minted ID, send it, and let the durable inbox.enqueued echo // upsert that same ID with the server's payload. Server admission is // idempotent per ID, so retrying with the identical payload cannot // double-admit. - prompt(input: SessionPromptInput) { - const id = input.id ?? SessionMessage.ID.create() + prompt(input: SessionPromptInput & { gate?: Promise }) { + const { gate, ...request } = input + const id = request.id ?? SessionMessage.ID.create() // A retry may reuse an ID that is already rendered — and possibly // already durable. Admit optimistically only for new IDs so a failed // retry cannot roll back acknowledged state. const fresh = - !messageIndex.get(input.sessionID)?.has(id) && - !store.session.pending[input.sessionID]?.some((item) => item.id === id) + !messageIndex.get(request.sessionID)?.has(id) && + !store.session.pending[request.sessionID]?.some((item) => item.id === id) if (fresh) { outbox.add(id) admitLocal({ id, - sessionID: input.sessionID, + sessionID: request.sessionID, timeCreated: Date.now(), type: "user", - delivery: input.delivery ?? "steer", + delivery: request.delivery ?? "steer", // Files and skills stay off the optimistic row: their durable // forms are server-loaded (content, mime, resolution), so they // fill in when the echo upserts the row. payload: { - text: input.text, - agents: input.agents?.map((agent) => ({ ...agent })), - metadata: input.metadata, + text: request.text, + agents: request.agents?.map((agent) => ({ ...agent })), + metadata: request.metadata, }, }) } // Wrapped so even a synchronous client failure reaches the rollback. - return Promise.resolve() - .then(() => api().session.prompt({ ...input, id })) - .catch((error) => { - // Roll back only rows this call admitted and the echo has not - // acknowledged: anything else is server state. - if (fresh && outbox.delete(id)) retractLocal(input.sessionID, id) - throw error - }) + // The POST additionally waits for the caller's gate, for any + // in-flight optimistic create of this session, and for the previous + // prompt's POST: the row renders now, the send happens once the + // session exists server-side and earlier prompts are admitted. + const previous = sending.get(request.sessionID) + const send = Promise.resolve() + .then(() => Promise.all([gate, creating.get(request.sessionID), previous])) + .then(() => api().session.prompt({ ...request, id })) + track( + sending, + request.sessionID, + send.then( + () => undefined, + () => undefined, + ), + ) + return send.catch((error) => { + // Roll back only rows this call admitted and the echo has not + // acknowledged: anything else is server state. + if (fresh && outbox.delete(id)) retractLocal(request.sessionID, id) + throw error + }) }, sync(sessionID: string, options?: { children?: boolean }) { return sync.run(options?.children ? `session.family:${sessionID}` : `session:${sessionID}`, async () => { diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 7bac36159c..a72eb1f0e2 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -19,6 +19,8 @@ import { useClipboard } from "../../context/clipboard" import { Spinner } from "../spinner" import { useClient } from "../../context/client" import { useRoute } from "../../context/route" +import { usePromptRef } from "../../context/prompt" +import { useSessionTabs } from "../../context/session-tabs" import { useEvent } from "../../context/event" import { editorSelectionKey, useEditorContext, type EditorSelection } from "../../context/editor" import { normalizePromptContent, openEditor } from "../../editor" @@ -201,6 +203,8 @@ export function Prompt(props: PromptProps) { const client = useClient() const editor = useEditorContext() const route = useRoute() + const promptRef = usePromptRef() + const sessionTabs = useSessionTabs() const data = useData() const directoryRecents = useDirectoryRecents() const keymapCommands = Keymap.useCommands() @@ -688,16 +692,20 @@ export function Prompt(props: PromptProps) { input.gotoBufferEnd() }, reset() { - input.clear() - input.extmarks.clear() - setStore("prompt", emptyPrompt()) - setStore("extmarkToPart", new Map()) + resetComposer() }, submit() { void submit() }, } + function resetComposer() { + input.extmarks.clear() + setStore("prompt", emptyPrompt()) + setStore("extmarkToPart", new Map()) + input.clear() + } + // Captured once: the session route is keyed by sessionID, so this Prompt // instance belongs to exactly one tab. Reading props.sessionID lazily would // observe the *next* route during onCleanup and stash under the wrong tab. @@ -873,10 +881,7 @@ export function Prompt(props: PromptProps) { run: () => { if (!store.prompt.text) return stash.push({ prompt: store.prompt }) - input.extmarks.clear() - input.clear() - setStore("prompt", emptyPrompt()) - setStore("extmarkToPart", new Map()) + resetComposer() dialog.clear() }, }, @@ -1168,102 +1173,139 @@ export function Prompt(props: PromptProps) { return false } + // Snapshot the composer and clear it synchronously, before the first await. + // Everything below reads the snapshot: text typed while a request is in + // flight lands in the already-empty composer and survives, and prompt + // history records exactly what was submitted instead of the live store + // (which may have absorbed mid-flight typing). Failure paths restore the + // snapshot unless the user has started typing something new. + const currentMode = store.mode + const entry = { ...store.prompt, mode: currentMode } + history.append(entry) + resetComposer() + props.onSubmit?.() + const restoreEntry = () => { + if (disposed || input.isDestroyed || input.plainText !== "") return + input.setText(entry.text) + setStore("prompt", entry) + setStore("mode", entry.mode ?? "normal") + restoreExtmarksFromPrompt(entry) + input.cursorOffset = entry.text.length + } + const variant = selection.variant let sessionID = props.sessionID let session = sessionID ? data.session.get(sessionID) : undefined let finishMoveProgress = false + // New-session sends wait for creation and environment setup. + let newSession: { gate: Promise; recover: (error: unknown) => void } | undefined if (sessionID == null) { const directory = await move.getDirectory() - if (move.pending() && !directory) return false + if (move.pending() && !directory) { + restoreEntry() + return false + } finishMoveProgress = Boolean(move.progress()) // 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 // by /cd before a session exists. const location = currentLocation.ref ?? data.location.default() - const created = await client.api.session - .create({ - location: directory ? { directory } : location, - agent: agent.id, - model: { - providerID: selection.providerID, - id: selection.modelID, - variant, - }, - }) - .catch(() => undefined) - - if (!created) { - if (finishMoveProgress) move.finishSubmit() - toast.show({ - message: "Creating a session failed. Open console for more details.", - variant: "error", - }) - - return true - } - + // Optimistic create: the data layer mints the ID client-side and admits + // a local session record synchronously, so the navigation below happens + // immediately — enter feels sent even while the create round-trip is in + // flight. Sends against the new session gate on the request. + const created = data.session.create({ + location: directory ? { directory } : location, + agent: agent.id, + model: { + providerID: selection.providerID, + id: selection.modelID, + variant, + }, + }) sessionID = created.id - session = created - if (created.location.workspaceID === undefined && terminalEnvironment.variables !== undefined) { - const error = await client.api.session - .environment({ sessionID, variables: terminalEnvironment.variables }) - .then( - () => undefined, - (error) => error, - ) - if (error) { - if (finishMoveProgress) move.finishSubmit() - toast.show({ title: "Failed to set session environment", message: errorMessage(error), variant: "error" }) - return true - } + session = data.session.get(created.id) + newSession = { + gate: created.request.then(async (info) => { + if (info.location.workspaceID === undefined && terminalEnvironment.variables !== undefined) { + await client.api.session.environment({ sessionID: created.id, variables: terminalEnvironment.variables }) + } + }), + recover: (error) => { + toast.show({ + title: data.session.get(created.id) ? "Failed to set up session" : "Creating a session failed", + message: errorMessage(error), + variant: "error", + }) + const active = + route.data.type === "session" && route.data.sessionID === created.id ? promptRef.current : undefined + const current = active?.current + const draft = current?.text + ? { prompt: { ...unwrap(current) }, cursor: current.text.length } + : (takeDraft(created.id) ?? { prompt: entry, cursor: entry.text.length }) + saveDraft(undefined, draft) + active?.reset() + if (sessionTabs.enabled()) { + sessionTabs.close(created.id) + } else if (route.data.type === "session" && route.data.sessionID === created.id) { + route.navigate({ type: "home" }) + } + }, } } - // Capture mode before it gets reset - const currentMode = store.mode - if (store.mode === "shell") { + const target = sessionID + const dispatch = (send: () => Promise) => { + const setup = newSession + if (setup) void setup.gate.then(send).catch(setup.recover) + else void send() + } + if (currentMode === "shell") { move.startSubmit() - void client.api.session.shell({ - sessionID, - command: inputText, - }) + dispatch(() => client.api.session.shell({ sessionID: target, command: inputText })) setStore("mode", "normal") } else if (slashHead && isCommand) { move.startSubmit() const model = { providerID: selection.providerID, id: selection.modelID, variant } - const cancelCommit = local.model.trackSessionCommit(sessionID, model) + const cancelCommit = local.model.trackSessionCommit(target, model) - void client.api.session - .command({ - sessionID, + const send = () => + client.api.session.command({ + sessionID: target, command: slashHead.name, arguments: slashHead.arguments, agent: agent.id, model, - files: store.prompt.files, - agents: store.prompt.agents, - skills: store.prompt.skills?.length ? store.prompt.skills : undefined, + files: entry.files, + agents: entry.agents, + skills: entry.skills?.length ? entry.skills : undefined, delivery, }) - .catch((error) => { - cancelCommit() - toast.show({ title: "Failed to run command", message: errorMessage(error), variant: "error" }) - }) + const setup = newSession + void (setup ? setup.gate.then(send) : send()).catch((error) => { + cancelCommit() + if (setup) return setup.recover(error) + toast.show({ title: "Failed to run command", message: errorMessage(error), variant: "error" }) + restoreEntry() + }) } else if (isSkill) { move.startSubmit() - void client.api.session.skill({ - sessionID, - skill: slashHead.name, - }) + dispatch(() => client.api.session.skill({ sessionID: target, skill: slashHead.name })) } else { move.startSubmit() - if (!session) { - await data.session.sync(sessionID) - session = data.session.get(sessionID) - } - if (session?.agent !== agent.id) { - await client.api.session.switchAgent({ sessionID, agent: agent.id }) + try { + if (!session) { + await data.session.sync(target) + session = data.session.get(target) + } + if (session?.agent !== agent.id) { + await client.api.session.switchAgent({ sessionID: target, agent: agent.id }) + } + } catch (error) { + toast.show({ title: "Failed to prepare session", message: errorMessage(error), variant: "error" }) + restoreEntry() + return true } if ( session?.model?.providerID !== selection.providerID || @@ -1271,83 +1313,94 @@ export function Prompt(props: PromptProps) { (session.model.variant ?? "default") !== (variant ?? "default") ) { const model = { providerID: selection.providerID, id: selection.modelID, variant } - const cancelCommit = local.model.trackSessionCommit(sessionID, model) - await client.api.session.switchModel({ sessionID, model }).catch((error) => { + const cancelCommit = local.model.trackSessionCommit(target, model) + const switchError = await client.api.session.switchModel({ sessionID: target, model }).then( + () => undefined, + (error) => error, + ) + if (switchError) { cancelCommit() - throw error - }) + toast.show({ title: "Failed to switch model", message: errorMessage(switchError), variant: "error" }) + restoreEntry() + return true + } } if (session?.revert) { - const error = await client.api.session.revert.commit({ sessionID }).then( + const error = await client.api.session.revert.commit({ sessionID: target }).then( () => undefined, (error) => error, ) if (error) { toast.show({ title: "Failed to commit revert", message: errorMessage(error), variant: "error" }) + restoreEntry() return false } } if (pendingEditorSelection) { // Keep editor context hidden while admitting it before the corresponding user prompt. - const error = await client.api.session - .synthetic({ - sessionID, + const send = () => + client.api.session.synthetic({ + sessionID: target, text: formatEditorContext(pendingEditorSelection), resume: false, }) - .then( + if (newSession) { + // Fold into the setup gate so the context still admits before the + // user prompt once the session exists. + newSession.gate = newSession.gate.then(send) + } else { + const error = await send().then( () => undefined, (error) => error, ) - if (error) { - toast.show({ title: "Failed to send editor context", message: errorMessage(error), variant: "error" }) - return false + if (error) { + toast.show({ title: "Failed to send editor context", message: errorMessage(error), variant: "error" }) + restoreEntry() + return false + } } } // The data layer admits optimistically: the prompt renders immediately // and rolls back if the server rejects it, so submission does not wait // on the network. On rejection the row is already rolled back; restore // the composer unless the user has started typing something new. - const entry = { ...store.prompt, mode: currentMode } data.session .prompt({ - sessionID, + sessionID: target, text: inputText, - files: store.prompt.files, - agents: store.prompt.agents, - skills: store.prompt.skills?.length ? store.prompt.skills : undefined, + files: entry.files, + agents: entry.agents, + skills: entry.skills?.length ? entry.skills : undefined, delivery, + gate: newSession?.gate, }) .catch((error) => { + if (newSession) return newSession.recover(error) toast.show({ title: "Failed to send prompt", message: errorMessage(error), variant: "error" }) - if (disposed || input.isDestroyed || input.plainText !== "") return - input.setText(entry.text) - setStore("prompt", entry) - setStore("mode", entry.mode ?? "normal") - restoreExtmarksFromPrompt(entry) - input.cursorOffset = entry.text.length + restoreEntry() }) if (pendingEditorSelection) editor.markSelectionSent() } - history.append({ - ...store.prompt, - mode: currentMode, - }) - input.extmarks.clear() - setStore("prompt", emptyPrompt()) - setStore("extmarkToPart", new Map()) - props.onSubmit?.() // Optimistic admission puts the message in the store synchronously, so // the session view renders it on arrival. if (!props.sessionID) { if (pendingEditorSelection) editor.preserveSelectionFromNewSession() + // Text typed while session creation was in flight lives in this (home) + // prompt, which unmounts on navigation and would stash it under the + // home key. Re-stash it under the new session so that composer restores + // it, and clear it here so onCleanup does not also stash it for home. + if (!disposed && !input.isDestroyed && store.prompt.text) { + // Copy before clearing: unwrap returns the live store target, and the + // resetComposer store write merges into that same object. + saveDraft(sessionID, { prompt: { ...unwrap(store.prompt) }, cursor: input.cursorOffset }) + resetComposer() + } route.navigate({ type: "session", sessionID, }) } - input.clear() if (finishMoveProgress) move.finishSubmit() return true } @@ -1509,10 +1562,7 @@ export function Prompt(props: PromptProps) { mode: store.mode, }) } - input.clear() - input.extmarks.clear() - setStore("prompt", emptyPrompt()) - setStore("extmarkToPart", new Map()) + resetComposer() } const highlight = createMemo(() => { diff --git a/packages/tui/src/context/session-tabs.tsx b/packages/tui/src/context/session-tabs.tsx index fedb1e4aec..1878b61228 100644 --- a/packages/tui/src/context/session-tabs.tsx +++ b/packages/tui/src/context/session-tabs.tsx @@ -76,6 +76,13 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp let history: SessionTabHistory = { entries: [], index: -1 } // User-closed tabs eligible for reopening; in-memory like history, deleted sessions pruned. let closedTabs: ClosedSessionTab[] = [] + // Storage mutations apply against the on-disk draft under a file lock, so + // a registration queued by the route effect can land AFTER a removal that + // ran while the write was still in flight — resurrecting a tab that was + // just closed. Removing a tab marks it cancelled so any late-applying + // registration becomes a no-op; navigating to the session again clears + // the mark. + const cancelledTabs = new Set() const scrollAnchors = new Map() const onFocus = () => setFocused(true) @@ -152,6 +159,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp if (!enabled()) return if (route.data.type !== "session" || route.data.sessionID === "dummy") return const sessionID = root(route.data.sessionID) + cancelledTabs.delete(sessionID) history = recordSessionTabHistory(history, sessionID) const fallback = newTab() ? NEW_SESSION_TAB_TITLE : undefined const tabs = openSessionTab(state().tabs, { @@ -160,6 +168,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp }) if (tabs === state().tabs) return update((draft) => { + if (cancelledTabs.has(sessionID)) return draft.tabs = openSessionTab(draft.tabs, { sessionID, title: title(sessionID, draft.tabs.find((tab) => tab.sessionID === sessionID)?.title, fallback), @@ -266,6 +275,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp function remove(sessionID: string, navigate: boolean) { const target = root(sessionID) + cancelledTabs.add(target) scrollAnchors.delete(target) const closed = closeSessionTab(state().tabs, target) const selected = navigate && current() === target @@ -352,6 +362,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp closedTabs = result.stack const tabs = result.tabs if (!tabs || !result.sessionID) return + cancelledTabs.delete(result.sessionID) update((draft) => { draft.tabs = tabs })