session-debug-panel.tsx
1 'use client' 2 3 import { useState, useEffect, useCallback } from 'react' 4 import type { Message } from '@/types' 5 import { IconButton } from '@/components/shared/icon-button' 6 import { CheckpointTimeline } from './checkpoint-timeline' 7 import { useAppStore } from '@/stores/use-app-store' 8 import { selectActiveSessionId } from '@/stores/slices/session-slice' 9 10 interface Props { 11 messages: Message[] 12 open: boolean 13 onClose: () => void 14 } 15 16 type EventType = 'user' | 'assistant' | 'delegation' | 'agent_result' | 'system' | 'error' | 'tool_call' 17 18 interface DebugEvent { 19 type: EventType 20 label: string 21 detail: string 22 extraDetail?: Record<string, unknown> | null 23 time: number 24 source: 'message' | 'execlog' 25 } 26 27 interface ExecLogEntry { 28 id: string 29 sessionId: string 30 runId: string | null 31 agentId: string | null 32 category: string 33 summary: string 34 detail: Record<string, unknown> | null 35 ts: number 36 } 37 38 function classifyMessage(msg: Message): DebugEvent { 39 const text = msg.text || '' 40 41 if (msg.role === 'user') { 42 if (text.startsWith('[System]')) { 43 return { type: 'system', label: 'System', detail: text.replace('[System] ', ''), time: msg.time, source: 'message' } 44 } 45 if (text.startsWith('[Agent ')) { 46 const match = text.match(/\[Agent (.+?) result\]/) 47 return { type: 'agent_result', label: `Agent: ${match?.[1] || 'Unknown'}`, detail: text.replace(/\[Agent .+? result\]:?\n?/, ''), time: msg.time, source: 'message' } 48 } 49 if (text.startsWith('[Memory search')) { 50 return { type: 'system', label: 'Memory Search', detail: text.replace('[Memory search results]:\n', ''), time: msg.time, source: 'message' } 51 } 52 return { type: 'user', label: 'User', detail: text, time: msg.time, source: 'message' } 53 } 54 55 // assistant 56 if (text.startsWith('[Delegating to ')) { 57 const match = text.match(/\[Delegating to (.+?)\]/) 58 return { type: 'delegation', label: `Delegate: ${match?.[1] || 'Unknown'}`, detail: text.replace(/\[Delegating to .+?\]:?\s?/, ''), time: msg.time, source: 'message' } 59 } 60 if (text.startsWith('[Error]')) { 61 return { type: 'error', label: 'Error', detail: text.replace('[Error] ', ''), time: msg.time, source: 'message' } 62 } 63 if (text.startsWith('Starting task:')) { 64 return { type: 'system', label: 'Task Start', detail: text, time: msg.time, source: 'message' } 65 } 66 return { type: 'assistant', label: 'Assistant', detail: text, time: msg.time, source: 'message' } 67 } 68 69 function classifyExecLogEntry(entry: ExecLogEntry): DebugEvent { 70 const catMap: Record<string, EventType> = { 71 error: 'error', 72 tool_call: 'tool_call', 73 tool_result: 'tool_call', 74 decision: 'system', 75 trigger: 'system', 76 loop_detection: 'system', 77 delegation_start: 'delegation', 78 delegation_complete: 'agent_result', 79 delegation_fail: 'error', 80 } 81 const type: EventType = catMap[entry.category] ?? 'system' 82 const labelMap: Record<string, string> = { 83 error: 'Error', 84 tool_call: 'Tool Call', 85 tool_result: 'Tool Result', 86 decision: 'Decision', 87 trigger: 'Trigger', 88 loop_detection: 'Loop Detect', 89 delegation_start: 'Delegation', 90 delegation_complete: 'Delegation Result', 91 delegation_fail: 'Delegation Error', 92 heartbeat_failure: 'Heartbeat Fail', 93 } 94 const label = labelMap[entry.category] ?? entry.category.replace(/_/g, ' ') 95 return { 96 type, 97 label, 98 detail: entry.summary, 99 extraDetail: entry.detail, 100 time: entry.ts, 101 source: 'execlog', 102 } 103 } 104 105 const TYPE_COLORS: Record<EventType, string> = { 106 user: '#6366F1', 107 assistant: '#a0a0b0', 108 delegation: '#F59E0B', 109 agent_result: '#10B981', 110 system: '#6B7280', 111 error: '#EF4444', 112 tool_call: '#8B5CF6', 113 } 114 115 function fmtTime(ts: number) { 116 return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) 117 } 118 119 function ExtraDetail({ data }: { data: Record<string, unknown> }) { 120 const entries = Object.entries(data).filter(([, v]) => v !== null && v !== undefined) 121 if (entries.length === 0) return null 122 return ( 123 <div className="mt-2 rounded-[8px] bg-black/30 border border-white/[0.06] p-3 text-[11px] font-mono space-y-1"> 124 {entries.map(([k, v]) => ( 125 <div key={k} className="flex gap-2 flex-wrap"> 126 <span className="text-text-3/70 shrink-0">{k}:</span> 127 <span className="text-text-2 break-all"> 128 {Array.isArray(v) 129 ? v.map(String).join(', ') || '(empty)' 130 : typeof v === 'object' 131 ? JSON.stringify(v) 132 : String(v)} 133 </span> 134 </div> 135 ))} 136 </div> 137 ) 138 } 139 140 export function SessionDebugPanel({ messages, open, onClose }: Props) { 141 const [tab, setTab] = useState<'log' | 'checkpoints'>('log') 142 const [filter, setFilter] = useState<EventType | 'all'>('all') 143 const [expandedIdx, setExpandedIdx] = useState<number | null>(null) 144 const [execLogs, setExecLogs] = useState<ExecLogEntry[]>([]) 145 const [loadingExec, setLoadingExec] = useState(false) 146 147 const currentSessionId = useAppStore(selectActiveSessionId) 148 149 const fetchExecLogs = useCallback(async (sessionId: string) => { 150 setLoadingExec(true) 151 try { 152 const res = await fetch(`/api/chats/${sessionId}/execution-log?limit=200`) 153 if (res.ok) { 154 const data = await res.json() as ExecLogEntry[] 155 setExecLogs(Array.isArray(data) ? data : []) 156 } 157 } catch { 158 // non-critical 159 } finally { 160 setLoadingExec(false) 161 } 162 }, []) 163 164 useEffect(() => { 165 if (open && currentSessionId) { 166 void fetchExecLogs(currentSessionId) 167 } else if (!open) { 168 setExecLogs([]) 169 setExpandedIdx(null) 170 } 171 }, [open, currentSessionId, fetchExecLogs]) 172 173 const msgEvents = messages.map(classifyMessage) 174 const execEvents = execLogs.map(classifyExecLogEntry) 175 176 // Merge and sort by time 177 const allEvents = [...msgEvents, ...execEvents].sort((a, b) => a.time - b.time) 178 const events = allEvents 179 const filtered = filter === 'all' ? events : events.filter((e) => e.type === filter) 180 181 if (!open) return null 182 183 const filters: { id: EventType | 'all'; label: string }[] = [ 184 { id: 'all', label: 'All' }, 185 { id: 'delegation', label: 'Delegations' }, 186 { id: 'agent_result', label: 'Results' }, 187 { id: 'error', label: 'Errors' }, 188 { id: 'system', label: 'System' }, 189 { id: 'tool_call', label: 'Tools' }, 190 ] 191 192 return ( 193 <div className="absolute inset-0 z-30 bg-bg/95 backdrop-blur-xl flex flex-col"> 194 {/* Header */} 195 <div className="flex items-center gap-3 px-5 py-3 border-b border-white/[0.06] shrink-0"> 196 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#6366F1" strokeWidth="2" strokeLinecap="round"> 197 <path d="M12 20V10" /> 198 <path d="M18 20V4" /> 199 <path d="M6 20v-4" /> 200 </svg> 201 <span className="font-display text-[16px] font-600 tracking-[-0.02em] flex-1">Session X-Ray</span> 202 203 <div className="flex bg-white/[0.04] p-0.5 rounded-[8px] mr-2"> 204 <button 205 onClick={() => setTab('log')} 206 className={`px-3 py-1 rounded-[6px] text-[11px] font-600 transition-all ${tab === 'log' ? 'bg-white/[0.08] text-text shadow-sm' : 'text-text-3 hover:text-text-2'}`} 207 > 208 Event Log 209 </button> 210 <button 211 onClick={() => setTab('checkpoints')} 212 className={`px-3 py-1 rounded-[6px] text-[11px] font-600 transition-all ${tab === 'checkpoints' ? 'bg-accent-soft text-accent-bright' : 'text-text-3 hover:text-text-2'}`} 213 > 214 Checkpoints 215 </button> 216 </div> 217 218 <IconButton onClick={onClose} aria-label="Close debug panel"> 219 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"> 220 <line x1="18" y1="6" x2="6" y2="18" /> 221 <line x1="6" y1="6" x2="18" y2="18" /> 222 </svg> 223 </IconButton> 224 </div> 225 226 {tab === 'log' ? ( 227 <> 228 {/* Filters */} 229 <div className="flex gap-2 px-5 py-3 border-b border-white/[0.04] overflow-x-auto shrink-0"> 230 {filters.map((f) => ( 231 <button 232 key={f.id} 233 onClick={() => setFilter(f.id)} 234 className={`px-3 py-1.5 rounded-[8px] text-[11px] font-600 cursor-pointer transition-all border whitespace-nowrap 235 ${filter === f.id 236 ? 'bg-accent-soft border-accent-bright/25 text-accent-bright' 237 : 'bg-surface border-white/[0.06] text-text-3 hover:text-text-2'}`} 238 style={{ fontFamily: 'inherit' }} 239 > 240 {f.label} 241 </button> 242 ))} 243 {currentSessionId && ( 244 <button 245 onClick={() => void fetchExecLogs(currentSessionId)} 246 disabled={loadingExec} 247 className="ml-auto px-3 py-1.5 rounded-[8px] text-[11px] font-600 cursor-pointer transition-all border bg-surface border-white/[0.06] text-text-3 hover:text-text-2 disabled:opacity-40 whitespace-nowrap" 248 style={{ fontFamily: 'inherit' }} 249 > 250 {loadingExec ? 'Refreshing…' : '↺ Refresh'} 251 </button> 252 )} 253 </div> 254 255 {/* Event timeline */} 256 <div className="flex-1 overflow-y-auto px-5 py-4"> 257 <div className="relative"> 258 {/* Timeline line */} 259 <div className="absolute left-[15px] top-0 bottom-0 w-px bg-white/[0.06]" /> 260 261 {filtered.map((event, i) => { 262 const color = TYPE_COLORS[event.type] 263 const expanded = expandedIdx === i 264 return ( 265 <button 266 key={i} 267 onClick={() => setExpandedIdx(expanded ? null : i)} 268 className="w-full text-left relative pl-10 pb-4 group cursor-pointer" 269 > 270 {/* Dot */} 271 <div 272 className="absolute left-[10px] top-1 w-[11px] h-[11px] rounded-full border-2" 273 style={{ borderColor: color, backgroundColor: expanded ? color : 'transparent' }} 274 /> 275 276 {/* Content */} 277 <div className="flex items-center gap-2 mb-0.5 flex-wrap"> 278 <span className="text-[11px] font-700 uppercase tracking-wider" style={{ color }}> 279 {event.label} 280 </span> 281 <span className="text-[10px] text-text-3/70 font-mono">{fmtTime(event.time)}</span> 282 {event.source === 'execlog' && ( 283 <span className="text-[9px] text-text-3/40 font-mono uppercase tracking-wider">exec</span> 284 )} 285 </div> 286 287 <p className={`text-[12px] text-text-3 leading-[1.5] ${expanded ? 'whitespace-pre-wrap' : 'line-clamp-2'}`}> 288 {event.detail} 289 </p> 290 291 {expanded && event.extraDetail && ( 292 <ExtraDetail data={event.extraDetail} /> 293 )} 294 295 {!expanded && event.detail.length > 150 && ( 296 <span className="text-[11px] text-accent-bright/60 mt-1 inline-block">click to expand</span> 297 )} 298 {!expanded && event.extraDetail && Object.keys(event.extraDetail).length > 0 && ( 299 <span className="text-[11px] text-accent-bright/60 mt-1 inline-block ml-2">+ details</span> 300 )} 301 </button> 302 ) 303 })} 304 305 {filtered.length === 0 && !loadingExec && ( 306 <p className="text-center text-[13px] text-text-3 py-12">No events matching filter</p> 307 )} 308 {filtered.length === 0 && loadingExec && ( 309 <p className="text-center text-[13px] text-text-3 py-12">Loading…</p> 310 )} 311 </div> 312 </div> 313 314 {/* Stats bar */} 315 <div className="flex items-center gap-4 px-5 py-3 border-t border-white/[0.06] shrink-0"> 316 {(['delegation', 'agent_result', 'error'] as EventType[]).map((type) => { 317 const count = events.filter((e) => e.type === type).length 318 if (!count) return null 319 return ( 320 <span key={type} className="flex items-center gap-1.5 text-[11px] font-mono" style={{ color: TYPE_COLORS[type] }}> 321 <span className="w-2 h-2 rounded-full" style={{ backgroundColor: TYPE_COLORS[type] }} /> 322 {count} {type === 'delegation' ? 'delegations' : type === 'agent_result' ? 'results' : 'errors'} 323 </span> 324 ) 325 })} 326 <span className="ml-auto text-[10px] text-text-3/40 font-mono">{execLogs.length} exec log entries</span> 327 </div> 328 </> 329 ) : ( 330 <div className="flex-1 overflow-y-auto"> 331 {currentSessionId ? ( 332 <CheckpointTimeline sessionId={currentSessionId} /> 333 ) : ( 334 <div className="p-12 text-center text-text-3">No active chat</div> 335 )} 336 </div> 337 )} 338 </div> 339 ) 340 } 341