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 { Alert, AlertDescription, AlertTitle } from "#/components/Alert/Alert";
@@ -251,10 +251,6 @@ export const ChatModelAdminPanel: FC<ChatModelAdminPanelProps> = ({
isDeletingModel,
modelMutationError,
}) => {
const [requestedProvider, setRequestedProvider] = useState<string | null>(
null,
);
// ── Sorted model configs ───────────────────────────────────
const modelConfigs = (modelConfigsData ?? []).slice().sort((a, b) => {
const cmp = a.provider.localeCompare(b.provider);
@@ -268,20 +264,6 @@ export const ChatModelAdminPanel: FC<ChatModelAdminPanelProps> = ({
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 modelConfigsUnavailable = modelConfigsData === null;
@@ -306,16 +288,12 @@ export const ChatModelAdminPanel: FC<ChatModelAdminPanelProps> = ({
onCreateProvider={onCreateProvider}
onUpdateProvider={onUpdateProvider}
onDeleteProvider={onDeleteProvider}
onSelectedProviderChange={setRequestedProvider}
/>
) : (
<ModelsSection
sectionLabel={sectionLabel}
sectionDescription={sectionDescription}
providerStates={providerStates}
selectedProvider={selectedProvider}
selectedProviderState={selectedProviderState}
onSelectedProviderChange={setRequestedProvider}
modelConfigs={modelConfigs}
modelConfigsUnavailable={modelConfigsUnavailable}
isCreating={isCreatingModel}
@@ -75,6 +75,8 @@ const validationSchema = Yup.object({
interface ModelFormProps {
/** When set, the form is in "edit" mode for the given model. */
editingModel?: TypesGen.ChatModelConfig;
/** When set without editingModel, the form creates from this model. */
duplicateSourceModel?: TypesGen.ChatModelConfig;
providerStates: readonly ProviderState[];
selectedProvider: string | null;
selectedProviderState: ProviderState | null;
@@ -95,6 +97,7 @@ interface ModelFormProps {
export const ModelForm: FC<ModelFormProps> = ({
editingModel,
duplicateSourceModel,
providerStates,
selectedProvider,
selectedProviderState,
@@ -107,8 +110,13 @@ export const ModelForm: FC<ModelFormProps> = ({
onCancel,
onDeleteModel,
}) => {
const initialModel = editingModel ?? duplicateSourceModel;
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 [showPricing, setShowPricing] = useState(false);
const [showProviderConfig, setShowProviderConfig] = useState(false);
@@ -119,9 +127,17 @@ export const ModelForm: FC<ModelFormProps> = ({
(selectedProviderState.hasEffectiveAPIKey ||
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>({
initialValues: buildInitialModelFormValues(editingModel),
initialValues,
validationSchema,
validateOnMount: true,
validateOnBlur: false,
@@ -137,7 +153,7 @@ export const ModelForm: FC<ModelFormProps> = ({
);
const buildResult = buildModelConfigFromForm(
selectedProviderState?.provider,
selectedProvider,
values.config,
);
if (Object.keys(buildResult.fieldErrors).length > 0) return;
@@ -174,12 +190,13 @@ export const ModelForm: FC<ModelFormProps> = ({
await onUpdateModel(editingModel.id, req);
} else {
if (!selectedProviderState?.providerConfig) return;
if (!selectedProvider || !selectedProviderState?.providerConfig) return;
const req: TypesGen.CreateChatModelConfigRequest = {
provider: selectedProviderState.provider,
provider: selectedProvider,
model: trimmedModel,
enabled: true,
enabled: values.enabled,
is_default: values.isDefault,
...(parsedContextLimit !== null && {
context_limit: parsedContextLimit,
}),
@@ -189,9 +206,6 @@ export const ModelForm: FC<ModelFormProps> = ({
...(trimmedDisplayName && {
display_name: trimmedDisplayName,
}),
...(values.isDefault && {
is_default: true,
}),
...(builtModelConfig && {
model_config: builtModelConfig,
}),
@@ -208,13 +222,14 @@ export const ModelForm: FC<ModelFormProps> = ({
const getFieldHelpers = getFormHelpers(form);
const modelConfigFormBuildResult = buildModelConfigFromForm(
selectedProviderState?.provider,
selectedProvider,
form.values.config,
);
const hasFieldErrors =
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) ───────
@@ -229,7 +244,7 @@ export const ModelForm: FC<ModelFormProps> = ({
<Select
value={selectedProvider ?? ""}
onValueChange={onSelectedProviderChange}
disabled={isEditing || providerStates.length === 0}
disabled={isEditing || isDuplicating || providerStates.length === 0}
>
<SelectTrigger
id="providerSelect"
@@ -257,7 +272,7 @@ export const ModelForm: FC<ModelFormProps> = ({
<div>
<BackButton onClick={onCancel} />
<h2 className="m-0 text-lg font-medium text-content-primary">
{isEditing ? "Edit Model" : "Add Model"}
{formTitle}
</h2>
<hr className="my-4 border-0 border-t border-solid border-border" />
<div className="space-y-3">{providerSelect}</div>
@@ -271,15 +286,15 @@ export const ModelForm: FC<ModelFormProps> = ({
<div>
<BackButton onClick={onCancel} />
<h2 className="m-0 text-lg font-medium text-content-primary">
Add Model
{formTitle}
</h2>
<hr className="my-4 border-0 border-t border-solid border-border" />
<div className="space-y-3">
{providerSelect}
<p className="text-sm text-content-secondary">
{!selectedProviderState.providerConfig
? "Create a managed provider config on the Providers tab before adding models."
: "Set an API key for this provider 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 managing models."}
</p>
</div>
</div>
@@ -295,7 +310,18 @@ export const ModelForm: FC<ModelFormProps> = ({
return (
<div className="flex min-h-full flex-col">
{/* 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">
{selectedProviderState && (
<ProviderIcon
@@ -309,10 +335,7 @@ export const ModelForm: FC<ModelFormProps> = ({
className="invisible col-start-1 row-start-1 whitespace-pre text-lg font-medium"
aria-hidden="true"
>
{form.values.displayName ||
(isEditing
? (editingModel?.model ?? "Model name")
: "Model name")}
{form.values.displayName || initialModel?.model || "Model name"}
</span>
<input
type="text"
@@ -320,14 +343,12 @@ export const ModelForm: FC<ModelFormProps> = ({
disabled={isSaving}
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"
placeholder={
isEditing ? (editingModel?.model ?? "Model name") : "Model name"
}
placeholder={initialModel?.model ?? "Model name"}
/>
</div>
<PencilIcon className="h-3.5 w-3.5 shrink-0 text-content-secondary" />
</div>{" "}
{editingModel && (
{initialModel && (
<Tooltip>
<TooltipTrigger asChild>
<span className="ml-auto inline-flex">
@@ -633,7 +654,11 @@ export const ModelForm: FC<ModelFormProps> = ({
disabled={isSaving || !form.isValid || hasFieldErrors}
>
{isSaving && <Spinner className="h-4 w-4" loading />}{" "}
{isEditing ? "Save" : "Add model"}
{isEditing
? "Save"
: isDuplicating
? "Create duplicate"
: "Add model"}
</Button>
</div>
</div>
@@ -1,5 +1,5 @@
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 { TooltipProvider } from "#/components/Tooltip/Tooltip";
import type { ProviderState } from "./ChatModelAdminPanel";
@@ -31,6 +31,19 @@ const providerState: ProviderState = {
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 = {
id: "model-config-id",
provider: "openai",
@@ -44,15 +57,47 @@ const baseModelConfig: TypesGen.ChatModelConfig = {
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> = {
title: "pages/AgentsPage/ChatModelAdminPanel/ModelsSection",
component: ModelsSection,
args: {
sectionLabel: "Models",
providerStates: [providerState],
selectedProvider: "openai",
selectedProviderState: providerState,
onSelectedProviderChange: fn(),
modelConfigs: [baseModelConfig],
modelConfigsUnavailable: false,
isCreating: false,
@@ -104,3 +149,306 @@ export const HidesPricingWarningForExplicitZeroPricing: Story = {
).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 {
ChevronDownIcon,
ChevronRightIcon,
PinIcon,
CopyIcon,
PencilIcon,
PlusIcon,
StarIcon,
TriangleAlertIcon,
} from "lucide-react";
import type { FC } from "react";
@@ -31,15 +32,30 @@ import { hasCustomPricing } from "./pricingFields";
type ModelView =
| { mode: "list" }
| { 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 {
sectionLabel?: string;
sectionDescription?: string;
providerStates: readonly ProviderState[];
selectedProvider: string | null;
selectedProviderState: ProviderState | null;
onSelectedProviderChange: (provider: string) => void;
modelConfigs: readonly TypesGen.ChatModelConfig[];
modelConfigsUnavailable: boolean;
isCreating: boolean;
@@ -59,9 +75,6 @@ export const ModelsSection: FC<ModelsSectionProps> = ({
sectionLabel,
sectionDescription,
providerStates,
selectedProvider,
selectedProviderState,
onSelectedProviderChange,
modelConfigs,
modelConfigsUnavailable,
isCreating,
@@ -90,6 +103,13 @@ export const ModelsSection: FC<ModelsSectionProps> = ({
const model = modelConfigs.find((m) => m.id === editModelId);
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");
if (addProvider) {
return { mode: "add", provider: addProvider };
@@ -97,12 +117,25 @@ export const ModelsSection: FC<ModelsSectionProps> = ({
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.
const clearModelView = () => {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.delete("model");
next.delete("newModel");
clearModelViewParams(next);
return next;
});
};
@@ -118,8 +151,7 @@ export const ModelsSection: FC<ModelsSectionProps> = ({
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
next.delete("model");
next.delete("newModel");
clearModelViewParams(next);
return next;
},
{ replace: true },
@@ -128,32 +160,42 @@ export const ModelsSection: FC<ModelsSectionProps> = ({
};
// 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 getEffectiveProvider = () => {
if (editingModel) {
return editingModel.provider;
}
if (view.mode === "add") {
return view.provider;
}
return selectedProvider;
};
const effectiveProvider = getEffectiveProvider();
const effectiveProviderState = effectiveProvider
? (providerStates.find((ps) => ps.provider === effectiveProvider) ?? null)
: selectedProviderState;
const duplicateSourceModel =
view.mode === "duplicate" ? view.sourceModel : undefined;
const effectiveProvider =
view.mode === "edit"
? view.model.provider
: view.mode === "duplicate"
? view.sourceModel.provider
: view.provider;
const effectiveProviderState =
providerStates.find((ps) => ps.provider === effectiveProvider) ?? null;
const formKey =
view.mode === "edit"
? `edit:${view.model.id}`
: view.mode === "duplicate"
? `duplicate:${view.sourceModel.id}`
: `add:${view.provider}`;
return (
<ModelForm
key={editingModel?.id ?? effectiveProvider ?? "new"}
key={formKey}
editingModel={editingModel}
duplicateSourceModel={duplicateSourceModel}
providerStates={providerStates}
selectedProvider={effectiveProvider}
selectedProviderState={effectiveProviderState}
onSelectedProviderChange={onSelectedProviderChange}
onSelectedProviderChange={(provider) => {
if (view.mode === "add") {
setModelViewParam("newModel", provider, { replace: true });
}
}}
modelConfigsUnavailable={modelConfigsUnavailable}
isSaving={isCreating || isUpdating}
isDeleting={isDeleting}
@@ -182,11 +224,7 @@ export const ModelsSection: FC<ModelsSectionProps> = ({
// Only show providers that have a deployment key configured or allow
// end users to bring their own key.
const addableProviders = providerStates.filter(
(ps) =>
ps.providerConfig &&
(ps.hasEffectiveAPIKey || ps.providerConfig.allow_user_api_key),
);
const addableProviders = providerStates.filter(canManageProviderModels);
const addButton = addableProviders.length > 0 && (
<DropdownMenu>
@@ -202,11 +240,7 @@ export const ModelsSection: FC<ModelsSectionProps> = ({
<DropdownMenuItem
key={ps.provider}
onClick={() => {
onSelectedProviderChange(ps.provider);
setSearchParams(
{ newModel: ps.provider },
{ state: { pushed: true } },
);
setModelViewParam("newModel", ps.provider);
}}
className="gap-2"
>
@@ -219,7 +253,7 @@ export const ModelsSection: FC<ModelsSectionProps> = ({
);
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 });
};
@@ -253,6 +287,17 @@ export const ModelsSection: FC<ModelsSectionProps> = ({
const showPricingWarning = !hasCustomPricing(
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 (
<div
@@ -262,16 +307,11 @@ export const ModelsSection: FC<ModelsSectionProps> = ({
i > 0 && "border-0 border-t border-solid border-border/50",
)}
>
{/* Clickable row content */}
<button
type="button"
onClick={() =>
setSearchParams(
{ model: modelConfig.id },
{ 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"
onClick={() => setModelViewParam("model", modelConfig.id)}
aria-label={`Open model: ${modelName}`}
className="flex min-w-0 flex-1 cursor-pointer items-center gap-3.5 border-0 bg-transparent p-0 text-left"
>
<ProviderIcon
provider={modelConfig.provider}
@@ -286,7 +326,7 @@ export const ModelsSection: FC<ModelsSectionProps> = ({
: "text-content-primary",
)}
>
{modelConfig.display_name || modelConfig.model}
{modelName}
</span>
{showPricingWarning && (
<span className="mt-1 flex items-center gap-1 text-xs text-content-warning">
@@ -301,51 +341,85 @@ export const ModelsSection: FC<ModelsSectionProps> = ({
</Badge>
)}
</button>
{/* Pin for default */}
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleSetDefault(modelConfig);
}}
aria-disabled={
isUpdating ||
modelConfig.is_default ||
!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
<div className="flex shrink-0 items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="subtle"
onClick={(event) => {
event.stopPropagation();
handleSetDefault(modelConfig);
}}
aria-disabled={starUnavailable}
aria-label={starLabel}
className={cn(
"h-4 w-4",
modelConfig.is_default && "fill-current",
"hover:bg-surface-secondary",
starUnavailable &&
"cursor-not-allowed text-content-secondary/40 hover:bg-transparent hover:text-content-secondary/40",
modelConfig.is_default && "text-content-primary",
)}
/>
</button>
</TooltipTrigger>
<TooltipContent side="left">
{!modelConfig.enabled
? "Cannot set a disabled model as default"
: modelConfig.is_default
? "Pinned as default for new conversations"
: "Pin as default for new conversations"}
</TooltipContent>
</Tooltip>
<ChevronRightIcon className="h-5 w-5 shrink-0 text-content-secondary" />
>
<StarIcon
className={cn(
modelConfig.is_default && "fill-current",
)}
/>
</Button>
</TooltipTrigger>
<TooltipContent side="top">
{!modelConfig.enabled
? "Cannot set a disabled model as default"
: 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>
);
})}
@@ -25,7 +25,6 @@ interface ProvidersSectionProps {
req: TypesGen.UpdateChatProviderConfigRequest,
) => Promise<unknown>;
onDeleteProvider: (providerConfigId: string) => Promise<void>;
onSelectedProviderChange: (provider: string) => void;
}
export const ProvidersSection: FC<ProvidersSectionProps> = ({
@@ -37,7 +36,6 @@ export const ProvidersSection: FC<ProvidersSectionProps> = ({
onCreateProvider,
onUpdateProvider,
onDeleteProvider,
onSelectedProviderChange,
}) => {
const [searchParams, setSearchParams] = useSearchParams();
const navigate = useNavigate();
@@ -141,7 +139,6 @@ export const ProvidersSection: FC<ProvidersSectionProps> = ({
key={providerState.provider}
aria-label={providerState.label}
onClick={() => {
onSelectedProviderChange(providerState.provider);
setSearchParams(
{ provider: providerState.provider },
{ state: { pushed: true } },
@@ -221,18 +221,18 @@ export const extractModelConfigFormState = (
// ── Build initial model form values ────────────────────────────
export const buildInitialModelFormValues = (
editingModel?: TypesGen.ChatModelConfig,
sourceModel?: TypesGen.ChatModelConfig,
): ModelFormValues => ({
model: editingModel?.model ?? "",
displayName: editingModel?.display_name ?? "",
enabled: editingModel?.enabled ?? true,
contextLimit: editingModel ? String(editingModel.context_limit) : "",
compressionThreshold: editingModel
? String(editingModel.compression_threshold)
model: sourceModel?.model ?? "",
displayName: sourceModel?.display_name ?? "",
enabled: sourceModel?.enabled ?? true,
contextLimit: sourceModel ? String(sourceModel.context_limit) : "",
compressionThreshold: sourceModel
? String(sourceModel.compression_threshold)
: "",
isDefault: editingModel?.is_default ?? false,
config: editingModel
? extractModelConfigFormState(editingModel)
isDefault: sourceModel?.is_default ?? false,
config: sourceModel
? extractModelConfigFormState(sourceModel)
: structuredClone(emptyModelConfigFormState),
});