/ src / components / runs / run-list.tsx
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  }