From f543a66be78fa3e73ea3878a67285e7712cc5314 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 15 Aug 2026 14:38:28 -0400 Subject: [PATCH] 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. --- packages/tui/src/component/prompt/index.tsx | 81 ++++++++++---- packages/tui/src/context/data.tsx | 36 +++++++ .../tui/test/cli/tui/data-optimistic.test.tsx | 102 ++++++++++++++++++ 3 files changed, 197 insertions(+), 22 deletions(-) diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 982f806a4ed..cfe0ec8cc39 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -59,8 +59,10 @@ import { import { useData } from "../../context/data" import { usePromptRef } from "../../context/prompt" import { useLocation } from "../../context/location" -import type { PromptFileAttachment, PromptSkillAttachment, SkillInfo } from "@opencode-ai/client" +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 { abbreviateHome } from "../../runtime" import { Slot } from "../../plugin/render" @@ -258,6 +260,7 @@ export function Prompt(props: PromptProps) { const route = useRoute() const data = useData() const activePrompt = usePromptRef() + const sessionTabs = useSessionTabs() const directoryRecents = useDirectoryRecents() const keymapCommands = Keymap.useCommands() const currentLocation = useLocation() @@ -1242,6 +1245,8 @@ export function Prompt(props: PromptProps) { const variant = selection.variant let sessionID = props.sessionID let finishMoveProgress = false + let createSession: (() => Promise) | undefined + let createLocation: LocationRef | undefined if (sessionID == null) { const directory = await move.getDirectory() if (move.pending() && !directory) return false @@ -1249,31 +1254,46 @@ export function Prompt(props: PromptProps) { // 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, + const location = directory ? { directory } : (currentLocation.ref ?? data.location.default()) + const model = { providerID: selection.providerID, id: selection.modelID, variant } + // Seed the session locally with a client-generated ID and create it in the + // background so navigation and the prompt echo render immediately. Optimistic + // 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, - model: { - providerID: selection.providerID, - id: selection.modelID, - variant, - }, + 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 + createLocation = location + createSession = () => client.api.session.create({ id: created, location, agent: agent.id, model }) } - sessionID = created.id + if (sessionID == null) { + 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 @@ -1335,8 +1355,14 @@ export function Prompt(props: PromptProps) { // Mark the editor context sent with the echo so a rapid follow-up submit // does not re-attach the same selection while admission is in flight. if (editorContextText) editor.markSelectionSent() + let createFailed = false void enqueueSubmit(submitSessionID, async () => { const error = await (async () => { + if (createSession) + await createSession().catch((error) => { + createFailed = true + throw error + }) let session = data.session.get(submitSessionID) if (!session) { await data.session.sync(submitSessionID) @@ -1375,6 +1401,17 @@ export function Prompt(props: PromptProps) { ) 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" }) }) diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 79e96043e27..a8360b22458 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -346,6 +346,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ messageIndex.delete(sessionID) optimisticPrompts.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}`) @@ -1066,6 +1067,41 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }, }, 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. diff --git a/packages/tui/test/cli/tui/data-optimistic.test.tsx b/packages/tui/test/cli/tui/data-optimistic.test.tsx index 3a65bdcd37e..9e76c7c7524 100644 --- a/packages/tui/test/cli/tui/data-optimistic.test.tsx +++ b/packages/tui/test/cli/tui/data-optimistic.test.tsx @@ -231,6 +231,108 @@ test("cancellation clears the optimistic echo for good", async () => { } }) +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"