Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 15 additions & 15 deletions ui/jest.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,20 +45,20 @@ jest.mock('next/router', () => ({
},
}));

const mockNextUseRouter = jest.fn(() => ({
push: jest.fn(),
replace: jest.fn(),
refresh: jest.fn(),
back: jest.fn(),
}));

const mockNextUsePathname = jest.fn(() => '');

const mockNextUseSearchParams = jest.fn(() => new URLSearchParams());

// Mock next/navigation
jest.mock('next/navigation', () => ({
useRouter() {
return {
push: jest.fn(),
replace: jest.fn(),
refresh: jest.fn(),
back: jest.fn(),
};
},
usePathname() {
return '';
},
useSearchParams() {
return new URLSearchParams();
},
}));
useRouter: mockNextUseRouter,
usePathname: mockNextUsePathname,
useSearchParams: mockNextUseSearchParams,
}));
160 changes: 118 additions & 42 deletions ui/src/components/AgentList.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,23 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { AgentGrid } from "@/components/AgentGrid";
import { AgentListView } from "@/components/AgentListView";
import { Plus, LayoutGrid, List } from "lucide-react";
import KagentLogo from "@/components/kagent-logo";
import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { ErrorState } from "./ErrorState";
import { Button } from "./ui/button";
import { LoadingState } from "./LoadingState";
import { useAgents } from "./AgentsProvider";
import { AppPageFrame } from "@/components/layout/AppPageFrame";
import { PageHeader } from "@/components/layout/PageHeader";
import { cn } from "@/lib/utils";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";

const AGENTS_VIEW_KEY = "kagent-agents-view";
const ALL_NAMESPACES = "__all__";
const DEFAULT_NAMESPACE = "default";
type AgentsView = "grid" | "list";

function readStoredView(): AgentsView {
Expand All @@ -25,8 +29,14 @@ function readStoredView(): AgentsView {
}

export default function AgentList() {
const { agents , loading, error } = useAgents();
const { agents, loading, error } = useAgents();
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [view, setView] = useState<AgentsView>("grid");
const namespaceFilter = searchParams.get("namespace") || ALL_NAMESPACES;

const normalizeNamespace = useCallback((namespace?: string) => namespace || DEFAULT_NAMESPACE, []);

useEffect(() => {
const id = requestAnimationFrame(() => {
Expand All @@ -44,6 +54,41 @@ export default function AgentList() {
}
}, []);

const onNamespaceChange = useCallback(
(namespace: string) => {
const q = new URLSearchParams(searchParams.toString());
if (namespace === ALL_NAMESPACES) {
q.delete("namespace");
} else {
q.set("namespace", namespace);
}

const query = q.toString();
router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });
},
[pathname, router, searchParams],
);

const namespaces = useMemo(() => {
const uniqueNamespaces = new Set(
(agents || []).map((item) => normalizeNamespace(item.agent.metadata.namespace)),
);

if (namespaceFilter !== ALL_NAMESPACES) {
uniqueNamespaces.add(namespaceFilter);
}

return Array.from(uniqueNamespaces).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }));
}, [agents, namespaceFilter, normalizeNamespace]);

const filteredAgents = useMemo(() => {
if (!agents || namespaceFilter === ALL_NAMESPACES) {
return agents || [];
}

return agents.filter((item) => normalizeNamespace(item.agent.metadata.namespace) === namespaceFilter);
}, [agents, namespaceFilter, normalizeNamespace]);

if (error) {
return <ErrorState message={error} />;
}
Expand All @@ -60,45 +105,65 @@ export default function AgentList() {
className="mb-8"
end={
agents && agents.length > 0 ? (
<div
className="flex w-full min-w-0 items-center justify-end gap-1 rounded-lg border border-border/60 bg-muted/20 p-1"
role="group"
aria-label="Layout"
>
<Button
type="button"
variant="ghost"
size="sm"
className={cn(
"h-8 gap-1.5 px-2.5 text-muted-foreground",
view === "grid" && "bg-card text-foreground shadow-sm",
)}
aria-pressed={view === "grid"}
aria-label="Show agents as cards"
onClick={() => setViewAndPersist("grid")}
>
<LayoutGrid className="h-4 w-4 shrink-0" aria-hidden />
<span className="hidden sm:inline" aria-hidden>
Cards
</span>
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className={cn(
"h-8 gap-1.5 px-2.5 text-muted-foreground",
view === "list" && "bg-card text-foreground shadow-sm",
)}
aria-pressed={view === "list"}
aria-label="Show agents as a list"
onClick={() => setViewAndPersist("list")}
<div className="flex w-full min-w-0 flex-col gap-3 sm:w-auto sm:flex-row sm:items-center">
<div className="w-full sm:w-56">
<label className="sr-only" htmlFor="agents-namespace-filter">
Namespace
</label>
<Select value={namespaceFilter} onValueChange={onNamespaceChange}>
<SelectTrigger id="agents-namespace-filter" aria-label="Namespace filter">
<SelectValue placeholder="All namespaces" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_NAMESPACES}>All namespaces</SelectItem>
{namespaces.map((namespace) => (
<SelectItem key={namespace} value={namespace}>
{namespace}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div
className="flex w-full min-w-0 items-center justify-end gap-1 rounded-lg border border-border/60 bg-muted/20 p-1"
role="group"
aria-label="Layout"
>
<List className="h-4 w-4 shrink-0" aria-hidden />
<span className="hidden sm:inline" aria-hidden>
List
</span>
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className={cn(
"h-8 gap-1.5 px-2.5 text-muted-foreground",
view === "grid" && "bg-card text-foreground shadow-sm",
)}
aria-pressed={view === "grid"}
aria-label="Show agents as cards"
onClick={() => setViewAndPersist("grid")}
>
<LayoutGrid className="h-4 w-4 shrink-0" aria-hidden />
<span className="hidden sm:inline" aria-hidden>
Cards
</span>
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className={cn(
"h-8 gap-1.5 px-2.5 text-muted-foreground",
view === "list" && "bg-card text-foreground shadow-sm",
)}
aria-pressed={view === "list"}
aria-label="Show agents as a list"
onClick={() => setViewAndPersist("list")}
>
<List className="h-4 w-4 shrink-0" aria-hidden />
<span className="hidden sm:inline" aria-hidden>
List
</span>
</Button>
</div>
</div>
) : null
}
Expand All @@ -116,10 +181,21 @@ export default function AgentList() {
</Link>
</Button>
</div>
) : filteredAgents.length === 0 ? (
<div className="rounded-xl border border-border/60 bg-card/30 py-12 text-center shadow-sm">
<KagentLogo className="mx-auto mb-4 h-16 w-16" />
<h2 className="mb-2 text-lg font-medium tracking-tight">No agents in this namespace</h2>
<p className="mb-6 text-pretty text-sm text-muted-foreground">
No agents match the <span className="font-mono">{namespaceFilter}</span> namespace filter.
</p>
<Button type="button" variant="outline" onClick={() => onNamespaceChange(ALL_NAMESPACES)}>
Show all namespaces
</Button>
</div>
) : view === "list" ? (
<AgentListView agentResponse={agents || []} />
<AgentListView agentResponse={filteredAgents} />
) : (
<AgentGrid agentResponse={agents || []} />
<AgentGrid agentResponse={filteredAgents} />
)}
</AppPageFrame>
);
Expand Down
114 changes: 114 additions & 0 deletions ui/src/components/__tests__/AgentList.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { render, screen } from "@testing-library/react";
import * as nextNavigation from "next/navigation";
import AgentList from "@/components/AgentList";
import { AgentsContext, type AgentsContextType } from "@/components/AgentsProvider";
import type { Agent, AgentResponse } from "@/types";

function createContextValue(agents: AgentResponse[]): AgentsContextType {
return {
agents,
models: [],
loading: false,
error: "",
tools: [],
refreshAgents: async () => {},
refreshModels: async () => {},
refreshTools: async () => {},
createNewAgent: async () => ({ message: "ok", data: {} as Agent }),
updateAgent: async () => ({ message: "ok", data: {} as Agent }),
getAgent: async () => null,
validateAgentData: () => ({}),
};
}

const agents: AgentResponse[] = [
{
id: 1,
agent: {
metadata: { name: "support-bot" },
spec: {
type: "Declarative",
description: "Answers support questions",
},
},
model: "gpt-4o",
modelProvider: "openai",
deploymentReady: true,
accepted: true,
},
{
id: 2,
agent: {
metadata: { name: "team-analyzer", namespace: "team-a" },
spec: {
type: "Declarative",
description: "Analyzes incidents",
},
},
model: "claude-sonnet",
modelProvider: "anthropic",
deploymentReady: true,
accepted: true,
},
];

describe("AgentList", () => {
const mockUseRouter = jest.mocked(nextNavigation.useRouter);
const mockUsePathname = jest.mocked(nextNavigation.usePathname);
const mockUseSearchParams = jest.mocked(nextNavigation.useSearchParams);

beforeEach(() => {
mockUseRouter.mockReturnValue({
push: jest.fn(),
replace: jest.fn(),
refresh: jest.fn(),
back: jest.fn(),
});
mockUsePathname.mockReturnValue("/agents");
mockUseSearchParams.mockReturnValue(new URLSearchParams());
});

afterEach(() => {
jest.clearAllMocks();
});

it("filters agents by namespace from the URL", () => {
mockUseSearchParams.mockReturnValue(new URLSearchParams("namespace=team-a"));

render(
<AgentsContext.Provider value={createContextValue(agents)}>
<AgentList />
</AgentsContext.Provider>,
);

expect(screen.getByLabelText("Namespace filter")).toBeInTheDocument();
expect(screen.getByText("team-a/team-analyzer")).toBeInTheDocument();
expect(screen.queryByText("default/support-bot")).not.toBeInTheDocument();
});

it("treats missing namespaces as default when filtering", () => {
mockUseSearchParams.mockReturnValue(new URLSearchParams("namespace=default"));

render(
<AgentsContext.Provider value={createContextValue(agents)}>
<AgentList />
</AgentsContext.Provider>,
);

expect(screen.getByText("default/support-bot")).toBeInTheDocument();
expect(screen.queryByText("team-a/team-analyzer")).not.toBeInTheDocument();
});

it("shows a filtered empty state when no agents match the namespace", () => {
mockUseSearchParams.mockReturnValue(new URLSearchParams("namespace=team-b"));

render(
<AgentsContext.Provider value={createContextValue(agents)}>
<AgentList />
</AgentsContext.Provider>,
);

expect(screen.getByText("No agents in this namespace")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Show all namespaces" })).toBeInTheDocument();
});
});
Loading