"use client"; import { useState, useMemo, useEffect, useRef } from "react"; import { ChevronDown, ChevronRight, MessageSquare, Wrench, Zap, AlertCircle, CheckCircle2, AlertTriangle, Bug, Info, FileOutput, Brain, Eye, Pencil, Terminal, Search, ListTodo, Layers, X, Filter, Circle, Play, Loader2, } from "lucide-react"; import { cn } from "@/lib/utils"; import { parseLogOutput, getLogTypeColors, shouldCollapseByDefault, type LogEntry, type LogEntryType, type ToolCategory, } from "@/lib/log-parser"; interface LogViewerProps { output: string; className?: string; } const getLogIcon = (type: LogEntryType) => { switch (type) { case "prompt": return ; case "tool_call": return ; case "tool_result": return ; case "phase": return ; case "error": return ; case "success": return ; case "warning": return ; case "thinking": return ; case "debug": return ; default: return ; } }; /** * Returns a tool-specific icon based on the tool category */ const getToolCategoryIcon = (category: ToolCategory | undefined) => { switch (category) { case "read": return ; case "edit": return ; case "write": return ; case "bash": return ; case "search": return ; case "todo": return ; case "task": return ; default: return ; } }; /** * Returns color classes for a tool category */ const getToolCategoryColor = (category: ToolCategory | undefined): string => { switch (category) { case "read": return "text-blue-400 bg-blue-500/10 border-blue-500/30"; case "edit": return "text-amber-400 bg-amber-500/10 border-amber-500/30"; case "write": return "text-emerald-400 bg-emerald-500/10 border-emerald-500/30"; case "bash": return "text-purple-400 bg-purple-500/10 border-purple-500/30"; case "search": return "text-cyan-400 bg-cyan-500/10 border-cyan-500/30"; case "todo": return "text-green-400 bg-green-500/10 border-green-500/30"; case "task": return "text-indigo-400 bg-indigo-500/10 border-indigo-500/30"; default: return "text-zinc-400 bg-zinc-500/10 border-zinc-500/30"; } }; /** * Interface for parsed todo items from TodoWrite tool */ interface TodoItem { content: string; status: "pending" | "in_progress" | "completed"; activeForm?: string; } /** * Parses TodoWrite JSON content and extracts todo items */ function parseTodoContent(content: string): TodoItem[] | null { try { // Find the JSON object in the content const jsonMatch = content.match(/\{[\s\S]*"todos"[\s\S]*\}/); if (!jsonMatch) return null; const parsed = JSON.parse(jsonMatch[0]) as { todos?: TodoItem[] }; if (!parsed.todos || !Array.isArray(parsed.todos)) return null; return parsed.todos; } catch { return null; } } /** * Renders a list of todo items with status icons and colors */ function TodoListRenderer({ todos }: { todos: TodoItem[] }) { const getStatusIcon = (status: TodoItem["status"]) => { switch (status) { case "completed": return ; case "in_progress": return ; case "pending": return ; default: return ; } }; const getStatusColor = (status: TodoItem["status"]) => { switch (status) { case "completed": return "text-emerald-300 line-through opacity-70"; case "in_progress": return "text-amber-300"; case "pending": return "text-zinc-400"; default: return "text-zinc-400"; } }; const getStatusBadge = (status: TodoItem["status"]) => { switch (status) { case "completed": return ( Done ); case "in_progress": return ( In Progress ); default: return null; } }; return (
{todos.map((todo, index) => (
{getStatusIcon(todo.status)}

{todo.content}

{todo.status === "in_progress" && todo.activeForm && (

{todo.activeForm}

)}
{getStatusBadge(todo.status)}
))}
); } interface LogEntryItemProps { entry: LogEntry; isExpanded: boolean; onToggle: () => void; } function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) { const colors = getLogTypeColors(entry.type); const hasContent = entry.content.length > 100; // For tool_call entries, use tool-specific styling const isToolCall = entry.type === "tool_call"; const toolCategory = entry.metadata?.toolCategory; const toolCategoryColors = isToolCall ? getToolCategoryColor(toolCategory) : ""; // Check if this is a TodoWrite entry and parse the todos const isTodoWrite = entry.metadata?.toolName === "TodoWrite"; const parsedTodos = useMemo(() => { if (!isTodoWrite) return null; return parseTodoContent(entry.content); }, [isTodoWrite, entry.content]); // Get the appropriate icon based on entry type and tool category const icon = isToolCall ? getToolCategoryIcon(toolCategory) : getLogIcon(entry.type); // Get collapsed preview text - prefer smart summary for tool calls const collapsedPreview = useMemo(() => { if (isExpanded) return ""; // Use smart summary if available if (entry.metadata?.summary) { return entry.metadata.summary; } // Fallback to truncated content return entry.content.slice(0, 80) + (entry.content.length > 80 ? "..." : ""); }, [isExpanded, entry.metadata?.summary, entry.content]); // Format content - detect and highlight JSON const formattedContent = useMemo(() => { let content = entry.content; // For tool_call entries, remove redundant "Tool: X" and "Input:" prefixes // since we already show the tool name in the header badge if (isToolCall) { // Remove "🔧 Tool: ToolName\n" or "Tool: ToolName\n" prefix content = content.replace(/^(?:🔧\s*)?Tool:\s*\w+\s*\n?/i, ""); // Remove standalone "Input:" label (keep the JSON that follows) content = content.replace(/^Input:\s*\n?/i, ""); content = content.trim(); } // For summary entries, remove the and tags if (entry.title === "Summary") { content = content.replace(/^\s*/i, ""); content = content.replace(/\s*<\/summary>\s*$/i, ""); content = content.trim(); } // Try to find and format JSON blocks const jsonRegex = /(\{[\s\S]*?\}|\[[\s\S]*?\])/g; let lastIndex = 0; const parts: { type: "text" | "json"; content: string }[] = []; let match; while ((match = jsonRegex.exec(content)) !== null) { // Add text before JSON if (match.index > lastIndex) { parts.push({ type: "text", content: content.slice(lastIndex, match.index), }); } // Try to parse and format JSON try { const parsed = JSON.parse(match[1]); parts.push({ type: "json", content: JSON.stringify(parsed, null, 2), }); } catch { // Not valid JSON, treat as text parts.push({ type: "text", content: match[1] }); } lastIndex = match.index + match[1].length; } // Add remaining text if (lastIndex < content.length) { parts.push({ type: "text", content: content.slice(lastIndex) }); } return parts.length > 0 ? parts : [{ type: "text" as const, content }]; }, [entry.content, entry.title, isToolCall]); // Get colors - use tool category colors for tool_call entries const colorParts = toolCategoryColors.split(" "); const textColor = isToolCall ? (colorParts[0] || "text-zinc-400") : colors.text; const bgColor = isToolCall ? (colorParts[1] || "bg-zinc-500/10") : colors.bg; const borderColor = isToolCall ? (colorParts[2] || "border-zinc-500/30") : colors.border; return (
{(isExpanded || !hasContent) && (
{/* Render TodoWrite entries with special formatting */} {parsedTodos ? ( ) : (
{formattedContent.map((part, index) => (
{part.type === "json" ? (
                      {part.content}
                    
) : (
                      {part.content}
                    
)}
))}
)}
)}
); } interface ToolCategoryStats { read: number; edit: number; write: number; bash: number; search: number; todo: number; task: number; other: number; } export function LogViewer({ output, className }: LogViewerProps) { const [expandedIds, setExpandedIds] = useState>(new Set()); const [searchQuery, setSearchQuery] = useState(""); const [hiddenTypes, setHiddenTypes] = useState>(new Set()); const [hiddenCategories, setHiddenCategories] = useState>(new Set()); // Parse entries and compute initial expanded state together const { entries, initialExpandedIds } = useMemo(() => { const parsedEntries = parseLogOutput(output); const toExpand: string[] = []; parsedEntries.forEach((entry) => { // If entry should NOT collapse by default, mark it for expansion if (!shouldCollapseByDefault(entry)) { toExpand.push(entry.id); } }); return { entries: parsedEntries, initialExpandedIds: new Set(toExpand), }; }, [output]); // Merge initial expanded IDs with user-toggled ones // Use a ref to track if we've applied initial state const appliedInitialRef = useRef>(new Set()); // Apply initial expanded state for new entries const effectiveExpandedIds = useMemo(() => { const result = new Set(expandedIds); initialExpandedIds.forEach((id) => { if (!appliedInitialRef.current.has(id)) { appliedInitialRef.current.add(id); result.add(id); } }); return result; }, [expandedIds, initialExpandedIds]); // Calculate stats for tool categories const stats = useMemo(() => { const toolCalls = entries.filter((e) => e.type === "tool_call"); const byCategory: ToolCategoryStats = { read: 0, edit: 0, write: 0, bash: 0, search: 0, todo: 0, task: 0, other: 0, }; toolCalls.forEach((tc) => { const cat = tc.metadata?.toolCategory || "other"; byCategory[cat]++; }); return { total: toolCalls.length, byCategory, errors: entries.filter((e) => e.type === "error").length, }; }, [entries]); // Filter entries based on search and hidden types/categories const filteredEntries = useMemo(() => { return entries.filter((entry) => { // Filter by hidden types if (hiddenTypes.has(entry.type)) return false; // Filter by hidden tool categories (for tool_call entries) if (entry.type === "tool_call" && entry.metadata?.toolCategory) { if (hiddenCategories.has(entry.metadata.toolCategory)) return false; } // Filter by search query if (searchQuery) { const query = searchQuery.toLowerCase(); return ( entry.content.toLowerCase().includes(query) || entry.title.toLowerCase().includes(query) || entry.metadata?.toolName?.toLowerCase().includes(query) || entry.metadata?.summary?.toLowerCase().includes(query) || entry.metadata?.filePath?.toLowerCase().includes(query) ); } return true; }); }, [entries, hiddenTypes, hiddenCategories, searchQuery]); const toggleEntry = (id: string) => { setExpandedIds((prev) => { const next = new Set(prev); if (next.has(id)) { next.delete(id); } else { next.add(id); } return next; }); }; const expandAll = () => { setExpandedIds(new Set(filteredEntries.map((e) => e.id))); }; const collapseAll = () => { setExpandedIds(new Set()); }; const toggleTypeFilter = (type: LogEntryType) => { setHiddenTypes((prev) => { const next = new Set(prev); if (next.has(type)) { next.delete(type); } else { next.add(type); } return next; }); }; const toggleCategoryFilter = (category: ToolCategory) => { setHiddenCategories((prev) => { const next = new Set(prev); if (next.has(category)) { next.delete(category); } else { next.add(category); } return next; }); }; const clearFilters = () => { setSearchQuery(""); setHiddenTypes(new Set()); setHiddenCategories(new Set()); }; const hasActiveFilters = searchQuery || hiddenTypes.size > 0 || hiddenCategories.size > 0; if (entries.length === 0) { return (

No log entries yet. Logs will appear here as the process runs.

{output && output.trim() && (
{output}
)}
); } // Count entries by type const typeCounts = entries.reduce((acc, entry) => { acc[entry.type] = (acc[entry.type] || 0) + 1; return acc; }, {} as Record); // Tool categories to display in stats bar const toolCategoryLabels: { key: ToolCategory; label: string }[] = [ { key: "read", label: "Read" }, { key: "edit", label: "Edit" }, { key: "write", label: "Write" }, { key: "bash", label: "Bash" }, { key: "search", label: "Search" }, { key: "todo", label: "Todo" }, { key: "task", label: "Task" }, { key: "other", label: "Other" }, ]; return (
{/* Sticky header with search, stats, and filters */} {/* Use -top-4 to compensate for parent's p-4 padding, pt-4 to restore visual spacing */}
{/* Search bar */}
setSearchQuery(e.target.value)} placeholder="Search logs..." className="w-full pl-8 pr-8 py-1.5 text-xs bg-zinc-900/50 border border-zinc-700/50 rounded-md text-zinc-200 placeholder:text-zinc-500 focus:outline-none focus:border-zinc-600" data-testid="log-search-input" /> {searchQuery && ( )}
{hasActiveFilters && ( )}
{/* Tool category stats bar */} {stats.total > 0 && (
{stats.total} tools: {toolCategoryLabels.map(({ key, label }) => { const count = stats.byCategory[key]; if (count === 0) return null; const isHidden = hiddenCategories.has(key); const colorClasses = getToolCategoryColor(key); return ( ); })} {stats.errors > 0 && ( {stats.errors} )}
)} {/* Header with type filters and controls */}
{Object.entries(typeCounts).map(([type, count]) => { const colors = getLogTypeColors(type as LogEntryType); const isHidden = hiddenTypes.has(type as LogEntryType); return ( ); })}
{filteredEntries.length}/{entries.length}
{/* Log entries */}
{filteredEntries.length === 0 ? (
No entries match your filters. {hasActiveFilters && ( )}
) : ( filteredEntries.map((entry) => ( toggleEntry(entry.id)} /> )) )}
); }