diff --git a/site/src/pages/AgentsPage/AgentDetail.stories.tsx b/site/src/pages/AgentsPage/AgentDetail.stories.tsx index 3b4118f881..2dbb65d289 100644 --- a/site/src/pages/AgentsPage/AgentDetail.stories.tsx +++ b/site/src/pages/AgentsPage/AgentDetail.stories.tsx @@ -44,7 +44,10 @@ const AgentDetailLayout: FC = () => { setChatErrorReason: () => {}, clearChatErrorReason: () => {}, requestArchiveAgent: () => {}, - requestArchiveAndDeleteWorkspace: () => {}, + requestArchiveAndDeleteWorkspace: ( + _chatId: string, + _workspaceId: string, + ) => {}, requestUnarchiveAgent: () => {}, isSidebarCollapsed: false, onToggleSidebarCollapsed: () => {}, diff --git a/site/src/pages/AgentsPage/AgentsPage.tsx b/site/src/pages/AgentsPage/AgentsPage.tsx index 5e3d297b2d..4149682311 100644 --- a/site/src/pages/AgentsPage/AgentsPage.tsx +++ b/site/src/pages/AgentsPage/AgentsPage.tsx @@ -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 ( - 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} - /> + <> + 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} + /> + 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." + /> + ); }; diff --git a/site/src/pages/AgentsPage/AgentsPageView.stories.tsx b/site/src/pages/AgentsPage/AgentsPageView.stories.tsx index d87e0a5116..f6547552fe 100644 --- a/site/src/pages/AgentsPage/AgentsPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentsPageView.stories.tsx @@ -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 = { 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 ( + { + 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", diff --git a/site/src/pages/AgentsPage/agentWorkspaceUtils.test.ts b/site/src/pages/AgentsPage/agentWorkspaceUtils.test.ts new file mode 100644 index 0000000000..b7b342e604 --- /dev/null +++ b/site/src/pages/AgentsPage/agentWorkspaceUtils.test.ts @@ -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"); + }); +}); diff --git a/site/src/pages/AgentsPage/agentWorkspaceUtils.ts b/site/src/pages/AgentsPage/agentWorkspaceUtils.ts new file mode 100644 index 0000000000..00c0476b8b --- /dev/null +++ b/site/src/pages/AgentsPage/agentWorkspaceUtils.ts @@ -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"; +}