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:
committed 2026-07-22 15:25:46 +00:00
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");
});
});
+98 -2
View File
@@ -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,