mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-10 03:08:10 +02:00
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:
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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user