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"