From 3a08d95907d7e771802a0e105b32ff6581f27f58 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 09:34:18 +0000 Subject: [PATCH] fix(app): preserve thinking trigger visibility invariant (#47256) Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com> --- packages/app/component-tests/composer.spec.ts | 81 ++++++++++++++++--- .../regression/prompt-thinking-level.spec.ts | 61 ++++++++++---- .../app/src/composer/composer.stories.tsx | 1 + packages/app/src/composer/editor/editor.css | 5 ++ packages/app/src/composer/editor/editor.tsx | 3 + 5 files changed, 124 insertions(+), 27 deletions(-) diff --git a/packages/app/component-tests/composer.spec.ts b/packages/app/component-tests/composer.spec.ts index d56f256fe47..60be7bfd751 100644 --- a/packages/app/component-tests/composer.spec.ts +++ b/packages/app/component-tests/composer.spec.ts @@ -88,27 +88,88 @@ story("renders a draft once and supports editing, caret restoration, and failure await expect(input).toHaveText("Preserve this draft on failure") }) -// Moved from packages/app/e2e/regression/prompt-thinking-level.spec.ts -story("shows the thinking level control while relevant", async ({ mount, page }) => { +story("shows thinking on composer hover or when a non-default variant is selected", async ({ mount, page }) => { const component = await mount("opencode-composer-flow--model-and-variant") const composer = component.locator('[data-component="composer"]') - const input = composer.locator('[data-component="composer-editor"]') + const input = composer.getByRole("textbox", { name: "Prompt", exact: true }) const control = composer.getByRole("button", { name: "Choose model variant" }) + await component.getByRole("status").click() await page.mouse.move(0, 0) - await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur()) - await expect(control).toBeVisible() + await expect(control).toHaveText("balanced") + await expect(control).toHaveCSS("opacity", "1") + await control.click() + await page.getByRole("menuitemradio", { name: "default", exact: true }).click() + await component.getByRole("status").click() + await expect(control).toHaveText("default") + await expect(control).toHaveCSS("opacity", "0") + await expect(control).toHaveCSS("pointer-events", "none") + + await input.hover() + await expect(control).toHaveCSS("opacity", "1") + await expect(control).toHaveCSS("pointer-events", "auto") + await input.click() + await page.mouse.move(0, 0) + await expect(input).toBeFocused() + await expect(control).toHaveCSS("opacity", "0") + + await input.hover() await control.click() const high = page.getByRole("menuitemradio", { name: "high" }) await expect(high).toBeVisible() await page.mouse.move(0, 0) - await expect(control).toBeVisible() + await expect(control).toHaveAttribute("aria-expanded", "true") + await expect(control).toHaveCSS("opacity", "1") await expect(high).toBeVisible() await high.click() + await component.getByRole("status").click() + await expect(control).toHaveText("high") + await expect(control).toHaveCSS("opacity", "1") - await input.focus() - await expect(control).toBeVisible() - await input.blur() - await expect(control).toBeVisible() + await control.click() + await page.getByRole("menuitemradio", { name: "default", exact: true }).click() + await component.getByRole("status").click() + await expect(control).toHaveCSS("opacity", "0") +}) + +story("keeps default thinking accessible by keyboard without composer hover", async ({ mount, page }) => { + const component = await mount("opencode-composer-flow--model-and-variant") + const input = component.getByRole("textbox", { name: "Prompt", exact: true }) + const control = component.getByRole("button", { name: "Choose model variant" }) + + await control.click() + await page.getByRole("menuitemradio", { name: "default", exact: true }).click() + await input.click() + await page.mouse.move(0, 0) + await expect(control).toHaveCSS("opacity", "0") + + // Tab through Add, Agent, and Model to the visually hidden thinking trigger. + for (let count = 0; count < 4; count++) await page.keyboard.press("Tab") + await expect(control).toBeFocused() + await expect(control).toHaveCSS("opacity", "1") + await page.keyboard.press("Enter") + await expect(control).toHaveAttribute("aria-expanded", "true") + await expect(page.getByRole("menuitemradio", { name: "default", exact: true })).toBeFocused() + await expect(control).toHaveCSS("opacity", "1") + await page.keyboard.press("Escape") + await expect(control).toHaveAttribute("aria-expanded", "false") + await expect(control).toBeFocused() + await expect(control).toHaveCSS("opacity", "1") + await page.keyboard.press("Enter") + await expect(page.getByRole("menuitemradio", { name: "default", exact: true })).toBeFocused() + await page.keyboard.press("End") + await expect(page.getByRole("menuitemradio", { name: "high", exact: true })).toBeFocused() + await page.keyboard.press("Enter") + await expect(control).toHaveText("high") + await expect(control).toBeFocused() + await page.keyboard.press("Enter") + await expect(page.getByRole("menuitemradio", { name: "default", exact: true })).toBeFocused() + await page.keyboard.press("Enter") + await expect(control).toHaveText("default") + await expect(control).toBeFocused() + await expect(control).toHaveCSS("opacity", "1") + await page.keyboard.press("Tab") + await expect(control).not.toBeFocused() + await expect(control).toHaveCSS("opacity", "0") }) diff --git a/packages/app/e2e/regression/prompt-thinking-level.spec.ts b/packages/app/e2e/regression/prompt-thinking-level.spec.ts index 7dd640a50c0..bc0f060783b 100644 --- a/packages/app/e2e/regression/prompt-thinking-level.spec.ts +++ b/packages/app/e2e/regression/prompt-thinking-level.spec.ts @@ -1,4 +1,4 @@ -import { expect, test, type Page } from "@playwright/test" +import { expect, test } from "@playwright/test" import { base64Encode } from "@opencode-ai/util/encode" import { mockOpenCodeServer } from "../utils/mock-server" import { expectAppVisible } from "../utils/waits" @@ -8,7 +8,7 @@ const projectID = "proj_prompt_thinking_level_regression" const sessionID = "ses_prompt_thinking_level_regression" const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` -test("shows the thinking level control while relevant", async ({ page }) => { +test("shows thinking on hover or a non-default selection while preserving keyboard access", async ({ page }) => { await mockOpenCodeServer(page, { directory, project: { @@ -52,30 +52,57 @@ test("shows the thinking level control while relevant", async ({ page }) => { }) await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`) const composer = page.locator('[data-component="composer"]') - const input = composer.locator('[data-component="composer-editor"]') + const input = composer.getByRole("textbox", { name: "Prompt", exact: true }) const control = composer.getByRole("button", { name: "Choose model variant" }) await expectAppVisible(composer) - await idleComposer(page) - await expect(control).toBeVisible() + await page.mouse.move(0, 0) + await expect(control).toHaveText("default") + await expect(control).toHaveCSS("opacity", "0") + await expect(control).toHaveCSS("pointer-events", "none") + await input.hover() + await expect(control).toHaveCSS("opacity", "1") + await input.click() + await page.mouse.move(0, 0) + await expect(input).toBeFocused() + await expect(control).toHaveCSS("opacity", "0") + + await input.hover() await control.click() const high = page.getByRole("menuitemradio", { name: "high" }) await expect(high).toBeVisible() await page.mouse.move(0, 0) - await expect(control).toBeVisible() + await expect(control).toHaveAttribute("aria-expanded", "true") + await expect(control).toHaveCSS("opacity", "1") await expect(high).toBeVisible() await high.click() - await idleComposer(page) - await input.focus() - await expect(control).toBeVisible() - - await idleComposer(page) - await expect(control).toBeVisible() -}) - -async function idleComposer(page: Page) { + await input.click() await page.mouse.move(0, 0) - await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur()) -} + await expect(control).toHaveText("high") + await expect(control).toHaveCSS("opacity", "1") + + await control.click() + await page.getByRole("menuitemradio", { name: "default", exact: true }).click() + await input.click() + await page.mouse.move(0, 0) + await expect(control).toHaveText("default") + await expect(control).toHaveCSS("opacity", "0") + + // The single-agent fixture has only Add and Model before the thinking trigger. + await page.keyboard.press("Tab") + await expect(composer.getByRole("button", { name: "Add images and files" })).toBeFocused() + await page.keyboard.press("Tab") + await expect(composer.getByRole("button", { name: "Thinking Model" })).toBeFocused() + await page.keyboard.press("Tab") + await expect(control).toBeFocused() + await expect(control).toHaveCSS("opacity", "1") + await page.keyboard.press("Enter") + await expect(page.getByRole("menuitemradio", { name: "default", exact: true })).toBeFocused() + await expect(control).toHaveCSS("opacity", "1") + await page.keyboard.press("Escape") + await expect(control).toBeFocused() + await page.keyboard.press("Tab") + await expect(control).toHaveCSS("opacity", "0") +}) diff --git a/packages/app/src/composer/composer.stories.tsx b/packages/app/src/composer/composer.stories.tsx index 63aa5456cbb..649c0f11d46 100644 --- a/packages/app/src/composer/composer.stories.tsx +++ b/packages/app/src/composer/composer.stories.tsx @@ -134,6 +134,7 @@ function ComposerStory(props: { }, variant: { options: () => [ + { id: "default", label: "default" }, { id: "balanced", label: "balanced" }, { id: "high", label: "high" }, ], diff --git a/packages/app/src/composer/editor/editor.css b/packages/app/src/composer/editor/editor.css index 36ea4146efc..c219055467b 100644 --- a/packages/app/src/composer/editor/editor.css +++ b/packages/app/src/composer/editor/editor.css @@ -2,6 +2,11 @@ content: "\200B"; } +[data-component="composer"]:not(:hover) .composer-variant-default:not(:focus-visible, [data-expanded]) { + opacity: 0; + pointer-events: none; +} + [data-color-scheme="dark"] [data-component="composer"][data-dock-border-underlay="true"], [data-color-scheme="dark"] [data-component="new-session"] [data-component="composer"] { background: var(--v2-background-bg-layer-01); diff --git a/packages/app/src/composer/editor/editor.tsx b/packages/app/src/composer/editor/editor.tsx index f672a020a00..878e5cc651f 100644 --- a/packages/app/src/composer/editor/editor.tsx +++ b/packages/app/src/composer/editor/editor.tsx @@ -263,6 +263,7 @@ export function ComposerEditor(props: ComposerEditorProps) { title={i18n.t("ui.promptInput.chooseVariant")} keybind={["Shift", "Mod", "D"]} control={control} + class={control.current() === "default" ? "composer-variant-default" : undefined} /> )} @@ -596,12 +597,14 @@ function ComposerEditorConfiguredSelect(props: { keybind?: string[] control: ComposerSelectControl model?: boolean + class?: string }) { const current = () => props.control.current() const providerID = () => props.control.options().find((option) => option.id === current())?.providerID return (