feat(site/src/pages/AgentsPage/components/ChatModelAdminPanel): add duplicate model action (#24728)

> Mux is acting on Mike's behalf.

Adds explicit star, edit, and duplicate actions to each Agents model
configuration row, replacing the chevron-only affordance.

Duplicate opens a prefilled create form backed by the existing create
mutation when the provider can manage models. The form copies editable
model fields and provider config while clearing default status so saving
a duplicate does not change the current default model.
This commit is contained in:
Michael Suchacz
2026-04-27 17:55:47 +02:00
committed by GitHub
parent ef6e452825
commit ebed01ac55
6 changed files with 582 additions and 160 deletions
@@ -1,4 +1,4 @@
import { type FC, useState } from "react"; import type { FC } from "react";
import type * as TypesGen from "#/api/typesGenerated"; import type * as TypesGen from "#/api/typesGenerated";
import { Alert, AlertDescription, AlertTitle } from "#/components/Alert/Alert"; import { Alert, AlertDescription, AlertTitle } from "#/components/Alert/Alert";
@@ -251,10 +251,6 @@ export const ChatModelAdminPanel: FC<ChatModelAdminPanelProps> = ({
isDeletingModel, isDeletingModel,
modelMutationError, modelMutationError,
}) => { }) => {
const [requestedProvider, setRequestedProvider] = useState<string | null>(
null,
);
// ── Sorted model configs ─────────────────────────────────── // ── Sorted model configs ───────────────────────────────────
const modelConfigs = (modelConfigsData ?? []).slice().sort((a, b) => { const modelConfigs = (modelConfigsData ?? []).slice().sort((a, b) => {
const cmp = a.provider.localeCompare(b.provider); const cmp = a.provider.localeCompare(b.provider);
@@ -268,20 +264,6 @@ export const ChatModelAdminPanel: FC<ChatModelAdminPanelProps> = ({
modelCatalogData, modelCatalogData,
); );
// Derive the effective selected provider from user intent + available
// providers. This avoids a useEffect + setState cycle that would cause
// an extra render with a stale value.
const selectedProvider =
requestedProvider &&
providerStates.some((ps) => ps.provider === requestedProvider)
? requestedProvider
: (providerStates[0]?.provider ?? null);
const selectedProviderState = selectedProvider
? (providerStates.find((ps) => ps.provider === selectedProvider) ?? null)
: null;
// ── Derived state ──────────────────────────────────────────
const providerConfigsUnavailable = providerConfigsData === null; const providerConfigsUnavailable = providerConfigsData === null;
const modelConfigsUnavailable = modelConfigsData === null; const modelConfigsUnavailable = modelConfigsData === null;
@@ -306,16 +288,12 @@ export const ChatModelAdminPanel: FC<ChatModelAdminPanelProps> = ({
onCreateProvider={onCreateProvider} onCreateProvider={onCreateProvider}
onUpdateProvider={onUpdateProvider} onUpdateProvider={onUpdateProvider}
onDeleteProvider={onDeleteProvider} onDeleteProvider={onDeleteProvider}
onSelectedProviderChange={setRequestedProvider}
/> />
) : ( ) : (
<ModelsSection <ModelsSection
sectionLabel={sectionLabel} sectionLabel={sectionLabel}
sectionDescription={sectionDescription} sectionDescription={sectionDescription}
providerStates={providerStates} providerStates={providerStates}
selectedProvider={selectedProvider}
selectedProviderState={selectedProviderState}
onSelectedProviderChange={setRequestedProvider}
modelConfigs={modelConfigs} modelConfigs={modelConfigs}
modelConfigsUnavailable={modelConfigsUnavailable} modelConfigsUnavailable={modelConfigsUnavailable}
isCreating={isCreatingModel} isCreating={isCreatingModel}
@@ -75,6 +75,8 @@ const validationSchema = Yup.object({
interface ModelFormProps { interface ModelFormProps {
/** When set, the form is in "edit" mode for the given model. */ /** When set, the form is in "edit" mode for the given model. */
editingModel?: TypesGen.ChatModelConfig; editingModel?: TypesGen.ChatModelConfig;
/** When set without editingModel, the form creates from this model. */
duplicateSourceModel?: TypesGen.ChatModelConfig;
providerStates: readonly ProviderState[]; providerStates: readonly ProviderState[];
selectedProvider: string | null; selectedProvider: string | null;
selectedProviderState: ProviderState | null; selectedProviderState: ProviderState | null;
@@ -95,6 +97,7 @@ interface ModelFormProps {
export const ModelForm: FC<ModelFormProps> = ({ export const ModelForm: FC<ModelFormProps> = ({
editingModel, editingModel,
duplicateSourceModel,
providerStates, providerStates,
selectedProvider, selectedProvider,
selectedProviderState, selectedProviderState,
@@ -107,8 +110,13 @@ export const ModelForm: FC<ModelFormProps> = ({
onCancel, onCancel,
onDeleteModel, onDeleteModel,
}) => { }) => {
const initialModel = editingModel ?? duplicateSourceModel;
const isEditing = Boolean(editingModel); const isEditing = Boolean(editingModel);
const isDefaultModel = isEditing && editingModel?.is_default === true; const isDuplicating = Boolean(duplicateSourceModel) && !isEditing;
const initialValues = {
...buildInitialModelFormValues(initialModel),
...(isDuplicating && { isDefault: false }),
};
const [showAdvanced, setShowAdvanced] = useState(false); const [showAdvanced, setShowAdvanced] = useState(false);
const [showPricing, setShowPricing] = useState(false); const [showPricing, setShowPricing] = useState(false);
const [showProviderConfig, setShowProviderConfig] = useState(false); const [showProviderConfig, setShowProviderConfig] = useState(false);
@@ -119,9 +127,17 @@ export const ModelForm: FC<ModelFormProps> = ({
(selectedProviderState.hasEffectiveAPIKey || (selectedProviderState.hasEffectiveAPIKey ||
selectedProviderState.providerConfig.allow_user_api_key), selectedProviderState.providerConfig.allow_user_api_key),
); );
const formTitle = isEditing
? "Edit Model"
: isDuplicating
? "Duplicate Model"
: "Add Model";
const formDescription = isDuplicating
? "Review the copied settings, then save to create a new model."
: undefined;
const form = useFormik<ModelFormValues>({ const form = useFormik<ModelFormValues>({
initialValues: buildInitialModelFormValues(editingModel), initialValues,
validationSchema, validationSchema,
validateOnMount: true, validateOnMount: true,
validateOnBlur: false, validateOnBlur: false,
@@ -137,7 +153,7 @@ export const ModelForm: FC<ModelFormProps> = ({
); );
const buildResult = buildModelConfigFromForm( const buildResult = buildModelConfigFromForm(
selectedProviderState?.provider, selectedProvider,
values.config, values.config,
); );
if (Object.keys(buildResult.fieldErrors).length > 0) return; if (Object.keys(buildResult.fieldErrors).length > 0) return;
@@ -174,12 +190,13 @@ export const ModelForm: FC<ModelFormProps> = ({
await onUpdateModel(editingModel.id, req); await onUpdateModel(editingModel.id, req);
} else { } else {
if (!selectedProviderState?.providerConfig) return; if (!selectedProvider || !selectedProviderState?.providerConfig) return;
const req: TypesGen.CreateChatModelConfigRequest = { const req: TypesGen.CreateChatModelConfigRequest = {
provider: selectedProviderState.provider, provider: selectedProvider,
model: trimmedModel, model: trimmedModel,
enabled: true, enabled: values.enabled,
is_default: values.isDefault,
...(parsedContextLimit !== null && { ...(parsedContextLimit !== null && {
context_limit: parsedContextLimit, context_limit: parsedContextLimit,
}), }),
@@ -189,9 +206,6 @@ export const ModelForm: FC<ModelFormProps> = ({
...(trimmedDisplayName && { ...(trimmedDisplayName && {
display_name: trimmedDisplayName, display_name: trimmedDisplayName,
}), }),
...(values.isDefault && {
is_default: true,
}),
...(builtModelConfig && { ...(builtModelConfig && {
model_config: builtModelConfig, model_config: builtModelConfig,
}), }),
@@ -208,13 +222,14 @@ export const ModelForm: FC<ModelFormProps> = ({
const getFieldHelpers = getFormHelpers(form); const getFieldHelpers = getFormHelpers(form);
const modelConfigFormBuildResult = buildModelConfigFromForm( const modelConfigFormBuildResult = buildModelConfigFromForm(
selectedProviderState?.provider, selectedProvider,
form.values.config, form.values.config,
); );
const hasFieldErrors = const hasFieldErrors =
Object.keys(modelConfigFormBuildResult.fieldErrors).length > 0; Object.keys(modelConfigFormBuildResult.fieldErrors).length > 0;
const defaultModelDisableGuard = isDefaultModel && form.values.enabled; const defaultModelDisableGuard =
isEditing && form.values.isDefault && form.values.enabled;
// ── Provider select (shared across all form states) ─────── // ── Provider select (shared across all form states) ───────
@@ -229,7 +244,7 @@ export const ModelForm: FC<ModelFormProps> = ({
<Select <Select
value={selectedProvider ?? ""} value={selectedProvider ?? ""}
onValueChange={onSelectedProviderChange} onValueChange={onSelectedProviderChange}
disabled={isEditing || providerStates.length === 0} disabled={isEditing || isDuplicating || providerStates.length === 0}
> >
<SelectTrigger <SelectTrigger
id="providerSelect" id="providerSelect"
@@ -257,7 +272,7 @@ export const ModelForm: FC<ModelFormProps> = ({
<div> <div>
<BackButton onClick={onCancel} /> <BackButton onClick={onCancel} />
<h2 className="m-0 text-lg font-medium text-content-primary"> <h2 className="m-0 text-lg font-medium text-content-primary">
{isEditing ? "Edit Model" : "Add Model"} {formTitle}
</h2> </h2>
<hr className="my-4 border-0 border-t border-solid border-border" /> <hr className="my-4 border-0 border-t border-solid border-border" />
<div className="space-y-3">{providerSelect}</div> <div className="space-y-3">{providerSelect}</div>
@@ -271,15 +286,15 @@ export const ModelForm: FC<ModelFormProps> = ({
<div> <div>
<BackButton onClick={onCancel} /> <BackButton onClick={onCancel} />
<h2 className="m-0 text-lg font-medium text-content-primary"> <h2 className="m-0 text-lg font-medium text-content-primary">
Add Model {formTitle}
</h2> </h2>
<hr className="my-4 border-0 border-t border-solid border-border" /> <hr className="my-4 border-0 border-t border-solid border-border" />
<div className="space-y-3"> <div className="space-y-3">
{providerSelect} {providerSelect}
<p className="text-sm text-content-secondary"> <p className="text-sm text-content-secondary">
{!selectedProviderState.providerConfig {!selectedProviderState.providerConfig
? "Create a managed provider config on the Providers tab before adding models." ? "Create a managed provider config on the Providers tab before managing models."
: "Set an API key for this provider on the Providers tab before adding models."} : "Set an API key for this provider on the Providers tab before managing models."}
</p> </p>
</div> </div>
</div> </div>
@@ -295,7 +310,18 @@ export const ModelForm: FC<ModelFormProps> = ({
return ( return (
<div className="flex min-h-full flex-col"> <div className="flex min-h-full flex-col">
{/* Back */} {/* Back */}
<BackButton onClick={onCancel} /> {/* Header - editable display name */} <BackButton onClick={onCancel} />
<div className="mb-4">
<h2 className="m-0 text-lg font-medium text-content-primary">
{formTitle}
</h2>
{formDescription && (
<p className="m-0 mt-1 text-sm text-content-secondary">
{formDescription}
</p>
)}
</div>
{/* Header - editable display name */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{selectedProviderState && ( {selectedProviderState && (
<ProviderIcon <ProviderIcon
@@ -309,10 +335,7 @@ export const ModelForm: FC<ModelFormProps> = ({
className="invisible col-start-1 row-start-1 whitespace-pre text-lg font-medium" className="invisible col-start-1 row-start-1 whitespace-pre text-lg font-medium"
aria-hidden="true" aria-hidden="true"
> >
{form.values.displayName || {form.values.displayName || initialModel?.model || "Model name"}
(isEditing
? (editingModel?.model ?? "Model name")
: "Model name")}
</span> </span>
<input <input
type="text" type="text"
@@ -320,14 +343,12 @@ export const ModelForm: FC<ModelFormProps> = ({
disabled={isSaving} disabled={isSaving}
spellCheck={false} spellCheck={false}
className="col-start-1 row-start-1 m-0 min-w-0 border-0 bg-transparent p-0 text-lg font-medium text-content-primary outline-none placeholder:text-content-secondary focus:ring-0" className="col-start-1 row-start-1 m-0 min-w-0 border-0 bg-transparent p-0 text-lg font-medium text-content-primary outline-none placeholder:text-content-secondary focus:ring-0"
placeholder={ placeholder={initialModel?.model ?? "Model name"}
isEditing ? (editingModel?.model ?? "Model name") : "Model name"
}
/> />
</div> </div>
<PencilIcon className="h-3.5 w-3.5 shrink-0 text-content-secondary" /> <PencilIcon className="h-3.5 w-3.5 shrink-0 text-content-secondary" />
</div>{" "} </div>{" "}
{editingModel && ( {initialModel && (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<span className="ml-auto inline-flex"> <span className="ml-auto inline-flex">
@@ -633,7 +654,11 @@ export const ModelForm: FC<ModelFormProps> = ({
disabled={isSaving || !form.isValid || hasFieldErrors} disabled={isSaving || !form.isValid || hasFieldErrors}
> >
{isSaving && <Spinner className="h-4 w-4" loading />}{" "} {isSaving && <Spinner className="h-4 w-4" loading />}{" "}
{isEditing ? "Save" : "Add model"} {isEditing
? "Save"
: isDuplicating
? "Create duplicate"
: "Add model"}
</Button> </Button>
</div> </div>
</div> </div>
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react-vite"; import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, within } from "storybook/test"; import { expect, fn, userEvent, waitFor, within } from "storybook/test";
import type * as TypesGen from "#/api/typesGenerated"; import type * as TypesGen from "#/api/typesGenerated";
import { TooltipProvider } from "#/components/Tooltip/Tooltip"; import { TooltipProvider } from "#/components/Tooltip/Tooltip";
import type { ProviderState } from "./ChatModelAdminPanel"; import type { ProviderState } from "./ChatModelAdminPanel";
@@ -31,6 +31,19 @@ const providerState: ProviderState = {
baseURL: "", baseURL: "",
}; };
const providerStateWithoutAPIKey: ProviderState = {
...providerState,
providerConfig: {
...providerState.providerConfig!,
has_api_key: false,
central_api_key_enabled: false,
allow_user_api_key: false,
},
hasManagedAPIKey: false,
hasCatalogAPIKey: false,
hasEffectiveAPIKey: false,
};
const baseModelConfig: TypesGen.ChatModelConfig = { const baseModelConfig: TypesGen.ChatModelConfig = {
id: "model-config-id", id: "model-config-id",
provider: "openai", provider: "openai",
@@ -44,15 +57,47 @@ const baseModelConfig: TypesGen.ChatModelConfig = {
updated_at: "2025-01-01T00:00:00Z", updated_at: "2025-01-01T00:00:00Z",
}; };
const disabledModelConfig: TypesGen.ChatModelConfig = {
...baseModelConfig,
id: "disabled-model-config-id",
model: "gpt-4.1-disabled",
display_name: "GPT-4.1 Disabled",
enabled: false,
};
const defaultModelConfig: TypesGen.ChatModelConfig = {
...baseModelConfig,
id: "default-model-config-id",
model: "gpt-4o",
display_name: "GPT-4o",
is_default: true,
};
const duplicateSourceModel: TypesGen.ChatModelConfig = {
...baseModelConfig,
id: "duplicate-source-model-id",
model: "gpt-4.1-default",
display_name: "GPT-4.1 Default",
is_default: true,
context_limit: 200000,
compression_threshold: 65,
model_config: {
max_output_tokens: 4096,
provider_options: {
openai: {
max_tool_calls: 4,
reasoning_effort: "high",
},
},
},
};
const meta: Meta<typeof ModelsSection> = { const meta: Meta<typeof ModelsSection> = {
title: "pages/AgentsPage/ChatModelAdminPanel/ModelsSection", title: "pages/AgentsPage/ChatModelAdminPanel/ModelsSection",
component: ModelsSection, component: ModelsSection,
args: { args: {
sectionLabel: "Models", sectionLabel: "Models",
providerStates: [providerState], providerStates: [providerState],
selectedProvider: "openai",
selectedProviderState: providerState,
onSelectedProviderChange: fn(),
modelConfigs: [baseModelConfig], modelConfigs: [baseModelConfig],
modelConfigsUnavailable: false, modelConfigsUnavailable: false,
isCreating: false, isCreating: false,
@@ -104,3 +149,306 @@ export const HidesPricingWarningForExplicitZeroPricing: Story = {
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
}, },
}; };
export const ShowsExplicitRowActions: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const user = userEvent.setup();
const rowButton = canvas.getByRole("button", {
name: "Open model: GPT-4.1",
});
const starButton = canvas.getByRole("button", {
name: "Set as default model: GPT-4.1",
});
const editButton = canvas.getByRole("button", {
name: "Edit model: GPT-4.1",
});
const copyButton = canvas.getByRole("button", {
name: "Duplicate model: GPT-4.1",
});
await expect(starButton).toBeVisible();
await expect(editButton).toBeVisible();
await expect(copyButton).toBeVisible();
rowButton.focus();
await expect(rowButton).toHaveFocus();
await user.tab();
await expect(starButton).toHaveFocus();
await user.tab();
await expect(editButton).toHaveFocus();
await user.tab();
await expect(copyButton).toHaveFocus();
},
};
export const OpensDuplicateFormWithoutCreating: Story = {
args: {
modelConfigs: [duplicateSourceModel],
onCreateModel: fn(async () => undefined),
onUpdateModel: fn(async () => undefined),
},
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
canvas.getByRole("button", {
name: "Duplicate model: GPT-4.1 Default",
}),
);
await expect(canvas.findByText("Duplicate Model")).resolves.toBeVisible();
expect(args.onCreateModel).not.toHaveBeenCalled();
expect(args.onUpdateModel).not.toHaveBeenCalled();
expect(canvas.getByDisplayValue("GPT-4.1 Default")).toBeVisible();
expect(canvas.getByLabelText(/Model Identifier/)).toHaveValue(
"gpt-4.1-default",
);
expect(canvas.getByLabelText(/Context Limit/)).toHaveValue("200000");
const enabledSwitch = canvas.getByRole("switch", { name: "Enabled" });
expect(enabledSwitch).toBeChecked();
expect(enabledSwitch).toBeEnabled();
await userEvent.click(canvas.getByRole("button", { name: /Advanced/ }));
expect(canvas.getByLabelText(/Compression Threshold/)).toHaveValue("65");
await userEvent.click(
canvas.getByRole("button", { name: /Provider Configuration/ }),
);
expect(canvas.getByLabelText("Max Tool Calls")).toHaveValue("4");
},
};
export const AbandonsDuplicateWithoutSaving: Story = {
args: {
modelConfigs: [duplicateSourceModel],
onCreateModel: fn(async () => undefined),
onUpdateModel: fn(async () => undefined),
},
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
const copyButtonName = "Duplicate model: GPT-4.1 Default";
await userEvent.click(canvas.getByRole("button", { name: copyButtonName }));
await expect(canvas.findByText("Duplicate Model")).resolves.toBeVisible();
await userEvent.click(canvas.getByRole("button", { name: "Cancel" }));
await expect(
canvas.findByRole("button", { name: copyButtonName }),
).resolves.toBeVisible();
await userEvent.click(canvas.getByRole("button", { name: copyButtonName }));
await expect(canvas.findByText("Duplicate Model")).resolves.toBeVisible();
await userEvent.click(canvas.getByRole("button", { name: "Back" }));
await expect(
canvas.findByRole("button", { name: copyButtonName }),
).resolves.toBeVisible();
expect(args.onCreateModel).not.toHaveBeenCalled();
expect(args.onUpdateModel).not.toHaveBeenCalled();
},
};
export const SavesDuplicateAsCreateRequest: Story = {
args: {
modelConfigs: [duplicateSourceModel],
onCreateModel: fn(async () => undefined),
onUpdateModel: fn(async () => undefined),
},
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
canvas.getByRole("button", {
name: "Duplicate model: GPT-4.1 Default",
}),
);
await expect(canvas.findByText("Duplicate Model")).resolves.toBeVisible();
const modelInput = canvas.getByLabelText(/Model Identifier/);
await userEvent.clear(modelInput);
await userEvent.type(modelInput, "gpt-4.1-copy");
const displayNameInput = canvas.getByDisplayValue("GPT-4.1 Default");
await userEvent.clear(displayNameInput);
await userEvent.type(displayNameInput, "GPT-4.1 Copy");
await userEvent.click(
canvas.getByRole("button", { name: "Create duplicate" }),
);
await waitFor(() => expect(args.onCreateModel).toHaveBeenCalledTimes(1));
expect(args.onUpdateModel).not.toHaveBeenCalled();
const createModelMock = args.onCreateModel as ReturnType<typeof fn>;
const createReq = createModelMock.mock.calls[0]?.[0];
if (!createReq) {
throw new Error("Expected create request.");
}
expect(createReq).toEqual({
provider: "openai",
model: "gpt-4.1-copy",
display_name: "GPT-4.1 Copy",
enabled: true,
is_default: false,
context_limit: 200000,
compression_threshold: 65,
model_config: {
max_output_tokens: 4096,
provider_options: {
openai: {
max_tool_calls: 4,
reasoning_effort: "high",
},
},
},
});
},
};
export const SavesNonDefaultDuplicateWithEditableEnabled: Story = {
args: {
modelConfigs: [baseModelConfig],
onCreateModel: fn(async () => undefined),
onUpdateModel: fn(async () => undefined),
},
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
canvas.getByRole("button", { name: "Duplicate model: GPT-4.1" }),
);
await expect(canvas.findByText("Duplicate Model")).resolves.toBeVisible();
const enabledSwitch = canvas.getByRole("switch", { name: "Enabled" });
expect(enabledSwitch).toBeChecked();
expect(enabledSwitch).toBeEnabled();
await userEvent.click(enabledSwitch);
const modelInput = canvas.getByLabelText(/Model Identifier/);
await userEvent.clear(modelInput);
await userEvent.type(modelInput, "gpt-4.1-copy");
await userEvent.click(
canvas.getByRole("button", { name: "Create duplicate" }),
);
await waitFor(() => expect(args.onCreateModel).toHaveBeenCalledTimes(1));
expect(args.onUpdateModel).not.toHaveBeenCalled();
const createModelMock = args.onCreateModel as ReturnType<typeof fn>;
expect(createModelMock.mock.calls[0]?.[0]).toEqual({
provider: "openai",
model: "gpt-4.1-copy",
display_name: "GPT-4.1",
enabled: false,
is_default: false,
context_limit: 128000,
compression_threshold: 80,
});
},
};
export const SavesDisabledDuplicateWithEditableEnabled: Story = {
args: {
modelConfigs: [disabledModelConfig],
onCreateModel: fn(async () => undefined),
onUpdateModel: fn(async () => undefined),
},
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
canvas.getByRole("button", {
name: "Duplicate model: GPT-4.1 Disabled",
}),
);
await expect(canvas.findByText("Duplicate Model")).resolves.toBeVisible();
const enabledSwitch = canvas.getByRole("switch", { name: "Enabled" });
expect(enabledSwitch).not.toBeChecked();
expect(enabledSwitch).toBeEnabled();
await userEvent.click(enabledSwitch);
const modelInput = canvas.getByLabelText(/Model Identifier/);
await userEvent.clear(modelInput);
await userEvent.type(modelInput, "gpt-4.1-disabled-copy");
await userEvent.click(
canvas.getByRole("button", { name: "Create duplicate" }),
);
await waitFor(() => expect(args.onCreateModel).toHaveBeenCalledTimes(1));
const createModelMock = args.onCreateModel as ReturnType<typeof fn>;
expect(createModelMock.mock.calls[0]?.[0]).toEqual({
provider: "openai",
model: "gpt-4.1-disabled-copy",
display_name: "GPT-4.1 Disabled",
enabled: true,
is_default: false,
context_limit: 128000,
compression_threshold: 80,
});
},
};
export const DisablesDuplicateWhenProviderCannotManageModels: Story = {
args: {
providerStates: [providerStateWithoutAPIKey],
modelConfigs: [baseModelConfig],
onCreateModel: fn(async () => undefined),
onUpdateModel: fn(async () => undefined),
},
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
const duplicateButton = canvas.getByRole("button", {
name: "Duplicate model: GPT-4.1",
});
expect(duplicateButton).toHaveAttribute("aria-disabled", "true");
await userEvent.click(duplicateButton);
expect(canvas.queryByText("Duplicate Model")).not.toBeInTheDocument();
expect(args.onCreateModel).not.toHaveBeenCalled();
},
};
export const RowActionsDoNotOpenRowBody: Story = {
args: {
modelConfigs: [baseModelConfig, defaultModelConfig, disabledModelConfig],
onCreateModel: fn(async () => undefined),
onUpdateModel: fn(async () => undefined),
},
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
const updateModelMock = args.onUpdateModel as ReturnType<typeof fn>;
await userEvent.click(
canvas.getByRole("button", {
name: "Set as default model: GPT-4.1",
}),
);
await waitFor(() => expect(args.onUpdateModel).toHaveBeenCalledTimes(1));
expect(updateModelMock.mock.calls[0]).toEqual([
"model-config-id",
{ is_default: true },
]);
expect(canvas.queryByText("Edit Model")).not.toBeInTheDocument();
await userEvent.click(
canvas.getByRole("button", { name: "Default model: GPT-4o" }),
);
expect(args.onUpdateModel).toHaveBeenCalledTimes(1);
expect(canvas.queryByText("Edit Model")).not.toBeInTheDocument();
const disabledStarButton = canvas.getByRole("button", {
name: "Set as default model: GPT-4.1 Disabled",
});
expect(disabledStarButton).toHaveAttribute("aria-disabled", "true");
await userEvent.click(disabledStarButton);
expect(args.onUpdateModel).toHaveBeenCalledTimes(1);
expect(canvas.queryByText("Edit Model")).not.toBeInTheDocument();
await userEvent.click(
canvas.getByRole("button", { name: "Duplicate model: GPT-4.1" }),
);
await expect(canvas.findByText("Duplicate Model")).resolves.toBeVisible();
expect(args.onCreateModel).not.toHaveBeenCalled();
expect(args.onUpdateModel).toHaveBeenCalledTimes(1);
expect(canvas.queryByText("Edit Model")).not.toBeInTheDocument();
await userEvent.click(canvas.getByRole("button", { name: "Back" }));
await userEvent.click(
await canvas.findByRole("button", { name: "Edit model: GPT-4.1" }),
);
await expect(canvas.findByText("Edit Model")).resolves.toBeVisible();
},
};
@@ -1,8 +1,9 @@
import { import {
ChevronDownIcon, ChevronDownIcon,
ChevronRightIcon, CopyIcon,
PinIcon, PencilIcon,
PlusIcon, PlusIcon,
StarIcon,
TriangleAlertIcon, TriangleAlertIcon,
} from "lucide-react"; } from "lucide-react";
import type { FC } from "react"; import type { FC } from "react";
@@ -31,15 +32,30 @@ import { hasCustomPricing } from "./pricingFields";
type ModelView = type ModelView =
| { mode: "list" } | { mode: "list" }
| { mode: "add"; provider: string } | { mode: "add"; provider: string }
| { mode: "edit"; model: TypesGen.ChatModelConfig }; | { mode: "edit"; model: TypesGen.ChatModelConfig }
| { mode: "duplicate"; sourceModel: TypesGen.ChatModelConfig };
const MODEL_VIEW_PARAMS = ["model", "newModel", "duplicate"] as const;
type ModelViewParam = (typeof MODEL_VIEW_PARAMS)[number];
const clearModelViewParams = (params: URLSearchParams) => {
for (const param of MODEL_VIEW_PARAMS) {
params.delete(param);
}
};
const canManageProviderModels = (providerState: ProviderState | undefined) => {
return Boolean(
providerState?.providerConfig &&
(providerState.hasEffectiveAPIKey ||
providerState.providerConfig.allow_user_api_key),
);
};
interface ModelsSectionProps { interface ModelsSectionProps {
sectionLabel?: string; sectionLabel?: string;
sectionDescription?: string; sectionDescription?: string;
providerStates: readonly ProviderState[]; providerStates: readonly ProviderState[];
selectedProvider: string | null;
selectedProviderState: ProviderState | null;
onSelectedProviderChange: (provider: string) => void;
modelConfigs: readonly TypesGen.ChatModelConfig[]; modelConfigs: readonly TypesGen.ChatModelConfig[];
modelConfigsUnavailable: boolean; modelConfigsUnavailable: boolean;
isCreating: boolean; isCreating: boolean;
@@ -59,9 +75,6 @@ export const ModelsSection: FC<ModelsSectionProps> = ({
sectionLabel, sectionLabel,
sectionDescription, sectionDescription,
providerStates, providerStates,
selectedProvider,
selectedProviderState,
onSelectedProviderChange,
modelConfigs, modelConfigs,
modelConfigsUnavailable, modelConfigsUnavailable,
isCreating, isCreating,
@@ -90,6 +103,13 @@ export const ModelsSection: FC<ModelsSectionProps> = ({
const model = modelConfigs.find((m) => m.id === editModelId); const model = modelConfigs.find((m) => m.id === editModelId);
return model ? { mode: "edit", model } : { mode: "list" }; return model ? { mode: "edit", model } : { mode: "list" };
} }
const duplicateModelId = searchParams.get("duplicate");
if (duplicateModelId) {
const sourceModel = modelConfigs.find((m) => m.id === duplicateModelId);
return sourceModel
? { mode: "duplicate", sourceModel }
: { mode: "list" };
}
const addProvider = searchParams.get("newModel"); const addProvider = searchParams.get("newModel");
if (addProvider) { if (addProvider) {
return { mode: "add", provider: addProvider }; return { mode: "add", provider: addProvider };
@@ -97,12 +117,25 @@ export const ModelsSection: FC<ModelsSectionProps> = ({
return { mode: "list" }; return { mode: "list" };
})(); })();
const setModelViewParam = (
param: ModelViewParam,
value: string,
options?: { replace?: boolean },
) => {
const nextParams = new URLSearchParams(searchParams);
clearModelViewParams(nextParams);
nextParams.set(param, value);
setSearchParams(nextParams, {
replace: options?.replace,
state: options?.replace ? location.state : { pushed: true },
});
};
// Clear model-related search params and return to the list. // Clear model-related search params and return to the list.
const clearModelView = () => { const clearModelView = () => {
setSearchParams((prev) => { setSearchParams((prev) => {
const next = new URLSearchParams(prev); const next = new URLSearchParams(prev);
next.delete("model"); clearModelViewParams(next);
next.delete("newModel");
return next; return next;
}); });
}; };
@@ -118,8 +151,7 @@ export const ModelsSection: FC<ModelsSectionProps> = ({
setSearchParams( setSearchParams(
(prev) => { (prev) => {
const next = new URLSearchParams(prev); const next = new URLSearchParams(prev);
next.delete("model"); clearModelViewParams(next);
next.delete("newModel");
return next; return next;
}, },
{ replace: true }, { replace: true },
@@ -128,32 +160,42 @@ export const ModelsSection: FC<ModelsSectionProps> = ({
}; };
// When the form is open it takes over the full panel. // When the form is open it takes over the full panel.
if (view.mode === "add" || view.mode === "edit") { if (
view.mode === "add" ||
view.mode === "edit" ||
view.mode === "duplicate"
) {
const editingModel = view.mode === "edit" ? view.model : undefined; const editingModel = view.mode === "edit" ? view.model : undefined;
const duplicateSourceModel =
const getEffectiveProvider = () => { view.mode === "duplicate" ? view.sourceModel : undefined;
if (editingModel) { const effectiveProvider =
return editingModel.provider; view.mode === "edit"
} ? view.model.provider
if (view.mode === "add") { : view.mode === "duplicate"
return view.provider; ? view.sourceModel.provider
} : view.provider;
return selectedProvider; const effectiveProviderState =
}; providerStates.find((ps) => ps.provider === effectiveProvider) ?? null;
const formKey =
const effectiveProvider = getEffectiveProvider(); view.mode === "edit"
const effectiveProviderState = effectiveProvider ? `edit:${view.model.id}`
? (providerStates.find((ps) => ps.provider === effectiveProvider) ?? null) : view.mode === "duplicate"
: selectedProviderState; ? `duplicate:${view.sourceModel.id}`
: `add:${view.provider}`;
return ( return (
<ModelForm <ModelForm
key={editingModel?.id ?? effectiveProvider ?? "new"} key={formKey}
editingModel={editingModel} editingModel={editingModel}
duplicateSourceModel={duplicateSourceModel}
providerStates={providerStates} providerStates={providerStates}
selectedProvider={effectiveProvider} selectedProvider={effectiveProvider}
selectedProviderState={effectiveProviderState} selectedProviderState={effectiveProviderState}
onSelectedProviderChange={onSelectedProviderChange} onSelectedProviderChange={(provider) => {
if (view.mode === "add") {
setModelViewParam("newModel", provider, { replace: true });
}
}}
modelConfigsUnavailable={modelConfigsUnavailable} modelConfigsUnavailable={modelConfigsUnavailable}
isSaving={isCreating || isUpdating} isSaving={isCreating || isUpdating}
isDeleting={isDeleting} isDeleting={isDeleting}
@@ -182,11 +224,7 @@ export const ModelsSection: FC<ModelsSectionProps> = ({
// Only show providers that have a deployment key configured or allow // Only show providers that have a deployment key configured or allow
// end users to bring their own key. // end users to bring their own key.
const addableProviders = providerStates.filter( const addableProviders = providerStates.filter(canManageProviderModels);
(ps) =>
ps.providerConfig &&
(ps.hasEffectiveAPIKey || ps.providerConfig.allow_user_api_key),
);
const addButton = addableProviders.length > 0 && ( const addButton = addableProviders.length > 0 && (
<DropdownMenu> <DropdownMenu>
@@ -202,11 +240,7 @@ export const ModelsSection: FC<ModelsSectionProps> = ({
<DropdownMenuItem <DropdownMenuItem
key={ps.provider} key={ps.provider}
onClick={() => { onClick={() => {
onSelectedProviderChange(ps.provider); setModelViewParam("newModel", ps.provider);
setSearchParams(
{ newModel: ps.provider },
{ state: { pushed: true } },
);
}} }}
className="gap-2" className="gap-2"
> >
@@ -219,7 +253,7 @@ export const ModelsSection: FC<ModelsSectionProps> = ({
); );
const handleSetDefault = (modelConfig: TypesGen.ChatModelConfig) => { const handleSetDefault = (modelConfig: TypesGen.ChatModelConfig) => {
if (modelConfig.is_default || !modelConfig.enabled) return; if (isUpdating || modelConfig.is_default || !modelConfig.enabled) return;
void onUpdateModel(modelConfig.id, { is_default: true }); void onUpdateModel(modelConfig.id, { is_default: true });
}; };
@@ -253,6 +287,17 @@ export const ModelsSection: FC<ModelsSectionProps> = ({
const showPricingWarning = !hasCustomPricing( const showPricingWarning = !hasCustomPricing(
modelConfig.model_config, modelConfig.model_config,
); );
const modelName = modelConfig.display_name || modelConfig.model;
const starLabel = modelConfig.is_default
? `Default model: ${modelName}`
: `Set as default model: ${modelName}`;
const starUnavailable =
isUpdating || modelConfig.is_default || !modelConfig.enabled;
const providerState = providerStates.find(
(ps) => ps.provider === modelConfig.provider,
);
const duplicateUnavailable =
!canManageProviderModels(providerState);
return ( return (
<div <div
@@ -262,16 +307,11 @@ export const ModelsSection: FC<ModelsSectionProps> = ({
i > 0 && "border-0 border-t border-solid border-border/50", i > 0 && "border-0 border-t border-solid border-border/50",
)} )}
> >
{/* Clickable row content */}
<button <button
type="button" type="button"
onClick={() => onClick={() => setModelViewParam("model", modelConfig.id)}
setSearchParams( aria-label={`Open model: ${modelName}`}
{ model: modelConfig.id }, className="flex min-w-0 flex-1 cursor-pointer items-center gap-3.5 border-0 bg-transparent p-0 text-left"
{ state: { pushed: true } },
)
}
className="flex min-w-0 flex-1 cursor-pointer items-center gap-3.5 bg-transparent border-0 p-0 text-left"
> >
<ProviderIcon <ProviderIcon
provider={modelConfig.provider} provider={modelConfig.provider}
@@ -286,7 +326,7 @@ export const ModelsSection: FC<ModelsSectionProps> = ({
: "text-content-primary", : "text-content-primary",
)} )}
> >
{modelConfig.display_name || modelConfig.model} {modelName}
</span> </span>
{showPricingWarning && ( {showPricingWarning && (
<span className="mt-1 flex items-center gap-1 text-xs text-content-warning"> <span className="mt-1 flex items-center gap-1 text-xs text-content-warning">
@@ -301,51 +341,85 @@ export const ModelsSection: FC<ModelsSectionProps> = ({
</Badge> </Badge>
)} )}
</button> </button>
{/* Pin for default */} <div className="flex shrink-0 items-center gap-1">
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <Button
type="button" size="icon"
onClick={(e) => { variant="subtle"
e.stopPropagation(); onClick={(event) => {
handleSetDefault(modelConfig); event.stopPropagation();
}} handleSetDefault(modelConfig);
aria-disabled={ }}
isUpdating || aria-disabled={starUnavailable}
modelConfig.is_default || aria-label={starLabel}
!modelConfig.enabled
}
aria-label={
modelConfig.is_default
? "Default model"
: "Set as default model"
}
className={cn(
"flex shrink-0 items-center justify-center bg-transparent border-0 p-0 transition-colors",
modelConfig.is_default
? "text-content-primary"
: !modelConfig.enabled
? "cursor-not-allowed text-content-secondary/30"
: "cursor-pointer text-content-secondary/30 hover:text-content-secondary",
)}
>
<PinIcon
className={cn( className={cn(
"h-4 w-4", "hover:bg-surface-secondary",
modelConfig.is_default && "fill-current", starUnavailable &&
"cursor-not-allowed text-content-secondary/40 hover:bg-transparent hover:text-content-secondary/40",
modelConfig.is_default && "text-content-primary",
)} )}
/> >
</button> <StarIcon
</TooltipTrigger> className={cn(
<TooltipContent side="left"> modelConfig.is_default && "fill-current",
{!modelConfig.enabled )}
? "Cannot set a disabled model as default" />
: modelConfig.is_default </Button>
? "Pinned as default for new conversations" </TooltipTrigger>
: "Pin as default for new conversations"} <TooltipContent side="top">
</TooltipContent> {!modelConfig.enabled
</Tooltip> ? "Cannot set a disabled model as default"
<ChevronRightIcon className="h-5 w-5 shrink-0 text-content-secondary" /> : modelConfig.is_default
? "Default for new conversations"
: "Set as default for new conversations"}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="subtle"
onClick={(event) => {
event.stopPropagation();
setModelViewParam("model", modelConfig.id);
}}
aria-label={`Edit model: ${modelName}`}
className="hover:bg-surface-secondary"
>
<PencilIcon />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Edit model</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="subtle"
onClick={(event) => {
event.stopPropagation();
if (duplicateUnavailable) return;
setModelViewParam("duplicate", modelConfig.id);
}}
aria-disabled={duplicateUnavailable}
aria-label={`Duplicate model: ${modelName}`}
className={cn(
"hover:bg-surface-secondary",
duplicateUnavailable &&
"cursor-not-allowed text-content-secondary/40 hover:bg-transparent hover:text-content-secondary/40",
)}
>
<CopyIcon />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
{duplicateUnavailable
? "Set an API key for this provider before duplicating models"
: "Duplicate model"}
</TooltipContent>
</Tooltip>
</div>
</div> </div>
); );
})} })}
@@ -25,7 +25,6 @@ interface ProvidersSectionProps {
req: TypesGen.UpdateChatProviderConfigRequest, req: TypesGen.UpdateChatProviderConfigRequest,
) => Promise<unknown>; ) => Promise<unknown>;
onDeleteProvider: (providerConfigId: string) => Promise<void>; onDeleteProvider: (providerConfigId: string) => Promise<void>;
onSelectedProviderChange: (provider: string) => void;
} }
export const ProvidersSection: FC<ProvidersSectionProps> = ({ export const ProvidersSection: FC<ProvidersSectionProps> = ({
@@ -37,7 +36,6 @@ export const ProvidersSection: FC<ProvidersSectionProps> = ({
onCreateProvider, onCreateProvider,
onUpdateProvider, onUpdateProvider,
onDeleteProvider, onDeleteProvider,
onSelectedProviderChange,
}) => { }) => {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -141,7 +139,6 @@ export const ProvidersSection: FC<ProvidersSectionProps> = ({
key={providerState.provider} key={providerState.provider}
aria-label={providerState.label} aria-label={providerState.label}
onClick={() => { onClick={() => {
onSelectedProviderChange(providerState.provider);
setSearchParams( setSearchParams(
{ provider: providerState.provider }, { provider: providerState.provider },
{ state: { pushed: true } }, { state: { pushed: true } },
@@ -221,18 +221,18 @@ export const extractModelConfigFormState = (
// ── Build initial model form values ──────────────────────────── // ── Build initial model form values ────────────────────────────
export const buildInitialModelFormValues = ( export const buildInitialModelFormValues = (
editingModel?: TypesGen.ChatModelConfig, sourceModel?: TypesGen.ChatModelConfig,
): ModelFormValues => ({ ): ModelFormValues => ({
model: editingModel?.model ?? "", model: sourceModel?.model ?? "",
displayName: editingModel?.display_name ?? "", displayName: sourceModel?.display_name ?? "",
enabled: editingModel?.enabled ?? true, enabled: sourceModel?.enabled ?? true,
contextLimit: editingModel ? String(editingModel.context_limit) : "", contextLimit: sourceModel ? String(sourceModel.context_limit) : "",
compressionThreshold: editingModel compressionThreshold: sourceModel
? String(editingModel.compression_threshold) ? String(sourceModel.compression_threshold)
: "", : "",
isDefault: editingModel?.is_default ?? false, isDefault: sourceModel?.is_default ?? false,
config: editingModel config: sourceModel
? extractModelConfigFormState(editingModel) ? extractModelConfigFormState(sourceModel)
: structuredClone(emptyModelConfigFormState), : structuredClone(emptyModelConfigFormState),
}); });