From 4a8178e9cf40bf77d1a22a2dc6ddc1a7d5189eae Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 7 Oct 2026 16:52:19 -0500 Subject: [PATCH] fix(ui): stabilize composer model selection and refine effort slider (#15444) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Task composers let users select an agent, a model, and an effort level. > - The model label changes width when users move the effort slider. > - This movement makes the control harder to use. > - This pull request keeps the label fixed while the picker is open. > - A larger slider gives clearer feedback during selection. ## Linked Issues or Issue Description **What existing behavior does this improve?** The shared model and effort picker in task composers. **Current behavior** The trigger shows each effort change while the picker is open. Different label lengths move the control. The slider is small. **Proposed behavior** The trigger shows “Select model” while any picker view is open. It shows the selected model and effort after the picker closes. The slider has a thicker track and a larger thumb with hover and press feedback. **Reason and benefit** Users can change effort without moving the trigger. The larger thumb gives clearer pointer feedback. ## What Changed - Keep the trigger text fixed while the picker is open. - Add slider size and feedback tokens. - Remove the gray input outline and thumb border. Show keyboard focus on the thumb. - Keep a thumb focus outline when Windows high contrast suppresses shadows. - Keep native keyboard and pointer controls. Respect reduced motion. - Test open and closed labels on desktop and mobile. ## Verification - Picker tests: 27 pass. Composer settings and new-task tests: 86 pass. - UI typecheck and UI build passed before the final CSS-only high-contrast fix. - Token gates pass. - Full typecheck and build stop at installed Runner API type mismatches outside these files. - Revised Chromium checks pass for desktop pointer drag, no input outline during drag, stable open label, keyboard input, and mobile rendering. The earlier rendered-image check confirmed reduced-motion behavior. - The earlier full local suite did not finish. The approved visual revision passed 113 focused tests. The final high-contrast fix passed 27 picker tests and token gates. - Chromium high-contrast and reduced-motion verification shows a visible thumb focus marker and working keyboard input. - Open the model picker. Change effort. Confirm “Select model” stays visible. Close the picker. Confirm the selected model and effort appear. - All CI gates pass on final head `7a64104e14251a354b06b7916d09d44692557c4f`, including full typecheck, tests, build, and browser E2E. Greptile: 5/5 with no findings. ## Risks - Low risk: shared UI only. Native range controls stay in place. - Browser thumb rendering can differ between engines. ## Model Used - OpenAI GPT-6 Codex. Code editing, tool use, and test execution. The runtime does not expose an exact model variant or context window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- .../ComposerRunSettingsPicker.test.tsx | 46 +++++++++++++++- .../task-chat/ComposerRunSettingsPicker.tsx | 4 +- .../task-chat/composer-run-settings.css | 55 ++++++++++++++++--- ui/src/index.css | 5 ++ 4 files changed, 98 insertions(+), 12 deletions(-) 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);