"use client"; import { useState, useRef, useCallback, useEffect, useMemo, forwardRef } from "react"; import { Virtuoso, type VirtuosoHandle, type Components } from "react-virtuoso"; import { Bot, ChevronRight, Brain, AlertCircle, CheckCircle2, XCircle, X, Loader2, Clock, Copy, Check, Filter, ArrowDownNarrowWide, ArrowUpNarrowWide, ListCollapse, Info, } from "lucide-react"; import { cn } from "@multica/ui/lib/utils"; import { copyText } from "@multica/ui/lib/clipboard"; import { Button } from "@multica/ui/components/ui/button"; import { Dialog, DialogContent, DialogTitle } from "@multica/ui/components/ui/dialog"; import { Popover, PopoverContent, PopoverTrigger } from "@multica/ui/components/ui/popover"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@multica/ui/components/ui/collapsible"; import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuSeparator, DropdownMenuCheckboxItem, DropdownMenuItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, } from "@multica/ui/components/ui/dropdown-menu"; import { ActorAvatar } from "../actor-avatar"; import { AttributionBadge } from "../../issues/components/attribution-badge"; import { RichContent } from "../../rich-content"; import { api } from "@multica/core/api"; import { useTranscriptViewStore, type TranscriptDetailDensity, type TranscriptFilterKey, type TranscriptSortDirection, } from "@multica/core/agents/stores"; import type { AgentTask, Agent, AgentRuntime } from "@multica/core/types/agent"; import { redactSecrets } from "./redact"; import type { TimelineItem } from "./build-timeline"; import { traceEventCopyText, traceEventDefaultExpanded, traceEventHasDetail, traceEventKind, traceEventLabel, traceEventSummary, traceEventSummaryIsMono, } from "./trace-event-presenter"; import { useT } from "../../i18n"; import "./task-transcript.css"; interface AgentTranscriptDialogProps { open: boolean; onOpenChange: (open: boolean) => void; task: AgentTask; items: TimelineItem[]; agentName: string; isLive?: boolean; /** * Optional content rendered between the header chips and the event list. * Used by autopilot run rows to surface the inbound webhook trigger * payload so it's visible regardless of whether the agent echoes it. * The dialog stays generic — slot content is the caller's concern. */ headerSlot?: React.ReactNode; } // ─── 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 ──────────────────────────────────────────────────────────────── // Presentation rules (kind/label/summary/default expansion) live in the pure // trace-event-presenter module; only view-plumbing helpers remain here. function getItemFilterKey(item: TimelineItem): TranscriptFilterKey { return item.tool && (item.type === "tool_use" || item.type === "tool_result") ? `tool:${item.tool}` : item.type; } 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`; } function formatRunTime(iso: string): string { return new Date(iso).toLocaleString(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", second: "2-digit", }); } // ─── Run detail row (ⓘ popover) ───────────────────────────────────────────── // One labeled fact in the diagnostic popover. When `onCopy` is given the whole // row is a copy button (used for the workdir path). function RunDetailRow({ label, value, mono, onCopy, copied, copyTitle, }: { label: string; value: string; mono?: boolean; onCopy?: () => void; copied?: boolean; copyTitle?: string; }) { const valueClass = cn("min-w-0 select-text break-all text-foreground/80", mono && "font-mono"); if (onCopy) { return ( ); } return (
{label} {value}
); } // ─── Main dialog ──────────────────────────────────────────────────────────── // Virtuoso mounts rows as direct children of its List element; carry the // divider styling the plain list container used to provide. Defined at module // scope — an inline `components` object would remount the list every render. const VirtuosoList = forwardRef>( function VirtuosoList(props, ref) { return
; }, ); const LIST_COMPONENTS: Components = { List: VirtuosoList }; export function AgentTranscriptDialog({ open, onOpenChange, task, items, agentName, isLive = false, headerSlot, }: AgentTranscriptDialogProps) { const { t } = useT("agents"); const [selectedSeq, setSelectedSeq] = useState(null); const [elapsed, setElapsed] = useState(""); const [copied, setCopied] = useState(false); const [copiedWorkdir, setCopiedWorkdir] = useState(false); const [agentInfo, setAgentInfo] = useState(null); const [runtimeInfo, setRuntimeInfo] = useState(null); // Row-level expand overrides. A row the user toggled follows the toggle; any // other row follows the density preference (see traceEventDefaultExpanded). // Switching density or task resets the overrides wholesale. const [rowOverrides, setRowOverrides] = useState>(() => new Map()); const sortDirection = useTranscriptViewStore((s) => s.sortDirection); const setSortDirection = useTranscriptViewStore((s) => s.setSortDirection); // Filters always persist across opens — a facet a run doesn't have simply // no-ops (see activeFilterKeys), so there is no reason to make persistence a // user-facing toggle. const selectedFilterKeys = useTranscriptViewStore((s) => s.selectedFilterKeys); const toggleFilterKey = useTranscriptViewStore((s) => s.toggleFilterKey); const clearFilters = useTranscriptViewStore((s) => s.clearFilterKeys); const density = useTranscriptViewStore((s) => s.density); const setDensity = useTranscriptViewStore((s) => s.setDensity); const virtuosoRef = useRef(null); useEffect(() => { setRowOverrides(new Map()); }, [task.id, density]); // Derive filter options from each item: // tool_use / tool_result → filter value = tool, display = "tool:Bash" // other types → display from traceEventLabel const filterOptions = useMemo(() => { const options = new Map(); for (const item of items) { const key = getItemFilterKey(item); if (item.tool && (item.type === "tool_use" || item.type === "tool_result")) { if (!options.has(key)) options.set(key, key); } else { if (!options.has(key)) { options.set(key, traceEventLabel(item)); } } } return Array.from(options.entries()).sort((a, b) => a[1].localeCompare(b[1])); }, [items]); const filterOptionKeys = useMemo( () => new Set(filterOptions.map(([value]) => value)), [filterOptions], ); const activeFilterKeys = useMemo( () => selectedFilterKeys.filter((key) => filterOptionKeys.has(key)), [selectedFilterKeys, filterOptionKeys], ); const activeFilterSet = useMemo(() => new Set(activeFilterKeys), [activeFilterKeys]); // Strict filter const filteredItems = useMemo(() => { if (activeFilterSet.size === 0) return items; return items.filter((item) => activeFilterSet.has(getItemFilterKey(item))); }, [items, activeFilterSet]); // Apply user-chosen sort direction. Reverse is a pure presentation concern — // the underlying timeline (and its seq numbers) is untouched, so copy/filter // and segment navigation continue to work against the same data. const displayItems = useMemo( () => (sortDirection === "newest_first" ? [...filteredItems].reverse() : filteredItems), [filteredItems, sortDirection], ); const isAntigravityLiveEmpty = isLive && displayItems.length === 0 && runtimeInfo?.provider === "antigravity"; // Newest-first shows live events as PREPENDS, and Virtuoso items opt out of // native scroll anchoring (`overflow-anchor: none`), so without compensation // every 500ms flush shifts the reading position. Virtuoso's contract: a // decrease of firstItemIndex by N anchors the viewport across an N-item // prepend, and the value must never increase within an instance — counting // down from a large base satisfies that while the list only grows. Sort, // filter, or task changes can shrink the list, so `listEpoch` remounts the // instance (fresh at top) instead of letting firstItemIndex climb. // `scrollToIndex`/`computeItemKey` are unaffected: indices stay data-relative // (verified against scrollToIndexSystem — it never reads firstItemIndex). const firstItemIndex = sortDirection === "newest_first" ? 1_000_000 - displayItems.length : 0; const listEpoch = `${task.id}:${sortDirection}:${activeFilterKeys.join(",")}`; // Toggling direction is a manual user action; jump the scroll container back // to the top so the newest end of the timeline (per the chosen direction) is // immediately visible. Avoids stranding the user mid-scroll on the wrong end. const handleSortDirectionChange = useCallback( (dir: typeof sortDirection) => { if (dir === sortDirection) return; setSortDirection(dir); virtuosoRef.current?.scrollTo({ top: 0 }); }, [sortDirection, setSortDirection], ); // 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]); // Rows are virtualized, so an off-screen target is not in the DOM; // navigate by index instead of a node ref. const handleSegmentClick = useCallback( (seq: number) => { setSelectedSeq(seq); const index = displayItems.findIndex((item) => item.seq === seq); if (index < 0) return; virtuosoRef.current?.scrollToIndex({ index, align: "center", behavior: "smooth" }); }, [displayItems], ); // Copy all events as text. Use the displayed order so users get the same // sequence they see on screen — matters when sort is set to newest-first. const handleCopyWorkdir = useCallback(() => { if (!task.relative_work_dir) return; void copyText(task.relative_work_dir).then((ok) => { if (!ok) return; setCopiedWorkdir(true); setTimeout(() => setCopiedWorkdir(false), 2000); }); }, [task.relative_work_dir]); const handleCopyAll = useCallback(() => { // Copy the full body of each event (not the truncated row summary), with // the same secret redaction the detail view applies. const text = displayItems .map((item) => redactSecrets(traceEventCopyText(item))) .join("\n\n"); void copyText(text).then((ok) => { if (!ok) return; setCopied(true); setTimeout(() => setCopied(false), 2000); }); }, [displayItems]); const handleRowExpandedChange = useCallback((seq: number, expanded: boolean) => { setRowOverrides((prev) => { const next = new Map(prev); next.set(seq, expanded); return next; }); }, []); // Duration const duration = task.started_at && task.completed_at ? formatDuration(task.started_at, task.completed_at) : isLive ? elapsed : null; const copyTranscriptLabel = copied ? t(($) => $.transcript.copied) : activeFilterKeys.length > 0 ? t(($) => $.transcript.copy_filtered) : t(($) => $.transcript.copy_all); // Status badge — full state machine, so queued/dispatched/cancelled render as // proper labels instead of raw enum text. const effectiveStatus = isLive ? "running" : task.status; const statusBadge = (() => { const base = "inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium"; switch (effectiveStatus) { case "running": return ( {t(($) => $.transcript.status_running)} ); case "completed": return ( {t(($) => $.transcript.status_completed)} ); case "failed": return ( {t(($) => $.transcript.status_failed)} ); case "cancelled": return ( {t(($) => $.transcript.status_cancelled)} ); case "queued": return ( {t(($) => $.transcript.status_queued)} ); case "dispatched": return ( {t(($) => $.transcript.status_dispatched)} ); case "waiting_local_directory": return ( {t(($) => $.transcript.status_waiting)} ); default: return ( {task.status} ); } })(); // Trigger source: one word answering "why does this run exist" — more useful // up front than the runtime/provider diagnostics, which move to the ⓘ popover. const triggerLabel = task.parent_task_id ? t(($) => $.transcript.trigger_retry) : task.kind === "comment" || task.trigger_comment_id ? t(($) => $.transcript.trigger_comment) : task.kind === "autopilot" || task.autopilot_run_id ? t(($) => $.transcript.trigger_autopilot) : task.kind === "chat" || task.chat_session_id ? t(($) => $.transcript.trigger_chat) : task.kind === "quick_create" ? t(($) => $.transcript.trigger_quick_create) : task.kind === "direct" || task.handoff_note ? t(($) => $.transcript.trigger_direct) : t(($) => $.transcript.trigger_initial); // Diagnostic detail for the ⓘ popover: everything a reader needs only when // debugging this specific run, kept off the always-visible surface. const providerLabel = runtimeInfo?.provider ? formatProvider(runtimeInfo.provider) : null; const createdLabel = task.created_at ? formatRunTime(task.created_at) : null; const startedLabel = task.started_at ? formatRunTime(task.started_at) : null; const completedLabel = task.completed_at ? formatRunTime(task.completed_at) : null; // "When was this run created" — a read-before-you-read fact worth the toolbar // surface (the ⓘ popover keeps the full-precision created/started/completed). const createdShort = task.created_at ? new Date(task.created_at).toLocaleString(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", }) : null; const hasTriggeredBy = !!task.attribution?.initiator; const hasRunDetails = !!runtimeInfo || !!task.relative_work_dir || !!createdLabel || !!startedLabel || !!completedLabel; return ( {t(($) => $.transcript.dialog_title)} {/* ── Header: identity only ────────────────────────────────── Tier 1 — everything a viewer needs BEFORE reading: outcome (status anchors the left), who ran it, why it exists (trigger), and who's accountable. All diagnostics move to the ⓘ popover. */}
{statusBadge} {/* Primary identity: the agent that ran this. It is the one foreground entity — avatar + medium weight. */}
{task.agent_id ? ( ) : (
)} {agentName || agentInfo?.name || ""}
{/* Provenance, one muted secondary unit set apart from the agent: who triggered the run and how — reads as " · ", not three peer entities. The person's avatar is dropped here so two same-size faces don't read as two agents. */}
{hasTriggeredBy && ( <> )} {triggerLabel}
{hasRunDetails && ( $.transcript.run_info)} title={t(($) => $.transcript.run_info)} className="text-muted-foreground" /> } >
{t(($) => $.transcript.run_info)}
{runtimeInfo && ( $.transcript.details_runtime)} value={runtimeInfo.name} /> )} {providerLabel && ( $.transcript.details_provider)} value={providerLabel} /> )} {runtimeInfo && ( $.transcript.details_mode)} value={runtimeInfo.runtime_mode} /> )} {task.relative_work_dir && ( $.transcript.details_workdir)} value={task.relative_work_dir} mono onCopy={handleCopyWorkdir} copied={copiedWorkdir} copyTitle={t(($) => $.transcript.copy_workdir)} /> )} {createdLabel && ( $.transcript.details_created)} value={createdLabel} /> )} {startedLabel && ( $.transcript.details_started)} value={startedLabel} /> )} {completedLabel && ( $.transcript.details_completed)} value={completedLabel} /> )}
)}
{/* ── List toolbar: read-before-you-read summary (left) + controls (right). Duration + event count fill the left, so the row balances instead of leaving dead space. ── */}
{createdShort && ( <> {t(($) => $.transcript.fact_created, { time: createdShort })} )} {duration && ( <> {t(($) => $.transcript.fact_took, { duration })} )} {activeFilterKeys.length > 0 ? t(($) => $.transcript.events_filtered, { shown: filteredItems.length, total: items.length }) : t(($) => $.transcript.events, { count: items.length })}
{items.length > 0 && ( $.transcript.density_label)} className="text-muted-foreground" /> } > {density === "smart" ? t(($) => $.transcript.density_smart) : density === "expanded" ? t(($) => $.transcript.density_expanded) : t(($) => $.transcript.density_collapsed)} setDensity(value as TranscriptDetailDensity)} > {( [ ["smart", t(($) => $.transcript.density_smart), t(($) => $.transcript.density_smart_desc)], ["expanded", t(($) => $.transcript.density_expanded), t(($) => $.transcript.density_expanded_desc)], ["collapsed", t(($) => $.transcript.density_collapsed), t(($) => $.transcript.density_collapsed_desc)], ] as const ).map(([value, name, description]) => ( {name} {description} ))} )} {items.length > 1 && ( $.transcript.sort_chronological), newestFirst: t(($) => $.transcript.sort_newest_first), ariaLabel: t(($) => $.transcript.sort_label), }} /> )} {filterOptions.length > 0 && ( 0 ? "brand" : "ghost"} size="sm" aria-label={t(($) => $.transcript.filter)} className={activeFilterKeys.length > 0 ? undefined : "text-muted-foreground"} /> } > {t(($) => $.transcript.filter)} {activeFilterKeys.length > 0 && ( {activeFilterKeys.length} )} {filterOptions.map(([value, label]) => ( toggleFilterKey(value)} > {label} ))} {selectedFilterKeys.length > 0 && ( <> {t(($) => $.transcript.clear_filters)} )} )}
{/* ── Timeline progress bar ─────────────────────────────── */} {displayItems.length > 0 && (
)} {/* ── Optional header slot (e.g. webhook payload preview) ── */} {headerSlot && (
{headerSlot}
)} {/* ── Event list ─────────────────────────────────────────── */}
{displayItems.length === 0 ? (
{isAntigravityLiveEmpty ? (
{t(($) => $.transcript.antigravity_live_unavailable)}
) : isLive ? (
{t(($) => $.transcript.waiting_events)}
) : ( t(($) => $.transcript.no_data) )}
) : ( // Virtualized so a multi-thousand-event run mounts a bounded number // of DOM rows (#5733). Rows expand/collapse to variable heights; // Virtuoso re-measures them via ResizeObserver. item.seq} components={LIST_COMPONENTS} itemContent={(_, item) => ( handleRowExpandedChange(item.seq, expanded)} /> )} /> )}
); } // ─── Sort direction toggle ────────────────────────────────────────────────── interface SortDirectionToggleProps { value: TranscriptSortDirection; onChange: (dir: TranscriptSortDirection) => void; labels: { chronological: string; newestFirst: string; ariaLabel: string }; } // Sort is a two-state toggle, not a mode picker: one button showing the // current direction that flips on click. Shares the toolbar button chassis so // it reads as the same control family as density/filter/copy — no tab strip. function SortDirectionToggle({ value, onChange, labels }: SortDirectionToggleProps) { const isChronological = value === "chronological"; return ( ); } // ─── Facts line separator ─────────────────────────────────────────────────── function FactDot() { return ( · ); } function formatProvider(provider: string): string { const map: Record = { claude: "Claude Code", "claude-code": "Claude Code", codex: "Codex", pi: "Pi", }; return map[provider.toLowerCase()] ?? provider; } // ─── Timeline bar (colored segments) ──────────────────────────────────────── function TimelineBar({ items, selectedSeq, onSegmentClick, }: { items: TimelineItem[]; selectedSeq: number | null; onSegmentClick: (seq: number) => void; }) { 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) => { const isSelected = selectedSeq !== null && items.slice(seg.startIdx, seg.endIdx + 1).some((i) => i.seq === selectedSeq); const color = colorClasses[seg.color]; const widthPercent = (seg.count / items.length) * 100; return ( ); })}
); } // ─── Transcript event row ─────────────────────────────────────────────────── interface TranscriptEventRowProps { item: TimelineItem; isSelected: boolean; expanded: boolean; onExpandedChange: (expanded: boolean) => void; } const TranscriptEventRow = ({ item, isSelected, expanded, onExpandedChange, }: TranscriptEventRowProps) => { const { t } = useT("agents"); const kind = traceEventKind(item); const color = getEventColor(item); const label = traceEventLabel(item); const summary = traceEventSummary(item); const date = useMemo( () => (item.created_at ? new Date(item.created_at) : null), [item.created_at], ); const hasDetail = traceEventHasDetail(item); // Prose kinds swap the one-line summary for the full body in place when // expanded (no box). Tool kinds keep the summary line and reveal the // params/output surface below it. const isProse = kind !== "tool_use" && kind !== "tool_result"; const showInlineBody = isProse && hasDetail && expanded; return (
{/* Type label badge */} {item.type === "thinking" && } {item.type === "error" && } {label} {showInlineBody ? (
{kind === "agent" ? ( ) : (
{item.content ?? ""}
)}
) : (
{hasDetail && ( )} {summary || t(($) => $.transcript.no_output)}
)} {/* Seq number / index */} #{item.seq} {/* Timestamp */} {date && ( {date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit", })} )}
{/* Expanded params/output for tool kinds — a quiet, borderless surface aligned to the content column. */} {!isProse && hasDetail && (
4000 ? redactSecrets(item.output.slice(0, 4000)) + "\n... (truncated)" : redactSecrets(item.output) : "" } />
)}
); }; // ─── Tool detail surface ──────────────────────────────────────────────────── /** * Long content fades out behind a "show all" affordance instead of trapping a * nested scrollbar inside the virtualized list. */ function ToolDetailSurface({ text }: { text: string }) { const { t } = useT("agents"); const [showAll, setShowAll] = useState(false); const isLong = text.length > 1600 || text.split("\n").length > 14; return (
        {text}
      
{isLong && !showAll && (
)}
); }