From f56563b40666023c95f5c7724dbc9ed0e3f3e618 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Wed, 4 Mar 2026 10:09:13 -0500 Subject: [PATCH] fix(site): replace modal delete confirmation with inline UI in agents admin (#22587) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem The agents admin panel (`/agents` → Admin button) is rendered inside a Radix Dialog (`ConfigureAgentsDialog`). Deleting a model or provider previously opened a MUI `DeleteDialog` on top, creating a modal-on-modal situation. The two dialog systems (Radix and MUI) don't coordinate focus trapping, scroll locking, or backdrop behavior, so the delete confirmation was broken. ## Solution Replace the modal `DeleteDialog` in both `ModelForm` and `ProviderForm` with an inline confirmation strip rendered in the footer area. Clicking "Delete" now swaps the footer to show: - A warning message ("Are you sure? This action is irreversible.") - Cancel and a destructive confirm button with loading spinner This keeps everything within the existing Radix Dialog content pane — no layering issues, no second modal. ## Changes | File | Change | |---|---| | `ModelForm.tsx` | Added `isDeleting` prop, changed `onDeleteModel` signature to async, added `confirmingDelete` state, inline confirmation footer | | `ProviderForm.tsx` | Removed `DeleteDialog` import/usage, replaced with inline confirmation footer | | `ModelsSection.tsx` | Removed `DeleteDialog` import/usage, removed `modelToDelete` state, passes new props to `ModelForm` | --- .../ChatModelAdminPanel.stories.tsx | 250 ++++++++++++++++++ .../ChatModelAdminPanel/ModelForm.tsx | 90 +++++-- .../ChatModelAdminPanel/ModelsSection.tsx | 23 +- .../ChatModelAdminPanel/ProviderForm.tsx | 91 ++++--- 4 files changed, 368 insertions(+), 86 deletions(-) diff --git a/site/src/pages/AgentsPage/ChatModelAdminPanel/ChatModelAdminPanel.stories.tsx b/site/src/pages/AgentsPage/ChatModelAdminPanel/ChatModelAdminPanel.stories.tsx index 6d059d75d3..435797e2d2 100644 --- a/site/src/pages/AgentsPage/ChatModelAdminPanel/ChatModelAdminPanel.stories.tsx +++ b/site/src/pages/AgentsPage/ChatModelAdminPanel/ChatModelAdminPanel.stories.tsx @@ -621,6 +621,256 @@ export const ModelFormBedrock: Story = { }, }; +export const ModelDeleteConfirmation: Story = { + args: { section: "models" as ChatModelAdminSection }, + beforeEach: () => { + setupChatSpies({ + providerConfigs: [ + createProviderConfig({ + id: "provider-openai", + provider: "openai", + display_name: "OpenAI", + source: "database", + has_api_key: true, + }), + ], + modelConfigs: [ + createModelConfig({ + id: "model-1", + provider: "openai", + model: "gpt-4o", + display_name: "GPT-4o", + }), + ], + modelCatalog: { providers: [] }, + }); + }, + play: async ({ canvasElement }) => { + const body = within(canvasElement.ownerDocument.body); + + // Click the model row to open the edit form. + await userEvent.click(await body.findByText("GPT-4o")); + + // The Delete button should be visible in the footer. + const deleteButton = await body.findByRole("button", { name: "Delete" }); + await expect(deleteButton).toBeInTheDocument(); + + // Click Delete to show the inline confirmation. + await userEvent.click(deleteButton); + + // The confirmation strip should appear — leave it visible + // so the Chromatic snapshot captures this state. + await expect( + await body.findByText(/Are you sure\? This action is irreversible/i), + ).toBeInTheDocument(); + await expect( + body.getByRole("button", { name: "Delete model" }), + ).toBeInTheDocument(); + await expect( + body.getByRole("button", { name: "Cancel" }), + ).toBeInTheDocument(); + }, +}; + +export const ModelDeleteCancelled: Story = { + args: { section: "models" as ChatModelAdminSection }, + beforeEach: () => { + setupChatSpies({ + providerConfigs: [ + createProviderConfig({ + id: "provider-openai", + provider: "openai", + display_name: "OpenAI", + source: "database", + has_api_key: true, + }), + ], + modelConfigs: [ + createModelConfig({ + id: "model-1", + provider: "openai", + model: "gpt-4o", + display_name: "GPT-4o", + }), + ], + modelCatalog: { providers: [] }, + }); + }, + play: async ({ canvasElement }) => { + const body = within(canvasElement.ownerDocument.body); + + // Navigate to edit form, trigger confirmation, then cancel. + await userEvent.click(await body.findByText("GPT-4o")); + await userEvent.click(await body.findByRole("button", { name: "Delete" })); + await body.findByText(/Are you sure/i); + await userEvent.click(body.getByRole("button", { name: "Cancel" })); + + // Normal footer should be restored. + await expect( + await body.findByRole("button", { name: "Delete" }), + ).toBeInTheDocument(); + await expect( + await body.findByRole("button", { name: "Save" }), + ).toBeInTheDocument(); + }, +}; + +export const ModelDeleteConfirmed: Story = { + args: { section: "models" as ChatModelAdminSection }, + beforeEach: () => { + setupChatSpies({ + providerConfigs: [ + createProviderConfig({ + id: "provider-openai", + provider: "openai", + display_name: "OpenAI", + source: "database", + has_api_key: true, + }), + ], + modelConfigs: [ + createModelConfig({ + id: "model-1", + provider: "openai", + model: "gpt-4o", + display_name: "GPT-4o", + }), + ], + modelCatalog: { providers: [] }, + }); + }, + play: async ({ canvasElement }) => { + const body = within(canvasElement.ownerDocument.body); + + // Navigate to edit form, trigger delete confirmation, then confirm. + await userEvent.click(await body.findByText("GPT-4o")); + await userEvent.click(await body.findByRole("button", { name: "Delete" })); + await userEvent.click( + await body.findByRole("button", { name: "Delete model" }), + ); + + // The delete API should have been called. + await waitFor(() => { + expect(API.deleteChatModelConfig).toHaveBeenCalledTimes(1); + }); + expect(API.deleteChatModelConfig).toHaveBeenCalledWith("model-1"); + }, +}; + +export const ProviderDeleteConfirmation: Story = { + args: { section: "providers" as ChatModelAdminSection }, + beforeEach: () => { + setupChatSpies({ + providerConfigs: [ + createProviderConfig({ + id: "provider-openai", + provider: "openai", + display_name: "OpenAI", + source: "database", + has_api_key: true, + }), + ], + modelConfigs: [], + modelCatalog: { providers: [] }, + }); + }, + play: async ({ canvasElement }) => { + const body = within(canvasElement.ownerDocument.body); + + // Navigate to the provider detail view. + await userEvent.click(await body.findByRole("button", { name: /OpenAI/i })); + + // Click Delete to show the inline confirmation. + const deleteButton = await body.findByRole("button", { name: "Delete" }); + await userEvent.click(deleteButton); + + // The confirmation strip should appear — leave it visible + // so the Chromatic snapshot captures this state. + await expect( + await body.findByText(/Are you sure\? This action is irreversible/i), + ).toBeInTheDocument(); + await expect( + body.getByRole("button", { name: "Delete provider" }), + ).toBeInTheDocument(); + await expect( + body.getByRole("button", { name: "Cancel" }), + ).toBeInTheDocument(); + }, +}; + +export const ProviderDeleteCancelled: Story = { + args: { section: "providers" as ChatModelAdminSection }, + beforeEach: () => { + setupChatSpies({ + providerConfigs: [ + createProviderConfig({ + id: "provider-openai", + provider: "openai", + display_name: "OpenAI", + source: "database", + has_api_key: true, + }), + ], + modelConfigs: [], + modelCatalog: { providers: [] }, + }); + }, + play: async ({ canvasElement }) => { + const body = within(canvasElement.ownerDocument.body); + + // Navigate to provider detail, trigger confirmation, then cancel. + await userEvent.click(await body.findByRole("button", { name: /OpenAI/i })); + await userEvent.click(await body.findByRole("button", { name: "Delete" })); + await body.findByText(/Are you sure/i); + await userEvent.click(body.getByRole("button", { name: "Cancel" })); + + // Normal footer should be restored. + await expect( + await body.findByRole("button", { name: "Delete" }), + ).toBeInTheDocument(); + await expect( + await body.findByRole("button", { name: "Save changes" }), + ).toBeInTheDocument(); + }, +}; + +export const ProviderDeleteConfirmed: Story = { + args: { section: "providers" as ChatModelAdminSection }, + beforeEach: () => { + setupChatSpies({ + providerConfigs: [ + createProviderConfig({ + id: "provider-openai", + provider: "openai", + display_name: "OpenAI", + source: "database", + has_api_key: true, + }), + ], + modelConfigs: [], + modelCatalog: { providers: [] }, + }); + }, + play: async ({ canvasElement }) => { + const body = within(canvasElement.ownerDocument.body); + + // Navigate to provider detail, trigger delete, then confirm. + await userEvent.click(await body.findByRole("button", { name: /OpenAI/i })); + await userEvent.click(await body.findByRole("button", { name: "Delete" })); + await userEvent.click( + await body.findByRole("button", { name: "Delete provider" }), + ); + + // The delete API should have been called. + await waitFor(() => { + expect(API.deleteChatProviderConfig).toHaveBeenCalledTimes(1); + }); + expect(API.deleteChatProviderConfig).toHaveBeenCalledWith( + "provider-openai", + ); + }, +}; + export const ValidatesModelConfigFields: Story = { args: { section: "models" as ChatModelAdminSection }, beforeEach: () => { diff --git a/site/src/pages/AgentsPage/ChatModelAdminPanel/ModelForm.tsx b/site/src/pages/AgentsPage/ChatModelAdminPanel/ModelForm.tsx index 6fbe00ef2a..c242886d56 100644 --- a/site/src/pages/AgentsPage/ChatModelAdminPanel/ModelForm.tsx +++ b/site/src/pages/AgentsPage/ChatModelAdminPanel/ModelForm.tsx @@ -64,6 +64,7 @@ type ModelFormProps = { onSelectedProviderChange: (provider: string) => void; modelConfigsUnavailable: boolean; isSaving: boolean; + isDeleting: boolean; onCreateModel: ( req: TypesGen.CreateChatModelConfigRequest, ) => Promise; @@ -72,7 +73,7 @@ type ModelFormProps = { req: TypesGen.UpdateChatModelConfigRequest, ) => Promise; onCancel: () => void; - onDeleteModel?: () => void; + onDeleteModel?: (modelConfigId: string) => Promise; }; export const ModelForm: FC = ({ @@ -83,6 +84,7 @@ export const ModelForm: FC = ({ onSelectedProviderChange, modelConfigsUnavailable, isSaving, + isDeleting, onCreateModel, onUpdateModel, onCancel, @@ -90,6 +92,7 @@ export const ModelForm: FC = ({ }) => { const isEditing = Boolean(editingModel); const [showAdvanced, setShowAdvanced] = useState(false); + const [confirmingDelete, setConfirmingDelete] = useState(false); const canManageModels = Boolean( selectedProviderState?.providerConfig && @@ -462,37 +465,68 @@ export const ModelForm: FC = ({ {/* Footer — pushed to bottom */}

-
- {isEditing && editingModel && onDeleteModel ? ( + {confirmingDelete && onDeleteModel && editingModel ? ( +
+

+ Are you sure? This action is irreversible. +

+
+ + +
+
+ ) : ( +
+ {isEditing && editingModel && onDeleteModel ? ( + + ) : ( + + )}{" "} - ) : ( - - )}{" "} - -
+
+ )}
diff --git a/site/src/pages/AgentsPage/ChatModelAdminPanel/ModelsSection.tsx b/site/src/pages/AgentsPage/ChatModelAdminPanel/ModelsSection.tsx index 47228f788d..ffbb1c7c2b 100644 --- a/site/src/pages/AgentsPage/ChatModelAdminPanel/ModelsSection.tsx +++ b/site/src/pages/AgentsPage/ChatModelAdminPanel/ModelsSection.tsx @@ -1,7 +1,6 @@ import type * as TypesGen from "api/typesGenerated"; import { Badge } from "components/Badge/Badge"; import { Button } from "components/Button/Button"; -import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog"; import { DropdownMenu, DropdownMenuContent, @@ -68,8 +67,6 @@ export const ModelsSection: FC = ({ onDeleteModel, }) => { const [view, setView] = useState({ mode: "list" }); - const [modelToDelete, setModelToDelete] = - useState(null); // When the form is open it takes over the full panel. if (view.mode === "add" || view.mode === "edit") { @@ -100,6 +97,7 @@ export const ModelsSection: FC = ({ onSelectedProviderChange={onSelectedProviderChange} modelConfigsUnavailable={modelConfigsUnavailable} isSaving={isCreating || isUpdating} + isDeleting={isDeleting} onCreateModel={async (req) => { await onCreateModel(req); setView({ mode: "list" }); @@ -111,8 +109,8 @@ export const ModelsSection: FC = ({ onCancel={() => setView({ mode: "list" })} onDeleteModel={ editingModel - ? () => { - setModelToDelete(editingModel); + ? async (id) => { + await onDeleteModel(id); setView({ mode: "list" }); } : undefined @@ -255,21 +253,6 @@ export const ModelsSection: FC = ({ ))} )} - - setModelToDelete(null)} - onConfirm={() => { - if (modelToDelete) { - void onDeleteModel(modelToDelete.id).finally(() => - setModelToDelete(null), - ); - } - }} - entity="model" - name={modelToDelete?.display_name || modelToDelete?.model || ""} - confirmLoading={isDeleting} - /> ); }; diff --git a/site/src/pages/AgentsPage/ChatModelAdminPanel/ProviderForm.tsx b/site/src/pages/AgentsPage/ChatModelAdminPanel/ProviderForm.tsx index cf0d3dfad6..437a3cd66b 100644 --- a/site/src/pages/AgentsPage/ChatModelAdminPanel/ProviderForm.tsx +++ b/site/src/pages/AgentsPage/ChatModelAdminPanel/ProviderForm.tsx @@ -1,7 +1,6 @@ import type * as TypesGen from "api/typesGenerated"; import { Alert, AlertDetail, AlertTitle } from "components/Alert/Alert"; import { Button } from "components/Button/Button"; -import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog"; import { Input } from "components/Input/Input"; import { Tooltip, @@ -62,7 +61,7 @@ export const ProviderForm: FC = ({ ); const [apiKeyTouched, setApiKeyTouched] = useState(false); const [baseURLValue, setBaseURLValue] = useState(initialValues.baseURL); - const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [confirmingDelete, setConfirmingDelete] = useState(false); const isAPIKeyEnvManaged = isEnvPreset && !providerConfig; const requiresAPIKey = !providerConfig && !isAPIKeyEnvManaged; @@ -253,46 +252,62 @@ export const ProviderForm: FC = ({ {/* Footer — pushed to bottom */}

-
- {providerConfig ? ( - - ) : ( -
- )} - + +
+
+ ) : ( +
+ {providerConfig ? ( + + ) : ( +
)} - {providerConfig ? "Save changes" : "Create provider config"} - -
+ +
+ )}
)} - - setShowDeleteDialog(false)} - onConfirm={() => { - if (providerConfig) { - void onDeleteProvider(providerConfig.id).finally(() => - setShowDeleteDialog(false), - ); - } - }} - entity="provider" - name={providerState.label} - confirmLoading={isProviderMutationPending} - /> ); };