diff --git a/ui/src/components/task-chat/ComposerRunSettingsPicker.test.tsx b/ui/src/components/task-chat/ComposerRunSettingsPicker.test.tsx index 759ab4cda0..9a61b1779e 100644 --- a/ui/src/components/task-chat/ComposerRunSettingsPicker.test.tsx +++ b/ui/src/components/task-chat/ComposerRunSettingsPicker.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import { act, type ComponentProps } from "react"; +import { act, useState, type ComponentProps } from "react"; import { flushSync } from "react-dom"; import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; @@ -59,6 +59,50 @@ afterEach(() => { }); describe("composer assignee picker", () => { + it.each([false, true])("keeps the trigger stable until the picker closes (mobile: %s)", async (mobile) => { + function ControlledPicker() { + const [settings, setSettings] = useState({ model: "gpt-6-sol", effort: "high", fast: false }); + return setSettings(next! as typeof settings)} + onAssigneeChange={vi.fn()} mobile={mobile} modelOptionsOverride={[{ id: "gpt-6-sol", label: "GPT-6 Sol" }, { id: "gpt-6-astra", label: "GPT-6 Astra" }]} />; + } + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + const queryClient = new QueryClient(); + flushSync(() => root!.render()); + const label = () => container!.querySelector('[data-testid="task-chat-composer-model-label"]')!.textContent; + expect(label()).toBe("GPT-6 Sol"); + await click("Select model and effort"); + expect(label()).toBe("Select model"); + const range = document.querySelector('input[aria-label="Effort"]')!; + flushSync(() => { + Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!.call(range, "1"); + range.dispatchEvent(new Event("input", { bubbles: true })); + }); + expect(range.getAttribute("aria-valuetext")).toBe("Low"); + expect(label()).toBe("Select model"); + expect(container!.querySelector('[aria-label="Select model and effort"]')!.textContent).not.toContain("Low"); + await click("Choose assignee"); + expect(label()).toBe("Select model"); + await click("Clippy"); + expect(label()).toBe("GPT-6 Sol"); + expect(container!.querySelector('[aria-label="Select model and effort"]')!.textContent).toContain("Low"); + await click("Select model and effort"); + await click("Choose exact model"); + await click("GPT-6 Astra"); + expect(label()).toBe("Select model"); + if (mobile) await click("Close picker"); + else { + await act(async () => { + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + } + expect(label()).toBe("GPT-6 Astra"); + expect(container!.querySelector('[aria-label="Select model and effort"]')!.textContent).toContain("Low"); + }); + it.each([ { mobile: false, view: "settings" as const }, { mobile: false, view: "models" as const }, diff --git a/ui/src/components/task-chat/ComposerRunSettingsPicker.tsx b/ui/src/components/task-chat/ComposerRunSettingsPicker.tsx index f4e6550e24..27e9cae0b5 100644 --- a/ui/src/components/task-chat/ComposerRunSettingsPicker.tsx +++ b/ui/src/components/task-chat/ComposerRunSettingsPicker.tsx @@ -200,8 +200,8 @@ export function ComposerRunSettingsPicker({ ยท : null} diff --git a/ui/src/components/task-chat/composer-run-settings.css b/ui/src/components/task-chat/composer-run-settings.css index d6cb4cd0e4..12a593f2e6 100644 --- a/ui/src/components/task-chat/composer-run-settings.css +++ b/ui/src/components/task-chat/composer-run-settings.css @@ -1,32 +1,49 @@ .composer-run-effort-range { display: block; appearance: none; - height: calc(var(--spacing) * 2); - border-radius: var(--radius-lg); + height: var(--composer-effort-track-height); + border-radius: var(--radius-4xl); background: linear-gradient(to right, var(--tc-mode-ask) var(--fill), var(--muted) var(--fill)); cursor: pointer; + /* Native input outlines cross the thumb because the input is track-height. */ + outline: none; } .composer-run-effort-range::-webkit-slider-thumb { appearance: none; - width: calc(var(--spacing) * 5); - height: calc(var(--spacing) * 5); - border: calc(var(--spacing) * 0.5) solid var(--popover); + width: var(--composer-effort-thumb-size); + height: var(--composer-effort-thumb-size); + border: 0; border-radius: 50%; background: var(--tc-mode-ask); box-shadow: var(--shadow-sm); + transition: transform var(--motion-duration-fast) var(--motion-ease-out), box-shadow var(--motion-duration-fast) var(--motion-ease-out); } .composer-run-effort-range::-moz-range-thumb { - width: calc(var(--spacing) * 5); - height: calc(var(--spacing) * 5); - border: calc(var(--spacing) * 0.5) solid var(--popover); + width: var(--composer-effort-thumb-size); + height: var(--composer-effort-thumb-size); + border: 0; border-radius: 50%; background: var(--tc-mode-ask); box-shadow: var(--shadow-sm); + transition: transform var(--motion-duration-fast) var(--motion-ease-out), box-shadow var(--motion-duration-fast) var(--motion-ease-out); } -.composer-run-effort-range:focus-visible { outline: calc(var(--spacing) * 0.5) solid var(--ring); outline-offset: calc(var(--spacing) * 0.5); } +/* Keep native pointer and keyboard behavior; animate only the thumb feedback. */ +.composer-run-effort-range:hover::-webkit-slider-thumb, +.composer-run-effort-range:focus-visible::-webkit-slider-thumb { + transform: scale(var(--composer-effort-hover-scale)); + box-shadow: var(--shadow-sm), var(--composer-effort-halo); +} +.composer-run-effort-range:hover::-moz-range-thumb, +.composer-run-effort-range:focus-visible::-moz-range-thumb { + transform: scale(var(--composer-effort-hover-scale)); + box-shadow: var(--shadow-sm), var(--composer-effort-halo); +} +.composer-run-effort-range:active::-webkit-slider-thumb { transform: scale(var(--composer-effort-press-scale)); } +.composer-run-effort-range:active::-moz-range-thumb { transform: scale(var(--composer-effort-press-scale)); } + .composer-run-settings-accent { color: var(--tc-mode-ask); } .composer-run-settings-height { overflow: hidden; transition: height var(--motion-duration-base) var(--motion-ease-out); } .composer-mobile-dialog { @@ -70,4 +87,24 @@ @media (prefers-reduced-motion: reduce) { .composer-run-settings-height { transition: none; } + .composer-run-effort-range::-webkit-slider-thumb, + .composer-run-effort-range:hover::-webkit-slider-thumb, + .composer-run-effort-range:focus-visible::-webkit-slider-thumb, + .composer-run-effort-range:active::-webkit-slider-thumb { transition: none; transform: none; } + .composer-run-effort-range::-moz-range-thumb, + .composer-run-effort-range:hover::-moz-range-thumb, + .composer-run-effort-range:focus-visible::-moz-range-thumb, + .composer-run-effort-range:active::-moz-range-thumb { transition: none; transform: none; } +} + +/* Forced colors suppress shadows, so keep keyboard focus on the thumb. */ +@media (forced-colors: active) { + .composer-run-effort-range:focus-visible::-webkit-slider-thumb { + outline: calc(var(--spacing) * 0.5) solid Highlight; + outline-offset: calc(var(--spacing) * 0.5); + } + .composer-run-effort-range:focus-visible::-moz-range-thumb { + outline: calc(var(--spacing) * 0.5) solid Highlight; + outline-offset: calc(var(--spacing) * 0.5); + } } diff --git a/ui/src/index.css b/ui/src/index.css index 60b7d89d19..b72edc24f6 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -99,6 +99,11 @@ --native-select-caret-inset: calc(var(--spacing) * 3); --native-select-caret-stroke: 1.5px; --native-select-end-padding: calc(var(--spacing) * 9); + --composer-effort-track-height: calc(var(--spacing) * 3); + --composer-effort-thumb-size: calc(var(--spacing) * 7); + --composer-effort-hover-scale: 1.08; + --composer-effort-press-scale: 1.16; + --composer-effort-halo: 0 0 0 calc(var(--spacing) * 1.5) color-mix(in oklab, var(--tc-mode-ask) 14%, transparent); --radius: 0.5rem; --background: oklch(1 0 0); --foreground: oklch(0.145 0 0);