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:
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
*/
|
||||
|
||||
export { AgentTypeForm } from './AgentTypeForm';
|
||||
export { AgentTypeList } from './AgentTypeList';
|
||||
export { AgentTypeList, type ViewMode } from './AgentTypeList';
|
||||
export { AgentTypeDetail } from './AgentTypeDetail';
|
||||
|
||||
84
frontend/src/components/ui/dynamic-icon.tsx
Normal file
84
frontend/src/components/ui/dynamic-icon.tsx
Normal 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);
|
||||
}
|
||||
93
frontend/src/components/ui/toggle-group.tsx
Normal file
93
frontend/src/components/ui/toggle-group.tsx
Normal 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 };
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user