Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
d550f9b
fix(site): disable archive actions for active chats
ibetitsmike Aug 18, 2026
0067ca4
fix(site/src/pages/AgentsPage): gate archive on chat family and expos…
ibetitsmike Aug 18, 2026
6238b81
fix(site/src/api/queries): keep parent entity children fresh on child…
ibetitsmike Aug 18, 2026
1f00764
fix(site/src/pages/AgentsPage): revalidate archive eligibility before…
ibetitsmike Aug 18, 2026
1e288b8
fix(site/src/pages/AgentsPage): archive before deleting the workspace
ibetitsmike Aug 18, 2026
08a2a44
fix(site/src/pages/AgentsPage): skip unarchive compensation on ambigu…
ibetitsmike Aug 18, 2026
4fcdba1
test(site/src/pages/AgentsPage): assert error class before reading re…
ibetitsmike Aug 18, 2026
508fa81
fix(site/src): restore pin on unarchive compensation and repair misse…
ibetitsmike Aug 18, 2026
49ea83e
fix(site/src/pages/AgentsPage): drop unarchive compensation after del…
ibetitsmike Aug 18, 2026
f10e466
fix(site/src/pages/AgentsPage): refresh workspace caches on delete-st…
ibetitsmike Aug 18, 2026
7eccd30
fix(site/src/pages/AgentsPage): refetch the open chat entity on watch…
ibetitsmike Aug 18, 2026
7be7012
fix(site/src/pages/AgentsPage): cancel parent entity refetch before c…
ibetitsmike Aug 18, 2026
b5a8e6c
fix(site/src): invalidate loaded family entities after archive-and-de…
ibetitsmike Aug 18, 2026
7f5d886
refactor(site/src): source the archive gate from the chat list and dr…
ibetitsmike Aug 20, 2026
dcbd396
Merge remote-tracking branch 'origin/main' into mike/chat-archive-thyr
ibetitsmike Aug 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions site/src/pages/AgentsPage/AgentChatPage.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ const AgentChatPageLayout: FC = () => {
requestUnpinAgent: () => {},
isArchiving: false,
archivingChatId: undefined,
activeChatChildren: undefined,
isSidebarCollapsed: false,
onToggleSidebarCollapsed: () => {},
onExpandSidebar: () => {},
Expand Down
5 changes: 5 additions & 0 deletions site/src/pages/AgentsPage/AgentChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ import {
} from "./AgentChatPageView";
import type { AgentsPageOutletContext } from "./AgentsPageLayout";
import type { ChatMessageInputRef } from "./components/AgentChatInput";
import { chatFamilyAllowsArchive } from "./components/ChatActionsMenuItems";
import {
type ChatDetailError,
isChatHookDeniedResponse,
Expand Down Expand Up @@ -833,6 +834,7 @@ const AgentChatPage: FC = () => {
requestUnpinAgent,
isArchiving,
archivingChatId,
activeChatChildren,
onOpenRenameDialog,
isSidebarCollapsed,
onToggleSidebarCollapsed,
Expand Down Expand Up @@ -2035,6 +2037,9 @@ const AgentChatPage: FC = () => {
}
isPinned={(chatRecord?.pin_order ?? 0) > 0}
isChildChat={parentChatID !== undefined}
isArchiveBlocked={
!chatFamilyAllowsArchive(liveChatStatus, activeChatChildren)
}
urlTransform={urlTransform}
hasMoreMessages={chatMessagesQuery.hasNextPage ?? false}
isFetchingMoreMessages={chatMessagesQuery.isFetchingNextPage}
Expand Down
3 changes: 3 additions & 0 deletions site/src/pages/AgentsPage/AgentChatPageView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ interface AgentChatPageViewProps {
isPinned?: boolean;
isChildChat?: boolean;
isArchivingThisChat?: boolean;
isArchiveBlocked?: boolean;

// Pagination for loading older messages.
hasMoreMessages: boolean;
Expand Down Expand Up @@ -378,6 +379,7 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
isPinned,
isChildChat,
isArchivingThisChat,
isArchiveBlocked,
hasMoreMessages,
isFetchingMoreMessages,
isHydratingMessages,
Expand Down Expand Up @@ -885,6 +887,7 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
isPinned={isPinned}
isChildChat={isChildChat}
isArchiving={isArchivingThisChat}
isArchiveBlocked={isArchiveBlocked}
hasWorkspace={Boolean(workspace)}
isArchived={isArchived}
diffStatusData={diffStatusData}
Expand Down
1 change: 1 addition & 0 deletions site/src/pages/AgentsPage/AgentEmbedPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ const AgentEmbedPage: FC = () => {
requestArchiveAndDeleteWorkspace,
isArchiving: false,
archivingChatId: undefined,
activeChatChildren: undefined,
isSidebarCollapsed,
onToggleSidebarCollapsed,
onExpandSidebar: () => {},
Expand Down
64 changes: 17 additions & 47 deletions site/src/pages/AgentsPage/AgentsPageLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -82,7 +81,6 @@ import { useAgentsPageKeybindings } from "./hooks/useAgentsPageKeybindings";
import { useAgentsPWA } from "./hooks/useAgentsPWA";
import { getAgentSidebarFilters } from "./utils/agentSidebarFilters";
import {
ArchiveAndDeleteError,
archiveChatAndDeleteWorkspace,
notifyArchiveAndDeleteFailed,
notifyDeleteQueueState,
Expand Down Expand Up @@ -112,6 +110,13 @@ export interface AgentsPageOutletContext {
requestReorderPinnedAgent?: (chatId: string, pinOrder: number) => void;
isArchiving: boolean;
archivingChatId: string | undefined;
/**
* The active chat's children from the chat list cache, which watch
* events keep fresh. The entity cache's embedded children are only a
* fetch-time snapshot, so gating archive actions on them could leave
* the actions disabled after a child finishes.
*/
activeChatChildren: readonly TypesGen.Chat[] | undefined;
onRenameTitle?: (chatId: string, title: string) => Promise<void>;
/** Opens the shared rename dialog so both menus drive the same instance. */
onOpenRenameDialog?: (chat: TypesGen.Chat) => void;
Expand Down Expand Up @@ -323,27 +328,24 @@ const AgentsPageLayout: FC = () => {
deleteBuild,
);
},
onError: (error, { workspaceId }) => {
onError: (error, { chatId, workspaceId }) => {
notifyArchiveAndDeleteFailed(
queryClient.getQueryData<TypesGen.Workspace>(
workspaceByIdKey(workspaceId),
),
error,
(path) => navigate(path),
);
// Archive failed after the delete already ran; refresh
// workspace state so consumers see the deletion.
if (error instanceof ArchiveAndDeleteError && error.step === "archive") {
void invalidateWorkspaceMutationQueries(queryClient, {
organizationName,
username: user.username,
});
}
// The archive may have committed server-side even when the
// request appeared to fail (transport errors), and on delete
// failures the chat stays archived; refetch every chat
// collection so all caches converge on the server.
void invalidateChatListQueries(queryClient);
void invalidateChatEntity(queryClient, chatId);
void invalidateChatsByWorkspace(queryClient);
void invalidateChatSearches(queryClient);
Comment thread
ibetitsmike marked this conversation as resolved.
Comment thread
ibetitsmike marked this conversation as resolved.
},
});
const [pendingArchiveChatId, setPendingArchiveChatId] = useState<
string | null
>(null);
const [pendingArchiveAndDelete, setPendingArchiveAndDelete] = useState<{
chatId: string;
workspaceId: string;
Expand Down Expand Up @@ -401,35 +403,12 @@ const AgentsPageLayout: FC = () => {
(archiveAndDeleteMutation.isPending
? archiveAndDeleteMutation.variables?.chatId
: undefined);
// A chat in any of these statuses has an in-flight run that
// archiving would interrupt, so ask for confirmation first.
const isActiveChat = (chat: TypesGen.Chat | undefined) =>
chat?.status === "running" ||
chat?.status === "interrupting" ||
chat?.status === "requires_action";
const requestArchiveAgent = (chatId: string) => {
if (isArchiving) {
return;
}
const chat =
queryClient.getQueryData<TypesGen.Chat>(chatEntityKey(chatId)) ??
chatList.find((candidate) => candidate.id === chatId);
if (chat === undefined || isActiveChat(chat)) {
setPendingArchiveChatId(chatId);
return;
}
archiveAgentMutation.mutate(chatId);
};
const handleConfirmArchiveAgent = () => {
if (!pendingArchiveChatId || isArchiving) {
return;
}
archiveAgentMutation.mutate(pendingArchiveChatId, {
onSettled: () => {
setPendingArchiveChatId(null);
},
});
};

// Track the active chat ID in a ref so the watchChats
// WebSocket handler can read it without re-subscribing
Expand Down Expand Up @@ -759,6 +738,7 @@ const AgentsPageLayout: FC = () => {
requestReorderPinnedAgent,
isArchiving,
archivingChatId,
activeChatChildren: chatList.find((c) => c.id === agentId)?.children,
onOpenRenameDialog: setChatPendingRename,
isSidebarCollapsed,
onToggleSidebarCollapsed: handleToggleSidebarCollapsed,
Expand Down Expand Up @@ -835,16 +815,6 @@ const AgentsPageLayout: FC = () => {
<Outlet context={outletContextValue} />
</div>
</div>
<ConfirmDialog
open={pendingArchiveChatId !== null}
onClose={() => setPendingArchiveChatId(null)}
onConfirm={handleConfirmArchiveAgent}
type="delete"
confirmText="Archive"
confirmLoading={archiveAgentMutation.isPending}
title="Archive agent?"
description="This agent is currently running. Archiving it will interrupt the current run."
/>
<DeleteDialog
key={pendingWorkspaceName}
isOpen={deleteDialogOpen}
Expand Down
44 changes: 41 additions & 3 deletions site/src/pages/AgentsPage/components/ChatActionsMenuItems.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import {
SquarePenIcon,
Trash2Icon,
} from "lucide-react";
import type { FC } from "react";
import { type FC, useId } from "react";
import type * as TypesGen from "#/api/typesGenerated";
import type {
ContextMenuItem,
ContextMenuSeparator,
Expand All @@ -17,6 +18,27 @@ import type {
DropdownMenuSeparator,
} from "#/components/DropdownMenu/DropdownMenu";

// Backend chatstate permits archive only from W, E0, and E1. Unknown status
// stays fail-open so the server conflict response remains the backstop.
const chatStatusAllowsArchive = (
status: TypesGen.ChatStatus | null | undefined,
): boolean =>
status === undefined ||
status === null ||
status === "waiting" ||
status === "error";

// Archive cascades atomically over the whole family, so the backend
// rejects it when any child is still active, not just the root. Children
// are embedded on chat records (depth capped at 1); a missing children
// array stays fail-open like an unknown status.
export const chatFamilyAllowsArchive = (
status: TypesGen.ChatStatus | null | undefined,
children: readonly TypesGen.Chat[] | null | undefined,
): boolean =>
chatStatusAllowsArchive(status) &&
(children ?? []).every((child) => chatStatusAllowsArchive(child.status));

type ItemComponent = typeof DropdownMenuItem | typeof ContextMenuItem;
type SeparatorComponent =
| typeof DropdownMenuSeparator
Expand All @@ -42,6 +64,7 @@ interface ChatActionsMenuItemsProps {
readonly isChildChat: boolean;
readonly hasWorkspace: boolean;
readonly isArchiving?: boolean;
readonly isArchiveBlocked?: boolean;
readonly subagentCount?: number;
readonly isSubagentsExpanded?: boolean;
readonly onToggleSubagents?: () => void;
Expand All @@ -62,6 +85,7 @@ export const ChatActionsMenuItems: FC<ChatActionsMenuItemsProps> = ({
isChildChat,
hasWorkspace,
isArchiving = false,
isArchiveBlocked = false,
subagentCount = 0,
isSubagentsExpanded = false,
onToggleSubagents,
Expand All @@ -78,6 +102,10 @@ export const ChatActionsMenuItems: FC<ChatActionsMenuItemsProps> = ({
const showPinAction =
!isArchived && !isChildChat && Boolean(onPinAgent && onUnpinAgent);
const showArchiveActions = !isArchived && !isChildChat;
const archiveBlockedHintId = useId();
const archiveBlockedDescribedBy = isArchiveBlocked
? archiveBlockedHintId
: undefined;

const subagentToggle = showSubagentsToggle ? (
<Item onSelect={onToggleSubagents}>
Expand Down Expand Up @@ -131,7 +159,8 @@ export const ChatActionsMenuItems: FC<ChatActionsMenuItemsProps> = ({
)}
<Item
className="text-content-destructive focus:text-content-destructive"
disabled={isArchiving}
aria-describedby={archiveBlockedDescribedBy}
disabled={isArchiving || isArchiveBlocked}
Comment thread
ibetitsmike marked this conversation as resolved.
onSelect={onArchiveAgent}
>
<ArchiveIcon className="size-3.5" />
Expand All @@ -140,13 +169,22 @@ export const ChatActionsMenuItems: FC<ChatActionsMenuItemsProps> = ({
{hasWorkspace && (
<Item
className="text-content-destructive focus:text-content-destructive"
disabled={isArchiving}
aria-describedby={archiveBlockedDescribedBy}
disabled={isArchiving || isArchiveBlocked}
onSelect={onArchiveAndDeleteWorkspace}
Comment thread
ibetitsmike marked this conversation as resolved.
>
<Trash2Icon className="size-3.5" />
Archive & delete workspace
</Item>
)}
{isArchiveBlocked && (
<div
id={archiveBlockedHintId}
className="max-w-56 px-2 py-1.5 text-xs text-content-secondary"
>
Interrupt or wait for the agent to finish first.
</div>
)}
</>
)}
</>
Expand Down
49 changes: 49 additions & 0 deletions site/src/pages/AgentsPage/components/ChatTopBar.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,55 @@ export const ArchiveAndDeleteWorkspaceItem: Story = {
},
};

export const IdleChatArchiveActionsEnabled: Story = {
args: {
hasWorkspace: true,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByLabelText("Open agent actions"));
const body = within(document.body);
const archiveItem = await body.findByRole("menuitem", {
name: "Archive agent",
});
const archiveAndDeleteItem = body.getByRole("menuitem", {
name: "Archive & delete workspace",
});
expect(archiveItem).not.toHaveAttribute("aria-disabled", "true");
expect(archiveAndDeleteItem).not.toHaveAttribute("aria-disabled", "true");
expect(
body.queryByText("Interrupt or wait for the agent to finish first."),
).not.toBeInTheDocument();
},
};

export const ActiveChatArchiveActionsDisabled: Story = {
args: {
hasWorkspace: true,
isArchiveBlocked: true,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByLabelText("Open agent actions"));
const body = within(document.body);
const archiveItem = await body.findByRole("menuitem", {
name: "Archive agent",
});
const archiveAndDeleteItem = body.getByRole("menuitem", {
name: "Archive & delete workspace",
});
expect(archiveItem).toHaveAttribute("aria-disabled", "true");
expect(archiveAndDeleteItem).toHaveAttribute("aria-disabled", "true");
const hint = "Interrupt or wait for the agent to finish first.";
// The menu content fades in, so visibility needs a retry window.
await waitFor(() => {
expect(body.getByText(hint)).toBeVisible();
});
expect(archiveItem).toHaveAccessibleDescription(hint);
expect(archiveAndDeleteItem).toHaveAccessibleDescription(hint);
},
};

export const PreservesArchivedFilterOnMobileBack: Story = {
decorators: mobileDecorator,
parameters: {
Expand Down
3 changes: 3 additions & 0 deletions site/src/pages/AgentsPage/components/ChatTopBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ type ChatTopBarProps = {
hasWorkspace?: boolean;
isArchived?: boolean;
isArchiving?: boolean;
isArchiveBlocked?: boolean;
isChildChat?: boolean;
isPinned?: boolean;
isSidebarCollapsed: boolean;
Expand Down Expand Up @@ -107,6 +108,7 @@ export const ChatTopBar: FC<ChatTopBarProps> = ({
hasWorkspace = false,
isArchived = false,
isArchiving = false,
isArchiveBlocked = false,
isChildChat = false,
isPinned = false,
isSidebarCollapsed,
Expand Down Expand Up @@ -223,6 +225,7 @@ export const ChatTopBar: FC<ChatTopBarProps> = ({
isChildChat={isChildChat}
hasWorkspace={hasWorkspace}
isArchiving={isArchiving}
isArchiveBlocked={isArchiveBlocked}
onPinAgent={onPinAgent}
onUnpinAgent={onUnpinAgent}
onArchiveAgent={onArchiveAgent}
Expand Down
Loading
Loading