From 1277de5b0d72d132e9ac8d03e52fc984a87c669a Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Thu, 9 Jul 2026 09:40:52 +0000 Subject: [PATCH] feat: add reasoning effort slider to the chat model selector --- coderd/exp_chats_test.go | 15 +- coderd/x/chatd/chatd.go | 3 +- .../AgentsPage/AgentChatPage.stories.tsx | 64 +++++++ site/src/pages/AgentsPage/AgentChatPage.tsx | 34 +++- .../pages/AgentsPage/AgentChatPageView.tsx | 6 + site/src/pages/AgentsPage/AgentCreatePage.tsx | 2 + .../AgentsPage/components/AgentChatInput.tsx | 6 + .../components/AgentCreateForm.stories.tsx | 59 +++++++ .../AgentsPage/components/AgentCreateForm.tsx | 16 ++ .../ChatElements/ModelSelector.stories.tsx | 156 +++++++++++++++++- .../components/ChatElements/ModelSelector.tsx | 87 +++++++++- .../AgentsPage/components/ChatPageContent.tsx | 6 + .../AgentsPage/utils/modelOptions.test.ts | 52 ++++++ .../pages/AgentsPage/utils/modelOptions.ts | 5 + .../pages/AgentsPage/utils/reasoningEffort.ts | 2 +- 15 files changed, 503 insertions(+), 10 deletions(-) diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index 2c01e75dcb1e0..f1a7403cf2de7 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -3897,8 +3897,11 @@ func TestCreateChatModelConfig(t *testing.T) { }) sdkErr := requireSDKError(t, err, http.StatusBadRequest) require.Equal(t, "Invalid model config.", sdkErr.Message) - require.Contains(t, sdkErr.Detail, `reasoning_effort.default " HIGH "`) - require.Contains(t, sdkErr.Detail, "must be one of none, minimal, low, medium, high, xhigh, max") + require.Equal( + t, + `reasoning_effort.default " HIGH " must be one of none, minimal, low, medium, high, xhigh, max`, + sdkErr.Detail, + ) }) t.Run("ReasoningEffortRejectsDefaultAboveMax", func(t *testing.T) { @@ -7029,8 +7032,9 @@ func TestSendMessageQueuesEffectiveModelConfigID(t *testing.T) { Type: codersdk.ChatInputPartTypeText, Text: "queue this with model b", }}, - ModelConfigID: ptr.Ref(modelConfigB.ID), - BusyBehavior: codersdk.ChatBusyBehaviorQueue, + ModelConfigID: ptr.Ref(modelConfigB.ID), + ReasoningEffort: ptr.Ref("high"), + BusyBehavior: codersdk.ChatBusyBehaviorQueue, }) require.NoError(t, err) require.True(t, resp.Queued) @@ -7043,10 +7047,13 @@ func TestSendMessageQueuesEffectiveModelConfigID(t *testing.T) { require.Len(t, queuedMessages, 1) require.True(t, queuedMessages[0].ModelConfigID.Valid) require.Equal(t, modelConfigB.ID, queuedMessages[0].ModelConfigID.UUID) + require.True(t, queuedMessages[0].ReasoningEffort.Valid) + require.Equal(t, database.ChatReasoningEffortHigh, queuedMessages[0].ReasoningEffort.ChatReasoningEffort) storedChat, err := db.GetChatByID(dbauthz.AsSystemRestricted(ctx), chat.ID) require.NoError(t, err) require.Equal(t, modelConfigA.ID, storedChat.LastModelConfigID) + require.False(t, storedChat.LastReasoningEffort.Valid) } func TestQueuedMessageWithoutOverrideCapturesEnqueueTimeModel(t *testing.T) { diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index 8d5fb25edc69f..31c91abbabdc1 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -2888,7 +2888,6 @@ type chatMessage struct { contextLimit int64 totalCostMicros int64 runtimeMs int64 - reasoningEffort string } type userChatMessage struct { @@ -2949,7 +2948,7 @@ func appendMessageFields( params.CreatedBy = append(params.CreatedBy, msg.createdBy) params.APIKeyID = append(params.APIKeyID, apiKeyID) params.ModelConfigID = append(params.ModelConfigID, msg.modelConfigID) - params.ReasoningEffort = append(params.ReasoningEffort, msg.reasoningEffort) + params.ReasoningEffort = append(params.ReasoningEffort, "") params.Role = append(params.Role, msg.role) params.Content = append(params.Content, string(msg.content.RawMessage)) params.ContentVersion = append(params.ContentVersion, msg.contentVersion) diff --git a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx index df8ed532ee393..487e5ccf45edb 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx @@ -21,6 +21,7 @@ import { } from "#/api/queries/chats"; import { workspaceByIdKey } from "#/api/queries/workspaces"; import type * as TypesGen from "#/api/typesGenerated"; +import { MockChatMessage } from "#/testHelpers/chatEntities"; import { MockChatModelConfig } from "#/testHelpers/chatModels"; import { MockGroup, @@ -122,6 +123,10 @@ const mockModelConfigs: TypesGen.ChatModelConfig[] = [ model: "gpt-4o", display_name: "GPT-4o", is_default: true, + model_config: { + reasoning_effort: { default: "medium", max: "high" }, + }, + reasoning_efforts: ["low", "medium", "high"], created_at: "2026-02-18T00:00:00.000Z", updated_at: "2026-02-18T00:00:00.000Z", }, @@ -1189,8 +1194,38 @@ export const WithMessageHistory: Story = { { diffUrl: undefined }, ), }, + beforeEach: () => { + spyOn(API.experimental, "getChat").mockResolvedValue({ + id: CHAT_ID, + ...baseChatFields, + title: "Markdown rendering showcase", + status: "waiting", + }); + spyOn(API.experimental, "editChatMessage").mockResolvedValue({ + message: { + ...MockChatMessage, + id: 5, + created_at: "2026-02-18T00:03:00.000Z", + }, + }); + }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); + const body = within(document.body); + const user = userEvent.setup(); + const changeReasoningEffort = async (key: string) => { + const modelSelector = canvas.getByRole("combobox", { name: "GPT-4o" }); + await user.click(modelSelector); + const slider = await body.findByRole("slider"); + slider.focus(); + await user.keyboard(key); + await user.click(modelSelector); + }; + const editLastMessage = async () => { + const buttons = canvas.getAllByRole("button", { name: "Edit message" }); + await user.click(buttons[buttons.length - 1]); + }; + expect( await canvas.findByText("Markdown rendering showcase"), ).toBeVisible(); @@ -1199,6 +1234,35 @@ export const WithMessageHistory: Story = { canvas.queryByText(/^This chat is owned by/), ).not.toBeInTheDocument(); }); + + await changeReasoningEffort("{ArrowRight}"); + await editLastMessage(); + await user.click(canvas.getByRole("button", { name: "Save Edit" })); + await waitFor(() => { + expect(API.experimental.editChatMessage).toHaveBeenCalledTimes(1); + expect( + canvas.getByRole("textbox", { name: "Chat message" }), + ).toBeEnabled(); + }); + + await editLastMessage(); + await changeReasoningEffort("{ArrowLeft}"); + await user.click(canvas.getByRole("button", { name: "Save Edit" })); + await waitFor(() => { + expect(API.experimental.editChatMessage).toHaveBeenCalledTimes(2); + }); + expect(API.experimental.editChatMessage).toHaveBeenNthCalledWith( + 1, + CHAT_ID, + 5, + expect.not.objectContaining({ reasoning_effort: expect.anything() }), + ); + expect(API.experimental.editChatMessage).toHaveBeenNthCalledWith( + 2, + CHAT_ID, + 5, + expect.objectContaining({ reasoning_effort: "medium" }), + ); }, }; diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index a57643b6e1d64..ac645dc296bf8 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -96,6 +96,7 @@ import { resolveModelSelector, } from "./utils/modelOptions"; import { parsePullRequestUrl } from "./utils/pullRequest"; +import { pickReasoningEffort } from "./utils/reasoningEffort"; import { type ChatDetailError, formatUsageLimitMessage, @@ -720,6 +721,8 @@ const AgentChatPage: FC = () => { const { organizations, experiments } = useDashboard(); const organizationName = getDefaultOrganizationName(organizations); const [selectedModel, setSelectedModel] = useState(""); + const [selectedReasoningEffort, setSelectedReasoningEffort] = useState(""); + const isEditReasoningEffortDirtyRef = useRef(false); const scrollToBottomRef = useRef<(() => void) | null>(null); const chatInputRef = useRef(null); const inputValueRef = useRef( @@ -1133,6 +1136,17 @@ const AgentChatPage: FC = () => { return modelOptions[0]?.id ?? ""; })(); + const effectiveModelOption = modelOptions.find( + (option) => option.id === effectiveSelectedModel, + ); + const effectiveReasoningEffort = effectiveModelOption + ? pickReasoningEffort( + selectedReasoningEffort || chatRecord?.last_reasoning_effort, + effectiveModelOption.reasoningEfforts ?? [], + effectiveModelOption.reasoningEffortDefault, + ) + : undefined; + const compressionThreshold = resolveCompactionThreshold( chatLastModelConfigID, userThresholdsQuery.data?.thresholds, @@ -1257,6 +1271,12 @@ const AgentChatPage: FC = () => { chatInputRef, inputValueRef, }); + const handleEditUserMessage = ( + ...args: Parameters + ) => { + isEditReasoningEffortDirtyRef.current = false; + editing.handleEditUserMessage(...args); + }; const chatTitle = chatQuery.data?.title; @@ -1453,9 +1473,13 @@ const AgentChatPage: FC = () => { pickerModelConfigID !== originalModelConfigID ? pickerModelConfigID : undefined; + // Omit so the backend preserves the original effort. const request: TypesGen.EditChatMessageRequest = { content, model_config_id: editSelectedModelConfigID, + reasoning_effort: isEditReasoningEffortDirtyRef.current + ? effectiveReasoningEffort + : undefined, }; const optimisticMessage = originalEditedMessage ? buildOptimisticEditedMessage({ @@ -1498,6 +1522,7 @@ const AgentChatPage: FC = () => { const request: CreateChatMessageRequestWithClearablePlanMode = { content, model_config_id: selectedModelConfigID, + reasoning_effort: effectiveReasoningEffort, mcp_server_ids: effectiveMCPServerIds.length > 0 ? [...effectiveMCPServerIds] @@ -1643,12 +1668,19 @@ const AgentChatPage: FC = () => { workspaceAgent={workspaceAgent} chatBuildId={chatQuery.data?.build_id} store={store} - editing={editing} + editing={{ ...editing, handleEditUserMessage }} effectiveSelectedModel={effectiveSelectedModel} setSelectedModel={setSelectedModel} modelOptions={modelOptions} modelSelectorPlaceholder={modelSelectorPlaceholder} modelSelectorHelp={modelSelectorHelp} + reasoningEffort={effectiveReasoningEffort} + onReasoningEffortChange={(value) => { + setSelectedReasoningEffort(value); + if (editing.editingMessageId !== null) { + isEditReasoningEffortDirtyRef.current = true; + } + }} canConfigureAgentSetup={permissions.editDeploymentConfig} providerCount={providerCount} modelCount={modelCount} diff --git a/site/src/pages/AgentsPage/AgentChatPageView.tsx b/site/src/pages/AgentsPage/AgentChatPageView.tsx index 1f46b9f8c130d..e61fcd1230557 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.tsx @@ -136,6 +136,8 @@ interface AgentChatPageViewProps { modelOptions: readonly ModelSelectorOption[]; modelSelectorPlaceholder: string; modelSelectorHelp?: ReactNode; + reasoningEffort?: string; + onReasoningEffortChange?: (value: string) => void; canConfigureAgentSetup: boolean; providerCount?: number; modelCount?: number; @@ -328,6 +330,8 @@ export const AgentChatPageView: FC = ({ modelOptions, modelSelectorPlaceholder, modelSelectorHelp, + reasoningEffort, + onReasoningEffortChange, canConfigureAgentSetup, providerCount, modelCount, @@ -941,6 +945,8 @@ export const AgentChatPageView: FC = ({ modelOptions={modelOptions} modelSelectorPlaceholder={modelSelectorPlaceholder} modelSelectorHelp={modelSelectorHelp} + reasoningEffort={reasoningEffort} + onReasoningEffortChange={onReasoningEffortChange} planModeEnabled={planModeEnabled} onPlanModeToggle={onPlanModeToggle} isModelCatalogLoading={isModelCatalogLoading} diff --git a/site/src/pages/AgentsPage/AgentCreatePage.tsx b/site/src/pages/AgentsPage/AgentCreatePage.tsx index 8de13a09a9b5c..23fa489a23d2b 100644 --- a/site/src/pages/AgentsPage/AgentCreatePage.tsx +++ b/site/src/pages/AgentsPage/AgentCreatePage.tsx @@ -88,6 +88,7 @@ const AgentCreatePage: FC = () => { fileIDs, workspaceId, model, + reasoningEffort, mcpServerIds, organizationId, planMode, @@ -110,6 +111,7 @@ const AgentCreatePage: FC = () => { plan_mode: planMode === "plan" ? "plan" : undefined, client_type: "ui", ...(model ? { model_config_id: model } : {}), + ...(reasoningEffort ? { reasoning_effort: reasoningEffort } : {}), }; const createdChat = await createMutation.mutateAsync(createRequest); diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.tsx index 7df03bbc01402..9867529733740 100644 --- a/site/src/pages/AgentsPage/components/AgentChatInput.tsx +++ b/site/src/pages/AgentsPage/components/AgentChatInput.tsx @@ -119,6 +119,8 @@ interface AgentChatInputProps { modelOptions: readonly ModelSelectorOption[]; modelSelectorPlaceholder: string; hasModelOptions: boolean; + reasoningEffort?: string; + onReasoningEffortChange?: (value: string) => void; planModeEnabled?: boolean; onPlanModeToggle?: (enabled: boolean) => void; isModelCatalogLoading?: boolean; @@ -354,6 +356,8 @@ export const AgentChatInput: FC = ({ modelOptions, modelSelectorPlaceholder, hasModelOptions, + reasoningEffort, + onReasoningEffortChange, planModeEnabled = false, onPlanModeToggle, isModelCatalogLoading = false, @@ -1428,6 +1432,8 @@ export const AgentChatInput: FC = ({ dropdownSide="top" dropdownAlign="start" enableMobileFullWidthDropdown + reasoningEffort={reasoningEffort} + onReasoningEffortChange={onReasoningEffortChange} /> )} {planModeEnabled && !shouldOverflowPlanningBadge && ( diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx index 8d2d7318e0d55..0ca8352bfd974 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx @@ -157,6 +157,7 @@ const getCreateOptions = (onCreateChat: unknown): CreateChatSubmission => { type CreateChatSubmission = { model?: string; + reasoningEffort?: string; }; export const RootPersonalModelOverrideModelSelected: Story = { @@ -305,6 +306,64 @@ export const ManualSelectionOverridesRootChatDefault: Story = { }, }; +// Model options with reasoning effort bounds configured. GPT-4o uses the +// full global scale; Claude is capped at medium. +const effortModelOptions = [ + { + ...modelOptions[0], + reasoningEffortDefault: "medium", + reasoningEfforts: [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", + ], + }, + { + ...modelOptions[1], + reasoningEffortDefault: "low", + reasoningEfforts: ["low", "medium"], + }, +] as const; + +export const SubmitsReasoningEffort: Story = { + args: { + ...defaultArgs, + onCreateChat: fn().mockResolvedValue(undefined), + modelOptions: [...effortModelOptions], + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + const body = within(canvasElement.ownerDocument.body); + + // Open the model selector; the effort row shows the model default. + await userEvent.click(canvas.getByRole("combobox", { name: "GPT-4o" })); + const slider = await body.findByRole("slider"); + // "medium" is the fourth of seven selectable efforts. + expect(slider).toHaveAttribute("aria-valuenow", "3"); + + // Bump the effort to "high" with the keyboard, then close. + await userEvent.tab(); + expect(slider).toHaveFocus(); + await userEvent.keyboard("{ArrowRight}"); + await waitFor(() => { + expect(slider).toHaveAttribute("aria-valuenow", "4"); + }); + await userEvent.keyboard("{Escape}"); + + await submitMessage(canvasElement, "create with reasoning effort"); + await waitFor(() => { + expect(args.onCreateChat).toHaveBeenCalled(); + }); + const options = getCreateOptions(args.onCreateChat); + expect(options.model).toBe(modelConfigID); + expect(options.reasoningEffort).toBe("high"); + }, +}; + const mockWorkspaces = [ { ...MockWorkspace, diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx index d1aaf04e4e6ce..2d1683d5229d3 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx @@ -20,6 +20,7 @@ import { hasConfiguredModelsInCatalog, hasUserFixableProviders, } from "../utils/modelOptions"; +import { pickReasoningEffort } from "../utils/reasoningEffort"; import { formatUsageLimitMessage, isChatUsageLimitExceededResponse, @@ -47,6 +48,7 @@ export type CreateChatOptions = { fileIDs?: string[]; workspaceId?: string; model?: string; + reasoningEffort?: string; mcpServerIds?: string[]; organizationId: string; planMode?: TypesGen.ChatPlanMode; @@ -238,6 +240,17 @@ export const AgentCreateForm: FC = ({ } return selectedModel || undefined; })(); + const [selectedReasoningEffort, setSelectedReasoningEffort] = useState(""); + const selectedModelOption = modelOptions.find( + (option) => option.id === selectedModel, + ); + const effectiveReasoningEffort = selectedModelOption + ? pickReasoningEffort( + selectedReasoningEffort, + selectedModelOption.reasoningEfforts ?? [], + selectedModelOption.reasoningEffortDefault, + ) + : undefined; const initialOrg = organizations.find((o) => o.is_default) ?? organizations[0]; const [selectedWorkspaceId, setSelectedWorkspaceId] = useState( @@ -367,6 +380,7 @@ export const AgentCreateForm: FC = ({ fileIDs, workspaceId: effectiveWorkspaceId ?? undefined, model: submittedModel, + reasoningEffort: effectiveReasoningEffort, organizationId, mcpServerIds: effectiveMCPServerIds.length > 0 @@ -529,6 +543,8 @@ export const AgentCreateForm: FC = ({ onModelChange={handleModelChange} modelOptions={modelOptions} modelSelectorPlaceholder={modelSelectorPlaceholder} + reasoningEffort={effectiveReasoningEffort} + onReasoningEffortChange={setSelectedReasoningEffort} isModelCatalogLoading={isModelCatalogLoading} hasModelOptions={hasModelOptions} planModeEnabled={planModeEnabled} diff --git a/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.stories.tsx b/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.stories.tsx index 01815edcae557..0311eb75aaf0d 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.stories.tsx @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect, fn, userEvent, waitFor, within } from "storybook/test"; +import { useState } from "react"; +import { expect, fn, screen, userEvent, waitFor, within } from "storybook/test"; import { ModelSelector, type ModelSelectorOption } from "./ModelSelector"; import { MockModelSelectorOption } from "./modelSelectorFixtures"; @@ -48,6 +49,24 @@ const anthropicModels: ModelSelectorOption[] = [ const allModels: ModelSelectorOption[] = [...openAIModels, ...anthropicModels]; +const effortModel: ModelSelectorOption = { + ...MockModelSelectorOption, + id: "openai/gpt-5", + model: "gpt-5", + displayName: "GPT-5", + contextLimit: 400_000, + reasoningEffortDefault: "medium", + reasoningEfforts: [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", + ], +}; + const meta: Meta = { title: "pages/AgentsPage/ChatElements/ModelSelector", component: ModelSelector, @@ -254,3 +273,138 @@ export const FiltersModels: Story = { ); }, }; + +// --------------------------------------------------------------------------- +// Reasoning effort row +// --------------------------------------------------------------------------- + +export const EffortRowHiddenWithoutConfig: Story = { + args: { + options: openAIModels, + value: "openai/gpt-4o", + reasoningEffort: "medium", + onReasoningEffortChange: fn(), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const body = within(document.body); + + await userEvent.click(canvas.getByRole("combobox")); + await body.findByRole("listbox"); + + expect(body.queryByRole("slider")).not.toBeInTheDocument(); + expect(body.queryByText("Effort")).not.toBeInTheDocument(); + }, +}; + +const EffortRowStory = ({ + onReasoningEffortChange, +}: { + onReasoningEffortChange: (value: string) => void; +}) => { + const [effort, setEffort] = useState("medium"); + return ( + { + onReasoningEffortChange(value); + setEffort(value); + }} + /> + ); +}; + +export const EffortRow: Story = { + args: { + onReasoningEffortChange: fn(), + }, + render: (args) => ( + args.onReasoningEffortChange?.(value)} + /> + ), + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + const body = within(document.body); + + await userEvent.click(canvas.getByRole("combobox", { name: "GPT-5" })); + await body.findByRole("listbox"); + + // The row is visible with one discrete step per selectable effort. + await waitFor(() => { + expect(body.getByText("Effort")).toBeVisible(); + }); + const slider = await body.findByRole("slider"); + expect(slider).toHaveAttribute("aria-valuemin", "0"); + expect(slider).toHaveAttribute("aria-valuemax", "6"); + // "medium" is the fourth of seven selectable efforts. + expect(slider).toHaveAttribute("aria-valuenow", "3"); + expect(body.getByText("Medium")).toBeVisible(); + + const infoTrigger = body.getByRole("button", { + name: "About reasoning effort", + }); + await userEvent.tab(); + expect(infoTrigger).toHaveFocus(); + expect(await screen.findByRole("tooltip")).toHaveTextContent( + "Controls how much reasoning the model performs before responding.", + ); + + await userEvent.tab(); + expect(slider).toHaveFocus(); + + await userEvent.keyboard("{ArrowRight}"); + await waitFor(() => { + expect(slider).toHaveAttribute("aria-valuenow", "4"); + }); + expect(args.onReasoningEffortChange).toHaveBeenCalledWith("high"); + expect(body.getByText("High")).toBeVisible(); + + await userEvent.keyboard("{ArrowRight}{ArrowRight}"); + await waitFor(() => { + expect(slider).toHaveAttribute("aria-valuenow", "6"); + }); + expect(args.onReasoningEffortChange).toHaveBeenCalledWith("max"); + expect(body.getByText("Max")).toBeVisible(); + + await userEvent.keyboard("{ArrowLeft}{ArrowLeft}{ArrowLeft}{ArrowLeft}"); + await waitFor(() => { + expect(slider).toHaveAttribute("aria-valuenow", "2"); + }); + expect(args.onReasoningEffortChange).toHaveBeenCalledWith("low"); + expect(body.getByText("Low")).toBeVisible(); + }, +}; + +export const EffortRowClampedToMax: Story = { + args: { + options: [ + { + ...effortModel, + reasoningEffortDefault: "low", + reasoningEfforts: ["none", "minimal", "low", "medium"], + }, + ], + value: "openai/gpt-5", + reasoningEffort: "low", + onReasoningEffortChange: fn(), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const body = within(document.body); + + await userEvent.click(canvas.getByRole("combobox")); + await body.findByRole("listbox"); + + // Selectable efforts stop at the configured max. + const slider = await body.findByRole("slider"); + expect(slider).toHaveAttribute("aria-valuemax", "3"); + expect(slider).toHaveAttribute("aria-valuenow", "2"); + await waitFor(() => { + expect(body.getByText("Low")).toBeVisible(); + }); + }, +}; diff --git a/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.tsx b/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.tsx index d6285f7ed87e0..ebd894b0c15fe 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.tsx @@ -1,4 +1,4 @@ -import { CheckIcon } from "lucide-react"; +import { CheckIcon, InfoIcon } from "lucide-react"; import { type FC, useState } from "react"; import { ChevronDownIcon } from "#/components/AnimatedIcons/ChevronDown"; import { Button } from "#/components/Button/Button"; @@ -15,9 +15,16 @@ import { PopoverContent, PopoverTrigger, } from "#/components/Popover/Popover"; +import { Slider } from "#/components/Slider/Slider"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "#/components/Tooltip/Tooltip"; import { ProviderIcon } from "#/pages/AISettingsPage/ProvidersPage/components/ProviderIcon"; import { formatProviderLabel as defaultFormatProviderLabel } from "#/utils/aiProviders"; import { cn } from "#/utils/cn"; +import { formatReasoningEffort } from "../../utils/reasoningEffort"; export interface ModelSelectorOption { id: string; @@ -28,6 +35,8 @@ export interface ModelSelectorOption { model: string; displayName: string; contextLimit?: number; + reasoningEffortDefault?: string; + reasoningEfforts?: readonly string[]; } interface ModelSelectorProps { @@ -44,6 +53,8 @@ interface ModelSelectorProps { contentClassName?: string; onTriggerTouchStart?: () => void; enableMobileFullWidthDropdown?: boolean; + reasoningEffort?: string; + onReasoningEffortChange?: (value: string) => void; } const formatContextLimit = (tokens: number): string => { @@ -85,6 +96,8 @@ export const ModelSelector: FC = ({ contentClassName, onTriggerTouchStart, enableMobileFullWidthDropdown = false, + reasoningEffort, + onReasoningEffortChange, }) => { const [open, setOpen] = useState(false); const [search, setSearch] = useState(""); @@ -224,11 +237,83 @@ export const ModelSelector: FC = ({ })} + {selectedModel && + reasoningEffort !== undefined && + onReasoningEffortChange && ( + + )} ); }; +interface ReasoningEffortRowProps { + option: ModelSelectorOption; + value: string; + onChange: (value: string) => void; +} + +// Effort row pinned below the model list. Lives outside the Command +// so it stays visible while the list scrolls and cmdk's arrow-key +// navigation does not capture the slider's keyboard interaction. +const ReasoningEffortRow: FC = ({ + option, + value, + onChange, +}) => { + const selectableEfforts = option.reasoningEfforts ?? []; + if (selectableEfforts.length === 0) { + return null; + } + const valueIndex = selectableEfforts.indexOf(value); + const effortIndex = valueIndex >= 0 ? valueIndex : 0; + + return ( +
+
+ + Effort + + + + + + + Controls how much reasoning the model performs before responding. + Higher effort can improve quality but is slower and costs more. + + +
+ { + const nextEffort = selectableEfforts[index]; + if (nextEffort && nextEffort !== value) { + onChange(nextEffort); + } + }} + min={0} + max={selectableEfforts.length - 1} + step={1} + /> + + {formatReasoningEffort(value)} + +
+ ); +}; + interface ModelOptionItemProps { option: ModelSelectorOption; isSelected: boolean; diff --git a/site/src/pages/AgentsPage/components/ChatPageContent.tsx b/site/src/pages/AgentsPage/components/ChatPageContent.tsx index 654951e4934de..0d7ca6c4688a5 100644 --- a/site/src/pages/AgentsPage/components/ChatPageContent.tsx +++ b/site/src/pages/AgentsPage/components/ChatPageContent.tsx @@ -173,6 +173,8 @@ interface ChatPageInputProps { modelOptions: readonly ModelSelectorOption[]; modelSelectorPlaceholder: string; modelSelectorHelp?: ReactNode; + reasoningEffort?: string; + onReasoningEffortChange?: (value: string) => void; canConfigureAgentSetup: boolean; providerCount?: number; modelCount?: number; @@ -244,6 +246,8 @@ export const ChatPageInput: FC = ({ modelOptions, modelSelectorPlaceholder, modelSelectorHelp, + reasoningEffort, + onReasoningEffortChange, canConfigureAgentSetup, providerCount, modelCount, @@ -504,6 +508,8 @@ export const ChatPageInput: FC = ({ onModelChange={onModelChange} modelOptions={modelOptions} modelSelectorPlaceholder={modelSelectorPlaceholder} + reasoningEffort={reasoningEffort} + onReasoningEffortChange={onReasoningEffortChange} planModeEnabled={planModeEnabled} onPlanModeToggle={onPlanModeToggle} isModelCatalogLoading={isModelCatalogLoading} diff --git a/site/src/pages/AgentsPage/utils/modelOptions.test.ts b/site/src/pages/AgentsPage/utils/modelOptions.test.ts index ad5866975e172..d009a01c26680 100644 --- a/site/src/pages/AgentsPage/utils/modelOptions.test.ts +++ b/site/src/pages/AgentsPage/utils/modelOptions.test.ts @@ -323,6 +323,58 @@ describe("getModelOptionsFromConfigs", () => { ]); }); + it("populates reasoning effort bounds from the model config", () => { + const configs = [ + createConfig({ + id: "config-effort", + ai_provider_id: "prov-openai", + model: "gpt-5", + display_name: "GPT-5", + model_config: { + reasoning_effort: { default: "medium", max: "xhigh" }, + }, + reasoning_efforts: ["minimal", "low", "medium", "high", "xhigh"], + }), + createConfig({ + id: "config-no-effort", + ai_provider_id: "prov-openai", + model: "gpt-4o", + display_name: "GPT-4o", + model_config: {}, + }), + ]; + const catalog = createCatalog([ + { provider: "openai", available: true, models: [] }, + ]); + + expect( + getModelOptionsFromConfigs(configs, catalog, providerInfoByID), + ).toEqual([ + { + id: "config-no-effort", + provider: "openai", + providerId: "prov-openai", + providerLabel: "OpenAI", + providerIcon: "", + model: "gpt-4o", + displayName: "GPT-4o", + contextLimit: 0, + }, + { + id: "config-effort", + provider: "openai", + providerId: "prov-openai", + providerLabel: "OpenAI", + providerIcon: "", + model: "gpt-5", + displayName: "GPT-5", + contextLimit: 0, + reasoningEffortDefault: "medium", + reasoningEfforts: ["minimal", "low", "medium", "high", "xhigh"], + }, + ]); + }); + it("excludes configs whose providers are unavailable", () => { const configs = [ createConfig({ diff --git a/site/src/pages/AgentsPage/utils/modelOptions.ts b/site/src/pages/AgentsPage/utils/modelOptions.ts index 6bae7d6730beb..fab4acf1046e7 100644 --- a/site/src/pages/AgentsPage/utils/modelOptions.ts +++ b/site/src/pages/AgentsPage/utils/modelOptions.ts @@ -253,6 +253,9 @@ export const getModelOptionsFromConfigs = ( const displayName = config.display_name.trim() || model; const contextLimit = asNumber(config.context_limit); + const reasoningEffort = config.model_config?.reasoning_effort; + const reasoningEffortDefault = asString(reasoningEffort?.default).trim(); + const reasoningEfforts = config.reasoning_efforts ?? []; options.push({ id: configID, provider, @@ -262,6 +265,8 @@ export const getModelOptionsFromConfigs = ( model, displayName, ...(contextLimit !== undefined ? { contextLimit } : {}), + ...(reasoningEffortDefault ? { reasoningEffortDefault } : {}), + ...(reasoningEfforts.length > 0 ? { reasoningEfforts } : {}), }); } diff --git a/site/src/pages/AgentsPage/utils/reasoningEffort.ts b/site/src/pages/AgentsPage/utils/reasoningEffort.ts index f3ae6364529df..8c023a0bf6332 100644 --- a/site/src/pages/AgentsPage/utils/reasoningEffort.ts +++ b/site/src/pages/AgentsPage/utils/reasoningEffort.ts @@ -2,7 +2,7 @@ export const formatReasoningEffort = (value: string): string => value.charAt(0).toUpperCase() + value.slice(1); -/** Chooses requested effort, then default effort, then the highest effort. */ +/** Chooses requested effort, then default effort, then the last selectable effort. */ export const pickReasoningEffort = ( value: string | undefined, efforts: readonly string[],