Files
paperclip/ui/src/components/NewAgentDialog.tsx
Forgotten 0131cf3449 Support concurrent heartbeat runs with maxConcurrentRuns policy
Add per-agent maxConcurrentRuns (1-10) controlling how many runs
execute simultaneously. Implements agent-level start lock, optimistic
claim-then-execute flow, atomic token accounting via SQL expressions,
and proper status resolution when parallel runs finish. Updates UI
config form, live run count display, and SSE invalidation to avoid
unnecessary refetches on run event streams.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 12:50:34 -06:00

290 lines
10 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, type CreateConfigValues } from "./AgentConfigForm";
import { defaultCreateValues } from "./agent-config-defaults";
import { getUIAdapter } from "../adapters";
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.hire(selectedCompanyId!, data),
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(selectedCompanyId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(selectedCompanyId!) });
reset();
closeNewAgent();
navigate(`/agents/${result.agent.id}`);
},
});
function reset() {
setName("");
setTitle("");
setRole("general");
setReportsTo("");
setConfigValues(defaultCreateValues);
setExpanded(true);
}
function buildAdapterConfig() {
const adapter = getUIAdapter(configValues.adapterType);
return adapter.buildAdapterConfig(configValues);
}
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,
wakeOnDemand: true,
cooldownSec: 10,
maxConcurrentRuns: 1,
},
},
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>
);
}