import { useState, useEffect, useRef, useMemo } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { X, Maximize2, Minimize2, Copy, RefreshCw, RotateCcw, ChevronDown, Bot, Clock, Hash, DollarSign, ExternalLink } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Toast, ToastContainer } from '@/components/ui/toast'; import { Popover } from '@/components/ui/popover'; import { api, type AgentRunWithMetrics } from '@/lib/api'; import { useOutputCache } from '@/lib/outputCache'; import { listen, type UnlistenFn } from '@tauri-apps/api/event'; import { StreamMessage } from './StreamMessage'; import { ErrorBoundary } from './ErrorBoundary'; import { formatISOTimestamp } from '@/lib/date-utils'; import { AGENT_ICONS } from './CCAgents'; import type { ClaudeStreamMessage } from './AgentExecution'; interface AgentRunOutputViewerProps { /** * The agent run to display */ run: AgentRunWithMetrics; /** * Callback when the viewer is closed */ onClose: () => void; /** * Optional callback to open full view */ onOpenFullView?: () => void; /** * Optional className for styling */ className?: string; } /** * AgentRunOutputViewer - Modal component for viewing agent execution output * * @example * setSelectedRun(null)} * /> */ export function AgentRunOutputViewer({ run, onClose, onOpenFullView, className }: AgentRunOutputViewerProps) { const [messages, setMessages] = useState([]); const [rawJsonlOutput, setRawJsonlOutput] = useState([]); const [loading, setLoading] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false); const [refreshing, setRefreshing] = useState(false); const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null); const [copyPopoverOpen, setCopyPopoverOpen] = useState(false); const [hasUserScrolled, setHasUserScrolled] = useState(false); const scrollAreaRef = useRef(null); const outputEndRef = useRef(null); const fullscreenScrollRef = useRef(null); const fullscreenMessagesEndRef = useRef(null); const unlistenRefs = useRef([]); const { getCachedOutput, setCachedOutput } = useOutputCache(); // Auto-scroll logic const isAtBottom = () => { const container = isFullscreen ? fullscreenScrollRef.current : scrollAreaRef.current; if (container) { const { scrollTop, scrollHeight, clientHeight } = container; const distanceFromBottom = scrollHeight - scrollTop - clientHeight; return distanceFromBottom < 1; } return true; }; const scrollToBottom = () => { if (!hasUserScrolled) { const endRef = isFullscreen ? fullscreenMessagesEndRef.current : outputEndRef.current; if (endRef) { endRef.scrollIntoView({ behavior: 'smooth' }); } } }; // Clean up listeners on unmount useEffect(() => { return () => { unlistenRefs.current.forEach(unlisten => unlisten()); }; }, []); // Auto-scroll when messages change useEffect(() => { const shouldAutoScroll = !hasUserScrolled || isAtBottom(); if (shouldAutoScroll) { scrollToBottom(); } }, [messages, hasUserScrolled, isFullscreen]); const loadOutput = async (skipCache = false) => { if (!run.id) return; try { // Check cache first if not skipping cache if (!skipCache) { const cached = getCachedOutput(run.id); if (cached) { const cachedJsonlLines = cached.output.split('\n').filter(line => line.trim()); setRawJsonlOutput(cachedJsonlLines); setMessages(cached.messages); // If cache is recent (less than 5 seconds old) and session isn't running, use cache only if (Date.now() - cached.lastUpdated < 5000 && run.status !== 'running') { return; } } } setLoading(true); const rawOutput = await api.getSessionOutput(run.id); // Parse JSONL output into messages const jsonlLines = rawOutput.split('\n').filter(line => line.trim()); setRawJsonlOutput(jsonlLines); const parsedMessages: ClaudeStreamMessage[] = []; for (const line of jsonlLines) { try { const message = JSON.parse(line) as ClaudeStreamMessage; parsedMessages.push(message); } catch (err) { console.error("Failed to parse message:", err, line); } } setMessages(parsedMessages); // Update cache setCachedOutput(run.id, { output: rawOutput, messages: parsedMessages, lastUpdated: Date.now(), status: run.status }); // Set up live event listeners for running sessions if (run.status === 'running') { setupLiveEventListeners(); try { await api.streamSessionOutput(run.id); } catch (streamError) { console.warn('Failed to start streaming, will poll instead:', streamError); } } } catch (error) { console.error('Failed to load agent output:', error); setToast({ message: 'Failed to load agent output', type: 'error' }); } finally { setLoading(false); } }; const setupLiveEventListeners = async () => { if (!run.id) return; try { // Clean up existing listeners unlistenRefs.current.forEach(unlisten => unlisten()); unlistenRefs.current = []; // Set up live event listeners with run ID isolation const outputUnlisten = await listen(`agent-output:${run.id}`, (event) => { try { // Store raw JSONL setRawJsonlOutput(prev => [...prev, event.payload]); // Parse and display const message = JSON.parse(event.payload) as ClaudeStreamMessage; setMessages(prev => [...prev, message]); } catch (err) { console.error("Failed to parse message:", err, event.payload); } }); const errorUnlisten = await listen(`agent-error:${run.id}`, (event) => { console.error("Agent error:", event.payload); setToast({ message: event.payload, type: 'error' }); }); const completeUnlisten = await listen(`agent-complete:${run.id}`, () => { setToast({ message: 'Agent execution completed', type: 'success' }); }); const cancelUnlisten = await listen(`agent-cancelled:${run.id}`, () => { setToast({ message: 'Agent execution was cancelled', type: 'error' }); }); unlistenRefs.current = [outputUnlisten, errorUnlisten, completeUnlisten, cancelUnlisten]; } catch (error) { console.error('Failed to set up live event listeners:', error); } }; // Copy functionality const handleCopyAsJsonl = async () => { const jsonl = rawJsonlOutput.join('\n'); await navigator.clipboard.writeText(jsonl); setCopyPopoverOpen(false); setToast({ message: 'Output copied as JSONL', type: 'success' }); }; const handleCopyAsMarkdown = async () => { let markdown = `# Agent Execution: ${run.agent_name}\n\n`; markdown += `**Task:** ${run.task}\n`; markdown += `**Model:** ${run.model === 'opus' ? 'Claude 4 Opus' : 'Claude 4 Sonnet'}\n`; markdown += `**Date:** ${formatISOTimestamp(run.created_at)}\n`; if (run.metrics?.duration_ms) markdown += `**Duration:** ${(run.metrics.duration_ms / 1000).toFixed(2)}s\n`; if (run.metrics?.total_tokens) markdown += `**Total Tokens:** ${run.metrics.total_tokens}\n`; if (run.metrics?.cost_usd) markdown += `**Cost:** $${run.metrics.cost_usd.toFixed(4)} USD\n`; markdown += `\n---\n\n`; for (const msg of messages) { if (msg.type === "system" && msg.subtype === "init") { markdown += `## System Initialization\n\n`; markdown += `- Session ID: \`${msg.session_id || 'N/A'}\`\n`; markdown += `- Model: \`${msg.model || 'default'}\`\n`; if (msg.cwd) markdown += `- Working Directory: \`${msg.cwd}\`\n`; if (msg.tools?.length) markdown += `- Tools: ${msg.tools.join(', ')}\n`; markdown += `\n`; } else if (msg.type === "assistant" && msg.message) { markdown += `## Assistant\n\n`; for (const content of msg.message.content || []) { if (content.type === "text") { markdown += `${content.text}\n\n`; } else if (content.type === "tool_use") { markdown += `### Tool: ${content.name}\n\n`; markdown += `\`\`\`json\n${JSON.stringify(content.input, null, 2)}\n\`\`\`\n\n`; } } if (msg.message.usage) { markdown += `*Tokens: ${msg.message.usage.input_tokens} in, ${msg.message.usage.output_tokens} out*\n\n`; } } else if (msg.type === "user" && msg.message) { markdown += `## User\n\n`; for (const content of msg.message.content || []) { if (content.type === "text") { markdown += `${content.text}\n\n`; } else if (content.type === "tool_result") { markdown += `### Tool Result\n\n`; markdown += `\`\`\`\n${content.content}\n\`\`\`\n\n`; } } } else if (msg.type === "result") { markdown += `## Execution Result\n\n`; if (msg.result) { markdown += `${msg.result}\n\n`; } if (msg.error) { markdown += `**Error:** ${msg.error}\n\n`; } } } await navigator.clipboard.writeText(markdown); setCopyPopoverOpen(false); setToast({ message: 'Output copied as Markdown', type: 'success' }); }; const refreshOutput = async () => { setRefreshing(true); await loadOutput(true); // Skip cache setRefreshing(false); }; const handleScroll = (e: React.UIEvent) => { const target = e.currentTarget; const { scrollTop, scrollHeight, clientHeight } = target; const distanceFromBottom = scrollHeight - scrollTop - clientHeight; setHasUserScrolled(distanceFromBottom > 50); }; // Load output on mount useEffect(() => { if (!run.id) return; // Check cache immediately for instant display const cached = getCachedOutput(run.id); if (cached) { const cachedJsonlLines = cached.output.split('\n').filter(line => line.trim()); setRawJsonlOutput(cachedJsonlLines); setMessages(cached.messages); } // Then load fresh data loadOutput(); }, [run.id]); const displayableMessages = useMemo(() => { return messages.filter((message) => { if (message.isMeta && !message.leafUuid && !message.summary) return false; if (message.type === "user" && message.message) { if (message.isMeta) return false; const msg = message.message; if (!msg.content || (Array.isArray(msg.content) && msg.content.length === 0)) return false; if (Array.isArray(msg.content)) { let hasVisibleContent = false; for (const content of msg.content) { if (content.type === "text") { hasVisibleContent = true; break; } if (content.type === "tool_result") { // Check if this tool result will be displayed as a widget let willBeSkipped = false; if (content.tool_use_id) { // Find the corresponding tool use for (let i = messages.indexOf(message) - 1; i >= 0; i--) { const prevMsg = messages[i]; if (prevMsg.type === 'assistant' && prevMsg.message?.content && Array.isArray(prevMsg.message.content)) { const toolUse = prevMsg.message.content.find((c: any) => c.type === 'tool_use' && c.id === content.tool_use_id); if (toolUse) { const toolName = toolUse.name?.toLowerCase(); const toolsWithWidgets = ['task','edit','multiedit','todowrite','ls','read','glob','bash','write','grep']; if (toolsWithWidgets.includes(toolName) || toolUse.name?.startsWith('mcp__')) { willBeSkipped = true; } break; } } } } if (!willBeSkipped) { hasVisibleContent = true; break; } } } if (!hasVisibleContent) return false; } } return true; }); }, [messages]); const renderIcon = (iconName: string) => { const Icon = AGENT_ICONS[iconName as keyof typeof AGENT_ICONS] || Bot; return ; }; const formatDuration = (ms?: number) => { if (!ms) return "N/A"; const seconds = Math.floor(ms / 1000); if (seconds < 60) return `${seconds}s`; const minutes = Math.floor(seconds / 60); const remainingSeconds = seconds % 60; return `${minutes}m ${remainingSeconds}s`; }; const formatTokens = (tokens?: number) => { if (!tokens) return "0"; if (tokens >= 1000) { return `${(tokens / 1000).toFixed(1)}k`; } return tokens.toString(); }; return ( <>
{renderIcon(run.agent_icon)}
{run.agent_name} {run.status === 'running' && (
Running
)}

{run.task}

{run.model === 'opus' ? 'Claude 4 Opus' : 'Claude 4 Sonnet'}
{formatISOTimestamp(run.created_at)}
{run.metrics?.duration_ms && ( {formatDuration(run.metrics.duration_ms)} )} {run.metrics?.total_tokens && (
{formatTokens(run.metrics.total_tokens)}
)} {run.metrics?.cost_usd && (
${run.metrics.cost_usd.toFixed(4)}
)}
Copy } content={
} open={copyPopoverOpen} onOpenChange={setCopyPopoverOpen} align="end" /> {onOpenFullView && ( )}
{loading ? (
Loading output...
) : messages.length === 0 ? (

No output available yet

) : (
{displayableMessages.map((message: ClaudeStreamMessage, index: number) => ( ))}
)} {/* Fullscreen Modal */} {isFullscreen && (
{renderIcon(run.agent_icon)}

{run.agent_name}

{run.task}

Copy Output } content={
} align="end" />
{messages.length === 0 ? (
No output available yet
) : ( <> {displayableMessages.map((message: ClaudeStreamMessage, index: number) => ( ))}
)}
)} {/* Toast Notification */} {toast && ( setToast(null)} /> )} ); }