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.

<details>
<summary>Implementation notes</summary>

- `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 `<ChatActionsMenuItems>`
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 `<ChatActionsMenuItems>` 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.

</details>

<details>
<summary>Decision log</summary>

- **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`.

</details>

<sub>Opened by Coder Agents on behalf of @tracyjohnsonux.</sub>
This commit is contained in:
TJ
2026-06-30 11:56:45 -07:00
committed by GitHub
parent 2be540e469
commit 3c889fb384
13 changed files with 423 additions and 184 deletions
@@ -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();
+38 -11
View File
@@ -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<string | null>(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}
@@ -166,7 +166,6 @@ const StoryAgentChatPageView: FC<StoryProps> = ({ editing, ...overrides }) => {
handleArchiveAgentAction: fn(),
handleUnarchiveAgentAction: fn(),
handleArchiveAndDeleteWorkspaceAction: fn(),
handleRegenerateTitle: fn(),
scrollContainerRef:
overrides.scrollContainerRef ?? defaultScrollContainerRef,
scrollToBottomRef: overrides.scrollToBottomRef ?? defaultScrollToBottomRef,
+19 -11
View File
@@ -187,13 +187,17 @@ interface AgentChatPageViewProps {
onImplementPlan?: () => Promise<void> | void;
onSendAskUserQuestionResponse?: (message: string) => Promise<void> | 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<HTMLDivElement | null>;
@@ -359,9 +363,13 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
handleArchiveAgentAction,
handleUnarchiveAgentAction,
handleArchiveAndDeleteWorkspaceAction,
handleRegenerateTitle,
handlePinAgentAction,
handleUnpinAgentAction,
handleOpenRenameDialogAction,
isPinned,
isChildChat,
isArchivingThisChat,
isRegeneratingTitle,
isRegenerateTitleDisabled,
scrollContainerRef,
scrollToBottomRef,
hasMoreMessages,
@@ -837,11 +845,13 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
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<AgentChatPageLoadingViewProps> = ({
}}
onArchiveAgent={() => {}}
onUnarchiveAgent={() => {}}
onRegenerateTitle={() => {}}
onArchiveAndDeleteWorkspace={() => {}}
hasWorkspace={false}
isSidebarCollapsed={isSidebarCollapsed}
@@ -1158,7 +1167,6 @@ export const AgentChatPageNotFoundView: FC<AgentChatPageNotFoundViewProps> = ({
}}
onArchiveAgent={() => {}}
onUnarchiveAgent={() => {}}
onRegenerateTitle={() => {}}
onArchiveAndDeleteWorkspace={() => {}}
hasWorkspace={false}
isSidebarCollapsed={isSidebarCollapsed}
@@ -230,6 +230,8 @@ const AgentEmbedPage: FC = () => {
requestPinAgent: () => {},
requestUnpinAgent: () => {},
requestArchiveAndDeleteWorkspace,
isArchiving: false,
archivingChatId: undefined,
// Title regeneration is not supported in embed mode.
regeneratingTitleChatIds: [],
isSidebarCollapsed,
@@ -261,7 +261,6 @@ const AgentTopBarRouteElement = () => {
panel={{ showSidebarPanel: false, onToggleSidebar: fn() }}
onArchiveAgent={fn()}
onArchiveAndDeleteWorkspace={fn()}
onRegenerateTitle={fn()}
onUnarchiveAgent={fn()}
isSidebarCollapsed={isSidebarCollapsed}
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
+12 -1
View File
@@ -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<void>;
onOpenRenameDialog?: (chat: TypesGen.Chat) => void;
regeneratingTitleChatIds: readonly string[];
isSidebarCollapsed: boolean;
onToggleSidebarCollapsed: () => void;
@@ -142,6 +145,9 @@ export const AgentsPageView: FC<AgentsPageViewProps> = ({
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
const [chatPendingRename, setChatPendingRename] =
useState<TypesGen.Chat | null>(null);
const outletContextValue: AgentsOutletContext = {
chatErrorReasons,
setChatErrorReason,
@@ -152,9 +158,12 @@ export const AgentsPageView: FC<AgentsPageViewProps> = ({
requestPinAgent,
requestUnpinAgent,
requestReorderPinnedAgent,
isArchiving,
archivingChatId,
onRegenerateTitle: (chatId: string) => {
onRegenerateTitle(chatId).catch(() => {});
},
onOpenRenameDialog: setChatPendingRename,
regeneratingTitleChatIds,
isSidebarCollapsed,
onToggleSidebarCollapsed,
@@ -194,6 +203,8 @@ export const AgentsPageView: FC<AgentsPageViewProps> = ({
onReorderPinnedAgent={requestReorderPinnedAgent}
onRenameTitle={onRenameTitle}
onProposeTitle={onProposeTitle}
chatPendingRename={chatPendingRename}
onChatPendingRenameChange={setChatPendingRename}
regeneratingTitleChatIds={regeneratingTitleChatIds}
onBeforeNewAgent={handleNewAgent}
isSearchDialogOpen={isSearchDialogOpen}
@@ -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<ChatActionsMenuItemsProps> = ({
isArchived,
isPinned,
isChildChat,
hasWorkspace,
isArchiving = false,
onPinAgent,
onUnpinAgent,
onArchiveAgent,
onUnarchiveAgent,
onArchiveAndDeleteWorkspace,
onOpenRenameDialog,
Item,
Separator,
}) => {
return (
<>
{!isArchived && !isChildChat && onPinAgent && onUnpinAgent && (
<Item onSelect={isPinned ? onUnpinAgent : onPinAgent}>
{isPinned ? (
<>
<PinOffIcon className="size-3.5" />
Unpin agent
</>
) : (
<>
<PinIcon className="size-3.5" />
Pin agent
</>
)}
</Item>
)}
{isArchived ? (
<Item disabled={isArchiving} onSelect={onUnarchiveAgent}>
<ArchiveRestoreIcon className="size-3.5" />
Unarchive agent
</Item>
) : (
<>
{onOpenRenameDialog && (
<Item onSelect={onOpenRenameDialog}>
<SquarePenIcon className="size-3.5" />
Rename chat
</Item>
)}
{(onOpenRenameDialog ||
(!isChildChat && onPinAgent && onUnpinAgent)) && <Separator />}
<Item
className="text-content-destructive focus:text-content-destructive"
disabled={isArchiving}
onSelect={onArchiveAgent}
>
<ArchiveIcon className="size-3.5" />
Archive agent
</Item>
{hasWorkspace && (
<Item
className="text-content-destructive focus:text-content-destructive"
disabled={isArchiving}
onSelect={onArchiveAndDeleteWorkspace}
>
<Trash2Icon className="size-3.5" />
Archive & delete workspace
</Item>
)}
</>
)}
</>
);
};
@@ -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();
},
};
@@ -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<ChatTopBarProps> = ({
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<ChatTopBarProps> = ({
</Button>
)}
{/* Title area */}
<div className="flex min-w-0 flex-1 items-center">
<div className="flex min-w-0 flex-1 items-center gap-1.5">
{chatTitle && (
<div
role="status"
@@ -204,8 +209,44 @@ export const ChatTopBar: FC<ChatTopBarProps> = ({
)}
</div>
)}
{/* 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 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="subtle"
className="size-7 shrink-0 text-content-secondary hover:text-content-primary"
aria-label="Open agent actions"
>
<EllipsisVerticalIcon className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="mobile-full-width-dropdown mobile-full-width-dropdown-top [&_[role=menuitem]]:text-[13px]"
>
<ChatActionsMenuItems
isArchived={isArchived}
isPinned={isPinned}
isChildChat={isChildChat}
hasWorkspace={hasWorkspace}
isArchiving={isArchiving}
onPinAgent={onPinAgent}
onUnpinAgent={onUnpinAgent}
onArchiveAgent={onArchiveAgent}
onUnarchiveAgent={onUnarchiveAgent}
onArchiveAndDeleteWorkspace={onArchiveAndDeleteWorkspace}
onOpenRenameDialog={onOpenRenameDialog}
Item={DropdownMenuItem}
Separator={DropdownMenuSeparator}
/>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{/* 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<ChatTopBarProps> = ({
renderChatSharingContent={renderChatSharingContent}
/>
)}
{!isEmbedded && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="subtle"
className="size-7 text-content-secondary hover:text-content-primary"
aria-label="Open agent actions"
>
<EllipsisIcon className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="mobile-full-width-dropdown mobile-full-width-dropdown-top [&_[role=menuitem]]:text-[13px]"
>
{!isArchived && onRegenerateTitle && (
<>
<DropdownMenuItem
disabled={isRegenerateTitleDisabled}
onSelect={onRegenerateTitle}
>
<WandSparklesIcon className="size-3.5" />
Generate new title
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
{isArchived ? (
<DropdownMenuItem onSelect={onUnarchiveAgent}>
<ArchiveRestoreIcon className="size-3.5" />
Unarchive Agent
</DropdownMenuItem>
) : (
<>
<DropdownMenuItem
className="text-content-destructive focus:text-content-destructive"
onSelect={onArchiveAgent}
>
<ArchiveIcon className="size-3.5" />
Archive Agent
</DropdownMenuItem>
{hasWorkspace && (
<DropdownMenuItem
className="text-content-destructive focus:text-content-destructive"
onSelect={onArchiveAndDeleteWorkspace}
>
<Trash2Icon className="size-3.5" />
Archive & Delete Workspace
</DropdownMenuItem>
)}
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
{!isEmbedded && (
<Button
variant="subtle"
@@ -1949,6 +1949,45 @@ export const ArchivedAgentUnarchiveOption: Story = {
},
};
export const AgentWithWorkspaceMenuFull: Story = {
args: {
chats: [
buildChat({
id: "chat-with-workspace",
title: "Agent with workspace",
workspace_id: "workspace-1",
updated_at: recentTimestamp,
}),
],
},
parameters: {
reactRouter: reactRouterParameters({
location: { path: "/agents" },
routing: agentsRouting,
}),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await waitFor(() => {
expect(canvas.getByText("Agent with workspace")).toBeInTheDocument();
});
const trigger = canvas.getByLabelText(
"Open actions for Agent with workspace",
);
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.getByText("Archive & delete workspace")).toBeInTheDocument();
});
const body = within(document.body);
expect(body.queryByText("Unpin agent")).not.toBeInTheDocument();
expect(body.queryByText("Unarchive agent")).not.toBeInTheDocument();
},
};
export const PinnedChatsSection: Story = {
args: {
chats: [
@@ -25,6 +25,14 @@ interface ChatsSidebarProps {
onReorderPinnedAgent?: (chatId: string, pinOrder: number) => void;
onRenameTitle?: (chatId: string, title: string) => Promise<void>;
onProposeTitle?: (chatId: string) => Promise<string>;
/**
* 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<ChatsSidebarProps> = (props) => {
onReorderPinnedAgent,
onRenameTitle,
onProposeTitle,
chatPendingRename: chatPendingRenameProp,
onChatPendingRenameChange,
onBeforeNewAgent,
isSearchDialogOpen,
onSearchDialogOpenChange,
@@ -103,7 +113,19 @@ export const ChatsSidebar: FC<ChatsSidebarProps> = (props) => {
const isApiKeysSection = isSettingsPanel && settingsSection === "api-keys";
const showApiKeysItem =
isAdmin || isApiKeysSection || Boolean(providerConfigsQuery.data?.length);
const [chatPendingRename, setChatPendingRename] = useState<Chat | null>(null);
const [internalChatPendingRename, setInternalChatPendingRename] =
useState<Chat | null>(null);
const isControlled = chatPendingRenameProp !== undefined;
const chatPendingRename = isControlled
? chatPendingRenameProp
: internalChatPendingRename;
const setChatPendingRename = (chat: Chat | null) => {
if (isControlled) {
onChatPendingRenameChange?.(chat);
} else {
setInternalChatPendingRename(chat);
}
};
return (
<div className="relative flex size-full min-h-0 border-0 border-r border-solid overflow-hidden">
@@ -1,13 +1,7 @@
import {
ArchiveIcon,
ArchiveRestoreIcon,
ChevronDownIcon,
ChevronRightIcon,
EllipsisVerticalIcon,
PinIcon,
PinOffIcon,
SquarePenIcon,
Trash2Icon,
UsersIcon,
} from "lucide-react";
import { type FC, useEffect, useState } from "react";
@@ -31,6 +25,7 @@ import {
import { Spinner } from "#/components/Spinner/Spinner";
import { cn } from "#/utils/cn";
import { shortRelativeTime } from "#/utils/time";
import { ChatActionsMenuItems } from "../../ChatActionsMenuItems";
import { asNonEmptyString } from "../../ChatConversation/blockUtils";
import { normalizeLocationSearch } from "../locationSearch";
import { useChatTree } from "./ChatTreeContext";
@@ -145,69 +140,25 @@ export const ChatTreeNode: FC<ChatTreeNodeProps> = ({ chat, isChildNode }) => {
const isRegeneratingThisChat = regeneratingTitleChatIds.includes(chat.id);
const isExpanded = normalizedSearch ? true : (expandedById[chatID] ?? false);
const renderMenuItems = ({
Item,
Separator,
}: {
Item: typeof DropdownMenuItem | typeof ContextMenuItem;
Separator: typeof DropdownMenuSeparator | typeof ContextMenuSeparator;
}) => (
<>
{!chat.archived && !isChildNode && (
<Item
onSelect={() =>
chat.pin_order > 0 ? onUnpinAgent(chat.id) : onPinAgent(chat.id)
}
>
{chat.pin_order > 0 ? (
<>
<PinOffIcon className="size-3.5" />
Unpin agent
</>
) : (
<>
<PinIcon className="size-3.5" />
Pin agent
</>
)}
</Item>
)}
{chat.archived ? (
<Item disabled={isArchiving} onSelect={() => onUnarchiveAgent(chat.id)}>
<ArchiveRestoreIcon className="size-3.5" />
Unarchive agent
</Item>
) : (
<>
{onOpenRenameDialog && (
<Item onSelect={() => onOpenRenameDialog(chat)}>
<SquarePenIcon className="size-3.5" />
Rename chat
</Item>
)}
<Separator />
<Item
className="text-content-destructive focus:text-content-destructive"
disabled={isArchiving}
onSelect={() => onArchiveAgent(chat.id)}
>
<ArchiveIcon className="size-3.5" />
Archive agent
</Item>
{workspaceId && (
<Item
className="text-content-destructive focus:text-content-destructive"
disabled={isArchiving}
onSelect={() => onArchiveAndDeleteWorkspace(chat.id, workspaceId)}
>
<Trash2Icon className="size-3.5" />
Archive & delete workspace
</Item>
)}
</>
)}
</>
);
const sharedMenuItemProps = {
isArchived: chat.archived,
isPinned: chat.pin_order > 0,
isChildChat: isChildNode,
hasWorkspace: Boolean(workspaceId),
isArchiving,
onPinAgent: () => onPinAgent(chat.id),
onUnpinAgent: () => onUnpinAgent(chat.id),
onArchiveAgent: () => onArchiveAgent(chat.id),
onUnarchiveAgent: () => onUnarchiveAgent(chat.id),
onArchiveAndDeleteWorkspace: () => {
if (workspaceId) {
onArchiveAndDeleteWorkspace(chat.id, workspaceId);
}
},
onOpenRenameDialog: onOpenRenameDialog
? () => onOpenRenameDialog(chat)
: undefined,
};
return (
<div className="flex min-w-0 flex-col gap-0.5">
@@ -370,20 +321,22 @@ export const ChatTreeNode: FC<ChatTreeNodeProps> = ({ chat, isChildNode }) => {
align="end"
className="[&_[role=menuitem]]:text-[13px]"
>
{renderMenuItems({
Item: DropdownMenuItem,
Separator: DropdownMenuSeparator,
})}
<ChatActionsMenuItems
{...sharedMenuItemProps}
Item={DropdownMenuItem}
Separator={DropdownMenuSeparator}
/>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</ContextMenuTrigger>
<ContextMenuContent className="[&_[role=menuitem]]:text-[13px]">
{renderMenuItems({
Item: ContextMenuItem,
Separator: ContextMenuSeparator,
})}
<ChatActionsMenuItems
{...sharedMenuItemProps}
Item={ContextMenuItem}
Separator={ContextMenuSeparator}
/>
</ContextMenuContent>
</ContextMenu>