NewAgentDialog now delegates adapter/heartbeat config to AgentConfigForm in create mode. AgentDetail replaces its inline ConfigSection/ConfigField/ConfigBool components with AgentConfigForm in edit mode, cutting ~250 lines. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
313 lines
11 KiB
TypeScript
313 lines
11 KiB
TypeScript
import { useState, useEffect } 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 } 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 {
|
|
Minimize2,
|
|
Maximize2,
|
|
Shield,
|
|
User,
|
|
} from "lucide-react";
|
|
import { cn } from "../lib/utils";
|
|
import { roleLabels } from "./agent-config-primitives";
|
|
import {
|
|
AgentConfigForm,
|
|
defaultCreateValues,
|
|
type CreateConfigValues,
|
|
} from "./AgentConfigForm";
|
|
|
|
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("");
|
|
|
|
// Config values (managed by AgentConfigForm)
|
|
const [configValues, setConfigValues] = useState<CreateConfigValues>(defaultCreateValues);
|
|
|
|
// Popover states
|
|
const [roleOpen, setRoleOpen] = useState(false);
|
|
const [reportsToOpen, setReportsToOpen] = 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", configValues.adapterType],
|
|
queryFn: () => agentsApi.adapterModels(configValues.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<string, unknown>) =>
|
|
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("");
|
|
setConfigValues(defaultCreateValues);
|
|
setExpanded(true);
|
|
}
|
|
|
|
function buildAdapterConfig() {
|
|
const v = configValues;
|
|
const ac: Record<string, unknown> = {};
|
|
if (v.cwd) ac.cwd = v.cwd;
|
|
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
|
|
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
|
|
if (v.model) ac.model = v.model;
|
|
ac.timeoutSec = 0;
|
|
ac.graceSec = 15;
|
|
|
|
if (v.adapterType === "claude_local") {
|
|
ac.maxTurnsPerRun = v.maxTurnsPerRun;
|
|
ac.dangerouslySkipPermissions = v.dangerouslySkipPermissions;
|
|
} else if (v.adapterType === "codex_local") {
|
|
ac.search = v.search;
|
|
ac.dangerouslyBypassApprovalsAndSandbox = v.dangerouslyBypassSandbox;
|
|
} else if (v.adapterType === "process") {
|
|
if (v.command) ac.command = v.command;
|
|
if (v.args) ac.args = v.args.split(",").map((a) => a.trim()).filter(Boolean);
|
|
} else if (v.adapterType === "http") {
|
|
if (v.url) ac.url = v.url;
|
|
}
|
|
return ac;
|
|
}
|
|
|
|
function handleSubmit() {
|
|
if (!selectedCompanyId || !name.trim()) return;
|
|
createAgent.mutate({
|
|
name: name.trim(),
|
|
role: effectiveRole,
|
|
...(title.trim() ? { title: title.trim() } : {}),
|
|
...(reportsTo ? { reportsTo } : {}),
|
|
adapterType: configValues.adapterType,
|
|
adapterConfig: buildAdapterConfig(),
|
|
runtimeConfig: {
|
|
heartbeat: {
|
|
enabled: configValues.heartbeatEnabled,
|
|
intervalSec: configValues.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);
|
|
|
|
return (
|
|
<Dialog
|
|
open={newAgentOpen}
|
|
onOpenChange={(open) => {
|
|
if (!open) { reset(); closeNewAgent(); }
|
|
}}
|
|
>
|
|
<DialogContent
|
|
showCloseButton={false}
|
|
className={cn("p-0 gap-0 overflow-hidden", expanded ? "sm:max-w-2xl" : "sm:max-w-lg")}
|
|
onKeyDown={handleKeyDown}
|
|
>
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border">
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
{selectedCompany && (
|
|
<span className="bg-muted px-1.5 py-0.5 rounded text-xs font-medium">
|
|
{selectedCompany.name.slice(0, 3).toUpperCase()}
|
|
</span>
|
|
)}
|
|
<span className="text-muted-foreground/60">›</span>
|
|
<span>New agent</span>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<Button variant="ghost" size="icon-xs" className="text-muted-foreground" onClick={() => setExpanded(!expanded)}>
|
|
{expanded ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
|
|
</Button>
|
|
<Button variant="ghost" size="icon-xs" className="text-muted-foreground" onClick={() => { reset(); closeNewAgent(); }}>
|
|
<span className="text-lg leading-none">×</span>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="overflow-y-auto max-h-[70vh]">
|
|
{/* Name */}
|
|
<div className="px-4 pt-3">
|
|
<input
|
|
className="w-full text-base font-medium bg-transparent outline-none placeholder:text-muted-foreground/50"
|
|
placeholder="Agent name"
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
|
|
{/* Title */}
|
|
<div className="px-4 pb-2">
|
|
<input
|
|
className="w-full bg-transparent outline-none text-sm text-muted-foreground placeholder:text-muted-foreground/40"
|
|
placeholder="Title (e.g. VP of Engineering)"
|
|
value={title}
|
|
onChange={(e) => setTitle(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
{/* Property chips: Role + Reports To */}
|
|
<div className="flex items-center gap-1.5 px-4 py-2 border-t border-border flex-wrap">
|
|
{/* Role */}
|
|
<Popover open={roleOpen} onOpenChange={setRoleOpen}>
|
|
<PopoverTrigger asChild>
|
|
<button
|
|
className={cn(
|
|
"inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors",
|
|
isFirstAgent && "opacity-60 cursor-not-allowed"
|
|
)}
|
|
disabled={isFirstAgent}
|
|
>
|
|
<Shield className="h-3 w-3 text-muted-foreground" />
|
|
{roleLabels[effectiveRole] ?? effectiveRole}
|
|
</button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-36 p-1" align="start">
|
|
{AGENT_ROLES.map((r) => (
|
|
<button
|
|
key={r}
|
|
className={cn(
|
|
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
|
r === role && "bg-accent"
|
|
)}
|
|
onClick={() => { setRole(r); setRoleOpen(false); }}
|
|
>
|
|
{roleLabels[r] ?? r}
|
|
</button>
|
|
))}
|
|
</PopoverContent>
|
|
</Popover>
|
|
|
|
{/* Reports To */}
|
|
<Popover open={reportsToOpen} onOpenChange={setReportsToOpen}>
|
|
<PopoverTrigger asChild>
|
|
<button
|
|
className={cn(
|
|
"inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors",
|
|
isFirstAgent && "opacity-60 cursor-not-allowed"
|
|
)}
|
|
disabled={isFirstAgent}
|
|
>
|
|
<User className="h-3 w-3 text-muted-foreground" />
|
|
{currentReportsTo
|
|
? `Reports to ${currentReportsTo.name}`
|
|
: isFirstAgent
|
|
? "Reports to: N/A (CEO)"
|
|
: "Reports to..."
|
|
}
|
|
</button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-48 p-1" align="start">
|
|
<button
|
|
className={cn(
|
|
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
|
!reportsTo && "bg-accent"
|
|
)}
|
|
onClick={() => { setReportsTo(""); setReportsToOpen(false); }}
|
|
>
|
|
No manager
|
|
</button>
|
|
{(agents ?? []).map((a) => (
|
|
<button
|
|
key={a.id}
|
|
className={cn(
|
|
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 truncate",
|
|
a.id === reportsTo && "bg-accent"
|
|
)}
|
|
onClick={() => { setReportsTo(a.id); setReportsToOpen(false); }}
|
|
>
|
|
{a.name}
|
|
<span className="text-muted-foreground ml-auto">{roleLabels[a.role] ?? a.role}</span>
|
|
</button>
|
|
))}
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
|
|
{/* Shared config form (adapter + heartbeat) */}
|
|
<AgentConfigForm
|
|
mode="create"
|
|
values={configValues}
|
|
onChange={(patch) => setConfigValues((prev) => ({ ...prev, ...patch }))}
|
|
adapterModels={adapterModels}
|
|
/>
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="flex items-center justify-between px-4 py-2.5 border-t border-border">
|
|
<span className="text-xs text-muted-foreground">
|
|
{isFirstAgent ? "This will be the CEO" : ""}
|
|
</span>
|
|
<Button
|
|
size="sm"
|
|
disabled={!name.trim() || createAgent.isPending}
|
|
onClick={handleSubmit}
|
|
>
|
|
{createAgent.isPending ? "Creating..." : "Create agent"}
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|