/ src / components / chat / session-debug-panel.tsx
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