feat(frontend): implement agent configuration pages (#41)

- Add agent types list page with search and filter functionality
- Add agent type detail/edit page with tabbed interface
- Create AgentTypeForm component with React Hook Form + Zod validation
- Implement model configuration (temperature, max tokens, top_p)
- Add MCP permission management with checkboxes
- Include personality prompt editor textarea
- Create TanStack Query hooks for agent-types API
- Add useDebounce hook for search optimization
- Comprehensive unit tests for all components (68 tests)

Components:
- AgentTypeList: Grid view with status badges, expertise tags
- AgentTypeDetail: Full detail view with model config, MCP permissions
- AgentTypeForm: Create/edit with 4 tabs (Basic, Model, Permissions, Personality)

Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-30 23:48:49 +01:00
parent e85788f79f
commit 68f1865a1e
17 changed files with 2888 additions and 0 deletions

View File

@@ -0,0 +1,416 @@
/**
* AgentTypeDetail Component
*
* Displays detailed information about a single agent type.
* Shows model configuration, permissions, personality, and instance stats.
*/
'use client';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { Skeleton } from '@/components/ui/skeleton';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import {
Bot,
ArrowLeft,
Copy,
Edit,
Trash2,
FileText,
Zap,
MessageSquare,
Shield,
Cpu,
CheckCircle2,
AlertTriangle,
} from 'lucide-react';
import type { AgentTypeResponse } from '@/lib/api/types/agentTypes';
import { AVAILABLE_MCP_SERVERS } from '@/lib/validations/agentType';
interface AgentTypeDetailProps {
agentType: AgentTypeResponse | null;
isLoading?: boolean;
onBack: () => void;
onEdit: () => void;
onDuplicate: () => void;
onDeactivate: () => void;
isDeactivating?: boolean;
className?: string;
}
/**
* Status badge component for agent types
*/
function AgentTypeStatusBadge({ isActive }: { isActive: boolean }) {
if (isActive) {
return (
<Badge className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200" variant="outline">
Active
</Badge>
);
}
return (
<Badge className="bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200" variant="outline">
Inactive
</Badge>
);
}
/**
* Loading skeleton for agent type detail
*/
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" />
</div>
</div>
<div className="grid gap-6 lg:grid-cols-3">
<div className="space-y-6 lg:col-span-2">
<Card>
<CardHeader>
<Skeleton className="h-6 w-32" />
</CardHeader>
<CardContent>
<Skeleton className="h-20 w-full" />
</CardContent>
</Card>
</div>
<div className="space-y-6">
<Card>
<CardHeader>
<Skeleton className="h-6 w-32" />
</CardHeader>
<CardContent>
<Skeleton className="h-40 w-full" />
</CardContent>
</Card>
</div>
</div>
</div>
);
}
/**
* Get model display name
*/
function getModelDisplayName(modelId: string): string {
const modelNames: Record<string, string> = {
'claude-opus-4-5-20251101': 'Claude Opus 4.5',
'claude-sonnet-4-20250514': 'Claude Sonnet 4',
'claude-3-5-sonnet-20241022': 'Claude 3.5 Sonnet',
};
return modelNames[modelId] || modelId;
}
export function AgentTypeDetail({
agentType,
isLoading = false,
onBack,
onEdit,
onDuplicate,
onDeactivate,
isDeactivating = false,
className,
}: AgentTypeDetailProps) {
if (isLoading) {
return <AgentTypeDetailSkeleton />;
}
if (!agentType) {
return (
<div className="py-12 text-center">
<AlertTriangle className="mx-auto h-12 w-12 text-muted-foreground" />
<h3 className="mt-4 font-semibold">Agent type not found</h3>
<p className="text-muted-foreground">
The requested agent type could not be found
</p>
<Button onClick={onBack} variant="outline" className="mt-4">
<ArrowLeft className="mr-2 h-4 w-4" />
Go Back
</Button>
</div>
);
}
const modelParams = agentType.model_params as {
temperature?: number;
max_tokens?: number;
top_p?: number;
};
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} />
</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>
{/* Expertise Card */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Zap className="h-5 w-5" />
Expertise Areas
</CardTitle>
</CardHeader>
<CardContent>
{agentType.expertise.length > 0 ? (
<div className="flex flex-wrap gap-2">
{agentType.expertise.map((skill) => (
<Badge key={skill} variant="secondary">
{skill}
</Badge>
))}
</div>
) : (
<p className="text-muted-foreground">No expertise areas defined</p>
)}
</CardContent>
</Card>
{/* Personality Prompt Card */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MessageSquare className="h-5 w-5" />
Personality Prompt
</CardTitle>
</CardHeader>
<CardContent>
<pre className="whitespace-pre-wrap rounded-lg bg-muted p-4 text-sm">
{agentType.personality_prompt}
</pre>
</CardContent>
</Card>
{/* MCP Permissions Card */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="h-5 w-5" />
MCP Permissions
</CardTitle>
<CardDescription>
Model Context Protocol servers this agent can access
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{AVAILABLE_MCP_SERVERS.map((server) => {
const isEnabled = agentType.mcp_servers.includes(server.id);
return (
<div
key={server.id}
className={`flex items-center justify-between rounded-lg border p-3 ${
isEnabled
? 'border-primary/20 bg-primary/5'
: 'border-muted bg-muted/50'
}`}
>
<div className="flex items-center gap-3">
<div
className={`flex h-8 w-8 items-center justify-center rounded-full ${
isEnabled ? 'bg-primary/10' : 'bg-muted'
}`}
>
{isEnabled ? (
<CheckCircle2 className="h-4 w-4 text-primary" />
) : (
<AlertTriangle className="h-4 w-4 text-muted-foreground" />
)}
</div>
<div>
<p className="font-medium">{server.name}</p>
<p className="text-xs text-muted-foreground">
{server.description}
</p>
</div>
</div>
<Badge variant={isEnabled ? 'default' : 'secondary'}>
{isEnabled ? 'Enabled' : 'Disabled'}
</Badge>
</div>
);
})}
</div>
</CardContent>
</Card>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Model Configuration */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<Cpu className="h-5 w-5" />
Model Configuration
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<p className="text-sm text-muted-foreground">Primary Model</p>
<p className="font-medium">
{getModelDisplayName(agentType.primary_model)}
</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Failover Model</p>
<p className="font-medium">
{agentType.fallback_models.length > 0
? getModelDisplayName(agentType.fallback_models[0])
: 'None configured'}
</p>
</div>
<Separator />
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Temperature</span>
<span className="font-medium">{modelParams.temperature ?? 0.7}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Max Tokens</span>
<span className="font-medium">
{(modelParams.max_tokens ?? 8192).toLocaleString()}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Top P</span>
<span className="font-medium">{modelParams.top_p ?? 0.95}</span>
</div>
</div>
</CardContent>
</Card>
{/* Instance Stats */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<Bot className="h-5 w-5" />
Instances
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-center">
<p className="text-4xl font-bold text-primary">
{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>
View Instances
</Button>
</CardContent>
</Card>
{/* Danger Zone */}
<Card className="border-destructive/50">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg text-destructive">
<AlertTriangle className="h-5 w-5" />
Danger Zone
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
className="w-full"
size="sm"
disabled={isDeactivating}
>
<Trash2 className="mr-2 h-4 w-4" />
{agentType.is_active ? 'Deactivate Type' : 'Delete Type'}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
<AlertDialogDescription>
{agentType.is_active
? `This will deactivate the "${agentType.name}" agent type. Existing instances will continue to work, but no new instances can be created from this type.`
: `This will permanently delete the "${agentType.name}" agent type. This action cannot be undone.`}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={onDeactivate}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{agentType.is_active ? 'Deactivate' : 'Delete'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</CardContent>
</Card>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,554 @@
/**
* AgentTypeForm Component
*
* React Hook Form-based form for creating and editing agent types.
* Features tabbed interface for organizing form sections.
*/
'use client';
import { useEffect, useState } from 'react';
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import {
FileText,
Cpu,
Shield,
MessageSquare,
Sliders,
Save,
ArrowLeft,
X,
} from 'lucide-react';
import {
agentTypeCreateSchema,
type AgentTypeCreateFormValues,
AVAILABLE_MODELS,
AVAILABLE_MCP_SERVERS,
defaultAgentTypeValues,
generateSlug,
} from '@/lib/validations/agentType';
import type { AgentTypeResponse } from '@/lib/api/types/agentTypes';
interface AgentTypeFormProps {
agentType?: AgentTypeResponse;
onSubmit: (data: AgentTypeCreateFormValues) => void;
onCancel: () => void;
isSubmitting?: boolean;
className?: string;
}
export function AgentTypeForm({
agentType,
onSubmit,
onCancel,
isSubmitting = false,
className,
}: AgentTypeFormProps) {
const isEditing = !!agentType;
const [activeTab, setActiveTab] = useState('basic');
const [expertiseInput, setExpertiseInput] = useState('');
// Always use create schema for validation - editing requires all fields too
const form = useForm<AgentTypeCreateFormValues>({
resolver: zodResolver(agentTypeCreateSchema),
defaultValues: agentType
? {
name: agentType.name,
slug: agentType.slug,
description: agentType.description,
expertise: agentType.expertise,
personality_prompt: agentType.personality_prompt,
primary_model: agentType.primary_model,
fallback_models: agentType.fallback_models,
model_params: (agentType.model_params ?? {
temperature: 0.7,
max_tokens: 8192,
top_p: 0.95,
}) as AgentTypeCreateFormValues['model_params'],
mcp_servers: agentType.mcp_servers,
tool_permissions: agentType.tool_permissions,
is_active: agentType.is_active,
}
: defaultAgentTypeValues,
});
const {
register,
control,
handleSubmit,
watch,
setValue,
formState: { errors },
} = form;
const watchName = watch('name');
const watchExpertise = watch('expertise') || [];
const watchMcpServers = watch('mcp_servers') || [];
// Auto-generate slug from name for new agent types
useEffect(() => {
if (!isEditing && watchName) {
const slug = generateSlug(watchName);
setValue('slug', slug, { shouldValidate: true });
}
}, [watchName, isEditing, setValue]);
const handleAddExpertise = () => {
if (expertiseInput.trim()) {
const newExpertise = expertiseInput.trim().toLowerCase();
if (!watchExpertise.includes(newExpertise)) {
setValue('expertise', [...watchExpertise, newExpertise]);
}
setExpertiseInput('');
}
};
const handleRemoveExpertise = (skill: string) => {
setValue(
'expertise',
watchExpertise.filter((e) => e !== skill)
);
};
const handleMcpServerToggle = (serverId: string, checked: boolean) => {
if (checked) {
setValue('mcp_servers', [...watchMcpServers, serverId]);
} else {
setValue(
'mcp_servers',
watchMcpServers.filter((s) => s !== serverId)
);
}
};
return (
<form onSubmit={handleSubmit(onSubmit)} className={className}>
{/* Header */}
<div className="mb-6 flex items-center gap-4">
<Button type="button" variant="ghost" size="icon" onClick={onCancel}>
<ArrowLeft className="h-4 w-4" />
<span className="sr-only">Go back</span>
</Button>
<div className="flex-1">
<h1 className="text-3xl font-bold">
{isEditing ? 'Edit Agent Type' : 'Create Agent Type'}
</h1>
<p className="text-muted-foreground">
{isEditing
? 'Modify agent type configuration'
: 'Define a new agent type template'}
</p>
</div>
<div className="flex gap-2">
<Button type="button" variant="outline" onClick={onCancel} disabled={isSubmitting}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
<Save className="mr-2 h-4 w-4" />
{isSubmitting ? 'Saving...' : isEditing ? 'Save Changes' : 'Create'}
</Button>
</div>
</div>
{/* Tabbed Form */}
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="basic">
<FileText className="mr-2 h-4 w-4" />
Basic Info
</TabsTrigger>
<TabsTrigger value="model">
<Cpu className="mr-2 h-4 w-4" />
Model
</TabsTrigger>
<TabsTrigger value="permissions">
<Shield className="mr-2 h-4 w-4" />
Permissions
</TabsTrigger>
<TabsTrigger value="personality">
<MessageSquare className="mr-2 h-4 w-4" />
Personality
</TabsTrigger>
</TabsList>
{/* Basic Info Tab */}
<TabsContent value="basic" className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Basic Information</CardTitle>
<CardDescription>
Define the agent type name, description, and expertise areas
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="name">
Name <span className="text-destructive">*</span>
</Label>
<Input
id="name"
placeholder="e.g., Software Architect"
{...register('name')}
aria-invalid={!!errors.name}
aria-describedby={errors.name ? 'name-error' : undefined}
/>
{errors.name && (
<p id="name-error" className="text-sm text-destructive" role="alert">
{errors.name.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="slug">
Slug <span className="text-destructive">*</span>
</Label>
<Input
id="slug"
placeholder="e.g., software-architect"
{...register('slug')}
aria-invalid={!!errors.slug}
aria-describedby={errors.slug ? 'slug-error' : undefined}
/>
{errors.slug && (
<p id="slug-error" className="text-sm text-destructive" role="alert">
{errors.slug.message}
</p>
)}
<p className="text-xs text-muted-foreground">
URL-friendly identifier (auto-generated from name)
</p>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="status">Status</Label>
<Controller
name="is_active"
control={control}
render={({ field }) => (
<Select
value={field.value ? 'active' : 'inactive'}
onValueChange={(val) => field.onChange(val === 'active')}
>
<SelectTrigger id="status">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="inactive">Inactive / Draft</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
placeholder="Describe what this agent type does..."
rows={3}
{...register('description')}
aria-invalid={!!errors.description}
aria-describedby={errors.description ? 'description-error' : undefined}
/>
{errors.description && (
<p id="description-error" className="text-sm text-destructive" role="alert">
{errors.description.message}
</p>
)}
</div>
<div className="space-y-2">
<Label>Expertise Areas</Label>
<p className="text-sm text-muted-foreground">
Add skills and areas of expertise
</p>
<div className="flex gap-2">
<Input
placeholder="e.g., System Design"
value={expertiseInput}
onChange={(e) => setExpertiseInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAddExpertise();
}
}}
/>
<Button type="button" variant="outline" onClick={handleAddExpertise}>
Add
</Button>
</div>
<div className="flex flex-wrap gap-2 pt-2">
{watchExpertise.map((skill) => (
<Badge key={skill} variant="secondary" className="gap-1">
{skill}
<button
type="button"
className="ml-1 rounded-full hover:bg-muted"
onClick={() => handleRemoveExpertise(skill)}
aria-label={`Remove ${skill}`}
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
</div>
</CardContent>
</Card>
</TabsContent>
{/* Model Configuration Tab */}
<TabsContent value="model" className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Model Selection</CardTitle>
<CardDescription>
Choose the AI models that power this agent type
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="primary_model">
Primary Model <span className="text-destructive">*</span>
</Label>
<Controller
name="primary_model"
control={control}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger id="primary_model">
<SelectValue placeholder="Select model" />
</SelectTrigger>
<SelectContent>
{AVAILABLE_MODELS.map((model) => (
<SelectItem key={model.value} value={model.value}>
{model.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
{errors.primary_model && (
<p className="text-sm text-destructive" role="alert">
{errors.primary_model.message}
</p>
)}
<p className="text-xs text-muted-foreground">
Main model used for this agent
</p>
</div>
<div className="space-y-2">
<Label htmlFor="fallback_model">Fallover Model</Label>
<Controller
name="fallback_models"
control={control}
render={({ field }) => (
<Select
value={field.value?.[0] || ''}
onValueChange={(val) => field.onChange([val])}
>
<SelectTrigger id="fallback_model">
<SelectValue placeholder="Select model" />
</SelectTrigger>
<SelectContent>
{AVAILABLE_MODELS.map((model) => (
<SelectItem key={model.value} value={model.value}>
{model.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<p className="text-xs text-muted-foreground">
Backup model if primary is unavailable
</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Sliders className="h-5 w-5" />
Model Parameters
</CardTitle>
<CardDescription>Fine-tune the model behavior</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-6 md:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="temperature">Temperature</Label>
<Controller
name="model_params.temperature"
control={control}
render={({ field }) => (
<Input
id="temperature"
type="number"
step="0.1"
min="0"
max="2"
value={field.value ?? 0.7}
onChange={(e) => field.onChange(parseFloat(e.target.value))}
/>
)}
/>
<p className="text-xs text-muted-foreground">
0 = deterministic, 2 = creative
</p>
</div>
<div className="space-y-2">
<Label htmlFor="max_tokens">Max Tokens</Label>
<Controller
name="model_params.max_tokens"
control={control}
render={({ field }) => (
<Input
id="max_tokens"
type="number"
step="1024"
min="1024"
max="128000"
value={field.value ?? 8192}
onChange={(e) => field.onChange(parseInt(e.target.value, 10))}
/>
)}
/>
<p className="text-xs text-muted-foreground">Maximum response length</p>
</div>
<div className="space-y-2">
<Label htmlFor="top_p">Top P</Label>
<Controller
name="model_params.top_p"
control={control}
render={({ field }) => (
<Input
id="top_p"
type="number"
step="0.05"
min="0"
max="1"
value={field.value ?? 0.95}
onChange={(e) => field.onChange(parseFloat(e.target.value))}
/>
)}
/>
<p className="text-xs text-muted-foreground">Nucleus sampling threshold</p>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
{/* MCP Permissions Tab */}
<TabsContent value="permissions" className="space-y-6">
<Card>
<CardHeader>
<CardTitle>MCP Server Permissions</CardTitle>
<CardDescription>
Configure which MCP servers this agent can access
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{AVAILABLE_MCP_SERVERS.map((server) => (
<div key={server.id} className="rounded-lg border p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Checkbox
id={`mcp-${server.id}`}
checked={watchMcpServers.includes(server.id)}
onCheckedChange={(checked) =>
handleMcpServerToggle(server.id, checked === true)
}
/>
<div>
<Label htmlFor={`mcp-${server.id}`} className="font-medium">
{server.name}
</Label>
<p className="text-sm text-muted-foreground">{server.description}</p>
</div>
</div>
</div>
</div>
))}
</CardContent>
</Card>
</TabsContent>
{/* Personality Prompt Tab */}
<TabsContent value="personality" className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Personality Prompt</CardTitle>
<CardDescription>
Define the agent&apos;s personality, behavior, and communication style. This
prompt shapes how the agent approaches tasks and interacts.
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<Textarea
id="personality_prompt"
placeholder="You are a..."
rows={15}
className="font-mono text-sm"
{...register('personality_prompt')}
aria-invalid={!!errors.personality_prompt}
aria-describedby={
errors.personality_prompt ? 'personality_prompt-error' : undefined
}
/>
{errors.personality_prompt && (
<p
id="personality_prompt-error"
className="text-sm text-destructive"
role="alert"
>
{errors.personality_prompt.message}
</p>
)}
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<span>
Character count: {watch('personality_prompt')?.length || 0}
</span>
<Separator orientation="vertical" className="h-4" />
<span className="text-xs">
Tip: Be specific about expertise, communication style, and decision-making
approach
</span>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</form>
);
}

View File

@@ -0,0 +1,248 @@
/**
* 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.
*/
'use client';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Separator } from '@/components/ui/separator';
import { Skeleton } from '@/components/ui/skeleton';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Bot, Plus, Search, Cpu } from 'lucide-react';
import type { AgentTypeResponse } from '@/lib/api/types/agentTypes';
interface AgentTypeListProps {
agentTypes: AgentTypeResponse[];
isLoading?: boolean;
searchQuery: string;
onSearchChange: (query: string) => void;
statusFilter: string;
onStatusFilterChange: (status: string) => void;
onSelect: (id: string) => void;
onCreate: () => void;
className?: string;
}
/**
* Status badge component for agent types
*/
function AgentTypeStatusBadge({ isActive }: { isActive: boolean }) {
if (isActive) {
return (
<Badge className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200" variant="outline">
Active
</Badge>
);
}
return (
<Badge className="bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200" variant="outline">
Inactive
</Badge>
);
}
/**
* Loading skeleton for agent type cards
*/
function AgentTypeCardSkeleton() {
return (
<Card className="h-[200px]">
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<Skeleton className="h-10 w-10 rounded-lg" />
<Skeleton className="h-5 w-16" />
</div>
<Skeleton className="mt-3 h-6 w-3/4" />
<Skeleton className="mt-2 h-4 w-full" />
</CardHeader>
<CardContent>
<div className="space-y-3">
<div className="flex flex-wrap gap-1">
<Skeleton className="h-5 w-16" />
<Skeleton className="h-5 w-20" />
<Skeleton className="h-5 w-14" />
</div>
<Separator />
<div className="flex items-center justify-between">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-20" />
</div>
</div>
</CardContent>
</Card>
);
}
/**
* Extract model display name from model ID
*/
function getModelDisplayName(modelId: string): string {
const parts = modelId.split('-');
if (parts.length >= 2) {
return parts.slice(0, 2).join(' ').replace('claude', 'Claude');
}
return modelId;
}
export function AgentTypeList({
agentTypes,
isLoading = false,
searchQuery,
onSearchChange,
statusFilter,
onStatusFilterChange,
onSelect,
onCreate,
className,
}: AgentTypeListProps) {
return (
<div className={className}>
{/* Header */}
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-3xl font-bold">Agent Types</h1>
<p className="text-muted-foreground">
Configure templates for spawning AI agent instances
</p>
</div>
<Button onClick={onCreate}>
<Plus className="mr-2 h-4 w-4" />
Create Agent Type
</Button>
</div>
{/* Filters */}
<div className="mb-6 flex flex-col gap-4 sm:flex-row">
<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
placeholder="Search agent types..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-9"
aria-label="Search agent types"
/>
</div>
<Select value={statusFilter} onValueChange={onStatusFilterChange}>
<SelectTrigger className="w-full sm:w-40" aria-label="Filter by status">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="inactive">Inactive</SelectItem>
</SelectContent>
</Select>
</div>
{/* Loading State */}
{isLoading && (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3, 4, 5, 6].map((i) => (
<AgentTypeCardSkeleton key={i} />
))}
</div>
)}
{/* Agent Type Grid */}
{!isLoading && agentTypes.length > 0 && (
<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>
<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>
))}
</div>
)}
{/* Empty State */}
{!isLoading && agentTypes.length === 0 && (
<div className="py-12 text-center">
<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'
? 'Try adjusting your search or filters'
: 'Create your first agent type to get started'}
</p>
{!searchQuery && statusFilter === 'all' && (
<Button onClick={onCreate} className="mt-4">
<Plus className="mr-2 h-4 w-4" />
Create Agent Type
</Button>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,9 @@
/**
* Agent Components
*
* Components for managing agent types and agent instances.
*/
export { AgentTypeForm } from './AgentTypeForm';
export { AgentTypeList } from './AgentTypeList';
export { AgentTypeDetail } from './AgentTypeDetail';