summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--packages/app/e2e/actions.ts157
-rw-r--r--packages/app/e2e/selectors.ts10
-rw-r--r--packages/app/e2e/session/session-composer-dock.spec.ts207
-rw-r--r--packages/app/src/components/prompt-input.tsx12
-rw-r--r--packages/app/src/pages/session.tsx13
-rw-r--r--packages/app/src/pages/session/composer/index.ts3
-rw-r--r--packages/app/src/pages/session/composer/session-composer-region.tsx124
-rw-r--r--packages/app/src/pages/session/composer/session-composer-state.ts158
-rw-r--r--packages/app/src/pages/session/composer/session-permission-dock.tsx74
-rw-r--r--packages/app/src/pages/session/composer/session-question-dock.tsx (renamed from packages/app/src/components/question-dock.tsx)2
-rw-r--r--packages/app/src/pages/session/composer/session-todo-dock.tsx (renamed from packages/app/src/components/session-todo-dock.tsx)11
-rw-r--r--packages/app/src/pages/session/session-prompt-dock.tsx318
-rw-r--r--packages/ui/src/components/dock-prompt.tsx7
-rw-r--r--packages/ui/src/components/dock-surface.css23
-rw-r--r--packages/ui/src/components/dock-surface.tsx54
-rw-r--r--packages/ui/src/components/message-part.css24
-rw-r--r--packages/ui/src/styles/index.css1
-rw-r--r--specs/session-composer-refactor-plan.md240
18 files changed, 1074 insertions, 364 deletions
diff --git a/packages/app/e2e/actions.ts b/packages/app/e2e/actions.ts
index 3467effa6..d42c0fceb 100644
--- a/packages/app/e2e/actions.ts
+++ b/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)
diff --git a/packages/app/e2e/selectors.ts b/packages/app/e2e/selectors.ts
index 1a0afbab1..be0bc0571 100644
--- a/packages/app/e2e/selectors.ts
+++ b/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"]'
diff --git a/packages/app/e2e/session/session-composer-dock.spec.ts b/packages/app/e2e/session/session-composer-dock.spec.ts
new file mode 100644
index 000000000..6bf7714a6
--- /dev/null
+++ b/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)
+ })
+ })
+})
diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx
index 8d97fccea..0777bacc7 100644
--- a/packages/app/src/components/prompt-input.tsx
+++ b/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>
)
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index 21ba4e7d7..496f0487d 100644
--- a/packages/app/src/pages/session.tsx
+++ b/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
diff --git a/packages/app/src/pages/session/composer/index.ts b/packages/app/src/pages/session/composer/index.ts
new file mode 100644
index 000000000..e244a1536
--- /dev/null
+++ b/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"
diff --git a/packages/app/src/pages/session/composer/session-composer-region.tsx b/packages/app/src/pages/session/composer/session-composer-region.tsx
new file mode 100644
index 000000000..ccf39f797
--- /dev/null
+++ b/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>
+ )
+}
diff --git a/packages/app/src/pages/session/composer/session-composer-state.ts b/packages/app/src/pages/session/composer/session-composer-state.ts
new file mode 100644
index 000000000..04c6f7e69
--- /dev/null
+++ b/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>
diff --git a/packages/app/src/pages/session/composer/session-permission-dock.tsx b/packages/app/src/pages/session/composer/session-permission-dock.tsx
new file mode 100644
index 000000000..06ff4f4aa
--- /dev/null
+++ b/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>
+ )
+}
diff --git a/packages/app/src/components/question-dock.tsx b/packages/app/src/pages/session/composer/session-question-dock.tsx
index cd2e495b1..97c81a49a 100644
--- a/packages/app/src/components/question-dock.tsx
+++ b/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()
diff --git a/packages/app/src/components/session-todo-dock.tsx b/packages/app/src/pages/session/composer/session-todo-dock.tsx
index aeb2e421b..ca7a5abd1 100644
--- a/packages/app/src/components/session-todo-dock.tsx
+++ b/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>
)
}
diff --git a/packages/app/src/pages/session/session-prompt-dock.tsx b/packages/app/src/pages/session/session-prompt-dock.tsx
deleted file mode 100644
index 0e0d06071..000000000
--- a/packages/app/src/pages/session/session-prompt-dock.tsx
+++ /dev/null
@@ -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>
- )
-}
diff --git a/packages/ui/src/components/dock-prompt.tsx b/packages/ui/src/components/dock-prompt.tsx
index 4def4862f..d774e7f17 100644
--- a/packages/ui/src/components/dock-prompt.tsx
+++ b/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>
)
}
diff --git a/packages/ui/src/components/dock-surface.css b/packages/ui/src/components/dock-surface.css
new file mode 100644
index 000000000..fd3430446
--- /dev/null
+++ b/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;
+}
diff --git a/packages/ui/src/components/dock-surface.tsx b/packages/ui/src/components/dock-surface.tsx
new file mode 100644
index 000000000..1c4af2ed5
--- /dev/null
+++ b/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>
+ )
+}
diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css
index f83eae097..254281858 100644
--- a/packages/ui/src/components/message-part.css
+++ b/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"] {
diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css
index f0a1275c3..efe00e5f1 100644
--- a/packages/ui/src/styles/index.css
+++ b/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);
diff --git a/specs/session-composer-refactor-plan.md b/specs/session-composer-refactor-plan.md
new file mode 100644
index 000000000..08fb0d832
--- /dev/null
+++ b/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.