mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 17:45:53 +01:00
Show thinking effort as segmented control for short lists
Present the runtime "Thinking effort" field as a single-click segmented button group when the adapter's option set is small (Claude 4, Cursor 3), matching the accepted runtime wireframe — all choices visible, one click (Recognition-over-Recall, Fitts). Adapters with long effort lists (Codex 6, OpenCode 7) keep the dropdown fallback to avoid overflow. The segmented group is a keyboard-navigable radiogroup (arrow keys move selection, roving tabindex). Behavior is covered by render tests for both the segmented (short-list) and dropdown (long-list) branches. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
0372cccc89
commit
ddc1ea554c
2 files changed
+148
-2
No files matched your search
@@ -650,4 +650,54 @@ describe("AgentConfigForm environment selector", () => {
|
||||
expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1);
|
||||
expect(result.container.textContent).toContain("Network unavailable");
|
||||
});
|
||||
|
||||
it("renders thinking effort as a segmented group for adapters with a short effort list", async () => {
|
||||
const result = await renderForm(
|
||||
[makeEnvironment({ id: "local-1", name: "Local", driver: "local" })],
|
||||
{ adapterType: "claude_local" },
|
||||
);
|
||||
roots.push(result.root);
|
||||
|
||||
const group = result.container.querySelector<HTMLElement>(
|
||||
'[role="radiogroup"][aria-label="Thinking effort"]',
|
||||
);
|
||||
expect(group).toBeTruthy();
|
||||
|
||||
const segments = Array.from(group!.querySelectorAll('[role="radio"]'));
|
||||
expect(segments.map((segment) => segment.textContent?.trim())).toEqual([
|
||||
"Auto",
|
||||
"Low",
|
||||
"Medium",
|
||||
"High",
|
||||
]);
|
||||
// "Auto" (the empty default) is selected when nothing is configured.
|
||||
expect(segments[0]?.getAttribute("aria-checked")).toBe("true");
|
||||
|
||||
await act(async () => {
|
||||
segments
|
||||
.find((segment) => segment.textContent?.trim() === "High")
|
||||
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const updatedSegments = Array.from(
|
||||
result.container.querySelectorAll('[role="radiogroup"][aria-label="Thinking effort"] [role="radio"]'),
|
||||
);
|
||||
const highSegment = updatedSegments.find((segment) => segment.textContent?.trim() === "High");
|
||||
expect(highSegment?.getAttribute("aria-checked")).toBe("true");
|
||||
expect(updatedSegments[0]?.getAttribute("aria-checked")).toBe("false");
|
||||
});
|
||||
|
||||
it("keeps thinking effort as a dropdown for adapters with a long effort list", async () => {
|
||||
const result = await renderForm(
|
||||
[makeEnvironment({ id: "local-1", name: "Local", driver: "local" })],
|
||||
{ adapterType: "opencode_local" },
|
||||
);
|
||||
roots.push(result.root);
|
||||
|
||||
expect(
|
||||
result.container.querySelector('[role="radiogroup"][aria-label="Thinking effort"]'),
|
||||
).toBeNull();
|
||||
expect(result.container.textContent).toContain("Thinking effort");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useRef, useMemo, useCallback, type ReactNode } from "react";
|
||||
import { useState, useEffect, useRef, useMemo, useCallback, type ReactNode, type KeyboardEvent as ReactKeyboardEvent } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type {
|
||||
Agent,
|
||||
@@ -223,6 +223,11 @@ const claudeThinkingEffortOptions = [
|
||||
{ id: "high", label: "High" },
|
||||
] as const;
|
||||
|
||||
// Present thinking effort as a single-click segmented button group when the
|
||||
// option set is small (Recognition-over-Recall, Fitts — all choices visible),
|
||||
// and fall back to a dropdown only for adapters whose effort list is long.
|
||||
const THINKING_EFFORT_SEGMENTED_MAX_OPTIONS = 5;
|
||||
|
||||
const MAX_TURN_CONTINUATION_DEFAULT_MAX_ATTEMPTS = 2;
|
||||
const MAX_TURN_CONTINUATION_MAX_ATTEMPTS_CAP = 10;
|
||||
const MAX_TURN_CONTINUATION_DEFAULT_DELAY_SEC = 1;
|
||||
@@ -1331,7 +1336,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
|
||||
{showThinkingEffort && (
|
||||
<>
|
||||
<ThinkingEffortDropdown
|
||||
<ThinkingEffortControl
|
||||
value={currentThinkingEffort}
|
||||
options={thinkingEffortOptions}
|
||||
onChange={(v) =>
|
||||
@@ -2240,6 +2245,97 @@ function CheapModelSection({
|
||||
);
|
||||
}
|
||||
|
||||
function ThinkingEffortControl({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
value: string;
|
||||
options: ReadonlyArray<{ id: string; label: string }>;
|
||||
onChange: (id: string) => void;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
// Small option sets render as a segmented group (matches the runtime
|
||||
// wireframe); long lists stay a dropdown to avoid overflow.
|
||||
if (options.length <= THINKING_EFFORT_SEGMENTED_MAX_OPTIONS) {
|
||||
return <ThinkingEffortSegmented value={value} options={options} onChange={onChange} />;
|
||||
}
|
||||
return (
|
||||
<ThinkingEffortDropdown
|
||||
value={value}
|
||||
options={options}
|
||||
onChange={onChange}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ThinkingEffortSegmented({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
options: ReadonlyArray<{ id: string; label: string }>;
|
||||
onChange: (id: string) => void;
|
||||
}) {
|
||||
const selected = options.find((option) => option.id === value) ?? options[0];
|
||||
|
||||
const handleKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
|
||||
const idx = options.findIndex((option) => option.id === selected?.id);
|
||||
if (idx === -1) return;
|
||||
let nextIdx: number | null = null;
|
||||
if (event.key === "ArrowRight" || event.key === "ArrowDown") {
|
||||
nextIdx = (idx + 1) % options.length;
|
||||
} else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
|
||||
nextIdx = (idx - 1 + options.length) % options.length;
|
||||
}
|
||||
if (nextIdx !== null) {
|
||||
event.preventDefault();
|
||||
onChange(options[nextIdx].id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Field label="Thinking effort" hint={help.thinkingEffort}>
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Thinking effort"
|
||||
onKeyDown={handleKeyDown}
|
||||
className="inline-flex w-full items-center gap-0.5 rounded-md border border-border bg-muted p-0.5"
|
||||
>
|
||||
{options.map((option) => {
|
||||
const isSelected = option.id === selected?.id;
|
||||
return (
|
||||
<button
|
||||
key={option.id || "auto"}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={isSelected}
|
||||
data-state={isSelected ? "checked" : "unchecked"}
|
||||
tabIndex={isSelected ? 0 : -1}
|
||||
onClick={() => onChange(option.id)}
|
||||
className={cn(
|
||||
"flex-1 rounded-[5px] px-2 py-1 text-sm whitespace-nowrap transition-colors",
|
||||
"focus-visible:outline-1 focus-visible:outline-ring",
|
||||
isSelected
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
function ThinkingEffortDropdown({
|
||||
value,
|
||||
options,
|
||||
|
||||
Reference in new issue
Block a user