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

fix(app): separate provider lifetimes and reactive ownership (#33739)

Luke Parker 2 месяцев назад
Родитель
Сommit
cfd75d62fe
37 измененных файлов с 838 добавлено и 274 удалено
  1. 2 2
      packages/app/package.json
  2. 6 6
      packages/app/src/app.tsx
  3. 4 4
      packages/app/src/components/dialog-connect-provider.tsx
  4. 3 2
      packages/app/src/components/dialog-custom-provider.tsx
  5. 3 1
      packages/app/src/components/dialog-manage-models.tsx
  6. 7 4
      packages/app/src/components/dialog-select-model-unpaid.tsx
  7. 7 2
      packages/app/src/components/dialog-select-model.tsx
  8. 5 5
      packages/app/src/components/dialog-select-provider.tsx
  9. 5 19
      packages/app/src/components/prompt-input.tsx
  10. 66 37
      packages/app/src/components/prompt-input/attachments.ts
  11. 31 0
      packages/app/src/components/prompt-input/submission-state.ts
  12. 1 0
      packages/app/src/components/prompt-input/submit.test.ts
  13. 28 31
      packages/app/src/components/prompt-input/submit.ts
  14. 43 0
      packages/app/src/components/prompt-input/transient-state.ts
  15. 3 1
      packages/app/src/components/session-context-usage.tsx
  16. 3 1
      packages/app/src/components/session/session-context-tab.tsx
  17. 5 1
      packages/app/src/context/directory-sync.ts
  18. 1 1
      packages/app/src/context/local.tsx
  19. 3 3
      packages/app/src/context/models.tsx
  20. 19 19
      packages/app/src/context/prompt.tsx
  21. 59 0
      packages/app/src/hooks/provider-catalog.test.ts
  22. 27 0
      packages/app/src/hooks/provider-catalog.ts
  23. 18 8
      packages/app/src/hooks/use-providers.ts
  24. 12 8
      packages/app/src/pages/directory-layout.tsx
  25. 99 79
      packages/app/src/pages/session.tsx
  26. 1 1
      packages/app/src/pages/session/composer/session-composer-region.tsx
  27. 37 0
      packages/app/src/pages/session/session-ownership.ts
  28. 72 36
      packages/app/src/pages/session/use-session-commands.tsx
  29. 7 1
      packages/app/src/utils/session-route.test.ts
  30. 9 0
      packages/app/src/utils/session-route.ts
  31. 90 0
      packages/app/test-browser/prompt-attachments.test.ts
  32. 14 0
      packages/app/test-browser/prompt-scope.test.ts
  33. 56 0
      packages/app/test-browser/prompt-submission-state.test.ts
  34. 36 0
      packages/app/test-browser/prompt-transient-state.test.ts
  35. 45 0
      packages/app/test-browser/session-ownership.test.ts
  36. 5 1
      packages/client/src/generated-effect/.httpapi-codegen.json
  37. 6 1
      packages/client/src/generated/.httpapi-codegen.json

+ 2 - 2
packages/app/package.json

@@ -17,9 +17,9 @@
     "dev": "vite",
     "build": "vite build",
     "serve": "vite preview",
-    "test": "bun run test:unit && bun run test:virtualizer",
+    "test": "bun run test:unit && bun run test:browser",
     "test:unit": "bun test --only-failures --preload ./happydom.ts ./src",
-    "test:virtualizer": "bun test --conditions=browser --preload ./happydom.ts ./test-browser/solid-virtual.test.ts",
+    "test:browser": "bun test --conditions=browser --preload ./happydom.ts ./test-browser",
     "test:unit:watch": "bun test --watch --preload ./happydom.ts ./src",
     "test:e2e": "playwright test",
     "test:e2e:local": "playwright test",

+ 6 - 6
packages/app/src/app.tsx

@@ -51,7 +51,7 @@ import LegacyLayout from "@/pages/layout"
 import NewLayout from "@/pages/layout-new"
 import { ErrorPage } from "./pages/error"
 import { useCheckServerHealth } from "./utils/server-health"
-import { legacySessionHref, requireServerKey, sessionHref } from "./utils/session-route"
+import { legacySessionHref, requireServerKey, selectSessionLineage, sessionHref } from "./utils/session-route"
 
 import Session from "@/pages/session"
 import { NewHome, LegacyHome } from "@/pages/home"
@@ -95,7 +95,7 @@ const TargetSessionRoute = () => {
   })
 
   return (
-    <Show when={`${params.serverKey}\0${params.id}`} keyed>
+    <Show when={requireServerKey(params.serverKey)} keyed>
       <ServerSDKProvider server={conn}>
         <ServerSyncProvider server={conn}>
           <ResolvedTargetSessionRoute />
@@ -119,7 +119,7 @@ function ResolvedTargetSessionRoute() {
     },
     ({ id, sync }) => sync.session.lineage.resolve(id),
   )
-  const current = createMemo(() => cached() ?? resolved())
+  const current = createMemo(() => selectSessionLineage(params.id, cached(), resolved()))
   const directory = createMemo(() => current()?.session.directory)
   const targetDirectory = () => directory()!
 
@@ -134,7 +134,7 @@ function ResolvedTargetSessionRoute() {
 
   return (
     <TargetServerScopedProviders directory={directory} sessionID={() => params.id}>
-      <Show when={!resolved.error} fallback={<ErrorPage error={resolved.error} />}>
+      <Show when={!!current() || resolved.state !== "errored"} fallback={<ErrorPage error={resolved.error} />}>
         <Show when={directory()}>
           <Show
             when={settings.general.newLayoutDesigns()}
@@ -294,7 +294,7 @@ function ServerScopedProviders(props: ServerScopedShellProps) {
     <PermissionProvider directory={props.directory}>
       <LayoutProvider>
         <NotificationProvider directory={props.directory} sessionID={props.sessionID}>
-          <ModelsProvider>{props.children}</ModelsProvider>
+          <ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
         </NotificationProvider>
       </LayoutProvider>
     </PermissionProvider>
@@ -323,7 +323,7 @@ function TargetServerScopedProviders(props: ServerScopedShellProps) {
   return (
     <PermissionProvider directory={props.directory}>
       <NotificationProvider directory={props.directory} sessionID={props.sessionID}>
-        <ModelsProvider>{props.children}</ModelsProvider>
+        <ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
       </NotificationProvider>
     </PermissionProvider>
   )

+ 4 - 4
packages/app/src/components/dialog-connect-provider.tsx

@@ -9,7 +9,7 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
 import { Spinner } from "@opencode-ai/ui/spinner"
 import { TextField } from "@opencode-ai/ui/text-field"
 import { showToast } from "@/utils/toast"
-import { createEffect, createMemo, createResource, Match, onCleanup, onMount, Switch } from "solid-js"
+import { type Accessor, createEffect, createMemo, createResource, Match, onCleanup, onMount, Switch } from "solid-js"
 import { createStore, produce } from "solid-js/store"
 import { Link } from "@/components/link"
 import { useServerSDK } from "@/context/server-sdk"
@@ -17,16 +17,16 @@ import { useServerSync } from "@/context/server-sync"
 import { useLanguage } from "@/context/language"
 import { useProviders } from "@/hooks/use-providers"
 
-export function DialogConnectProvider(props: { provider: string }) {
+export function DialogConnectProvider(props: { provider: string; directory?: Accessor<string | undefined> }) {
   const dialog = useDialog()
   const serverSync = useServerSync()
   const serverSDK = useServerSDK()
   const language = useLanguage()
-  const providers = useProviders()
+  const providers = useProviders(props.directory)
 
   const all = () => {
     void import("./dialog-select-provider").then((x) => {
-      dialog.show(() => <x.DialogSelectProvider />)
+      dialog.show(() => <x.DialogSelectProvider directory={props.directory} />)
     })
   }
 

+ 3 - 2
packages/app/src/components/dialog-custom-provider.tsx

@@ -6,7 +6,7 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
 import { useMutation } from "@tanstack/solid-query"
 import { TextField } from "@opencode-ai/ui/text-field"
 import { showToast } from "@/utils/toast"
-import { batch, For } from "solid-js"
+import { type Accessor, batch, For } from "solid-js"
 import { createStore, produce } from "solid-js/store"
 import { Link } from "@/components/link"
 import { useServerSDK } from "@/context/server-sdk"
@@ -17,6 +17,7 @@ import { DialogSelectProvider } from "./dialog-select-provider"
 
 type Props = {
   back?: "providers" | "close"
+  directory?: Accessor<string | undefined>
 }
 
 export function DialogCustomProvider(props: Props) {
@@ -40,7 +41,7 @@ export function DialogCustomProvider(props: Props) {
       dialog.close()
       return
     }
-    dialog.show(() => <DialogSelectProvider />)
+    dialog.show(() => <DialogSelectProvider directory={props.directory} />)
   }
 
   const addModel = () => {

+ 3 - 1
packages/app/src/components/dialog-manage-models.tsx

@@ -9,14 +9,16 @@ import { popularProviders } from "@/hooks/use-providers"
 import { useLanguage } from "@/context/language"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { DialogSelectProvider } from "./dialog-select-provider"
+import { decode64 } from "@/utils/base64"
 
 export const DialogManageModels: Component = () => {
   const local = useLocal()
   const language = useLanguage()
   const dialog = useDialog()
+  const directory = () => decode64(local.slug())
 
   const handleConnectProvider = () => {
-    dialog.show(() => <DialogSelectProvider />)
+    dialog.show(() => <DialogSelectProvider directory={directory} />)
   }
   const providerRank = (id: string) => popularProviders.indexOf(id)
   const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID)

+ 7 - 4
packages/app/src/components/dialog-select-model-unpaid.tsx

@@ -10,24 +10,27 @@ import { useLocal } from "@/context/local"
 import { popularProviders, useProviders } from "@/hooks/use-providers"
 import { ModelTooltip } from "./model-tooltip"
 import { useLanguage } from "@/context/language"
+import { decode64 } from "@/utils/base64"
 
 type ModelState = ReturnType<typeof useLocal>["model"]
 
 export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props) => {
-  const model = props.model ?? useLocal().model
+  const local = useLocal()
+  const model = props.model ?? local.model
   const dialog = useDialog()
-  const providers = useProviders()
+  const directory = () => decode64(local.slug())
+  const providers = useProviders(directory)
   const language = useLanguage()
 
   const connect = (provider: string) => {
     void import("./dialog-connect-provider").then((x) => {
-      dialog.show(() => <x.DialogConnectProvider provider={provider} />)
+      dialog.show(() => <x.DialogConnectProvider provider={provider} directory={directory} />)
     })
   }
 
   const all = () => {
     void import("./dialog-select-provider").then((x) => {
-      dialog.show(() => <x.DialogSelectProvider />)
+      dialog.show(() => <x.DialogSelectProvider directory={directory} />)
     })
   }
 

+ 7 - 2
packages/app/src/components/dialog-select-model.tsx

@@ -12,6 +12,7 @@ import { List } from "@opencode-ai/ui/list"
 import { Tooltip } from "@opencode-ai/ui/tooltip"
 import { ModelTooltip } from "./model-tooltip"
 import { useLanguage } from "@/context/language"
+import { decode64 } from "@/utils/base64"
 
 const isFree = (provider: string, cost: { input: number } | undefined) =>
   provider === "opencode" && (!cost || cost.input === 0)
@@ -104,6 +105,8 @@ export function ModelSelectorPopover(props: {
     dismiss: null,
   })
   const dialog = useDialog()
+  const local = useLocal()
+  const directory = () => decode64(local.slug())
 
   const close = (dismiss: Dismiss) => {
     setStore("dismiss", dismiss)
@@ -120,7 +123,7 @@ export function ModelSelectorPopover(props: {
   const handleConnectProvider = () => {
     close("provider")
     void import("./dialog-select-provider").then((x) => {
-      dialog.show(() => <x.DialogSelectProvider />)
+      dialog.show(() => <x.DialogSelectProvider directory={directory} />)
     })
   }
   const language = useLanguage()
@@ -199,10 +202,12 @@ export function ModelSelectorPopover(props: {
 export const DialogSelectModel: Component<{ provider?: string; model?: ModelState }> = (props) => {
   const dialog = useDialog()
   const language = useLanguage()
+  const local = useLocal()
+  const directory = () => decode64(local.slug())
 
   const provider = () => {
     void import("./dialog-select-provider").then((x) => {
-      dialog.show(() => <x.DialogSelectProvider />)
+      dialog.show(() => <x.DialogSelectProvider directory={directory} />)
     })
   }
 

+ 5 - 5
packages/app/src/components/dialog-select-provider.tsx

@@ -1,4 +1,4 @@
-import { Component, Show } from "solid-js"
+import { type Accessor, Component, Show } from "solid-js"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { popularProviders, useProviders } from "@/hooks/use-providers"
 import { Dialog } from "@opencode-ai/ui/dialog"
@@ -11,9 +11,9 @@ import { DialogCustomProvider } from "./dialog-custom-provider"
 
 const CUSTOM_ID = "_custom"
 
-export const DialogSelectProvider: Component = () => {
+export const DialogSelectProvider: Component<{ directory?: Accessor<string | undefined> }> = (props) => {
   const dialog = useDialog()
-  const providers = useProviders()
+  const providers = useProviders(props.directory)
   const language = useLanguage()
 
   const popularGroup = () => language.t("dialog.provider.group.popular")
@@ -56,10 +56,10 @@ export const DialogSelectProvider: Component = () => {
         onSelect={(x) => {
           if (!x) return
           if (x.id === CUSTOM_ID) {
-            dialog.show(() => <DialogCustomProvider back="providers" />)
+            dialog.show(() => <DialogCustomProvider back="providers" directory={props.directory} />)
             return
           }
-          dialog.show(() => <DialogConnectProvider provider={x.id} />)
+          dialog.show(() => <DialogConnectProvider provider={x.id} directory={props.directory} />)
         }}
       >
         {(i) => (

+ 5 - 19
packages/app/src/components/prompt-input.tsx

@@ -67,6 +67,7 @@ import { PromptContextItems } from "./prompt-input/context-items"
 import { PromptImageAttachments } from "./prompt-input/image-attachments"
 import { PromptDragOverlay } from "./prompt-input/drag-overlay"
 import { promptPlaceholder } from "./prompt-input/placeholder"
+import { createPromptInputTransientState } from "./prompt-input/transient-state"
 import { showToast } from "@/utils/toast"
 import { ImagePreview } from "@opencode-ai/ui/image-preview"
 import { pathKey } from "@/utils/path-key"
@@ -346,25 +347,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
   )
 
-  const [store, setStore] = createStore<{
-    popover: "at" | "slash" | null
-    historyIndex: number
-    savedPrompt: PromptHistoryEntry | null
-    placeholder: number
-    draggingType: "image" | "@mention" | null
-    mode: "normal" | "shell"
-    applyingHistory: boolean
-    variantOpen: boolean
-  }>({
-    popover: null,
-    historyIndex: -1,
-    savedPrompt: null as PromptHistoryEntry | null,
-    placeholder: Math.floor(Math.random() * EXAMPLES.length),
-    draggingType: null,
-    mode: "normal",
-    applyingHistory: false,
-    variantOpen: false,
-  })
+  const [store, setStore] = createPromptInputTransientState(
+    () => prompt.capture(),
+    Math.floor(Math.random() * EXAMPLES.length),
+  )
   const [picker, setPicker] = createStore({
     projectOpen: false,
     projectSearch: "",

+ 66 - 37
packages/app/src/components/prompt-input/attachments.ts

@@ -25,6 +25,19 @@ function dataUrl(file: File, mime: string) {
   })
 }
 
+type PromptTarget = Pick<ReturnType<ReturnType<typeof usePrompt>["capture"]>, "current" | "cursor" | "set">
+type AttachmentTarget = { prompt: PromptTarget; cursor: number | undefined }
+
+type PromptAttachmentsCoreInput = {
+  capture: () => PromptTarget
+  editor: () => HTMLDivElement | undefined
+  focusEditor?: () => void
+  addPart?: (part: ContentPart) => boolean
+  warn?: () => void
+  readClipboardImage?: () => Promise<File | null>
+  getPathForFile?: (file: File) => string
+}
+
 type PromptAttachmentsInput = {
   prompt: ReturnType<typeof usePrompt>
   editor: () => HTMLDivElement | undefined
@@ -36,27 +49,22 @@ type PromptAttachmentsInput = {
   getPathForFile?: (file: File) => string
 }
 
-export function createPromptAttachments(input: PromptAttachmentsInput) {
-  const prompt = input.prompt
-  const language = useLanguage()
-
-  const warn = () => {
-    showToast({
-      title: language.t("prompt.toast.pasteUnsupported.title"),
-      description: language.t("prompt.toast.pasteUnsupported.description"),
-    })
+export function createPromptAttachmentsCore(input: PromptAttachmentsCoreInput) {
+  const capture = (): AttachmentTarget | undefined => {
+    const prompt = input.capture()
+    const editor = input.editor()
+    if (!editor) return
+    return { prompt, cursor: prompt.cursor() ?? getCursorPosition(editor) }
   }
 
-  const add = async (file: File, toast = true) => {
+  const add = async (file: File, toast = true, target = capture()) => {
+    if (!target) return false
     const mime = await attachmentMime(file)
     if (!mime) {
-      if (toast) warn()
+      if (toast) input.warn?.()
       return false
     }
 
-    const editor = input.editor()
-    if (!editor) return false
-
     const url = await dataUrl(file, mime)
     if (!url) return false
 
@@ -68,34 +76,42 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
       mime,
       dataUrl: url,
     }
-    const cursor = prompt.cursor() ?? getCursorPosition(editor)
-    prompt.set([...prompt.current(), attachment], cursor)
+    target.prompt.set([...target.prompt.current(), attachment], target.cursor)
     return true
   }
 
   const addAttachment = (file: File) => add(file)
 
-  const addAttachments = async (files: File[], toast = true) => {
+  const addAttachments = async (files: File[], toast = true, target = capture()) => {
     let found = false
 
     for (const file of files) {
-      const ok = await add(file, false)
+      const ok = await add(file, false, target)
       if (ok) found = true
     }
 
-    if (!found && files.length > 0 && toast) warn()
+    if (!found && files.length > 0 && toast) input.warn?.()
     return found
   }
 
+  const addClipboardAttachment = async (pending: Promise<File | null>, target = capture()) => {
+    const file = await pending
+    if (!file) return false
+    return add(file, true, target)
+  }
+
   const removeAttachment = (id: string) => {
-    const current = prompt.current()
+    const target = input.capture()
+    const current = target.current()
     const next = current.filter((part) => part.type !== "image" || part.id !== id)
-    prompt.set(next, prompt.cursor())
+    target.set(next, target.cursor())
   }
 
   const handlePaste = async (event: ClipboardEvent) => {
     const clipboardData = event.clipboardData
     if (!clipboardData) return
+    const target = capture()
+    if (!target) return
 
     event.preventDefault()
     event.stopPropagation()
@@ -107,7 +123,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
     })
 
     if (files.length > 0) {
-      await addAttachments(files)
+      await addAttachments(files, true, target)
       return
     }
 
@@ -115,11 +131,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
 
     // Desktop: Browser clipboard has no images and no text, try platform's native clipboard for images
     if (input.readClipboardImage && !plainText) {
-      const file = await input.readClipboardImage()
-      if (file) {
-        await addAttachment(file)
-        return
-      }
+      if (await addClipboardAttachment(input.readClipboardImage(), target)) return
     }
 
     if (!plainText) return
@@ -127,9 +139,9 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
     const text = normalizePaste(plainText)
 
     const put = () => {
-      if (input.addPart({ type: "text", content: text, start: 0, end: 0 })) return true
-      input.focusEditor()
-      return input.addPart({ type: "text", content: text, start: 0, end: 0 })
+      if (input.addPart?.({ type: "text", content: text, start: 0, end: 0 })) return true
+      input.focusEditor?.()
+      return input.addPart?.({ type: "text", content: text, start: 0, end: 0 }) ?? false
     }
 
     if (pasteMode(text) === "manual") {
@@ -143,6 +155,28 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
     put()
   }
 
+  return {
+    addAttachment,
+    addAttachments,
+    addClipboardAttachment,
+    removeAttachment,
+    handlePaste,
+  }
+}
+
+export function createPromptAttachments(input: PromptAttachmentsInput) {
+  const language = useLanguage()
+  const attachments = createPromptAttachmentsCore({
+    ...input,
+    capture: input.prompt.capture,
+    warn: () => {
+      showToast({
+        title: language.t("prompt.toast.pasteUnsupported.title"),
+        description: language.t("prompt.toast.pasteUnsupported.description"),
+      })
+    },
+  })
+
   const handleGlobalDragOver = (event: DragEvent) => {
     if (input.isDialogActive()) return
 
@@ -181,7 +215,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
     const dropped = event.dataTransfer?.files
     if (!dropped) return
 
-    await addAttachments(Array.from(dropped))
+    await attachments.addAttachments(Array.from(dropped))
   }
 
   onMount(() => {
@@ -190,10 +224,5 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
     makeEventListener(document, "drop", handleGlobalDrop)
   })
 
-  return {
-    addAttachment,
-    addAttachments,
-    removeAttachment,
-    handlePaste,
-  }
+  return attachments
 }

+ 31 - 0
packages/app/src/components/prompt-input/submission-state.ts

@@ -0,0 +1,31 @@
+import { type ContextItem, type Prompt, type usePrompt } from "@/context/prompt"
+
+type PromptTarget = ReturnType<ReturnType<typeof usePrompt>["capture"]>
+
+export function createPromptSubmissionState(input: {
+  target: PromptTarget
+  prompt: Prompt
+  context: (ContextItem & { key: string })[]
+}) {
+  let target = input.target
+  let cleared: Prompt | undefined
+
+  return {
+    prompt: input.prompt,
+    context: input.context,
+    target: () => target,
+    clear() {
+      target.reset()
+      cleared = target.current()
+    },
+    retarget(next: PromptTarget) {
+      input.context.forEach(next.context.add)
+      target = next
+    },
+    current: (value: PromptTarget) => target === value,
+    restore() {
+      if (cleared !== undefined && target.current() !== cleared) return
+      return { target, prompt: input.prompt, context: input.context }
+    },
+  }
+}

+ 1 - 0
packages/app/src/components/prompt-input/submit.test.ts

@@ -41,6 +41,7 @@ const prompt = {
     replaceComments: () => undefined,
     items: () => [],
   },
+  capture: () => prompt,
 }
 
 const clientFor = (directory: string) => {

+ 28 - 31
packages/app/src/components/prompt-input/submit.ts

@@ -4,7 +4,6 @@ import { base64Encode } from "@opencode-ai/core/util/encode"
 import { Binary } from "@opencode-ai/core/util/binary"
 import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
 import { batch, type Accessor } from "solid-js"
-import type { FileSelection } from "@/context/file"
 import { useServer } from "@/context/server"
 import { useTabs } from "@/context/tabs"
 import { useServerSync, type ServerSync } from "@/context/server-sync"
@@ -21,6 +20,7 @@ import { buildRequestParts } from "./build-request-parts"
 import { setCursorPosition } from "./editor-dom"
 import { formatServerError } from "@/utils/server-errors"
 import { ScopedKey } from "@/utils/server-scope"
+import { createPromptSubmissionState } from "./submission-state"
 
 type PendingPrompt = {
   abort: AbortController
@@ -194,15 +194,6 @@ type PromptSubmitInput = {
   onSubmit?: () => void
 }
 
-type CommentItem = {
-  path: string
-  selection?: FileSelection
-  comment?: string
-  commentID?: string
-  commentOrigin?: "review" | "file"
-  preview?: string
-}
-
 export function createPromptSubmit(input: PromptSubmitInput) {
   const navigate = useNavigate()
   const sdk = useSDK()
@@ -251,9 +242,12 @@ export function createPromptSubmit(input: PromptSubmitInput) {
       .catch(() => {})
   }
 
-  const restoreCommentItems = (items: CommentItem[]) => {
+  const restoreCommentItems = (
+    target: ReturnType<ReturnType<typeof usePrompt>["capture"]>,
+    items: (ContextItem & { key: string })[],
+  ) => {
     for (const item of items) {
-      prompt.context.add({
+      target.context.add({
         type: "file",
         path: item.path,
         selection: item.selection,
@@ -265,15 +259,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
     }
   }
 
-  const removeCommentItems = (items: { key: string }[]) => {
-    for (const item of items) {
-      prompt.context.remove(item.key)
-    }
-  }
-
-  const clearContext = () => {
-    for (const item of prompt.context.items()) {
-      prompt.context.remove(item.key)
+  const clearContext = (target: ReturnType<ReturnType<typeof usePrompt>["capture"]>) => {
+    for (const item of target.context.items()) {
+      target.context.remove(item.key)
     }
   }
 
@@ -295,7 +283,14 @@ export function createPromptSubmit(input: PromptSubmitInput) {
   const handleSubmit = async (event: Event) => {
     event.preventDefault()
 
-    const currentPrompt = prompt.current()
+    const target = prompt.capture()
+    const submission = createPromptSubmissionState({
+      target,
+      prompt: target.current(),
+      context: target.context.items().slice(),
+    })
+    const currentPrompt = submission.prompt
+    const context = submission.context
     const text = currentPrompt.map((part) => ("content" in part ? part.content : "")).join("")
     const images = input.imageAttachments().slice()
     const mode = input.mode()
@@ -387,6 +382,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
         const draftID = search.draftId
         if (draftID) tabs.promoteDraft(draftID, { server: server.key, sessionId: session.id })
         else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
+        submission.retarget(prompt.capture({ dir: base64Encode(sessionDirectory), id: session.id }))
       }
     }
     if (!session) {
@@ -402,7 +398,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
       providerID: currentModel.provider.id,
     }
     const agent = currentAgent.name
-    const context = prompt.context.items().slice()
     const draft: FollowupDraft = {
       sessionID: session.id,
       sessionDirectory,
@@ -414,13 +409,16 @@ export function createPromptSubmit(input: PromptSubmitInput) {
     }
 
     const clearInput = () => {
-      prompt.reset()
+      submission.clear()
       input.setMode("normal")
       input.setPopover(null)
     }
 
     const restoreInput = () => {
-      prompt.set(currentPrompt, input.promptLength(currentPrompt))
+      const restored = submission.restore()
+      if (!restored) return false
+      restored.target.set(restored.prompt, input.promptLength(restored.prompt))
+      if (!submission.current(prompt.capture())) return true
       input.setMode(mode)
       input.setPopover(null)
       requestAnimationFrame(() => {
@@ -430,11 +428,12 @@ export function createPromptSubmit(input: PromptSubmitInput) {
         setCursorPosition(editor, input.promptLength(currentPrompt))
         input.queueScroll()
       })
+      return true
     }
 
     if (!isNewSession && mode === "normal" && input.shouldQueue?.()) {
       input.onQueue?.(draft)
-      clearContext()
+      clearContext(submission.target())
       clearInput()
       return
     }
@@ -504,7 +503,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
       })
     }
 
-    removeCommentItems(commentItems)
+    for (const item of commentItems) submission.target().context.remove(item.key)
     clearInput()
 
     const waitForWorktree = async () => {
@@ -521,8 +520,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
           sync().set("session_status", session.id, { type: "idle" })
         }
         removeOptimisticMessage()
-        restoreCommentItems(commentItems)
-        restoreInput()
+        if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
       }
 
       pending.set(pendingKey(session.id), { abort: controller, cleanup })
@@ -584,8 +582,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
         description: errorMessage(err),
       })
       removeOptimisticMessage()
-      restoreCommentItems(commentItems)
-      restoreInput()
+      if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
     })
   }
 

+ 43 - 0
packages/app/src/components/prompt-input/transient-state.ts

@@ -0,0 +1,43 @@
+import { createComputed, on, type Accessor } from "solid-js"
+import { createStore, type SetStoreFunction } from "solid-js/store"
+import type { PromptHistoryEntry } from "./history"
+
+export type PromptInputTransientState = {
+  popover: "at" | "slash" | null
+  historyIndex: number
+  savedPrompt: PromptHistoryEntry | null
+  placeholder: number
+  draggingType: "image" | "@mention" | null
+  mode: "normal" | "shell"
+  applyingHistory: boolean
+  variantOpen: boolean
+}
+
+function resetPromptInputTransientState(setStore: SetStoreFunction<PromptInputTransientState>) {
+  setStore({
+    popover: null,
+    historyIndex: -1,
+    savedPrompt: null,
+    draggingType: null,
+    mode: "normal",
+    applyingHistory: false,
+    variantOpen: false,
+  })
+}
+
+export function createPromptInputTransientState(identity: Accessor<unknown>, placeholder: number) {
+  const [store, setStore] = createStore<PromptInputTransientState>({
+    popover: null,
+    historyIndex: -1,
+    savedPrompt: null,
+    placeholder,
+    draggingType: null,
+    mode: "normal",
+    applyingHistory: false,
+    variantOpen: false,
+  })
+
+  createComputed(on(identity, () => resetPromptInputTransientState(setStore), { defer: true }))
+
+  return [store, setStore] as const
+}

+ 3 - 1
packages/app/src/components/session-context-usage.tsx

@@ -8,6 +8,7 @@ import { useLayout } from "@/context/layout"
 import { useSync } from "@/context/sync"
 import { useLanguage } from "@/context/language"
 import { useProviders } from "@/hooks/use-providers"
+import { useSDK } from "@/context/sdk"
 import { getSessionContextMetrics } from "@/components/session/session-context-metrics"
 import { useSessionLayout } from "@/pages/session/session-layout"
 import { createSessionTabs } from "@/pages/session/helpers"
@@ -33,7 +34,8 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
   const file = useFile()
   const layout = useLayout()
   const language = useLanguage()
-  const providers = useProviders()
+  const sdk = useSDK()
+  const providers = useProviders(() => sdk().directory)
   const { params, tabs, view } = useSessionLayout()
 
   const variant = createMemo(() => props.variant ?? "button")

+ 3 - 1
packages/app/src/components/session/session-context-tab.tsx

@@ -13,6 +13,7 @@ import { ScrollView } from "@opencode-ai/ui/scroll-view"
 import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
 import { useLanguage } from "@/context/language"
 import { useProviders } from "@/hooks/use-providers"
+import { useSDK } from "@/context/sdk"
 import { useSessionLayout } from "@/pages/session/session-layout"
 import { getSessionContextMetrics } from "./session-context-metrics"
 import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
@@ -93,7 +94,8 @@ const emptyUserMessages: UserMessage[] = []
 export function SessionContextTab() {
   const sync = useSync()
   const language = useLanguage()
-  const providers = useProviders()
+  const sdk = useSDK()
+  const providers = useProviders(() => sdk().directory)
   const { params, view } = useSessionLayout()
 
   const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))

+ 5 - 1
packages/app/src/context/directory-sync.ts

@@ -1,5 +1,5 @@
 import { Binary } from "@opencode-ai/core/util/binary"
-import type { Message, Part } from "@opencode-ai/sdk/v2/client"
+import type { Message, Part, Session } from "@opencode-ai/sdk/v2/client"
 import { createMemo } from "solid-js"
 import { produce, reconcile, type SetStoreFunction } from "solid-js/store"
 import type { createServerSdkContext } from "./server-sdk"
@@ -73,6 +73,10 @@ export const createDirSyncContext = (
       if (match.found) return serverSync.data.project[match.index]
     },
     session: {
+      remember(session: Session) {
+        serverSync.session.remember(session)
+        index(session.id)
+      },
       get(sessionID: string) {
         const session = serverSync.session.get(sessionID)
         if (session?.directory === directory) return session

+ 1 - 1
packages/app/src/context/local.tsx

@@ -60,7 +60,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
     const sdk = useSDK()
     const sync = useSync()
     const serverSDK = useServerSDK()
-    const providers = useProviders()
+    const providers = useProviders(() => sdk().directory)
     const models = useModels()
 
     const id = createMemo(() => params.id || undefined)

+ 3 - 3
packages/app/src/context/models.tsx

@@ -1,4 +1,4 @@
-import { createMemo } from "solid-js"
+import { type Accessor, createMemo } from "solid-js"
 import { createStore } from "solid-js/store"
 import { DateTime } from "luxon"
 import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda"
@@ -25,8 +25,8 @@ function modelKey(model: ModelKey) {
 export const { use: useModels, provider: ModelsProvider } = createSimpleContext({
   name: "Models",
   gate: false,
-  init: () => {
-    const providers = useProviders()
+  init: (props: { directory?: Accessor<string | undefined> } = {}) => {
+    const providers = useProviders(props.directory)
 
     const [store, setStore, _, ready] = persisted(
       Persist.global("model", ["model.v1"]),

+ 19 - 19
packages/app/src/context/prompt.tsx

@@ -9,7 +9,7 @@ import { useServerSDK } from "./server-sdk"
 import type { ServerScope } from "@/utils/server-scope"
 import { useSDK } from "./sdk"
 import { useTabs, type Tab } from "./tabs"
-import { useServer } from "./server"
+import { ServerConnection, useServer } from "./server"
 import { requireServerKey } from "@/utils/session-route"
 import { useSettings } from "./settings"
 
@@ -169,6 +169,15 @@ type PromptStore = {
 
 type Scope = { draftID: string } | { dir: string; id?: string }
 
+export function selectPromptTab(tabs: Tab[], scope: Scope, server: ServerConnection.Key) {
+  if ("draftID" in scope) return tabs.find((tab) => tab.type === "draft" && tab.draftID === scope.draftID)
+  if (!scope.id) return
+  return (
+    tabs.find((tab) => tab.type === "session" && tab.server === server && tab.sessionId === scope.id) ??
+    ({ type: "session", server, sessionId: scope.id } satisfies Tab)
+  )
+}
+
 function scopeKey(scope: Scope) {
   if ("draftID" in scope) return `draft:${scope.draftID}`
   return `${scope.dir}:${scope.id ?? WORKSPACE_KEY}`
@@ -213,7 +222,7 @@ function promptStore(): PromptStore {
 function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction<PromptStore>) {
   const actions = createPromptActions(setStore)
 
-  return {
+  const value = {
     current: () => store.prompt,
     cursor: createMemo(() => store.cursor),
     dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT),
@@ -250,7 +259,9 @@ function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction<P
     },
     set: actions.set,
     reset: actions.reset,
+    capture: () => value,
   }
+  return value
 }
 
 export function createPromptState() {
@@ -301,21 +312,11 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
     }
 
     const owner = getOwner()
-    const tab = createMemo<Tab | undefined>(() => {
-      if (!settings.general.newLayoutDesigns()) return
-      if (search.draftId) {
-        return tabs.store.find((item) => item.type === "draft" && item.draftID === search.draftId)
-      }
-      if (!params.id) return
-      const serverKey = params.serverKey ? requireServerKey(params.serverKey) : server.key
-      return (
-        tabs.store.find(
-          (item) => item.type === "session" && item.server === serverKey && item.sessionId === params.id,
-        ) ?? { type: "session", server: serverKey, sessionId: params.id }
-      )
-    })
+    const serverKey = () => (params.serverKey ? requireServerKey(params.serverKey) : server.key)
+    const scope = () =>
+      search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }
     const load = (scope: Scope) => {
-      const current = tab()
+      const current = settings.general.newLayoutDesigns() ? selectPromptTab(tabs.store, scope, serverKey()) : undefined
       if (current) {
         return createTabPromptState(tabs, current, serverSDK().scope, scope)
       }
@@ -341,14 +342,13 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
       return entry.value
     }
 
-    const session = createMemo(() =>
-      load(search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }),
-    )
+    const session = createMemo(() => load(scope()))
     const pick = (scope?: Scope) => (scope ? load(scope) : session())
     const ready = createPromptReady(session)
 
     return {
       ready,
+      capture: (scope?: Scope) => pick(scope).capture(),
       current: () => session().current(),
       cursor: () => session().cursor(),
       dirty: () => session().dirty(),

+ 59 - 0
packages/app/src/hooks/provider-catalog.test.ts

@@ -0,0 +1,59 @@
+import { expect, test } from "bun:test"
+import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
+import { selectProviderCatalog } from "./provider-catalog"
+
+const catalog = (id: string): NormalizedProviderListResponse => ({
+  all: new Map([[id, { id, name: id, source: "api", env: [], options: {}, models: {} }]]),
+  connected: [id],
+  default: { [id]: `${id}-model` },
+})
+
+test("selects the ready catalog for an explicit directory", () => {
+  const directory = catalog("directory")
+
+  expect(
+    selectProviderCatalog({
+      explicit: true,
+      directory: "/repo",
+      catalog: { ready: true, providers: directory },
+    }),
+  ).toBe(directory)
+})
+
+test("returns an empty catalog while an explicit directory is unresolved", () => {
+  expect(selectProviderCatalog({ explicit: true })).toEqual({ all: new Map(), connected: [], default: {} })
+  expect(
+    selectProviderCatalog({
+      explicit: true,
+      directory: "/repo",
+      catalog: { ready: false, providers: catalog("directory") },
+    }),
+  ).toEqual({ all: new Map(), connected: [], default: {} })
+})
+
+test("uses the route catalog when it is ready", () => {
+  const directory = catalog("directory")
+
+  expect(
+    selectProviderCatalog({
+      explicit: false,
+      directory: "/repo",
+      catalog: { ready: true, providers: directory },
+      global: catalog("global"),
+    }),
+  ).toBe(directory)
+})
+
+test("falls back to the global catalog for route consumers", () => {
+  const global = catalog("global")
+
+  expect(selectProviderCatalog({ explicit: false, global })).toBe(global)
+  expect(
+    selectProviderCatalog({
+      explicit: false,
+      directory: "/repo",
+      catalog: { ready: false, providers: catalog("directory") },
+      global,
+    }),
+  ).toBe(global)
+})

+ 27 - 0
packages/app/src/hooks/provider-catalog.ts

@@ -0,0 +1,27 @@
+import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
+
+const emptyProviderCatalog: NormalizedProviderListResponse = { all: new Map(), connected: [], default: {} }
+
+type DirectoryCatalog = {
+  ready: boolean
+  providers: NormalizedProviderListResponse
+}
+
+type ProviderCatalogInput =
+  | {
+      explicit: true
+      directory?: string
+      catalog?: DirectoryCatalog
+    }
+  | {
+      explicit: false
+      directory?: string
+      catalog?: DirectoryCatalog
+      global: NormalizedProviderListResponse
+    }
+
+export function selectProviderCatalog(input: ProviderCatalogInput) {
+  if (input.directory && input.catalog?.ready) return input.catalog.providers
+  if (input.explicit) return emptyProviderCatalog
+  return input.global
+}

+ 18 - 8
packages/app/src/hooks/use-providers.ts

@@ -2,7 +2,8 @@ import { useServerSync } from "@/context/server-sync"
 import { decode64 } from "@/utils/base64"
 import { useParams } from "@solidjs/router"
 import { Iterable, pipe } from "effect"
-import { createMemo } from "solid-js"
+import type { Accessor } from "solid-js"
+import { selectProviderCatalog } from "./provider-catalog"
 
 export const popularProviders = [
   "opencode",
@@ -16,16 +17,25 @@ export const popularProviders = [
 ]
 const popularProviderSet = new Set(popularProviders)
 
-export function useProviders() {
+export function useProviders(directory?: Accessor<string | undefined>) {
   const serverSync = useServerSync()
   const params = useParams()
-  const dir = createMemo(() => decode64(params.dir) ?? "")
+  const dir = () => (directory ? directory() : decode64(params.dir))
   const providers = () => {
-    if (dir()) {
-      const [projectStore] = serverSync().child(dir())
-      if (projectStore.provider_ready) return projectStore.provider
-    }
-    return serverSync().data.provider
+    const value = dir()
+    const projectStore = value ? serverSync().child(value)[0] : undefined
+    if (directory)
+      return selectProviderCatalog({
+        explicit: true,
+        directory: value,
+        catalog: projectStore && { ready: projectStore.provider_ready, providers: projectStore.provider },
+      })
+    return selectProviderCatalog({
+      explicit: false,
+      directory: value,
+      catalog: projectStore && { ready: projectStore.provider_ready, providers: projectStore.provider },
+      global: serverSync().data.provider,
+    })
   }
   return {
     all: () => providers().all,

+ 12 - 8
packages/app/src/pages/directory-layout.tsx

@@ -58,14 +58,18 @@ export function DirectoryDataProvider(
   })
 
   return (
-    <DataProvider
-      data={sync().data}
-      directory={directory()}
-      onNavigateToSession={(sessionID: string) => navigate(href(sessionID))}
-      onSessionHref={href}
-    >
-      <LocalProvider>{props.children}</LocalProvider>
-    </DataProvider>
+    <Show when={directory()} keyed>
+      {(directory) => (
+        <DataProvider
+          data={sync().data}
+          directory={directory}
+          onNavigateToSession={(sessionID: string) => navigate(href(sessionID))}
+          onSessionHref={href}
+        >
+          <LocalProvider>{props.children}</LocalProvider>
+        </DataProvider>
+      )}
+    </Show>
   )
 }
 

+ 99 - 79
packages/app/src/pages/session.tsx

@@ -67,6 +67,7 @@ import { Persist, persisted } from "@/utils/persist"
 import { extractPromptFromParts } from "@/utils/prompt"
 import { formatServerError } from "@/utils/server-errors"
 import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs"
+import { createSessionOwnership } from "./session/session-ownership"
 
 type FollowupItem = FollowupDraft & { id: string }
 type FollowupEdit = Pick<FollowupItem, "id" | "prompt" | "context">
@@ -75,6 +76,35 @@ const emptyFollowups: FollowupItem[] = []
 type ChangeMode = "git" | "branch" | "turn"
 type VcsMode = "git" | "branch"
 
+const sessionViewState = () => ({
+  messageId: undefined as string | undefined,
+  mobileTab: "session" as "session" | "changes",
+  changes: "git" as ChangeMode,
+})
+
+async function runPromptRollbackMutation<T, R>(input: {
+  capturePrompt: () => { current: () => T[]; set: (value: T[]) => void; reset: () => void }
+  optimistic: (prompt: { set: (value: T[]) => void; reset: () => void }) => void
+  request: () => Promise<R>
+  complete: (result: R) => void
+  rollback: () => void
+  fail: (error: unknown) => void
+}) {
+  const prompt = input.capturePrompt()
+  const previous = prompt.current().slice()
+  batch(() => input.optimistic(prompt))
+  await input
+    .request()
+    .then(input.complete)
+    .catch((error) => {
+      batch(() => {
+        input.rollback()
+        prompt.set(previous)
+      })
+      input.fail(error)
+    })
+}
+
 export default function Page() {
   const serverSync = useServerSync()
   const layout = useLayout()
@@ -94,6 +124,7 @@ export default function Page() {
   const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
   const location = useLocation()
   const { params, sessionKey, workspaceKey, tabs, view } = useSessionLayout()
+  const sessionOwnership = createSessionOwnership(sessionKey)
   const newSessionDesign = createMemo(() => settings.general.newLayoutDesigns())
 
   createEffect(() => {
@@ -256,9 +287,7 @@ export default function Page() {
   )
 
   const [store, setStore] = createStore({
-    messageId: undefined as string | undefined,
-    mobileTab: "session" as "session" | "changes",
-    changes: "git" as ChangeMode,
+    ...sessionViewState(),
     newSessionWorktree: "main",
     deferRender: false,
   })
@@ -282,8 +311,9 @@ export default function Page() {
     const key = sessionKey()
     if (key !== prev) {
       setStore("deferRender", true)
+      const owner = sessionOwnership.capture()
       requestAnimationFrame(() => {
-        setTimeout(() => setStore("deferRender", false), 0)
+        setTimeout(() => owner.run(() => setStore("deferRender", false)), 0)
       })
     }
     return key
@@ -549,8 +579,7 @@ export default function Page() {
     on(
       sessionKey,
       () => {
-        setStore("messageId", undefined)
-        setStore("changes", "git")
+        setStore(sessionViewState())
         setUi("pendingMessage", undefined)
       },
       { defer: true },
@@ -1127,27 +1156,37 @@ export default function Page() {
 
   let captureHistoryAnchor = () => {}
   let restoreHistoryAnchor = (_done: boolean) => {}
-  let historyRequest = false
+  const historyRequests = new Set<string>()
   let historyContinuationFrame: number | undefined
   const loadOlder = async () => {
-    if (historyRequest || historyLoading()) return
-    historyRequest = true
+    const owner = sessionOwnership.capture()
+    if (historyLoading() || historyRequests.has(owner.key)) return
+    historyRequests.add(owner.key)
     const before = timeline.messages().length
     try {
-      await timeline.history.loadOlder({ before: () => captureHistoryAnchor(), after: restoreHistoryAnchor })
+      await timeline.history.loadOlder({
+        before: () => owner.run(captureHistoryAnchor),
+        after: (done) => owner.run(() => restoreHistoryAnchor(done)),
+      })
     } finally {
-      historyRequest = false
+      historyRequests.delete(owner.key)
     }
-    if (timeline.messages().length <= before) return
+    if (!owner.current() || timeline.messages().length <= before) return
     if (!autoScroll.userScrolled() || !scroller || scroller.scrollTop >= 200 || !historyMore()) return
     if (historyContinuationFrame !== undefined) cancelAnimationFrame(historyContinuationFrame)
     historyContinuationFrame = requestAnimationFrame(() => {
       historyContinuationFrame = undefined
-      onHistoryScroll()
+      owner.run(onHistoryScroll)
     })
   }
   const onHistoryScroll = () => {
-    if (historyRequest || historyLoading() || !autoScroll.userScrolled() || !scroller || scroller.scrollTop >= 200)
+    if (
+      historyRequests.has(sessionOwnership.key()) ||
+      historyLoading() ||
+      !autoScroll.userScrolled() ||
+      !scroller ||
+      scroller.scrollTop >= 200
+    )
       return
     void loadOlder()
   }
@@ -1218,23 +1257,13 @@ export default function Page() {
     })
   }
 
-  const merge = (next: NonNullable<ReturnType<typeof info>>) =>
-    sync().set("session", (list) => {
-      const idx = list.findIndex((item) => item.id === next.id)
-      if (idx < 0) return list
-      const out = list.slice()
-      out[idx] = next
-      return out
-    })
+  const merge = (next: NonNullable<ReturnType<typeof info>>, target = sync()) => target.session.remember(next)
 
-  const roll = (sessionID: string, next: NonNullable<ReturnType<typeof info>>["revert"]) =>
-    sync().set("session", (list) => {
-      const idx = list.findIndex((item) => item.id === sessionID)
-      if (idx < 0) return list
-      const out = list.slice()
-      out[idx] = { ...out[idx], revert: next }
-      return out
-    })
+  const roll = (sessionID: string, next: NonNullable<ReturnType<typeof info>>["revert"], target = sync()) => {
+    const session = target.session.get(sessionID)
+    if (!session) return
+    target.session.remember({ ...session, revert: next })
+  }
 
   const busy = (sessionID: string) => sync().data.session_working(sessionID)
 
@@ -1252,6 +1281,7 @@ export default function Page() {
 
   const followupMutation = useMutation(() => ({
     mutationFn: async (input: { sessionID: string; id: string; manual?: boolean }) => {
+      const owner = sessionOwnership.capture()
       const item = (followup.items[input.sessionID] ?? []).find((entry) => entry.id === input.id)
       if (!item) return
 
@@ -1272,7 +1302,7 @@ export default function Page() {
       if (!ok) return
 
       setFollowup("items", input.sessionID, (items) => (items ?? []).filter((entry) => entry.id !== input.id))
-      if (input.manual) resumeScroll()
+      if (input.manual) owner.run(resumeScroll)
     },
   }))
 
@@ -1361,25 +1391,23 @@ export default function Page() {
 
   const revertMutation = useMutation(() => ({
     mutationFn: async (input: { sessionID: string; messageID: string }) => {
-      const prev = prompt.current().slice()
-      const last = info()?.revert
+      const client = sdk().client
+      const target = sync()
+      const last = target.session.get(input.sessionID)?.revert
       const value = draft(input.messageID)
-      batch(() => {
-        roll(input.sessionID, { messageID: input.messageID })
-        prompt.set(value)
+      await runPromptRollbackMutation({
+        capturePrompt: prompt.capture,
+        optimistic: (prompt) => {
+          roll(input.sessionID, { messageID: input.messageID }, target)
+          prompt.set(value)
+        },
+        request: () => halt(input.sessionID).then(() => client.session.revert(input)),
+        complete: (result) => {
+          if (result.data) merge(result.data, target)
+        },
+        rollback: () => roll(input.sessionID, last, target),
+        fail,
       })
-      await halt(input.sessionID)
-        .then(() => sdk().client.session.revert(input))
-        .then((result) => {
-          if (result.data) merge(result.data)
-        })
-        .catch((err) => {
-          batch(() => {
-            roll(input.sessionID, last)
-            prompt.set(prev)
-          })
-          fail(err)
-        })
     },
   }))
 
@@ -1388,39 +1416,31 @@ export default function Page() {
       const sessionID = params.id
       if (!sessionID) return
 
+      const client = sdk().client
+      const target = sync()
       const next = userMessages().find((item) => item.id > id)
-      const prev = prompt.current().slice()
-      const last = info()?.revert
-
-      batch(() => {
-        roll(sessionID, next ? { messageID: next.id } : undefined)
-        if (next) {
-          prompt.set(draft(next.id))
-          return
-        }
-        prompt.reset()
+      const last = target.session.get(sessionID)?.revert
+
+      await runPromptRollbackMutation({
+        capturePrompt: prompt.capture,
+        optimistic: (promptSession) => {
+          roll(sessionID, next ? { messageID: next.id } : undefined, target)
+          if (next) {
+            promptSession.set(draft(next.id))
+            return
+          }
+          promptSession.reset()
+        },
+        request: () =>
+          !next
+            ? halt(sessionID).then(() => client.session.unrevert({ sessionID }))
+            : halt(sessionID).then(() => client.session.revert({ sessionID, messageID: next.id })),
+        complete: (result) => {
+          if (result.data) merge(result.data, target)
+        },
+        rollback: () => roll(sessionID, last, target),
+        fail,
       })
-
-      const task = !next
-        ? halt(sessionID).then(() => sdk().client.session.unrevert({ sessionID }))
-        : halt(sessionID).then(() =>
-            sdk().client.session.revert({
-              sessionID,
-              messageID: next.id,
-            }),
-          )
-
-      await task
-        .then((result) => {
-          if (result.data) merge(result.data)
-        })
-        .catch((err) => {
-          batch(() => {
-            roll(sessionID, last)
-            prompt.set(prev)
-          })
-          fail(err)
-        })
     },
   }))
 

+ 1 - 1
packages/app/src/pages/session/composer/session-composer-region.tsx

@@ -70,7 +70,7 @@ export function SessionComposerRegion(props: {
   const sdk = useSDK()
   const queryOptions = useQueryOptions()
   const local = useLocal()
-  const providers = useProviders()
+  const providers = useProviders(() => sdk().directory)
   const settings = useSettings()
   const server = useServer()
   const tabs = useTabs()

+ 37 - 0
packages/app/src/pages/session/session-ownership.ts

@@ -0,0 +1,37 @@
+import { createComputed, onCleanup } from "solid-js"
+
+export function createSessionOwnership(sessionKey: () => string) {
+  let current = sessionKey()
+  let generation = 0
+  const transition = () => {
+    const next = sessionKey()
+    if (next === current) return
+    current = next
+    generation++
+  }
+  createComputed(transition)
+  onCleanup(() => generation++)
+
+  return {
+    key: () => {
+      transition()
+      return `${generation}:${current}`
+    },
+    capture() {
+      transition()
+      const captured = generation
+      return {
+        key: `${captured}:${current}`,
+        current: () => {
+          transition()
+          return generation === captured
+        },
+        run<T>(action: () => T) {
+          transition()
+          if (generation !== captured) return
+          return action()
+        },
+      }
+    },
+  }
+}

+ 72 - 36
packages/app/src/pages/session/use-session-commands.tsx

@@ -20,6 +20,7 @@ import { UserMessage } from "@opencode-ai/sdk/v2"
 import { useSessionLayout } from "@/pages/session/session-layout"
 import { useTabs } from "@/context/tabs"
 import { requireServerKey } from "@/utils/session-route"
+import { createSessionOwnership } from "./session-ownership"
 
 export type SessionCommandContext = {
   navigateMessageByOffset: (offset: number) => void
@@ -50,7 +51,24 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
   const sessionTabs = useTabs()
   const layout = useLayout()
   const navigate = useNavigate()
-  const { params, tabs, view } = useSessionLayout()
+  const { params, sessionKey, tabs, view } = useSessionLayout()
+  const sessionOwnership = createSessionOwnership(sessionKey)
+  const openDialog = async <T,>(load: () => Promise<T>, show: (value: T) => void) => {
+    const owner = sessionOwnership.capture()
+    const value = await load()
+    owner.run(() => show(value))
+  }
+  const runCommand = async <T,>(input: {
+    owner: ReturnType<ReturnType<typeof createSessionOwnership>["capture"]>
+    prompt: T
+    request: () => Promise<unknown>
+    updatePrompt: (prompt: T) => void
+    updateViewport: () => void
+  }) => {
+    await input.request()
+    input.updatePrompt(input.prompt)
+    input.owner.run(input.updateViewport)
+  }
 
   const info = () => {
     const id = params.id
@@ -217,9 +235,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
   }
 
   const openFile = () => {
-    void import("@/components/dialog-select-file").then((x) => {
-      dialog.show(() => <x.DialogSelectFile onOpenFile={showAllFiles} />)
-    })
+    void openDialog(
+      () => import("@/components/dialog-select-file"),
+      (x) => dialog.show(() => <x.DialogSelectFile onOpenFile={showAllFiles} />),
+    )
   }
 
   const closeTab = () => {
@@ -253,15 +272,17 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
   }
 
   const chooseModel = () => {
-    void import("@/components/dialog-select-model").then((x) => {
-      dialog.show(() => <x.DialogSelectModel model={local.model} />)
-    })
+    void openDialog(
+      () => import("@/components/dialog-select-model"),
+      (x) => dialog.show(() => <x.DialogSelectModel model={local.model} />),
+    )
   }
 
   const chooseMcp = () => {
-    void import("@/components/dialog-select-mcp").then((x) => {
-      dialog.show(() => <x.DialogSelectMcp />)
-    })
+    void openDialog(
+      () => import("@/components/dialog-select-mcp"),
+      (x) => dialog.show(() => <x.DialogSelectMcp />),
+    )
   }
 
   const toggleAutoAccept = () => {
@@ -285,47 +306,61 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
   const undo = async () => {
     const sessionID = params.id
     if (!sessionID) return
-
-    if (sync().data.session_working(params.id ?? "")) {
-      await sdk()
-        .client.session.abort({ sessionID })
-        .catch(() => {})
-    }
-
+    const owner = sessionOwnership.capture()
+    const client = sdk().client
+    const directory = sdk().directory
+    const promptSession = prompt.capture()
     const revert = info()?.revert?.messageID
-    const message = findLast(userMessages(), (x) => !revert || x.id < revert)
+    const messages = userMessages()
+    const message = findLast(messages, (x) => !revert || x.id < revert)
     if (!message) return
-
-    await sdk().client.session.revert({ sessionID, messageID: message.id })
     const parts = sync().data.part[message.id]
-    if (parts) {
-      const restored = extractPromptFromParts(parts, { directory: sdk().directory })
-      prompt.set(restored)
+
+    if (sync().data.session_working(sessionID)) {
+      await client.session.abort({ sessionID }).catch(() => {})
     }
 
-    const prev = findLast(userMessages(), (x) => x.id < message.id)
-    setActiveMessage(prev)
+    await runCommand({
+      owner,
+      prompt: promptSession,
+      request: () => client.session.revert({ sessionID, messageID: message.id }),
+      updatePrompt: (promptSession) => {
+        if (parts) promptSession.set(extractPromptFromParts(parts, { directory }))
+      },
+      updateViewport: () => setActiveMessage(findLast(messages, (x) => x.id < message.id)),
+    })
   }
 
   const redo = async () => {
     const sessionID = params.id
     if (!sessionID) return
+    const owner = sessionOwnership.capture()
+    const client = sdk().client
+    const messages = userMessages()
+    const promptSession = prompt.capture()
 
     const revertMessageID = info()?.revert?.messageID
     if (!revertMessageID) return
 
-    const next = userMessages().find((x) => x.id > revertMessageID)
+    const next = messages.find((x) => x.id > revertMessageID)
     if (!next) {
-      await sdk().client.session.unrevert({ sessionID })
-      prompt.reset()
-      const last = findLast(userMessages(), (x) => x.id >= revertMessageID)
-      setActiveMessage(last)
+      await runCommand({
+        owner,
+        prompt: promptSession,
+        request: () => client.session.unrevert({ sessionID }),
+        updatePrompt: (promptSession) => promptSession.reset(),
+        updateViewport: () => setActiveMessage(findLast(messages, (x) => x.id >= revertMessageID)),
+      })
       return
     }
 
-    await sdk().client.session.revert({ sessionID, messageID: next.id })
-    const prev = findLast(userMessages(), (x) => x.id < next.id)
-    setActiveMessage(prev)
+    await runCommand({
+      owner,
+      prompt: promptSession,
+      request: () => client.session.revert({ sessionID, messageID: next.id }),
+      updatePrompt: () => undefined,
+      updateViewport: () => setActiveMessage(findLast(messages, (x) => x.id < next.id)),
+    })
   }
 
   const compact = async () => {
@@ -349,9 +384,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
   }
 
   const fork = () => {
-    void import("@/components/dialog-fork").then((x) => {
-      dialog.show(() => <x.DialogFork />)
-    })
+    void openDialog(
+      () => import("@/components/dialog-fork"),
+      (x) => dialog.show(() => <x.DialogFork />),
+    )
   }
 
   const shareCmds = () => {

+ 7 - 1
packages/app/src/utils/session-route.test.ts

@@ -1,6 +1,6 @@
 import { describe, expect, test } from "bun:test"
 import { ServerConnection } from "@/context/server"
-import { legacySessionHref, requireServerKey, rootSession, sessionHref } from "./session-route"
+import { legacySessionHref, requireServerKey, rootSession, selectSessionLineage, sessionHref } from "./session-route"
 
 describe("session routes", () => {
   test("builds and decodes a server-keyed session route", () => {
@@ -45,4 +45,10 @@ describe("session routes", () => {
 
     expect(rootSession(sessions.child, async (id) => sessions[id]!)).rejects.toThrow("Session parent cycle: child")
   })
+
+  test("ignores a resolved lineage retained from the previous route", () => {
+    const previous = { session: { id: "A" }, root: { id: "A" } }
+
+    expect(selectSessionLineage("B", undefined, previous)).toBeUndefined()
+  })
 })

+ 9 - 0
packages/app/src/utils/session-route.ts

@@ -18,6 +18,15 @@ export function requireServerKey(segment: string | undefined) {
 
 type SessionParent = { id: string; parentID?: string }
 
+export function selectSessionLineage<T extends { session: { id: string } }>(
+  sessionID: string,
+  cached: T | undefined,
+  resolved: T | undefined,
+) {
+  if (cached?.session.id === sessionID) return cached
+  if (resolved?.session.id === sessionID) return resolved
+}
+
 export async function rootSession<T extends SessionParent>(session: T, get: (sessionID: string) => Promise<T>) {
   const seen = new Set([session.id])
   let current = session

+ 90 - 0
packages/app/test-browser/prompt-attachments.test.ts

@@ -0,0 +1,90 @@
+import { describe, expect, test } from "bun:test"
+import { createRoot } from "solid-js"
+import { createPromptAttachmentsCore } from "@/components/prompt-input/attachments"
+import { createPromptState } from "@/context/prompt"
+
+describe("prompt attachment session ownership", () => {
+  test("adds an asynchronously read image to the session where the read started", async () => {
+    await createRoot(async (dispose) => {
+      const sessions = { A: createPromptState(), B: createPromptState() }
+      let active: "A" | "B" = "A"
+      const attachments = createPromptAttachmentsCore({
+        capture: () => sessions[active].capture(),
+        editor: () => document.createElement("div"),
+      })
+      const pending = attachments.addAttachment(new File([new Uint8Array(1024 * 1024)], "a.png", { type: "image/png" }))
+
+      active = "B"
+      await pending
+
+      expect(images(sessions.A)).toHaveLength(1)
+      expect(images(sessions.B)).toHaveLength(0)
+      dispose()
+    })
+  })
+
+  test("finishes the captured attachment after the active editor is removed", async () => {
+    await createRoot(async (dispose) => {
+      const prompt = createPromptState()
+      let editor: HTMLDivElement | undefined = document.createElement("div")
+      const attachments = createPromptAttachmentsCore({
+        capture: prompt.capture,
+        editor: () => editor,
+      })
+      const pending = attachments.addAttachment(new File([new Uint8Array(1024 * 1024)], "a.png", { type: "image/png" }))
+
+      editor = undefined
+      await pending
+
+      expect(images(prompt)).toHaveLength(1)
+      dispose()
+    })
+  })
+
+  test("keeps every file in a batch on the session where the batch started", async () => {
+    await createRoot(async (dispose) => {
+      const sessions = { A: createPromptState(), B: createPromptState() }
+      let active: "A" | "B" = "A"
+      const attachments = createPromptAttachmentsCore({
+        capture: () => sessions[active].capture(),
+        editor: () => document.createElement("div"),
+      })
+      const pending = attachments.addAttachments([
+        new File([new Uint8Array(1024 * 1024)], "first.png", { type: "image/png" }),
+        new File([new Uint8Array(1024 * 1024)], "second.png", { type: "image/png" }),
+      ])
+
+      active = "B"
+      await pending
+
+      expect(images(sessions.A)).toHaveLength(2)
+      expect(images(sessions.B)).toHaveLength(0)
+      dispose()
+    })
+  })
+
+  test("keeps a delayed native clipboard image on the session where paste started", async () => {
+    await createRoot(async (dispose) => {
+      const sessions = { A: createPromptState(), B: createPromptState() }
+      const read = Promise.withResolvers<File | null>()
+      let active: "A" | "B" = "A"
+      const attachments = createPromptAttachmentsCore({
+        capture: () => sessions[active].capture(),
+        editor: () => document.createElement("div"),
+      })
+      const pending = attachments.addClipboardAttachment(read.promise)
+
+      active = "B"
+      read.resolve(new File([new Uint8Array(1024 * 1024)], "clipboard.png", { type: "image/png" }))
+      await pending
+
+      expect(images(sessions.A)).toHaveLength(1)
+      expect(images(sessions.B)).toHaveLength(0)
+      dispose()
+    })
+  })
+})
+
+function images(prompt: ReturnType<typeof createPromptState>) {
+  return prompt.current().filter((part) => part.type === "image")
+}

+ 14 - 0
packages/app/test-browser/prompt-scope.test.ts

@@ -0,0 +1,14 @@
+import { expect, test } from "bun:test"
+import { ServerConnection } from "@/context/server"
+import { selectPromptTab } from "@/context/prompt"
+import type { Tab } from "@/context/tabs"
+
+test("selects the explicitly scoped session tab instead of the active tab", () => {
+  const server = ServerConnection.Key.make("local")
+  const tabs: Tab[] = [
+    { type: "session", server, sessionId: "A" },
+    { type: "session", server, sessionId: "B" },
+  ]
+
+  expect(selectPromptTab(tabs, { dir: "repo", id: "B" }, server)).toBe(tabs[1])
+})

+ 56 - 0
packages/app/test-browser/prompt-submission-state.test.ts

@@ -0,0 +1,56 @@
+import { describe, expect, test } from "bun:test"
+import { createPromptState } from "@/context/prompt"
+import { createPromptSubmissionState } from "@/components/prompt-input/submission-state"
+
+describe("prompt submission state", () => {
+  test("keeps failed submission restoration with the prompt where it started", () => {
+    const target = createPromptState()
+    const submission = createPromptSubmissionState({
+      target,
+      prompt: [{ type: "text", content: "prompt-A", start: 0, end: 8 }],
+      context: [{ key: "file:src/index.ts:undefined:undefined", type: "file", path: "src/index.ts" }],
+    })
+
+    expect(submission.restore()).toEqual({
+      target,
+      prompt: [{ type: "text", content: "prompt-A", start: 0, end: 8 }],
+      context: [{ key: "file:src/index.ts:undefined:undefined", type: "file", path: "src/index.ts" }],
+    })
+  })
+
+  test("moves first-submit restoration and context to the promoted session", () => {
+    const draft = createPromptState()
+    const session = createPromptState()
+    const submission = createPromptSubmissionState({
+      target: draft,
+      prompt: [{ type: "text", content: "first prompt", start: 0, end: 12 }],
+      context: [{ key: "file:src/index.ts:undefined:undefined", type: "file", path: "src/index.ts" }],
+    })
+
+    submission.retarget(session)
+
+    expect(submission.restore()).toEqual({
+      target: session,
+      prompt: [{ type: "text", content: "first prompt", start: 0, end: 12 }],
+      context: [{ key: "file:src/index.ts:undefined:undefined", type: "file", path: "src/index.ts" }],
+    })
+    expect(session.context.items()).toHaveLength(1)
+    expect(session.context.items()[0]).toMatchObject({ type: "file", path: "src/index.ts" })
+  })
+
+  test("does not restore over a prompt edited after submission", () => {
+    const target = createPromptState()
+    target.set([{ type: "text", content: "submitted", start: 0, end: 9 }])
+    const submission = createPromptSubmissionState({
+      target,
+      prompt: target.current(),
+      context: [],
+    })
+
+    submission.clear()
+    target.set([{ type: "text", content: "new draft", start: 0, end: 9 }])
+
+    expect(submission.restore()).toBeUndefined()
+    expect(target.current()[0]).toMatchObject({ type: "text", content: "new draft" })
+  })
+})

+ 36 - 0
packages/app/test-browser/prompt-transient-state.test.ts

@@ -0,0 +1,36 @@
+import { expect, test } from "bun:test"
+import { createRoot, createSignal } from "solid-js"
+import { createPromptInputTransientState } from "@/components/prompt-input/transient-state"
+
+test("resets transient prompt input state when the prompt session changes", () => {
+  createRoot((dispose) => {
+    const [identity, setIdentity] = createSignal("A")
+    const [state, setState] = createPromptInputTransientState(identity, 3)
+    setState({
+      popover: "slash",
+      historyIndex: 2,
+      savedPrompt: {
+        prompt: [{ type: "text", content: "draft-A", start: 0, end: 7 }],
+        comments: [],
+      },
+      draggingType: "image",
+      mode: "shell",
+      applyingHistory: true,
+      variantOpen: true,
+    })
+
+    setIdentity("B")
+
+    expect(state).toMatchObject({
+      popover: null,
+      historyIndex: -1,
+      savedPrompt: null,
+      placeholder: 3,
+      draggingType: null,
+      mode: "normal",
+      applyingHistory: false,
+      variantOpen: false,
+    })
+    dispose()
+  })
+})

+ 45 - 0
packages/app/test-browser/session-ownership.test.ts

@@ -0,0 +1,45 @@
+import { describe, expect, test } from "bun:test"
+import { createRoot, createSignal } from "solid-js"
+import { createSessionOwnership } from "@/pages/session/session-ownership"
+
+describe("createSessionOwnership", () => {
+  test("invalidates captured work when its Solid owner is disposed", () => {
+    let current = true
+    createRoot((dispose) => {
+      const owner = createSessionOwnership(() => "A").capture()
+      dispose()
+      current = owner.current()
+    })
+
+    expect(current).toBe(false)
+  })
+
+  test("does not run a continuation after navigation", () => {
+    createRoot((dispose) => {
+      const [session, setSession] = createSignal("A")
+      const owner = createSessionOwnership(session).capture()
+      let ran = false
+
+      setSession("B")
+      owner.run(() => {
+        ran = true
+      })
+
+      expect(ran).toBe(false)
+      dispose()
+    })
+  })
+
+  test("does not revive a continuation after A to B to A navigation", () => {
+    createRoot((dispose) => {
+      const [session, setSession] = createSignal("A")
+      const owner = createSessionOwnership(session).capture()
+
+      setSession("B")
+      setSession("A")
+
+      expect(owner.current()).toBe(false)
+      dispose()
+    })
+  })
+})

+ 5 - 1
packages/client/src/generated-effect/.httpapi-codegen.json

@@ -1 +1,5 @@
-["client-error.ts", "client.ts", "index.ts"]
+[
+  "client-error.ts",
+  "client.ts",
+  "index.ts"
+]

+ 6 - 1
packages/client/src/generated/.httpapi-codegen.json

@@ -1 +1,6 @@
-["client-error.ts", "client.ts", "index.ts", "types.ts"]
+[
+  "client-error.ts",
+  "client.ts",
+  "index.ts",
+  "types.ts"
+]