Przeglądaj źródła

refactor(app): split session composer (#33954)

Brendan Allan 2 miesięcy temu
rodzic
commit
a2b847e29e

+ 30 - 22
packages/app/src/components/prompt-input.stories.tsx

@@ -2,7 +2,7 @@
 import { createStore } from "solid-js/store"
 import type { Todo } from "@opencode-ai/sdk/v2"
 import { createPromptState } from "@/context/prompt"
-import { SessionComposerRegion } from "@/pages/session/composer"
+import { SessionComposerRegion, createSessionComposerRegionController } from "@/pages/session/composer"
 import { createPromptInputHistory, PromptInput } from "./prompt-input"
 
 function createPromptInputStoryRuntime() {
@@ -54,12 +54,6 @@ function PromptInputExample() {
       paid: true,
       loading: false,
     },
-    projects: {
-      available: [{ name: "Story project", worktree: "/tmp/story", sandboxes: [] }],
-      directory: "/tmp/story",
-      select() {},
-      add() {},
-    },
     session: {
       id: "story-session",
       tabs: {
@@ -142,7 +136,6 @@ function PromptInputWithOpenDock() {
       paid: true,
       loading: false,
     },
-    projects: { available: [], directory: "/tmp/story", select: () => {}, add: () => {} },
     session: {
       id: "story-session",
       tabs: {
@@ -166,22 +159,37 @@ function PromptInputWithOpenDock() {
     closing: () => false,
     opening: () => false,
   }
-
   return (
     <SessionComposerRegion
-      state={state}
-      sessionKey="story-session"
-      sessionID="story-session"
-      controls={inputControls}
-      promptInput={{ ...input, ref: () => {}, newSessionWorktree: "", onNewSessionWorktreeReset: () => {} }}
-      todo={{
-        collapsed: controls.todoCollapsed,
-        onToggle: () => setControls("todoCollapsed", (collapsed) => !collapsed),
-      }}
-      ready
-      centered={false}
-      onResponseSubmit={() => {}}
-      setPromptDockRef={() => {}}
+      controller={
+        createSessionComposerRegionController({
+          state,
+          sessionKey: () => "story-session",
+          sessionID: () => "story-session",
+          prompt: input.state,
+          ready: () => true,
+          centered: () => false,
+          todo: {
+            collapsed: () => controls.todoCollapsed,
+            onToggle: () => setControls("todoCollapsed", (collapsed) => !collapsed),
+          },
+          followup: () => undefined,
+          revert: () => undefined,
+          onResponseSubmit: () => {},
+          openParent: () => {},
+          setPromptRef: () => {},
+          setDockRef: () => {},
+        })
+      }
+      promptInput={
+        <PromptInput
+          controls={inputControls}
+          {...input}
+          ref={() => {}}
+          newSessionWorktree=""
+          onNewSessionWorktreeReset={() => {}}
+        />
+      }
     />
   )
 }

+ 4 - 329
packages/app/src/components/prompt-input.tsx

@@ -4,8 +4,6 @@ import {
   createEffect,
   on,
   Component,
-  splitProps,
-  For,
   Show,
   onCleanup,
   createMemo,
@@ -13,10 +11,8 @@ import {
   createResource,
   Switch,
   Match,
-  type ComponentProps,
   type JSX,
 } from "solid-js"
-import { Popover as KobaltePopover } from "@kobalte/core/popover"
 import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
 import type { useLocal } from "@/context/local"
 import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
@@ -30,15 +26,14 @@ import {
   AgentPart,
   FileAttachmentPart,
 } from "@/context/prompt"
-import { getProjectAvatarVariant, useLayout } from "@/context/layout"
+import { useLayout } from "@/context/layout"
 import { useSDK } from "@/context/sdk"
 import { useSync } from "@/context/sync"
 import { useComments } from "@/context/comments"
 import { Button } from "@opencode-ai/ui/button"
 import { DockShellForm, DockTray } from "@opencode-ai/ui/dock-surface"
-import { Icon, type IconProps } from "@opencode-ai/ui/icon"
+import { Icon } from "@opencode-ai/ui/icon"
 import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
-import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
 import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
 import { IconButton } from "@opencode-ai/ui/icon-button"
 import { Select } from "@opencode-ai/ui/select"
@@ -71,8 +66,6 @@ import { promptPlaceholder } from "./prompt-input/placeholder"
 import { createPromptInputTransientState } from "./prompt-input/transient-state"
 import { showToast } from "@/utils/toast"
 import { ImagePreview } from "@opencode-ai/ui/image-preview"
-import { pathKey } from "@/utils/path-key"
-import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
 
 export type PromptInputState = ReturnType<typeof usePrompt>
 
@@ -100,20 +93,6 @@ export type PromptInputControls = {
     paid: boolean
     loading: boolean
   }
-  projects: {
-    available: {
-      name?: string
-      id?: string
-      worktree: string
-      sandboxes?: string[]
-      icon?: { color?: string; url?: string; override?: string }
-      server?: { key: string; name: string }
-    }[]
-    directory: string
-    server?: string
-    select: (worktree: string, server?: string) => void
-    add: (title: string, server?: string) => void
-  }
   session: {
     id?: string
     tabs: {
@@ -184,6 +163,7 @@ export interface PromptInputProps {
   onQueue?: (draft: FollowupDraft) => void
   onAbort?: () => void
   onSubmit?: () => void
+  toolbar?: JSX.Element
 }
 
 const EXAMPLES = [
@@ -232,7 +212,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   let fileInputRef: HTMLInputElement | undefined
   let scrollRef!: HTMLDivElement
   let slashPopoverRef!: HTMLDivElement
-  let projectSearchRef: HTMLInputElement | undefined
 
   const mirror = { input: false }
   const inset = 56
@@ -360,11 +339,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     () => prompt.capture(),
     Math.floor(Math.random() * EXAMPLES.length),
   )
-  const [picker, setPicker] = createStore({
-    projectOpen: false,
-    projectSearch: "",
-  })
-
   const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 })
   const motion = (value: number) => ({
     opacity: value,
@@ -1401,137 +1375,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   }))
 
   const newSession = () => props.variant === "new-session"
-  const projects = createMemo(() => props.controls.projects.available)
-  const projectForDirectory = (directory: string | undefined) => {
-    if (!directory) return
-    const key = pathKey(directory)
-    return projects().find(
-      (project) =>
-        (!project.server || project.server.key === props.controls.projects.server) &&
-        (pathKey(project.worktree) === key || project.sandboxes?.some((sandbox) => pathKey(sandbox) === key)),
-    )
-  }
-  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(() => props.controls.agents.visible && props.controls.agents.options.length > 0)
-  const selectProject = (worktree: string, server?: string) => {
-    setPicker({
-      projectOpen: false,
-      projectSearch: "",
-    })
-    if (pathKey(worktree) === pathKey(selectedProject()?.worktree ?? "") && server === selectedProject()?.server?.key) {
-      restoreFocus()
-      return
-    }
-    props.controls.projects.select(worktree, server)
-    restoreFocus()
-  }
-  const addProject = (server?: string) => {
-    props.controls.projects.add(language.t("command.project.open"), server)
-  }
-
-  const projectItems = createMemo<ComposerPickerItemState[]>(() =>
-    projectResults().map((project) => ({
-      leading: (
-        <ProjectAvatar
-          fallback={displayName(project)}
-          src={getProjectAvatarSource(project.id, project.icon)}
-          variant={getProjectAvatarVariant(project.icon?.color)}
-        />
-      ),
-      label: displayName(project),
-      selected:
-        selectedProject()?.worktree === project.worktree && selectedProject()?.server?.key === project.server?.key,
-      onSelect: () => selectProject(project.worktree, project.server?.key),
-    })),
-  )
-  const projectServers = createMemo(() =>
-    projects()
-      .map((project) => project.server)
-      .filter((server, index, all) => server && all.findIndex((item) => item?.key === server.key) === index),
-  )
-  const addProjectLabel = createMemo(() => language.t("session.new.project.add"))
-  const projectGroups = createMemo(() => {
-    if (projectServers().length <= 1) return
-    return projectServers()
-      .map((server) => ({
-        label: server!.name,
-        server: server!.key,
-        items: projectItems().filter((_, index) => projectResults()[index]?.server?.key === server!.key),
-        action: {
-          icon: "plus" as const,
-          label: addProjectLabel(),
-          onSelect: () => {
-            setPicker("projectOpen", false)
-            addProject(server!.key)
-          },
-        },
-      }))
-      .filter((group) => group.items.length > 0)
-  })
-  const projectPickerState: ComposerPickerState = {
-    get open() {
-      return picker.projectOpen
-    },
-    trigger: {
-      action: "prompt-project",
-      get icon() {
-        return selectedProject() ? undefined : "folder"
-      },
-      get leading() {
-        const project = selectedProject()
-        if (!project) return
-        return (
-          <ProjectAvatar
-            fallback={displayName(project)}
-            src={getProjectAvatarSource(project.id, project.icon)}
-            variant={getProjectAvatarVariant(project.icon?.color)}
-          />
-        )
-      },
-      get label() {
-        const project = selectedProject()
-        return project ? displayName(project) : language.t("session.new.project.new")
-      },
-      class: "max-w-[203px]",
-      get style() {
-        return control()
-      },
-      onPress: () => setPicker("projectOpen", true),
-    },
-    get search() {
-      return picker.projectSearch
-    },
-    searchPlaceholder: language.t("session.new.project.search"),
-    clearLabel: language.t("common.clear"),
-    get items() {
-      return projectItems()
-    },
-    get groups() {
-      return projectGroups()
-    },
-    action: {
-      icon: "plus",
-      get label() {
-        return addProjectLabel()
-      },
-      onSelect: () => {
-        setPicker("projectOpen", false)
-        addProject(projectServers()[0]?.key)
-      },
-    },
-    onOpenChange: (open) => {
-      setPicker("projectOpen", open)
-      if (open) requestAnimationFrame(() => projectSearchRef?.focus())
-    },
-    onSearchInput: (value) => setPicker("projectSearch", value),
-    onSearchClear: () => setPicker("projectSearch", ""),
-    searchRef: (el) => (projectSearchRef = el),
-  }
   const agentControlState = createMemo<ComposerAgentControlState>(() => ({
     title: language.t("command.agent.cycle"),
     keybind: command.keybind("agent.cycle"),
@@ -1543,15 +1387,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       restoreFocus()
     },
   }))
-  const newProjectTriggerState = createMemo<ComposerPickerTriggerState>(() => ({
-    action: "prompt-project",
-    icon: "folder-add-left",
-    label: language.t("session.new.project.new"),
-    class: "max-w-[160px]",
-    style: control(),
-    onPress: () => void addProject(),
-  }))
-
   return (
     <div class="relative size-full flex flex-col gap-0">
       {(promptReady(), null)}
@@ -1682,9 +1517,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                   <Show when={showAgentControl()}>
                     <ComposerAgentControl state={agentControlState()} />
                   </Show>
-                  <Show when={newSession() && !selectedProject()}>
-                    <ComposerPickerTrigger state={newProjectTriggerState()} />
-                  </Show>
+                  {props.toolbar}
                   <ComposerModelControl state={modelControlState()} />
                   <Show when={store.mode !== "shell" && showVariantControl()}>
                     <div
@@ -1738,11 +1571,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                 </Tooltip>
               </div>
             </DockShellForm>
-            <Show when={newSession() && selectedProject()}>
-              <div class="flex h-7 min-w-0 items-center gap-0 px-2">
-                <ComposerPicker state={projectPickerState} />
-              </div>
-            </Show>
           </div>
         </Match>
         <Match when>
@@ -2084,40 +1912,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   )
 }
 
-type ComposerPickerItemState = {
-  icon?: IconProps["name"]
-  leading?: JSX.Element
-  label: string
-  selected?: boolean
-  onSelect: () => void
-}
-
-type ComposerPickerTriggerState = {
-  action: string
-  icon?: IconProps["name"]
-  leading?: JSX.Element
-  label: string
-  class?: string
-  style: JSX.CSSProperties | undefined
-  onPress: () => void
-}
-
-type ComposerPickerState = {
-  open: boolean
-  trigger: ComposerPickerTriggerState
-  search: string
-  searchPlaceholder: string
-  clearLabel: string
-  items: ComposerPickerItemState[]
-  groups?: { label: string; server: string; items: ComposerPickerItemState[]; action: ComposerPickerItemState }[]
-  action: ComposerPickerItemState
-  listClass?: string
-  searchRef: (el: HTMLInputElement) => void
-  onOpenChange: (open: boolean) => void
-  onSearchInput: (value: string) => void
-  onSearchClear: () => void
-}
-
 type ComposerAgentControlState = {
   title: string
   keybind: string
@@ -2140,125 +1934,6 @@ type ComposerModelControlState = {
   onUnpaidClick: () => void
 }
 
-function ComposerPickerTrigger(props: ComponentProps<"button"> & { state: ComposerPickerTriggerState }) {
-  const [local, rest] = splitProps(props, ["state", "class", "style", "onClick"])
-  return (
-    <button
-      {...rest}
-      data-action={local.state.action}
-      type="button"
-      class={`flex h-7 min-w-0 items-center gap-1.5 rounded-sm px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none ${local.state.class ?? ""}`}
-      style={local.state.style}
-      onClick={() => local.state.onPress()}
-    >
-      <Show
-        when={local.state.leading}
-        fallback={
-          <Show when={local.state.icon}>
-            {(icon) => <Icon name={icon()} size="small" class="shrink-0 text-v2-icon-icon-muted" />}
-          </Show>
-        }
-      >
-        {(leading) => leading()}
-      </Show>
-      <span class="min-w-0 truncate leading-5">{local.state.label}</span>
-      <Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
-    </button>
-  )
-}
-
-function ComposerPickerMenuItem(props: { state: ComposerPickerItemState }) {
-  return (
-    <button
-      type="button"
-      class="flex h-7 w-full items-center gap-2 rounded-sm px-3 text-left text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
-      onClick={props.state.onSelect}
-    >
-      <Show
-        when={props.state.leading}
-        fallback={
-          <Show when={props.state.icon}>
-            {(icon) => <Icon name={icon()} size="small" class="shrink-0 text-v2-icon-icon-base" />}
-          </Show>
-        }
-      >
-        {(leading) => leading()}
-      </Show>
-      <span class="min-w-0 flex-1 truncate leading-5">{props.state.label}</span>
-      <Show when={props.state.selected}>
-        <Icon name="check-small" size="small" class="shrink-0 text-v2-icon-icon-base" />
-      </Show>
-    </button>
-  )
-}
-
-function ComposerPicker(props: { state: ComposerPickerState }) {
-  return (
-    <KobaltePopover
-      open={props.state.open}
-      placement="bottom-start"
-      gutter={4}
-      modal={false}
-      onOpenChange={props.state.onOpenChange}
-    >
-      <KobaltePopover.Trigger as={ComposerPickerTrigger} state={props.state.trigger} />
-      <KobaltePopover.Portal>
-        <KobaltePopover.Content
-          class="w-[243px] overflow-hidden rounded-md bg-v2-background-bg-layer-01 shadow-[var(--v2-elevation-floating)] focus:outline-none"
-          onOpenAutoFocus={(event) => event.preventDefault()}
-        >
-          <div class={`flex flex-col p-0.5 ${props.state.listClass ?? ""}`}>
-            <div class="flex h-7 items-center gap-2 rounded px-3 text-v2-icon-icon-muted">
-              <Icon name="magnifying-glass" size="small" class="shrink-0" />
-              <input
-                ref={props.state.searchRef}
-                value={props.state.search}
-                placeholder={props.state.searchPlaceholder}
-                class="h-7 min-w-0 flex-1 border-0 bg-transparent text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint"
-                onInput={(event) => props.state.onSearchInput(event.currentTarget.value)}
-              />
-              <Show when={props.state.search.trim()}>
-                <button
-                  type="button"
-                  class="flex size-5 items-center justify-center rounded text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover"
-                  onClick={props.state.onSearchClear}
-                  aria-label={props.state.clearLabel}
-                >
-                  <Icon name="close-small" size="small" />
-                </button>
-              </Show>
-            </div>
-            <Show
-              when={props.state.groups}
-              fallback={<For each={props.state.items}>{(item) => <ComposerPickerMenuItem state={item} />}</For>}
-            >
-              {(groups) => (
-                <For each={groups()}>
-                  {(group) => (
-                    <div>
-                      <div class="flex h-7 select-none items-center pl-1.5 pr-3 text-[11px] font-[530] leading-none tracking-[0.05px] text-v2-text-text-faint">
-                        {group.label}
-                      </div>
-                      <For each={group.items}>{(item) => <ComposerPickerMenuItem state={item} />}</For>
-                      <ComposerPickerMenuItem state={group.action} />
-                    </div>
-                  )}
-                </For>
-              )}
-            </Show>
-          </div>
-          <Show when={!props.state.groups}>
-            <div class="h-px bg-v2-border-border-muted" />
-            <div class="flex flex-col p-0.5">
-              <ComposerPickerMenuItem state={props.state.action} />
-            </div>
-          </Show>
-        </KobaltePopover.Content>
-      </KobaltePopover.Portal>
-    </KobaltePopover>
-  )
-}
-
 function ComposerAgentControl(props: { state: ComposerAgentControlState }) {
   return (
     <div class="relative">

+ 278 - 0
packages/app/src/components/prompt-project-selector.tsx

@@ -0,0 +1,278 @@
+import { Popover } from "@kobalte/core/popover"
+import { For, Show, splitProps, type Accessor, type ComponentProps } from "solid-js"
+import { createStore } from "solid-js/store"
+import { Icon } from "@opencode-ai/ui/icon"
+import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
+import { getProjectAvatarVariant } from "@/context/layout"
+import { useLanguage } from "@/context/language"
+import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
+import { pathKey } from "@/utils/path-key"
+
+export type PromptProject = {
+  name?: string
+  id?: string
+  worktree: string
+  sandboxes?: string[]
+  icon?: { color?: string; url?: string; override?: string }
+  server?: { key: string; name: string }
+}
+
+export type PromptProjectControls = {
+  available: PromptProject[]
+  directory: string
+  server?: string
+  select: (worktree: string, server?: string) => void
+  add: (title: string, server?: string) => void
+}
+
+export function createPromptProjectController(input: {
+  controls: Accessor<PromptProjectControls>
+  onDone: () => void
+}) {
+  const language = useLanguage()
+  const [store, setStore] = createStore({ open: false, search: "" })
+  let searchRef: HTMLInputElement | undefined
+
+  const selected = () => {
+    const key = pathKey(input.controls().directory)
+    return input
+      .controls()
+      .available.find(
+        (project) =>
+          (!project.server || project.server.key === input.controls().server) &&
+          (pathKey(project.worktree) === key || project.sandboxes?.some((sandbox) => pathKey(sandbox) === key)),
+      )
+  }
+  const projects = () => {
+    const search = store.search.trim().toLowerCase()
+    if (!search) return input.controls().available
+    return input.controls().available.filter((project) => displayName(project).toLowerCase().includes(search))
+  }
+  const servers = () =>
+    input
+      .controls()
+      .available.map((project) => project.server)
+      .filter((server, index, all) => server && all.findIndex((item) => item?.key === server.key) === index)
+  const close = () => {
+    setStore({ open: false, search: "" })
+    input.onDone()
+  }
+  const select = (project: PromptProject) => {
+    if (
+      pathKey(project.worktree) !== pathKey(selected()?.worktree ?? "") ||
+      project.server?.key !== selected()?.server?.key
+    ) {
+      input.controls().select(project.worktree, project.server?.key)
+    }
+    close()
+  }
+  const add = (server?: string) => {
+    setStore("open", false)
+    input.controls().add(language.t("command.project.open"), server)
+  }
+
+  return {
+    selected,
+    projects,
+    servers,
+    open: () => store.open,
+    search: () => store.search,
+    labels: {
+      add: () => language.t("session.new.project.add"),
+      clear: () => language.t("common.clear"),
+      new: () => language.t("session.new.project.new"),
+      search: () => language.t("session.new.project.search"),
+    },
+    add,
+    select,
+    setOpen(open: boolean) {
+      setStore("open", open)
+      if (open) requestAnimationFrame(() => searchRef?.focus())
+    },
+    setSearch(value: string) {
+      setStore("search", value)
+    },
+    setSearchRef(el: HTMLInputElement) {
+      searchRef = el
+    },
+  }
+}
+
+export type PromptProjectController = ReturnType<typeof createPromptProjectController>
+
+export function PromptProjectSelector(props: { controller: PromptProjectController }) {
+  return (
+    <Popover
+      open={props.controller.open()}
+      placement="bottom-start"
+      gutter={4}
+      modal={false}
+      onOpenChange={(open) => props.controller.setOpen(open)}
+    >
+      <Popover.Trigger as={ProjectTrigger} controller={props.controller} />
+      <Popover.Portal>
+        <Popover.Content
+          class="w-[243px] overflow-hidden rounded-md bg-v2-background-bg-layer-01 shadow-[var(--v2-elevation-floating)] focus:outline-none"
+          onOpenAutoFocus={(event) => event.preventDefault()}
+        >
+          <div class="flex flex-col p-0.5">
+            <div class="flex h-7 items-center gap-2 rounded px-3 text-v2-icon-icon-muted">
+              <Icon name="magnifying-glass" size="small" class="shrink-0" />
+              <input
+                ref={(el) => props.controller.setSearchRef(el)}
+                value={props.controller.search()}
+                placeholder={props.controller.labels.search()}
+                class="h-7 min-w-0 flex-1 border-0 bg-transparent text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint"
+                onInput={(event) => props.controller.setSearch(event.currentTarget.value)}
+              />
+              <Show when={props.controller.search().trim()}>
+                <button
+                  type="button"
+                  class="flex size-5 items-center justify-center rounded text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover"
+                  onClick={() => props.controller.setSearch("")}
+                  aria-label={props.controller.labels.clear()}
+                >
+                  <Icon name="close-small" size="small" />
+                </button>
+              </Show>
+            </div>
+            <Show
+              when={props.controller.servers().length > 1}
+              fallback={
+                <For each={props.controller.projects()}>
+                  {(project) => (
+                    <ProjectItem
+                      project={project}
+                      selected={props.controller.selected()}
+                      onSelect={props.controller.select}
+                    />
+                  )}
+                </For>
+              }
+            >
+              <For each={props.controller.servers()}>
+                {(server) => (
+                  <div>
+                    <div class="flex h-7 select-none items-center pl-1.5 pr-3 text-[11px] font-[530] leading-none tracking-[0.05px] text-v2-text-text-faint">
+                      {server!.name}
+                    </div>
+                    <For each={props.controller.projects().filter((project) => project.server?.key === server!.key)}>
+                      {(project) => (
+                        <ProjectItem
+                          project={project}
+                          selected={props.controller.selected()}
+                          onSelect={props.controller.select}
+                        />
+                      )}
+                    </For>
+                    <ProjectAction
+                      label={props.controller.labels.add()}
+                      onSelect={() => props.controller.add(server!.key)}
+                    />
+                  </div>
+                )}
+              </For>
+            </Show>
+          </div>
+          <Show when={props.controller.servers().length <= 1}>
+            <div class="h-px bg-v2-border-border-muted" />
+            <div class="flex flex-col p-0.5">
+              <ProjectAction
+                label={props.controller.labels.add()}
+                onSelect={() => props.controller.add(props.controller.servers()[0]?.key)}
+              />
+            </div>
+          </Show>
+        </Popover.Content>
+      </Popover.Portal>
+    </Popover>
+  )
+}
+
+export function PromptProjectAddButton(props: { controller: PromptProjectController }) {
+  return (
+    <button
+      data-action="prompt-project"
+      type="button"
+      class="flex h-7 min-w-0 max-w-[160px] items-center gap-1.5 rounded-sm px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
+      onClick={() => props.controller.add()}
+    >
+      <Icon name="folder-add-left" size="small" class="shrink-0 text-v2-icon-icon-muted" />
+      <span class="min-w-0 truncate leading-5">{props.controller.labels.new()}</span>
+      <Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
+    </button>
+  )
+}
+
+function ProjectTrigger(props: ComponentProps<"button"> & { controller: PromptProjectController }) {
+  const [local, rest] = splitProps(props, ["controller", "class", "onClick"])
+  const project = () => local.controller.selected()
+  return (
+    <button
+      {...rest}
+      data-action="prompt-project"
+      type="button"
+      class="flex h-7 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
+      onClick={() => local.controller.setOpen(true)}
+    >
+      <Show
+        when={project()}
+        fallback={<Icon name="folder-add-left" size="small" class="shrink-0 text-v2-icon-icon-muted" />}
+      >
+        {(item) => (
+          <ProjectAvatar
+            fallback={displayName(item())}
+            src={getProjectAvatarSource(item().id, item().icon)}
+            variant={getProjectAvatarVariant(item().icon?.color)}
+          />
+        )}
+      </Show>
+      <span class="min-w-0 truncate leading-5">
+        {project() ? displayName(project()!) : local.controller.labels.new()}
+      </span>
+      <Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
+    </button>
+  )
+}
+
+function ProjectItem(props: {
+  project: PromptProject
+  selected?: PromptProject
+  onSelect: (project: PromptProject) => void
+}) {
+  return (
+    <button
+      type="button"
+      class="flex h-7 w-full items-center gap-2 rounded-sm px-3 text-left text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
+      onClick={() => props.onSelect(props.project)}
+    >
+      <ProjectAvatar
+        fallback={displayName(props.project)}
+        src={getProjectAvatarSource(props.project.id, props.project.icon)}
+        variant={getProjectAvatarVariant(props.project.icon?.color)}
+      />
+      <span class="min-w-0 flex-1 truncate leading-5">{displayName(props.project)}</span>
+      <Show
+        when={
+          props.selected?.worktree === props.project.worktree &&
+          props.selected?.server?.key === props.project.server?.key
+        }
+      >
+        <Icon name="check-small" size="small" class="shrink-0 text-v2-icon-icon-base" />
+      </Show>
+    </button>
+  )
+}
+
+function ProjectAction(props: { label: string; onSelect: () => void }) {
+  return (
+    <button
+      type="button"
+      class="flex h-7 w-full items-center gap-2 rounded-sm px-3 text-left text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
+      onClick={props.onSelect}
+    >
+      <Icon name="plus" size="small" />
+      <span class="min-w-0 flex-1 truncate leading-5">{props.label}</span>
+    </button>
+  )
+}

+ 57 - 29
packages/app/src/pages/new-session.tsx

@@ -1,19 +1,23 @@
-import { createEffect, createMemo, onMount, untrack } from "solid-js"
+import { Show, createEffect, createMemo, createResource, untrack } from "solid-js"
 import { createStore } from "solid-js/store"
 import { useSearchParams } from "@solidjs/router"
 import { NewSessionDesignView } from "@/components/session"
+import { PromptInput } from "@/components/prompt-input"
+import {
+  PromptProjectAddButton,
+  PromptProjectSelector,
+  createPromptProjectController,
+} from "@/components/prompt-project-selector"
 import { useComments } from "@/context/comments"
 import { usePrompt } from "@/context/prompt"
 import { useSDK } from "@/context/sdk"
 import { useSync } from "@/context/sync"
 import { useServerSync } from "@/context/server-sync"
-import {
-  createSessionComposerControls,
-  createSessionComposerState,
-  SessionComposerRegion,
-} from "@/pages/session/composer"
+import { useLanguage } from "@/context/language"
+import { createPromptInputController, createPromptProjectControls } from "@/pages/session/composer"
 import { useSessionKey } from "@/pages/session/session-layout"
 import { useComposerCommands } from "@/pages/session/use-composer-commands"
+import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
 
 /**
  * The `/new-session` draft page. Unlike `session.tsx`, this only renders the prompt
@@ -26,6 +30,7 @@ export default function NewSessionPage() {
   const sync = useSync()
   const serverSync = useServerSync()
   const comments = useComments()
+  const language = useLanguage()
   const route = useSessionKey()
   const [searchParams, setSearchParams] = useSearchParams<{ draftId?: string; prompt?: string }>()
 
@@ -33,12 +38,16 @@ export default function NewSessionPage() {
 
   let inputRef: HTMLDivElement | undefined
 
-  const composer = createSessionComposerState()
-  const composerControls = createSessionComposerControls({
+  const inputController = createPromptInputController({
     sessionKey: route.sessionKey,
     sessionID: () => route.params.id,
     queryOptions: serverSync().queryOptions,
   })
+  const projectControls = createPromptProjectControls()
+  const projectController = createPromptProjectController({
+    controls: projectControls,
+    onDone: () => inputRef?.focus(),
+  })
 
   const [store, setStore] = createStore({
     worktree: "main",
@@ -61,9 +70,15 @@ export default function NewSessionPage() {
     })
   })
 
-  onMount(() => {
+  createEffect(() => {
+    if (!prompt.ready()) return
     requestAnimationFrame(() => inputRef?.focus())
   })
+  const ready = Promise.resolve()
+  const [promptReady] = createResource(
+    () => prompt.ready.promise ?? ready,
+    (promise) => promise.then(() => true),
+  )
 
   return (
     <div class="relative size-full overflow-hidden flex flex-col">
@@ -71,26 +86,39 @@ export default function NewSessionPage() {
         <div class="@container relative flex flex-col min-h-0 h-full bg-background-stronger flex-1">
           <div class="flex-1 min-h-0 overflow-hidden rounded-[10px]">
             <NewSessionDesignView>
-              <SessionComposerRegion
-                state={composer}
-                sessionKey={route.sessionKey()}
-                sessionID={route.params.id}
-                controls={composerControls()}
-                promptInput={{
-                  ref: (el) => {
-                    inputRef = el
-                  },
-                  newSessionWorktree: newSessionWorktree(),
-                  onNewSessionWorktreeReset: () => setStore("worktree", "main"),
-                  onSubmit: () => comments.clear(),
-                }}
-                todo={{ collapsed: false, onToggle: () => {} }}
-                ready
-                centered={false}
-                placement="inline"
-                onResponseSubmit={() => {}}
-                setPromptDockRef={() => {}}
-              />
+              <div class={NEW_SESSION_CONTENT_WIDTH}>
+                <Show
+                  when={prompt.ready() || promptReady()}
+                  fallback={
+                    <div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak pointer-events-none">
+                      {language.t("prompt.loading")}
+                    </div>
+                  }
+                >
+                  <div class="flex flex-col gap-3">
+                    <PromptInput
+                      controls={inputController()}
+                      variant="new-session"
+                      ref={(el) => {
+                        inputRef = el
+                      }}
+                      newSessionWorktree={newSessionWorktree()}
+                      onNewSessionWorktreeReset={() => setStore("worktree", "main")}
+                      onSubmit={() => comments.clear()}
+                      toolbar={
+                        <Show when={!projectController.selected()}>
+                          <PromptProjectAddButton controller={projectController} />
+                        </Show>
+                      }
+                    />
+                    <Show when={projectController.selected()}>
+                      <div class="flex h-7 min-w-0 items-center gap-0 px-2">
+                        <PromptProjectSelector controller={projectController} />
+                      </div>
+                    </Show>
+                  </div>
+                </Show>
+              </div>
             </NewSessionDesignView>
           </div>
         </div>

+ 70 - 60
packages/app/src/pages/session.tsx

@@ -42,10 +42,12 @@ import { useServerSDK } from "@/context/server-sdk"
 import { useSettings } from "@/context/settings"
 import { useSync } from "@/context/sync"
 import { useTerminal } from "@/context/terminal"
+import { PromptInput } from "@/components/prompt-input"
 import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit"
 import {
-  createSessionComposerControls,
-  createSessionComposerState,
+  createPromptInputController,
+  createSessionComposerController,
+  createSessionComposerRegionController,
   SessionComposerRegion,
 } from "@/pages/session/composer"
 import {
@@ -156,8 +158,8 @@ export default function Page() {
     },
   })
 
-  const composer = createSessionComposerState()
-  const composerControls = createSessionComposerControls({
+  const composer = createSessionComposerController()
+  const inputController = createPromptInputController({
     sessionKey,
     sessionID: () => params.id,
     queryOptions: serverSync().queryOptions,
@@ -1569,62 +1571,28 @@ export default function Page() {
 
   useUsageExceededDialogs()
 
-  const composerRegion = (placement: "dock" | "inline") => (
-    <SessionComposerRegion
-      state={composer}
-      sessionKey={sessionKey()}
-      sessionID={params.id}
-      controls={composerControls()}
-      promptInput={{
-        ref: (el) => {
-          inputRef = el
-        },
-        newSessionWorktree: newSessionWorktree(),
-        onNewSessionWorktreeReset: () => setStore("newSessionWorktree", "main"),
-        onSubmit: () => {
-          comments.clear()
-          resumeScroll()
-        },
-      }}
-      todo={{
-        collapsed: view().todoCollapsed.get(),
+  const composerRegion = () => {
+    const controller = createSessionComposerRegionController({
+      state: composer,
+      sessionKey,
+      sessionID: () => params.id,
+      prompt,
+      ready: () => !store.deferRender && messagesReady(),
+      centered,
+      todo: {
+        collapsed: () => view().todoCollapsed.get(),
         onToggle: () => view().todoCollapsed.set(!view().todoCollapsed.get()),
-      }}
-      ready={!store.deferRender && messagesReady()}
-      centered={placement === "dock" && centered()}
-      placement={placement}
-      openParent={() => {
-        const id = info()?.parentID
-        if (!id) return
-        navigate(
-          params.serverKey
-            ? sessionHref(requireServerKey(params.serverKey), id)
-            : legacySessionHref(sdk().directory, id),
-        )
-      }}
-      onResponseSubmit={resumeScroll}
-      followup={
+      },
+      followup: () =>
         params.id && !isChildSession()
           ? {
-              queue: queueEnabled,
               items: followupDock(),
               sending: sendingFollowup(),
-              edit: editingFollowup(),
-              onQueue: queueFollowup,
-              onAbort: () => {
-                const id = params.id
-                if (!id) return
-                setFollowup("paused", id, true)
-              },
-              onSend: (id) => {
-                void sendFollowup(params.id!, id, { manual: true })
-              },
+              onSend: (id) => void sendFollowup(params.id!, id, { manual: true }),
               onEdit: editFollowup,
-              onEditLoaded: clearFollowupEdit,
             }
-          : undefined
-      }
-      revert={
+          : undefined,
+      revert: () =>
         rolled().length > 0
           ? {
               items: rolled(),
@@ -1632,13 +1600,53 @@ export default function Page() {
               disabled: reverting(),
               onRestore: restore,
             }
-          : undefined
-      }
-      setPromptDockRef={(el) => {
+          : undefined,
+      onResponseSubmit: resumeScroll,
+      openParent: () => {
+        const id = info()?.parentID
+        if (!id) return
+        navigate(
+          params.serverKey
+            ? sessionHref(requireServerKey(params.serverKey), id)
+            : legacySessionHref(sdk().directory, id),
+        )
+      },
+      setPromptRef: (el) => {
+        inputRef = el
+      },
+      setDockRef: (el) => {
         promptDock = el
-      }}
-    />
-  )
+      },
+    })
+    return (
+      <SessionComposerRegion
+        controller={controller}
+        promptInput={
+          <PromptInput
+            controls={inputController()}
+            ref={(el) => {
+              inputRef = el
+            }}
+            newSessionWorktree={newSessionWorktree()}
+            onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
+            onSubmit={() => {
+              comments.clear()
+              resumeScroll()
+            }}
+            edit={editingFollowup()}
+            onEditLoaded={clearFollowupEdit}
+            shouldQueue={queueEnabled}
+            onQueue={queueFollowup}
+            onAbort={() => {
+              const id = params.id
+              if (!id) return
+              setFollowup("paused", id, true)
+            }}
+          />
+        }
+      />
+    )
+  }
 
   const mobileTabs = (compact = false, bottom = false) => (
     <Tabs value={store.mobileTab} class="h-auto">
@@ -1775,7 +1783,9 @@ export default function Page() {
               </Switch>
             </div>
 
-            <Show when={(params.id || !newSessionDesign()) && !mobileChanges()}>{composerRegion("dock")}</Show>
+            <Show when={(params.id || !newSessionDesign()) && !mobileChanges()}>
+              {(_) => composerRegion()}
+            </Show>
             <Show when={!!params.id && mobileTabsBottom()}>{mobileTabs(true, true)}</Show>
           </div>
 

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

@@ -1,3 +1,4 @@
 export { SessionComposerRegion } from "./session-composer-region"
-export { createSessionComposerControls } from "./session-composer-controls"
-export { createSessionComposerState } from "./session-composer-state"
+export { createPromptInputController, createPromptProjectControls } from "./session-composer-controls"
+export { createSessionComposerController } from "./session-composer-state"
+export { createSessionComposerRegionController } from "./session-composer-region-controller"

+ 43 - 38
packages/app/src/pages/session/composer/session-composer-controls.ts

@@ -3,6 +3,7 @@ import { createQuery } from "@tanstack/solid-query"
 import { useNavigate, useSearchParams } from "@solidjs/router"
 import { type Accessor, createMemo } from "solid-js"
 import type { PromptInputControls } from "@/components/prompt-input"
+import type { PromptProjectControls } from "@/components/prompt-project-selector"
 import { useDirectoryPicker } from "@/components/directory-picker"
 import { useGlobal } from "@/context/global"
 import { useLayout } from "@/context/layout"
@@ -17,26 +18,55 @@ import { useTabs } from "@/context/tabs"
 import { useProviders } from "@/hooks/use-providers"
 import { pathKey } from "@/utils/path-key"
 
-export function createSessionComposerControls(input: {
+export function createPromptInputController(input: {
   sessionKey: Accessor<string>
   sessionID: Accessor<string | undefined>
   queryOptions: Pick<QueryOptionsApi, "agents" | "providers">
 }) {
-  const navigate = useNavigate()
   const layout = useLayout()
   const local = useLocal()
   const providers = useProviders()
   const settings = useSettings()
+  const sync = useSync()
+  const sdk = useSDK()
+  const view = layout.view(input.sessionKey)
+  const agentsQuery = createQuery(() => input.queryOptions.agents(pathKey(sdk().directory)))
+  const globalProvidersQuery = createQuery(() => input.queryOptions.providers(null))
+  const providersQuery = createQuery(() => input.queryOptions.providers(pathKey(sdk().directory)))
+
+  return createMemo<PromptInputControls>(() => ({
+    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,
+    },
+    session: {
+      id: input.sessionID(),
+      tabs: layout.tabs(input.sessionKey),
+      reviewPanel: view.reviewPanel,
+    },
+    newLayoutDesigns: settings.general.newLayoutDesigns(),
+  }))
+}
+
+export function createPromptProjectControls() {
+  const navigate = useNavigate()
+  const layout = useLayout()
   const server = useServer()
   const serverSDK = useServerSDK()
-  const sync = useSync()
   const sdk = useSDK()
   const tabs = useTabs()
   const global = useGlobal()
   const pickDirectory = useDirectoryPicker()
   const [search] = useSearchParams<{ draftId?: string }>()
-  const view = layout.view(input.sessionKey)
-
   const projectServer = () => serverSDK().server
   const projectServerCtx = createMemo(() => global.ensureServerCtx(projectServer()))
   const projects = createMemo(() => {
@@ -44,17 +74,13 @@ export function createSessionComposerControls(input: {
       return search.draftId ? projectServerCtx().projects.list() : layout.projects.list()
     }
     return server.list.flatMap((conn) => {
-      const server = { key: ServerConnection.key(conn), name: serverName(conn) }
+      const item = { key: ServerConnection.key(conn), name: serverName(conn) }
       return global
         .ensureServerCtx(conn)
         .projects.list()
-        .map((project) => ({ ...project, server }))
+        .map((project) => ({ ...project, server: item }))
     })
   })
-  const agentsQuery = createQuery(() => input.queryOptions.agents(pathKey(sdk().directory)))
-  const globalProvidersQuery = createQuery(() => input.queryOptions.providers(null))
-  const providersQuery = createQuery(() => input.queryOptions.providers(pathKey(sdk().directory)))
-
   const selectProject = (worktree: string, serverKey?: string) => {
     const conn = serverKey ? server.list.find((conn) => ServerConnection.key(conn) === serverKey) : projectServer()
     if (search.draftId) {
@@ -94,32 +120,11 @@ export function createSessionComposerControls(input: {
     })
   }
 
-  return createMemo<PromptInputControls>(() => ({
-    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: projects(),
-      directory: sdk().directory,
-      server: server.list.length > 1 ? ServerConnection.key(projectServer()) : undefined,
-      select: selectProject,
-      add: addProject,
-    },
-    session: {
-      id: input.sessionID(),
-      tabs: layout.tabs(input.sessionKey),
-      reviewPanel: view.reviewPanel,
-    },
-    newLayoutDesigns: settings.general.newLayoutDesigns(),
+  return createMemo<PromptProjectControls>(() => ({
+    available: projects(),
+    directory: sdk().directory,
+    server: server.list.length > 1 ? ServerConnection.key(projectServer()) : undefined,
+    select: selectProject,
+    add: addProject,
   }))
 }

+ 145 - 0
packages/app/src/pages/session/composer/session-composer-region-controller.ts

@@ -0,0 +1,145 @@
+import { createResizeObserver } from "@solid-primitives/resize-observer"
+import { useSpring } from "@opencode-ai/ui/motion-spring"
+import { type Accessor, createEffect, createMemo, createResource, onCleanup } from "solid-js"
+import { createStore } from "solid-js/store"
+import type { PromptInputState } from "@/components/prompt-input"
+import { useSync } from "@/context/sync"
+import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff"
+import type { SessionComposerController } from "./session-composer-state"
+
+export type SessionComposerFollowupDock = {
+  items: { id: string; text: string }[]
+  sending?: string
+  onSend: (id: string) => void
+  onEdit: (id: string) => void
+}
+
+export type SessionComposerRevertDock = {
+  items: { id: string; text: string }[]
+  restoring?: string
+  disabled?: boolean
+  onRestore: (id: string) => void
+}
+
+export function createSessionComposerRegionController(input: {
+  state: SessionComposerController
+  sessionKey: Accessor<string>
+  sessionID: Accessor<string | undefined>
+  prompt: PromptInputState
+  ready: Accessor<boolean>
+  centered: Accessor<boolean>
+  todo: {
+    collapsed: Accessor<boolean>
+    onToggle: () => void
+  }
+  followup: Accessor<SessionComposerFollowupDock | undefined>
+  revert: Accessor<SessionComposerRevertDock | undefined>
+  onResponseSubmit: () => void
+  openParent: () => void
+  setPromptRef: (el: HTMLDivElement) => void
+  setDockRef: (el: HTMLDivElement) => void
+}) {
+  const sync = useSync()
+  const [store, setStore] = createStore({
+    ready: input.ready() || input.state.dock(),
+    height: 320,
+    body: undefined as HTMLDivElement | undefined,
+  })
+  let timer: number | undefined
+  let frame: number | undefined
+
+  const clear = () => {
+    if (timer !== undefined) window.clearTimeout(timer)
+    if (frame !== undefined) cancelAnimationFrame(frame)
+    timer = undefined
+    frame = undefined
+  }
+
+  createEffect(() => {
+    input.sessionKey()
+    const ready = input.ready()
+    const dock = input.state.dock()
+
+    clear()
+    if (store.ready || (!ready && !dock)) return
+    if (dock) {
+      setStore("ready", true)
+      return
+    }
+
+    frame = requestAnimationFrame(() => {
+      frame = undefined
+      timer = window.setTimeout(() => {
+        setStore("ready", true)
+        timer = undefined
+      }, 140)
+    })
+  })
+
+  createEffect(() => {
+    if (!input.prompt.ready()) return
+    setSessionHandoff(input.sessionKey(), {
+      prompt: input.prompt
+        .current()
+        .map((part) => {
+          if (part.type === "file") return `[file:${part.path}]`
+          if (part.type === "agent") return `@${part.name}`
+          if (part.type === "image") return `[image:${part.filename}]`
+          return part.content
+        })
+        .join("")
+        .trim(),
+    })
+  })
+
+  createEffect(() => {
+    const el = store.body
+    if (!el) return
+    const update = () => setStore("height", el.getBoundingClientRect().height)
+    createResizeObserver(el, update)
+    update()
+  })
+
+  onCleanup(clear)
+
+  const parentID = createMemo(() => {
+    const id = input.sessionID()
+    return id ? sync().session.get(id)?.parentID : undefined
+  })
+  const open = createMemo(() => store.ready && input.state.dock() && !input.state.closing())
+  const progress = useSpring(
+    () => (open() ? 1 : 0),
+    { visualDuration: 0.3, bounce: 0 },
+    () => `${input.sessionKey()}\0${store.ready}`,
+  )
+  const value = createMemo(() => Math.max(0, Math.min(1, progress())))
+  const ready = Promise.resolve()
+  const [promptReady] = createResource(
+    () => input.prompt.ready.promise ?? ready,
+    (promise) => promise.then(() => true),
+  )
+
+  return {
+    state: input.state,
+    centered: input.centered,
+    todo: input.todo,
+    followup: input.followup,
+    revert: input.revert,
+    onResponseSubmit: input.onResponseSubmit,
+    openParent: input.openParent,
+    setPromptRef: input.setPromptRef,
+    setDockRef: input.setDockRef,
+    parentID,
+    child: () => !!parentID(),
+    showComposer: () => !input.state.blocked() || !!parentID(),
+    handoffPrompt: () => getSessionHandoff(input.sessionKey())?.prompt,
+    promptReady: () => input.prompt.ready() || promptReady(),
+    dock: () => (store.ready && input.state.dock()) || value() > 0.001,
+    dockProgress: value,
+    dockHeight: () => Math.max(78, store.height),
+    lift: () => (input.revert()?.items.length ? 18 : 36 * value()),
+    setDockBodyRef: (el: HTMLDivElement) => setStore("body", el),
+  }
+}
+
+export type SessionComposerRegionController = ReturnType<typeof createSessionComposerRegionController>

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

@@ -1,216 +1,83 @@
-import { Show, createEffect, createMemo, createResource, onCleanup } from "solid-js"
-import { createStore } from "solid-js/store"
-import { useSpring } from "@opencode-ai/ui/motion-spring"
-import { PromptInput, type PromptInputControls, type PromptInputProps } from "@/components/prompt-input"
+import { Show, type JSX } from "solid-js"
 import { useLanguage } from "@/context/language"
-import { usePrompt } from "@/context/prompt"
-import { useSync } from "@/context/sync"
-import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff"
 import { SessionPermissionDock } from "@/pages/session/composer/session-permission-dock"
 import { SessionQuestionDock } from "@/pages/session/composer/session-question-dock"
 import { SessionFollowupDock } from "@/pages/session/composer/session-followup-dock"
 import { SessionRevertDock } from "@/pages/session/composer/session-revert-dock"
-import type { SessionComposerState } from "@/pages/session/composer/session-composer-state"
 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 type { SessionComposerRegionController } from "./session-composer-region-controller"
 
 export function SessionComposerRegion(props: {
-  state: SessionComposerState
-  sessionKey: string
-  sessionID?: string
-  controls: PromptInputControls
-  promptInput: Omit<PromptInputProps, "controls" | "variant">
-  todo: {
-    collapsed: boolean
-    onToggle: () => void
-  }
-  ready: boolean
-  centered: boolean
-  placement?: "dock" | "inline"
-  openParent?: () => void
-  onResponseSubmit: () => void
-  followup?: {
-    queue: () => boolean
-    items: { id: string; text: string }[]
-    sending?: string
-    edit?: { id: string; prompt: FollowupDraft["prompt"]; context: FollowupDraft["context"] }
-    onQueue: (draft: FollowupDraft) => void
-    onAbort: () => void
-    onSend: (id: string) => void
-    onEdit: (id: string) => void
-    onEditLoaded: () => void
-  }
-  revert?: {
-    items: { id: string; text: string }[]
-    restoring?: string
-    disabled?: boolean
-    onRestore: (id: string) => void
-  }
-  setPromptDockRef: (el: HTMLDivElement) => void
+  controller: SessionComposerRegionController
+  promptInput: JSX.Element
 }) {
-  const prompt = props.promptInput.state ?? usePrompt()
   const language = useLanguage()
-  const sync = useSync()
-
-  const handoffPrompt = createMemo(() => getSessionHandoff(props.sessionKey)?.prompt)
-  const info = createMemo(() => (props.sessionID ? sync().session.get(props.sessionID) : undefined))
-  const parentID = createMemo(() => info()?.parentID)
-  const child = createMemo(() => !!parentID())
-  const showComposer = createMemo(() => !props.state.blocked() || child())
-
-  const previewPrompt = () =>
-    prompt
-      .current()
-      .map((part) => {
-        if (part.type === "file") return `[file:${part.path}]`
-        if (part.type === "agent") return `@${part.name}`
-        if (part.type === "image") return `[image:${part.filename}]`
-        return part.content
-      })
-      .join("")
-      .trim()
-
-  createEffect(() => {
-    if (!prompt.ready()) return
-    setSessionHandoff(props.sessionKey, { prompt: previewPrompt() })
-  })
-
-  const [store, setStore] = createStore({
-    ready: props.ready || props.state.dock(),
-    height: 320,
-    body: undefined as HTMLDivElement | undefined,
-  })
-  let timer: number | undefined
-  let frame: number | undefined
-
-  const clear = () => {
-    if (timer !== undefined) {
-      window.clearTimeout(timer)
-      timer = undefined
-    }
-    if (frame !== undefined) {
-      cancelAnimationFrame(frame)
-      frame = undefined
-    }
+  const controller = props.controller
+  const rolled = () => {
+    const revert = controller.revert()
+    return revert?.items.length ? revert : undefined
   }
 
-  createEffect(() => {
-    props.sessionKey
-    const ready = props.ready
-    const dock = props.state.dock()
-    const delay = 140
-
-    clear()
-    if (store.ready || (!ready && !dock)) return
-    if (dock) {
-      setStore("ready", true)
-      return
-    }
-
-    frame = requestAnimationFrame(() => {
-      frame = undefined
-      timer = window.setTimeout(() => {
-        setStore("ready", true)
-        timer = undefined
-      }, delay)
-    })
-  })
-
-  onCleanup(clear)
-
-  const open = createMemo(() => store.ready && props.state.dock() && !props.state.closing())
-  const progress = useSpring(
-    () => (open() ? 1 : 0),
-    { visualDuration: 0.3, bounce: 0 },
-    () => `${props.sessionKey}\0${store.ready}`,
-  )
-  const value = createMemo(() => Math.max(0, Math.min(1, progress())))
-  const dock = createMemo(() => (store.ready && props.state.dock()) || value() > 0.001)
-  const rolled = createMemo(() => (props.revert?.items.length ? props.revert : undefined))
-  const lift = createMemo(() => (rolled() ? 18 : 36 * value()))
-  const full = createMemo(() => Math.max(78, store.height))
-
-  createEffect(() => {
-    const el = store.body
-    if (!el) return
-    const update = () => setStore("height", el.getBoundingClientRect().height)
-    createResizeObserver(store.body, update)
-    update()
-  })
-
-  const ready = Promise.resolve()
-  const [promptReadyResource] = createResource(
-    () => prompt.ready.promise ?? ready,
-    (promise) => promise.then(() => true),
-  )
-
   return (
     <div
-      ref={props.setPromptDockRef}
+      ref={controller.setDockRef}
       data-component="session-prompt-dock"
-      classList={{
-        "w-full flex flex-col justify-center items-center pointer-events-none": true,
-        "shrink-0 pb-3 bg-background-stronger": props.placement !== "inline",
-      }}
+      class="w-full shrink-0 flex flex-col justify-center items-center pb-3 bg-background-stronger pointer-events-none"
     >
       <div
         classList={{
-          "w-full pointer-events-auto": true,
-          "px-3": props.placement !== "inline",
-          [NEW_SESSION_CONTENT_WIDTH]: props.placement === "inline",
-          "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
+          "w-full px-3 pointer-events-auto": true,
+          "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": controller.centered(),
         }}
       >
-        <Show when={props.state.questionRequest()} keyed>
+        <Show when={controller.state.questionRequest()} keyed>
           {(request) => (
             <div>
-              <SessionQuestionDock request={request} onSubmit={props.onResponseSubmit} />
+              <SessionQuestionDock request={request} onSubmit={controller.onResponseSubmit} />
             </div>
           )}
         </Show>
 
-        <Show when={props.state.permissionRequest()} keyed>
+        <Show when={controller.state.permissionRequest()} keyed>
           {(request) => (
             <div>
               <SessionPermissionDock
                 request={request}
-                responding={props.state.permissionResponding()}
+                responding={controller.state.permissionResponding()}
                 onDecide={(response) => {
-                  props.onResponseSubmit()
-                  props.state.decide(response)
+                  controller.onResponseSubmit()
+                  controller.state.decide(response)
                 }}
               />
             </div>
           )}
         </Show>
 
-        <Show when={showComposer()}>
-          <Show when={dock()}>
+        <Show when={controller.showComposer()}>
+          <Show when={controller.dock()}>
             <div
               classList={{
                 "overflow-hidden": true,
-                "pointer-events-none": value() < 0.98,
+                "pointer-events-none": controller.dockProgress() < 0.98,
               }}
               style={{
-                "max-height": `${full() * value()}px`,
+                "max-height": `${controller.dockHeight() * controller.dockProgress()}px`,
               }}
             >
-              <div ref={(el) => setStore("body", el)}>
+              <div ref={controller.setDockBodyRef}>
                 <SessionTodoDock
-                  todos={props.state.todos()}
-                  collapsed={props.todo.collapsed}
-                  onToggle={props.todo.onToggle}
+                  todos={controller.state.todos()}
+                  collapsed={controller.todo.collapsed()}
+                  onToggle={controller.todo.onToggle}
                   collapseLabel={language.t("session.todo.collapse")}
                   expandLabel={language.t("session.todo.expand")}
-                  dockProgress={value()}
+                  dockProgress={controller.dockProgress()}
                 />
               </div>
             </div>
           </Show>
           <Show
-            when={prompt.ready() || promptReadyResource()}
+            when={controller.promptReady()}
             fallback={
               <>
                 <Show when={rolled()} keyed>
@@ -227,9 +94,9 @@ export function SessionComposerRegion(props: {
                 </Show>
                 <div
                   class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none"
-                  style={{ "margin-top": `${-36 * value()}px` }}
+                  style={{ "margin-top": `${-36 * controller.dockProgress()}px` }}
                 >
-                  {handoffPrompt() || language.t("prompt.loading")}
+                  {controller.handoffPrompt() || language.t("prompt.loading")}
                 </div>
               </>
             }
@@ -238,7 +105,7 @@ export function SessionComposerRegion(props: {
               {(revert) => (
                 <div
                   style={{
-                    "margin-top": `${-36 * value()}px`,
+                    "margin-top": `${-36 * controller.dockProgress()}px`,
                   }}
                 >
                   <SessionRevertDock
@@ -255,44 +122,35 @@ export function SessionComposerRegion(props: {
                 "relative z-30": true,
               }}
               style={{
-                "margin-top": `${-lift()}px`,
+                "margin-top": `${-controller.lift()}px`,
               }}
             >
-              <Show when={props.followup?.items.length}>
+              <Show when={controller.followup()?.items.length}>
                 <SessionFollowupDock
-                  items={props.followup!.items}
-                  sending={props.followup!.sending}
-                  onSend={props.followup!.onSend}
-                  onEdit={props.followup!.onEdit}
+                  items={controller.followup()!.items}
+                  sending={controller.followup()!.sending}
+                  onSend={controller.followup()!.onSend}
+                  onEdit={controller.followup()!.onEdit}
                 />
               </Show>
               <Show
-                when={child()}
+                when={controller.child()}
                 fallback={
-                  <Show when={!props.state.blocked()}>
-                    <PromptInput
-                      {...props.promptInput}
-                      controls={props.controls}
-                      variant={props.placement === "inline" ? "new-session" : undefined}
-                      edit={props.followup?.edit}
-                      onEditLoaded={props.followup?.onEditLoaded}
-                      shouldQueue={props.followup?.queue}
-                      onQueue={props.followup?.onQueue}
-                      onAbort={props.followup?.onAbort}
-                    />
+                  <Show when={!controller.state.blocked()}>
+                    {props.promptInput}
                   </Show>
                 }
               >
                 <div
-                  ref={props.promptInput.ref}
+                  ref={controller.setPromptRef}
                   class="w-full rounded-[12px] border border-border-weak-base bg-background-base p-3 text-16-regular text-text-weak"
                 >
                   <span>{language.t("session.child.promptDisabled")} </span>
-                  <Show when={parentID() && props.openParent}>
+                  <Show when={controller.parentID()}>
                     <button
                       type="button"
                       class="text-text-base transition-colors hover:text-text-strong"
-                      onClick={props.openParent}
+                      onClick={controller.openParent}
                     >
                       {language.t("session.child.backToParent")}
                     </button>

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

@@ -25,7 +25,7 @@ export const todoDockAtBoundary = (state: ReturnType<typeof todoState>) => state
 
 const idle = { type: "idle" as const }
 
-export function createSessionComposerState(options?: { closeMs?: number | (() => number) }) {
+export function createSessionComposerController(options?: { closeMs?: number | (() => number) }) {
   const params = useParams()
   const sdk = useSDK()
   const sync = useSync()
@@ -201,4 +201,4 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
   }
 }
 
-export type SessionComposerState = ReturnType<typeof createSessionComposerState>
+export type SessionComposerController = ReturnType<typeof createSessionComposerController>

+ 35 - 34
packages/app/src/pages/session/composer/todo-panel-motion.stories.tsx

@@ -3,7 +3,13 @@ import { createEffect, createMemo, onCleanup } from "solid-js"
 import { createStore } from "solid-js/store"
 import type { Todo } from "@opencode-ai/sdk/v2"
 import { useServerSync } from "@/context/global-sync"
-import { SessionComposerRegion, createSessionComposerState } from "@/pages/session/composer"
+import { PromptInput } from "@/components/prompt-input"
+import { usePrompt } from "@/context/prompt"
+import {
+  SessionComposerRegion,
+  createSessionComposerController,
+  createSessionComposerRegionController,
+} from "@/pages/session/composer"
 
 export default {
   title: "UI/Todo Panel Motion",
@@ -60,7 +66,6 @@ const controls = {
     paid: true,
     loading: false,
   },
-  projects: { available: [], directory: "/tmp/story", select: () => {}, add: () => {} },
   session: {
     id: "story-session",
     tabs: { active: () => undefined, all: () => [], open: () => {}, setActive: () => {} },
@@ -149,6 +154,7 @@ const css = `
 export const Playground = {
   render: () => {
     const global = useServerSync()
+    const prompt = usePrompt()
     const [cfg, setCfg] = createStore({
       open: true,
       collapsed: false,
@@ -188,7 +194,7 @@ export const Playground = {
     const countMask = () => cfg.countMask
     const countMaskHeight = () => cfg.countMaskHeight
     const countWidthDuration = () => cfg.countWidthDuration
-    const state = createSessionComposerState({ closeMs: () => Math.round(dockCloseDuration() * 1000) })
+    const state = createSessionComposerController({ closeMs: () => Math.round(dockCloseDuration() * 1000) })
     let frame
     let scrollRef
 
@@ -217,7 +223,6 @@ export const Playground = {
 
     const collapsed = () => cfg.collapsed
     const setCollapsed = (value: boolean) => setCfg("collapsed", value)
-
     const openDock = () => {
       clear()
       setCfg("open", true)
@@ -281,36 +286,32 @@ export const Playground = {
 
                 <div>
                   <SessionComposerRegion
-                    state={state}
-                    sessionKey="story-session"
-                    sessionID="story-session"
-                    controls={controls}
-                    promptInput={{
-                      submission: { abort: () => {}, handleSubmit: (event) => event.preventDefault() },
-                      ref: () => {},
-                      newSessionWorktree: "",
-                      onNewSessionWorktreeReset: () => {},
-                    }}
-                    todo={{ collapsed: collapsed(), onToggle: () => setCollapsed(!collapsed()) }}
-                    ready
-                    centered={false}
-                    onResponseSubmit={pin}
-                    setPromptDockRef={() => {}}
-                    dockOpenVisualDuration={dockOpenDuration()}
-                    dockOpenBounce={dockOpenBounce()}
-                    dockCloseVisualDuration={dockCloseDuration()}
-                    dockCloseBounce={dockCloseBounce()}
-                    drawerExpandVisualDuration={drawerExpandDuration()}
-                    drawerExpandBounce={drawerExpandBounce()}
-                    drawerCollapseVisualDuration={drawerCollapseDuration()}
-                    drawerCollapseBounce={drawerCollapseBounce()}
-                    subtitleDuration={subtitleDuration()}
-                    subtitleTravel={subtitleAuto() ? undefined : subtitleTravel()}
-                    subtitleEdge={subtitleAuto() ? undefined : subtitleEdge()}
-                    countDuration={countDuration()}
-                    countMask={countMask()}
-                    countMaskHeight={countMaskHeight()}
-                    countWidthDuration={countWidthDuration()}
+                    controller={
+                      createSessionComposerRegionController({
+                        state,
+                        sessionKey: () => "story-session",
+                        sessionID: () => "story-session",
+                        prompt,
+                        ready: () => true,
+                        centered: () => false,
+                        todo: { collapsed, onToggle: () => setCollapsed(!collapsed()) },
+                        followup: () => undefined,
+                        revert: () => undefined,
+                        onResponseSubmit: pin,
+                        openParent: () => {},
+                        setPromptRef: () => {},
+                        setDockRef: () => {},
+                      })
+                    }
+                    promptInput={
+                      <PromptInput
+                        controls={controls}
+                        submission={{ abort: () => {}, handleSubmit: (event) => event.preventDefault() }}
+                        ref={() => {}}
+                        newSessionWorktree=""
+                        onNewSessionWorktreeReset={() => {}}
+                      />
+                    }
                   />
                 </div>
               </div>