'use client'; import { useState, useEffect, useRef, useMemo, useSyncExternalStore } from 'react'; import { Button } from '@/components/ui/button'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { ChevronDown, ChevronUp, ChevronsUpDown, ChevronsDownUp, Bug, Trash2, Copy, Download } from 'lucide-react'; import { PanelContainer, PanelHeader } from '@/components/ui/panel'; import { MemoryMonitor } from './memory-monitor'; import { MessagesTab, type ForceState } from './messages-tab'; import { configManager } from '@/lib/config/storage'; import { requestSnapshotStore } from '@/lib/llm/request-snapshot'; import { toast } from 'sonner'; import type { DebugEvent } from '@/lib/stores/types'; interface DebugPanelProps { events: DebugEvent[]; onClear?: () => void; onClose?: () => void; } export function DebugPanel({ events, onClear, onClose }: DebugPanelProps) { const [activeTab, setActiveTab] = useState<'events' | 'messages'>('events'); const [filter, setFilter] = useState(''); const eventsEndRef = useRef(null); const [autoScroll, setAutoScroll] = useState(true); const [streamDebug, setStreamDebug] = useState(() => configManager.getDebugStreamEnabled()); const [eventsForce, setEventsForce] = useState(null); const messageSnapshot = useSyncExternalStore( (l) => requestSnapshotStore.subscribe(l), () => requestSnapshotStore.getSnapshot(), () => requestSnapshotStore.getSnapshot(), ); // Compress consecutive assistant_delta, tool_param_delta, and reasoning_delta events // Only store count, not individual events - prevents O(N²) memory growth const compressedEvents = useMemo(() => { const result: DebugEvent[] = []; let currentDeltaGroup: DebugEvent | null = null; const COMPRESSIBLE_EVENTS = new Set(['assistant_delta', 'tool_param_delta', 'reasoning_delta']); for (const event of events) { const shouldCompress = COMPRESSIBLE_EVENTS.has(event.event); if (shouldCompress) { // If we're already in a group of the same type, just increment count if (currentDeltaGroup && currentDeltaGroup.event === event.event) { currentDeltaGroup.count = (currentDeltaGroup.count || 1) + 1; // Don't accumulate data.all - that causes O(N²) memory usage // The count is sufficient for debugging purposes } else { // Start a new group if (currentDeltaGroup) { result.push(currentDeltaGroup); } currentDeltaGroup = { ...event, count: 1 }; } } else { // Non-compressible event, flush any current group and add this event if (currentDeltaGroup) { result.push(currentDeltaGroup); currentDeltaGroup = null; } result.push(event); } } // Flush any remaining group if (currentDeltaGroup) { result.push(currentDeltaGroup); } return result; }, [events]); // Scroll to bottom when new events arrive useEffect(() => { if (autoScroll && eventsEndRef.current) { eventsEndRef.current.scrollIntoView({ behavior: 'smooth' }); } }, [compressedEvents, autoScroll]); // Clear all events const handleClear = () => { onClear?.(); }; // Export events as JSON const handleExport = () => { const json = JSON.stringify(events, null, 2); const blob = new Blob([json], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `debug-events-${Date.now()}.json`; a.click(); URL.revokeObjectURL(url); }; const handleCopyEvents = async () => { try { await navigator.clipboard.writeText(JSON.stringify(events, null, 2)); toast.success('Events copied'); } catch { toast.error('Copy failed'); } }; // Filter events const filteredEvents = filter ? compressedEvents.filter(e => e.event.toLowerCase().includes(filter.toLowerCase())) : compressedEvents; // Group events by type (use original events for accurate counts) const eventCounts = events.reduce((acc, e) => { acc[e.event] = (acc[e.event] || 0) + 1; return acc; }, {} as Record); return ( {/* Tabs */}
{([ ['events', `Events (${filteredEvents.length}/${events.length})`], ['messages', messageSnapshot ? `Messages (${messageSnapshot.messages.length})` : 'Messages'], ] as const).map(([key, label]) => ( ))}
{activeTab === 'messages' ? ( ) : ( <> {/* Event Counts */}
{Object.entries(eventCounts).map(([event, count]) => ( ))}
{/* Filter Input */}
setFilter(e.target.value)} className="w-full px-2 py-1 text-xs rounded bg-background border border-border" />
{/* Toggles + actions */}
{/* Events List */}
{filteredEvents.length === 0 ? (
No events yet. Events will appear here as they occur.
) : ( filteredEvents.map((event) => ( )) )}
)} ); } function EventItem({ event, force }: { event: DebugEvent; force?: ForceState }) { const [isOpen, setIsOpen] = useState(false); // Expand/collapse-all override — rows remain individually toggleable after useEffect(() => { if (force) setIsOpen(force.open); }, [force]); const time = new Date(event.timestamp).toLocaleTimeString(); // Color code by event type const getEventColor = (eventType: string) => { if (eventType.includes('error') || eventType.includes('failed')) return 'text-red-500'; if (eventType.includes('retry')) return 'text-yellow-500'; if (eventType.includes('completed') || eventType.includes('success')) return 'text-green-500'; if (eventType.includes('tool')) return 'text-blue-500'; if (eventType.includes('agent')) return 'text-purple-500'; if (eventType.includes('plan')) return 'text-orange-500'; return 'text-foreground'; }; return (
{isOpen ? : } {time} {event.event} {event.count && event.count > 1 && ( ({event.count}) )}
{JSON.stringify(event.data, null, 2)}
); }