feat(agents): add category and display fields to AgentTypeForm

Add new "Category & Display" card in Basic Info tab with:
- Category dropdown to select agent category
- Sort order input for display ordering
- Icon text input with Lucide icon name
- Color picker with hex input and visual color selector
- Typical tasks tag input for agent capabilities
- Collaboration hints tag input for agent relationships

Updates include:
- TAB_FIELD_MAPPING with new field mappings
- State and handlers for typical_tasks and collaboration_hints
- Fix tests to use getAllByRole for multiple Add buttons

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-06 16:21:28 +01:00
parent 5717bffd63
commit 93cc37224c
2 changed files with 242 additions and 6 deletions

View File

@@ -36,6 +36,7 @@ import {
type AgentTypeCreateFormValues,
AVAILABLE_MODELS,
AVAILABLE_MCP_SERVERS,
AGENT_TYPE_CATEGORIES,
defaultAgentTypeValues,
generateSlug,
} from '@/lib/validations/agentType';
@@ -57,6 +58,13 @@ const TAB_FIELD_MAPPING = {
description: 'basic',
expertise: 'basic',
is_active: 'basic',
// Category and display fields
category: 'basic',
icon: 'basic',
color: 'basic',
sort_order: 'basic',
typical_tasks: 'basic',
collaboration_hints: 'basic',
primary_model: 'model',
fallback_models: 'model',
model_params: 'model',
@@ -121,6 +129,8 @@ export function AgentTypeForm({
const isEditing = !!agentType;
const [activeTab, setActiveTab] = useState('basic');
const [expertiseInput, setExpertiseInput] = useState('');
const [typicalTaskInput, setTypicalTaskInput] = useState('');
const [collaborationHintInput, setCollaborationHintInput] = useState('');
// Memoize initial values transformation
const initialValues = useMemo(() => transformAgentTypeToFormValues(agentType), [agentType]);
@@ -151,6 +161,10 @@ export function AgentTypeForm({
const watchExpertise = watch('expertise') || [];
/* istanbul ignore next -- defensive fallback, mcp_servers always has default */
const watchMcpServers = watch('mcp_servers') || [];
/* istanbul ignore next -- defensive fallback, typical_tasks always has default */
const watchTypicalTasks = watch('typical_tasks') || [];
/* istanbul ignore next -- defensive fallback, collaboration_hints always has default */
const watchCollaborationHints = watch('collaboration_hints') || [];
// Reset form when agentType changes (e.g., switching to edit mode)
useEffect(() => {
@@ -196,6 +210,40 @@ export function AgentTypeForm({
}
};
const handleAddTypicalTask = () => {
if (typicalTaskInput.trim()) {
const newTask = typicalTaskInput.trim();
if (!watchTypicalTasks.includes(newTask)) {
setValue('typical_tasks', [...watchTypicalTasks, newTask]);
}
setTypicalTaskInput('');
}
};
const handleRemoveTypicalTask = (task: string) => {
setValue(
'typical_tasks',
watchTypicalTasks.filter((t) => t !== task)
);
};
const handleAddCollaborationHint = () => {
if (collaborationHintInput.trim()) {
const newHint = collaborationHintInput.trim().toLowerCase();
if (!watchCollaborationHints.includes(newHint)) {
setValue('collaboration_hints', [...watchCollaborationHints, newHint]);
}
setCollaborationHintInput('');
}
};
const handleRemoveCollaborationHint = (hint: string) => {
setValue(
'collaboration_hints',
watchCollaborationHints.filter((h) => h !== hint)
);
};
// Handle form submission with validation
const onFormSubmit = useCallback(
(e: React.FormEvent<HTMLFormElement>) => {
@@ -383,6 +431,188 @@ export function AgentTypeForm({
</div>
</CardContent>
</Card>
{/* Category & Display Card */}
<Card>
<CardHeader>
<CardTitle>Category & Display</CardTitle>
<CardDescription>
Organize and customize how this agent type appears in the UI
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="category">Category</Label>
<Controller
name="category"
control={control}
render={({ field }) => (
<Select
value={field.value ?? ''}
onValueChange={(val) => field.onChange(val || null)}
>
<SelectTrigger id="category">
<SelectValue placeholder="Select category" />
</SelectTrigger>
<SelectContent>
{AGENT_TYPE_CATEGORIES.map((cat) => (
<SelectItem key={cat.value} value={cat.value}>
{cat.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<p className="text-xs text-muted-foreground">
Group agents by their primary role
</p>
</div>
<div className="space-y-2">
<Label htmlFor="sort_order">Sort Order</Label>
<Input
id="sort_order"
type="number"
min={0}
max={1000}
{...register('sort_order', { valueAsNumber: true })}
aria-invalid={!!errors.sort_order}
/>
{errors.sort_order && (
<p className="text-sm text-destructive" role="alert">
{errors.sort_order.message}
</p>
)}
<p className="text-xs text-muted-foreground">Display order within category</p>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="icon">Icon</Label>
<Input
id="icon"
placeholder="e.g., git-branch"
{...register('icon')}
aria-invalid={!!errors.icon}
/>
{errors.icon && (
<p className="text-sm text-destructive" role="alert">
{errors.icon.message}
</p>
)}
<p className="text-xs text-muted-foreground">Lucide icon name for UI display</p>
</div>
<div className="space-y-2">
<Label htmlFor="color">Color</Label>
<div className="flex gap-2">
<Input
id="color"
placeholder="#3B82F6"
{...register('color')}
aria-invalid={!!errors.color}
className="flex-1"
/>
<Controller
name="color"
control={control}
render={({ field }) => (
<input
type="color"
value={field.value ?? '#3B82F6'}
onChange={(e) => field.onChange(e.target.value)}
className="h-9 w-9 cursor-pointer rounded border"
/>
)}
/>
</div>
{errors.color && (
<p className="text-sm text-destructive" role="alert">
{errors.color.message}
</p>
)}
<p className="text-xs text-muted-foreground">Hex color for visual distinction</p>
</div>
</div>
<Separator />
<div className="space-y-2">
<Label>Typical Tasks</Label>
<p className="text-sm text-muted-foreground">Tasks this agent type excels at</p>
<div className="flex gap-2">
<Input
placeholder="e.g., Design system architecture"
value={typicalTaskInput}
onChange={(e) => setTypicalTaskInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAddTypicalTask();
}
}}
/>
<Button type="button" variant="outline" onClick={handleAddTypicalTask}>
Add
</Button>
</div>
<div className="flex flex-wrap gap-2 pt-2">
{watchTypicalTasks.map((task) => (
<Badge key={task} variant="secondary" className="gap-1">
{task}
<button
type="button"
className="ml-1 rounded-full hover:bg-muted"
onClick={() => handleRemoveTypicalTask(task)}
aria-label={`Remove ${task}`}
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
</div>
<div className="space-y-2">
<Label>Collaboration Hints</Label>
<p className="text-sm text-muted-foreground">
Agent slugs that work well with this type
</p>
<div className="flex gap-2">
<Input
placeholder="e.g., backend-engineer"
value={collaborationHintInput}
onChange={(e) => setCollaborationHintInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAddCollaborationHint();
}
}}
/>
<Button type="button" variant="outline" onClick={handleAddCollaborationHint}>
Add
</Button>
</div>
<div className="flex flex-wrap gap-2 pt-2">
{watchCollaborationHints.map((hint) => (
<Badge key={hint} variant="outline" className="gap-1">
{hint}
<button
type="button"
className="ml-1 rounded-full hover:bg-muted"
onClick={() => handleRemoveCollaborationHint(hint)}
aria-label={`Remove ${hint}`}
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
</div>
</CardContent>
</Card>
</TabsContent>
{/* Model Configuration Tab */}

View File

@@ -199,7 +199,8 @@ describe('AgentTypeForm', () => {
const expertiseInput = screen.getByPlaceholderText(/e.g., system design/i);
await user.type(expertiseInput, 'new skill');
await user.click(screen.getByRole('button', { name: /^add$/i }));
// Click the first "Add" button (for expertise)
await user.click(screen.getAllByRole('button', { name: /^add$/i })[0]);
expect(screen.getByText('new skill')).toBeInTheDocument();
});
@@ -461,7 +462,8 @@ describe('AgentTypeForm', () => {
// Agent type already has 'system design'
const expertiseInput = screen.getByPlaceholderText(/e.g., system design/i);
await user.type(expertiseInput, 'system design');
await user.click(screen.getByRole('button', { name: /^add$/i }));
// Click the first "Add" button (for expertise)
await user.click(screen.getAllByRole('button', { name: /^add$/i })[0]);
// Should still only have one 'system design' badge
const badges = screen.getAllByText('system design');
@@ -472,7 +474,8 @@ describe('AgentTypeForm', () => {
const user = userEvent.setup();
render(<AgentTypeForm {...defaultProps} />);
const addButton = screen.getByRole('button', { name: /^add$/i });
// Click the first "Add" button (for expertise)
const addButton = screen.getAllByRole('button', { name: /^add$/i })[0];
await user.click(addButton);
// No badges should be added
@@ -485,7 +488,8 @@ describe('AgentTypeForm', () => {
const expertiseInput = screen.getByPlaceholderText(/e.g., system design/i);
await user.type(expertiseInput, 'API Design');
await user.click(screen.getByRole('button', { name: /^add$/i }));
// Click the first "Add" button (for expertise)
await user.click(screen.getAllByRole('button', { name: /^add$/i })[0]);
expect(screen.getByText('api design')).toBeInTheDocument();
});
@@ -496,7 +500,8 @@ describe('AgentTypeForm', () => {
const expertiseInput = screen.getByPlaceholderText(/e.g., system design/i);
await user.type(expertiseInput, ' testing ');
await user.click(screen.getByRole('button', { name: /^add$/i }));
// Click the first "Add" button (for expertise)
await user.click(screen.getAllByRole('button', { name: /^add$/i })[0]);
expect(screen.getByText('testing')).toBeInTheDocument();
});
@@ -509,7 +514,8 @@ describe('AgentTypeForm', () => {
/e.g., system design/i
) as HTMLInputElement;
await user.type(expertiseInput, 'new skill');
await user.click(screen.getByRole('button', { name: /^add$/i }));
// Click the first "Add" button (for expertise)
await user.click(screen.getAllByRole('button', { name: /^add$/i })[0]);
expect(expertiseInput.value).toBe('');
});