Build out agent management UI: detail page, create dialog, list view
Add NewAgentDialog for creating agents with adapter config. Expand AgentDetail page with tabbed view (overview, runs, config, logs), run history timeline, and live status. Enhance Agents list page with richer cards and filtering. Update AgentProperties panel, API client, query keys, and utility helpers. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import type { Agent, AgentKeyCreated, HeartbeatRun } from "@paperclip/shared";
|
||||
import type { Agent, AgentKeyCreated, AgentRuntimeState, HeartbeatRun } from "@paperclip/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
export interface OrgNode {
|
||||
@@ -20,6 +20,8 @@ export const agentsApi = {
|
||||
resume: (id: string) => api.post<Agent>(`/agents/${id}/resume`, {}),
|
||||
terminate: (id: string) => api.post<Agent>(`/agents/${id}/terminate`, {}),
|
||||
createKey: (id: string, name: string) => api.post<AgentKeyCreated>(`/agents/${id}/keys`, { name }),
|
||||
runtimeState: (id: string) => api.get<AgentRuntimeState>(`/agents/${id}/runtime-state`),
|
||||
resetSession: (id: string) => api.post<void>(`/agents/${id}/runtime-state/reset-session`, {}),
|
||||
invoke: (id: string) => api.post<HeartbeatRun>(`/agents/${id}/heartbeat/invoke`, {}),
|
||||
wakeup: (
|
||||
id: string,
|
||||
|
||||
@@ -14,4 +14,5 @@ export const heartbeatsApi = {
|
||||
api.get<{ runId: string; store: string; logRef: string; content: string; nextOffset?: number }>(
|
||||
`/heartbeat-runs/${runId}/log?offset=${encodeURIComponent(String(offset))}&limitBytes=${encodeURIComponent(String(limitBytes))}`,
|
||||
),
|
||||
cancel: (runId: string) => api.post<void>(`/heartbeat-runs/${runId}/cancel`, {}),
|
||||
};
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import type { Agent } from "@paperclip/shared";
|
||||
import type { Agent, AgentRuntimeState } from "@paperclip/shared";
|
||||
import { StatusBadge } from "./StatusBadge";
|
||||
import { formatCents, formatDate } from "../lib/utils";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
interface AgentPropertiesProps {
|
||||
agent: Agent;
|
||||
runtimeState?: AgentRuntimeState;
|
||||
}
|
||||
|
||||
const adapterLabels: Record<string, string> = {
|
||||
claude_local: "Claude (local)",
|
||||
codex_local: "Codex (local)",
|
||||
process: "Process",
|
||||
http: "HTTP",
|
||||
};
|
||||
|
||||
function PropertyRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-1.5">
|
||||
@@ -16,7 +24,7 @@ function PropertyRow({ label, children }: { label: string; children: React.React
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentProperties({ agent }: AgentPropertiesProps) {
|
||||
export function AgentProperties({ agent, runtimeState }: AgentPropertiesProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
@@ -32,7 +40,7 @@ export function AgentProperties({ agent }: AgentPropertiesProps) {
|
||||
</PropertyRow>
|
||||
)}
|
||||
<PropertyRow label="Adapter">
|
||||
<span className="text-sm font-mono">{agent.adapterType}</span>
|
||||
<span className="text-sm font-mono">{adapterLabels[agent.adapterType] ?? agent.adapterType}</span>
|
||||
</PropertyRow>
|
||||
<PropertyRow label="Context">
|
||||
<span className="text-sm">{agent.contextMode}</span>
|
||||
@@ -60,6 +68,16 @@ export function AgentProperties({ agent }: AgentPropertiesProps) {
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-1">
|
||||
{runtimeState?.sessionId && (
|
||||
<PropertyRow label="Session">
|
||||
<span className="text-xs font-mono">{runtimeState.sessionId.slice(0, 12)}...</span>
|
||||
</PropertyRow>
|
||||
)}
|
||||
{runtimeState?.lastError && (
|
||||
<PropertyRow label="Last error">
|
||||
<span className="text-xs text-red-400 truncate max-w-[160px]">{runtimeState.lastError}</span>
|
||||
</PropertyRow>
|
||||
)}
|
||||
{agent.lastHeartbeatAt && (
|
||||
<PropertyRow label="Last Heartbeat">
|
||||
<span className="text-sm">{formatDate(agent.lastHeartbeatAt)}</span>
|
||||
|
||||
@@ -34,7 +34,7 @@ export function CommandPalette() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { openNewIssue } = useDialog();
|
||||
const { openNewIssue, openNewAgent } = useDialog();
|
||||
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
@@ -133,7 +133,12 @@ export function CommandPalette() {
|
||||
Create new issue
|
||||
<span className="ml-auto text-xs text-muted-foreground">C</span>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={() => go("/agents")}>
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
setOpen(false);
|
||||
openNewAgent();
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create new agent
|
||||
</CommandItem>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { PropertiesPanel } from "./PropertiesPanel";
|
||||
import { CommandPalette } from "./CommandPalette";
|
||||
import { NewIssueDialog } from "./NewIssueDialog";
|
||||
import { NewProjectDialog } from "./NewProjectDialog";
|
||||
import { NewAgentDialog } from "./NewAgentDialog";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { usePanel } from "../context/PanelContext";
|
||||
import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts";
|
||||
@@ -49,6 +50,7 @@ export function Layout() {
|
||||
<CommandPalette />
|
||||
<NewIssueDialog />
|
||||
<NewProjectDialog />
|
||||
<NewAgentDialog />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
664
ui/src/components/NewAgentDialog.tsx
Normal file
664
ui/src/components/NewAgentDialog.tsx
Normal file
@@ -0,0 +1,664 @@
|
||||
import { useState } 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, AGENT_ADAPTER_TYPES } 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 {
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
Bot,
|
||||
User,
|
||||
Shield,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
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",
|
||||
};
|
||||
|
||||
const adapterLabels: Record<string, string> = {
|
||||
claude_local: "Claude (local)",
|
||||
codex_local: "Codex (local)",
|
||||
process: "Process",
|
||||
http: "HTTP",
|
||||
};
|
||||
|
||||
export function NewAgentDialog() {
|
||||
const { newAgentOpen, closeNewAgent } = useDialog();
|
||||
const { selectedCompanyId, selectedCompany } = useCompany();
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
// Identity
|
||||
const [name, setName] = useState("");
|
||||
const [title, setTitle] = useState("");
|
||||
const [role, setRole] = useState("general");
|
||||
const [reportsTo, setReportsTo] = useState("");
|
||||
const [capabilities, setCapabilities] = useState("");
|
||||
|
||||
// Adapter
|
||||
const [adapterType, setAdapterType] = useState<string>("claude_local");
|
||||
const [cwd, setCwd] = useState("");
|
||||
const [promptTemplate, setPromptTemplate] = useState("");
|
||||
const [bootstrapPrompt, setBootstrapPrompt] = useState("");
|
||||
const [model, setModel] = useState("");
|
||||
|
||||
// claude_local specific
|
||||
const [maxTurnsPerRun, setMaxTurnsPerRun] = useState(80);
|
||||
const [dangerouslySkipPermissions, setDangerouslySkipPermissions] = useState(true);
|
||||
|
||||
// codex_local specific
|
||||
const [search, setSearch] = useState(false);
|
||||
const [dangerouslyBypassSandbox, setDangerouslyBypassSandbox] = useState(true);
|
||||
|
||||
// process specific
|
||||
const [command, setCommand] = useState("");
|
||||
const [args, setArgs] = useState("");
|
||||
|
||||
// http specific
|
||||
const [url, setUrl] = useState("");
|
||||
|
||||
// Heartbeat
|
||||
const [heartbeatEnabled, setHeartbeatEnabled] = useState(true);
|
||||
const [intervalSec, setIntervalSec] = useState(300);
|
||||
const [wakeOnAssignment, setWakeOnAssignment] = useState(true);
|
||||
const [wakeOnOnDemand, setWakeOnOnDemand] = useState(true);
|
||||
const [wakeOnAutomation, setWakeOnAutomation] = useState(true);
|
||||
const [cooldownSec, setCooldownSec] = useState(10);
|
||||
|
||||
// Runtime
|
||||
const [contextMode, setContextMode] = useState("thin");
|
||||
const [budgetMonthlyCents, setBudgetMonthlyCents] = useState(0);
|
||||
const [timeoutSec, setTimeoutSec] = useState(900);
|
||||
const [graceSec, setGraceSec] = useState(15);
|
||||
|
||||
// Sections
|
||||
const [adapterOpen, setAdapterOpen] = useState(true);
|
||||
const [heartbeatOpen, setHeartbeatOpen] = useState(false);
|
||||
const [runtimeOpen, setRuntimeOpen] = useState(false);
|
||||
|
||||
// Popover states
|
||||
const [roleOpen, setRoleOpen] = useState(false);
|
||||
const [reportsToOpen, setReportsToOpen] = useState(false);
|
||||
const [adapterTypeOpen, setAdapterTypeOpen] = useState(false);
|
||||
|
||||
const { data: agents } = useQuery({
|
||||
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
||||
queryFn: () => agentsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId && newAgentOpen,
|
||||
});
|
||||
|
||||
const isFirstAgent = !agents || agents.length === 0;
|
||||
const effectiveRole = isFirstAgent ? "ceo" : role;
|
||||
|
||||
const createAgent = useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) =>
|
||||
agentsApi.create(selectedCompanyId!, data),
|
||||
onSuccess: (agent) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(selectedCompanyId!) });
|
||||
reset();
|
||||
closeNewAgent();
|
||||
navigate(`/agents/${agent.id}`);
|
||||
},
|
||||
});
|
||||
|
||||
function reset() {
|
||||
setName("");
|
||||
setTitle("");
|
||||
setRole("general");
|
||||
setReportsTo("");
|
||||
setCapabilities("");
|
||||
setAdapterType("claude_local");
|
||||
setCwd("");
|
||||
setPromptTemplate("");
|
||||
setBootstrapPrompt("");
|
||||
setModel("");
|
||||
setMaxTurnsPerRun(80);
|
||||
setDangerouslySkipPermissions(true);
|
||||
setSearch(false);
|
||||
setDangerouslyBypassSandbox(true);
|
||||
setCommand("");
|
||||
setArgs("");
|
||||
setUrl("");
|
||||
setHeartbeatEnabled(true);
|
||||
setIntervalSec(300);
|
||||
setWakeOnAssignment(true);
|
||||
setWakeOnOnDemand(true);
|
||||
setWakeOnAutomation(true);
|
||||
setCooldownSec(10);
|
||||
setContextMode("thin");
|
||||
setBudgetMonthlyCents(0);
|
||||
setTimeoutSec(900);
|
||||
setGraceSec(15);
|
||||
setExpanded(false);
|
||||
setAdapterOpen(true);
|
||||
setHeartbeatOpen(false);
|
||||
setRuntimeOpen(false);
|
||||
}
|
||||
|
||||
function buildAdapterConfig() {
|
||||
const config: Record<string, unknown> = {};
|
||||
if (cwd) config.cwd = cwd;
|
||||
if (promptTemplate) config.promptTemplate = promptTemplate;
|
||||
if (bootstrapPrompt) config.bootstrapPromptTemplate = bootstrapPrompt;
|
||||
if (model) config.model = model;
|
||||
config.timeoutSec = timeoutSec;
|
||||
config.graceSec = graceSec;
|
||||
|
||||
if (adapterType === "claude_local") {
|
||||
config.maxTurnsPerRun = maxTurnsPerRun;
|
||||
config.dangerouslySkipPermissions = dangerouslySkipPermissions;
|
||||
} else if (adapterType === "codex_local") {
|
||||
config.search = search;
|
||||
config.dangerouslyBypassApprovalsAndSandbox = dangerouslyBypassSandbox;
|
||||
} else if (adapterType === "process") {
|
||||
if (command) config.command = command;
|
||||
if (args) config.args = args.split(",").map((a) => a.trim()).filter(Boolean);
|
||||
} else if (adapterType === "http") {
|
||||
if (url) config.url = url;
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
if (!selectedCompanyId || !name.trim()) return;
|
||||
createAgent.mutate({
|
||||
name: name.trim(),
|
||||
role: effectiveRole,
|
||||
...(title.trim() ? { title: title.trim() } : {}),
|
||||
...(reportsTo ? { reportsTo } : {}),
|
||||
...(capabilities.trim() ? { capabilities: capabilities.trim() } : {}),
|
||||
adapterType,
|
||||
adapterConfig: buildAdapterConfig(),
|
||||
runtimeConfig: {
|
||||
heartbeat: {
|
||||
enabled: heartbeatEnabled,
|
||||
intervalSec,
|
||||
wakeOnAssignment,
|
||||
wakeOnOnDemand,
|
||||
wakeOnAutomation,
|
||||
cooldownSec,
|
||||
},
|
||||
},
|
||||
contextMode,
|
||||
budgetMonthlyCents,
|
||||
});
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
const currentAgent = (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">›</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">×</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cn("overflow-y-auto", expanded ? "max-h-[70vh]" : "max-h-[50vh]")}>
|
||||
{/* 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 */}
|
||||
<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" />
|
||||
{currentAgent ? currentAgent.name : isFirstAgent ? "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>
|
||||
|
||||
{/* Adapter type */}
|
||||
<Popover open={adapterTypeOpen} onOpenChange={setAdapterTypeOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors">
|
||||
<Bot className="h-3 w-3 text-muted-foreground" />
|
||||
{adapterLabels[adapterType] ?? adapterType}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-40 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-xs rounded hover:bg-accent/50",
|
||||
t === adapterType && "bg-accent"
|
||||
)}
|
||||
onClick={() => { setAdapterType(t); setAdapterTypeOpen(false); }}
|
||||
>
|
||||
{adapterLabels[t] ?? t}
|
||||
</button>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* Capabilities */}
|
||||
<div className="px-4 py-2 border-t border-border">
|
||||
<input
|
||||
className="w-full bg-transparent outline-none text-sm text-muted-foreground placeholder:text-muted-foreground/40"
|
||||
placeholder="Capabilities (what can this agent do?)"
|
||||
value={capabilities}
|
||||
onChange={(e) => setCapabilities(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Adapter Config Section */}
|
||||
<CollapsibleSection
|
||||
title="Adapter Configuration"
|
||||
open={adapterOpen}
|
||||
onToggle={() => setAdapterOpen(!adapterOpen)}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<Field label="Working directory">
|
||||
<input
|
||||
className="w-full bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40"
|
||||
placeholder="/path/to/project"
|
||||
value={cwd}
|
||||
onChange={(e) => setCwd(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Prompt template">
|
||||
<textarea
|
||||
className="w-full bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40 resize-none min-h-[60px]"
|
||||
placeholder="You are agent {{ agent.name }}..."
|
||||
value={promptTemplate}
|
||||
onChange={(e) => setPromptTemplate(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Bootstrap prompt (first run)">
|
||||
<textarea
|
||||
className="w-full bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40 resize-none min-h-[40px]"
|
||||
placeholder="Optional initial setup prompt"
|
||||
value={bootstrapPrompt}
|
||||
onChange={(e) => setBootstrapPrompt(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Model">
|
||||
<input
|
||||
className="w-full bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40"
|
||||
placeholder="e.g. claude-sonnet-4-5-20250929"
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{adapterType === "claude_local" && (
|
||||
<>
|
||||
<Field label="Max turns per run">
|
||||
<input
|
||||
type="number"
|
||||
className="w-full bg-transparent outline-none text-sm font-mono"
|
||||
value={maxTurnsPerRun}
|
||||
onChange={(e) => setMaxTurnsPerRun(Number(e.target.value))}
|
||||
/>
|
||||
</Field>
|
||||
<ToggleField
|
||||
label="Skip permissions"
|
||||
checked={dangerouslySkipPermissions}
|
||||
onChange={setDangerouslySkipPermissions}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{adapterType === "codex_local" && (
|
||||
<>
|
||||
<ToggleField label="Enable search" checked={search} onChange={setSearch} />
|
||||
<ToggleField
|
||||
label="Bypass sandbox"
|
||||
checked={dangerouslyBypassSandbox}
|
||||
onChange={setDangerouslyBypassSandbox}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{adapterType === "process" && (
|
||||
<>
|
||||
<Field label="Command">
|
||||
<input
|
||||
className="w-full bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40"
|
||||
placeholder="e.g. node, python"
|
||||
value={command}
|
||||
onChange={(e) => setCommand(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Args (comma-separated)">
|
||||
<input
|
||||
className="w-full bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40"
|
||||
placeholder="e.g. script.js, --flag"
|
||||
value={args}
|
||||
onChange={(e) => setArgs(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
{adapterType === "http" && (
|
||||
<Field label="Webhook URL">
|
||||
<input
|
||||
className="w-full bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40"
|
||||
placeholder="https://..."
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Heartbeat Policy Section */}
|
||||
<CollapsibleSection
|
||||
title="Heartbeat Policy"
|
||||
open={heartbeatOpen}
|
||||
onToggle={() => setHeartbeatOpen(!heartbeatOpen)}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<ToggleField label="Enabled" checked={heartbeatEnabled} onChange={setHeartbeatEnabled} />
|
||||
<Field label="Interval (seconds)">
|
||||
<input
|
||||
type="number"
|
||||
className="w-full bg-transparent outline-none text-sm font-mono"
|
||||
value={intervalSec}
|
||||
onChange={(e) => setIntervalSec(Number(e.target.value))}
|
||||
/>
|
||||
</Field>
|
||||
<ToggleField label="Wake on assignment" checked={wakeOnAssignment} onChange={setWakeOnAssignment} />
|
||||
<ToggleField label="Wake on on-demand" checked={wakeOnOnDemand} onChange={setWakeOnOnDemand} />
|
||||
<ToggleField label="Wake on automation" checked={wakeOnAutomation} onChange={setWakeOnAutomation} />
|
||||
<Field label="Cooldown (seconds)">
|
||||
<input
|
||||
type="number"
|
||||
className="w-full bg-transparent outline-none text-sm font-mono"
|
||||
value={cooldownSec}
|
||||
onChange={(e) => setCooldownSec(Number(e.target.value))}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Runtime Section */}
|
||||
<CollapsibleSection
|
||||
title="Runtime"
|
||||
open={runtimeOpen}
|
||||
onToggle={() => setRuntimeOpen(!runtimeOpen)}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<Field label="Context mode">
|
||||
<div className="flex gap-2">
|
||||
{(["thin", "fat"] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
className={cn(
|
||||
"px-2 py-1 text-xs rounded border",
|
||||
m === contextMode
|
||||
? "border-foreground bg-accent"
|
||||
: "border-border hover:bg-accent/50"
|
||||
)}
|
||||
onClick={() => setContextMode(m)}
|
||||
>
|
||||
{m}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="Monthly budget (cents)">
|
||||
<input
|
||||
type="number"
|
||||
className="w-full bg-transparent outline-none text-sm font-mono"
|
||||
value={budgetMonthlyCents}
|
||||
onChange={(e) => setBudgetMonthlyCents(Number(e.target.value))}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Timeout (seconds)">
|
||||
<input
|
||||
type="number"
|
||||
className="w-full bg-transparent outline-none text-sm font-mono"
|
||||
value={timeoutSec}
|
||||
onChange={(e) => setTimeoutSec(Number(e.target.value))}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Grace period (seconds)">
|
||||
<input
|
||||
type="number"
|
||||
className="w-full bg-transparent outline-none text-sm font-mono"
|
||||
value={graceSec}
|
||||
onChange={(e) => setGraceSec(Number(e.target.value))}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
function CollapsibleSection({
|
||||
title,
|
||||
open,
|
||||
onToggle,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="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" />}
|
||||
{title}
|
||||
</button>
|
||||
{open && <div className="px-4 pb-3">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{label}</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleField({
|
||||
label,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ export const queryKeys = {
|
||||
agents: {
|
||||
list: (companyId: string) => ["agents", companyId] as const,
|
||||
detail: (id: string) => ["agents", "detail", id] as const,
|
||||
runtimeState: (id: string) => ["agents", "runtime-state", id] as const,
|
||||
},
|
||||
issues: {
|
||||
list: (companyId: string) => ["issues", companyId] as const,
|
||||
|
||||
@@ -16,3 +16,23 @@ export function formatDate(date: Date | string): string {
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export function relativeTime(date: Date | string): string {
|
||||
const now = Date.now();
|
||||
const then = new Date(date).getTime();
|
||||
const diffSec = Math.round((now - then) / 1000);
|
||||
if (diffSec < 60) return "just now";
|
||||
const diffMin = Math.round(diffSec / 60);
|
||||
if (diffMin < 60) return `${diffMin}m ago`;
|
||||
const diffHr = Math.round(diffMin / 60);
|
||||
if (diffHr < 24) return `${diffHr}h ago`;
|
||||
const diffDay = Math.round(diffHr / 24);
|
||||
if (diffDay < 30) return `${diffDay}d ago`;
|
||||
return formatDate(date);
|
||||
}
|
||||
|
||||
export function formatTokens(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,20 +1,50 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { agentsApi, type OrgNode } from "../api/agents";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { StatusBadge } from "../components/StatusBadge";
|
||||
import { EntityRow } from "../components/EntityRow";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { formatCents } from "../lib/utils";
|
||||
import { Bot } from "lucide-react";
|
||||
import { formatCents, relativeTime, cn } from "../lib/utils";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Bot, Plus, List, GitBranch } from "lucide-react";
|
||||
import type { Agent } from "@paperclip/shared";
|
||||
|
||||
const adapterLabels: Record<string, string> = {
|
||||
claude_local: "Claude",
|
||||
codex_local: "Codex",
|
||||
process: "Process",
|
||||
http: "HTTP",
|
||||
};
|
||||
|
||||
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",
|
||||
};
|
||||
|
||||
type FilterTab = "all" | "active" | "paused" | "error";
|
||||
|
||||
function filterAgents(agents: Agent[], tab: FilterTab): Agent[] {
|
||||
if (tab === "all") return agents;
|
||||
if (tab === "active") return agents.filter((a) => a.status === "active" || a.status === "running" || a.status === "idle");
|
||||
if (tab === "paused") return agents.filter((a) => a.status === "paused");
|
||||
if (tab === "error") return agents.filter((a) => a.status === "error" || a.status === "terminated");
|
||||
return agents;
|
||||
}
|
||||
|
||||
export function Agents() {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { openNewAgent } = useDialog();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const navigate = useNavigate();
|
||||
const [tab, setTab] = useState<FilterTab>("all");
|
||||
const [view, setView] = useState<"list" | "org">("list");
|
||||
|
||||
const { data: agents, isLoading, error } = useQuery({
|
||||
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
||||
@@ -22,6 +52,12 @@ export function Agents() {
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
||||
const { data: orgTree } = useQuery({
|
||||
queryKey: queryKeys.org(selectedCompanyId!),
|
||||
queryFn: () => agentsApi.org(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId && view === "org",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([{ label: "Agents" }]);
|
||||
}, [setBreadcrumbs]);
|
||||
@@ -30,9 +66,51 @@ export function Agents() {
|
||||
return <EmptyState icon={Bot} message="Select a company to view agents." />;
|
||||
}
|
||||
|
||||
const filtered = filterAgents(agents ?? [], tab);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">Agents</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* View toggle */}
|
||||
<div className="flex items-center border border-border rounded-md">
|
||||
<button
|
||||
className={cn(
|
||||
"p-1.5 transition-colors",
|
||||
view === "list" ? "bg-accent text-foreground" : "text-muted-foreground hover:bg-accent/50"
|
||||
)}
|
||||
onClick={() => setView("list")}
|
||||
>
|
||||
<List className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
"p-1.5 transition-colors",
|
||||
view === "org" ? "bg-accent text-foreground" : "text-muted-foreground hover:bg-accent/50"
|
||||
)}
|
||||
onClick={() => setView("org")}
|
||||
>
|
||||
<GitBranch className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<Button size="sm" onClick={openNewAgent}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1.5" />
|
||||
New Agent
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{view === "list" && (
|
||||
<Tabs value={tab} onValueChange={(v) => setTab(v as FilterTab)}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="all">All{agents ? ` (${agents.length})` : ""}</TabsTrigger>
|
||||
<TabsTrigger value="active">Active</TabsTrigger>
|
||||
<TabsTrigger value="paused">Paused</TabsTrigger>
|
||||
<TabsTrigger value="error">Error</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
{isLoading && <p className="text-sm text-muted-foreground">Loading...</p>}
|
||||
{error && <p className="text-sm text-destructive">{error.message}</p>}
|
||||
@@ -40,13 +118,16 @@ export function Agents() {
|
||||
{agents && agents.length === 0 && (
|
||||
<EmptyState
|
||||
icon={Bot}
|
||||
message="No agents yet. Agents are created via the API or templates."
|
||||
message="No agents yet."
|
||||
action="New Agent"
|
||||
onAction={openNewAgent}
|
||||
/>
|
||||
)}
|
||||
|
||||
{agents && agents.length > 0 && (
|
||||
{/* List view */}
|
||||
{view === "list" && filtered.length > 0 && (
|
||||
<div className="border border-border rounded-md">
|
||||
{agents.map((agent) => {
|
||||
{filtered.map((agent) => {
|
||||
const budgetPct =
|
||||
agent.budgetMonthlyCents > 0
|
||||
? Math.round((agent.spentMonthlyCents / agent.budgetMonthlyCents) * 100)
|
||||
@@ -62,7 +143,9 @@ export function Agents() {
|
||||
<span className="relative flex h-2.5 w-2.5">
|
||||
<span
|
||||
className={`absolute inline-flex h-full w-full rounded-full ${
|
||||
agent.status === "active"
|
||||
agent.status === "running"
|
||||
? "bg-cyan-400 animate-pulse"
|
||||
: agent.status === "active"
|
||||
? "bg-green-400"
|
||||
: agent.status === "paused"
|
||||
? "bg-yellow-400"
|
||||
@@ -75,6 +158,14 @@ export function Agents() {
|
||||
}
|
||||
trailing={
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
{adapterLabels[agent.adapterType] ?? agent.adapterType}
|
||||
</span>
|
||||
{agent.lastHeartbeatAt && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{relativeTime(agent.lastHeartbeatAt)}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-16 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
@@ -100,6 +191,73 @@ export function Agents() {
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view === "list" && agents && agents.length > 0 && filtered.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">
|
||||
No agents match the selected filter.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Org chart view */}
|
||||
{view === "org" && orgTree && orgTree.length > 0 && (
|
||||
<div className="py-4">
|
||||
{orgTree.map((node) => (
|
||||
<OrgTreeNode key={node.id} node={node} depth={0} navigate={navigate} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view === "org" && orgTree && orgTree.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">
|
||||
No organizational hierarchy defined.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OrgTreeNode({
|
||||
node,
|
||||
depth,
|
||||
navigate,
|
||||
}: {
|
||||
node: OrgNode;
|
||||
depth: number;
|
||||
navigate: (path: string) => void;
|
||||
}) {
|
||||
const statusColor =
|
||||
node.status === "active" || node.status === "running"
|
||||
? "bg-green-400"
|
||||
: node.status === "paused"
|
||||
? "bg-yellow-400"
|
||||
: node.status === "error"
|
||||
? "bg-red-400"
|
||||
: "bg-neutral-400";
|
||||
|
||||
return (
|
||||
<div style={{ paddingLeft: depth * 24 }}>
|
||||
<button
|
||||
className="flex items-center gap-3 px-3 py-2 rounded-md hover:bg-accent/30 transition-colors w-full text-left"
|
||||
onClick={() => navigate(`/agents/${node.id}`)}
|
||||
>
|
||||
<span className="relative flex h-2.5 w-2.5 shrink-0">
|
||||
<span className={`absolute inline-flex h-full w-full rounded-full ${statusColor}`} />
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium">{node.name}</span>
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
{roleLabels[node.role] ?? node.role}
|
||||
</span>
|
||||
</div>
|
||||
<StatusBadge status={node.status} />
|
||||
</button>
|
||||
{node.reports && node.reports.length > 0 && (
|
||||
<div className="border-l border-border/50 ml-4">
|
||||
{node.reports.map((child) => (
|
||||
<OrgTreeNode key={child.id} node={child} depth={depth + 1} navigate={navigate} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user