mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat(site): add collapsible archived agents section to sidebar (#22551)
This commit is contained in:
@@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user