| '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<string>(''); |
| const eventsEndRef = useRef<HTMLDivElement>(null); |
| const [autoScroll, setAutoScroll] = useState(true); |
| const [streamDebug, setStreamDebug] = useState<boolean>(() => configManager.getDebugStreamEnabled()); |
| const [eventsForce, setEventsForce] = useState<ForceState>(null); |
| const messageSnapshot = useSyncExternalStore( |
| (l) => requestSnapshotStore.subscribe(l), |
| () => requestSnapshotStore.getSnapshot(), |
| () => requestSnapshotStore.getSnapshot(), |
| ); |
|
|
| |
| |
| 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 (currentDeltaGroup && currentDeltaGroup.event === event.event) { |
| currentDeltaGroup.count = (currentDeltaGroup.count || 1) + 1; |
| |
| |
| } else { |
| |
| if (currentDeltaGroup) { |
| result.push(currentDeltaGroup); |
| } |
| currentDeltaGroup = { ...event, count: 1 }; |
| } |
| } else { |
| |
| if (currentDeltaGroup) { |
| result.push(currentDeltaGroup); |
| currentDeltaGroup = null; |
| } |
| result.push(event); |
| } |
| } |
|
|
| |
| if (currentDeltaGroup) { |
| result.push(currentDeltaGroup); |
| } |
|
|
| return result; |
| }, [events]); |
|
|
| |
| useEffect(() => { |
| if (autoScroll && eventsEndRef.current) { |
| eventsEndRef.current.scrollIntoView({ behavior: 'smooth' }); |
| } |
| }, [compressedEvents, autoScroll]); |
|
|
| |
| const handleClear = () => { |
| onClear?.(); |
| }; |
|
|
| |
| 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'); |
| } |
| }; |
|
|
| |
| const filteredEvents = filter |
| ? compressedEvents.filter(e => e.event.toLowerCase().includes(filter.toLowerCase())) |
| : compressedEvents; |
|
|
| |
| const eventCounts = events.reduce((acc, e) => { |
| acc[e.event] = (acc[e.event] || 0) + 1; |
| return acc; |
| }, {} as Record<string, number>); |
|
|
| return ( |
| <PanelContainer> |
| <PanelHeader |
| icon={Bug} |
| title="Debug Events" |
| onClose={onClose} |
| panelKey="debug" |
| > |
| <MemoryMonitor /> |
| </PanelHeader> |
| |
| {/* Tabs */} |
| <div className="flex border-b border-border text-xs"> |
| {([ |
| ['events', `Events (${filteredEvents.length}/${events.length})`], |
| ['messages', messageSnapshot ? `Messages (${messageSnapshot.messages.length})` : 'Messages'], |
| ] as const).map(([key, label]) => ( |
| <button |
| key={key} |
| onClick={() => setActiveTab(key)} |
| className={`px-3 py-1.5 border-b-2 -mb-px whitespace-nowrap ${ |
| activeTab === key |
| ? 'border-primary text-foreground font-semibold' |
| : 'border-transparent text-muted-foreground hover:text-foreground' |
| }`} |
| > |
| {label} |
| </button> |
| ))} |
| </div> |
| |
| {activeTab === 'messages' ? ( |
| <MessagesTab /> |
| ) : ( |
| <> |
| {/* Event Counts */} |
| <div className="p-2 border-b border-border bg-muted/20 text-xs"> |
| <div className="flex flex-wrap gap-2"> |
| {Object.entries(eventCounts).map(([event, count]) => ( |
| <button |
| key={event} |
| onClick={() => setFilter(filter === event ? '' : event)} |
| className={`px-2 py-1 rounded ${ |
| filter === event |
| ? 'bg-primary text-primary-foreground' |
| : 'bg-muted hover:bg-muted/80' |
| }`} |
| > |
| {event} ({count}) |
| </button> |
| ))} |
| </div> |
| </div> |
| |
| {/* Filter Input */} |
| <div className="p-2 border-b border-border"> |
| <input |
| type="text" |
| placeholder="Filter events..." |
| value={filter} |
| onChange={(e) => setFilter(e.target.value)} |
| className="w-full px-2 py-1 text-xs rounded bg-background border border-border" |
| /> |
| </div> |
| |
| {/* Toggles + actions */} |
| <div className="p-2 border-b border-border flex items-center gap-3 flex-wrap"> |
| <label className="text-xs flex items-center gap-1 cursor-pointer"> |
| <input |
| type="checkbox" |
| checked={autoScroll} |
| onChange={(e) => setAutoScroll(e.target.checked)} |
| className="rounded" |
| /> |
| Auto-scroll |
| </label> |
| <label |
| className="text-xs flex items-center gap-1 cursor-pointer" |
| title="Emit llm_request and stream_raw_chunk events. Ephemeral, not persisted." |
| > |
| <input |
| type="checkbox" |
| checked={streamDebug} |
| onChange={(e) => { |
| setStreamDebug(e.target.checked); |
| configManager.setDebugStreamEnabled(e.target.checked); |
| }} |
| className="rounded" |
| /> |
| Stream debug |
| </label> |
| <div className="ml-auto flex items-center"> |
| <Button variant="ghost" size="sm" className="h-5 w-5 p-0" title="Expand all" |
| onClick={() => setEventsForce(v => ({ open: true, v: (v?.v ?? 0) + 1 }))}> |
| <ChevronsUpDown className="h-3 w-3" /> |
| </Button> |
| <Button variant="ghost" size="sm" className="h-5 w-5 p-0" title="Collapse all" |
| onClick={() => setEventsForce(v => ({ open: false, v: (v?.v ?? 0) + 1 }))}> |
| <ChevronsDownUp className="h-3 w-3" /> |
| </Button> |
| <Button variant="ghost" size="sm" className="h-5 w-5 p-0" title="Copy events as JSON" onClick={handleCopyEvents}> |
| <Copy className="h-3 w-3" /> |
| </Button> |
| <Button variant="ghost" size="sm" className="h-5 w-5 p-0" title="Export to JSON file" onClick={handleExport}> |
| <Download className="h-3 w-3" /> |
| </Button> |
| <Button variant="ghost" size="sm" className="h-5 w-5 p-0" title="Clear all events" onClick={handleClear}> |
| <Trash2 className="h-3 w-3" /> |
| </Button> |
| </div> |
| </div> |
| |
| {/* Events List */} |
| <div className="flex-1 overflow-y-auto p-2 space-y-1"> |
| {filteredEvents.length === 0 ? ( |
| <div className="text-xs text-muted-foreground text-center p-4"> |
| No events yet. Events will appear here as they occur. |
| </div> |
| ) : ( |
| filteredEvents.map((event) => ( |
| <EventItem key={event.id} event={event} force={eventsForce} /> |
| )) |
| )} |
| <div ref={eventsEndRef} /> |
| </div> |
| </> |
| )} |
|
|
| </PanelContainer> |
| ); |
| } |
|
|
| function EventItem({ event, force }: { event: DebugEvent; force?: ForceState }) { |
| const [isOpen, setIsOpen] = useState(false); |
| |
| useEffect(() => { |
| if (force) setIsOpen(force.open); |
| }, [force]); |
| const time = new Date(event.timestamp).toLocaleTimeString(); |
|
|
| |
| 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 ( |
| <Collapsible open={isOpen} onOpenChange={setIsOpen}> |
| <CollapsibleTrigger className="w-full text-left"> |
| <div className="flex items-center gap-2 p-1.5 rounded hover:bg-muted/50 text-xs"> |
| {isOpen ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />} |
| <span className="text-muted-foreground font-mono">{time}</span> |
| <span className={`font-semibold ${getEventColor(event.event)}`}> |
| {event.event} |
| </span> |
| {event.count && event.count > 1 && ( |
| <span className="text-muted-foreground font-mono"> |
| ({event.count}) |
| </span> |
| )} |
| </div> |
| </CollapsibleTrigger> |
| <CollapsibleContent> |
| <div className="ml-6 p-2 bg-muted/30 rounded text-xs font-mono overflow-x-auto"> |
| <pre>{JSON.stringify(event.data, null, 2)}</pre> |
| </div> |
| </CollapsibleContent> |
| </Collapsible> |
| ); |
| } |
|
|