feat(tui): optimistic session creation on first prompt (#43687)

This commit is contained in:
Kit Langton
2026-08-21 14:21:31 -04:00
committed by GitHub
parent b2551b4e5d
commit 2e5ec616d2
3 changed files with 288 additions and 125 deletions
+119 -17
View File
@@ -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<string>()
// 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<string>()
// 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<string, Promise<unknown>>()
// 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<string, Promise<unknown>>()
// Register `promise` under `key` until it settles. A later registration
// replaces an earlier one; settlement only clears its own entry.
function track(map: Map<string, Promise<unknown>>, key: string, promise: Promise<unknown>) {
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<unknown> }) {
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 () => {
+158 -108
View File
@@ -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<unknown>; 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<unknown>) => {
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(() => {
+11
View File
@@ -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<string>()
const scrollAnchors = new Map<string, ScrollAnchor>()
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
})