From d16f254714004769ad31a40c075a7b1bb6e2059d Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 8 Jul 2026 21:31:34 +0200 Subject: [PATCH] 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. --- .../components/ChatActionsMenuItems.tsx | 71 ++++++---- .../components/ChatTopBar.stories.tsx | 28 +++- .../AgentsPage/components/ChatTopBar.tsx | 76 ++++++----- .../ChatsSidebar/ChatsSidebar.stories.tsx | 128 +++++++++++++++++- .../ChatsSidebar/tree/ChatTreeNode.tsx | 66 +++++---- 5 files changed, 283 insertions(+), 86 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatActionsMenuItems.tsx b/site/src/pages/AgentsPage/components/ChatActionsMenuItems.tsx index a479115c36..84b35e4208 100644 --- a/site/src/pages/AgentsPage/components/ChatActionsMenuItems.tsx +++ b/site/src/pages/AgentsPage/components/ChatActionsMenuItems.tsx @@ -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 = ({ Item, Separator, }) => { + const showPinAction = + !isArchived && !isChildChat && Boolean(onPinAgent && onUnpinAgent); + const showArchiveActions = !isArchived && !isChildChat; + return ( <> - {!isArchived && !isChildChat && onPinAgent && onUnpinAgent && ( + {showPinAction && ( {isPinned ? ( <> @@ -71,10 +89,12 @@ export const ChatActionsMenuItems: FC = ({ )} {isArchived ? ( - - - Unarchive agent - + !isChildChat && ( + + + Unarchive agent + + ) ) : ( <> {onOpenRenameDialog && ( @@ -83,25 +103,28 @@ export const ChatActionsMenuItems: FC = ({ Rename chat )} - {(onOpenRenameDialog || - (!isChildChat && onPinAgent && onUnpinAgent)) && } - - - Archive agent - - {hasWorkspace && ( - - - Archive & delete workspace - + {showArchiveActions && ( + <> + {(onOpenRenameDialog || showPinAction) && } + + + Archive agent + + {hasWorkspace && ( + + + Archive & delete workspace + + )} + )} )} diff --git a/site/src/pages/AgentsPage/components/ChatTopBar.stories.tsx b/site/src/pages/AgentsPage/components/ChatTopBar.stories.tsx index 27db98bcf9..ad894826c5 100644 --- a/site/src/pages/AgentsPage/components/ChatTopBar.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatTopBar.stories.tsx @@ -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(); }, }; diff --git a/site/src/pages/AgentsPage/components/ChatTopBar.tsx b/site/src/pages/AgentsPage/components/ChatTopBar.tsx index 44e680ead5..8f45433717 100644 --- a/site/src/pages/AgentsPage/components/ChatTopBar.tsx +++ b/site/src/pages/AgentsPage/components/ChatTopBar.tsx @@ -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 = ({ )} {/* 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 && ( - - - + + - - - - - - - - )} + + + + )} {/* PR link. On mobile: icon + number; on desktop: icon + title. Hidden on desktop when the sidebar panel is open diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.stories.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.stories.tsx index 780aa7ab13..02c75ad8cf 100644 --- a/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.stories.tsx @@ -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: [ diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/tree/ChatTreeNode.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/tree/ChatTreeNode.tsx index 746beee2be..c60daa84c4 100644 --- a/site/src/pages/AgentsPage/components/ChatsSidebar/tree/ChatTreeNode.tsx +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/tree/ChatTreeNode.tsx @@ -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 = ({ 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 = ({ chat, isChildNode }) => { return (
- +
= ({ chat, isChildNode }) => { loading /> ) : ( - + {chat.has_unread && !isActiveChat ? ( = ({ chat, isChildNode }) => { aria-label="Shared chat" /> )} - - - + + - - - - - - - + + + + )}