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:
Forgotten
2026-02-17 20:46:12 -06:00
parent 6dbbf1bbec
commit b95c05a242
10 changed files with 396 additions and 45 deletions

View File

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

View File

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

View File

@@ -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) => {

View File

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

View File

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

View File

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

View File

@@ -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">&times;</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>

View File

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

View File

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

View File

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