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/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() { - { 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 ( +
+
+ + {(request) => ( +
+ +
+ )} +
+ + + {(request) => ( +
+ +
+ )} +
+ + + + {handoffPrompt() || language.t("prompt.loading")} +
+ } + > + +
+ +
+
+
+ +
+ + +
+ + ) +} 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 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[0]) + if (value === key) return "" + return value + } + + return ( + + + + +
{language.t("notification.permission.title")}
+ + } + footer={ + <> +
+
+ + + +
+ + } + > + +
+
+
+ + 0}> +
+
+
+ + ) +} diff --git a/packages/app/src/components/question-dock.tsx b/packages/app/src/pages/session/composer/session-question-dock.tsx similarity index 99% rename from packages/app/src/components/question-dock.tsx rename to 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 similarity index 95% rename from packages/app/src/components/session-todo-dock.tsx rename to 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 ( -
- + ) } 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() - 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 ( -
-
- - {(req) => { - return ( -
- -
- ) - }} -
- - - {(perm) => { - const toolDescription = () => { - const key = `settings.permissions.tool.${perm.permission}.description` - const value = language.t(key as Parameters[0]) - if (value === key) return "" - return value - } - - return ( -
- - - - -
{language.t("notification.permission.title")}
-
- } - footer={ - <> -
-
- - - -
- - } - > - -
-
-
- - 0}> -
-
-
- -
- ) - }} -
- - - - {handoffPrompt() || language.t("prompt.loading")} -
- } - > - -
- -
-
-
- -
- - -
-
- ) -} 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 (
-
+
{props.header}
{props.children}
-
-
{props.footer}
+ + {props.footer}
) } 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 ( +
+ {split.children} +
+ ) +} + +export function DockShellForm(props: ComponentProps<"form">) { + const [split, rest] = splitProps(props, ["children", "class", "classList"]) + return ( +
+ {split.children} +
+ ) +} + +export function DockTray(props: DockTrayProps) { + const [split, rest] = splitProps(props, ["attach", "children", "class", "classList"]) + return ( +
+ {split.children} +
+ ) +} 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.