Merges paperclipai/paperclip#62 onto latest master (494448d). Adds complete OpenCode provider with strict model selection, dynamic model discovery, CLI/server/UI adapter registration. Resolved conflicts with master's cursor adapter additions, node v24 typing, and containerized opencode support (201d91b). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
469 lines
15 KiB
TypeScript
469 lines
15 KiB
TypeScript
import { useState, useRef, useEffect, useCallback } from "react";
|
|
import {
|
|
Tooltip,
|
|
TooltipTrigger,
|
|
TooltipContent,
|
|
} from "@/components/ui/tooltip";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
} from "@/components/ui/dialog";
|
|
import { Button } from "@/components/ui/button";
|
|
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/OpenCode), OpenClaw webhook, spawned process, or generic HTTP webhook.",
|
|
cwd: "Default working directory fallback for local adapters. 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.",
|
|
chrome: "Enable Claude's Chrome integration by passing --chrome.",
|
|
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.",
|
|
maxTurnsPerRun: "Maximum number of agentic turns (tool calls) per heartbeat run.",
|
|
command: "The command to execute (e.g. node, python).",
|
|
localCommand: "Override the path to the CLI command you want the adapter to call (e.g. /usr/local/bin/claude, codex, opencode).",
|
|
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)",
|
|
opencode_local: "OpenCode (local)",
|
|
openclaw: "OpenClaw",
|
|
cursor: "Cursor (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}
|
|
/>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* "Choose" button that opens a dialog explaining the user must manually
|
|
* type the path due to browser security limitations.
|
|
*/
|
|
export function ChoosePathButton() {
|
|
const [open, setOpen] = useState(false);
|
|
return (
|
|
<>
|
|
<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={() => setOpen(true)}
|
|
>
|
|
Choose
|
|
</button>
|
|
<Dialog open={open} onOpenChange={setOpen}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Specify path manually</DialogTitle>
|
|
<DialogDescription>
|
|
Browser security blocks apps from reading full local paths via a file picker.
|
|
Copy the absolute path and paste it into the input.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-4 text-sm">
|
|
<section className="space-y-1.5">
|
|
<p className="font-medium">macOS (Finder)</p>
|
|
<ol className="list-decimal space-y-1 pl-5 text-muted-foreground">
|
|
<li>Find the folder in Finder.</li>
|
|
<li>Hold <kbd>Option</kbd> and right-click the folder.</li>
|
|
<li>Click "Copy <folder name> as Pathname".</li>
|
|
<li>Paste the result into the path input.</li>
|
|
</ol>
|
|
<p className="rounded-md bg-muted px-2 py-1 font-mono text-xs">
|
|
/Users/yourname/Documents/project
|
|
</p>
|
|
</section>
|
|
<section className="space-y-1.5">
|
|
<p className="font-medium">Windows (File Explorer)</p>
|
|
<ol className="list-decimal space-y-1 pl-5 text-muted-foreground">
|
|
<li>Find the folder in File Explorer.</li>
|
|
<li>Hold <kbd>Shift</kbd> and right-click the folder.</li>
|
|
<li>Click "Copy as path".</li>
|
|
<li>Paste the result into the path input.</li>
|
|
</ol>
|
|
<p className="rounded-md bg-muted px-2 py-1 font-mono text-xs">
|
|
C:\Users\yourname\Documents\project
|
|
</p>
|
|
</section>
|
|
<section className="space-y-1.5">
|
|
<p className="font-medium">Terminal fallback (macOS/Linux)</p>
|
|
<ol className="list-decimal space-y-1 pl-5 text-muted-foreground">
|
|
<li>Run <code>cd /path/to/folder</code>.</li>
|
|
<li>Run <code>pwd</code>.</li>
|
|
<li>Copy the output and paste it into the path input.</li>
|
|
</ol>
|
|
</section>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setOpen(false)}>
|
|
OK
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Label + input rendered on the same line (inline layout for compact fields).
|
|
*/
|
|
export function InlineField({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
|
|
return (
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex items-center gap-1.5 shrink-0">
|
|
<label className="text-xs text-muted-foreground">{label}</label>
|
|
{hint && <HintIcon text={hint} />}
|
|
</div>
|
|
<div className="w-24 ml-auto">{children}</div>
|
|
</div>
|
|
);
|
|
}
|