feat: Add intelligent auto-router and enhanced integrations

- Add intelligent-router.sh hook for automatic agent routing
- Add AUTO-TRIGGER-SUMMARY.md documentation
- Add FINAL-INTEGRATION-SUMMARY.md documentation
- Complete Prometheus integration (6 commands + 4 tools)
- Complete Dexto integration (12 commands + 5 tools)
- Enhanced Ralph with access to all agents
- Fix /clawd command (removed disable-model-invocation)
- Update hooks.json to v5 with intelligent routing
- 291 total skills now available
- All 21 commands with automatic routing

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
admin
2026-01-28 00:27:56 +04:00
Unverified
parent 3b128ba3bd
commit b52318eeae
1724 changed files with 351216 additions and 0 deletions

View File

@@ -0,0 +1,643 @@
import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react';
import { useNavigate } from '@tanstack/react-router';
import { useQueryClient } from '@tanstack/react-query';
import { queryKeys } from '@/lib/queryKeys.js';
import { cn } from '@/lib/utils';
import {
useAgents,
useAgentPath,
useSwitchAgent,
useInstallAgent,
useUninstallAgent,
} from '../hooks/useAgents';
import { useRecentAgentsStore } from '@/lib/stores/recentAgentsStore';
import { useSessionStore } from '@/lib/stores/sessionStore';
import { Button } from '../ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '../ui/dropdown-menu';
import {
ChevronDown,
Check,
DownloadCloud,
Sparkles,
Trash2,
BadgeCheck,
Plus,
} from 'lucide-react';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import CreateAgentModal from './CreateAgentModal';
import { useAnalytics } from '@/lib/analytics/index.js';
type AgentItem = {
id: string;
name: string;
description: string;
author?: string;
tags?: string[];
type: 'builtin' | 'custom';
};
type AgentsResponse = {
installed: AgentItem[];
available: AgentItem[];
current: { id: string | null; name: string | null };
};
type AgentSelectorProps = {
mode?: 'default' | 'badge' | 'title';
};
export default function AgentSelector({ mode = 'default' }: AgentSelectorProps) {
const navigate = useNavigate();
const currentSessionId = useSessionStore((s) => s.currentSessionId);
const analytics = useAnalytics();
const analyticsRef = useRef(analytics);
const recentAgents = useRecentAgentsStore((state) => state.recentAgents);
const addToRecentAgents = useRecentAgentsStore((state) => state.addRecentAgent);
const [switching, setSwitching] = useState(false);
const [open, setOpen] = useState(false);
const [createModalOpen, setCreateModalOpen] = useState(false);
// Keep analytics ref up to date to avoid stale closure issues
useEffect(() => {
analyticsRef.current = analytics;
}, [analytics]);
const queryClient = useQueryClient();
// Invalidate all agent-specific queries when switching agents
// This replaces the DOM event pattern (dexto:agentSwitched)
const invalidateAgentSpecificQueries = useCallback(() => {
queryClient.invalidateQueries({ queryKey: queryKeys.sessions.all });
queryClient.invalidateQueries({ queryKey: ['sessions', 'history'] }); // All session histories
queryClient.invalidateQueries({ queryKey: queryKeys.memories.all });
queryClient.invalidateQueries({ queryKey: queryKeys.servers.all });
queryClient.invalidateQueries({ queryKey: ['servers', 'tools'] });
queryClient.invalidateQueries({ queryKey: queryKeys.resources.all });
queryClient.invalidateQueries({ queryKey: ['greeting'] }); // Hierarchical invalidation
queryClient.invalidateQueries({ queryKey: queryKeys.prompts.all });
queryClient.invalidateQueries({ queryKey: queryKeys.agent.config }); // Agent config (CustomizePanel)
}, [queryClient]);
// Check if an agent path is from the global ~/.dexto directory
// Global pattern: /Users/<user>/.dexto/agents or /home/<user>/.dexto/agents
// Also handles Windows: C:\Users\<user>\.dexto\agents
const isGlobalAgent = useCallback((path: string): boolean => {
// Match paths where .dexto appears within first 4 segments (home directory level)
// POSIX: /Users/username/.dexto/agents/... (index 2)
// Windows: C:/Users/username/.dexto/agents/... (index 3, drive letter adds extra segment)
// Project: /Users/username/Projects/my-project/.dexto/agents/... (5+ segments)
// Normalize Windows backslashes to forward slashes for consistent parsing
const normalized = path.replace(/\\/g, '/');
const segments = normalized.split('/').filter(Boolean);
const dextoIndex = segments.findIndex((s) => s === '.dexto');
return dextoIndex >= 0 && dextoIndex <= 3;
}, []);
// Fetch agents list and path using typed hooks
const { data: agentsData, isLoading: agentsLoading, refetch: refetchAgents } = useAgents();
const { data: currentAgentPathData } = useAgentPath();
const installed = useMemo(() => agentsData?.installed || [], [agentsData?.installed]);
const available = useMemo(() => agentsData?.available || [], [agentsData?.available]);
const currentId = agentsData?.current.id || null;
const currentAgentPath = currentAgentPathData ?? null;
// Agent mutations using typed hooks
const switchAgentMutation = useSwitchAgent();
const installAgentMutation = useInstallAgent();
const deleteAgentMutation = useUninstallAgent();
// Sync current agent path to recent agents when it loads
useEffect(() => {
if (currentAgentPath?.path && currentAgentPath?.name) {
addToRecentAgents({
id: currentAgentPath.name,
name: currentAgentPath.name,
path: currentAgentPath.path,
});
}
}, [currentAgentPath, addToRecentAgents]);
const loading = agentsLoading;
const handleSwitch = useCallback(
async (agentId: string) => {
try {
setSwitching(true);
// Check if the agent exists in the installed list
const agent = installed.find((agent) => agent.id === agentId);
if (!agent) {
console.error(`Agent not found in installed list: ${agentId}`);
throw new Error(
`Agent '${agentId}' not found. Please refresh the agents list.`
);
}
// Capture current agent ID before switch
const fromAgentId = currentId;
await switchAgentMutation.mutateAsync({ id: agentId });
setOpen(false); // Close dropdown after successful switch
// Track agent switch using ref to avoid stale closure
analyticsRef.current.trackAgentSwitched({
fromAgentId,
toAgentId: agentId,
toAgentName: agent.name,
sessionId: currentSessionId || undefined,
});
// Invalidate all agent-specific queries
invalidateAgentSpecificQueries();
// Navigate back to home after switching agents
// The ChatApp component will automatically handle returnToWelcome when sessionId prop is undefined
navigate({ to: '/' });
} catch (err) {
console.error(
`Switch agent failed: ${err instanceof Error ? err.message : String(err)}`
);
const errorMessage = err instanceof Error ? err.message : 'Failed to switch agent';
alert(`Failed to switch agent: ${errorMessage}`);
} finally {
setSwitching(false);
}
},
[
installed,
navigate,
currentId,
currentSessionId,
switchAgentMutation,
invalidateAgentSpecificQueries,
]
);
const handleSwitchToPath = useCallback(
async (agent: { id: string; name: string; path: string }) => {
try {
setSwitching(true);
// Capture current agent ID before switch
const fromAgentId = currentId;
await switchAgentMutation.mutateAsync({ id: agent.id, path: agent.path });
setOpen(false); // Close dropdown after successful switch
// Add to recent agents
addToRecentAgents(agent);
// Track agent switch using ref to avoid stale closure
analyticsRef.current.trackAgentSwitched({
fromAgentId,
toAgentId: agent.id,
toAgentName: agent.name,
sessionId: currentSessionId || undefined,
});
// Invalidate all agent-specific queries
invalidateAgentSpecificQueries();
// Navigate back to home after switching agents
// The ChatApp component will automatically handle returnToWelcome when sessionId prop is undefined
navigate({ to: '/' });
} catch (err) {
console.error(
`Switch agent failed: ${err instanceof Error ? err.message : String(err)}`
);
const errorMessage = err instanceof Error ? err.message : 'Failed to switch agent';
alert(`Failed to switch agent: ${errorMessage}`);
} finally {
setSwitching(false);
}
},
[
addToRecentAgents,
navigate,
currentId,
currentSessionId,
switchAgentMutation,
invalidateAgentSpecificQueries,
]
);
const handleInstall = useCallback(
async (agentId: string) => {
try {
setSwitching(true);
// Capture current agent ID before operations
const fromAgentId = currentId;
// Step 1: Install the agent
await installAgentMutation.mutateAsync({ id: agentId });
// Step 2: Refetch agents list to ensure cache has fresh data
await queryClient.refetchQueries({ queryKey: queryKeys.agents.all });
// Step 3: Verify agent is now in installed list
const freshData = queryClient.getQueryData<AgentsResponse>(queryKeys.agents.all);
const agent = freshData?.installed.find((a) => a.id === agentId);
if (!agent) {
throw new Error(
`Agent '${agentId}' not found after installation. Please refresh.`
);
}
// Step 4: Switch to the newly installed agent
await switchAgentMutation.mutateAsync({ id: agentId });
setOpen(false);
// Step 5: Track the switch analytics
analyticsRef.current.trackAgentSwitched({
fromAgentId,
toAgentId: agentId,
toAgentName: agent.name,
sessionId: currentSessionId || undefined,
});
// Step 6: Invalidate all agent-specific queries
invalidateAgentSpecificQueries();
// Step 7: Navigate to home
// The ChatApp component will automatically handle returnToWelcome when sessionId prop is undefined
navigate({ to: '/' });
} catch (err) {
console.error(
`Install/switch agent failed: ${err instanceof Error ? err.message : String(err)}`
);
const errorMessage =
err instanceof Error ? err.message : 'Failed to install/switch agent';
alert(`Failed to install/switch agent: ${errorMessage}`);
} finally {
setSwitching(false);
}
},
[
navigate,
currentId,
currentSessionId,
queryClient,
installAgentMutation,
switchAgentMutation,
invalidateAgentSpecificQueries,
]
);
const handleDelete = useCallback(
async (agent: AgentItem, e: React.MouseEvent) => {
e.stopPropagation(); // Prevent triggering switch when clicking delete
if (!confirm(`Are you sure you want to delete the custom agent "${agent.name}"?`)) {
return;
}
try {
setSwitching(true);
await deleteAgentMutation.mutateAsync({ id: agent.id });
} catch (err) {
console.error(
`Delete agent failed: ${err instanceof Error ? err.message : String(err)}`
);
const errorMessage = err instanceof Error ? err.message : 'Failed to delete agent';
alert(`Failed to delete agent: ${errorMessage}`);
} finally {
setSwitching(false);
}
},
[deleteAgentMutation]
);
const currentLabel = useMemo(() => {
if (!currentId) return 'Choose Agent';
const match =
installed.find((agent) => agent.id === currentId) ||
available.find((agent) => agent.id === currentId);
return match?.name ?? currentId;
}, [available, currentId, installed]);
const handleAgentCreated = useCallback(
async (_agentName: string) => {
await refetchAgents();
},
[refetchAgents]
);
const getButtonClassName = (mode: string) => {
switch (mode) {
case 'badge':
// Teal text, transparent bg
return `h-9 px-4 text-lg font-medium rounded-lg bg-transparent text-teal-600 hover:bg-muted/50 hover:text-teal-700 dark:text-teal-400 dark:hover:text-teal-300 transition-colors min-w-[120px] max-w-[180px] md:min-w-[160px] md:max-w-[280px] lg:max-w-[400px] xl:max-w-[500px]`;
case 'title':
return `h-11 px-4 text-lg font-bold rounded-lg bg-gradient-to-r from-teal-500/30 to-teal-500/40 text-teal-600 hover:from-teal-500/50 hover:to-teal-500/60 hover:text-teal-700 focus-visible:ring-2 focus-visible:ring-teal-500/50 focus-visible:ring-offset-2 border border-teal-500/40 dark:text-teal-400 dark:hover:text-teal-300 dark:border-teal-400 transition-all duration-200 shadow-lg hover:shadow-xl`;
default:
return `h-10 px-3 text-sm rounded-lg bg-teal-500/40 text-teal-600 hover:bg-teal-500/50 hover:text-teal-700 focus-visible:ring-2 focus-visible:ring-teal-500/50 focus-visible:ring-offset-2 border border-teal-500/50 dark:text-teal-400 dark:hover:text-teal-300 dark:border-teal-400 transition-all duration-200 shadow-lg hover:shadow-xl`;
}
};
return (
<>
<DropdownMenu open={open} onOpenChange={setOpen}>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
variant={mode === 'badge' ? 'ghost' : 'default'}
size="sm"
className={getButtonClassName(mode)}
disabled={switching}
>
<div className="flex items-center justify-between w-full min-w-0">
{mode !== 'badge' && (
<Sparkles className="w-4 h-4 mr-2 flex-shrink-0" />
)}
<span
className={cn(
'truncate min-w-0',
mode === 'badge'
? 'flex-1 text-left'
: 'flex-1 text-center px-1'
)}
>
{switching
? 'Switching...'
: mode === 'title'
? `Agent: ${currentLabel}`
: currentLabel}
</span>
<ChevronDown className="w-4 h-4 ml-1.5 flex-shrink-0 opacity-60" />
</div>
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom">Select agent</TooltipContent>
</Tooltip>
<DropdownMenuContent
align="start"
className="w-80 max-h-96 overflow-y-auto shadow-xl border-border/30"
>
{loading && (
<DropdownMenuItem disabled className="text-center text-muted-foreground">
Loading agents...
</DropdownMenuItem>
)}
{!loading && (
<>
{/* Create New Agent Button */}
<DropdownMenuItem
onClick={() => {
setCreateModalOpen(true);
setOpen(false);
}}
disabled={switching}
className="cursor-pointer py-3 px-3 mx-1 my-1 bg-gradient-to-r from-primary/10 to-primary/5 hover:from-primary/15 hover:to-primary/10 border border-primary/20 hover:border-primary/30 transition-all rounded-md shadow-sm"
>
<div className="flex items-center gap-2 w-full">
<Plus className="w-4 h-4 text-primary" />
<span className="font-semibold text-primary">New Agent</span>
</div>
</DropdownMenuItem>
{/* Current Agent (if loaded from file and not in installed list) */}
{currentAgentPath &&
!installed.some((a) => a.id === currentAgentPath.name) && (
<>
<div className="px-3 py-2 mt-1 text-xs font-bold text-teal-600 dark:text-teal-400 uppercase tracking-wider border-b border-border/20">
Currently Active
</div>
<DropdownMenuItem
onClick={() =>
handleSwitchToPath({
id: currentAgentPath.name,
name: currentAgentPath.name,
path: currentAgentPath.path,
})
}
disabled={
switching || currentId === currentAgentPath.name
}
className="cursor-pointer py-3 px-3 hover:bg-muted/60 transition-colors rounded-md mx-1"
>
<div className="flex items-center justify-between w-full">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm truncate">
{currentAgentPath.name}
</span>
{currentId === currentAgentPath.name && (
<Check className="w-4 h-4 text-green-600 flex-shrink-0 animate-in fade-in duration-200" />
)}
</div>
<p className="text-xs text-muted-foreground mt-0.5 truncate">
Loaded from file
</p>
</div>
</div>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
{/* Recent Agents */}
{recentAgents.length > 0 && (
<>
<div className="px-3 py-2 mt-1 text-xs font-bold text-foreground/70 uppercase tracking-wider border-b border-border/20">
Recent
</div>
{recentAgents
.filter(
(ra) =>
!installed.some((a) => a.id === ra.id) &&
ra.id !== currentAgentPath?.name &&
!isGlobalAgent(ra.path) // Filter out global dexto directory agents
)
.slice(0, 3)
.map((agent) => (
<DropdownMenuItem
key={agent.path}
onClick={() => handleSwitchToPath(agent)}
disabled={switching || agent.id === currentId}
className="cursor-pointer py-3 px-3 hover:bg-muted/60 transition-colors rounded-md mx-1"
>
<div className="flex items-center justify-between w-full">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm truncate">
{agent.name}
</span>
{agent.id === currentId && (
<Check className="w-4 h-4 text-green-600 flex-shrink-0 animate-in fade-in duration-200" />
)}
</div>
<p
className="text-xs text-muted-foreground mt-0.5 truncate"
title={agent.path}
>
{agent.path}
</p>
</div>
</div>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
</>
)}
{/* Installed Custom Agents */}
{installed.filter((a) => a.type === 'custom').length > 0 && (
<>
<div className="px-3 py-2 mt-1 text-xs font-bold text-primary uppercase tracking-wider flex items-center gap-1 border-b border-border/20">
<BadgeCheck className="w-3 h-3" />
Custom Agents
</div>
{installed
.filter((a) => a.type === 'custom')
.map((agent) => (
<DropdownMenuItem
key={agent.id}
onClick={() => handleSwitch(agent.id)}
disabled={switching || agent.id === currentId}
className="cursor-pointer py-3 px-3 hover:bg-muted/60 transition-colors rounded-md mx-1"
>
<div className="flex items-center justify-between w-full gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm truncate">
{agent.name}
</span>
{agent.id === currentId && (
<Check className="w-4 h-4 text-green-600 flex-shrink-0 animate-in fade-in duration-200" />
)}
</div>
<p className="text-xs text-muted-foreground mt-0.5 truncate">
{agent.description}
</p>
{agent.author && (
<p className="text-xs text-muted-foreground/80 mt-0.5">
by {agent.author}
</p>
)}
</div>
<button
onClick={(e) => handleDelete(agent, e)}
disabled={switching}
className="p-1 hover:bg-red-100 dark:hover:bg-red-900/30 rounded transition-colors"
title="Delete custom agent"
>
<Trash2 className="w-4 h-4 text-red-600 dark:text-red-400" />
</button>
</div>
</DropdownMenuItem>
))}
</>
)}
{/* Installed Builtin Agents */}
{installed.filter((a) => a.type === 'builtin').length > 0 && (
<>
{installed.filter((a) => a.type === 'custom').length > 0 && (
<DropdownMenuSeparator />
)}
<div className="px-3 py-2 mt-1 text-xs font-bold text-foreground/70 uppercase tracking-wider border-b border-border/20">
Installed
</div>
{installed
.filter((a) => a.type === 'builtin')
.map((agent) => (
<DropdownMenuItem
key={agent.id}
onClick={() => handleSwitch(agent.id)}
disabled={switching || agent.id === currentId}
className="cursor-pointer py-3 px-3 hover:bg-muted/60 transition-colors rounded-md mx-1"
>
<div className="flex items-center justify-between w-full">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm truncate">
{agent.name}
</span>
{agent.id === currentId && (
<Check className="w-4 h-4 text-green-600 flex-shrink-0 animate-in fade-in duration-200" />
)}
</div>
<p className="text-xs text-muted-foreground mt-0.5 truncate">
{agent.description}
</p>
{agent.author && (
<p className="text-xs text-muted-foreground/80 mt-0.5">
by {agent.author}
</p>
)}
</div>
</div>
</DropdownMenuItem>
))}
</>
)}
{/* Available Builtin Agents */}
{available.filter((a) => a.type === 'builtin').length > 0 && (
<>
{installed.length > 0 && <DropdownMenuSeparator />}
<div className="px-3 py-2 mt-1 text-xs font-bold text-foreground/70 uppercase tracking-wider border-b border-border/20">
Available
</div>
{available
.filter((a) => a.type === 'builtin')
.map((agent) => (
<DropdownMenuItem
key={agent.id}
onClick={() => handleInstall(agent.id)}
disabled={switching}
className="cursor-pointer py-3 px-3 hover:bg-muted/60 transition-colors rounded-md mx-1"
>
<div className="flex items-center justify-between w-full">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm truncate">
{agent.name}
</span>
<DownloadCloud className="w-4 h-4 text-blue-600 flex-shrink-0" />
</div>
<p className="text-xs text-muted-foreground mt-0.5 truncate">
{agent.description}
</p>
{agent.author && (
<p className="text-xs text-muted-foreground/80 mt-0.5">
by {agent.author}
</p>
)}
</div>
</div>
</DropdownMenuItem>
))}
</>
)}
{!loading && installed.length === 0 && available.length === 0 && (
<DropdownMenuItem
disabled
className="text-center text-muted-foreground"
>
No agents found
</DropdownMenuItem>
)}
</>
)}
</DropdownMenuContent>
</DropdownMenu>
<CreateAgentModal
open={createModalOpen}
onOpenChange={setCreateModalOpen}
onAgentCreated={handleAgentCreated}
/>
</>
);
}

View File

@@ -0,0 +1,372 @@
import React, { useState } from 'react';
import { useCreateAgent, type CreateAgentPayload } from '../hooks/useAgents';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '../ui/dialog';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Textarea } from '../ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
import { AlertCircle, Loader2, Eye, EyeOff, Info } from 'lucide-react';
import { LLM_PROVIDERS } from '@dexto/core';
interface CreateAgentModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onAgentCreated?: (agentName: string) => void;
}
interface FormData {
id: string;
idManuallyEdited: boolean;
name: string;
description: string;
provider: string;
model: string;
apiKey: string;
systemPrompt: string;
}
const initialFormData: FormData = {
id: '',
idManuallyEdited: false,
name: '',
description: '',
provider: 'anthropic',
model: 'claude-sonnet-4-5-20250929',
apiKey: '',
systemPrompt: '',
};
// Convert name to a valid ID (lowercase, hyphens, no special chars)
function nameToId(name: string): string {
return name
.toLowerCase()
.trim()
.replace(/[^a-z0-9\s-]/g, '') // Remove special chars
.replace(/\s+/g, '-') // Spaces to hyphens
.replace(/-+/g, '-') // Multiple hyphens to single
.replace(/^-|-$/g, ''); // Trim leading/trailing hyphens
}
export default function CreateAgentModal({
open,
onOpenChange,
onAgentCreated,
}: CreateAgentModalProps) {
const [form, setForm] = useState<FormData>(initialFormData);
const [errors, setErrors] = useState<Record<string, string>>({});
const [createError, setCreateError] = useState<string | null>(null);
const [showApiKey, setShowApiKey] = useState(false);
const createAgentMutation = useCreateAgent();
const isCreating = createAgentMutation.isPending;
const updateField = (field: keyof FormData, value: string) => {
setForm((prev) => {
const next = { ...prev, [field]: value };
// Auto-generate ID from name if ID hasn't been manually edited
if (field === 'name' && !prev.idManuallyEdited) {
next.id = nameToId(value);
}
// Mark ID as manually edited if user types in it
if (field === 'id') {
next.idManuallyEdited = true;
}
return next;
});
if (errors[field]) {
setErrors((prev) => {
const next = { ...prev };
delete next[field];
return next;
});
}
};
const validateForm = (): boolean => {
const newErrors: Record<string, string> = {};
if (!form.id.trim()) {
newErrors.id = 'Required';
} else if (!/^[a-z0-9-]+$/.test(form.id)) {
newErrors.id = 'Lowercase letters, numbers, and hyphens only';
}
if (!form.name.trim()) {
newErrors.name = 'Required';
}
if (!form.description.trim()) {
newErrors.description = 'Required';
}
if (!form.provider) {
newErrors.provider = 'Required';
}
if (!form.model.trim()) {
newErrors.model = 'Required';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleCreate = async () => {
if (!validateForm()) return;
setCreateError(null);
const payload: CreateAgentPayload = {
id: form.id.trim(),
name: form.name.trim(),
description: form.description.trim(),
config: {
llm: {
provider: form.provider as CreateAgentPayload['config']['llm']['provider'],
model: form.model.trim(),
apiKey: form.apiKey.trim() || undefined,
},
...(form.systemPrompt.trim() && {
systemPrompt: {
contributors: [
{
id: 'primary',
type: 'static' as const,
priority: 0,
enabled: true,
content: form.systemPrompt.trim(),
},
],
},
}),
},
};
createAgentMutation.mutate(payload, {
onSuccess: (data) => {
setForm(initialFormData);
setErrors({});
onOpenChange(false);
if (onAgentCreated && data.id) {
onAgentCreated(data.id);
}
},
onError: (error: Error) => {
setCreateError(error.message);
},
});
};
const handleCancel = () => {
setForm(initialFormData);
setErrors({});
setCreateError(null);
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md max-h-[85vh] flex flex-col gap-0 p-0 overflow-hidden">
<DialogHeader className="px-5 pt-5 pb-4 border-b border-border/40">
<DialogTitle className="text-base">Create Agent</DialogTitle>
<DialogDescription className="text-sm">
Configure your new agent. Advanced options can be set after creation.
</DialogDescription>
</DialogHeader>
{/* Error */}
{createError && (
<div className="mx-5 mt-4 p-3 rounded-lg bg-destructive/10 border border-destructive/20 flex items-start gap-2">
<AlertCircle className="h-4 w-4 text-destructive shrink-0 mt-0.5" />
<p className="text-sm text-destructive">{createError}</p>
</div>
)}
{/* Form */}
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-5">
{/* Identity */}
<Section title="Identity">
<Field label="Name" required error={errors.name}>
<Input
value={form.name}
onChange={(e) => updateField('name', e.target.value)}
placeholder="My Agent"
aria-invalid={!!errors.name}
/>
</Field>
<Field
label="ID"
required
error={errors.id}
hint={!form.idManuallyEdited ? 'Auto-generated from name' : undefined}
>
<Input
value={form.id}
onChange={(e) => updateField('id', e.target.value)}
placeholder="my-agent"
aria-invalid={!!errors.id}
className="font-mono text-sm"
/>
</Field>
<Field label="Description" required error={errors.description}>
<Input
value={form.description}
onChange={(e) => updateField('description', e.target.value)}
placeholder="A helpful assistant for..."
aria-invalid={!!errors.description}
/>
</Field>
</Section>
{/* Model */}
<Section title="Language Model">
<div className="grid grid-cols-2 gap-3">
<Field label="Provider" required error={errors.provider}>
<Select
value={form.provider}
onValueChange={(value) => updateField('provider', value)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select provider..." />
</SelectTrigger>
<SelectContent>
{LLM_PROVIDERS.map((p) => (
<SelectItem key={p} value={p}>
{p.charAt(0).toUpperCase() +
p.slice(1).replace(/-/g, ' ')}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field label="Model" required error={errors.model}>
<Input
value={form.model}
onChange={(e) => updateField('model', e.target.value)}
placeholder="claude-sonnet-4-5-20250929"
aria-invalid={!!errors.model}
/>
</Field>
</div>
<Field label="API Key" hint="Leave empty to use environment variable">
<div className="relative">
<Input
type={showApiKey ? 'text' : 'password'}
value={form.apiKey}
onChange={(e) => updateField('apiKey', e.target.value)}
placeholder="$ANTHROPIC_API_KEY"
className="pr-9"
/>
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded hover:bg-muted/50 transition-colors"
aria-label={showApiKey ? 'Hide API key' : 'Show API key'}
>
{showApiKey ? (
<EyeOff className="h-4 w-4 text-muted-foreground" />
) : (
<Eye className="h-4 w-4 text-muted-foreground" />
)}
</button>
</div>
</Field>
</Section>
{/* System Prompt */}
<Section title="System Prompt" optional>
<Field>
<Textarea
value={form.systemPrompt}
onChange={(e) => updateField('systemPrompt', e.target.value)}
placeholder="You are a helpful assistant..."
rows={4}
className="font-mono text-sm resize-y"
/>
</Field>
<p className="text-[11px] text-muted-foreground/70 flex items-center gap-1">
<Info className="h-3 w-3" />
You can add MCP servers and other options after creation
</p>
</Section>
</div>
{/* Footer */}
<DialogFooter className="px-5 py-4 border-t border-border/40 bg-muted/20">
<Button variant="outline" onClick={handleCancel} disabled={isCreating}>
Cancel
</Button>
<Button onClick={handleCreate} disabled={isCreating}>
{isCreating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ============================================================================
// SHARED COMPONENTS
// ============================================================================
function Section({
title,
optional,
children,
}: {
title: string;
optional?: boolean;
children: React.ReactNode;
}) {
return (
<div>
<div className="mb-2.5 flex items-baseline gap-2">
<h3 className="text-sm font-medium text-foreground">{title}</h3>
{optional && (
<span className="text-[10px] text-muted-foreground/60 uppercase tracking-wide">
Optional
</span>
)}
</div>
<div className="space-y-3">{children}</div>
</div>
);
}
function Field({
label,
required,
hint,
error,
children,
}: {
label?: string;
required?: boolean;
hint?: string;
error?: string;
children: React.ReactNode;
}) {
return (
<div>
{label && (
<label className="block text-xs font-medium text-muted-foreground mb-1.5">
{label}
{required && <span className="text-destructive ml-0.5">*</span>}
</label>
)}
{children}
{hint && !error && <p className="text-[11px] text-muted-foreground/70 mt-1">{hint}</p>}
{error && <p className="text-[11px] text-destructive mt-1">{error}</p>}
</div>
);
}