import { useState, useEffect, useRef } from 'react' import { X, FolderOpen, RefreshCw, ChevronDown, ChevronUp, Download, Search } from 'lucide-react' import { Button } from './ui/button' import { logger } from '../lib/logger' import { getBackendCredentials } from '../lib/backend' interface LogViewerProps { isOpen: boolean onClose: () => void embedded?: boolean } // Wraps every case-insensitive occurrence of `query` in the line with a , // keeping all other text as-is -- marks matches instead of hiding non-matching // lines, so surrounding context stays visible while searching. function highlightMatches(line: string, query: string): React.ReactNode { if (!query) return line const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') const parts = line.split(new RegExp(`(${escaped})`, 'gi')) if (parts.length === 1) return line // String.split with a capturing group alternates [text, match, text, match, ...] -- // odd indices are always the captured matches. return parts.map((part, i) => i % 2 === 1 ? {part} : {part}, ) } export function LogViewer({ isOpen, onClose, embedded = false }: LogViewerProps) { const [logs, setLogs] = useState([]) const [logPath, setLogPath] = useState('') const [isLoading, setIsLoading] = useState(false) const [autoScroll, setAutoScroll] = useState(true) const [searchInput, setSearchInput] = useState('') const [searchQuery, setSearchQuery] = useState('') const [tokenCopied, setTokenCopied] = useState(false) const logContainerRef = useRef(null) // Autoscroll doesn't make sense while browsing search matches -- remember // the user's prior preference so clearing the search restores it exactly. const savedAutoScrollRef = useRef(null) // Debounce the raw input so typing doesn't fire an IPC round-trip (a full // untruncated log read while searching) on every keystroke. useEffect(() => { const timer = setTimeout(() => setSearchQuery(searchInput.trim()), 300) return () => clearTimeout(timer) }, [searchInput]) const fetchLogs = async () => { if (!window.electronAPI?.getLogs) return setIsLoading(true) try { const result = await window.electronAPI.getLogs({ query: searchQuery || undefined }) setLogs(result.lines || []) setLogPath(result.logPath || '') } catch (error) { logger.error(`Failed to fetch logs: ${error}`) } finally { setIsLoading(false) } } useEffect(() => { if (!isOpen) return fetchLogs() // Live-tail only when NOT searching. A search reads the whole session file and // ships every line over IPC (then re-highlights each), so polling that every 2s is a // large CPU/IPC cost for no gain -- results are a point-in-time view. Fetch once per // query change instead (this effect re-runs on searchQuery), and skip the interval. if (searchQuery) return const interval = setInterval(fetchLogs, 2000) return () => clearInterval(interval) }, [isOpen, searchQuery]) const matchCount = searchQuery ? logs.filter(l => l.toLowerCase().includes(searchQuery.toLowerCase())).length : 0 useEffect(() => { const isSearching = Boolean(searchQuery) if (isSearching && savedAutoScrollRef.current === null) { savedAutoScrollRef.current = autoScroll setAutoScroll(false) } else if (!isSearching && savedAutoScrollRef.current !== null) { setAutoScroll(savedAutoScrollRef.current) savedAutoScrollRef.current = null } // autoScroll intentionally excluded -- this only reacts to searchQuery // transitions, not to the manual toggle (which is disabled while searching anyway). // eslint-disable-next-line react-hooks/exhaustive-deps }, [searchQuery]) useEffect(() => { if (autoScroll && logContainerRef.current) { logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight } }, [logs, autoScroll]) const handleOpenFolder = async () => { if (window.electronAPI?.openLogFolder) { await window.electronAPI.openLogFolder() } } // Copy the backend Bearer token so local QA/perf scripts can authenticate // against the Electron-spawned backend (its token is random per launch). // Surfaced via a semi-hidden control in the footer (see below). const handleCopyToken = async () => { try { const { token } = await getBackendCredentials() if (!token) { logger.error('No backend token available to copy') return } await navigator.clipboard.writeText(token) setTokenCopied(true) setTimeout(() => setTokenCopied(false), 1200) } catch (error) { logger.error(`Failed to copy backend token: ${error}`) } } const handleDownload = () => { const content = logs.join('\n') const blob = new Blob([content], { type: 'text/plain' }) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = logPath ? logPath.split(/[/\\]/).pop() || 'session.log' : 'session.log' a.click() URL.revokeObjectURL(url) } if (!isOpen) return null const panel = (
{/* Header */}

Logs

{logPath}
{!embedded && ( )}
{/* Search */}
setSearchInput(e.target.value)} placeholder="Search logs (case-insensitive)..." className="flex-1 bg-transparent text-xs text-zinc-200 placeholder:text-zinc-600 focus:outline-none" /> {searchQuery && ( {matchCount} matching line{matchCount === 1 ? '' : 's'} )}
{/* Log content */}
{logs.length === 0 ? (
No logs yet...
) : (
{logs.map((line, index) => { // Color code log levels let lineClass = 'text-zinc-300' if (line.includes(' - ERROR - ') || line.includes(' - CRITICAL - ')) { lineClass = 'text-red-400' } else if (line.includes(' - WARNING - ')) { lineClass = 'text-yellow-400' } else if (line.includes(' - INFO - ')) { lineClass = 'text-blue-300' } else if (line.includes(' - DEBUG - ')) { lineClass = 'text-zinc-500' } return (
{highlightMatches(line, searchQuery)}
) })}
)}
{/* Footer */}
{logs.length} lines {searchQuery ? '(full session)' : '(last 200)'}
{/* Semi-hidden: copies the backend Bearer token for local QA/perf scripts (the Electron-spawned backend's token is random per launch). Muted until hover so it stays out of the way. */} Auto-refreshing every 2s
) if (embedded) { return panel } return (
{panel}
) }