From d2e4b9753d307502423da406b742e86c1b4f6d9a Mon Sep 17 00:00:00 2001 From: Jiayuan Zhang Date: Thu, 9 Apr 2026 02:58:04 +0800 Subject: [PATCH] feat(issues): add fullscreen agent execution transcript view (#524) * feat(issues): add fullscreen agent execution transcript view Adds a new "expand" button (Maximize2 icon) to both the live agent card and execution history entries. Clicking it opens a fullscreen dialog with: - A colored timeline progress bar showing execution flow at a glance (green = agent text, violet = thinking, blue = tool calls, gray = results, red = errors) - Detailed event list with type labels, summaries, and expandable detail - Click-to-scroll: clicking a timeline segment scrolls to that event - Copy-all button for the full transcript Inspired by Anthropic's Cloud Managed Agents session transcript UI. Co-Authored-By: Claude Opus 4.6 (1M context) * feat(issues): add runtime and agent metadata to transcript dialog Adds metadata chips to the transcript dialog header showing: - Runtime provider (e.g., "Claude Code", "Codex") - Runtime environment name + mode (local/cloud) - Agent description - Duration, tool count, event count, and creation time Metadata is fetched on dialog open via existing API endpoints. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .../issues/components/agent-live-card.tsx | 52 +- .../components/agent-transcript-dialog.tsx | 628 ++++++++++++++++++ 2 files changed, 679 insertions(+), 1 deletion(-) create mode 100644 apps/web/features/issues/components/agent-transcript-dialog.tsx diff --git a/apps/web/features/issues/components/agent-live-card.tsx b/apps/web/features/issues/components/agent-live-card.tsx index 08dcb381..620fb8ee 100644 --- a/apps/web/features/issues/components/agent-live-card.tsx +++ b/apps/web/features/issues/components/agent-live-card.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useEffect, useCallback, useRef } from "react"; -import { Bot, ChevronRight, ChevronDown, Loader2, ArrowDown, Brain, AlertCircle, Clock, CheckCircle2, XCircle, Square } from "lucide-react"; +import { Bot, ChevronRight, ChevronDown, Loader2, ArrowDown, Brain, AlertCircle, Clock, CheckCircle2, XCircle, Square, Maximize2 } from "lucide-react"; import { api } from "@/shared/api"; import { useWSEvent } from "@/features/realtime"; import type { TaskMessagePayload, TaskCompletedPayload, TaskFailedPayload, TaskCancelledPayload } from "@/shared/types/events"; @@ -12,6 +12,7 @@ import { ActorAvatar } from "@/components/common/actor-avatar"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { useActorName } from "@/features/workspace"; import { redactSecrets } from "../utils/redact"; +import { AgentTranscriptDialog } from "./agent-transcript-dialog"; // ─── Shared types & helpers ───────────────────────────────────────────────── @@ -239,6 +240,7 @@ function SingleAgentLiveCard({ task, items, issueId, agentName, scrollContainerR const [open, setOpen] = useState(false); const [autoScroll, setAutoScroll] = useState(true); const [cancelling, setCancelling] = useState(false); + const [transcriptOpen, setTranscriptOpen] = useState(false); const scrollRef = useRef(null); const ignoreScrollRef = useRef(false); @@ -331,6 +333,13 @@ function SingleAgentLiveCard({ task, items, issueId, agentName, scrollContainerR )}
+
+ + {/* Fullscreen transcript dialog */} + ); } @@ -450,8 +469,10 @@ export function TaskRunHistory({ issueId }: TaskRunHistoryProps) { } function TaskRunEntry({ task }: { task: AgentTask }) { + const { getActorName } = useActorName(); const [open, setOpen] = useState(false); const [items, setItems] = useState(null); + const [transcriptOpen, setTranscriptOpen] = useState(false); const loadMessages = useCallback(() => { if (items !== null) return; // already loaded @@ -487,6 +508,24 @@ function TaskRunEntry({ task }: { task: AgentTask }) { {task.status} +
@@ -504,6 +543,17 @@ function TaskRunEntry({ task }: { task: AgentTask }) { )}
+ + {/* Fullscreen transcript dialog */} + {items !== null && ( + + )} ); } diff --git a/apps/web/features/issues/components/agent-transcript-dialog.tsx b/apps/web/features/issues/components/agent-transcript-dialog.tsx new file mode 100644 index 00000000..a7fa5a39 --- /dev/null +++ b/apps/web/features/issues/components/agent-transcript-dialog.tsx @@ -0,0 +1,628 @@ +"use client"; + +import { useState, useRef, useCallback, useEffect } from "react"; +import { + Bot, + ChevronRight, + Brain, + AlertCircle, + CheckCircle2, + XCircle, + X, + Loader2, + Clock, + Copy, + Check, + Monitor, + Cloud, + Cpu, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { ActorAvatar } from "@/components/common/actor-avatar"; +import { api } from "@/shared/api"; +import type { AgentTask, Agent, AgentRuntime } from "@/shared/types/agent"; +import { redactSecrets } from "../utils/redact"; + +// ─── Types ────────────────────────────────────────────────────────────────── + +interface TimelineItem { + seq: number; + type: "tool_use" | "tool_result" | "thinking" | "text" | "error"; + tool?: string; + content?: string; + input?: Record; + output?: string; +} + +interface AgentTranscriptDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + task: AgentTask; + items: TimelineItem[]; + agentName: string; + isLive?: boolean; +} + +// ─── Color mapping for timeline segments ──────────────────────────────────── + +type EventColor = "agent" | "thinking" | "tool" | "result" | "error"; + +function getEventColor(item: TimelineItem): EventColor { + switch (item.type) { + case "text": + return "agent"; + case "thinking": + return "thinking"; + case "tool_use": + return "tool"; + case "tool_result": + return "result"; + case "error": + return "error"; + default: + return "result"; + } +} + +const colorClasses: Record = { + agent: { bg: "bg-emerald-400/60", bgActive: "bg-emerald-500", label: "bg-emerald-500" }, + thinking: { bg: "bg-violet-400/60", bgActive: "bg-violet-500", label: "bg-violet-500/20 text-violet-700 dark:text-violet-300" }, + tool: { bg: "bg-blue-400/60", bgActive: "bg-blue-500", label: "bg-blue-500/20 text-blue-700 dark:text-blue-300" }, + result: { bg: "bg-slate-300/60 dark:bg-slate-600/60", bgActive: "bg-slate-400 dark:bg-slate-500", label: "bg-muted text-muted-foreground" }, + error: { bg: "bg-red-400/60", bgActive: "bg-red-500", label: "bg-red-500/20 text-red-700 dark:text-red-300" }, +}; + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +function getEventLabel(item: TimelineItem): string { + switch (item.type) { + case "text": + return "Agent"; + case "thinking": + return "Thinking"; + case "tool_use": + return item.tool ?? "Tool"; + case "tool_result": + return item.tool ? `${item.tool}` : "Result"; + case "error": + return "Error"; + default: + return "Event"; + } +} + +function getEventSummary(item: TimelineItem): string { + switch (item.type) { + case "text": + return item.content?.split("\n").filter(Boolean).pop() ?? ""; + case "thinking": + return item.content?.slice(0, 200) ?? ""; + case "tool_use": { + if (!item.input) return ""; + const inp = item.input as Record; + if (inp.query) return inp.query; + if (inp.file_path) return shortenPath(inp.file_path); + if (inp.path) return shortenPath(inp.path); + if (inp.pattern) return inp.pattern; + if (inp.description) return String(inp.description); + if (inp.command) { + const cmd = String(inp.command); + return cmd.length > 120 ? cmd.slice(0, 120) + "..." : cmd; + } + if (inp.prompt) { + const p = String(inp.prompt); + return p.length > 120 ? p.slice(0, 120) + "..." : p; + } + if (inp.skill) return String(inp.skill); + for (const v of Object.values(inp)) { + if (typeof v === "string" && v.length > 0 && v.length < 120) return v; + } + return ""; + } + case "tool_result": + return item.output?.slice(0, 200) ?? ""; + case "error": + return item.content ?? ""; + default: + return ""; + } +} + +function shortenPath(p: string): string { + const parts = p.split("/"); + if (parts.length <= 3) return p; + return ".../" + parts.slice(-2).join("/"); +} + +function formatDuration(start: string, end: string): string { + const ms = new Date(end).getTime() - new Date(start).getTime(); + const seconds = Math.floor(ms / 1000); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${minutes}m ${secs}s`; +} + +function formatElapsedMs(ms: number): string { + const seconds = Math.floor(ms / 1000); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${minutes}m ${secs}s`; +} + +// ─── Main dialog ──────────────────────────────────────────────────────────── + +export function AgentTranscriptDialog({ + open, + onOpenChange, + task, + items, + agentName, + isLive = false, +}: AgentTranscriptDialogProps) { + const [selectedIdx, setSelectedIdx] = useState(null); + const [elapsed, setElapsed] = useState(""); + const [copied, setCopied] = useState(false); + const [agentInfo, setAgentInfo] = useState(null); + const [runtimeInfo, setRuntimeInfo] = useState(null); + const eventRefs = useRef>(new Map()); + const scrollContainerRef = useRef(null); + + // Fetch agent and runtime metadata when dialog opens + useEffect(() => { + if (!open) return; + let cancelled = false; + + if (task.agent_id) { + api.getAgent(task.agent_id).then((agent) => { + if (!cancelled) setAgentInfo(agent); + }).catch(() => {}); + } + + if (task.runtime_id) { + api.listRuntimes().then((runtimes) => { + if (cancelled) return; + const rt = runtimes.find((r) => r.id === task.runtime_id); + if (rt) setRuntimeInfo(rt); + }).catch(() => {}); + } + + return () => { cancelled = true; }; + }, [open, task.agent_id, task.runtime_id]); + + // Elapsed time for live tasks + useEffect(() => { + if (!isLive || (!task.started_at && !task.dispatched_at)) return; + const startRef = task.started_at ?? task.dispatched_at!; + const update = () => setElapsed(formatElapsedMs(Date.now() - new Date(startRef).getTime())); + update(); + const interval = setInterval(update, 1000); + return () => clearInterval(interval); + }, [isLive, task.started_at, task.dispatched_at]); + + // Click a timeline segment → scroll to event + const handleSegmentClick = useCallback((idx: number) => { + setSelectedIdx(idx); + const el = eventRefs.current.get(idx); + if (el) { + el.scrollIntoView({ behavior: "smooth", block: "center" }); + } + }, []); + + // Copy all events as text + const handleCopyAll = useCallback(() => { + const text = items + .map((item) => { + const label = getEventLabel(item); + const summary = getEventSummary(item); + return `[${label}] ${summary}`; + }) + .join("\n"); + navigator.clipboard.writeText(text).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }); + }, [items]); + + // Duration + const duration = + task.started_at && task.completed_at + ? formatDuration(task.started_at, task.completed_at) + : isLive + ? elapsed + : null; + + const toolCount = items.filter((i) => i.type === "tool_use").length; + + // Status display + const statusBadge = isLive ? ( + + + Running + + ) : task.status === "completed" ? ( + + + Completed + + ) : task.status === "failed" ? ( + + + Failed + + ) : ( + + {task.status} + + ); + + return ( + + + Agent Execution Transcript + + {/* ── Header ─────────────────────────────────────────────── */} +
+ {/* Top row: agent name, status, actions */} +
+
+ {task.agent_id ? ( + + ) : ( +
+ +
+ )} + {agentName} +
+ + {statusBadge} + +
+ + +
+
+ + {/* Metadata chips row */} +
+ {/* Runtime provider */} + {runtimeInfo?.provider && ( + }> + {formatProvider(runtimeInfo.provider)} + + )} + + {/* Runtime environment */} + {runtimeInfo && ( + : } + > + {runtimeInfo.name} + ({runtimeInfo.runtime_mode}) + + )} + + {/* Agent type / description */} + {agentInfo?.description && ( + }> + {agentInfo.description.length > 40 ? agentInfo.description.slice(0, 40) + "..." : agentInfo.description} + + )} + + {/* Duration */} + {duration && ( + }> + {duration} + + )} + + {/* Event counts */} + {toolCount > 0 && ( + {toolCount} tool calls + )} + {items.length} events + + {/* Created time */} + {task.created_at && ( + + {new Date(task.created_at).toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + })} + + )} +
+
+ + {/* ── Timeline progress bar ─────────────────────────────── */} + {items.length > 0 && ( +
+ +
+ )} + + {/* ── Event list ─────────────────────────────────────────── */} +
+ {items.length === 0 ? ( +
+ {isLive ? ( +
+ + Waiting for events... +
+ ) : ( + "No execution data recorded." + )} +
+ ) : ( +
+ {items.map((item, idx) => ( + { + if (el) eventRefs.current.set(idx, el); + else eventRefs.current.delete(idx); + }} + item={item} + index={idx} + isSelected={selectedIdx === idx} + onClick={() => setSelectedIdx(idx === selectedIdx ? null : idx)} + /> + ))} +
+ )} +
+
+
+ ); +} + +// ─── Timeline bar (colored segments) ──────────────────────────────────────── + +// ─── Metadata chip ────────────────────────────────────────────────────────── + +function MetadataChip({ icon, children }: { icon?: React.ReactNode; children: React.ReactNode }) { + return ( + + {icon} + {children} + + ); +} + +function formatProvider(provider: string): string { + const map: Record = { + claude: "Claude Code", + "claude-code": "Claude Code", + codex: "Codex", + }; + return map[provider.toLowerCase()] ?? provider; +} + +// ─── Timeline bar (colored segments) ──────────────────────────────────────── + +function TimelineBar({ + items, + selectedIdx, + onSegmentClick, +}: { + items: TimelineItem[]; + selectedIdx: number | null; + onSegmentClick: (idx: number) => void; +}) { + // Group consecutive items of the same color into segments for cleaner display + const segments: { startIdx: number; endIdx: number; color: EventColor; count: number }[] = []; + let currentColor: EventColor | null = null; + let currentStart = 0; + + for (let i = 0; i < items.length; i++) { + const item = items[i]!; + const color = getEventColor(item); + if (color !== currentColor) { + if (currentColor !== null) { + segments.push({ startIdx: currentStart, endIdx: i - 1, color: currentColor, count: i - currentStart }); + } + currentColor = color; + currentStart = i; + } + } + if (currentColor !== null) { + segments.push({ startIdx: currentStart, endIdx: items.length - 1, color: currentColor, count: items.length - currentStart }); + } + + return ( +
+ {segments.map((seg, segIdx) => { + const isSelected = selectedIdx !== null && selectedIdx >= seg.startIdx && selectedIdx <= seg.endIdx; + const color = colorClasses[seg.color]; + // Width proportional to number of events in segment + const widthPercent = (seg.count / items.length) * 100; + + return ( + + ); + })} +
+ ); +} + +// ─── Transcript event row ─────────────────────────────────────────────────── + +interface TranscriptEventRowProps { + item: TimelineItem; + index: number; + isSelected: boolean; + onClick: () => void; +} + +const TranscriptEventRow = ({ + ref, + item, + index, + isSelected, + onClick, +}: TranscriptEventRowProps & { ref?: React.Ref }) => { + const [expanded, setExpanded] = useState(false); + const color = getEventColor(item); + const label = getEventLabel(item); + const summary = getEventSummary(item); + + const hasDetail = + (item.type === "tool_use" && item.input && Object.keys(item.input).length > 0) || + (item.type === "tool_result" && item.output && item.output.length > 0) || + (item.type === "thinking" && item.content && item.content.length > 0) || + (item.type === "text" && item.content && item.content.split("\n").length > 1) || + (item.type === "error" && item.content && item.content.length > 0); + + return ( +
+ +
+ {/* Type label badge */} + + {item.type === "thinking" && } + {item.type === "error" && } + {label} + + + {/* Summary */} + +
+ {hasDetail && ( + + )} + {summary || "(empty)"} +
+
+ + {/* Seq number / index */} + + #{item.seq} + +
+ + {/* Expanded detail */} + {hasDetail && ( + +
+
+ +
+
+
+ )} +
+
+ ); +}; + +// ─── Event detail content ─────────────────────────────────────────────────── + +function EventDetailContent({ item }: { item: TimelineItem }) { + switch (item.type) { + case "tool_use": + return ( +
+          {item.input ? redactSecrets(JSON.stringify(item.input, null, 2)) : ""}
+        
+ ); + case "tool_result": + return ( +
+          {item.output
+            ? item.output.length > 4000
+              ? redactSecrets(item.output.slice(0, 4000)) + "\n... (truncated)"
+              : redactSecrets(item.output)
+            : ""}
+        
+ ); + case "thinking": + return ( +
+          {item.content ?? ""}
+        
+ ); + case "text": + return ( +
+          {item.content ?? ""}
+        
+ ); + case "error": + return ( +
+          {item.content ?? ""}
+        
+ ); + default: + return null; + } +}