summaryrefslogtreecommitdiffhomepage
path: root/packages/app
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-02-19 15:02:45 -0600
committerGitHub <[email protected]>2026-02-19 15:02:45 -0600
commit49cc872c4415f081b4208d16fd0d85e425a75eed (patch)
treeed78239c2a2bb941c5008f378a00592f5b10717a /packages/app
parentf8dad0ae170acb9667d9402c162f7c29980373c1 (diff)
downloadopencode-49cc872c4415f081b4208d16fd0d85e425a75eed.tar.gz
opencode-49cc872c4415f081b4208d16fd0d85e425a75eed.zip
chore: refactor composer/dock components (#14328)
Diffstat (limited to 'packages/app')
-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
12 files changed, 752 insertions, 337 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>
- )
-}