From 9ce379f7910d1f7f488652fdc7f52671e322efff Mon Sep 17 00:00:00 2001 From: Jiayuan Zhang Date: Thu, 9 Apr 2026 02:43:11 +0800 Subject: [PATCH] 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) --- .../issues/components/agent-live-card.tsx | 52 +- .../components/agent-transcript-dialog.tsx | 536 ++++++++++++++++++ 2 files changed, 587 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 08dcb3814..620fb8ee8 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 000000000..63b8a0a5b --- /dev/null +++ b/apps/web/features/issues/components/agent-transcript-dialog.tsx @@ -0,0 +1,536 @@ +"use client"; + +import { useState, useRef, useCallback, useEffect } from "react"; +import { + Bot, + ChevronRight, + Brain, + AlertCircle, + CheckCircle2, + XCircle, + X, + Loader2, + Clock, + Copy, + Check, +} 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 type { AgentTask } 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 eventRefs = useRef>(new Map()); + const scrollContainerRef = useRef(null); + + // 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 ─────────────────────────────────────────────── */} +
+
+ {task.agent_id ? ( + + ) : ( +
+ +
+ )} + {agentName} +
+ + {statusBadge} + +
+ {duration && ( + + + {duration} + + )} + {toolCount > 0 && ( + {toolCount} tool calls + )} + {items.length} events +
+ +
+ + +
+
+ + {/* ── 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) ──────────────────────────────────────── + +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; + } +}