feat(site): add collapsible archived agents section to sidebar (#22551)

This commit is contained in:
Danielle Maywood
2026-03-03 15:30:59 +00:00
committed by GitHub
parent 2d7009e50d
commit cdb7145982
2 changed files with 243 additions and 26 deletions
@@ -59,6 +59,7 @@ const meta: Meta<typeof AgentsSidebar> = {
modelOptions: defaultModelOptions,
modelConfigs: defaultModelConfigs,
onArchiveAgent: fn(),
onArchiveAndDeleteWorkspace: fn(),
onNewAgent: fn(),
isCreating: false,
},
@@ -323,3 +324,156 @@ export const ActiveChatAncestryExpanded: Story = {
).toHaveAttribute("aria-expanded", "true");
},
};
const todayTimestamp = new Date().toISOString();
export const ArchivedAgentsCollapsed: Story = {
args: {
chats: [
buildChat({
id: "active-1",
title: "Active agent one",
updated_at: todayTimestamp,
}),
buildChat({
id: "active-2",
title: "Active agent two",
updated_at: todayTimestamp,
}),
buildChat({
id: "archived-1",
title: "Archived agent one",
archived: true,
}),
buildChat({
id: "archived-2",
title: "Archived agent two",
archived: true,
}),
],
},
parameters: {
reactRouter: reactRouterParameters({
location: { path: "/agents" },
routing: agentsRouting,
}),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await waitFor(() => {
expect(canvas.getByText("Active agent one")).toBeInTheDocument();
expect(canvas.getByText("Active agent two")).toBeInTheDocument();
expect(canvas.getByText("Archived (2)")).toBeInTheDocument();
});
expect(canvas.queryByText("Archived agent one")).not.toBeInTheDocument();
expect(canvas.queryByText("Archived agent two")).not.toBeInTheDocument();
},
};
export const ArchivedAgentsExpanded: Story = {
args: {
chats: [
buildChat({
id: "active-1",
title: "Active agent one",
updated_at: todayTimestamp,
}),
buildChat({
id: "active-2",
title: "Active agent two",
updated_at: todayTimestamp,
}),
buildChat({
id: "archived-1",
title: "Archived agent one",
archived: true,
}),
buildChat({
id: "archived-2",
title: "Archived agent two",
archived: true,
}),
],
},
parameters: {
reactRouter: reactRouterParameters({
location: { path: "/agents" },
routing: agentsRouting,
}),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await waitFor(() => {
expect(canvas.getByText("Archived (2)")).toBeInTheDocument();
});
await userEvent.click(canvas.getByText("Archived (2)"));
await waitFor(() => {
expect(canvas.getByText("Archived agent one")).toBeInTheDocument();
expect(canvas.getByText("Archived agent two")).toBeInTheDocument();
});
},
};
export const ArchivedAgentsSearchAutoExpands: Story = {
args: {
chats: [
buildChat({
id: "active-task",
title: "Active task",
updated_at: todayTimestamp,
}),
buildChat({
id: "old-archived",
title: "Old archived task",
archived: true,
}),
],
},
parameters: {
reactRouter: reactRouterParameters({
location: { path: "/agents" },
routing: agentsRouting,
}),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.type(
canvas.getByPlaceholderText("Search agents..."),
"archived",
);
await waitFor(() => {
expect(canvas.getByText("Old archived task")).toBeInTheDocument();
});
},
};
export const NoArchivedSection: Story = {
args: {
chats: [
buildChat({
id: "chat-a",
title: "First active agent",
updated_at: todayTimestamp,
}),
buildChat({
id: "chat-b",
title: "Second active agent",
updated_at: todayTimestamp,
}),
],
},
parameters: {
reactRouter: reactRouterParameters({
location: { path: "/agents" },
routing: agentsRouting,
}),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await waitFor(() => {
expect(canvas.getByText("First active agent")).toBeInTheDocument();
expect(canvas.getByText("Second active agent")).toBeInTheDocument();
});
expect(canvas.queryByText(/^Archived \(/)).not.toBeInTheDocument();
},
};
+89 -26
View File
@@ -7,6 +7,11 @@ import type {
import { ErrorAlert } from "components/Alert/ErrorAlert";
import type { ModelSelectorOption } from "components/ai-elements";
import { Button } from "components/Button/Button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "components/Collapsible/Collapsible";
import {
DropdownMenu,
DropdownMenuContent,
@@ -537,6 +542,7 @@ export const AgentsSidebar: FC<AgentsSidebarProps> = (props) => {
const [search, setSearch] = useState("");
const normalizedSearch = search.trim().toLowerCase();
const [expandedById, setExpandedById] = useState<Record<string, boolean>>({});
const [isArchivedExpanded, setIsArchivedExpanded] = useState(false);
const chatTree = useMemo(() => buildChatTree(chats), [chats]);
const chatById = useMemo(() => {
@@ -555,6 +561,24 @@ export const AgentsSidebar: FC<AgentsSidebarProps> = (props) => {
() => chatTree.rootIds.filter((chatID) => visibleChatIDs.has(chatID)),
[chatTree.rootIds, visibleChatIDs],
);
const activeRootIDs = useMemo(
() =>
visibleRootIDs.filter((id) => {
const chat = chatById.get(id);
return chat && !chat.archived;
}),
[visibleRootIDs, chatById],
);
const archivedRootIDs = useMemo(
() =>
visibleRootIDs.filter((id) => {
const chat = chatById.get(id);
return chat?.archived;
}),
[visibleRootIDs, chatById],
);
const effectiveArchivedExpanded =
normalizedSearch && archivedRootIDs.length > 0 ? true : isArchivedExpanded;
// Auto-expand ancestors of the active chat so it's always visible.
useEffect(() => {
@@ -704,37 +728,76 @@ export const AgentsSidebar: FC<AgentsSidebarProps> = (props) => {
</>
) : (
<ChatTreeContext.Provider value={chatTreeCtx}>
{visibleRootIDs.length === 0 ? (
{activeRootIDs.length === 0 && archivedRootIDs.length === 0 ? (
<div className="rounded-lg border border-dashed border-border-default bg-surface-primary p-4 text-center text-xs text-content-secondary">
{normalizedSearch ? "No matching agents" : "No agents yet"}
</div>
) : (
TIME_GROUPS.map((group) => {
const groupChats = visibleRootIDs
.map((id) => chatById.get(id))
.filter(
(chat): chat is Chat =>
chat !== undefined &&
getTimeGroup(chat.updated_at) === group,
);
if (groupChats.length === 0) return null;
return (
<div key={group}>
<div className="mb-1 ml-2.5 flex items-center justify-between text-xs font-medium text-content-secondary">
<span>{group}</span>
</div>
<div className="flex flex-col gap-0.5">
{groupChats.map((chat) => (
<ChatTreeNode
key={chat.id}
chat={chat}
isChildNode={false}
/>
))}
</div>
<div className="divide-y divide-border">
{activeRootIDs.length > 0 && (
<div className="pb-2">
{TIME_GROUPS.map((group) => {
const groupChats = activeRootIDs
.map((id) => chatById.get(id))
.filter(
(chat): chat is Chat =>
chat !== undefined &&
getTimeGroup(chat.updated_at) === group,
);
if (groupChats.length === 0) return null;
return (
<div key={group}>
<div className="mb-1 ml-2.5 flex items-center justify-between text-xs font-medium text-content-secondary">
<span>{group}</span>
</div>
<div className="flex flex-col gap-0.5">
{groupChats.map((chat) => (
<ChatTreeNode
key={chat.id}
chat={chat}
isChildNode={false}
/>
))}
</div>
</div>
);
})}
</div>
);
})
)}
{archivedRootIDs.length > 0 && (
<Collapsible
className="pt-2"
open={effectiveArchivedExpanded}
onOpenChange={setIsArchivedExpanded}
>
<CollapsibleTrigger asChild>
<div className="mb-1 ml-2.5 flex cursor-pointer items-center justify-between text-xs font-medium text-content-secondary">
<span>Archived ({archivedRootIDs.length})</span>
{effectiveArchivedExpanded ? (
<ChevronDownIcon className="h-3 w-3" />
) : (
<ChevronRightIcon className="h-3 w-3" />
)}
</div>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="flex flex-col gap-0.5">
{archivedRootIDs.map((id) => {
const chat = chatById.get(id);
if (!chat) return null;
return (
<ChatTreeNode
key={chat.id}
chat={chat}
isChildNode={false}
/>
);
})}
</div>
</CollapsibleContent>
</Collapsible>
)}{" "}
</div>
)}
</ChatTreeContext.Provider>
)}