From 3c889fb38401e9c8e8e39bd2e1d6212f53bdc6f8 Mon Sep 17 00:00:00 2001 From: TJ Date: Tue, 30 Jun 2026 11:56:45 -0700 Subject: [PATCH] fix(site/src/pages/AgentsPage): normalize chat menus to a kebab icon and matching items (#26588) The chat title menu used a horizontal ellipsis (meatball) and a different set of items than the sidebar chat-row menu. Designers asked to normalize both menus on the sidebar shape since the sidebar is the more recent design. The chat top bar now uses the same `EllipsisVerticalIcon` (kebab) as the sidebar row and exposes the same items in the same order: Pin/Unpin agent, Rename chat, Archive agent, and Archive & delete workspace. Labels are sentence case throughout. The standalone "Generate new title" item is removed because the rename dialog's Generate button covers the same workflow, which is what the sidebar already exposes. The kebab now sits inline with the title rather than off to the right, so it reads as a title menu instead of a global action. Both menus render from a single `ChatActionsMenuItems` component so they cannot drift in the future. The shared component is polymorphic over `Item` and `Separator`, which lets the sidebar drive both its kebab (`DropdownMenu`) and its right-click context menu (`ContextMenu`) from the same JSX.
Implementation notes - `ChatActionsMenuItems.tsx`: new shared component. Renders the Pin/Unpin, Rename, Archive, Archive & delete workspace, and Unarchive items off a flat set of flags (`isArchived`, `isPinned`, `isChildChat`, `hasWorkspace`, `isArchiving`) and zero-arg handlers, plus polymorphic `Item`/`Separator` components. - `ChatTopBar.tsx`: replace the inline `EllipsisIcon` + duplicated dropdown body with the kebab trigger and a `` call. Move the dropdown trigger into the title area so it sits next to the title text; switch `DropdownMenuContent` to `align="start"` so the menu opens flush with the trigger's left edge. Replace the old `onRegenerateTitle`/`isRegenerateTitleDisabled` props with `onPinAgent`, `onUnpinAgent`, `onOpenRenameDialog`, `isPinned`, and `isChildChat`. - `ChatTreeNode.tsx`: delete the inline `renderMenuItems` helper and call `` from both the `DropdownMenuContent` and the `ContextMenuContent`. Pin handlers are pre-bound to `chat.id`; `onOpenRenameDialog` is pre-bound to the chat object. - `AgentsPageView.tsx`: lift the rename-chat dialog state up here and expose it through `AgentsOutletContext.onOpenRenameDialog`, so the sidebar row menu and the chat top bar open the same dialog instance. - `ChatsSidebar.tsx`: accept optional controlled `chatPendingRename`/`onChatPendingRenameChange` props with internal-state fallback (controlled-or-uncontrolled pattern). Existing stories and tests are untouched. - `AgentChatPage.tsx`: consume `requestPinAgent`, `requestUnpinAgent`, and `onOpenRenameDialog` from the outlet context, and pass new `handlePinAgentAction`, `handleUnpinAgentAction`, `handleOpenRenameDialog`, `isPinned`, and `isChildChat` props through `AgentChatPageView` to the top bar. - Stories: `ChatTopBar.stories.tsx` replaces the `GenerateTitle` story with `RenameChatItem`, `PinAgentItem`, `UnpinAgentItem`, and `ChildChatHidesPinAction`. Updated label expectations to sentence case across stories. `AgentChatPage.stories.tsx` updated for the new "Archive agent" label.
Decision log - **Source of truth.** Per the designer, the sidebar menu is the canonical list; the chat title menu is what should change. - **One menu body.** A shared `ChatActionsMenuItems` component drives both menus so they cannot drift. Reviewers only need to read the menu items once. - **Icon parity.** Both menus now use `EllipsisVerticalIcon` (kebab). The old meatball was the only `EllipsisIcon` use in this surface. - **Kebab position.** Moved next to the title so the kebab reads as a per-chat action against the title, not a global top-bar action. Share and Toggle-panel stay on the right of the bar. - **"Generate new title" removed, not preserved as extra.** Keeping it would have re-introduced the inconsistency the designer is asking to remove. The rename dialog's Generate button already runs the propose flow and lets the user accept or edit the suggestion. The auto-regenerate code path (`requestRegenerateTitle`, `regeneratingTitleChatIds`, the title spinner) is left in place because removing it expands the diff and the regenerate plumbing may be re-used; only the menu trigger is gone. - **Single dialog instance.** Two separate `RenameChatDialog` instances would have worked, but lifting the state to `AgentsPageView` keeps a single source of truth and lets the top bar open the dialog the user already knows. `ChatsSidebar` keeps internal-state fallback so existing stories and tests do not need to thread state. - **`isChildChat`.** Pin/Unpin is hidden for child chats to match `ChatTreeNode`, which only renders the Pin item when `!isChildNode`.
Opened by Coder Agents on behalf of @tracyjohnsonux. --- .../AgentsPage/AgentChatPage.stories.tsx | 4 +- site/src/pages/AgentsPage/AgentChatPage.tsx | 49 +++++-- .../AgentsPage/AgentChatPageView.stories.tsx | 1 - .../pages/AgentsPage/AgentChatPageView.tsx | 30 +++-- site/src/pages/AgentsPage/AgentEmbedPage.tsx | 2 + .../AgentsPage/AgentsPageView.stories.tsx | 1 - site/src/pages/AgentsPage/AgentsPageView.tsx | 13 +- .../components/ChatActionsMenuItems.tsx | 110 ++++++++++++++++ .../components/ChatTopBar.stories.tsx | 104 +++++++++++++-- .../AgentsPage/components/ChatTopBar.tsx | 123 ++++++++---------- .../ChatsSidebar/ChatsSidebar.stories.tsx | 39 ++++++ .../components/ChatsSidebar/ChatsSidebar.tsx | 24 +++- .../ChatsSidebar/tree/ChatTreeNode.tsx | 107 +++++---------- 13 files changed, 423 insertions(+), 184 deletions(-) create mode 100644 site/src/pages/AgentsPage/components/ChatActionsMenuItems.tsx diff --git a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx index efb313ff2f..cee9a31e76 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx @@ -60,6 +60,8 @@ const AgentChatPageLayout: FC = () => { requestUnarchiveAgent: () => {}, requestPinAgent: () => {}, requestUnpinAgent: () => {}, + isArchiving: false, + archivingChatId: undefined, onRegenerateTitle: () => {}, regeneratingTitleChatIds: [], isSidebarCollapsed: false, @@ -1479,7 +1481,7 @@ export const CompletedWithDiffPanel: Story = { // Verify menu items are rendered. const body = within(document.body); await waitFor(() => { - expect(body.getByText("Archive Agent")).toBeInTheDocument(); + expect(body.getByText("Archive agent")).toBeInTheDocument(); }); // Workspace items moved to the workspace pill popover. expect(body.queryByText("Open in Cursor")).not.toBeInTheDocument(); diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index e3ad6a9d5a..230c1a4af8 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -705,7 +705,11 @@ const AgentChatPage: FC = () => { requestArchiveAgent, requestArchiveAndDeleteWorkspace, requestUnarchiveAgent, - onRegenerateTitle, + requestPinAgent, + requestUnpinAgent, + isArchiving, + archivingChatId, + onOpenRenameDialog, regeneratingTitleChatIds, isSidebarCollapsed, onToggleSidebarCollapsed, @@ -978,7 +982,6 @@ const AgentChatPage: FC = () => { has_more: chatMessagesQuery.data?.pages.at(-1)?.has_more ?? false, } : undefined; - const isRegenerateTitleDisabled = isArchived || isRegeneratingThisChat; const chatLastModelConfigID = chatRecord?.last_model_config_id; // Destructure mutation results directly so the React Compiler @@ -1286,6 +1289,30 @@ const AgentChatPage: FC = () => { requestUnarchiveAgent(agentId); }; + const handlePinAgentAction = () => { + if (!agentId || isArchived) { + return; + } + requestPinAgent(agentId); + }; + + const handleUnpinAgentAction = () => { + if (!agentId || isArchived) { + return; + } + requestUnpinAgent(agentId); + }; + + const handleOpenRenameDialogAction = + onOpenRenameDialog && chatRecord + ? () => { + if (isArchived) { + return; + } + onOpenRenameDialog(chatRecord); + } + : undefined; + // Signal ready only after the store has synced fetched messages, // so the DOM actually contains them when the parent scrolls. const chatReadyFiredRef = useRef(null); @@ -1538,13 +1565,6 @@ const AgentChatPage: FC = () => { }); } - const handleRegenerateTitle = () => { - if (!agentId || isRegenerateTitleDisabled || !onRegenerateTitle) { - return; - } - onRegenerateTitle(agentId); - }; - const handleSendAskUserQuestionResponse = async (message: string) => { await submitChatTurn({ message, @@ -1663,9 +1683,16 @@ const AgentChatPage: FC = () => { handleArchiveAndDeleteWorkspaceAction={ handleArchiveAndDeleteWorkspaceAction } - handleRegenerateTitle={handleRegenerateTitle} + handlePinAgentAction={handlePinAgentAction} + handleUnpinAgentAction={handleUnpinAgentAction} + handleOpenRenameDialogAction={handleOpenRenameDialogAction} + isArchivingThisChat={ + isArchiving && + (archivingChatId === undefined || archivingChatId === agentId) + } + isPinned={(chatRecord?.pin_order ?? 0) > 0} + isChildChat={parentChatID !== undefined} isRegeneratingTitle={isRegeneratingThisChat} - isRegenerateTitleDisabled={isRegenerateTitleDisabled} urlTransform={urlTransform} scrollContainerRef={scrollContainerRef} scrollToBottomRef={scrollToBottomRef} diff --git a/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx b/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx index 918f3ffaf6..d7569404f4 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx @@ -166,7 +166,6 @@ const StoryAgentChatPageView: FC = ({ editing, ...overrides }) => { handleArchiveAgentAction: fn(), handleUnarchiveAgentAction: fn(), handleArchiveAndDeleteWorkspaceAction: fn(), - handleRegenerateTitle: fn(), scrollContainerRef: overrides.scrollContainerRef ?? defaultScrollContainerRef, scrollToBottomRef: overrides.scrollToBottomRef ?? defaultScrollToBottomRef, diff --git a/site/src/pages/AgentsPage/AgentChatPageView.tsx b/site/src/pages/AgentsPage/AgentChatPageView.tsx index 460c23d440..d3db158ff0 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.tsx @@ -187,13 +187,17 @@ interface AgentChatPageViewProps { onImplementPlan?: () => Promise | void; onSendAskUserQuestionResponse?: (message: string) => Promise | void; - // Archive actions. + // Chat actions. handleArchiveAgentAction: () => void; handleUnarchiveAgentAction: () => void; handleArchiveAndDeleteWorkspaceAction: () => void; - handleRegenerateTitle?: () => void; + handlePinAgentAction?: () => void; + handleUnpinAgentAction?: () => void; + handleOpenRenameDialogAction?: () => void; + isPinned?: boolean; + isChildChat?: boolean; + isArchivingThisChat?: boolean; isRegeneratingTitle?: boolean; - isRegenerateTitleDisabled?: boolean; // Scroll container ref. scrollContainerRef: RefObject; @@ -359,9 +363,13 @@ export const AgentChatPageView: FC = ({ handleArchiveAgentAction, handleUnarchiveAgentAction, handleArchiveAndDeleteWorkspaceAction, - handleRegenerateTitle, + handlePinAgentAction, + handleUnpinAgentAction, + handleOpenRenameDialogAction, + isPinned, + isChildChat, + isArchivingThisChat, isRegeneratingTitle, - isRegenerateTitleDisabled, scrollContainerRef, scrollToBottomRef, hasMoreMessages, @@ -837,11 +845,13 @@ export const AgentChatPageView: FC = ({ onArchiveAndDeleteWorkspace={ handleArchiveAndDeleteWorkspaceAction } - {...(handleRegenerateTitle - ? { onRegenerateTitle: handleRegenerateTitle } - : {})} + onPinAgent={handlePinAgentAction} + onUnpinAgent={handleUnpinAgentAction} + onOpenRenameDialog={handleOpenRenameDialogAction} + isPinned={isPinned} + isChildChat={isChildChat} + isArchiving={isArchivingThisChat} isRegeneratingTitle={isRegeneratingTitle} - isRegenerateTitleDisabled={isRegenerateTitleDisabled} hasWorkspace={Boolean(workspace)} isArchived={isArchived} diffStatusData={diffStatusData} @@ -1080,7 +1090,6 @@ export const AgentChatPageLoadingView: FC = ({ }} onArchiveAgent={() => {}} onUnarchiveAgent={() => {}} - onRegenerateTitle={() => {}} onArchiveAndDeleteWorkspace={() => {}} hasWorkspace={false} isSidebarCollapsed={isSidebarCollapsed} @@ -1158,7 +1167,6 @@ export const AgentChatPageNotFoundView: FC = ({ }} onArchiveAgent={() => {}} onUnarchiveAgent={() => {}} - onRegenerateTitle={() => {}} onArchiveAndDeleteWorkspace={() => {}} hasWorkspace={false} isSidebarCollapsed={isSidebarCollapsed} diff --git a/site/src/pages/AgentsPage/AgentEmbedPage.tsx b/site/src/pages/AgentsPage/AgentEmbedPage.tsx index c256f0f3d8..d3f9fe787e 100644 --- a/site/src/pages/AgentsPage/AgentEmbedPage.tsx +++ b/site/src/pages/AgentsPage/AgentEmbedPage.tsx @@ -230,6 +230,8 @@ const AgentEmbedPage: FC = () => { requestPinAgent: () => {}, requestUnpinAgent: () => {}, requestArchiveAndDeleteWorkspace, + isArchiving: false, + archivingChatId: undefined, // Title regeneration is not supported in embed mode. regeneratingTitleChatIds: [], isSidebarCollapsed, diff --git a/site/src/pages/AgentsPage/AgentsPageView.stories.tsx b/site/src/pages/AgentsPage/AgentsPageView.stories.tsx index 4b72eedba4..818992636c 100644 --- a/site/src/pages/AgentsPage/AgentsPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentsPageView.stories.tsx @@ -261,7 +261,6 @@ const AgentTopBarRouteElement = () => { panel={{ showSidebarPanel: false, onToggleSidebar: fn() }} onArchiveAgent={fn()} onArchiveAndDeleteWorkspace={fn()} - onRegenerateTitle={fn()} onUnarchiveAgent={fn()} isSidebarCollapsed={isSidebarCollapsed} onToggleSidebarCollapsed={onToggleSidebarCollapsed} diff --git a/site/src/pages/AgentsPage/AgentsPageView.tsx b/site/src/pages/AgentsPage/AgentsPageView.tsx index 6df47b4b1e..e49948c877 100644 --- a/site/src/pages/AgentsPage/AgentsPageView.tsx +++ b/site/src/pages/AgentsPage/AgentsPageView.tsx @@ -1,4 +1,4 @@ -import { type FC, type RefObject, useRef } from "react"; +import { type FC, type RefObject, useRef, useState } from "react"; import { Outlet, useLocation } from "react-router"; import type * as TypesGen from "#/api/typesGenerated"; import { cn } from "#/utils/cn"; @@ -26,8 +26,11 @@ export interface AgentsOutletContext { requestPinAgent: (chatId: string) => void; requestUnpinAgent: (chatId: string) => void; requestReorderPinnedAgent?: (chatId: string, pinOrder: number) => void; + isArchiving: boolean; + archivingChatId: string | undefined; onRegenerateTitle?: (chatId: string) => void; onRenameTitle?: (chatId: string, title: string) => Promise; + onOpenRenameDialog?: (chat: TypesGen.Chat) => void; regeneratingTitleChatIds: readonly string[]; isSidebarCollapsed: boolean; onToggleSidebarCollapsed: () => void; @@ -142,6 +145,9 @@ export const AgentsPageView: FC = ({ const scrollContainerRef = useRef(null); + const [chatPendingRename, setChatPendingRename] = + useState(null); + const outletContextValue: AgentsOutletContext = { chatErrorReasons, setChatErrorReason, @@ -152,9 +158,12 @@ export const AgentsPageView: FC = ({ requestPinAgent, requestUnpinAgent, requestReorderPinnedAgent, + isArchiving, + archivingChatId, onRegenerateTitle: (chatId: string) => { onRegenerateTitle(chatId).catch(() => {}); }, + onOpenRenameDialog: setChatPendingRename, regeneratingTitleChatIds, isSidebarCollapsed, onToggleSidebarCollapsed, @@ -194,6 +203,8 @@ export const AgentsPageView: FC = ({ onReorderPinnedAgent={requestReorderPinnedAgent} onRenameTitle={onRenameTitle} onProposeTitle={onProposeTitle} + chatPendingRename={chatPendingRename} + onChatPendingRenameChange={setChatPendingRename} regeneratingTitleChatIds={regeneratingTitleChatIds} onBeforeNewAgent={handleNewAgent} isSearchDialogOpen={isSearchDialogOpen} diff --git a/site/src/pages/AgentsPage/components/ChatActionsMenuItems.tsx b/site/src/pages/AgentsPage/components/ChatActionsMenuItems.tsx new file mode 100644 index 0000000000..a479115c36 --- /dev/null +++ b/site/src/pages/AgentsPage/components/ChatActionsMenuItems.tsx @@ -0,0 +1,110 @@ +import { + ArchiveIcon, + ArchiveRestoreIcon, + PinIcon, + PinOffIcon, + SquarePenIcon, + Trash2Icon, +} from "lucide-react"; +import type { FC } from "react"; +import type { + ContextMenuItem, + ContextMenuSeparator, +} from "#/components/ContextMenu/ContextMenu"; +import type { + DropdownMenuItem, + DropdownMenuSeparator, +} from "#/components/DropdownMenu/DropdownMenu"; + +type ItemComponent = typeof DropdownMenuItem | typeof ContextMenuItem; +type SeparatorComponent = + | typeof DropdownMenuSeparator + | typeof ContextMenuSeparator; + +interface ChatActionsMenuItemsProps { + readonly isArchived: boolean; + readonly isPinned: boolean; + readonly isChildChat: boolean; + readonly hasWorkspace: boolean; + readonly isArchiving?: boolean; + readonly onPinAgent?: () => void; + readonly onUnpinAgent?: () => void; + readonly onArchiveAgent: () => void; + readonly onUnarchiveAgent: () => void; + readonly onArchiveAndDeleteWorkspace: () => void; + /** When omitted, the "Rename chat" item is hidden. */ + readonly onOpenRenameDialog?: () => void; + readonly Item: ItemComponent; + readonly Separator: SeparatorComponent; +} + +export const ChatActionsMenuItems: FC = ({ + isArchived, + isPinned, + isChildChat, + hasWorkspace, + isArchiving = false, + onPinAgent, + onUnpinAgent, + onArchiveAgent, + onUnarchiveAgent, + onArchiveAndDeleteWorkspace, + onOpenRenameDialog, + Item, + Separator, +}) => { + return ( + <> + {!isArchived && !isChildChat && onPinAgent && onUnpinAgent && ( + + {isPinned ? ( + <> + + Unpin agent + + ) : ( + <> + + Pin agent + + )} + + )} + {isArchived ? ( + + + Unarchive agent + + ) : ( + <> + {onOpenRenameDialog && ( + + + Rename chat + + )} + {(onOpenRenameDialog || + (!isChildChat && onPinAgent && onUnpinAgent)) && } + + + 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 02c3826c14..e53001d1b2 100644 --- a/site/src/pages/AgentsPage/components/ChatTopBar.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatTopBar.stories.tsx @@ -20,7 +20,9 @@ const defaultProps = { }, onArchiveAgent: fn(), onArchiveAndDeleteWorkspace: fn(), - onRegenerateTitle: fn(), + onPinAgent: fn(), + onUnpinAgent: fn(), + onOpenRenameDialog: fn(), onUnarchiveAgent: fn(), isSidebarCollapsed: false, onToggleSidebarCollapsed: fn(), @@ -107,6 +109,12 @@ export const NoTitle: Story = { args: { chatTitle: undefined, }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect( + canvas.queryByLabelText("Open agent actions"), + ).not.toBeInTheDocument(); + }, }; export const WithOpenPR: Story = { @@ -254,14 +262,89 @@ export const MobileWithClosedPR: Story = { }, }; -export const GenerateTitle: Story = { +export const RenameChatItem: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); const trigger = canvas.getByLabelText("Open agent actions"); await userEvent.click(trigger); await waitFor(() => { const body = within(document.body); - expect(body.getByText("Generate new title")).toBeInTheDocument(); + expect(body.getByText("Pin agent")).toBeInTheDocument(); + expect(body.getByText("Rename chat")).toBeInTheDocument(); + expect(body.getByText("Archive agent")).toBeInTheDocument(); + }); + const body = within(document.body); + expect(body.queryByText("Generate new title")).not.toBeInTheDocument(); + expect( + body.queryByText("Archive & delete workspace"), + ).not.toBeInTheDocument(); + }, +}; + +export const PinAgentItem: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const trigger = canvas.getByLabelText("Open agent actions"); + await userEvent.click(trigger); + await waitFor(() => { + const body = within(document.body); + expect(body.getByText("Pin agent")).toBeInTheDocument(); + expect(body.getByText("Rename chat")).toBeInTheDocument(); + expect(body.getByText("Archive agent")).toBeInTheDocument(); + expect(body.queryByText("Unpin agent")).not.toBeInTheDocument(); + }); + }, +}; + +export const UnpinAgentItem: Story = { + args: { + isPinned: true, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const trigger = canvas.getByLabelText("Open agent actions"); + await userEvent.click(trigger); + await waitFor(() => { + const body = within(document.body); + expect(body.getByText("Unpin agent")).toBeInTheDocument(); + expect(body.getByText("Rename chat")).toBeInTheDocument(); + expect(body.getByText("Archive agent")).toBeInTheDocument(); + expect(body.queryByText("Pin agent")).not.toBeInTheDocument(); + }); + }, +}; + +export const ChildChatHidesPinAction: Story = { + args: { + isChildChat: true, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const trigger = canvas.getByLabelText("Open agent actions"); + await userEvent.click(trigger); + 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(); + }, +}; + +export const ArchiveAndDeleteWorkspaceItem: Story = { + args: { + hasWorkspace: true, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const trigger = canvas.getByLabelText("Open agent actions"); + await userEvent.click(trigger); + await waitFor(() => { + const body = within(document.body); + expect(body.getByText("Archive agent")).toBeInTheDocument(); + expect(body.getByText("Archive & delete workspace")).toBeInTheDocument(); }); }, }; @@ -311,7 +394,7 @@ export const ShareChatButton: Story = { expect(await body.findByText("Share chat")).toBeInTheDocument(); await userEvent.click(canvas.getByLabelText("Open agent actions")); - await body.findByText("Generate new title"); + await body.findByText("Rename chat"); expect( body.queryByRole("menuitem", { name: "Share" }), ).not.toBeInTheDocument(); @@ -332,7 +415,7 @@ export const ShareChatButtonHiddenWithoutPermission: Story = { ).not.toBeInTheDocument(); await userEvent.click(canvas.getByLabelText("Open agent actions")); const body = within(document.body); - await body.findByText("Generate new title"); + await body.findByText("Rename chat"); expect( body.queryByRole("menuitem", { name: "Share" }), ).not.toBeInTheDocument(); @@ -346,19 +429,18 @@ export const ArchivedWithUnarchive: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - // Open the actions dropdown const trigger = canvas.getByLabelText("Open agent actions"); await userEvent.click(trigger); - // Verify "Unarchive Agent" is shown instead of "Archive Agent" await waitFor(() => { const body = within(document.body); - expect(body.getByText("Unarchive Agent")).toBeInTheDocument(); + expect(body.getByText("Unarchive agent")).toBeInTheDocument(); }); const body = within(document.body); - expect(body.queryByText("Generate new title")).not.toBeInTheDocument(); - expect(body.queryByText("Archive Agent")).not.toBeInTheDocument(); + expect(body.queryByText("Rename chat")).not.toBeInTheDocument(); + expect(body.queryByText("Pin agent")).not.toBeInTheDocument(); + expect(body.queryByText("Archive agent")).not.toBeInTheDocument(); expect( - body.queryByText("Archive & Delete Workspace"), + body.queryByText("Archive & delete workspace"), ).not.toBeInTheDocument(); }, }; diff --git a/site/src/pages/AgentsPage/components/ChatTopBar.tsx b/site/src/pages/AgentsPage/components/ChatTopBar.tsx index ca6b1155fd..4ede5d8ae2 100644 --- a/site/src/pages/AgentsPage/components/ChatTopBar.tsx +++ b/site/src/pages/AgentsPage/components/ChatTopBar.tsx @@ -1,16 +1,12 @@ import { - ArchiveIcon, - ArchiveRestoreIcon, ArrowLeftIcon, ChevronRightIcon, - EllipsisIcon, + EllipsisVerticalIcon, PanelLeftIcon, PanelRightCloseIcon, PanelRightOpenIcon, Share2Icon, - Trash2Icon, UsersIcon, - WandSparklesIcon, } from "lucide-react"; import { type FC, Fragment, type ReactNode, useState } from "react"; import { Link, useLocation } from "react-router"; @@ -28,6 +24,7 @@ import { Popover, PopoverTrigger } from "#/components/Popover/Popover"; import { Spinner } from "#/components/Spinner/Spinner"; import { cn } from "#/utils/cn"; import { parsePullRequestUrl } from "../utils/pullRequest"; +import { ChatActionsMenuItems } from "./ChatActionsMenuItems"; import { useEmbedContext } from "./EmbedContext"; import { PrStateIcon } from "./GitPanel/GitPanel"; @@ -47,11 +44,15 @@ type ChatTopBarProps = { onArchiveAgent: () => void; onUnarchiveAgent: () => void; onArchiveAndDeleteWorkspace: () => void; - onRegenerateTitle?: () => void; + onPinAgent?: () => void; + onUnpinAgent?: () => void; + onOpenRenameDialog?: () => void; isRegeneratingTitle?: boolean; - isRegenerateTitleDisabled?: boolean; hasWorkspace?: boolean; isArchived?: boolean; + isArchiving?: boolean; + isChildChat?: boolean; + isPinned?: boolean; isSidebarCollapsed: boolean; onToggleSidebarCollapsed: () => void; diffStatusData?: ChatDiffStatus; @@ -99,11 +100,15 @@ export const ChatTopBar: FC = ({ onArchiveAgent, onUnarchiveAgent, onArchiveAndDeleteWorkspace, - onRegenerateTitle, + onPinAgent, + onUnpinAgent, + onOpenRenameDialog, isRegeneratingTitle, - isRegenerateTitleDisabled, - hasWorkspace, - isArchived, + hasWorkspace = false, + isArchived = false, + isArchiving = false, + isChildChat = false, + isPinned = false, isSidebarCollapsed, onToggleSidebarCollapsed, diffStatusData, @@ -153,7 +158,7 @@ export const ChatTopBar: FC = ({ )} {/* Title area */} -
+
{chatTitle && (
= ({ )}
)} + {/* 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 — mobile: icon + number; desktop: icon + title. + {/* PR link. On mobile: icon + number; on desktop: icon + title. Hidden on desktop when the sidebar panel is open (which already shows PR info). */} {prUrl && hasPR && ( @@ -238,62 +279,6 @@ export const ChatTopBar: FC = ({ renderChatSharingContent={renderChatSharingContent} /> )} - {!isEmbedded && ( - - - - - - {!isArchived && onRegenerateTitle && ( - <> - - - Generate new title - - - - )} - {isArchived ? ( - - - Unarchive Agent - - ) : ( - <> - - - Archive Agent - - {hasWorkspace && ( - - - Archive & Delete Workspace - - )} - - )} - - - )} {!isEmbedded && (