Pārlūkot izejas kodu

feat(app): add prompt input story (#32308)

Brendan Allan 2 mēneši atpakaļ
vecāks
revīzija
d37ddc501c

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

@@ -0,0 +1,117 @@
+// @ts-nocheck
+import { createStore } from "solid-js/store"
+import { createPromptState } from "@/context/prompt"
+import { createPromptInputHistory, PromptInput } from "./prompt-input"
+
+function PromptInputExample() {
+  const state = createPromptState()
+  const history = createPromptInputHistory()
+  const [controls, setControls] = createStore({
+    agent: "build",
+    variant: undefined as string | undefined,
+    comments: 0,
+    tabs: [] as string[],
+    activeTab: undefined as string | undefined,
+    reviewOpen: false,
+  })
+  const model = {
+    current: () => ({ id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", provider: { id: "anthropic" } }),
+    variant: {
+      list: () => ["fast", "thinking"],
+      current: () => controls.variant,
+      set: (variant?: string) => setControls("variant", variant),
+    },
+  }
+  const submission = {
+    abort() {},
+    handleSubmit(event: Event) {
+      event.preventDefault()
+      state.reset()
+    },
+  }
+  const inputControls = {
+    agents: {
+      available: [{ name: "review", hidden: false, mode: "subagent" }],
+      options: ["build", "review", "plan"],
+      get current() {
+        return controls.agent
+      },
+      loading: false,
+      visible: true,
+      select: (agent?: string) => setControls("agent", agent ?? "build"),
+    },
+    model: {
+      selection: model,
+      paid: true,
+      loading: false,
+    },
+    projects: {
+      available: [{ name: "Story project", worktree: "/tmp/story", sandboxes: [] }],
+      directory: "/tmp/story",
+      select() {},
+      add() {},
+    },
+    session: {
+      id: "story-session",
+      tabs: {
+        active: () => controls.activeTab,
+        all: () => controls.tabs,
+        open: (tab: string) => setControls("tabs", (tabs) => (tabs.includes(tab) ? tabs : [...tabs, tab])),
+        setActive: (tab: string) => setControls("activeTab", tab),
+      },
+      reviewPanel: {
+        opened: () => controls.reviewOpen,
+        open: () => setControls("reviewOpen", true),
+      },
+    },
+    newLayoutDesigns: true,
+  }
+  const addReviewComment = () => {
+    const comment = controls.comments + 1
+    setControls("comments", comment)
+    state.context.add({
+      type: "file",
+      path: "src/components/prompt-input.tsx",
+      selection: {
+        startLine: 84 + comment,
+        startChar: 0,
+        endLine: 84 + comment,
+        endChar: 0,
+      },
+      comment: `Review comment ${comment}`,
+      commentID: `review-comment-${comment}`,
+      commentOrigin: "review",
+      preview: "export const PromptInput = ...",
+    })
+  }
+
+  return (
+    <div class="flex flex-col gap-3">
+      <PromptInput controls={inputControls} state={state} history={history} submission={submission} />
+      <div>
+        <button
+          type="button"
+          class="rounded-md border border-border-weak-base bg-background-base px-2.5 py-1.5 text-12-medium text-text-base hover:bg-background-stronger"
+          onClick={addReviewComment}
+        >
+          Add review comment
+        </button>
+      </div>
+    </div>
+  )
+}
+
+export default {
+  title: "App/PromptInput",
+  id: "app-prompt-input",
+  component: PromptInput,
+}
+
+export const Basic = {
+  render: () => (
+    <div class="pt-10">
+      <h1 class="mb-4">Prompt Input</h1>
+      <PromptInputExample />
+    </div>
+  ),
+}

+ 178 - 147
packages/app/src/components/prompt-input.tsx

@@ -17,8 +17,8 @@ import {
   type JSX,
 } from "solid-js"
 import { Popover as KobaltePopover } from "@kobalte/core/popover"
-import { createStore } from "solid-js/store"
-import { useLocal } from "@/context/local"
+import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
+import type { useLocal } from "@/context/local"
 import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
 import {
   ContentPart,
@@ -31,10 +31,7 @@ import {
   FileAttachmentPart,
 } from "@/context/prompt"
 import { useLayout } from "@/context/layout"
-import { useNavigate, useSearchParams } from "@solidjs/router"
 import { useSDK } from "@/context/sdk"
-import { useServer } from "@/context/server"
-import { useTabs } from "@/context/tabs"
 import { useSync } from "@/context/sync"
 import { useComments } from "@/context/comments"
 import { Button } from "@opencode-ai/ui/button"
@@ -46,14 +43,11 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
 import { Select } from "@opencode-ai/ui/select"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { ModelSelectorPopover } from "@/components/dialog-select-model"
-import { useProviders } from "@/hooks/use-providers"
 import { useCommand } from "@/context/command"
 import { Persist, persisted } from "@/utils/persist"
 import { usePermission } from "@/context/permission"
 import { useLanguage } from "@/context/language"
 import { usePlatform } from "@/context/platform"
-import { useSettings } from "@/context/settings"
-import { useSessionLayout } from "@/pages/session/session-layout"
 import { createSessionTabs } from "@/pages/session/helpers"
 import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
 import { createPromptAttachments } from "./prompt-input/attachments"
@@ -73,18 +67,104 @@ 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 { useDirectoryPicker } from "./directory-picker"
 import { showToast } from "@/utils/toast"
 import { ImagePreview } from "@opencode-ai/ui/image-preview"
-import { useQueries } from "@tanstack/solid-query"
-import { useQueryOptions } from "@/context/server-sync"
 import { pathKey } from "@/utils/path-key"
-import { base64Encode } from "@opencode-ai/core/util/encode"
 import { displayName } from "@/pages/layout/helpers"
 
-interface PromptInputProps {
+export type PromptInputState = ReturnType<typeof usePrompt>
+
+export type PromptInputHistory = {
+  entries: (mode: "normal" | "shell") => PromptHistoryStoredEntry[]
+  add: (prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) => void
+}
+
+export type PromptInputSubmission = {
+  abort: () => Promise<void> | void
+  handleSubmit: (event: Event) => Promise<void> | void
+}
+
+export type PromptInputControls = {
+  agents: {
+    available: { name: string; hidden?: boolean; mode: string }[]
+    options: string[]
+    current: string
+    loading: boolean
+    visible: boolean
+    select: (name: string | undefined) => void
+  }
+  model: {
+    selection: ReturnType<typeof useLocal>["model"]
+    paid: boolean
+    loading: boolean
+  }
+  projects: {
+    available: { name?: string; worktree: string; sandboxes?: string[] }[]
+    directory: string
+    select: (worktree: string) => void
+    add: (title: string) => void
+  }
+  session: {
+    id?: string
+    tabs: {
+      active: () => string | undefined
+      all: () => string[]
+      open: (tab: string) => void | Promise<void>
+      setActive: (tab: string) => void
+    }
+    reviewPanel: {
+      opened: () => boolean
+      open: () => void
+    }
+  }
+  newLayoutDesigns: boolean
+}
+
+export function createPromptInputHistory(): PromptInputHistory {
+  const [normal, setNormal] = createStore<PromptHistoryState>({ entries: [] })
+  const [shell, setShell] = createStore<PromptHistoryState>({ entries: [] })
+  return createPromptInputHistoryStore(normal, setNormal, shell, setShell)
+}
+
+type PromptHistoryState = { entries: PromptHistoryStoredEntry[] }
+
+function createPromptInputHistoryStore(
+  normal: Store<PromptHistoryState>,
+  setNormal: SetStoreFunction<PromptHistoryState>,
+  shell: Store<PromptHistoryState>,
+  setShell: SetStoreFunction<PromptHistoryState>,
+): PromptInputHistory {
+  return {
+    entries: (mode) => (mode === "shell" ? shell.entries : normal.entries),
+    add(prompt, mode, comments) {
+      const current = mode === "shell" ? shell : normal
+      const setCurrent = mode === "shell" ? setShell : setNormal
+      const next = prependHistoryEntry(current.entries, prompt, comments)
+      if (next === current.entries) return
+      setCurrent("entries", next)
+    },
+  }
+}
+
+function createPersistedPromptInputHistory() {
+  const [normal, setNormal] = persisted(
+    Persist.global("prompt-history", ["prompt-history.v1"]),
+    createStore<PromptHistoryState>({ entries: [] }),
+  )
+  const [shell, setShell] = persisted(
+    Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]),
+    createStore<PromptHistoryState>({ entries: [] }),
+  )
+  return createPromptInputHistoryStore(normal, setNormal, shell, setShell)
+}
+
+export interface PromptInputProps {
   class?: string
   variant?: "dock" | "new-session"
+  state?: PromptInputState
+  history?: PromptInputHistory
+  submission?: PromptInputSubmission
+  controls: PromptInputControls
   ref?: (el: HTMLDivElement) => void
   newSessionWorktree?: string
   onNewSessionWorktreeReset?: () => void
@@ -126,27 +206,18 @@ const EXAMPLES = [
 
 export const PromptInput: Component<PromptInputProps> = (props) => {
   const sdk = useSDK()
-  const navigate = useNavigate()
-  const queryOptions = useQueryOptions()
 
   const sync = useSync()
-  const local = useLocal()
   const files = useFile()
-  const prompt = usePrompt()
+  const prompt = props.state ?? usePrompt()
   const layout = useLayout()
-  const server = useServer()
   const comments = useComments()
   const dialog = useDialog()
-  const providers = useProviders()
   const command = useCommand()
   const permission = usePermission()
   const language = useLanguage()
   const platform = usePlatform()
-  const pickDirectory = useDirectoryPicker()
-  const settings = useSettings()
-  const tabsStore = useTabs()
-  const [search] = useSearchParams<{ draftId?: string }>()
-  const { params, tabs, view } = useSessionLayout()
+  const tabs = () => props.controls.session.tabs
   let editorRef!: HTMLDivElement
   let fileInputRef: HTMLInputElement | undefined
   let scrollRef!: HTMLDivElement
@@ -204,7 +275,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   }).activeFileTab
 
   const commentInReview = (path: string) => {
-    const sessionID = params.id
+    const sessionID = props.controls.session.id
     if (!sessionID) return false
 
     const diffs = sync().data.session_diff[sessionID]
@@ -237,14 +308,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
 
     const wantsReview = item.commentOrigin === "review" || (item.commentOrigin !== "file" && commentInReview(item.path))
     if (wantsReview) {
-      if (!view().reviewPanel.opened()) view().reviewPanel.open()
+      if (!props.controls.session.reviewPanel.opened()) props.controls.session.reviewPanel.open()
       layout.fileTree.setTab("changes")
       tabs().setActive("review")
       queueCommentFocus()
       return
     }
 
-    if (!view().reviewPanel.opened()) view().reviewPanel.open()
+    if (!props.controls.session.reviewPanel.opened()) props.controls.session.reviewPanel.open()
     layout.fileTree.setTab("all")
     const tab = files.tab(item.path)
     void tabs().open(tab)
@@ -269,8 +340,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
 
     return paths
   })
-  const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))
-  const working = createMemo(() => sync().data.session_working(params.id ?? ""))
+  const info = createMemo(() =>
+    props.controls.session.id ? sync().session.get(props.controls.session.id) : undefined,
+  )
+  const working = createMemo(() => sync().data.session_working(props.controls.session.id ?? ""))
   const imageAttachments = createMemo(() =>
     prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
   )
@@ -347,29 +420,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   })
 
   const hasUserPrompt = createMemo(() => {
-    const sessionID = params.id
+    const sessionID = props.controls.session.id
     if (!sessionID) return false
     const messages = sync().data.message[sessionID]
     if (!messages) return false
     return messages.some((m) => m.role === "user")
   })
 
-  const [history, setHistory] = persisted(
-    Persist.global("prompt-history", ["prompt-history.v1"]),
-    createStore<{
-      entries: PromptHistoryStoredEntry[]
-    }>({
-      entries: [],
-    }),
-  )
-  const [shellHistory, setShellHistory] = persisted(
-    Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]),
-    createStore<{
-      entries: PromptHistoryStoredEntry[]
-    }>({
-      entries: [],
-    }),
-  )
+  const history = props.history ?? createPersistedPromptInputHistory()
 
   const suggest = createMemo(() => !hasUserPrompt())
 
@@ -573,8 +631,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   }
 
   createEffect(() => {
-    params.id
-    if (params.id) return
+    props.controls.session.id
+    if (props.controls.session.id) return
     if (!suggest()) return
     const interval = setInterval(() => {
       setStore("placeholder", (prev) => (prev + 1) % EXAMPLES.length)
@@ -603,11 +661,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   }
 
   const agentList = createMemo(() =>
-    sync()
-      .data.agent.filter((agent) => !agent.hidden && agent.mode !== "primary")
+    props.controls.agents.available
+      .filter((agent) => !agent.hidden && agent.mode !== "primary")
       .map((agent): AtOption => ({ type: "agent", name: agent.name, display: agent.name })),
   )
-  const agentNames = createMemo(() => local.agent.list().map((agent) => agent.name))
 
   const handleAtSelect = (option: AtOption | undefined) => {
     if (!option) return
@@ -1033,11 +1090,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   }
 
   const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
-    const currentHistory = mode === "shell" ? shellHistory : history
-    const setCurrentHistory = mode === "shell" ? setShellHistory : setHistory
-    const next = prependHistoryEntry(currentHistory.entries, prompt, mode === "shell" ? [] : historyComments())
-    if (next === currentHistory.entries) return
-    setCurrentHistory("entries", next)
+    history.add(prompt, mode, mode === "shell" ? [] : historyComments())
   }
 
   createEffect(
@@ -1082,7 +1135,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   const navigateHistory = (direction: "up" | "down") => {
     const result = navigatePromptHistory({
       direction,
-      entries: store.mode === "shell" ? shellHistory.entries : history.entries,
+      entries: history.entries(store.mode),
       historyIndex: store.historyIndex,
       currentPrompt: prompt.current(),
       currentComments: historyComments(),
@@ -1096,6 +1149,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   }
 
   const { addAttachment, addAttachments, removeAttachment, handlePaste } = createPromptAttachments({
+    prompt,
     editor: () => editorRef,
     isDialogActive: () => !!dialog.active,
     setDraggingType: (type) => setStore("draggingType", type),
@@ -1122,38 +1176,41 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     />
   )
 
-  const variants = createMemo(() => ["default", ...local.model.variant.list()])
+  const variants = createMemo(() => ["default", ...props.controls.model.selection.variant.list()])
   // Check provider variants directly: `variants` also includes the UI-only default option.
-  const showVariantControl = createMemo(() => local.model.variant.list().length > 0)
+  const showVariantControl = createMemo(() => props.controls.model.selection.variant.list().length > 0)
   const accepting = createMemo(() => {
-    const id = params.id
+    const id = props.controls.session.id
     if (!id) return permission.isAutoAcceptingDirectory(sdk().directory)
     return permission.isAutoAccepting(id, sdk().directory)
   })
 
-  const { abort, handleSubmit } = createPromptSubmit({
-    info,
-    imageAttachments,
-    commentCount,
-    autoAccept: () => accepting(),
-    mode: () => store.mode,
-    working,
-    editor: () => editorRef,
-    queueScroll,
-    promptLength,
-    addToHistory,
-    resetHistoryNavigation: () => {
-      resetHistoryNavigation(true)
-    },
-    setMode: (mode) => setStore("mode", mode),
-    setPopover: (popover) => setStore("popover", popover),
-    newSessionWorktree: () => props.newSessionWorktree,
-    onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
-    shouldQueue: props.shouldQueue,
-    onQueue: props.onQueue,
-    onAbort: props.onAbort,
-    onSubmit: props.onSubmit,
-  })
+  const { abort, handleSubmit } =
+    props.submission ??
+    createPromptSubmit({
+      prompt,
+      info,
+      imageAttachments,
+      commentCount,
+      autoAccept: () => accepting(),
+      mode: () => store.mode,
+      working,
+      editor: () => editorRef,
+      queueScroll,
+      promptLength,
+      addToHistory,
+      resetHistoryNavigation: () => {
+        resetHistoryNavigation(true)
+      },
+      setMode: (mode) => setStore("mode", mode),
+      setPopover: (popover) => setStore("popover", popover),
+      newSessionWorktree: () => props.newSessionWorktree,
+      onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
+      shouldQueue: props.shouldQueue,
+      onQueue: props.onQueue,
+      onAbort: props.onAbort,
+      onSubmit: props.onSubmit,
+    })
 
   const handleKeyDown = (event: KeyboardEvent) => {
     if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "u") {
@@ -1317,17 +1374,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     }
   }
 
-  const [agentsQuery, globalProvidersQuery, providersQuery] = useQueries(() => ({
-    queries: [
-      queryOptions().agents(pathKey(sdk().directory)),
-      queryOptions().providers(null),
-      queryOptions().providers(pathKey(sdk().directory)),
-    ],
-  }))
-
-  const agentsLoading = () => agentsQuery.isLoading
+  const agentsLoading = () => props.controls.agents.loading
   const agentsShouldFadeIn = createMemo((prev) => prev ?? agentsLoading())
-  const providersLoading = () => agentsLoading() || providersQuery.isLoading || globalProvidersQuery.isLoading
+  const providersLoading = () => props.controls.model.loading
   const providersShouldFadeIn = createMemo((prev) => prev ?? providersLoading())
 
   const [promptReady] = createResource(
@@ -1342,23 +1391,23 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
 
   const modelControlState = createMemo<ComposerModelControlState>(() => ({
     loading: providersLoading(),
-    paid: providers.paid().length > 0,
+    paid: props.controls.model.paid,
     title: language.t("command.model.choose"),
     keybind: command.keybind("model.choose"),
-    model: local.model,
-    providerID: local.model.current()?.provider?.id,
-    modelName: local.model.current()?.name ?? language.t("dialog.model.select.title"),
+    model: props.controls.model.selection,
+    providerID: props.controls.model.selection.current()?.provider?.id,
+    modelName: props.controls.model.selection.current()?.name ?? language.t("dialog.model.select.title"),
     style: control(),
     onClose: restoreFocus,
     onUnpaidClick: () => {
       void import("@/components/dialog-select-model-unpaid").then((x) => {
-        dialog.show(() => <x.DialogSelectModelUnpaid model={local.model} />)
+        dialog.show(() => <x.DialogSelectModelUnpaid model={props.controls.model.selection} />)
       })
     },
   }))
 
   const newSession = () => props.variant === "new-session"
-  const projects = createMemo(() => layout.projects.list())
+  const projects = createMemo(() => props.controls.projects.available)
   const projectForDirectory = (directory: string | undefined) => {
     if (!directory) return
     const key = pathKey(directory)
@@ -1366,13 +1415,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       (project) => pathKey(project.worktree) === key || project.sandboxes?.some((sandbox) => pathKey(sandbox) === key),
     )
   }
-  const selectedProject = createMemo(() => projectForDirectory(sdk().directory))
+  const selectedProject = createMemo(() => projectForDirectory(props.controls.projects.directory))
   const projectResults = createMemo(() => {
     const search = picker.projectSearch.trim().toLowerCase()
     if (!search) return projects()
     return projects().filter((project) => displayName(project).toLowerCase().includes(search))
   })
-  const showAgentControl = createMemo(() => settings.visibility.customAgents() && agentNames().length > 0)
+  const showAgentControl = createMemo(() => props.controls.agents.visible && props.controls.agents.options.length > 0)
   const selectProject = (worktree: string) => {
     setPicker({
       projectOpen: false,
@@ -1382,33 +1431,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       restoreFocus()
       return
     }
-    layout.projects.open(worktree)
-    server.projects.touch(worktree)
-
-    // On the draft route, retarget the existing draft in place so we keep the same
-    // draft id (and its tab/prompt) instead of spawning a new draft for the new directory.
-    const draftID = search.draftId
-    if (draftID) {
-      tabsStore.updateDraft(draftID, { server: server.key, directory: worktree })
-      restoreFocus()
-      return
-    }
-
-    navigate(`/${base64Encode(worktree)}/session`)
+    props.controls.projects.select(worktree)
+    restoreFocus()
   }
   const addProject = () => {
-    const conn = server.current
-    if (!conn) return
-    const select = (result: string | string[] | null) => {
-      const directory = Array.isArray(result) ? result[0] : result
-      if (!directory) return
-      selectProject(directory)
-    }
-    pickDirectory({
-      server: conn,
-      title: language.t("command.project.open"),
-      onSelect: select,
-    })
+    props.controls.projects.add(language.t("command.project.open"))
   }
 
   const projectPickerState = createMemo<ComposerPickerState>(() => ({
@@ -1449,11 +1476,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   const agentControlState = createMemo<ComposerAgentControlState>(() => ({
     title: language.t("command.agent.cycle"),
     keybind: command.keybind("agent.cycle"),
-    options: agentNames(),
-    current: local.agent.current()?.name ?? "",
+    options: props.controls.agents.options,
+    current: props.controls.agents.current,
     style: control(),
     onSelect: (value) => {
-      local.agent.set(value)
+      props.controls.agents.select(value)
       restoreFocus()
     },
   }))
@@ -1485,7 +1512,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
         t={(key) => language.t(key as Parameters<typeof language.t>[0])}
       />
       <Switch>
-        <Match when={settings.general.newLayoutDesigns()}>
+        <Match when={props.controls.newLayoutDesigns}>
           <div class="flex flex-col gap-3">
             <DockShellForm
               data-component={newSession() ? "session-new-composer" : "session-composer"}
@@ -1605,7 +1632,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                       data-component="prompt-variant-control"
                       classList={{
                         "hidden group-hover/prompt-input:block group-focus-within/prompt-input:block":
-                          !local.model.variant.current() && !store.variantOpen,
+                          !props.controls.model.selection.variant.current() && !store.variantOpen,
                       }}
                     >
                       <TooltipKeybind
@@ -1617,11 +1644,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                         <Select
                           size="normal"
                           options={variants()}
-                          current={local.model.variant.current() ?? "default"}
+                          current={props.controls.model.selection.variant.current() ?? "default"}
                           label={(x) => (x === "default" ? language.t("common.default") : x)}
                           onOpenChange={(open) => setStore("variantOpen", open)}
                           onSelect={(value) => {
-                            local.model.variant.set(value === "default" ? undefined : value)
+                            props.controls.model.selection.variant.set(value === "default" ? undefined : value)
                             restoreFocus()
                           }}
                           class="capitalize max-w-[160px] justify-start text-v2-text-text-faint"
@@ -1861,10 +1888,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                         >
                           <Select
                             size="normal"
-                            options={agentNames()}
-                            current={local.agent.current()?.name ?? ""}
+                            options={props.controls.agents.options}
+                            current={props.controls.agents.current}
                             onSelect={(value) => {
-                              local.agent.set(value)
+                              props.controls.agents.select(value)
                               restoreFocus()
                             }}
                             class="capitalize max-w-[160px] text-text-base"
@@ -1883,7 +1910,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                           style={providersShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
                         >
                           <Show
-                            when={providers.paid().length > 0}
+                            when={props.controls.model.paid}
                             fallback={
                               <TooltipKeybind
                                 placement="top"
@@ -1900,19 +1927,22 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                                   style={control()}
                                   onClick={() => {
                                     void import("@/components/dialog-select-model-unpaid").then((x) => {
-                                      dialog.show(() => <x.DialogSelectModelUnpaid model={local.model} />)
+                                      dialog.show(() => (
+                                        <x.DialogSelectModelUnpaid model={props.controls.model.selection} />
+                                      ))
                                     })
                                   }}
                                 >
-                                  <Show when={local.model.current()?.provider?.id}>
+                                  <Show when={props.controls.model.selection.current()?.provider?.id}>
                                     <ProviderIcon
-                                      id={local.model.current()?.provider?.id ?? ""}
+                                      id={props.controls.model.selection.current()?.provider?.id ?? ""}
                                       class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
                                       style={{ "will-change": "opacity", transform: "translateZ(0)" }}
                                     />
                                   </Show>
                                   <span class="truncate">
-                                    {local.model.current()?.name ?? language.t("dialog.model.select.title")}
+                                    {props.controls.model.selection.current()?.name ??
+                                      language.t("dialog.model.select.title")}
                                   </span>
                                   <Icon name="chevron-down" size="small" class="shrink-0" />
                                 </Button>
@@ -1926,7 +1956,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                               keybind={command.keybind("model.choose")}
                             >
                               <ModelSelectorPopover
-                                model={local.model}
+                                model={props.controls.model.selection}
                                 triggerAs={Button}
                                 triggerProps={{
                                   variant: "ghost",
@@ -1937,15 +1967,16 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                                 }}
                                 onClose={restoreFocus}
                               >
-                                <Show when={local.model.current()?.provider?.id}>
+                                <Show when={props.controls.model.selection.current()?.provider?.id}>
                                   <ProviderIcon
-                                    id={local.model.current()?.provider?.id ?? ""}
+                                    id={props.controls.model.selection.current()?.provider?.id ?? ""}
                                     class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
                                     style={{ "will-change": "opacity", transform: "translateZ(0)" }}
                                   />
                                 </Show>
                                 <span class="truncate">
-                                  {local.model.current()?.name ?? language.t("dialog.model.select.title")}
+                                  {props.controls.model.selection.current()?.name ??
+                                    language.t("dialog.model.select.title")}
                                 </span>
                                 <Icon name="chevron-down" size="small" class="shrink-0" />
                               </ModelSelectorPopover>
@@ -1966,10 +1997,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                               <Select
                                 size="normal"
                                 options={variants()}
-                                current={local.model.variant.current() ?? "default"}
+                                current={props.controls.model.selection.variant.current() ?? "default"}
                                 label={(x) => (x === "default" ? language.t("common.default") : x)}
                                 onSelect={(value) => {
-                                  local.model.variant.set(value === "default" ? undefined : value)
+                                  props.controls.model.selection.variant.set(value === "default" ? undefined : value)
                                   restoreFocus()
                                 }}
                                 class="capitalize max-w-[160px] text-text-base"

+ 3 - 2
packages/app/src/components/prompt-input/attachments.ts

@@ -1,7 +1,7 @@
 import { onMount } from "solid-js"
 import { makeEventListener } from "@solid-primitives/event-listener"
 import { showToast } from "@/utils/toast"
-import { usePrompt, type ContentPart, type ImageAttachmentPart } from "@/context/prompt"
+import { type ContentPart, type ImageAttachmentPart, type usePrompt } from "@/context/prompt"
 import { useLanguage } from "@/context/language"
 import { uuid } from "@/utils/uuid"
 import { getCursorPosition } from "./editor-dom"
@@ -26,6 +26,7 @@ function dataUrl(file: File, mime: string) {
 }
 
 type PromptAttachmentsInput = {
+  prompt: ReturnType<typeof usePrompt>
   editor: () => HTMLDivElement | undefined
   isDialogActive: () => boolean
   setDraggingType: (type: "image" | "@mention" | null) => void
@@ -35,7 +36,7 @@ type PromptAttachmentsInput = {
 }
 
 export function createPromptAttachments(input: PromptAttachmentsInput) {
-  const prompt = usePrompt()
+  const prompt = input.prompt
   const language = useLanguage()
 
   const warn = () => {

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

@@ -50,7 +50,7 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
                 >
                   <div class="flex items-center gap-1.5">
                     <FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" />
-                    <div class="flex items-center text-11-regular min-w-0 font-medium">
+                    <div class="flex items-center text-[12px] min-w-0 font-medium leading-5">
                       <span class="text-text-strong whitespace-nowrap">{label}</span>
                       <Show when={item.selection}>
                         {(sel) => (

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

@@ -26,6 +26,22 @@ let selected = "/repo/worktree-a"
 let variant: string | undefined
 
 const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
+const prompt = {
+  ready: () => Object.assign(() => true, { promise: Promise.resolve(true) }),
+  current: () => promptValue,
+  cursor: () => 0,
+  dirty: () => true,
+  reset: () => undefined,
+  set: () => undefined,
+  context: {
+    add: () => undefined,
+    remove: () => undefined,
+    removeComment: () => undefined,
+    updateComment: () => undefined,
+    replaceComments: () => undefined,
+    items: () => [],
+  },
+}
 
 const clientFor = (directory: string) => {
   createdClients.push(directory)
@@ -73,6 +89,7 @@ beforeAll(async () => {
   }))
 
   mock.module("@opencode-ai/ui/toast", () => ({
+    Toast: { Region: () => null },
     showToast: () => 0,
   }))
 
@@ -116,16 +133,7 @@ beforeAll(async () => {
   }))
 
   mock.module("@/context/prompt", () => ({
-    usePrompt: () => ({
-      current: () => promptValue,
-      reset: () => undefined,
-      set: () => undefined,
-      context: {
-        add: () => undefined,
-        remove: () => undefined,
-        items: () => [],
-      },
-    }),
+    usePrompt: () => prompt,
   }))
 
   mock.module("@/context/layout", () => ({
@@ -232,6 +240,7 @@ beforeEach(() => {
 describe("prompt submit worktree selection", () => {
   test("reads the latest worktree accessor value per submit", async () => {
     const submit = createPromptSubmit({
+      prompt,
       info: () => undefined,
       imageAttachments: () => [],
       commentCount: () => 0,
@@ -269,6 +278,7 @@ describe("prompt submit worktree selection", () => {
 
   test("applies auto-accept to newly created sessions", async () => {
     const submit = createPromptSubmit({
+      prompt,
       info: () => undefined,
       imageAttachments: () => [],
       commentCount: () => 0,
@@ -299,6 +309,7 @@ describe("prompt submit worktree selection", () => {
     variant = "high"
 
     const submit = createPromptSubmit({
+      prompt,
       info: () => ({ id: "session-1" }),
       imageAttachments: () => [],
       commentCount: () => 0,
@@ -330,6 +341,7 @@ describe("prompt submit worktree selection", () => {
 
   test("seeds new sessions before optimistic prompts are added", async () => {
     const submit = createPromptSubmit({
+      prompt,
       info: () => undefined,
       imageAttachments: () => [],
       commentCount: () => 0,

+ 3 - 2
packages/app/src/components/prompt-input/submit.ts

@@ -12,7 +12,7 @@ import { useLanguage } from "@/context/language"
 import { useLayout } from "@/context/layout"
 import { useLocal } from "@/context/local"
 import { usePermission } from "@/context/permission"
-import { type ContextItem, type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt"
+import { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt } from "@/context/prompt"
 import { useSDK, type DirectorySDK } from "@/context/sdk"
 import { useSync, type DirectorySync } from "@/context/sync"
 import { Identifier } from "@/utils/id"
@@ -174,6 +174,7 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
 }
 
 type PromptSubmitInput = {
+  prompt: ReturnType<typeof usePrompt>
   info: Accessor<{ id: string } | undefined>
   imageAttachments: Accessor<ImageAttachmentPart[]>
   commentCount: Accessor<number>
@@ -211,7 +212,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
   const serverSync = useServerSync()
   const local = useLocal()
   const permission = usePermission()
-  const prompt = usePrompt()
+  const prompt = input.prompt
   const layout = useLayout()
   const language = useLanguage()
   const params = useParams()

+ 32 - 14
packages/app/src/context/prompt.tsx

@@ -153,6 +153,14 @@ 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 }
 
 function scopeKey(scope: Scope) {
@@ -174,25 +182,26 @@ function promptTarget(serverScope: ServerScope, scope: Scope) {
 function createPromptSession(serverScope: ServerScope, scope: Scope) {
   const [store, setStore, _, ready] = persisted(
     promptTarget(serverScope, scope),
-    createStore<{
-      prompt: Prompt
-      cursor?: number
-      context: {
-        items: (ContextItem & { key: string })[]
-      }
-    }>({
-      prompt: clonePrompt(DEFAULT_PROMPT),
-      cursor: undefined,
-      context: {
-        items: [],
-      },
-    }),
+    createStore<PromptStore>(promptStore()),
   )
 
+  return { ready, ...createPromptStateValue(store, setStore) }
+}
+
+function promptStore(): PromptStore {
+  return {
+    prompt: clonePrompt(DEFAULT_PROMPT),
+    cursor: undefined,
+    context: {
+      items: [],
+    },
+  }
+}
+
+function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction<PromptStore>) {
   const actions = createPromptActions(setStore)
 
   return {
-    ready,
     current: () => store.prompt,
     cursor: createMemo(() => store.cursor),
     dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT),
@@ -232,6 +241,15 @@ function createPromptSession(serverScope: ServerScope, scope: Scope) {
   }
 }
 
+export function createPromptState() {
+  const [store, setStore] = createStore<PromptStore>(promptStore())
+  const ready = Object.assign(() => true, { promise: Promise.resolve(true) })
+  return {
+    ready: () => ready,
+    ...createPromptStateValue(store, setStore),
+  }
+}
+
 export const { use: usePrompt, provider: PromptProvider } = createSimpleContext({
   name: "Prompt",
   gate: false,

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

@@ -1,6 +1,6 @@
 import { Show, createEffect, createMemo, onCleanup } from "solid-js"
 import { createStore } from "solid-js/store"
-import { useNavigate } from "@solidjs/router"
+import { useNavigate, useSearchParams } from "@solidjs/router"
 import { useSpring } from "@opencode-ai/ui/motion-spring"
 import { useLayout } from "@/context/layout"
 import { PromptInput } from "@/components/prompt-input"
@@ -18,6 +18,17 @@ import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock"
 import type { FollowupDraft } from "@/components/prompt-input/submit"
 import { createResizeObserver } from "@solid-primitives/resize-observer"
 import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
+import { createQuery } from "@tanstack/solid-query"
+import { useQueryOptions } from "@/context/server-sync"
+import { useSDK } from "@/context/sdk"
+import { pathKey } from "@/utils/path-key"
+import { useLocal } from "@/context/local"
+import { useProviders } from "@/hooks/use-providers"
+import { useSettings } from "@/context/settings"
+import { useServer } from "@/context/server"
+import { useTabs } from "@/context/tabs"
+import { useDirectoryPicker } from "@/components/directory-picker"
+import { base64Encode } from "@opencode-ai/core/util/encode"
 
 export function SessionComposerRegion(props: {
   state: SessionComposerState
@@ -54,8 +65,68 @@ export function SessionComposerRegion(props: {
   const language = useLanguage()
   const route = useSessionKey()
   const sync = useSync()
+  const sdk = useSDK()
+  const queryOptions = useQueryOptions()
+  const local = useLocal()
+  const providers = useProviders()
+  const settings = useSettings()
+  const server = useServer()
+  const tabs = useTabs()
+  const pickDirectory = useDirectoryPicker()
+  const [search] = useSearchParams<{ draftId?: string }>()
   const view = layout.view(route.sessionKey)
 
+  const agentsQuery = createQuery(() => queryOptions().agents(pathKey(sdk().directory)))
+  const globalProvidersQuery = createQuery(() => queryOptions().providers(null))
+  const providersQuery = createQuery(() => queryOptions().providers(pathKey(sdk().directory)))
+  const selectProject = (worktree: string) => {
+    layout.projects.open(worktree)
+    server.projects.touch(worktree)
+    if (search.draftId) {
+      tabs.updateDraft(search.draftId, { server: server.key, directory: worktree })
+      return
+    }
+    navigate(`/${base64Encode(worktree)}/session`)
+  }
+  const addProject = (title: string) => {
+    if (!server.current) return
+    pickDirectory({
+      server: server.current,
+      title,
+      onSelect: (result) => {
+        const directory = Array.isArray(result) ? result[0] : result
+        if (directory) selectProject(directory)
+      },
+    })
+  }
+  const controls = createMemo(() => ({
+    agents: {
+      available: sync().data.agent,
+      options: local.agent.list().map((agent) => agent.name),
+      current: local.agent.current()?.name ?? "",
+      loading: agentsQuery.isLoading,
+      visible: settings.visibility.customAgents(),
+      select: local.agent.set,
+    },
+    model: {
+      selection: local.model,
+      paid: providers.paid().length > 0,
+      loading: agentsQuery.isLoading || providersQuery.isLoading || globalProvidersQuery.isLoading,
+    },
+    projects: {
+      available: layout.projects.list(),
+      directory: sdk().directory,
+      select: selectProject,
+      add: addProject,
+    },
+    session: {
+      id: route.params.id,
+      tabs: layout.tabs(route.sessionKey),
+      reviewPanel: view.reviewPanel,
+    },
+    newLayoutDesigns: settings.general.newLayoutDesigns(),
+  }))
+
   const handoffPrompt = createMemo(() => getSessionHandoff(route.sessionKey())?.prompt)
   const info = createMemo(() => (route.params.id ? sync().session.get(route.params.id) : undefined))
   const parentID = createMemo(() => info()?.parentID)
@@ -263,6 +334,7 @@ export function SessionComposerRegion(props: {
                 fallback={
                   <Show when={!props.state.blocked()}>
                     <PromptInput
+                      controls={controls()}
                       variant={props.placement === "inline" ? "new-session" : undefined}
                       ref={props.inputRef}
                       newSessionWorktree={props.newSessionWorktree}

+ 1 - 1
packages/storybook/.storybook/main.ts

@@ -21,7 +21,7 @@ export default defineMain({
     "@storybook/addon-a11y",
     "@storybook/addon-vitest",
   ],
-  stories: ["../../ui/src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
+  stories: ["../../ui/src/**/*.stories.@(js|jsx|mjs|ts|tsx)", "../../app/src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
   async viteFinal(config) {
     const { mergeConfig, searchForWorkspaceRoot } = await import("vite")
     return mergeConfig(config, {

+ 3 - 0
packages/storybook/.storybook/mocks/app/context/permission.ts

@@ -12,6 +12,9 @@ export function usePermission() {
     isAutoAccepting(sessionID: string, directory?: string) {
       return accepted.has(key(sessionID, directory))
     },
+    isAutoAcceptingDirectory() {
+      return false
+    },
     toggleAutoAccept(sessionID: string, directory?: string) {
       const next = key(sessionID, directory)
       if (accepted.has(next)) {

+ 36 - 28
packages/storybook/.storybook/mocks/app/context/prompt.ts

@@ -1,4 +1,4 @@
-import { createSignal } from "solid-js"
+import { createStore } from "solid-js/store"
 
 interface PartBase {
   content: string
@@ -60,48 +60,50 @@ export function isPromptEqual(a: Prompt, b: Prompt) {
   return a.every((part, i) => JSON.stringify(part) === JSON.stringify(b[i]))
 }
 
-let index = 0
-const [prompt, setPrompt] = createSignal<Prompt>(clonePrompt(DEFAULT_PROMPT))
-const [cursor, setCursor] = createSignal<number>(0)
-const [items, setItems] = createSignal<ContextItem[]>([])
+export function createPromptState() {
+  const [store, setStore] = createStore({
+    prompt: clonePrompt(DEFAULT_PROMPT),
+    cursor: 0,
+    items: [] as ContextItem[],
+  })
+  let index = 0
+  const ready = Object.assign(() => true, { promise: Promise.resolve(true) })
+  const withKey = (item: Omit<ContextItem, "key"> & { key?: string }): ContextItem => ({
+    ...item,
+    key: item.key ?? `ctx:${++index}`,
+  })
 
-const withKey = (item: Omit<ContextItem, "key"> & { key?: string }): ContextItem => ({
-  ...item,
-  key: item.key ?? `ctx:${++index}`,
-})
-
-export function usePrompt() {
   return {
-    ready: () => true,
-    current: prompt,
-    cursor,
-    dirty: () => !isPromptEqual(prompt(), DEFAULT_PROMPT),
+    ready: () => ready,
+    current: () => store.prompt,
+    cursor: () => store.cursor,
+    dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT),
     set(next: Prompt, cursorPosition?: number) {
-      setPrompt(clonePrompt(next))
-      if (cursorPosition !== undefined) setCursor(cursorPosition)
+      setStore("prompt", clonePrompt(next))
+      if (cursorPosition !== undefined) setStore("cursor", cursorPosition)
     },
     reset() {
-      setPrompt(clonePrompt(DEFAULT_PROMPT))
-      setCursor(0)
-      setItems((current) => current.filter((item) => !!item.comment?.trim()))
+      setStore("prompt", clonePrompt(DEFAULT_PROMPT))
+      setStore("cursor", 0)
+      setStore("items", (current) => current.filter((item) => !!item.comment?.trim()))
     },
     context: {
-      items,
+      items: () => store.items,
       add(item: Omit<ContextItem, "key"> & { key?: string }) {
         const next = withKey(item)
-        if (items().some((current) => current.key === next.key)) return
-        setItems((current) => [...current, next])
+        if (store.items.some((current) => current.key === next.key)) return
+        setStore("items", (current) => [...current, next])
       },
       remove(key: string) {
-        setItems((current) => current.filter((item) => item.key !== key))
+        setStore("items", (current) => current.filter((item) => item.key !== key))
       },
       removeComment(path: string, commentID: string) {
-        setItems((current) =>
+        setStore("items", (current) =>
           current.filter((item) => !(item.type === "file" && item.path === path && item.commentID === commentID)),
         )
       },
       updateComment(path: string, commentID: string, next: Partial<ContextItem>) {
-        setItems((current) =>
+        setStore("items", (current) =>
           current.map((item) => {
             if (item.type !== "file" || item.path !== path || item.commentID !== commentID) return item
             return withKey({ ...item, ...next })
@@ -109,9 +111,15 @@ export function usePrompt() {
         )
       },
       replaceComments(next: Array<Omit<ContextItem, "key"> & { key?: string }>) {
-        const nonComment = items().filter((item) => !item.comment?.trim())
-        setItems([...nonComment, ...next.map(withKey)])
+        const nonComment = store.items.filter((item) => !item.comment?.trim())
+        setStore("items", [...nonComment, ...next.map(withKey)])
       },
     },
   }
 }
+
+const prompt = createPromptState()
+
+export function usePrompt() {
+  return prompt
+}

+ 10 - 8
packages/storybook/.storybook/mocks/app/context/sdk.ts

@@ -12,14 +12,16 @@ const make = (directory: string) => ({
 })
 
 const root = "/tmp/story"
+const sdk = {
+  directory: root,
+  scope: "story-server",
+  url: "http://localhost:4096",
+  client: make(root),
+  createClient(input: { directory: string }) {
+    return make(input.directory)
+  },
+}
 
 export function useSDK() {
-  return {
-    directory: root,
-    url: "http://localhost:4096",
-    client: make(root),
-    createClient(input: { directory: string }) {
-      return make(input.directory)
-    },
-  }
+  return () => sdk
 }

+ 17 - 14
packages/storybook/.storybook/mocks/app/context/sync.ts

@@ -9,24 +9,27 @@ const [data, setData] = createStore({
     "story-session": [] as Array<{ id: string; role: string }>,
   } as Record<string, Array<{ id: string; role: string }>>,
   session_status: {} as Record<string, { type: "idle" | "busy" }>,
+  session_working: () => false,
   agent: [{ name: "build", mode: "task", hidden: false }],
   command: [{ name: "fix", description: "Run fix command", source: "project" }],
 })
 
-export function useSync() {
-  return {
-    data,
-    set(...input: unknown[]) {
-      ;(setData as (...args: unknown[]) => void)(...input)
+const sync = {
+  data,
+  set(...input: unknown[]) {
+    ;(setData as (...args: unknown[]) => void)(...input)
+  },
+  session: {
+    get(id: string) {
+      return { id }
     },
-    session: {
-      get(id: string) {
-        return { id }
-      },
-      optimistic: {
-        add() {},
-        remove() {},
-      },
+    optimistic: {
+      add() {},
+      remove() {},
     },
-  }
+  },
+}
+
+export function useSync() {
+  return () => sync
 }

+ 4 - 0
packages/storybook/.storybook/mocks/solid-router.tsx

@@ -11,6 +11,10 @@ export function useNavigate() {
   return () => undefined
 }
 
+export function useSearchParams<T extends Record<string, string>>() {
+  return [{} as Partial<T>, () => undefined] as const
+}
+
 export function useLocation() {
   return {
     pathname: "/story/session/story-session",

+ 1 - 0
packages/storybook/.storybook/preview.tsx

@@ -1,4 +1,5 @@
 import "@opencode-ai/ui/styles/tailwind"
+import "@opencode-ai/ui/v2/styles/tailwind.css"
 
 import { createEffect, onCleanup, onMount } from "solid-js"
 import addonA11y from "@storybook/addon-a11y"