"use client"; import { useState, useEffect, useCallback, useRef } from "react"; import { Bot, ChevronRight, ChevronDown, Loader2, ArrowDown, Brain, AlertCircle, Clock, CheckCircle2, XCircle, Square, Maximize2 } from "lucide-react"; import { api } from "@multica/core/api"; import { useWSEvent } from "@multica/core/realtime"; import type { TaskMessagePayload, TaskCompletedPayload, TaskFailedPayload, TaskCancelledPayload } from "@multica/core/types/events"; import type { AgentTask } from "@multica/core/types/agent"; import { cn } from "@multica/ui/lib/utils"; import { toast } from "sonner"; import { ActorAvatar } from "../../common/actor-avatar"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@multica/ui/components/ui/collapsible"; import { useActorName } from "@multica/core/workspace/hooks"; import { redactSecrets } from "../utils/redact"; import { AgentTranscriptDialog } from "./agent-transcript-dialog"; // ─── Shared types & helpers ───────────────────────────────────────────────── /** A unified timeline entry: tool calls, thinking, text, and errors in chronological order. */ interface TimelineItem { seq: number; type: "tool_use" | "tool_result" | "thinking" | "text" | "error"; tool?: string; content?: string; input?: Record; output?: string; } function formatElapsed(startedAt: string): string { const elapsed = Date.now() - new Date(startedAt).getTime(); const seconds = Math.floor(elapsed / 1000); if (seconds < 60) return `${seconds}s`; const minutes = Math.floor(seconds / 60); const secs = seconds % 60; return `${minutes}m ${secs}s`; } 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 shortenPath(p: string): string { const parts = p.split("/"); if (parts.length <= 3) return p; return ".../" + parts.slice(-2).join("/"); } function getToolSummary(item: TimelineItem): string { if (!item.input) return ""; const inp = item.input as Record; // WebSearch / web search if (inp.query) return inp.query; // File operations if (inp.file_path) return shortenPath(inp.file_path); if (inp.path) return shortenPath(inp.path); if (inp.pattern) return inp.pattern; // Bash if (inp.description) return String(inp.description); if (inp.command) { const cmd = String(inp.command); return cmd.length > 100 ? cmd.slice(0, 100) + "..." : cmd; } // Agent if (inp.prompt) { const p = String(inp.prompt); return p.length > 100 ? p.slice(0, 100) + "..." : p; } // Skill if (inp.skill) return String(inp.skill); // Fallback: show first string value for (const v of Object.values(inp)) { if (typeof v === "string" && v.length > 0 && v.length < 120) return v; } return ""; } /** Build a chronologically ordered timeline from raw messages. */ function buildTimeline(msgs: TaskMessagePayload[]): TimelineItem[] { const items: TimelineItem[] = []; for (const msg of msgs) { items.push({ seq: msg.seq, type: msg.type, tool: msg.tool, content: msg.content ? redactSecrets(msg.content) : msg.content, input: msg.input, output: msg.output ? redactSecrets(msg.output) : msg.output, }); } return items.sort((a, b) => a.seq - b.seq); } // ─── Per-task state ───────────────────────────────────────────────────────── interface TaskState { task: AgentTask; items: TimelineItem[]; } // ─── AgentLiveCard (real-time view for multiple agents) ─────────────────── interface AgentLiveCardProps { issueId: string; } export function AgentLiveCard({ issueId }: AgentLiveCardProps) { const { getActorName } = useActorName(); const [taskStates, setTaskStates] = useState>(new Map()); const seenSeqs = useRef(new Set()); // Fetch active tasks on mount useEffect(() => { let cancelled = false; api.getActiveTasksForIssue(issueId).then(({ tasks }) => { if (cancelled || tasks.length === 0) return; // Show cards immediately with empty timeline setTaskStates((prev) => { const next = new Map(prev); for (const task of tasks) { if (!next.has(task.id)) { next.set(task.id, { task, items: [] }); } } return next; }); // Load messages per task in the background for (const task of tasks) { api.listTaskMessages(task.id).then((msgs) => { if (cancelled) return; const timeline = buildTimeline(msgs); for (const m of msgs) seenSeqs.current.add(`${m.task_id}:${m.seq}`); setTaskStates((prev) => { const next = new Map(prev); const existing = next.get(task.id); if (existing) { // Merge: keep any WS-delivered items not in the loaded batch const loadedSeqs = new Set(timeline.map((i) => i.seq)); const wsOnly = existing.items.filter((i) => !loadedSeqs.has(i.seq)); const merged = [...timeline, ...wsOnly].sort((a, b) => a.seq - b.seq); next.set(task.id, { task: existing.task, items: merged }); } else { next.set(task.id, { task, items: timeline }); } return next; }); }).catch(console.error); } }).catch(console.error); return () => { cancelled = true; }; }, [issueId]); // Handle real-time task messages — route by task_id useWSEvent( "task:message", useCallback((payload: unknown) => { const msg = payload as TaskMessagePayload; if (msg.issue_id !== issueId) return; const key = `${msg.task_id}:${msg.seq}`; if (seenSeqs.current.has(key)) return; seenSeqs.current.add(key); const item: TimelineItem = { seq: msg.seq, type: msg.type, tool: msg.tool, content: msg.content, input: msg.input, output: msg.output, }; setTaskStates((prev) => { const next = new Map(prev); const existing = next.get(msg.task_id); if (existing) { const items = [...existing.items, item].sort((a, b) => a.seq - b.seq); next.set(msg.task_id, { ...existing, items }); } // If we don't have this task yet, the dispatch handler will pick it up return next; }); }, [issueId]), ); // Handle task end events — remove only the specific task const handleTaskEnd = useCallback((payload: unknown) => { const p = payload as { task_id: string; issue_id: string }; if (p.issue_id !== issueId) return; setTaskStates((prev) => { const next = new Map(prev); next.delete(p.task_id); return next; }); }, [issueId]); useWSEvent("task:completed", handleTaskEnd); useWSEvent("task:failed", handleTaskEnd); useWSEvent("task:cancelled", handleTaskEnd); // Pick up newly dispatched tasks useWSEvent( "task:dispatch", useCallback((payload: unknown) => { const p = payload as { issue_id?: string }; if (p.issue_id && p.issue_id !== issueId) return; api.getActiveTasksForIssue(issueId).then(({ tasks }) => { setTaskStates((prev) => { const next = new Map(prev); for (const task of tasks) { if (!next.has(task.id)) { next.set(task.id, { task, items: [] }); } } return next; }); }).catch(console.error); }, [issueId]), ); if (taskStates.size === 0) return null; const entries = Array.from(taskStates.values()); const [firstEntry, ...restEntries] = entries; if (!firstEntry) return null; return ( <> {/* Primary agent — sticky at top of the Activity section */}
{/* Additional agents — scroll with the page */} {restEntries.length > 0 && (
{restEntries.map(({ task, items }) => ( ))}
)} ); } // ─── SingleAgentLiveCard (one card per running task) ────────────────────── interface SingleAgentLiveCardProps { task: AgentTask; items: TimelineItem[]; issueId: string; agentName: string; } function SingleAgentLiveCard({ task, items, issueId, agentName }: SingleAgentLiveCardProps) { const [elapsed, setElapsed] = useState(""); const [open, setOpen] = useState(false); const [autoScroll, setAutoScroll] = useState(true); const [cancelling, setCancelling] = useState(false); const [transcriptOpen, setTranscriptOpen] = useState(false); const scrollRef = useRef(null); // Elapsed time useEffect(() => { if (!task.started_at && !task.dispatched_at) return; const startRef = task.started_at ?? task.dispatched_at!; setElapsed(formatElapsed(startRef)); const interval = setInterval(() => setElapsed(formatElapsed(startRef)), 1000); return () => clearInterval(interval); }, [task.started_at, task.dispatched_at]); // Auto-scroll timeline to bottom useEffect(() => { if (autoScroll && scrollRef.current) { scrollRef.current.scrollTop = scrollRef.current.scrollHeight; } }, [items, autoScroll]); const handleScroll = useCallback(() => { if (!scrollRef.current) return; const { scrollTop, scrollHeight, clientHeight } = scrollRef.current; setAutoScroll(scrollHeight - scrollTop - clientHeight < 40); }, []); const toggleOpen = useCallback(() => { setOpen(!open); }, [open]); const handleCancel = useCallback(async () => { if (cancelling) return; setCancelling(true); try { await api.cancelTask(issueId, task.id); } catch (e) { toast.error(e instanceof Error ? e.message : "Failed to cancel task"); setCancelling(false); } }, [task.id, issueId, cancelling]); const toolCount = items.filter((i) => i.type === "tool_use").length; return (
{/* Header — click to toggle timeline */}
{ if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggleOpen(); } }} > {task.agent_id ? ( ) : (
)}
{agentName} is working {elapsed} {toolCount > 0 && ( {toolCount} tools )}
{/* Timeline — grid-rows animation for smooth collapse/expand */}
{items.length > 0 ? (
{items.map((item, idx) => ( ))} {!autoScroll && ( )}
) : (

Live log is not available for this agent provider. Results will appear when the task completes.

)}
{/* Fullscreen transcript dialog */}
); } // ─── TaskRunHistory (past execution logs) ────────────────────────────────── interface TaskRunHistoryProps { issueId: string; } export function TaskRunHistory({ issueId }: TaskRunHistoryProps) { const [tasks, setTasks] = useState([]); const [open, setOpen] = useState(false); useEffect(() => { api.listTasksByIssue(issueId).then(setTasks).catch(console.error); }, [issueId]); // Refresh when a task completes useWSEvent( "task:completed", useCallback((payload: unknown) => { const p = payload as TaskCompletedPayload; if (p.issue_id !== issueId) return; api.listTasksByIssue(issueId).then(setTasks).catch(console.error); }, [issueId]), ); useWSEvent( "task:failed", useCallback((payload: unknown) => { const p = payload as TaskFailedPayload; if (p.issue_id !== issueId) return; api.listTasksByIssue(issueId).then(setTasks).catch(console.error); }, [issueId]), ); // Refresh when a task is cancelled useWSEvent( "task:cancelled", useCallback((payload: unknown) => { const p = payload as TaskCancelledPayload; if (p.issue_id !== issueId) return; api.listTasksByIssue(issueId).then(setTasks).catch(console.error); }, [issueId]), ); const completedTasks = tasks.filter((t) => t.status === "completed" || t.status === "failed" || t.status === "cancelled"); if (completedTasks.length === 0) return null; return ( Execution history ({completedTasks.length})
{completedTasks.map((task) => ( ))}
); } 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 api.listTaskMessages(task.id).then((msgs) => { setItems(buildTimeline(msgs)); }).catch((e) => { console.error(e); setItems([]); }); }, [task.id, items]); useEffect(() => { if (open) loadMessages(); }, [open, loadMessages]); const duration = task.started_at && task.completed_at ? formatDuration(task.started_at, task.completed_at) : null; return ( {task.status === "completed" ? ( ) : ( )} {new Date(task.created_at).toLocaleString(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" })} {duration && {duration}} {task.status} { e.stopPropagation(); // Load messages before opening the transcript dialog if (items === null) { api.listTaskMessages(task.id).then((msgs) => { setItems(buildTimeline(msgs)); setTranscriptOpen(true); }).catch(console.error); } else { setTranscriptOpen(true); } }} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); e.stopPropagation(); e.currentTarget.click(); } }} className="flex items-center justify-center rounded p-0.5 text-muted-foreground hover:text-foreground hover:bg-accent/50 transition-colors cursor-pointer" title="Expand transcript" >
{items === null ? (
Loading...
) : items.length === 0 ? (

No execution data recorded.

) : ( items.map((item, idx) => ( )) )}
{/* Fullscreen transcript dialog */} {items !== null && ( )}
); } // ─── Shared timeline row rendering ────────────────────────────────────────── function TimelineRow({ item }: { item: TimelineItem }) { switch (item.type) { case "tool_use": return ; case "tool_result": return ; case "thinking": return ; case "text": return ; case "error": return ; default: return null; } } function ToolCallRow({ item }: { item: TimelineItem }) { const [open, setOpen] = useState(false); const summary = getToolSummary(item); const hasInput = item.input && Object.keys(item.input).length > 0; return ( {item.tool} {summary && {summary}} {hasInput && (
            {redactSecrets(JSON.stringify(item.input, null, 2))}
          
)}
); } function ToolResultRow({ item }: { item: TimelineItem }) { const [open, setOpen] = useState(false); const output = item.output ?? ""; if (!output) return null; const preview = output.length > 120 ? output.slice(0, 120) + "..." : output; return ( {item.tool ? `${item.tool} result: ` : "result: "}{preview}
          {output.length > 4000 ? output.slice(0, 4000) + "\n... (truncated)" : output}
        
); } function ThinkingRow({ item }: { item: TimelineItem }) { const [open, setOpen] = useState(false); const text = item.content ?? ""; if (!text) return null; const preview = text.length > 150 ? text.slice(0, 150) + "..." : text; return ( {preview}
          {text}
        
); } function TextRow({ item }: { item: TimelineItem }) { const text = item.content ?? ""; if (!text.trim()) return null; const lines = text.trim().split("\n").filter(Boolean); const last = lines[lines.length - 1] ?? ""; if (!last) return null; return (
{last}
); } function ErrorRow({ item }: { item: TimelineItem }) { return (
{item.content}
); }