diff --git a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx index 86bf90598c95c..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, @@ -1477,7 +1479,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..e173cc9b843aa 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -704,7 +704,11 @@ const AgentChatPage: FC = () => { requestArchiveAgent, requestArchiveAndDeleteWorkspace, requestUnarchiveAgent, - onRegenerateTitle, + requestPinAgent, + requestUnpinAgent, + isArchiving, + archivingChatId, + onOpenRenameDialog, regeneratingTitleChatIds, isSidebarCollapsed, onToggleSidebarCollapsed, @@ -974,7 +978,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 +1285,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); @@ -1534,13 +1561,6 @@ const AgentChatPage: FC = () => { }); } - const handleRegenerateTitle = () => { - if (!agentId || isRegenerateTitleDisabled || !onRegenerateTitle) { - return; - } - onRegenerateTitle(agentId); - }; - const handleSendAskUserQuestionResponse = async (message: string) => { await submitChatTurn({ message, @@ -1658,9 +1678,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.tsx b/site/src/pages/AgentsPage/AgentChatPageView.tsx index 46b7879bf28f5..d6898d4a0fb0d 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.tsx @@ -186,13 +186,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; @@ -358,9 +362,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 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.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..be194e5cdd9dc 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,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 so both menus drive the same instance. */ + onOpenRenameDialog?: (chat: TypesGen.Chat) => void; regeneratingTitleChatIds: readonly string[]; isSidebarCollapsed: boolean; onToggleSidebarCollapsed: () => void; @@ -142,6 +146,11 @@ 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, @@ -152,9 +161,12 @@ export const AgentsPageView: FC = ({ requestPinAgent, requestUnpinAgent, requestReorderPinnedAgent, + isArchiving, + archivingChatId, onRegenerateTitle: (chatId: string) => { onRegenerateTitle(chatId).catch(() => {}); }, + onOpenRenameDialog: setChatPendingRename, regeneratingTitleChatIds, isSidebarCollapsed, onToggleSidebarCollapsed, @@ -194,6 +206,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 0000000000000..a479115c36da0 --- /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 02c3826c14626..ea72cadf0fe5a 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,67 @@ 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(); + 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.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(); + }); + 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 +362,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 +383,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 +397,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..d2b5848108a58 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. */} + {!isEmbedded && ( + + + + + + + + + )}
- {/* 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 +278,6 @@ export const ChatTopBar: FC = ({ renderChatSharingContent={renderChatSharingContent} /> )} - {!isEmbedded && ( - - - - - - {!isArchived && onRegenerateTitle && ( - <> - - - Generate new title - - - - )} - {isArchived ? ( - - - Unarchive Agent - - ) : ( - <> - - - Archive Agent - - {hasWorkspace && ( - - - Archive & Delete Workspace - - )} - - )} - - - )} {!isEmbedded && (