mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat(site/src/pages/AgentsPage): surface subagents in the chat sidebar (#28234)
## What Makes subagents more discoverable in the agent chat sidebar, without disturbing the existing status icon or layout. - **Parent chat rows show a subagent count** with a bot icon (e.g. `3 🤖`) at the start of the metadata line, next to the diff stats. Only shown when a chat has subagents; uses lucide `BotIcon`, the same icon this codebase already uses to denote agents. Passive indicator (the leading status icon, timestamp, and kebab are untouched). - **The chat actions menu gains a "Show subagents (N)" / "Hide subagents" toggle**, grouped directly under "Rename chat" in both the kebab (⋮) and the right-click context menu. It expands/collapses the row's subagents and is only rendered when the chat has subagents. Expansion still uses the existing sidebar expand state, so the hover chevron, the menu toggle, and the count all stay in sync. ## Notes / decisions - Subagent children are capped at depth 1, so the count and toggle only appear on parent chats. - The menu toggle lives in the shared `ChatActionsMenuItems`, so it appears in both the kebab and the right-click menu; the top-bar kebab intentionally does not pass the props, so it stays hidden there. - The on-row `N 🤖` indicator is currently passive (clicking the row opens the chat as before). Can be made a click-to-expand control if wanted. ## Testing Validated in Storybook (`ChatsSidebar` stories) with 0 TypeScript errors. Added a `SubagentsMenuToggle` story with a `play` function asserting the label swaps between "Show subagents (3)" and "Hide subagents" and that children expand. Existing `ChatsSidebar.test.tsx` does not assert menu contents and is unaffected. --- *This PR was generated by Coder Agents on behalf of @tracyjohnsonux.*
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
ArchiveIcon,
|
||||
ArchiveRestoreIcon,
|
||||
GitForkIcon,
|
||||
PinIcon,
|
||||
PinOffIcon,
|
||||
SquarePenIcon,
|
||||
@@ -41,6 +42,9 @@ interface ChatActionsMenuItemsProps {
|
||||
readonly isChildChat: boolean;
|
||||
readonly hasWorkspace: boolean;
|
||||
readonly isArchiving?: boolean;
|
||||
readonly subagentCount?: number;
|
||||
readonly isSubagentsExpanded?: boolean;
|
||||
readonly onToggleSubagents?: () => void;
|
||||
readonly onPinAgent?: () => void;
|
||||
readonly onUnpinAgent?: () => void;
|
||||
readonly onArchiveAgent: () => void;
|
||||
@@ -58,6 +62,9 @@ export const ChatActionsMenuItems: FC<ChatActionsMenuItemsProps> = ({
|
||||
isChildChat,
|
||||
hasWorkspace,
|
||||
isArchiving = false,
|
||||
subagentCount = 0,
|
||||
isSubagentsExpanded = false,
|
||||
onToggleSubagents,
|
||||
onPinAgent,
|
||||
onUnpinAgent,
|
||||
onArchiveAgent,
|
||||
@@ -67,10 +74,20 @@ export const ChatActionsMenuItems: FC<ChatActionsMenuItemsProps> = ({
|
||||
Item,
|
||||
Separator,
|
||||
}) => {
|
||||
const showSubagentsToggle = Boolean(onToggleSubagents) && subagentCount > 0;
|
||||
const showPinAction =
|
||||
!isArchived && !isChildChat && Boolean(onPinAgent && onUnpinAgent);
|
||||
const showArchiveActions = !isArchived && !isChildChat;
|
||||
|
||||
const subagentToggle = showSubagentsToggle ? (
|
||||
<Item onSelect={onToggleSubagents}>
|
||||
<GitForkIcon className="size-3.5 rotate-180" />
|
||||
{isSubagentsExpanded
|
||||
? "Hide subagents"
|
||||
: `Show subagents (${subagentCount})`}
|
||||
</Item>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{showPinAction && (
|
||||
@@ -90,10 +107,13 @@ export const ChatActionsMenuItems: FC<ChatActionsMenuItemsProps> = ({
|
||||
)}
|
||||
{isArchived ? (
|
||||
!isChildChat && (
|
||||
<Item disabled={isArchiving} onSelect={onUnarchiveAgent}>
|
||||
<ArchiveRestoreIcon className="size-3.5" />
|
||||
Unarchive agent
|
||||
</Item>
|
||||
<>
|
||||
<Item disabled={isArchiving} onSelect={onUnarchiveAgent}>
|
||||
<ArchiveRestoreIcon className="size-3.5" />
|
||||
Unarchive agent
|
||||
</Item>
|
||||
{subagentToggle}
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
@@ -103,9 +123,12 @@ export const ChatActionsMenuItems: FC<ChatActionsMenuItemsProps> = ({
|
||||
Rename chat
|
||||
</Item>
|
||||
)}
|
||||
{subagentToggle}
|
||||
{showArchiveActions && (
|
||||
<>
|
||||
{(onOpenRenameDialog || showPinAction) && <Separator />}
|
||||
{(onOpenRenameDialog || showPinAction || showSubagentsToggle) && (
|
||||
<Separator />
|
||||
)}
|
||||
<Item
|
||||
className="text-content-destructive focus:text-content-destructive"
|
||||
disabled={isArchiving}
|
||||
|
||||
@@ -2007,6 +2007,86 @@ export const AgentWithWorkspaceMenuFull: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
// A collapsed parent chat exposes a "Show subagents (N)" action in its
|
||||
// actions menu; selecting it expands the children and the label flips to
|
||||
// "Hide subagents". Leaf chats never show the toggle.
|
||||
export const SubagentsMenuToggle: Story = {
|
||||
args: {
|
||||
chats: [
|
||||
buildChat({
|
||||
id: "root-subagents",
|
||||
title: "Parent with subagents",
|
||||
workspace_id: "workspace-1",
|
||||
updated_at: recentTimestamp,
|
||||
children: [
|
||||
buildChat({
|
||||
id: "subagent-1",
|
||||
title: "Subagent one",
|
||||
parent_chat_id: "root-subagents",
|
||||
root_chat_id: "root-subagents",
|
||||
}),
|
||||
buildChat({
|
||||
id: "subagent-2",
|
||||
title: "Subagent two",
|
||||
parent_chat_id: "root-subagents",
|
||||
root_chat_id: "root-subagents",
|
||||
}),
|
||||
buildChat({
|
||||
id: "subagent-3",
|
||||
title: "Subagent three",
|
||||
parent_chat_id: "root-subagents",
|
||||
root_chat_id: "root-subagents",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
},
|
||||
parameters: {
|
||||
reactRouter: reactRouterParameters({
|
||||
// Route to the parent (not a child) so the tree starts collapsed and
|
||||
// the menu reads "Show subagents (3)".
|
||||
location: {
|
||||
path: "/agents/root-subagents",
|
||||
pathParams: { agentId: "root-subagents" },
|
||||
},
|
||||
routing: agentsRouting,
|
||||
}),
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await waitFor(() => {
|
||||
expect(canvas.getByText("Parent with subagents")).toBeInTheDocument();
|
||||
});
|
||||
// Collapsed by default: children are not rendered yet.
|
||||
expect(canvas.queryByText("Subagent one")).not.toBeInTheDocument();
|
||||
|
||||
const trigger = canvas.getByLabelText(
|
||||
"Open actions for Parent with subagents",
|
||||
);
|
||||
await userEvent.click(trigger);
|
||||
const body = within(document.body);
|
||||
await waitFor(() => {
|
||||
expect(body.getByText("Show subagents (3)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Selecting the toggle closes the menu and expands the children.
|
||||
await userEvent.click(body.getByText("Show subagents (3)"));
|
||||
await waitFor(() => {
|
||||
expect(canvas.getByText("Subagent one")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Reopening the menu now offers the inverse action.
|
||||
await userEvent.click(
|
||||
canvas.getByLabelText("Open actions for Parent with subagents"),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
within(document.body).getByText("Hide subagents"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const ArchivedChildChatRowHasNoActionsMenu: Story = {
|
||||
args: {
|
||||
chats: [
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
BotIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
EllipsisVerticalIcon,
|
||||
@@ -150,6 +151,9 @@ export const ChatTreeNode: FC<ChatTreeNodeProps> = ({ chat, isChildNode }) => {
|
||||
isChildChat: isChildNode,
|
||||
hasWorkspace: Boolean(workspaceId),
|
||||
isArchiving,
|
||||
subagentCount: childIDs.length,
|
||||
isSubagentsExpanded: isExpanded,
|
||||
onToggleSubagents: () => toggleExpanded(chatID),
|
||||
onPinAgent: () => onPinAgent(chat.id),
|
||||
onUnpinAgent: () => onUnpinAgent(chat.id),
|
||||
onArchiveAgent: () => onArchiveAgent(chat.id),
|
||||
@@ -174,8 +178,6 @@ export const ChatTreeNode: FC<ChatTreeNodeProps> = ({ chat, isChildNode }) => {
|
||||
"group relative flex min-w-0 select-none [@media(pointer:coarse)]:[-webkit-touch-callout:none] items-start gap-1.5 rounded-md pl-1 pr-1.5 text-content-secondary",
|
||||
"transition-none [@media(hover:hover)]:hover:bg-surface-tertiary/50 [@media(hover:hover)]:hover:text-content-primary has-[[data-state=open]]:bg-surface-tertiary",
|
||||
"has-[[aria-current=page]]:bg-surface-quaternary/25 has-[[aria-current=page]]:text-content-primary [@media(hover:hover)]:has-[[aria-current=page]]:hover:bg-surface-quaternary/50",
|
||||
isChildNode &&
|
||||
"before:absolute before:-left-2.5 before:top-[17px] before:h-px before:w-2.5 before:bg-border-default/70",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
@@ -240,6 +242,17 @@ export const ChatTreeNode: FC<ChatTreeNodeProps> = ({ chat, isChildNode }) => {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
{hasChildren && (
|
||||
<span
|
||||
className="inline-flex shrink-0 items-center gap-0.5 text-[13px] leading-4 tabular-nums text-content-secondary"
|
||||
title={`${childIDs.length} ${
|
||||
childIDs.length === 1 ? "subagent" : "subagents"
|
||||
}`}
|
||||
>
|
||||
{childIDs.length}
|
||||
<BotIcon className="size-3.5" aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
{hasLinkedDiffStatus && hasLineStats && (
|
||||
<span
|
||||
className="inline-flex shrink-0 items-center gap-0.5 text-[13px] leading-4 tabular-nums"
|
||||
@@ -349,7 +362,7 @@ export const ChatTreeNode: FC<ChatTreeNodeProps> = ({ chat, isChildNode }) => {
|
||||
</ContextMenu>
|
||||
|
||||
{hasChildren && isExpanded && (
|
||||
<div className="relative ml-4 flex flex-col border-l border-border-default/60 pl-2.5">
|
||||
<div className="relative ml-4 flex flex-col pl-2.5">
|
||||
{childIDs.map((childID) => {
|
||||
const childChat = chatById.get(childID);
|
||||
if (!childChat) return null;
|
||||
|
||||
Reference in New Issue
Block a user