Files
paperclip/ui/src/components/agent-config-primitives.tsx
Forgotten d6024b3ca5 Enhance UI: favicon, AgentDetail overhaul, PageTabBar, and config form
Add favicon and web manifest branding assets. Major AgentDetail page
rework with tabbed sections, run history, and live status. Add
PageTabBar component for consistent page-level tabs. Expand
AgentConfigForm with more adapter fields. Improve NewAgentDialog,
OnboardingWizard, and Issues page layouts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 13:02:23 -06:00

374 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.",
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. One KEY=VALUE per line.",
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}
/>
);
}