import { useState, useEffect, useRef, useMemo } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { X, Maximize2, Minimize2, Copy, RefreshCw, RotateCcw, ChevronDown } 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 } from '@/lib/api'; import { useOutputCache } from '@/lib/outputCache'; import type { AgentRun } from '@/lib/api'; import { listen, type UnlistenFn } from '@tauri-apps/api/event'; import { StreamMessage } from './StreamMessage'; import { ErrorBoundary } from './ErrorBoundary'; interface SessionOutputViewerProps { session: AgentRun; onClose: () => void; className?: string; } // Use the same message interface as AgentExecution for consistency export interface ClaudeStreamMessage { type: "system" | "assistant" | "user" | "result"; subtype?: string; message?: { content?: any[]; usage?: { input_tokens: number; output_tokens: number; }; }; usage?: { input_tokens: number; output_tokens: number; }; [key: string]: any; } export function SessionOutputViewer({ session, onClose, className }: SessionOutputViewerProps) { 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 similar to AgentExecution 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 (!session.id) return; try { // Check cache first if not skipping cache if (!skipCache) { const cached = getCachedOutput(session.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 && session.status !== 'running') { return; } } } setLoading(true); const rawOutput = await api.getSessionOutput(session.id); // Parse JSONL output into messages using AgentExecution style 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(session.id, { output: rawOutput, messages: parsedMessages, lastUpdated: Date.now(), status: session.status }); // Set up live event listeners for running sessions if (session.status === 'running') { setupLiveEventListeners(); try { await api.streamSessionOutput(session.id); } catch (streamError) { console.warn('Failed to start streaming, will poll instead:', streamError); } } } catch (error) { console.error('Failed to load session output:', error); setToast({ message: '加载会话输出失败', type: 'error' }); } finally { setLoading(false); } }; const setupLiveEventListeners = async () => { if (!session.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:${session.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:${session.id}`, (event) => { console.error("Agent error:", event.payload); setToast({ message: event.payload, type: 'error' }); }); const completeUnlisten = await listen(`agent-complete:${session.id}`, () => { setToast({ message: '智能体执行完成', type: 'success' }); // Don't set status here as the parent component should handle it }); const cancelUnlisten = await listen(`agent-cancelled:${session.id}`, () => { setToast({ message: '智能体执行已取消', type: 'error' }); }); unlistenRefs.current = [outputUnlisten, errorUnlisten, completeUnlisten, cancelUnlisten]; } catch (error) { console.error('Failed to set up live event listeners:', error); } }; // Copy functionality similar to AgentExecution const handleCopyAsJsonl = async () => { const jsonl = rawJsonlOutput.join('\n'); await navigator.clipboard.writeText(jsonl); setCopyPopoverOpen(false); setToast({ message: '输出已复制为JSONL', type: 'success' }); }; const handleCopyAsMarkdown = async () => { let markdown = `# Agent Session: ${session.agent_name}\n\n`; markdown += `**Status:** ${session.status}\n`; if (session.task) markdown += `**Task:** ${session.task}\n`; if (session.model) markdown += `**Model:** ${session.model}\n`; markdown += `**Date:** ${new Date().toISOString()}\n\n`; markdown += `---\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: '输出已复制为Markdown', type: 'success' }); }; const refreshOutput = async () => { setRefreshing(true); try { await loadOutput(true); // Skip cache when manually refreshing setToast({ message: '输出已刷新', type: 'success' }); } catch (error) { console.error('Failed to refresh output:', error); setToast({ message: '刷新输出失败', type: 'error' }); } finally { setRefreshing(false); } }; // Load output on mount and check cache first useEffect(() => { if (!session.id) return; // Check cache immediately for instant display const cached = getCachedOutput(session.id); if (cached) { const cachedJsonlLines = cached.output.split('\n').filter(line => line.trim()); setRawJsonlOutput(cachedJsonlLines); setMessages(cached.messages); } // Then load fresh data loadOutput(); }, [session.id]); const displayableMessages = useMemo(() => { return messages.filter((message, index) => { 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") { let willBeSkipped = false; if (content.tool_use_id) { for (let i = index - 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]); return ( <>
{session.agent_icon}
{session.agent_name} - 输出
{session.status} {session.status === 'running' && (
实时
)} {messages.length} 条消息
{messages.length > 0 && ( <> 复制输出 } content={
} open={copyPopoverOpen} onOpenChange={setCopyPopoverOpen} align="end" /> )}
{loading ? (
正在加载输出...
) : (
{ // Mark that user has scrolled manually if (!hasUserScrolled) { setHasUserScrolled(true); } // If user scrolls back to bottom, re-enable auto-scroll if (isAtBottom()) { setHasUserScrolled(false); } }} > {messages.length === 0 ? (
{session.status === 'running' ? ( <>

等待输出...

智能体正在运行但尚未收到输出

) : ( <>

暂无输出

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

{session.agent_name} - 输出

{session.status === 'running' && (
运行中
)}
{messages.length > 0 && ( 复制输出 } content={
} open={copyPopoverOpen} onOpenChange={setCopyPopoverOpen} align="end" /> )}
{/* Modal Content */}
{ // Mark that user has scrolled manually if (!hasUserScrolled) { setHasUserScrolled(true); } // If user scrolls back to bottom, re-enable auto-scroll if (isAtBottom()) { setHasUserScrolled(false); } }} > {messages.length === 0 ? (
{session.status === 'running' ? ( <>

等待输出...

智能体正在运行但尚未收到输出

) : ( <>

暂无输出

)}
) : ( <> {displayableMessages.map((message: ClaudeStreamMessage, index: number) => ( ))}
)}
)} {/* Toast Notification */} {toast && ( setToast(null)} /> )} ); }