From 3ac83330aa38a2aa2dbbced6a7376e0bd66eb9e6 Mon Sep 17 00:00:00 2001 From: Asadur Date: Sat, 22 Aug 2026 20:09:37 +0600 Subject: [PATCH 1/5] feat(ollama): add reasoning effort selectors and gate on Enable Thinking - Chat bar and settings page share option computation via reasoning-effort.ts; both hide when Enable Thinking is unticked - Ollama settings now renders its own thinking checkbox + effort dropdown; generic ThinkingBudget is skipped for it - Fetcher advertises low/medium/high/max for thinking-capable models - Handler sends think: "max" verbatim (previously clamped to "high") and maps "none" to think: false so the None option disables thinking --- .../providers/__tests__/native-ollama.spec.ts | 63 ++++- .../fetchers/__tests__/ollama.test.ts | 28 ++ src/api/providers/fetchers/ollama.ts | 10 + src/api/providers/native-ollama.ts | 45 ++-- .../src/components/chat/ChatTextArea.tsx | 2 + .../chat/ReasoningEffortSelector.tsx | 176 ++++++++++++ .../ReasoningEffortSelector.spec.tsx | 254 ++++++++++++++++++ .../src/components/settings/ApiOptions.tsx | 5 +- .../settings/ReasoningModeSelector.tsx | 125 +++++++++ .../src/components/settings/SettingsView.tsx | 81 ++++-- .../components/settings/ThinkingBudget.tsx | 57 +--- .../ApiOptions.ollama-thinking.spec.tsx | 210 +++++++++++++++ .../__tests__/ReasoningModeSelector.spec.tsx | 155 +++++++++++ .../components/settings/providers/Ollama.tsx | 64 +++-- .../providers/__tests__/Ollama.spec.tsx | 97 ++++++- webview-ui/src/utils/reasoning-effort.ts | 83 ++++++ 16 files changed, 1342 insertions(+), 113 deletions(-) create mode 100644 webview-ui/src/components/chat/ReasoningEffortSelector.tsx create mode 100644 webview-ui/src/components/chat/__tests__/ReasoningEffortSelector.spec.tsx create mode 100644 webview-ui/src/components/settings/ReasoningModeSelector.tsx create mode 100644 webview-ui/src/components/settings/__tests__/ApiOptions.ollama-thinking.spec.tsx create mode 100644 webview-ui/src/components/settings/__tests__/ReasoningModeSelector.spec.tsx create mode 100644 webview-ui/src/utils/reasoning-effort.ts 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..bd3081cccc 100644 --- a/src/api/providers/fetchers/__tests__/ollama.test.ts +++ b/src/api/providers/fetchers/__tests__/ollama.test.ts @@ -114,6 +114,34 @@ 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", () => { + const modelDataWithThinking = { + ...ollamaModelsData["qwen3-2to16:latest"], + capabilities: ["completion", "tools", "thinking"], + } + + const parsedModel = parseOllamaModel(modelDataWithThinking as Parameters[0]) + + expect(parsedModel).not.toBeNull() + // Ollama Cloud accepts low/medium/high/max and rejects "xhigh", so the + // selector must surface exactly those native effort levels. + expect(parsedModel!.supportsReasoningEffort).toEqual(["low", "medium", "high", "max"]) + expect(parsedModel!.reasoningEffort).toBe("medium") + }) + + 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..15899b9103 100644 --- a/src/api/providers/fetchers/ollama.ts +++ b/src/api/providers/fetchers/ollama.ts @@ -59,6 +59,16 @@ 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. Ollama Cloud accepts low/medium/high/max and + // rejects "xhigh", so advertise exactly those values (matching the user's + // API verification) so the reasoning selector shows the model's real options + // instead of falling back to the generic low/medium/high defaults. + if (rawModel.capabilities?.includes("thinking")) { + modelInfo.supportsReasoningEffort = ["low", "medium", "high", "max"] + modelInfo.reasoningEffort = "medium" + } + return 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 4cf57d1e3e..0fa04a58af 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 "./ChatView" import ContextMenu from "./ContextMenu" @@ -1319,6 +1320,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..33ec9cfcb9 --- /dev/null +++ b/webview-ui/src/components/chat/ReasoningEffortSelector.tsx @@ -0,0 +1,176 @@ +/* +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`, so the values shown here always match Settings. +For Ollama, the synthesized model info prepends "none" so the dropdown always +lists None alongside the model's advertised effort levels (e.g. +low/medium/high/max for cloud ollama models). +*/ + +import { useCallback, useMemo, useState } from "react" + +import { type ModelInfo, ollamaDefaultModelInfo, providerIdentifiers } from "@roo-code/types" + +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 { + getReasoningEffortSelection, + getReasoningEffortTranslationKey, + 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") + + // Build the modelInfo the same way the Ollama settings page does, so the + // chat dropdown lists exactly the same options as the settings dropdown. + // For Ollama, prepend "none" so users can pick "None" alongside the model's + // advertised effort levels. For other providers, fall through to whatever + // the selected model advertises (the selector stays hidden when nothing + // advertises `supportsReasoningEffort`). + const modelInfo = useMemo(() => { + if (provider === providerIdentifiers.ollama) { + if (selectedModelInfo?.supportsReasoningEffort) { + const advertised = selectedModelInfo.supportsReasoningEffort + return { + ...selectedModelInfo, + supportsReasoningEffort: Array.isArray(advertised) + ? advertised.includes("none") + ? advertised + : (["none", ...advertised] as typeof advertised) + : advertised, + } + } + + // No advertised info yet (router models still loading, or local model + // without thinking metadata). Fall back to a synthesized modelInfo + // exposing the levels Ollama's native `think` parameter supports so + // the selector can render immediately. + return { ...ollamaDefaultModelInfo, supportsReasoningEffort: ["none", "low", "medium", "high"] } + } + + return selectedModelInfo + }, [provider, selectedModelInfo]) + + const { isReasoningEffortSupported, availableOptions, currentReasoningEffort } = getReasoningEffortSelection( + apiConfiguration, + modelInfo, + ) + + 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" in chat keeps + // enableReasoningEffort as-is and stores reasoningEffort: "none", + // which `getOllamaThinkParam()` translates to `think: true` with + // `reasoning: "none"` (an explicit "no reasoning level" choice that + // ollama accepts and that the settings dropdown shows verbatim). + vscode.postMessage({ + type: "upsertApiConfiguration", + text: currentApiConfigName, + apiConfiguration: { + ...apiConfiguration, + reasoningEffort: option, + }, + }) + setOpen(false) + }, + [apiConfiguration, currentApiConfigName], + ) + + // The Enable Thinking checkbox in the settings page owns + // `enableReasoningEffort`; the chat selector only edits `reasoningEffort` + // (it never flips the flag). Mirror that gate here so the selector hides + // whenever thinking is unticked, keeping the chat bar and the settings + // checkbox in sync. Models with `requiredReasoningEffort` (reasoning is + // mandatory) always render the selector, matching the settings page. + if ( + !isReasoningEffortSupported || + (apiConfiguration?.enableReasoningEffort !== true && !modelInfo?.requiredReasoningEffort) + ) { + return null + } + + const title = t("settings:providers.reasoningEffort.label") + + return ( + + + + {t(getReasoningEffortTranslationKey(currentReasoningEffort))} + + + +
+
+

{title}

+
+
+ {availableOptions.map((option) => ( +
handleSelect(option)} + className={cn( + "px-3 py-1.5 text-sm cursor-pointer flex items-center", + "hover:bg-vscode-list-hoverBackground", + option === currentReasoningEffort && + "bg-vscode-list-activeSelectionBackground text-vscode-list-activeSelectionForeground", + )}> + + {t(getReasoningEffortTranslationKey(option))} + + {option === currentReasoningEffort && ( +
+ +
+ )} +
+ ))} +
+
+
+
+ ) +} 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..a9f99e1dd9 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/ReasoningEffortSelector.spec.tsx @@ -0,0 +1,254 @@ +// npx vitest src/components/chat/__tests__/ReasoningEffortSelector.spec.tsx + +import type { ReactNode } from "react" + +import { render, screen, fireEvent } from "@/utils/test-utils" + +import { ReasoningEffortSelector } from "../ReasoningEffortSelector" + +const { postMessageMock, extensionState, selectedModel } = vi.hoisted(() => ({ + postMessageMock: vi.fn(), + extensionState: { + currentApiConfigName: "default" as string | undefined, + apiConfiguration: {} as Record, + }, + selectedModel: { + provider: "ollama" as string, + id: "qwen3" as string | undefined, + info: undefined as Record | undefined, + }, +})) + +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. +vi.mock("@src/components/ui", () => ({ + Popover: ({ children, open }: { children?: ReactNode; open?: boolean }) => ( +
+ {children} +
+ ), + PopoverTrigger: ({ children, disabled, ...props }: { children?: ReactNode; disabled?: boolean }) => ( + + ), + PopoverContent: ({ children }: { children?: ReactNode }) =>
{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 = {} + + render() + + expect(screen.queryByTestId("reasoning-effort-trigger")).not.toBeInTheDocument() + }) + + it("shows the stored effort for an ollama model that advertises levels including max", () => { + selectedModel.info = { supportsReasoningEffort: ["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("always lists None as an option for ollama, regardless of model info", () => { + // No advertised info yet (router still loading). Selector should still + // render with the synthesized [None, low, medium, high] set so the chat + // bar is usable immediately on app boot. + 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-none")).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("prepends None to a model's advertised options when missing", () => { + // Some ollama cloud models advertise ["low","medium","high","max"] but + // not "none". The chat selector prepends "none" so users can pick it. + selectedModel.info = { supportsReasoningEffort: ["low", "medium", "high", "max"] } + extensionState.apiConfiguration = { + apiProvider: "ollama", + ollamaModelId: "qwen3", + reasoningEffort: "medium", + enableReasoningEffort: true, + } + + render() + + expect(screen.getByTestId("reasoning-effort-option-none")).toBeInTheDocument() + expect(screen.getByTestId("reasoning-effort-option-max")).toBeInTheDocument() + }) + + it("persists only reasoningEffort so enableReasoningEffort stays in sync with the settings checkbox", () => { + selectedModel.info = { supportsReasoningEffort: ["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: 'none' 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 here mirrors what the settings + // dropdown does (same field, same value). + selectedModel.info = { supportsReasoningEffort: ["low", "medium", "high", "max"] } + extensionState.apiConfiguration = { + apiProvider: "ollama", + ollamaModelId: "qwen3", + reasoningEffort: "high", + enableReasoningEffort: true, + } + + render() + fireEvent.click(screen.getByTestId("reasoning-effort-option-none")) + + expect(postMessageMock).toHaveBeenCalledWith( + expect.objectContaining({ + type: "upsertApiConfiguration", + apiConfiguration: expect.objectContaining({ + reasoningEffort: "none", + enableReasoningEffort: true, + }), + }), + ) + }) + + 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 = { supportsReasoningEffort: ["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 = { 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 = { 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 = { supportsReasoningEffort: ["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() + }) +}) diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 3e1495baff..40dad6a720 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -764,7 +764,10 @@ const ApiOptions = ({ )} - {!fromWelcomeView && ( + {/* Ollama renders its own reasoning control inside the provider + component (gated by its "Enable thinking" checkbox), so skip the + generic ThinkingBudget for it to avoid a duplicate selector. */} + {!fromWelcomeView && selectedProvider !== providerIdentifiers.ollama && ( + - true → options ["disable","low","medium","high"] + - array → options are exactly the provided values +- When the model does not support reasoning effort, this component renders nothing. +*/ + +import { useEffect } from "react" + +import { type ProviderSettings, type ModelInfo, type ReasoningEffortExtended } from "@roo-code/types" + +import { useAppTranslation } from "@src/i18n/TranslationContext" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@src/components/ui" +import { + getReasoningEffortSelection, + getReasoningEffortTranslationKey, + type ReasoningEffortOption, +} from "@src/utils/reasoning-effort" + +interface ReasoningModeSelectorProps { + apiConfiguration: ProviderSettings + setApiConfigurationField: ( + field: K, + value: ProviderSettings[K], + isUserAction?: boolean, + ) => void + modelInfo?: ModelInfo +} + +export const ReasoningModeSelector = ({ + apiConfiguration, + setApiConfigurationField, + modelInfo, +}: ReasoningModeSelectorProps) => { + const { t } = useAppTranslation() + + // Option computation is shared with ThinkingBudget and the chat input bar + // selector so every control agrees on the option set and clamped value. + // "disable" turns off reasoning entirely; "none" is a valid reasoning level. + // Both display as "None" in the UI but behave differently. + const { isReasoningEffortSupported, availableOptions, currentReasoningEffort, storedReasoningEffort } = + getReasoningEffortSelection(apiConfiguration, modelInfo) + + // Set default reasoning effort when model supports it and no value is set. + useEffect(() => { + if ( + isReasoningEffortSupported && + modelInfo?.requiredReasoningEffort && + storedReasoningEffort !== currentReasoningEffort && + currentReasoningEffort !== "disable" + ) { + setApiConfigurationField("reasoningEffort", currentReasoningEffort as ReasoningEffortExtended, false) + } + }, [ + isReasoningEffortSupported, + storedReasoningEffort, + currentReasoningEffort, + modelInfo?.requiredReasoningEffort, + setApiConfigurationField, + ]) + + // Sync enableReasoningEffort based on selection. "disable" turns off reasoning; + // "none" is a valid level (reasoning enabled). + useEffect(() => { + if (!isReasoningEffortSupported) return + const shouldEnable = modelInfo?.requiredReasoningEffort || currentReasoningEffort !== "disable" + if (shouldEnable && apiConfiguration.enableReasoningEffort !== true) { + setApiConfigurationField("enableReasoningEffort", true, false) + } + }, [ + isReasoningEffortSupported, + modelInfo?.requiredReasoningEffort, + currentReasoningEffort, + apiConfiguration.enableReasoningEffort, + setApiConfigurationField, + ]) + + if (!isReasoningEffortSupported) { + return null + } + + return ( +
+ + +
+ ) +} diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 952c5615af..9fd0e2ddeb 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -40,11 +40,13 @@ import { DEFAULT_AUTO_CLOSE_ZOO_OPENED_NEW_FILES, DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, ImageGenerationProvider, + providerIdentifiers, } from "@roo-code/types" import { vscode } from "@src/utils/vscode" import { cn } from "@src/lib/utils" import { useAppTranslation } from "@src/i18n/TranslationContext" +import { useSelectedModel } from "@src/components/ui/hooks/useSelectedModel" import { ExtensionStateContextType, useExtensionState } from "@src/context/ExtensionStateContext" import { AlertDialog, @@ -68,6 +70,7 @@ import { SetCachedStateField, SetExperimentEnabled } from "./types" import { SectionHeader } from "./SectionHeader" import ApiConfigManager from "./ApiConfigManager" import ApiOptions from "./ApiOptions" +import { ReasoningModeSelector } from "./ReasoningModeSelector" import { AutoApproveSettings } from "./AutoApproveSettings" import { CheckpointSettings } from "./CheckpointSettings" import { NotificationSettings } from "./NotificationSettings" @@ -221,6 +224,8 @@ const SettingsView = forwardRef(({ onDone, t const apiConfiguration = useMemo(() => cachedState.apiConfiguration ?? {}, [cachedState.apiConfiguration]) + const selectedModelInfo = useSelectedModel(apiConfiguration).info + useEffect(() => { // Update when currentApiConfigName or mode changes. // Expected to be triggered by loadApiConfiguration/upsertApiConfiguration or mode switch. @@ -766,33 +771,55 @@ const SettingsView = forwardRef(({ onDone, t {t("settings:sections.providers")}
- - checkUnsaveChanges(() => - vscode.postMessage({ type: "loadApiConfiguration", text: configName }), - ) - } - onDeleteConfig={(configName: string) => - vscode.postMessage({ type: "deleteApiConfiguration", text: configName }) - } - onRenameConfig={(oldName: string, newName: string) => { - vscode.postMessage({ - type: "renameApiConfiguration", - values: { oldName, newName }, - apiConfiguration, - }) - prevApiConfigName.current = newName - }} - onUpsertConfig={(configName: string) => - vscode.postMessage({ - type: "upsertApiConfiguration", - text: configName, - apiConfiguration, - }) - } - /> +
+
+ + checkUnsaveChanges(() => + vscode.postMessage({ + type: "loadApiConfiguration", + text: configName, + }), + ) + } + onDeleteConfig={(configName: string) => + vscode.postMessage({ + type: "deleteApiConfiguration", + text: configName, + }) + } + onRenameConfig={(oldName: string, newName: string) => { + vscode.postMessage({ + type: "renameApiConfiguration", + values: { oldName, newName }, + apiConfiguration, + }) + prevApiConfigName.current = newName + }} + onUpsertConfig={(configName: string) => + vscode.postMessage({ + type: "upsertApiConfiguration", + text: configName, + apiConfiguration, + }) + } + /> +
+ {/* Ollama owns its reasoning-effort UI inside the provider + component (gated by its "Enable thinking" checkbox, which + controls the native think parameter). Rendering the + top-level selector for Ollama would duplicate it and its + sync effect would force enableReasoningEffort back on. */} + {apiConfiguration.apiProvider !== providerIdentifiers.ollama && ( + + )} +
= - supports === true - ? (reasoningEfforts as readonly ReasoningEffortOption[]) - : Array.isArray(supports) - ? (supports as ReadonlyArray) - : (reasoningEfforts as readonly ReasoningEffortOption[]) - - // Add "disable" option only when: - // 1. requiredReasoningEffort is not true, AND - // 2. supportsReasoningEffort is boolean true (not an explicit array) - // When the model provides an explicit array, respect those exact values. - const shouldAutoAddDisable = - !modelInfo?.requiredReasoningEffort && supports === true && !baseAvailableOptions.includes("disable") - const availableOptions: ReadonlyArray = shouldAutoAddDisable - ? ["disable", ...baseAvailableOptions] - : baseAvailableOptions - - // Default reasoning effort - use model's default if available - // GPT-5 models have "medium" as their default in the model configuration - const modelDefaultReasoningEffort = modelInfo?.reasoningEffort as ReasoningEffortExtended | undefined - const defaultReasoningEffort: ReasoningEffortOption = modelInfo?.requiredReasoningEffort - ? modelDefaultReasoningEffort || "medium" - : "disable" - // Current reasoning effort from settings, or fall back to default. - // Clamp to availableOptions so the Select trigger always renders a valid option. - const storedReasoningEffort = apiConfiguration.reasoningEffort as ReasoningEffortOption | undefined - const rawReasoningEffort: ReasoningEffortOption = storedReasoningEffort || defaultReasoningEffort - const fallbackReasoningEffort = availableOptions.includes(defaultReasoningEffort) - ? defaultReasoningEffort - : (availableOptions[0] ?? rawReasoningEffort) - const currentReasoningEffort: ReasoningEffortOption = availableOptions.includes(rawReasoningEffort) - ? rawReasoningEffort - : fallbackReasoningEffort + const { isReasoningEffortSupported, availableOptions, currentReasoningEffort, storedReasoningEffort } = + getReasoningEffortSelection(apiConfiguration, modelInfo) // Set default reasoning effort when model supports it and no value is set useEffect(() => { @@ -294,9 +265,7 @@ export const ThinkingBudget = ({ apiConfiguration, setApiConfigurationField, mod @@ -304,9 +273,7 @@ export const ThinkingBudget = ({ apiConfiguration, setApiConfigurationField, mod {availableOptions.map((value) => ( - {value === "none" || value === "disable" - ? t("settings:providers.reasoningEffort.none") - : t(`settings:providers.reasoningEffort.${value}`)} + {t(getReasoningEffortTranslationKey(value))} ))} diff --git a/webview-ui/src/components/settings/__tests__/ApiOptions.ollama-thinking.spec.tsx b/webview-ui/src/components/settings/__tests__/ApiOptions.ollama-thinking.spec.tsx new file mode 100644 index 0000000000..ee1f66afc9 --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/ApiOptions.ollama-thinking.spec.tsx @@ -0,0 +1,210 @@ +// npx vitest src/components/settings/__tests__/ApiOptions.ollama-thinking.spec.tsx + +import { render, screen } from "@/utils/test-utils" +import { providerIdentifiers, type ProviderSettings } from "@roo-code/types" +import type { ChangeEventHandler, InputHTMLAttributes, ReactNode } from "react" + +import type { useOpenRouterModelProviders } from "@src/components/ui/hooks/useOpenRouterModelProviders" + +import ApiOptions, { type ApiOptionsProps } from "../ApiOptions" + +type OpenRouterModelProvidersQueryResult = Pick, "data"> + +type ChildrenProps = { children?: ReactNode } + +type VSCodeTextFieldMockProps = ChildrenProps & + Pick, "value" | "placeholder"> & { + onInput?: ChangeEventHandler + } + +type SearchableSelectMockProps = { + value?: string + onValueChange: (value: string) => void + options: Array<{ value: string; label: string }> + "data-testid"?: string +} + +type SelectMockProps = ChildrenProps & { + value?: string + onValueChange?: (value: string) => void +} + +type UseSelectedModelReturn = { provider?: string; id?: string; info: Record } + +const { useOpenRouterModelProvidersMock, useSelectedModelMock } = vi.hoisted(() => ({ + useOpenRouterModelProvidersMock: vi.fn<() => OpenRouterModelProvidersQueryResult>(() => ({ data: undefined })), + useSelectedModelMock: vi.fn( + (configuration: ProviderSettings): UseSelectedModelReturn => ({ + provider: configuration.apiProvider, + id: configuration.apiModelId, + info: {}, + }), + ), +})) + +vi.mock("@src/context/ExtensionStateContext", () => ({ + useExtensionState: () => ({ + organizationAllowList: { allowAll: true, providers: {} }, + openAiCodexIsAuthenticated: false, + kimiCodeIsAuthenticated: false, + kimiCodeOAuthState: undefined, + }), +})) + +vi.mock("@src/components/ui/hooks/useRouterModels", () => ({ + useRouterModels: () => ({ data: {}, refetch: vi.fn() }), +})) + +vi.mock("@src/components/ui/hooks/useZooGatewayRouterModelsSync", () => ({ + useZooGatewayRouterModelsSync: vi.fn(), +})) + +vi.mock("@src/components/ui/hooks/useOpenRouterModelProviders", () => ({ + useOpenRouterModelProviders: useOpenRouterModelProvidersMock, + OPENROUTER_DEFAULT_PROVIDER_NAME: "Auto", +})) + +vi.mock("@src/components/ui/hooks/useSelectedModel", () => ({ + useSelectedModel: useSelectedModelMock, +})) + +vi.mock("@src/components/ui/hooks/useLmStudioModels", () => ({ + requestLmStudioModels: vi.fn(), +})) + +vi.mock("../providers", () => { + const provider = (testId: string) => () =>
+ return { + Anthropic: provider("provider-anthropic"), + Baseten: provider("provider-baseten"), + Bedrock: provider("provider-bedrock"), + DeepSeek: provider("provider-deepseek"), + Gemini: provider("provider-gemini"), + LMStudio: provider("provider-lmstudio"), + LiteLLM: provider("provider-litellm"), + Mistral: provider("provider-mistral"), + Moonshot: provider("provider-moonshot"), + KimiCode: provider("provider-kimi-code"), + Ollama: provider("provider-ollama"), + OpenAI: provider("provider-openai-native"), + OpenAICompatible: provider("provider-openai"), + OpenAICodex: provider("provider-openai-codex"), + OpenRouter: provider("provider-openrouter"), + Poe: provider("provider-poe"), + QwenCode: provider("provider-qwen-code"), + Requesty: provider("provider-requesty"), + SambaNova: provider("provider-sambanova"), + Unbound: provider("provider-unbound"), + Vertex: provider("provider-vertex"), + VSCodeLM: provider("provider-vscode-lm"), + XAI: provider("provider-xai"), + ZAi: provider("provider-zai"), + Fireworks: provider("provider-fireworks"), + Friendli: provider("provider-friendli"), + VercelAiGateway: provider("provider-vercel-ai-gateway"), + OpenCodeGo: provider("provider-opencode-go"), + Kenari: provider("provider-kenari"), + NanoGPT: provider("provider-nanogpt"), + ZooGateway: provider("provider-zoo-gateway"), + MiniMax: provider("provider-minimax"), + Mimo: provider("provider-mimo"), + } +}) + +vi.mock("../providers/BedrockCustomArn", () => ({ + BedrockCustomArn: () =>
, +})) +vi.mock("../ModelPicker", () => ({ ModelPicker: () => null })) +vi.mock("../ApiErrorMessage", () => ({ + ApiErrorMessage: ({ errorMessage }: { errorMessage: string }) =>
{String(errorMessage)}
, +})) +// Sentinel (not null) so tests can assert whether the generic ThinkingBudget renders. +vi.mock("../ThinkingBudget", () => ({ ThinkingBudget: () =>
})) +vi.mock("../Verbosity", () => ({ Verbosity: () => null })) +vi.mock("../TodoListSettingsControl", () => ({ TodoListSettingsControl: () => null })) +vi.mock("../TemperatureControl", () => ({ TemperatureControl: () => null })) +vi.mock("../RateLimitSecondsControl", () => ({ RateLimitSecondsControl: () => null })) +vi.mock("../ConsecutiveMistakeLimitControl", () => ({ ConsecutiveMistakeLimitControl: () => null })) + +vi.mock("@vscode/webview-ui-toolkit/react", () => ({ + VSCodeTextField: ({ children, value, onInput, placeholder }: VSCodeTextFieldMockProps) => ( + + ), + VSCodeLink: ({ children }: ChildrenProps) => {children}, +})) + +vi.mock("@/components/ui", () => ({ + SearchableSelect: ({ value, onValueChange, options, "data-testid": testId }: SearchableSelectMockProps) => ( +
+ +
+ ), + Collapsible: ({ children }: ChildrenProps) =>
{children}
, + CollapsibleTrigger: ({ children }: ChildrenProps) =>
{children}
, + CollapsibleContent: ({ children }: ChildrenProps) =>
{children}
, + Select: ({ value, onValueChange, children }: SelectMockProps) => ( + + ), + SelectTrigger: ({ children }: ChildrenProps) => <>{children}, + SelectValue: () => null, + SelectContent: ({ children }: ChildrenProps) => <>{children}, + SelectItem: ({ value, children }: { value?: string; children?: ReactNode }) => ( + + ), +})) + +const renderApiOptions = (props: Partial = {}) => + render( + undefined} + uriScheme={undefined} + apiConfiguration={{}} + setApiConfigurationField={() => undefined} + {...props} + />, + ) + +describe("ApiOptions reasoning effort placement", () => { + beforeEach(() => { + useSelectedModelMock.mockImplementation((configuration: ProviderSettings) => ({ + provider: configuration.apiProvider, + id: configuration.apiModelId, + info: {}, + })) + }) + + it("does not render the generic ThinkingBudget for Ollama", () => { + // Ollama renders its own reasoning-effort control inside the provider + // component (gated by its "Enable thinking" checkbox), so the generic one + // must be skipped to avoid the duplicated "Model Reasoning Effort" selector. + renderApiOptions({ + apiConfiguration: { + apiProvider: providerIdentifiers.ollama, + ollamaModelId: "qwen3", + enableReasoningEffort: true, + reasoningEffort: "medium", + }, + }) + + expect(screen.getByTestId("provider-ollama")).toBeInTheDocument() + expect(screen.queryByTestId("thinking-budget")).not.toBeInTheDocument() + }) + + it("renders the generic ThinkingBudget for non-Ollama providers", () => { + renderApiOptions({ apiConfiguration: { apiProvider: providerIdentifiers.anthropic } }) + + expect(screen.getByTestId("thinking-budget")).toBeInTheDocument() + }) +}) diff --git a/webview-ui/src/components/settings/__tests__/ReasoningModeSelector.spec.tsx b/webview-ui/src/components/settings/__tests__/ReasoningModeSelector.spec.tsx new file mode 100644 index 0000000000..3ac4478a8c --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/ReasoningModeSelector.spec.tsx @@ -0,0 +1,155 @@ +// npx vitest src/components/settings/__tests__/ReasoningModeSelector.spec.tsx + +import React from "react" + +import { render, screen, fireEvent } from "@/utils/test-utils" + +import type { ModelInfo } from "@roo-code/types" + +import { ReasoningModeSelector } from "../ReasoningModeSelector" + +// Mock the Select primitives so we can drive selection without Radix deps. +vi.mock("@/components/ui", () => ({ + Select: ({ children, value, onValueChange }: any) => ( +
+ {React.Children.map(children, (child) => React.cloneElement(child, { onValueChange }))} +
+ ), + SelectTrigger: ({ children }: any) => , + SelectValue: ({ placeholder }: any) => {placeholder}, + SelectContent: ({ children, onValueChange }: any) => ( +
+ {React.Children.map(children, (child) => React.cloneElement(child, { onValueChange }))} +
+ ), + SelectItem: ({ children, value, onValueChange }: any) => ( +
onValueChange?.(value)}> + {children} +
+ ), +})) + +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ + t: (key: string) => key, + }), +})) + +describe("ReasoningModeSelector", () => { + const mockSetApiConfigurationField = vi.fn() + + const baseModelInfo: ModelInfo = { + contextWindow: 200000, + supportsPromptCache: true, + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it("renders nothing when the model does not support reasoning effort", () => { + render( + , + ) + + expect(screen.queryByTestId("reasoning-effort")).toBeNull() + expect(screen.queryByTestId("select")).toBeNull() + }) + + it("renders nothing when no model info is available", () => { + render( + , + ) + + expect(screen.queryByTestId("reasoning-effort")).toBeNull() + }) + + it("shows [disable, low, medium, high] when supportsReasoningEffort is boolean true", () => { + render( + , + ) + + const select = screen.getByTestId("select") + // default is "disable" + expect(select.getAttribute("data-value")).toBe("disable") + + expect(screen.getByTestId("select-item-disable")).toBeInTheDocument() + expect(screen.getByTestId("select-item-low")).toBeInTheDocument() + expect(screen.getByTestId("select-item-medium")).toBeInTheDocument() + expect(screen.getByTestId("select-item-high")).toBeInTheDocument() + // boolean true never synthesizes "max" + expect(screen.queryByTestId("select-item-max")).toBeNull() + }) + + it("shows exactly the advertised array values (e.g. Ollama thinking models)", () => { + render( + , + ) + + expect(screen.getByTestId("select-item-low")).toBeInTheDocument() + expect(screen.getByTestId("select-item-medium")).toBeInTheDocument() + expect(screen.getByTestId("select-item-high")).toBeInTheDocument() + expect(screen.getByTestId("select-item-max")).toBeInTheDocument() + // An explicit array must not auto-add a "disable" option. + expect(screen.queryByTestId("select-item-disable")).toBeNull() + }) + + it("selecting a non-disable effort enables reasoning and persists the effort", () => { + render( + , + ) + + // The mocked SelectItem wires onClick to the Select's onValueChange. + fireEvent.click(screen.getByTestId("select-item-high")) + + expect(mockSetApiConfigurationField).toHaveBeenCalledWith("enableReasoningEffort", true) + expect(mockSetApiConfigurationField).toHaveBeenCalledWith("reasoningEffort", "high") + }) + + it("selecting 'disable' turns reasoning off and persists the disable sentinel", () => { + render( + , + ) + + fireEvent.click(screen.getByTestId("select-item-disable")) + + expect(mockSetApiConfigurationField).toHaveBeenCalledWith("enableReasoningEffort", false) + expect(mockSetApiConfigurationField).toHaveBeenCalledWith("reasoningEffort", "disable") + }) + + it("reflects the currently persisted reasoning effort as the select value", () => { + render( + , + ) + + expect(screen.getByTestId("select").getAttribute("data-value")).toBe("medium") + }) +}) diff --git a/webview-ui/src/components/settings/providers/Ollama.tsx b/webview-ui/src/components/settings/providers/Ollama.tsx index 8d1e7348f4..0a013357db 100644 --- a/webview-ui/src/components/settings/providers/Ollama.tsx +++ b/webview-ui/src/components/settings/providers/Ollama.tsx @@ -12,6 +12,7 @@ import { import { useAppTranslation } from "@src/i18n/TranslationContext" import { useRouterModels } from "@src/components/ui/hooks/useRouterModels" +import { useSelectedModel } from "@src/components/ui/hooks/useSelectedModel" import { Button } from "@src/components/ui" import { vscode } from "@src/utils/vscode" @@ -39,6 +40,32 @@ export const Ollama = ({ apiConfiguration, setApiConfigurationField }: OllamaPro const [refreshError, setRefreshError] = useState() const refreshStatusRef = useRef(refreshStatus) const routerModels = useRouterModels() + // Use the same modelInfo source as the chat bar / top-of-tab selector so the + // reasoning-effort dropdown advertises the model's real levels (e.g. cloud + // ollama models expose ["low","medium","high","max"]). When the model info + // is unavailable, synthesize a fallback that exposes the effort levels the + // ollama native `think` parameter supports so the dropdown still works. + // "none" is always prepended for Ollama because ollama's native `think` + // parameter accepts it as an explicit reasoning level alongside low/med/ + // high/max, and users want to disable reasoning without removing the + // enableReasoningEffort flag. + const { info: selectedModelInfo } = useSelectedModel(apiConfiguration) + const reasoningModelInfo = selectedModelInfo?.supportsReasoningEffort + ? ({ + ...selectedModelInfo, + supportsReasoningEffort: Array.isArray(selectedModelInfo.supportsReasoningEffort) + ? selectedModelInfo.supportsReasoningEffort.includes("none") + ? selectedModelInfo.supportsReasoningEffort + : ([ + "none", + ...selectedModelInfo.supportsReasoningEffort, + ] as typeof selectedModelInfo.supportsReasoningEffort) + : selectedModelInfo.supportsReasoningEffort, + } as typeof selectedModelInfo) + : ({ + ...ollamaDefaultModelInfo, + supportsReasoningEffort: ["none", "low", "medium", "high"], + } as typeof selectedModelInfo) const handleInputChange = useCallback( ( @@ -224,21 +251,28 @@ export const Ollama = ({ apiConfiguration, setApiConfigurationField }: OllamaPro }}> {t("settings:providers.ollama.thinking")} -
- {t("settings:providers.ollama.thinkingHelp")} -
- {!!apiConfiguration.enableReasoningEffort && ( - + {/* Help text and the effort dropdown only appear when thinking is + enabled. The chat bar selector (in ChatTextArea) reads the same + enableReasoningEffort flag via useExtensionState, so unticking + the checkbox and saving collapses both surfaces together. */} + {apiConfiguration.enableReasoningEffort && ( + <> +
+ {t("settings:providers.ollama.thinkingHelp")} +
+ + )}
diff --git a/webview-ui/src/components/settings/providers/__tests__/Ollama.spec.tsx b/webview-ui/src/components/settings/providers/__tests__/Ollama.spec.tsx index a4e14b4d81..eefba55e40 100644 --- a/webview-ui/src/components/settings/providers/__tests__/Ollama.spec.tsx +++ b/webview-ui/src/components/settings/providers/__tests__/Ollama.spec.tsx @@ -62,6 +62,14 @@ vi.mock("@src/components/ui/hooks/useRouterModels", () => ({ useRouterModels: () => ({ data: {}, isLoading: false, error: null }), })) +const { useSelectedModelMock } = vi.hoisted(() => ({ + useSelectedModelMock: vi.fn(), +})) + +vi.mock("@src/components/ui/hooks/useSelectedModel", () => ({ + useSelectedModel: useSelectedModelMock, +})) + const { postMessageMock } = vi.hoisted(() => ({ postMessageMock: vi.fn(), })) @@ -89,6 +97,9 @@ describe("Ollama Component - thinking setting", () => { beforeEach(() => { vi.clearAllMocks() + // Default: no model info surfaced through useSelectedModel so the + // synthesized fallback (low/medium/high) is what we observe. + useSelectedModelMock.mockReturnValue({ provider: "ollama", id: "qwen3", info: undefined }) }) it("should render the thinking checkbox unchecked by default", () => { @@ -124,7 +135,9 @@ describe("Ollama Component - thinking setting", () => { expect(input.checked).toBe(true) }) - it("should render the thinking help text", () => { + it("should not render the thinking help text when thinking is disabled", () => { + // The help text only appears alongside the reasoning-effort dropdown, + // both of which are gated on `enableReasoningEffort`. Untick -> collapse. render( { />, ) + expect(screen.queryByText("settings:providers.ollama.thinkingHelp")).not.toBeInTheDocument() + }) + + it("should render the thinking help text when thinking is enabled", () => { + render( + , + ) + expect(screen.getByText("settings:providers.ollama.thinkingHelp")).toBeInTheDocument() }) @@ -201,9 +230,13 @@ describe("Ollama Component - thinking setting", () => { expect(mockSetApiConfigurationField).not.toHaveBeenCalledWith("reasoningEffort", expect.anything()) }) - it("should render ThinkingBudget with supportsReasoningEffort when thinking is enabled", () => { + it("should render ThinkingBudget with a synthesized low/medium/high fallback when no model info is loaded", () => { + // Default mock above returns info: undefined, so the fallback synthesis + // kicks in. The dropdown is also gated by enableReasoningEffort so we set + // that explicitly to make it render. const apiConfiguration: Partial = { enableReasoningEffort: true, + reasoningEffort: "medium", } render( @@ -215,7 +248,9 @@ describe("Ollama Component - thinking setting", () => { const thinkingBudget = screen.getByTestId("thinking-budget") expect(thinkingBudget).toBeInTheDocument() - expect(thinkingBudget.getAttribute("data-supports")).toBe("true") + // The fallback is `["none","low","medium","high"]` so users get None in + // the list alongside the model's effort levels. + expect(thinkingBudget.getAttribute("data-supports")).toBe("none,low,medium,high") }) it("should not render ThinkingBudget when thinking is disabled", () => { @@ -232,6 +267,62 @@ describe("Ollama Component - thinking setting", () => { expect(screen.queryByTestId("thinking-budget")).toBeNull() }) + + it("should pass the model's real supportsReasoningEffort array (with 'none' prepended) to ThinkingBudget when advertised", () => { + // When the selected model advertises effort levels (e.g. qwen3 on Ollama + // Cloud includes "max"), the settings selector surfaces those native + // levels with "none" prepended so users get the full None/low/med/high/ + // max list. + useSelectedModelMock.mockReturnValue({ + provider: "ollama", + id: "qwen3", + info: { supportsReasoningEffort: ["low", "medium", "high", "max"] }, + }) + + render( + , + ) + + const thinkingBudget = screen.getByTestId("thinking-budget") + expect(thinkingBudget.getAttribute("data-supports")).toBe("none,low,medium,high,max") + }) + + it("should not duplicate 'none' when the model already advertises it", () => { + // Idempotent: re-prepending "none" must not create a duplicate entry in + // the option list shown to the user. + useSelectedModelMock.mockReturnValue({ + provider: "ollama", + id: "qwen3", + info: { supportsReasoningEffort: ["none", "low", "medium", "high", "max"] }, + }) + + render( + , + ) + + const thinkingBudget = screen.getByTestId("thinking-budget") + expect(thinkingBudget.getAttribute("data-supports")).toBe("none,low,medium,high,max") + }) }) describe("Ollama Component - refresh models", () => { diff --git a/webview-ui/src/utils/reasoning-effort.ts b/webview-ui/src/utils/reasoning-effort.ts new file mode 100644 index 0000000000..4c63479309 --- /dev/null +++ b/webview-ui/src/utils/reasoning-effort.ts @@ -0,0 +1,83 @@ +import { type ModelInfo, type ProviderSettings, type ReasoningEffortExtended, reasoningEfforts } from "@roo-code/types" + +// "disable" turns reasoning off entirely; "none" is a real reasoning level. +// Both render with the same "None" label in the UI, and arrays from +// supportsReasoningEffort may include "disable" (e.g. Z.ai GLM). +export type ReasoningEffortOption = ReasoningEffortExtended | "disable" + +export interface ReasoningEffortSelection { + isReasoningEffortSupported: boolean + availableOptions: ReadonlyArray + currentReasoningEffort: ReasoningEffortOption + storedReasoningEffort: ReasoningEffortOption | undefined +} + +/** + * Computes the reasoning-effort dropdown state shared by every selector in the + * app (Providers settings tab, per-provider settings, chat input bar). All + * selectors must agree on the option set and clamping so they never show + * different values for the same stored field. + * + * Capability surface: + * - modelInfo.supportsReasoningEffort: true → options ["low","medium","high"] + * - array → options are exactly the provided values + * - "disable" is prepended only when supportsReasoningEffort is boolean true + * and requiredReasoningEffort is not set; explicit arrays are respected as-is. + * + * The stored value is clamped to the option set so the trigger always renders + * a valid option. + */ +export function getReasoningEffortSelection( + apiConfiguration: ProviderSettings | undefined, + modelInfo: ModelInfo | undefined, +): ReasoningEffortSelection { + const isReasoningEffortSupported = !!modelInfo && !!modelInfo.supportsReasoningEffort + + const supports = modelInfo?.supportsReasoningEffort + const baseAvailableOptions: ReadonlyArray = + supports === true + ? (reasoningEfforts as readonly ReasoningEffortOption[]) + : Array.isArray(supports) + ? (supports as ReadonlyArray) + : (reasoningEfforts as readonly ReasoningEffortOption[]) + + // Add "disable" option only when: + // 1. requiredReasoningEffort is not true, AND + // 2. supportsReasoningEffort is boolean true (not an explicit array) + // When the model provides an explicit array, respect those exact values. + const shouldAutoAddDisable = + !modelInfo?.requiredReasoningEffort && supports === true && !baseAvailableOptions.includes("disable") + const availableOptions: ReadonlyArray = shouldAutoAddDisable + ? ["disable", ...baseAvailableOptions] + : baseAvailableOptions + + // Default reasoning effort - use model's default if available + // GPT-5 models have "medium" as their default in the model configuration + const modelDefaultReasoningEffort = modelInfo?.reasoningEffort as ReasoningEffortExtended | undefined + const defaultReasoningEffort: ReasoningEffortOption = modelInfo?.requiredReasoningEffort + ? modelDefaultReasoningEffort || "medium" + : "disable" + // Current reasoning effort from settings, or fall back to default. + // Clamp to availableOptions so the Select trigger always renders a valid option. + const storedReasoningEffort = apiConfiguration?.reasoningEffort as ReasoningEffortOption | undefined + const rawReasoningEffort: ReasoningEffortOption = storedReasoningEffort || defaultReasoningEffort + const fallbackReasoningEffort = availableOptions.includes(defaultReasoningEffort) + ? defaultReasoningEffort + : (availableOptions[0] ?? rawReasoningEffort) + const currentReasoningEffort: ReasoningEffortOption = availableOptions.includes(rawReasoningEffort) + ? rawReasoningEffort + : fallbackReasoningEffort + + return { isReasoningEffortSupported, availableOptions, currentReasoningEffort, storedReasoningEffort } +} + +/** + * Maps a reasoning-effort option to its translation key. Both "disable" and + * "none" display as "None" per UX, but "disable" omits reasoning parameters + * while "none" sends an explicit none level. + */ +export function getReasoningEffortTranslationKey(option: ReasoningEffortOption): string { + return option === "none" || option === "disable" + ? "settings:providers.reasoningEffort.none" + : `settings:providers.reasoningEffort.${option}` +} From a31a5860544550353cb0574f8a7c5b2ef0990776 Mon Sep 17 00:00:00 2001 From: Asadur Date: Sun, 23 Aug 2026 00:24:19 +0600 Subject: [PATCH 2/5] refactor(ollama): model-specific thinking efforts, shared selector options, a11y + visual tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fetcher advertises per-model effort arrays: gpt-oss gets ["low","medium","high"] (it rejects "max" and ignores think: false), other thinking models get ["disable","low","medium","high","max"] - Chat and settings share one Ollama normalization (getOllamaReasoningModelInfo); the UI no longer prepends "none" — off-support comes from the fetcher's "disable" sentinel - Selector options become keyboard-navigable buttons (listbox pattern); specs use typed mocks - Add Playwright visual baselines for the chat toolbar (dark/light, default/narrow) with the new selector --- .../fetchers/__tests__/ollama.test.ts | 100 ++++++- src/api/providers/fetchers/ollama.ts | 89 +++++- .../chat/ReasoningEffortSelector.tsx | 148 ++++++---- .../__tests__/ChatTextArea.visual.fixture.tsx | 131 +++++++++ .../chat/__tests__/ChatTextArea.visual.tsx | 84 ++++++ .../ReasoningEffortSelector.spec.tsx | 254 +++++++++++++++--- .../chat-toolbar-default-dark.png | Bin 0 -> 5438 bytes .../chat-toolbar-default-light.png | Bin 0 -> 4926 bytes .../chat-toolbar-narrow-dark.png | Bin 0 -> 4663 bytes .../chat-toolbar-narrow-light.png | Bin 0 -> 3930 bytes .../__tests__/ReasoningModeSelector.spec.tsx | 100 +++++-- .../components/settings/providers/Ollama.tsx | 67 ++--- .../providers/__tests__/Ollama.spec.tsx | 48 ++-- webview-ui/src/utils/reasoning-effort.ts | 61 ++++- 14 files changed, 897 insertions(+), 185 deletions(-) create mode 100644 webview-ui/src/components/chat/__tests__/ChatTextArea.visual.fixture.tsx create mode 100644 webview-ui/src/components/chat/__tests__/ChatTextArea.visual.tsx create mode 100644 webview-ui/src/components/chat/__tests__/__screenshots__/chat-toolbar-default-dark.png create mode 100644 webview-ui/src/components/chat/__tests__/__screenshots__/chat-toolbar-default-light.png create mode 100644 webview-ui/src/components/chat/__tests__/__screenshots__/chat-toolbar-narrow-dark.png create mode 100644 webview-ui/src/components/chat/__tests__/__screenshots__/chat-toolbar-narrow-light.png diff --git a/src/api/providers/fetchers/__tests__/ollama.test.ts b/src/api/providers/fetchers/__tests__/ollama.test.ts index bd3081cccc..25e29053ff 100644 --- a/src/api/providers/fetchers/__tests__/ollama.test.ts +++ b/src/api/providers/fetchers/__tests__/ollama.test.ts @@ -116,6 +116,12 @@ describe("Ollama Fetcher", () => { }) 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"], @@ -124,12 +130,100 @@ describe("Ollama Fetcher", () => { const parsedModel = parseOllamaModel(modelDataWithThinking as Parameters[0]) expect(parsedModel).not.toBeNull() - // Ollama Cloud accepts low/medium/high/max and rejects "xhigh", so the - // selector must surface exactly those native effort levels. - expect(parsedModel!.supportsReasoningEffort).toEqual(["low", "medium", "high", "max"]) + 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"], diff --git a/src/api/providers/fetchers/ollama.ts b/src/api/providers/fetchers/ollama.ts index 15899b9103..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 @@ -60,12 +127,20 @@ export const parseOllamaModel = (rawModel: OllamaModelInfoResponse): ModelInfo | }) // Models that advertise the "thinking" capability expose a native - // reasoning-effort control. Ollama Cloud accepts low/medium/high/max and - // rejects "xhigh", so advertise exactly those values (matching the user's - // API verification) so the reasoning selector shows the model's real options - // instead of falling back to the generic low/medium/high defaults. + // 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 = ["low", "medium", "high", "max"] + modelInfo.supportsReasoningEffort = [...getOllamaThinkingEfforts(rawModel, modelId)] modelInfo.reasoningEffort = "medium" } @@ -108,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/webview-ui/src/components/chat/ReasoningEffortSelector.tsx b/webview-ui/src/components/chat/ReasoningEffortSelector.tsx index 33ec9cfcb9..4daf249874 100644 --- a/webview-ui/src/components/chat/ReasoningEffortSelector.tsx +++ b/webview-ui/src/components/chat/ReasoningEffortSelector.tsx @@ -8,15 +8,18 @@ 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`, so the values shown here always match Settings. -For Ollama, the synthesized model info prepends "none" so the dropdown always -lists None alongside the model's advertised effort levels (e.g. -low/medium/high/max for cloud ollama models). +`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, useMemo, useState } from "react" +import { useCallback, useMemo, useRef, useState } from "react" -import { type ModelInfo, ollamaDefaultModelInfo, providerIdentifiers } from "@roo-code/types" +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" @@ -25,6 +28,7 @@ import { useAppTranslation } from "@src/i18n/TranslationContext" import { useExtensionState } from "@src/context/ExtensionStateContext" import { useSelectedModel } from "@src/components/ui/hooks/useSelectedModel" import { + getOllamaReasoningModelInfo, getReasoningEffortSelection, getReasoningEffortTranslationKey, type ReasoningEffortOption, @@ -42,32 +46,16 @@ export const ReasoningEffortSelector = ({ disabled = false, triggerClassName = " const { provider, info: selectedModelInfo } = useSelectedModel(apiConfiguration) const [open, setOpen] = useState(false) const portalContainer = useRooPortal("roo-portal") + const listRef = useRef(null) - // Build the modelInfo the same way the Ollama settings page does, so the - // chat dropdown lists exactly the same options as the settings dropdown. - // For Ollama, prepend "none" so users can pick "None" alongside the model's - // advertised effort levels. For other providers, fall through to whatever - // the selected model advertises (the selector stays hidden when nothing - // advertises `supportsReasoningEffort`). + // 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) { - if (selectedModelInfo?.supportsReasoningEffort) { - const advertised = selectedModelInfo.supportsReasoningEffort - return { - ...selectedModelInfo, - supportsReasoningEffort: Array.isArray(advertised) - ? advertised.includes("none") - ? advertised - : (["none", ...advertised] as typeof advertised) - : advertised, - } - } - - // No advertised info yet (router models still loading, or local model - // without thinking metadata). Fall back to a synthesized modelInfo - // exposing the levels Ollama's native `think` parameter supports so - // the selector can render immediately. - return { ...ollamaDefaultModelInfo, supportsReasoningEffort: ["none", "low", "medium", "high"] } + return getOllamaReasoningModelInfo(selectedModelInfo) } return selectedModelInfo @@ -86,11 +74,15 @@ export const ReasoningEffortSelector = ({ disabled = false, triggerClassName = " // Write only `reasoningEffort`. The Enable Thinking checkbox in the // settings page owns `enableReasoningEffort` independently, so the chat - // selector never flips it. Picking "None" in chat keeps - // enableReasoningEffort as-is and stores reasoningEffort: "none", - // which `getOllamaThinkParam()` translates to `think: true` with - // `reasoning: "none"` (an explicit "no reasoning level" choice that - // ollama accepts and that the settings dropdown shows verbatim). + // 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, @@ -104,6 +96,37 @@ export const ReasoningEffortSelector = ({ disabled = false, triggerClassName = " [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 new file mode 100644 index 0000000000..f8e96f6555 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/ChatTextArea.visual.fixture.tsx @@ -0,0 +1,131 @@ +/* v8 ignore file -- Playwright component fixture is covered by the visual test. */ +import React from "react" + +import { TranslationContext } from "@src/i18n/TranslationContext" +import { TooltipProvider } from "@src/components/ui/tooltip" + +// 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, tuned for the +// compact toolbar (min-width + ellipsis + flex-shrink so the row overflows +// gracefully at narrow widths). ModeSelector uses flex-shrink-0 in production, +// but the narrow-width snapshot needs to show truncation across the whole row +// (mode truncated, then bits of api-config and reasoning peeking through) — so +// the fixture gives the mode trigger the same shrinkable treatment as its +// siblings to exercise the overflow layout meaningfully. +const MODE_TRIGGER = `${TRIGGER_BASE} min-w-0 text-ellipsis overflow-hidden flex-shrink` +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" + +// 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) => ( + translations[key] ?? key, + i18n: null as unknown as typeof import("../../../i18n/setup").default, + }}> + +
+
+ + + + +
+
+ + +
+
+
+
+) diff --git a/webview-ui/src/components/chat/__tests__/ChatTextArea.visual.tsx b/webview-ui/src/components/chat/__tests__/ChatTextArea.visual.tsx new file mode 100644 index 0000000000..2913b97bc9 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/ChatTextArea.visual.tsx @@ -0,0 +1,84 @@ +import React from "react" + +import { expect, test } from "../../../../playwright/coverage-fixture" +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", + editorBackground: "#1e1e1e", + }, + { + name: "light", + bodyClass: "vscode-light", + themeId: "Default Light Modern", + editorBackground: "#ffffff", + }, +] 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) + + await expect + .poll(() => + trigger.evaluate((element) => { + const body = element.ownerDocument.body + const styles = getComputedStyle(body) + return { + documentClass: element.ownerDocument.documentElement.className, + editorBackground: styles.getPropertyValue("--vscode-editor-background").trim(), + } + }), + ) + .toEqual({ + documentClass: theme.bodyClass, + editorBackground: theme.editorBackground, + }) + + 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 index a9f99e1dd9..3204393a72 100644 --- a/webview-ui/src/components/chat/__tests__/ReasoningEffortSelector.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ReasoningEffortSelector.spec.tsx @@ -4,19 +4,38 @@ 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" as string | undefined, - apiConfiguration: {} as Record, - }, + currentApiConfigName: "default", + apiConfiguration: {} as ProviderSettings, + } as ExtensionStateShape, selectedModel: { - provider: "ollama" as string, - id: "qwen3" as string | undefined, - info: undefined as Record | undefined, - }, + provider: "ollama", + id: "qwen3", + info: undefined as ModelInfo | undefined, + } as SelectedModelShape, })) vi.mock("@src/utils/vscode", () => ({ @@ -42,18 +61,37 @@ vi.mock("@src/components/ui/hooks/useSelectedModel", () => ({ })) // 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 }: { children?: ReactNode; open?: boolean }) => ( -
+ Popover: ({ children, open, ...rest }: PopoverMockProps) => ( +
{children}
), - PopoverTrigger: ({ children, disabled, ...props }: { children?: ReactNode; disabled?: boolean }) => ( + PopoverTrigger: ({ children, disabled, ...props }: PopoverTriggerMockProps) => ( ), - PopoverContent: ({ children }: { children?: ReactNode }) =>
{children}
, + PopoverContent: ({ children }: PopoverContentMockProps) =>
{children}
, StandardTooltip: ({ children }: { children?: ReactNode }) => <>{children}, })) @@ -61,7 +99,10 @@ describe("ReasoningEffortSelector", () => { beforeEach(() => { vi.clearAllMocks() extensionState.currentApiConfigName = "default" - extensionState.apiConfiguration = { apiProvider: "ollama", ollamaModelId: "qwen3" } + extensionState.apiConfiguration = { + apiProvider: "ollama", + ollamaModelId: "qwen3", + } selectedModel.provider = "ollama" selectedModel.id = "qwen3" selectedModel.info = undefined @@ -69,7 +110,7 @@ describe("ReasoningEffortSelector", () => { it("renders nothing for non-Ollama providers without advertised reasoning effort", () => { selectedModel.provider = "anthropic" - selectedModel.info = {} + selectedModel.info = { contextWindow: 200000, supportsPromptCache: true } render() @@ -77,7 +118,14 @@ describe("ReasoningEffortSelector", () => { }) it("shows the stored effort for an ollama model that advertises levels including max", () => { - selectedModel.info = { supportsReasoningEffort: ["low", "medium", "high", "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", @@ -93,10 +141,11 @@ describe("ReasoningEffortSelector", () => { expect(screen.getByTestId("reasoning-effort-option-max")).toBeInTheDocument() }) - it("always lists None as an option for ollama, regardless of model info", () => { - // No advertised info yet (router still loading). Selector should still - // render with the synthesized [None, low, medium, high] set so the chat - // bar is usable immediately on app boot. + 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", @@ -107,31 +156,43 @@ describe("ReasoningEffortSelector", () => { render() expect(screen.getByTestId("reasoning-effort-trigger")).toBeInTheDocument() - expect(screen.getByTestId("reasoning-effort-option-none")).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("prepends None to a model's advertised options when missing", () => { - // Some ollama cloud models advertise ["low","medium","high","max"] but - // not "none". The chat selector prepends "none" so users can pick it. - selectedModel.info = { supportsReasoningEffort: ["low", "medium", "high", "max"] } + 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: "qwen3", + ollamaModelId: "gpt-oss:20b", reasoningEffort: "medium", enableReasoningEffort: true, } render() - expect(screen.getByTestId("reasoning-effort-option-none")).toBeInTheDocument() - expect(screen.getByTestId("reasoning-effort-option-max")).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() + 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 = { supportsReasoningEffort: ["low", "medium", "high", "max"] } + selectedModel.info = { + contextWindow: 40960, + supportsPromptCache: true, + supportsReasoningEffort: ["disable", "low", "medium", "high", "max"], + } extensionState.apiConfiguration = { apiProvider: "ollama", ollamaModelId: "qwen3", @@ -154,12 +215,18 @@ describe("ReasoningEffortSelector", () => { }) }) - it("stores reasoningEffort: 'none' when None is selected without flipping enableReasoningEffort", () => { + 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 here mirrors what the settings - // dropdown does (same field, same value). - selectedModel.info = { supportsReasoningEffort: ["low", "medium", "high", "max"] } + // 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", @@ -168,13 +235,13 @@ describe("ReasoningEffortSelector", () => { } render() - fireEvent.click(screen.getByTestId("reasoning-effort-option-none")) + fireEvent.click(screen.getByTestId("reasoning-effort-option-disable")) expect(postMessageMock).toHaveBeenCalledWith( expect.objectContaining({ type: "upsertApiConfiguration", apiConfiguration: expect.objectContaining({ - reasoningEffort: "none", + reasoningEffort: "disable", enableReasoningEffort: true, }), }), @@ -200,7 +267,11 @@ describe("ReasoningEffortSelector", () => { // 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 = { supportsReasoningEffort: ["low", "medium", "high", "max"] } + selectedModel.info = { + contextWindow: 40960, + supportsPromptCache: true, + supportsReasoningEffort: ["disable", "low", "medium", "high", "max"], + } extensionState.apiConfiguration = { apiProvider: "ollama", ollamaModelId: "qwen3", @@ -216,7 +287,11 @@ describe("ReasoningEffortSelector", () => { 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 = { supportsReasoningEffort: true } + selectedModel.info = { + contextWindow: 40960, + supportsPromptCache: true, + supportsReasoningEffort: true, + } extensionState.apiConfiguration = { apiProvider: "ollama", ollamaModelId: "qwen3" } render() @@ -227,7 +302,12 @@ describe("ReasoningEffortSelector", () => { 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 = { supportsReasoningEffort: true, requiredReasoningEffort: true } + selectedModel.info = { + contextWindow: 256000, + supportsPromptCache: true, + supportsReasoningEffort: true, + requiredReasoningEffort: true, + } extensionState.apiConfiguration = { apiProvider: "ollama", ollamaModelId: "kimi-k2" } render() @@ -238,7 +318,11 @@ describe("ReasoningEffortSelector", () => { it("does not persist when there is no active profile", () => { extensionState.currentApiConfigName = undefined selectedModel.provider = "ollama" - selectedModel.info = { supportsReasoningEffort: ["low", "medium", "high", "max"] } + selectedModel.info = { + contextWindow: 40960, + supportsPromptCache: true, + supportsReasoningEffort: ["disable", "low", "medium", "high", "max"], + } extensionState.apiConfiguration = { apiProvider: "ollama", ollamaModelId: "qwen3", @@ -251,4 +335,100 @@ describe("ReasoningEffortSelector", () => { 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 , - SelectValue: ({ placeholder }: any) => {placeholder}, - SelectContent: ({ children, onValueChange }: any) => ( + SelectTrigger: ({ children }: SelectTriggerMockProps) => , + SelectValue: ({ placeholder }: SelectValueMockProps) => {placeholder}, + SelectContent: ({ children, onValueChange }: SelectContentMockProps) => (
- {React.Children.map(children, (child) => React.cloneElement(child, { onValueChange }))} + {React.Children.map(children, (child) => + React.isValidElement(child) + ? React.cloneElement(child as React.ReactElement, { onValueChange }) + : child, + )}
), - SelectItem: ({ children, value, onValueChange }: any) => ( + SelectItem: ({ children, value, onValueChange }: SelectItemMockProps) => (
onValueChange?.(value)}> {children}
@@ -35,22 +69,27 @@ vi.mock("@src/i18n/TranslationContext", () => ({ }), })) +// Minimal typed ModelInfo fixture. ProviderSettings and ModelInfo are Zod +// objects whose fields are all optional, so an empty object is a valid value — +// no `as any` is needed. Spread in capability overrides per scenario. +const baseModelInfo: ModelInfo = { + contextWindow: 200000, + supportsPromptCache: true, +} + describe("ReasoningModeSelector", () => { const mockSetApiConfigurationField = vi.fn() - const baseModelInfo: ModelInfo = { - contextWindow: 200000, - supportsPromptCache: true, - } - beforeEach(() => { vi.clearAllMocks() }) it("renders nothing when the model does not support reasoning effort", () => { + const apiConfiguration: ProviderSettings = {} + render( , @@ -61,9 +100,11 @@ describe("ReasoningModeSelector", () => { }) it("renders nothing when no model info is available", () => { + const apiConfiguration: ProviderSettings = {} + render( , @@ -73,9 +114,11 @@ describe("ReasoningModeSelector", () => { }) it("shows [disable, low, medium, high] when supportsReasoningEffort is boolean true", () => { + const apiConfiguration: ProviderSettings = {} + render( , @@ -94,9 +137,15 @@ describe("ReasoningModeSelector", () => { }) it("shows exactly the advertised array values (e.g. Ollama thinking models)", () => { + // The fetcher advertises a verbatim array including "disable" for models + // that honor think: false (qwen3) and omitting it for models that don't + // (gpt-oss). The selector must surface the array as-is — no "none" + // prepend, no auto-added "disable" for explicit arrays. + const apiConfiguration: ProviderSettings = {} + render( , @@ -111,9 +160,11 @@ describe("ReasoningModeSelector", () => { }) it("selecting a non-disable effort enables reasoning and persists the effort", () => { + const apiConfiguration: ProviderSettings = {} + render( , @@ -127,9 +178,14 @@ describe("ReasoningModeSelector", () => { }) it("selecting 'disable' turns reasoning off and persists the disable sentinel", () => { + const apiConfiguration: ProviderSettings = { + enableReasoningEffort: true, + reasoningEffort: "high", + } + render( , @@ -142,9 +198,13 @@ describe("ReasoningModeSelector", () => { }) it("reflects the currently persisted reasoning effort as the select value", () => { + const apiConfiguration: ProviderSettings = { + reasoningEffort: "medium", + } + render( , diff --git a/webview-ui/src/components/settings/providers/Ollama.tsx b/webview-ui/src/components/settings/providers/Ollama.tsx index 0a013357db..af4e2dce9b 100644 --- a/webview-ui/src/components/settings/providers/Ollama.tsx +++ b/webview-ui/src/components/settings/providers/Ollama.tsx @@ -14,6 +14,7 @@ import { useAppTranslation } from "@src/i18n/TranslationContext" import { useRouterModels } from "@src/components/ui/hooks/useRouterModels" import { useSelectedModel } from "@src/components/ui/hooks/useSelectedModel" import { Button } from "@src/components/ui" +import { getOllamaReasoningModelInfo } from "@src/utils/reasoning-effort" import { vscode } from "@src/utils/vscode" import { inputEventTransform } from "../transforms" @@ -41,31 +42,15 @@ export const Ollama = ({ apiConfiguration, setApiConfigurationField }: OllamaPro const refreshStatusRef = useRef(refreshStatus) const routerModels = useRouterModels() // Use the same modelInfo source as the chat bar / top-of-tab selector so the - // reasoning-effort dropdown advertises the model's real levels (e.g. cloud - // ollama models expose ["low","medium","high","max"]). When the model info - // is unavailable, synthesize a fallback that exposes the effort levels the - // ollama native `think` parameter supports so the dropdown still works. - // "none" is always prepended for Ollama because ollama's native `think` - // parameter accepts it as an explicit reasoning level alongside low/med/ - // high/max, and users want to disable reasoning without removing the - // enableReasoningEffort flag. + // reasoning-effort dropdown advertises the model's real levels. The shared + // `getOllamaReasoningModelInfo` helper is the single Ollama normalization: + // it passes the model's advertised capability array through verbatim (the + // fetcher already includes "disable" for models that honor think: false and + // omits it for models that don't, e.g. gpt-oss), and falls back to a + // synthesized [disable, low, medium, high] set when no model info has loaded + // yet so the dropdown is still usable on app boot. No UI-side "none" prepend. const { info: selectedModelInfo } = useSelectedModel(apiConfiguration) - const reasoningModelInfo = selectedModelInfo?.supportsReasoningEffort - ? ({ - ...selectedModelInfo, - supportsReasoningEffort: Array.isArray(selectedModelInfo.supportsReasoningEffort) - ? selectedModelInfo.supportsReasoningEffort.includes("none") - ? selectedModelInfo.supportsReasoningEffort - : ([ - "none", - ...selectedModelInfo.supportsReasoningEffort, - ] as typeof selectedModelInfo.supportsReasoningEffort) - : selectedModelInfo.supportsReasoningEffort, - } as typeof selectedModelInfo) - : ({ - ...ollamaDefaultModelInfo, - supportsReasoningEffort: ["none", "low", "medium", "high"], - } as typeof selectedModelInfo) + const reasoningModelInfo = getOllamaReasoningModelInfo(selectedModelInfo, ollamaDefaultModelInfo) const handleInputChange = useCallback( ( @@ -233,19 +218,23 @@ export const Ollama = ({ apiConfiguration, setApiConfigurationField }: OllamaPro if (checked) { // Restore the last selected effort level if one was - // previously chosen; otherwise default to "medium" so - // the request actually enables Ollama's native think - // parameter. Without a value, the ThinkingBudget Select - // would show "None" (disable) and getOllamaThinkParam() - // would return undefined, sending no think parameter - // despite the checkbox being on. Preserving the prior - // value avoids wiping the user's effort choice when + // previously chosen; otherwise default to "medium" so the + // request actually sends Ollama's native `think` parameter. + // The request handler reads the *stored* `reasoningEffort` + // (not the clamped display value), and when it is `undefined` + // it returns `undefined` (no think param) — leaving the + // model/Modelfile to decide whether to think, rather than + // explicitly enabling it. Defaulting to "medium" sends an + // explicit `think: "medium"`. Ollama has no native string + // "none" thinking level; "disable" is a UI-only sentinel that + // the handler maps to `think: false`. Preserving the prior + // value also avoids wiping the user's effort choice when // toggling the checkbox off and back on. setApiConfigurationField("reasoningEffort", apiConfiguration.reasoningEffort ?? "medium") } // When unchecked, leave reasoningEffort untouched so the // user's prior selection is preserved across toggles. The - // handler gates on enableReasoningEffort === true, so a + // request handler gates on enableReasoningEffort === true, so a // stale reasoningEffort value will not emit a think param // while the checkbox is off. }}> @@ -263,13 +252,13 @@ export const Ollama = ({ apiConfiguration, setApiConfigurationField }: OllamaPro diff --git a/webview-ui/src/components/settings/providers/__tests__/Ollama.spec.tsx b/webview-ui/src/components/settings/providers/__tests__/Ollama.spec.tsx index eefba55e40..0a0db06deb 100644 --- a/webview-ui/src/components/settings/providers/__tests__/Ollama.spec.tsx +++ b/webview-ui/src/components/settings/providers/__tests__/Ollama.spec.tsx @@ -230,10 +230,12 @@ describe("Ollama Component - thinking setting", () => { expect(mockSetApiConfigurationField).not.toHaveBeenCalledWith("reasoningEffort", expect.anything()) }) - it("should render ThinkingBudget with a synthesized low/medium/high fallback when no model info is loaded", () => { + it("should render ThinkingBudget with a synthesized disable/low/medium/high fallback when no model info is loaded", () => { // Default mock above returns info: undefined, so the fallback synthesis // kicks in. The dropdown is also gated by enableReasoningEffort so we set - // that explicitly to make it render. + // that explicitly to make it render. The fallback includes "disable" (the + // UI sentinel for think: false) rather than a fake "none" level, because + // Ollama has no native string "none" thinking level. const apiConfiguration: Partial = { enableReasoningEffort: true, reasoningEffort: "medium", @@ -248,9 +250,9 @@ describe("Ollama Component - thinking setting", () => { const thinkingBudget = screen.getByTestId("thinking-budget") expect(thinkingBudget).toBeInTheDocument() - // The fallback is `["none","low","medium","high"]` so users get None in - // the list alongside the model's effort levels. - expect(thinkingBudget.getAttribute("data-supports")).toBe("none,low,medium,high") + // The fallback is `["disable","low","medium","high"]` so users get None + // (the disable sentinel) in the list alongside the effort levels. + expect(thinkingBudget.getAttribute("data-supports")).toBe("disable,low,medium,high") }) it("should not render ThinkingBudget when thinking is disabled", () => { @@ -268,15 +270,17 @@ describe("Ollama Component - thinking setting", () => { expect(screen.queryByTestId("thinking-budget")).toBeNull() }) - it("should pass the model's real supportsReasoningEffort array (with 'none' prepended) to ThinkingBudget when advertised", () => { - // When the selected model advertises effort levels (e.g. qwen3 on Ollama - // Cloud includes "max"), the settings selector surfaces those native - // levels with "none" prepended so users get the full None/low/med/high/ - // max list. + it("should pass the model's real supportsReasoningEffort array verbatim (no 'none' prepend) to ThinkingBudget when advertised", () => { + // The fetcher includes "disable" in the advertised array for models that + // honor think: false, so off-support is part of the capability array the + // selector respects verbatim — there is no UI-side "none" prepend. For a + // qwen3-style model the advertised array is + // ["disable","low","medium","high","max"] and the settings selector + // surfaces exactly that. useSelectedModelMock.mockReturnValue({ provider: "ollama", id: "qwen3", - info: { supportsReasoningEffort: ["low", "medium", "high", "max"] }, + info: { supportsReasoningEffort: ["disable", "low", "medium", "high", "max"] }, }) render( @@ -294,16 +298,18 @@ describe("Ollama Component - thinking setting", () => { ) const thinkingBudget = screen.getByTestId("thinking-budget") - expect(thinkingBudget.getAttribute("data-supports")).toBe("none,low,medium,high,max") + expect(thinkingBudget.getAttribute("data-supports")).toBe("disable,low,medium,high,max") }) - it("should not duplicate 'none' when the model already advertises it", () => { - // Idempotent: re-prepending "none" must not create a duplicate entry in - // the option list shown to the user. + it("should pass a gpt-oss advertised array verbatim, with no 'disable' option", () => { + // gpt-oss ignores think: false, so reasoning cannot be disabled; the + // fetcher omits "disable" and "max" and advertises exactly + // ["low","medium","high"]. The selector must surface that verbatim and + // must not inject a "disable"/"none" option that the model can't honor. useSelectedModelMock.mockReturnValue({ provider: "ollama", - id: "qwen3", - info: { supportsReasoningEffort: ["none", "low", "medium", "high", "max"] }, + id: "gpt-oss:20b", + info: { supportsReasoningEffort: ["low", "medium", "high"] }, }) render( @@ -311,9 +317,9 @@ describe("Ollama Component - thinking setting", () => { apiConfiguration={ { apiProvider: "ollama", - ollamaModelId: "qwen3", + ollamaModelId: "gpt-oss:20b", enableReasoningEffort: true, - reasoningEffort: "max", + reasoningEffort: "medium", } as ProviderSettings } setApiConfigurationField={mockSetApiConfigurationField} @@ -321,7 +327,9 @@ describe("Ollama Component - thinking setting", () => { ) const thinkingBudget = screen.getByTestId("thinking-budget") - expect(thinkingBudget.getAttribute("data-supports")).toBe("none,low,medium,high,max") + expect(thinkingBudget.getAttribute("data-supports")).toBe("low,medium,high") + // No disable/none option for gpt-oss (it ignores think: false) + expect(thinkingBudget.getAttribute("data-supports")).not.toContain("disable") }) }) diff --git a/webview-ui/src/utils/reasoning-effort.ts b/webview-ui/src/utils/reasoning-effort.ts index 4c63479309..767b5bae99 100644 --- a/webview-ui/src/utils/reasoning-effort.ts +++ b/webview-ui/src/utils/reasoning-effort.ts @@ -1,10 +1,21 @@ -import { type ModelInfo, type ProviderSettings, type ReasoningEffortExtended, reasoningEfforts } from "@roo-code/types" +import type { ModelInfo } from "@roo-code/types/model" +import type { ProviderSettings } from "@roo-code/types" +import type { ReasoningEffortExtended } from "@roo-code/types/model" // "disable" turns reasoning off entirely; "none" is a real reasoning level. // Both render with the same "None" label in the UI, and arrays from // supportsReasoningEffort may include "disable" (e.g. Z.ai GLM). export type ReasoningEffortOption = ReasoningEffortExtended | "disable" +// The base effort levels for `supportsReasoningEffort === true`. Inlined here +// (rather than imported from `@roo-code/types/model`) because that module +// evaluates a Zod schema at import time, which the Playwright CT Vite build +// externalizes (`z` is not defined at runtime). Keeping this a local const makes +// the util CT-safe for visual tests that render ReasoningEffortSelector, while +// staying behavior-identical to the exported `reasoningEfforts` constant — if +// the source set changes in `packages/types/src/model.ts`, update this too. +const BASE_REASONING_EFFORTS = ["low", "medium", "high"] as const + export interface ReasoningEffortSelection { isReasoningEffortSupported: boolean availableOptions: ReadonlyArray @@ -36,10 +47,10 @@ export function getReasoningEffortSelection( const supports = modelInfo?.supportsReasoningEffort const baseAvailableOptions: ReadonlyArray = supports === true - ? (reasoningEfforts as readonly ReasoningEffortOption[]) + ? (BASE_REASONING_EFFORTS as readonly ReasoningEffortOption[]) : Array.isArray(supports) ? (supports as ReadonlyArray) - : (reasoningEfforts as readonly ReasoningEffortOption[]) + : (BASE_REASONING_EFFORTS as readonly ReasoningEffortOption[]) // Add "disable" option only when: // 1. requiredReasoningEffort is not true, AND @@ -71,6 +82,50 @@ export function getReasoningEffortSelection( return { isReasoningEffortSupported, availableOptions, currentReasoningEffort, storedReasoningEffort } } +/** + * Builds the `ModelInfo` the Ollama settings page and chat selector feed into + * `getReasoningEffortSelection`. This is the single shared Ollama normalization + * so both surfaces advertise the same options and can't drift. + * + * Ollama reasoning levels are model-specific (see the fetcher's + * `getOllamaThinkingEfforts`). When the selected model has already advertised a + * capability array (e.g. qwen3 → ["disable","low","medium","high","max"], + * gpt-oss → ["low","medium","high"]), it is passed through verbatim — no UI-side + * "none"/"disable" prepend. The fetcher is responsible for including "disable" + * in that array only for models that honor `think: false`, so off-support is + * modeled explicitly per model rather than bolted on in the UI. + * + * When no model info has loaded yet (router still loading, or a local model + * without thinking metadata), synthesize a fallback exposing the levels the + * Ollama native `think` parameter supports by default, including "disable" so + * the dropdown is usable immediately on app boot. The fallback is + * self-contained (it does not import `ollamaDefaultModelInfo`, which lives in a + * Zod-evaluating module) so this util stays Playwright-CT-safe for visual tests + * that render ReasoningEffortSelector. `defaultModelInfo` is optional and only + * spread when a caller (e.g. the settings page, which already imports it) wants + * to preserve the full default ModelInfo shape. + */ +export function getOllamaReasoningModelInfo( + selectedModelInfo: ModelInfo | undefined, + defaultModelInfo?: ModelInfo, +): ModelInfo { + if (selectedModelInfo?.supportsReasoningEffort) { + // Preserve the advertised array exactly; do not prepend "none" or + // "disable". The fetcher already includes "disable" for models that + // honor think: false and omits it for models that don't (gpt-oss). + return selectedModelInfo + } + + return { + // Minimal fallback shape for the reasoning selector; matches the + // reasoning-relevant fields of ollamaDefaultModelInfo without importing + // the Zod-evaluating providers/ollama module. + contextWindow: defaultModelInfo?.contextWindow ?? 200_000, + supportsPromptCache: defaultModelInfo?.supportsPromptCache ?? true, + supportsReasoningEffort: ["disable", "low", "medium", "high"], + } +} + /** * Maps a reasoning-effort option to its translation key. Both "disable" and * "none" display as "None" per UX, but "disable" omits reasoning parameters From 416e0ea41ae9279262eecfca7748f95f55a5b0ee Mon Sep 17 00:00:00 2001 From: Asadur Date: Sun, 23 Aug 2026 01:32:37 +0600 Subject: [PATCH 3/5] fix(ollama): persist clamped effort on model switch to keep UI and requests in sync - Add normalizeReasoningEffortOnModelChange: when the selected model's capability array drops the stored value (e.g. "disable" on qwen3 is absent from gpt-oss), persist the clamped fallback the selector shows so the stored effort, displayed effort, and sent think param agree - Chat selector applies it at the model-switch boundary via a reasoningEffort-only write (soft toggle; enableReasoningEffort untouched) - Cover with unit tests for reasoning-effort util; extend selector spec; refresh chat-toolbar visual baselines --- .../chat/ReasoningEffortSelector.tsx | 37 +++++++-- .../__tests__/ChatTextArea.visual.fixture.tsx | 26 +++--- .../chat/__tests__/ChatTextArea.visual.tsx | 23 +++--- .../ReasoningEffortSelector.spec.tsx | 58 ++++++++++++++ .../chat-toolbar-default-dark.png | Bin 5438 -> 4896 bytes .../chat-toolbar-default-light.png | Bin 4926 -> 4418 bytes .../chat-toolbar-narrow-dark.png | Bin 4663 -> 4328 bytes .../chat-toolbar-narrow-light.png | Bin 3930 -> 3825 bytes .../utils/__tests__/reasoning-effort.spec.ts | 74 ++++++++++++++++++ webview-ui/src/utils/reasoning-effort.ts | 33 ++++++++ 10 files changed, 223 insertions(+), 28 deletions(-) create mode 100644 webview-ui/src/utils/__tests__/reasoning-effort.spec.ts diff --git a/webview-ui/src/components/chat/ReasoningEffortSelector.tsx b/webview-ui/src/components/chat/ReasoningEffortSelector.tsx index 4daf249874..4396ced00b 100644 --- a/webview-ui/src/components/chat/ReasoningEffortSelector.tsx +++ b/webview-ui/src/components/chat/ReasoningEffortSelector.tsx @@ -16,7 +16,7 @@ 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, useMemo, useRef, useState } from "react" +import { useCallback, useEffect, useMemo, useRef, useState } from "react" import type { ModelInfo } from "@roo-code/types/model" import { providerIdentifiers } from "@roo-code/types/provider-identifiers" @@ -31,6 +31,7 @@ import { getOllamaReasoningModelInfo, getReasoningEffortSelection, getReasoningEffortTranslationKey, + normalizeReasoningEffortOnModelChange, type ReasoningEffortOption, } from "@src/utils/reasoning-effort" import { vscode } from "@src/utils/vscode" @@ -61,10 +62,36 @@ export const ReasoningEffortSelector = ({ disabled = false, triggerClassName = " return selectedModelInfo }, [provider, selectedModelInfo]) - const { isReasoningEffortSupported, availableOptions, currentReasoningEffort } = getReasoningEffortSelection( - apiConfiguration, - modelInfo, - ) + 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) => { 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 f8e96f6555..e629bd5c89 100644 --- a/webview-ui/src/components/chat/__tests__/ChatTextArea.visual.fixture.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatTextArea.visual.fixture.tsx @@ -32,14 +32,13 @@ const TRIGGER_BASE = "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, tuned for the -// compact toolbar (min-width + ellipsis + flex-shrink so the row overflows -// gracefully at narrow widths). ModeSelector uses flex-shrink-0 in production, -// but the narrow-width snapshot needs to show truncation across the whole row -// (mode truncated, then bits of api-config and reasoning peeking through) — so -// the fixture gives the mode trigger the same shrinkable treatment as its -// siblings to exercise the overflow layout meaningfully. -const MODE_TRIGGER = `${TRIGGER_BASE} min-w-0 text-ellipsis overflow-hidden flex-shrink` +// 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 " + @@ -85,6 +84,15 @@ interface ChatToolbarFixtureProps { * 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, @@ -96,7 +104,7 @@ export const ChatToolbarFixture = ({ width }: ChatToolbarFixtureProps) => ( style={{ width: `${width}px` }}>
, - SelectValue: ({ placeholder }: SelectValueMockProps) => {placeholder}, - SelectContent: ({ children, onValueChange }: SelectContentMockProps) => ( -
- {React.Children.map(children, (child) => - React.isValidElement(child) - ? React.cloneElement(child as React.ReactElement, { onValueChange }) - : child, - )} -
- ), - SelectItem: ({ children, value, onValueChange }: SelectItemMockProps) => ( -
onValueChange?.(value)}> - {children} -
- ), -})) - -vi.mock("@src/i18n/TranslationContext", () => ({ - useAppTranslation: () => ({ - t: (key: string) => key, - }), -})) - -// Minimal typed ModelInfo fixture. ProviderSettings and ModelInfo are Zod -// objects whose fields are all optional, so an empty object is a valid value — -// no `as any` is needed. Spread in capability overrides per scenario. -const baseModelInfo: ModelInfo = { - contextWindow: 200000, - supportsPromptCache: true, -} - -describe("ReasoningModeSelector", () => { - const mockSetApiConfigurationField = vi.fn() - - beforeEach(() => { - vi.clearAllMocks() - }) - - it("renders nothing when the model does not support reasoning effort", () => { - const apiConfiguration: ProviderSettings = {} - - render( - , - ) - - expect(screen.queryByTestId("reasoning-effort")).toBeNull() - expect(screen.queryByTestId("select")).toBeNull() - }) - - it("renders nothing when no model info is available", () => { - const apiConfiguration: ProviderSettings = {} - - render( - , - ) - - expect(screen.queryByTestId("reasoning-effort")).toBeNull() - }) - - it("shows [disable, low, medium, high] when supportsReasoningEffort is boolean true", () => { - const apiConfiguration: ProviderSettings = {} - - render( - , - ) - - const select = screen.getByTestId("select") - // default is "disable" - expect(select.getAttribute("data-value")).toBe("disable") - - expect(screen.getByTestId("select-item-disable")).toBeInTheDocument() - expect(screen.getByTestId("select-item-low")).toBeInTheDocument() - expect(screen.getByTestId("select-item-medium")).toBeInTheDocument() - expect(screen.getByTestId("select-item-high")).toBeInTheDocument() - // boolean true never synthesizes "max" - expect(screen.queryByTestId("select-item-max")).toBeNull() - }) - - it("shows exactly the advertised array values (e.g. Ollama thinking models)", () => { - // The fetcher advertises a verbatim array including "disable" for models - // that honor think: false (qwen3) and omitting it for models that don't - // (gpt-oss). The selector must surface the array as-is — no "none" - // prepend, no auto-added "disable" for explicit arrays. - const apiConfiguration: ProviderSettings = {} - - render( - , - ) - - expect(screen.getByTestId("select-item-low")).toBeInTheDocument() - expect(screen.getByTestId("select-item-medium")).toBeInTheDocument() - expect(screen.getByTestId("select-item-high")).toBeInTheDocument() - expect(screen.getByTestId("select-item-max")).toBeInTheDocument() - // An explicit array must not auto-add a "disable" option. - expect(screen.queryByTestId("select-item-disable")).toBeNull() - }) - - it("selecting a non-disable effort enables reasoning and persists the effort", () => { - const apiConfiguration: ProviderSettings = {} - - render( - , - ) - - // The mocked SelectItem wires onClick to the Select's onValueChange. - fireEvent.click(screen.getByTestId("select-item-high")) - - expect(mockSetApiConfigurationField).toHaveBeenCalledWith("enableReasoningEffort", true) - expect(mockSetApiConfigurationField).toHaveBeenCalledWith("reasoningEffort", "high") - }) - - it("selecting 'disable' turns reasoning off and persists the disable sentinel", () => { - const apiConfiguration: ProviderSettings = { - enableReasoningEffort: true, - reasoningEffort: "high", - } - - render( - , - ) - - fireEvent.click(screen.getByTestId("select-item-disable")) - - expect(mockSetApiConfigurationField).toHaveBeenCalledWith("enableReasoningEffort", false) - expect(mockSetApiConfigurationField).toHaveBeenCalledWith("reasoningEffort", "disable") - }) - - it("reflects the currently persisted reasoning effort as the select value", () => { - const apiConfiguration: ProviderSettings = { - reasoningEffort: "medium", - } - - render( - , - ) - - expect(screen.getByTestId("select").getAttribute("data-value")).toBe("medium") - }) -}) diff --git a/webview-ui/src/utils/__tests__/reasoning-effort.spec.ts b/webview-ui/src/utils/__tests__/reasoning-effort.spec.ts index f6ec15ddd2..cefc8ec5f7 100644 --- a/webview-ui/src/utils/__tests__/reasoning-effort.spec.ts +++ b/webview-ui/src/utils/__tests__/reasoning-effort.spec.ts @@ -25,7 +25,7 @@ describe("normalizeReasoningEffortOnModelChange", () => { }) it("returns undefined when the stored value is still a valid option", () => { - const apiConfiguration = { reasoningEffort: "medium" } as ProviderSettings + const apiConfiguration = { reasoningEffort: "medium" } satisfies ProviderSettings const selection = getReasoningEffortSelection(apiConfiguration, gptOssModel) expect(normalizeReasoningEffortOnModelChange(selection)).toBeUndefined() }) @@ -41,7 +41,7 @@ describe("normalizeReasoningEffortOnModelChange", () => { // request mapper would send think: false while the UI shows "Low". The // helper returns the clamped value to persist so the stored effort, the // displayed effort, and the request stay in sync. - const apiConfiguration = { reasoningEffort: "disable" } as ProviderSettings + const apiConfiguration = { reasoningEffort: "disable" } satisfies ProviderSettings const selection = getReasoningEffortSelection(apiConfiguration, gptOssModel) // Sanity: the display clamped away from "disable"... @@ -58,7 +58,7 @@ describe("normalizeReasoningEffortOnModelChange", () => { it("clamps 'max' to the fallback when switching from a max-capable model to gpt-oss", () => { // Symmetric case: "max" is valid for qwen3 but not gpt-oss. Switching // models should normalize the stored "max" to gpt-oss's first option. - const apiConfiguration = { reasoningEffort: "max" } as ProviderSettings + const apiConfiguration = { reasoningEffort: "max" } satisfies ProviderSettings const selection = getReasoningEffortSelection(apiConfiguration, gptOssModel) expect(normalizeReasoningEffortOnModelChange(selection)).toBe("low") @@ -67,7 +67,7 @@ describe("normalizeReasoningEffortOnModelChange", () => { it("does not normalize when switching back to a model that still supports the stored value", () => { // "low" is valid for both qwen3 and gpt-oss, so switching between them // must not trigger a write. - const apiConfiguration = { reasoningEffort: "low" } as ProviderSettings + const apiConfiguration = { reasoningEffort: "low" } satisfies ProviderSettings const selection = getReasoningEffortSelection(apiConfiguration, gptOssModel) expect(normalizeReasoningEffortOnModelChange(selection)).toBeUndefined() })