Explorar el Código

chore: refactor composer/dock components (#14328)

Adam hace 6 meses
padre
commit
49cc872c44

+ 157 - 0
packages/app/e2e/actions.ts

@@ -332,6 +332,163 @@ export async function withSession<T>(
   }
 }
 
+const seedSystem = [
+  "You are seeding deterministic e2e UI state.",
+  "Follow the user's instruction exactly.",
+  "When asked to call a tool, call exactly that tool exactly once with the exact JSON input.",
+  "Do not call any extra tools.",
+].join(" ")
+
+const wait = async <T>(input: { probe: () => Promise<T | undefined>; timeout?: number }) => {
+  const timeout = input.timeout ?? 30_000
+  const end = Date.now() + timeout
+  while (Date.now() < end) {
+    const value = await input.probe()
+    if (value !== undefined) return value
+    await new Promise((resolve) => setTimeout(resolve, 250))
+  }
+}
+
+const seed = async <T>(input: {
+  sessionID: string
+  prompt: string
+  sdk: ReturnType<typeof createSdk>
+  probe: () => Promise<T | undefined>
+  timeout?: number
+  attempts?: number
+}) => {
+  for (let i = 0; i < (input.attempts ?? 2); i++) {
+    await input.sdk.session.promptAsync({
+      sessionID: input.sessionID,
+      agent: "build",
+      system: seedSystem,
+      parts: [{ type: "text", text: input.prompt }],
+    })
+    const value = await wait({ probe: input.probe, timeout: input.timeout })
+    if (value !== undefined) return value
+  }
+}
+
+export async function seedSessionQuestion(
+  sdk: ReturnType<typeof createSdk>,
+  input: {
+    sessionID: string
+    questions: Array<{
+      header: string
+      question: string
+      options: Array<{ label: string; description: string }>
+      multiple?: boolean
+      custom?: boolean
+    }>
+  },
+) {
+  const first = input.questions[0]
+  if (!first) throw new Error("Question seed requires at least one question")
+
+  const text = [
+    "Your only valid response is one question tool call.",
+    `Use this JSON input: ${JSON.stringify({ questions: input.questions })}`,
+    "Do not output plain text.",
+    "After calling the tool, wait for the user response.",
+  ].join("\n")
+
+  const result = await seed({
+    sdk,
+    sessionID: input.sessionID,
+    prompt: text,
+    timeout: 30_000,
+    probe: async () => {
+      const list = await sdk.question.list().then((x) => x.data ?? [])
+      return list.find((item) => item.sessionID === input.sessionID && item.questions[0]?.header === first.header)
+    },
+  })
+
+  if (!result) throw new Error("Timed out seeding question request")
+  return { id: result.id }
+}
+
+export async function seedSessionPermission(
+  sdk: ReturnType<typeof createSdk>,
+  input: {
+    sessionID: string
+    permission: string
+    patterns: string[]
+    description?: string
+  },
+) {
+  const text = [
+    "Your only valid response is one bash tool call.",
+    `Use this JSON input: ${JSON.stringify({
+      command: input.patterns[0] ? `ls ${JSON.stringify(input.patterns[0])}` : "pwd",
+      workdir: "/",
+      description: input.description ?? `seed ${input.permission} permission request`,
+    })}`,
+    "Do not output plain text.",
+  ].join("\n")
+
+  const result = await seed({
+    sdk,
+    sessionID: input.sessionID,
+    prompt: text,
+    timeout: 30_000,
+    probe: async () => {
+      const list = await sdk.permission.list().then((x) => x.data ?? [])
+      return list.find((item) => item.sessionID === input.sessionID)
+    },
+  })
+
+  if (!result) throw new Error("Timed out seeding permission request")
+  return { id: result.id }
+}
+
+export async function seedSessionTodos(
+  sdk: ReturnType<typeof createSdk>,
+  input: {
+    sessionID: string
+    todos: Array<{ content: string; status: string; priority: string }>
+  },
+) {
+  const text = [
+    "Your only valid response is one todowrite tool call.",
+    `Use this JSON input: ${JSON.stringify({ todos: input.todos })}`,
+    "Do not output plain text.",
+  ].join("\n")
+  const target = JSON.stringify(input.todos)
+
+  const result = await seed({
+    sdk,
+    sessionID: input.sessionID,
+    prompt: text,
+    timeout: 30_000,
+    probe: async () => {
+      const todos = await sdk.session.todo({ sessionID: input.sessionID }).then((x) => x.data ?? [])
+      if (JSON.stringify(todos) !== target) return
+      return true
+    },
+  })
+
+  if (!result) throw new Error("Timed out seeding todos")
+  return true
+}
+
+export async function clearSessionDockSeed(sdk: ReturnType<typeof createSdk>, sessionID: string) {
+  const [questions, permissions] = await Promise.all([
+    sdk.question.list().then((x) => x.data ?? []),
+    sdk.permission.list().then((x) => x.data ?? []),
+  ])
+
+  await Promise.all([
+    ...questions
+      .filter((item) => item.sessionID === sessionID)
+      .map((item) => sdk.question.reject({ requestID: item.id }).catch(() => undefined)),
+    ...permissions
+      .filter((item) => item.sessionID === sessionID)
+      .map((item) => sdk.permission.reply({ requestID: item.id, reply: "reject" }).catch(() => undefined)),
+  ])
+
+  return true
+}
+
 export async function openStatusPopover(page: Page) {
   await defocus(page)
 

+ 10 - 0
packages/app/e2e/selectors.ts

@@ -1,5 +1,15 @@
 export const promptSelector = '[data-component="prompt-input"]'
 export const terminalSelector = '[data-component="terminal"]'
+export const sessionComposerDockSelector = '[data-component="session-prompt-dock"]'
+export const questionDockSelector = '[data-component="dock-prompt"][data-kind="question"]'
+export const permissionDockSelector = '[data-component="dock-prompt"][data-kind="permission"]'
+export const permissionRejectSelector = `${permissionDockSelector} [data-slot="permission-footer-actions"] [data-component="button"]:nth-child(1)`
+export const permissionAllowAlwaysSelector = `${permissionDockSelector} [data-slot="permission-footer-actions"] [data-component="button"]:nth-child(2)`
+export const permissionAllowOnceSelector = `${permissionDockSelector} [data-slot="permission-footer-actions"] [data-component="button"]:nth-child(3)`
+export const sessionTodoDockSelector = '[data-component="session-todo-dock"]'
+export const sessionTodoToggleSelector = '[data-action="session-todo-toggle"]'
+export const sessionTodoToggleButtonSelector = '[data-action="session-todo-toggle-button"]'
+export const sessionTodoListSelector = '[data-slot="session-todo-list"]'
 
 export const modelVariantCycleSelector = '[data-action="model-variant-cycle"]'
 export const settingsLanguageSelectSelector = '[data-action="settings-language"]'

+ 207 - 0
packages/app/e2e/session/session-composer-dock.spec.ts

@@ -0,0 +1,207 @@
+import { test, expect } from "../fixtures"
+import { clearSessionDockSeed, seedSessionPermission, seedSessionQuestion, seedSessionTodos } from "../actions"
+import {
+  permissionDockSelector,
+  promptSelector,
+  questionDockSelector,
+  sessionComposerDockSelector,
+  sessionTodoDockSelector,
+  sessionTodoListSelector,
+  sessionTodoToggleButtonSelector,
+} from "../selectors"
+
+type Sdk = Parameters<typeof clearSessionDockSeed>[0]
+
+async function withDockSession<T>(sdk: Sdk, title: string, fn: (session: { id: string; title: string }) => Promise<T>) {
+  const session = await sdk.session.create({ title }).then((r) => r.data)
+  if (!session?.id) throw new Error("Session create did not return an id")
+  return fn(session)
+}
+
+test.setTimeout(120_000)
+
+async function withDockSeed<T>(sdk: Sdk, sessionID: string, fn: () => Promise<T>) {
+  try {
+    return await fn()
+  } finally {
+    await clearSessionDockSeed(sdk, sessionID).catch(() => undefined)
+  }
+}
+
+test("default dock shows prompt input", async ({ page, sdk, gotoSession }) => {
+  await withDockSession(sdk, "e2e composer dock default", async (session) => {
+    await gotoSession(session.id)
+
+    await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
+    await expect(page.locator(promptSelector)).toBeVisible()
+    await expect(page.locator(questionDockSelector)).toHaveCount(0)
+    await expect(page.locator(permissionDockSelector)).toHaveCount(0)
+
+    await page.locator(promptSelector).click()
+    await expect(page.locator(promptSelector)).toBeFocused()
+  })
+})
+
+test("blocked question flow unblocks after submit", async ({ page, sdk, gotoSession }) => {
+  await withDockSession(sdk, "e2e composer dock question", async (session) => {
+    await withDockSeed(sdk, session.id, async () => {
+      await gotoSession(session.id)
+
+      await seedSessionQuestion(sdk, {
+        sessionID: session.id,
+        questions: [
+          {
+            header: "Need input",
+            question: "Pick one option",
+            options: [
+              { label: "Continue", description: "Continue now" },
+              { label: "Stop", description: "Stop here" },
+            ],
+          },
+        ],
+      })
+
+      const dock = page.locator(questionDockSelector)
+      await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1)
+      await expect(page.locator(promptSelector)).toHaveCount(0)
+
+      await dock.locator('[data-slot="question-option"]').first().click()
+      await dock.getByRole("button", { name: /submit/i }).click()
+
+      await expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(0)
+      await expect(page.locator(promptSelector)).toBeVisible()
+    })
+  })
+})
+
+test("blocked permission flow supports allow once", async ({ page, sdk, gotoSession }) => {
+  await withDockSession(sdk, "e2e composer dock permission once", async (session) => {
+    await withDockSeed(sdk, session.id, async () => {
+      await gotoSession(session.id)
+
+      await seedSessionPermission(sdk, {
+        sessionID: session.id,
+        permission: "bash",
+        patterns: ["README.md"],
+        description: "Need permission for command",
+      })
+
+      await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
+      await expect(page.locator(promptSelector)).toHaveCount(0)
+
+      await page
+        .locator(permissionDockSelector)
+        .getByRole("button", { name: /allow once/i })
+        .click()
+      await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
+      await expect(page.locator(promptSelector)).toBeVisible()
+    })
+  })
+})
+
+test("blocked permission flow supports reject", async ({ page, sdk, gotoSession }) => {
+  await withDockSession(sdk, "e2e composer dock permission reject", async (session) => {
+    await withDockSeed(sdk, session.id, async () => {
+      await gotoSession(session.id)
+
+      await seedSessionPermission(sdk, {
+        sessionID: session.id,
+        permission: "bash",
+        patterns: ["REJECT.md"],
+      })
+
+      await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
+      await expect(page.locator(promptSelector)).toHaveCount(0)
+
+      await page.locator(permissionDockSelector).getByRole("button", { name: /deny/i }).click()
+      await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
+      await expect(page.locator(promptSelector)).toBeVisible()
+    })
+  })
+})
+
+test("blocked permission flow supports allow always", async ({ page, sdk, gotoSession }) => {
+  await withDockSession(sdk, "e2e composer dock permission always", async (session) => {
+    await withDockSeed(sdk, session.id, async () => {
+      await gotoSession(session.id)
+
+      await seedSessionPermission(sdk, {
+        sessionID: session.id,
+        permission: "bash",
+        patterns: ["README.md"],
+        description: "Need permission for command",
+      })
+
+      await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
+      await expect(page.locator(promptSelector)).toHaveCount(0)
+
+      await page
+        .locator(permissionDockSelector)
+        .getByRole("button", { name: /allow always/i })
+        .click()
+      await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
+      await expect(page.locator(promptSelector)).toBeVisible()
+    })
+  })
+})
+
+test("todo dock transitions and collapse behavior", async ({ page, sdk, gotoSession }) => {
+  await withDockSession(sdk, "e2e composer dock todo", async (session) => {
+    await withDockSeed(sdk, session.id, async () => {
+      await gotoSession(session.id)
+
+      await seedSessionTodos(sdk, {
+        sessionID: session.id,
+        todos: [
+          { content: "first task", status: "pending", priority: "high" },
+          { content: "second task", status: "in_progress", priority: "medium" },
+        ],
+      })
+
+      await expect.poll(() => page.locator(sessionTodoDockSelector).count(), { timeout: 10_000 }).toBe(1)
+      await expect(page.locator(sessionTodoListSelector)).toBeVisible()
+
+      await page.locator(sessionTodoToggleButtonSelector).click()
+      await expect(page.locator(sessionTodoListSelector)).toBeHidden()
+
+      await page.locator(sessionTodoToggleButtonSelector).click()
+      await expect(page.locator(sessionTodoListSelector)).toBeVisible()
+
+      await seedSessionTodos(sdk, {
+        sessionID: session.id,
+        todos: [
+          { content: "first task", status: "completed", priority: "high" },
+          { content: "second task", status: "cancelled", priority: "medium" },
+        ],
+      })
+
+      await expect.poll(() => page.locator(sessionTodoDockSelector).count(), { timeout: 10_000 }).toBe(0)
+    })
+  })
+})
+
+test("keyboard focus stays off prompt while blocked", async ({ page, sdk, gotoSession }) => {
+  await withDockSession(sdk, "e2e composer dock keyboard", async (session) => {
+    await withDockSeed(sdk, session.id, async () => {
+      await gotoSession(session.id)
+
+      await seedSessionQuestion(sdk, {
+        sessionID: session.id,
+        questions: [
+          {
+            header: "Need input",
+            question: "Pick one option",
+            options: [{ label: "Continue", description: "Continue now" }],
+          },
+        ],
+      })
+
+      await expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(1)
+      await expect(page.locator(promptSelector)).toHaveCount(0)
+
+      await page.locator("main").click({ position: { x: 5, y: 5 } })
+      await page.keyboard.type("abc")
+      await expect(page.locator(promptSelector)).toHaveCount(0)
+    })
+  })
+})

+ 6 - 6
packages/app/src/components/prompt-input.tsx

@@ -20,6 +20,7 @@ import { useParams } from "@solidjs/router"
 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 } from "@opencode-ai/ui/icon"
 import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
 import type { IconName } from "@opencode-ai/ui/icons/provider"
@@ -1045,12 +1046,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
         commandKeybind={command.keybind}
         t={(key) => language.t(key as Parameters<typeof language.t>[0])}
       />
-      <form
+      <DockShellForm
         onSubmit={handleSubmit}
         classList={{
           "group/prompt-input": true,
-          "bg-surface-raised-stronger-non-alpha shadow-xs-border relative z-10": true,
-          "rounded-[12px] overflow-clip focus-within:shadow-xs-border": true,
+          "focus-within:shadow-xs-border": true,
           "border-icon-info-active border-dashed": store.draggingType !== null,
           [props.class ?? ""]: !!props.class,
         }}
@@ -1243,9 +1243,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
             </div>
           </Show>
         </div>
-      </form>
+      </DockShellForm>
       <Show when={store.mode === "normal" || store.mode === "shell"}>
-        <div class="-mt-3.5 bg-background-base border border-border-weak-base relative z-0 rounded-[12px] rounded-tl-0 rounded-tr-0 overflow-clip">
+        <DockTray attach="top">
           <div class="px-2 pt-5.5 pb-2 flex items-center gap-2 min-w-0">
             <div class="flex items-center gap-1.5 min-w-0 flex-1">
               <Show when={store.mode === "shell"}>
@@ -1385,7 +1385,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
               />
             </div>
           </div>
-        </div>
+        </DockTray>
       </Show>
     </div>
   )

+ 5 - 8
packages/app/src/pages/session.tsx

@@ -27,7 +27,7 @@ import { SessionReviewTab, type DiffStyle, type SessionReviewTabProps } from "@/
 import { TerminalPanel } from "@/pages/session/terminal-panel"
 import { MessageTimeline } from "@/pages/session/message-timeline"
 import { useSessionCommands } from "@/pages/session/use-session-commands"
-import { SessionPromptDock } from "@/pages/session/session-prompt-dock"
+import { SessionComposerRegion, createSessionComposerState } from "@/pages/session/composer"
 import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs"
 import { SessionSidePanel } from "@/pages/session/session-side-panel"
 import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
@@ -54,11 +54,7 @@ export default function Page() {
     },
   })
 
-  const blocked = createMemo(() => {
-    const sessionID = params.id
-    if (!sessionID) return false
-    return !!sync.data.permission[sessionID]?.[0] || !!sync.data.question[sessionID]?.[0]
-  })
+  const composer = createSessionComposerState()
 
   const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
   const workspaceKey = createMemo(() => params.dir ?? "")
@@ -401,7 +397,7 @@ export default function Page() {
     }
 
     if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
-      if (blocked()) return
+      if (composer.blocked()) return
       inputRef?.focus()
     }
   }
@@ -1090,7 +1086,8 @@ export default function Page() {
             </Switch>
           </div>
 
-          <SessionPromptDock
+          <SessionComposerRegion
+            state={composer}
             centered={centered()}
             inputRef={(el) => {
               inputRef = el

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

@@ -0,0 +1,3 @@
+export { SessionComposerRegion } from "./session-composer-region"
+export { createSessionComposerBlocked, createSessionComposerState } from "./session-composer-state"
+export type { SessionComposerState } from "./session-composer-state"

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

@@ -0,0 +1,124 @@
+import { Show, createEffect, createMemo } from "solid-js"
+import { useParams } from "@solidjs/router"
+import { PromptInput } from "@/components/prompt-input"
+import { useLanguage } from "@/context/language"
+import { usePrompt } from "@/context/prompt"
+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 type { SessionComposerState } from "@/pages/session/composer/session-composer-state"
+import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock"
+
+export function SessionComposerRegion(props: {
+  state: SessionComposerState
+  centered: boolean
+  inputRef: (el: HTMLDivElement) => void
+  newSessionWorktree: string
+  onNewSessionWorktreeReset: () => void
+  onSubmit: () => void
+  setPromptDockRef: (el: HTMLDivElement) => void
+}) {
+  const params = useParams()
+  const prompt = usePrompt()
+  const language = useLanguage()
+
+  const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
+  const handoffPrompt = createMemo(() => getSessionHandoff(sessionKey())?.prompt)
+
+  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(sessionKey(), { prompt: previewPrompt() })
+  })
+
+  return (
+    <div
+      ref={props.setPromptDockRef}
+      data-component="session-prompt-dock"
+      class="shrink-0 w-full pb-3 flex flex-col justify-center items-center bg-background-stronger pointer-events-none"
+    >
+      <div
+        classList={{
+          "w-full px-3 pointer-events-auto": true,
+          "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
+        }}
+      >
+        <Show when={props.state.questionRequest()} keyed>
+          {(request) => (
+            <div>
+              <SessionQuestionDock request={request} />
+            </div>
+          )}
+        </Show>
+
+        <Show when={props.state.permissionRequest()} keyed>
+          {(request) => (
+            <div>
+              <SessionPermissionDock
+                request={request}
+                responding={props.state.permissionResponding()}
+                onDecide={props.state.decide}
+              />
+            </div>
+          )}
+        </Show>
+
+        <Show when={!props.state.blocked()}>
+          <Show
+            when={prompt.ready()}
+            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 whitespace-pre-wrap pointer-events-none">
+                {handoffPrompt() || language.t("prompt.loading")}
+              </div>
+            }
+          >
+            <Show when={props.state.dock()}>
+              <div
+                classList={{
+                  "transition-[max-height,opacity,transform] duration-[400ms] ease-out overflow-hidden": true,
+                  "max-h-[320px]": !props.state.closing(),
+                  "max-h-0 pointer-events-none": props.state.closing(),
+                  "opacity-0 translate-y-9": props.state.closing() || props.state.opening(),
+                  "opacity-100 translate-y-0": !props.state.closing() && !props.state.opening(),
+                }}
+              >
+                <SessionTodoDock
+                  todos={props.state.todos()}
+                  title={language.t("session.todo.title")}
+                  collapseLabel={language.t("session.todo.collapse")}
+                  expandLabel={language.t("session.todo.expand")}
+                />
+              </div>
+            </Show>
+            <div
+              classList={{
+                "relative z-10": true,
+                "transition-[margin] duration-[400ms] ease-out": true,
+                "-mt-9": props.state.dock() && !props.state.closing(),
+                "mt-0": !props.state.dock() || props.state.closing(),
+              }}
+            >
+              <PromptInput
+                ref={props.inputRef}
+                newSessionWorktree={props.newSessionWorktree}
+                onNewSessionWorktreeReset={props.onNewSessionWorktreeReset}
+                onSubmit={props.onSubmit}
+              />
+            </div>
+          </Show>
+        </Show>
+      </div>
+    </div>
+  )
+}

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

@@ -0,0 +1,158 @@
+import { createEffect, createMemo, on, onCleanup } from "solid-js"
+import { createStore } from "solid-js/store"
+import type { PermissionRequest, QuestionRequest, Todo } from "@opencode-ai/sdk/v2"
+import { useParams } from "@solidjs/router"
+import { showToast } from "@opencode-ai/ui/toast"
+import { useGlobalSync } from "@/context/global-sync"
+import { useLanguage } from "@/context/language"
+import { useSDK } from "@/context/sdk"
+import { useSync } from "@/context/sync"
+
+export function createSessionComposerBlocked() {
+  const params = useParams()
+  const sync = useSync()
+  return createMemo(() => {
+    const id = params.id
+    if (!id) return false
+    return !!sync.data.permission[id]?.[0] || !!sync.data.question[id]?.[0]
+  })
+}
+
+export function createSessionComposerState() {
+  const params = useParams()
+  const sdk = useSDK()
+  const sync = useSync()
+  const globalSync = useGlobalSync()
+  const language = useLanguage()
+
+  const questionRequest = createMemo((): QuestionRequest | undefined => {
+    const id = params.id
+    if (!id) return
+    return sync.data.question[id]?.[0]
+  })
+
+  const permissionRequest = createMemo((): PermissionRequest | undefined => {
+    const id = params.id
+    if (!id) return
+    return sync.data.permission[id]?.[0]
+  })
+
+  const blocked = createSessionComposerBlocked()
+
+  const todos = createMemo((): Todo[] => {
+    const id = params.id
+    if (!id) return []
+    return globalSync.data.session_todo[id] ?? []
+  })
+
+  const [store, setStore] = createStore({
+    responding: undefined as string | undefined,
+    dock: todos().length > 0,
+    closing: false,
+    opening: false,
+  })
+
+  const permissionResponding = createMemo(() => {
+    const perm = permissionRequest()
+    if (!perm) return false
+    return store.responding === perm.id
+  })
+
+  const decide = (response: "once" | "always" | "reject") => {
+    const perm = permissionRequest()
+    if (!perm) return
+    if (store.responding === perm.id) return
+
+    setStore("responding", perm.id)
+    sdk.client.permission
+      .respond({ sessionID: perm.sessionID, permissionID: perm.id, response })
+      .catch((err: unknown) => {
+        const description = err instanceof Error ? err.message : String(err)
+        showToast({ title: language.t("common.requestFailed"), description })
+      })
+      .finally(() => {
+        setStore("responding", (id) => (id === perm.id ? undefined : id))
+      })
+  }
+
+  const done = createMemo(
+    () => todos().length > 0 && todos().every((todo) => todo.status === "completed" || todo.status === "cancelled"),
+  )
+
+  let timer: number | undefined
+  let raf: number | undefined
+
+  const scheduleClose = () => {
+    if (timer) window.clearTimeout(timer)
+    timer = window.setTimeout(() => {
+      setStore({ dock: false, closing: false })
+      timer = undefined
+    }, 400)
+  }
+
+  createEffect(
+    on(
+      () => [todos().length, done()] as const,
+      ([count, complete], prev) => {
+        if (raf) cancelAnimationFrame(raf)
+        raf = undefined
+
+        if (count === 0) {
+          if (timer) window.clearTimeout(timer)
+          timer = undefined
+          setStore({ dock: false, closing: false, opening: false })
+          return
+        }
+
+        if (!complete) {
+          if (timer) window.clearTimeout(timer)
+          timer = undefined
+          const hidden = !store.dock || store.closing
+          setStore({ dock: true, closing: false })
+          if (hidden) {
+            setStore("opening", true)
+            raf = requestAnimationFrame(() => {
+              setStore("opening", false)
+              raf = undefined
+            })
+            return
+          }
+          setStore("opening", false)
+          return
+        }
+
+        if (prev && prev[1]) {
+          if (store.closing && !timer) scheduleClose()
+          return
+        }
+
+        setStore({ dock: true, opening: false, closing: true })
+        scheduleClose()
+      },
+    ),
+  )
+
+  onCleanup(() => {
+    if (!timer) return
+    window.clearTimeout(timer)
+  })
+
+  onCleanup(() => {
+    if (!raf) return
+    cancelAnimationFrame(raf)
+  })
+
+  return {
+    blocked,
+    questionRequest,
+    permissionRequest,
+    permissionResponding,
+    decide,
+    todos,
+    dock: () => store.dock,
+    closing: () => store.closing,
+    opening: () => store.opening,
+  }
+}
+
+export type SessionComposerState = ReturnType<typeof createSessionComposerState>

+ 74 - 0
packages/app/src/pages/session/composer/session-permission-dock.tsx

@@ -0,0 +1,74 @@
+import { For, Show } from "solid-js"
+import type { PermissionRequest } from "@opencode-ai/sdk/v2"
+import { Button } from "@opencode-ai/ui/button"
+import { DockPrompt } from "@opencode-ai/ui/dock-prompt"
+import { Icon } from "@opencode-ai/ui/icon"
+import { useLanguage } from "@/context/language"
+
+export function SessionPermissionDock(props: {
+  request: PermissionRequest
+  responding: boolean
+  onDecide: (response: "once" | "always" | "reject") => void
+}) {
+  const language = useLanguage()
+
+  const toolDescription = () => {
+    const key = `settings.permissions.tool.${props.request.permission}.description`
+    const value = language.t(key as Parameters<typeof language.t>[0])
+    if (value === key) return ""
+    return value
+  }
+
+  return (
+    <DockPrompt
+      kind="permission"
+      header={
+        <div data-slot="permission-row" data-variant="header">
+          <span data-slot="permission-icon">
+            <Icon name="warning" size="normal" />
+          </span>
+          <div data-slot="permission-header-title">{language.t("notification.permission.title")}</div>
+        </div>
+      }
+      footer={
+        <>
+          <div />
+          <div data-slot="permission-footer-actions">
+            <Button variant="ghost" size="normal" onClick={() => props.onDecide("reject")} disabled={props.responding}>
+              {language.t("ui.permission.deny")}
+            </Button>
+            <Button
+              variant="secondary"
+              size="normal"
+              onClick={() => props.onDecide("always")}
+              disabled={props.responding}
+            >
+              {language.t("ui.permission.allowAlways")}
+            </Button>
+            <Button variant="primary" size="normal" onClick={() => props.onDecide("once")} disabled={props.responding}>
+              {language.t("ui.permission.allowOnce")}
+            </Button>
+          </div>
+        </>
+      }
+    >
+      <Show when={toolDescription()}>
+        <div data-slot="permission-row">
+          <span data-slot="permission-spacer" aria-hidden="true" />
+          <div data-slot="permission-hint">{toolDescription()}</div>
+        </div>
+      </Show>
+
+      <Show when={props.request.patterns.length > 0}>
+        <div data-slot="permission-row">
+          <span data-slot="permission-spacer" aria-hidden="true" />
+          <div data-slot="permission-patterns">
+            <For each={props.request.patterns}>
+              {(pattern) => <code class="text-12-regular text-text-base break-all">{pattern}</code>}
+            </For>
+          </div>
+        </div>
+      </Show>
+    </DockPrompt>
+  )
+}

+ 1 - 1
packages/app/src/components/question-dock.tsx → packages/app/src/pages/session/composer/session-question-dock.tsx

@@ -8,7 +8,7 @@ import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
 import { useLanguage } from "@/context/language"
 import { useSDK } from "@/context/sdk"
 
-export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => {
+export const SessionQuestionDock: Component<{ request: QuestionRequest }> = (props) => {
   const sdk = useSDK()
   const language = useLanguage()
 

+ 7 - 4
packages/app/src/components/session-todo-dock.tsx → packages/app/src/pages/session/composer/session-todo-dock.tsx

@@ -1,5 +1,6 @@
 import type { Todo } from "@opencode-ai/sdk/v2"
 import { Checkbox } from "@opencode-ai/ui/checkbox"
+import { DockTray } from "@opencode-ai/ui/dock-surface"
 import { IconButton } from "@opencode-ai/ui/icon-button"
 import { For, Show, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
 import { createStore } from "solid-js/store"
@@ -54,13 +55,14 @@ export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseL
   const preview = createMemo(() => active()?.content ?? "")
 
   return (
-    <div
+    <DockTray
+      data-component="session-todo-dock"
       classList={{
-        "bg-background-base border border-border-weak-base relative z-0 rounded-[12px] overflow-clip": true,
         "h-[78px]": store.collapsed,
       }}
     >
       <div
+        data-action="session-todo-toggle"
         class="pl-3 pr-2 py-2 flex items-center gap-2"
         role="button"
         tabIndex={0}
@@ -81,6 +83,7 @@ export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseL
         </Show>
         <div classList={{ "ml-auto": !store.collapsed, "ml-1": store.collapsed }}>
           <IconButton
+            data-action="session-todo-toggle-button"
             icon="chevron-down"
             size="normal"
             variant="ghost"
@@ -98,10 +101,10 @@ export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseL
         </div>
       </div>
 
-      <div hidden={store.collapsed}>
+      <div data-slot="session-todo-list" hidden={store.collapsed}>
         <TodoList todos={props.todos} open={!store.collapsed} />
       </div>
-    </div>
+    </DockTray>
   )
 }
 

+ 0 - 318
packages/app/src/pages/session/session-prompt-dock.tsx

@@ -1,318 +0,0 @@
-import { For, Show, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
-import type { PermissionRequest, QuestionRequest, Todo } from "@opencode-ai/sdk/v2"
-import { useParams } from "@solidjs/router"
-import { Button } from "@opencode-ai/ui/button"
-import { DockPrompt } from "@opencode-ai/ui/dock-prompt"
-import { Icon } from "@opencode-ai/ui/icon"
-import { showToast } from "@opencode-ai/ui/toast"
-import { PromptInput } from "@/components/prompt-input"
-import { QuestionDock } from "@/components/question-dock"
-import { SessionTodoDock } from "@/components/session-todo-dock"
-import { useGlobalSync } from "@/context/global-sync"
-import { useLanguage } from "@/context/language"
-import { usePrompt } from "@/context/prompt"
-import { useSDK } from "@/context/sdk"
-import { useSync } from "@/context/sync"
-import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff"
-
-export function SessionPromptDock(props: {
-  centered: boolean
-  inputRef: (el: HTMLDivElement) => void
-  newSessionWorktree: string
-  onNewSessionWorktreeReset: () => void
-  onSubmit: () => void
-  setPromptDockRef: (el: HTMLDivElement) => void
-}) {
-  const params = useParams()
-  const sdk = useSDK()
-  const sync = useSync()
-  const globalSync = useGlobalSync()
-  const prompt = usePrompt()
-  const language = useLanguage()
-
-  const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
-  const handoffPrompt = createMemo(() => getSessionHandoff(sessionKey())?.prompt)
-
-  const todos = createMemo((): Todo[] => {
-    const id = params.id
-    if (!id) return []
-    return globalSync.data.session_todo[id] ?? []
-  })
-
-  const questionRequest = createMemo((): QuestionRequest | undefined => {
-    const sessionID = params.id
-    if (!sessionID) return
-    return sync.data.question[sessionID]?.[0]
-  })
-
-  const permissionRequest = createMemo((): PermissionRequest | undefined => {
-    const sessionID = params.id
-    if (!sessionID) return
-    return sync.data.permission[sessionID]?.[0]
-  })
-
-  const blocked = createMemo(() => !!permissionRequest() || !!questionRequest())
-
-  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(sessionKey(), { prompt: previewPrompt() })
-  })
-
-  const [responding, setResponding] = createSignal<string | undefined>()
-  const permissionResponding = () => {
-    const perm = permissionRequest()
-    if (!perm) return false
-    return responding() === perm.id
-  }
-
-  const decide = (response: "once" | "always" | "reject") => {
-    const perm = permissionRequest()
-    if (!perm) return
-    if (responding() === perm.id) return
-
-    setResponding(perm.id)
-    sdk.client.permission
-      .respond({ sessionID: perm.sessionID, permissionID: perm.id, response })
-      .catch((err: unknown) => {
-        const message = err instanceof Error ? err.message : String(err)
-        showToast({ title: language.t("common.requestFailed"), description: message })
-      })
-      .finally(() => {
-        setResponding((id) => (id === perm.id ? undefined : id))
-      })
-  }
-
-  const done = createMemo(
-    () => todos().length > 0 && todos().every((todo) => todo.status === "completed" || todo.status === "cancelled"),
-  )
-
-  const [dock, setDock] = createSignal(todos().length > 0)
-  const [closing, setClosing] = createSignal(false)
-  const [opening, setOpening] = createSignal(false)
-  let timer: number | undefined
-  let raf: number | undefined
-
-  const scheduleClose = () => {
-    if (timer) window.clearTimeout(timer)
-    timer = window.setTimeout(() => {
-      setDock(false)
-      setClosing(false)
-      timer = undefined
-    }, 400)
-  }
-
-  createEffect(
-    on(
-      () => [todos().length, done()] as const,
-      ([count, complete], prev) => {
-        if (raf) cancelAnimationFrame(raf)
-        raf = undefined
-
-        if (count === 0) {
-          if (timer) window.clearTimeout(timer)
-          timer = undefined
-          setDock(false)
-          setClosing(false)
-          setOpening(false)
-          return
-        }
-
-        if (!complete) {
-          if (timer) window.clearTimeout(timer)
-          timer = undefined
-          const wasHidden = !dock() || closing()
-          setDock(true)
-          setClosing(false)
-          if (wasHidden) {
-            setOpening(true)
-            raf = requestAnimationFrame(() => {
-              setOpening(false)
-              raf = undefined
-            })
-            return
-          }
-          setOpening(false)
-          return
-        }
-
-        if (prev && prev[1]) {
-          if (closing() && !timer) scheduleClose()
-          return
-        }
-
-        setDock(true)
-        setOpening(false)
-        setClosing(true)
-        scheduleClose()
-      },
-    ),
-  )
-
-  onCleanup(() => {
-    if (!timer) return
-    window.clearTimeout(timer)
-  })
-
-  onCleanup(() => {
-    if (!raf) return
-    cancelAnimationFrame(raf)
-  })
-
-  return (
-    <div
-      ref={props.setPromptDockRef}
-      data-component="session-prompt-dock"
-      class="shrink-0 w-full pb-3 flex flex-col justify-center items-center bg-background-stronger pointer-events-none"
-    >
-      <div
-        classList={{
-          "w-full px-3 pointer-events-auto": true,
-          "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
-        }}
-      >
-        <Show when={questionRequest()} keyed>
-          {(req) => {
-            return (
-              <div>
-                <QuestionDock request={req} />
-              </div>
-            )
-          }}
-        </Show>
-
-        <Show when={permissionRequest()} keyed>
-          {(perm) => {
-            const toolDescription = () => {
-              const key = `settings.permissions.tool.${perm.permission}.description`
-              const value = language.t(key as Parameters<typeof language.t>[0])
-              if (value === key) return ""
-              return value
-            }
-
-            return (
-              <div>
-                <DockPrompt
-                  kind="permission"
-                  header={
-                    <div data-slot="permission-row" data-variant="header">
-                      <span data-slot="permission-icon">
-                        <Icon name="warning" size="normal" />
-                      </span>
-                      <div data-slot="permission-header-title">{language.t("notification.permission.title")}</div>
-                    </div>
-                  }
-                  footer={
-                    <>
-                      <div />
-                      <div data-slot="permission-footer-actions">
-                        <Button
-                          variant="ghost"
-                          size="normal"
-                          onClick={() => decide("reject")}
-                          disabled={permissionResponding()}
-                        >
-                          {language.t("ui.permission.deny")}
-                        </Button>
-                        <Button
-                          variant="secondary"
-                          size="normal"
-                          onClick={() => decide("always")}
-                          disabled={permissionResponding()}
-                        >
-                          {language.t("ui.permission.allowAlways")}
-                        </Button>
-                        <Button
-                          variant="primary"
-                          size="normal"
-                          onClick={() => decide("once")}
-                          disabled={permissionResponding()}
-                        >
-                          {language.t("ui.permission.allowOnce")}
-                        </Button>
-                      </div>
-                    </>
-                  }
-                >
-                  <Show when={toolDescription()}>
-                    <div data-slot="permission-row">
-                      <span data-slot="permission-spacer" aria-hidden="true" />
-                      <div data-slot="permission-hint">{toolDescription()}</div>
-                    </div>
-                  </Show>
-
-                  <Show when={perm.patterns.length > 0}>
-                    <div data-slot="permission-row">
-                      <span data-slot="permission-spacer" aria-hidden="true" />
-                      <div data-slot="permission-patterns">
-                        <For each={perm.patterns}>
-                          {(pattern) => <code class="text-12-regular text-text-base break-all">{pattern}</code>}
-                        </For>
-                      </div>
-                    </div>
-                  </Show>
-                </DockPrompt>
-              </div>
-            )
-          }}
-        </Show>
-
-        <Show when={!blocked()}>
-          <Show
-            when={prompt.ready()}
-            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 whitespace-pre-wrap pointer-events-none">
-                {handoffPrompt() || language.t("prompt.loading")}
-              </div>
-            }
-          >
-            <Show when={dock()}>
-              <div
-                classList={{
-                  "transition-[max-height,opacity,transform] duration-[400ms] ease-out overflow-hidden": true,
-                  "max-h-[320px]": !closing(),
-                  "max-h-0 pointer-events-none": closing(),
-                  "opacity-0 translate-y-9": closing() || opening(),
-                  "opacity-100 translate-y-0": !closing() && !opening(),
-                }}
-              >
-                <SessionTodoDock
-                  todos={todos()}
-                  title={language.t("session.todo.title")}
-                  collapseLabel={language.t("session.todo.collapse")}
-                  expandLabel={language.t("session.todo.expand")}
-                />
-              </div>
-            </Show>
-            <div
-              classList={{
-                "relative z-10": true,
-                "transition-[margin] duration-[400ms] ease-out": true,
-                "-mt-9": dock() && !closing(),
-                "mt-0": !dock() || closing(),
-              }}
-            >
-              <PromptInput
-                ref={props.inputRef}
-                newSessionWorktree={props.newSessionWorktree}
-                onNewSessionWorktreeReset={props.onNewSessionWorktreeReset}
-                onSubmit={props.onSubmit}
-              />
-            </div>
-          </Show>
-        </Show>
-      </div>
-    </div>
-  )
-}

+ 4 - 3
packages/ui/src/components/dock-prompt.tsx

@@ -1,4 +1,5 @@
 import type { JSX } from "solid-js"
+import { DockShell, DockTray } from "./dock-surface"
 
 export function DockPrompt(props: {
   kind: "question" | "permission"
@@ -11,11 +12,11 @@ export function DockPrompt(props: {
 
   return (
     <div data-component="dock-prompt" data-kind={props.kind} ref={props.ref}>
-      <div data-slot={slot("body")}>
+      <DockShell data-slot={slot("body")}>
         <div data-slot={slot("header")}>{props.header}</div>
         <div data-slot={slot("content")}>{props.children}</div>
-      </div>
-      <div data-slot={slot("footer")}>{props.footer}</div>
+      </DockShell>
+      <DockTray data-slot={slot("footer")}>{props.footer}</DockTray>
     </div>
   )
 }

+ 23 - 0
packages/ui/src/components/dock-surface.css

@@ -0,0 +1,23 @@
+[data-dock-surface="shell"] {
+  background-color: var(--surface-raised-stronger-non-alpha);
+  box-shadow: var(--shadow-xs-border);
+  position: relative;
+  z-index: 10;
+  border-radius: 12px;
+  overflow: clip;
+}
+
+[data-dock-surface="tray"] {
+  background-color: var(--background-base);
+  border: 1px solid var(--border-weak-base);
+  position: relative;
+  z-index: 0;
+  border-radius: 12px;
+  overflow: clip;
+}
+
+[data-dock-surface="tray"][data-dock-attach="top"] {
+  margin-top: -0.875rem;
+  border-top-left-radius: 0;
+  border-top-right-radius: 0;
+}

+ 54 - 0
packages/ui/src/components/dock-surface.tsx

@@ -0,0 +1,54 @@
+import { type ComponentProps, splitProps } from "solid-js"
+
+export interface DockTrayProps extends ComponentProps<"div"> {
+  attach?: "none" | "top"
+}
+
+export function DockShell(props: ComponentProps<"div">) {
+  const [split, rest] = splitProps(props, ["children", "class", "classList"])
+  return (
+    <div
+      {...rest}
+      data-dock-surface="shell"
+      classList={{
+        ...(split.classList ?? {}),
+        [split.class ?? ""]: !!split.class,
+      }}
+    >
+      {split.children}
+    </div>
+  )
+}
+
+export function DockShellForm(props: ComponentProps<"form">) {
+  const [split, rest] = splitProps(props, ["children", "class", "classList"])
+  return (
+    <form
+      {...rest}
+      data-dock-surface="shell"
+      classList={{
+        ...(split.classList ?? {}),
+        [split.class ?? ""]: !!split.class,
+      }}
+    >
+      {split.children}
+    </form>
+  )
+}
+
+export function DockTray(props: DockTrayProps) {
+  const [split, rest] = splitProps(props, ["attach", "children", "class", "classList"])
+  return (
+    <div
+      {...rest}
+      data-dock-surface="tray"
+      data-dock-attach={split.attach || "none"}
+      classList={{
+        ...(split.classList ?? {}),
+        [split.class ?? ""]: !!split.class,
+      }}
+    >
+      {split.children}
+    </div>
+  )
+}

+ 0 - 24
packages/ui/src/components/message-part.css

@@ -768,12 +768,6 @@
     flex: 1;
     min-height: 0;
     padding: 12px 12px 0;
-    background-color: var(--surface-raised-stronger-non-alpha);
-    border-radius: 12px;
-    box-shadow: var(--shadow-xs-border);
-    overflow: clip;
-    position: relative;
-    z-index: 10;
   }
 
   [data-slot="permission-header"] {
@@ -856,13 +850,7 @@
     justify-content: space-between;
     flex-shrink: 0;
     padding: 32px 8px 8px;
-    background-color: var(--background-base);
-    border: 1px solid var(--border-weak-base);
-    border-radius: 12px;
-    overflow: clip;
     margin-top: -24px;
-    position: relative;
-    z-index: 0;
   }
 
   [data-slot="permission-footer-actions"] {
@@ -892,12 +880,6 @@
     flex: 1;
     min-height: 0;
     padding: 8px 8px 0;
-    background-color: var(--surface-raised-stronger-non-alpha);
-    border-radius: 12px;
-    box-shadow: var(--shadow-xs-border);
-    overflow: clip;
-    position: relative;
-    z-index: 10;
   }
 
   [data-slot="question-header"] {
@@ -1181,13 +1163,7 @@
     justify-content: space-between;
     flex-shrink: 0;
     padding: 32px 8px 8px;
-    background-color: var(--background-base);
-    border: 1px solid var(--border-weak-base);
-    border-radius: 12px;
-    overflow: clip;
     margin-top: -24px;
-    position: relative;
-    z-index: 0;
   }
 
   [data-slot="question-footer-actions"] {

+ 1 - 0
packages/ui/src/styles/index.css

@@ -23,6 +23,7 @@
 @import "../components/file-icon.css" layer(components);
 @import "../components/hover-card.css" layer(components);
 @import "../components/provider-icon.css" layer(components);
+@import "../components/dock-surface.css" layer(components);
 @import "../components/icon.css" layer(components);
 @import "../components/icon-button.css" layer(components);
 @import "../components/image-preview.css" layer(components);

+ 240 - 0
specs/session-composer-refactor-plan.md

@@ -0,0 +1,240 @@
+# Session Composer Refactor Plan
+
+## Goal
+
+Improve structure, ownership, and reuse for the bottom-of-session composer area without changing user-visible behavior.
+
+Scope:
+
+- `packages/ui/src/components/dock-prompt.tsx`
+- `packages/app/src/components/session-todo-dock.tsx`
+- `packages/app/src/components/question-dock.tsx`
+- `packages/app/src/pages/session/session-prompt-dock.tsx`
+- related shared UI in `packages/app/src/components/prompt-input.tsx`
+
+## Decisions Up Front
+
+1. **`session-prompt-dock` should stay route-scoped.**
+   It is session-page orchestration, so it belongs under `pages/session`, not global `src/components`.
+
+2. **The orchestrator should keep blocking ownership.**
+   A single component should decide whether to show blockers (`question`/`permission`) or the regular prompt input. This avoids drift and duplicate logic.
+
+3. **Current component does too much.**
+   Split state derivation, permission actions, and rendering into smaller units while preserving behavior.
+
+4. **There is style duplication worth addressing.**
+   The prompt top shell and lower tray (`prompt-input.tsx`) visually overlap with dock shells/footers and todo containers. We should extract reusable dock surface primitives.
+
+---
+
+## Phase 0 (Mandatory Gate): Baseline E2E Coverage
+
+No refactor work starts until this phase is complete and green locally.
+
+### 0.1 Deterministic test harness
+
+Add a test-only way to put a session into exact dock states, so tests do not rely on model/tool nondeterminism.
+
+Proposed implementation:
+
+- Add a guarded e2e route in backend (enabled only when a dedicated env flag is set by e2e-local runner).
+  - New route file: `packages/opencode/src/server/routes/e2e.ts`
+  - Mount from: `packages/opencode/src/server/server.ts`
+  - Gate behind env flag (for example `OPENCODE_E2E=1`) so this route is never exposed in normal runs.
+- Add seed helpers in app e2e layer:
+  - `packages/app/e2e/actions.ts` (or `fixtures.ts`) helpers to:
+    - seed question request for a session
+    - seed permission request for a session
+    - seed/update todos for a session
+    - clear seeded blockers/todos
+- Update e2e-local runner to set the flag:
+  - `packages/app/script/e2e-local.ts`
+
+### 0.2 New e2e spec
+
+Create a focused spec:
+
+- `packages/app/e2e/session/session-composer-dock.spec.ts`
+
+Test matrix (minimum required):
+
+1. **Default prompt dock**
+   - no blocker state
+   - assert prompt input is visible and focusable
+   - assert blocker cards are absent
+
+2. **Blocked question flow**
+   - seed question request for session
+   - assert question dock renders
+   - assert prompt input is not shown/active
+   - answer and submit
+   - assert unblock and prompt input returns
+
+3. **Blocked permission flow**
+   - seed permission request with patterns + optional description
+   - assert permission dock renders expected actions
+   - assert prompt input is not shown/active
+   - test each response path (`once`, `always`, `reject`) across tests
+   - assert unblock behavior
+
+4. **Todo dock transitions and collapse behavior**
+   - seed todos with `pending`/`in_progress`
+   - assert todo dock appears above prompt and can collapse/expand
+   - update todos to all completed/cancelled
+   - assert close animation path and eventual hide
+
+5. **Keyboard focus behavior while blocked**
+   - with blocker active, typing from document context must not focus prompt input
+   - blocker actions remain keyboard reachable
+
+Notes:
+
+- Prefer stable selectors (`data-component`, `data-slot`, role/name).
+- Extend `packages/app/e2e/selectors.ts` as needed.
+- Use `expect.poll` for async transitions.
+
+### 0.3 Gate commands (must pass before Phase 1)
+
+Run from `packages/app` (never from repo root):
+
+```bash
+bun test:e2e:local -- e2e/session/session-composer-dock.spec.ts
+bun test:e2e:local -- e2e/prompt/prompt.spec.ts e2e/prompt/prompt-multiline.spec.ts e2e/commands/input-focus.spec.ts
+bun test:e2e:local
+```
+
+If any fail, stop and fix before refactor.
+
+---
+
+## Phase 1: Structural Refactor (No Intended Behavior Changes)
+
+### 1.1 Colocate session-composer files
+
+Create a route-local composer folder:
+
+```txt
+packages/app/src/pages/session/composer/
+  session-composer-region.tsx      # rename/move from session-prompt-dock.tsx
+  session-composer-state.ts        # derived state + actions
+  session-permission-dock.tsx      # extracted from inline JSX
+  session-question-dock.tsx        # moved from src/components/question-dock.tsx
+  session-todo-dock.tsx            # moved from src/components/session-todo-dock.tsx
+  index.ts
+```
+
+Import updates:
+
+- `packages/app/src/pages/session.tsx` imports `SessionComposerRegion` from `pages/session/composer`.
+
+### 1.2 Split responsibilities
+
+- Keep `session-composer-region.tsx` focused on rendering orchestration:
+  - blocker mode vs normal mode
+  - relative stacking (todo above prompt)
+  - handoff fallback rendering
+- Move side-effect/business pieces into `session-composer-state.ts`:
+  - derive `questionRequest`, `permissionRequest`, `blocked`, todo visibility state
+  - permission response action + in-flight state
+  - todo close/open animation state
+
+### 1.3 Remove duplicate blocked logic in `session.tsx`
+
+Current `session.tsx` computes `blocked` independently. Make the composer state the single source for blocker status consumed by both:
+
+- page-level keydown autofocus guard
+- composer rendering guard
+
+### 1.4 Keep prompt gating in orchestrator
+
+`session-composer-region` should remain responsible for choosing whether `PromptInput` renders when blocked.
+
+Rationale:
+
+- this is layout-mode orchestration, not prompt implementation detail
+- keeps blocker and prompt transitions coordinated in one place
+
+### 1.5 Phase 1 acceptance criteria
+
+- No intentional behavior deltas.
+- Phase 0 suite remains green.
+- `session-prompt-dock` no longer exists as a large mixed-responsibility component.
+- Session composer files are colocated under `pages/session/composer`.
+
+---
+
+## Phase 2: Reuse + Styling Maintainability
+
+### 2.1 Extract shared dock surface primitives
+
+Create reusable shell/tray wrappers to remove repeated visual scaffolding:
+
+- primary elevated surface (prompt top shell / dock body)
+- secondary tray surface (prompt bottom bar / dock footer / todo shell)
+
+Proposed targets:
+
+- `packages/ui/src/components` for shared primitives if reused by both app and ui components
+- or `packages/app/src/pages/session/composer` first, then promote to ui after proving reuse
+
+### 2.2 Apply primitives to current components
+
+Adopt in:
+
+- `packages/app/src/components/prompt-input.tsx`
+- `packages/app/src/pages/session/composer/session-todo-dock.tsx`
+- `packages/ui/src/components/dock-prompt.tsx` (where appropriate)
+
+Focus on deduping patterns seen in:
+
+- prompt elevated shell styles (`prompt-input.tsx` form container)
+- prompt lower tray (`prompt-input.tsx` bottom panel)
+- dock prompt footer/body and todo dock container
+
+### 2.3 De-risk style ownership
+
+- Move dock-specific styling out of overly broad files (for example, avoid keeping new dock-specific rules buried in unrelated message-part styling files).
+- Keep slot names stable unless tests are updated in the same PR.
+
+### 2.4 Optional follow-up (if low risk)
+
+Evaluate extracting shared question/permission presentational pieces used by:
+
+- `packages/app/src/pages/session/composer/session-question-dock.tsx`
+- `packages/ui/src/components/message-part.tsx`
+
+Only do this if behavior parity is protected by tests and the change is still reviewable.
+
+### 2.5 Phase 2 acceptance criteria
+
+- Reduced duplicated shell/tray styling code.
+- No regressions in blocker/todo/prompt transitions.
+- Phase 0 suite remains green.
+
+---
+
+## Implementation Sequence (single branch)
+
+1. **Step A - Baseline safety net**
+   - Add e2e harness + new session composer dock spec + selector/helpers.
+   - Must pass locally before any refactor work proceeds.
+
+2. **Step B - Phase 1 colocation/splitting**
+   - Move/rename files, extract state and permission component, keep behavior.
+
+3. **Step C - Phase 1 dedupe blocked source**
+   - Remove duplicate blocked derivation and wire page autofocus guard to shared source.
+
+4. **Step D - Phase 2 style primitives**
+   - Introduce shared surface primitives and migrate prompt/todo/dock usage.
+
+5. **Step E (optional) - shared question/permission presentational extraction**
+
+---
+
+## Rollback Strategy
+
+- Keep each step logically isolated and easy to revert.
+- If regressions occur, revert the latest completed step first and rerun the Phase 0 suite.
+- If style extraction destabilizes behavior, keep structural Phase 1 changes and revert only Phase 2 styling commits.