"use client"; import { useState, useRef, useCallback, useEffect, useMemo } from "react"; import { Bot, ChevronRight, Brain, AlertCircle, CheckCircle2, XCircle, X, Loader2, Clock, Copy, Check, Monitor, Cloud, Cpu, Filter, Folder, ArrowDownNarrowWide, ArrowUpNarrowWide, } from "lucide-react"; import { cn } from "@multica/ui/lib/utils"; import { copyText } from "@multica/ui/lib/clipboard"; import { Dialog, DialogContent, DialogTitle } from "@multica/ui/components/ui/dialog"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@multica/ui/components/ui/collapsible"; import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuSeparator, DropdownMenuCheckboxItem, DropdownMenuItem, } from "@multica/ui/components/ui/dropdown-menu"; import { ActorAvatar } from "../actor-avatar"; import { api } from "@multica/core/api"; import { useTranscriptViewStore, 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 { useT } from "../../i18n"; 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 ──────────────────────────────────────────────────────────────── 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").find((l) => l.trim().length > 0) ?? ""; 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, 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); const [selectedTools, setSelectedTools] = useState>(new Set()); const sortDirection = useTranscriptViewStore((s) => s.sortDirection); const setSortDirection = useTranscriptViewStore((s) => s.setSortDirection); const eventRefs = useRef>(new Map()); const scrollContainerRef = useRef(null); // Derive filter options from each item: // tool_use / tool_result → filter value = tool, display = "tool:Bash" // other types → display from getEventLabel const filterOptions = useMemo(() => { const options = new Map(); for (const item of items) { if (item.tool && (item.type === "tool_use" || item.type === "tool_result")) { const key = `tool:${item.tool}`; if (!options.has(key)) options.set(key, key); } else { const value = item.type; if (!options.has(value)) { options.set(value, getEventLabel(item)); } } } return Array.from(options.entries()).sort((a, b) => a[1].localeCompare(b[1])); }, [items]); // Resolve filter key for each item — mirrors filterOptions derivation exactly const itemFilterKey = (item: TimelineItem) => item.tool && (item.type === "tool_use" || item.type === "tool_result") ? `tool:${item.tool}` : item.type; // Strict filter const filteredItems = useMemo(() => { if (selectedTools.size === 0) return items; return items.filter((item) => selectedTools.has(itemFilterKey(item))); }, [items, selectedTools]); // 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], ); // 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); scrollContainerRef.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]); const handleSegmentClick = useCallback((seq: number) => { setSelectedSeq(seq); eventRefs.current.get(seq)?.scrollIntoView({ behavior: "smooth", block: "center" }); }, []); // 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(() => { const text = displayItems .map((item) => { const label = getEventLabel(item); const summary = getEventSummary(item); return `[${label}] ${summary}`; }) .join("\n"); void copyText(text).then((ok) => { if (!ok) return; setCopied(true); setTimeout(() => setCopied(false), 2000); }); }, [displayItems]); // Toggle tool filter const toggleTool = useCallback((tool: string) => { setSelectedTools((prev) => { const next = new Set(prev); if (next.has(tool)) next.delete(tool); else next.add(tool); return next; }); }, []); const clearFilters = useCallback(() => { setSelectedTools(new Set()); }, []); // 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 ? ( {t(($) => $.transcript.status_running)} ) : task.status === "completed" ? ( {t(($) => $.transcript.status_completed)} ) : task.status === "failed" ? ( {t(($) => $.transcript.status_failed)} ) : ( {task.status} ); return ( {t(($) => $.transcript.dialog_title)} {/* ── Header ─────────────────────────────────────────────── */}
{/* Top row: agent name, status, actions */}
{task.agent_id ? ( ) : (
)} {agentName}
{statusBadge}
{items.length > 1 && ( $.transcript.sort_chronological), newestFirst: t(($) => $.transcript.sort_newest_first), ariaLabel: t(($) => $.transcript.sort_label), }} /> )} {filterOptions.length > 0 && ( 0 ? "text-blue-600 dark:text-blue-400 bg-blue-500/10 hover:bg-blue-500/20" : "text-muted-foreground hover:text-foreground hover:bg-accent", )} > {t(($) => $.transcript.filter)} {selectedTools.size > 0 && ( {selectedTools.size} )} {filterOptions.map(([value, label]) => ( toggleTool(value)} > {label} ))} {selectedTools.size > 0 && ( <> {t(($) => $.transcript.clear_filters)} )} )}
{/* 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 && ( {t(($) => $.transcript.tool_calls, { count: toolCount })} )} {selectedTools.size > 0 ? t(($) => $.transcript.events_filtered, { shown: filteredItems.length, total: items.length }) : t(($) => $.transcript.events, { count: items.length })} {/* Working directory — server-derived display path. Falls back to nothing when older backends omit the field rather than rendering `work_dir` raw and leaking the user's home directory. The absolute `task.work_dir` deliberately never reaches the DOM anywhere — only `relative_work_dir` is safe to render / put in title / copy to clipboard, because the server has already stripped $HOME and the username out of it. The button truncates because real workdir paths are routinely long enough to push every other chip off the row. */} {task.relative_work_dir && ( )} {/* 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 ─────────────────────────────── */} {displayItems.length > 0 && (
)} {/* ── Optional header slot (e.g. webhook payload preview) ── */} {headerSlot && (
{headerSlot}
)} {/* ── Event list ─────────────────────────────────────────── */}
{displayItems.length === 0 ? (
{isLive ? (
{t(($) => $.transcript.waiting_events)}
) : ( t(($) => $.transcript.no_data) )}
) : (
{displayItems.map((item) => ( { if (el) eventRefs.current.set(item.seq, el); else eventRefs.current.delete(item.seq); }} item={item} isSelected={selectedSeq === item.seq} /> ))}
)}
); } // ─── Sort direction toggle ────────────────────────────────────────────────── interface SortDirectionToggleProps { value: TranscriptSortDirection; onChange: (dir: TranscriptSortDirection) => void; labels: { chronological: string; newestFirst: string; ariaLabel: string }; } function SortDirectionToggle({ value, onChange, labels }: SortDirectionToggleProps) { return (
); } // ─── 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", 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; } const TranscriptEventRow = ({ ref, item, isSelected, }: TranscriptEventRowProps & { ref?: React.Ref }) => { const [expanded, setExpanded] = useState(false); const color = getEventColor(item); const label = getEventLabel(item); const summary = getEventSummary(item); const date = useMemo( () => (item.created_at ? new Date(item.created_at) : null), [item.created_at], ); 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.length > 0) || (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} {/* Timestamp */} {date && ( {date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit", })} )}
{/* 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; } }