From 49cc872c4415f081b4208d16fd0d85e425a75eed Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 19 Feb 2026 15:02:45 -0600 Subject: chore: refactor composer/dock components (#14328) --- packages/app/e2e/actions.ts | 157 ++++++++ packages/app/e2e/selectors.ts | 10 + .../app/e2e/session/session-composer-dock.spec.ts | 207 ++++++++++ packages/app/src/components/prompt-input.tsx | 12 +- packages/app/src/components/question-dock.tsx | 425 --------------------- packages/app/src/components/session-todo-dock.tsx | 208 ---------- packages/app/src/pages/session.tsx | 13 +- packages/app/src/pages/session/composer/index.ts | 3 + .../session/composer/session-composer-region.tsx | 124 ++++++ .../session/composer/session-composer-state.ts | 158 ++++++++ .../session/composer/session-permission-dock.tsx | 74 ++++ .../session/composer/session-question-dock.tsx | 425 +++++++++++++++++++++ .../pages/session/composer/session-todo-dock.tsx | 211 ++++++++++ .../app/src/pages/session/session-prompt-dock.tsx | 318 --------------- packages/ui/src/components/dock-prompt.tsx | 7 +- packages/ui/src/components/dock-surface.css | 23 ++ packages/ui/src/components/dock-surface.tsx | 54 +++ packages/ui/src/components/message-part.css | 24 -- packages/ui/src/styles/index.css | 1 + specs/session-composer-refactor-plan.md | 240 ++++++++++++ 20 files changed, 1702 insertions(+), 992 deletions(-) create mode 100644 packages/app/e2e/session/session-composer-dock.spec.ts delete mode 100644 packages/app/src/components/question-dock.tsx delete mode 100644 packages/app/src/components/session-todo-dock.tsx create mode 100644 packages/app/src/pages/session/composer/index.ts create mode 100644 packages/app/src/pages/session/composer/session-composer-region.tsx create mode 100644 packages/app/src/pages/session/composer/session-composer-state.ts create mode 100644 packages/app/src/pages/session/composer/session-permission-dock.tsx create mode 100644 packages/app/src/pages/session/composer/session-question-dock.tsx create mode 100644 packages/app/src/pages/session/composer/session-todo-dock.tsx delete mode 100644 packages/app/src/pages/session/session-prompt-dock.tsx create mode 100644 packages/ui/src/components/dock-surface.css create mode 100644 packages/ui/src/components/dock-surface.tsx create mode 100644 specs/session-composer-refactor-plan.md 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( } } +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 (input: { probe: () => Promise; 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 (input: { + sessionID: string + prompt: string + sdk: ReturnType + probe: () => Promise + 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, + 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, + 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, + 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, 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[0] + +async function withDockSession(sdk: Sdk, title: string, fn: (session: { id: string; title: string }) => Promise) { + 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(sdk: Sdk, sessionID: string, fn: () => Promise) { + 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 = (props) => { commandKeybind={command.keybind} t={(key) => language.t(key as Parameters[0])} /> -
= (props) => { -
+ -
+
@@ -1385,7 +1385,7 @@ export const PromptInput: Component = (props) => { />
-
+
) diff --git a/packages/app/src/components/question-dock.tsx b/packages/app/src/components/question-dock.tsx deleted file mode 100644 index cd2e495b1..000000000 --- a/packages/app/src/components/question-dock.tsx +++ /dev/null @@ -1,425 +0,0 @@ -import { For, Show, createMemo, onCleanup, onMount, type Component } from "solid-js" -import { createStore } from "solid-js/store" -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 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) => { - const sdk = useSDK() - const language = useLanguage() - - const questions = createMemo(() => props.request.questions) - const total = createMemo(() => questions().length) - - const [store, setStore] = createStore({ - tab: 0, - answers: [] as QuestionAnswer[], - custom: [] as string[], - customOn: [] as boolean[], - editing: false, - sending: false, - }) - - let root: HTMLDivElement | undefined - - const question = createMemo(() => questions()[store.tab]) - const options = createMemo(() => question()?.options ?? []) - const input = createMemo(() => store.custom[store.tab] ?? "") - const on = createMemo(() => store.customOn[store.tab] === true) - const multi = createMemo(() => question()?.multiple === true) - - const summary = createMemo(() => { - const n = Math.min(store.tab + 1, total()) - return `${n} of ${total()} questions` - }) - - const last = createMemo(() => store.tab >= total() - 1) - - const customUpdate = (value: string, selected: boolean = on()) => { - const prev = input().trim() - const next = value.trim() - - setStore("custom", store.tab, value) - if (!selected) return - - if (multi()) { - setStore("answers", store.tab, (current = []) => { - const removed = prev ? current.filter((item) => item.trim() !== prev) : current - if (!next) return removed - if (removed.some((item) => item.trim() === next)) return removed - return [...removed, next] - }) - return - } - - setStore("answers", store.tab, next ? [next] : []) - } - - const measure = () => { - if (!root) return - - const scroller = document.querySelector(".session-scroller") - const head = scroller instanceof HTMLElement ? scroller.firstElementChild : undefined - const top = - head instanceof HTMLElement && head.classList.contains("sticky") ? head.getBoundingClientRect().bottom : 0 - if (!top) { - root.style.removeProperty("--question-prompt-max-height") - return - } - - const dock = root.closest('[data-component="session-prompt-dock"]') - if (!(dock instanceof HTMLElement)) return - - const dockBottom = dock.getBoundingClientRect().bottom - const below = Math.max(0, dockBottom - root.getBoundingClientRect().bottom) - const gap = 8 - const max = Math.max(240, Math.floor(dockBottom - top - gap - below)) - root.style.setProperty("--question-prompt-max-height", `${max}px`) - } - - onMount(() => { - let raf: number | undefined - const update = () => { - if (raf !== undefined) cancelAnimationFrame(raf) - raf = requestAnimationFrame(() => { - raf = undefined - measure() - }) - } - - update() - window.addEventListener("resize", update) - - const dock = root?.closest('[data-component="session-prompt-dock"]') - const scroller = document.querySelector(".session-scroller") - const observer = new ResizeObserver(update) - if (dock instanceof HTMLElement) observer.observe(dock) - if (scroller instanceof HTMLElement) observer.observe(scroller) - - onCleanup(() => { - window.removeEventListener("resize", update) - observer.disconnect() - if (raf !== undefined) cancelAnimationFrame(raf) - }) - }) - - const fail = (err: unknown) => { - const message = err instanceof Error ? err.message : String(err) - showToast({ title: language.t("common.requestFailed"), description: message }) - } - - const reply = async (answers: QuestionAnswer[]) => { - if (store.sending) return - - setStore("sending", true) - try { - await sdk.client.question.reply({ requestID: props.request.id, answers }) - } catch (err) { - fail(err) - } finally { - setStore("sending", false) - } - } - - const reject = async () => { - if (store.sending) return - - setStore("sending", true) - try { - await sdk.client.question.reject({ requestID: props.request.id }) - } catch (err) { - fail(err) - } finally { - setStore("sending", false) - } - } - - const submit = () => void reply(questions().map((_, i) => store.answers[i] ?? [])) - - const pick = (answer: string, custom: boolean = false) => { - setStore("answers", store.tab, [answer]) - if (custom) setStore("custom", store.tab, answer) - if (!custom) setStore("customOn", store.tab, false) - setStore("editing", false) - } - - const toggle = (answer: string) => { - setStore("answers", store.tab, (current = []) => { - if (current.includes(answer)) return current.filter((item) => item !== answer) - return [...current, answer] - }) - } - - const customToggle = () => { - if (store.sending) return - - if (!multi()) { - setStore("customOn", store.tab, true) - setStore("editing", true) - customUpdate(input(), true) - return - } - - const next = !on() - setStore("customOn", store.tab, next) - if (next) { - setStore("editing", true) - customUpdate(input(), true) - return - } - - const value = input().trim() - if (value) setStore("answers", store.tab, (current = []) => current.filter((item) => item.trim() !== value)) - setStore("editing", false) - } - - const customOpen = () => { - if (store.sending) return - if (!on()) setStore("customOn", store.tab, true) - setStore("editing", true) - customUpdate(input(), true) - } - - const selectOption = (optIndex: number) => { - if (store.sending) return - - if (optIndex === options().length) { - customOpen() - return - } - - const opt = options()[optIndex] - if (!opt) return - if (multi()) { - toggle(opt.label) - return - } - pick(opt.label) - } - - const commitCustom = () => { - setStore("editing", false) - customUpdate(input()) - } - - const next = () => { - if (store.sending) return - if (store.editing) commitCustom() - - if (store.tab >= total() - 1) { - submit() - return - } - - setStore("tab", store.tab + 1) - setStore("editing", false) - } - - const back = () => { - if (store.sending) return - if (store.tab <= 0) return - setStore("tab", store.tab - 1) - setStore("editing", false) - } - - const jump = (tab: number) => { - if (store.sending) return - setStore("tab", tab) - setStore("editing", false) - } - - return ( - (root = el)} - header={ - <> -
{summary()}
-
- - {(_, i) => ( -
- - } - footer={ - <> - -
- 0}> - - - -
- - } - > -
{question()?.question}
- {language.t("ui.question.singleHint")}}> -
{language.t("ui.question.multiHint")}
-
-
- - {(opt, i) => { - const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false - return ( - - ) - }} - - - - - - {language.t("ui.messagePart.option.typeOwnAnswer")} - {input() || language.t("ui.question.custom.placeholder")} - - - } - > -
{ - if (store.sending) { - e.preventDefault() - return - } - if (e.target instanceof HTMLTextAreaElement) return - const input = e.currentTarget.querySelector('[data-slot="question-custom-input"]') - if (input instanceof HTMLTextAreaElement) input.focus() - }} - onSubmit={(e) => { - e.preventDefault() - commitCustom() - }} - > - - - {language.t("ui.messagePart.option.typeOwnAnswer")} -