mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
refactor(site/src/pages/AgentsPage): use model selector for personal overrides (#27229)
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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 (
|
||||
<ModelSelector
|
||||
options={[...openAIModels, effortModel]}
|
||||
value="openai/gpt-5"
|
||||
onValueChange={fn()}
|
||||
value={model}
|
||||
onValueChange={(value) => {
|
||||
onValueChange(value);
|
||||
setModel(value);
|
||||
}}
|
||||
reasoningEffort={effort}
|
||||
onReasoningEffortChange={(value) => {
|
||||
onReasoningEffortChange(value);
|
||||
@@ -323,6 +355,7 @@ export const EffortRow: Story = {
|
||||
},
|
||||
render: (args) => (
|
||||
<EffortRowStory
|
||||
onValueChange={args.onValueChange}
|
||||
onReasoningEffortChange={(value) => 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");
|
||||
|
||||
@@ -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(
|
||||
<ModelSelector
|
||||
options={mockModelOptions}
|
||||
value="gpt-4o-mini"
|
||||
onValueChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
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");
|
||||
});
|
||||
@@ -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<ModelSelectorProps> = ({
|
||||
options,
|
||||
value,
|
||||
onValueChange,
|
||||
triggerAriaLabel,
|
||||
disabled = false,
|
||||
placeholder = "Select model",
|
||||
unsetLabel,
|
||||
@@ -115,6 +121,7 @@ export const ModelSelector: FC<ModelSelectorProps> = ({
|
||||
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<ModelSelectorProps> = ({
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild disabled={isDisabled}>
|
||||
<Button
|
||||
aria-label={selectedModel ? selectedModel.displayName : placeholder}
|
||||
aria-label={
|
||||
triggerAriaLabel
|
||||
? `${triggerAriaLabel}, ${triggerLabel}`
|
||||
: triggerLabel
|
||||
}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
disabled={isDisabled}
|
||||
@@ -157,9 +168,7 @@ export const ModelSelector: FC<ModelSelectorProps> = ({
|
||||
)}
|
||||
onTouchStart={onTriggerTouchStart}
|
||||
>
|
||||
<span className="truncate">
|
||||
{selectedModel ? selectedModel.displayName : placeholder}
|
||||
</span>
|
||||
<span className="truncate">{triggerLabel}</span>
|
||||
<ChevronDownIcon open={open} className="size-icon-sm" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
@@ -263,7 +272,13 @@ export const ModelSelector: FC<ModelSelectorProps> = ({
|
||||
isSelected={option.id === value}
|
||||
onSelect={() => {
|
||||
onValueChange(option.id);
|
||||
handleOpenChange(false);
|
||||
setSearch("");
|
||||
if (
|
||||
!option.reasoningEfforts?.length ||
|
||||
!onReasoningEffortChange
|
||||
) {
|
||||
handleOpenChange(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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<PersonalOverrideMode, "model"> => {
|
||||
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 = () => (
|
||||
<SelectPrimitive.Separator className="-mx-1 my-1 h-px bg-border" />
|
||||
);
|
||||
|
||||
export const PersonalModelOverrideRow: FC<PersonalModelOverrideRowProps> = ({
|
||||
context,
|
||||
title,
|
||||
@@ -259,7 +193,21 @@ export const PersonalModelOverrideRow: FC<PersonalModelOverrideRowProps> = ({
|
||||
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<PersonalModelOverrideRowProps> = ({
|
||||
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<PersonalModelOverrideRowProps> = ({
|
||||
<section aria-label={title} className="flex flex-col gap-3">
|
||||
<SectionHeader label={title} description={description} level="section" />
|
||||
<form className="flex flex-col gap-3" onSubmit={form.handleSubmit}>
|
||||
<Select
|
||||
<ModelSelector
|
||||
options={[...defaultModeOptions, ...modelOptions]}
|
||||
value={selectionValue}
|
||||
onValueChange={(value) => {
|
||||
if (isDefaultModeOption(value)) {
|
||||
@@ -316,83 +257,47 @@ export const PersonalModelOverrideRow: FC<PersonalModelOverrideRowProps> = ({
|
||||
return;
|
||||
}
|
||||
const option = modelOptions.find((option) => option.id === value);
|
||||
const reasoningEffortDefault = option
|
||||
? option.reasoningEffortDefault
|
||||
: undefined;
|
||||
let reasoningEffort = "";
|
||||
if (option) {
|
||||
reasoningEffort =
|
||||
pickReasoningEffort(
|
||||
"",
|
||||
option.reasoningEfforts ?? [],
|
||||
option.reasoningEffortDefault,
|
||||
) ?? "";
|
||||
}
|
||||
void form.setValues({
|
||||
mode: "model",
|
||||
model_config_id: value,
|
||||
reasoning_effort:
|
||||
pickReasoningEffort(
|
||||
"",
|
||||
option?.reasoningEfforts ?? [],
|
||||
reasoningEffortDefault,
|
||||
) ?? "",
|
||||
reasoning_effort: reasoningEffort,
|
||||
});
|
||||
}}
|
||||
disabled={isFormDisabled}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label={`${title} behavior`}
|
||||
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]"
|
||||
>
|
||||
<SelectValue placeholder="Select...">{selectionLabel}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="min-w-[18rem]">
|
||||
{isInvalidRootDeploymentDefault && (
|
||||
<>
|
||||
<SelectItem value="deployment_default" disabled>
|
||||
Invalid deployment default
|
||||
</SelectItem>
|
||||
<SelectSeparator />
|
||||
</>
|
||||
)}
|
||||
<SelectGroup>
|
||||
{defaultModeOptions.map((mode) => (
|
||||
<DefaultModeSelectItem
|
||||
key={mode}
|
||||
mode={mode}
|
||||
context={context}
|
||||
deploymentDefault={deploymentDefault}
|
||||
modelConfigs={modelConfigs}
|
||||
/>
|
||||
))}
|
||||
</SelectGroup>
|
||||
<SelectSeparator />
|
||||
{isUnavailableSelectedModel && (
|
||||
<>
|
||||
<SelectItem value={form.values.model_config_id} disabled>
|
||||
{getUnavailableModelLabel(
|
||||
placeholder={
|
||||
isInvalidRootDeploymentDefault
|
||||
? "Invalid deployment default"
|
||||
: isUnavailableSelectedModel
|
||||
? getUnavailableModelLabel(
|
||||
form.values.model_config_id,
|
||||
modelConfigs,
|
||||
)}
|
||||
</SelectItem>
|
||||
<SelectSeparator />
|
||||
</>
|
||||
)}
|
||||
<SelectGroup>
|
||||
{modelOptions.map((option) => (
|
||||
<SelectItem key={option.id} value={option.id}>
|
||||
{getModelOptionLabel(option)}
|
||||
</SelectItem>
|
||||
))}
|
||||
{modelOptions.length === 0 && (
|
||||
<SelectItem value="__empty_models__" disabled>
|
||||
{isLoading ? "Loading models..." : "No enabled models found."}
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{selectedReasoningEffort !== undefined && selectedModelOption && (
|
||||
<PersonalReasoningEffortRow
|
||||
option={selectedModelOption}
|
||||
value={selectedReasoningEffort}
|
||||
onChange={(value) =>
|
||||
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 && (
|
||||
<p role="status" className="m-0 text-xs text-content-secondary">
|
||||
{isLoading ? "Loading models..." : "No enabled models found."}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ModelOverrideAlerts
|
||||
isUnavailableSavedModel={isUnavailableSavedModel}
|
||||
unavailableMessage="The saved model is unavailable and will be ignored until you choose a valid model override."
|
||||
@@ -428,76 +333,3 @@ export const PersonalModelOverrideRow: FC<PersonalModelOverrideRowProps> = ({
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
interface PersonalReasoningEffortRowProps {
|
||||
option: ModelSelectorOption;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
const PersonalReasoningEffortRow: FC<PersonalReasoningEffortRowProps> = ({
|
||||
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 (
|
||||
<div className="flex items-center gap-3 md:w-[18rem]">
|
||||
<span className="shrink-0 text-content-secondary text-sm">Effort</span>
|
||||
<Slider
|
||||
aria-label={`${option.displayName} reasoning effort`}
|
||||
value={[effortIndex]}
|
||||
onValueChange={([index]) => {
|
||||
const nextEffort = selectableEfforts[index];
|
||||
if (nextEffort && nextEffort !== value) {
|
||||
onChange(nextEffort);
|
||||
}
|
||||
}}
|
||||
min={0}
|
||||
max={selectableEfforts.length - 1}
|
||||
step={1}
|
||||
/>
|
||||
<span className="shrink-0 rounded bg-surface-secondary px-1.5 py-0.5 text-content-secondary text-xs font-medium leading-[18px]">
|
||||
{formatReasoningEffort(value)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface DefaultModeSelectItemProps {
|
||||
mode: Exclude<PersonalOverrideMode, "model">;
|
||||
context: PersonalOverrideContext;
|
||||
deploymentDefault?: TypesGen.ChatModelOverrideResponse;
|
||||
modelConfigs: readonly TypesGen.ChatModelConfig[];
|
||||
}
|
||||
|
||||
const DefaultModeSelectItem: FC<DefaultModeSelectItemProps> = ({
|
||||
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 (
|
||||
<SelectItem value={mode}>
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="truncate text-content-primary">{label}</span>
|
||||
<span className="truncate text-content-secondary text-xs leading-tight">
|
||||
{description}
|
||||
</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user