diff --git a/ui/src/components/CopyText.tsx b/ui/src/components/CopyText.tsx new file mode 100644 index 00000000..811f89b9 --- /dev/null +++ b/ui/src/components/CopyText.tsx @@ -0,0 +1,48 @@ +import { useCallback, useRef, useState } from "react"; +import { cn } from "@/lib/utils"; + +interface CopyTextProps { + text: string; + /** What to display. Defaults to `text`. */ + children?: React.ReactNode; + className?: string; + /** Tooltip message shown after copying. Default: "Copied!" */ + copiedLabel?: string; +} + +export function CopyText({ text, children, className, copiedLabel = "Copied!" }: CopyTextProps) { + const [visible, setVisible] = useState(false); + const timerRef = useRef>(); + const triggerRef = useRef(null); + + const handleClick = useCallback(() => { + navigator.clipboard.writeText(text); + clearTimeout(timerRef.current); + setVisible(true); + timerRef.current = setTimeout(() => setVisible(false), 1500); + }, [text]); + + return ( + + + + {copiedLabel} + + + ); +} diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 1a2d977f..80268644 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -15,6 +15,7 @@ import { adapterLabels, roleLabels } from "../components/agent-config-primitives import { getUIAdapter, buildTranscript } from "../adapters"; import type { TranscriptEntry } from "../adapters"; import { StatusBadge } from "../components/StatusBadge"; +import { CopyText } from "../components/CopyText"; import { EntityRow } from "../components/EntityRow"; import { formatCents, formatDate, relativeTime, formatTokens } from "../lib/utils"; import { cn } from "../lib/utils"; @@ -43,6 +44,7 @@ import { Eye, EyeOff, Copy, + ChevronRight, } from "lucide-react"; import { Input } from "@/components/ui/input"; import type { Agent, HeartbeatRun, HeartbeatRunEvent, AgentRuntimeState } from "@paperclip/shared"; @@ -660,6 +662,7 @@ function RunsTab({ runs, companyId, agentId, selectedRunId, adapterType }: { run function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: string }) { const queryClient = useQueryClient(); const metrics = runMetrics(run); + const [sessionOpen, setSessionOpen] = useState(false); const cancelRun = useMutation({ mutationFn: () => heartbeatsApi.cancel(run.id), @@ -668,108 +671,125 @@ function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: strin }, }); + const timeFormat: Intl.DateTimeFormatOptions = { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false }; + const startTime = run.startedAt ? new Date(run.startedAt).toLocaleTimeString("en-US", timeFormat) : null; + const endTime = run.finishedAt ? new Date(run.finishedAt).toLocaleTimeString("en-US", timeFormat) : null; + const durationSec = run.startedAt && run.finishedAt + ? Math.round((new Date(run.finishedAt).getTime() - new Date(run.startedAt).getTime()) / 1000) + : null; + const hasMetrics = metrics.input > 0 || metrics.output > 0 || metrics.cached > 0 || metrics.cost > 0; + const hasSession = !!(run.sessionIdBefore || run.sessionIdAfter); + const sessionChanged = run.sessionIdBefore && run.sessionIdAfter && run.sessionIdBefore !== run.sessionIdAfter; + const sessionId = run.sessionIdAfter || run.sessionIdBefore; + const hasNonZeroExit = run.exitCode !== null && run.exitCode !== 0; + return ( -
- {/* Status timeline */} -
-
- Status: - +
+ {/* Run summary card */} +
+
+ {/* Left column: status + timing */} +
+
+ + {(run.status === "running" || run.status === "queued") && ( + + )} +
+ {startTime && ( +
+
+ {startTime} + {endTime && } + {endTime} +
+
+ {relativeTime(run.startedAt!)} + {run.finishedAt && <> → {relativeTime(run.finishedAt)}} +
+ {durationSec !== null && ( +
+ Duration: {durationSec >= 60 ? `${Math.floor(durationSec / 60)}m ${durationSec % 60}s` : `${durationSec}s`} +
+ )} +
+ )} + {run.error && ( +
+ {run.error} + {run.errorCode && ({run.errorCode})} +
+ )} + {hasNonZeroExit && ( +
+ Exit code {run.exitCode} + {run.signal && (signal: {run.signal})} +
+ )} +
+ + {/* Right column: metrics */} + {hasMetrics && ( +
+
+
Input
+
{formatTokens(metrics.input)}
+
+
+
Output
+
{formatTokens(metrics.output)}
+
+
+
Cached
+
{formatTokens(metrics.cached)}
+
+
+
Cost
+
{metrics.cost > 0 ? `$${metrics.cost.toFixed(4)}` : "-"}
+
+
+ )}
- {run.startedAt && ( -
- Started: - {formatDate(run.startedAt)} {new Date(run.startedAt).toLocaleTimeString()} -
- )} - {run.finishedAt && ( -
- Finished: - {formatDate(run.finishedAt)} {new Date(run.finishedAt).toLocaleTimeString()} -
- )} - {run.startedAt && run.finishedAt && ( -
- Duration: - {Math.round((new Date(run.finishedAt).getTime() - new Date(run.startedAt).getTime()) / 1000)}s + + {/* Collapsible session row */} + {hasSession && ( +
+ + {sessionOpen && ( +
+ {run.sessionIdBefore && ( +
+ {sessionChanged ? "Before" : "ID"} + +
+ )} + {sessionChanged && run.sessionIdAfter && ( +
+ After + +
+ )} +
+ )}
)}
- {/* Token breakdown */} - {(metrics.input > 0 || metrics.output > 0 || metrics.cached > 0 || metrics.cost > 0) && ( -
-
- Input: - {formatTokens(metrics.input)} -
-
- Output: - {formatTokens(metrics.output)} -
- {metrics.cached > 0 && ( -
- Cached: - {formatTokens(metrics.cached)} -
- )} - {metrics.cost > 0 && ( -
- Cost: - ${metrics.cost.toFixed(4)} -
- )} -
- )} - - {/* Session info */} - {(run.sessionIdBefore || run.sessionIdAfter) && ( -
- {run.sessionIdBefore && ( -
- Session before: - -
- )} - {run.sessionIdAfter && ( -
- Session after: - -
- )} -
- )} - - {/* Error */} - {run.error && ( -
- Error: - {run.error} - {run.errorCode && ({run.errorCode})} -
- )} - - {/* Exit info */} - {run.exitCode !== null && ( -
- Exit code: - {run.exitCode} - {run.signal && (signal: {run.signal})} -
- )} - {/* stderr excerpt for failed runs */} {run.stderrExcerpt && (
@@ -786,21 +806,6 @@ function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
)} - {/* Cancel button for running */} - {(run.status === "running" || run.status === "queued") && ( - - )} - - - {/* Log viewer */}
@@ -865,10 +870,12 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin } }, [initialEvents]); - // Auto-scroll + // Auto-scroll only for live runs useEffect(() => { - logEndRef.current?.scrollIntoView({ behavior: "smooth" }); - }, [events, logLines]); + if (isLive) { + logEndRef.current?.scrollIntoView({ behavior: "smooth" }); + } + }, [events, logLines, isLive]); // Fetch persisted shell log useEffect(() => { @@ -897,6 +904,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin let first = true; while (!cancelled) { const result = await heartbeatsApi.log(run.id, offset, first ? firstLimit : 256_000); + if (cancelled) break; appendLogContent(result.content, result.nextOffset === undefined); const next = result.nextOffset ?? offset + result.content.length; setLogOffset(next); @@ -1058,61 +1066,74 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin )} {transcript.map((entry, idx) => { const time = new Date(entry.ts).toLocaleTimeString("en-US", { hour12: false }); + const grid = "grid grid-cols-[auto_auto_1fr] gap-x-3 items-baseline"; + const tsCell = "text-neutral-600 select-none w-16"; + const lblCell = "w-20"; + const contentCell = "min-w-0 whitespace-pre-wrap break-words"; + const expandCell = "col-span-full md:col-start-3 md:col-span-1"; + if (entry.kind === "assistant") { return ( -
-
- {time} - assistant - {entry.text} -
+
+ {time} + assistant + {entry.text}
); } if (entry.kind === "tool_call") { return ( -
-
- {time} - tool - {entry.name} -
-
+              
+ {time} + tool_call + {entry.name} +
                   {JSON.stringify(entry.input, null, 2)}
                 
); } + if (entry.kind === "tool_result") { + return ( +
+ {time} + tool_result + {entry.isError ? error : } +
+                  {entry.content}
+                
+
+ ); + } + if (entry.kind === "init") { return ( -
- {time} - init - Claude initialized (model: {entry.model}{entry.sessionId ? `, session: ${entry.sessionId}` : ""}) +
+ {time} + init + model: {entry.model}{entry.sessionId ? `, session: ${entry.sessionId}` : ""}
); } if (entry.kind === "result") { return ( -
-
- {time} - result - - tokens in={formatTokens(entry.inputTokens)} out={formatTokens(entry.outputTokens)} cached={formatTokens(entry.cachedTokens)} cost=${entry.costUsd.toFixed(6)} - -
+
+ {time} + result + + tokens in={formatTokens(entry.inputTokens)} out={formatTokens(entry.outputTokens)} cached={formatTokens(entry.cachedTokens)} cost=${entry.costUsd.toFixed(6)} + {(entry.subtype || entry.isError || entry.errors.length > 0) && ( -
+
subtype={entry.subtype || "unknown"} is_error={entry.isError ? "true" : "false"} {entry.errors.length > 0 ? ` errors=${entry.errors.join(" | ")}` : ""}
)} {entry.text && ( -
{entry.text}
+
{entry.text}
)}
); @@ -1126,18 +1147,12 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin const color = entry.kind === "stderr" ? "text-red-300" : entry.kind === "system" ? "text-blue-300" : - "text-foreground"; + "text-neutral-500"; return ( -
- - {time} - - - {label} - - - {rawText} - +
+ {time} + {label} + {rawText}
) })}