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:
Forgotten
2026-02-17 12:33:04 -06:00
parent b12bf6e7dd
commit 8f17b6fb52
10 changed files with 1798 additions and 117 deletions

View File

@@ -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,

View File

@@ -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`, {}),
};

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
);
}

View 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">&rsaquo;</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">&times;</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>
);
}

View File

@@ -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,

View File

@@ -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

View File

@@ -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">
<h2 className="text-lg font-semibold">Agents</h2>
<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,19 +143,29 @@ 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"
? "bg-green-400"
: agent.status === "paused"
? "bg-yellow-400"
: agent.status === "error"
? "bg-red-400"
: "bg-neutral-400"
agent.status === "running"
? "bg-cyan-400 animate-pulse"
: agent.status === "active"
? "bg-green-400"
: agent.status === "paused"
? "bg-yellow-400"
: agent.status === "error"
? "bg-red-400"
: "bg-neutral-400"
}`}
/>
</span>
}
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>
);
}