mirror of
https://github.com/coder/coder.git
synced 2026-10-31 19:36:41 +08:00
fix(site/src): disable archive actions for active chats (#28264)
Closes [CODAGT-632](https://linear.app/codercom/issue/CODAGT-632/gray-out-the-archive-chat-button-when-chat-is-running). The chatd state machine only permits archiving a chat family from idle execution states (`waiting`/`error`); archiving an active chat fails with HTTP 409. The agents UI still offered "Archive agent" and "Archive & delete workspace" for active chats, and `AgentsPageLayout` showed a confirm dialog claiming "Archiving it will interrupt the current run", which is no longer true: confirming just surfaced the 409 error toast. ## Changes - `ChatActionsMenuItems` gains a `chatFamilyAllowsArchive` helper mirroring the backend rule: archive is allowed only when the root chat and every embedded child are `waiting`/`error` (archive cascades atomically over the family, so an active child also rejects it). Unknown status stays fail-open with the server 409 as backstop. A new `isArchiveBlocked` prop disables both archive items; unarchive, pin, and rename are unaffected. - While blocked, the menu shows a visible hint ("Interrupt or wait for the agent to finish first.") and both disabled items reference it via `aria-describedby`. - Sidebar rows (`ChatTreeNode`, covering the dropdown and the right-click context menu) gate on the chat list record, which watch events already keep fresh. The chat top bar gates on the live stream status (`liveChatStatus`) plus the same list record's children, passed through the layout outlet context, so all three menus read one live source and re-evaluate without a reload. - Removed the stale "Archive agent?" interrupt-confirm dialog and its pending-state plumbing from `AgentsPageLayout`; archive now mutates directly. - "Archive & delete workspace" is now archive-first: the archive call is the atomic server-side eligibility check and completes before the destructive delete, so a chat that becomes active mid-flow can never lose its workspace. On delete failure the chat deliberately stays archived, with no compensating unarchive: the delete outcome can be ambiguous client-side (a late 5xx can arrive after the delete build committed), so restoring the chat could resurrect it while its workspace is being deleted. Instead the mutation refetches all chat collections on any failure and the toast directs the user to the archived filter, where Unarchive is one click. This is a deliberate departure from the self-restoring retry surface of #26900. The workspace-deletion confirm dialog is unchanged. ## Scope The client gate is best-effort UX; the server-side 409 is the correctness backstop. Rare staleness (a chat outside the loaded sidebar pages, events missed across a socket reconnect) leaves the actions enabled and the server rejects the request with the existing error toast. Cache-coherency hardening for those windows was deliberately removed to keep the change proportionate to the defect. ## Testing - Storybook play stories: disabled archive items with the accessible hint for an active chat and for an idle parent with a running child; enabled items for an idle chat; in the top bar menu and the sidebar dropdown/context menu (`ChatTopBar.stories.tsx`, `ChatsSidebar.stories.tsx`). - Unit tests: archive-first ordering, delete failures keep the chat archived and rethrow (`agentWorkspaceUtils.test.ts`). - `pnpm -C site check`, `pnpm -C site lint:types`, whole-file vitest runs for every touched test/story file. - Remote dogfood UAT on a deployment built from this branch: archive items disabled while running and in `requires_action`, re-enabled live on transition to `waiting`, archive works from `waiting` and `error` with no confirm dialog, unarchive unaffected. > Shux acted on Mike's behalf to create this pull request.
This commit is contained in:
@@ -73,6 +73,7 @@ const AgentChatPageLayout: FC = () => {
|
||||
requestUnpinAgent: () => {},
|
||||
isArchiving: false,
|
||||
archivingChatId: undefined,
|
||||
activeChatChildren: undefined,
|
||||
isSidebarCollapsed: false,
|
||||
onToggleSidebarCollapsed: () => {},
|
||||
onExpandSidebar: () => {},
|
||||
|
||||
@@ -85,6 +85,7 @@ import {
|
||||
} from "./AgentChatPageView";
|
||||
import type { AgentsPageOutletContext } from "./AgentsPageLayout";
|
||||
import type { ChatMessageInputRef } from "./components/AgentChatInput";
|
||||
import { chatFamilyAllowsArchive } from "./components/ChatActionsMenuItems";
|
||||
import {
|
||||
type ChatDetailError,
|
||||
isChatHookDeniedResponse,
|
||||
@@ -857,6 +858,7 @@ const AgentChatPage: FC = () => {
|
||||
requestUnpinAgent,
|
||||
isArchiving,
|
||||
archivingChatId,
|
||||
activeChatChildren,
|
||||
onOpenRenameDialog,
|
||||
isSidebarCollapsed,
|
||||
onToggleSidebarCollapsed,
|
||||
@@ -2062,6 +2064,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}
|
||||
|
||||
@@ -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<AgentChatPageViewProps> = ({
|
||||
isPinned,
|
||||
isChildChat,
|
||||
isArchivingThisChat,
|
||||
isArchiveBlocked,
|
||||
hasMoreMessages,
|
||||
isFetchingMoreMessages,
|
||||
isHydratingMessages,
|
||||
@@ -890,6 +892,7 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
|
||||
isPinned={isPinned}
|
||||
isChildChat={isChildChat}
|
||||
isArchiving={isArchivingThisChat}
|
||||
isArchiveBlocked={isArchiveBlocked}
|
||||
hasWorkspace={Boolean(workspace)}
|
||||
isArchived={isArchived}
|
||||
diffStatusData={diffStatusData}
|
||||
|
||||
@@ -227,6 +227,7 @@ const AgentEmbedPage: FC = () => {
|
||||
requestArchiveAndDeleteWorkspace,
|
||||
isArchiving: false,
|
||||
archivingChatId: undefined,
|
||||
activeChatChildren: undefined,
|
||||
isSidebarCollapsed,
|
||||
onToggleSidebarCollapsed,
|
||||
onExpandSidebar: () => {},
|
||||
|
||||
@@ -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<void>;
|
||||
/** 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<TypesGen.Workspace>(
|
||||
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<TypesGen.Chat>(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 = () => {
|
||||
<Outlet context={outletContextValue} />
|
||||
</div>
|
||||
</div>
|
||||
<ConfirmDialog
|
||||
open={pendingArchiveChatId !== null}
|
||||
onClose={() => 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."
|
||||
/>
|
||||
<DeleteDialog
|
||||
key={pendingWorkspaceName}
|
||||
isOpen={deleteDialogOpen}
|
||||
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
SquarePenIcon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { type FC, useId } from "react";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import type {
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
@@ -17,6 +18,27 @@ import type {
|
||||
DropdownMenuSeparator,
|
||||
} from "#/components/DropdownMenu/DropdownMenu";
|
||||
|
||||
// Backend chatstate permits archive only from W, E0, and E1. Unknown status
|
||||
// stays fail-open so the server conflict response remains the backstop.
|
||||
const chatStatusAllowsArchive = (
|
||||
status: TypesGen.ChatStatus | null | undefined,
|
||||
): boolean =>
|
||||
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<ChatActionsMenuItemsProps> = ({
|
||||
isChildChat,
|
||||
hasWorkspace,
|
||||
isArchiving = false,
|
||||
isArchiveBlocked = false,
|
||||
subagentCount = 0,
|
||||
isSubagentsExpanded = false,
|
||||
onToggleSubagents,
|
||||
@@ -78,6 +102,10 @@ export const ChatActionsMenuItems: FC<ChatActionsMenuItemsProps> = ({
|
||||
const showPinAction =
|
||||
!isArchived && !isChildChat && Boolean(onPinAgent && onUnpinAgent);
|
||||
const showArchiveActions = !isArchived && !isChildChat;
|
||||
const archiveBlockedHintId = useId();
|
||||
const archiveBlockedDescribedBy = isArchiveBlocked
|
||||
? archiveBlockedHintId
|
||||
: undefined;
|
||||
|
||||
const subagentToggle = showSubagentsToggle ? (
|
||||
<Item onSelect={onToggleSubagents}>
|
||||
@@ -131,7 +159,8 @@ export const ChatActionsMenuItems: FC<ChatActionsMenuItemsProps> = ({
|
||||
)}
|
||||
<Item
|
||||
className="text-content-destructive focus:text-content-destructive"
|
||||
disabled={isArchiving}
|
||||
aria-describedby={archiveBlockedDescribedBy}
|
||||
disabled={isArchiving || isArchiveBlocked}
|
||||
onSelect={onArchiveAgent}
|
||||
>
|
||||
<ArchiveIcon className="size-3.5" />
|
||||
@@ -140,13 +169,22 @@ export const ChatActionsMenuItems: FC<ChatActionsMenuItemsProps> = ({
|
||||
{hasWorkspace && (
|
||||
<Item
|
||||
className="text-content-destructive focus:text-content-destructive"
|
||||
disabled={isArchiving}
|
||||
aria-describedby={archiveBlockedDescribedBy}
|
||||
disabled={isArchiving || isArchiveBlocked}
|
||||
onSelect={onArchiveAndDeleteWorkspace}
|
||||
>
|
||||
<Trash2Icon className="size-3.5" />
|
||||
Archive & delete workspace
|
||||
</Item>
|
||||
)}
|
||||
{isArchiveBlocked && (
|
||||
<div
|
||||
id={archiveBlockedHintId}
|
||||
className="max-w-56 px-2 py-1.5 text-xs text-content-secondary"
|
||||
>
|
||||
Interrupt or wait for the agent to finish first.
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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<ChatTopBarProps> = ({
|
||||
hasWorkspace = false,
|
||||
isArchived = false,
|
||||
isArchiving = false,
|
||||
isArchiveBlocked = false,
|
||||
isChildChat = false,
|
||||
isPinned = false,
|
||||
isSidebarCollapsed,
|
||||
@@ -223,6 +225,7 @@ export const ChatTopBar: FC<ChatTopBarProps> = ({
|
||||
isChildChat={isChildChat}
|
||||
hasWorkspace={hasWorkspace}
|
||||
isArchiving={isArchiving}
|
||||
isArchiveBlocked={isArchiveBlocked}
|
||||
onPinAgent={onPinAgent}
|
||||
onUnpinAgent={onUnpinAgent}
|
||||
onArchiveAgent={onArchiveAgent}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<ChatTreeNodeProps> = ({
|
||||
isChildChat: isChildNode,
|
||||
hasWorkspace: Boolean(workspaceId),
|
||||
isArchiving,
|
||||
isArchiveBlocked: !chatFamilyAllowsArchive(chat.status, chat.children),
|
||||
subagentCount: childIDs.length,
|
||||
isSubagentsExpanded: isExpanded,
|
||||
onToggleSubagents: () => toggleExpanded(chatID),
|
||||
|
||||
@@ -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;
|
||||
await expect(promise).rejects.toMatchObject({
|
||||
step: "delete",
|
||||
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");
|
||||
});
|
||||
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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user