diff options
| author | Adam <[email protected]> | 2026-02-19 15:02:45 -0600 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-02-19 15:02:45 -0600 |
| commit | 49cc872c4415f081b4208d16fd0d85e425a75eed (patch) | |
| tree | ed78239c2a2bb941c5008f378a00592f5b10717a /packages/app | |
| parent | f8dad0ae170acb9667d9402c162f7c29980373c1 (diff) | |
| download | opencode-49cc872c4415f081b4208d16fd0d85e425a75eed.tar.gz opencode-49cc872c4415f081b4208d16fd0d85e425a75eed.zip | |
chore: refactor composer/dock components (#14328)
Diffstat (limited to 'packages/app')
| -rw-r--r-- | packages/app/e2e/actions.ts | 157 | ||||
| -rw-r--r-- | packages/app/e2e/selectors.ts | 10 | ||||
| -rw-r--r-- | packages/app/e2e/session/session-composer-dock.spec.ts | 207 | ||||
| -rw-r--r-- | packages/app/src/components/prompt-input.tsx | 12 | ||||
| -rw-r--r-- | packages/app/src/pages/session.tsx | 13 | ||||
| -rw-r--r-- | packages/app/src/pages/session/composer/index.ts | 3 | ||||
| -rw-r--r-- | packages/app/src/pages/session/composer/session-composer-region.tsx | 124 | ||||
| -rw-r--r-- | packages/app/src/pages/session/composer/session-composer-state.ts | 158 | ||||
| -rw-r--r-- | packages/app/src/pages/session/composer/session-permission-dock.tsx | 74 | ||||
| -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.tsx | 318 |
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> - ) -} |
