Browse Source

feat(app): extract prompt state and add composer model selection (#36227)

Brendan Allan 1 month ago
parent
commit
8a03fc265b

+ 1 - 0
packages/app/src/components/prompt-input.tsx

@@ -1335,6 +1335,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       onQueue: props.onQueue,
       onAbort: props.onAbort,
       onSubmit: props.onSubmit,
+      model: props.controls.model.selection,
     })
 
   const handleKeyDown = (event: KeyboardEvent) => {

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

@@ -1,5 +1,6 @@
 import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
 import type { Prompt } from "@/context/prompt"
+import type { ModelSelection } from "@/context/local"
 
 let createPromptSubmit: typeof import("./submit").createPromptSubmit
 
@@ -33,6 +34,10 @@ const prompt = {
   current: () => promptValue,
   cursor: () => 0,
   dirty: () => true,
+  model: {
+    current: () => undefined,
+    set: () => undefined,
+  },
   reset: () => undefined,
   set: () => undefined,
   context: {
@@ -378,6 +383,39 @@ describe("prompt submit worktree selection", () => {
     })
   })
 
+  test("uses an injected model selection", async () => {
+    params = { id: "session-1" }
+    const model = {
+      current: () => ({ id: "draft-model", provider: { id: "draft-provider" } }),
+      variant: { current: () => "draft-variant" },
+    } as unknown as ModelSelection
+    const submit = createPromptSubmit({
+      prompt,
+      info: () => ({ id: "session-1" }),
+      imageAttachments: () => [],
+      commentCount: () => 0,
+      autoAccept: () => false,
+      mode: () => "normal",
+      working: () => false,
+      editor: () => undefined,
+      queueScroll: () => undefined,
+      promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
+      addToHistory: () => undefined,
+      resetHistoryNavigation: () => undefined,
+      setMode: () => undefined,
+      setPopover: () => undefined,
+      model,
+    })
+
+    await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
+
+    expect(optimistic[0]).toMatchObject({
+      message: {
+        model: { providerID: "draft-provider", modelID: "draft-model", variant: "draft-variant" },
+      },
+    })
+  })
+
   test("seeds new sessions before optimistic prompts are added", async () => {
     const submit = createPromptSubmit({
       prompt,

+ 10 - 4
packages/app/src/components/prompt-input/submit.ts

@@ -8,7 +8,7 @@ import { useTabs } from "@/context/tabs"
 import { useServerSync, type ServerSync } from "@/context/server-sync"
 import { useLanguage } from "@/context/language"
 import { useLayout } from "@/context/layout"
-import { useLocal } from "@/context/local"
+import { useLocal, type ModelSelection } from "@/context/local"
 import { usePermission } from "@/context/permission"
 import { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt } from "@/context/prompt"
 import { useSDK, type DirectorySDK } from "@/context/sdk"
@@ -191,6 +191,7 @@ type PromptSubmitInput = {
   onQueue?: (draft: FollowupDraft) => void
   onAbort?: () => void
   onSubmit?: () => void
+  model?: ModelSelection
 }
 
 export function createPromptSubmit(input: PromptSubmitInput) {
@@ -298,9 +299,10 @@ export function createPromptSubmit(input: PromptSubmitInput) {
       return
     }
 
-    const currentModel = local.model.current()
+    const modelSelection = input.model ?? local.model
+    const currentModel = modelSelection.current()
     const currentAgent = local.agent.current()
-    const variant = local.model.variant.current()
+    const variant = modelSelection.variant.current()
     if (!currentModel || !currentAgent) {
       showToast({
         title: language.t("prompt.toast.modelAgentRequired.title"),
@@ -377,7 +379,11 @@ export function createPromptSubmit(input: PromptSubmitInput) {
         await startTransition(() => {
           if (!session) return
           if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory)
-          local.session.promote(sessionDirectory, session.id)
+          local.session.promote(sessionDirectory, session.id, {
+            agent: currentAgent.name,
+            model: { providerID: currentModel.provider.id, modelID: currentModel.id },
+            variant: variant ?? null,
+          })
           layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
           const draftID = search.draftId
           if (draftID) tabs.promoteDraft(draftID, { server: tabs.draft(draftID).server, sessionId: session.id })

+ 10 - 2
packages/app/src/components/titlebar.tsx

@@ -25,6 +25,7 @@ import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/comp
 import { useGlobal } from "@/context/global"
 import { ServerConnection, useServer } from "@/context/server"
 import { tabKey, useTabs } from "@/context/tabs"
+import type { PromptSession } from "@/context/prompt"
 import "./titlebar.css"
 import { newTabTooltipKeybind } from "./command-tooltip-keybind"
 
@@ -324,13 +325,20 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
               const route = layout.route()
               const activeSession = session()
               if (route.type === "session" && activeSession) {
-                tabs.newDraft({ server: route.server ?? server.key, directory: activeSession.directory }, "")
+                const sessionTab = {
+                  type: "session" as const,
+                  server: route.server ?? server.key,
+                  sessionId: activeSession.id,
+                }
+                const model = tabs.stateValue<PromptSession>(sessionTab, "prompt")?.model.current()
+                tabs.newDraft({ server: sessionTab.server, directory: activeSession.directory }, "", model)
                 return
               }
 
               const activeTab = currentTab()
               if (activeTab?.type === "draft") {
-                tabs.newDraft({ server: activeTab.server, directory: activeTab.directory }, "")
+                const model = tabs.stateValue<PromptSession>(activeTab, "prompt")?.model.current()
+                tabs.newDraft({ server: activeTab.server, directory: activeTab.directory }, "", model)
                 return
               }
 

+ 6 - 3
packages/app/src/context/local.tsx

@@ -67,7 +67,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
     const list = createMemo(() => sync().data.agent.filter((item) => item.mode !== "subagent" && !item.hidden))
     const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
 
-    const [saved, setSaved] = persisted(
+    const [saved, setSaved, , savedReady] = persisted(
       {
         ...Persist.serverWorkspace(serverSDK().scope, sdk().directory, "model-selection", ["model-selection.v1"]),
         migrate,
@@ -375,11 +375,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
       model,
       agent,
       session: {
+        ready: savedReady,
         reset() {
           setStore({ draft: undefined, promoting: undefined })
         },
-        promote(dir: string, session: string) {
-          const next = clone(snapshot())
+        promote(dir: string, session: string, state?: State) {
+          const next = clone(state ?? snapshot())
           if (!next) return
           const key = handoffKey(serverSDK().scope, dir, session)
           handoff.set(key, next)
@@ -409,3 +410,5 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
     return result
   },
 })
+
+export type ModelSelection = ReturnType<typeof useLocal>["model"]

+ 29 - 0
packages/app/src/context/prompt-state.test.ts

@@ -0,0 +1,29 @@
+import { describe, expect, test } from "bun:test"
+import { createRoot } from "solid-js"
+import { createPromptState, DEFAULT_PROMPT } from "./prompt-state"
+
+describe("prompt state initialization", () => {
+  test("initializes prompt text, cursor, and model together", () => {
+    createRoot((dispose) => {
+      const model = { providerID: "anthropic", modelID: "claude", variant: "high" }
+      const prompt = createPromptState({ prompt: "hello", model })
+
+      expect(prompt.current()).toEqual([{ type: "text", content: "hello", start: 0, end: 5 }])
+      expect(prompt.cursor()).toBe(5)
+      expect(prompt.model.current()).toEqual(model)
+      expect(prompt.model.current()).not.toBe(model)
+      dispose()
+    })
+  })
+
+  test("uses the default prompt without initial values", () => {
+    createRoot((dispose) => {
+      const prompt = createPromptState()
+
+      expect(prompt.current()).toEqual(DEFAULT_PROMPT)
+      expect(prompt.cursor()).toBeUndefined()
+      expect(prompt.model.current()).toBeUndefined()
+      dispose()
+    })
+  })
+})

+ 265 - 0
packages/app/src/context/prompt-state.ts

@@ -0,0 +1,265 @@
+import { checksum } from "@opencode-ai/core/util/encode"
+import type { FilePartSource } from "@opencode-ai/sdk/v2/client"
+import { batch, createMemo, type Accessor } from "solid-js"
+import { createStore, type SetStoreFunction } from "solid-js/store"
+import type { FileSelection } from "@/context/file"
+import { Persist, persisted } from "@/utils/persist"
+import type { ServerScope } from "@/utils/server-scope"
+
+interface PartBase {
+  content: string
+  start: number
+  end: number
+}
+
+export interface TextPart extends PartBase {
+  type: "text"
+}
+
+export interface FileAttachmentPart extends PartBase {
+  type: "file"
+  path: string
+  selection?: FileSelection
+  mime?: string
+  filename?: string
+  url?: string
+  source?: FilePartSource
+}
+
+export interface AgentPart extends PartBase {
+  type: "agent"
+  name: string
+}
+
+export interface ImageAttachmentPart {
+  type: "image"
+  id: string
+  filename: string
+  sourcePath?: string
+  mime: string
+  dataUrl: string
+}
+
+export type ContentPart = TextPart | FileAttachmentPart | AgentPart | ImageAttachmentPart
+export type Prompt = ContentPart[]
+
+export type PromptModel = {
+  providerID: string
+  modelID: string
+  variant?: string | null
+}
+
+export type FileContextItem = {
+  type: "file"
+  path: string
+  selection?: FileSelection
+  comment?: string
+  commentID?: string
+  commentOrigin?: "review" | "file"
+  preview?: string
+}
+
+export type ContextItem = FileContextItem
+export type PromptScope = { draftID: string } | { dir: string; id?: string }
+
+export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
+
+type PromptStore = {
+  prompt: Prompt
+  cursor?: number
+  model?: PromptModel
+  context: {
+    items: (ContextItem & { key: string })[]
+  }
+}
+
+type InitialPrompt = {
+  prompt?: string
+  model?: PromptModel
+}
+
+function isSelectionEqual(a?: FileSelection, b?: FileSelection) {
+  if (!a && !b) return true
+  if (!a || !b) return false
+  return (
+    a.startLine === b.startLine && a.startChar === b.startChar && a.endLine === b.endLine && a.endChar === b.endChar
+  )
+}
+
+function isPartEqual(partA: ContentPart, partB: ContentPart) {
+  switch (partA.type) {
+    case "text":
+      return partB.type === "text" && partA.content === partB.content
+    case "file":
+      return (
+        partB.type === "file" &&
+        partA.path === partB.path &&
+        partA.mime === partB.mime &&
+        partA.filename === partB.filename &&
+        isSelectionEqual(partA.selection, partB.selection)
+      )
+    case "agent":
+      return partB.type === "agent" && partA.name === partB.name
+    case "image":
+      return partB.type === "image" && partA.id === partB.id
+  }
+}
+
+export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean {
+  if (promptA.length !== promptB.length) return false
+  for (let i = 0; i < promptA.length; i++) {
+    if (!isPartEqual(promptA[i], promptB[i])) return false
+  }
+  return true
+}
+
+function cloneSelection(selection?: FileSelection) {
+  if (!selection) return undefined
+  return { ...selection }
+}
+
+function clonePart(part: ContentPart): ContentPart {
+  if (part.type === "text") return { ...part }
+  if (part.type === "image") return { ...part }
+  if (part.type === "agent") return { ...part }
+  return {
+    ...part,
+    selection: cloneSelection(part.selection),
+  }
+}
+
+function clonePrompt(prompt: Prompt): Prompt {
+  return prompt.map(clonePart)
+}
+
+function contextItemKey(item: ContextItem) {
+  if (item.type !== "file") return item.type
+  const start = item.selection?.startLine
+  const end = item.selection?.endLine
+  const key = `${item.type}:${item.path}:${start}:${end}`
+
+  if (item.commentID) return `${key}:c=${item.commentID}`
+  const comment = item.comment?.trim()
+  if (!comment) return key
+  const digest = checksum(comment) ?? comment
+  return `${key}:c=${digest.slice(0, 8)}`
+}
+
+function isCommentItem(item: ContextItem | (ContextItem & { key: string })) {
+  return item.type === "file" && !!item.comment?.trim()
+}
+
+function createPromptActions(setStore: SetStoreFunction<PromptStore>) {
+  return {
+    set(prompt: Prompt, cursorPosition?: number) {
+      const next = clonePrompt(prompt)
+      batch(() => {
+        setStore("prompt", next)
+        if (cursorPosition !== undefined) setStore("cursor", cursorPosition)
+      })
+    },
+    reset() {
+      batch(() => {
+        setStore("prompt", clonePrompt(DEFAULT_PROMPT))
+        setStore("cursor", 0)
+      })
+    },
+  }
+}
+
+function promptTarget(serverScope: ServerScope, scope: PromptScope) {
+  if ("draftID" in scope) return Persist.draft(scope.draftID, "prompt")
+  const legacy = `${scope.dir}/prompt${scope.id ? "/" + scope.id : ""}.v2`
+  return Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt", [legacy])
+}
+
+function promptStore(initial?: InitialPrompt): PromptStore {
+  const text = initial?.prompt
+  return {
+    prompt:
+      text === undefined ? clonePrompt(DEFAULT_PROMPT) : [{ type: "text", content: text, start: 0, end: text.length }],
+    cursor: text === undefined ? undefined : text.length,
+    model: initial?.model ? { ...initial.model } : undefined,
+    context: {
+      items: [],
+    },
+  }
+}
+
+function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction<PromptStore>) {
+  const actions = createPromptActions(setStore)
+  const value = {
+    current: () => store.prompt,
+    cursor: createMemo(() => store.cursor),
+    dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT),
+    model: {
+      current: () => store.model,
+      set: (model: PromptModel | undefined) => setStore("model", model),
+    },
+    context: {
+      items: createMemo(() => store.context.items),
+      add(item: ContextItem) {
+        const key = contextItemKey(item)
+        if (store.context.items.find((x) => x.key === key)) return
+        setStore("context", "items", (items) => [...items, { key, ...item }])
+      },
+      remove(key: string) {
+        setStore("context", "items", (items) => items.filter((x) => x.key !== key))
+      },
+      removeComment(path: string, commentID: string) {
+        setStore("context", "items", (items) =>
+          items.filter((item) => !(item.type === "file" && item.path === path && item.commentID === commentID)),
+        )
+      },
+      updateComment(path: string, commentID: string, next: Partial<FileContextItem> & { comment?: string }) {
+        setStore("context", "items", (items) =>
+          items.map((item) => {
+            if (item.type !== "file" || item.path !== path || item.commentID !== commentID) return item
+            const value = { ...item, ...next }
+            return { ...value, key: contextItemKey(value) }
+          }),
+        )
+      },
+      replaceComments(items: FileContextItem[]) {
+        setStore("context", "items", (current) => [
+          ...current.filter((item) => !isCommentItem(item)),
+          ...items.map((item) => ({ ...item, key: contextItemKey(item) })),
+        ])
+      },
+    },
+    set: actions.set,
+    reset: actions.reset,
+    capture: () => value,
+  }
+  return value
+}
+
+function createPersistedPrompt(target: ReturnType<typeof promptTarget>, initial?: InitialPrompt) {
+  const [store, setStore, _, ready] = persisted(target, createStore<PromptStore>(promptStore(initial)))
+  return { ready, ...createPromptStateValue(store, setStore) }
+}
+
+export function createPromptSession(serverScope: ServerScope, scope: PromptScope, initial?: InitialPrompt) {
+  return createPersistedPrompt(promptTarget(serverScope, scope), initial)
+}
+
+export function createDraftPromptSession(draftID: string, initial?: InitialPrompt) {
+  return createPersistedPrompt(Persist.draft(draftID, "prompt"), initial)
+}
+
+export type PromptSession = ReturnType<typeof createPromptSession>
+
+export function createPromptReady(session: Accessor<PromptSession>) {
+  return Object.defineProperty(() => session().ready(), "promise", {
+    get: () => session().ready.promise,
+  }) as (() => boolean) & { readonly promise: Promise<unknown> | undefined }
+}
+
+export function createPromptState(initial?: InitialPrompt) {
+  const [store, setStore] = createStore<PromptStore>(promptStore(initial))
+  const ready = Object.assign(() => true, { promise: Promise.resolve(true) })
+  return {
+    ready,
+    ...createPromptStateValue(store, setStore),
+  }
+}

+ 50 - 272
packages/app/src/context/prompt.tsx

@@ -1,186 +1,49 @@
+import { base64Encode } from "@opencode-ai/core/util/encode"
 import { createSimpleContext } from "@opencode-ai/ui/context"
-import { base64Encode, checksum } from "@opencode-ai/core/util/encode"
 import { useParams, useSearchParams } from "@solidjs/router"
-import { batch, createMemo, createRoot, getOwner, onCleanup, type Accessor } from "solid-js"
-import { createStore, type SetStoreFunction } from "solid-js/store"
-import type { FileSelection } from "@/context/file"
-import { Persist, persisted } from "@/utils/persist"
+import { createMemo, createRoot, getOwner, onCleanup } from "solid-js"
+import { requireServerKey } from "@/utils/session-route"
+import { ServerConnection } from "./server"
 import { useServerSDK } from "./server-sdk"
-import type { ServerScope } from "@/utils/server-scope"
+import { useSettings } from "./settings"
 import { useSDK } from "./sdk"
 import { useTabs, type Tab } from "./tabs"
-import { ServerConnection } from "./server"
-import { requireServerKey } from "@/utils/session-route"
-import { useSettings } from "./settings"
-import type { FilePartSource } from "@opencode-ai/sdk/v2/client"
-
-interface PartBase {
-  content: string
-  start: number
-  end: number
-}
-
-export interface TextPart extends PartBase {
-  type: "text"
-}
-
-export interface FileAttachmentPart extends PartBase {
-  type: "file"
-  path: string
-  selection?: FileSelection
-  mime?: string
-  filename?: string
-  url?: string
-  source?: FilePartSource
-}
-
-export interface AgentPart extends PartBase {
-  type: "agent"
-  name: string
-}
-
-export interface ImageAttachmentPart {
-  type: "image"
-  id: string
-  filename: string
-  sourcePath?: string
-  mime: string
-  dataUrl: string
-}
-
-export type ContentPart = TextPart | FileAttachmentPart | AgentPart | ImageAttachmentPart
-export type Prompt = ContentPart[]
-
-export type FileContextItem = {
-  type: "file"
-  path: string
-  selection?: FileSelection
-  comment?: string
-  commentID?: string
-  commentOrigin?: "review" | "file"
-  preview?: string
-}
-
-export type ContextItem = FileContextItem
-
-export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
-
-function isSelectionEqual(a?: FileSelection, b?: FileSelection) {
-  if (!a && !b) return true
-  if (!a || !b) return false
-  return (
-    a.startLine === b.startLine && a.startChar === b.startChar && a.endLine === b.endLine && a.endChar === b.endChar
-  )
-}
-
-function isPartEqual(partA: ContentPart, partB: ContentPart) {
-  switch (partA.type) {
-    case "text":
-      return partB.type === "text" && partA.content === partB.content
-    case "file":
-      return (
-        partB.type === "file" &&
-        partA.path === partB.path &&
-        partA.mime === partB.mime &&
-        partA.filename === partB.filename &&
-        isSelectionEqual(partA.selection, partB.selection)
-      )
-    case "agent":
-      return partB.type === "agent" && partA.name === partB.name
-    case "image":
-      return partB.type === "image" && partA.id === partB.id
-  }
-}
-
-export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean {
-  if (promptA.length !== promptB.length) return false
-  for (let i = 0; i < promptA.length; i++) {
-    if (!isPartEqual(promptA[i], promptB[i])) return false
-  }
-  return true
-}
-
-function cloneSelection(selection?: FileSelection) {
-  if (!selection) return undefined
-  return { ...selection }
-}
-
-function clonePart(part: ContentPart): ContentPart {
-  if (part.type === "text") return { ...part }
-  if (part.type === "image") return { ...part }
-  if (part.type === "agent") return { ...part }
-  return {
-    ...part,
-    selection: cloneSelection(part.selection),
-  }
-}
-
-function clonePrompt(prompt: Prompt): Prompt {
-  return prompt.map(clonePart)
-}
-
-function contextItemKey(item: ContextItem) {
-  if (item.type !== "file") return item.type
-  const start = item.selection?.startLine
-  const end = item.selection?.endLine
-  const key = `${item.type}:${item.path}:${start}:${end}`
-
-  if (item.commentID) {
-    return `${key}:c=${item.commentID}`
-  }
-
-  const comment = item.comment?.trim()
-  if (!comment) return key
-  const digest = checksum(comment) ?? comment
-  return `${key}:c=${digest.slice(0, 8)}`
-}
-
-function isCommentItem(item: ContextItem | (ContextItem & { key: string })) {
-  return item.type === "file" && !!item.comment?.trim()
-}
-
-function createPromptActions(
-  setStore: SetStoreFunction<{
-    prompt: Prompt
-    cursor?: number
-    context: {
-      items: (ContextItem & { key: string })[]
-    }
-  }>,
-) {
-  return {
-    set(prompt: Prompt, cursorPosition?: number) {
-      const next = clonePrompt(prompt)
-      batch(() => {
-        setStore("prompt", next)
-        if (cursorPosition !== undefined) setStore("cursor", cursorPosition)
-      })
-    },
-    reset() {
-      batch(() => {
-        setStore("prompt", clonePrompt(DEFAULT_PROMPT))
-        setStore("cursor", 0)
-      })
-    },
-  }
-}
+import {
+  createPromptReady,
+  createPromptSession,
+  type ContextItem,
+  type FileContextItem,
+  type Prompt,
+  type PromptModel,
+  type PromptScope,
+  type PromptSession,
+} from "./prompt-state"
+
+export {
+  createPromptReady,
+  createPromptSession,
+  createPromptState,
+  DEFAULT_PROMPT,
+  isPromptEqual,
+} from "./prompt-state"
+export type {
+  AgentPart,
+  ContentPart,
+  ContextItem,
+  FileAttachmentPart,
+  FileContextItem,
+  ImageAttachmentPart,
+  Prompt,
+  PromptModel,
+  PromptScope,
+  PromptSession,
+  TextPart,
+} from "./prompt-state"
 
 const WORKSPACE_KEY = "__workspace__"
 const MAX_PROMPT_SESSIONS = 20
 
-type PromptSession = ReturnType<typeof createPromptSession>
-
-type PromptStore = {
-  prompt: Prompt
-  cursor?: number
-  context: {
-    items: (ContextItem & { key: string })[]
-  }
-}
-
-type Scope = { draftID: string } | { dir: string; id?: string }
-
-export function selectPromptTab(tabs: Tab[], scope: Scope, server: ServerConnection.Key) {
+export function selectPromptTab(tabs: Tab[], scope: PromptScope, server: ServerConnection.Key) {
   if ("draftID" in scope) return tabs.find((tab) => tab.type === "draft" && tab.draftID === scope.draftID)
   if (!scope.id) return
   return (
@@ -189,7 +52,7 @@ export function selectPromptTab(tabs: Tab[], scope: Scope, server: ServerConnect
   )
 }
 
-function scopeKey(scope: Scope) {
+function scopeKey(scope: PromptScope) {
   if ("draftID" in scope) return `draft:${scope.draftID}`
   return `${scope.dir}:${scope.id ?? WORKSPACE_KEY}`
 }
@@ -199,91 +62,6 @@ type PromptCacheEntry = {
   dispose: VoidFunction
 }
 
-function promptTarget(serverScope: ServerScope, scope: Scope) {
-  if ("draftID" in scope) return Persist.draft(scope.draftID, "prompt")
-  const legacy = `${scope.dir}/prompt${scope.id ? "/" + scope.id : ""}.v2`
-  return Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt", [legacy])
-}
-
-export function createPromptSession(serverScope: ServerScope, scope: Scope) {
-  const [store, setStore, _, ready] = persisted(
-    promptTarget(serverScope, scope),
-    createStore<PromptStore>(promptStore()),
-  )
-
-  return { ready, ...createPromptStateValue(store, setStore) }
-}
-
-export function createPromptReady(session: Accessor<PromptSession>) {
-  return Object.defineProperty(() => session().ready(), "promise", {
-    get: () => session().ready.promise,
-  }) as (() => boolean) & { readonly promise: Promise<unknown> | undefined }
-}
-
-function promptStore(): PromptStore {
-  return {
-    prompt: clonePrompt(DEFAULT_PROMPT),
-    cursor: undefined,
-    context: {
-      items: [],
-    },
-  }
-}
-
-function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction<PromptStore>) {
-  const actions = createPromptActions(setStore)
-
-  const value = {
-    current: () => store.prompt,
-    cursor: createMemo(() => store.cursor),
-    dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT),
-    context: {
-      items: createMemo(() => store.context.items),
-      add(item: ContextItem) {
-        const key = contextItemKey(item)
-        if (store.context.items.find((x) => x.key === key)) return
-        setStore("context", "items", (items) => [...items, { key, ...item }])
-      },
-      remove(key: string) {
-        setStore("context", "items", (items) => items.filter((x) => x.key !== key))
-      },
-      removeComment(path: string, commentID: string) {
-        setStore("context", "items", (items) =>
-          items.filter((item) => !(item.type === "file" && item.path === path && item.commentID === commentID)),
-        )
-      },
-      updateComment(path: string, commentID: string, next: Partial<FileContextItem> & { comment?: string }) {
-        setStore("context", "items", (items) =>
-          items.map((item) => {
-            if (item.type !== "file" || item.path !== path || item.commentID !== commentID) return item
-            const value = { ...item, ...next }
-            return { ...value, key: contextItemKey(value) }
-          }),
-        )
-      },
-      replaceComments(items: FileContextItem[]) {
-        setStore("context", "items", (current) => [
-          ...current.filter((item) => !isCommentItem(item)),
-          ...items.map((item) => ({ ...item, key: contextItemKey(item) })),
-        ])
-      },
-    },
-    set: actions.set,
-    reset: actions.reset,
-    capture: () => value,
-  }
-  return value
-}
-
-export function createPromptState() {
-  const [store, setStore] = createStore<PromptStore>(promptStore())
-  const ready = Object.assign(() => true, { promise: Promise.resolve(true) })
-  return {
-    ready,
-    ...createPromptStateValue(store, setStore),
-  }
-}
-
 export const createTabPromptState = (
   tabs: ReturnType<typeof useTabs>,
   tab: Tab,
@@ -303,9 +81,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
     const cache = new Map<string, PromptCacheEntry>()
 
     const disposeAll = () => {
-      for (const entry of cache.values()) {
-        entry.dispose()
-      }
+      for (const entry of cache.values()) entry.dispose()
       cache.clear()
     }
 
@@ -324,13 +100,11 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
     const owner = getOwner()
     const serverKey = () =>
       params.serverKey ? requireServerKey(params.serverKey) : ServerConnection.key(serverSDK().server)
-    const scope = () =>
+    const scope = (): PromptScope =>
       search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }
-    const load = (scope: Scope) => {
+    const load = (scope: PromptScope) => {
       const current = settings.general.newLayoutDesigns() ? selectPromptTab(tabs.store, scope, serverKey()) : undefined
-      if (current) {
-        return createTabPromptState(tabs, current, serverSDK().scope, scope)
-      }
+      if (current) return createTabPromptState(tabs, current, serverSDK().scope, scope)
 
       const key = scopeKey(scope)
       const existing = cache.get(key)
@@ -354,15 +128,19 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
     }
 
     const session = createMemo(() => load(scope()))
-    const pick = (scope?: Scope) => (scope ? load(scope) : session())
+    const pick = (scope?: PromptScope) => (scope ? load(scope) : session())
     const ready = createPromptReady(session)
 
     return {
       ready,
-      capture: (scope?: Scope) => pick(scope).capture(),
+      capture: (scope?: PromptScope) => pick(scope).capture(),
       current: () => session().current(),
       cursor: () => session().cursor(),
       dirty: () => session().dirty(),
+      model: {
+        current: () => session().model.current(),
+        set: (model: PromptModel | undefined) => session().model.set(model),
+      },
       context: {
         items: () => session().context.items(),
         add: (item: ContextItem) => session().context.add(item),
@@ -372,8 +150,8 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
           session().context.updateComment(path, commentID, next),
         replaceComments: (items: FileContextItem[]) => session().context.replaceComments(items),
       },
-      set: (prompt: Prompt, cursorPosition?: number, scope?: Scope) => pick(scope).set(prompt, cursorPosition),
-      reset: (scope?: Scope) => pick(scope).reset(),
+      set: (prompt: Prompt, cursorPosition?: number, scope?: PromptScope) => pick(scope).set(prompt, cursorPosition),
+      reset: (scope?: PromptScope) => pick(scope).reset(),
     }
   },
 })

+ 3 - 0
packages/app/src/context/tab-memory.ts

@@ -16,6 +16,9 @@ export function createTabMemory(owner: Owner | null) {
   }
 
   return {
+    get<T>(key: string, name: string) {
+      return entries.get(key)?.get(name)?.value as T | undefined
+    },
     ensure<T>(key: string, name: string, init: () => T) {
       const state = entries.get(key) ?? new Map<string, Entry>()
       if (!entries.has(key)) entries.set(key, state)

+ 2 - 0
packages/app/src/context/tabs.test.ts

@@ -22,6 +22,8 @@ describe("tab memory", () => {
       })
 
       expect(memory.ensure("tab", "prompt", () => ({ value: "other" }))).toBe(first)
+      expect(memory.get<typeof first>("tab", "prompt")).toBe(first)
+      expect(memory.get("missing", "prompt")).toBeUndefined()
       expect(memory.ensure("other", "prompt", () => ({ value: "other" }))).not.toBe(first)
 
       memory.remove("tab")

+ 9 - 3
packages/app/src/context/tabs.tsx

@@ -11,6 +11,7 @@ import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events"
 import { sessionHref } from "@/utils/session-route"
 import { createTabMemory } from "./tab-memory"
 import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs"
+import { createDraftPromptSession, type PromptModel } from "./prompt-state"
 
 export type SessionTab = {
   type: "session"
@@ -207,15 +208,17 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
         if (!tab || tab.type !== "draft") throw new Error(`Draft not found: ${draftID}`)
         return tab
       },
-      newDraft(draft: Omit<DraftTab, "type" | "draftID">, prompt?: string) {
+      newDraft(draft: Omit<DraftTab, "type" | "draftID">, prompt?: string, model?: PromptModel) {
         const draftID = uuid()
+        const tab = { type: "draft" as const, draftID, ...draft }
+        memory.ensure(tabKey(tab), "prompt", () => createDraftPromptSession(draftID, { prompt, model }))
         void startTransition(() => {
           setStore(
             produce((tabs) => {
-              tabs.push({ type: "draft", draftID, ...draft })
+              tabs.push(tab)
             }),
           )
-          navigate(prompt ? `${draftHref(draftID)}&prompt=${encodeURIComponent(prompt)}` : draftHref(draftID))
+          navigate(draftHref(draftID))
         })
       },
       updateDraft(draftID: string, draft: Partial<Omit<DraftTab, "type" | "draftID">>) {
@@ -373,6 +376,9 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
       state<T>(tab: Tab, name: string, init: () => T) {
         return memory.ensure(tabKey(tab), name, init)
       },
+      stateValue<T>(tab: Tab, name: string) {
+        return memory.get<T>(tabKey(tab), name)
+      },
     }
 
     return { ...actions, store, info, ready, recentReady }

+ 6 - 1
packages/app/src/pages/new-session.tsx

@@ -31,6 +31,8 @@ import { useProviders } from "@/hooks/use-providers"
 import { useSettingsDialog } from "@/components/settings-dialog"
 import { Persist, persisted } from "@/utils/persist"
 import createPresence from "solid-presence"
+import { useLocal } from "@/context/local"
+import { createPromptModelSelection } from "@/pages/session/composer/prompt-model-selection"
 
 const workspaceBarEnabled = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
 const providerTipDismissalDuration = 30 * 24 * 60 * 60 * 1000
@@ -54,8 +56,10 @@ export default function NewSessionPage() {
   const openProviderSettings = useSettingsDialog("providers")
   const route = useSessionKey()
   const [searchParams, setSearchParams] = useSearchParams<{ draftId?: string; prompt?: string }>()
+  const local = useLocal()
+  const model = createPromptModelSelection({ agent: local.agent.current })
 
-  useComposerCommands()
+  useComposerCommands({ model })
 
   let inputRef: HTMLDivElement | undefined
 
@@ -63,6 +67,7 @@ export default function NewSessionPage() {
     sessionKey: route.sessionKey,
     sessionID: () => route.params.id,
     queryOptions: serverSync().queryOptions,
+    model,
   })
   const projectControls = createPromptProjectControls()
   const projectController = createPromptProjectController({

+ 12 - 1
packages/app/src/pages/session.tsx

@@ -69,7 +69,7 @@ import { MessageTimeline } from "@/pages/session/timeline/message-timeline"
 import { createTimelineModel } from "@/pages/session/timeline/model"
 import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
 import { useSessionLayout } from "@/pages/session/session-layout"
-import { syncSessionModel } from "@/pages/session/session-model-helpers"
+import { restorePromptModel, syncPromptModel, syncSessionModel } from "@/pages/session/session-model-helpers"
 import {
   clampSessionPanelWidth,
   SESSION_PANEL_WIDTH_MIN,
@@ -557,6 +557,17 @@ export default function Page() {
     ),
   )
 
+  let restoredModelSession: string | undefined
+  createEffect(() => {
+    const id = params.id
+    if (!id || !prompt.ready() || !local.session.ready()) return
+    if (restoredModelSession !== id) {
+      restoredModelSession = id
+      if (restorePromptModel(local, prompt)) return
+    }
+    syncPromptModel(local, prompt)
+  })
+
   createEffect(
     on(
       () => ({ dir: sdk().directory, id: params.id }),

+ 133 - 0
packages/app/src/pages/session/composer/prompt-model-selection.ts

@@ -0,0 +1,133 @@
+import { batch, createMemo, startTransition } from "solid-js"
+import { useModels } from "@/context/models"
+import type { ModelKey, ModelSelection } from "@/context/local"
+import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "@/context/model-variant"
+import { usePrompt } from "@/context/prompt"
+import { useSDK } from "@/context/sdk"
+import { useSync } from "@/context/sync"
+import { useProviders } from "@/hooks/use-providers"
+
+export function createPromptModelSelection(input: { agent: () => { model?: ModelKey; variant?: string } | undefined }) {
+  const sdk = useSDK()
+  const sync = useSync()
+  const models = useModels()
+  const prompt = usePrompt()
+  const providers = useProviders(() => sdk().directory)
+  const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
+
+  const valid = (model: ModelKey) => {
+    const provider = providers.all().get(model.providerID)
+    return !!provider?.models[model.modelID] && connected().has(model.providerID)
+  }
+
+  const configured = () => {
+    const value = sync().data.config.model
+    if (!value) return
+    const [providerID, modelID] = value.split("/")
+    const model = { providerID, modelID }
+    if (valid(model)) return model
+  }
+
+  const recent = () => models.recent.list().find(valid)
+  const fallback = () => {
+    const defaults = providers.default()
+    return providers.connected().flatMap((provider) => {
+      const modelID = defaults[provider.id] ?? Object.values(provider.models)[0]?.id
+      return modelID ? [{ providerID: provider.id, modelID }] : []
+    })[0]
+  }
+
+  const current = () => {
+    const key = [prompt.model.current(), input.agent()?.model, configured(), recent(), fallback()].find(
+      (item): item is ModelKey => !!item && valid(item),
+    )
+    if (!key) return
+    return models.find(key)
+  }
+  const recentModels = createMemo(() =>
+    models.recent
+      .list()
+      .map(models.find)
+      .filter((item): item is NonNullable<typeof item> => !!item),
+  )
+
+  const selection = {
+    ready: models.ready,
+    current,
+    recent: recentModels,
+    list: models.list,
+    cycle(direction: 1 | -1) {
+      const items = recentModels()
+      const item = current()
+      if (!item) return
+      const index = items.findIndex((entry) => entry.provider.id === item.provider.id && entry.id === item.id)
+      if (index === -1) return
+      const next = items[(index + direction + items.length) % items.length]
+      if (next) selection.set({ providerID: next.provider.id, modelID: next.id })
+    },
+    set(item: ModelKey | undefined, options?: { recent?: boolean }) {
+      startTransition(() =>
+        batch(() => {
+          prompt.model.set(item ? { ...item, variant: prompt.model.current()?.variant } : undefined)
+          if (!item) return
+          models.setVisibility(item, true)
+          if (options?.recent) models.recent.push(item)
+        }),
+      )
+    },
+    visible: models.visible,
+    setVisibility: models.setVisibility,
+    variant: {
+      configured() {
+        const item = input.agent()
+        const model = current()
+        if (!item || !model) return
+        return getConfiguredAgentVariant({
+          agent: { model: item.model, variant: item.variant },
+          model: { providerID: model.provider.id, modelID: model.id, variants: model.variants },
+        })
+      },
+      selected() {
+        return prompt.model.current()?.variant
+      },
+      current() {
+        const resolved = resolveModelVariant({
+          variants: this.list(),
+          selected: this.selected(),
+          configured: this.configured(),
+        })
+        if (resolved) return resolved
+        const model = current()
+        if (!model) return
+        const saved = models.variant.get({ providerID: model.provider.id, modelID: model.id })
+        if (saved && this.list().includes(saved)) return saved
+      },
+      list() {
+        return Object.keys(current()?.variants ?? {})
+      },
+      set(value: string | undefined) {
+        startTransition(() =>
+          batch(() => {
+            const model = current()
+            if (!model) return
+            prompt.model.set({ providerID: model.provider.id, modelID: model.id, variant: value ?? null })
+            models.variant.set({ providerID: model.provider.id, modelID: model.id }, value)
+          }),
+        )
+      },
+      cycle() {
+        const variants = this.list()
+        if (variants.length === 0) return
+        this.set(
+          cycleModelVariant({
+            variants,
+            selected: this.selected(),
+            configured: this.configured(),
+          }),
+        )
+      },
+    },
+  } satisfies ModelSelection
+
+  return selection
+}

+ 3 - 2
packages/app/src/pages/session/composer/session-composer-controls.ts

@@ -7,7 +7,7 @@ import type { PromptProjectControls } from "@/components/prompt-project-selector
 import { useDirectoryPicker } from "@/components/directory-picker"
 import { useGlobal } from "@/context/global"
 import { useLayout } from "@/context/layout"
-import { useLocal } from "@/context/local"
+import { useLocal, type ModelSelection } from "@/context/local"
 import type { QueryOptionsApi } from "@/context/server-sync"
 import { useServerSDK } from "@/context/server-sdk"
 import { serverName, ServerConnection, useServer } from "@/context/server"
@@ -22,6 +22,7 @@ export function createPromptInputController(input: {
   sessionKey: Accessor<string>
   sessionID: Accessor<string | undefined>
   queryOptions: Pick<QueryOptionsApi, "agents" | "providers">
+  model?: ModelSelection
 }) {
   const layout = useLayout()
   const local = useLocal()
@@ -44,7 +45,7 @@ export function createPromptInputController(input: {
       select: local.agent.set,
     },
     model: {
-      selection: local.model,
+      selection: input.model ?? local.model,
       paid: providers.paid().length > 0,
       loading: agentsQuery.isLoading || providersQuery.isLoading || globalProvidersQuery.isLoading,
     },

+ 100 - 1
packages/app/src/pages/session/session-model-helpers.test.ts

@@ -1,6 +1,6 @@
 import { describe, expect, test } from "bun:test"
 import type { UserMessage } from "@opencode-ai/sdk/v2"
-import { resetSessionModel, syncSessionModel } from "./session-model-helpers"
+import { resetSessionModel, restorePromptModel, syncPromptModel, syncSessionModel } from "./session-model-helpers"
 
 const message = (input?: { agent?: string; model?: UserMessage["model"] }) =>
   ({
@@ -50,3 +50,102 @@ describe("resetSessionModel", () => {
     expect(calls).toEqual(["reset"])
   })
 })
+
+describe("syncPromptModel", () => {
+  test("stores the effective session model in prompt state", () => {
+    const calls: unknown[] = []
+
+    syncPromptModel(
+      {
+        model: {
+          current: () => ({ id: "claude-sonnet-4", provider: { id: "anthropic" } }),
+          set() {},
+          variant: { current: () => "high", set() {} },
+        },
+      },
+      {
+        model: {
+          current: () => undefined,
+          set: (model) => calls.push(model),
+        },
+      },
+    )
+
+    expect(calls).toEqual([{ providerID: "anthropic", modelID: "claude-sonnet-4", variant: "high" }])
+  })
+
+  test("does not rewrite an unchanged prompt model", () => {
+    const calls: unknown[] = []
+    const model = { providerID: "anthropic", modelID: "claude-sonnet-4", variant: "high" }
+
+    syncPromptModel(
+      {
+        model: {
+          current: () => ({ id: model.modelID, provider: { id: model.providerID } }),
+          set() {},
+          variant: { current: () => model.variant, set() {} },
+        },
+      },
+      {
+        model: {
+          current: () => model,
+          set: (value) => calls.push(value),
+        },
+      },
+    )
+
+    expect(calls).toEqual([])
+  })
+})
+
+describe("restorePromptModel", () => {
+  test("restores the persisted prompt model into session selection", () => {
+    const calls: unknown[] = []
+    const restored = restorePromptModel(
+      {
+        model: {
+          current: () => ({ id: "gpt", provider: { id: "openai" } }),
+          set: (model) => calls.push(model),
+          variant: {
+            current: () => undefined,
+            set: (variant) => calls.push(variant),
+          },
+        },
+      },
+      {
+        model: {
+          current: () => ({ providerID: "anthropic", modelID: "claude", variant: "high" }),
+          set() {},
+        },
+      },
+    )
+
+    expect(restored).toBe(true)
+    expect(calls).toEqual([{ providerID: "anthropic", modelID: "claude" }, "high"])
+  })
+
+  test("does nothing without a persisted prompt model", () => {
+    const calls: unknown[] = []
+    const restored = restorePromptModel(
+      {
+        model: {
+          current: () => ({ id: "gpt", provider: { id: "openai" } }),
+          set: (model) => calls.push(model),
+          variant: {
+            current: () => undefined,
+            set: (variant) => calls.push(variant),
+          },
+        },
+      },
+      {
+        model: {
+          current: () => undefined,
+          set() {},
+        },
+      },
+    )
+
+    expect(restored).toBe(false)
+    expect(calls).toEqual([])
+  })
+})

+ 47 - 0
packages/app/src/pages/session/session-model-helpers.ts

@@ -7,6 +7,24 @@ type Local = {
   }
 }
 
+type ModelSelection = {
+  model: {
+    current(): { id: string; provider: { id: string } } | undefined
+    set(model: { providerID: string; modelID: string }): void
+    variant: {
+      current(): string | undefined
+      set(variant: string | undefined): void
+    }
+  }
+}
+
+type PromptState = {
+  model: {
+    current(): { providerID: string; modelID: string; variant?: string | null } | undefined
+    set(model: { providerID: string; modelID: string; variant?: string | null }): void
+  }
+}
+
 export const resetSessionModel = (local: Local) => {
   local.session.reset()
 }
@@ -14,3 +32,32 @@ export const resetSessionModel = (local: Local) => {
 export const syncSessionModel = (local: Local, msg: UserMessage) => {
   local.session.restore(msg)
 }
+
+export const syncPromptModel = (local: ModelSelection, prompt: PromptState) => {
+  const model = local.model.current()
+  if (!model) return
+  const next = {
+    providerID: model.provider.id,
+    modelID: model.id,
+    variant: local.model.variant.current(),
+  }
+  const current = prompt.model.current()
+  if (current?.providerID === next.providerID && current.modelID === next.modelID && current.variant === next.variant)
+    return
+  prompt.model.set(next)
+}
+
+export const restorePromptModel = (local: ModelSelection, prompt: PromptState) => {
+  const model = prompt.model.current()
+  if (!model) return false
+  const current = local.model.current()
+  if (
+    current?.provider.id === model.providerID &&
+    current.id === model.modelID &&
+    local.model.variant.current() === (model.variant ?? undefined)
+  )
+    return true
+  local.model.set({ providerID: model.providerID, modelID: model.modelID })
+  local.model.variant.set(model.variant ?? undefined)
+  return true
+}

+ 5 - 4
packages/app/src/pages/session/use-composer-commands.tsx

@@ -1,6 +1,6 @@
 import { useCommand, type CommandOption } from "@/context/command"
 import { useLanguage } from "@/context/language"
-import { useLocal } from "@/context/local"
+import { useLocal, type ModelSelection } from "@/context/local"
 import { useSettings } from "@/context/settings"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { getCursorPosition, setCursorPosition } from "@/components/prompt-input/editor-dom"
@@ -14,7 +14,7 @@ const withCategory = (category: string) => {
   })
 }
 
-export const useComposerCommands = () => {
+export const useComposerCommands = (input: { model?: ModelSelection } = {}) => {
   const command = useCommand()
   const dialog = useDialog()
   const language = useLanguage()
@@ -22,6 +22,7 @@ export const useComposerCommands = () => {
   const settings = useSettings()
   const { sessionKey } = useSessionLayout()
   const sessionOwnership = createSessionOwnership(sessionKey)
+  const model = input.model ?? local.model
   const modelCommand = withCategory(language.t("command.category.model"))
   const agentCommand = withCategory(language.t("command.category.agent"))
 
@@ -43,7 +44,7 @@ export const useComposerCommands = () => {
     }
     const { DialogSelectModel } = await import("@/components/dialog-select-model")
     owner.run(() => {
-      void dialog.show(() => <DialogSelectModel model={local.model} />, restoreComposer)
+      void dialog.show(() => <DialogSelectModel model={model} />, restoreComposer)
     })
   }
 
@@ -61,7 +62,7 @@ export const useComposerCommands = () => {
       title: language.t("command.model.variant.cycle"),
       description: language.t("command.model.variant.cycle.description"),
       keybind: "shift+mod+d",
-      onSelect: () => local.model.variant.cycle(),
+      onSelect: () => model.variant.cycle(),
     }),
     agentCommand({
       id: "agent.cycle",

+ 1 - 1
packages/app/src/utils/persist.ts

@@ -463,7 +463,7 @@ function localStorageDirect(): SyncStorage {
   }
 }
 
-const DRAFT_PERSISTED_KEYS = ["prompt", "comments", "model-selection", "file-view", "layout"]
+const DRAFT_PERSISTED_KEYS = ["prompt", "comments", "file-view", "layout"]
 
 export function draftPersistedKeys() {
   return DRAFT_PERSISTED_KEYS