From ebed01ac55df0318c2c685c14ab76e1c44aa545f Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 27 Apr 2026 17:55:47 +0200 Subject: [PATCH] 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. --- .../ChatModelAdminPanel.tsx | 24 +- .../ChatModelAdminPanel/ModelForm.tsx | 77 ++-- .../ModelsSection.stories.tsx | 356 +++++++++++++++++- .../ChatModelAdminPanel/ModelsSection.tsx | 262 ++++++++----- .../ChatModelAdminPanel/ProvidersSection.tsx | 3 - .../modelConfigFormLogic.ts | 20 +- 6 files changed, 582 insertions(+), 160 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ChatModelAdminPanel.tsx b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ChatModelAdminPanel.tsx index 0b4dcc0768..fdc617c618 100644 --- a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ChatModelAdminPanel.tsx +++ b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ChatModelAdminPanel.tsx @@ -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 = ({ isDeletingModel, modelMutationError, }) => { - const [requestedProvider, setRequestedProvider] = useState( - 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 = ({ 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 = ({ onCreateProvider={onCreateProvider} onUpdateProvider={onUpdateProvider} onDeleteProvider={onDeleteProvider} - onSelectedProviderChange={setRequestedProvider} /> ) : ( = ({ editingModel, + duplicateSourceModel, providerStates, selectedProvider, selectedProviderState, @@ -107,8 +110,13 @@ export const ModelForm: FC = ({ 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 = ({ (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({ - initialValues: buildInitialModelFormValues(editingModel), + initialValues, validationSchema, validateOnMount: true, validateOnBlur: false, @@ -137,7 +153,7 @@ export const ModelForm: FC = ({ ); const buildResult = buildModelConfigFromForm( - selectedProviderState?.provider, + selectedProvider, values.config, ); if (Object.keys(buildResult.fieldErrors).length > 0) return; @@ -174,12 +190,13 @@ export const ModelForm: FC = ({ 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 = ({ ...(trimmedDisplayName && { display_name: trimmedDisplayName, }), - ...(values.isDefault && { - is_default: true, - }), ...(builtModelConfig && { model_config: builtModelConfig, }), @@ -208,13 +222,14 @@ export const ModelForm: FC = ({ 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 = ({ = ({ 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"} /> {" "} - {editingModel && ( + {initialModel && ( @@ -633,7 +654,11 @@ export const ModelForm: FC = ({ disabled={isSaving || !form.isValid || hasFieldErrors} > {isSaving && }{" "} - {isEditing ? "Save" : "Add model"} + {isEditing + ? "Save" + : isDuplicating + ? "Create duplicate" + : "Add model"} diff --git a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ModelsSection.stories.tsx b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ModelsSection.stories.tsx index 8d6451282b..3f79b4d404 100644 --- a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ModelsSection.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ModelsSection.stories.tsx @@ -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 = { 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; + 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; + 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; + 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; + + 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(); + }, +}; diff --git a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ModelsSection.tsx b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ModelsSection.tsx index ddb15d9d2e..4d7bab50ee 100644 --- a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ModelsSection.tsx +++ b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ModelsSection.tsx @@ -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 = ({ sectionLabel, sectionDescription, providerStates, - selectedProvider, - selectedProviderState, - onSelectedProviderChange, modelConfigs, modelConfigsUnavailable, isCreating, @@ -90,6 +103,13 @@ export const ModelsSection: FC = ({ 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 = ({ 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 = ({ 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 = ({ }; // 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 ( { + if (view.mode === "add") { + setModelViewParam("newModel", provider, { replace: true }); + } + }} modelConfigsUnavailable={modelConfigsUnavailable} isSaving={isCreating || isUpdating} isDeleting={isDeleting} @@ -182,11 +224,7 @@ export const ModelsSection: FC = ({ // 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 && ( @@ -202,11 +240,7 @@ export const ModelsSection: FC = ({ { - 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 = ({ ); 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 = ({ 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 (
= ({ i > 0 && "border-0 border-t border-solid border-border/50", )} > - {/* Clickable row content */} - {/* Pin for default */} - - - - - - {!modelConfig.enabled - ? "Cannot set a disabled model as default" - : modelConfig.is_default - ? "Pinned as default for new conversations" - : "Pin as default for new conversations"} - - - + > + + + + + {!modelConfig.enabled + ? "Cannot set a disabled model as default" + : modelConfig.is_default + ? "Default for new conversations" + : "Set as default for new conversations"} + + + + + + + Edit model + + + + + + + {duplicateUnavailable + ? "Set an API key for this provider before duplicating models" + : "Duplicate model"} + + +
); })} diff --git a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ProvidersSection.tsx b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ProvidersSection.tsx index 75e0ca2fcb..911bf2dec1 100644 --- a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ProvidersSection.tsx +++ b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ProvidersSection.tsx @@ -25,7 +25,6 @@ interface ProvidersSectionProps { req: TypesGen.UpdateChatProviderConfigRequest, ) => Promise; onDeleteProvider: (providerConfigId: string) => Promise; - onSelectedProviderChange: (provider: string) => void; } export const ProvidersSection: FC = ({ @@ -37,7 +36,6 @@ export const ProvidersSection: FC = ({ onCreateProvider, onUpdateProvider, onDeleteProvider, - onSelectedProviderChange, }) => { const [searchParams, setSearchParams] = useSearchParams(); const navigate = useNavigate(); @@ -141,7 +139,6 @@ export const ProvidersSection: FC = ({ key={providerState.provider} aria-label={providerState.label} onClick={() => { - onSelectedProviderChange(providerState.provider); setSearchParams( { provider: providerState.provider }, { state: { pushed: true } }, diff --git a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/modelConfigFormLogic.ts b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/modelConfigFormLogic.ts index d832473387..1a0ed7133c 100644 --- a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/modelConfigFormLogic.ts +++ b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/modelConfigFormLogic.ts @@ -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), });