import { useState, useRef, useEffect, useCallback } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useNavigate } from "react-router-dom"; import { useDialog } from "../context/DialogContext"; import { useCompany } from "../context/CompanyContext"; import { agentsApi } from "../api/agents"; import { queryKeys } from "../lib/queryKeys"; import { AGENT_ROLES, AGENT_ADAPTER_TYPES } from "@paperclip/shared"; import { Dialog, DialogContent, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { Tooltip, TooltipTrigger, TooltipContent, } from "@/components/ui/tooltip"; import { Minimize2, Maximize2, Shield, User, ChevronDown, ChevronRight, Heart, HelpCircle, FolderOpen, } from "lucide-react"; import { cn } from "../lib/utils"; const roleLabels: Record = { ceo: "CEO", cto: "CTO", cmo: "CMO", cfo: "CFO", engineer: "Engineer", designer: "Designer", pm: "PM", qa: "QA", devops: "DevOps", researcher: "Researcher", general: "General", }; const adapterLabels: Record = { claude_local: "Claude (local)", codex_local: "Codex (local)", process: "Process", http: "HTTP", }; /* ---- Help text for (?) tooltips ---- */ const help: Record = { name: "Display name for this agent.", title: "Job title shown in the org chart.", role: "Organizational role. Determines position and capabilities.", reportsTo: "The agent this one reports to in the org hierarchy.", adapterType: "How this agent runs: local CLI (Claude/Codex), spawned process, or HTTP webhook.", cwd: "The working directory where the agent operates. Should be an absolute path on the server.", promptTemplate: "The prompt sent to the agent on each heartbeat. Supports {{ agent.id }}, {{ agent.name }}, {{ agent.role }} variables.", model: "Override the default model used by the adapter.", dangerouslySkipPermissions: "Run Claude without permission prompts. Required for unattended operation.", dangerouslyBypassSandbox: "Run Codex without sandbox restrictions. Required for filesystem/network access.", search: "Enable Codex web search capability during runs.", bootstrapPrompt: "Prompt used only on the first run (no existing session). Used for initial agent setup.", maxTurnsPerRun: "Maximum number of agentic turns (tool calls) per heartbeat run.", command: "The command to execute (e.g. node, python).", args: "Command-line arguments, comma-separated.", webhookUrl: "The URL that receives POST requests when the agent is invoked.", heartbeatInterval: "Run this agent automatically on a timer. Useful for periodic tasks like checking for new work.", intervalSec: "Seconds between automatic heartbeat invocations.", }; export function NewAgentDialog() { const { newAgentOpen, closeNewAgent } = useDialog(); const { selectedCompanyId, selectedCompany } = useCompany(); const queryClient = useQueryClient(); const navigate = useNavigate(); const [expanded, setExpanded] = useState(true); // Identity const [name, setName] = useState(""); const [title, setTitle] = useState(""); const [role, setRole] = useState("general"); const [reportsTo, setReportsTo] = useState(""); // Adapter const [adapterType, setAdapterType] = useState("claude_local"); const [cwd, setCwd] = useState(""); const [promptTemplate, setPromptTemplate] = useState(""); const [model, setModel] = useState(""); // claude_local specific const [dangerouslySkipPermissions, setDangerouslySkipPermissions] = useState(false); // codex_local specific const [search, setSearch] = useState(false); const [dangerouslyBypassSandbox, setDangerouslyBypassSandbox] = useState(false); // process specific const [command, setCommand] = useState(""); const [args, setArgs] = useState(""); // http specific const [url, setUrl] = useState(""); // Advanced adapter fields const [bootstrapPrompt, setBootstrapPrompt] = useState(""); const [maxTurnsPerRun, setMaxTurnsPerRun] = useState(80); // Heartbeat const [heartbeatEnabled, setHeartbeatEnabled] = useState(false); const [intervalSec, setIntervalSec] = useState(300); // Sections const [adapterAdvancedOpen, setAdapterAdvancedOpen] = useState(false); const [heartbeatOpen, setHeartbeatOpen] = useState(false); // Popover states const [roleOpen, setRoleOpen] = useState(false); const [reportsToOpen, setReportsToOpen] = useState(false); const [modelOpen, setModelOpen] = useState(false); const { data: agents } = useQuery({ queryKey: queryKeys.agents.list(selectedCompanyId!), queryFn: () => agentsApi.list(selectedCompanyId!), enabled: !!selectedCompanyId && newAgentOpen, }); const { data: adapterModels } = useQuery({ queryKey: ["adapter-models", adapterType], queryFn: () => agentsApi.adapterModels(adapterType), enabled: newAgentOpen, }); const isFirstAgent = !agents || agents.length === 0; const effectiveRole = isFirstAgent ? "ceo" : role; // Auto-fill for CEO useEffect(() => { if (newAgentOpen && isFirstAgent) { if (!name) setName("CEO"); if (!title) setTitle("CEO"); } }, [newAgentOpen, isFirstAgent]); // eslint-disable-line react-hooks/exhaustive-deps const createAgent = useMutation({ mutationFn: (data: Record) => agentsApi.create(selectedCompanyId!, data), onSuccess: (agent) => { queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(selectedCompanyId!) }); reset(); closeNewAgent(); navigate(`/agents/${agent.id}`); }, }); function reset() { setName(""); setTitle(""); setRole("general"); setReportsTo(""); setAdapterType("claude_local"); setCwd(""); setPromptTemplate(""); setModel(""); setDangerouslySkipPermissions(false); setSearch(false); setDangerouslyBypassSandbox(false); setCommand(""); setArgs(""); setUrl(""); setBootstrapPrompt(""); setMaxTurnsPerRun(80); setHeartbeatEnabled(false); setIntervalSec(300); setExpanded(true); setAdapterAdvancedOpen(false); setHeartbeatOpen(false); } function buildAdapterConfig() { const config: Record = {}; if (cwd) config.cwd = cwd; if (promptTemplate) config.promptTemplate = promptTemplate; if (bootstrapPrompt) config.bootstrapPromptTemplate = bootstrapPrompt; if (model) config.model = model; config.timeoutSec = 0; config.graceSec = 15; if (adapterType === "claude_local") { config.maxTurnsPerRun = maxTurnsPerRun; config.dangerouslySkipPermissions = dangerouslySkipPermissions; } else if (adapterType === "codex_local") { config.search = search; config.dangerouslyBypassApprovalsAndSandbox = dangerouslyBypassSandbox; } else if (adapterType === "process") { if (command) config.command = command; if (args) config.args = args.split(",").map((a) => a.trim()).filter(Boolean); } else if (adapterType === "http") { if (url) config.url = url; } return config; } function handleSubmit() { if (!selectedCompanyId || !name.trim()) return; createAgent.mutate({ name: name.trim(), role: effectiveRole, ...(title.trim() ? { title: title.trim() } : {}), ...(reportsTo ? { reportsTo } : {}), adapterType, adapterConfig: buildAdapterConfig(), runtimeConfig: { heartbeat: { enabled: heartbeatEnabled, intervalSec, wakeOnAssignment: true, wakeOnOnDemand: true, wakeOnAutomation: true, cooldownSec: 10, }, }, contextMode: "thin", budgetMonthlyCents: 0, }); } function handleKeyDown(e: React.KeyboardEvent) { if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); handleSubmit(); } } const currentReportsTo = (agents ?? []).find((a) => a.id === reportsTo); const selectedModel = (adapterModels ?? []).find((m) => m.id === model); return ( { if (!open) { reset(); closeNewAgent(); } }} > {/* Header */}
{selectedCompany && ( {selectedCompany.name.slice(0, 3).toUpperCase()} )} New agent
{/* Name */}
setName(e.target.value)} autoFocus />
{/* Title */}
setTitle(e.target.value)} />
{/* Property chips: Role + Reports To */}
{/* Role */} {AGENT_ROLES.map((r) => ( ))} {/* Reports To */} {(agents ?? []).map((a) => ( ))}
{/* Adapter type dropdown (above config section) */}
{AGENT_ADAPTER_TYPES.map((t) => ( ))}
{/* Adapter Configuration (always open) */}
Adapter Configuration
{/* Working directory — basic, shown for local adapters */} {(adapterType === "claude_local" || adapterType === "codex_local") && (
setCwd(e.target.value)} />
)} {/* Prompt template — basic, auto-expanding */} {(adapterType === "claude_local" || adapterType === "codex_local") && ( )} {/* Skip permissions — basic for claude */} {adapterType === "claude_local" && ( )} {/* Bypass sandbox + search — basic for codex */} {adapterType === "codex_local" && ( <> )} {/* Process-specific fields */} {adapterType === "process" && ( <> setCommand(e.target.value)} /> setArgs(e.target.value)} /> )} {/* HTTP-specific fields */} {adapterType === "http" && ( setUrl(e.target.value)} /> )} {/* Advanced section for local adapters */} {(adapterType === "claude_local" || adapterType === "codex_local") && ( setAdapterAdvancedOpen(!adapterAdvancedOpen)} >
{/* Model dropdown */} {(adapterModels ?? []).map((m) => ( ))} {/* Bootstrap prompt */} {/* Max turns — claude only */} {adapterType === "claude_local" && ( setMaxTurnsPerRun(Number(e.target.value))} /> )}
)}
{/* Heartbeat Policy */} } open={heartbeatOpen} onToggle={() => setHeartbeatOpen(!heartbeatOpen)} bordered >
{/* Footer */}
{isFirstAgent ? "This will be the CEO" : ""}
); } /* ---- Reusable components ---- */ function HintIcon({ text }: { text: string }) { return ( {text} ); } function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) { return (
{hint && }
{children}
); } function ToggleField({ label, hint, checked, onChange, }: { label: string; hint?: string; checked: boolean; onChange: (v: boolean) => void; }) { return (
{label} {hint && }
); } function ToggleWithNumber({ label, hint, checked, onCheckedChange, number, onNumberChange, numberLabel, numberHint, showNumber, }: { label: string; hint?: string; checked: boolean; onCheckedChange: (v: boolean) => void; number: number; onNumberChange: (v: number) => void; numberLabel: string; numberHint?: string; showNumber: boolean; }) { return (
{label} {hint && }
{showNumber && (
Run heartbeat every onNumberChange(Number(e.target.value))} /> {numberLabel} {numberHint && }
)}
); } function CollapsibleSection({ title, icon, open, onToggle, bordered, children, }: { title: string; icon?: React.ReactNode; open: boolean; onToggle: () => void; bordered?: boolean; children: React.ReactNode; }) { return (
{open &&
{children}
}
); } function AutoExpandTextarea({ value, onChange, placeholder, minRows, }: { value: string; onChange: (v: string) => void; placeholder?: string; minRows?: number; }) { const textareaRef = useRef(null); const rows = minRows ?? 3; const lineHeight = 20; // approx line height in px for text-sm mono const minHeight = rows * lineHeight; const adjustHeight = useCallback(() => { const el = textareaRef.current; if (!el) return; el.style.height = "auto"; el.style.height = `${Math.max(minHeight, el.scrollHeight)}px`; }, [minHeight]); useEffect(() => { adjustHeight(); }, [value, adjustHeight]); return (