import "./NewAgentDialog.css"; import { useState, useEffect, useCallback } from "react"; import { createPortal } from "react-dom"; import type { Agent, AgentCapability, ModelInfo, AgentGenerationSpec, PluginRuntimeInfo, AgentOnboardingSummary } from "../api"; import { createAgent, fetchAgents, fetchModels, updateGlobalSettings } from "../api"; import * as apiModule from "../api"; import { CustomModelDropdown } from "./CustomModelDropdown"; import { ProviderIcon } from "./ProviderIcon"; import { AgentGenerationModal } from "./AgentGenerationModal"; import { AGENT_PRESETS, type AgentPreset } from "./agent-presets"; import { SkillMultiselect } from "./SkillMultiselect"; import { AgentAvatar } from "./AgentAvatar"; import { ExperimentalAgentOnboardingModal } from "./ExperimentalAgentOnboardingModal"; export interface NewAgentDialogProps { isOpen: boolean; onClose: () => void; onCreated: () => void; projectId?: string; prefillDraft?: AgentOnboardingSummary | null; agentOnboardingEnabled?: boolean; existingAgents?: Agent[]; onPrefillDraft?: (draft: AgentOnboardingSummary | null) => void; } const AGENT_ROLES: { value: AgentCapability; label: string; icon: string }[] = [ { value: "triage", label: "Triage", icon: "⊕" }, { value: "executor", label: "Executor", icon: "▶" }, { value: "reviewer", label: "Reviewer", icon: "⊙" }, { value: "merger", label: "Merger", icon: "⊞" }, { value: "scheduler", label: "Scheduler", icon: "◷" }, { value: "engineer", label: "Engineer", icon: "⎔" }, { value: "custom", label: "Custom", icon: "✦" }, ]; type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high"; /** Set of valid AgentCapability values for mapping generated roles */ const VALID_CAPABILITIES = new Set(["triage", "executor", "reviewer", "merger", "scheduler", "engineer", "custom"]); interface RuntimeConfig { model: string; thinkingLevel: ThinkingLevel; maxTurns: number; } type StepZeroTab = "presets" | "custom"; export function NewAgentDialog({ isOpen, onClose, onCreated, projectId, prefillDraft = null, agentOnboardingEnabled = false, existingAgents = [], onPrefillDraft, }: NewAgentDialogProps) { const [step, setStep] = useState(0); const [stepZeroTab, setStepZeroTab] = useState("presets"); const [name, setName] = useState(""); const [title, setTitle] = useState(""); const [icon, setIcon] = useState(""); const [role, setRole] = useState("custom"); const [reportsTo, setReportsTo] = useState(""); const [instructionsPath, setInstructionsPath] = useState(""); const [instructionsText, setInstructionsText] = useState(""); const [heartbeatProcedurePath, setHeartbeatProcedurePath] = useState(""); const [soul, setSoul] = useState(""); const [memory, setMemory] = useState(""); const [runtimeConfig, setRuntimeConfig] = useState({ model: "", thinkingLevel: "off", maxTurns: 1000, }); const [selectedPresetId, setSelectedPresetId] = useState(null); const [selectedSkills, setSelectedSkills] = useState([]); const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); const [isGenerationModalOpen, setIsGenerationModalOpen] = useState(false); const [isInterviewOpen, setIsInterviewOpen] = useState(false); // Model dropdown state const [availableModels, setAvailableModels] = useState([]); const [modelsLoading, setModelsLoading] = useState(false); const [favoriteProviders, setFavoriteProviders] = useState([]); const [favoriteModels, setFavoriteModels] = useState([]); const [runtimeMode, setRuntimeMode] = useState<"model" | "runtime">("model"); const [selectedRuntimeId, setSelectedRuntimeId] = useState(""); const [availableRuntimes, setAvailableRuntimes] = useState([]); const [runtimesLoading, setRuntimesLoading] = useState(false); // Manager dropdown state const [availableManagers, setAvailableManagers] = useState([]); const [managersLoading, setManagersLoading] = useState(false); // Load models when dialog opens — guard prevents async setState after test assertions useEffect(() => { if (!isOpen) return; setModelsLoading(true); fetchModels() .then((response) => { setAvailableModels(response.models); setFavoriteProviders(response.favoriteProviders); setFavoriteModels(response.favoriteModels); }) .catch(() => { // Gracefully handle — dropdown will show empty list }) .finally(() => setModelsLoading(false)); }, [isOpen]); // Load manager options when dialog opens useEffect(() => { if (!isOpen) return; setManagersLoading(true); setAvailableManagers([]); fetchAgents(undefined, projectId) .then((agents) => { setAvailableManagers(agents); }) .catch(() => { // Gracefully handle — manager selector will show "No manager" only setAvailableManagers([]); }) .finally(() => setManagersLoading(false)); }, [isOpen, projectId]); // Load plugin runtimes when dialog opens useEffect(() => { if (!isOpen) return; const fetchPluginRuntimes = apiModule.fetchPluginRuntimes; if (typeof fetchPluginRuntimes !== "function") { setAvailableRuntimes([]); setRuntimesLoading(false); return; } setRuntimesLoading(true); setAvailableRuntimes([]); fetchPluginRuntimes(projectId) .then((runtimes) => { setAvailableRuntimes(runtimes); }) .catch(() => { // Gracefully handle — runtime selector will show empty state setAvailableRuntimes([]); }) .finally(() => setRuntimesLoading(false)); }, [isOpen, projectId]); // Selected model in "provider/modelId" format, or "" for default const selectedModel = runtimeConfig.model.includes("/") ? runtimeConfig.model : ""; const handleGenerated = useCallback((spec: AgentGenerationSpec) => { // Map generated role to AgentCapability, default to "custom" if unrecognized const mappedRole = VALID_CAPABILITIES.has(spec.role) ? (spec.role as AgentCapability) : "custom"; setName(spec.title); setTitle(spec.description); setIcon(spec.icon); setRole(mappedRole); // Map generated systemPrompt to instructionsText setInstructionsText(spec.systemPrompt); setRuntimeConfig(c => ({ ...c, thinkingLevel: spec.thinkingLevel, maxTurns: spec.maxTurns, })); setIsGenerationModalOpen(false); // Advance to Step 1 so user can review model selection setStep(1); }, []); const handleModelChange = useCallback((value: string) => { // value is "provider/modelId" or "" for default setRuntimeConfig(c => ({ ...c, model: value })); }, []); const handleRuntimeModeChange = useCallback((mode: "model" | "runtime") => { setRuntimeMode(mode); if (mode === "model") { setSelectedRuntimeId(""); } }, []); const handleToggleFavorite = useCallback(async (provider: string) => { const currentFavorites = favoriteProviders; const isFavorite = currentFavorites.includes(provider); const newFavorites = isFavorite ? currentFavorites.filter(p => p !== provider) : [provider, ...currentFavorites]; setFavoriteProviders(newFavorites); try { await updateGlobalSettings({ favoriteProviders: newFavorites, favoriteModels }); } catch { // Revert on error setFavoriteProviders(currentFavorites); } }, [favoriteProviders, favoriteModels]); const handleToggleModelFavorite = useCallback(async (modelId: string) => { const currentFavorites = favoriteModels; const isFavorite = currentFavorites.includes(modelId); const newFavorites = isFavorite ? currentFavorites.filter(m => m !== modelId) : [modelId, ...currentFavorites]; setFavoriteModels(newFavorites); try { await updateGlobalSettings({ favoriteProviders, favoriteModels: newFavorites }); } catch { // Revert on error setFavoriteModels(currentFavorites); } }, [favoriteProviders, favoriteModels]); const handlePresetSelect = useCallback((preset: AgentPreset) => { setSelectedPresetId(preset.id); setName(preset.name); setIcon(preset.icon); setTitle(preset.description ?? preset.title); setRole(preset.role); setSoul(preset.soul ?? ""); setInstructionsText(preset.instructionsText ?? ""); // Advance to Step 1 so user can review model selection setStep(1); }, []); const applyDraftToForm = useCallback((draft: AgentOnboardingSummary) => { const runtimeHint = draft.runtimeHint?.trim() ?? ""; const modelSelection = draft.model?.trim() || draft.modelHint?.trim() || ""; setStep(1); setStepZeroTab("custom"); setName(draft.name ?? ""); setTitle(draft.title ?? ""); setIcon(draft.icon ?? ""); setRole((VALID_CAPABILITIES.has(draft.role) ? draft.role : "custom") as AgentCapability); setReportsTo(draft.reportsTo ?? ""); setInstructionsText(draft.instructionsText ?? ""); setHeartbeatProcedurePath(draft.heartbeatProcedurePath ?? ""); setSoul(draft.soul ?? ""); setMemory(draft.memory ?? ""); setSelectedSkills(Array.isArray(draft.skills) ? draft.skills : []); setRuntimeConfig((current) => ({ ...current, model: runtimeHint ? "" : modelSelection, thinkingLevel: draft.thinkingLevel ?? current.thinkingLevel, maxTurns: draft.maxTurns ?? current.maxTurns, })); if (runtimeHint) { setRuntimeMode("runtime"); setSelectedRuntimeId(runtimeHint); } else { setRuntimeMode("model"); setSelectedRuntimeId(""); } }, []); useEffect(() => { if (!isOpen || !prefillDraft) return; applyDraftToForm(prefillDraft); }, [isOpen, prefillDraft, applyDraftToForm]); if (!isOpen) return null; const handleClose = () => { setStep(0); setStepZeroTab("presets"); setName(""); setTitle(""); setIcon(""); setRole("custom"); setReportsTo(""); setInstructionsPath(""); setInstructionsText(""); setHeartbeatProcedurePath(""); setSoul(""); setMemory(""); setRuntimeConfig({ model: "", thinkingLevel: "off", maxTurns: 1000 }); setRuntimeMode("model"); setSelectedRuntimeId(""); setSelectedPresetId(null); setSelectedSkills([]); setError(null); setIsGenerationModalOpen(false); setIsInterviewOpen(false); onClose(); }; const handleCreate = async () => { if (!name.trim()) return; setIsSubmitting(true); setError(null); try { const runtimeCfg: Record = {}; if (runtimeMode === "runtime") { if (selectedRuntimeId.trim()) runtimeCfg.runtimeHint = selectedRuntimeId.trim(); } else if (runtimeConfig.model.trim()) { runtimeCfg.model = runtimeConfig.model.trim(); } if (runtimeConfig.thinkingLevel !== "off") runtimeCfg.thinkingLevel = runtimeConfig.thinkingLevel; if (runtimeConfig.maxTurns !== 1000) runtimeCfg.maxTurns = runtimeConfig.maxTurns; await createAgent({ name: name.trim(), role, ...(title.trim() ? { title: title.trim() } : {}), ...(icon.trim() ? { icon: icon.trim() } : {}), ...(reportsTo.trim() ? { reportsTo: reportsTo.trim() } : {}), ...(instructionsPath.trim() ? { instructionsPath: instructionsPath.trim() } : {}), ...(instructionsText.trim() ? { instructionsText: instructionsText.trim() } : {}), ...(heartbeatProcedurePath.trim() ? { heartbeatProcedurePath: heartbeatProcedurePath.trim() } : {}), ...(soul.trim() ? { soul: soul.trim() } : {}), ...(memory.trim() ? { memory: memory.trim() } : {}), ...(Object.keys(runtimeCfg).length > 0 ? { runtimeConfig: runtimeCfg } : {}), ...(selectedSkills.length > 0 ? { metadata: { skills: selectedSkills } } : {}), }, projectId); handleClose(); onCreated(); } catch (err: unknown) { setError(err instanceof Error ? err.message : "Failed to create agent"); } finally { setIsSubmitting(false); } }; const selectedRole = AGENT_ROLES.find(r => r.value === role); const selectedReportsToId = reportsTo.trim(); const selectedManager = selectedReportsToId ? availableManagers.find((manager) => manager.id === selectedReportsToId) : undefined; const selectedRuntime = selectedRuntimeId ? availableRuntimes.find((runtime) => runtime.runtimeId === selectedRuntimeId) : undefined; const renderRuntimeSourceSection = (sourceLabelId: string) => ( <>
{runtimeMode === "model" ? (
{modelsLoading ? (
Loading models…
) : ( )}
) : (
{runtimesLoading ? (
Loading runtimes…
) : ( )}
)} ); // Render through a portal to document.body so the overlay escapes any // ancestor stacking context / `overflow: hidden`. Without this, `position: // fixed` on the overlay was being trapped under .agents-view, so the // dialog rendered with its top hidden behind the in-page Agents header on // mobile (the header isn't taller than the dialog top — it's just stacked // above it because the dialog couldn't escape its container). return createPortal(
{ if (e.target === e.currentTarget) handleClose(); }}>
{/* Header */}
New Agent
{/* Step indicator */}
{[0, 1, 2].map(i => (
))}
{/* Body */}
{step === 0 && (
{agentOnboardingEnabled && (
)}
{stepZeroTab === "presets" && (
Choose a preset persona to prefill role, identity, soul, and instructions
{AGENT_PRESETS.map(preset => ( ))}
)} {stepZeroTab === "custom" && (
Identity
setName(e.target.value)} />
setTitle(e.target.value)} />
setIcon(e.target.value)} />
{AGENT_ROLES.map(r => ( ))}
Configuration