mission progress UI: auto-refresh + Team tab + Live events tab
ci / gates (push) Successful in 6s
ci / frontend (push) Successful in 36s
ci / rust (push) Successful in 2m59s
ci / e2e (push) Skipped
ci / publish (push) Successful in 3m9s

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.
This commit is contained in:
Omar Sobh
2026-07-20 15:31:41 -07:00
parent cf735312f8
commit 3ba0485e7d
8 changed files with 534 additions and 1 deletions
@@ -0,0 +1,239 @@
"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>
);
}