mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user