diff --git a/src/api/providers/__tests__/native-ollama.spec.ts b/src/api/providers/__tests__/native-ollama.spec.ts index 8fcbf4a0a1..504e676c59 100644 --- a/src/api/providers/__tests__/native-ollama.spec.ts +++ b/src/api/providers/__tests__/native-ollama.spec.ts @@ -319,14 +319,13 @@ describe("NativeOllamaHandler", () => { it("should map reasoningEffort levels to Ollama think values", async () => { const cases: Array< - [NonNullable, boolean | "high" | "medium" | "low"] + [NonNullable, boolean | "high" | "medium" | "low" | "max"] > = [ ["low", "low"], ["medium", "medium"], ["high", "high"], ["xhigh", "high"], - ["max", "high"], - ["none", true], + ["max", "max"], ["minimal", true], ["disable", false], ] @@ -362,6 +361,36 @@ describe("NativeOllamaHandler", () => { } }) + it("should send think: false when reasoningEffort is 'none'", async () => { + // Ollama has no native "none" thinking level; the "None" option in + // the selector means thinking disabled, so it maps to think: false + // rather than enabling thinking with the default budget. + const options: ApiHandlerOptions = { + apiModelId: "qwen3", + ollamaModelId: "qwen3", + ollamaBaseUrl: "http://localhost:11434", + enableReasoningEffort: true, + reasoningEffort: "none", + } + + handler = new NativeOllamaHandler(options) + + mockChat.mockImplementation(async function* () { + yield { message: { content: "ok" } } + }) + + const stream = handler.createMessage("System", [{ role: "user" as const, content: "Hi" }]) + for await (const _ of stream) { + // consume + } + + expect(mockChat).toHaveBeenCalledWith( + expect.objectContaining({ + think: false, + }), + ) + }) + it("should not send think parameter when reasoningEffort is undefined", async () => { mockChat.mockImplementation(async function* () { yield { message: { content: "ok" } } @@ -782,6 +811,34 @@ describe("NativeOllamaHandler", () => { ) }) + it("should send think: max in completePrompt when reasoningEffort is max", async () => { + // The Ollama API accepts "max" as the highest thinking level; the SDK + // passes it through verbatim. Selectors surface "max" for models that + // advertise it (e.g. qwen3 on Ollama Cloud), so the single-shot path + // must honor it rather than clamping to "high". + const options: ApiHandlerOptions = { + apiModelId: "qwen3", + ollamaModelId: "qwen3", + ollamaBaseUrl: "http://localhost:11434", + enableReasoningEffort: true, + reasoningEffort: "max", + } + + handler = new NativeOllamaHandler(options) + + mockChat.mockResolvedValue({ + message: { content: "Response" }, + }) + + await handler.completePrompt("Test prompt") + + expect(mockChat).toHaveBeenCalledWith( + expect.objectContaining({ + think: "max", + }), + ) + }) + it("should not send think parameter in completePrompt when reasoningEffort is undefined", async () => { mockChat.mockResolvedValue({ message: { content: "Response" }, diff --git a/src/api/providers/fetchers/__tests__/ollama.test.ts b/src/api/providers/fetchers/__tests__/ollama.test.ts index 9c0b547e88..25e29053ff 100644 --- a/src/api/providers/fetchers/__tests__/ollama.test.ts +++ b/src/api/providers/fetchers/__tests__/ollama.test.ts @@ -114,6 +114,128 @@ describe("Ollama Fetcher", () => { expect(parsedModel!.supportsImages).toBe(true) expect(parsedModel!.contextWindow).toBeGreaterThan(0) }) + + it("should advertise native reasoning effort for models with the 'thinking' capability", () => { + // qwen3 is a thinking model that accepts the full low/medium/high/max + // set documented by Ollama's generic `think` API and honors `think: + // false`, so it advertises ["disable","low","medium","high","max"]. The + // "disable" sentinel is part of the capability array (not a UI-side + // prepend) so the selector respects it verbatim and never offers an + // un-disableable "None". No "xhigh" is advertised. + const modelDataWithThinking = { + ...ollamaModelsData["qwen3-2to16:latest"], + capabilities: ["completion", "tools", "thinking"], + } + + const parsedModel = parseOllamaModel(modelDataWithThinking as Parameters[0]) + + expect(parsedModel).not.toBeNull() + expect(parsedModel!.supportsReasoningEffort).toEqual(["disable", "low", "medium", "high", "max"]) + expect(parsedModel!.reasoningEffort).toBe("medium") + }) + + it("should advertise only low/medium/high for gpt-oss thinking models (no max, no disable)", () => { + // Regression: Ollama's generic `think` API documents low/medium/high/max, + // but gpt-oss only accepts low/medium/high + // (https://ollama.com/library/gpt-oss: "Easily adjust the reasoning + // effort (low, medium, high) ..."). gpt-oss also ignores `think: false`, + // so reasoning cannot be disabled — the advertised array must omit both + // "max" and the "disable" sentinel. Advertising "max" would surface a + // choice the model rejects at request time, and advertising "disable" + // would offer an un-disableable "None". The array must be model-specific, + // not a single constant keyed off the boolean "thinking" capability. + // Ollama reports family "gptoss" and architecture "gptoss" (no hyphen) + // for gpt-oss in the /api/show response; the model id keeps the hyphen + // ("gpt-oss:20b"). Detection must match all three forms. + const gptOssModelData = { + ...ollamaModelsData["qwen3-2to16:latest"], + details: { + ...ollamaModelsData["qwen3-2to16:latest"].details, + family: "gptoss", + families: ["gptoss"], + parameter_size: "9.5B", + }, + model_info: { + ...ollamaModelsData["qwen3-2to16:latest"].model_info, + "general.architecture": "gptoss", + }, + capabilities: ["completion", "tools", "thinking"], + } + + const parsedModel = parseOllamaModel( + gptOssModelData as Parameters[0], + "gpt-oss:20b", + ) + + expect(parsedModel).not.toBeNull() + expect(parsedModel!.supportsReasoningEffort).toEqual(["low", "medium", "high"]) + // "max" must not be advertised for gpt-oss + expect(parsedModel!.supportsReasoningEffort).not.toContain("max") + // "disable" must not be advertised for gpt-oss (it ignores think: false) + expect(parsedModel!.supportsReasoningEffort).not.toContain("disable") + expect(parsedModel!.reasoningEffort).toBe("medium") + }) + + it("should detect gpt-oss from the architecture field alone (gptoss, no hyphen)", () => { + // Ollama strips the hyphen for general.architecture, reporting "gptoss". + // The heuristic must match the no-hyphen form even when the model id and + // family are absent or different. + const gptOssByArchitectureOnly = { + ...ollamaModelsData["qwen3-2to16:latest"], + details: { + ...ollamaModelsData["qwen3-2to16:latest"].details, + family: "other", + }, + model_info: { + ...ollamaModelsData["qwen3-2to16:latest"].model_info, + "general.architecture": "gptoss", + }, + capabilities: ["completion", "tools", "thinking"], + } + + const parsedModel = parseOllamaModel(gptOssByArchitectureOnly as Parameters[0]) + + expect(parsedModel!.supportsReasoningEffort).toEqual(["low", "medium", "high"]) + }) + + it("should detect gpt-oss from the model id even when family/architecture differ", () => { + // The model id ("gpt-oss:120b") keeps the hyphen and is the most reliable + // signal the user actually selected gpt-oss. Detection must match on the + // id even if Ollama reports a different family/architecture (e.g. a + // future quant or custom Modelfile). + const gptOssByIdOnly = { + ...ollamaModelsData["qwen3-2to16:latest"], + details: { + ...ollamaModelsData["qwen3-2to16:latest"].details, + family: "unknown", + }, + model_info: { + ...ollamaModelsData["qwen3-2to16:latest"].model_info, + "general.architecture": "unknown", + }, + capabilities: ["completion", "tools", "thinking"], + } + + const parsedModel = parseOllamaModel( + gptOssByIdOnly as Parameters[0], + "gpt-oss:120b", + ) + + expect(parsedModel!.supportsReasoningEffort).toEqual(["low", "medium", "high"]) + }) + + it("should not advertise reasoning effort when the 'thinking' capability is absent", () => { + const modelDataWithoutThinking = { + ...ollamaModelsData["qwen3-2to16:latest"], + capabilities: ["completion", "tools"], + } + + const parsedModel = parseOllamaModel(modelDataWithoutThinking as Parameters[0]) + + expect(parsedModel).not.toBeNull() + expect(parsedModel!.supportsReasoningEffort).toBeUndefined() + expect(parsedModel!.reasoningEffort).toBeUndefined() + }) }) describe("getOllamaModels", () => { diff --git a/src/api/providers/fetchers/ollama.ts b/src/api/providers/fetchers/ollama.ts index 9f88d75327..ada4b017b1 100644 --- a/src/api/providers/fetchers/ollama.ts +++ b/src/api/providers/fetchers/ollama.ts @@ -29,6 +29,73 @@ const OllamaModelInfoResponseSchema = z.object({ capabilities: z.array(z.string()).optional(), }) +// Reasoning-effort levels a thinking-capable Ollama model advertises. These are +// model-specific, not a single constant: Ollama's generic `think` API documents +// low/medium/high/max (https://docs.ollama.com/capabilities/thinking), but +// gpt-oss only accepts low/medium/high (see https://ollama.com/library/gpt-oss: +// "Easily adjust the reasoning effort (low, medium, high) ..."). Advertising +// "max" for gpt-oss surfaces a choice the model rejects at request time, so the +// fetcher must pick the array per model rather than from the boolean capability. +// +// Whether reasoning can be turned *off* is also model-specific. Ollama's native +// `think` parameter has no string "none" level — disabling thinking is `think: +// false`. Most thinking models (qwen3, deepseek-r1, ...) honor `think: false`, +// but gpt-oss ignores it and always reasons, so it must not advertise a +// "disable"/"None" option. We model off-support explicitly by including the +// "disable" UI sentinel in the capability array for models that honor +// `think: false`, and omitting it for gpt-oss. The shared +// `getReasoningEffortSelection` then respects explicit arrays verbatim (it +// only auto-adds "disable" for `supportsReasoningEffort === true`), so the +// settings page and chat selector render exactly what the model advertises +// without a UI-side "none" prepend. +// +// `getOllamaThinkingEfforts` resolves the advertised levels from model metadata +// when present, falling back to a family-based heuristic. Tests cover both the +// gpt-oss regression (no "max", no "disable") and the default thinking-model +// case (low/medium/high/max plus "disable"). +const GPT_OSS_THINKING_EFFORTS = ["low", "medium", "high"] as const +const DEFAULT_THINKING_EFFORTS = ["disable", "low", "medium", "high", "max"] as const + +// gpt-oss reports family "gptoss" and architecture "gptoss" in model_info / +// details (Ollama strips the hyphen from the model id "gpt-oss" for these +// fields — see https://ollama.com/library/gpt-oss, which lists arch "gptoss"). +// The model id itself keeps the hyphen ("gpt-oss:20b" / "gpt-oss:120b"), so we +// match on the id too. Detect on any of id / family / architecture, comparing +// a hyphen/underscore-normalized form so "gpt-oss", "gptoss", and "gpt_oss" all +// match regardless of which field Ollama populates for a given tag. +function isGptOssModel(rawModel: OllamaModelInfoResponse, modelId?: string): boolean { + const normalize = (value: unknown): string => + typeof value === "string" ? value.toLowerCase().replace(/[-_]/g, "") : "" + + if (normalize(modelId).startsWith("gptoss")) { + return true + } + + if (normalize(rawModel.details.family) === "gptoss") { + return true + } + + const architecture = rawModel.model_info["general.architecture"] + if (normalize(architecture) === "gptoss") { + return true + } + + return false +} + +// Resolve the reasoning-effort levels a thinking-capable Ollama model advertises, +// including the "disable" UI sentinel when the model honors `think: false`. +// gpt-oss only accepts low/medium/high and ignores `think: false`, so it omits +// "disable"; other thinking models (qwen3, etc. on Ollama Cloud) accept the full +// low/medium/high/max set and honor `think: false`, so they include "disable". +// Exported for tests. +export function getOllamaThinkingEfforts( + rawModel: OllamaModelInfoResponse, + modelId?: string, +): readonly ("disable" | "low" | "medium" | "high" | "max")[] { + return isGptOssModel(rawModel, modelId) ? GPT_OSS_THINKING_EFFORTS : DEFAULT_THINKING_EFFORTS +} + const OllamaModelsResponseSchema = z.object({ models: z.array(OllamaModelSchema), }) @@ -37,7 +104,7 @@ type OllamaModelsResponse = z.infer type OllamaModelInfoResponse = z.infer -export const parseOllamaModel = (rawModel: OllamaModelInfoResponse): ModelInfo | null => { +export const parseOllamaModel = (rawModel: OllamaModelInfoResponse, modelId?: string): ModelInfo | null => { const contextKey = Object.keys(rawModel.model_info).find((k) => k.includes("context_length")) const contextWindow = contextKey && typeof rawModel.model_info[contextKey] === "number" ? rawModel.model_info[contextKey] : undefined @@ -59,6 +126,24 @@ export const parseOllamaModel = (rawModel: OllamaModelInfoResponse): ModelInfo | // Inherit the sane default (4096) from ollamaDefaultModelInfo instead. }) + // Models that advertise the "thinking" capability expose a native + // reasoning-effort control. Thinking levels — and whether reasoning can be + // turned off at all — are model-specific, not a single constant: + // - gpt-oss accepts only low/medium/high and ignores `think: false`, so it + // advertises exactly ["low","medium","high"] (no "max", no "disable"). + // - Other thinking models (qwen3, etc. on Ollama Cloud) accept the full + // low/medium/high/max set and honor `think: false`, so they advertise + // ["disable","low","medium","high","max"]. + // (see https://docs.ollama.com/capabilities/thinking and + // https://ollama.com/library/gpt-oss). Including the "disable" UI sentinel + // explicitly means off-support is part of the capability array the selector + // respects verbatim, rather than a UI-side "none" prepend that would also + // offer an un-disableable "None" for gpt-oss. + if (rawModel.capabilities?.includes("thinking")) { + modelInfo.supportsReasoningEffort = [...getOllamaThinkingEfforts(rawModel, modelId)] + modelInfo.reasoningEffort = "medium" + } + return modelInfo } @@ -98,7 +183,7 @@ export async function getOllamaModels( { headers }, ) .then((ollamaModelInfo) => { - const modelInfo = parseOllamaModel(ollamaModelInfo.data) + const modelInfo = parseOllamaModel(ollamaModelInfo.data, ollamaModel.model) // Only include models that support native tools if (modelInfo) { models[ollamaModel.name] = modelInfo diff --git a/src/api/providers/native-ollama.ts b/src/api/providers/native-ollama.ts index eb380d1eb2..136cf47faf 100644 --- a/src/api/providers/native-ollama.ts +++ b/src/api/providers/native-ollama.ts @@ -14,6 +14,14 @@ interface OllamaChatOptions { num_ctx?: number } +// The installed ollama SDK (v0.6.x) types the `think` request field as +// `boolean | "high" | "medium" | "low"`, but the Ollama API also accepts +// `"max"` (see https://docs.ollama.com/capabilities/thinking). The runtime +// serializes the field verbatim, so `getOllamaThinkParam` returns the wider +// union and the `client.chat` call sites cast down to the SDK's narrower +// type. Remove those casts once the SDK types catch up. +export type OllamaThinkParam = boolean | "high" | "medium" | "low" | "max" | undefined + // Narrow local types for non-Anthropic content blocks that may be carried in // the conversation history. The Anthropic SDK union does not include the // custom `reasoning` block (used by non-Anthropic protocols) or the @@ -311,7 +319,7 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio /** * Maps the configured reasoning effort setting to Ollama's native `think` - * request parameter (boolean | "high" | "medium" | "low"). + * request parameter (boolean | "high" | "medium" | "low" | "max"). * * Requires an explicit Ollama opt-in (`enableReasoningEffort === true`) * before translating `reasoningEffort`. This prevents inherited @@ -323,19 +331,16 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio * the model/Modelfile in control (preserving prior behavior where models * that emit think/thought tags in content are still handled by TagMatcher). * - * Note: The Ollama API itself also accepts `"max"` (see - * https://docs.ollama.com/capabilities/thinking), but the installed - * `ollama` SDK (v0.6.x) only types `think` as - * `boolean | "high" | "medium" | "low"`. Until the SDK types catch up, - * "xhigh"/"max" efforts are clamped to "high". - * * - enableReasoningEffort !== true -> undefined (no think param sent) * - "disable" -> false (thinking off) - * - "none" / "minimal" -> true (enable thinking with default budget) - * - "low" / "medium" / "high" -> the matching effort level - * - "xhigh" / "max" -> "high" (highest level the SDK currently supports) + * - "none" -> false (thinking off; Ollama has no native "none" level, and + * the "None" selector label means thinking disabled) + * - "minimal" -> true (enable thinking with default budget) + * - "low" / "medium" / "high" / "max" -> the matching effort level + * - "xhigh" -> "high" (highest level the Ollama API accepts; see + * https://docs.ollama.com/capabilities/thinking) */ - private getOllamaThinkParam(): boolean | "high" | "medium" | "low" | undefined { + private getOllamaThinkParam(): OllamaThinkParam { // Require an explicit Ollama opt-in before mapping reasoningEffort. // Without this guard, a stale reasoningEffort inherited from another // provider config could still emit a think param when the UI checkbox @@ -351,8 +356,8 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio switch (effort) { case "disable": - return false case "none": + return false case "minimal": return true case "low": @@ -362,7 +367,9 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio case "high": case "xhigh": case "max": - return "high" + // "max" is accepted verbatim by the Ollama API; "xhigh" (used by + // some third-party model catalogs) clamps to "high". + return effort === "max" ? "max" : "high" default: return undefined } @@ -377,9 +384,7 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio * Returns a tuple of `[chatOptions, thinkParam]` where `thinkParam` is * `undefined` when no `think` field should be sent to Ollama. */ - private buildChatRequestOptions( - useR1Format: boolean, - ): [OllamaChatOptions, boolean | "high" | "medium" | "low" | undefined] { + private buildChatRequestOptions(useR1Format: boolean): [OllamaChatOptions, OllamaThinkParam] { const chatOptions: OllamaChatOptions = { temperature: this.options.modelTemperature ?? (useR1Format ? DEEP_SEEK_DEFAULT_TEMPERATURE : 0), } @@ -427,14 +432,16 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio // Create the actual API request promise. The `stream: true` literal // is kept inline so TypeScript selects the streaming overload of // client.chat. The `think` parameter is spread conditionally to - // avoid sending an explicit `think: undefined` to the runtime. + // avoid sending an explicit `think: undefined` to the runtime. The + // cast narrows our wider OllamaThinkParam (which includes "max", + // accepted by the API) to the SDK's stale union type. const stream = await client.chat({ model: modelId, messages: ollamaMessages, stream: true, options: chatOptions, tools: this.convertToolsToOllama(metadata?.tools), - ...(thinkParam !== undefined ? { think: thinkParam } : {}), + ...(thinkParam !== undefined ? { think: thinkParam as boolean | "high" | "medium" | "low" } : {}), }) let totalInputTokens = 0 @@ -566,7 +573,7 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio messages: [{ role: "user", content: prompt }], stream: false, options: chatOptions, - ...(thinkParam !== undefined ? { think: thinkParam } : {}), + ...(thinkParam !== undefined ? { think: thinkParam as boolean | "high" | "medium" | "low" } : {}), }) return response.message?.content || "" diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index 3b94fb6e74..89b67c17cd 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -27,6 +27,7 @@ import { StandardTooltip } from "@src/components/ui" import Thumbnails from "../common/Thumbnails" import { ModeSelector } from "./ModeSelector" import { ApiConfigSelector } from "./ApiConfigSelector" +import { ReasoningEffortSelector } from "./ReasoningEffortSelector" import { AutoApproveDropdown } from "./AutoApproveDropdown" import { MAX_IMAGES_PER_MESSAGE } from "./constants" import ContextMenu from "./ContextMenu" @@ -1311,6 +1312,7 @@ export const ChatTextArea = forwardRef( lockApiConfigAcrossModes={!!lockApiConfigAcrossModes} onToggleLockApiConfig={handleToggleLockApiConfig} /> +
diff --git a/webview-ui/src/components/chat/ReasoningEffortSelector.tsx b/webview-ui/src/components/chat/ReasoningEffortSelector.tsx new file mode 100644 index 0000000000..4396ced00b --- /dev/null +++ b/webview-ui/src/components/chat/ReasoningEffortSelector.tsx @@ -0,0 +1,239 @@ +/* +Reasoning Effort selector for the chat input bottom bar. + +Sits beside the API configuration profile picker so the active model's reasoning +effort can be changed without opening Settings. It reads and writes the same +`reasoningEffort` / `enableReasoningEffort` fields of the active profile that the +Providers settings page edits (persisted via the `upsertApiConfiguration` +message), so both controls stay in sync through the extension state broadcast. + +Option computation is shared with the settings selectors via +`getReasoningEffortSelection`, and the Ollama model-info normalization is shared +via `getOllamaReasoningModelInfo`, so the values shown here always match Settings +and both surfaces can't drift. The fetcher's capability array is passed through +verbatim (it includes "disable" for models that honor think: false and omits it +for models that don't, e.g. gpt-oss); the fallback synthesizes +[disable, low, medium, high] when no model info has loaded yet. +*/ + +import { useCallback, useEffect, useMemo, useRef, useState } from "react" + +import type { ModelInfo } from "@roo-code/types/model" +import { providerIdentifiers } from "@roo-code/types/provider-identifiers" + +import { cn } from "@src/lib/utils" +import { useRooPortal } from "@src/components/ui/hooks/useRooPortal" +import { Popover, PopoverContent, PopoverTrigger, StandardTooltip } from "@src/components/ui" +import { useAppTranslation } from "@src/i18n/TranslationContext" +import { useExtensionState } from "@src/context/ExtensionStateContext" +import { useSelectedModel } from "@src/components/ui/hooks/useSelectedModel" +import { + getOllamaReasoningModelInfo, + getReasoningEffortSelection, + getReasoningEffortTranslationKey, + normalizeReasoningEffortOnModelChange, + type ReasoningEffortOption, +} from "@src/utils/reasoning-effort" +import { vscode } from "@src/utils/vscode" + +interface ReasoningEffortSelectorProps { + disabled?: boolean + triggerClassName?: string +} + +export const ReasoningEffortSelector = ({ disabled = false, triggerClassName = "" }: ReasoningEffortSelectorProps) => { + const { t } = useAppTranslation() + const { apiConfiguration, currentApiConfigName } = useExtensionState() + const { provider, info: selectedModelInfo } = useSelectedModel(apiConfiguration) + const [open, setOpen] = useState(false) + const portalContainer = useRooPortal("roo-portal") + const listRef = useRef(null) + + // Build the modelInfo through the same shared Ollama normalization the + // settings page uses, so the chat dropdown lists exactly the same options + // as the settings dropdown. The fetcher's advertised array is passed through + // verbatim; the fallback synthesizes [disable, low, medium, high] when no + // model info has loaded yet so the selector can render immediately. + const modelInfo = useMemo(() => { + if (provider === providerIdentifiers.ollama) { + return getOllamaReasoningModelInfo(selectedModelInfo) + } + + return selectedModelInfo + }, [provider, selectedModelInfo]) + + const { isReasoningEffortSupported, availableOptions, currentReasoningEffort, storedReasoningEffort } = + getReasoningEffortSelection(apiConfiguration, modelInfo) + + // Normalize the stored reasoning effort at the model-switch boundary. When + // the user switches from a disable-capable model (e.g. qwen3, with + // reasoningEffort: "disable") to one that omits "disable" (gpt-oss → + // ["low","medium","high"]), the stored value falls out of the new capability + // array. getReasoningEffortSelection already clamps the *displayed* effort to + // the fallback, but the *stored* value stays "disable" — so the native + // request mapper would send think: false while the UI shows "Low". This + // effect persists the clamped value so the stored effort, the displayed + // effort, and the request stay in sync. It only writes reasoningEffort (never + // enableReasoningEffort), mirroring the chat selector's soft-toggle contract. + useEffect(() => { + if (!currentApiConfigName || !apiConfiguration) { + return + } + const persisted = normalizeReasoningEffortOnModelChange({ storedReasoningEffort, currentReasoningEffort }) + if (persisted === undefined) { + return + } + vscode.postMessage({ + type: "upsertApiConfiguration", + text: currentApiConfigName, + apiConfiguration: { + ...apiConfiguration, + reasoningEffort: persisted, + }, + }) + }, [apiConfiguration, currentApiConfigName, storedReasoningEffort, currentReasoningEffort]) + + const handleSelect = useCallback( + (option: ReasoningEffortOption) => { + if (!currentApiConfigName || !apiConfiguration) { + return + } + + // Write only `reasoningEffort`. The Enable Thinking checkbox in the + // settings page owns `enableReasoningEffort` independently, so the chat + // selector never flips it. Picking "None" (the "disable" option) in + // chat keeps enableReasoningEffort as-is and stores + // reasoningEffort: "disable". `getOllamaThinkParam()` gates the think + // param on `enableReasoningEffort === true` first, so when that flag + // stays on it still emits no think param only if reasoningEffort maps + // to off — but the chat selector is a soft toggle that does not change + // the flag, so the authoritative on/off state remains the settings + // checkbox. There is no separate `reasoning: "none"` field; Ollama has + // no native "none" string level, and "disable" maps to think: false. + vscode.postMessage({ + type: "upsertApiConfiguration", + text: currentApiConfigName, + apiConfiguration: { + ...apiConfiguration, + reasoningEffort: option, + }, + }) + setOpen(false) + }, + [apiConfiguration, currentApiConfigName], + ) + + // Keyboard navigation for the option list. The container acts as a listbox + // (role="listbox") and each option is a native + ) + })} +
+ + + + ) +} diff --git a/webview-ui/src/components/chat/__tests__/ChatTextArea.visual.fixture.tsx b/webview-ui/src/components/chat/__tests__/ChatTextArea.visual.fixture.tsx index 4374eaea51..e629bd5c89 100644 --- a/webview-ui/src/components/chat/__tests__/ChatTextArea.visual.fixture.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatTextArea.visual.fixture.tsx @@ -1,35 +1,139 @@ -import React, { useState } from "react" +/* v8 ignore file -- Playwright component fixture is covered by the visual test. */ +import React from "react" -import { defaultModeSlug, type Mode } from "@roo/modes" -import { AppProviders } from "../../../../playwright/AppProviders" -import { ChatTextArea } from "../ChatTextArea" +import { TranslationContext } from "@src/i18n/TranslationContext" +import { TooltipProvider } from "@src/components/ui/tooltip" -export function ChatTextAreaStory() { - const [inputValue, setInputValue] = useState("Audit contrast across the Zoo Code webview") - const [selectedImages, setSelectedImages] = useState([]) - const [mode, setMode] = useState(defaultModeSlug) +// Translations for the labels shown in the snapshot. +const translations: Record = { + "chat:selectMode": "Select mode for interaction", + "chat:selectApiConfig": "Select API configuration", + "settings:providers.reasoningEffort.label": "Model Reasoning Effort", + "settings:providers.reasoningEffort.none": "None", + "settings:providers.reasoningEffort.low": "Low", + "settings:providers.reasoningEffort.medium": "Medium", + "settings:providers.reasoningEffort.high": "High", + "settings:providers.reasoningEffort.max": "Max", +} + +// Shared trigger styling for the four chat-toolbar controls. These reproduce +// each real control's PopoverTrigger classes so the snapshot faithfully +// captures the compact toolbar layout the user sees at a glance. The real +// components (ModeSelector, ApiConfigSelector, ReasoningEffortSelector, +// AutoApproveDropdown) all share this base; only minor variations differ +// (min-w-0, gap, first-use highlight). We render lightweight stand-ins rather +// than the real components because the real components' import graphs evaluate +// Zod schemas at module load (`@roo-code/types/model`), which the Playwright +// CT Vite build externalizes (`z is not defined` at runtime). Stand-ins keep +// the visual test free of that dependency while capturing the exact layout. +const TRIGGER_BASE = + "inline-flex items-center relative whitespace-nowrap px-1.5 py-1 text-xs " + + "bg-transparent border border-[rgba(255,255,255,0.08)] rounded-md text-vscode-foreground " + + "transition-all duration-150 focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder focus-visible:ring-inset " + + "opacity-90 hover:opacity-100 hover:bg-[rgba(255,255,255,0.03)] hover:border-[rgba(255,255,255,0.15)] cursor-pointer" + +// The same triggerClassName ChatTextArea passes to each control in production. +// ModeSelector gets `text-ellipsis overflow-hidden flex-shrink-0` (no min-w-0), +// so it keeps its full width and does not shrink — matching the production +// layout the narrow-width snapshot must validate. ApiConfigSelector and +// ReasoningEffortSelector get `min-w-[28px] text-ellipsis overflow-hidden +// flex-shrink`, so they shrink/ellipsis first when the row overflows. +const MODE_TRIGGER = `${TRIGGER_BASE} text-ellipsis overflow-hidden flex-shrink-0` +const SHRINK_TRIGGER = `${TRIGGER_BASE} min-w-0 text-ellipsis overflow-hidden flex-shrink` +const AUTO_APPROVE_TRIGGER = + "inline-flex items-center gap-1.5 relative whitespace-nowrap px-1.5 py-1 text-xs " + + "bg-transparent border border-[rgba(255,255,255,0.08)] rounded-md text-vscode-foreground " + + "transition-all duration-150 focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder focus-visible:ring-inset " + + "max-[300px]:shrink-0 " + + "opacity-90 hover:opacity-100 hover:bg-[rgba(255,255,255,0.03)] hover:border-[rgba(255,255,255,0.15)] cursor-pointer " + + "min-w-[28px] text-ellipsis overflow-hidden flex-shrink" + +// Right-cluster icon-button styling. IndexingStatusBadge is a ghost Button with +// a Database lucide icon + a status dot; ZooCodeAuthBadge (signed-out) is a +// size-5 rounded-full button with a person SVG. Reproduced as stand-ins for the +// same Zod-import reason as the left-cluster triggers. +const ICON_BUTTON_BASE = + "relative inline-flex items-center justify-center bg-transparent border-none p-1.5 " + + "rounded-md min-w-[28px] min-h-[28px] text-vscode-foreground opacity-85 " + + "transition-all duration-150 hover:opacity-100 hover:bg-[rgba(255,255,255,0.03)] " + + "focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder cursor-pointer" - return ( - +// Person icon (signed-out Zoo Code auth state), matching ZooCodeAuthBadge's SVG. +const PersonIcon = () => ( + + + + +) + +interface ChatToolbarFixtureProps { + /** Container width in px. The narrow state exercises the row's + * overflow/flex-shrink behavior (the compact toolbar's primary concern). */ + width: number +} + +/** + * Mounts the full compact chat-input toolbar row the user sees at a glance: + * the left cluster of dropdowns — [Select mode] [Select API configuration] + * [Model reasoning effort] [Auto-approval] — and the right cluster of icon + * buttons — [Codebase indexing] [Sign in to Zoo Code]. The visual test + * snapshots how the new reasoning-effort selector sits alongside the existing + * controls, at both a default and a narrow width. The container mirrors the + * real ChatTextArea structure: an outer `flex items-center gap-2`, an inner + * `flex-1 min-w-0 overflow-clip` for the dropdowns, and a `flex-shrink-0` + * right cluster with `gap-0.5`. + */ +export const ChatToolbarFixture = ({ width }: ChatToolbarFixtureProps) => ( + // `i18n` is the production TranslationContext's `typeof i18next`, which we + // cannot satisfy with a real instance in a Playwright CT fixture without + // importing and initializing i18next. The CT config aliases + // `@src/i18n/TranslationContext` to a shim whose `i18n` is `unknown`, but + // `tsc` type-checks against the real module, so the double assertion through + // `unknown` is required to bridge `null` to `typeof i18next`. This is the + // "last resort" double assertion the coding guidelines allow for + // unavoidable casts; the `t` function (the only field the fixture actually + // uses) is provided directly. + translations[key] ?? key, + i18n: null as unknown as typeof import("../../../i18n/setup").default, + }}> +
- undefined} - onSelectImages={() => undefined} - shouldDisableImages={false} - mode={mode} - setMode={setMode} - modeShortcutText="Ctrl+. for next mode" - /> + className="flex items-center gap-2 bg-vscode-editor-background p-2 text-vscode-foreground" + style={{ width: `${width}px` }}> +
+ + + + +
+
+ + +
-
- ) -} + + +) diff --git a/webview-ui/src/components/chat/__tests__/ChatTextArea.visual.tsx b/webview-ui/src/components/chat/__tests__/ChatTextArea.visual.tsx index a18bcfe817..6a8ede3361 100644 --- a/webview-ui/src/components/chat/__tests__/ChatTextArea.visual.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatTextArea.visual.tsx @@ -1,29 +1,79 @@ import React from "react" import { expect, test } from "../../../../playwright/coverage-fixture" -import { applyVisualTheme, visualThemes } from "../../../../playwright/themes" -import { ChatTextAreaStory } from "./ChatTextArea.visual.fixture" - -for (const theme of visualThemes) { - test(`renders the production chat composer in the VS Code ${theme.name} theme`, async ({ mount, page }) => { - await applyVisualTheme(page, theme) - // The full provider bundle leaves a bare Zod reference after CT tree-shaking. - await page.evaluate(() => Object.assign(globalThis, { z: undefined })) - const component = await mount() - const story = component.getByTestId("chat-text-area-story") - const editor = story.getByRole("textbox") - await expect(editor).toBeVisible() - await expect(story).toHaveScreenshot(`chat-composer-resting-${theme.name}.png`) - - await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur()) - for ( - let index = 0; - index < 10 && !(await editor.evaluate((element) => element === document.activeElement)); - index++ - ) { - await page.keyboard.press("Tab") - } - await expect(editor).toBeFocused() - await expect(story).toHaveScreenshot(`chat-composer-focus-${theme.name}.png`) - }) +import { ChatToolbarFixture } from "./ChatTextArea.visual.fixture" + +// Visual baseline for the compact chat-input toolbar row the PR changes by +// adding the reasoning-effort selector. webview-ui/AGENTS.md requires a +// *.visual.tsx snapshot for at-a-glance layout changes; this covers the full +// toolbar — the left cluster [Select mode] [Select API configuration] +// [Model reasoning effort] [Auto-approval] and the right cluster [Codebase +// indexing] [Sign in to Zoo Code] — so the snapshot captures how the new +// control sits alongside every existing one, in both the default and +// narrow-width states (the narrow state exercises the row's min-w-0 / +// text-ellipsis / flex-shrink overflow behavior, the compact toolbar's +// primary concern) and in both VS Code dark and light themes. +// +// Baselines were generated with `pnpm test:visual:docker:update` from webview-ui/ +// (host-rendered screenshots are not the source of truth). To update, re-run +// that command and commit the resulting __screenshots__ PNGs. + +const themes = [ + { + name: "dark", + bodyClass: "vscode-dark", + themeId: "Default Dark Modern", + }, + { + name: "light", + bodyClass: "vscode-light", + themeId: "Default Light Modern", + }, +] as const + +// Default chat-input toolbar width and a narrow width that forces the row into +// its overflow/flex-shrink layout: wide enough that mode truncates but leaves +// room for bits of api-config and reasoning to peek through (the behavior the +// compact toolbar's min-w-0 / text-ellipsis / flex-shrink classes exist for). +const WIDTHS = [ + { name: "default", width: 520 }, + { name: "narrow", width: 380 }, +] as const + +for (const theme of themes) { + for (const { name: widthName, width } of WIDTHS) { + test(`renders the compact chat-input toolbar at ${widthName} width in the VS Code ${theme.name} theme`, async ({ + mount, + }) => { + const component = await mount() + + const trigger = component.getByTestId("reasoning-effort-trigger") + await trigger.evaluate((element, { bodyClass, themeId }) => { + const { document } = element.ownerDocument.defaultView! + document.documentElement.className = bodyClass + document.body.className = bodyClass + document.body.dataset.vscodeThemeId = themeId + }, theme) + + // Confirm the theme class was applied before snapshotting. We assert + // only the documentClass (not the resolved --vscode-editor-background + // hex), because the token's exact value varies across Playwright image + // revisions and is not what this baseline guards; the screenshot itself + // proves the theme rendered. + await expect + .poll(() => + trigger.evaluate((element) => ({ + documentClass: element.ownerDocument.documentElement.className, + })), + ) + .toEqual({ documentClass: theme.bodyClass }) + + await component.evaluate(async () => { + await document.fonts.ready + await new Promise((resolve) => requestAnimationFrame(() => resolve())) + }) + + await expect(component).toHaveScreenshot(`chat-toolbar-${widthName}-${theme.name}.png`) + }) + } } diff --git a/webview-ui/src/components/chat/__tests__/ReasoningEffortSelector.spec.tsx b/webview-ui/src/components/chat/__tests__/ReasoningEffortSelector.spec.tsx new file mode 100644 index 0000000000..0b9849c46a --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/ReasoningEffortSelector.spec.tsx @@ -0,0 +1,492 @@ +// npx vitest src/components/chat/__tests__/ReasoningEffortSelector.spec.tsx + +import type { ReactNode } from "react" + +import { render, screen, fireEvent } from "@/utils/test-utils" + +import type { ModelInfo, ProviderSettings } from "@roo-code/types" + +import { ReasoningEffortSelector } from "../ReasoningEffortSelector" + +// Typed shape the selector reads from useExtensionState. Only the fields the +// component touches are modeled so a schema change to ExtensionState surfaces +// here as a compile error rather than being swallowed by `any`. +interface ExtensionStateShape { + currentApiConfigName: string | undefined + apiConfiguration: ProviderSettings +} + +// Typed shape the selector reads from useSelectedModel. `info` is the model's +// advertised capability surface; setting `supportsReasoningEffort` drives the +// option set the dropdown renders. +interface SelectedModelShape { + provider: string + id: string | undefined + info: ModelInfo | undefined +} + +const { postMessageMock, extensionState, selectedModel } = vi.hoisted(() => ({ + postMessageMock: vi.fn(), + extensionState: { + currentApiConfigName: "default", + apiConfiguration: {} as ProviderSettings, + } as ExtensionStateShape, + selectedModel: { + provider: "ollama", + id: "qwen3", + info: undefined as ModelInfo | undefined, + } as SelectedModelShape, +})) + +vi.mock("@src/utils/vscode", () => ({ + vscode: { postMessage: postMessageMock }, +})) + +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock("@src/components/ui/hooks/useRooPortal", () => ({ + useRooPortal: () => document.body, +})) + +vi.mock("@src/context/ExtensionStateContext", () => ({ + useExtensionState: () => extensionState, +})) + +vi.mock("@src/components/ui/hooks/useSelectedModel", () => ({ + useSelectedModel: () => selectedModel, +})) + +// Mock Popover components to be testable, mirroring ApiConfigSelector.spec. +// Typed props keep the mock in sync with the real Popover surface so a prop +// rename in the real components breaks the test instead of being silently +// swallowed by `any`. +interface PopoverMockProps { + children?: ReactNode + open?: boolean + onOpenChange?: (open: boolean) => void + "data-testid"?: string +} +interface PopoverTriggerMockProps { + children?: ReactNode + disabled?: boolean + className?: string + "data-testid"?: string +} +interface PopoverContentMockProps { + children?: ReactNode + className?: string +} +vi.mock("@src/components/ui", () => ({ + Popover: ({ children, open, ...rest }: PopoverMockProps) => ( +
+ {children} +
+ ), + PopoverTrigger: ({ children, disabled, ...props }: PopoverTriggerMockProps) => ( + + ), + PopoverContent: ({ children }: PopoverContentMockProps) =>
{children}
, + StandardTooltip: ({ children }: { children?: ReactNode }) => <>{children}, +})) + +describe("ReasoningEffortSelector", () => { + beforeEach(() => { + vi.clearAllMocks() + extensionState.currentApiConfigName = "default" + extensionState.apiConfiguration = { + apiProvider: "ollama", + ollamaModelId: "qwen3", + } + selectedModel.provider = "ollama" + selectedModel.id = "qwen3" + selectedModel.info = undefined + }) + + it("renders nothing for non-Ollama providers without advertised reasoning effort", () => { + selectedModel.provider = "anthropic" + selectedModel.info = { contextWindow: 200000, supportsPromptCache: true } + + render() + + expect(screen.queryByTestId("reasoning-effort-trigger")).not.toBeInTheDocument() + }) + + it("shows the stored effort for an ollama model that advertises levels including max", () => { + // The fetcher advertises ["disable","low","medium","high","max"] for + // thinking models that honor think: false (qwen3). The selector passes + // that array through verbatim, so "max" is selectable. + selectedModel.info = { + contextWindow: 40960, + supportsPromptCache: true, + supportsReasoningEffort: ["disable", "low", "medium", "high", "max"], + } + extensionState.apiConfiguration = { + apiProvider: "ollama", + ollamaModelId: "qwen3", + reasoningEffort: "max", + enableReasoningEffort: true, + } + + render() + + expect(screen.getByTestId("reasoning-effort-trigger")).toHaveTextContent( + "settings:providers.reasoningEffort.max", + ) + expect(screen.getByTestId("reasoning-effort-option-max")).toBeInTheDocument() + }) + + it("lists Disable (None) as an option for ollama when the model advertises it, via the verbatim array", () => { + // No advertised info yet (router still loading). The shared fallback + // synthesizes ["disable","low","medium","high"] so the chat bar is usable + // immediately on app boot. "disable" is the UI sentinel for think: false, + // not a fake "none" thinking level. + selectedModel.info = undefined + extensionState.apiConfiguration = { + apiProvider: "ollama", + ollamaModelId: "deepseek-v4-flash:0731", + enableReasoningEffort: true, + } + + render() + + expect(screen.getByTestId("reasoning-effort-trigger")).toBeInTheDocument() + expect(screen.getByTestId("reasoning-effort-option-disable")).toBeInTheDocument() + expect(screen.getByTestId("reasoning-effort-option-low")).toBeInTheDocument() + expect(screen.getByTestId("reasoning-effort-option-medium")).toBeInTheDocument() + expect(screen.getByTestId("reasoning-effort-option-high")).toBeInTheDocument() + }) + + it("does not prepend a 'none'/'disable' option to an advertised array that omits it (gpt-oss)", () => { + // gpt-oss ignores think: false, so the fetcher advertises exactly + // ["low","medium","high"] with no "disable". The selector must surface that + // verbatim and must not inject a disable/none option the model can't honor. + selectedModel.info = { + contextWindow: 131072, + supportsPromptCache: true, + supportsReasoningEffort: ["low", "medium", "high"], + } + extensionState.apiConfiguration = { + apiProvider: "ollama", + ollamaModelId: "gpt-oss:20b", + reasoningEffort: "medium", + enableReasoningEffort: true, + } + + render() + + expect(screen.getByTestId("reasoning-effort-option-low")).toBeInTheDocument() + expect(screen.getByTestId("reasoning-effort-option-medium")).toBeInTheDocument() + expect(screen.getByTestId("reasoning-effort-option-high")).toBeInTheDocument() + expect(screen.queryByTestId("reasoning-effort-option-disable")).not.toBeInTheDocument() + expect(screen.queryByTestId("reasoning-effort-option-none")).not.toBeInTheDocument() + }) + + it("persists only reasoningEffort so enableReasoningEffort stays in sync with the settings checkbox", () => { + selectedModel.info = { + contextWindow: 40960, + supportsPromptCache: true, + supportsReasoningEffort: ["disable", "low", "medium", "high", "max"], + } + extensionState.apiConfiguration = { + apiProvider: "ollama", + ollamaModelId: "qwen3", + reasoningEffort: "max", + enableReasoningEffort: true, + } + + render() + fireEvent.click(screen.getByTestId("reasoning-effort-option-low")) + + expect(postMessageMock).toHaveBeenCalledWith({ + type: "upsertApiConfiguration", + text: "default", + apiConfiguration: { + apiProvider: "ollama", + ollamaModelId: "qwen3", + reasoningEffort: "low", + enableReasoningEffort: true, + }, + }) + }) + + it("stores reasoningEffort: 'disable' when None is selected without flipping enableReasoningEffort", () => { + // The chat selector is a soft toggle: it only writes reasoningEffort, + // leaving the Enable Thinking checkbox in the settings page in charge of + // enableReasoningEffort. Selecting None (the "disable" option) stores + // reasoningEffort: "disable". There is no separate reasoning: "none" + // field; Ollama has no native string "none" level, and "disable" maps to + // think: false via getOllamaThinkParam(). + selectedModel.info = { + contextWindow: 40960, + supportsPromptCache: true, + supportsReasoningEffort: ["disable", "low", "medium", "high", "max"], + } + extensionState.apiConfiguration = { + apiProvider: "ollama", + ollamaModelId: "qwen3", + reasoningEffort: "high", + enableReasoningEffort: true, + } + + render() + fireEvent.click(screen.getByTestId("reasoning-effort-option-disable")) + + expect(postMessageMock).toHaveBeenCalledWith( + expect.objectContaining({ + type: "upsertApiConfiguration", + apiConfiguration: expect.objectContaining({ + reasoningEffort: "disable", + enableReasoningEffort: true, + }), + }), + ) + }) + + it("normalizes a stale 'disable' to the clamped fallback on switch from a disable-capable model to gpt-oss", () => { + // Regression: the user saved "disable" on qwen3 (which honors think: + // false) and then selected gpt-oss (which omits "disable" from its + // capability array). The selector's useEffect must persist the clamped + // fallback (gpt-oss's first option "low") so the stored effort matches + // what the UI shows and what the native request mapper sends, instead of + // leaving reasoningEffort: "disable" to map to think: false. + selectedModel.provider = "ollama" + selectedModel.id = "gpt-oss:20b" + selectedModel.info = { + contextWindow: 131072, + supportsPromptCache: true, + supportsReasoningEffort: ["low", "medium", "high"], + } + extensionState.apiConfiguration = { + apiProvider: "ollama", + ollamaModelId: "gpt-oss:20b", + reasoningEffort: "disable", + enableReasoningEffort: true, + } + + render() + + expect(postMessageMock).toHaveBeenCalledWith( + expect.objectContaining({ + type: "upsertApiConfiguration", + text: "default", + apiConfiguration: expect.objectContaining({ + reasoningEffort: "low", + // enableReasoningEffort is untouched (chat selector is a soft toggle) + enableReasoningEffort: true, + }), + }), + ) + }) + + it("does not persist when the stored effort is still valid for the selected model", () => { + // "low" is valid for gpt-oss, so switching to gpt-oss with a stored "low" + // must not fire a normalization write. + selectedModel.provider = "ollama" + selectedModel.id = "gpt-oss:20b" + selectedModel.info = { + contextWindow: 131072, + supportsPromptCache: true, + supportsReasoningEffort: ["low", "medium", "high"], + } + extensionState.apiConfiguration = { + apiProvider: "ollama", + ollamaModelId: "gpt-oss:20b", + reasoningEffort: "low", + enableReasoningEffort: true, + } + + render() + + expect(postMessageMock).not.toHaveBeenCalled() + }) + + it("renders for ollama even when no model info has loaded", () => { + // The user expects the chat bar to show a reasoning selector for ollama + // no matter what — this is the primary use case for the chat selector. + selectedModel.info = undefined + extensionState.apiConfiguration = { + apiProvider: "ollama", + ollamaModelId: "deepseek-v4-flash:0731", + enableReasoningEffort: true, + } + + render() + + expect(screen.getByTestId("reasoning-effort-trigger")).toBeInTheDocument() + }) + + it("hides the selector when Enable Thinking (enableReasoningEffort) is unticked", () => { + // The settings checkbox owns enableReasoningEffort. Unticking it and + // saving must collapse the chat-bar selector even though the model + // supports reasoning effort and a value is still stored. + selectedModel.info = { + contextWindow: 40960, + supportsPromptCache: true, + supportsReasoningEffort: ["disable", "low", "medium", "high", "max"], + } + extensionState.apiConfiguration = { + apiProvider: "ollama", + ollamaModelId: "qwen3", + reasoningEffort: "high", + enableReasoningEffort: false, + } + + render() + + expect(screen.queryByTestId("reasoning-effort-trigger")).not.toBeInTheDocument() + }) + + it("hides the selector when enableReasoningEffort is unset", () => { + // A fresh profile with no thinking selection has no explicit opt-in, so + // the selector stays hidden until the user enables thinking in settings. + selectedModel.info = { + contextWindow: 40960, + supportsPromptCache: true, + supportsReasoningEffort: true, + } + extensionState.apiConfiguration = { apiProvider: "ollama", ollamaModelId: "qwen3" } + + render() + + expect(screen.queryByTestId("reasoning-effort-trigger")).not.toBeInTheDocument() + }) + + it("renders the selector for required-reasoning models even when the flag is unset", () => { + // requiredReasoningEffort means reasoning is mandatory, so the selector + // stays available without an explicit opt-in. + selectedModel.info = { + contextWindow: 256000, + supportsPromptCache: true, + supportsReasoningEffort: true, + requiredReasoningEffort: true, + } + extensionState.apiConfiguration = { apiProvider: "ollama", ollamaModelId: "kimi-k2" } + + render() + + expect(screen.getByTestId("reasoning-effort-trigger")).toBeInTheDocument() + }) + + it("does not persist when there is no active profile", () => { + extensionState.currentApiConfigName = undefined + selectedModel.provider = "ollama" + selectedModel.info = { + contextWindow: 40960, + supportsPromptCache: true, + supportsReasoningEffort: ["disable", "low", "medium", "high", "max"], + } + extensionState.apiConfiguration = { + apiProvider: "ollama", + ollamaModelId: "qwen3", + reasoningEffort: "high", + enableReasoningEffort: true, + } + + render() + fireEvent.click(screen.getByTestId("reasoning-effort-option-low")) + + expect(postMessageMock).not.toHaveBeenCalled() + }) + + describe("keyboard interaction", () => { + it("renders native button options with listbox/option semantics so they are keyboard-operable", () => { + // Regression: the previous implementation rendered non-focusable + //
rows, so keyboard users could not select an effort and + // screen readers did not announce them as selectable items. The fix + // renders native