Просмотр исходного кода

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.
Kit Langton 1 день назад
Родитель
Сommit
f543a66be7

+ 58 - 21
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<unknown>) | 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)
+        sessionID = created
+        createLocation = location
+        createSession = () => client.api.session.create({ id: created, location, agent: agent.id, model })
+      }
 
-      if (!created) {
-        if (finishMoveProgress) move.finishSubmit()
-        toast.show({
-          message: "Creating a session failed. Open console for more details.",
-          variant: "error",
-        })
+      if (sessionID == null) {
+        const created = await client.api.session
+          .create({ location, agent: agent.id, model })
+          .catch(() => undefined)
 
-        return true
-      }
+        if (!created) {
+          if (finishMoveProgress) move.finishSubmit()
+          toast.show({
+            message: "Creating a session failed. Open console for more details.",
+            variant: "error",
+          })
 
-      sessionID = created.id
+          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" })
       })

+ 36 - 0
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.

+ 102 - 0
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"