feat: filter agents by model, status, harness, and connection (#20317)

Add shared filters to both All Agents layouts and views. Keep chart ancestors and bulk selections when filters hide agents. Use the same metadata for filter choices and table cells.

Add tests and update the filter journey and design guidance.
This commit is contained in:
Dotta authored and GitHub committed 2026-11-09 14:21:38 -06:00
1 parent 61c169753e
commit 98f49af70a
9 files changed
+334 -59

No files matched your search

+7 -1
View File
@@ -1030,7 +1030,13 @@ or certify backend behavior or keyboard accessibility.
Agents and Connectors compose the same `ToolbarSearch`. Connectors also use canonical Standard filled tabs. `AgentsToolbar` is shared by the roster page and
toolbar references; `ConnectorsToolbar` is shared by Browse and toolbar references. Keep search
first, source selection next where present, and view/actions last. The agent roster uses a harness filter icon without a status selector.
first, source selection next where present, and view/actions last. The agent roster filter icon opens model, status, harness and connection selectors.
Each selector includes an All option. Selected filters intersect with name, role
and title search. Clear filters resets metadata selectors and keeps the search.
Filter choices use the same metadata as the roster cells, including harness defaults,
local credentials, connection pools, and agents that still need setup. Filters stay
active across list and org-chart views, retain matching nodes' ancestors, and preserve
bulk selection. The count reports matching agents, without ancestor context.
Connector source choices use radio semantics and preserve Browse's existing
automatic search expansion; they do not switch separate content panels.
Legacy agent status and built-in URLs redirect to the full roster at `/agents/all`.
+9 -38
View File
@@ -1,20 +1,18 @@
import { lazy, Suspense, useEffect, useMemo, useRef, useState, type ReactNode, type SetStateAction } from "react";
import { createPortal } from "react-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Filter, Pencil, X } from "lucide-react";
import { Check, Pencil, X } from "lucide-react";
import type { Agent } from "@paperclipai/shared";
import { agentsApi } from "@/api/agents";
import { describeError } from "@/api/errors";
import { bindAccount } from "@/lib/account-scope";
import { aiConnectionsApi } from "@/api/ai-connections";
import { getAdapterLabel } from "@/adapters/adapter-display-registry";
import { agentConnection, agentDefaultModel, bulkAgentDisabledReason, bulkAgentHarness, bulkConnectionName } from "@/lib/bulk-agent-settings";
import { pendingAgentSetupDisplayAdapter } from "@/lib/agent-setup-choice";
import { bulkAgentDisabledReason, bulkAgentHarness } from "@/lib/bulk-agent-settings";
import { buildAgentRosterIndex } from "@/lib/agent-roster-filters";
import { AgentStatusBadge } from "@/components/StatusBadge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { cn } from "@/lib/utils";
const AgentBulkSettingsSheet = lazy(() => import("./AgentBulkSettingsSheet").then(m => ({ default: m.AgentBulkSettingsSheet })));
@@ -34,18 +32,17 @@ export function AgentBulkRoster({ companyId, agents, visibleAgents, renderAgent,
selectionRevision.current += 1;
setSelected(next);
};
const [filter, setFilter] = useState("all");
const [editing, setEditing] = useState<Agent[] | null>(null);
const [notice, setNotice] = useState("");
const accounts = useQuery({ queryKey: ["ai-connections", companyId], queryFn: () => aiConnectionsApi.list(companyId) });
const visible = visibleAgents.filter(a => filter === "all" || bulkAgentHarness(a) === filter);
const visible = visibleAgents;
const rosterIndex = useMemo(() => buildAgentRosterIndex(agents, accounts.data), [agents, accounts.data]);
const selectable = visible.filter(a => !bulkAgentDisabledReason(a));
const selectedAgents = useMemo(() => agents.filter(a => selected.includes(a.id) && !bulkAgentDisabledReason(a)), [agents, selected]);
const [lastSelection, setLastSelection] = useState<Agent[]>([]);
useEffect(() => { if (selectedAgents.length) setLastSelection(selectedAgents); }, [selectedAgents]);
// Retain the card contents while it drops out, so its width does not collapse mid-animation.
const actionAgents = selectedAgents.length ? selectedAgents : lastSelection;
const harnesses = [...new Set(agents.map(bulkAgentHarness))].sort((a, b) => getAdapterLabel(a).localeCompare(getAdapterLabel(b)));
const groups = [...new Set(actionAgents.map(bulkAgentHarness))];
const all = selectable.length > 0 && selectable.every(a => selected.includes(a.id));
const some = selectable.some(a => selected.includes(a.id));
@@ -76,31 +73,6 @@ export function AgentBulkRoster({ companyId, agents, visibleAgents, renderAgent,
}
return <div className="space-y-4">
<div data-canonical-toolbar="agent-filters" role="toolbar" aria-label="Agent filters" className="flex flex-wrap items-center justify-between gap-3">
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" aria-label="Filter agents" title={filter === "all" ? "Filter" : `Filter: ${getAdapterLabel(filter)}`}
className={cn("relative h-8 w-8 shrink-0", filter !== "all" && "text-blue-600 dark:text-blue-400")}>
<Filter className="h-3.5 w-3.5" aria-hidden />
{filter !== "all" && <span aria-hidden className="absolute -right-1 -top-1 flex h-3.5 w-3.5 items-center justify-center rounded-full bg-blue-600 text-(length:--text-nano) font-bold text-white">1</span>}
</Button>
</PopoverTrigger>
<PopoverContent aria-label="Agent filters" align="start" className="w-64 space-y-3 p-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Filters</span>
{filter !== "all" && <button type="button" className="text-xs text-muted-foreground hover:text-foreground" onClick={() => setFilter("all")}>Clear</button>}
</div>
<div role="group" aria-label="Harness" className="space-y-1">
<div className="px-2 text-xs font-medium text-muted-foreground">Harness</div>
{["all", ...harnesses].map(h => <button key={h} type="button" aria-pressed={filter === h} onClick={() => setFilter(h)}
className={cn("flex w-full items-center justify-between rounded-sm px-2 py-1 text-left text-sm", filter === h ? "bg-accent/50 text-foreground" : "text-muted-foreground hover:bg-accent/50 hover:text-foreground")}>
{h === "all" ? "All harnesses" : getAdapterLabel(h)}{filter === h && <Check className="size-3.5" aria-hidden />}
</button>)}
</div>
</PopoverContent>
</Popover>
<span className="text-xs text-muted-foreground">{visible.length} {visible.length === 1 ? "agent" : "agents"}</span>
</div>
{createPortal(<div role="region" aria-label="Bulk actions" aria-hidden={selectedAgents.length === 0} inert={selectedAgents.length === 0}
data-state={selectedAgents.length > 0 ? "open" : "closed"} className="agent-selection-bar flex flex-wrap items-center gap-3 rounded-lg border bg-background p-3">
<span className="text-sm font-medium">{actionAgents.length} selected</span>
@@ -113,22 +85,21 @@ export function AgentBulkRoster({ companyId, agents, visibleAgents, renderAgent,
{opening && <p role="status" className="text-sm text-muted-foreground">Loading current agent settings…</p>}
{openError && <p role="alert" className="text-sm text-destructive">{openError}</p>}
{notice && <p role="status" className="flex items-center gap-2 text-sm"><Check className="size-4" aria-hidden />{notice}</p>}
{accounts.error && <p className="text-xs text-destructive">Connection names couldn’t be loaded. <Button variant="link" size="sm" onClick={() => void accounts.refetch()}>Retry</Button></p>}
<div className="overflow-x-auto"><table aria-label="Agent roster" className="agent-roster w-full text-left text-sm">
<thead className="text-xs text-muted-foreground [&_th]:px-3 [&_th]:py-3 [&_th]:font-normal"><tr>
<th className="w-10"><Checkbox aria-label="Select all visible editable agents" checked={all ? true : some ? "indeterminate" : false} disabled={!selectable.length} onCheckedChange={toggleAll} /></th>
<th scope="col">Agent</th>{renderChat && <th scope="col"><span className="sr-only">Chat</span></th>}<th scope="col">Status</th><th scope="col">Harness</th><th scope="col">Connection</th><th scope="col">Default model</th>
</tr></thead>
<tbody>{visible.map(agent => {
const pendingSetup = pendingAgentSetupDisplayAdapter(agent);
const metadata = rosterIndex.byAgentId.get(agent.id)!;
return <tr key={agent.id} data-state={selected.includes(agent.id) ? "selected" : undefined} className="group/agent-row border-t border-border data-[state=selected]:bg-accent/30 hover:bg-accent/50 focus-within:bg-accent/50 [&_td]:px-3 [&_td]:align-middle">
<td title={bulkAgentDisabledReason(agent)}><Checkbox aria-label={`Select ${agent.name}`} checked={selectedAgents.some(a => a.id === agent.id)} disabled={!!bulkAgentDisabledReason(agent)} onCheckedChange={value => changeSelection(value ? [...new Set([...selected, agent.id])] : selected.filter(id => id !== agent.id))} /></td>
<td className="min-w-64 p-0">{renderAgent(agent)}</td>
{renderChat && <td>{renderChat(agent)}</td>}
<td>{renderStatus ? renderStatus(agent) : <AgentStatusBadge status={agent.status} />}</td>
<td className="whitespace-nowrap text-xs">{getAdapterLabel(pendingSetup ?? bulkAgentHarness(agent))}</td>
<td className="text-xs">{pendingSetup ? "Not configured" : ["http", "process"].includes(agent.adapterType) ? "Managed externally" : bulkConnectionName(agentConnection(agent), accounts.data)}</td>
<td className="font-mono text-xs">{pendingSetup ? "Not available yet" : agentDefaultModel(agent) ?? "Harness default"}</td>
<td className="whitespace-nowrap text-xs">{metadata.harness.label}</td>
<td className="text-xs">{metadata.connection.label}</td>
<td className="font-mono text-xs">{metadata.model.label}</td>
</tr>;
})}</tbody>
</table></div>
@@ -0,0 +1,69 @@
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Filter } from "lucide-react";
import type { Agent } from "@paperclipai/shared";
import { aiConnectionsApi } from "@/api/ai-connections";
import { agentRosterFilterFields, buildAgentRosterIndex, emptyAgentRosterFilters, matchesAgentRosterFilters, type AgentRosterFilterField, type AgentRosterFilters as FilterState, type AgentRosterOption } from "@/lib/agent-roster-filters";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { NativeSelect } from "@/components/ui/select";
const emptyAgents: Agent[] = [];
const labels: Record<AgentRosterFilterField, string> = { model: "Model", status: "Status", harness: "Harness", connection: "Connection" };
const allLabels: Record<AgentRosterFilterField, string> = { model: "All models", status: "All statuses", harness: "All harnesses", connection: "All connections" };
/** Page-owned state keeps filters when switching views and isolates them by company. */
export function useAgentRosterFilters(companyId: string | null, agents: Agent[] = emptyAgents) {
const [selection, setSelection] = useState({ companyId, filters: emptyAgentRosterFilters });
const filters = selection.companyId === companyId ? selection.filters : emptyAgentRosterFilters;
const accounts = useQuery({ queryKey: ["ai-connections", companyId], queryFn: () => aiConnectionsApi.list(companyId!), enabled: !!companyId });
const index = useMemo(() => buildAgentRosterIndex(agents, accounts.data), [agents, accounts.data]);
const activeCount = agentRosterFilterFields.filter(field => filters[field]).length;
return {
filters, options: index.options, activeCount,
onChange: (next: FilterState) => setSelection({ companyId, filters: next }),
matches: (agent: Agent) => matchesAgentRosterFilters(index.byAgentId.get(agent.id), filters),
connectionError: accounts.error,
retryConnections: () => void accounts.refetch(),
};
}
export function AgentRosterFilters({ filters, options, onChange, activeCount, count, connectionError, retryConnections }: {
filters: FilterState;
options: Record<AgentRosterFilterField, AgentRosterOption[]>;
onChange: (filters: FilterState) => void;
activeCount: number;
count: number;
connectionError?: unknown;
retryConnections: () => void;
}) {
return <div data-canonical-toolbar="agent-filters" role="toolbar" aria-label="Agent filters" className="flex flex-wrap items-center justify-between gap-3">
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" aria-label="Filter agents" title={activeCount ? `Filters: ${activeCount}` : "Filter agents"}
className={cn("relative shrink-0", activeCount > 0 && "text-blue-600 dark:text-blue-400")}>
<Filter className="size-3.5" aria-hidden />
{activeCount > 0 && <span aria-hidden className="absolute -right-1 -top-1 flex size-3.5 items-center justify-center rounded-full bg-blue-600 text-(length:--text-nano) font-bold text-white">{activeCount}</span>}
</Button>
</PopoverTrigger>
<PopoverContent aria-label="Agent filter choices" align="start" className="max-h-96 w-72 space-y-3 overflow-y-auto p-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Filters</span>
{activeCount > 0 && <Button variant="ghost" size="sm" onClick={() => onChange(emptyAgentRosterFilters)}>Clear filters</Button>}
</div>
{agentRosterFilterFields.map(field => <label key={field} className="block space-y-1 text-xs font-medium text-muted-foreground">
<span>{labels[field]}</span>
<NativeSelect aria-label={labels[field]} value={filters[field]} onChange={event => onChange({ ...filters, [field]: event.target.value })}
className="h-11 text-base md:text-sm">
<option value="">{allLabels[field]}</option>
{filters[field] && !options[field].some(option => option.value === filters[field]) && <option value={filters[field]}>Previously selected {labels[field].toLowerCase()}</option>}
{options[field].map(option => <option key={option.value} value={option.value}>{option.label}</option>)}
</NativeSelect>
</label>)}
</PopoverContent>
</Popover>
<span className="text-xs text-muted-foreground">{count} {count === 1 ? "agent" : "agents"}</span>
{!!connectionError && <p role="alert" className="basis-full text-xs text-destructive">Connection names couldn’t be loaded. <Button variant="link" size="sm" onClick={retryConnections}>Retry</Button></p>}
</div>;
}
+65
View File
@@ -0,0 +1,65 @@
import type { Agent, AiConnectionList } from "@paperclipai/shared";
import { describe, expect, it } from "vitest";
import { buildAgentRosterIndex, emptyAgentRosterFilters, matchesAgentRosterFilters } from "./agent-roster-filters";
const connectionId = "11111111-1111-4111-8111-111111111111";
const otherConnectionId = "22222222-2222-4222-8222-222222222222";
const grantId = "33333333-3333-4333-8333-333333333333";
const agent = (id: string, overrides: Partial<Agent> = {}) => ({
id, status: "idle", adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {}, ...overrides,
}) as Agent;
const binding = (id = connectionId) => ({ provider: "openai", method: "api_key", mode: "shared", connectionId: id, grantId } as const);
describe("agent roster filters", () => {
it("intersects all four filters using connection identity even when names are equal", () => {
const agents = [
agent("match", { adapterType: "paperclip_runner", adapterConfig: { provider: "codex", model: " custom-model " }, runtimeConfig: { aiConnection: binding() } }),
agent("other-connection", { adapterConfig: { model: "custom-model" }, runtimeConfig: { aiConnection: binding(otherConnectionId) } }),
agent("other-model", { adapterConfig: { model: "another-model" }, runtimeConfig: { aiConnection: binding() } }),
agent("other-status", { status: "paused", adapterConfig: { model: "custom-model" }, runtimeConfig: { aiConnection: binding() } }),
agent("other-harness", { adapterType: "claude_local", adapterConfig: { model: "custom-model" }, runtimeConfig: { aiConnection: binding() } }),
];
const accounts = { connections: [connectionId, otherConnectionId].map(id => ({ id, grantId, name: "Team" })) } as AiConnectionList;
const { byAgentId, options } = buildAgentRosterIndex(agents, accounts);
const filters = { model: "model:custom-model", harness: "codex_local", status: "idle", connection: `connection:${connectionId}` };
expect(agents.filter(a => matchesAgentRosterFilters(byAgentId.get(a.id), filters)).map(a => a.id)).toEqual(["match"]);
expect(options.connection).toHaveLength(2);
expect(options.connection.map(c => c.label)).toEqual(["Team (1)", "Team (2)"]);
expect(byAgentId.get("match")?.connection.label).toBe("Team (1)");
expect(byAgentId.get("other-model")?.connection.label).toBe("Team (1)");
expect(options.model.map(m => m.label)).toEqual(["another-model", "custom-model"]);
expect(agents.filter(a => matchesAgentRosterFilters(byAgentId.get(a.id), emptyAgentRosterFilters))).toEqual(agents);
});
it("represents defaults, pending setup, external agents, responsible defaults and pools as displayed", () => {
const agents = [agent("local"), agent("external", { adapterType: "http" }),
agent("responsible", { runtimeConfig: { aiConnection: { mode: "responsible_user", provider: "openai", method: "subscription" } } }),
agent("pool", { runtimeConfig: { aiConnection: { mode: "router", connectionId } } }),
agent("pending", { status: "paused", adapterType: "process", pauseReason: "setup", metadata: {
setupAdapterPending: true, setupAdapterChoice: { adapterType: "paperclip_runner", runnerProvider: "antigravity", runner: "auto" },
} }), agent("hidden", { status: "terminated", adapterConfig: { model: "hidden-model" } }), agent("approval", { status: "pending_approval" }),
];
const { byAgentId, options } = buildAgentRosterIndex(agents);
expect(byAgentId.get("pending")?.harness.value).toBe("antigravity_runtime");
expect(byAgentId.get("pending")?.model.label).toBe("Not available yet");
expect(options.connection.map(c => c.label)).toEqual(["Connection pool", "Local credentials", "Managed externally", "Not configured", "Responsible user's default"]);
expect(options.model.map(m => m.label)).toEqual(["Harness default", "Not available yet"]);
expect(byAgentId.has("hidden")).toBe(false);
expect(byAgentId.has("approval")).toBe(false);
expect(options.status.map(s => s.value)).toEqual(["idle", "paused"]);
expect(matchesAgentRosterFilters(byAgentId.get("local"), { ...emptyAgentRosterFilters, model: "missing" })).toBe(false);
});
it("indexes hundreds of connection names once for a large roster", () => {
let reads = 0;
const id = (i: number) => `10000000-0000-4000-8000-${String(i).padStart(12, "0")}`;
const connections = Array.from({ length: 500 }, (_, i) => ({ get id() {
if (++reads > 500) throw new Error("Connections were scanned for each agent");
return id(i);
}, grantId, name: `Account ${i}` }));
const agents = Array.from({ length: 1000 }, (_, i) => agent(String(i), { runtimeConfig: { aiConnection: binding(id(i % 500)) } }));
const { byAgentId } = buildAgentRosterIndex(agents, { connections } as AiConnectionList);
expect(byAgentId.size).toBe(1000);
expect(reads).toBe(500);
});
});
+68
View File
@@ -0,0 +1,68 @@
import type { Agent, AiConnectionList } from "@paperclipai/shared";
import { getAdapterLabel } from "@/adapters/adapter-display-registry";
import { pendingAgentSetupDisplayAdapter } from "./agent-setup-choice";
import { agentConnection, agentDefaultModel, bulkAgentHarness } from "./bulk-agent-settings";
export const agentRosterFilterFields = ["model", "status", "harness", "connection"] as const;
export type AgentRosterFilterField = (typeof agentRosterFilterFields)[number];
export type AgentRosterFilters = Record<AgentRosterFilterField, string>;
export type AgentRosterOption = { value: string; label: string };
export const emptyAgentRosterFilters: AgentRosterFilters = { model: "", status: "", harness: "", connection: "" };
/** Use the same metadata for filter choices and roster cells. Index account names once. */
export function buildAgentRosterIndex(agents: readonly Agent[], accounts?: AiConnectionList) {
const connectionNames = new Map(accounts?.connections.map(c => [JSON.stringify([c.id, c.grantId]), c.name]));
const poolNames = new Map(accounts?.pools?.map(pool => [pool.id, pool.name]));
const byAgentId = new Map<string, Record<AgentRosterFilterField, AgentRosterOption>>();
const choices = Object.fromEntries(agentRosterFilterFields.map(field => [field, new Map<string, AgentRosterOption>()])) as Record<AgentRosterFilterField, Map<string, AgentRosterOption>>;
for (const agent of agents) {
if (["terminated", "pending_approval"].includes(agent.status)) continue;
const pending = pendingAgentSetupDisplayAdapter(agent);
const harness = pending ?? bulkAgentHarness(agent);
const model = agentDefaultModel(agent);
const binding = agentConnection(agent);
let connection: AgentRosterOption;
if (pending) connection = { value: "unconfigured", label: "Not configured" };
else if (["http", "process"].includes(agent.adapterType)) connection = { value: "external", label: "Managed externally" };
else if (!binding) connection = { value: "local", label: "Local credentials" };
else if (binding.mode === "responsible_user") connection = { value: "responsible_user", label: "Responsible user's default" };
else if (binding.mode === "router") connection = { value: `pool:${binding.connectionId}`, label: poolNames.get(binding.connectionId) ?? "Connection pool" };
else connection = { value: `connection:${binding.connectionId}`, label: connectionNames.get(JSON.stringify([binding.connectionId, binding.grantId])) ?? "Assigned connection" };
const values: Record<AgentRosterFilterField, AgentRosterOption> = {
harness: { value: harness, label: getAdapterLabel(harness) },
model: pending ? { value: "unconfigured", label: "Not available yet" }
: model ? { value: `model:${model}`, label: model } : { value: "default", label: "Harness default" },
status: { value: agent.status, label: agent.status.charAt(0).toUpperCase() + agent.status.slice(1) },
connection,
};
byAgentId.set(agent.id, values);
for (const field of agentRosterFilterFields) {
values[field] = choices[field].get(values[field].value) ?? values[field];
choices[field].set(values[field].value, values[field]);
}
}
const options = Object.fromEntries(agentRosterFilterFields.map(field => [field,
[...choices[field].values()].sort((a, b) => a.label.localeCompare(b.label) || a.value.localeCompare(b.value)),
])) as Record<AgentRosterFilterField, AgentRosterOption[]>;
// Names can be equal or unavailable. Keep distinct saved connections selectable
// without exposing their internal IDs. Roster cells use the same suffixes.
const labelCounts = new Map<string, number>();
for (const option of options.connection) labelCounts.set(option.label, (labelCounts.get(option.label) ?? 0) + 1);
const occurrences = new Map<string, number>();
for (const option of options.connection) {
const label = option.label;
if (labelCounts.get(label)! < 2) continue;
const occurrence = (occurrences.get(label) ?? 0) + 1;
occurrences.set(label, occurrence);
option.label = `${label} (${occurrence})`;
}
return { byAgentId, options };
}
export function matchesAgentRosterFilters(
values: Record<AgentRosterFilterField, AgentRosterOption> | undefined,
filters: AgentRosterFilters,
) {
return agentRosterFilterFields.every(field => !filters[field] || values?.[field].value === filters[field]);
}
+10 -10
View File
@@ -1,5 +1,6 @@
import { PrimaryAgentIndicator } from "@/components/primary-agent/PrimaryAgentPresentation";
import { AgentBulkRoster } from "@/components/agents/AgentBulkRoster";
import { AgentRosterFilters, useAgentRosterFilters } from "@/components/agents/AgentRosterFilters";
import { AgentAvatar } from "@/components/AgentAvatar";
import { useState, useEffect, useMemo, lazy, Suspense } from "react";
import { Link, useNavigate, useLocation } from "@/lib/router";
@@ -152,10 +153,10 @@ function resolveAgentEnvironment(
: describeMissingEnvironment(environmentId);
}
function filterOrgTree(nodes: OrgNode[], tab: FilterTab, builtInAgentIds: Set<string>): OrgNode[] {
function filterOrgTree(nodes: OrgNode[], tab: FilterTab, builtInAgentIds: Set<string>, matchingIds?: Set<string>): OrgNode[] {
return nodes
.reduce<OrgNode[]>((acc, node) => {
const filteredReports = filterOrgTree(node.reports, tab, builtInAgentIds);
const filteredReports = filterOrgTree(node.reports, tab, builtInAgentIds, matchingIds);
// Hidden agents (terminated / pending_approval) never render as a row, but
// any visible reports are promoted so the tree doesn't lose live agents.
if (HIDDEN_AGENT_STATUSES.has(node.status)) {
@@ -165,7 +166,7 @@ function filterOrgTree(nodes: OrgNode[], tab: FilterTab, builtInAgentIds: Set<st
const nodeMatches = tab === "builtin"
? builtInAgentIds.has(node.id)
: matchesFilter(node.status, tab);
if (nodeMatches || filteredReports.length > 0) {
if ((nodeMatches && (!matchingIds || matchingIds.has(node.id))) || filteredReports.length > 0) {
acc.push({ ...node, reports: filteredReports });
}
return acc;
@@ -215,6 +216,7 @@ export function Agents() {
enabled: !!selectedCompanyId,
});
const { data: agents, isLoading } = agentsQuery;
const rosterFilters = useAgentRosterFilters(selectedCompanyId, agents);
const agentsView = useQueryView(agentsQuery);
const { data: orgTree } = useQuery({
@@ -328,8 +330,8 @@ export function Agents() {
);
}
const filtered = filterAgents(agents ?? [], tab, builtInAgentIds);
const filteredOrg = filterOrgTree(orgTree ?? [], tab, builtInAgentIds);
const filtered = filterAgents(agents ?? [], tab, builtInAgentIds).filter(rosterFilters.matches);
const filteredOrg = filterOrgTree(orgTree ?? [], tab, builtInAgentIds, rosterFilters.activeCount ? new Set(filtered.map(agent => agent.id)) : undefined);
const environmentDataLoading = environmentsEnabled && environments === undefined;
const showEnvironmentColumn = environmentsEnabled && (environments === undefined || environments.length > 1);
const resolveRenderedEnvironment = (agentId: string) => (
@@ -455,9 +457,7 @@ export function Agents() {
</div>
</div>
{filtered.length > 0 && (
<p className="text-xs text-muted-foreground">{filtered.length} agent{filtered.length !== 1 ? "s" : ""}</p>
)}
<AgentRosterFilters {...rosterFilters} count={filtered.length} />
{agents && agents.length === 0 && (
<EmptyState
@@ -471,7 +471,7 @@ export function Agents() {
{/* List view */}
{effectiveView === "list" && agents && agents.length > 0 && (
<AgentBulkRoster key={selectedCompanyId} companyId={selectedCompanyId}
agents={agents.filter(agent => !HIDDEN_AGENT_STATUSES.has(agent.status))}
agents={agents}
visibleAgents={filtered} renderAgent={renderAgentRow} renderStatus={renderAgentStatus} />
)}
@@ -499,7 +499,7 @@ export function Agents() {
{effectiveView === "org" && orgTree && orgTree.length > 0 && filteredOrg.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-8">
No agents match the selected status.
No agents match the selected filters.
</p>
)}
+87
View File
@@ -7,6 +7,7 @@ import { QueryClient, QueryClientProvider, onlineManager } from "@tanstack/react
import type { Agent, Environment, EnvironmentCapabilities } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ApiError } from "@/api/client";
import { queryKeys } from "@/lib/queryKeys";
import { TooltipProvider } from "@/components/ui/tooltip";
import { ToastProvider } from "../context/ToastContext";
import type { BuiltInAgentState } from "../api/builtInAgents";
@@ -415,6 +416,92 @@ describe("Agents", () => {
expect(mockRouterState.navigate).not.toHaveBeenCalled();
});
it.each([
["streamlined", Agents],
["production", ProductionAgents],
] as const)("%s: combines metadata filters, keeps hidden selections, and retains org ancestors", async (_mode, AgentList) => {
const agents = [
makeAgent({ id: "manager", name: "Manager", adapterType: "process" }),
makeAgent({ id: "alpha", name: "Alpha", status: "idle", adapterConfig: { model: "custom-model" }, reportsTo: "manager" }),
makeAgent({ id: "beta", name: "Beta", status: "paused", adapterType: "claude_local", adapterConfig: { model: "other-model" } }),
];
mockAgentsApi.list.mockResolvedValue(agents);
mockAgentsApi.org.mockResolvedValue([
{ ...agents[0], reports: [{ ...agents[1], reports: [] }] }, { ...agents[2], reports: [] },
]);
root = createRoot(container);
await act(async () => root!.render(<QueryClientProvider client={queryClient}><TooltipProvider><ToastProvider><AgentList /></ToastProvider></TooltipProvider></QueryClientProvider>));
await flushReact();
await flushReact();
await act(() => container.querySelector<HTMLButtonElement>('[aria-label="List view"]')!.click());
await flushReact();
await act(() => container.querySelector<HTMLButtonElement>('[aria-label="Select Beta"]')!.click());
await act(() => container.querySelector<HTMLButtonElement>('[aria-label="Filter agents"]')!.click());
const select = async (field: string, value: string) => {
const control = document.querySelector<HTMLSelectElement>(`select[aria-label="${field}"]`)!;
expect(control).not.toBeNull();
await act(() => { control.value = value; control.dispatchEvent(new Event("change", { bubbles: true })); });
};
await select("Model", "model:custom-model");
await select("Status", "idle");
await select("Harness", "codex_local");
await select("Connection", "local");
expect(container.querySelector("tbody")?.textContent).toContain("Alpha");
expect(container.querySelector("tbody")?.textContent).not.toContain("Beta");
expect(container.querySelector("tbody")?.textContent).not.toContain("Manager");
expect(document.querySelector('[aria-label="Bulk actions"]')?.textContent).toContain("1 hidden by filters");
expect(container.querySelector('[aria-label="Filter agents"]')?.textContent).toBe("4");
expect(container.querySelector('[aria-label="Agent filters"]')?.textContent).toContain("1 agent");
await act(() => { queryClient.setQueryData(queryKeys.agents.list("company-1"), agents.map(agent => agent.id === "alpha" ? { ...agent, adapterConfig: { model: "new-model" } } : agent)); });
await flushReact();
expect(document.querySelector<HTMLSelectElement>('select[aria-label="Model"]')?.selectedOptions[0].textContent).toBe("Previously selected model");
expect(container.textContent).toContain("No agents match these filters.");
await act(() => { queryClient.setQueryData(queryKeys.agents.list("company-1"), agents); });
await flushReact();
await select("Connection", "external");
expect(container.textContent).toContain("No agents match these filters.");
await select("Connection", "local");
// Close the nonmodal popover before switching views.
await act(() => container.querySelector<HTMLButtonElement>('[aria-label="Filter agents"]')!.click());
await act(() => container.querySelector<HTMLButtonElement>('[aria-label="Org chart view"]')!.click());
await flushReact();
await flushReact();
expect(container.textContent).toContain("Alpha");
expect(container.textContent).toContain("Manager");
expect(container.textContent).not.toContain("Beta");
await act(() => container.querySelector<HTMLButtonElement>('[aria-label="Filter agents"]')!.click());
await select("Status", "paused");
expect(container.textContent).toContain("No agents match");
const clear = [...document.querySelectorAll<HTMLButtonElement>("button")].find(button => button.textContent === "Clear filters")!;
await act(() => clear.click());
expect(container.textContent).toContain("Beta");
expect(container.querySelector('[aria-label="Filter agents"]')?.textContent).toBe("");
});
it("intersects metadata filters with search and keeps search when filters are cleared", async () => {
mockAgentsApi.list.mockResolvedValue([
makeAgent({ id: "alpha", name: "Alpha", status: "idle" }),
makeAgent({ id: "beta", name: "Beta", status: "paused" }),
]);
root = createRoot(container);
await act(async () => renderAgentsView(root, queryClient));
await flushReact();
await flushReact();
const input = container.querySelector<HTMLInputElement>('input[aria-label="Search agents"]')!;
await act(() => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, "beta");
input.dispatchEvent(new Event("input", { bubbles: true }));
});
await act(() => container.querySelector<HTMLButtonElement>('[aria-label="Filter agents"]')!.click());
const status = document.querySelector<HTMLSelectElement>('select[aria-label="Status"]')!;
await act(() => { status.value = "idle"; status.dispatchEvent(new Event("change", { bubbles: true })); });
expect(container.textContent).toContain("No agents match these filters.");
await act(() => [...document.querySelectorAll<HTMLButtonElement>("button")].find(button => button.textContent === "Clear filters")!.click());
expect(input.value).toBe("beta");
expect(container.querySelector("tbody")?.textContent).toContain("Beta");
expect(container.querySelector("tbody")?.textContent).not.toContain("Alpha");
});
it.each([
["streamlined", Agents],
["production", ProductionAgents],
+8 -4
View File
@@ -1,5 +1,6 @@
import { PrimaryAgentIndicator } from "@/components/primary-agent/PrimaryAgentPresentation";
import { AgentBulkRoster } from "@/components/agents/AgentBulkRoster";
import { AgentRosterFilters, useAgentRosterFilters } from "@/components/agents/AgentRosterFilters";
import { AgentAvatar } from "@/components/AgentAvatar";
import { AgentChatPin, useAgentChatPins } from "@/components/AgentChatPin";
import { useAgentChatEnabled } from "../hooks/useAgentChatEnabled";
@@ -239,6 +240,7 @@ export function Agents({ initialView = "list" }: { initialView?: AgentsView } =
enabled: !!selectedCompanyId,
});
const { data: agents, isLoading } = agentsQuery;
const rosterFilters = useAgentRosterFilters(selectedCompanyId, agents);
const agentsView = useQueryView(agentsQuery);
const { data: orgTree } = useQuery({
@@ -346,8 +348,8 @@ export function Agents({ initialView = "list" }: { initialView?: AgentsView } =
);
}
const filtered = filterAgents(agents ?? [], tab, builtInAgentIds, query);
const filteredOrg = filterOrgTree(orgTree ?? [], tab, builtInAgentIds, query.trim() ? new Set(filtered.map(agent => agent.id)) : undefined);
const filtered = filterAgents(agents ?? [], tab, builtInAgentIds, query).filter(rosterFilters.matches);
const filteredOrg = filterOrgTree(orgTree ?? [], tab, builtInAgentIds, query.trim() || rosterFilters.activeCount ? new Set(filtered.map(agent => agent.id)) : undefined);
const environmentDataLoading = environmentsEnabled && environments === undefined;
const showEnvironmentColumn = environmentsEnabled && (environments === undefined || environments.length > 1);
const resolveRenderedEnvironment = (agentId: string) => (
@@ -436,6 +438,8 @@ export function Agents({ initialView = "list" }: { initialView?: AgentsView } =
<AgentsToolbar query={query} onQueryChange={setQuery} view={effectiveView} onViewChange={setView}
hideViewToggle={forceListView} onNewAgent={openNewAgent} />
<AgentRosterFilters {...rosterFilters} count={filtered.length} />
<div className={cn("min-h-0 space-y-4", effectiveView === "org" && "flex flex-1 flex-col")}>
{agents && agents.length === 0 && (
@@ -450,7 +454,7 @@ export function Agents({ initialView = "list" }: { initialView?: AgentsView } =
{/* List view */}
{effectiveView === "list" && agents && agents.length > 0 && (
<AgentBulkRoster key={selectedCompanyId} companyId={selectedCompanyId}
agents={agents.filter(agent => !HIDDEN_AGENT_STATUSES.has(agent.status))}
agents={agents}
visibleAgents={filtered} renderAgent={renderAgentRow} renderStatus={renderAgentStatus}
renderChat={agentChat.enabled ? agent => (
// Use Radix's standard hover delay, isolated from the app's instant provider.
@@ -482,7 +486,7 @@ export function Agents({ initialView = "list" }: { initialView?: AgentsView } =
{effectiveView === "org" && orgTree && orgTree.length > 0 && filteredOrg.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-8">
No agents match your search and selected status.
No agents match your search and filters.
</p>
)}
@@ -72,7 +72,7 @@ const meta = {
component: BulkSettingsPage,
parameters: { layout: "fullscreen", initialEntries: ["/PAP/agents/all"], paperclip: {
capabilities: ["agents/organization", "agents/instructions"], implementation: "production", boundary: "fixtures",
notes: "Actual Agents page, filter icon/popover with preserved selection, bulk roster, sheet, account picker, model intersection and narrow PATCH client. Includes paused agents at normal contrast. The roster has no status selector or persistent selection hint. HTTP accounts/catalogs/writes are synthetic, including one transient failure and an empty local Hermes catalog. Does not establish live provider credential validity or database persistence. The original approved proposal remains in Design proposals.",
notes: "Actual Agents page, filter icon/popover with preserved selection, bulk roster, sheet, account picker, model intersection and narrow PATCH client. Includes paused agents at normal contrast. The filter popover has model, status, harness and connection selectors. The roster has no persistent selection hint. HTTP accounts/catalogs/writes are synthetic, including one transient failure and an empty local Hermes catalog. Does not establish live provider credential validity or database persistence. The original approved proposal remains in Design proposals.",
} },
} satisfies Meta<typeof BulkSettingsPage>;
export default meta;
@@ -85,16 +85,21 @@ export const FilterJourney: Story = { play: async ({ canvasElement }) => {
const screen = within(canvasElement.ownerDocument.body);
await userEvent.click(await page.findByRole("checkbox", { name: "Select Sage" }));
await userEvent.click(page.getByRole("button", { name: "Filter agents" }));
const filters = within(await screen.findByRole("dialog", { name: "Agent filters" }));
await userEvent.click(filters.getByRole("button", { name: "Codex" }));
const filters = within(await screen.findByRole("dialog", { name: "Agent filter choices" }));
await userEvent.selectOptions(filters.getByRole("combobox", { name: "Harness" }), "codex_local");
await expect(page.queryByRole("checkbox", { name: "Select Sage" })).not.toBeInTheDocument();
await expect(page.getByText("1 hidden by filters")).toBeVisible();
await expect(screen.getByText("1 hidden by filters")).toBeVisible();
await expect(page.getByRole("checkbox", { name: "Select Scout" })).toBeEnabled();
await userEvent.click(filters.getByRole("button", { name: "Clear" }));
await userEvent.selectOptions(filters.getByRole("combobox", { name: "Model" }), "model:gpt-5.3-codex");
await userEvent.selectOptions(filters.getByRole("combobox", { name: "Status" }), "paused");
await userEvent.selectOptions(filters.getByRole("combobox", { name: "Connection" }), `connection:${accounts[0].id}`);
await expect(page.getByRole("checkbox", { name: "Select Scout" })).toBeVisible();
await expect(page.queryByRole("checkbox", { name: "Select Atlas" })).not.toBeInTheDocument();
await userEvent.click(filters.getByRole("button", { name: "Clear filters" }));
await expect(await page.findByRole("checkbox", { name: "Select Sage" })).toBeChecked();
await userEvent.keyboard("{Escape}");
await expect(page.getByRole("button", { name: "Filter agents" })).toHaveFocus();
await userEvent.click(page.getByRole("button", { name: "Clear selection" }));
await userEvent.click(screen.getByRole("button", { name: "Clear selection" }));
} };
async function selectCodex(canvasElement: HTMLElement, mixed = false) {