From d550f9bd168eb701431247b42dadf2e546c18c41 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:17:05 +0000 Subject: [PATCH 01/14] fix(site): disable archive actions for active chats --- site/src/pages/AgentsPage/AgentChatPage.tsx | 2 + .../pages/AgentsPage/AgentChatPageView.tsx | 3 + .../src/pages/AgentsPage/AgentsPageLayout.tsx | 37 ---------- .../components/ChatActionsMenuItems.tsx | 17 ++++- .../components/ChatTopBar.stories.tsx | 39 ++++++++++ .../AgentsPage/components/ChatTopBar.tsx | 3 + .../ChatsSidebar/ChatsSidebar.stories.tsx | 73 +++++++++++++++++++ .../ChatsSidebar/tree/ChatTreeNode.tsx | 2 + 8 files changed, 137 insertions(+), 39 deletions(-) diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index efe81b3a185..1dc37f3d40f 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -82,6 +82,7 @@ import { } from "./AgentChatPageView"; import type { AgentsPageOutletContext } from "./AgentsPageLayout"; import type { ChatMessageInputRef } from "./components/AgentChatInput"; +import { chatStatusAllowsArchive } from "./components/ChatActionsMenuItems"; import { type ChatDetailError, isChatHookDeniedResponse, @@ -2086,6 +2087,7 @@ const AgentChatPage: FC = () => { } isPinned={(chatRecord?.pin_order ?? 0) > 0} isChildChat={parentChatID !== undefined} + isArchiveBlocked={!chatStatusAllowsArchive(liveChatStatus)} 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 8c24c0631d6..4b213fa5010 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.tsx @@ -206,6 +206,7 @@ interface AgentChatPageViewProps { isPinned?: boolean; isChildChat?: boolean; isArchivingThisChat?: boolean; + isArchiveBlocked?: boolean; // Pagination for loading older messages. hasMoreMessages: boolean; @@ -380,6 +381,7 @@ export const AgentChatPageView: FC = ({ isPinned, isChildChat, isArchivingThisChat, + isArchiveBlocked, hasMoreMessages, isFetchingMoreMessages, isHydratingMessages, @@ -876,6 +878,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/AgentsPageLayout.tsx b/site/src/pages/AgentsPage/AgentsPageLayout.tsx index a76dbebb88d..c4a3b6260fc 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 { @@ -341,9 +340,6 @@ const AgentsPageLayout: FC = () => { } }, }); - const [pendingArchiveChatId, setPendingArchiveChatId] = useState< - string | null - >(null); const [pendingArchiveAndDelete, setPendingArchiveAndDelete] = useState<{ chatId: string; workspaceId: string; @@ -401,35 +397,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 @@ -835,16 +808,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"; + type ItemComponent = typeof DropdownMenuItem | typeof ContextMenuItem; type SeparatorComponent = | typeof DropdownMenuSeparator @@ -41,6 +52,7 @@ interface ChatActionsMenuItemsProps { readonly isChildChat: boolean; readonly hasWorkspace: boolean; readonly isArchiving?: boolean; + readonly isArchiveBlocked?: boolean; readonly onPinAgent?: () => void; readonly onUnpinAgent?: () => void; readonly onArchiveAgent: () => void; @@ -58,6 +70,7 @@ export const ChatActionsMenuItems: FC = ({ isChildChat, hasWorkspace, isArchiving = false, + isArchiveBlocked = false, onPinAgent, onUnpinAgent, onArchiveAgent, @@ -108,7 +121,7 @@ export const ChatActionsMenuItems: FC = ({ {(onOpenRenameDialog || showPinAction) && } @@ -117,7 +130,7 @@ export const ChatActionsMenuItems: FC = ({ {hasWorkspace && ( diff --git a/site/src/pages/AgentsPage/components/ChatTopBar.stories.tsx b/site/src/pages/AgentsPage/components/ChatTopBar.stories.tsx index 4e9c5ea75c5..c9d26289c5a 100644 --- a/site/src/pages/AgentsPage/components/ChatTopBar.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatTopBar.stories.tsx @@ -367,6 +367,45 @@ 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"); + }, +}; + +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"); + }, +}; + 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 958dc6f6cfd..4cbba6355ee 100644 --- a/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.stories.tsx @@ -2007,6 +2007,79 @@ 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, + }), + ], + }, + 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"); + }, +}; + export const ArchivedChildChatRowHasNoActionsMenu: Story = { args: { chats: [ diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/tree/ChatTreeNode.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/tree/ChatTreeNode.tsx index be0480ca49d..f135e0afe61 100644 --- a/site/src/pages/AgentsPage/components/ChatsSidebar/tree/ChatTreeNode.tsx +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/tree/ChatTreeNode.tsx @@ -28,6 +28,7 @@ import { shortRelativeTime } from "#/utils/time"; import { ChatActionsMenuItems, chatHasMenuActions, + chatStatusAllowsArchive, } from "../../ChatActionsMenuItems"; import { asNonEmptyString } from "../../ChatConversation/blockUtils"; import { normalizeLocationSearch } from "../locationSearch"; @@ -150,6 +151,7 @@ export const ChatTreeNode: FC = ({ chat, isChildNode }) => { isChildChat: isChildNode, hasWorkspace: Boolean(workspaceId), isArchiving, + isArchiveBlocked: !chatStatusAllowsArchive(chat.status), onPinAgent: () => onPinAgent(chat.id), onUnpinAgent: () => onUnpinAgent(chat.id), onArchiveAgent: () => onArchiveAgent(chat.id), From 0067ca4d470be50c67935113840b2d1c044cf23d Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:01:53 +0000 Subject: [PATCH 02/14] fix(site/src/pages/AgentsPage): gate archive on chat family and expose the reason --- site/src/pages/AgentsPage/AgentChatPage.tsx | 6 ++- .../components/ChatActionsMenuItems.tsx | 29 +++++++++++++- .../components/ChatTopBar.stories.tsx | 10 +++++ .../ChatsSidebar/ChatsSidebar.stories.tsx | 40 +++++++++++++++++++ .../ChatsSidebar/tree/ChatTreeNode.tsx | 4 +- 5 files changed, 83 insertions(+), 6 deletions(-) diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index 1dc37f3d40f..c7b9751a95d 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -82,7 +82,7 @@ import { } from "./AgentChatPageView"; import type { AgentsPageOutletContext } from "./AgentsPageLayout"; import type { ChatMessageInputRef } from "./components/AgentChatInput"; -import { chatStatusAllowsArchive } from "./components/ChatActionsMenuItems"; +import { chatFamilyAllowsArchive } from "./components/ChatActionsMenuItems"; import { type ChatDetailError, isChatHookDeniedResponse, @@ -2087,7 +2087,9 @@ const AgentChatPage: FC = () => { } isPinned={(chatRecord?.pin_order ?? 0) > 0} isChildChat={parentChatID !== undefined} - isArchiveBlocked={!chatStatusAllowsArchive(liveChatStatus)} + isArchiveBlocked={ + !chatFamilyAllowsArchive(liveChatStatus, chatRecord?.children) + } urlTransform={urlTransform} hasMoreMessages={chatMessagesQuery.hasNextPage ?? false} isFetchingMoreMessages={chatMessagesQuery.isFetchingNextPage} diff --git a/site/src/pages/AgentsPage/components/ChatActionsMenuItems.tsx b/site/src/pages/AgentsPage/components/ChatActionsMenuItems.tsx index db277713c63..e17c68c8154 100644 --- a/site/src/pages/AgentsPage/components/ChatActionsMenuItems.tsx +++ b/site/src/pages/AgentsPage/components/ChatActionsMenuItems.tsx @@ -6,7 +6,7 @@ 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, @@ -19,7 +19,7 @@ import type { // Backend chatstate permits archive only from W, E0, and E1. Unknown status // stays fail-open so the server conflict response remains the backstop. -export const chatStatusAllowsArchive = ( +const chatStatusAllowsArchive = ( status: TypesGen.ChatStatus | null | undefined, ): boolean => status === undefined || @@ -27,6 +27,17 @@ export const chatStatusAllowsArchive = ( 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 null array from +// stale caches 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 @@ -83,6 +94,10 @@ export const ChatActionsMenuItems: FC = ({ const showPinAction = !isArchived && !isChildChat && Boolean(onPinAgent && onUnpinAgent); const showArchiveActions = !isArchived && !isChildChat; + const archiveBlockedHintId = useId(); + const archiveBlockedDescribedBy = isArchiveBlocked + ? archiveBlockedHintId + : undefined; return ( <> @@ -121,6 +136,7 @@ export const ChatActionsMenuItems: FC = ({ {(onOpenRenameDialog || showPinAction) && } @@ -130,6 +146,7 @@ export const ChatActionsMenuItems: FC = ({ {hasWorkspace && ( @@ -137,6 +154,14 @@ export const ChatActionsMenuItems: FC = ({ 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 c9d26289c5a..7e7d79dac52 100644 --- a/site/src/pages/AgentsPage/components/ChatTopBar.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatTopBar.stories.tsx @@ -383,6 +383,9 @@ export const IdleChatArchiveActionsEnabled: Story = { }); 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(); }, }; @@ -403,6 +406,13 @@ export const ActiveChatArchiveActionsDisabled: Story = { }); 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); }, }; diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.stories.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.stories.tsx index 4cbba6355ee..f95bf42ef3e 100644 --- a/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.stories.tsx @@ -2024,6 +2024,22 @@ export const ArchiveActionsFollowChatStatus: Story = { 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: { @@ -2077,6 +2093,30 @@ export const ArchiveActionsFollowChatStatus: Story = { 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); }, }; diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/tree/ChatTreeNode.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/tree/ChatTreeNode.tsx index f135e0afe61..1c89a43d146 100644 --- a/site/src/pages/AgentsPage/components/ChatsSidebar/tree/ChatTreeNode.tsx +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/tree/ChatTreeNode.tsx @@ -27,8 +27,8 @@ import { cn } from "#/utils/cn"; import { shortRelativeTime } from "#/utils/time"; import { ChatActionsMenuItems, + chatFamilyAllowsArchive, chatHasMenuActions, - chatStatusAllowsArchive, } from "../../ChatActionsMenuItems"; import { asNonEmptyString } from "../../ChatConversation/blockUtils"; import { normalizeLocationSearch } from "../locationSearch"; @@ -151,7 +151,7 @@ export const ChatTreeNode: FC = ({ chat, isChildNode }) => { isChildChat: isChildNode, hasWorkspace: Boolean(workspaceId), isArchiving, - isArchiveBlocked: !chatStatusAllowsArchive(chat.status), + isArchiveBlocked: !chatFamilyAllowsArchive(chat.status, chat.children), onPinAgent: () => onPinAgent(chat.id), onUnpinAgent: () => onUnpinAgent(chat.id), onArchiveAgent: () => onArchiveAgent(chat.id), From 6238b818c177bf0be20b7dcdc2589ac3b01be803 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:14:27 +0000 Subject: [PATCH 03/14] fix(site/src/api/queries): keep parent entity children fresh on child watch events --- site/src/api/queries/chats.test.ts | 53 ++++++++++++++++++++++++++++++ site/src/api/queries/chats.ts | 44 +++++++++++++++++++++++++ 2 files changed, 97 insertions(+) diff --git a/site/src/api/queries/chats.test.ts b/site/src/api/queries/chats.test.ts index 05d0397d5d7..a90eaa64fbc 100644 --- a/site/src/api/queries/chats.test.ts +++ b/site/src/api/queries/chats.test.ts @@ -2368,6 +2368,27 @@ describe("addChildToParentInCache", () => { const result = readInfiniteChats(queryClient); expect(result?.[0].children).toHaveLength(1); }); + + it("mirrors the insertion into the parent's entity cache", () => { + const queryClient = createTestQueryClient(); + const parent = makeChat("parent-1"); + seedInfiniteChats(queryClient, [parent]); + queryClient.setQueryData(chatEntityKey("parent-1"), parent); + + const child = makeChat("child-1", { + parent_chat_id: "parent-1", + root_chat_id: "parent-1", + }); + addChildToParentInCache(queryClient, child, "parent-1"); + // A second insert must not duplicate the entity-cache entry. + addChildToParentInCache(queryClient, child, "parent-1"); + + const cachedParent = queryClient.getQueryData( + chatEntityKey("parent-1"), + ); + expect(cachedParent?.children).toHaveLength(1); + expect(cachedParent?.children?.[0].id).toBe("child-1"); + }); }); describe("updateChildInParentCache", () => { @@ -2990,6 +3011,38 @@ describe("mergeWatchedChatIntoCaches", () => { }); }); + it("merges a child status change into the parent entity's embedded child", () => { + const queryClient = createTestQueryClient(); + const childId = "child-1"; + const cachedChild = makeChat(childId, { + parent_chat_id: "parent-1", + root_chat_id: "parent-1", + status: "waiting", + updated_at: "2025-01-01T00:00:00.000Z", + }); + const parent = makeChat("parent-1", { children: [cachedChild] }); + const watchedChild = makeChat(childId, { + parent_chat_id: "parent-1", + root_chat_id: "parent-1", + status: "running", + updated_at: "2025-01-01T00:05:00.000Z", + }); + + queryClient.setQueryData(chatEntityKey("parent-1"), parent); + + mergeWatchedChatIntoCaches(queryClient, watchedChild, { + eventKind: "status_change", + }); + + expect( + queryClient.getQueryData(chatEntityKey("parent-1")) + ?.children?.[0], + ).toMatchObject({ + status: "running", + updated_at: "2025-01-01T00:05:00.000Z", + }); + }); + it("does not let an older watch payload clobber newer cached metadata", () => { const queryClient = createTestQueryClient(); const chatId = "chat-1"; diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index e3231513920..d3fbfd7ad8b 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -204,6 +204,23 @@ export const addChildToParentInCache = ( }); return changed ? next : chats; }); + // Mirror the insertion into the parent's entity cache so the chat + // detail page sees new family members without a refetch. + queryClient.setQueryData( + chatEntityKey(parentId), + (cachedParent) => { + if ( + !cachedParent || + cachedParent.children?.some((ch) => ch.id === child.id) + ) { + return cachedParent; + } + return { + ...cachedParent, + children: [child, ...(cachedParent.children ?? [])], + }; + }, + ); }; /** @@ -652,6 +669,33 @@ export const mergeWatchedChatIntoCaches = ( return mergeCachedChat(cachedChat); }, ); + // The parent's entity cache embeds child snapshots too (the chat + // detail page reads family state from it), so merge the child there + // as well, not only in the infinite-list caches. + if (watchedChat.parent_chat_id) { + queryClient.setQueryData( + chatEntityKey(watchedChat.parent_chat_id), + (cachedParent) => { + if (!cachedParent?.children?.length) { + return cachedParent; + } + let changed = false; + const nextChildren = cachedParent.children.map((child) => { + if (child.id !== watchedChat.id) { + return child; + } + const merged = mergeCachedChat(child); + if (merged !== child) { + changed = true; + } + return merged; + }); + return changed + ? { ...cachedParent, children: nextChildren } + : cachedParent; + }, + ); + } }; const getNextOptimisticPinOrder = (queryClient: QueryClient): number => { From 1f00764c7cad74dea9e9dbaf1a5da00ec9fc16e9 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:23:06 +0000 Subject: [PATCH 04/14] fix(site/src/pages/AgentsPage): revalidate archive eligibility before workspace deletion --- .../src/pages/AgentsPage/AgentsPageLayout.tsx | 9 +++++ .../utils/agentWorkspaceUtils.test.ts | 39 ++++++++++++++++++- .../AgentsPage/utils/agentWorkspaceUtils.ts | 12 ++++++ 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/site/src/pages/AgentsPage/AgentsPageLayout.tsx b/site/src/pages/AgentsPage/AgentsPageLayout.tsx index c4a3b6260fc..2d3c63fa72b 100644 --- a/site/src/pages/AgentsPage/AgentsPageLayout.tsx +++ b/site/src/pages/AgentsPage/AgentsPageLayout.tsx @@ -65,6 +65,7 @@ import { cn } from "#/utils/cn"; import { pageTitle } from "#/utils/page"; import { createReconnectingWebSocket } from "#/utils/reconnectingWebSocket"; import { emptyInputStorageKey } from "./components/AgentCreateForm"; +import { chatFamilyAllowsArchive } from "./components/ChatActionsMenuItems"; import { type ChatDetailError, chatDetailErrorsEqual, @@ -300,6 +301,14 @@ const AgentsPageLayout: FC = () => { workspaceId, (id) => API.experimental.updateChat(id, { archived: true }), (id) => API.deleteWorkspace(id), + async (id) => { + const chat = await API.experimental.getChat(id); + if (!chatFamilyAllowsArchive(chat.status, chat.children)) { + throw new Error( + "The agent is running. Interrupt or wait for it to finish first.", + ); + } + }, ), onSuccess: ({ chatId, workspaceId, deleteBuild }) => { applyChatArchiveStateToCaches(queryClient, chatId, true); diff --git a/site/src/pages/AgentsPage/utils/agentWorkspaceUtils.test.ts b/site/src/pages/AgentsPage/utils/agentWorkspaceUtils.test.ts index 941a23ee1a6..ac7e9fa3aed 100644 --- a/site/src/pages/AgentsPage/utils/agentWorkspaceUtils.test.ts +++ b/site/src/pages/AgentsPage/utils/agentWorkspaceUtils.test.ts @@ -280,8 +280,9 @@ describe("archiveChatAndDeleteWorkspace", () => { const BUILD_OK = { job: { queue_position: 0, queue_size: 1 }, } as unknown as WorkspaceBuild; + const validateOk = async () => undefined; - it("archives and deletes when both succeed, deleting first", async () => { + it("archives and deletes when both succeed, validating then deleting first", async () => { const callOrder: string[] = []; const doArchive = vi.fn(async () => { callOrder.push("archive"); @@ -290,6 +291,9 @@ describe("archiveChatAndDeleteWorkspace", () => { callOrder.push("delete"); return BUILD_OK; }); + const validateArchive = vi.fn(async () => { + callOrder.push("validate"); + }); await expect( archiveChatAndDeleteWorkspace( @@ -297,6 +301,7 @@ describe("archiveChatAndDeleteWorkspace", () => { "workspace-1", doArchive, doDelete, + validateArchive, ), ).resolves.toEqual({ chatId: "chat-1", @@ -307,7 +312,31 @@ describe("archiveChatAndDeleteWorkspace", () => { expect(doArchive).toHaveBeenCalledWith("chat-1"); expect(doDelete).toHaveBeenCalledTimes(1); expect(doDelete).toHaveBeenCalledWith("workspace-1"); - expect(callOrder).toEqual(["delete", "archive"]); + expect(validateArchive).toHaveBeenCalledWith("chat-1"); + expect(callOrder).toEqual(["validate", "delete", "archive"]); + }); + + it("does not delete the workspace when archive validation fails", async () => { + const doArchive = vi.fn(async () => undefined); + const doDelete = vi.fn(async () => BUILD_OK); + const validateArchive = vi.fn(async () => { + throw new Error("chat family is active"); + }); + + const result = archiveChatAndDeleteWorkspace( + "chat-1", + "workspace-1", + doArchive, + doDelete, + validateArchive, + ); + await expect(result).rejects.toBeInstanceOf(ArchiveAndDeleteError); + await expect(result).rejects.toMatchObject({ + step: "archive", + deleteEnqueued: false, + }); + expect(doDelete).not.toHaveBeenCalled(); + expect(doArchive).not.toHaveBeenCalled(); }); it("archives even when delete returns 404, with null deleteBuild", async () => { @@ -332,6 +361,7 @@ describe("archiveChatAndDeleteWorkspace", () => { "workspace-1", doArchive, doDelete, + validateOk, ), ).resolves.toEqual({ chatId: "chat-1", @@ -359,6 +389,7 @@ describe("archiveChatAndDeleteWorkspace", () => { "workspace-1", doArchive, doDelete, + validateOk, ), ).resolves.toEqual({ chatId: "chat-1", @@ -387,6 +418,7 @@ describe("archiveChatAndDeleteWorkspace", () => { "workspace-1", doArchive, doDelete, + validateOk, ); await expect(promise).rejects.toBeInstanceOf(ArchiveAndDeleteError); const err = await promise.catch((e: unknown) => e); @@ -408,6 +440,7 @@ describe("archiveChatAndDeleteWorkspace", () => { "workspace-1", doArchive, doDelete, + validateOk, ); await expect(promise).rejects.toBeInstanceOf(ArchiveAndDeleteError); const err = await promise.catch((e: unknown) => e); @@ -434,6 +467,7 @@ describe("archiveChatAndDeleteWorkspace", () => { "workspace-1", doArchive, doDelete, + validateOk, ); const err = (await promise.catch( (e: unknown) => e, @@ -454,6 +488,7 @@ describe("archiveChatAndDeleteWorkspace", () => { "workspace-1", doArchive, doDelete, + validateOk, ); expect(result.deleteBuild).toBe(build); }); diff --git a/site/src/pages/AgentsPage/utils/agentWorkspaceUtils.ts b/site/src/pages/AgentsPage/utils/agentWorkspaceUtils.ts index ae349c49ef5..5028f1e49cd 100644 --- a/site/src/pages/AgentsPage/utils/agentWorkspaceUtils.ts +++ b/site/src/pages/AgentsPage/utils/agentWorkspaceUtils.ts @@ -109,16 +109,28 @@ export class ArchiveAndDeleteError extends Error { } // Delete-first, archive-second. 404/410 on delete falls through to archive. +// Deleting first keeps the chat (and its retry surface) in the sidebar when +// the delete enqueue fails, but it makes a late archive rejection +// destructive: the workspace would be gone while the chat stays active. The +// validation callback re-checks archive eligibility against fresh server +// state right before the irreversible delete, closing the window where the +// family became active while a confirmation dialog was open. export async function archiveChatAndDeleteWorkspace( chatId: string, workspaceId: string, doArchive: (chatId: string) => Promise, doDelete: (workspaceId: string) => Promise, + validateArchive: (chatId: string) => Promise, ): Promise<{ chatId: string; workspaceId: string; deleteBuild: WorkspaceBuild | null; }> { + try { + await validateArchive(chatId); + } catch (error) { + throw new ArchiveAndDeleteError("archive", error); + } let deleteBuild: WorkspaceBuild | null = null; try { deleteBuild = await doDelete(workspaceId); From 1e288b8896dca553a8566178895f44c20f0ce28e Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:32:08 +0000 Subject: [PATCH 05/14] fix(site/src/pages/AgentsPage): archive before deleting the workspace --- .../src/pages/AgentsPage/AgentsPageLayout.tsx | 25 +-- .../utils/agentWorkspaceUtils.test.ts | 145 +++++++++--------- .../AgentsPage/utils/agentWorkspaceUtils.ts | 55 ++++--- 3 files changed, 107 insertions(+), 118 deletions(-) diff --git a/site/src/pages/AgentsPage/AgentsPageLayout.tsx b/site/src/pages/AgentsPage/AgentsPageLayout.tsx index 2d3c63fa72b..9b49c76130c 100644 --- a/site/src/pages/AgentsPage/AgentsPageLayout.tsx +++ b/site/src/pages/AgentsPage/AgentsPageLayout.tsx @@ -65,7 +65,6 @@ import { cn } from "#/utils/cn"; import { pageTitle } from "#/utils/page"; import { createReconnectingWebSocket } from "#/utils/reconnectingWebSocket"; import { emptyInputStorageKey } from "./components/AgentCreateForm"; -import { chatFamilyAllowsArchive } from "./components/ChatActionsMenuItems"; import { type ChatDetailError, chatDetailErrorsEqual, @@ -301,14 +300,7 @@ const AgentsPageLayout: FC = () => { workspaceId, (id) => API.experimental.updateChat(id, { archived: true }), (id) => API.deleteWorkspace(id), - async (id) => { - const chat = await API.experimental.getChat(id); - if (!chatFamilyAllowsArchive(chat.status, chat.children)) { - throw new Error( - "The agent is running. Interrupt or wait for it to finish first.", - ); - } - }, + (id) => API.experimental.updateChat(id, { archived: false }), ), onSuccess: ({ chatId, workspaceId, deleteBuild }) => { applyChatArchiveStateToCaches(queryClient, chatId, true); @@ -331,7 +323,7 @@ const AgentsPageLayout: FC = () => { deleteBuild, ); }, - onError: (error, { workspaceId }) => { + onError: (error, { chatId, workspaceId }) => { notifyArchiveAndDeleteFailed( queryClient.getQueryData( workspaceByIdKey(workspaceId), @@ -339,13 +331,12 @@ 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 chat may have been archived and then restored (delete + // failure) or left archived (restore failure); refetch chat + // state so the sidebar converges on the server's view. + if (error instanceof ArchiveAndDeleteError && error.step === "delete") { + void invalidateChatListQueries(queryClient); + void invalidateChatEntity(queryClient, chatId); } }, }); diff --git a/site/src/pages/AgentsPage/utils/agentWorkspaceUtils.test.ts b/site/src/pages/AgentsPage/utils/agentWorkspaceUtils.test.ts index ac7e9fa3aed..fcab5247529 100644 --- a/site/src/pages/AgentsPage/utils/agentWorkspaceUtils.test.ts +++ b/site/src/pages/AgentsPage/utils/agentWorkspaceUtils.test.ts @@ -280,9 +280,9 @@ describe("archiveChatAndDeleteWorkspace", () => { const BUILD_OK = { job: { queue_position: 0, queue_size: 1 }, } as unknown as WorkspaceBuild; - const validateOk = async () => undefined; + const unarchiveOk = async () => undefined; - it("archives and deletes when both succeed, validating then deleting first", async () => { + it("archives first, then deletes, when both succeed", async () => { const callOrder: string[] = []; const doArchive = vi.fn(async () => { callOrder.push("archive"); @@ -291,8 +291,8 @@ describe("archiveChatAndDeleteWorkspace", () => { callOrder.push("delete"); return BUILD_OK; }); - const validateArchive = vi.fn(async () => { - callOrder.push("validate"); + const doUnarchive = vi.fn(async () => { + callOrder.push("unarchive"); }); await expect( @@ -301,7 +301,7 @@ describe("archiveChatAndDeleteWorkspace", () => { "workspace-1", doArchive, doDelete, - validateArchive, + doUnarchive, ), ).resolves.toEqual({ chatId: "chat-1", @@ -312,34 +312,35 @@ describe("archiveChatAndDeleteWorkspace", () => { expect(doArchive).toHaveBeenCalledWith("chat-1"); expect(doDelete).toHaveBeenCalledTimes(1); expect(doDelete).toHaveBeenCalledWith("workspace-1"); - expect(validateArchive).toHaveBeenCalledWith("chat-1"); - expect(callOrder).toEqual(["validate", "delete", "archive"]); + expect(doUnarchive).not.toHaveBeenCalled(); + expect(callOrder).toEqual(["archive", "delete"]); }); - it("does not delete the workspace when archive validation fails", async () => { - const doArchive = vi.fn(async () => undefined); - const doDelete = vi.fn(async () => BUILD_OK); - const validateArchive = vi.fn(async () => { - throw new Error("chat family is active"); + 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 doUnarchive = vi.fn(async () => undefined); const result = archiveChatAndDeleteWorkspace( "chat-1", "workspace-1", doArchive, doDelete, - validateArchive, + doUnarchive, ); await expect(result).rejects.toBeInstanceOf(ArchiveAndDeleteError); await expect(result).rejects.toMatchObject({ step: "archive", - deleteEnqueued: false, + cause, }); expect(doDelete).not.toHaveBeenCalled(); - expect(doArchive).not.toHaveBeenCalled(); + expect(doUnarchive).not.toHaveBeenCalled(); }); - it("archives even when delete returns 404, with null deleteBuild", async () => { + it("keeps the archive when delete returns 404, with null deleteBuild", async () => { const callOrder: string[] = []; const doArchive = vi.fn(async () => { callOrder.push("archive"); @@ -354,6 +355,9 @@ describe("archiveChatAndDeleteWorkspace", () => { }, }; }); + const doUnarchive = vi.fn(async () => { + callOrder.push("unarchive"); + }); await expect( archiveChatAndDeleteWorkspace( @@ -361,17 +365,18 @@ describe("archiveChatAndDeleteWorkspace", () => { "workspace-1", doArchive, doDelete, - validateOk, + doUnarchive, ), ).resolves.toEqual({ chatId: "chat-1", workspaceId: "workspace-1", deleteBuild: null, }); - expect(callOrder).toEqual(["delete", "archive"]); + expect(doUnarchive).not.toHaveBeenCalled(); + 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 { @@ -382,6 +387,7 @@ describe("archiveChatAndDeleteWorkspace", () => { }, }; }); + const doUnarchive = vi.fn(async () => undefined); await expect( archiveChatAndDeleteWorkspace( @@ -389,7 +395,7 @@ describe("archiveChatAndDeleteWorkspace", () => { "workspace-1", doArchive, doDelete, - validateOk, + doUnarchive, ), ).resolves.toEqual({ chatId: "chat-1", @@ -398,10 +404,14 @@ describe("archiveChatAndDeleteWorkspace", () => { }); expect(doArchive).toHaveBeenCalledTimes(1); expect(doDelete).toHaveBeenCalledTimes(1); + expect(doUnarchive).not.toHaveBeenCalled(); }); - it("wraps non-404-or-410 delete failures and skips archive", async () => { - const doArchive = vi.fn(async () => undefined); + it("unarchives the chat when the delete enqueue fails", async () => { + const callOrder: string[] = []; + const doArchive = vi.fn(async () => { + callOrder.push("archive"); + }); const cause = { isAxiosError: true, response: { @@ -410,70 +420,53 @@ describe("archiveChatAndDeleteWorkspace", () => { }, }; const doDelete = vi.fn(async () => { + callOrder.push("delete"); throw cause; }); - - const promise = archiveChatAndDeleteWorkspace( - "chat-1", - "workspace-1", - doArchive, - doDelete, - validateOk, - ); - 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 doUnarchive = vi.fn(async () => { + callOrder.push("unarchive"); }); - const doDelete = vi.fn(async () => BUILD_OK); const promise = archiveChatAndDeleteWorkspace( "chat-1", "workspace-1", doArchive, doDelete, - validateOk, + doUnarchive, ); 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).step).toBe("delete"); expect((err as ArchiveAndDeleteError).cause).toBe(cause); - expect((err as ArchiveAndDeleteError).deleteEnqueued).toBe(true); - expect(doDelete).toHaveBeenCalledTimes(1); - expect(doArchive).toHaveBeenCalledTimes(1); + expect((err as ArchiveAndDeleteError).unarchiveFailed).toBe(false); + expect(doUnarchive).toHaveBeenCalledWith("chat-1"); + expect(callOrder).toEqual(["archive", "delete", "unarchive"]); }); - it("marks archive failures with deleteEnqueued=false when delete was skipped", async () => { - const doArchive = vi.fn(async () => { - throw new Error("archive failed"); - }); + it("flags unarchiveFailed when the compensating unarchive also fails", async () => { + const doArchive = vi.fn(async () => undefined); const doDelete = vi.fn(async () => { throw { isAxiosError: true, - response: { status: 410, data: { message: "gone" } }, + response: { status: 500, data: { message: "boom" } }, }; }); + const doUnarchive = vi.fn(async () => { + throw new Error("unarchive failed"); + }); const promise = archiveChatAndDeleteWorkspace( "chat-1", "workspace-1", doArchive, doDelete, - validateOk, + doUnarchive, ); const err = (await promise.catch( (e: unknown) => e, )) as ArchiveAndDeleteError; - expect(err.step).toBe("archive"); - expect(err.deleteEnqueued).toBe(false); + expect(err.step).toBe("delete"); + expect(err.unarchiveFailed).toBe(true); }); it("returns the delete build payload on success", async () => { @@ -488,7 +481,7 @@ describe("archiveChatAndDeleteWorkspace", () => { "workspace-1", doArchive, doDelete, - validateOk, + unarchiveOk, ); expect(result.deleteBuild).toBe(build); }); @@ -859,48 +852,48 @@ describe("notifyArchiveAndDeleteFailed", () => { 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)", () => { + it("handles archive-step failure with no workspace in cache", () => { const onOpen = vi.fn(); notifyArchiveAndDeleteFailed( - makeWorkspace({ name: "already-gone" }), - new ArchiveAndDeleteError("archive", new Error("forbidden"), false), + undefined, + new ArchiveAndDeleteError("archive", new Error("forbidden")), onOpen, ); expect(toastError).toHaveBeenCalledTimes(1); - const message = toastError.mock.calls[0][0] as string; - expect(message).toContain("already-gone"); + const [message, options] = toastError.mock.calls[0] as [string, undefined]; + expect(message).toContain("the workspace"); expect(message).toContain("Failed to archive"); - expect(message).not.toContain("Deleting"); + expect(options).toBeUndefined(); }); - it("handles archive-step failure with no workspace in cache", () => { + it("explains that the chat stayed archived when the restore also failed", () => { const onOpen = vi.fn(); notifyArchiveAndDeleteFailed( - undefined, - new ArchiveAndDeleteError("archive", new Error("forbidden"), true), + makeWorkspace({ name: "stuck-ws", owner_name: "dana" }), + new ArchiveAndDeleteError("delete", new Error("boom"), true), 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(options).toBeUndefined(); + const [, options] = toastError.mock.calls[0] as [ + string, + { description: string }, + ]; + expect(options.description).toContain("remains archived"); }); it("surfaces the original error's message when present", () => { diff --git a/site/src/pages/AgentsPage/utils/agentWorkspaceUtils.ts b/site/src/pages/AgentsPage/utils/agentWorkspaceUtils.ts index 5028f1e49cd..011a8408e2f 100644 --- a/site/src/pages/AgentsPage/utils/agentWorkspaceUtils.ts +++ b/site/src/pages/AgentsPage/utils/agentWorkspaceUtils.ts @@ -91,43 +91,48 @@ export function isWorkspaceNotFound(error: unknown): boolean { export class ArchiveAndDeleteError extends Error { readonly step: "delete" | "archive"; - readonly deleteEnqueued: boolean; + /** + * Only meaningful for delete-step errors: the workspace delete + * failed and the compensating unarchive failed too, so the chat is + * still archived even though its workspace was not deleted. + */ + readonly unarchiveFailed: boolean; declare readonly cause: unknown; constructor( step: "delete" | "archive", cause: unknown, - deleteEnqueued = false, + unarchiveFailed = false, ) { super( step === "delete" ? "workspace delete failed" : "chat archive failed", { cause }, ); this.step = step; - this.deleteEnqueued = deleteEnqueued; + this.unarchiveFailed = unarchiveFailed; } } -// Delete-first, archive-second. 404/410 on delete falls through to archive. -// Deleting first keeps the chat (and its retry surface) in the sidebar when -// the delete enqueue fails, but it makes a late archive rejection -// destructive: the workspace would be gone while the chat stays active. The -// validation callback re-checks archive eligibility against fresh server -// state right before the irreversible delete, closing the window where the -// family became active while a confirmation dialog was open. +// 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. When the delete enqueue then fails, the chat is +// unarchived again so its retry surface returns to the sidebar; 404/410 on +// delete mean the workspace is already gone and the archive stands. export async function archiveChatAndDeleteWorkspace( chatId: string, workspaceId: string, doArchive: (chatId: string) => Promise, doDelete: (workspaceId: string) => Promise, - validateArchive: (chatId: string) => Promise, + doUnarchive: (chatId: string) => Promise, ): Promise<{ chatId: string; workspaceId: string; deleteBuild: WorkspaceBuild | null; }> { try { - await validateArchive(chatId); + await doArchive(chatId); } catch (error) { throw new ArchiveAndDeleteError("archive", error); } @@ -136,14 +141,15 @@ export async function archiveChatAndDeleteWorkspace( deleteBuild = await doDelete(workspaceId); } catch (error) { if (!isWorkspaceNotFound(error)) { - throw new ArchiveAndDeleteError("delete", error); + let unarchiveFailed = false; + try { + await doUnarchive(chatId); + } catch { + unarchiveFailed = true; + } + throw new ArchiveAndDeleteError("delete", error, unarchiveFailed); } } - try { - await doArchive(chatId); - } catch (error) { - throw new ArchiveAndDeleteError("archive", error, deleteBuild !== null); - } return { chatId, workspaceId, deleteBuild }; } @@ -254,11 +260,7 @@ 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; @@ -269,12 +271,15 @@ export function notifyArchiveAndDeleteFailed( return; } + const unarchiveFailed = + error instanceof ArchiveAndDeleteError && error.unarchiveFailed; const path = `/@${workspace.owner_name}/${workspace.name}`; toast.error( getErrorMessage(cause, `Failed to delete workspace "${workspace.name}".`), { - description: - "The chat was not archived. Open the workspace to delete it manually.", + description: unarchiveFailed + ? "The chat could not be restored and remains archived. Open the workspace to delete it manually." + : "The chat was not archived. Open the workspace to delete it manually.", action: { label: "Open workspace", onClick: () => onOpenWorkspace(path), From 08a2a44625616b361a640180a8372a9ccdc22ed1 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:41:51 +0000 Subject: [PATCH 06/14] fix(site/src/pages/AgentsPage): skip unarchive compensation on ambiguous delete failures --- .../src/pages/AgentsPage/AgentsPageLayout.tsx | 7 ++- .../utils/agentWorkspaceUtils.test.ts | 57 +++++++++++++++-- .../AgentsPage/utils/agentWorkspaceUtils.ts | 62 +++++++++++++------ 3 files changed, 101 insertions(+), 25 deletions(-) diff --git a/site/src/pages/AgentsPage/AgentsPageLayout.tsx b/site/src/pages/AgentsPage/AgentsPageLayout.tsx index 9b49c76130c..f90d7c7a527 100644 --- a/site/src/pages/AgentsPage/AgentsPageLayout.tsx +++ b/site/src/pages/AgentsPage/AgentsPageLayout.tsx @@ -332,11 +332,14 @@ const AgentsPageLayout: FC = () => { (path) => navigate(path), ); // The chat may have been archived and then restored (delete - // failure) or left archived (restore failure); refetch chat - // state so the sidebar converges on the server's view. + // failure) or left archived (restore failure or ambiguous + // delete); refetch every collection that watch events may + // have already pruned so all caches converge on the server. if (error instanceof ArchiveAndDeleteError && error.step === "delete") { void invalidateChatListQueries(queryClient); void invalidateChatEntity(queryClient, chatId); + void invalidateChatsByWorkspace(queryClient); + void invalidateChatSearches(queryClient); } }, }); diff --git a/site/src/pages/AgentsPage/utils/agentWorkspaceUtils.test.ts b/site/src/pages/AgentsPage/utils/agentWorkspaceUtils.test.ts index fcab5247529..cbf65ceeb75 100644 --- a/site/src/pages/AgentsPage/utils/agentWorkspaceUtils.test.ts +++ b/site/src/pages/AgentsPage/utils/agentWorkspaceUtils.test.ts @@ -438,12 +438,37 @@ describe("archiveChatAndDeleteWorkspace", () => { const err = await promise.catch((e: unknown) => e); expect((err as ArchiveAndDeleteError).step).toBe("delete"); expect((err as ArchiveAndDeleteError).cause).toBe(cause); - expect((err as ArchiveAndDeleteError).unarchiveFailed).toBe(false); + expect((err as ArchiveAndDeleteError).recovery).toBe("unarchived"); expect(doUnarchive).toHaveBeenCalledWith("chat-1"); expect(callOrder).toEqual(["archive", "delete", "unarchive"]); }); - it("flags unarchiveFailed when the compensating unarchive also fails", async () => { + it("skips the unarchive when the delete outcome is unknown", async () => { + const doArchive = vi.fn(async () => undefined); + // No HTTP response: the request may have been processed even + // though the client saw a timeout. + const cause = { isAxiosError: true, code: "ECONNABORTED" }; + const doDelete = vi.fn(async () => { + throw cause; + }); + const doUnarchive = vi.fn(async () => undefined); + + const promise = archiveChatAndDeleteWorkspace( + "chat-1", + "workspace-1", + doArchive, + doDelete, + doUnarchive, + ); + const err = (await promise.catch( + (e: unknown) => e, + )) as ArchiveAndDeleteError; + expect(err.step).toBe("delete"); + expect(err.recovery).toBe("skipped-unknown-outcome"); + expect(doUnarchive).not.toHaveBeenCalled(); + }); + + it("flags the recovery when the compensating unarchive also fails", async () => { const doArchive = vi.fn(async () => undefined); const doDelete = vi.fn(async () => { throw { @@ -466,7 +491,7 @@ describe("archiveChatAndDeleteWorkspace", () => { (e: unknown) => e, )) as ArchiveAndDeleteError; expect(err.step).toBe("delete"); - expect(err.unarchiveFailed).toBe(true); + expect(err.recovery).toBe("unarchive-failed"); }); it("returns the delete build payload on success", async () => { @@ -885,7 +910,30 @@ describe("notifyArchiveAndDeleteFailed", () => { const onOpen = vi.fn(); notifyArchiveAndDeleteFailed( makeWorkspace({ name: "stuck-ws", owner_name: "dana" }), - new ArchiveAndDeleteError("delete", new Error("boom"), true), + new ArchiveAndDeleteError( + "delete", + new Error("boom"), + "unarchive-failed", + ), + onOpen, + ); + expect(toastError).toHaveBeenCalledTimes(1); + const [, options] = toastError.mock.calls[0] as [ + string, + { description: string }, + ]; + expect(options.description).toContain("remains archived"); + }); + + it("explains the unknown delete outcome when the compensation was skipped", () => { + const onOpen = vi.fn(); + notifyArchiveAndDeleteFailed( + makeWorkspace({ name: "timeout-ws" }), + new ArchiveAndDeleteError( + "delete", + new Error("timeout"), + "skipped-unknown-outcome", + ), onOpen, ); expect(toastError).toHaveBeenCalledTimes(1); @@ -893,6 +941,7 @@ describe("notifyArchiveAndDeleteFailed", () => { string, { description: string }, ]; + expect(options.description).toContain("delete result is unknown"); expect(options.description).toContain("remains archived"); }); diff --git a/site/src/pages/AgentsPage/utils/agentWorkspaceUtils.ts b/site/src/pages/AgentsPage/utils/agentWorkspaceUtils.ts index 011a8408e2f..014c13bb22d 100644 --- a/site/src/pages/AgentsPage/utils/agentWorkspaceUtils.ts +++ b/site/src/pages/AgentsPage/utils/agentWorkspaceUtils.ts @@ -89,27 +89,35 @@ export function isWorkspaceNotFound(error: unknown): boolean { return status === 404 || status === 410; } +/** + * Outcome of the compensating unarchive after a delete-step failure: + * the chat was restored ("unarchived"), the restore itself failed + * ("unarchive-failed"), or the delete outcome was ambiguous (no HTTP + * response) so the compensation was skipped and the chat stays + * archived ("skipped-unknown-outcome"). + */ +type ArchiveRecovery = + | "unarchived" + | "unarchive-failed" + | "skipped-unknown-outcome"; + export class ArchiveAndDeleteError extends Error { readonly step: "delete" | "archive"; - /** - * Only meaningful for delete-step errors: the workspace delete - * failed and the compensating unarchive failed too, so the chat is - * still archived even though its workspace was not deleted. - */ - readonly unarchiveFailed: boolean; + /** Only set for delete-step errors. */ + readonly recovery?: ArchiveRecovery; declare readonly cause: unknown; constructor( step: "delete" | "archive", cause: unknown, - unarchiveFailed = false, + recovery?: ArchiveRecovery, ) { super( step === "delete" ? "workspace delete failed" : "chat archive failed", { cause }, ); this.step = step; - this.unarchiveFailed = unarchiveFailed; + this.recovery = recovery; } } @@ -141,13 +149,23 @@ export async function archiveChatAndDeleteWorkspace( deleteBuild = await doDelete(workspaceId); } catch (error) { if (!isWorkspaceNotFound(error)) { - let unarchiveFailed = false; - try { - await doUnarchive(chatId); - } catch { - unarchiveFailed = true; + // A failure without an HTTP response (timeout, dropped + // connection) leaves the delete outcome unknown: the enqueue + // may have succeeded server-side, and restoring the chat would + // resurface it while its workspace is being deleted. Only + // compensate on a definitive server response. + const definitiveRejection = + isAxiosError(error) && error.response !== undefined; + let recovery: ArchiveRecovery = "skipped-unknown-outcome"; + if (definitiveRejection) { + try { + await doUnarchive(chatId); + recovery = "unarchived"; + } catch { + recovery = "unarchive-failed"; + } } - throw new ArchiveAndDeleteError("delete", error, unarchiveFailed); + throw new ArchiveAndDeleteError("delete", error, recovery); } } return { chatId, workspaceId, deleteBuild }; @@ -271,15 +289,21 @@ export function notifyArchiveAndDeleteFailed( return; } - const unarchiveFailed = - error instanceof ArchiveAndDeleteError && error.unarchiveFailed; + const recovery = + error instanceof ArchiveAndDeleteError ? error.recovery : undefined; + const descriptions: Record = { + unarchived: + "The chat was not archived. Open the workspace to delete it manually.", + "unarchive-failed": + "The chat could not be restored and remains archived. Open the workspace to delete it manually.", + "skipped-unknown-outcome": + "The delete result is unknown and the workspace may still be deleting, so the chat remains archived.", + }; const path = `/@${workspace.owner_name}/${workspace.name}`; toast.error( getErrorMessage(cause, `Failed to delete workspace "${workspace.name}".`), { - description: unarchiveFailed - ? "The chat could not be restored and remains archived. Open the workspace to delete it manually." - : "The chat was not archived. Open the workspace to delete it manually.", + description: descriptions[recovery ?? "unarchived"], action: { label: "Open workspace", onClick: () => onOpenWorkspace(path), From 4fcdba1876301a0a4d890c0be277d9cc86fc94d4 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:50:34 +0000 Subject: [PATCH 07/14] test(site/src/pages/AgentsPage): assert error class before reading recovery fields --- .../utils/agentWorkspaceUtils.test.ts | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/site/src/pages/AgentsPage/utils/agentWorkspaceUtils.test.ts b/site/src/pages/AgentsPage/utils/agentWorkspaceUtils.test.ts index cbf65ceeb75..c5f1c321cd1 100644 --- a/site/src/pages/AgentsPage/utils/agentWorkspaceUtils.test.ts +++ b/site/src/pages/AgentsPage/utils/agentWorkspaceUtils.test.ts @@ -460,11 +460,11 @@ describe("archiveChatAndDeleteWorkspace", () => { doDelete, doUnarchive, ); - const err = (await promise.catch( - (e: unknown) => e, - )) as ArchiveAndDeleteError; - expect(err.step).toBe("delete"); - expect(err.recovery).toBe("skipped-unknown-outcome"); + await expect(promise).rejects.toBeInstanceOf(ArchiveAndDeleteError); + await expect(promise).rejects.toMatchObject({ + step: "delete", + recovery: "skipped-unknown-outcome", + }); expect(doUnarchive).not.toHaveBeenCalled(); }); @@ -487,11 +487,11 @@ describe("archiveChatAndDeleteWorkspace", () => { doDelete, doUnarchive, ); - const err = (await promise.catch( - (e: unknown) => e, - )) as ArchiveAndDeleteError; - expect(err.step).toBe("delete"); - expect(err.recovery).toBe("unarchive-failed"); + await expect(promise).rejects.toBeInstanceOf(ArchiveAndDeleteError); + await expect(promise).rejects.toMatchObject({ + step: "delete", + recovery: "unarchive-failed", + }); }); it("returns the delete build payload on success", async () => { From 508fa811585bba4a1c057eac9cda89944b41ad6c Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 18 Aug 2026 16:04:34 +0000 Subject: [PATCH 08/14] fix(site/src): restore pin on unarchive compensation and repair missed children --- site/src/api/queries/chats.test.ts | 23 +++++++++++++++++++ site/src/api/queries/chats.ts | 17 +++++++++++--- .../src/pages/AgentsPage/AgentsPageLayout.tsx | 20 ++++++++++++---- 3 files changed, 53 insertions(+), 7 deletions(-) diff --git a/site/src/api/queries/chats.test.ts b/site/src/api/queries/chats.test.ts index a90eaa64fbc..fe869f4d047 100644 --- a/site/src/api/queries/chats.test.ts +++ b/site/src/api/queries/chats.test.ts @@ -3043,6 +3043,29 @@ describe("mergeWatchedChatIntoCaches", () => { }); }); + it("appends a child missing from the parent entity's embedded children", () => { + const queryClient = createTestQueryClient(); + // The child's `created` watch event was missed (socket + // disconnect), so the cached parent has no embedded child. + const parent = makeChat("parent-1", { children: [] }); + const watchedChild = makeChat("child-1", { + parent_chat_id: "parent-1", + root_chat_id: "parent-1", + status: "running", + }); + + queryClient.setQueryData(chatEntityKey("parent-1"), parent); + + mergeWatchedChatIntoCaches(queryClient, watchedChild, { + eventKind: "status_change", + }); + + expect( + queryClient.getQueryData(chatEntityKey("parent-1")) + ?.children?.[0], + ).toMatchObject({ id: "child-1", status: "running" }); + }); + it("does not let an older watch payload clobber newer cached metadata", () => { const queryClient = createTestQueryClient(); const chatId = "chat-1"; diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index d3fbfd7ad8b..5c491e273f7 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -671,16 +671,27 @@ export const mergeWatchedChatIntoCaches = ( ); // The parent's entity cache embeds child snapshots too (the chat // detail page reads family state from it), so merge the child there - // as well, not only in the infinite-list caches. + // as well, not only in the infinite-list caches. A child missing + // from the cached parent (its `created` event was lost to a socket + // disconnect) is appended so later child events still repair the + // family; archive removals cannot be resurrected this way because + // children only emit these events while their family is live. if (watchedChat.parent_chat_id) { queryClient.setQueryData( chatEntityKey(watchedChat.parent_chat_id), (cachedParent) => { - if (!cachedParent?.children?.length) { + if (!cachedParent) { return cachedParent; } + const children = cachedParent.children ?? []; + if (!children.some((child) => child.id === watchedChat.id)) { + return { + ...cachedParent, + children: [watchedChat, ...children], + }; + } let changed = false; - const nextChildren = cachedParent.children.map((child) => { + const nextChildren = children.map((child) => { if (child.id !== watchedChat.id) { return child; } diff --git a/site/src/pages/AgentsPage/AgentsPageLayout.tsx b/site/src/pages/AgentsPage/AgentsPageLayout.tsx index f90d7c7a527..6cb31c92d6c 100644 --- a/site/src/pages/AgentsPage/AgentsPageLayout.tsx +++ b/site/src/pages/AgentsPage/AgentsPageLayout.tsx @@ -294,14 +294,26 @@ const AgentsPageLayout: FC = () => { }: { chatId: string; workspaceId: string; - }) => - archiveChatAndDeleteWorkspace( + }) => { + // Captured before the archive resets pin_order to 0, so the + // compensating unarchive can restore the pinned state. + const previousPinOrder = + queryClient.getQueryData(chatEntityKey(chatId)) + ?.pin_order ?? + chatList.find((chat) => chat.id === chatId)?.pin_order ?? + 0; + return archiveChatAndDeleteWorkspace( chatId, workspaceId, (id) => API.experimental.updateChat(id, { archived: true }), (id) => API.deleteWorkspace(id), - (id) => API.experimental.updateChat(id, { archived: false }), - ), + (id) => + API.experimental.updateChat(id, { + archived: false, + ...(previousPinOrder > 0 ? { pin_order: previousPinOrder } : {}), + }), + ); + }, onSuccess: ({ chatId, workspaceId, deleteBuild }) => { applyChatArchiveStateToCaches(queryClient, chatId, true); removeChatFromChatsByWorkspace(queryClient, chatId); From 49ea83e8f6c1f18448f9d3bd6af6efc9bb2e8811 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 18 Aug 2026 17:25:42 +0000 Subject: [PATCH 09/14] fix(site/src/pages/AgentsPage): drop unarchive compensation after delete failures The delete outcome can be ambiguous client-side (a late 5xx can arrive after the delete build was committed), so restoring the chat risks resurrecting it while its workspace is being deleted, and the pin restore path was broken anyway. Keep the chat archived on delete failure, refetch all chat collections on any failure so caches converge on the server, and point the failure toast at the archived filter where Unarchive is one click. --- .../src/pages/AgentsPage/AgentsPageLayout.tsx | 38 ++--- .../utils/agentWorkspaceUtils.test.ts | 131 ++---------------- .../AgentsPage/utils/agentWorkspaceUtils.ts | 69 +++------ 3 files changed, 39 insertions(+), 199 deletions(-) diff --git a/site/src/pages/AgentsPage/AgentsPageLayout.tsx b/site/src/pages/AgentsPage/AgentsPageLayout.tsx index 6cb31c92d6c..4e8ff58a1fc 100644 --- a/site/src/pages/AgentsPage/AgentsPageLayout.tsx +++ b/site/src/pages/AgentsPage/AgentsPageLayout.tsx @@ -81,7 +81,6 @@ import { useAgentsPageKeybindings } from "./hooks/useAgentsPageKeybindings"; import { useAgentsPWA } from "./hooks/useAgentsPWA"; import { getAgentSidebarFilters } from "./utils/agentSidebarFilters"; import { - ArchiveAndDeleteError, archiveChatAndDeleteWorkspace, notifyArchiveAndDeleteFailed, notifyDeleteQueueState, @@ -294,26 +293,13 @@ const AgentsPageLayout: FC = () => { }: { chatId: string; workspaceId: string; - }) => { - // Captured before the archive resets pin_order to 0, so the - // compensating unarchive can restore the pinned state. - const previousPinOrder = - queryClient.getQueryData(chatEntityKey(chatId)) - ?.pin_order ?? - chatList.find((chat) => chat.id === chatId)?.pin_order ?? - 0; - return archiveChatAndDeleteWorkspace( + }) => + archiveChatAndDeleteWorkspace( chatId, workspaceId, (id) => API.experimental.updateChat(id, { archived: true }), (id) => API.deleteWorkspace(id), - (id) => - API.experimental.updateChat(id, { - archived: false, - ...(previousPinOrder > 0 ? { pin_order: previousPinOrder } : {}), - }), - ); - }, + ), onSuccess: ({ chatId, workspaceId, deleteBuild }) => { applyChatArchiveStateToCaches(queryClient, chatId, true); removeChatFromChatsByWorkspace(queryClient, chatId); @@ -343,16 +329,14 @@ const AgentsPageLayout: FC = () => { error, (path) => navigate(path), ); - // The chat may have been archived and then restored (delete - // failure) or left archived (restore failure or ambiguous - // delete); refetch every collection that watch events may - // have already pruned so all caches converge on the server. - if (error instanceof ArchiveAndDeleteError && error.step === "delete") { - void invalidateChatListQueries(queryClient); - void invalidateChatEntity(queryClient, chatId); - void invalidateChatsByWorkspace(queryClient); - void invalidateChatSearches(queryClient); - } + // 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 [pendingArchiveAndDelete, setPendingArchiveAndDelete] = useState<{ diff --git a/site/src/pages/AgentsPage/utils/agentWorkspaceUtils.test.ts b/site/src/pages/AgentsPage/utils/agentWorkspaceUtils.test.ts index c5f1c321cd1..441a275ed2b 100644 --- a/site/src/pages/AgentsPage/utils/agentWorkspaceUtils.test.ts +++ b/site/src/pages/AgentsPage/utils/agentWorkspaceUtils.test.ts @@ -280,7 +280,6 @@ describe("archiveChatAndDeleteWorkspace", () => { const BUILD_OK = { job: { queue_position: 0, queue_size: 1 }, } as unknown as WorkspaceBuild; - const unarchiveOk = async () => undefined; it("archives first, then deletes, when both succeed", async () => { const callOrder: string[] = []; @@ -291,9 +290,6 @@ describe("archiveChatAndDeleteWorkspace", () => { callOrder.push("delete"); return BUILD_OK; }); - const doUnarchive = vi.fn(async () => { - callOrder.push("unarchive"); - }); await expect( archiveChatAndDeleteWorkspace( @@ -301,7 +297,6 @@ describe("archiveChatAndDeleteWorkspace", () => { "workspace-1", doArchive, doDelete, - doUnarchive, ), ).resolves.toEqual({ chatId: "chat-1", @@ -312,7 +307,6 @@ describe("archiveChatAndDeleteWorkspace", () => { expect(doArchive).toHaveBeenCalledWith("chat-1"); expect(doDelete).toHaveBeenCalledTimes(1); expect(doDelete).toHaveBeenCalledWith("workspace-1"); - expect(doUnarchive).not.toHaveBeenCalled(); expect(callOrder).toEqual(["archive", "delete"]); }); @@ -322,14 +316,12 @@ describe("archiveChatAndDeleteWorkspace", () => { throw cause; }); const doDelete = vi.fn(async () => BUILD_OK); - const doUnarchive = vi.fn(async () => undefined); const result = archiveChatAndDeleteWorkspace( "chat-1", "workspace-1", doArchive, doDelete, - doUnarchive, ); await expect(result).rejects.toBeInstanceOf(ArchiveAndDeleteError); await expect(result).rejects.toMatchObject({ @@ -337,7 +329,6 @@ describe("archiveChatAndDeleteWorkspace", () => { cause, }); expect(doDelete).not.toHaveBeenCalled(); - expect(doUnarchive).not.toHaveBeenCalled(); }); it("keeps the archive when delete returns 404, with null deleteBuild", async () => { @@ -355,9 +346,6 @@ describe("archiveChatAndDeleteWorkspace", () => { }, }; }); - const doUnarchive = vi.fn(async () => { - callOrder.push("unarchive"); - }); await expect( archiveChatAndDeleteWorkspace( @@ -365,14 +353,12 @@ describe("archiveChatAndDeleteWorkspace", () => { "workspace-1", doArchive, doDelete, - doUnarchive, ), ).resolves.toEqual({ chatId: "chat-1", workspaceId: "workspace-1", deleteBuild: null, }); - expect(doUnarchive).not.toHaveBeenCalled(); expect(callOrder).toEqual(["archive", "delete"]); }); @@ -387,7 +373,6 @@ describe("archiveChatAndDeleteWorkspace", () => { }, }; }); - const doUnarchive = vi.fn(async () => undefined); await expect( archiveChatAndDeleteWorkspace( @@ -395,7 +380,6 @@ describe("archiveChatAndDeleteWorkspace", () => { "workspace-1", doArchive, doDelete, - doUnarchive, ), ).resolves.toEqual({ chatId: "chat-1", @@ -404,10 +388,9 @@ describe("archiveChatAndDeleteWorkspace", () => { }); expect(doArchive).toHaveBeenCalledTimes(1); expect(doDelete).toHaveBeenCalledTimes(1); - expect(doUnarchive).not.toHaveBeenCalled(); }); - it("unarchives the chat when the delete enqueue fails", async () => { + it("keeps the chat archived and rethrows when the delete enqueue fails", async () => { const callOrder: string[] = []; const doArchive = vi.fn(async () => { callOrder.push("archive"); @@ -423,75 +406,19 @@ describe("archiveChatAndDeleteWorkspace", () => { callOrder.push("delete"); throw cause; }); - const doUnarchive = vi.fn(async () => { - callOrder.push("unarchive"); - }); - - const promise = archiveChatAndDeleteWorkspace( - "chat-1", - "workspace-1", - doArchive, - doDelete, - doUnarchive, - ); - 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((err as ArchiveAndDeleteError).recovery).toBe("unarchived"); - expect(doUnarchive).toHaveBeenCalledWith("chat-1"); - expect(callOrder).toEqual(["archive", "delete", "unarchive"]); - }); - - it("skips the unarchive when the delete outcome is unknown", async () => { - const doArchive = vi.fn(async () => undefined); - // No HTTP response: the request may have been processed even - // though the client saw a timeout. - const cause = { isAxiosError: true, code: "ECONNABORTED" }; - const doDelete = vi.fn(async () => { - throw cause; - }); - const doUnarchive = vi.fn(async () => undefined); - - const promise = archiveChatAndDeleteWorkspace( - "chat-1", - "workspace-1", - doArchive, - doDelete, - doUnarchive, - ); - await expect(promise).rejects.toBeInstanceOf(ArchiveAndDeleteError); - await expect(promise).rejects.toMatchObject({ - step: "delete", - recovery: "skipped-unknown-outcome", - }); - expect(doUnarchive).not.toHaveBeenCalled(); - }); - - it("flags the recovery when the compensating unarchive also fails", async () => { - const doArchive = vi.fn(async () => undefined); - const doDelete = vi.fn(async () => { - throw { - isAxiosError: true, - response: { status: 500, data: { message: "boom" } }, - }; - }); - const doUnarchive = vi.fn(async () => { - throw new Error("unarchive failed"); - }); const promise = archiveChatAndDeleteWorkspace( "chat-1", "workspace-1", doArchive, doDelete, - doUnarchive, ); await expect(promise).rejects.toBeInstanceOf(ArchiveAndDeleteError); await expect(promise).rejects.toMatchObject({ step: "delete", - recovery: "unarchive-failed", + cause, }); + expect(callOrder).toEqual(["archive", "delete"]); }); it("returns the delete build payload on success", async () => { @@ -506,7 +433,6 @@ describe("archiveChatAndDeleteWorkspace", () => { "workspace-1", doArchive, doDelete, - unarchiveOk, ); expect(result.deleteBuild).toBe(build); }); @@ -841,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, @@ -852,7 +778,12 @@ describe("notifyArchiveAndDeleteFailed", () => { expect(toastError.mock.calls[0][0]).toContain( "Failed to delete workspace.", ); - expect(toastError.mock.calls[0][1]).toBeUndefined(); + const options = toastError.mock.calls[0][1] as { + description: string; + action?: unknown; + }; + expect(options.description).toContain("archived filter"); + expect(options.action).toBeUndefined(); }); it("includes workspace name and an Open workspace action when delete fails", () => { @@ -871,7 +802,8 @@ 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"); @@ -906,45 +838,6 @@ describe("notifyArchiveAndDeleteFailed", () => { expect(options).toBeUndefined(); }); - it("explains that the chat stayed archived when the restore also failed", () => { - const onOpen = vi.fn(); - notifyArchiveAndDeleteFailed( - makeWorkspace({ name: "stuck-ws", owner_name: "dana" }), - new ArchiveAndDeleteError( - "delete", - new Error("boom"), - "unarchive-failed", - ), - onOpen, - ); - expect(toastError).toHaveBeenCalledTimes(1); - const [, options] = toastError.mock.calls[0] as [ - string, - { description: string }, - ]; - expect(options.description).toContain("remains archived"); - }); - - it("explains the unknown delete outcome when the compensation was skipped", () => { - const onOpen = vi.fn(); - notifyArchiveAndDeleteFailed( - makeWorkspace({ name: "timeout-ws" }), - new ArchiveAndDeleteError( - "delete", - new Error("timeout"), - "skipped-unknown-outcome", - ), - onOpen, - ); - expect(toastError).toHaveBeenCalledTimes(1); - const [, options] = toastError.mock.calls[0] as [ - string, - { description: string }, - ]; - expect(options.description).toContain("delete result is unknown"); - expect(options.description).toContain("remains archived"); - }); - it("surfaces the original error's message when present", () => { const onOpen = vi.fn(); notifyArchiveAndDeleteFailed( diff --git a/site/src/pages/AgentsPage/utils/agentWorkspaceUtils.ts b/site/src/pages/AgentsPage/utils/agentWorkspaceUtils.ts index 014c13bb22d..f320464287f 100644 --- a/site/src/pages/AgentsPage/utils/agentWorkspaceUtils.ts +++ b/site/src/pages/AgentsPage/utils/agentWorkspaceUtils.ts @@ -89,35 +89,16 @@ export function isWorkspaceNotFound(error: unknown): boolean { return status === 404 || status === 410; } -/** - * Outcome of the compensating unarchive after a delete-step failure: - * the chat was restored ("unarchived"), the restore itself failed - * ("unarchive-failed"), or the delete outcome was ambiguous (no HTTP - * response) so the compensation was skipped and the chat stays - * archived ("skipped-unknown-outcome"). - */ -type ArchiveRecovery = - | "unarchived" - | "unarchive-failed" - | "skipped-unknown-outcome"; - export class ArchiveAndDeleteError extends Error { readonly step: "delete" | "archive"; - /** Only set for delete-step errors. */ - readonly recovery?: ArchiveRecovery; declare readonly cause: unknown; - constructor( - step: "delete" | "archive", - cause: unknown, - recovery?: ArchiveRecovery, - ) { + constructor(step: "delete" | "archive", cause: unknown) { super( step === "delete" ? "workspace delete failed" : "chat archive failed", { cause }, ); this.step = step; - this.recovery = recovery; } } @@ -125,15 +106,18 @@ export class ArchiveAndDeleteError extends Error { // 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. When the delete enqueue then fails, the chat is -// unarchived again so its retry surface returns to the sidebar; 404/410 on -// delete mean the workspace is already gone and the archive stands. +// 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, doArchive: (chatId: string) => Promise, doDelete: (workspaceId: string) => Promise, - doUnarchive: (chatId: string) => Promise, ): Promise<{ chatId: string; workspaceId: string; @@ -149,23 +133,7 @@ export async function archiveChatAndDeleteWorkspace( deleteBuild = await doDelete(workspaceId); } catch (error) { if (!isWorkspaceNotFound(error)) { - // A failure without an HTTP response (timeout, dropped - // connection) leaves the delete outcome unknown: the enqueue - // may have succeeded server-side, and restoring the chat would - // resurface it while its workspace is being deleted. Only - // compensate on a definitive server response. - const definitiveRejection = - isAxiosError(error) && error.response !== undefined; - let recovery: ArchiveRecovery = "skipped-unknown-outcome"; - if (definitiveRejection) { - try { - await doUnarchive(chatId); - recovery = "unarchived"; - } catch { - recovery = "unarchive-failed"; - } - } - throw new ArchiveAndDeleteError("delete", error, recovery); + throw new ArchiveAndDeleteError("delete", error); } } return { chatId, workspaceId, deleteBuild }; @@ -284,26 +252,21 @@ export function notifyArchiveAndDeleteFailed( 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; } - const recovery = - error instanceof ArchiveAndDeleteError ? error.recovery : undefined; - const descriptions: Record = { - unarchived: - "The chat was not archived. Open the workspace to delete it manually.", - "unarchive-failed": - "The chat could not be restored and remains archived. Open the workspace to delete it manually.", - "skipped-unknown-outcome": - "The delete result is unknown and the workspace may still be deleting, so the chat remains archived.", - }; const path = `/@${workspace.owner_name}/${workspace.name}`; toast.error( getErrorMessage(cause, `Failed to delete workspace "${workspace.name}".`), { - description: descriptions[recovery ?? "unarchived"], + description, action: { label: "Open workspace", onClick: () => onOpenWorkspace(path), From f10e4669ec25e229ab5473b7a925fc0fcceb70bd Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 18 Aug 2026 17:35:57 +0000 Subject: [PATCH 10/14] fix(site/src/pages/AgentsPage): refresh workspace caches on delete-step failures A failed delete may still have committed server-side, so the error path now invalidates workspace queries like the success path does. Also replace an avoidable cast in the no-workspace toast test with matcher assertions. --- site/src/pages/AgentsPage/AgentsPageLayout.tsx | 10 ++++++++++ .../AgentsPage/utils/agentWorkspaceUtils.test.ts | 14 ++++++-------- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/site/src/pages/AgentsPage/AgentsPageLayout.tsx b/site/src/pages/AgentsPage/AgentsPageLayout.tsx index 4e8ff58a1fc..a57a2b2231d 100644 --- a/site/src/pages/AgentsPage/AgentsPageLayout.tsx +++ b/site/src/pages/AgentsPage/AgentsPageLayout.tsx @@ -81,6 +81,7 @@ import { useAgentsPageKeybindings } from "./hooks/useAgentsPageKeybindings"; import { useAgentsPWA } from "./hooks/useAgentsPWA"; import { getAgentSidebarFilters } from "./utils/agentSidebarFilters"; import { + ArchiveAndDeleteError, archiveChatAndDeleteWorkspace, notifyArchiveAndDeleteFailed, notifyDeleteQueueState, @@ -337,6 +338,15 @@ const AgentsPageLayout: FC = () => { void invalidateChatEntity(queryClient, chatId); void invalidateChatsByWorkspace(queryClient); void invalidateChatSearches(queryClient); + // A failed delete may still have committed server-side (lost + // response, late 5xx), so refresh workspace state too. On + // archive-step failures the delete never ran. + if (error instanceof ArchiveAndDeleteError && error.step === "delete") { + void invalidateWorkspaceMutationQueries(queryClient, { + organizationName, + username: user.username, + }); + } }, }); const [pendingArchiveAndDelete, setPendingArchiveAndDelete] = useState<{ diff --git a/site/src/pages/AgentsPage/utils/agentWorkspaceUtils.test.ts b/site/src/pages/AgentsPage/utils/agentWorkspaceUtils.test.ts index 441a275ed2b..365d087479c 100644 --- a/site/src/pages/AgentsPage/utils/agentWorkspaceUtils.test.ts +++ b/site/src/pages/AgentsPage/utils/agentWorkspaceUtils.test.ts @@ -775,15 +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"), + }), ); - const options = toastError.mock.calls[0][1] as { - description: string; - action?: unknown; - }; - expect(options.description).toContain("archived filter"); - expect(options.action).toBeUndefined(); + expect(toastError.mock.calls[0][1]).not.toHaveProperty("action"); }); it("includes workspace name and an Open workspace action when delete fails", () => { From 7eccd300de686dfd570df652e06d5e875673af6d Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 18 Aug 2026 17:44:56 +0000 Subject: [PATCH 11/14] fix(site/src/pages/AgentsPage): refetch the open chat entity on watch reconnect The watch stream does not replay events missed while disconnected, so a child created and started during the gap could be absent from the open parent's embedded children for its whole run, leaving archive actions incorrectly enabled. --- site/src/pages/AgentsPage/AgentsPageLayout.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/site/src/pages/AgentsPage/AgentsPageLayout.tsx b/site/src/pages/AgentsPage/AgentsPageLayout.tsx index a57a2b2231d..c1ce005fa34 100644 --- a/site/src/pages/AgentsPage/AgentsPageLayout.tsx +++ b/site/src/pages/AgentsPage/AgentsPageLayout.tsx @@ -685,6 +685,15 @@ const AgentsPageLayout: FC = () => { void invalidateChatListQueries(queryClient); void invalidateChatsByWorkspace(queryClient); void invalidateChatSearches(queryClient); + // The watch stream does not replay events missed while + // disconnected, so refetch the open chat's entity: its + // embedded child snapshots gate the archive actions and a + // child created during the gap may emit no further events + // for its whole run. + const activeChatId = activeChatIDRef.current; + if (activeChatId) { + void invalidateChatEntity(queryClient, activeChatId); + } }, }); }, [queryClient]); From 7be701222cf0a5440f350e752cc9060d558ce3c3 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 18 Aug 2026 17:52:10 +0000 Subject: [PATCH 12/14] fix(site/src/pages/AgentsPage): cancel parent entity refetch before child watch writes Child watch events write into the parent's embedded children, so an in-flight parent entity GET issued before the event could overwrite the newer child state when it settles. --- site/src/pages/AgentsPage/AgentsPageLayout.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/site/src/pages/AgentsPage/AgentsPageLayout.tsx b/site/src/pages/AgentsPage/AgentsPageLayout.tsx index c1ce005fa34..95ede469da7 100644 --- a/site/src/pages/AgentsPage/AgentsPageLayout.tsx +++ b/site/src/pages/AgentsPage/AgentsPageLayout.tsx @@ -618,6 +618,16 @@ const AgentsPageLayout: FC = () => { // the fallback title. void cancelChatListRefetches(queryClient); void cancelLoadedChatEntityRefetch(queryClient, updatedChat.id); + // Child events also write into the parent's embedded + // children, so cancel the parent's in-flight entity + // refetch too; a response issued before this event could + // otherwise overwrite the newer child state. + if (updatedChat.parent_chat_id) { + void cancelLoadedChatEntityRefetch( + queryClient, + updatedChat.parent_chat_id, + ); + } if (chatEvent.kind === "created") { if (updatedChat.parent_chat_id) { From b5a8e6c22fef30894bd99d62bde298191c417af4 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:01:45 +0000 Subject: [PATCH 13/14] fix(site/src): invalidate loaded family entities after archive-and-delete Archive cascades server-side over the whole family, but both mutation paths invalidated only the root entity, so a mounted child page kept stale archived state. A new invalidateChatFamilyEntities helper invalidates the root and every loaded child entity. --- site/src/api/queries/chats.test.ts | 29 +++++++++++++++++++ site/src/api/queries/chats.ts | 26 +++++++++++++++++ .../src/pages/AgentsPage/AgentsPageLayout.tsx | 9 ++++-- 3 files changed, 61 insertions(+), 3 deletions(-) diff --git a/site/src/api/queries/chats.test.ts b/site/src/api/queries/chats.test.ts index fe869f4d047..1ef7c623ed9 100644 --- a/site/src/api/queries/chats.test.ts +++ b/site/src/api/queries/chats.test.ts @@ -51,6 +51,7 @@ import { invalidateChatDebugRuns, invalidateChatDiffContents, invalidateChatEntity, + invalidateChatFamilyEntities, invalidateChatListQueries, invalidateChatMessages, invalidateChatPrompts, @@ -254,6 +255,34 @@ describe("advisor config query factories", () => { }); }); +describe("invalidateChatFamilyEntities", () => { + it("invalidates the root and its loaded children, not other chats", async () => { + const queryClient = createTestQueryClient(); + const root = makeChat("root-1"); + const child = makeChat("child-1", { parent_chat_id: "root-1" }); + const unrelated = makeChat("other-1"); + + queryClient.setQueryData(chatEntityKey(root.id), root); + queryClient.setQueryData(chatEntityKey(child.id), child); + queryClient.setQueryData(chatEntityKey(unrelated.id), unrelated); + + await invalidateChatFamilyEntities(queryClient, root.id); + + expect( + queryClient.getQueryState(chatEntityKey(root.id))?.isInvalidated, + "root entity should be invalidated", + ).toBe(true); + expect( + queryClient.getQueryState(chatEntityKey(child.id))?.isInvalidated, + "child entity should be invalidated", + ).toBe(true); + expect( + queryClient.getQueryState(chatEntityKey(unrelated.id))?.isInvalidated, + "unrelated entity should NOT be invalidated", + ).not.toBe(true); + }); +}); + describe("invalidateChatListQueries", () => { it("invalidates flat and infinite chat list queries", async () => { const queryClient = createTestQueryClient(); diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index 5c491e273f7..57650b72ea7 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -776,6 +776,32 @@ export const invalidateChatEntity = ( exact: true, }); +/** + * Invalidates the root chat's entity and every loaded child entity of + * its family. Archive operations cascade server-side over the whole + * family, so after a partial archive-and-delete failure each mounted + * family member must refetch its own entity. + */ +export const invalidateChatFamilyEntities = ( + queryClient: QueryClient, + rootChatId: string, +) => { + const entities = queryClient.getQueriesData({ + queryKey: chatEntitiesFamilyKey, + }); + return Promise.all( + entities + .filter( + ([, chat]) => + chat !== undefined && + (chat.id === rootChatId || chat.parent_chat_id === rootChatId), + ) + .map(([queryKey]) => + queryClient.invalidateQueries({ queryKey, exact: true }), + ), + ); +}; + export const invalidateChatListQueries = (queryClient: QueryClient) => queryClient.invalidateQueries({ queryKey: chatListFamilyKey, diff --git a/site/src/pages/AgentsPage/AgentsPageLayout.tsx b/site/src/pages/AgentsPage/AgentsPageLayout.tsx index 95ede469da7..57a2fc57e5b 100644 --- a/site/src/pages/AgentsPage/AgentsPageLayout.tsx +++ b/site/src/pages/AgentsPage/AgentsPageLayout.tsx @@ -30,6 +30,7 @@ import { invalidateChatCostTree, invalidateChatDiffContents, invalidateChatEntity, + invalidateChatFamilyEntities, invalidateChatListQueries, invalidateChatSearches, invalidateChatsByWorkspace, @@ -308,7 +309,7 @@ const AgentsPageLayout: FC = () => { clearPersistedSidebarTabId(chatId); clearPersistedRightPanelState(chatId); void invalidateChatListQueries(queryClient); - void invalidateChatEntity(queryClient, chatId); + void invalidateChatFamilyEntities(queryClient, chatId); void invalidateChatsByWorkspace(queryClient); void invalidateChatSearches(queryClient); void invalidateWorkspaceMutationQueries(queryClient, { @@ -333,9 +334,11 @@ const AgentsPageLayout: FC = () => { // 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. + // collection and every loaded family entity (the archive + // cascades over children, and a mounted child page reads its + // own entity) so all caches converge on the server. void invalidateChatListQueries(queryClient); - void invalidateChatEntity(queryClient, chatId); + void invalidateChatFamilyEntities(queryClient, chatId); void invalidateChatsByWorkspace(queryClient); void invalidateChatSearches(queryClient); // A failed delete may still have committed server-side (lost From 7f5d8867b17a8b7dad21d67e174b97558b56ac47 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 20 Aug 2026 19:11:54 +0000 Subject: [PATCH 14/14] refactor(site/src): source the archive gate from the chat list and drop cache mirroring --- site/src/api/queries/chats.test.ts | 105 ------------------ site/src/api/queries/chats.ts | 81 -------------- .../AgentsPage/AgentChatPage.stories.tsx | 1 + site/src/pages/AgentsPage/AgentChatPage.tsx | 3 +- site/src/pages/AgentsPage/AgentEmbedPage.tsx | 1 + .../src/pages/AgentsPage/AgentsPageLayout.tsx | 46 ++------ .../components/ChatActionsMenuItems.tsx | 4 +- 7 files changed, 17 insertions(+), 224 deletions(-) diff --git a/site/src/api/queries/chats.test.ts b/site/src/api/queries/chats.test.ts index 1ef7c623ed9..05d0397d5d7 100644 --- a/site/src/api/queries/chats.test.ts +++ b/site/src/api/queries/chats.test.ts @@ -51,7 +51,6 @@ import { invalidateChatDebugRuns, invalidateChatDiffContents, invalidateChatEntity, - invalidateChatFamilyEntities, invalidateChatListQueries, invalidateChatMessages, invalidateChatPrompts, @@ -255,34 +254,6 @@ describe("advisor config query factories", () => { }); }); -describe("invalidateChatFamilyEntities", () => { - it("invalidates the root and its loaded children, not other chats", async () => { - const queryClient = createTestQueryClient(); - const root = makeChat("root-1"); - const child = makeChat("child-1", { parent_chat_id: "root-1" }); - const unrelated = makeChat("other-1"); - - queryClient.setQueryData(chatEntityKey(root.id), root); - queryClient.setQueryData(chatEntityKey(child.id), child); - queryClient.setQueryData(chatEntityKey(unrelated.id), unrelated); - - await invalidateChatFamilyEntities(queryClient, root.id); - - expect( - queryClient.getQueryState(chatEntityKey(root.id))?.isInvalidated, - "root entity should be invalidated", - ).toBe(true); - expect( - queryClient.getQueryState(chatEntityKey(child.id))?.isInvalidated, - "child entity should be invalidated", - ).toBe(true); - expect( - queryClient.getQueryState(chatEntityKey(unrelated.id))?.isInvalidated, - "unrelated entity should NOT be invalidated", - ).not.toBe(true); - }); -}); - describe("invalidateChatListQueries", () => { it("invalidates flat and infinite chat list queries", async () => { const queryClient = createTestQueryClient(); @@ -2397,27 +2368,6 @@ describe("addChildToParentInCache", () => { const result = readInfiniteChats(queryClient); expect(result?.[0].children).toHaveLength(1); }); - - it("mirrors the insertion into the parent's entity cache", () => { - const queryClient = createTestQueryClient(); - const parent = makeChat("parent-1"); - seedInfiniteChats(queryClient, [parent]); - queryClient.setQueryData(chatEntityKey("parent-1"), parent); - - const child = makeChat("child-1", { - parent_chat_id: "parent-1", - root_chat_id: "parent-1", - }); - addChildToParentInCache(queryClient, child, "parent-1"); - // A second insert must not duplicate the entity-cache entry. - addChildToParentInCache(queryClient, child, "parent-1"); - - const cachedParent = queryClient.getQueryData( - chatEntityKey("parent-1"), - ); - expect(cachedParent?.children).toHaveLength(1); - expect(cachedParent?.children?.[0].id).toBe("child-1"); - }); }); describe("updateChildInParentCache", () => { @@ -3040,61 +2990,6 @@ describe("mergeWatchedChatIntoCaches", () => { }); }); - it("merges a child status change into the parent entity's embedded child", () => { - const queryClient = createTestQueryClient(); - const childId = "child-1"; - const cachedChild = makeChat(childId, { - parent_chat_id: "parent-1", - root_chat_id: "parent-1", - status: "waiting", - updated_at: "2025-01-01T00:00:00.000Z", - }); - const parent = makeChat("parent-1", { children: [cachedChild] }); - const watchedChild = makeChat(childId, { - parent_chat_id: "parent-1", - root_chat_id: "parent-1", - status: "running", - updated_at: "2025-01-01T00:05:00.000Z", - }); - - queryClient.setQueryData(chatEntityKey("parent-1"), parent); - - mergeWatchedChatIntoCaches(queryClient, watchedChild, { - eventKind: "status_change", - }); - - expect( - queryClient.getQueryData(chatEntityKey("parent-1")) - ?.children?.[0], - ).toMatchObject({ - status: "running", - updated_at: "2025-01-01T00:05:00.000Z", - }); - }); - - it("appends a child missing from the parent entity's embedded children", () => { - const queryClient = createTestQueryClient(); - // The child's `created` watch event was missed (socket - // disconnect), so the cached parent has no embedded child. - const parent = makeChat("parent-1", { children: [] }); - const watchedChild = makeChat("child-1", { - parent_chat_id: "parent-1", - root_chat_id: "parent-1", - status: "running", - }); - - queryClient.setQueryData(chatEntityKey("parent-1"), parent); - - mergeWatchedChatIntoCaches(queryClient, watchedChild, { - eventKind: "status_change", - }); - - expect( - queryClient.getQueryData(chatEntityKey("parent-1")) - ?.children?.[0], - ).toMatchObject({ id: "child-1", status: "running" }); - }); - it("does not let an older watch payload clobber newer cached metadata", () => { const queryClient = createTestQueryClient(); const chatId = "chat-1"; diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index 57650b72ea7..e3231513920 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -204,23 +204,6 @@ export const addChildToParentInCache = ( }); return changed ? next : chats; }); - // Mirror the insertion into the parent's entity cache so the chat - // detail page sees new family members without a refetch. - queryClient.setQueryData( - chatEntityKey(parentId), - (cachedParent) => { - if ( - !cachedParent || - cachedParent.children?.some((ch) => ch.id === child.id) - ) { - return cachedParent; - } - return { - ...cachedParent, - children: [child, ...(cachedParent.children ?? [])], - }; - }, - ); }; /** @@ -669,44 +652,6 @@ export const mergeWatchedChatIntoCaches = ( return mergeCachedChat(cachedChat); }, ); - // The parent's entity cache embeds child snapshots too (the chat - // detail page reads family state from it), so merge the child there - // as well, not only in the infinite-list caches. A child missing - // from the cached parent (its `created` event was lost to a socket - // disconnect) is appended so later child events still repair the - // family; archive removals cannot be resurrected this way because - // children only emit these events while their family is live. - if (watchedChat.parent_chat_id) { - queryClient.setQueryData( - chatEntityKey(watchedChat.parent_chat_id), - (cachedParent) => { - if (!cachedParent) { - return cachedParent; - } - const children = cachedParent.children ?? []; - if (!children.some((child) => child.id === watchedChat.id)) { - return { - ...cachedParent, - children: [watchedChat, ...children], - }; - } - let changed = false; - const nextChildren = children.map((child) => { - if (child.id !== watchedChat.id) { - return child; - } - const merged = mergeCachedChat(child); - if (merged !== child) { - changed = true; - } - return merged; - }); - return changed - ? { ...cachedParent, children: nextChildren } - : cachedParent; - }, - ); - } }; const getNextOptimisticPinOrder = (queryClient: QueryClient): number => { @@ -776,32 +721,6 @@ export const invalidateChatEntity = ( exact: true, }); -/** - * Invalidates the root chat's entity and every loaded child entity of - * its family. Archive operations cascade server-side over the whole - * family, so after a partial archive-and-delete failure each mounted - * family member must refetch its own entity. - */ -export const invalidateChatFamilyEntities = ( - queryClient: QueryClient, - rootChatId: string, -) => { - const entities = queryClient.getQueriesData({ - queryKey: chatEntitiesFamilyKey, - }); - return Promise.all( - entities - .filter( - ([, chat]) => - chat !== undefined && - (chat.id === rootChatId || chat.parent_chat_id === rootChatId), - ) - .map(([queryKey]) => - queryClient.invalidateQueries({ queryKey, exact: true }), - ), - ); -}; - export const invalidateChatListQueries = (queryClient: QueryClient) => queryClient.invalidateQueries({ queryKey: chatListFamilyKey, diff --git a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx index 41168f472bd..1371adf6f5f 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx @@ -70,6 +70,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 c7b9751a95d..b8819ea3fce 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -897,6 +897,7 @@ const AgentChatPage: FC = () => { requestUnpinAgent, isArchiving, archivingChatId, + activeChatChildren, onOpenRenameDialog, isSidebarCollapsed, onToggleSidebarCollapsed, @@ -2088,7 +2089,7 @@ const AgentChatPage: FC = () => { isPinned={(chatRecord?.pin_order ?? 0) > 0} isChildChat={parentChatID !== undefined} isArchiveBlocked={ - !chatFamilyAllowsArchive(liveChatStatus, chatRecord?.children) + !chatFamilyAllowsArchive(liveChatStatus, activeChatChildren) } urlTransform={urlTransform} hasMoreMessages={chatMessagesQuery.hasNextPage ?? false} 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 57a2fc57e5b..552a6fbd491 100644 --- a/site/src/pages/AgentsPage/AgentsPageLayout.tsx +++ b/site/src/pages/AgentsPage/AgentsPageLayout.tsx @@ -30,7 +30,6 @@ import { invalidateChatCostTree, invalidateChatDiffContents, invalidateChatEntity, - invalidateChatFamilyEntities, invalidateChatListQueries, invalidateChatSearches, invalidateChatsByWorkspace, @@ -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; @@ -309,7 +314,7 @@ const AgentsPageLayout: FC = () => { clearPersistedSidebarTabId(chatId); clearPersistedRightPanelState(chatId); void invalidateChatListQueries(queryClient); - void invalidateChatFamilyEntities(queryClient, chatId); + void invalidateChatEntity(queryClient, chatId); void invalidateChatsByWorkspace(queryClient); void invalidateChatSearches(queryClient); void invalidateWorkspaceMutationQueries(queryClient, { @@ -334,22 +339,11 @@ const AgentsPageLayout: FC = () => { // 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 and every loaded family entity (the archive - // cascades over children, and a mounted child page reads its - // own entity) so all caches converge on the server. + // collection so all caches converge on the server. void invalidateChatListQueries(queryClient); - void invalidateChatFamilyEntities(queryClient, chatId); + void invalidateChatEntity(queryClient, chatId); void invalidateChatsByWorkspace(queryClient); void invalidateChatSearches(queryClient); - // A failed delete may still have committed server-side (lost - // response, late 5xx), so refresh workspace state too. On - // archive-step failures the delete never ran. - if (error instanceof ArchiveAndDeleteError && error.step === "delete") { - void invalidateWorkspaceMutationQueries(queryClient, { - organizationName, - username: user.username, - }); - } }, }); const [pendingArchiveAndDelete, setPendingArchiveAndDelete] = useState<{ @@ -621,16 +615,6 @@ const AgentsPageLayout: FC = () => { // the fallback title. void cancelChatListRefetches(queryClient); void cancelLoadedChatEntityRefetch(queryClient, updatedChat.id); - // Child events also write into the parent's embedded - // children, so cancel the parent's in-flight entity - // refetch too; a response issued before this event could - // otherwise overwrite the newer child state. - if (updatedChat.parent_chat_id) { - void cancelLoadedChatEntityRefetch( - queryClient, - updatedChat.parent_chat_id, - ); - } if (chatEvent.kind === "created") { if (updatedChat.parent_chat_id) { @@ -698,15 +682,6 @@ const AgentsPageLayout: FC = () => { void invalidateChatListQueries(queryClient); void invalidateChatsByWorkspace(queryClient); void invalidateChatSearches(queryClient); - // The watch stream does not replay events missed while - // disconnected, so refetch the open chat's entity: its - // embedded child snapshots gate the archive actions and a - // child created during the gap may emit no further events - // for its whole run. - const activeChatId = activeChatIDRef.current; - if (activeChatId) { - void invalidateChatEntity(queryClient, activeChatId); - } }, }); }, [queryClient]); @@ -763,6 +738,7 @@ const AgentsPageLayout: FC = () => { requestReorderPinnedAgent, isArchiving, archivingChatId, + activeChatChildren: chatList.find((c) => c.id === agentId)?.children, onOpenRenameDialog: setChatPendingRename, isSidebarCollapsed, onToggleSidebarCollapsed: handleToggleSidebarCollapsed, diff --git a/site/src/pages/AgentsPage/components/ChatActionsMenuItems.tsx b/site/src/pages/AgentsPage/components/ChatActionsMenuItems.tsx index e17c68c8154..ebcbd70cd88 100644 --- a/site/src/pages/AgentsPage/components/ChatActionsMenuItems.tsx +++ b/site/src/pages/AgentsPage/components/ChatActionsMenuItems.tsx @@ -29,8 +29,8 @@ const chatStatusAllowsArchive = ( // 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 null array from -// stale caches stays fail-open like an unknown status. +// 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,