diff --git a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx index bada8299013..338b2063285 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx @@ -71,6 +71,7 @@ const AgentChatPageLayout: FC = () => { requestUnpinAgent: () => {}, isArchiving: false, archivingChatId: undefined, + activeChatChildren: undefined, isSidebarCollapsed: false, onToggleSidebarCollapsed: () => {}, onExpandSidebar: () => {}, diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index 9d2b6dabe85..f79dd3a06dd 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -84,6 +84,7 @@ import { } from "./AgentChatPageView"; import type { AgentsPageOutletContext } from "./AgentsPageLayout"; import type { ChatMessageInputRef } from "./components/AgentChatInput"; +import { chatFamilyAllowsArchive } from "./components/ChatActionsMenuItems"; import { type ChatDetailError, isChatHookDeniedResponse, @@ -833,6 +834,7 @@ const AgentChatPage: FC = () => { requestUnpinAgent, isArchiving, archivingChatId, + activeChatChildren, onOpenRenameDialog, isSidebarCollapsed, onToggleSidebarCollapsed, @@ -2035,6 +2037,9 @@ const AgentChatPage: FC = () => { } isPinned={(chatRecord?.pin_order ?? 0) > 0} isChildChat={parentChatID !== undefined} + isArchiveBlocked={ + !chatFamilyAllowsArchive(liveChatStatus, activeChatChildren) + } urlTransform={urlTransform} hasMoreMessages={chatMessagesQuery.hasNextPage ?? false} isFetchingMoreMessages={chatMessagesQuery.isFetchingNextPage} diff --git a/site/src/pages/AgentsPage/AgentChatPageView.tsx b/site/src/pages/AgentsPage/AgentChatPageView.tsx index 24df0aa8fc4..aa742244cf0 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.tsx @@ -203,6 +203,7 @@ interface AgentChatPageViewProps { isPinned?: boolean; isChildChat?: boolean; isArchivingThisChat?: boolean; + isArchiveBlocked?: boolean; // Pagination for loading older messages. hasMoreMessages: boolean; @@ -378,6 +379,7 @@ export const AgentChatPageView: FC = ({ isPinned, isChildChat, isArchivingThisChat, + isArchiveBlocked, hasMoreMessages, isFetchingMoreMessages, isHydratingMessages, @@ -885,6 +887,7 @@ export const AgentChatPageView: FC = ({ isPinned={isPinned} isChildChat={isChildChat} isArchiving={isArchivingThisChat} + isArchiveBlocked={isArchiveBlocked} hasWorkspace={Boolean(workspace)} isArchived={isArchived} diffStatusData={diffStatusData} diff --git a/site/src/pages/AgentsPage/AgentEmbedPage.tsx b/site/src/pages/AgentsPage/AgentEmbedPage.tsx index 8b22d58d536..b6af5cb6f3c 100644 --- a/site/src/pages/AgentsPage/AgentEmbedPage.tsx +++ b/site/src/pages/AgentsPage/AgentEmbedPage.tsx @@ -227,6 +227,7 @@ const AgentEmbedPage: FC = () => { requestArchiveAndDeleteWorkspace, isArchiving: false, archivingChatId: undefined, + activeChatChildren: undefined, isSidebarCollapsed, onToggleSidebarCollapsed, onExpandSidebar: () => {}, diff --git a/site/src/pages/AgentsPage/AgentsPageLayout.tsx b/site/src/pages/AgentsPage/AgentsPageLayout.tsx index a76dbebb88d..552a6fbd491 100644 --- a/site/src/pages/AgentsPage/AgentsPageLayout.tsx +++ b/site/src/pages/AgentsPage/AgentsPageLayout.tsx @@ -55,7 +55,6 @@ import { workspaceByIdKey, } from "#/api/queries/workspaces"; import type * as TypesGen from "#/api/typesGenerated"; -import { ConfirmDialog } from "#/components/Dialog/ConfirmDialog/ConfirmDialog"; import { DeleteDialog } from "#/components/Dialog/DeleteDialog/DeleteDialog"; import { useAuthenticated } from "#/hooks/useAuthenticated"; import { @@ -82,7 +81,6 @@ import { useAgentsPageKeybindings } from "./hooks/useAgentsPageKeybindings"; import { useAgentsPWA } from "./hooks/useAgentsPWA"; import { getAgentSidebarFilters } from "./utils/agentSidebarFilters"; import { - ArchiveAndDeleteError, archiveChatAndDeleteWorkspace, notifyArchiveAndDeleteFailed, notifyDeleteQueueState, @@ -112,6 +110,13 @@ export interface AgentsPageOutletContext { requestReorderPinnedAgent?: (chatId: string, pinOrder: number) => void; isArchiving: boolean; archivingChatId: string | undefined; + /** + * The active chat's children from the chat list cache, which watch + * events keep fresh. The entity cache's embedded children are only a + * fetch-time snapshot, so gating archive actions on them could leave + * the actions disabled after a child finishes. + */ + activeChatChildren: readonly TypesGen.Chat[] | undefined; onRenameTitle?: (chatId: string, title: string) => Promise; /** Opens the shared rename dialog so both menus drive the same instance. */ onOpenRenameDialog?: (chat: TypesGen.Chat) => void; @@ -323,7 +328,7 @@ const AgentsPageLayout: FC = () => { deleteBuild, ); }, - onError: (error, { workspaceId }) => { + onError: (error, { chatId, workspaceId }) => { notifyArchiveAndDeleteFailed( queryClient.getQueryData( workspaceByIdKey(workspaceId), @@ -331,19 +336,16 @@ const AgentsPageLayout: FC = () => { error, (path) => navigate(path), ); - // Archive failed after the delete already ran; refresh - // workspace state so consumers see the deletion. - if (error instanceof ArchiveAndDeleteError && error.step === "archive") { - void invalidateWorkspaceMutationQueries(queryClient, { - organizationName, - username: user.username, - }); - } + // The archive may have committed server-side even when the + // request appeared to fail (transport errors), and on delete + // failures the chat stays archived; refetch every chat + // collection so all caches converge on the server. + void invalidateChatListQueries(queryClient); + void invalidateChatEntity(queryClient, chatId); + void invalidateChatsByWorkspace(queryClient); + void invalidateChatSearches(queryClient); }, }); - const [pendingArchiveChatId, setPendingArchiveChatId] = useState< - string | null - >(null); const [pendingArchiveAndDelete, setPendingArchiveAndDelete] = useState<{ chatId: string; workspaceId: string; @@ -401,35 +403,12 @@ const AgentsPageLayout: FC = () => { (archiveAndDeleteMutation.isPending ? archiveAndDeleteMutation.variables?.chatId : undefined); - // A chat in any of these statuses has an in-flight run that - // archiving would interrupt, so ask for confirmation first. - const isActiveChat = (chat: TypesGen.Chat | undefined) => - chat?.status === "running" || - chat?.status === "interrupting" || - chat?.status === "requires_action"; const requestArchiveAgent = (chatId: string) => { if (isArchiving) { return; } - const chat = - queryClient.getQueryData(chatEntityKey(chatId)) ?? - chatList.find((candidate) => candidate.id === chatId); - if (chat === undefined || isActiveChat(chat)) { - setPendingArchiveChatId(chatId); - return; - } archiveAgentMutation.mutate(chatId); }; - const handleConfirmArchiveAgent = () => { - if (!pendingArchiveChatId || isArchiving) { - return; - } - archiveAgentMutation.mutate(pendingArchiveChatId, { - onSettled: () => { - setPendingArchiveChatId(null); - }, - }); - }; // Track the active chat ID in a ref so the watchChats // WebSocket handler can read it without re-subscribing @@ -759,6 +738,7 @@ const AgentsPageLayout: FC = () => { requestReorderPinnedAgent, isArchiving, archivingChatId, + activeChatChildren: chatList.find((c) => c.id === agentId)?.children, onOpenRenameDialog: setChatPendingRename, isSidebarCollapsed, onToggleSidebarCollapsed: handleToggleSidebarCollapsed, @@ -835,16 +815,6 @@ const AgentsPageLayout: FC = () => { - setPendingArchiveChatId(null)} - onConfirm={handleConfirmArchiveAgent} - type="delete" - confirmText="Archive" - confirmLoading={archiveAgentMutation.isPending} - title="Archive agent?" - description="This agent is currently running. Archiving it will interrupt the current run." - /> + status === undefined || + status === null || + status === "waiting" || + status === "error"; + +// Archive cascades atomically over the whole family, so the backend +// rejects it when any child is still active, not just the root. Children +// are embedded on chat records (depth capped at 1); a missing children +// array stays fail-open like an unknown status. +export const chatFamilyAllowsArchive = ( + status: TypesGen.ChatStatus | null | undefined, + children: readonly TypesGen.Chat[] | null | undefined, +): boolean => + chatStatusAllowsArchive(status) && + (children ?? []).every((child) => chatStatusAllowsArchive(child.status)); + type ItemComponent = typeof DropdownMenuItem | typeof ContextMenuItem; type SeparatorComponent = | typeof DropdownMenuSeparator @@ -42,6 +64,7 @@ interface ChatActionsMenuItemsProps { readonly isChildChat: boolean; readonly hasWorkspace: boolean; readonly isArchiving?: boolean; + readonly isArchiveBlocked?: boolean; readonly subagentCount?: number; readonly isSubagentsExpanded?: boolean; readonly onToggleSubagents?: () => void; @@ -62,6 +85,7 @@ export const ChatActionsMenuItems: FC = ({ isChildChat, hasWorkspace, isArchiving = false, + isArchiveBlocked = false, subagentCount = 0, isSubagentsExpanded = false, onToggleSubagents, @@ -78,6 +102,10 @@ export const ChatActionsMenuItems: FC = ({ const showPinAction = !isArchived && !isChildChat && Boolean(onPinAgent && onUnpinAgent); const showArchiveActions = !isArchived && !isChildChat; + const archiveBlockedHintId = useId(); + const archiveBlockedDescribedBy = isArchiveBlocked + ? archiveBlockedHintId + : undefined; const subagentToggle = showSubagentsToggle ? ( @@ -131,7 +159,8 @@ export const ChatActionsMenuItems: FC = ({ )} @@ -140,13 +169,22 @@ export const ChatActionsMenuItems: FC = ({ {hasWorkspace && ( Archive & delete workspace )} + {isArchiveBlocked && ( +
+ Interrupt or wait for the agent to finish first. +
+ )} )} diff --git a/site/src/pages/AgentsPage/components/ChatTopBar.stories.tsx b/site/src/pages/AgentsPage/components/ChatTopBar.stories.tsx index 4e9c5ea75c5..7e7d79dac52 100644 --- a/site/src/pages/AgentsPage/components/ChatTopBar.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatTopBar.stories.tsx @@ -367,6 +367,55 @@ export const ArchiveAndDeleteWorkspaceItem: Story = { }, }; +export const IdleChatArchiveActionsEnabled: Story = { + args: { + hasWorkspace: true, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByLabelText("Open agent actions")); + const body = within(document.body); + const archiveItem = await body.findByRole("menuitem", { + name: "Archive agent", + }); + const archiveAndDeleteItem = body.getByRole("menuitem", { + name: "Archive & delete workspace", + }); + expect(archiveItem).not.toHaveAttribute("aria-disabled", "true"); + expect(archiveAndDeleteItem).not.toHaveAttribute("aria-disabled", "true"); + expect( + body.queryByText("Interrupt or wait for the agent to finish first."), + ).not.toBeInTheDocument(); + }, +}; + +export const ActiveChatArchiveActionsDisabled: Story = { + args: { + hasWorkspace: true, + isArchiveBlocked: true, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByLabelText("Open agent actions")); + const body = within(document.body); + const archiveItem = await body.findByRole("menuitem", { + name: "Archive agent", + }); + const archiveAndDeleteItem = body.getByRole("menuitem", { + name: "Archive & delete workspace", + }); + expect(archiveItem).toHaveAttribute("aria-disabled", "true"); + expect(archiveAndDeleteItem).toHaveAttribute("aria-disabled", "true"); + const hint = "Interrupt or wait for the agent to finish first."; + // The menu content fades in, so visibility needs a retry window. + await waitFor(() => { + expect(body.getByText(hint)).toBeVisible(); + }); + expect(archiveItem).toHaveAccessibleDescription(hint); + expect(archiveAndDeleteItem).toHaveAccessibleDescription(hint); + }, +}; + export const PreservesArchivedFilterOnMobileBack: Story = { decorators: mobileDecorator, parameters: { diff --git a/site/src/pages/AgentsPage/components/ChatTopBar.tsx b/site/src/pages/AgentsPage/components/ChatTopBar.tsx index 8f454337177..0eebefc453e 100644 --- a/site/src/pages/AgentsPage/components/ChatTopBar.tsx +++ b/site/src/pages/AgentsPage/components/ChatTopBar.tsx @@ -52,6 +52,7 @@ type ChatTopBarProps = { hasWorkspace?: boolean; isArchived?: boolean; isArchiving?: boolean; + isArchiveBlocked?: boolean; isChildChat?: boolean; isPinned?: boolean; isSidebarCollapsed: boolean; @@ -107,6 +108,7 @@ export const ChatTopBar: FC = ({ hasWorkspace = false, isArchived = false, isArchiving = false, + isArchiveBlocked = false, isChildChat = false, isPinned = false, isSidebarCollapsed, @@ -223,6 +225,7 @@ export const ChatTopBar: FC = ({ isChildChat={isChildChat} hasWorkspace={hasWorkspace} isArchiving={isArchiving} + isArchiveBlocked={isArchiveBlocked} onPinAgent={onPinAgent} onUnpinAgent={onUnpinAgent} onArchiveAgent={onArchiveAgent} diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.stories.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.stories.tsx index ada70da5e03..b9e9f607790 100644 --- a/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.stories.tsx @@ -2054,6 +2054,119 @@ export const AgentWithWorkspaceMenuFull: Story = { }, }; +export const ArchiveActionsFollowChatStatus: Story = { + args: { + chats: [ + buildChat({ + id: "running-archive-actions", + title: "Running agent", + status: "running", + workspace_id: "workspace-running", + updated_at: recentTimestamp, + }), + buildChat({ + id: "idle-archive-actions", + title: "Idle agent", + status: "waiting", + workspace_id: "workspace-idle", + updated_at: recentTimestamp, + }), + buildChat({ + id: "idle-parent-archive-actions", + title: "Idle parent agent", + status: "waiting", + workspace_id: "workspace-idle-parent", + updated_at: recentTimestamp, + children: [ + buildChat({ + id: "running-child-archive-actions", + title: "Running sub-agent", + status: "running", + parent_chat_id: "idle-parent-archive-actions", + root_chat_id: "idle-parent-archive-actions", + }), + ], + }), + ], + }, + parameters: { + reactRouter: reactRouterParameters({ + location: { path: "/agents" }, + routing: agentsRouting, + }), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await waitFor(() => { + expect(canvas.getByText("Running agent")).toBeInTheDocument(); + expect(canvas.getByText("Idle agent")).toBeInTheDocument(); + }); + + await userEvent.click( + canvas.getByLabelText("Open actions for Running agent"), + ); + let body = within(document.body); + expect( + await body.findByRole("menuitem", { name: "Archive agent" }), + ).toHaveAttribute("aria-disabled", "true"); + expect( + body.getByRole("menuitem", { name: "Archive & delete workspace" }), + ).toHaveAttribute("aria-disabled", "true"); + await userEvent.keyboard("{Escape}"); + await waitFor(() => { + expect(within(document.body).queryByRole("menu")).not.toBeInTheDocument(); + }); + + fireEvent.contextMenu( + canvas.getByTestId("agents-tree-node-running-archive-actions"), + ); + body = within(document.body); + expect( + await body.findByRole("menuitem", { name: "Archive agent" }), + ).toHaveAttribute("aria-disabled", "true"); + expect( + body.getByRole("menuitem", { name: "Archive & delete workspace" }), + ).toHaveAttribute("aria-disabled", "true"); + await userEvent.keyboard("{Escape}"); + await waitFor(() => { + expect(within(document.body).queryByRole("menu")).not.toBeInTheDocument(); + }); + + await userEvent.click(canvas.getByLabelText("Open actions for Idle agent")); + body = within(document.body); + expect( + await body.findByRole("menuitem", { name: "Archive agent" }), + ).not.toHaveAttribute("aria-disabled", "true"); + expect( + body.getByRole("menuitem", { name: "Archive & delete workspace" }), + ).not.toHaveAttribute("aria-disabled", "true"); + await userEvent.keyboard("{Escape}"); + await waitFor(() => { + expect(within(document.body).queryByRole("menu")).not.toBeInTheDocument(); + }); + + // Archive cascades over the family, so an idle parent with a + // running child must stay blocked, with the reason exposed. + await userEvent.click( + canvas.getByLabelText("Open actions for Idle parent agent"), + ); + body = within(document.body); + const parentArchiveItem = await body.findByRole("menuitem", { + name: "Archive agent", + }); + expect(parentArchiveItem).toHaveAttribute("aria-disabled", "true"); + expect( + body.getByRole("menuitem", { name: "Archive & delete workspace" }), + ).toHaveAttribute("aria-disabled", "true"); + const hint = "Interrupt or wait for the agent to finish first."; + // The menu content fades in, so visibility needs a retry window. + await waitFor(() => { + expect(body.getByText(hint)).toBeVisible(); + }); + expect(parentArchiveItem).toHaveAccessibleDescription(hint); + }, +}; + // A collapsed parent chat exposes a "Show subagents (N)" action in its // actions menu; selecting it expands the children and the label flips to // "Hide subagents". Leaf chats never show the toggle. diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/tree/ChatTreeNode.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/tree/ChatTreeNode.tsx index 5de84d2c670..51fa16b0df0 100644 --- a/site/src/pages/AgentsPage/components/ChatsSidebar/tree/ChatTreeNode.tsx +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/tree/ChatTreeNode.tsx @@ -27,6 +27,7 @@ import { cn } from "#/utils/cn"; import { shortRelativeTime } from "#/utils/time"; import { ChatActionsMenuItems, + chatFamilyAllowsArchive, chatHasMenuActions, } from "../../ChatActionsMenuItems"; import { asNonEmptyString } from "../../ChatConversation/blockUtils"; @@ -165,6 +166,7 @@ export const ChatTreeNode: FC = ({ isChildChat: isChildNode, hasWorkspace: Boolean(workspaceId), isArchiving, + isArchiveBlocked: !chatFamilyAllowsArchive(chat.status, chat.children), subagentCount: childIDs.length, isSubagentsExpanded: isExpanded, onToggleSubagents: () => toggleExpanded(chatID), diff --git a/site/src/pages/AgentsPage/utils/agentWorkspaceUtils.test.ts b/site/src/pages/AgentsPage/utils/agentWorkspaceUtils.test.ts index 941a23ee1a6..365d087479c 100644 --- a/site/src/pages/AgentsPage/utils/agentWorkspaceUtils.test.ts +++ b/site/src/pages/AgentsPage/utils/agentWorkspaceUtils.test.ts @@ -281,7 +281,7 @@ describe("archiveChatAndDeleteWorkspace", () => { job: { queue_position: 0, queue_size: 1 }, } as unknown as WorkspaceBuild; - it("archives and deletes when both succeed, deleting first", async () => { + it("archives first, then deletes, when both succeed", async () => { const callOrder: string[] = []; const doArchive = vi.fn(async () => { callOrder.push("archive"); @@ -307,10 +307,31 @@ describe("archiveChatAndDeleteWorkspace", () => { expect(doArchive).toHaveBeenCalledWith("chat-1"); expect(doDelete).toHaveBeenCalledTimes(1); expect(doDelete).toHaveBeenCalledWith("workspace-1"); - expect(callOrder).toEqual(["delete", "archive"]); + expect(callOrder).toEqual(["archive", "delete"]); }); - it("archives even when delete returns 404, with null deleteBuild", async () => { + it("does not delete the workspace when archive fails", async () => { + const cause = new Error("Cannot archive an active chat."); + const doArchive = vi.fn(async () => { + throw cause; + }); + const doDelete = vi.fn(async () => BUILD_OK); + + const result = archiveChatAndDeleteWorkspace( + "chat-1", + "workspace-1", + doArchive, + doDelete, + ); + await expect(result).rejects.toBeInstanceOf(ArchiveAndDeleteError); + await expect(result).rejects.toMatchObject({ + step: "archive", + cause, + }); + expect(doDelete).not.toHaveBeenCalled(); + }); + + it("keeps the archive when delete returns 404, with null deleteBuild", async () => { const callOrder: string[] = []; const doArchive = vi.fn(async () => { callOrder.push("archive"); @@ -338,10 +359,10 @@ describe("archiveChatAndDeleteWorkspace", () => { workspaceId: "workspace-1", deleteBuild: null, }); - expect(callOrder).toEqual(["delete", "archive"]); + expect(callOrder).toEqual(["archive", "delete"]); }); - it("archives even when delete returns 410, with null deleteBuild", async () => { + it("keeps the archive when delete returns 410, with null deleteBuild", async () => { const doArchive = vi.fn(async () => undefined); const doDelete = vi.fn(async () => { throw { @@ -369,8 +390,11 @@ describe("archiveChatAndDeleteWorkspace", () => { expect(doDelete).toHaveBeenCalledTimes(1); }); - it("wraps non-404-or-410 delete failures and skips archive", async () => { - const doArchive = vi.fn(async () => undefined); + it("keeps the chat archived and rethrows when the delete enqueue fails", async () => { + const callOrder: string[] = []; + const doArchive = vi.fn(async () => { + callOrder.push("archive"); + }); const cause = { isAxiosError: true, response: { @@ -379,6 +403,7 @@ describe("archiveChatAndDeleteWorkspace", () => { }, }; const doDelete = vi.fn(async () => { + callOrder.push("delete"); throw cause; }); @@ -389,57 +414,11 @@ describe("archiveChatAndDeleteWorkspace", () => { doDelete, ); await expect(promise).rejects.toBeInstanceOf(ArchiveAndDeleteError); - const err = await promise.catch((e: unknown) => e); - expect((err as ArchiveAndDeleteError).step).toBe("delete"); - expect((err as ArchiveAndDeleteError).cause).toBe(cause); - expect(doDelete).toHaveBeenCalledTimes(1); - expect(doArchive).not.toHaveBeenCalled(); - }); - - it("wraps archive failures that follow a successful delete", async () => { - const cause = new Error("archive failed"); - const doArchive = vi.fn(async () => { - throw cause; - }); - const doDelete = vi.fn(async () => BUILD_OK); - - const promise = archiveChatAndDeleteWorkspace( - "chat-1", - "workspace-1", - doArchive, - doDelete, - ); - await expect(promise).rejects.toBeInstanceOf(ArchiveAndDeleteError); - const err = await promise.catch((e: unknown) => e); - expect((err as ArchiveAndDeleteError).step).toBe("archive"); - expect((err as ArchiveAndDeleteError).cause).toBe(cause); - expect((err as ArchiveAndDeleteError).deleteEnqueued).toBe(true); - expect(doDelete).toHaveBeenCalledTimes(1); - expect(doArchive).toHaveBeenCalledTimes(1); - }); - - it("marks archive failures with deleteEnqueued=false when delete was skipped", async () => { - const doArchive = vi.fn(async () => { - throw new Error("archive failed"); + await expect(promise).rejects.toMatchObject({ + step: "delete", + cause, }); - const doDelete = vi.fn(async () => { - throw { - isAxiosError: true, - response: { status: 410, data: { message: "gone" } }, - }; - }); - - const promise = archiveChatAndDeleteWorkspace( - "chat-1", - "workspace-1", - doArchive, - doDelete, - ); - const err = (await promise.catch( - (e: unknown) => e, - )) as ArchiveAndDeleteError; - expect(err.step).toBe("archive"); - expect(err.deleteEnqueued).toBe(false); + expect(callOrder).toEqual(["archive", "delete"]); }); it("returns the delete build payload on success", async () => { @@ -788,7 +767,7 @@ describe("notifyArchiveAndDeleteFailed", () => { toastError.mockClear(); }); - it("shows a generic delete-failed toast when workspace is not in cache", () => { + it("shows a delete-failed toast without an action when workspace is not in cache", () => { const onOpen = vi.fn(); notifyArchiveAndDeleteFailed( undefined, @@ -796,10 +775,13 @@ describe("notifyArchiveAndDeleteFailed", () => { onOpen, ); expect(toastError).toHaveBeenCalledTimes(1); - expect(toastError.mock.calls[0][0]).toContain( - "Failed to delete workspace.", + expect(toastError).toHaveBeenCalledWith( + expect.stringContaining("Failed to delete workspace."), + expect.objectContaining({ + description: expect.stringContaining("archived filter"), + }), ); - expect(toastError.mock.calls[0][1]).toBeUndefined(); + expect(toastError.mock.calls[0][1]).not.toHaveProperty("action"); }); it("includes workspace name and an Open workspace action when delete fails", () => { @@ -818,53 +800,39 @@ describe("notifyArchiveAndDeleteFailed", () => { }, ]; expect(message).toContain("left-behind"); - expect(options.description).toContain("not archived"); + expect(options.description).toContain("was archived"); + expect(options.description).toContain("archived filter"); expect(options.action.label).toBe("Open workspace"); options.action.onClick(); expect(onOpen).toHaveBeenCalledWith("/@bob/left-behind"); }); - it("announces partial success when only the archive step fails after enqueue", () => { + it("shows the archive-failed toast without an action when archive is rejected", () => { const onOpen = vi.fn(); notifyArchiveAndDeleteFailed( - makeWorkspace({ name: "deleting-ws" }), - new ArchiveAndDeleteError("archive", new Error("forbidden"), true), + makeWorkspace({ name: "still-running" }), + new ArchiveAndDeleteError("archive", new Error("forbidden")), onOpen, ); expect(toastError).toHaveBeenCalledTimes(1); const [message, options] = toastError.mock.calls[0] as [string, undefined]; - expect(message).toContain("deleting-ws"); - expect(message).toContain("Deleting"); - expect(message).toContain("failed to archive"); + expect(message).toContain("still-running"); + expect(message).toContain("Failed to archive"); expect(options).toBeUndefined(); expect(onOpen).not.toHaveBeenCalled(); }); - it("omits the 'Deleting' claim when the workspace was already gone (delete swallowed)", () => { - const onOpen = vi.fn(); - notifyArchiveAndDeleteFailed( - makeWorkspace({ name: "already-gone" }), - new ArchiveAndDeleteError("archive", new Error("forbidden"), false), - onOpen, - ); - expect(toastError).toHaveBeenCalledTimes(1); - const message = toastError.mock.calls[0][0] as string; - expect(message).toContain("already-gone"); - expect(message).toContain("Failed to archive"); - expect(message).not.toContain("Deleting"); - }); - it("handles archive-step failure with no workspace in cache", () => { const onOpen = vi.fn(); notifyArchiveAndDeleteFailed( undefined, - new ArchiveAndDeleteError("archive", new Error("forbidden"), true), + new ArchiveAndDeleteError("archive", new Error("forbidden")), onOpen, ); expect(toastError).toHaveBeenCalledTimes(1); const [message, options] = toastError.mock.calls[0] as [string, undefined]; expect(message).toContain("the workspace"); - expect(message).toContain("failed to archive"); + expect(message).toContain("Failed to archive"); expect(options).toBeUndefined(); }); diff --git a/site/src/pages/AgentsPage/utils/agentWorkspaceUtils.ts b/site/src/pages/AgentsPage/utils/agentWorkspaceUtils.ts index ae349c49ef5..f320464287f 100644 --- a/site/src/pages/AgentsPage/utils/agentWorkspaceUtils.ts +++ b/site/src/pages/AgentsPage/utils/agentWorkspaceUtils.ts @@ -91,24 +91,28 @@ export function isWorkspaceNotFound(error: unknown): boolean { export class ArchiveAndDeleteError extends Error { readonly step: "delete" | "archive"; - readonly deleteEnqueued: boolean; declare readonly cause: unknown; - constructor( - step: "delete" | "archive", - cause: unknown, - deleteEnqueued = false, - ) { + constructor(step: "delete" | "archive", cause: unknown) { super( step === "delete" ? "workspace delete failed" : "chat archive failed", { cause }, ); this.step = step; - this.deleteEnqueued = deleteEnqueued; } } -// Delete-first, archive-second. 404/410 on delete falls through to archive. +// Archive-first, delete-second. The archive is the reversible step and +// doubles as the eligibility check: the server rejects it with 409 while +// any family member is active, before anything destructive happens, so a +// chat that became active while a confirmation dialog was open can never +// lose its workspace. 404/410 on delete mean the workspace is already +// gone and the archive stands. There is deliberately no compensating +// unarchive when the delete enqueue fails: the delete outcome can be +// ambiguous client-side (a late 5xx can arrive after the build was +// committed), so restoring the chat risks resurrecting it while its +// workspace is being deleted. The chat stays archived and the failure +// toast points at the archived filter, where Unarchive is one click. export async function archiveChatAndDeleteWorkspace( chatId: string, workspaceId: string, @@ -119,6 +123,11 @@ export async function archiveChatAndDeleteWorkspace( workspaceId: string; deleteBuild: WorkspaceBuild | null; }> { + try { + await doArchive(chatId); + } catch (error) { + throw new ArchiveAndDeleteError("archive", error); + } let deleteBuild: WorkspaceBuild | null = null; try { deleteBuild = await doDelete(workspaceId); @@ -127,11 +136,6 @@ export async function archiveChatAndDeleteWorkspace( throw new ArchiveAndDeleteError("delete", error); } } - try { - await doArchive(chatId); - } catch (error) { - throw new ArchiveAndDeleteError("archive", error, deleteBuild !== null); - } return { chatId, workspaceId, deleteBuild }; } @@ -242,18 +246,19 @@ export function notifyArchiveAndDeleteFailed( if (step === "archive") { const label = workspace ? `"${workspace.name}"` : "the workspace"; - const deleteEnqueued = - error instanceof ArchiveAndDeleteError && error.deleteEnqueued; - const prefix = deleteEnqueued - ? `Deleting ${label}, but failed to archive the chat.` - : `Failed to archive the chat for ${label}.`; + const prefix = `Failed to archive the chat for ${label}.`; const detail = getErrorMessage(cause, ""); toast.error(detail ? `${prefix} ${detail}` : prefix); return; } + const description = + "The chat was archived but the workspace delete failed. Unarchive it from the archived filter, or open the workspace to delete it manually."; + if (!workspace) { - toast.error(getErrorMessage(cause, "Failed to delete workspace.")); + toast.error(getErrorMessage(cause, "Failed to delete workspace."), { + description, + }); return; } @@ -261,8 +266,7 @@ export function notifyArchiveAndDeleteFailed( toast.error( getErrorMessage(cause, `Failed to delete workspace "${workspace.name}".`), { - description: - "The chat was not archived. Open the workspace to delete it manually.", + description, action: { label: "Open workspace", onClick: () => onOpenWorkspace(path),