Improve agent detail, issue creation, and approvals pages
Expand AgentDetail with heartbeat history and manual trigger controls. Enhance NewIssueDialog with richer field options. Add agent connection string retrieval API. Improve issue routes with parent chain resolution. Clean up Approvals page layout. Update query keys and validators. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { useParams, useNavigate, Link } from "react-router-dom";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { agentsApi, type AgentKey } from "../api/agents";
|
||||
import { heartbeatsApi } from "../api/heartbeats";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { usePanel } from "../context/PanelContext";
|
||||
@@ -39,7 +39,12 @@ import {
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
Plus,
|
||||
Key,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Copy,
|
||||
} from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type { Agent, HeartbeatRun, HeartbeatRunEvent, AgentRuntimeState } from "@paperclip/shared";
|
||||
|
||||
const runStatusIcons: Record<string, { icon: typeof CheckCircle2; color: string }> = {
|
||||
@@ -239,6 +244,7 @@ export function AgentDetail() {
|
||||
<TabsTrigger value="runs">Runs{heartbeats ? ` (${heartbeats.length})` : ""}</TabsTrigger>
|
||||
<TabsTrigger value="issues">Issues ({assignedIssues.length})</TabsTrigger>
|
||||
<TabsTrigger value="costs">Costs</TabsTrigger>
|
||||
<TabsTrigger value="keys">API Keys</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* OVERVIEW TAB */}
|
||||
@@ -369,6 +375,11 @@ export function AgentDetail() {
|
||||
<TabsContent value="costs" className="mt-4">
|
||||
<CostsTab agent={agent} runtimeState={runtimeState ?? undefined} runs={heartbeats ?? []} />
|
||||
</TabsContent>
|
||||
|
||||
{/* KEYS TAB */}
|
||||
<TabsContent value="keys" className="mt-4">
|
||||
<KeysTab agentId={agent.id} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
@@ -829,3 +840,175 @@ function CostsTab({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---- Keys Tab ---- */
|
||||
|
||||
function KeysTab({ agentId }: { agentId: string }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [newKeyName, setNewKeyName] = useState("");
|
||||
const [newToken, setNewToken] = useState<string | null>(null);
|
||||
const [tokenVisible, setTokenVisible] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const { data: keys, isLoading } = useQuery({
|
||||
queryKey: queryKeys.agents.keys(agentId),
|
||||
queryFn: () => agentsApi.listKeys(agentId),
|
||||
});
|
||||
|
||||
const createKey = useMutation({
|
||||
mutationFn: () => agentsApi.createKey(agentId, newKeyName.trim() || "Default"),
|
||||
onSuccess: (data) => {
|
||||
setNewToken(data.token);
|
||||
setTokenVisible(true);
|
||||
setNewKeyName("");
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.keys(agentId) });
|
||||
},
|
||||
});
|
||||
|
||||
const revokeKey = useMutation({
|
||||
mutationFn: (keyId: string) => agentsApi.revokeKey(agentId, keyId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.keys(agentId) });
|
||||
},
|
||||
});
|
||||
|
||||
function copyToken() {
|
||||
if (!newToken) return;
|
||||
navigator.clipboard.writeText(newToken);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
const activeKeys = (keys ?? []).filter((k: AgentKey) => !k.revokedAt);
|
||||
const revokedKeys = (keys ?? []).filter((k: AgentKey) => k.revokedAt);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
{/* New token banner */}
|
||||
{newToken && (
|
||||
<div className="border border-yellow-600/40 bg-yellow-500/5 rounded-lg p-4 space-y-2">
|
||||
<p className="text-sm font-medium text-yellow-400">
|
||||
API key created — copy it now, it will not be shown again.
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 bg-neutral-950 rounded px-3 py-1.5 text-xs font-mono text-green-300 truncate">
|
||||
{tokenVisible ? newToken : newToken.replace(/./g, "•")}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setTokenVisible((v) => !v)}
|
||||
title={tokenVisible ? "Hide" : "Show"}
|
||||
>
|
||||
{tokenVisible ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={copyToken}
|
||||
title="Copy"
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
{copied && <span className="text-xs text-green-400">Copied!</span>}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground text-xs"
|
||||
onClick={() => setNewToken(null)}
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create new key */}
|
||||
<div className="border border-border rounded-lg p-4 space-y-3">
|
||||
<h3 className="text-sm font-medium flex items-center gap-2">
|
||||
<Key className="h-4 w-4" />
|
||||
Create API Key
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
API keys allow this agent to authenticate calls to the Paperclip server.
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="Key name (e.g. production)"
|
||||
value={newKeyName}
|
||||
onChange={(e) => setNewKeyName(e.target.value)}
|
||||
className="h-8 text-sm"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") createKey.mutate();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => createKey.mutate()}
|
||||
disabled={createKey.isPending}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active keys */}
|
||||
{isLoading && <p className="text-sm text-muted-foreground">Loading keys...</p>}
|
||||
|
||||
{!isLoading && activeKeys.length === 0 && !newToken && (
|
||||
<p className="text-sm text-muted-foreground">No active API keys.</p>
|
||||
)}
|
||||
|
||||
{activeKeys.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2">
|
||||
Active Keys
|
||||
</h3>
|
||||
<div className="border border-border rounded-md divide-y divide-border">
|
||||
{activeKeys.map((key: AgentKey) => (
|
||||
<div key={key.id} className="flex items-center justify-between px-4 py-2.5">
|
||||
<div>
|
||||
<span className="text-sm font-medium">{key.name}</span>
|
||||
<span className="text-xs text-muted-foreground ml-3">
|
||||
Created {formatDate(key.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive text-xs"
|
||||
onClick={() => revokeKey.mutate(key.id)}
|
||||
disabled={revokeKey.isPending}
|
||||
>
|
||||
Revoke
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Revoked keys (collapsed) */}
|
||||
{revokedKeys.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2">
|
||||
Revoked Keys
|
||||
</h3>
|
||||
<div className="border border-border rounded-md divide-y divide-border opacity-50">
|
||||
{revokedKeys.map((key: AgentKey) => (
|
||||
<div key={key.id} className="flex items-center justify-between px-4 py-2.5">
|
||||
<div>
|
||||
<span className="text-sm line-through">{key.name}</span>
|
||||
<span className="text-xs text-muted-foreground ml-3">
|
||||
Revoked {key.revokedAt ? formatDate(key.revokedAt) : ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,6 +31,16 @@ function statusIcon(status: string) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function PayloadField({ label, value }: { label: string; value: unknown }) {
|
||||
if (!value) return null;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground w-24 shrink-0 text-xs">{label}</span>
|
||||
<span>{String(value)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HireAgentPayload({ payload }: { payload: Record<string, unknown> }) {
|
||||
return (
|
||||
<div className="mt-3 space-y-1.5 text-sm">
|
||||
@@ -38,25 +48,15 @@ function HireAgentPayload({ payload }: { payload: Record<string, unknown> }) {
|
||||
<span className="text-muted-foreground w-24 shrink-0 text-xs">Name</span>
|
||||
<span className="font-medium">{String(payload.name ?? "—")}</span>
|
||||
</div>
|
||||
{payload.role && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground w-24 shrink-0 text-xs">Role</span>
|
||||
<span>{String(payload.role)}</span>
|
||||
</div>
|
||||
)}
|
||||
{payload.title && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground w-24 shrink-0 text-xs">Title</span>
|
||||
<span>{String(payload.title)}</span>
|
||||
</div>
|
||||
)}
|
||||
{payload.capabilities && (
|
||||
<PayloadField label="Role" value={payload.role} />
|
||||
<PayloadField label="Title" value={payload.title} />
|
||||
{!!payload.capabilities && (
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-muted-foreground w-24 shrink-0 text-xs pt-0.5">Capabilities</span>
|
||||
<span className="text-muted-foreground">{String(payload.capabilities)}</span>
|
||||
</div>
|
||||
)}
|
||||
{payload.adapterType && (
|
||||
{!!payload.adapterType && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground w-24 shrink-0 text-xs">Adapter</span>
|
||||
<span className="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">
|
||||
@@ -72,13 +72,8 @@ function CeoStrategyPayload({ payload }: { payload: Record<string, unknown> }) {
|
||||
const plan = payload.plan ?? payload.description ?? payload.strategy ?? payload.text;
|
||||
return (
|
||||
<div className="mt-3 space-y-1.5 text-sm">
|
||||
{payload.title && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground w-24 shrink-0 text-xs">Title</span>
|
||||
<span className="font-medium">{String(payload.title)}</span>
|
||||
</div>
|
||||
)}
|
||||
{plan && (
|
||||
<PayloadField label="Title" value={payload.title} />
|
||||
{!!plan && (
|
||||
<div className="mt-2 rounded-md bg-muted/40 px-3 py-2 text-sm text-muted-foreground whitespace-pre-wrap font-mono text-xs max-h-48 overflow-y-auto">
|
||||
{String(plan)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user