diff --git a/DESIGN.md b/DESIGN.md index e2ccd58ecf0..69ba9efee3a 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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`. diff --git a/ui/src/components/agents/AgentBulkRoster.tsx b/ui/src/components/agents/AgentBulkRoster.tsx index 5a2e0cdcc8b..208bc733d7d 100644 --- a/ui/src/components/agents/AgentBulkRoster.tsx +++ b/ui/src/components/agents/AgentBulkRoster.tsx @@ -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(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([]); 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
-
- - - - - -
- Filters - {filter !== "all" && } -
-
-
Harness
- {["all", ...harnesses].map(h => )} -
-
-
- {visible.length} {visible.length === 1 ? "agent" : "agents"} -
{createPortal(
0 ? "open" : "closed"} className="agent-selection-bar flex flex-wrap items-center gap-3 rounded-lg border bg-background p-3"> {actionAgents.length} selected @@ -113,22 +85,21 @@ export function AgentBulkRoster({ companyId, agents, visibleAgents, renderAgent, {opening &&

Loading current agent settings…

} {openError &&

{openError}

} {notice &&

{notice}

} - {accounts.error &&

Connection names couldn’t be loaded.

}
{renderChat && }{visible.map(agent => { - const pendingSetup = pendingAgentSetupDisplayAdapter(agent); + const metadata = rosterIndex.byAgentId.get(agent.id)!; return {renderChat && } - - - + + + ; })}
AgentChatStatusHarnessConnectionDefault model
a.id === agent.id)} disabled={!!bulkAgentDisabledReason(agent)} onCheckedChange={value => changeSelection(value ? [...new Set([...selected, agent.id])] : selected.filter(id => id !== agent.id))} /> {renderAgent(agent)}{renderChat(agent)}{renderStatus ? renderStatus(agent) : }{getAdapterLabel(pendingSetup ?? bulkAgentHarness(agent))}{pendingSetup ? "Not configured" : ["http", "process"].includes(agent.adapterType) ? "Managed externally" : bulkConnectionName(agentConnection(agent), accounts.data)}{pendingSetup ? "Not available yet" : agentDefaultModel(agent) ?? "Harness default"}{metadata.harness.label}{metadata.connection.label}{metadata.model.label}
diff --git a/ui/src/components/agents/AgentRosterFilters.tsx b/ui/src/components/agents/AgentRosterFilters.tsx new file mode 100644 index 00000000000..e8568c40132 --- /dev/null +++ b/ui/src/components/agents/AgentRosterFilters.tsx @@ -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 = { model: "Model", status: "Status", harness: "Harness", connection: "Connection" }; +const allLabels: Record = { 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; + onChange: (filters: FilterState) => void; + activeCount: number; + count: number; + connectionError?: unknown; + retryConnections: () => void; +}) { + return
+ + + + + +
+ Filters + {activeCount > 0 && } +
+ {agentRosterFilterFields.map(field => )} +
+
+ {count} {count === 1 ? "agent" : "agents"} + {!!connectionError &&

Connection names couldn’t be loaded.

} +
; +} diff --git a/ui/src/lib/agent-roster-filters.test.ts b/ui/src/lib/agent-roster-filters.test.ts new file mode 100644 index 00000000000..6b549202374 --- /dev/null +++ b/ui/src/lib/agent-roster-filters.test.ts @@ -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 = {}) => ({ + 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); + }); +}); diff --git a/ui/src/lib/agent-roster-filters.ts b/ui/src/lib/agent-roster-filters.ts new file mode 100644 index 00000000000..a56b399feec --- /dev/null +++ b/ui/src/lib/agent-roster-filters.ts @@ -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; +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>(); + const choices = Object.fromEntries(agentRosterFilterFields.map(field => [field, new Map()])) as Record>; + + 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 = { + 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; + // 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(); + for (const option of options.connection) labelCounts.set(option.label, (labelCounts.get(option.label) ?? 0) + 1); + const occurrences = new Map(); + 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 | undefined, + filters: AgentRosterFilters, +) { + return agentRosterFilterFields.every(field => !filters[field] || values?.[field].value === filters[field]); +} diff --git a/ui/src/pages/Agents.production.tsx b/ui/src/pages/Agents.production.tsx index 29a6dd9f9a8..327cfbe0480 100644 --- a/ui/src/pages/Agents.production.tsx +++ b/ui/src/pages/Agents.production.tsx @@ -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): OrgNode[] { +function filterOrgTree(nodes: OrgNode[], tab: FilterTab, builtInAgentIds: Set, matchingIds?: Set): OrgNode[] { return nodes .reduce((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 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() {
- {filtered.length > 0 && ( -

{filtered.length} agent{filtered.length !== 1 ? "s" : ""}

- )} + {agents && agents.length === 0 && ( 0 && ( !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 && (

- No agents match the selected status. + No agents match the selected filters.

)} diff --git a/ui/src/pages/Agents.test.tsx b/ui/src/pages/Agents.test.tsx index d85ba92f5bb..be526413f0d 100644 --- a/ui/src/pages/Agents.test.tsx +++ b/ui/src/pages/Agents.test.tsx @@ -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()); + await flushReact(); + await flushReact(); + await act(() => container.querySelector('[aria-label="List view"]')!.click()); + await flushReact(); + await act(() => container.querySelector('[aria-label="Select Beta"]')!.click()); + await act(() => container.querySelector('[aria-label="Filter agents"]')!.click()); + const select = async (field: string, value: string) => { + const control = document.querySelector(`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('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('[aria-label="Filter agents"]')!.click()); + await act(() => container.querySelector('[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('[aria-label="Filter agents"]')!.click()); + await select("Status", "paused"); + expect(container.textContent).toContain("No agents match"); + const clear = [...document.querySelectorAll("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('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('[aria-label="Filter agents"]')!.click()); + const status = document.querySelector('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("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], diff --git a/ui/src/pages/Agents.tsx b/ui/src/pages/Agents.tsx index d77dd0fa8c2..c397a3e6a01 100644 --- a/ui/src/pages/Agents.tsx +++ b/ui/src/pages/Agents.tsx @@ -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 } = + +
{agents && agents.length === 0 && ( @@ -450,7 +454,7 @@ export function Agents({ initialView = "list" }: { initialView?: AgentsView } = {/* List view */} {effectiveView === "list" && agents && agents.length > 0 && ( !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 && (

- No agents match your search and selected status. + No agents match your search and filters.

)} diff --git a/ui/storybook/stories/agents/bulk-settings.stories.tsx b/ui/storybook/stories/agents/bulk-settings.stories.tsx index 690ab6ae5e0..2853bc64907 100644 --- a/ui/storybook/stories/agents/bulk-settings.stories.tsx +++ b/ui/storybook/stories/agents/bulk-settings.stories.tsx @@ -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; 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) {