mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -44,7 +44,10 @@ const AgentDetailLayout: FC = () => {
|
||||
setChatErrorReason: () => {},
|
||||
clearChatErrorReason: () => {},
|
||||
requestArchiveAgent: () => {},
|
||||
requestArchiveAndDeleteWorkspace: () => {},
|
||||
requestArchiveAndDeleteWorkspace: (
|
||||
_chatId: string,
|
||||
_workspaceId: string,
|
||||
) => {},
|
||||
requestUnarchiveAgent: () => {},
|
||||
isSidebarCollapsed: false,
|
||||
onToggleSidebarCollapsed: () => {},
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
Reference in New Issue
Block a user