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:
TJ
2026-08-18 13:25:42 -07:00
committed by GitHub
parent 72a6c8ae72
commit b3f05a23fc
3 changed files with 124 additions and 8 deletions
@@ -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;