Files
paperclip/ui/src/components/NewAgentDialog.tsx
Forgotten 080964a361 Refactor NewAgentDialog and AgentDetail to use AgentConfigForm
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>
2026-02-17 20:07:30 -06:00

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">&rsaquo;</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">&times;</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>
);
}