feat(ui): add search to Agent Hub tab and admin agents table

Ports the Model Hub search to the AI Hub Agent Hub tab and the admin
/agents toolbar as a client-side filter over agent name and description.
Extracts the hub search matching into utils/searchUtils and fixes the
public Model Hub rendering the whole catalog when a search matches
nothing (LIT-5230)
This commit is contained in:
ryan-crabbe-berri 2026-09-01 11:46:58 -07:00
parent 82dd36c1a4
commit d9f7f9ea16
9 changed files with 282 additions and 134 deletions

View file

@ -74,6 +74,42 @@ describe("AgentsTable", () => {
expect(onDeleteClick).toHaveBeenCalledWith("agent-9", "Doomed Agent");
});
it("filters agents by name or by agent card description", async () => {
const user = userEvent.setup();
render(
<AgentsTable
agents={[
makeAgent({ agent_id: "a1", agent_name: "Billing Router" }),
makeAgent({
agent_id: "a2",
agent_name: "Second Agent",
agent_card_params: { description: "handles support tickets" },
}),
]}
{...baseProps}
/>,
);
const search = screen.getByPlaceholderText("Search agent names or descriptions...");
await user.type(search, "billing");
expect(screen.getByText("Billing Router")).toBeInTheDocument();
expect(screen.queryByText("Second Agent")).not.toBeInTheDocument();
await user.clear(search);
await user.type(search, "support tickets");
expect(screen.getByText("Second Agent")).toBeInTheDocument();
expect(screen.queryByText("Billing Router")).not.toBeInTheDocument();
});
it("shows the no-match empty state when the search matches nothing", async () => {
const user = userEvent.setup();
render(<AgentsTable agents={[makeAgent()]} {...baseProps} />);
await user.type(screen.getByPlaceholderText("Search agent names or descriptions..."), "zzzz");
expect(screen.queryByText("Test Agent")).not.toBeInTheDocument();
expect(screen.getByText("No matching agents")).toBeInTheDocument();
});
it("hides the actions column entirely for non-admins", () => {
const agent = makeAgent({ agent_id: "agent-2" });
render(<AgentsTable agents={[agent]} {...baseProps} isAdmin={false} />);

View file

@ -1,13 +1,15 @@
"use client";
import { SortingState } from "@tanstack/react-table";
import { Bot, CircleCheck } from "lucide-react";
import { Bot, CircleCheck, Search as SearchIcon, X } from "lucide-react";
import React, { useMemo, useState } from "react";
import { Agent } from "@/components/agents/types";
import { DataTable } from "@/components/shared/DataTable";
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group";
import { Switch } from "@/components/ui/switch";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { filterBySearchTerm } from "@/utils/searchUtils";
import { getAgentsTableColumns } from "./AgentsTableColumns";
@ -24,14 +26,18 @@ interface AgentsTableProps {
const DEFAULT_SORTING: SortingState = [{ id: "created_at", desc: true }];
function EmptyState() {
function EmptyState({ isFiltered }: { isFiltered: boolean }) {
return (
<div className="flex flex-col items-center gap-1 py-6">
<div className="mb-1 flex size-10 items-center justify-center rounded-lg bg-muted">
<Bot className="size-5 text-muted-foreground" />
</div>
<div className="text-sm font-medium text-foreground">No agents yet</div>
<div className="text-sm text-muted-foreground">Add an agent to make it available in your organization.</div>
<div className="text-sm font-medium text-foreground">{isFiltered ? "No matching agents" : "No agents yet"}</div>
<div className="text-sm text-muted-foreground">
{isFiltered
? "Adjust the search to see more agents."
: "Add an agent to make it available in your organization."}
</div>
</div>
);
}
@ -47,6 +53,11 @@ const AgentsTable: React.FC<AgentsTableProps> = ({
onDeleteClick,
}) => {
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
const [searchTerm, setSearchTerm] = useState("");
const filteredAgents = useMemo(
() => filterBySearchTerm(agents, searchTerm, (agent) => [agent.agent_name, agent.agent_card_params?.description]),
[agents, searchTerm],
);
const columns = useMemo(
() => getAgentsTableColumns({ isAdmin, onAgentClick, onDeleteClick }),
@ -55,7 +66,7 @@ const AgentsTable: React.FC<AgentsTableProps> = ({
return (
<DataTable
data={agents}
data={filteredAgents}
columns={columns}
getRowId={(agent, index) => agent.agent_id || String(index)}
sortingMode="client"
@ -63,10 +74,27 @@ const AgentsTable: React.FC<AgentsTableProps> = ({
onSortingChange={setSorting}
isLoading={isLoading}
loadingMessage="Loading agents…"
noDataMessage={<EmptyState />}
noDataMessage={<EmptyState isFiltered={agents.length > 0} />}
size="compact"
toolbar={() => (
<div className="flex items-center justify-end">
<div className="flex flex-wrap items-center justify-between gap-2">
<InputGroup className="max-w-sm">
<InputGroupAddon>
<SearchIcon className="size-4 text-muted-foreground" />
</InputGroupAddon>
<InputGroupInput
placeholder="Search agent names or descriptions..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
{searchTerm && (
<InputGroupAddon align="inline-end">
<InputGroupButton size="icon-xs" aria-label="Clear search" onClick={() => setSearchTerm("")}>
<X />
</InputGroupButton>
</InputGroupAddon>
)}
</InputGroup>
<TooltipProvider delay={300}>
<Tooltip>
<TooltipTrigger

View file

@ -203,12 +203,12 @@ describe("ModelHubTable", () => {
});
describe("hub tabs", () => {
const renderHub = async () => {
const renderHub = async (agents: object[] = []) => {
vi.mocked(networking.modelHubCall).mockResolvedValue({
data: [{ model_group: "claude-opus-4-8", providers: ["anthropic"], mode: "chat" }],
});
vi.mocked(networking.getConfigFieldSetting).mockResolvedValue({ field_value: false });
vi.mocked(networking.getAgentsList).mockResolvedValue({ agents: [] });
vi.mocked(networking.getAgentsList).mockResolvedValue({ agents });
vi.mocked(networking.fetchMCPServers).mockResolvedValue([]);
vi.mocked(networking.getUiSettings).mockResolvedValue({ values: {} });
mockUseUISettings.mockReturnValue({ data: { values: {} }, isLoading: false });
@ -230,6 +230,37 @@ describe("ModelHubTable", () => {
expect(await screen.findByPlaceholderText("Search model names...")).toHaveValue("opus");
});
it("filters the Agent Hub table by name or description and shows the no-match state", async () => {
const { user } = await renderHub([
{
agent_id: "a1",
agent_card_params: { name: "Billing Router", description: "routes billing questions" },
litellm_params: { is_public: false },
},
{
agent_id: "a2",
agent_card_params: { name: "Support Bot", description: "handles support tickets" },
litellm_params: { is_public: false },
},
]);
const agentCount = (expected: string) =>
screen.getByText((_, el) => el?.tagName === "P" && el.textContent === expected);
await user.click(screen.getByRole("tab", { name: "Agent Hub" }));
expect(await screen.findByText("Billing Router")).toBeInTheDocument();
const search = screen.getByPlaceholderText("Search agent names or descriptions...");
await user.type(search, "support tickets");
expect(screen.queryByText("Billing Router")).not.toBeInTheDocument();
expect(screen.getByText("Support Bot")).toBeInTheDocument();
expect(agentCount("Showing 1 of 2 agents")).toBeInTheDocument();
await user.clear(search);
await user.type(search, "zzzz");
expect(screen.getByText("No matching agents")).toBeInTheDocument();
expect(agentCount("Showing 0 of 2 agents")).toBeInTheDocument();
});
it("renders the hub strip as underlined tabs rather than a segmented pill", async () => {
await renderHub();

View file

@ -23,13 +23,15 @@ import {
import PublicModelHub from "@/components/public_model_hub";
import { copyToClipboard } from "@/utils/dataUtils";
import { isAdminRole, isProxyAdminRole } from "@/utils/roles";
import { filterBySearchTerm } from "@/utils/searchUtils";
import { SortingState } from "@tanstack/react-table";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Copy, Inbox } from "lucide-react";
import { Copy, Inbox, Search as SearchIcon, X } from "lucide-react";
import { useRouter } from "next/navigation";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
@ -80,6 +82,7 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({ accessToken, publicPage,
const [agentLoading, setAgentLoading] = useState<boolean>(true);
const [selectedAgent, setSelectedAgent] = useState<null | AgentHubData>(null);
const [isAgentModalVisible, setIsAgentModalVisible] = useState(false);
const [agentSearchTerm, setAgentSearchTerm] = useState("");
// MCP Hub state
const [mcpHubData, setMcpHubData] = useState<MCPServerData[] | null>(null);
const [mcpLoading, setMcpLoading] = useState<boolean>(true);
@ -385,6 +388,10 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({ accessToken, publicPage,
const modelColumns = useMemo(() => getModelHubTableColumns({ onModelClick: showModal }), [showModal]);
const agentColumns = useMemo(() => getAgentHubTableColumns({ onAgentClick: showAgentModal }), [showAgentModal]);
const filteredAgentData = useMemo(
() => filterBySearchTerm(agentHubData ?? [], agentSearchTerm, (agent) => [agent.name, agent.description]),
[agentHubData, agentSearchTerm],
);
const mcpColumns = useMemo(() => getMCPHubTableColumns({ onServerClick: showMcpModal }), [showMcpModal]);
// If this is a public page, use the dedicated PublicModelHub component
@ -505,9 +512,34 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({ accessToken, publicPage,
</div>
)}
<div className="mb-4">
<p className="text-sm font-medium mb-2">Search Agents:</p>
<InputGroup className="max-w-sm">
<InputGroupAddon>
<SearchIcon className="size-4 text-muted-foreground" />
</InputGroupAddon>
<InputGroupInput
placeholder="Search agent names or descriptions..."
value={agentSearchTerm}
onChange={(e) => setAgentSearchTerm(e.target.value)}
/>
{agentSearchTerm && (
<InputGroupAddon align="inline-end">
<InputGroupButton
size="icon-xs"
aria-label="Clear search"
onClick={() => setAgentSearchTerm("")}
>
<X />
</InputGroupButton>
</InputGroupAddon>
)}
</InputGroup>
</div>
{/* Agent Table */}
<DataTable
data={agentHubData || []}
data={filteredAgentData}
columns={agentColumns}
getRowId={(agent, index) => agent.agent_id || agent.name || String(index)}
sortingMode="client"
@ -516,7 +548,14 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({ accessToken, publicPage,
isLoading={agentLoading}
loadingMessage="Loading agents…"
noDataMessage={
<HubEmptyState title="No agents yet" body="Agents added to this proxy will appear here." />
<HubEmptyState
title={agentHubData?.length ? "No matching agents" : "No agents yet"}
body={
agentHubData?.length
? "Adjust the search to see more agents."
: "Agents added to this proxy will appear here."
}
/>
}
size="compact"
/>
@ -524,7 +563,7 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({ accessToken, publicPage,
<div className="mt-4 text-center space-y-2">
<p className="text-sm text-muted-foreground">
Showing {agentHubData?.length || 0} agent{agentHubData?.length !== 1 ? "s" : ""}
Showing {filteredAgentData.length} of {agentHubData?.length || 0} agents
</p>
</div>
</TabsContent>

View file

@ -1,5 +1,6 @@
import React, { useState, useEffect, useMemo, useRef } from "react";
import { Card } from "@/components/ui/card";
import { matchesSearchTerm } from "@/utils/searchUtils";
interface ModelGroupInfo {
model_group: string;
@ -76,7 +77,7 @@ const ModelFilters: React.FC<ModelFiltersProps> = ({
const filteredData = useMemo(() => {
return (
modelHubData?.filter((model) => {
const matchesSearch = model.model_group.toLowerCase().includes(searchTerm.toLowerCase());
const matchesSearch = matchesSearchTerm(searchTerm, [model.model_group]);
const matchesProvider = selectedProvider === "" || model.providers.includes(selectedProvider);
const matchesMode = selectedMode === "" || model.mode === selectedMode;

View file

@ -134,6 +134,27 @@ describe("PublicModelHub", () => {
expect(within(gpt35Row as HTMLElement).getByText("Unknown")).toBeInTheDocument();
});
});
it("shows no models when the search has no matches (LIT-5230 regression)", async () => {
const networkingModule = await import("./networking");
vi.mocked(networkingModule.modelHubPublicModelsCall).mockResolvedValue([
{ model_group: "gpt-4", providers: ["openai"], mode: "chat" },
{ model_group: "claude-3", providers: ["anthropic"], mode: "chat" },
]);
render(<PublicModelHub />);
expect(await screen.findByText("gpt-4")).toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText("Search model names... (smart search enabled)"), {
target: { value: "zzzz" },
});
await waitFor(() => {
expect(screen.queryByText("gpt-4")).not.toBeInTheDocument();
expect(screen.queryByText("claude-3")).not.toBeInTheDocument();
expect(screen.getByText("No matching models")).toBeInTheDocument();
});
});
it("handles non-array response gracefully (regression test for e.filter crash)", async () => {
const networkingModule = await import("./networking");
// Mock the API to return an object (like an error response) instead of an array

View file

@ -47,6 +47,7 @@ import { generateCodeSnippet } from "@/components/chat_ui/CodeSnippets";
import { getEndpointType } from "@/components/chat_ui/mode_endpoint_mapping";
import { MessageType } from "@/components/chat_ui/types";
import { getProviderLogoAndName } from "./provider_info_helpers";
import { filterBySearchTerm, rankBySearchRelevance } from "@/utils/searchUtils";
interface PublicModelHubProps {
accessToken?: string | null;
@ -236,52 +237,11 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
const filteredData = useMemo(() => {
if (!modelHubData || !Array.isArray(modelHubData)) return [];
let searchResults = modelHubData;
// Apply search if there's a search term
if (searchTerm.trim()) {
const lowercaseSearch = searchTerm.toLowerCase();
const searchWords = lowercaseSearch.split(/\s+/);
// First, try flexible matching that handles different separators
const exactMatches = modelHubData.filter((model) => {
const modelName = model.model_group.toLowerCase();
// Check if it contains the exact search term
if (modelName.includes(lowercaseSearch)) {
return true;
}
// Check if it contains all search words (handles spaces vs slashes/dashes)
return searchWords.every((word) => modelName.includes(word));
});
// If we have exact matches, rank them by relevance
if (exactMatches.length > 0) {
searchResults = exactMatches.sort((a, b) => {
const aName = a.model_group.toLowerCase();
const bName = b.model_group.toLowerCase();
// Calculate relevance scores
const aExactMatch = aName === lowercaseSearch ? 1000 : 0;
const bExactMatch = bName === lowercaseSearch ? 1000 : 0;
const aStartsWith = aName.startsWith(lowercaseSearch) ? 100 : 0;
const bStartsWith = bName.startsWith(lowercaseSearch) ? 100 : 0;
const aContainsWords = lowercaseSearch.split(/\s+/).every((word) => aName.includes(word)) ? 50 : 0;
const bContainsWords = lowercaseSearch.split(/\s+/).every((word) => bName.includes(word)) ? 50 : 0;
const aLength = aName.length;
const bLength = bName.length;
const aScore = aExactMatch + aStartsWith + aContainsWords + (1000 - aLength);
const bScore = bExactMatch + bStartsWith + bContainsWords + (1000 - bLength);
return bScore - aScore; // Higher score first
});
}
}
const searchResults = rankBySearchRelevance(
filterBySearchTerm(modelHubData, searchTerm, (model) => [model.model_group]),
searchTerm,
(model) => model.model_group,
);
// Apply other filters
return searchResults.filter((model) => {
@ -310,43 +270,11 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
const filteredAgentData = useMemo(() => {
if (!agentHubData || !Array.isArray(agentHubData)) return [];
let searchResults = agentHubData;
// Apply search if there's a search term
if (agentSearchTerm.trim()) {
const lowercaseSearch = agentSearchTerm.toLowerCase();
const searchWords = lowercaseSearch.split(/\s+/);
searchResults = agentHubData.filter((agent) => {
const agentName = agent.name.toLowerCase();
const agentDescription = agent.description.toLowerCase();
// Check if it contains the exact search term
if (agentName.includes(lowercaseSearch) || agentDescription.includes(lowercaseSearch)) {
return true;
}
// Check if it contains all search words
return searchWords.every((word) => agentName.includes(word) || agentDescription.includes(word));
});
// Sort by relevance
searchResults = searchResults.sort((a, b) => {
const aName = a.name.toLowerCase();
const bName = b.name.toLowerCase();
const aExactMatch = aName === lowercaseSearch ? 1000 : 0;
const bExactMatch = bName === lowercaseSearch ? 1000 : 0;
const aStartsWith = aName.startsWith(lowercaseSearch) ? 100 : 0;
const bStartsWith = bName.startsWith(lowercaseSearch) ? 100 : 0;
const aScore = aExactMatch + aStartsWith + (1000 - aName.length);
const bScore = bExactMatch + bStartsWith + (1000 - bName.length);
return bScore - aScore;
});
}
const searchResults = rankBySearchRelevance(
filterBySearchTerm(agentHubData, agentSearchTerm, (agent) => [agent.name, agent.description]),
agentSearchTerm,
(agent) => agent.name,
);
// Apply skill filters
return searchResults.filter((agent) => {
@ -361,43 +289,11 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
const filteredMcpData = useMemo(() => {
if (!mcpHubData || !Array.isArray(mcpHubData)) return [];
let searchResults = mcpHubData;
// Apply search if there's a search term
if (mcpSearchTerm.trim()) {
const lowercaseSearch = mcpSearchTerm.toLowerCase();
const searchWords = lowercaseSearch.split(/\s+/);
searchResults = mcpHubData.filter((server) => {
const serverName = server.server_name.toLowerCase();
const serverDescription = (server.mcp_info?.description || "").toLowerCase();
// Check if it contains the exact search term
if (serverName.includes(lowercaseSearch) || serverDescription.includes(lowercaseSearch)) {
return true;
}
// Check if it contains all search words
return searchWords.every((word) => serverName.includes(word) || serverDescription.includes(word));
});
// Sort by relevance
searchResults = searchResults.sort((a, b) => {
const aName = a.server_name.toLowerCase();
const bName = b.server_name.toLowerCase();
const aExactMatch = aName === lowercaseSearch ? 1000 : 0;
const bExactMatch = bName === lowercaseSearch ? 1000 : 0;
const aStartsWith = aName.startsWith(lowercaseSearch) ? 100 : 0;
const bStartsWith = bName.startsWith(lowercaseSearch) ? 100 : 0;
const aScore = aExactMatch + aStartsWith + (1000 - aName.length);
const bScore = bExactMatch + bStartsWith + (1000 - bName.length);
return bScore - aScore;
});
}
const searchResults = rankBySearchRelevance(
filterBySearchTerm(mcpHubData, mcpSearchTerm, (server) => [server.server_name, server.mcp_info?.description]),
mcpSearchTerm,
(server) => server.server_name,
);
// Apply transport filters
return searchResults.filter((server) => {

View file

@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import { filterBySearchTerm, matchesSearchTerm, rankBySearchRelevance } from "./searchUtils";
describe("matchesSearchTerm", () => {
it("matches everything on an empty or whitespace-only term", () => {
expect(matchesSearchTerm("", ["anything"])).toBe(true);
expect(matchesSearchTerm(" ", ["anything"])).toBe(true);
});
it("matches a substring of any field, case-insensitively", () => {
expect(matchesSearchTerm("BILL", ["Billing Router", "routes invoices"])).toBe(true);
expect(matchesSearchTerm("invoice", ["Billing Router", "routes invoices"])).toBe(true);
});
it("matches when every word appears in some field", () => {
expect(matchesSearchTerm("router invoices", ["Billing Router", "routes invoices"])).toBe(true);
expect(matchesSearchTerm("router refunds", ["Billing Router", "routes invoices"])).toBe(false);
});
it("returns false when nothing matches", () => {
expect(matchesSearchTerm("zzzz", ["Billing Router", "routes invoices"])).toBe(false);
});
it("ignores null and undefined fields", () => {
expect(matchesSearchTerm("billing", [null, undefined, "Billing Router"])).toBe(true);
expect(matchesSearchTerm("billing", [null, undefined])).toBe(false);
});
});
describe("filterBySearchTerm", () => {
const agents = [
{ name: "Billing Router", description: "routes invoices" },
{ name: "Support Bot", description: "handles tickets" },
];
it("keeps only items whose fields match", () => {
expect(filterBySearchTerm(agents, "tickets", (a) => [a.name, a.description])).toEqual([agents[1]]);
});
it("returns an empty list when nothing matches", () => {
expect(filterBySearchTerm(agents, "zzzz", (a) => [a.name, a.description])).toEqual([]);
});
it("returns all items for an empty term", () => {
expect(filterBySearchTerm(agents, "", (a) => [a.name, a.description])).toEqual(agents);
});
});
describe("rankBySearchRelevance", () => {
it("orders exact match, then prefix match, then shorter names", () => {
const items = [{ name: "gpt-4o-mini-transcribe" }, { name: "gpt-4o" }, { name: "chatgpt-4o-latest" }];
expect(rankBySearchRelevance(items, "gpt-4o", (m) => m.name).map((m) => m.name)).toEqual([
"gpt-4o",
"gpt-4o-mini-transcribe",
"chatgpt-4o-latest",
]);
});
it("keeps the original order for an empty term", () => {
const items = [{ name: "b" }, { name: "a" }];
expect(rankBySearchRelevance(items, "", (m) => m.name)).toEqual(items);
});
});

View file

@ -0,0 +1,32 @@
type SearchField = string | null | undefined;
const normalizeTerm = (term: string): string => term.trim().toLowerCase();
export function matchesSearchTerm(term: string, fields: ReadonlyArray<SearchField>): boolean {
const needle = normalizeTerm(term);
if (needle === "") return true;
const haystacks = fields.filter((field): field is string => typeof field === "string").map((f) => f.toLowerCase());
if (haystacks.some((haystack) => haystack.includes(needle))) return true;
return needle.split(/\s+/).every((word) => haystacks.some((haystack) => haystack.includes(word)));
}
export function filterBySearchTerm<T>(
items: ReadonlyArray<T>,
term: string,
fields: (item: T) => ReadonlyArray<SearchField>,
): T[] {
return items.filter((item) => matchesSearchTerm(term, fields(item)));
}
export function rankBySearchRelevance<T>(items: ReadonlyArray<T>, term: string, name: (item: T) => string): T[] {
const needle = normalizeTerm(term);
if (needle === "") return [...items];
const score = (item: T): number => {
const candidate = name(item).toLowerCase();
return (candidate === needle ? 1000 : 0) + (candidate.startsWith(needle) ? 100 : 0) + (1000 - candidate.length);
};
return [...items].sort((a, b) => score(b) - score(a));
}