fix(ui): stabilize composer model selection and refine effort slider (#15444)

## 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 <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-07 21:52:19 +00:00
1 parent 378e6d95e1
commit 4a8178e9cf
4 files changed
+98 -12

No files matched your search

@@ -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 <ComposerRunSettingsPicker companyId="company-1" assigneeValue="agent:a1" currentAssigneeValue="agent:a1"
options={options} agents={agents} settings={settings} onSettingsChange={(next) => 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(<QueryClientProvider client={queryClient}><ControlledPicker /></QueryClientProvider>));
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<HTMLInputElement>('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 },
@@ -200,8 +200,8 @@ export function ComposerRunSettingsPicker({
<span className="text-muted-foreground" aria-hidden>·</span>
<button type="button" disabled={disabled} aria-label="Select model and effort" aria-haspopup="dialog" aria-expanded={open && view !== "agents"} onClick={() => { setView("settings"); setOpen(true); }}
className="flex h-8 min-w-0 max-w-full items-center gap-1.5 rounded-full px-2.5 text-muted-foreground hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50">
<span data-testid="task-chat-composer-model-label" className="min-w-0 truncate">{modelName || "Default"}</span>
{effort ? <span className="hidden shrink-0 sm:inline">{effortLabel}</span> : null}
<span data-testid="task-chat-composer-model-label" className="min-w-0 truncate">{open ? "Select model" : modelName || "Default"}</span>
{!open && effort ? <span className="hidden shrink-0 sm:inline">{effortLabel}</span> : null}
<ChevronDown className="size-3 shrink-0" aria-hidden />
</button>
</> : null}
@@ -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);
}
}
+5
View File
@@ -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);