Files
paperclip/ui/src/components/agent-config-primitives.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

373 lines
11 KiB
TypeScript

import { useState, useRef, useEffect, useCallback } from "react";
import {
Tooltip,
TooltipTrigger,
TooltipContent,
} from "@/components/ui/tooltip";
import { HelpCircle, ChevronDown, ChevronRight } from "lucide-react";
import { cn } from "../lib/utils";
/* ---- Help text for (?) tooltips ---- */
export const help: Record<string, string> = {
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.",
capabilities: "Describes what this agent can do. Shown in the org chart and used for task routing.",
adapterType: "How this agent runs: local CLI (Claude/Codex), spawned process, or HTTP webhook.",
cwd: "The working directory where the agent operates. Use an absolute path on the machine running Paperclip.",
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.",
thinkingEffort: "Control model reasoning depth. Supported values vary by adapter/model.",
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).",
localCommand: "Override the local CLI command (e.g. claude, /usr/local/bin/claude, codex).",
args: "Command-line arguments, comma-separated.",
extraArgs: "Extra CLI arguments for local adapters, comma-separated.",
envVars: "Environment variables injected into the adapter process. Use plain values or secret references.",
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.",
timeoutSec: "Maximum seconds a run can take before being terminated. 0 means no timeout.",
graceSec: "Seconds to wait after sending interrupt before force-killing the process.",
wakeOnDemand: "Allow this agent to be woken by assignments, API calls, UI actions, or automated systems.",
cooldownSec: "Minimum seconds between consecutive heartbeat runs.",
maxConcurrentRuns: "Maximum number of heartbeat runs that can execute simultaneously for this agent.",
budgetMonthlyCents: "Monthly spending limit in cents. 0 means no limit.",
};
export const adapterLabels: Record<string, string> = {
claude_local: "Claude (local)",
codex_local: "Codex (local)",
process: "Process",
http: "HTTP",
};
export const roleLabels: Record<string, string> = {
ceo: "CEO", cto: "CTO", cmo: "CMO", cfo: "CFO",
engineer: "Engineer", designer: "Designer", pm: "PM",
qa: "QA", devops: "DevOps", researcher: "Researcher", general: "General",
};
/* ---- Primitive components ---- */
export function HintIcon({ text }: { text: string }) {
return (
<Tooltip>
<TooltipTrigger asChild>
<button type="button" className="inline-flex text-muted-foreground/50 hover:text-muted-foreground transition-colors">
<HelpCircle className="h-3 w-3" />
</button>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
{text}
</TooltipContent>
</Tooltip>
);
}
export function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
return (
<div>
<div className="flex items-center gap-1.5 mb-1">
<label className="text-xs text-muted-foreground">{label}</label>
{hint && <HintIcon text={hint} />}
</div>
{children}
</div>
);
}
export function ToggleField({
label,
hint,
checked,
onChange,
}: {
label: string;
hint?: string;
checked: boolean;
onChange: (v: boolean) => void;
}) {
return (
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<span className="text-xs text-muted-foreground">{label}</span>
{hint && <HintIcon text={hint} />}
</div>
<button
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
checked ? "bg-green-600" : "bg-muted"
)}
onClick={() => onChange(!checked)}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
checked ? "translate-x-4.5" : "translate-x-0.5"
)}
/>
</button>
</div>
);
}
export function ToggleWithNumber({
label,
hint,
checked,
onCheckedChange,
number,
onNumberChange,
numberLabel,
numberHint,
numberPrefix,
showNumber,
}: {
label: string;
hint?: string;
checked: boolean;
onCheckedChange: (v: boolean) => void;
number: number;
onNumberChange: (v: number) => void;
numberLabel: string;
numberHint?: string;
numberPrefix?: string;
showNumber: boolean;
}) {
return (
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-1.5">
<span className="text-xs text-muted-foreground">{label}</span>
{hint && <HintIcon text={hint} />}
</div>
<button
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors shrink-0",
checked ? "bg-green-600" : "bg-muted"
)}
onClick={() => onCheckedChange(!checked)}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
checked ? "translate-x-4.5" : "translate-x-0.5"
)}
/>
</button>
</div>
{showNumber && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
{numberPrefix && <span>{numberPrefix}</span>}
<input
type="number"
className="w-16 rounded-md border border-border px-2 py-0.5 bg-transparent outline-none text-xs font-mono text-center"
value={number}
onChange={(e) => onNumberChange(Number(e.target.value))}
/>
<span>{numberLabel}</span>
{numberHint && <HintIcon text={numberHint} />}
</div>
)}
</div>
);
}
export function CollapsibleSection({
title,
icon,
open,
onToggle,
bordered,
children,
}: {
title: string;
icon?: React.ReactNode;
open: boolean;
onToggle: () => void;
bordered?: boolean;
children: React.ReactNode;
}) {
return (
<div className={cn(bordered && "border-t border-border")}>
<button
className="flex items-center gap-2 w-full px-4 py-2 text-xs font-medium text-muted-foreground hover:bg-accent/30 transition-colors"
onClick={onToggle}
>
{open ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
{icon}
{title}
</button>
{open && <div className="px-4 pb-3">{children}</div>}
</div>
);
}
export function AutoExpandTextarea({
value,
onChange,
onBlur,
placeholder,
minRows,
}: {
value: string;
onChange: (v: string) => void;
onBlur?: () => void;
placeholder?: string;
minRows?: number;
}) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const rows = minRows ?? 3;
const lineHeight = 20;
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 (
<textarea
ref={textareaRef}
className="w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40 resize-none overflow-hidden"
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
onBlur={onBlur}
style={{ minHeight }}
/>
);
}
/**
* Text input that manages internal draft state.
* Calls `onCommit` on blur (and optionally on every change if `immediate` is set).
*/
export function DraftInput({
value,
onCommit,
immediate,
className,
...props
}: {
value: string;
onCommit: (v: string) => void;
immediate?: boolean;
className?: string;
} & Omit<React.InputHTMLAttributes<HTMLInputElement>, "value" | "onChange" | "className">) {
const [draft, setDraft] = useState(value);
useEffect(() => setDraft(value), [value]);
return (
<input
className={className}
value={draft}
onChange={(e) => {
setDraft(e.target.value);
if (immediate) onCommit(e.target.value);
}}
onBlur={() => {
if (draft !== value) onCommit(draft);
}}
{...props}
/>
);
}
/**
* Auto-expanding textarea with draft state and blur-commit.
*/
export function DraftTextarea({
value,
onCommit,
immediate,
placeholder,
minRows,
}: {
value: string;
onCommit: (v: string) => void;
immediate?: boolean;
placeholder?: string;
minRows?: number;
}) {
const [draft, setDraft] = useState(value);
useEffect(() => setDraft(value), [value]);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const rows = minRows ?? 3;
const lineHeight = 20;
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(); }, [draft, adjustHeight]);
return (
<textarea
ref={textareaRef}
className="w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40 resize-none overflow-hidden"
placeholder={placeholder}
value={draft}
onChange={(e) => {
setDraft(e.target.value);
if (immediate) onCommit(e.target.value);
}}
onBlur={() => {
if (draft !== value) onCommit(draft);
}}
style={{ minHeight }}
/>
);
}
/**
* Number input with draft state and blur-commit.
*/
export function DraftNumberInput({
value,
onCommit,
immediate,
className,
...props
}: {
value: number;
onCommit: (v: number) => void;
immediate?: boolean;
className?: string;
} & Omit<React.InputHTMLAttributes<HTMLInputElement>, "value" | "onChange" | "className" | "type">) {
const [draft, setDraft] = useState(String(value));
useEffect(() => setDraft(String(value)), [value]);
return (
<input
type="number"
className={className}
value={draft}
onChange={(e) => {
setDraft(e.target.value);
if (immediate) onCommit(Number(e.target.value) || 0);
}}
onBlur={() => {
const num = Number(draft) || 0;
if (num !== value) onCommit(num);
}}
{...props}
/>
);
}