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:
@@ -16,7 +16,9 @@ export const createIssueSchema = z.object({
|
||||
|
||||
export type CreateIssue = z.infer<typeof createIssueSchema>;
|
||||
|
||||
export const updateIssueSchema = createIssueSchema.partial();
|
||||
export const updateIssueSchema = createIssueSchema.partial().extend({
|
||||
comment: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
export type UpdateIssue = z.infer<typeof updateIssueSchema>;
|
||||
|
||||
|
||||
@@ -247,6 +247,13 @@ export function agentRoutes(db: Db) {
|
||||
res.json(agent);
|
||||
});
|
||||
|
||||
router.get("/agents/:id/keys", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const id = req.params.id as string;
|
||||
const keys = await svc.listKeys(id);
|
||||
res.json(keys);
|
||||
});
|
||||
|
||||
router.post("/agents/:id/keys", validate(createAgentKeySchema), async (req, res) => {
|
||||
assertBoard(req);
|
||||
const id = req.params.id as string;
|
||||
@@ -268,6 +275,17 @@ export function agentRoutes(db: Db) {
|
||||
res.status(201).json(key);
|
||||
});
|
||||
|
||||
router.delete("/agents/:id/keys/:keyId", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const keyId = req.params.keyId as string;
|
||||
const revoked = await svc.revokeKey(keyId);
|
||||
if (!revoked) {
|
||||
res.status(404).json({ error: "Key not found" });
|
||||
return;
|
||||
}
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.post("/agents/:id/wakeup", validate(wakeAgentSchema), async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const agent = await svc.getById(id);
|
||||
|
||||
@@ -87,7 +87,8 @@ export function issueRoutes(db: Db) {
|
||||
}
|
||||
assertCompanyAccess(req, existing.companyId);
|
||||
|
||||
const issue = await svc.update(id, req.body);
|
||||
const { comment: commentBody, ...updateFields } = req.body;
|
||||
const issue = await svc.update(id, updateFields);
|
||||
if (!issue) {
|
||||
res.status(404).json({ error: "Issue not found" });
|
||||
return;
|
||||
@@ -102,9 +103,43 @@ export function issueRoutes(db: Db) {
|
||||
action: "issue.updated",
|
||||
entityType: "issue",
|
||||
entityId: issue.id,
|
||||
details: req.body,
|
||||
details: updateFields,
|
||||
});
|
||||
|
||||
let comment = null;
|
||||
if (commentBody) {
|
||||
comment = await svc.addComment(id, commentBody, {
|
||||
agentId: actor.agentId ?? undefined,
|
||||
userId: actor.actorType === "user" ? actor.actorId : undefined,
|
||||
});
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: issue.companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
action: "issue.comment_added",
|
||||
entityType: "issue",
|
||||
entityId: issue.id,
|
||||
details: { commentId: comment.id },
|
||||
});
|
||||
|
||||
// @-mention wakeups
|
||||
svc.findMentionedAgents(issue.companyId, commentBody).then((ids) => {
|
||||
for (const mentionedId of ids) {
|
||||
heartbeat.wakeup(mentionedId, {
|
||||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
reason: `Mentioned in comment on issue ${id}`,
|
||||
payload: { issueId: id, commentId: comment!.id },
|
||||
requestedByActorType: actor.actorType,
|
||||
requestedByActorId: actor.actorId,
|
||||
contextSnapshot: { issueId: id, commentId: comment!.id, source: "comment.mention" },
|
||||
}).catch((err) => logger.warn({ err, agentId: mentionedId }, "failed to wake mentioned agent"));
|
||||
}
|
||||
}).catch((err) => logger.warn({ err, issueId: id }, "failed to resolve @-mentions"));
|
||||
}
|
||||
|
||||
const assigneeChanged =
|
||||
req.body.assigneeAgentId !== undefined && req.body.assigneeAgentId !== existing.assigneeAgentId;
|
||||
if (assigneeChanged && issue.assigneeAgentId) {
|
||||
@@ -121,7 +156,7 @@ export function issueRoutes(db: Db) {
|
||||
.catch((err) => logger.warn({ err, issueId: issue.id }, "failed to wake assignee on issue update"));
|
||||
}
|
||||
|
||||
res.json(issue);
|
||||
res.json({ ...issue, comment });
|
||||
});
|
||||
|
||||
router.delete("/issues/:id", async (req, res) => {
|
||||
|
||||
@@ -153,6 +153,26 @@ export function agentService(db: Db) {
|
||||
};
|
||||
},
|
||||
|
||||
listKeys: (id: string) =>
|
||||
db
|
||||
.select({
|
||||
id: agentApiKeys.id,
|
||||
name: agentApiKeys.name,
|
||||
createdAt: agentApiKeys.createdAt,
|
||||
revokedAt: agentApiKeys.revokedAt,
|
||||
})
|
||||
.from(agentApiKeys)
|
||||
.where(eq(agentApiKeys.agentId, id)),
|
||||
|
||||
revokeKey: async (keyId: string) => {
|
||||
const rows = await db
|
||||
.update(agentApiKeys)
|
||||
.set({ revokedAt: new Date() })
|
||||
.where(eq(agentApiKeys.id, keyId))
|
||||
.returning();
|
||||
return rows[0] ?? null;
|
||||
},
|
||||
|
||||
orgForCompany: async (companyId: string) => {
|
||||
const rows = await db.select().from(agents).where(eq(agents.companyId, companyId));
|
||||
const byManager = new Map<string | null, typeof rows>();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, desc, eq, inArray, isNull, or, sql } from "drizzle-orm";
|
||||
import { and, asc, desc, eq, inArray, isNull, or, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclip/db";
|
||||
import { agents, issues, issueComments } from "@paperclip/db";
|
||||
import { conflict, notFound, unprocessable } from "../errors.js";
|
||||
@@ -55,7 +55,8 @@ export function issueService(db: Db) {
|
||||
}
|
||||
if (filters?.projectId) conditions.push(eq(issues.projectId, filters.projectId));
|
||||
|
||||
return db.select().from(issues).where(and(...conditions)).orderBy(desc(issues.updatedAt));
|
||||
const priorityOrder = sql`CASE ${issues.priority} WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END`;
|
||||
return db.select().from(issues).where(and(...conditions)).orderBy(asc(priorityOrder), desc(issues.updatedAt));
|
||||
},
|
||||
|
||||
getById: (id: string) =>
|
||||
@@ -156,6 +157,11 @@ export function issueService(db: Db) {
|
||||
|
||||
if (!current) throw notFound("Issue not found");
|
||||
|
||||
// If this agent already owns it and it's in_progress, return it (no self-409)
|
||||
if (current.assigneeAgentId === agentId && current.status === "in_progress") {
|
||||
return db.select().from(issues).where(eq(issues.id, id)).then((rows) => rows[0]!);
|
||||
}
|
||||
|
||||
throw conflict("Issue checkout conflict", {
|
||||
issueId: current.id,
|
||||
status: current.status,
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import type { Agent, AgentKeyCreated, AgentRuntimeState, HeartbeatRun } from "@paperclip/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
export interface AgentKey {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: Date;
|
||||
revokedAt: Date | null;
|
||||
}
|
||||
|
||||
export interface AdapterModel {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -24,7 +31,9 @@ export const agentsApi = {
|
||||
pause: (id: string) => api.post<Agent>(`/agents/${id}/pause`, {}),
|
||||
resume: (id: string) => api.post<Agent>(`/agents/${id}/resume`, {}),
|
||||
terminate: (id: string) => api.post<Agent>(`/agents/${id}/terminate`, {}),
|
||||
listKeys: (id: string) => api.get<AgentKey[]>(`/agents/${id}/keys`),
|
||||
createKey: (id: string, name: string) => api.post<AgentKeyCreated>(`/agents/${id}/keys`, { name }),
|
||||
revokeKey: (agentId: string, keyId: string) => api.delete<{ ok: true }>(`/agents/${agentId}/keys/${keyId}`),
|
||||
runtimeState: (id: string) => api.get<AgentRuntimeState>(`/agents/${id}/runtime-state`),
|
||||
resetSession: (id: string) => api.post<void>(`/agents/${id}/runtime-state/reset-session`, {}),
|
||||
adapterModels: (type: string) => api.get<AdapterModel[]>(`/adapters/${type}/models`),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
@@ -33,6 +33,36 @@ import {
|
||||
import { cn } from "../lib/utils";
|
||||
import type { Project, Agent } from "@paperclip/shared";
|
||||
|
||||
const DRAFT_KEY = "paperclip:issue-draft";
|
||||
const DEBOUNCE_MS = 800;
|
||||
|
||||
interface IssueDraft {
|
||||
title: string;
|
||||
description: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
assigneeId: string;
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
function loadDraft(): IssueDraft | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(DRAFT_KEY);
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw) as IssueDraft;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveDraft(draft: IssueDraft) {
|
||||
localStorage.setItem(DRAFT_KEY, JSON.stringify(draft));
|
||||
}
|
||||
|
||||
function clearDraft() {
|
||||
localStorage.removeItem(DRAFT_KEY);
|
||||
}
|
||||
|
||||
const statuses = [
|
||||
{ value: "backlog", label: "Backlog", color: "text-muted-foreground" },
|
||||
{ value: "todo", label: "Todo", color: "text-blue-400" },
|
||||
@@ -59,6 +89,7 @@ export function NewIssueDialog() {
|
||||
const [assigneeId, setAssigneeId] = useState("");
|
||||
const [projectId, setProjectId] = useState("");
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const draftTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Popover states
|
||||
const [statusOpen, setStatusOpen] = useState(false);
|
||||
@@ -84,13 +115,42 @@ export function NewIssueDialog() {
|
||||
issuesApi.create(selectedCompanyId!, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId!) });
|
||||
clearDraft();
|
||||
reset();
|
||||
closeNewIssue();
|
||||
},
|
||||
});
|
||||
|
||||
// Debounced draft saving
|
||||
const scheduleSave = useCallback(
|
||||
(draft: IssueDraft) => {
|
||||
if (draftTimer.current) clearTimeout(draftTimer.current);
|
||||
draftTimer.current = setTimeout(() => {
|
||||
if (draft.title.trim()) saveDraft(draft);
|
||||
}, DEBOUNCE_MS);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Save draft on meaningful changes
|
||||
useEffect(() => {
|
||||
if (newIssueOpen) {
|
||||
if (!newIssueOpen) return;
|
||||
scheduleSave({ title, description, status, priority, assigneeId, projectId });
|
||||
}, [title, description, status, priority, assigneeId, projectId, newIssueOpen, scheduleSave]);
|
||||
|
||||
// Restore draft or apply defaults when dialog opens
|
||||
useEffect(() => {
|
||||
if (!newIssueOpen) return;
|
||||
|
||||
const draft = loadDraft();
|
||||
if (draft && draft.title.trim()) {
|
||||
setTitle(draft.title);
|
||||
setDescription(draft.description);
|
||||
setStatus(draft.status || "todo");
|
||||
setPriority(draft.priority);
|
||||
setAssigneeId(newIssueDefaults.assigneeAgentId ?? draft.assigneeId);
|
||||
setProjectId(newIssueDefaults.projectId ?? draft.projectId);
|
||||
} else {
|
||||
setStatus(newIssueDefaults.status ?? "todo");
|
||||
setPriority(newIssueDefaults.priority ?? "");
|
||||
setProjectId(newIssueDefaults.projectId ?? "");
|
||||
@@ -98,6 +158,13 @@ export function NewIssueDialog() {
|
||||
}
|
||||
}, [newIssueOpen, newIssueDefaults]);
|
||||
|
||||
// Cleanup timer on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (draftTimer.current) clearTimeout(draftTimer.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
function reset() {
|
||||
setTitle("");
|
||||
setDescription("");
|
||||
@@ -108,6 +175,12 @@ export function NewIssueDialog() {
|
||||
setExpanded(false);
|
||||
}
|
||||
|
||||
function discardDraft() {
|
||||
clearDraft();
|
||||
reset();
|
||||
closeNewIssue();
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
if (!selectedCompanyId || !title.trim()) return;
|
||||
createIssue.mutate({
|
||||
@@ -127,6 +200,7 @@ export function NewIssueDialog() {
|
||||
}
|
||||
}
|
||||
|
||||
const hasDraft = title.trim().length > 0 || description.trim().length > 0;
|
||||
const currentStatus = statuses.find((s) => s.value === status) ?? statuses[1]!;
|
||||
const currentPriority = priorities.find((p) => p.value === priority);
|
||||
const currentAssignee = (agents ?? []).find((a) => a.id === assigneeId);
|
||||
@@ -136,22 +210,21 @@ export function NewIssueDialog() {
|
||||
<Dialog
|
||||
open={newIssueOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
reset();
|
||||
closeNewIssue();
|
||||
}
|
||||
if (!open) closeNewIssue();
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className={cn(
|
||||
"p-0 gap-0",
|
||||
expanded ? "sm:max-w-2xl" : "sm:max-w-lg"
|
||||
"p-0 gap-0 flex flex-col",
|
||||
expanded
|
||||
? "sm:max-w-2xl h-[calc(100vh-6rem)] max-h-[calc(100vh-6rem)]"
|
||||
: "sm:max-w-lg"
|
||||
)}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{/* Header bar */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border">
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border shrink-0">
|
||||
<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">
|
||||
@@ -174,7 +247,7 @@ export function NewIssueDialog() {
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="text-muted-foreground"
|
||||
onClick={() => { reset(); closeNewIssue(); }}
|
||||
onClick={() => closeNewIssue()}
|
||||
>
|
||||
<span className="text-lg leading-none">×</span>
|
||||
</Button>
|
||||
@@ -182,9 +255,9 @@ export function NewIssueDialog() {
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div className="px-4 pt-3">
|
||||
<div className="px-4 pt-4 pb-2 shrink-0">
|
||||
<input
|
||||
className="w-full text-base font-medium bg-transparent outline-none placeholder:text-muted-foreground/50"
|
||||
className="w-full text-lg font-semibold bg-transparent outline-none placeholder:text-muted-foreground/50"
|
||||
placeholder="Issue title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
@@ -193,11 +266,11 @@ export function NewIssueDialog() {
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="px-4 pb-2">
|
||||
<div className={cn("px-4 pb-2", expanded ? "flex-1 min-h-0" : "")}>
|
||||
<textarea
|
||||
className={cn(
|
||||
"w-full bg-transparent outline-none text-sm text-muted-foreground placeholder:text-muted-foreground/40 resize-none",
|
||||
expanded ? "min-h-[200px]" : "min-h-[60px]"
|
||||
expanded ? "h-full" : "min-h-[60px]"
|
||||
)}
|
||||
placeholder="Add description..."
|
||||
value={description}
|
||||
@@ -206,7 +279,7 @@ export function NewIssueDialog() {
|
||||
</div>
|
||||
|
||||
{/* Property chips bar */}
|
||||
<div className="flex items-center gap-1.5 px-4 py-2 border-t border-border flex-wrap">
|
||||
<div className="flex items-center gap-1.5 px-4 py-2 border-t border-border flex-wrap shrink-0">
|
||||
{/* Status chip */}
|
||||
<Popover open={statusOpen} onOpenChange={setStatusOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
@@ -359,13 +432,22 @@ export function NewIssueDialog() {
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end px-4 py-2.5 border-t border-border">
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-t border-border shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground"
|
||||
onClick={discardDraft}
|
||||
disabled={!hasDraft && !loadDraft()}
|
||||
>
|
||||
Discard Draft
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!title.trim() || createIssue.isPending}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{createIssue.isPending ? "Creating..." : "Create issue"}
|
||||
{createIssue.isPending ? "Creating..." : "Create Issue"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
@@ -8,6 +8,7 @@ export const queryKeys = {
|
||||
list: (companyId: string) => ["agents", companyId] as const,
|
||||
detail: (id: string) => ["agents", "detail", id] as const,
|
||||
runtimeState: (id: string) => ["agents", "runtime-state", id] as const,
|
||||
keys: (agentId: string) => ["agents", "keys", agentId] as const,
|
||||
},
|
||||
issues: {
|
||||
list: (companyId: string) => ["issues", companyId] as const,
|
||||
|
||||
@@ -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