feat(agents): implement grid/list view toggle and enhance filters

- Added grid and list view modes to AgentTypeList with user preference management.
- Enhanced filtering with category selection alongside existing search and status filters.
- Updated AgentTypeDetail with category badges and improved layout.
- Added unit tests for grid/list views and category filtering in AgentTypeList.
- Introduced `@radix-ui/react-toggle-group` for view mode toggle in AgentTypeList.
This commit is contained in:
2026-01-06 18:17:46 +01:00
parent 8e16e2645e
commit 3cb6c8d13b
12 changed files with 1208 additions and 137 deletions

View File

@@ -1,8 +1,8 @@
/**
* Agent Types List Page
*
* Displays a list of agent types with search and filter functionality.
* Allows navigation to agent type detail and creation pages.
* Displays a list of agent types with search, status, and category filters.
* Supports grid and list view modes with user preference persistence.
*/
'use client';
@@ -10,9 +10,10 @@
import { useState, useCallback, useMemo } from 'react';
import { useRouter } from '@/lib/i18n/routing';
import { toast } from 'sonner';
import { AgentTypeList } from '@/components/agents';
import { AgentTypeList, type ViewMode } from '@/components/agents';
import { useAgentTypes } from '@/lib/api/hooks/useAgentTypes';
import { useDebounce } from '@/lib/hooks/useDebounce';
import type { AgentTypeCategory } from '@/lib/api/types/agentTypes';
export default function AgentTypesPage() {
const router = useRouter();
@@ -20,6 +21,8 @@ export default function AgentTypesPage() {
// Filter state
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState('all');
const [categoryFilter, setCategoryFilter] = useState('all');
const [viewMode, setViewMode] = useState<ViewMode>('grid');
// Debounce search for API calls
const debouncedSearch = useDebounce(searchQuery, 300);
@@ -31,20 +34,24 @@ export default function AgentTypesPage() {
return undefined; // 'all' returns undefined to not filter
}, [statusFilter]);
// Determine category filter value
const categoryFilterValue = useMemo(() => {
if (categoryFilter === 'all') return undefined;
return categoryFilter as AgentTypeCategory;
}, [categoryFilter]);
// Fetch agent types
const { data, isLoading, error } = useAgentTypes({
search: debouncedSearch || undefined,
is_active: isActiveFilter,
category: categoryFilterValue,
page: 1,
limit: 50,
});
// Filter results client-side for 'all' status
// Get filtered agent types
const filteredAgentTypes = useMemo(() => {
if (!data?.data) return [];
// When status is 'all', we need to fetch both and combine
// For now, the API returns based on is_active filter
return data.data;
}, [data?.data]);
@@ -71,6 +78,16 @@ export default function AgentTypesPage() {
setStatusFilter(status);
}, []);
// Handle category filter change
const handleCategoryFilterChange = useCallback((category: string) => {
setCategoryFilter(category);
}, []);
// Handle view mode change
const handleViewModeChange = useCallback((mode: ViewMode) => {
setViewMode(mode);
}, []);
// Show error toast if fetch fails
if (error) {
toast.error('Failed to load agent types', {
@@ -87,6 +104,10 @@ export default function AgentTypesPage() {
onSearchChange={handleSearchChange}
statusFilter={statusFilter}
onStatusFilterChange={handleStatusFilterChange}
categoryFilter={categoryFilter}
onCategoryFilterChange={handleCategoryFilterChange}
viewMode={viewMode}
onViewModeChange={handleViewModeChange}
onSelect={handleSelect}
onCreate={handleCreate}
/>

View File

@@ -2,7 +2,8 @@
* AgentTypeDetail Component
*
* Displays detailed information about a single agent type.
* Shows model configuration, permissions, personality, and instance stats.
* Features a hero header with icon/color, category, typical tasks,
* collaboration hints, model configuration, and instance stats.
*/
'use client';
@@ -36,8 +37,13 @@ import {
Cpu,
CheckCircle2,
AlertTriangle,
Sparkles,
Users,
Check,
} from 'lucide-react';
import type { AgentTypeResponse } from '@/lib/api/types/agentTypes';
import { DynamicIcon } from '@/components/ui/dynamic-icon';
import type { AgentTypeResponse, AgentTypeCategory } from '@/lib/api/types/agentTypes';
import { CATEGORY_METADATA } from '@/lib/api/types/agentTypes';
import { AVAILABLE_MCP_SERVERS } from '@/lib/validations/agentType';
interface AgentTypeDetailProps {
@@ -51,6 +57,30 @@ interface AgentTypeDetailProps {
className?: string;
}
/**
* Category badge with color
*/
function CategoryBadge({ category }: { category: AgentTypeCategory | null }) {
if (!category) return null;
const meta = CATEGORY_METADATA[category];
if (!meta) return null;
return (
<Badge
variant="outline"
className="font-medium"
style={{
borderColor: meta.color,
color: meta.color,
backgroundColor: `${meta.color}10`,
}}
>
{meta.label}
</Badge>
);
}
/**
* Status badge component for agent types
*/
@@ -81,11 +111,22 @@ function AgentTypeStatusBadge({ isActive }: { isActive: boolean }) {
function AgentTypeDetailSkeleton() {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Skeleton className="h-10 w-10" />
<div className="flex-1">
<Skeleton className="h-8 w-64" />
<Skeleton className="mt-2 h-4 w-48" />
{/* Hero skeleton */}
<div className="rounded-xl border p-6">
<div className="flex items-start gap-6">
<Skeleton className="h-20 w-20 rounded-xl" />
<div className="flex-1 space-y-3">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-4 w-96" />
<div className="flex gap-2">
<Skeleton className="h-6 w-20" />
<Skeleton className="h-6 w-24" />
</div>
</div>
<div className="flex gap-2">
<Skeleton className="h-9 w-24" />
<Skeleton className="h-9 w-20" />
</div>
</div>
</div>
<div className="grid gap-6 lg:grid-cols-3">
@@ -161,57 +202,134 @@ export function AgentTypeDetail({
top_p?: number;
};
const agentColor = agentType.color || '#3B82F6';
return (
<div className={className}>
{/* Header */}
<div className="mb-6 flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={onBack}>
<ArrowLeft className="h-4 w-4" />
<span className="sr-only">Go back</span>
</Button>
<div className="flex-1">
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold">{agentType.name}</h1>
<AgentTypeStatusBadge isActive={agentType.is_active} />
{/* Back button */}
<Button variant="ghost" size="sm" onClick={onBack} className="mb-4">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Agent Types
</Button>
{/* Hero Header */}
<div
className="mb-6 overflow-hidden rounded-xl border"
style={{
background: `linear-gradient(135deg, ${agentColor}08 0%, transparent 60%)`,
borderColor: `${agentColor}30`,
}}
>
<div
className="h-1.5 w-full"
style={{ background: `linear-gradient(90deg, ${agentColor}, ${agentColor}60)` }}
/>
<div className="p-6">
<div className="flex flex-col gap-6 md:flex-row md:items-start">
{/* Icon */}
<div
className="flex h-20 w-20 shrink-0 items-center justify-center rounded-xl"
style={{
backgroundColor: `${agentColor}15`,
boxShadow: `0 8px 32px ${agentColor}20`,
}}
>
<DynamicIcon
name={agentType.icon}
className="h-10 w-10"
style={{ color: agentColor }}
fallback="bot"
/>
</div>
{/* Info */}
<div className="flex-1 space-y-3">
<div>
<h1 className="text-3xl font-bold">{agentType.name}</h1>
<p className="mt-1 text-muted-foreground">
{agentType.description || 'No description provided'}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<AgentTypeStatusBadge isActive={agentType.is_active} />
<CategoryBadge category={agentType.category} />
<span className="text-sm text-muted-foreground">
Last updated:{' '}
{new Date(agentType.updated_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</span>
</div>
</div>
{/* Actions */}
<div className="flex shrink-0 gap-2">
<Button variant="outline" size="sm" onClick={onDuplicate}>
<Copy className="mr-2 h-4 w-4" />
Duplicate
</Button>
<Button size="sm" onClick={onEdit}>
<Edit className="mr-2 h-4 w-4" />
Edit
</Button>
</div>
</div>
<p className="text-muted-foreground">
Last modified:{' '}
{new Date(agentType.updated_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={onDuplicate}>
<Copy className="mr-2 h-4 w-4" />
Duplicate
</Button>
<Button size="sm" onClick={onEdit}>
<Edit className="mr-2 h-4 w-4" />
Edit
</Button>
</div>
</div>
<div className="grid gap-6 lg:grid-cols-3">
{/* Main Content */}
<div className="space-y-6 lg:col-span-2">
{/* Description Card */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="h-5 w-5" />
Description
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">
{agentType.description || 'No description provided'}
</p>
</CardContent>
</Card>
{/* What This Agent Does Best */}
{agentType.typical_tasks.length > 0 && (
<Card className="border-primary/20 bg-gradient-to-br from-primary/5 to-transparent">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<Sparkles className="h-5 w-5 text-primary" />
What This Agent Does Best
</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-2">
{agentType.typical_tasks.map((task, index) => (
<li key={index} className="flex items-start gap-2">
<Check
className="mt-0.5 h-4 w-4 shrink-0 text-primary"
style={{ color: agentColor }}
/>
<span className="text-sm">{task}</span>
</li>
))}
</ul>
</CardContent>
</Card>
)}
{/* Works Well With */}
{agentType.collaboration_hints.length > 0 && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<Users className="h-5 w-5" />
Works Well With
</CardTitle>
<CardDescription>
Agents that complement this type for effective collaboration
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{agentType.collaboration_hints.map((hint, index) => (
<Badge key={index} variant="secondary" className="text-sm">
{hint}
</Badge>
))}
</div>
</CardContent>
</Card>
)}
{/* Expertise Card */}
<Card>
@@ -355,7 +473,9 @@ export function AgentTypeDetail({
</CardHeader>
<CardContent>
<div className="text-center">
<p className="text-4xl font-bold text-primary">{agentType.instance_count}</p>
<p className="text-4xl font-bold" style={{ color: agentColor }}>
{agentType.instance_count}
</p>
<p className="text-sm text-muted-foreground">Active instances</p>
</div>
<Button variant="outline" className="mt-4 w-full" size="sm" disabled>
@@ -364,6 +484,36 @@ export function AgentTypeDetail({
</CardContent>
</Card>
{/* Agent Info */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<FileText className="h-5 w-5" />
Details
</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Slug</span>
<code className="rounded bg-muted px-1.5 py-0.5 text-xs">{agentType.slug}</code>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Sort Order</span>
<span>{agentType.sort_order}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Created</span>
<span>
{new Date(agentType.created_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</span>
</div>
</CardContent>
</Card>
{/* Danger Zone */}
<Card className="border-destructive/50">
<CardHeader>

View File

@@ -1,8 +1,8 @@
/**
* AgentTypeList Component
*
* Displays a grid of agent type cards with search and filter functionality.
* Used on the main agent types page for browsing and selecting agent types.
* Displays agent types in grid or list view with search, status, and category filters.
* Shows icon, color accent, and category for each agent type.
*/
'use client';
@@ -20,8 +20,14 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Bot, Plus, Search, Cpu } from 'lucide-react';
import type { AgentTypeResponse } from '@/lib/api/types/agentTypes';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import { Bot, Plus, Search, Cpu, LayoutGrid, List } from 'lucide-react';
import { DynamicIcon } from '@/components/ui/dynamic-icon';
import type { AgentTypeResponse, AgentTypeCategory } from '@/lib/api/types/agentTypes';
import { CATEGORY_METADATA } from '@/lib/api/types/agentTypes';
import { AGENT_TYPE_CATEGORIES } from '@/lib/validations/agentType';
export type ViewMode = 'grid' | 'list';
interface AgentTypeListProps {
agentTypes: AgentTypeResponse[];
@@ -30,6 +36,10 @@ interface AgentTypeListProps {
onSearchChange: (query: string) => void;
statusFilter: string;
onStatusFilterChange: (status: string) => void;
categoryFilter: string;
onCategoryFilterChange: (category: string) => void;
viewMode: ViewMode;
onViewModeChange: (mode: ViewMode) => void;
onSelect: (id: string) => void;
onCreate: () => void;
className?: string;
@@ -60,11 +70,36 @@ function AgentTypeStatusBadge({ isActive }: { isActive: boolean }) {
}
/**
* Loading skeleton for agent type cards
* Category badge with color
*/
function CategoryBadge({ category }: { category: AgentTypeCategory | null }) {
if (!category) return null;
const meta = CATEGORY_METADATA[category];
if (!meta) return null;
return (
<Badge
variant="outline"
className="text-xs font-medium"
style={{
borderColor: meta.color,
color: meta.color,
backgroundColor: `${meta.color}10`,
}}
>
{meta.label}
</Badge>
);
}
/**
* Loading skeleton for agent type cards (grid view)
*/
function AgentTypeCardSkeleton() {
return (
<Card className="h-[200px]">
<Card className="h-[220px] overflow-hidden">
<div className="h-1 w-full bg-muted" />
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<Skeleton className="h-10 w-10 rounded-lg" />
@@ -91,6 +126,23 @@ function AgentTypeCardSkeleton() {
);
}
/**
* Loading skeleton for list view
*/
function AgentTypeListSkeleton() {
return (
<div className="flex items-center gap-4 rounded-lg border p-4">
<Skeleton className="h-12 w-12 rounded-lg" />
<div className="flex-1 space-y-2">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-4 w-96" />
</div>
<Skeleton className="h-5 w-20" />
<Skeleton className="h-5 w-16" />
</div>
);
}
/**
* Extract model display name from model ID
*/
@@ -103,6 +155,169 @@ function getModelDisplayName(modelId: string): string {
return modelId;
}
/**
* Grid card view for agent type
*/
function AgentTypeGridCard({
type,
onSelect,
}: {
type: AgentTypeResponse;
onSelect: (id: string) => void;
}) {
const agentColor = type.color || '#3B82F6';
return (
<Card
className="cursor-pointer overflow-hidden transition-all hover:shadow-lg"
onClick={() => onSelect(type.id)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSelect(type.id);
}
}}
aria-label={`View ${type.name} agent type`}
style={{
borderTopColor: agentColor,
borderTopWidth: '3px',
}}
>
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div
className="flex h-11 w-11 items-center justify-center rounded-lg"
style={{
backgroundColor: `${agentColor}15`,
}}
>
<DynamicIcon
name={type.icon}
className="h-5 w-5"
style={{ color: agentColor }}
fallback="bot"
/>
</div>
<div className="flex flex-col items-end gap-1">
<AgentTypeStatusBadge isActive={type.is_active} />
<CategoryBadge category={type.category} />
</div>
</div>
<CardTitle className="mt-3 line-clamp-1">{type.name}</CardTitle>
<CardDescription className="line-clamp-2">
{type.description || 'No description provided'}
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{/* Expertise tags */}
<div className="flex flex-wrap gap-1">
{type.expertise.slice(0, 3).map((skill) => (
<Badge key={skill} variant="secondary" className="text-xs">
{skill}
</Badge>
))}
{type.expertise.length > 3 && (
<Badge variant="outline" className="text-xs">
+{type.expertise.length - 3}
</Badge>
)}
{type.expertise.length === 0 && (
<span className="text-xs text-muted-foreground">No expertise defined</span>
)}
</div>
<Separator />
{/* Metadata */}
<div className="flex items-center justify-between text-sm text-muted-foreground">
<div className="flex items-center gap-1">
<Cpu className="h-3.5 w-3.5" />
<span className="text-xs">{getModelDisplayName(type.primary_model)}</span>
</div>
<div className="flex items-center gap-1">
<Bot className="h-3.5 w-3.5" />
<span className="text-xs">{type.instance_count} instances</span>
</div>
</div>
</div>
</CardContent>
</Card>
);
}
/**
* List row view for agent type
*/
function AgentTypeListRow({
type,
onSelect,
}: {
type: AgentTypeResponse;
onSelect: (id: string) => void;
}) {
const agentColor = type.color || '#3B82F6';
return (
<div
className="flex cursor-pointer items-center gap-4 rounded-lg border p-4 transition-all hover:border-primary hover:shadow-md"
onClick={() => onSelect(type.id)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSelect(type.id);
}
}}
aria-label={`View ${type.name} agent type`}
style={{
borderLeftColor: agentColor,
borderLeftWidth: '4px',
}}
>
{/* Icon */}
<div
className="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg"
style={{ backgroundColor: `${agentColor}15` }}
>
<DynamicIcon
name={type.icon}
className="h-6 w-6"
style={{ color: agentColor }}
fallback="bot"
/>
</div>
{/* Main content */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="font-semibold">{type.name}</h3>
<CategoryBadge category={type.category} />
</div>
<p className="line-clamp-1 text-sm text-muted-foreground">
{type.description || 'No description'}
</p>
<div className="mt-1 flex items-center gap-3 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Cpu className="h-3 w-3" />
{getModelDisplayName(type.primary_model)}
</span>
<span>{type.expertise.length} expertise areas</span>
<span>{type.instance_count} instances</span>
</div>
</div>
{/* Status */}
<div className="shrink-0">
<AgentTypeStatusBadge isActive={type.is_active} />
</div>
</div>
);
}
export function AgentTypeList({
agentTypes,
isLoading = false,
@@ -110,6 +325,10 @@ export function AgentTypeList({
onSearchChange,
statusFilter,
onStatusFilterChange,
categoryFilter,
onCategoryFilterChange,
viewMode,
onViewModeChange,
onSelect,
onCreate,
className,
@@ -131,7 +350,7 @@ export function AgentTypeList({
</div>
{/* Filters */}
<div className="mb-6 flex flex-col gap-4 sm:flex-row">
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
@@ -142,8 +361,25 @@ export function AgentTypeList({
aria-label="Search agent types"
/>
</div>
{/* Category Filter */}
<Select value={categoryFilter} onValueChange={onCategoryFilterChange}>
<SelectTrigger className="w-full sm:w-44" aria-label="Filter by category">
<SelectValue placeholder="All Categories" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Categories</SelectItem>
{AGENT_TYPE_CATEGORIES.map((cat) => (
<SelectItem key={cat.value} value={cat.value}>
{cat.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Status Filter */}
<Select value={statusFilter} onValueChange={onStatusFilterChange}>
<SelectTrigger className="w-full sm:w-40" aria-label="Filter by status">
<SelectTrigger className="w-full sm:w-36" aria-label="Filter by status">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
@@ -152,10 +388,25 @@ export function AgentTypeList({
<SelectItem value="inactive">Inactive</SelectItem>
</SelectContent>
</Select>
{/* View Mode Toggle */}
<ToggleGroup
type="single"
value={viewMode}
onValueChange={(value: string) => value && onViewModeChange(value as ViewMode)}
className="hidden sm:flex"
>
<ToggleGroupItem value="grid" aria-label="Grid view" size="sm">
<LayoutGrid className="h-4 w-4" />
</ToggleGroupItem>
<ToggleGroupItem value="list" aria-label="List view" size="sm">
<List className="h-4 w-4" />
</ToggleGroupItem>
</ToggleGroup>
</div>
{/* Loading State */}
{isLoading && (
{/* Loading State - Grid */}
{isLoading && viewMode === 'grid' && (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3, 4, 5, 6].map((i) => (
<AgentTypeCardSkeleton key={i} />
@@ -163,71 +414,29 @@ export function AgentTypeList({
</div>
)}
{/* Agent Type Grid */}
{!isLoading && agentTypes.length > 0 && (
{/* Loading State - List */}
{isLoading && viewMode === 'list' && (
<div className="space-y-3">
{[1, 2, 3, 4, 5, 6].map((i) => (
<AgentTypeListSkeleton key={i} />
))}
</div>
)}
{/* Agent Type Grid View */}
{!isLoading && agentTypes.length > 0 && viewMode === 'grid' && (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{agentTypes.map((type) => (
<Card
key={type.id}
className="cursor-pointer transition-all hover:border-primary hover:shadow-md"
onClick={() => onSelect(type.id)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSelect(type.id);
}
}}
aria-label={`View ${type.name} agent type`}
>
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Bot className="h-5 w-5 text-primary" />
</div>
<AgentTypeStatusBadge isActive={type.is_active} />
</div>
<CardTitle className="mt-3">{type.name}</CardTitle>
<CardDescription className="line-clamp-2">
{type.description || 'No description provided'}
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{/* Expertise tags */}
<div className="flex flex-wrap gap-1">
{type.expertise.slice(0, 3).map((skill) => (
<Badge key={skill} variant="secondary" className="text-xs">
{skill}
</Badge>
))}
{type.expertise.length > 3 && (
<Badge variant="outline" className="text-xs">
+{type.expertise.length - 3}
</Badge>
)}
{type.expertise.length === 0 && (
<span className="text-xs text-muted-foreground">No expertise defined</span>
)}
</div>
<AgentTypeGridCard key={type.id} type={type} onSelect={onSelect} />
))}
</div>
)}
<Separator />
{/* Metadata */}
<div className="flex items-center justify-between text-sm text-muted-foreground">
<div className="flex items-center gap-1">
<Cpu className="h-3.5 w-3.5" />
<span className="text-xs">{getModelDisplayName(type.primary_model)}</span>
</div>
<div className="flex items-center gap-1">
<Bot className="h-3.5 w-3.5" />
<span className="text-xs">{type.instance_count} instances</span>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Agent Type List View */}
{!isLoading && agentTypes.length > 0 && viewMode === 'list' && (
<div className="space-y-3">
{agentTypes.map((type) => (
<AgentTypeListRow key={type.id} type={type} onSelect={onSelect} />
))}
</div>
)}
@@ -238,11 +447,11 @@ export function AgentTypeList({
<Bot className="mx-auto h-12 w-12 text-muted-foreground" />
<h3 className="mt-4 font-semibold">No agent types found</h3>
<p className="text-muted-foreground">
{searchQuery || statusFilter !== 'all'
{searchQuery || statusFilter !== 'all' || categoryFilter !== 'all'
? 'Try adjusting your search or filters'
: 'Create your first agent type to get started'}
</p>
{!searchQuery && statusFilter === 'all' && (
{!searchQuery && statusFilter === 'all' && categoryFilter === 'all' && (
<Button onClick={onCreate} className="mt-4">
<Plus className="mr-2 h-4 w-4" />
Create Agent Type

View File

@@ -5,5 +5,5 @@
*/
export { AgentTypeForm } from './AgentTypeForm';
export { AgentTypeList } from './AgentTypeList';
export { AgentTypeList, type ViewMode } from './AgentTypeList';
export { AgentTypeDetail } from './AgentTypeDetail';

View File

@@ -0,0 +1,84 @@
/**
* DynamicIcon Component
*
* Renders Lucide icons dynamically by name string.
* Useful when icon names come from data (e.g., database).
*/
import * as LucideIcons from 'lucide-react';
import type { LucideProps } from 'lucide-react';
/**
* Map of icon names to their components.
* Uses kebab-case names (e.g., 'clipboard-check') as keys.
*/
const iconMap: Record<string, React.ComponentType<LucideProps>> = {
// Development
'clipboard-check': LucideIcons.ClipboardCheck,
briefcase: LucideIcons.Briefcase,
'file-text': LucideIcons.FileText,
'git-branch': LucideIcons.GitBranch,
code: LucideIcons.Code,
server: LucideIcons.Server,
layout: LucideIcons.Layout,
smartphone: LucideIcons.Smartphone,
// Design
palette: LucideIcons.Palette,
search: LucideIcons.Search,
// Quality
shield: LucideIcons.Shield,
'shield-check': LucideIcons.ShieldCheck,
// Operations
settings: LucideIcons.Settings,
'settings-2': LucideIcons.Settings2,
// AI/ML
brain: LucideIcons.Brain,
microscope: LucideIcons.Microscope,
eye: LucideIcons.Eye,
'message-square': LucideIcons.MessageSquare,
// Data
'bar-chart': LucideIcons.BarChart,
database: LucideIcons.Database,
// Leadership
users: LucideIcons.Users,
target: LucideIcons.Target,
// Domain Expert
calculator: LucideIcons.Calculator,
'heart-pulse': LucideIcons.HeartPulse,
'flask-conical': LucideIcons.FlaskConical,
lightbulb: LucideIcons.Lightbulb,
'book-open': LucideIcons.BookOpen,
// Generic
bot: LucideIcons.Bot,
cpu: LucideIcons.Cpu,
};
interface DynamicIconProps extends Omit<LucideProps, 'name'> {
/** Icon name in kebab-case (e.g., 'clipboard-check', 'bot') */
name: string | null | undefined;
/** Fallback icon name if the specified icon is not found */
fallback?: string;
}
/**
* Renders a Lucide icon dynamically by name.
*
* @example
* ```tsx
* <DynamicIcon name="clipboard-check" className="h-5 w-5" />
* <DynamicIcon name={agent.icon} fallback="bot" />
* ```
*/
export function DynamicIcon({ name, fallback = 'bot', ...props }: DynamicIconProps) {
const iconName = name || fallback;
const IconComponent = iconMap[iconName] || iconMap[fallback] || LucideIcons.Bot;
return <IconComponent {...props} />;
}
/**
* Get available icon names for validation or display
*/
export function getAvailableIconNames(): string[] {
return Object.keys(iconMap);
}

View File

@@ -0,0 +1,93 @@
'use client';
import * as React from 'react';
import * as ToggleGroupPrimitive from '@radix-ui/react-toggle-group';
import { type VariantProps, cva } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const toggleGroupVariants = cva(
'inline-flex items-center justify-center rounded-md border bg-transparent',
{
variants: {
variant: {
default: 'bg-transparent',
outline: 'border border-input',
},
},
defaultVariants: {
variant: 'outline',
},
}
);
const toggleGroupItemVariants = cva(
'inline-flex items-center justify-center whitespace-nowrap text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground',
{
variants: {
variant: {
default: 'bg-transparent hover:bg-muted hover:text-muted-foreground',
outline: 'bg-transparent hover:bg-accent hover:text-accent-foreground',
},
size: {
default: 'h-10 px-3',
sm: 'h-9 px-2.5',
lg: 'h-11 px-5',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);
const ToggleGroupContext = React.createContext<VariantProps<typeof toggleGroupItemVariants>>({
size: 'default',
variant: 'default',
});
const ToggleGroup = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleGroupVariants> &
VariantProps<typeof toggleGroupItemVariants>
>(({ className, variant, size, children, ...props }, ref) => (
<ToggleGroupPrimitive.Root
ref={ref}
className={cn(toggleGroupVariants({ variant }), className)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>{children}</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
));
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName;
const ToggleGroupItem = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleGroupItemVariants>
>(({ className, children, variant, size, ...props }, ref) => {
const context = React.useContext(ToggleGroupContext);
return (
<ToggleGroupPrimitive.Item
ref={ref}
className={cn(
toggleGroupItemVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
);
});
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName;
export { ToggleGroup, ToggleGroupItem };

View File

@@ -44,10 +44,10 @@ const DEFAULT_PAGE_LIMIT = 20;
export function useAgentTypes(params: AgentTypeListParams = {}) {
const { user } = useAuth();
const { page = 1, limit = DEFAULT_PAGE_LIMIT, is_active = true, search } = params;
const { page = 1, limit = DEFAULT_PAGE_LIMIT, is_active = true, search, category } = params;
return useQuery({
queryKey: agentTypeKeys.list({ page, limit, is_active, search }),
queryKey: agentTypeKeys.list({ page, limit, is_active, search, category }),
queryFn: async (): Promise<AgentTypeListResponse> => {
const response = await apiClient.instance.get('/api/v1/agent-types', {
params: {
@@ -55,6 +55,7 @@ export function useAgentTypes(params: AgentTypeListParams = {}) {
limit,
is_active,
...(search ? { search } : {}),
...(category ? { category } : {}),
},
});
return response.data;