diff --git a/src/components/AgentPanel.tsx b/src/components/AgentPanel.tsx index e433e47..b71cf00 100644 --- a/src/components/AgentPanel.tsx +++ b/src/components/AgentPanel.tsx @@ -2,6 +2,7 @@ import { useState, useRef } from 'react' import type { Agent } from '@/lib/schema' +import { RunHistory } from './RunHistory' type StreamEvent = | { type: 'run:start'; runId: string } @@ -16,6 +17,9 @@ export function AgentPanel({ agent }: { agent: Agent }) { const [prompt, setPrompt] = useState('') const [events, setEvents] = useState([]) const [running, setRunning] = useState(false) + // Bumped each time a live run ends, so RunHistory re-fetches the + // newly persisted row without a full page reload. + const [historyVersion, setHistoryVersion] = useState(0) const outputRef = useRef(null) async function run() { @@ -75,6 +79,7 @@ export function AgentPanel({ agent }: { agent: Agent }) { ]) } finally { setRunning(false) + setHistoryVersion((v) => v + 1) } } @@ -156,6 +161,8 @@ export function AgentPanel({ agent }: { agent: Agent }) { ))} )} + + ) } diff --git a/src/components/RunHistory.tsx b/src/components/RunHistory.tsx new file mode 100644 index 0000000..32163cb --- /dev/null +++ b/src/components/RunHistory.tsx @@ -0,0 +1,188 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import type { Run } from '@/lib/schema' + +/** + * Past runs panel for a single agent. Reads from + * GET /api/agents/[id]/run (which returns the 25 most recent runs) + * and renders them as a collapsed list. Click a row to expand its + * recorded JSONL output inline. + * + * `version` is a re-fetch trigger. Parent components bump it after a + * live run ends so the history list refreshes without a full reload. + */ +export function RunHistory({ + agentId, + version = 0, +}: { + agentId: string + version?: number +}) { + const [runs, setRuns] = useState(null) + const [error, setError] = useState(null) + const [loading, setLoading] = useState(false) + const [expanded, setExpanded] = useState(null) + + const load = useCallback(async () => { + setLoading(true) + setError(null) + try { + const res = await fetch(`/api/agents/${agentId}/run`) + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { + error?: string + } + setError(body.error || 'failed to load history') + setRuns([]) + return + } + const body = (await res.json()) as { runs: Run[] } + // Normalize timestamps: API ships ISO strings or numbers depending + // on serialization. Coerce to Date for the formatters. + const normalized = body.runs.map((r) => ({ + ...r, + startedAt: new Date(r.startedAt), + endedAt: r.endedAt ? new Date(r.endedAt) : null, + })) as Run[] + setRuns(normalized) + } catch (err) { + setError(err instanceof Error ? err.message : 'failed to load history') + setRuns([]) + } finally { + setLoading(false) + } + }, [agentId]) + + useEffect(() => { + load() + }, [load, version]) + + return ( +
+
+

+ History +

+ +
+ + {error && ( +
{error}
+ )} + + {runs && runs.length === 0 && !error && ( +
no runs yet.
+ )} + + {runs && runs.length > 0 && ( +
    + {runs.map((run) => ( + + setExpanded((cur) => (cur === run.id ? null : run.id)) + } + /> + ))} +
+ )} +
+ ) +} + +function RunRow({ + run, + expanded, + onToggle, +}: { + run: Run + expanded: boolean + onToggle: () => void +}) { + const status = run.status + const exit = run.exitCode + const statusColor = + status === 'running' + ? 'text-amber-400' + : exit === 0 + ? 'text-green-500' + : 'text-red-400' + + return ( +
  • + + {expanded && ( +
    + {run.output ? ( +
    +              {run.output}
    +            
    + ) : ( +
    + no output recorded. +
    + )} +
    + )} +
  • + ) +} + +function previewPrompt(s: string): string { + const trimmed = s.replace(/\s+/g, ' ').trim() + return trimmed.length > 80 ? trimmed.slice(0, 77) + '...' : trimmed +} + +function formatRelative(d: Date): string { + const now = Date.now() + const then = d.getTime() + const sec = Math.max(0, Math.floor((now - then) / 1000)) + if (sec < 60) return `${sec}s ago` + const min = Math.floor(sec / 60) + if (min < 60) return `${min}m ago` + const hr = Math.floor(min / 60) + if (hr < 24) return `${hr}h ago` + const day = Math.floor(hr / 24) + if (day < 30) return `${day}d ago` + return d.toISOString().slice(0, 10) +} + +function formatDuration( + start: Date, + end: Date | null, + status: string, +): string { + if (status === 'running' || !end) return 'running' + const ms = end.getTime() - start.getTime() + if (ms < 0) return '' + const sec = ms / 1000 + if (sec < 60) return `${sec.toFixed(1)}s` + const m = Math.floor(sec / 60) + const s = Math.floor(sec - m * 60) + return `${m}m ${String(s).padStart(2, '0')}s` +}