feat(site): cascade disable models when deleting a provider

Provider deletion now disables associated models (enabled=false) instead
of soft-deleting them. Disabled models remain visible in the admin model
list so they can be reassigned to a different provider later.

Changes:
- ProviderForm: cascade calls onDisableModel (update with enabled:false)
  instead of onDeleteModel, dialog description says 'disable', escape
  guard prevents dismissal during cascade.
- UpdateProviderPageView: replaced old DeleteDialog with type-to-confirm
  Dialog, cascade disables models via updateChatModelConfig, invalidates
  all 3 query keys via invalidateChatConfigurationQueries, guards against
  isLoading/isError on model configs query.
- ModelsSection: disabled badge uses grey (variant=default) instead of
  orange (variant=warning).
- AI Settings ProviderForm: name field description updated.
- chats.ts: export invalidateChatConfigurationQueries for reuse.
This commit is contained in:
Tracy Johnson
2026-05-29 07:22:21 +00:00
parent eb2c2799ca
commit 2edf052d17
7 changed files with 190 additions and 40 deletions
+3 -1
View File
@@ -1743,7 +1743,9 @@ export const deleteUserChatProviderKey = (queryClient: QueryClient) => ({
},
});
const invalidateChatConfigurationQueries = async (queryClient: QueryClient) => {
export const invalidateChatConfigurationQueries = async (
queryClient: QueryClient,
) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: chatProviderConfigsKey }),
queryClient.invalidateQueries({ queryKey: chatModelConfigsKey }),
@@ -4,18 +4,31 @@ import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { Link, Navigate, useNavigate, useParams } from "react-router";
import { toast } from "sonner";
import { API } from "#/api/api";
import { getErrorMessage } from "#/api/errors";
import {
aiProvider,
deleteAIProviderMutation,
updateAIProviderMutation,
} from "#/api/queries/aiProviders";
import {
chatModelConfigs,
invalidateChatConfigurationQueries,
} from "#/api/queries/chats";
import { Avatar } from "#/components/Avatar/Avatar";
import { Badge } from "#/components/Badge/Badge";
import { Button } from "#/components/Button/Button";
import { DeleteDialog } from "#/components/Dialogs/DeleteDialog/DeleteDialog";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "#/components/Dialog/Dialog";
import { Input } from "#/components/Input/Input";
import { Loader } from "#/components/Loader/Loader";
import { SettingsHeaderTitle } from "#/components/SettingsHeader/SettingsHeader";
import { Spinner } from "#/components/Spinner/Spinner";
import { Switch } from "#/components/Switch/Switch";
import { pageTitle } from "#/utils/page";
import { ProviderForm } from "../components/ProviderForm";
@@ -36,6 +49,8 @@ const UpdateProviderPageView: React.FC = () => {
const navigate = useNavigate();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteConfirmText, setDeleteConfirmText] = useState("");
const [isCascadeDeleting, setIsCascadeDeleting] = useState(false);
const providerQuery = useQuery({
...aiProvider(providerId ?? ""),
@@ -54,6 +69,16 @@ const UpdateProviderPageView: React.FC = () => {
deleteAIProviderMutation(queryClient, providerId ?? ""),
);
const modelConfigsQuery = useQuery({
...chatModelConfigs(),
enabled: Boolean(providerId),
});
const associatedModels = (modelConfigsQuery.data ?? []).filter(
(mc) => mc.ai_provider_id === provider?.id,
);
const associatedModelCount = associatedModels.length;
// Rendered into every non-redirect return so the document title reflects
// the provider as soon as we know it; falls back to a placeholder while
// the query is in flight.
@@ -210,36 +235,123 @@ const UpdateProviderPageView: React.FC = () => {
}}
/>
</div>
<DeleteDialog
key={provider.name}
isOpen={deleteDialogOpen}
title="Delete provider"
entity="provider"
name={provider.name}
confirmLoading={deleteMutation.isPending}
onCancel={() => {
setDeleteDialogOpen(false);
<Dialog
open={deleteDialogOpen}
onOpenChange={(open) => {
if (!open && !isCascadeDeleting && !deleteMutation.isPending) {
setDeleteDialogOpen(false);
setDeleteConfirmText("");
}
}}
onConfirm={() => {
deleteMutation.mutate(undefined, {
onSuccess: () => {
toast.success(
`Provider "${provider.display_name || provider.name}" deleted.`,
);
setDeleteDialogOpen(false);
void navigate(BACK_HREF, { replace: true });
},
onError: (error) => {
toast.error(
getErrorMessage(
error,
`Failed to delete provider "${provider.display_name || provider.name}".`,
),
);
},
});
}}
/>
>
<DialogContent variant="destructive">
<DialogHeader>
<DialogTitle>Delete provider</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-3 text-sm text-content-secondary">
<p className="m-0 font-medium text-content-destructive">
Deleting this provider is irreversible!
</p>
{associatedModelCount > 0 && (
<ul className="m-0 pl-5">
<li>
Deleting this provider will also disable{" "}
<strong className="text-content-primary">
{associatedModelCount}{" "}
{associatedModelCount === 1 ? "model" : "models"}
</strong>{" "}
from your settings.
</li>
</ul>
)}
<p className="m-0">
Type{" "}
<strong className="text-content-primary">
{provider.name}
</strong>{" "}
to confirm.
</p>
<Input
id="delete-confirm"
aria-label={`Type ${provider.name} to confirm`}
autoFocus
autoComplete="off"
placeholder={provider.name}
value={deleteConfirmText}
onChange={(e) => setDeleteConfirmText(e.target.value)}
/>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setDeleteDialogOpen(false);
setDeleteConfirmText("");
}}
disabled={isCascadeDeleting || deleteMutation.isPending}
>
Cancel
</Button>
<Button
variant="destructive"
className="disabled:border-border"
disabled={
deleteConfirmText !== provider.name ||
isCascadeDeleting ||
deleteMutation.isPending ||
modelConfigsQuery.isLoading ||
modelConfigsQuery.isError
}
onClick={() => {
const deleteAll = async () => {
setIsCascadeDeleting(true);
try {
for (const mc of associatedModels) {
await API.experimental.updateChatModelConfig(mc.id, {
enabled: false,
});
}
await invalidateChatConfigurationQueries(queryClient);
deleteMutation.mutate(undefined, {
onSuccess: () => {
toast.success(
`Provider "${provider.display_name || provider.name}" deleted.`,
);
setDeleteDialogOpen(false);
setDeleteConfirmText("");
void navigate(BACK_HREF, { replace: true });
},
onError: (error) => {
toast.error(
getErrorMessage(
error,
`Failed to delete provider "${provider.display_name || provider.name}".`,
),
);
},
onSettled: () => {
setIsCascadeDeleting(false);
},
});
} catch (error) {
toast.error(
getErrorMessage(error, "Failed to delete provider."),
);
setIsCascadeDeleting(false);
await invalidateChatConfigurationQueries(queryClient);
}
};
void deleteAll();
}}
>
{(isCascadeDeleting || deleteMutation.isPending) && (
<Spinner className="h-4 w-4" loading />
)}
Delete provider
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</>
);
@@ -303,7 +303,7 @@ export const ProviderForm: FC<ProviderFormProps> = ({
required
field={getFieldHelpers("name")}
label="Name"
description="Unique identifier (used in urls, can't be changed)"
description="URL identifier. Cannot be changed."
className="w-full"
placeholder={namePlaceholder(form.values.type)}
disabled={editing}
@@ -341,7 +341,7 @@ export const ProviderForm: FC<ProviderFormProps> = ({
required
field={getFieldHelpers("name")}
label="Name"
description="Unique identifier (used in urls, can't be changed)"
description="URL identifier. Cannot be changed."
className="w-full"
placeholder={namePlaceholder(form.values.type)}
disabled={editing}
@@ -324,6 +324,7 @@ export const ChatModelAdminPanel: FC<ChatModelAdminPanelProps> = ({
onCreateProvider={onCreateProvider}
onUpdateProvider={onUpdateProvider}
onDeleteProvider={onDeleteProvider}
onDisableModel={onUpdateModel}
/>
) : (
<ModelsSection
@@ -377,7 +377,7 @@ export const ModelsSection: FC<ModelsSectionProps> = ({
)}
</div>
{modelConfig.enabled === false && (
<Badge size="xs" variant="warning">
<Badge size="xs" variant="default">
disabled
</Badge>
)}
@@ -8,6 +8,8 @@ import {
useState,
} from "react";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import { getErrorMessage } from "#/api/errors";
import type * as TypesGen from "#/api/typesGenerated";
import { Alert, AlertDescription, AlertTitle } from "#/components/Alert/Alert";
import { Button } from "#/components/Button/Button";
@@ -45,6 +47,10 @@ interface ProviderFormProps {
req: TypesGen.UpdateChatProviderConfigRequest,
) => Promise<unknown>;
onDeleteProvider: (providerConfigId: string) => Promise<void>;
onDisableModel: (
modelConfigId: string,
req: TypesGen.UpdateChatModelConfigRequest,
) => Promise<unknown>;
onBack: () => void;
}
@@ -55,6 +61,7 @@ export const ProviderForm: FC<ProviderFormProps> = ({
onCreateProvider,
onUpdateProvider,
onDeleteProvider,
onDisableModel,
onBack,
}) => {
const navigate = useNavigate();
@@ -80,6 +87,7 @@ export const ProviderForm: FC<ProviderFormProps> = ({
const [apiKeyModified, setApiKeyModified] = useState(false);
const [baseURLValue, setBaseURLValue] = useState(initialValues.baseURL);
const [confirmingDelete, setConfirmingDelete] = useState(false);
const [isCascadeDeleting, setIsCascadeDeleting] = useState(false);
const isBedrockProvider = provider === "bedrock";
const isAPIKeyEnvManaged = isEnvPreset && !providerConfig;
@@ -106,10 +114,11 @@ export const ProviderForm: FC<ProviderFormProps> = ({
? "Bedrock runtime endpoint. Use the AWS region for the models this provider should call."
: "Endpoint used to call this provider.";
const apiKeyPlaceholder = isBedrockProvider ? "Enter bearer token" : "sk-...";
const associatedModelCount = providerState.modelConfigs.length;
const deleteProviderDescription =
"Are you sure you want to delete this provider? The provider will be " +
"disabled and hidden from new model configuration. Existing model " +
"configs that reference it remain saved but cannot run until updated.";
associatedModelCount > 0
? `Deleting this provider will also disable ${associatedModelCount} ${associatedModelCount === 1 ? "model" : "models"} from your settings.`
: "Are you sure you want to delete this provider? This action is irreversible.";
const hasNewProviderConfiguration = !providerConfig;
const isDirty =
@@ -371,10 +380,29 @@ export const ProviderForm: FC<ProviderFormProps> = ({
<ConfirmDeleteDialog
entity="provider"
description={deleteProviderDescription}
onConfirm={() => void onDeleteProvider(providerConfig.id)}
isPending={isProviderMutationPending}
onConfirm={() => {
setIsCascadeDeleting(true);
const chain = providerState.modelConfigs.reduce<Promise<unknown>>(
(prev, mc) =>
prev.then(() => onDisableModel(mc.id, { enabled: false })),
Promise.resolve(),
);
chain
.then(() => onDeleteProvider(providerConfig.id))
.catch((error: unknown) => {
toast.error(
getErrorMessage(error, "Failed to delete provider."),
);
})
.then(() => setIsCascadeDeleting(false));
}}
isPending={isCascadeDeleting || isProviderMutationPending}
open={confirmingDelete}
onOpenChange={(open) => !open && setConfirmingDelete(false)}
onOpenChange={(open) => {
if (!open && !isCascadeDeleting && !isProviderMutationPending) {
setConfirmingDelete(false);
}
}}
/>
)}
</div>
@@ -10,6 +10,7 @@ import {
type AIProviderType,
AIProviderTypes,
type CreateChatProviderConfigRequest,
type UpdateChatModelConfigRequest,
type UpdateChatProviderConfigRequest,
} from "#/api/typesGenerated";
import { Badge } from "#/components/Badge/Badge";
@@ -79,6 +80,10 @@ interface ProvidersSectionProps {
req: UpdateChatProviderConfigRequest,
) => Promise<unknown>;
onDeleteProvider: (providerConfigId: string) => Promise<void>;
onDisableModel: (
modelConfigId: string,
req: UpdateChatModelConfigRequest,
) => Promise<unknown>;
}
export const ProvidersSection: FC<ProvidersSectionProps> = ({
@@ -90,6 +95,7 @@ export const ProvidersSection: FC<ProvidersSectionProps> = ({
onCreateProvider,
onUpdateProvider,
onDeleteProvider,
onDisableModel,
}) => {
const [searchParams, setSearchParams] = useSearchParams();
const navigate = useNavigate();
@@ -198,6 +204,7 @@ export const ProvidersSection: FC<ProvidersSectionProps> = ({
}
}}
onBack={clearProviderView}
onDisableModel={onDisableModel}
/>
);
}