forked from cardosofelipe/fast-next-template
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:
@@ -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 */}
|
||||
|
||||
@@ -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('');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user