mission progress UI: auto-refresh + Team tab + Live events tab
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user