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" /> )} - - - + + - - - - - - - + + + + )}