feat(site): add collapsible agent sections (#25173)

closes CODAGT-395

<img width="426" height="480" alt="Screenshot 2026-05-12 at 19 04 03"
src="https://github.com/user-attachments/assets/ba336ecf-3e90-48b0-b5d3-b10499909953"
/>

Adds collapsible section headers to the Agents sidebar, including
visible counts and chevrons for pinned and time-grouped chats.

The section header toggle keeps nested chat expansion state independent,
preserves the existing filter dropdown behavior, and adds Storybook
coverage for counts, collapse or expand interactions, and filter menu
behavior.
This commit is contained in:
Jaayden Halko
2026-05-12 19:07:57 +07:00
committed by GitHub
parent a55430b8fd
commit 8ba24e0e54
4 changed files with 320 additions and 126 deletions
@@ -592,6 +592,115 @@ export const MixedCacheDoesNotDuplicateChild: Story = {
// without embedding a literal date that drifts across calendar days.
const recentTimestamp = new Date(Date.now() - 60_000).toISOString();
const timestampAtLocalNoon = (dayOffset: number) => {
const date = new Date();
date.setHours(12, 0, 0, 0);
date.setDate(date.getDate() + dayOffset);
return date.toISOString();
};
const todayTimestamp = timestampAtLocalNoon(0);
const yesterdayTimestamp = timestampAtLocalNoon(-1);
const thisWeekTimestamp = timestampAtLocalNoon(-3);
export const SectionHeadersCollapseAndFilter: Story = {
args: {
chats: [
buildChat({
id: "pinned-section-one",
title: "Pinned section one",
pin_order: 1,
updated_at: todayTimestamp,
}),
buildChat({
id: "pinned-section-two",
title: "Pinned section two",
pin_order: 2,
updated_at: todayTimestamp,
}),
buildChat({
id: "today-section-one",
title: "Today section one",
updated_at: todayTimestamp,
}),
buildChat({
id: "today-section-two",
title: "Today section two",
updated_at: todayTimestamp,
}),
buildChat({
id: "yesterday-section-one",
title: "Yesterday section one",
updated_at: yesterdayTimestamp,
}),
buildChat({
id: "week-section-one",
title: "This week section one",
updated_at: thisWeekTimestamp,
}),
],
},
parameters: {
reactRouter: reactRouterParameters({
location: { path: "/agents" },
routing: agentsRouting,
}),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const body = within(document.body);
await expect(canvas.getByText("Pinned (2)")).toBeInTheDocument();
await expect(canvas.getByText("Today (2)")).toBeInTheDocument();
await expect(canvas.getByText("Yesterday (1)")).toBeInTheDocument();
await expect(canvas.getByText("This Week (1)")).toBeInTheDocument();
const pinnedToggle = canvas.getByRole("button", {
name: "Collapse Pinned section",
});
await userEvent.click(pinnedToggle);
await expect(pinnedToggle).toHaveAttribute("aria-expanded", "false");
expect(canvas.queryByText("Pinned section one")).not.toBeInTheDocument();
expect(canvas.queryByText("Pinned section two")).not.toBeInTheDocument();
expect(canvas.getByText("Today section one")).toBeInTheDocument();
await userEvent.click(
canvas.getByRole("button", { name: "Expand Pinned section" }),
);
await expect(
canvas.getByRole("button", { name: "Collapse Pinned section" }),
).toHaveAttribute("aria-expanded", "true");
expect(canvas.getByText("Pinned section one")).toBeInTheDocument();
expect(canvas.getByText("Pinned section two")).toBeInTheDocument();
const todayToggle = canvas.getByRole("button", {
name: "Collapse Today section",
});
await userEvent.click(todayToggle);
await expect(todayToggle).toHaveAttribute("aria-expanded", "false");
expect(canvas.queryByText("Today section one")).not.toBeInTheDocument();
expect(canvas.queryByText("Today section two")).not.toBeInTheDocument();
expect(canvas.getByText("Yesterday section one")).toBeInTheDocument();
await userEvent.click(canvas.getByTestId("agents-section-toggle-Today"));
await expect(
canvas.getByRole("button", { name: "Collapse Today section" }),
).toHaveAttribute("aria-expanded", "true");
expect(canvas.getByText("Today section one")).toBeInTheDocument();
expect(canvas.getByText("Today section two")).toBeInTheDocument();
await userEvent.click(
canvas.getByRole("button", { name: "Filter agents" }),
);
await expect(
await body.findByRole("menuitem", { name: /Archived/i }),
).toBeInTheDocument();
await expect(
canvas.getByTestId("agents-section-toggle-Pinned"),
).toHaveAttribute("aria-expanded", "true");
await userEvent.keyboard("{Escape}");
},
};
export const RenameChatAvailableDuringRegeneration: Story = {
args: {
chats: [
@@ -1627,7 +1736,7 @@ export const PinnedChatsSection: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await waitFor(() => {
expect(canvas.getByText("Pinned")).toBeInTheDocument();
expect(canvas.getByText("Pinned (1)")).toBeInTheDocument();
expect(canvas.getByText("My pinned agent")).toBeInTheDocument();
});
@@ -1636,7 +1745,7 @@ export const PinnedChatsSection: Story = {
expect(allPinnedLinks).toHaveLength(1);
// Unpinned chats appear under their time group, not Pinned.
expect(canvas.getByText("Today")).toBeInTheDocument();
expect(canvas.getByText("Today (2)")).toBeInTheDocument();
expect(canvas.getByText("Regular agent one")).toBeInTheDocument();
},
};
@@ -1708,37 +1817,6 @@ export const UnpinContextMenu: Story = {
},
};
export const FilterOnPinnedHeader: Story = {
args: {
chats: [
buildChat({
id: "pinned-filter",
title: "Pinned Chat",
updated_at: recentTimestamp,
pin_order: 1,
}),
buildChat({
id: "unpinned-filter",
title: "Unpinned Chat",
updated_at: recentTimestamp,
}),
],
},
parameters: {
reactRouter: reactRouterParameters({
location: { path: "/agents" },
routing: agentsRouting,
}),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await waitFor(() => {
expect(canvas.getByText("Pinned")).toBeInTheDocument();
expect(canvas.getByLabelText("Filter agents")).toBeInTheDocument();
});
},
};
export const FilterOnTimeGroupNoPins: Story = {
args: {
chats: [
@@ -1758,7 +1836,7 @@ export const FilterOnTimeGroupNoPins: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await waitFor(() => {
expect(canvas.getByText("Today")).toBeInTheDocument();
expect(canvas.getByText("Today (1)")).toBeInTheDocument();
expect(canvas.getByLabelText("Filter agents")).toBeInTheDocument();
});
},
@@ -27,8 +27,7 @@ import {
ChevronDownIcon,
ChevronRightIcon,
CoinsIcon,
EllipsisIcon,
FilterIcon,
EllipsisVerticalIcon,
FlaskConicalIcon,
GitMergeIcon,
GitPullRequestArrowIcon,
@@ -109,6 +108,7 @@ import { asNonEmptyString } from "../ChatConversation/blockUtils";
import type { ModelSelectorOption } from "../ChatElements";
import { asString } from "../ChatElements/runtimeTypeUtils";
import { UsageIndicator } from "../UsageIndicator";
import { FilterDropdown } from "./FilterDropdown";
import { RenameChatDialog } from "./RenameChatDialog";
type SidebarView =
@@ -780,7 +780,7 @@ const ChatTreeNode: FC<ChatTreeNodeProps> = ({ chat, isChildNode }) => {
className="absolute inset-0 flex h-6 w-7 min-w-0 justify-end rounded-none px-0 opacity-0 text-content-secondary hover:text-content-primary [@media(hover:hover)]:group-hover:opacity-100 data-[state=open]:opacity-100"
aria-label={`Open actions for ${chat.title}`}
>
<EllipsisIcon className="h-3.5 w-3.5" />
<EllipsisVerticalIcon className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
@@ -863,6 +863,54 @@ const SortableChatTreeNode: FC<{
);
};
const PINNED_SECTION_KEY = "Pinned";
const getSectionToggleTestId = (sectionKey: string) =>
`agents-section-toggle-${sectionKey.replaceAll(" ", "-")}`;
interface ChatSectionHeaderProps {
readonly label: string;
readonly count: number;
readonly expanded: boolean;
readonly onToggle: () => void;
readonly testId: string;
}
const ChatSectionHeader: FC<ChatSectionHeaderProps> = ({
label,
count,
expanded,
onToggle,
testId,
}) => {
const actionLabel = expanded ? "Collapse" : "Expand";
return (
<div className="group/header mb-1 ml-2.5 mr-2 flex h-7 items-center text-xs font-medium text-content-secondary">
<button
type="button"
className="flex h-7 min-w-0 flex-1 cursor-pointer appearance-none items-center rounded-md border-0 bg-transparent p-0 text-left font-sans text-xs font-medium text-current focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-content-link [@media(hover:hover)]:group-hover/header:text-content-primary"
aria-expanded={expanded}
aria-label={`${actionLabel} ${label} section`}
data-testid={testId}
onClick={onToggle}
>
<span className="min-w-0 flex-1 truncate">
{label} ({count})
</span>
<span className="flex h-6 w-7 shrink-0 items-center justify-end">
<ChevronDownIcon
aria-hidden="true"
className={cn(
"h-3.5 w-3.5 text-current transition-transform",
expanded && "rotate-180",
)}
/>
</span>
</button>
</div>
);
};
export const AgentsSidebar: FC<AgentsSidebarProps> = (props) => {
const {
chats,
@@ -921,6 +969,9 @@ export const AgentsSidebar: FC<AgentsSidebarProps> = (props) => {
isAdmin || isApiKeysSection || Boolean(providerConfigsQuery.data?.length);
const normalizedSearch = "";
const [expandedById, setExpandedById] = useState<Record<string, boolean>>({});
const [collapsedSections, setCollapsedSections] = useState<
Record<string, boolean>
>({});
const [chatPendingRename, setChatPendingRename] = useState<Chat | null>(null);
const chatTree = buildChatTree(chats);
@@ -1010,57 +1061,6 @@ export const AgentsSidebar: FC<AgentsSidebarProps> = (props) => {
onReorderPinnedAgent?.(activeId, newIndex + 1);
};
// Attach the archived filter to the first visible section header.
// When the list is empty, fall back to contextual empty-state links
// instead of a floating standalone icon.
const showFilterOnPinned = pinnedChats.length > 0;
const firstNonEmptyGroup = showFilterOnPinned
? undefined
: TIME_GROUPS.find((group) =>
visibleRootIDs.some((id) => {
const chat = chatById.get(id);
return (
chat !== undefined &&
getTimeGroup(chat.updated_at) === group &&
chat.pin_order === 0
);
}),
);
const filterDropdown = (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="subtle"
size="icon"
aria-label="Filter agents"
className={cn(
"h-7 w-7 min-w-0 text-content-secondary hover:text-content-primary",
archivedFilter === "archived" && "text-content-primary",
)}
>
<FilterIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="mobile-full-width-dropdown mobile-full-width-dropdown-top-below-header [&_[role=menuitem]]:text-[13px]"
>
<DropdownMenuItem onSelect={() => onArchivedFilterChange?.("active")}>
Active
{archivedFilter === "active" && (
<CheckIcon className="ml-auto h-3.5 w-3.5" />
)}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onArchivedFilterChange?.("archived")}>
Archived
{archivedFilter === "archived" && (
<CheckIcon className="ml-auto h-3.5 w-3.5" />
)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
// Auto-expand ancestors of the active chat so it's always visible.
// Only runs when activeChatId changes, not on every parentById
// recalculation, so user-initiated collapse is preserved.
@@ -1097,6 +1097,12 @@ export const AgentsSidebar: FC<AgentsSidebarProps> = (props) => {
const toggleExpanded = (chatID: string) => {
setExpandedById((prev) => ({ ...prev, [chatID]: !prev[chatID] }));
};
const toggleSection = (sectionKey: string) => {
setCollapsedSections((prev) => ({
...prev,
[sectionKey]: !prev[sectionKey],
}));
};
const chatTreeCtx: ChatTreeContextValue = {
chatTree,
@@ -1253,35 +1259,52 @@ export const AgentsSidebar: FC<AgentsSidebarProps> = (props) => {
<div>
{visibleRootIDs.length > 0 && (
<div className="pb-2">
<div className="mb-2 flex h-5 justify-end pr-1.5">
<FilterDropdown
archivedFilter={archivedFilter}
onArchivedFilterChange={onArchivedFilterChange}
/>
</div>
{/* ── Pinned section ── */}
{pinnedChats.length > 0 && (
<div className="[&:not(:first-child)]:mt-3">
<div className="mb-1 ml-2.5 -mr-0.5 flex items-center justify-between text-xs font-medium text-content-secondary">
<span>Pinned</span>
{showFilterOnPinned && filterDropdown}
</div>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={pinnedChatIds}
strategy={verticalListSortingStrategy}
<ChatSectionHeader
label={PINNED_SECTION_KEY}
count={pinnedChats.length}
expanded={
!collapsedSections[PINNED_SECTION_KEY]
}
onToggle={() =>
toggleSection(PINNED_SECTION_KEY)
}
testId={getSectionToggleTestId(
PINNED_SECTION_KEY,
)}
/>
{!collapsedSections[PINNED_SECTION_KEY] && (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<div
ref={pinnedContainerRef}
className="flex flex-col gap-0.5"
<SortableContext
items={pinnedChatIds}
strategy={verticalListSortingStrategy}
>
{sortedPinnedChats.map((chat) => (
<SortableChatTreeNode
key={chat.id}
chat={chat}
/>
))}
</div>
</SortableContext>
</DndContext>
<div
ref={pinnedContainerRef}
className="flex flex-col gap-0.5"
>
{sortedPinnedChats.map((chat) => (
<SortableChatTreeNode
key={chat.id}
chat={chat}
/>
))}
</div>
</SortableContext>
</DndContext>
)}
</div>
)}
{/* ── Time-grouped sections ── */}
@@ -1295,25 +1318,30 @@ export const AgentsSidebar: FC<AgentsSidebarProps> = (props) => {
chat.pin_order === 0,
);
if (groupChats.length === 0) return null;
const isGroupExpanded = !collapsedSections[group];
return (
<div
key={group}
className="[&:not(:first-child)]:mt-3"
>
<div className="mb-1 ml-2.5 -mr-0.5 flex items-center justify-between text-xs font-medium text-content-secondary">
<span>{group}</span>
{group === firstNonEmptyGroup &&
filterDropdown}
</div>
<div className="flex flex-col gap-0.5">
{groupChats.map((chat) => (
<ChatTreeNode
key={chat.id}
chat={chat}
isChildNode={false}
/>
))}
</div>
<ChatSectionHeader
label={group}
count={groupChats.length}
expanded={isGroupExpanded}
onToggle={() => toggleSection(group)}
testId={getSectionToggleTestId(group)}
/>
{isGroupExpanded && (
<div className="flex flex-col gap-0.5">
{groupChats.map((chat) => (
<ChatTreeNode
key={chat.id}
chat={chat}
isChildNode={false}
/>
))}
</div>
)}
</div>
);
})}
@@ -0,0 +1,33 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, within } from "storybook/test";
import { FilterDropdown } from "./FilterDropdown";
const meta: Meta<typeof FilterDropdown> = {
title: "pages/AgentsPage/FilterDropdown",
component: FilterDropdown,
args: {
archivedFilter: "active",
onArchivedFilterChange: fn(),
},
};
export default meta;
type Story = StoryObj<typeof FilterDropdown>;
export const OpensFilterMenu: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const body = within(document.body);
await userEvent.click(
canvas.getByRole("button", { name: "Filter agents" }),
);
await expect(
await body.findByRole("menuitem", { name: /Active/i }),
).toBeInTheDocument();
await expect(
await body.findByRole("menuitem", { name: /Archived/i }),
).toBeInTheDocument();
},
};
@@ -0,0 +1,55 @@
import { CheckIcon, FilterIcon } from "lucide-react";
import type { FC } from "react";
import { Button } from "#/components/Button/Button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "#/components/DropdownMenu/DropdownMenu";
import { cn } from "#/utils/cn";
type ArchivedFilter = "active" | "archived";
interface FilterDropdownProps {
readonly archivedFilter: ArchivedFilter;
readonly onArchivedFilterChange?: (filter: ArchivedFilter) => void;
}
export const FilterDropdown: FC<FilterDropdownProps> = ({
archivedFilter,
onArchivedFilterChange,
}) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="subtle"
size="icon"
aria-label="Filter agents"
className={cn(
"h-7 w-7 min-w-0 justify-end rounded-none px-0 text-content-secondary hover:text-content-primary",
archivedFilter === "archived" && "text-content-primary",
)}
>
<FilterIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="mobile-full-width-dropdown mobile-full-width-dropdown-top-below-header [&_[role=menuitem]]:text-[13px]"
>
<DropdownMenuItem onSelect={() => onArchivedFilterChange?.("active")}>
Active
{archivedFilter === "active" && (
<CheckIcon className="ml-auto h-3.5 w-3.5" />
)}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onArchivedFilterChange?.("archived")}>
Archived
{archivedFilter === "archived" && (
<CheckIcon className="ml-auto h-3.5 w-3.5" />
)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);