feat: add confirmation dialog to archive & delete workspace action (#23150)

* Adds a "molly-guard" to require users to type the workspace name
before the 'Archive & delete workspace' action fires. This prevents
accidental deletion of 'pet' workspaces.
* This is only shown for workspaces created *before* the chat was
created. The logic here is that any workspace that existed previous to
the chat *cannot* have been created by the chat.
This commit is contained in:
Cian Johnston
2026-03-19 15:22:55 +00:00
committed by GitHub
parent 6d214644f6
commit c107d2bf5d
5 changed files with 297 additions and 36 deletions
@@ -44,7 +44,10 @@ const AgentDetailLayout: FC = () => {
setChatErrorReason: () => {},
clearChatErrorReason: () => {},
requestArchiveAgent: () => {},
requestArchiveAndDeleteWorkspace: () => {},
requestArchiveAndDeleteWorkspace: (
_chatId: string,
_workspaceId: string,
) => {},
requestUnarchiveAgent: () => {},
isSidebarCollapsed: false,
onToggleSidebarCollapsed: () => {},
+109 -35
View File
@@ -14,7 +14,9 @@ import {
unarchiveChat,
updateInfiniteChatsCache,
} from "api/queries/chats";
import { workspaceById } from "api/queries/workspaces";
import type * as TypesGen from "api/typesGenerated";
import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog";
import { useAuthenticated } from "hooks";
import { useDashboard } from "modules/dashboard/useDashboard";
import {
@@ -41,6 +43,7 @@ import {
import { maybePlayChime } from "./AgentDetail/useAgentChime";
import type { AgentsOutletContext } from "./AgentsPageView";
import { AgentsPageView } from "./AgentsPageView";
import { resolveArchiveAndDeleteAction } from "./agentWorkspaceUtils";
import {
getModelOptionsFromCatalog,
getNormalizedModelRef,
@@ -167,6 +170,10 @@ const AgentsPage: FC = () => {
toast.error(getErrorMessage(error, "Failed to archive agent."));
},
});
const [pendingArchiveAndDelete, setPendingArchiveAndDelete] = useState<{
chatId: string;
workspaceId: string;
} | null>(null);
const unarchiveChatBase = unarchiveChat(queryClient);
const unarchiveAgentMutation = useMutation({
...unarchiveChatBase,
@@ -260,13 +267,48 @@ const AgentsPage: FC = () => {
[isArchiving, archiveAgentMutation],
);
const requestArchiveAndDeleteWorkspace = useCallback(
(chatId: string, workspaceId: string) => {
if (!isArchiving) {
archiveAndDeleteMutation.mutate({ chatId, workspaceId });
async (chatId: string, workspaceId: string) => {
if (isArchiving) {
return;
}
try {
const action = await resolveArchiveAndDeleteAction(
() => queryClient.fetchQuery(workspaceById(workspaceId)),
() =>
readInfiniteChatsCache(queryClient)?.find((c) => c.id === chatId)
?.created_at,
);
if (action === "proceed") {
archiveAndDeleteMutation.mutate(
{ chatId, workspaceId },
{
onSettled: () => navigate("/agents"),
},
);
} else {
setPendingArchiveAndDelete({ chatId, workspaceId });
}
} catch {
toast.error("Failed to look up workspace for deletion.");
}
},
[isArchiving, archiveAndDeleteMutation],
[isArchiving, queryClient, archiveAndDeleteMutation, navigate],
);
const handleConfirmArchiveAndDelete = useCallback(() => {
if (pendingArchiveAndDelete && !isArchiving) {
archiveAndDeleteMutation.mutate(pendingArchiveAndDelete, {
onSettled: () => {
setPendingArchiveAndDelete(null);
navigate("/agents");
},
});
}
}, [
pendingArchiveAndDelete,
isArchiving,
archiveAndDeleteMutation,
navigate,
]);
const requestUnarchiveAgent = useCallback(
(chatId: string) => {
unarchiveAgentMutation.mutate(chatId);
@@ -434,6 +476,9 @@ const AgentsPage: FC = () => {
...(isDiffStatusEvent && {
diff_status: updatedChat.diff_status,
}),
// workspace_id can arrive on any event kind once
// the workspace is associated with the chat.
workspace_id: updatedChat.workspace_id ?? c.workspace_id,
updated_at:
c.updated_at > updatedChat.updated_at
? c.updated_at
@@ -456,6 +501,8 @@ const AgentsPage: FC = () => {
...(isDiffStatusEvent && {
diff_status: updatedChat.diff_status,
}),
workspace_id:
updatedChat.workspace_id ?? previousChat.workspace_id,
updated_at:
previousChat.updated_at > updatedChat.updated_at
? previousChat.updated_at
@@ -464,7 +511,6 @@ const AgentsPage: FC = () => {
},
);
});
return ws;
},
onOpen() {
@@ -477,37 +523,65 @@ const AgentsPage: FC = () => {
onNewAgent: handleNewAgent,
});
// Fetch workspace name for the confirmation dialog. Only
// enabled when pendingArchiveAndDelete is set (i.e. the
// resolve step determined confirmation is needed). The
// workspace data is usually already cached from the
// fetchQuery in requestArchiveAndDeleteWorkspace.
const pendingWorkspaceQuery = useQuery({
...workspaceById(pendingArchiveAndDelete?.workspaceId ?? ""),
enabled: Boolean(pendingArchiveAndDelete?.workspaceId),
});
const pendingWorkspaceName = pendingWorkspaceQuery.data?.name ?? "";
const deleteDialogOpen =
pendingArchiveAndDelete !== null && Boolean(pendingWorkspaceName);
return (
<AgentsPageView
agentId={agentId}
chatList={chatList}
catalogModelOptions={catalogModelOptions}
modelConfigs={chatModelConfigsQuery.data ?? []}
logoUrl={appearance.logo_url}
handleNewAgent={handleNewAgent}
isCreating={createMutation.isPending}
isArchiving={isArchiving}
archivingChatId={archivingChatId}
isChatsLoading={chatsQuery.isLoading}
chatsLoadError={chatsQuery.error}
onRetryChatsLoad={() => void chatsQuery.refetch()}
onCollapseSidebar={() => setIsSidebarCollapsed(true)}
isSidebarCollapsed={isSidebarCollapsed}
onExpandSidebar={() => setIsSidebarCollapsed(false)}
outletContext={outletContext}
onCreateChat={handleCreateChat}
createError={createMutation.error}
modelCatalog={chatModelsQuery.data}
isModelCatalogLoading={chatModelsQuery.isLoading}
isModelConfigsLoading={chatModelConfigsQuery.isLoading}
modelCatalogError={chatModelsQuery.error}
isAgentsAdmin={isAgentsAdmin}
hasNextPage={chatsQuery.hasNextPage}
onLoadMore={() => void chatsQuery.fetchNextPage()}
isFetchingNextPage={chatsQuery.isFetchingNextPage}
archivedFilter={archivedFilter}
onArchivedFilterChange={setArchivedFilter}
/>
<>
<AgentsPageView
agentId={agentId}
chatList={chatList}
catalogModelOptions={catalogModelOptions}
modelConfigs={chatModelConfigsQuery.data ?? []}
logoUrl={appearance.logo_url}
handleNewAgent={handleNewAgent}
isCreating={createMutation.isPending}
isArchiving={isArchiving}
archivingChatId={archivingChatId}
isChatsLoading={chatsQuery.isLoading}
chatsLoadError={chatsQuery.error}
onRetryChatsLoad={() => void chatsQuery.refetch()}
onCollapseSidebar={() => setIsSidebarCollapsed(true)}
isSidebarCollapsed={isSidebarCollapsed}
onExpandSidebar={() => setIsSidebarCollapsed(false)}
outletContext={outletContext}
onCreateChat={handleCreateChat}
createError={createMutation.error}
modelCatalog={chatModelsQuery.data}
isModelCatalogLoading={chatModelsQuery.isLoading}
isModelConfigsLoading={chatModelConfigsQuery.isLoading}
modelCatalogError={chatModelsQuery.error}
isAgentsAdmin={isAgentsAdmin}
hasNextPage={chatsQuery.hasNextPage}
onLoadMore={() => void chatsQuery.fetchNextPage()}
isFetchingNextPage={chatsQuery.isFetchingNextPage}
archivedFilter={archivedFilter}
onArchivedFilterChange={setArchivedFilter}
/>
<DeleteDialog
key={pendingWorkspaceName}
isOpen={deleteDialogOpen}
onConfirm={handleConfirmArchiveAndDelete}
onCancel={() => setPendingArchiveAndDelete(null)}
entity="workspace"
name={pendingWorkspaceName}
confirmLoading={archiveAndDeleteMutation.isPending}
title="Archive agent & delete workspace"
verb="Archiving and deleting"
info="This will archive the agent and permanently delete the associated workspace and all its resources."
/>
</>
);
};
@@ -5,7 +5,9 @@ import { API } from "api/api";
import type * as TypesGen from "api/typesGenerated";
import type { Chat } from "api/typesGenerated";
import type { ModelSelectorOption } from "components/ai-elements";
import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog";
import dayjs from "dayjs";
import { useState } from "react";
import {
expect,
fn,
@@ -174,6 +176,8 @@ const meta: Meta<typeof AgentsPageView> = {
analyticsNow: fixedAnalyticsNow,
archivedFilter: "active" as const,
onArchivedFilterChange: fn(),
hasNextPage: false,
onLoadMore: fn(),
isFetchingNextPage: false,
onCreateChat: fn(),
createError: undefined,
@@ -325,6 +329,61 @@ export const ArchivingAgent: Story = {
},
};
/**
* Standalone story for the delete-confirmation dialog with
* agents-specific copy (title, verb, info). The dialog now lives in
* AgentsPage (the container) rather than AgentsPageView, so we
* render it directly here to preserve interaction-test coverage.
*/
export const DeleteConfirmationDialog: Story = {
render: function Render() {
const [isOpen, setIsOpen] = useState(true);
const [isLoading, setIsLoading] = useState(false);
const onConfirm = fn();
return (
<DeleteDialog
key="my-workspace"
isOpen={isOpen}
onConfirm={() => {
onConfirm();
setIsLoading(true);
}}
onCancel={() => setIsOpen(false)}
entity="workspace"
name="my-workspace"
confirmLoading={isLoading}
title="Archive agent & delete workspace"
verb="Archiving and deleting"
info="This will archive the agent and permanently delete the associated workspace and all its resources."
/>
);
},
play: async () => {
const dialog = await screen.findByRole("dialog");
await expect(dialog).toBeInTheDocument();
await expect(
within(dialog).getByText("Archive agent & delete workspace"),
).toBeInTheDocument();
// Confirm button should be disabled before typing the workspace name.
const confirmButton = within(dialog).getByRole("button", {
name: /delete/i,
});
await expect(confirmButton).toBeDisabled();
// Type the workspace name to satisfy the confirmation guard.
const input = within(dialog).getByLabelText(/name of the workspace/i);
await userEvent.type(input, "my-workspace");
await expect(confirmButton).toBeEnabled();
// Click confirm and verify the callback fires, then enters loading state.
await userEvent.click(confirmButton);
await waitFor(() => {
expect(confirmButton).toBeDisabled();
});
},
};
export const WithAgentSelected: Story = {
args: {
agentId: "chat-1",
@@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";
import {
isWorkspaceAutoCreated,
resolveArchiveAndDeleteAction,
} from "./agentWorkspaceUtils";
describe("isWorkspaceAutoCreated", () => {
it.each([
{
name: "workspace created after chat",
workspace: "2026-01-01T00:00:05Z",
chat: "2026-01-01T00:00:00Z",
expected: true,
},
{
name: "workspace created at same time as chat",
workspace: "2026-01-01T12:00:00Z",
chat: "2026-01-01T12:00:00Z",
expected: true,
},
{
name: "workspace created before chat",
workspace: "2026-01-01T11:59:59Z",
chat: "2026-01-01T12:00:00Z",
expected: false,
},
{
name: "sub-second precision difference",
workspace: "2026-01-01T00:00:00.001Z",
chat: "2026-01-01T00:00:00.000Z",
expected: true,
},
{
name: "workspace predates chat by days",
workspace: "2026-03-10T10:00:00Z",
chat: "2026-03-15T10:00:00Z",
expected: false,
},
])("$name → $expected", ({ workspace, chat, expected }) => {
expect(isWorkspaceAutoCreated(workspace, chat)).toBe(expected);
});
});
describe("resolveArchiveAndDeleteAction", () => {
it.each([
{
name: "auto-created workspace → proceed",
workspaceCreatedAt: "2026-01-01T00:00:05Z",
chatCreatedAt: "2026-01-01T00:00:00Z",
expected: "proceed",
},
{
name: "workspace predates chat → confirm",
workspaceCreatedAt: "2025-12-01T00:00:00Z",
chatCreatedAt: "2026-01-01T00:00:00Z",
expected: "confirm",
},
{
name: "chat not found in cache → confirm",
workspaceCreatedAt: "2026-01-01T00:00:05Z",
chatCreatedAt: undefined,
expected: "confirm",
},
])("$name", async ({ workspaceCreatedAt, chatCreatedAt, expected }) => {
const result = await resolveArchiveAndDeleteAction(
async () => ({ created_at: workspaceCreatedAt }),
() => chatCreatedAt,
);
expect(result).toBe(expected);
});
it("propagates workspace fetch errors", async () => {
await expect(
resolveArchiveAndDeleteAction(
async () => {
throw new Error("not found");
},
() => "2026-01-01T00:00:00Z",
),
).rejects.toThrow("not found");
});
});
@@ -0,0 +1,43 @@
/**
* Determines whether a workspace was auto-created by a chat.
* Workspaces created at or after the chat's creation time are
* considered auto-created (the chat provisioned them). Pre-existing
* workspaces that were manually associated need a confirmation
* dialog before deletion.
*/
export function isWorkspaceAutoCreated(
workspaceCreatedAt: string,
chatCreatedAt: string,
): boolean {
return new Date(workspaceCreatedAt) >= new Date(chatCreatedAt);
}
/**
* Resolves whether an archive-and-delete action should proceed
* immediately or require user confirmation. Fetches the workspace
* to compare its creation time against the chat's. Auto-created
* workspaces (provisioned by the chat) skip the confirmation
* dialog; pre-existing workspaces require the user to type the
* workspace name.
*
* @param fetchWorkspace - Retrieves the workspace (e.g. via
* `queryClient.fetchQuery`). The result must include
* `created_at`.
* @param getChatCreatedAt - Returns the chat's `created_at`
* timestamp, or `undefined` if the chat is not in the cache.
* @returns `"proceed"` to skip the dialog, `"confirm"` to show it.
*/
export async function resolveArchiveAndDeleteAction(
fetchWorkspace: () => Promise<{ created_at: string }>,
getChatCreatedAt: () => string | undefined,
): Promise<"proceed" | "confirm"> {
const workspace = await fetchWorkspace();
const chatCreatedAt = getChatCreatedAt();
if (
chatCreatedAt &&
isWorkspaceAutoCreated(workspace.created_at, chatCreatedAt)
) {
return "proceed";
}
return "confirm";
}