Extract AgentConfigForm and agent-config-primitives components
Shared primitives (Field, ToggleField, ToggleWithNumber, CollapsibleSection, DraftInput, DraftTextarea, DraftNumberInput, HintIcon, help text, adapterLabels, roleLabels) extracted into agent-config-primitives.tsx. AgentConfigForm is a dual-mode form supporting both create (controlled values) and edit (save-on-blur per field) modes, used by NewAgentDialog and AgentDetail. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
621
ui/src/components/AgentConfigForm.tsx
Normal file
621
ui/src/components/AgentConfigForm.tsx
Normal file
@@ -0,0 +1,621 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { AGENT_ADAPTER_TYPES } from "@paperclip/shared";
|
||||||
|
import type { Agent } from "@paperclip/shared";
|
||||||
|
import type { AdapterModel } from "../api/agents";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import { FolderOpen, Heart, ChevronDown } from "lucide-react";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
ToggleField,
|
||||||
|
ToggleWithNumber,
|
||||||
|
CollapsibleSection,
|
||||||
|
AutoExpandTextarea,
|
||||||
|
DraftInput,
|
||||||
|
DraftTextarea,
|
||||||
|
DraftNumberInput,
|
||||||
|
HintIcon,
|
||||||
|
help,
|
||||||
|
adapterLabels,
|
||||||
|
} from "./agent-config-primitives";
|
||||||
|
|
||||||
|
/* ---- Create mode values ---- */
|
||||||
|
|
||||||
|
export interface CreateConfigValues {
|
||||||
|
adapterType: string;
|
||||||
|
cwd: string;
|
||||||
|
promptTemplate: string;
|
||||||
|
model: string;
|
||||||
|
dangerouslySkipPermissions: boolean;
|
||||||
|
search: boolean;
|
||||||
|
dangerouslyBypassSandbox: boolean;
|
||||||
|
command: string;
|
||||||
|
args: string;
|
||||||
|
url: string;
|
||||||
|
bootstrapPrompt: string;
|
||||||
|
maxTurnsPerRun: number;
|
||||||
|
heartbeatEnabled: boolean;
|
||||||
|
intervalSec: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultCreateValues: CreateConfigValues = {
|
||||||
|
adapterType: "claude_local",
|
||||||
|
cwd: "",
|
||||||
|
promptTemplate: "",
|
||||||
|
model: "",
|
||||||
|
dangerouslySkipPermissions: false,
|
||||||
|
search: false,
|
||||||
|
dangerouslyBypassSandbox: false,
|
||||||
|
command: "",
|
||||||
|
args: "",
|
||||||
|
url: "",
|
||||||
|
bootstrapPrompt: "",
|
||||||
|
maxTurnsPerRun: 80,
|
||||||
|
heartbeatEnabled: false,
|
||||||
|
intervalSec: 300,
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---- Props ---- */
|
||||||
|
|
||||||
|
type AgentConfigFormProps = {
|
||||||
|
adapterModels?: AdapterModel[];
|
||||||
|
} & (
|
||||||
|
| {
|
||||||
|
mode: "create";
|
||||||
|
values: CreateConfigValues;
|
||||||
|
onChange: (patch: Partial<CreateConfigValues>) => void;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
mode: "edit";
|
||||||
|
agent: Agent;
|
||||||
|
onSave: (patch: Record<string, unknown>) => void;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ---- Shared input class ---- */
|
||||||
|
const inputClass =
|
||||||
|
"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";
|
||||||
|
|
||||||
|
/* ---- Form ---- */
|
||||||
|
|
||||||
|
export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||||
|
const { mode, adapterModels } = props;
|
||||||
|
const isCreate = mode === "create";
|
||||||
|
|
||||||
|
// Resolve adapter type + config + heartbeat from props
|
||||||
|
const adapterType = isCreate ? props.values.adapterType : props.agent.adapterType;
|
||||||
|
const isLocal = adapterType === "claude_local" || adapterType === "codex_local";
|
||||||
|
|
||||||
|
// Edit mode: extract from agent
|
||||||
|
const config = !isCreate ? ((props.agent.adapterConfig ?? {}) as Record<string, unknown>) : {};
|
||||||
|
const runtimeConfig = !isCreate ? ((props.agent.runtimeConfig ?? {}) as Record<string, unknown>) : {};
|
||||||
|
const heartbeat = !isCreate ? ((runtimeConfig.heartbeat ?? {}) as Record<string, unknown>) : {};
|
||||||
|
|
||||||
|
// Section toggle state
|
||||||
|
const [adapterAdvancedOpen, setAdapterAdvancedOpen] = useState(!isCreate);
|
||||||
|
const [heartbeatOpen, setHeartbeatOpen] = useState(!isCreate);
|
||||||
|
|
||||||
|
// Popover states
|
||||||
|
const [modelOpen, setModelOpen] = useState(false);
|
||||||
|
|
||||||
|
// Create mode helpers
|
||||||
|
const val = isCreate ? props.values : null;
|
||||||
|
const set = isCreate
|
||||||
|
? (patch: Partial<CreateConfigValues>) => props.onChange(patch)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Edit mode helpers
|
||||||
|
const saveConfig = !isCreate
|
||||||
|
? (field: string, value: unknown) =>
|
||||||
|
props.onSave({ adapterConfig: { ...config, [field]: value } })
|
||||||
|
: null;
|
||||||
|
const saveHeartbeat = !isCreate
|
||||||
|
? (field: string, value: unknown) =>
|
||||||
|
props.onSave({
|
||||||
|
runtimeConfig: { ...runtimeConfig, heartbeat: { ...heartbeat, [field]: value } },
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
const saveIdentity = !isCreate
|
||||||
|
? (field: string, value: unknown) => props.onSave({ [field]: value })
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Current model for display
|
||||||
|
const currentModelId = isCreate ? val!.model : String(config.model ?? "");
|
||||||
|
const selectedModel = (adapterModels ?? []).find((m) => m.id === currentModelId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* ---- Identity (edit only) ---- */}
|
||||||
|
{!isCreate && (
|
||||||
|
<div className="border-b border-border">
|
||||||
|
<div className="px-4 py-2 text-xs font-medium text-muted-foreground">Identity</div>
|
||||||
|
<div className="px-4 pb-3 space-y-3">
|
||||||
|
<Field label="Name" hint={help.name}>
|
||||||
|
<DraftInput
|
||||||
|
value={props.agent.name}
|
||||||
|
onCommit={(v) => saveIdentity!("name", v)}
|
||||||
|
className={inputClass}
|
||||||
|
placeholder="Agent name"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Title" hint={help.title}>
|
||||||
|
<DraftInput
|
||||||
|
value={props.agent.title ?? ""}
|
||||||
|
onCommit={(v) => saveIdentity!("title", v || null)}
|
||||||
|
className={inputClass}
|
||||||
|
placeholder="e.g. VP of Engineering"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Capabilities" hint={help.capabilities}>
|
||||||
|
<DraftTextarea
|
||||||
|
value={props.agent.capabilities ?? ""}
|
||||||
|
onCommit={(v) => saveIdentity!("capabilities", v || null)}
|
||||||
|
placeholder="Describe what this agent can do..."
|
||||||
|
minRows={2}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ---- Adapter type ---- */}
|
||||||
|
<div className={cn("px-4 py-2.5", isCreate ? "border-t border-border" : "border-b border-border")}>
|
||||||
|
<Field label="Adapter" hint={help.adapterType}>
|
||||||
|
{isCreate ? (
|
||||||
|
<AdapterTypeDropdown value={adapterType} onChange={(t) => set!({ adapterType: t })} />
|
||||||
|
) : (
|
||||||
|
<div className="text-sm font-mono px-2.5 py-1.5">{adapterLabels[adapterType] ?? adapterType}</div>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ---- Adapter Configuration ---- */}
|
||||||
|
<div className={cn(isCreate ? "border-t border-border" : "border-b border-border")}>
|
||||||
|
<div className="px-4 py-2 text-xs font-medium text-muted-foreground">
|
||||||
|
Adapter Configuration
|
||||||
|
</div>
|
||||||
|
<div className="px-4 pb-3 space-y-3">
|
||||||
|
{/* Working directory */}
|
||||||
|
{isLocal && (
|
||||||
|
<Field label="Working directory" hint={help.cwd}>
|
||||||
|
<div className="flex items-center gap-2 rounded-md border border-border px-2.5 py-1.5">
|
||||||
|
<FolderOpen className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||||
|
<DraftInput
|
||||||
|
value={isCreate ? val!.cwd : String(config.cwd ?? "")}
|
||||||
|
onCommit={(v) =>
|
||||||
|
isCreate ? set!({ cwd: v }) : saveConfig!("cwd", v || undefined)
|
||||||
|
}
|
||||||
|
immediate={isCreate}
|
||||||
|
className="w-full bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40"
|
||||||
|
placeholder="/path/to/project"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground hover:bg-accent/50 transition-colors shrink-0"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
// @ts-expect-error -- showDirectoryPicker is not in all TS lib defs yet
|
||||||
|
const handle = await window.showDirectoryPicker({ mode: "read" });
|
||||||
|
if (isCreate) set!({ cwd: handle.name });
|
||||||
|
else saveConfig!("cwd", handle.name);
|
||||||
|
} catch {
|
||||||
|
// user cancelled or API unsupported
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Choose
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Prompt template */}
|
||||||
|
{isLocal && (
|
||||||
|
<Field label="Prompt template" hint={help.promptTemplate}>
|
||||||
|
{isCreate ? (
|
||||||
|
<AutoExpandTextarea
|
||||||
|
placeholder="You are agent {{ agent.name }}. Your role is {{ agent.role }}..."
|
||||||
|
value={val!.promptTemplate}
|
||||||
|
onChange={(v) => set!({ promptTemplate: v })}
|
||||||
|
minRows={4}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<DraftTextarea
|
||||||
|
value={String(config.promptTemplate ?? "")}
|
||||||
|
onCommit={(v) => saveConfig!("promptTemplate", v || undefined)}
|
||||||
|
placeholder="You are agent {{ agent.name }}. Your role is {{ agent.role }}..."
|
||||||
|
minRows={4}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Claude-specific: Skip permissions */}
|
||||||
|
{adapterType === "claude_local" && (
|
||||||
|
<ToggleField
|
||||||
|
label="Skip permissions"
|
||||||
|
hint={help.dangerouslySkipPermissions}
|
||||||
|
checked={
|
||||||
|
isCreate
|
||||||
|
? val!.dangerouslySkipPermissions
|
||||||
|
: config.dangerouslySkipPermissions !== false
|
||||||
|
}
|
||||||
|
onChange={(v) =>
|
||||||
|
isCreate
|
||||||
|
? set!({ dangerouslySkipPermissions: v })
|
||||||
|
: saveConfig!("dangerouslySkipPermissions", v)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Codex-specific: Bypass sandbox + Search */}
|
||||||
|
{adapterType === "codex_local" && (
|
||||||
|
<>
|
||||||
|
<ToggleField
|
||||||
|
label="Bypass sandbox"
|
||||||
|
hint={help.dangerouslyBypassSandbox}
|
||||||
|
checked={
|
||||||
|
isCreate
|
||||||
|
? val!.dangerouslyBypassSandbox
|
||||||
|
: config.dangerouslyBypassApprovalsAndSandbox !== false
|
||||||
|
}
|
||||||
|
onChange={(v) =>
|
||||||
|
isCreate
|
||||||
|
? set!({ dangerouslyBypassSandbox: v })
|
||||||
|
: saveConfig!("dangerouslyBypassApprovalsAndSandbox", v)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ToggleField
|
||||||
|
label="Enable search"
|
||||||
|
hint={help.search}
|
||||||
|
checked={isCreate ? val!.search : !!config.search}
|
||||||
|
onChange={(v) =>
|
||||||
|
isCreate ? set!({ search: v }) : saveConfig!("search", v)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Process-specific */}
|
||||||
|
{adapterType === "process" && (
|
||||||
|
<>
|
||||||
|
<Field label="Command" hint={help.command}>
|
||||||
|
<DraftInput
|
||||||
|
value={isCreate ? val!.command : String(config.command ?? "")}
|
||||||
|
onCommit={(v) =>
|
||||||
|
isCreate ? set!({ command: v }) : saveConfig!("command", v || undefined)
|
||||||
|
}
|
||||||
|
immediate={isCreate}
|
||||||
|
className={inputClass}
|
||||||
|
placeholder="e.g. node, python"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Args (comma-separated)" hint={help.args}>
|
||||||
|
<DraftInput
|
||||||
|
value={isCreate ? val!.args : String(config.args ?? "")}
|
||||||
|
onCommit={(v) =>
|
||||||
|
isCreate
|
||||||
|
? set!({ args: v })
|
||||||
|
: saveConfig!(
|
||||||
|
"args",
|
||||||
|
v
|
||||||
|
? v
|
||||||
|
.split(",")
|
||||||
|
.map((a) => a.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
: undefined,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
immediate={isCreate}
|
||||||
|
className={inputClass}
|
||||||
|
placeholder="e.g. script.js, --flag"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* HTTP-specific */}
|
||||||
|
{adapterType === "http" && (
|
||||||
|
<Field label="Webhook URL" hint={help.webhookUrl}>
|
||||||
|
<DraftInput
|
||||||
|
value={isCreate ? val!.url : String(config.url ?? "")}
|
||||||
|
onCommit={(v) =>
|
||||||
|
isCreate ? set!({ url: v }) : saveConfig!("url", v || undefined)
|
||||||
|
}
|
||||||
|
immediate={isCreate}
|
||||||
|
className={inputClass}
|
||||||
|
placeholder="https://..."
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Advanced adapter section */}
|
||||||
|
{isLocal && (
|
||||||
|
<>
|
||||||
|
{isCreate ? (
|
||||||
|
<CollapsibleSection
|
||||||
|
title="Advanced Adapter Configuration"
|
||||||
|
open={adapterAdvancedOpen}
|
||||||
|
onToggle={() => setAdapterAdvancedOpen(!adapterAdvancedOpen)}
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<ModelDropdown
|
||||||
|
models={adapterModels ?? []}
|
||||||
|
value={val!.model}
|
||||||
|
onChange={(v) => set!({ model: v })}
|
||||||
|
open={modelOpen}
|
||||||
|
onOpenChange={setModelOpen}
|
||||||
|
/>
|
||||||
|
<Field label="Bootstrap prompt (first run)" hint={help.bootstrapPrompt}>
|
||||||
|
<AutoExpandTextarea
|
||||||
|
placeholder="Optional initial setup prompt for the first run"
|
||||||
|
value={val!.bootstrapPrompt}
|
||||||
|
onChange={(v) => set!({ bootstrapPrompt: v })}
|
||||||
|
minRows={2}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
{adapterType === "claude_local" && (
|
||||||
|
<Field label="Max turns per run" hint={help.maxTurnsPerRun}>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className={inputClass}
|
||||||
|
value={val!.maxTurnsPerRun}
|
||||||
|
onChange={(e) => set!({ maxTurnsPerRun: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CollapsibleSection>
|
||||||
|
) : (
|
||||||
|
/* Edit mode: show advanced fields inline (no collapse) */
|
||||||
|
<div className="space-y-3 pt-2 border-t border-border/50">
|
||||||
|
<div className="text-[10px] font-medium text-muted-foreground/60 uppercase tracking-wider">
|
||||||
|
Advanced
|
||||||
|
</div>
|
||||||
|
<ModelDropdown
|
||||||
|
models={adapterModels ?? []}
|
||||||
|
value={String(config.model ?? "")}
|
||||||
|
onChange={(v) => saveConfig!("model", v || undefined)}
|
||||||
|
open={modelOpen}
|
||||||
|
onOpenChange={setModelOpen}
|
||||||
|
/>
|
||||||
|
<Field label="Bootstrap prompt (first run)" hint={help.bootstrapPrompt}>
|
||||||
|
<DraftTextarea
|
||||||
|
value={String(config.bootstrapPromptTemplate ?? "")}
|
||||||
|
onCommit={(v) => saveConfig!("bootstrapPromptTemplate", v || undefined)}
|
||||||
|
placeholder="Optional initial setup prompt for the first run"
|
||||||
|
minRows={2}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
{adapterType === "claude_local" && (
|
||||||
|
<Field label="Max turns per run" hint={help.maxTurnsPerRun}>
|
||||||
|
<DraftNumberInput
|
||||||
|
value={Number(config.maxTurnsPerRun ?? 80)}
|
||||||
|
onCommit={(v) => saveConfig!("maxTurnsPerRun", v || 80)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
<Field label="Timeout (sec)" hint={help.timeoutSec}>
|
||||||
|
<DraftNumberInput
|
||||||
|
value={Number(config.timeoutSec ?? 0)}
|
||||||
|
onCommit={(v) => saveConfig!("timeoutSec", v)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Interrupt grace period (sec)" hint={help.graceSec}>
|
||||||
|
<DraftNumberInput
|
||||||
|
value={Number(config.graceSec ?? 15)}
|
||||||
|
onCommit={(v) => saveConfig!("graceSec", v)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ---- Heartbeat Policy ---- */}
|
||||||
|
{isCreate ? (
|
||||||
|
<CollapsibleSection
|
||||||
|
title="Heartbeat Policy"
|
||||||
|
icon={<Heart className="h-3 w-3" />}
|
||||||
|
open={heartbeatOpen}
|
||||||
|
onToggle={() => setHeartbeatOpen(!heartbeatOpen)}
|
||||||
|
bordered
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<ToggleWithNumber
|
||||||
|
label="Heartbeat on interval"
|
||||||
|
hint={help.heartbeatInterval}
|
||||||
|
checked={val!.heartbeatEnabled}
|
||||||
|
onCheckedChange={(v) => set!({ heartbeatEnabled: v })}
|
||||||
|
number={val!.intervalSec}
|
||||||
|
onNumberChange={(v) => set!({ intervalSec: v })}
|
||||||
|
numberLabel="sec"
|
||||||
|
numberPrefix="Run heartbeat every"
|
||||||
|
numberHint={help.intervalSec}
|
||||||
|
showNumber={val!.heartbeatEnabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CollapsibleSection>
|
||||||
|
) : (
|
||||||
|
<div className="border-b border-border">
|
||||||
|
<div className="px-4 py-2 text-xs font-medium text-muted-foreground flex items-center gap-2">
|
||||||
|
<Heart className="h-3 w-3" />
|
||||||
|
Heartbeat Policy
|
||||||
|
</div>
|
||||||
|
<div className="px-4 pb-3 space-y-3">
|
||||||
|
<ToggleWithNumber
|
||||||
|
label="Heartbeat on interval"
|
||||||
|
hint={help.heartbeatInterval}
|
||||||
|
checked={heartbeat.enabled !== false}
|
||||||
|
onCheckedChange={(v) => saveHeartbeat!("enabled", v)}
|
||||||
|
number={Number(heartbeat.intervalSec ?? 300)}
|
||||||
|
onNumberChange={(v) => saveHeartbeat!("intervalSec", v)}
|
||||||
|
numberLabel="sec"
|
||||||
|
numberPrefix="Run heartbeat every"
|
||||||
|
numberHint={help.intervalSec}
|
||||||
|
showNumber={heartbeat.enabled !== false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Edit-only: wake-on-* and cooldown */}
|
||||||
|
<div className="space-y-3 pt-2 border-t border-border/50">
|
||||||
|
<div className="text-[10px] font-medium text-muted-foreground/60 uppercase tracking-wider">
|
||||||
|
Advanced
|
||||||
|
</div>
|
||||||
|
<ToggleField
|
||||||
|
label="Wake on assignment"
|
||||||
|
hint={help.wakeOnAssignment}
|
||||||
|
checked={heartbeat.wakeOnAssignment !== false}
|
||||||
|
onChange={(v) => saveHeartbeat!("wakeOnAssignment", v)}
|
||||||
|
/>
|
||||||
|
<ToggleField
|
||||||
|
label="Wake on on-demand"
|
||||||
|
hint={help.wakeOnOnDemand}
|
||||||
|
checked={heartbeat.wakeOnOnDemand !== false}
|
||||||
|
onChange={(v) => saveHeartbeat!("wakeOnOnDemand", v)}
|
||||||
|
/>
|
||||||
|
<ToggleField
|
||||||
|
label="Wake on automation"
|
||||||
|
hint={help.wakeOnAutomation}
|
||||||
|
checked={heartbeat.wakeOnAutomation !== false}
|
||||||
|
onChange={(v) => saveHeartbeat!("wakeOnAutomation", v)}
|
||||||
|
/>
|
||||||
|
<Field label="Cooldown (sec)" hint={help.cooldownSec}>
|
||||||
|
<DraftNumberInput
|
||||||
|
value={Number(heartbeat.cooldownSec ?? 10)}
|
||||||
|
onCommit={(v) => saveHeartbeat!("cooldownSec", v)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ---- Runtime (edit only) ---- */}
|
||||||
|
{!isCreate && (
|
||||||
|
<div className="border-b border-border">
|
||||||
|
<div className="px-4 py-2 text-xs font-medium text-muted-foreground">Runtime</div>
|
||||||
|
<div className="px-4 pb-3 space-y-3">
|
||||||
|
<Field label="Context mode" hint={help.contextMode}>
|
||||||
|
<div className="text-sm font-mono px-2.5 py-1.5">
|
||||||
|
{props.agent.contextMode}
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
<Field label="Monthly budget (cents)" hint={help.budgetMonthlyCents}>
|
||||||
|
<DraftNumberInput
|
||||||
|
value={props.agent.budgetMonthlyCents}
|
||||||
|
onCommit={(v) => saveIdentity!("budgetMonthlyCents", v)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Internal sub-components ---- */
|
||||||
|
|
||||||
|
function AdapterTypeDropdown({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (type: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent/50 transition-colors w-full justify-between">
|
||||||
|
<span>{adapterLabels[value] ?? value}</span>
|
||||||
|
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-1" align="start">
|
||||||
|
{AGENT_ADAPTER_TYPES.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
|
||||||
|
t === value && "bg-accent",
|
||||||
|
)}
|
||||||
|
onClick={() => onChange(t)}
|
||||||
|
>
|
||||||
|
{adapterLabels[t] ?? t}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModelDropdown({
|
||||||
|
models,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: {
|
||||||
|
models: AdapterModel[];
|
||||||
|
value: string;
|
||||||
|
onChange: (id: string) => void;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const selected = models.find((m) => m.id === value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Field label="Model" hint={help.model}>
|
||||||
|
<Popover open={open} onOpenChange={onOpenChange}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent/50 transition-colors w-full justify-between">
|
||||||
|
<span className={cn(!value && "text-muted-foreground")}>
|
||||||
|
{selected ? selected.label : value || "Default"}
|
||||||
|
</span>
|
||||||
|
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-1" align="start">
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
|
||||||
|
!value && "bg-accent",
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
onChange("");
|
||||||
|
onOpenChange(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Default
|
||||||
|
</button>
|
||||||
|
{models.map((m) => (
|
||||||
|
<button
|
||||||
|
key={m.id}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-between w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
|
||||||
|
m.id === value && "bg-accent",
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
onChange(m.id);
|
||||||
|
onOpenChange(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{m.label}</span>
|
||||||
|
<span className="text-xs text-muted-foreground font-mono">{m.id}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
}
|
||||||
370
ui/src/components/agent-config-primitives.tsx
Normal file
370
ui/src/components/agent-config-primitives.tsx
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
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. 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.",
|
||||||
|
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.",
|
||||||
|
wakeOnAssignment: "Automatically wake this agent when a new issue is assigned to it.",
|
||||||
|
wakeOnOnDemand: "Allow this agent to be woken on demand via the API or UI.",
|
||||||
|
wakeOnAutomation: "Allow automated systems to wake this agent.",
|
||||||
|
cooldownSec: "Minimum seconds between consecutive heartbeat runs.",
|
||||||
|
contextMode: "How context is managed between runs (thin = fresh context each run).",
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user