Leon4gr45/builder
0
1'use client';2 3import { useState, useEffect, useRef, useMemo, useSyncExternalStore } from 'react';4import { Button } from '@/components/ui/button';5import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';6import { ChevronDown, ChevronUp, ChevronsUpDown, ChevronsDownUp, Bug, Trash2, Copy, Download } from 'lucide-react';7import { PanelContainer, PanelHeader } from '@/components/ui/panel';8import { MemoryMonitor } from './memory-monitor';9import { MessagesTab, type ForceState } from './messages-tab';10import { configManager } from '@/lib/config/storage';11import { requestSnapshotStore } from '@/lib/llm/request-snapshot';12import { toast } from 'sonner';13 14import type { DebugEvent } from '@/lib/stores/types';15 16interface DebugPanelProps {17 events: DebugEvent[];18 onClear?: () => void;19 onClose?: () => void;20}21 22export function DebugPanel({ events, onClear, onClose }: DebugPanelProps) {23 const [activeTab, setActiveTab] = useState<'events' | 'messages'>('events');24 const [filter, setFilter] = useState<string>('');25 const eventsEndRef = useRef<HTMLDivElement>(null);26 const [autoScroll, setAutoScroll] = useState(true);27 const [streamDebug, setStreamDebug] = useState<boolean>(() => configManager.getDebugStreamEnabled());28 const [eventsForce, setEventsForce] = useState<ForceState>(null);29 const messageSnapshot = useSyncExternalStore(30 (l) => requestSnapshotStore.subscribe(l),31 () => requestSnapshotStore.getSnapshot(),32 () => requestSnapshotStore.getSnapshot(),33 );34 35 // Compress consecutive assistant_delta, tool_param_delta, and reasoning_delta events36 // Only store count, not individual events - prevents O(N²) memory growth37 const compressedEvents = useMemo(() => {38 const result: DebugEvent[] = [];39 let currentDeltaGroup: DebugEvent | null = null;40 41 const COMPRESSIBLE_EVENTS = new Set(['assistant_delta', 'tool_param_delta', 'reasoning_delta']);42 43 for (const event of events) {44 const shouldCompress = COMPRESSIBLE_EVENTS.has(event.event);45 46 if (shouldCompress) {47 // If we're already in a group of the same type, just increment count48 if (currentDeltaGroup && currentDeltaGroup.event === event.event) {49 currentDeltaGroup.count = (currentDeltaGroup.count || 1) + 1;50 // Don't accumulate data.all - that causes O(N²) memory usage51 // The count is sufficient for debugging purposes52 } else {53 // Start a new group54 if (currentDeltaGroup) {55 result.push(currentDeltaGroup);56 }57 currentDeltaGroup = { ...event, count: 1 };58 }59 } else {60 // Non-compressible event, flush any current group and add this event61 if (currentDeltaGroup) {62 result.push(currentDeltaGroup);63 currentDeltaGroup = null;64 }65 result.push(event);66 }67 }68 69 // Flush any remaining group70 if (currentDeltaGroup) {71 result.push(currentDeltaGroup);72 }73 74 return result;75 }, [events]);76 77 // Scroll to bottom when new events arrive78 useEffect(() => {79 if (autoScroll && eventsEndRef.current) {80 eventsEndRef.current.scrollIntoView({ behavior: 'smooth' });81 }82 }, [compressedEvents, autoScroll]);83 84 // Clear all events85 const handleClear = () => {86 onClear?.();87 };88 89 // Export events as JSON90 const handleExport = () => {91 const json = JSON.stringify(events, null, 2);92 const blob = new Blob([json], { type: 'application/json' });93 const url = URL.createObjectURL(blob);94 const a = document.createElement('a');95 a.href = url;96 a.download = `debug-events-${Date.now()}.json`;97 a.click();98 URL.revokeObjectURL(url);99 };100 101 const handleCopyEvents = async () => {102 try {103 await navigator.clipboard.writeText(JSON.stringify(events, null, 2));104 toast.success('Events copied');105 } catch {106 toast.error('Copy failed');107 }108 };109 110 // Filter events111 const filteredEvents = filter112 ? compressedEvents.filter(e => e.event.toLowerCase().includes(filter.toLowerCase()))113 : compressedEvents;114 115 // Group events by type (use original events for accurate counts)116 const eventCounts = events.reduce((acc, e) => {117 acc[e.event] = (acc[e.event] || 0) + 1;118 return acc;119 }, {} as Record<string, number>);120 121 return (122 <PanelContainer>123 <PanelHeader124 icon={Bug}125 title="Debug Events"126 onClose={onClose}127 panelKey="debug"128 >129 <MemoryMonitor />130 </PanelHeader>131 132 {/* Tabs */}133 <div className="flex border-b border-border text-xs">134 {([135 ['events', `Events (${filteredEvents.length}/${events.length})`],136 ['messages', messageSnapshot ? `Messages (${messageSnapshot.messages.length})` : 'Messages'],137 ] as const).map(([key, label]) => (138 <button139 key={key}140 onClick={() => setActiveTab(key)}141 className={`px-3 py-1.5 border-b-2 -mb-px whitespace-nowrap ${142 activeTab === key143 ? 'border-primary text-foreground font-semibold'144 : 'border-transparent text-muted-foreground hover:text-foreground'145 }`}146 >147 {label}148 </button>149 ))}150 </div>151 152 {activeTab === 'messages' ? (153 <MessagesTab />154 ) : (155 <>156 {/* Event Counts */}157 <div className="p-2 border-b border-border bg-muted/20 text-xs">158 <div className="flex flex-wrap gap-2">159 {Object.entries(eventCounts).map(([event, count]) => (160 <button161 key={event}162 onClick={() => setFilter(filter === event ? '' : event)}163 className={`px-2 py-1 rounded ${164 filter === event165 ? 'bg-primary text-primary-foreground'166 : 'bg-muted hover:bg-muted/80'167 }`}168 >169 {event} ({count})170 </button>171 ))}172 </div>173 </div>174 175 {/* Filter Input */}176 <div className="p-2 border-b border-border">177 <input178 type="text"179 placeholder="Filter events..."180 value={filter}181 onChange={(e) => setFilter(e.target.value)}182 className="w-full px-2 py-1 text-xs rounded bg-background border border-border"183 />184 </div>185 186 {/* Toggles + actions */}187 <div className="p-2 border-b border-border flex items-center gap-3 flex-wrap">188 <label className="text-xs flex items-center gap-1 cursor-pointer">189 <input190 type="checkbox"191 checked={autoScroll}192 onChange={(e) => setAutoScroll(e.target.checked)}193 className="rounded"194 />195 Auto-scroll196 </label>197 <label198 className="text-xs flex items-center gap-1 cursor-pointer"199 title="Emit llm_request and stream_raw_chunk events. Ephemeral, not persisted."200 >201 <input202 type="checkbox"203 checked={streamDebug}204 onChange={(e) => {205 setStreamDebug(e.target.checked);206 configManager.setDebugStreamEnabled(e.target.checked);207 }}208 className="rounded"209 />210 Stream debug211 </label>212 <div className="ml-auto flex items-center">213 <Button variant="ghost" size="sm" className="h-5 w-5 p-0" title="Expand all"214 onClick={() => setEventsForce(v => ({ open: true, v: (v?.v ?? 0) + 1 }))}>215 <ChevronsUpDown className="h-3 w-3" />216 </Button>217 <Button variant="ghost" size="sm" className="h-5 w-5 p-0" title="Collapse all"218 onClick={() => setEventsForce(v => ({ open: false, v: (v?.v ?? 0) + 1 }))}>219 <ChevronsDownUp className="h-3 w-3" />220 </Button>221 <Button variant="ghost" size="sm" className="h-5 w-5 p-0" title="Copy events as JSON" onClick={handleCopyEvents}>222 <Copy className="h-3 w-3" />223 </Button>224 <Button variant="ghost" size="sm" className="h-5 w-5 p-0" title="Export to JSON file" onClick={handleExport}>225 <Download className="h-3 w-3" />226 </Button>227 <Button variant="ghost" size="sm" className="h-5 w-5 p-0" title="Clear all events" onClick={handleClear}>228 <Trash2 className="h-3 w-3" />229 </Button>230 </div>231 </div>232 233 {/* Events List */}234 <div className="flex-1 overflow-y-auto p-2 space-y-1">235 {filteredEvents.length === 0 ? (236 <div className="text-xs text-muted-foreground text-center p-4">237 No events yet. Events will appear here as they occur.238 </div>239 ) : (240 filteredEvents.map((event) => (241 <EventItem key={event.id} event={event} force={eventsForce} />242 ))243 )}244 <div ref={eventsEndRef} />245 </div>246 </>247 )}248 249 </PanelContainer>250 );251}252 253function EventItem({ event, force }: { event: DebugEvent; force?: ForceState }) {254 const [isOpen, setIsOpen] = useState(false);255 // Expand/collapse-all override — rows remain individually toggleable after256 useEffect(() => {257 if (force) setIsOpen(force.open);258 }, [force]);259 const time = new Date(event.timestamp).toLocaleTimeString();260 261 // Color code by event type262 const getEventColor = (eventType: string) => {263 if (eventType.includes('error') || eventType.includes('failed')) return 'text-red-500';264 if (eventType.includes('retry')) return 'text-yellow-500';265 if (eventType.includes('completed') || eventType.includes('success')) return 'text-green-500';266 if (eventType.includes('tool')) return 'text-blue-500';267 if (eventType.includes('agent')) return 'text-purple-500';268 if (eventType.includes('plan')) return 'text-orange-500';269 return 'text-foreground';270 };271 272 return (273 <Collapsible open={isOpen} onOpenChange={setIsOpen}>274 <CollapsibleTrigger className="w-full text-left">275 <div className="flex items-center gap-2 p-1.5 rounded hover:bg-muted/50 text-xs">276 {isOpen ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}277 <span className="text-muted-foreground font-mono">{time}</span>278 <span className={`font-semibold ${getEventColor(event.event)}`}>279 {event.event}280 </span>281 {event.count && event.count > 1 && (282 <span className="text-muted-foreground font-mono">283 ({event.count})284 </span>285 )}286 </div>287 </CollapsibleTrigger>288 <CollapsibleContent>289 <div className="ml-6 p-2 bg-muted/30 rounded text-xs font-mono overflow-x-auto">290 <pre>{JSON.stringify(event.data, null, 2)}</pre>291 </div>292 </CollapsibleContent>293 </Collapsible>294 );295}296 