diff --git a/packages/app/e2e/regression/session-request-docks.spec.ts b/packages/app/e2e/regression/session-request-docks.spec.ts index e66cf81504..036eaaef42 100644 --- a/packages/app/e2e/regression/session-request-docks.spec.ts +++ b/packages/app/e2e/regression/session-request-docks.spec.ts @@ -38,6 +38,29 @@ test("shows a pending question dock", async ({ page }) => { await expect(question.getByRole("radio", { name: /Extended/ })).toBeVisible() await expect(page.locator('[data-component="session-composer"]')).toHaveCount(0) + const rejectRequests: string[] = [] + page.on("request", (request) => { + if (request.method() !== "POST") return + if (new URL(request.url()).pathname === "/question/question-request/reject") rejectRequests.push(request.url()) + }) + + await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click() + await expect(question).toBeVisible() + await expect(question.getByText("Which implementation should be used?")).toBeVisible() + await expect(question.getByText("Select one answer")).toBeHidden() + await expect(question.getByRole("radio", { name: /Minimal/ })).toBeHidden() + await expect(question.getByRole("radio", { name: /Extended/ })).toBeHidden() + await expect(question.getByRole("button", { name: "Dismiss" })).toBeVisible() + await expect(question.getByRole("button", { name: "Submit" })).toBeVisible() + await expect(page.locator('[data-component="question-minimized-dock"]')).toHaveCount(0) + expect(rejectRequests).toEqual([]) + + await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click() + await expect(question).toBeVisible() + await expect(question.getByText("Which implementation should be used?")).toBeVisible() + await expect(question.getByRole("radio", { name: /Minimal/ })).toBeVisible() + expect(rejectRequests).toEqual([]) + await question.getByRole("radio", { name: /Minimal/ }).click() const reply = page.waitForRequest( (request) => request.method() === "POST" && new URL(request.url()).pathname === "/question/question-request/reply", diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 561fbb5888..0124fcf6ed 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -657,6 +657,10 @@ export const dict = { "session.todo.expand": "Expand", "session.todo.progress": "{{done}} of {{total}} todos completed", "session.question.progress": "{{current}} of {{total}} questions", + "session.question.minimize": "Minimize question", + "session.question.restore": "Restore question", + "session.question.pending.one": "{{count}} pending question", + "session.question.pending.other": "{{count}} pending questions", "session.followupDock.summary.one": "{{count}} queued message", "session.followupDock.summary.other": "{{count}} queued messages", "session.followupDock.sendNow": "Send now", diff --git a/packages/app/src/pages/session/composer/session-question-dock.tsx b/packages/app/src/pages/session/composer/session-question-dock.tsx index 93ab8818c6..d9a456f6e1 100644 --- a/packages/app/src/pages/session/composer/session-question-dock.tsx +++ b/packages/app/src/pages/session/composer/session-question-dock.tsx @@ -1,9 +1,10 @@ -import { For, Show, createMemo, onCleanup, onMount, type Component } from "solid-js" +import { For, Show, createEffect, createMemo, onCleanup, onMount, type Component } from "solid-js" import { createStore } from "solid-js/store" import { useMutation } from "@tanstack/solid-query" import { Button } from "@opencode-ai/ui/button" import { DockPrompt } from "@opencode-ai/session-ui/dock-prompt" import { Icon } from "@opencode-ai/ui/icon" +import { useSpring } from "@opencode-ai/ui/motion-spring" import { showToast } from "@/utils/toast" import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2" import { useLanguage } from "@/context/language" @@ -77,9 +78,12 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit customOn: cached?.customOn ?? ([] as boolean[]), editing: false, focus: 0, + minimized: false, + optionsHeight: 180, }) let root: HTMLDivElement | undefined + let optionsRef: HTMLDivElement | undefined let customRef: HTMLButtonElement | undefined let optsRef: HTMLButtonElement[] = [] let replied = false @@ -96,11 +100,13 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit const n = Math.min(store.tab + 1, total()) return language.t("session.question.progress", { current: n, total: total() }) }) - const customLabel = () => language.t("ui.messagePart.option.typeOwnAnswer") const customPlaceholder = () => language.t("ui.question.custom.placeholder") const last = createMemo(() => store.tab >= total() - 1) + const collapse = useSpring(() => (store.minimized ? 1 : 0), { visualDuration: 0.3, bounce: 0 }) + const hidden = createMemo(() => Math.max(0, Math.min(1, collapse()))) + const optionsOff = createMemo(() => hidden() > 0.98) const customUpdate = (value: string, selected: boolean = on()) => { const prev = input().trim() @@ -192,6 +198,14 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit focus(pickFocus()) }) + createEffect(() => { + const el = optionsRef + if (!el) return + const update = () => setStore("optionsHeight", (height) => Math.max(height, el.scrollHeight)) + update() + createResizeObserver(el, update) + }) + onCleanup(() => { if (focusFrame !== undefined) cancelAnimationFrame(focusFrame) if (replied) return @@ -405,7 +419,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit const tab = store.tab + 1 setStore("tab", tab) setStore("editing", false) - focus(pickFocus(tab)) + if (!store.minimized) focus(pickFocus(tab)) } const back = () => { @@ -414,163 +428,212 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit const tab = store.tab - 1 setStore("tab", tab) setStore("editing", false) - focus(pickFocus(tab)) + if (!store.minimized) focus(pickFocus(tab)) } const jump = (tab: number) => { if (sending()) return setStore("tab", tab) setStore("editing", false) - focus(pickFocus(tab)) + if (!store.minimized) focus(pickFocus(tab)) + } + + const minimize = () => { + if (sending()) return + setStore("editing", false) + setStore("minimized", true) + } + + const restore = () => { + if (sending()) return + setStore("minimized", false) + focus(pickFocus()) } return ( - (root = el)} - onKeyDown={nav} - header={ - <> -
{summary()}
- 1}> -
- - {(_, i) => ( -
-
- - } - footer={ - <> - -
- 0}> - - - -
- - } - > -
- {question()?.question} -
- {language.t("ui.question.singleHint")}}> -
{language.t("ui.question.multiHint")}
-
-
- - {(opt, i) => ( - - - setStore("focus", options().length)} - onClick={customOpen} - > - - - {customLabel()} - {input() || customPlaceholder()} - - - } - > -
{ - if (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() - }} - > - - - {customLabel()} -