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,
|
type AgentTypeCreateFormValues,
|
||||||
AVAILABLE_MODELS,
|
AVAILABLE_MODELS,
|
||||||
AVAILABLE_MCP_SERVERS,
|
AVAILABLE_MCP_SERVERS,
|
||||||
|
AGENT_TYPE_CATEGORIES,
|
||||||
defaultAgentTypeValues,
|
defaultAgentTypeValues,
|
||||||
generateSlug,
|
generateSlug,
|
||||||
} from '@/lib/validations/agentType';
|
} from '@/lib/validations/agentType';
|
||||||
@@ -57,6 +58,13 @@ const TAB_FIELD_MAPPING = {
|
|||||||
description: 'basic',
|
description: 'basic',
|
||||||
expertise: 'basic',
|
expertise: 'basic',
|
||||||
is_active: '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',
|
primary_model: 'model',
|
||||||
fallback_models: 'model',
|
fallback_models: 'model',
|
||||||
model_params: 'model',
|
model_params: 'model',
|
||||||
@@ -121,6 +129,8 @@ export function AgentTypeForm({
|
|||||||
const isEditing = !!agentType;
|
const isEditing = !!agentType;
|
||||||
const [activeTab, setActiveTab] = useState('basic');
|
const [activeTab, setActiveTab] = useState('basic');
|
||||||
const [expertiseInput, setExpertiseInput] = useState('');
|
const [expertiseInput, setExpertiseInput] = useState('');
|
||||||
|
const [typicalTaskInput, setTypicalTaskInput] = useState('');
|
||||||
|
const [collaborationHintInput, setCollaborationHintInput] = useState('');
|
||||||
|
|
||||||
// Memoize initial values transformation
|
// Memoize initial values transformation
|
||||||
const initialValues = useMemo(() => transformAgentTypeToFormValues(agentType), [agentType]);
|
const initialValues = useMemo(() => transformAgentTypeToFormValues(agentType), [agentType]);
|
||||||
@@ -151,6 +161,10 @@ export function AgentTypeForm({
|
|||||||
const watchExpertise = watch('expertise') || [];
|
const watchExpertise = watch('expertise') || [];
|
||||||
/* istanbul ignore next -- defensive fallback, mcp_servers always has default */
|
/* istanbul ignore next -- defensive fallback, mcp_servers always has default */
|
||||||
const watchMcpServers = watch('mcp_servers') || [];
|
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)
|
// Reset form when agentType changes (e.g., switching to edit mode)
|
||||||
useEffect(() => {
|
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
|
// Handle form submission with validation
|
||||||
const onFormSubmit = useCallback(
|
const onFormSubmit = useCallback(
|
||||||
(e: React.FormEvent<HTMLFormElement>) => {
|
(e: React.FormEvent<HTMLFormElement>) => {
|
||||||
@@ -383,6 +431,188 @@ export function AgentTypeForm({
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
</TabsContent>
|
||||||
|
|
||||||
{/* Model Configuration Tab */}
|
{/* Model Configuration Tab */}
|
||||||
|
|||||||
@@ -199,7 +199,8 @@ describe('AgentTypeForm', () => {
|
|||||||
|
|
||||||
const expertiseInput = screen.getByPlaceholderText(/e.g., system design/i);
|
const expertiseInput = screen.getByPlaceholderText(/e.g., system design/i);
|
||||||
await user.type(expertiseInput, 'new skill');
|
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();
|
expect(screen.getByText('new skill')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -461,7 +462,8 @@ describe('AgentTypeForm', () => {
|
|||||||
// Agent type already has 'system design'
|
// Agent type already has 'system design'
|
||||||
const expertiseInput = screen.getByPlaceholderText(/e.g., system design/i);
|
const expertiseInput = screen.getByPlaceholderText(/e.g., system design/i);
|
||||||
await user.type(expertiseInput, 'system design');
|
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
|
// Should still only have one 'system design' badge
|
||||||
const badges = screen.getAllByText('system design');
|
const badges = screen.getAllByText('system design');
|
||||||
@@ -472,7 +474,8 @@ describe('AgentTypeForm', () => {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<AgentTypeForm {...defaultProps} />);
|
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);
|
await user.click(addButton);
|
||||||
|
|
||||||
// No badges should be added
|
// No badges should be added
|
||||||
@@ -485,7 +488,8 @@ describe('AgentTypeForm', () => {
|
|||||||
|
|
||||||
const expertiseInput = screen.getByPlaceholderText(/e.g., system design/i);
|
const expertiseInput = screen.getByPlaceholderText(/e.g., system design/i);
|
||||||
await user.type(expertiseInput, 'API Design');
|
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();
|
expect(screen.getByText('api design')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -496,7 +500,8 @@ describe('AgentTypeForm', () => {
|
|||||||
|
|
||||||
const expertiseInput = screen.getByPlaceholderText(/e.g., system design/i);
|
const expertiseInput = screen.getByPlaceholderText(/e.g., system design/i);
|
||||||
await user.type(expertiseInput, ' testing ');
|
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();
|
expect(screen.getByText('testing')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -509,7 +514,8 @@ describe('AgentTypeForm', () => {
|
|||||||
/e.g., system design/i
|
/e.g., system design/i
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
await user.type(expertiseInput, 'new skill');
|
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('');
|
expect(expertiseInput.value).toBe('');
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user