diff --git a/ui/src/components/AgentConfigForm.render.test.tsx b/ui/src/components/AgentConfigForm.render.test.tsx index a8126b20fcc..2e977b1281a 100644 --- a/ui/src/components/AgentConfigForm.render.test.tsx +++ b/ui/src/components/AgentConfigForm.render.test.tsx @@ -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( + '[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"); + }); }); diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index 99bc1f75eeb..5dbeee42a59 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -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 && ( <> - @@ -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 ; + } + return ( + + ); +} + +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) => { + 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 ( + +
+ {options.map((option) => { + const isSelected = option.id === selected?.id; + return ( + + ); + })} +
+
+ ); +} + function ThinkingEffortDropdown({ value, options,