run-list.tsx
1 'use client' 2 3 import { useEffect, useState, useCallback } from 'react' 4 import { api } from '@/lib/app/api-client' 5 import { useNow } from '@/hooks/use-now' 6 import { useWs } from '@/hooks/use-ws' 7 import { BottomSheet } from '@/components/shared/bottom-sheet' 8 import type { RunEventRecord, SessionRunRecord, SessionRunStatus } from '@/types' 9 import { PageLoader } from '@/components/ui/page-loader' 10 import { formatElapsed } from '@/lib/format-display' 11 import { GroundingPanel } from '@/components/knowledge/grounding-panel' 12 13 const STATUS_COLORS: Record<SessionRunStatus, { bg: string; text: string }> = { 14 queued: { bg: 'bg-yellow-500/10', text: 'text-yellow-400' }, 15 running: { bg: 'bg-blue-500/10', text: 'text-blue-400' }, 16 completed: { bg: 'bg-emerald-500/10', text: 'text-emerald-400' }, 17 failed: { bg: 'bg-red-500/10', text: 'text-red-400' }, 18 cancelled: { bg: 'bg-white/[0.06]', text: 'text-text-3' }, 19 } 20 21 const ALL_STATUSES: SessionRunStatus[] = ['queued', 'running', 'completed', 'failed', 'cancelled'] 22 23 function relativeTime(ts: number, now: number | null): string { 24 if (!now) return 'recently' 25 const diff = now - ts 26 if (diff < 60_000) return `${Math.floor(diff / 1000)}s ago` 27 if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago` 28 if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago` 29 return `${Math.floor(diff / 86_400_000)}d ago` 30 } 31 32 33 34 export function RunList() { 35 const now = useNow({ intervalMs: 1000 }) 36 const [runs, setRuns] = useState<SessionRunRecord[]>([]) 37 const [loading, setLoading] = useState(true) 38 const [autoRefresh, setAutoRefresh] = useState(false) 39 const [statusFilter, setStatusFilter] = useState<SessionRunStatus | null>(null) 40 const [selected, setSelected] = useState<SessionRunRecord | null>(null) 41 const [selectedEvents, setSelectedEvents] = useState<RunEventRecord[]>([]) 42 const [eventsLoading, setEventsLoading] = useState(false) 43 44 const fetchRuns = useCallback(async () => { 45 try { 46 const res = await api<SessionRunRecord[]>('GET', '/runs?limit=200') 47 setRuns(Array.isArray(res) ? res : []) 48 } catch { /* ignore */ } 49 setLoading(false) 50 }, []) 51 52 useEffect(() => { 53 // eslint-disable-next-line react-hooks/set-state-in-effect 54 fetchRuns() 55 }, [fetchRuns]) 56 57 useWs('runs', fetchRuns, autoRefresh ? 3000 : undefined) 58 59 useEffect(() => { 60 if (!selected) return 61 let cancelled = false 62 api<RunEventRecord[]>('GET', `/runs/${selected.id}/events?limit=200`) 63 .then((events) => { 64 if (cancelled) return 65 setSelectedEvents(Array.isArray(events) ? events : []) 66 }) 67 .catch(() => { 68 if (!cancelled) setSelectedEvents([]) 69 }) 70 .finally(() => { 71 if (!cancelled) setEventsLoading(false) 72 }) 73 return () => { cancelled = true } 74 }, [selected]) 75 76 const closeSelected = useCallback(() => { 77 setSelected(null) 78 setSelectedEvents([]) 79 setEventsLoading(false) 80 }, []) 81 82 const openSelected = useCallback((run: SessionRunRecord) => { 83 setSelected(run) 84 setEventsLoading(true) 85 }, []) 86 87 const filtered = statusFilter ? runs.filter((r) => r.status === statusFilter) : runs 88 const selectedResultGrounding = selectedEvents 89 .slice() 90 .reverse() 91 .find((event) => event.phase === 'status' && ((event.citations?.length || 0) > 0 || event.retrievalTrace?.hits?.length)) 92 93 if (loading) { 94 return <PageLoader label="Loading runs..." /> 95 } 96 97 return ( 98 <div className="flex-1 flex flex-col overflow-hidden"> 99 {/* Controls */} 100 <div className="px-5 py-2 space-y-2 shrink-0" style={{ animation: 'fade-up 0.4s var(--ease-spring)' }}> 101 {/* Status filter + auto-refresh */} 102 <div className="flex items-center gap-1.5 flex-wrap"> 103 <button 104 onClick={() => setStatusFilter(null)} 105 className={`px-2 py-1 rounded-[6px] text-[10px] font-700 uppercase tracking-wider cursor-pointer transition-all border-none ${ 106 !statusFilter ? 'bg-accent-soft text-accent-bright' : 'bg-white/[0.02] text-text-3/70' 107 }`} 108 > 109 ALL 110 </button> 111 {ALL_STATUSES.map((s) => ( 112 <button 113 key={s} 114 onClick={() => setStatusFilter(statusFilter === s ? null : s)} 115 className={`px-2 py-1 rounded-[6px] text-[10px] font-700 uppercase tracking-wider cursor-pointer transition-all border-none ${ 116 statusFilter === s ? `${STATUS_COLORS[s].bg} ${STATUS_COLORS[s].text}` : 'bg-white/[0.02] text-text-3/70' 117 }`} 118 > 119 {s} 120 </button> 121 ))} 122 <div className="flex-1" /> 123 <button 124 onClick={() => setAutoRefresh(!autoRefresh)} 125 className={`px-2 py-1 rounded-[6px] text-[10px] font-600 cursor-pointer transition-all border-none flex items-center gap-1.5 ${ 126 autoRefresh ? 'bg-green-500/10 text-green-400' : 'bg-white/[0.04] text-text-3' 127 }`} 128 > 129 {autoRefresh && <span className="w-1.5 h-1.5 rounded-full bg-green-400 animate-pulse" />} 130 {autoRefresh ? 'LIVE' : 'PAUSED'} 131 </button> 132 </div> 133 </div> 134 135 {/* Count */} 136 <div className="px-5 py-1 text-[10px] text-text-3/60" style={{ animation: 'fade-in 0.6s ease 0.1s both' }}> 137 {filtered.length} run{filtered.length !== 1 ? 's' : ''} 138 </div> 139 140 {/* Run list */} 141 <div className="flex-1 overflow-y-auto px-4 pb-8"> 142 {filtered.length === 0 ? ( 143 <div className="flex items-center justify-center h-32 text-text-3 text-[12px]" style={{ animation: 'fade-up 0.5s var(--ease-spring)' }}> 144 No runs found 145 </div> 146 ) : ( 147 <div className="space-y-1"> 148 {filtered.map((run, idx) => ( 149 <button 150 key={run.id} 151 onClick={() => openSelected(run)} 152 className="w-full text-left p-3 rounded-[10px] border border-white/[0.06] bg-surface hover:bg-surface-2 transition-all cursor-pointer block hover:scale-[1.01] active:scale-[0.99]" 153 style={{ 154 animation: 'fade-up 0.4s var(--ease-spring) both', 155 animationDelay: `${0.1 + idx * 0.02}s` 156 }} 157 > 158 <div className="flex items-center gap-2 mb-1"> 159 <span className={`text-[9px] font-700 uppercase tracking-wider px-1.5 py-0.5 rounded-[4px] ${STATUS_COLORS[run.status].bg} ${STATUS_COLORS[run.status].text}`}> 160 {run.status} 161 </span> 162 <span className="text-[11px] text-text-3/60 font-mono">{run.source}</span> 163 <span className="text-[10px] text-text-3/40 ml-auto">{relativeTime(run.queuedAt, now)}</span> 164 </div> 165 <div className="text-[12px] text-text-2 truncate">{run.messagePreview || run.id}</div> 166 {run.startedAt && ( 167 <div className="text-[10px] text-text-3/50 mt-1"> 168 Duration: {formatElapsed(run.startedAt, run.endedAt, now)} 169 </div> 170 )} 171 </button> 172 ))} 173 </div> 174 )} 175 </div> 176 177 {/* Detail Sheet */} 178 <BottomSheet open={!!selected} onClose={closeSelected}> 179 {selected && ( 180 <div style={{ animation: 'fade-in 0.3s ease' }}> 181 <div className="mb-6"> 182 <div className="flex items-center gap-3 mb-3"> 183 <span className={`text-[11px] font-700 uppercase tracking-wider px-2.5 py-1 rounded-[6px] ${STATUS_COLORS[selected.status].bg} ${STATUS_COLORS[selected.status].text}`}> 184 {selected.status} 185 </span> 186 <span className="text-[12px] font-mono text-text-3/60">{selected.source}</span> 187 </div> 188 <h2 className="font-display text-[20px] font-700 tracking-[-0.02em] mb-2 leading-snug"> 189 Run Details 190 </h2> 191 <p className="text-[12px] text-text-3/60 font-mono">{selected.id}</p> 192 </div> 193 194 {/* Timing */} 195 <div className="mb-6 space-y-2"> 196 <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em]">Timing</label> 197 <div className="grid grid-cols-2 gap-2"> 198 <div className="p-2.5 rounded-[10px] bg-white/[0.02] border border-white/[0.04]"> 199 <div className="text-[10px] text-text-3/60 mb-0.5">Queued</div> 200 <div className="text-[12px] text-text font-mono">{new Date(selected.queuedAt).toLocaleString()}</div> 201 </div> 202 {selected.startedAt && ( 203 <div className="p-2.5 rounded-[10px] bg-white/[0.02] border border-white/[0.04]"> 204 <div className="text-[10px] text-text-3/60 mb-0.5">Started</div> 205 <div className="text-[12px] text-text font-mono">{new Date(selected.startedAt).toLocaleString()}</div> 206 </div> 207 )} 208 {selected.endedAt && ( 209 <div className="p-2.5 rounded-[10px] bg-white/[0.02] border border-white/[0.04]"> 210 <div className="text-[10px] text-text-3/60 mb-0.5">Ended</div> 211 <div className="text-[12px] text-text font-mono">{new Date(selected.endedAt).toLocaleString()}</div> 212 </div> 213 )} 214 <div className="p-2.5 rounded-[10px] bg-white/[0.02] border border-white/[0.04]"> 215 <div className="text-[10px] text-text-3/60 mb-0.5">Duration</div> 216 <div className="text-[12px] text-text font-mono">{formatElapsed(selected.startedAt, selected.endedAt, now)}</div> 217 </div> 218 </div> 219 </div> 220 221 {/* Message */} 222 {selected.messagePreview && ( 223 <div className="mb-6"> 224 <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-2">Message</label> 225 <pre className="text-[11px] text-text-3/80 font-mono whitespace-pre-wrap break-all bg-white/[0.02] rounded-[12px] p-4 max-h-[200px] overflow-auto border border-white/[0.04]"> 226 {selected.messagePreview} 227 </pre> 228 </div> 229 )} 230 231 {/* Error */} 232 {selected.error && ( 233 <div className="mb-6"> 234 <label className="block font-display text-[12px] font-600 text-red-400 uppercase tracking-[0.08em] mb-2">Error</label> 235 <pre className="text-[11px] text-red-300/80 font-mono whitespace-pre-wrap break-all bg-red-500/[0.05] rounded-[12px] p-4 max-h-[200px] overflow-auto border border-red-500/[0.1]"> 236 {selected.error} 237 </pre> 238 </div> 239 )} 240 241 {/* Result */} 242 {selected.resultPreview && ( 243 <div className="mb-6"> 244 <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-2">Result</label> 245 <pre className="text-[11px] text-text-3/80 font-mono whitespace-pre-wrap break-all bg-white/[0.02] rounded-[12px] p-4 max-h-[200px] overflow-auto border border-white/[0.04]"> 246 {selected.resultPreview} 247 </pre> 248 {selectedResultGrounding && ( 249 <div className="mt-3"> 250 <GroundingPanel 251 citations={selectedResultGrounding.citations} 252 retrievalTrace={selectedResultGrounding.retrievalTrace} 253 compact 254 /> 255 </div> 256 )} 257 </div> 258 )} 259 260 <div className="mb-2"> 261 <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-2">Replay</label> 262 <div className="rounded-[12px] border border-white/[0.04] bg-white/[0.02] max-h-[260px] overflow-auto"> 263 {eventsLoading ? ( 264 <div className="p-4 text-[11px] text-text-3/60">Loading events...</div> 265 ) : selectedEvents.length === 0 ? ( 266 <div className="p-4 text-[11px] text-text-3/60">No persisted replay events for this run.</div> 267 ) : ( 268 <div className="divide-y divide-white/[0.04]"> 269 {selectedEvents.map((event) => ( 270 <div key={event.id} className="px-4 py-3"> 271 <div className="flex items-center gap-2 mb-1"> 272 <span className="text-[10px] text-text-3/50 font-mono">{new Date(event.timestamp).toLocaleTimeString()}</span> 273 <span className="text-[10px] uppercase tracking-[0.08em] text-text-3/60">{event.phase}</span> 274 {event.status && <span className="text-[10px] text-text-3/60">{event.status}</span>} 275 </div> 276 <div className="text-[11px] text-text-2 whitespace-pre-wrap break-words"> 277 {event.summary || event.event.text || event.event.toolOutput || event.event.toolName || event.event.t} 278 </div> 279 {(event.citations?.length || event.retrievalTrace?.hits?.length) ? ( 280 <div className="mt-2"> 281 <GroundingPanel 282 citations={event.citations} 283 retrievalTrace={event.retrievalTrace} 284 compact 285 /> 286 </div> 287 ) : null} 288 </div> 289 ))} 290 </div> 291 )} 292 </div> 293 </div> 294 </div> 295 )} 296 </BottomSheet> 297 </div> 298 ) 299 }