Fills the biggest UX gap surfaced during the deploy walk: hosted
missions had no live-progress surface at all. Now they do.
Auto-refresh:
- MissionCanvas grows a second useEffect that polls getMission
every 3s while mission.status === 'running'. Stops immediately
on terminal state (completed / failed / cancelled). Phases,
Tasks, Artifacts, Benchmarks all update without a manual click.
Team tab (new):
- MissionTeamTab.tsx — fetches /api/teams/{id} + /api/team/claws,
shows a card per member with role slot + an "Open" pill that
calls onOpenClaw(clawId) → Dashboard flips to AGENT tier with
that claw selected, dropping the operator into the existing
ClawCommandCenter surface (WorkingOnNow, ReasoningStream, etc).
Live events tab (new):
- MissionLiveEvents.tsx — polls /api/missions/{id}/runs every 5s
for the topology_runs bound to this mission, opens one
EventSource per active run against /api/topology-runs/{id}/events,
renders as a chronological scrolling feed with per-event kind
pills + per-run short-id badges. Auto-scrolls unless the
operator scrolled up. New runs auto-attach; terminal runs
close cleanly.
Backend:
- cm-db::repo::topology_runs::list_by_mission — SELECT ... FROM
topology_runs WHERE mission_id = $1 ORDER BY created_at DESC.
Uses runtime sqlx::query (not the macro) to avoid a sqlx cache
regen just for this route.
- TopologyRunSummary gains #[derive(Serialize)] + rfc3339 codecs.
- GET /api/missions/{id}/runs — workspace-scoped, returns
{ runs: [...] }.
Dashboard wires onOpenClaw on MissionCanvas → setAgentId + setTier("claw").
Verified: cargo check --workspace + tsc --noEmit + eslint --quiet
all green.
240 lines
7.2 KiB
TypeScript
240 lines
7.2 KiB
TypeScript
"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<MissionRunSummary[]>([]);
|
|
const [feed, setFeed] = useState<FeedItem[]>([]);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const seq = useRef(0);
|
|
const scrollRef = useRef<HTMLDivElement | null>(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<string>).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<string>).data ?? "");
|
|
}
|
|
});
|
|
es.addEventListener("done", (ev) => {
|
|
try {
|
|
const data = JSON.parse((ev as MessageEvent<string>).data ?? "{}");
|
|
push(
|
|
data.status === "failed" ? "failed" : "done",
|
|
String(data.error ?? data.final_output ?? data.status ?? "done"),
|
|
);
|
|
} catch {
|
|
push("done", (ev as MessageEvent<string>).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 (
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 10, height: "70vh" }}>
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 10,
|
|
fontFamily: mono,
|
|
fontSize: 10,
|
|
letterSpacing: ".14em",
|
|
color: "#7cd6e0",
|
|
textTransform: "uppercase",
|
|
}}
|
|
>
|
|
<span>Live events</span>
|
|
<span style={{ color: "#8a8a92" }}>
|
|
· {runs.length} run{runs.length === 1 ? "" : "s"} · {runIds.length}{" "}
|
|
active
|
|
</span>
|
|
{error && <span style={{ color: "#ff8a7a" }}>{error}</span>}
|
|
</div>
|
|
{runs.length === 0 ? (
|
|
<div style={{ padding: 24, color: "#8a8a92", fontSize: 13 }}>
|
|
No runs yet. Runs appear here once phases start executing.
|
|
</div>
|
|
) : (
|
|
<div
|
|
ref={scrollRef}
|
|
style={{
|
|
flex: 1,
|
|
minHeight: 0,
|
|
overflowY: "auto",
|
|
padding: 12,
|
|
background: "#0a0a0d",
|
|
border: "1px solid rgba(255,255,255,.06)",
|
|
borderRadius: 10,
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: 4,
|
|
fontFamily: mono,
|
|
fontSize: 11,
|
|
}}
|
|
>
|
|
{feed.length === 0 ? (
|
|
<div style={{ color: "#6a6a72", fontSize: 12 }}>
|
|
Waiting for events…
|
|
</div>
|
|
) : (
|
|
feed.map((f) => (
|
|
<div key={f.key} style={{ display: "flex", gap: 8 }}>
|
|
<span style={{ color: "#6a6a72", flex: "none", width: 62 }}>
|
|
{f.ts.slice(11, 19)}
|
|
</span>
|
|
<span
|
|
style={{
|
|
color:
|
|
f.kind === "failed"
|
|
? "#ff8a7a"
|
|
: f.kind === "done"
|
|
? "#5fd08a"
|
|
: "#c9a0ff",
|
|
flex: "none",
|
|
width: 90,
|
|
textTransform: "uppercase",
|
|
letterSpacing: ".06em",
|
|
fontSize: 10,
|
|
}}
|
|
>
|
|
{f.kind}
|
|
</span>
|
|
<span
|
|
style={{
|
|
color: "#6a6a72",
|
|
flex: "none",
|
|
width: 78,
|
|
fontSize: 10,
|
|
}}
|
|
title={f.runId}
|
|
>
|
|
{f.runId.slice(0, 8)}
|
|
</span>
|
|
<span
|
|
style={{
|
|
color: "#cfcfd5",
|
|
flex: 1,
|
|
whiteSpace: "pre-wrap",
|
|
wordBreak: "break-word",
|
|
}}
|
|
>
|
|
{f.text}
|
|
</span>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|