fix(site/src/pages/AgentsPage): remove archive actions for child chats (#27063)

Child chats (sub-agent chats) no longer offer archive-state actions in
their menus. Archive state is root-only on the backend and cascades to
children (`coderd/exp_chats.go` rejects `archived` changes when
`parent_chat_id` is set), so a child's "Archive agent", "Archive &
delete workspace", and "Unarchive agent" items always failed with a 400.
All chat action menus (chat header kebab, sidebar row dropdown, sidebar
right-click context menu) render the shared `ChatActionsMenuItems`,
which already hides Pin/Unpin for child chats; this extends the same
gating to the archive and unarchive items.

Since an archived child chat then has no menu actions at all, the menu
triggers are hidden for archived child chats (`chatHasMenuActions`): the
header kebab and the sidebar row's dropdown trigger are not rendered,
and the row's right-click context menu is disabled. Archived root chats
keep their "Unarchive agent" action.

Stories: renamed the ChatTopBar child-chat story to
`ChildChatHidesPinAndArchiveActions` and extended it to assert both
archive items are hidden, plus new stories for the archived-child cases
(`ArchivedChildChatHasNoActionsMenu`,
`ArchivedChildChatRowHasNoActionsMenu`) and a sidebar child-menu story
(`ChildChatMenuHidesArchiveActions`).

Closes CODAGT-631.

> This PR was created by Mux, an AI agent working on behalf of Mike.
This commit is contained in:
Michael Suchacz
2026-07-08 21:31:34 +02:00
committed by GitHub
parent 990f0a5529
commit d16f254714
5 changed files with 283 additions and 86 deletions
@@ -21,6 +21,20 @@ type SeparatorComponent =
| typeof DropdownMenuSeparator
| typeof ContextMenuSeparator;
/**
* Archive state is root-only on the backend and cascades to children, so
* child chats expose no archive or unarchive actions. An archived child chat
* therefore has no menu actions at all; call sites use this to hide the menu
* trigger instead of rendering an empty menu.
*/
export const chatHasMenuActions = ({
isArchived,
isChildChat,
}: {
isArchived: boolean;
isChildChat: boolean;
}): boolean => !(isArchived && isChildChat);
interface ChatActionsMenuItemsProps {
readonly isArchived: boolean;
readonly isPinned: boolean;
@@ -53,9 +67,13 @@ export const ChatActionsMenuItems: FC<ChatActionsMenuItemsProps> = ({
Item,
Separator,
}) => {
const showPinAction =
!isArchived && !isChildChat && Boolean(onPinAgent && onUnpinAgent);
const showArchiveActions = !isArchived && !isChildChat;
return (
<>
{!isArchived && !isChildChat && onPinAgent && onUnpinAgent && (
{showPinAction && (
<Item onSelect={isPinned ? onUnpinAgent : onPinAgent}>
{isPinned ? (
<>
@@ -71,10 +89,12 @@ export const ChatActionsMenuItems: FC<ChatActionsMenuItemsProps> = ({
</Item>
)}
{isArchived ? (
<Item disabled={isArchiving} onSelect={onUnarchiveAgent}>
<ArchiveRestoreIcon className="size-3.5" />
Unarchive agent
</Item>
!isChildChat && (
<Item disabled={isArchiving} onSelect={onUnarchiveAgent}>
<ArchiveRestoreIcon className="size-3.5" />
Unarchive agent
</Item>
)
) : (
<>
{onOpenRenameDialog && (
@@ -83,25 +103,28 @@ export const ChatActionsMenuItems: FC<ChatActionsMenuItemsProps> = ({
Rename chat
</Item>
)}
{(onOpenRenameDialog ||
(!isChildChat && onPinAgent && onUnpinAgent)) && <Separator />}
<Item
className="text-content-destructive focus:text-content-destructive"
disabled={isArchiving}
onSelect={onArchiveAgent}
>
<ArchiveIcon className="size-3.5" />
Archive agent
</Item>
{hasWorkspace && (
<Item
className="text-content-destructive focus:text-content-destructive"
disabled={isArchiving}
onSelect={onArchiveAndDeleteWorkspace}
>
<Trash2Icon className="size-3.5" />
Archive & delete workspace
</Item>
{showArchiveActions && (
<>
{(onOpenRenameDialog || showPinAction) && <Separator />}
<Item
className="text-content-destructive focus:text-content-destructive"
disabled={isArchiving}
onSelect={onArchiveAgent}
>
<ArchiveIcon className="size-3.5" />
Archive agent
</Item>
{hasWorkspace && (
<Item
className="text-content-destructive focus:text-content-destructive"
disabled={isArchiving}
onSelect={onArchiveAndDeleteWorkspace}
>
<Trash2Icon className="size-3.5" />
Archive & delete workspace
</Item>
)}
</>
)}
</>
)}
@@ -307,9 +307,10 @@ export const UnpinAgentItem: Story = {
},
};
export const ChildChatHidesPinAction: Story = {
export const ChildChatHidesPinAndArchiveActions: Story = {
args: {
isChildChat: true,
hasWorkspace: true,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
@@ -318,11 +319,34 @@ export const ChildChatHidesPinAction: Story = {
await waitFor(() => {
const body = within(document.body);
expect(body.getByText("Rename chat")).toBeInTheDocument();
expect(body.getByText("Archive agent")).toBeInTheDocument();
});
const body = within(document.body);
expect(body.queryByText("Pin agent")).not.toBeInTheDocument();
expect(body.queryByText("Unpin agent")).not.toBeInTheDocument();
expect(body.queryByText("Archive agent")).not.toBeInTheDocument();
expect(
body.queryByText("Archive & delete workspace"),
).not.toBeInTheDocument();
},
};
export const ArchivedChildChatHasNoActionsMenu: Story = {
args: {
isChildChat: true,
isArchived: true,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await waitFor(() => {
expect(
canvas.getByText("Build authentication feature"),
).toBeInTheDocument();
});
// Archive state is root-only, so an archived child chat has no menu
// actions at all and the actions trigger is hidden entirely.
expect(
canvas.queryByLabelText("Open agent actions"),
).not.toBeInTheDocument();
},
};
@@ -23,7 +23,10 @@ import {
import { Popover, PopoverTrigger } from "#/components/Popover/Popover";
import { cn } from "#/utils/cn";
import { parsePullRequestUrl } from "../utils/pullRequest";
import { ChatActionsMenuItems } from "./ChatActionsMenuItems";
import {
ChatActionsMenuItems,
chatHasMenuActions,
} from "./ChatActionsMenuItems";
import { useEmbedContext } from "./EmbedContext";
import { PrStateIcon } from "./GitPanel/GitPanel";
@@ -194,41 +197,44 @@ export const ChatTopBar: FC<ChatTopBarProps> = ({
</div>
)}
{/* Actions menu sits inline with the title so it tracks the title's right edge.
Suppressed when there is no chat to act on (loading and not-found views). */}
{!isEmbedded && chatTitle && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="subtle"
className="size-7 shrink-0 text-content-secondary hover:text-content-primary"
aria-label="Open agent actions"
Suppressed when there is no chat to act on (loading and not-found views)
and when the chat has no menu actions (archived child chats). */}
{!isEmbedded &&
chatTitle &&
chatHasMenuActions({ isArchived, isChildChat }) && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="subtle"
className="size-7 shrink-0 text-content-secondary hover:text-content-primary"
aria-label="Open agent actions"
>
<EllipsisVerticalIcon className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="mobile-full-width-dropdown mobile-full-width-dropdown-top [&_[role=menuitem]]:text-[13px]"
>
<EllipsisVerticalIcon className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="mobile-full-width-dropdown mobile-full-width-dropdown-top [&_[role=menuitem]]:text-[13px]"
>
<ChatActionsMenuItems
isArchived={isArchived}
isPinned={isPinned}
isChildChat={isChildChat}
hasWorkspace={hasWorkspace}
isArchiving={isArchiving}
onPinAgent={onPinAgent}
onUnpinAgent={onUnpinAgent}
onArchiveAgent={onArchiveAgent}
onUnarchiveAgent={onUnarchiveAgent}
onArchiveAndDeleteWorkspace={onArchiveAndDeleteWorkspace}
onOpenRenameDialog={onOpenRenameDialog}
Item={DropdownMenuItem}
Separator={DropdownMenuSeparator}
/>
</DropdownMenuContent>
</DropdownMenu>
)}
<ChatActionsMenuItems
isArchived={isArchived}
isPinned={isPinned}
isChildChat={isChildChat}
hasWorkspace={hasWorkspace}
isArchiving={isArchiving}
onPinAgent={onPinAgent}
onUnpinAgent={onUnpinAgent}
onArchiveAgent={onArchiveAgent}
onUnarchiveAgent={onUnarchiveAgent}
onArchiveAndDeleteWorkspace={onArchiveAndDeleteWorkspace}
onOpenRenameDialog={onOpenRenameDialog}
Item={DropdownMenuItem}
Separator={DropdownMenuSeparator}
/>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{/* PR link. On mobile: icon + number; on desktop: icon + title.
Hidden on desktop when the sidebar panel is open
@@ -2,7 +2,14 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
import type { ComponentProps } from "react";
import { useEffect, useState } from "react";
import { useLocation } from "react-router";
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
import {
expect,
fireEvent,
fn,
userEvent,
waitFor,
within,
} from "storybook/test";
import { reactRouterParameters } from "storybook-addon-remix-react-router";
import { userChatProviderConfigsKey } from "#/api/queries/chats";
import type * as TypesGen from "#/api/typesGenerated";
@@ -2037,6 +2044,125 @@ export const AgentWithWorkspaceMenuFull: Story = {
},
};
export const ArchivedChildChatRowHasNoActionsMenu: Story = {
args: {
chats: [
buildChat({
id: "root-archived",
title: "Archived root agent",
archived: true,
children: [
buildChat({
id: "child-archived",
title: "Archived child agent",
archived: true,
parent_chat_id: "root-archived",
root_chat_id: "root-archived",
}),
],
}),
],
sidebarFilters: { ...defaultSidebarFilters, archiveStatus: "archived" },
},
parameters: {
reactRouter: reactRouterParameters({
location: {
path: "/agents/child-archived",
pathParams: { agentId: "child-archived" },
},
routing: agentsRouting,
}),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await waitFor(() => {
expect(canvas.getByText("Archived child agent")).toBeInTheDocument();
});
// The archived root keeps its actions menu (unarchive lives there).
expect(
canvas.getByLabelText("Open actions for Archived root agent"),
).toBeInTheDocument();
// Archive state is root-only, so the archived child has no menu
// actions at all: its dropdown trigger is hidden entirely.
expect(
canvas.queryByLabelText("Open actions for Archived child agent"),
).not.toBeInTheDocument();
// The timestamp normally swaps out for the actions trigger on hover
// (a CSS-only group-hover swap). Without menu actions there is no
// trigger, so the row keeps its timestamp: ChatTreeNode only applies
// the hover-hidden classes when the row has menu actions. CSS :hover
// cannot be reliably driven in this environment, so this story
// asserts the timestamp is present and visible in the resting state.
const childRow = canvas.getByTestId("agents-tree-node-child-archived");
expect(within(childRow).getByText("1w")).toBeVisible();
// Positive control: right-clicking the root row opens a context menu,
// proving the context menu mechanism works in this story.
fireEvent.contextMenu(canvas.getByTestId("agents-tree-node-root-archived"));
await waitFor(() => {
expect(within(document.body).getByRole("menu")).toBeInTheDocument();
});
await userEvent.keyboard("{Escape}");
await waitFor(() => {
expect(within(document.body).queryByRole("menu")).not.toBeInTheDocument();
});
// Right-clicking the archived child row must not open a context menu.
fireEvent.contextMenu(
canvas.getByTestId("agents-tree-node-child-archived"),
);
expect(within(document.body).queryByRole("menu")).not.toBeInTheDocument();
},
};
export const ChildChatMenuHidesArchiveActions: Story = {
args: {
chats: [
buildChat({
id: "root-child-menu",
title: "Root agent",
children: [
buildChat({
id: "child-menu",
title: "Child agent",
parent_chat_id: "root-child-menu",
root_chat_id: "root-child-menu",
workspace_id: "workspace-1",
}),
],
}),
],
},
parameters: {
reactRouter: reactRouterParameters({
location: {
path: "/agents/child-menu",
pathParams: { agentId: "child-menu" },
},
routing: agentsRouting,
}),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await waitFor(() => {
expect(canvas.getByText("Child agent")).toBeInTheDocument();
});
const trigger = canvas.getByLabelText("Open actions for Child agent");
await userEvent.click(trigger);
await waitFor(() => {
const body = within(document.body);
expect(body.getByText("Rename chat")).toBeInTheDocument();
});
const body = within(document.body);
expect(body.queryByText("Pin agent")).not.toBeInTheDocument();
expect(body.queryByText("Archive agent")).not.toBeInTheDocument();
expect(
body.queryByText("Archive & delete workspace"),
).not.toBeInTheDocument();
},
};
export const PinnedChatsSection: Story = {
args: {
chats: [
@@ -25,7 +25,10 @@ import {
import { Spinner } from "#/components/Spinner/Spinner";
import { cn } from "#/utils/cn";
import { shortRelativeTime } from "#/utils/time";
import { ChatActionsMenuItems } from "../../ChatActionsMenuItems";
import {
ChatActionsMenuItems,
chatHasMenuActions,
} from "../../ChatActionsMenuItems";
import { asNonEmptyString } from "../../ChatConversation/blockUtils";
import { normalizeLocationSearch } from "../locationSearch";
import { useChatTree } from "./ChatTreeContext";
@@ -138,6 +141,10 @@ export const ChatTreeNode: FC<ChatTreeNodeProps> = ({ chat, isChildNode }) => {
const isArchivingThisChat = isArchiving && archivingChatId === chat.id;
const isExpanded = normalizedSearch ? true : (expandedById[chatID] ?? false);
const hasMenuActions = chatHasMenuActions({
isArchived: chat.archived,
isChildChat: isChildNode,
});
const sharedMenuItemProps = {
isArchived: chat.archived,
isPinned: chat.pin_order > 0,
@@ -161,7 +168,7 @@ export const ChatTreeNode: FC<ChatTreeNodeProps> = ({ chat, isChildNode }) => {
return (
<div className="flex min-w-0 flex-col gap-0.5">
<ContextMenu>
<ContextMenuTrigger asChild>
<ContextMenuTrigger asChild disabled={!hasMenuActions}>
<div
data-testid={`agents-tree-node-${chat.id}`}
className={cn(
@@ -270,7 +277,16 @@ export const ChatTreeNode: FC<ChatTreeNodeProps> = ({ chat, isChildNode }) => {
loading
/>
) : (
<span className="flex items-center justify-end text-xs text-content-secondary/50 tabular-nums [@media(hover:hover)]:group-hover:hidden group-has-[[data-state=open]]:hidden">
<span
className={cn(
"flex items-center justify-end text-xs text-content-secondary/50 tabular-nums",
// The timestamp swaps out for the actions trigger on
// hover; without menu actions there is no trigger, so
// keep the timestamp visible.
hasMenuActions &&
"[@media(hover:hover)]:group-hover:hidden group-has-[[data-state=open]]:hidden",
)}
>
{chat.has_unread && !isActiveChat ? (
<span
className="size-2 shrink-0 rounded-full bg-content-link pr-1"
@@ -297,28 +313,30 @@ export const ChatTreeNode: FC<ChatTreeNodeProps> = ({ chat, isChildNode }) => {
aria-label="Shared chat"
/>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="subtle"
className="absolute inset-0 flex h-6 w-7 min-w-0 justify-end rounded-none px-0 opacity-0 text-content-secondary hover:text-content-primary [@media(hover:hover)]:group-hover:opacity-100 data-[state=open]:opacity-100"
aria-label={`Open actions for ${chat.title}`}
{hasMenuActions && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="subtle"
className="absolute inset-0 flex h-6 w-7 min-w-0 justify-end rounded-none px-0 opacity-0 text-content-secondary hover:text-content-primary [@media(hover:hover)]:group-hover:opacity-100 data-[state=open]:opacity-100"
aria-label={`Open actions for ${chat.title}`}
>
<EllipsisVerticalIcon className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="[&_[role=menuitem]]:text-[13px]"
>
<EllipsisVerticalIcon className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="[&_[role=menuitem]]:text-[13px]"
>
<ChatActionsMenuItems
{...sharedMenuItemProps}
Item={DropdownMenuItem}
Separator={DropdownMenuSeparator}
/>
</DropdownMenuContent>
</DropdownMenu>
<ChatActionsMenuItems
{...sharedMenuItemProps}
Item={DropdownMenuItem}
Separator={DropdownMenuSeparator}
/>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
</ContextMenuTrigger>