"use client"; // Live event stream for a mission — subscribes to every ACTIVE // topology_run bound to this mission via /api/topology-runs/{id}/events // (SSE, resumes on Last-Event-ID). Renders as a chronological scrolling // feed with per-run color coding + event kind pills. // // The list of runs itself is fetched from /api/missions/{id}/runs and // re-polled every 5s while any run is running, so newly-spawned runs // (a coding phase kicking off after research completes) automatically // attach without a page reload. import { useEffect, useMemo, useRef, useState } from "react"; import { listMissionRuns, type MissionRunSummary } from "@/lib/api/missions"; const mono = "ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace"; interface FeedItem { key: number; runId: string; ts: string; kind: string; text: string; } export function MissionLiveEvents({ missionId, visible, }: { missionId: string; visible: boolean; }) { const [runs, setRuns] = useState([]); const [feed, setFeed] = useState([]); const [error, setError] = useState(null); const seq = useRef(0); const scrollRef = useRef(null); // Poll the run list every 5s while there's any activity — cheap and // lets a newly-enqueued run auto-attach without a manual refresh. useEffect(() => { let alive = true; const load = async () => { try { const r = await listMissionRuns(missionId); if (alive) setRuns(r.runs); } catch (e) { if (alive) setError(e instanceof Error ? e.message : "runs load failed"); } }; void load(); const t = setInterval(load, 5000); return () => { alive = false; clearInterval(t); }; }, [missionId]); // Deduped set of run ids to open SSE on. We watch every run, not // just running ones — that way a run that flips to completed while // this tab was closed still replays its checkpoint records. const runIds = useMemo( () => runs .filter((r) => r.status === "running" || r.status === "queued") .map((r) => r.id), [runs], ); // Open one EventSource per active run. React re-runs this effect // whenever runIds changes; the cleanup closes stale connections. useEffect(() => { if (!visible) return; const sources: EventSource[] = []; for (const rid of runIds) { const es = new EventSource(`/api/topology-runs/${rid}/events`); const push = (kind: string, text: string) => { seq.current += 1; setFeed((prev) => [ ...prev, { key: seq.current, runId: rid, ts: new Date().toISOString(), kind, text, }, ].slice(-400), ); }; es.addEventListener("step", (ev) => { try { const data = JSON.parse((ev as MessageEvent).data ?? "{}"); const kind = String(data.kind ?? data.step_type ?? "step"); const text = data.summary ?? data.text ?? data.tool ?? data.node ?? JSON.stringify(data).slice(0, 200); push(kind, String(text)); } catch { push("step", (ev as MessageEvent).data ?? ""); } }); es.addEventListener("done", (ev) => { try { const data = JSON.parse((ev as MessageEvent).data ?? "{}"); push( data.status === "failed" ? "failed" : "done", String(data.error ?? data.final_output ?? data.status ?? "done"), ); } catch { push("done", (ev as MessageEvent).data ?? ""); } es.close(); }); es.onerror = () => { // Retry-on-error is built into EventSource; log-and-continue // is what we want unless the run is terminal — in which case // 'done' already closed above. }; sources.push(es); } return () => { for (const es of sources) es.close(); }; }, [runIds, visible]); // Auto-scroll to bottom on new events (unless the operator scrolled up). useEffect(() => { const el = scrollRef.current; if (!el) return; const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 80; if (nearBottom) el.scrollTop = el.scrollHeight; }, [feed.length]); return (
Live events · {runs.length} run{runs.length === 1 ? "" : "s"} · {runIds.length}{" "} active {error && {error}}
{runs.length === 0 ? (
No runs yet. Runs appear here once phases start executing.
) : (
{feed.length === 0 ? (
Waiting for events…
) : ( feed.map((f) => (
{f.ts.slice(11, 19)} {f.kind} {f.runId.slice(0, 8)} {f.text}
)) )}
)}
); }