diff --git a/site/src/pages/AgentsPage/AgentSettingsUserAgentsPageView.stories.tsx b/site/src/pages/AgentsPage/AgentSettingsUserAgentsPageView.stories.tsx index 14b455ecc5..d778534f7e 100644 --- a/site/src/pages/AgentsPage/AgentSettingsUserAgentsPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentSettingsUserAgentsPageView.stories.tsx @@ -70,6 +70,16 @@ const claudeModelConfig = buildModelConfig({ context_limit: 200_000, }); +const reasoningModelConfig = buildModelConfig({ + id: "model-gpt-5", + model: "gpt-5", + display_name: "GPT-5", + model_config: { + reasoning_effort: { default: "medium", max: "high" }, + }, + reasoning_efforts: ["none", "minimal", "low", "medium", "high"], +}); + const disabledModelConfig = buildModelConfig({ id: "model-disabled", model: "gpt-4.1-legacy", @@ -87,10 +97,21 @@ const inaccessibleModelConfig = buildModelConfig({ const modelConfigs = [ defaultModelConfig, claudeModelConfig, + reasoningModelConfig, disabledModelConfig, inaccessibleModelConfig, ]; +const reasoningModelOption: ModelSelectorOption = { + id: reasoningModelConfig.id, + provider: "openai", + model: reasoningModelConfig.model, + displayName: reasoningModelConfig.display_name, + contextLimit: reasoningModelConfig.context_limit, + reasoningEffortDefault: "medium", + reasoningEfforts: ["none", "minimal", "low", "medium", "high"], +}; + const modelOptions: ModelSelectorOption[] = [ { id: defaultModelConfig.id, @@ -106,6 +127,7 @@ const modelOptions: ModelSelectorOption[] = [ displayName: claudeModelConfig.display_name, contextLimit: claudeModelConfig.context_limit, }, + reasoningModelOption, ]; const buildOverridesResponse = ( @@ -168,14 +190,16 @@ const getSection = async ( const selectOption = async ( section: HTMLElement, canvasElement: HTMLElement, - comboboxName: string | RegExp, + comboboxName: string, optionName: string | RegExp, ) => { - await userEvent.click( - within(section).getByRole("combobox", { name: comboboxName }), - ); + const combobox = within(section).getByRole("combobox", { + name: comboboxName, + }); + await userEvent.click(combobox); const body = within(canvasElement.ownerDocument.body); await userEvent.click(await body.findByRole("option", { name: optionName })); + return combobox; }; const meta = { @@ -236,10 +260,20 @@ export const EnabledWithSavedValues: Story = { }), play: async ({ canvasElement, args }) => { const rootSection = await getSection(canvasElement, "Root agent model"); + const exploreSection = await getSection( + canvasElement, + "Explore subagent model", + ); + expect( + within(exploreSection).getByRole("combobox", { + name: "Explore subagent model behavior, Claude Sonnet 4", + }), + ).toHaveTextContent("Claude Sonnet 4"); + await selectOption( rootSection, canvasElement, - "Root agent model behavior", + "Root agent model behavior, Chat default: GPT 4.1 Mini", /Claude Sonnet 4/i, ); const rootSaveButton = within(rootSection).getByRole("button", { @@ -263,12 +297,13 @@ export const EnabledWithSavedValues: Story = { await selectOption( generalSection, canvasElement, - "General subagent model behavior", + "General subagent model behavior, Deployment default: Claude Sonnet 4", /Chat default/i, ); await userEvent.click( within(generalSection).getByRole("button", { name: "Save" }), ); + await waitFor(() => { expect(args.onSaveGeneralModelOverride).toHaveBeenCalledWith( { mode: "chat_default", model_config_id: "" }, @@ -278,6 +313,112 @@ export const EnabledWithSavedValues: Story = { }, }; +export const SavedReasoningModel: Story = { + args: buildArgs({ + modelOptions: [ + { + id: defaultModelConfig.id, + provider: "openai", + model: defaultModelConfig.model, + displayName: defaultModelConfig.display_name, + contextLimit: defaultModelConfig.context_limit, + }, + { + id: reasoningModelConfig.id, + provider: "openai", + model: reasoningModelConfig.model, + displayName: reasoningModelConfig.display_name, + contextLimit: reasoningModelConfig.context_limit, + reasoningEffortDefault: "medium", + reasoningEfforts: ["none", "minimal", "low", "medium", "high"], + }, + ], + overridesData: buildOverridesResponse({ + root: buildOverride("root", { + mode: "model", + model_config_id: defaultModelConfig.id, + is_set: true, + }), + }), + }), + play: async ({ canvasElement, args }) => { + const rootSection = await getSection(canvasElement, "Root agent model"); + const modelPicker = await selectOption( + rootSection, + canvasElement, + "Root agent model behavior, GPT 4.1 Mini", + /GPT-5/i, + ); + + const body = within(canvasElement.ownerDocument.body); + expect(modelPicker).toHaveAttribute("aria-expanded", "true"); + expect(await body.findByRole("listbox")).toBeVisible(); + const slider = await body.findByRole("slider"); + expect(slider).toBeVisible(); + 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(); + + await userEvent.tab(); + expect(slider).toHaveFocus(); + await userEvent.keyboard("{ArrowRight}"); + await waitFor(() => { + expect(slider).toHaveAttribute("aria-valuenow", "4"); + }); + expect(body.getByText("High")).toBeVisible(); + + await userEvent.keyboard("{Escape}"); + await userEvent.click( + within(rootSection).getByRole("button", { name: "Save" }), + ); + await waitFor(() => { + expect(args.onSaveRootModelOverride).toHaveBeenCalledWith( + { + mode: "model", + model_config_id: reasoningModelConfig.id, + reasoning_effort: "high", + }, + expect.anything(), + ); + }); + }, +}; + +export const SavedLowReasoningEffort: Story = { + args: buildArgs({ + modelOptions: [reasoningModelOption], + overridesData: buildOverridesResponse({ + root: buildOverride("root", { + mode: "model", + model_config_id: reasoningModelConfig.id, + reasoning_effort: "low", + is_set: true, + }), + }), + }), + play: async ({ canvasElement }) => { + const rootSection = await getSection(canvasElement, "Root agent model"); + const modelPicker = within(rootSection).getByRole("combobox", { + name: "Root agent model behavior, GPT-5", + }); + expect(modelPicker).toHaveTextContent("GPT-5"); + await userEvent.click(modelPicker); + + const body = within(canvasElement.ownerDocument.body); + expect(modelPicker).toHaveAttribute("aria-expanded", "true"); + const slider = await body.findByRole("slider"); + expect(slider).toHaveAttribute("aria-valuenow", "2"); + await waitFor(() => { + expect(body.getByText("Low")).toBeVisible(); + }); + }, +}; + export const MalformedSavedValues: Story = { args: buildArgs({ overridesData: buildOverridesResponse({ @@ -460,19 +601,19 @@ export const ModelConfigsError: Story = { await selectOption( rootSection, canvasElement, - "Root agent model behavior", + "Root agent model behavior, Claude Sonnet 4", /Chat default/i, ); await selectOption( generalSection, canvasElement, - "General subagent model behavior", + "General subagent model behavior, Claude Sonnet 4", /Deployment default/i, ); await selectOption( exploreSection, canvasElement, - "Explore subagent model behavior", + "Explore subagent model behavior, Claude Sonnet 4", /Chat default/i, ); @@ -493,7 +634,7 @@ export const LoadingState: Story = { const rootSection = await getSection(canvasElement, "Root agent model"); expect( within(rootSection).getByRole("combobox", { - name: "Root agent model behavior", + name: "Root agent model behavior, Chat default: GPT 4.1 Mini", }), ).toBeDisabled(); expect( @@ -576,7 +717,7 @@ export const AdminDisabledReadOnly: Story = { const rootSection = await getSection(canvasElement, "Root agent model"); expect( within(rootSection).getByRole("combobox", { - name: "Root agent model behavior", + name: "Root agent model behavior, GPT 4.1 Mini", }), ).toBeDisabled(); expect( @@ -609,7 +750,7 @@ export const InvalidRootDeploymentDefault: Story = { await selectOption( rootSection, canvasElement, - "Root agent model behavior", + "Root agent model behavior, Invalid deployment default", /Chat default/i, ); await userEvent.click( diff --git a/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.stories.tsx b/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.stories.tsx index 0311eb75aa..99933bb0d3 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.stories.tsx @@ -92,6 +92,21 @@ export const WithSelectedValue: Story = { }, }; +export const CustomTriggerLabel: Story = { + args: { + options: openAIModels, + value: "openai/gpt-4o", + triggerAriaLabel: "Agent model behavior", + }, + play: async ({ canvasElement }) => { + expect( + within(canvasElement).getByRole("combobox", { + name: "Agent model behavior, GPT-4o", + }), + ).toBeInTheDocument(); + }, +}; + export const CustomPlaceholder: Story = { args: { placeholder: "Choose a model…", @@ -197,6 +212,7 @@ export const SelectsModel: Story = { options: openAIModels, value: "", onValueChange: fn(), + onReasoningEffortChange: fn(), }, play: async ({ canvasElement, args }) => { const canvas = within(canvasElement); @@ -204,10 +220,20 @@ export const SelectsModel: Story = { const trigger = canvas.getByRole("combobox"); await userEvent.click(trigger); - const listbox = await within(document.body).findByRole("listbox"); + const body = within(document.body); + const listbox = await body.findByRole("listbox"); + const search = body.getByPlaceholderText("Search..."); + await userEvent.type(search, "mini"); await userEvent.click(within(listbox).getByText("GPT-4o Mini")); expect(args.onValueChange).toHaveBeenCalledWith("openai/gpt-4o-mini"); + await waitFor(() => { + expect(trigger).toHaveAttribute("aria-expanded", "false"); + expect(body.queryByRole("listbox")).not.toBeInTheDocument(); + }); + + await userEvent.click(trigger); + expect(await body.findByPlaceholderText("Search...")).toHaveValue(""); }, }; @@ -298,16 +324,22 @@ export const EffortRowHiddenWithoutConfig: Story = { }; const EffortRowStory = ({ + onValueChange, onReasoningEffortChange, }: { + onValueChange: (value: string) => void; onReasoningEffortChange: (value: string) => void; }) => { + const [model, setModel] = useState("openai/gpt-4o"); const [effort, setEffort] = useState("medium"); return ( { + onValueChange(value); + setModel(value); + }} reasoningEffort={effort} onReasoningEffortChange={(value) => { onReasoningEffortChange(value); @@ -323,6 +355,7 @@ export const EffortRow: Story = { }, render: (args) => ( args.onReasoningEffortChange?.(value)} /> ), @@ -330,11 +363,20 @@ export const EffortRow: Story = { const canvas = within(canvasElement); const body = within(document.body); - await userEvent.click(canvas.getByRole("combobox", { name: "GPT-5" })); - await body.findByRole("listbox"); + const trigger = canvas.getByRole("combobox", { name: "GPT-4o" }); + await userEvent.click(trigger); + const listbox = await body.findByRole("listbox"); + const search = body.getByPlaceholderText("Search..."); + await userEvent.type(search, "gpt-5"); + await userEvent.click( + within(listbox).getByRole("option", { name: /GPT-5/ }), + ); - // The row is visible with one discrete step per selectable effort. + expect(args.onValueChange).toHaveBeenCalledWith("openai/gpt-5"); await waitFor(() => { + expect(trigger).toHaveAttribute("aria-expanded", "true"); + expect(listbox).toBeVisible(); + expect(search).toHaveValue(""); expect(body.getByText("Effort")).toBeVisible(); }); const slider = await body.findByRole("slider"); diff --git a/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.test.tsx b/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.test.tsx deleted file mode 100644 index 6a0dd847ab..0000000000 --- a/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.test.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { screen } from "@testing-library/react"; -import { render } from "#/testHelpers/renderHelpers"; -import { ModelSelector, type ModelSelectorOption } from "./ModelSelector"; -import { MockModelSelectorOption } from "./modelSelectorFixtures"; - -const mockModelOptions: readonly ModelSelectorOption[] = [ - { - ...MockModelSelectorOption, - id: "gpt-4o-mini", - model: "gpt-4o-mini", - displayName: "GPT-4o mini", - }, - { - ...MockModelSelectorOption, - id: "claude-opus", - provider: "anthropic", - model: "claude-opus-4-1", - displayName: "Claude Opus 4.1", - contextLimit: 1_000_000, - }, -]; - -test("suppresses mouse-focus ring but keeps keyboard-focus ring on model selector trigger", () => { - render( - , - ); - - const trigger = screen.getByRole("combobox"); - - // Mouse-focus ring should be suppressed. - expect(trigger.className).toContain("focus:ring-0"); - // Keyboard-focus ring should remain. - expect(trigger.className).toContain("focus-visible:ring-2"); - expect(trigger.className).not.toContain("focus-visible:ring-0"); -}); diff --git a/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.tsx b/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.tsx index fc807873bf..4b56f07745 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.tsx @@ -43,6 +43,11 @@ interface ModelSelectorProps { options: readonly ModelSelectorOption[]; value: string; onValueChange: (value: string) => void; + /** + * When set, the trigger's accessible name is this contextual label followed + * by the selected model's display name or the placeholder. + */ + triggerAriaLabel?: string; disabled?: boolean; placeholder?: string; /** @@ -92,6 +97,7 @@ export const ModelSelector: FC = ({ options, value, onValueChange, + triggerAriaLabel, disabled = false, placeholder = "Select model", unsetLabel, @@ -115,6 +121,7 @@ export const ModelSelector: FC = ({ setOpen(nextOpen); }; const selectedModel = options.find((option) => option.id === value); + const triggerLabel = selectedModel?.displayName ?? placeholder; // With an unset option the selector stays usable even when no model // options exist, so a saved override can still be switched back. const isDisabled = disabled || (options.length === 0 && !unsetLabel); @@ -144,7 +151,11 @@ export const ModelSelector: FC = ({ @@ -263,7 +272,13 @@ export const ModelSelector: FC = ({ isSelected={option.id === value} onSelect={() => { onValueChange(option.id); - handleOpenChange(false); + setSearch(""); + if ( + !option.reasoningEfforts?.length || + !onReasoningEffortChange + ) { + handleOpenChange(false); + } }} /> ))} diff --git a/site/src/pages/AgentsPage/components/PersonalModelOverrideRow.tsx b/site/src/pages/AgentsPage/components/PersonalModelOverrideRow.tsx index 5b96de5f0e..29234f02b5 100644 --- a/site/src/pages/AgentsPage/components/PersonalModelOverrideRow.tsx +++ b/site/src/pages/AgentsPage/components/PersonalModelOverrideRow.tsx @@ -1,23 +1,10 @@ import { useFormik } from "formik"; -import { Select as SelectPrimitive } from "radix-ui"; import type { FC } from "react"; import type * as TypesGen from "#/api/typesGenerated"; import { Alert, AlertDescription } from "#/components/Alert/Alert"; import { Button } from "#/components/Button/Button"; -import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectTrigger, - SelectValue, -} from "#/components/Select/Select"; -import { Slider } from "#/components/Slider/Slider"; -import { - formatReasoningEffort, - pickReasoningEffort, -} from "../utils/reasoningEffort"; -import type { ModelSelectorOption } from "./ChatElements"; +import { pickReasoningEffort } from "../utils/reasoningEffort"; +import { ModelSelector, type ModelSelectorOption } from "./ChatElements"; import { ModelOverrideAlerts } from "./ModelOverrideAlerts"; import { SectionHeader } from "./SectionHeader"; @@ -107,10 +94,6 @@ const getModelConfigLabel = (modelConfig: TypesGen.ChatModelConfig): string => { return modelConfig.display_name.trim() || modelConfig.model || modelConfig.id; }; -const getModelOptionLabel = (option: ModelSelectorOption): string => { - return option.displayName.trim() || option.model || option.id; -}; - const getModelConfigLabelByID = ( modelConfigID: string, modelConfigs: readonly TypesGen.ChatModelConfig[], @@ -173,61 +156,12 @@ const getDeploymentDefaultDescription = ( ); }; -const getSelectionLabel = ({ - context, - deploymentDefault, - isInvalidRootDeploymentDefault, - modelConfigs, - modelOptions, - values, -}: { - context: PersonalOverrideContext; - deploymentDefault?: TypesGen.ChatModelOverrideResponse; - isInvalidRootDeploymentDefault: boolean; - modelConfigs: readonly TypesGen.ChatModelConfig[]; - modelOptions: readonly ModelSelectorOption[]; - values: PersonalOverrideFormValues; -}): string => { - if (isInvalidRootDeploymentDefault) { - return "Invalid deployment default"; - } - - switch (values.mode) { - case "chat_default": - return `Chat default: ${getChatDefaultDescription(context, modelConfigs)}`; - case "deployment_default": - return `Deployment default: ${getDeploymentDefaultDescription( - deploymentDefault, - modelConfigs, - )}`; - case "model": { - const modelConfigID = values.model_config_id.trim(); - const modelOption = modelOptions.find( - (option) => option.id === modelConfigID, - ); - if (modelOption) { - return getModelOptionLabel(modelOption); - } - return modelConfigID === "" - ? "Select..." - : getUnavailableModelLabel(modelConfigID, modelConfigs); - } - } -}; - const isDefaultModeOption = ( value: string, ): value is Exclude => { return value === "chat_default" || value === "deployment_default"; }; -// Local separator for use inside SelectContent. Defined here instead of -// in the core Select component so the styling stays scoped to this -// feature until a shared design lands. -const SelectSeparator: FC = () => ( - -); - export const PersonalModelOverrideRow: FC = ({ context, title, @@ -259,7 +193,21 @@ export const PersonalModelOverrideRow: FC = ({ disabled || isSaving || isLoading || !hasLoadedOverride; const canSave = hasLoadedOverride && !disabled && (form.dirty || isMalformedOverride); - const defaultModeOptions = getDefaultModeOptions(context); + const defaultModeOptions = getDefaultModeOptions(context).map((mode) => { + const label = + mode === "deployment_default" ? "Deployment default" : "Chat default"; + const modeDescription = + mode === "deployment_default" + ? getDeploymentDefaultDescription(deploymentDefault, modelConfigs) + : getChatDefaultDescription(context, modelConfigs); + return { + id: mode, + provider: "defaults", + providerLabel: "Defaults", + model: mode, + displayName: `${label}: ${modeDescription}`, + }; + }); const isInvalidRootDeploymentDefault = context === "root" && overrideData?.mode === "deployment_default"; const isUnavailableSavedModel = @@ -286,14 +234,6 @@ export const PersonalModelOverrideRow: FC = ({ selectedModelOption.reasoningEffortDefault, ) : undefined; - const selectionLabel = getSelectionLabel({ - context, - deploymentDefault, - isInvalidRootDeploymentDefault, - modelConfigs, - modelOptions, - values: form.values, - }); const canSaveSelection = canSave && (form.values.mode !== "model" || @@ -304,7 +244,8 @@ export const PersonalModelOverrideRow: FC = ({
- - {selectedReasoningEffort !== undefined && selectedModelOption && ( - - void form.setFieldValue("reasoning_effort", value) - } - /> + ) + : "Select..." + } + triggerAriaLabel={`${title} behavior`} + emptyMessage="No matching models found." + className="h-10 w-full justify-between rounded-md border border-border border-solid bg-transparent px-3 text-sm shadow-sm md:w-[18rem]" + contentClassName="min-w-[18rem]" + reasoningEffort={selectedReasoningEffort} + onReasoningEffortChange={(value) => + void form.setFieldValue("reasoning_effort", value) + } + /> + {modelOptions.length === 0 && ( +

+ {isLoading ? "Loading models..." : "No enabled models found."} +

)} + = ({
); }; - -interface PersonalReasoningEffortRowProps { - option: ModelSelectorOption; - value: string; - onChange: (value: string) => void; -} - -const PersonalReasoningEffortRow: 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 - { - const nextEffort = selectableEfforts[index]; - if (nextEffort && nextEffort !== value) { - onChange(nextEffort); - } - }} - min={0} - max={selectableEfforts.length - 1} - step={1} - /> - - {formatReasoningEffort(value)} - -
- ); -}; - -interface DefaultModeSelectItemProps { - mode: Exclude; - context: PersonalOverrideContext; - deploymentDefault?: TypesGen.ChatModelOverrideResponse; - modelConfigs: readonly TypesGen.ChatModelConfig[]; -} - -const DefaultModeSelectItem: FC = ({ - mode, - context, - deploymentDefault, - modelConfigs, -}) => { - const label = - mode === "deployment_default" ? "Deployment default" : "Chat default"; - const description = - mode === "deployment_default" - ? getDeploymentDefaultDescription(deploymentDefault, modelConfigs) - : getChatDefaultDescription(context, modelConfigs); - - return ( - - - {label} - - {description} - - - - ); -};