/** * ChatLogBrowser — Browse log entries with compact inline tree * * Transforms LogEntry[] into a navigable list. * Drill into any entry to inspect its fields as a compact line-based tree. * JSON-containing messages are auto-parsed into browsable sub-trees. */ import React, { useState, useMemo, useCallback, useRef, useEffect } from 'react'; import { LogEntry } from '@/contexts/LogContext'; import { resolveNavItems } from '@/components/json/JSONTreeWalker'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { cn } from '@/lib/utils'; import { ListFilter, Trash2, Search, X, ChevronRight, ArrowLeft, Home, } from 'lucide-react'; // ── Types ──────────────────────────────────────────────────────────────── interface ChatLogBrowserProps { logs: LogEntry[]; clearLogs: () => void; title?: string; } type LevelFilter = 'all' | 'info' | 'debug' | 'warning' | 'error' | 'success'; // ── Helpers ────────────────────────────────────────────────────────────── const LEVEL_COLORS: Record = { error: 'text-red-500', warning: 'text-yellow-500', info: 'text-blue-500', debug: 'text-purple-400', success: 'text-green-500', }; const LEVEL_BG: Record = { error: 'bg-red-500/10 border-red-500/20', warning: 'bg-yellow-500/10 border-yellow-500/20', info: 'bg-blue-500/10 border-blue-500/20', debug: 'bg-purple-400/10 border-purple-400/20', success: 'bg-green-500/10 border-green-500/20', }; /** Try to extract JSON from a log message string. */ function tryParseJson(msg: string): any | null { try { const parsed = JSON.parse(msg); if (typeof parsed === 'object' && parsed !== null) return parsed; } catch { /* not pure JSON */ } const firstBrace = msg.search(/[{\[]/); if (firstBrace >= 0) { const sub = msg.slice(firstBrace); try { const parsed = JSON.parse(sub); if (typeof parsed === 'object' && parsed !== null) return parsed; } catch { /* no valid JSON suffix */ } } return null; } /** Build a structured object from a LogEntry for tree browsing. */ function logEntryToTree(log: LogEntry): Record { const tree: Record = { level: log.level, timestamp: log.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', }), message: log.message, }; if (log.category) tree.category = log.category; // Structured data from tools (preferred) or auto-parsed JSON from message if (log.data) { tree.data = log.data; } else { const parsed = tryParseJson(log.message); if (parsed) tree.parsedContent = parsed; } return tree; } /** Short preview of a log message. */ function previewMessage(msg: string, maxLen = 80): string { const line = msg.replace(/\n/g, ' ').trim(); return line.length > maxLen ? line.slice(0, maxLen) + '…' : line; } /** Format a value for compact display. */ function compactValue(val: any): string { if (val === null) return 'null'; if (val === undefined) return 'undefined'; if (typeof val === 'object') { if (Array.isArray(val)) return `[${val.length} items]`; const keys = Object.keys(val); return `{${keys.length} keys}`; } const s = String(val); return s.length > 120 ? s.slice(0, 120) + '…' : s; } // ── Compact Tree View ──────────────────────────────────────────────────── interface DeepSearchResult { path: string[]; key: string; value: any; displayValue: string; canDrillIn: boolean; } /** Recursively collect all matching key/value pairs with their full paths. */ function deepCollect(obj: any, terms: string[], currentPath: string[], results: DeepSearchResult[], maxResults = 100): void { if (results.length >= maxResults || obj == null || typeof obj !== 'object') return; const entries = Array.isArray(obj) ? obj.map((v, i) => [String(i), v] as [string, any]) : Object.entries(obj); for (const [key, value] of entries) { const keyLower = key.toLowerCase(); const isObj = value !== null && typeof value === 'object'; const isArr = Array.isArray(value); const valStr = isObj ? (isArr ? `[${(value as any[]).length} items]` : `{${Object.keys(value as object).length} keys}`) : String(value ?? 'null'); const valLower = valStr.toLowerCase(); const fullPath = [...currentPath, key]; // Check if this entry matches const keyMatches = terms.every(t => keyLower.includes(t)); const valMatches = !isObj && terms.every(t => valLower.includes(t)); if (keyMatches || valMatches) { results.push({ path: currentPath, key, value, displayValue: valStr, canDrillIn: isObj, }); } // Recurse into children regardless (to find deeper matches) if (isObj && results.length < maxResults) { deepCollect(value, terms, fullPath, results, maxResults); } } } export const CompactTreeView: React.FC<{ data: any; onExit: () => void; header: React.ReactNode; }> = ({ data, onExit, header }) => { const [path, setPath] = useState([]); const [selectedIdx, setSelectedIdx] = useState(0); const [search, setSearch] = useState(''); const containerRef = useRef(null); const searchRef = useRef(null); // Resolve items at current path (normal browsing) const rawItems = useMemo(() => { return resolveNavItems(data, path) || []; }, [data, path]); // Deep search results (flat list with paths) const searchResults = useMemo(() => { if (!search.trim()) return null; const terms = search.toLowerCase().split(' ').filter(Boolean); const results: DeepSearchResult[] = []; deepCollect(data, terms, [], results); return results; }, [data, search]); // Active list: search results or normal items const isSearchMode = searchResults !== null; const listLength = isSearchMode ? searchResults!.length : rawItems.length; // Clamp selection useEffect(() => { if (selectedIdx >= listLength) setSelectedIdx(Math.max(0, listLength - 1)); }, [listLength, selectedIdx]); // Auto-focus container on mount so keyboard nav works immediately useEffect(() => { requestAnimationFrame(() => containerRef.current?.focus()); }, []); // Reset search on path change (selectedIdx is managed by goUp / drill-in) useEffect(() => { setSearch(''); }, [path]); // Navigate to a deep search result const navigateToResult = useCallback((result: DeepSearchResult) => { // Navigate to the parent path, so the matched key is visible in the list if (result.canDrillIn) { setPath([...result.path, result.key]); } else { setPath(result.path); } setSelectedIdx(0); }, []); // Keyboard nav useEffect(() => { const el = containerRef.current; if (!el) return; const handler = (e: KeyboardEvent) => { if (e.target instanceof HTMLInputElement) { if (e.key === 'Escape') { e.preventDefault(); setSearch(''); containerRef.current?.focus(); } else if (e.key === 'ArrowDown') { e.preventDefault(); setSelectedIdx(i => i >= listLength - 1 ? 0 : i + 1); } else if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedIdx(i => i <= 0 ? listLength - 1 : i - 1); } else if (e.key === 'Enter') { e.preventDefault(); if (isSearchMode && searchResults![selectedIdx]) { navigateToResult(searchResults![selectedIdx]); } } return; } if (e.key === 'ArrowDown') { e.preventDefault(); setSelectedIdx(i => i >= listLength - 1 ? 0 : i + 1); } else if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedIdx(i => i <= 0 ? listLength - 1 : i - 1); } else if (e.key === 'ArrowRight' || e.key === 'Enter') { e.preventDefault(); if (isSearchMode) { const r = searchResults![selectedIdx]; if (r) navigateToResult(r); } else { const item = rawItems[selectedIdx]; if (item?.canDrillIn) { setPath(p => [...p, item.key]); setSelectedIdx(0); } } } else if (e.key === 'ArrowLeft' || e.key === 'Backspace') { e.preventDefault(); if (path.length > 0) { const lastSeg = path[path.length - 1]; const parentPath = path.slice(0, -1); const parentItems = resolveNavItems(data, parentPath) || []; const restoredIdx = parentItems.findIndex((it: any) => it.key === lastSeg); setPath(parentPath); setSelectedIdx(restoredIdx >= 0 ? restoredIdx : 0); } else { onExit(); } } else if (e.key === 'Escape') { e.preventDefault(); if (path.length > 0) { const firstSeg = path[0]; const rootItems = resolveNavItems(data, []) || []; const restoredIdx = rootItems.findIndex((it: any) => it.key === firstSeg); setPath([]); setSelectedIdx(restoredIdx >= 0 ? restoredIdx : 0); } else onExit(); } else if (e.key === 'Home') { e.preventDefault(); setSelectedIdx(0); } else if (e.key === 'End') { e.preventDefault(); setSelectedIdx(listLength - 1); } else if (e.key === '/' || (e.key === 'f' && e.ctrlKey)) { e.preventDefault(); searchRef.current?.focus(); } }; el.addEventListener('keydown', handler); return () => el.removeEventListener('keydown', handler); }, [listLength, selectedIdx, path, onExit, isSearchMode, searchResults, rawItems, navigateToResult]); // Scroll selection into view useEffect(() => { const el = containerRef.current?.querySelector(`[data-tree-idx="${selectedIdx}"]`); el?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); }, [selectedIdx]); return (
{/* Header with back */} {header} {/* Search bar */}
{ setSearch(e.target.value); setSelectedIdx(0); }} /> {search && ( )} {search && ( {listLength} )}
{/* Breadcrumb (only in browse mode) */} {!isSearchMode && path.length > 0 && (
{path.map((seg, i) => ( ))}
)} {/* Items / Search Results */}
{isSearchMode ? ( /* ── Deep search results ── */ searchResults!.length === 0 ? (
No matches
) : ( searchResults!.map((result, idx) => (
{ setSelectedIdx(idx); navigateToResult(result); }} className={cn( "px-2 py-1 rounded cursor-pointer transition-colors group", selectedIdx === idx ? "bg-primary/10 ring-1 ring-primary/30" : "hover:bg-muted/50", )} > {/* Path breadcrumb */} {result.path.length > 0 && (
{result.path.join(' › ')}
)} {/* Key : Value */}
{result.key} : {result.displayValue.length > 120 ? result.displayValue.slice(0, 120) + '…' : result.displayValue} {result.canDrillIn && ( )}
)) ) ) : ( /* ── Normal browse items ── */ rawItems.length === 0 ? (
Empty
) : ( rawItems.map((item, idx) => (
{ setSelectedIdx(idx); if (item.canDrillIn) { setPath(p => [...p, item.key]); setSelectedIdx(0); } }} className={cn( "flex items-center gap-1.5 px-2 py-[3px] rounded cursor-pointer transition-colors group text-[11px] font-mono", selectedIdx === idx ? "bg-primary/10 ring-1 ring-primary/30" : "hover:bg-muted/50", )} > {item.key} : {compactValue(item.value)} {item.canDrillIn && ( )}
)) ) )}
); }; // ── Main Component ─────────────────────────────────────────────────────── const ChatLogBrowser: React.FC = ({ logs, clearLogs, title = 'Chat Logs' }) => { const [levelFilter, setLevelFilter] = useState('all'); const [searchTerm, setSearchTerm] = useState(''); const [selectedIdx, setSelectedIdx] = useState(null); const [highlightedIdx, setHighlightedIdx] = useState(0); const listRef = useRef(null); // Filtered logs const filtered = useMemo(() => { let result = logs; if (levelFilter !== 'all') { result = result.filter(l => l.level === levelFilter); } if (searchTerm.trim()) { const terms = searchTerm.toLowerCase().split(' ').filter(Boolean); result = result.filter(l => terms.every(t => l.message.toLowerCase().includes(t) || l.level.includes(t) || (l.category || '').toLowerCase().includes(t) ) ); } return result; }, [logs, levelFilter, searchTerm]); // Counts per level const counts = useMemo(() => { const c: Record = { all: logs.length }; for (const l of logs) c[l.level] = (c[l.level] || 0) + 1; return c; }, [logs]); // Reset highlight when filter changes useEffect(() => { setHighlightedIdx(0); }, [levelFilter, searchTerm]); // Keyboard nav in list mode useEffect(() => { if (selectedIdx !== null) return; const el = listRef.current; if (!el) return; const handler = (e: KeyboardEvent) => { if (e.target instanceof HTMLInputElement) return; if (e.key === 'ArrowDown') { e.preventDefault(); setHighlightedIdx(i => i >= filtered.length - 1 ? 0 : i + 1); } else if (e.key === 'ArrowUp') { e.preventDefault(); setHighlightedIdx(i => i <= 0 ? filtered.length - 1 : i - 1); } else if (e.key === 'Enter' || e.key === 'ArrowRight') { e.preventDefault(); if (filtered.length > 0) setSelectedIdx(highlightedIdx); } else if (e.key === 'Home') { e.preventDefault(); setHighlightedIdx(0); } else if (e.key === 'End') { e.preventDefault(); setHighlightedIdx(filtered.length - 1); } }; el.addEventListener('keydown', handler); return () => el.removeEventListener('keydown', handler); }, [selectedIdx, filtered, highlightedIdx]); // Scroll highlighted into view useEffect(() => { const el = listRef.current?.querySelector(`[data-log-idx="${highlightedIdx}"]`); el?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); }, [highlightedIdx]); const handleBack = useCallback(() => { setSelectedIdx(null); requestAnimationFrame(() => listRef.current?.focus()); }, []); // ── Drilled-in view (compact inline tree) ──────────────────────────── if (selectedIdx !== null) { const log = filtered[selectedIdx]; if (!log) { setSelectedIdx(null); return null; } const treeData = logEntryToTree(log); return (
[{log.level.toUpperCase()}] {previewMessage(log.message, 40)}
} /> ); } // ── List view ──────────────────────────────────────────────────────── const TABS: { key: LevelFilter; label: string }[] = [ { key: 'all', label: 'All' }, { key: 'info', label: 'Info' }, { key: 'debug', label: 'Debug' }, { key: 'warning', label: 'Warn' }, { key: 'error', label: 'Err' }, { key: 'success', label: 'OK' }, ]; return (
{/* Header */}

{title} ({filtered.length})

{/* Search */}
setSearchTerm(e.target.value)} /> {searchTerm && ( )}
{/* Level tabs */}
{TABS.map(tab => ( ))}
{/* Log list */}
{filtered.length === 0 ? (

No logs{searchTerm ? ' matching filter' : ''}

) : (
{filtered.map((log, idx) => (
{ setHighlightedIdx(idx); setSelectedIdx(idx); }} className={cn( "flex items-start gap-1.5 px-2 py-1 rounded cursor-pointer transition-colors group text-[11px] font-mono", highlightedIdx === idx ? "bg-primary/10 ring-1 ring-primary/30" : "hover:bg-muted/50", )} > {/* Time */} {log.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })} {/* Level badge */} {log.level.slice(0, 3).toUpperCase()} {/* Message preview */} {previewMessage(log.message)} {/* Drill-in hint */}
))}
)}
); }; export default ChatLogBrowser;