From 88103c48702045cd83d427ad407c5d872fecce82 Mon Sep 17 00:00:00 2001 From: Tracy Johnson Date: Mon, 22 Jun 2026 21:20:46 +0000 Subject: [PATCH 1/4] fix(site/src/pages/AgentsPage): normalize chat menus to a kebab icon and matching items 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. - Switch the chat top bar trigger to the kebab icon (EllipsisVerticalIcon) so both menus use the same affordance. - Match the sidebar's items and order in the top bar: Pin/Unpin agent, Rename chat, Archive agent, Archive & delete workspace. Sentence case throughout. - Lift the rename-chat dialog state up to AgentsPageView so a single dialog instance is opened from either the sidebar row menu or the chat title menu via the outlet context. ChatsSidebar keeps internal-state fallback so existing stories and tests are unchanged. - Remove the standalone "Generate new title" item; the rename dialog's Generate button covers the same workflow and is what the sidebar already exposes. --- .../AgentsPage/AgentChatPage.stories.tsx | 2 +- site/src/pages/AgentsPage/AgentChatPage.tsx | 43 +++++++++--- .../pages/AgentsPage/AgentChatPageView.tsx | 25 ++++--- .../AgentsPage/AgentsPageView.stories.tsx | 1 - site/src/pages/AgentsPage/AgentsPageView.tsx | 12 +++- .../components/ChatTopBar.stories.tsx | 70 ++++++++++++++++--- .../AgentsPage/components/ChatTopBar.tsx | 62 ++++++++++------ .../components/ChatsSidebar/ChatsSidebar.tsx | 24 ++++++- 8 files changed, 182 insertions(+), 57 deletions(-) diff --git a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx index 86bf90598c95c..d46ce7e5ca0bd 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx @@ -1477,7 +1477,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 80aa853169096..9392478caa200 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -704,7 +704,9 @@ const AgentChatPage: FC = () => { requestArchiveAgent, requestArchiveAndDeleteWorkspace, requestUnarchiveAgent, - onRegenerateTitle, + requestPinAgent, + requestUnpinAgent, + onOpenRenameDialog, regeneratingTitleChatIds, isSidebarCollapsed, onToggleSidebarCollapsed, @@ -974,7 +976,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 @@ -1282,6 +1283,30 @@ const AgentChatPage: FC = () => { requestUnarchiveAgent(agentId); }; + const handlePinAgentAction = () => { + if (!agentId || isArchived) { + return; + } + requestPinAgent(agentId); + }; + + const handleUnpinAgentAction = () => { + if (!agentId || isArchived) { + return; + } + requestUnpinAgent(agentId); + }; + + const handleOpenRenameDialog = + 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); @@ -1534,13 +1559,6 @@ const AgentChatPage: FC = () => { }); } - const handleRegenerateTitle = () => { - if (!agentId || isRegenerateTitleDisabled || !onRegenerateTitle) { - return; - } - onRegenerateTitle(agentId); - }; - const handleSendAskUserQuestionResponse = async (message: string) => { await submitChatTurn({ message, @@ -1658,9 +1676,12 @@ const AgentChatPage: FC = () => { handleArchiveAndDeleteWorkspaceAction={ handleArchiveAndDeleteWorkspaceAction } - handleRegenerateTitle={handleRegenerateTitle} + handlePinAgentAction={handlePinAgentAction} + handleUnpinAgentAction={handleUnpinAgentAction} + handleOpenRenameDialog={handleOpenRenameDialog} + isPinned={(chatRecord?.pin_order ?? 0) > 0} + isChildChat={parentChat !== undefined} isRegeneratingTitle={isRegeneratingThisChat} - isRegenerateTitleDisabled={isRegenerateTitleDisabled} urlTransform={urlTransform} scrollContainerRef={scrollContainerRef} scrollToBottomRef={scrollToBottomRef} diff --git a/site/src/pages/AgentsPage/AgentChatPageView.tsx b/site/src/pages/AgentsPage/AgentChatPageView.tsx index 46b7879bf28f5..c47d601817f50 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.tsx @@ -190,9 +190,12 @@ interface AgentChatPageViewProps { handleArchiveAgentAction: () => void; handleUnarchiveAgentAction: () => void; handleArchiveAndDeleteWorkspaceAction: () => void; - handleRegenerateTitle?: () => void; + handlePinAgentAction?: () => void; + handleUnpinAgentAction?: () => void; + handleOpenRenameDialog?: () => void; + isPinned?: boolean; + isChildChat?: boolean; isRegeneratingTitle?: boolean; - isRegenerateTitleDisabled?: boolean; // Scroll container ref. scrollContainerRef: RefObject; @@ -358,9 +361,12 @@ export const AgentChatPageView: FC = ({ handleArchiveAgentAction, handleUnarchiveAgentAction, handleArchiveAndDeleteWorkspaceAction, - handleRegenerateTitle, + handlePinAgentAction, + handleUnpinAgentAction, + handleOpenRenameDialog, + isPinned, + isChildChat, isRegeneratingTitle, - isRegenerateTitleDisabled, scrollContainerRef, scrollToBottomRef, hasMoreMessages, @@ -837,11 +843,12 @@ export const AgentChatPageView: FC = ({ onArchiveAndDeleteWorkspace={ handleArchiveAndDeleteWorkspaceAction } - {...(handleRegenerateTitle - ? { onRegenerateTitle: handleRegenerateTitle } - : {})} + onPinAgent={handlePinAgentAction} + onUnpinAgent={handleUnpinAgentAction} + onOpenRenameDialog={handleOpenRenameDialog} + isPinned={isPinned} + isChildChat={isChildChat} isRegeneratingTitle={isRegeneratingTitle} - isRegenerateTitleDisabled={isRegenerateTitleDisabled} hasWorkspace={Boolean(workspace)} isArchived={isArchived} diffStatusData={diffStatusData} @@ -1080,7 +1087,6 @@ export const AgentChatPageLoadingView: FC = ({ }} onArchiveAgent={() => {}} onUnarchiveAgent={() => {}} - onRegenerateTitle={() => {}} onArchiveAndDeleteWorkspace={() => {}} hasWorkspace={false} isSidebarCollapsed={isSidebarCollapsed} @@ -1158,7 +1164,6 @@ export const AgentChatPageNotFoundView: FC = ({ }} onArchiveAgent={() => {}} onUnarchiveAgent={() => {}} - onRegenerateTitle={() => {}} onArchiveAndDeleteWorkspace={() => {}} hasWorkspace={false} isSidebarCollapsed={isSidebarCollapsed} diff --git a/site/src/pages/AgentsPage/AgentsPageView.stories.tsx b/site/src/pages/AgentsPage/AgentsPageView.stories.tsx index f085144bd7cf2..0c217169d3842 100644 --- a/site/src/pages/AgentsPage/AgentsPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentsPageView.stories.tsx @@ -253,7 +253,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 6df47b4b1ed89..ade729c14b843 100644 --- a/site/src/pages/AgentsPage/AgentsPageView.tsx +++ b/site/src/pages/AgentsPage/AgentsPageView.tsx @@ -1,6 +1,7 @@ -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 type { Chat } from "#/api/typesGenerated"; import { cn } from "#/utils/cn"; import { pageTitle } from "#/utils/page"; import type { ModelSelectorOption } from "./components/ChatElements"; @@ -28,6 +29,8 @@ export interface AgentsOutletContext { requestReorderPinnedAgent?: (chatId: string, pinOrder: number) => void; onRegenerateTitle?: (chatId: string) => void; onRenameTitle?: (chatId: string, title: string) => Promise; + /** Opens the shared rename dialog for the given chat. */ + onOpenRenameDialog?: (chat: Chat) => void; regeneratingTitleChatIds: readonly string[]; isSidebarCollapsed: boolean; onToggleSidebarCollapsed: () => void; @@ -142,6 +145,10 @@ export const AgentsPageView: FC = ({ const scrollContainerRef = useRef(null); + // State for the shared rename-chat dialog. Lifted here so both the + // sidebar menu and the chat top bar open the same dialog instance. + const [chatPendingRename, setChatPendingRename] = useState(null); + const outletContextValue: AgentsOutletContext = { chatErrorReasons, setChatErrorReason, @@ -155,6 +162,7 @@ export const AgentsPageView: FC = ({ onRegenerateTitle: (chatId: string) => { onRegenerateTitle(chatId).catch(() => {}); }, + onOpenRenameDialog: setChatPendingRename, regeneratingTitleChatIds, isSidebarCollapsed, onToggleSidebarCollapsed, @@ -194,6 +202,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/ChatTopBar.stories.tsx b/site/src/pages/AgentsPage/components/ChatTopBar.stories.tsx index 02c3826c14626..ef8657222da28 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(), @@ -254,18 +256,65 @@ 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("Rename chat")).toBeInTheDocument(); + }); + const body = within(document.body); + expect(body.queryByText("Generate new title")).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(); + }); + }, +}; + +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(); }); }, }; +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(); + }); + const body = within(document.body); + expect(body.queryByText("Pin agent")).not.toBeInTheDocument(); + expect(body.queryByText("Unpin agent")).not.toBeInTheDocument(); + }, +}; + export const PreservesArchivedFilterOnMobileBack: Story = { decorators: mobileDecorator, parameters: { @@ -311,7 +360,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 +381,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 +395,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 ca6b1155fd2b2..f3a1e868e824c 100644 --- a/site/src/pages/AgentsPage/components/ChatTopBar.tsx +++ b/site/src/pages/AgentsPage/components/ChatTopBar.tsx @@ -3,14 +3,16 @@ import { ArchiveRestoreIcon, ArrowLeftIcon, ChevronRightIcon, - EllipsisIcon, + EllipsisVerticalIcon, PanelLeftIcon, PanelRightCloseIcon, PanelRightOpenIcon, + PinIcon, + PinOffIcon, Share2Icon, + SquarePenIcon, Trash2Icon, UsersIcon, - WandSparklesIcon, } from "lucide-react"; import { type FC, Fragment, type ReactNode, useState } from "react"; import { Link, useLocation } from "react-router"; @@ -47,11 +49,14 @@ type ChatTopBarProps = { onArchiveAgent: () => void; onUnarchiveAgent: () => void; onArchiveAndDeleteWorkspace: () => void; - onRegenerateTitle?: () => void; + onPinAgent?: () => void; + onUnpinAgent?: () => void; + onOpenRenameDialog?: () => void; isRegeneratingTitle?: boolean; - isRegenerateTitleDisabled?: boolean; hasWorkspace?: boolean; isArchived?: boolean; + isChildChat?: boolean; + isPinned?: boolean; isSidebarCollapsed: boolean; onToggleSidebarCollapsed: () => void; diffStatusData?: ChatDiffStatus; @@ -99,11 +104,14 @@ export const ChatTopBar: FC = ({ onArchiveAgent, onUnarchiveAgent, onArchiveAndDeleteWorkspace, - onRegenerateTitle, + onPinAgent, + onUnpinAgent, + onOpenRenameDialog, isRegeneratingTitle, - isRegenerateTitleDisabled, hasWorkspace, isArchived, + isChildChat, + isPinned, isSidebarCollapsed, onToggleSidebarCollapsed, diffStatusData, @@ -247,38 +255,50 @@ export const ChatTopBar: FC = ({ className="size-7 text-content-secondary hover:text-content-primary" aria-label="Open agent actions" > - + - {!isArchived && onRegenerateTitle && ( - <> - - - Generate new title - - - + {!isArchived && !isChildChat && (onPinAgent || onUnpinAgent) && ( + + {isPinned ? ( + <> + + Unpin agent + + ) : ( + <> + + Pin agent + + )} + )} {isArchived ? ( - Unarchive Agent + Unarchive agent ) : ( <> + {onOpenRenameDialog && ( + + + Rename chat + + )} + - Archive Agent + Archive agent {hasWorkspace && ( = ({ onSelect={onArchiveAndDeleteWorkspace} > - Archive & Delete Workspace + Archive & delete workspace )} diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.tsx index 07cd483b44985..304f35210637c 100644 --- a/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.tsx +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.tsx @@ -25,6 +25,14 @@ interface ChatsSidebarProps { onReorderPinnedAgent?: (chatId: string, pinOrder: number) => void; onRenameTitle?: (chatId: string, title: string) => Promise; onProposeTitle?: (chatId: string) => Promise; + /** + * Controlled value for the rename-chat dialog. When provided alongside + * `onChatPendingRenameChange`, the dialog is opened by the parent so + * the chat top bar and the sidebar share a single dialog instance. + * Falls back to internal state when omitted. + */ + chatPendingRename?: Chat | null; + onChatPendingRenameChange?: (chat: Chat | null) => void; onBeforeNewAgent?: () => void; isSearchDialogOpen: boolean; onSearchDialogOpenChange: (open: boolean) => void; @@ -60,6 +68,8 @@ export const ChatsSidebar: FC = (props) => { onReorderPinnedAgent, onRenameTitle, onProposeTitle, + chatPendingRename: chatPendingRenameProp, + onChatPendingRenameChange, onBeforeNewAgent, isSearchDialogOpen, onSearchDialogOpenChange, @@ -103,7 +113,19 @@ export const ChatsSidebar: FC = (props) => { const isApiKeysSection = isSettingsPanel && settingsSection === "api-keys"; const showApiKeysItem = isAdmin || isApiKeysSection || Boolean(providerConfigsQuery.data?.length); - const [chatPendingRename, setChatPendingRename] = useState(null); + const [internalChatPendingRename, setInternalChatPendingRename] = + useState(null); + const isControlled = chatPendingRenameProp !== undefined; + const chatPendingRename = isControlled + ? chatPendingRenameProp + : internalChatPendingRename; + const setChatPendingRename = (chat: Chat | null) => { + if (isControlled) { + onChatPendingRenameChange?.(chat); + } else { + setInternalChatPendingRename(chat); + } + }; return (
From e13c850896abd9523dde0afb1cf52fa12cc42c4a Mon Sep 17 00:00:00 2001 From: Tracy Johnson Date: Mon, 22 Jun 2026 21:33:15 +0000 Subject: [PATCH 2/4] fix(site/src/pages/AgentsPage): place kebab inline with chat title Moves the agent actions kebab menu from the right-hand actions cluster into the title area, sitting immediately after the title text. The dropdown now aligns to its trigger's left edge so it reads as a title menu rather than a global action. --- .../AgentsPage/components/ChatTopBar.tsx | 75 ++++++++++--------- 1 file changed, 38 insertions(+), 37 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatTopBar.tsx b/site/src/pages/AgentsPage/components/ChatTopBar.tsx index f3a1e868e824c..e058ba8643f01 100644 --- a/site/src/pages/AgentsPage/components/ChatTopBar.tsx +++ b/site/src/pages/AgentsPage/components/ChatTopBar.tsx @@ -161,7 +161,7 @@ export const ChatTopBar: FC = ({ )} {/* Title area */} -
+
{chatTitle && (
= ({ )}
)} -
- {/* PR link — mobile: icon + number; desktop: icon + title. - Hidden on desktop when the sidebar panel is open - (which already shows PR info). */} - {prUrl && hasPR && ( - - - - {prTitle || (prNumberMatch ? `#${prNumberMatch}` : "PR")} - - - {prNumberMatch ? prNumberMatch : "PR"} - - - )} - {/* Actions area */} -
- {!isEmbedded && renderChatSharingContent && ( - - )} + {/* Actions menu sits inline with the title so it tracks the title's right edge. */} {!isEmbedded && ( {!isArchived && !isChildChat && (onPinAgent || onUnpinAgent) && ( @@ -314,6 +281,40 @@ export const ChatTopBar: FC = ({ )} +
+ {/* 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 && ( + + + + {prTitle || (prNumberMatch ? `#${prNumberMatch}` : "PR")} + + + {prNumberMatch ? prNumberMatch : "PR"} + + + )} + {/* Actions area */} +
+ {!isEmbedded && renderChatSharingContent && ( + + )} {!isEmbedded && (
- {renderMenuItems({ - Item: ContextMenuItem, - Separator: ContextMenuSeparator, - })} + From 19b5d74b53436b8d652ce42b7623cb89ee4b48e4 Mon Sep 17 00:00:00 2001 From: Tracy Johnson Date: Mon, 22 Jun 2026 23:51:08 +0000 Subject: [PATCH 4/4] fix(site/src/pages/AgentsPage): address coder-agents-review feedback - Derive isChildChat from getParentChatID on the primary chat query so Pin agent does not flicker on for child chats while the parent chat fetch is in flight (CRF-2, P2). The component already computes the same value used elsewhere. - Conditionally render the separator in ChatActionsMenuItems so it never appears alone above 'Archive agent' when no items render above it (CRF-1). - Thread isArchiving from the outlet context through ChatTopBar to the shared menu so the top bar's destructive items disable in lockstep with the sidebar's during an archive request (CRF-3). - Rename the misleading '// Archive actions.' section comment in AgentChatPageView to '// Chat actions.' since the block now also groups pin, rename, and regenerating-title state (CRF-4). - Drop doc comments that restate the type signature in ChatActionsMenuItems and AgentsPageView (CRF-5). - Rename handleOpenRenameDialog to handleOpenRenameDialogAction to match the Action suffix used by the other handlers in the block (CRF-6). - Use TypesGen.Chat throughout AgentsPageView instead of mixing a second named import (CRF-7). - Add negative assertions for the hidden Pin/Unpin label in PinAgentItem and UnpinAgentItem stories to mirror the pattern in ChildChatHidesPinAction and ArchivedWithUnarchive (CRF-8). --- site/src/pages/AgentsPage/AgentChatPage.stories.tsx | 2 ++ site/src/pages/AgentsPage/AgentChatPage.tsx | 12 +++++++++--- site/src/pages/AgentsPage/AgentChatPageView.tsx | 11 +++++++---- site/src/pages/AgentsPage/AgentEmbedPage.tsx | 2 ++ site/src/pages/AgentsPage/AgentsPageView.tsx | 12 ++++++++---- .../AgentsPage/components/ChatActionsMenuItems.tsx | 13 ++----------- .../AgentsPage/components/ChatTopBar.stories.tsx | 2 ++ site/src/pages/AgentsPage/components/ChatTopBar.tsx | 3 +++ 8 files changed, 35 insertions(+), 22 deletions(-) diff --git a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx index d46ce7e5ca0bd..3572223832137 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, diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index 9392478caa200..e173cc9b843aa 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -706,6 +706,8 @@ const AgentChatPage: FC = () => { requestUnarchiveAgent, requestPinAgent, requestUnpinAgent, + isArchiving, + archivingChatId, onOpenRenameDialog, regeneratingTitleChatIds, isSidebarCollapsed, @@ -1297,7 +1299,7 @@ const AgentChatPage: FC = () => { requestUnpinAgent(agentId); }; - const handleOpenRenameDialog = + const handleOpenRenameDialogAction = onOpenRenameDialog && chatRecord ? () => { if (isArchived) { @@ -1678,9 +1680,13 @@ const AgentChatPage: FC = () => { } handlePinAgentAction={handlePinAgentAction} handleUnpinAgentAction={handleUnpinAgentAction} - handleOpenRenameDialog={handleOpenRenameDialog} + handleOpenRenameDialogAction={handleOpenRenameDialogAction} + isArchivingThisChat={ + isArchiving && + (archivingChatId === undefined || archivingChatId === agentId) + } isPinned={(chatRecord?.pin_order ?? 0) > 0} - isChildChat={parentChat !== undefined} + isChildChat={parentChatID !== undefined} isRegeneratingTitle={isRegeneratingThisChat} urlTransform={urlTransform} scrollContainerRef={scrollContainerRef} diff --git a/site/src/pages/AgentsPage/AgentChatPageView.tsx b/site/src/pages/AgentsPage/AgentChatPageView.tsx index c47d601817f50..d6898d4a0fb0d 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.tsx @@ -186,15 +186,16 @@ interface AgentChatPageViewProps { onImplementPlan?: () => Promise | void; onSendAskUserQuestionResponse?: (message: string) => Promise | void; - // Archive actions. + // Chat actions. handleArchiveAgentAction: () => void; handleUnarchiveAgentAction: () => void; handleArchiveAndDeleteWorkspaceAction: () => void; handlePinAgentAction?: () => void; handleUnpinAgentAction?: () => void; - handleOpenRenameDialog?: () => void; + handleOpenRenameDialogAction?: () => void; isPinned?: boolean; isChildChat?: boolean; + isArchivingThisChat?: boolean; isRegeneratingTitle?: boolean; // Scroll container ref. @@ -363,9 +364,10 @@ export const AgentChatPageView: FC = ({ handleArchiveAndDeleteWorkspaceAction, handlePinAgentAction, handleUnpinAgentAction, - handleOpenRenameDialog, + handleOpenRenameDialogAction, isPinned, isChildChat, + isArchivingThisChat, isRegeneratingTitle, scrollContainerRef, scrollToBottomRef, @@ -845,9 +847,10 @@ export const AgentChatPageView: FC = ({ } onPinAgent={handlePinAgentAction} onUnpinAgent={handleUnpinAgentAction} - onOpenRenameDialog={handleOpenRenameDialog} + onOpenRenameDialog={handleOpenRenameDialogAction} isPinned={isPinned} isChildChat={isChildChat} + isArchiving={isArchivingThisChat} isRegeneratingTitle={isRegeneratingTitle} hasWorkspace={Boolean(workspace)} isArchived={isArchived} diff --git a/site/src/pages/AgentsPage/AgentEmbedPage.tsx b/site/src/pages/AgentsPage/AgentEmbedPage.tsx index c256f0f3d8331..d3f9fe787eaf0 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.tsx b/site/src/pages/AgentsPage/AgentsPageView.tsx index ade729c14b843..be194e5cdd9dc 100644 --- a/site/src/pages/AgentsPage/AgentsPageView.tsx +++ b/site/src/pages/AgentsPage/AgentsPageView.tsx @@ -1,7 +1,6 @@ import { type FC, type RefObject, useRef, useState } from "react"; import { Outlet, useLocation } from "react-router"; import type * as TypesGen from "#/api/typesGenerated"; -import type { Chat } from "#/api/typesGenerated"; import { cn } from "#/utils/cn"; import { pageTitle } from "#/utils/page"; import type { ModelSelectorOption } from "./components/ChatElements"; @@ -27,10 +26,12 @@ 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; - /** Opens the shared rename dialog for the given chat. */ - onOpenRenameDialog?: (chat: Chat) => void; + /** Opens the shared rename dialog so both menus drive the same instance. */ + onOpenRenameDialog?: (chat: TypesGen.Chat) => void; regeneratingTitleChatIds: readonly string[]; isSidebarCollapsed: boolean; onToggleSidebarCollapsed: () => void; @@ -147,7 +148,8 @@ export const AgentsPageView: FC = ({ // State for the shared rename-chat dialog. Lifted here so both the // sidebar menu and the chat top bar open the same dialog instance. - const [chatPendingRename, setChatPendingRename] = useState(null); + const [chatPendingRename, setChatPendingRename] = + useState(null); const outletContextValue: AgentsOutletContext = { chatErrorReasons, @@ -159,6 +161,8 @@ export const AgentsPageView: FC = ({ requestPinAgent, requestUnpinAgent, requestReorderPinnedAgent, + isArchiving, + archivingChatId, onRegenerateTitle: (chatId: string) => { onRegenerateTitle(chatId).catch(() => {}); }, diff --git a/site/src/pages/AgentsPage/components/ChatActionsMenuItems.tsx b/site/src/pages/AgentsPage/components/ChatActionsMenuItems.tsx index aaa2e7d615002..a479115c36da0 100644 --- a/site/src/pages/AgentsPage/components/ChatActionsMenuItems.tsx +++ b/site/src/pages/AgentsPage/components/ChatActionsMenuItems.tsx @@ -16,10 +16,6 @@ import type { DropdownMenuSeparator, } from "#/components/DropdownMenu/DropdownMenu"; -/** - * Polymorphic item/separator components. Lets the same menu body render into - * either a DropdownMenu (kebab-triggered) or a ContextMenu (right-click). - */ type ItemComponent = typeof DropdownMenuItem | typeof ContextMenuItem; type SeparatorComponent = | typeof DropdownMenuSeparator @@ -30,7 +26,6 @@ interface ChatActionsMenuItemsProps { readonly isPinned: boolean; readonly isChildChat: boolean; readonly hasWorkspace: boolean; - /** Disables destructive actions while an archive request is in flight. */ readonly isArchiving?: boolean; readonly onPinAgent?: () => void; readonly onUnpinAgent?: () => void; @@ -43,11 +38,6 @@ interface ChatActionsMenuItemsProps { readonly Separator: SeparatorComponent; } -/** - * Shared body of the per-chat actions menu. Used by both the chat top bar's - * kebab and the sidebar row's kebab/right-click menus so the two stay in - * lockstep. - */ export const ChatActionsMenuItems: FC = ({ isArchived, isPinned, @@ -93,7 +83,8 @@ export const ChatActionsMenuItems: FC = ({ Rename chat )} - + {(onOpenRenameDialog || + (!isChildChat && onPinAgent && onUnpinAgent)) && } { const body = within(document.body); expect(body.getByText("Pin agent")).toBeInTheDocument(); + expect(body.queryByText("Unpin agent")).not.toBeInTheDocument(); }); }, }; @@ -293,6 +294,7 @@ export const UnpinAgentItem: Story = { await waitFor(() => { const body = within(document.body); expect(body.getByText("Unpin agent")).toBeInTheDocument(); + expect(body.queryByText("Pin agent")).not.toBeInTheDocument(); }); }, }; diff --git a/site/src/pages/AgentsPage/components/ChatTopBar.tsx b/site/src/pages/AgentsPage/components/ChatTopBar.tsx index 4fce124ab97c8..d2b5848108a58 100644 --- a/site/src/pages/AgentsPage/components/ChatTopBar.tsx +++ b/site/src/pages/AgentsPage/components/ChatTopBar.tsx @@ -50,6 +50,7 @@ type ChatTopBarProps = { isRegeneratingTitle?: boolean; hasWorkspace?: boolean; isArchived?: boolean; + isArchiving?: boolean; isChildChat?: boolean; isPinned?: boolean; isSidebarCollapsed: boolean; @@ -105,6 +106,7 @@ export const ChatTopBar: FC = ({ isRegeneratingTitle, hasWorkspace = false, isArchived = false, + isArchiving = false, isChildChat = false, isPinned = false, isSidebarCollapsed, @@ -229,6 +231,7 @@ export const ChatTopBar: FC = ({ isPinned={isPinned} isChildChat={isChildChat} hasWorkspace={hasWorkspace} + isArchiving={isArchiving} onPinAgent={onPinAgent} onUnpinAgent={onUnpinAgent} onArchiveAgent={onArchiveAgent}