missions: live activity stream per running run on phase cards
ci / gates (push) Successful in 5s
ci / frontend (push) Successful in 25s
ci / rust (push) Successful in 4m18s
ci / e2e (push) Skipped
ci / publish (push) Successful in 4m22s

Adds a "show activity" toggle to any running topology_run row on
the phase card. Expanded rows mount a compact SSE tail from
/api/topology-runs/{id}/events, rendering step/reasoning/tool
events as they arrive — same stream the LIVE tab consumes, just
scoped to one run.

Extracted the phase-runs list into PhaseRunsList to keep
MissionCanvas under the 1250-line budget.
This commit is contained in:
Omar Sobh
2026-07-21 14:53:04 -07:00
parent 94fecb526c
commit 66e57c5c1c
3 changed files with 293 additions and 96 deletions
@@ -36,6 +36,7 @@ import { MissionLiveEvents } from "./MissionLiveEvents";
import { MissionLivePane } from "./MissionLivePane"; import { MissionLivePane } from "./MissionLivePane";
import { MissionTeamTab } from "./MissionTeamTab"; import { MissionTeamTab } from "./MissionTeamTab";
import { MissionWizard } from "./MissionWizard"; import { MissionWizard } from "./MissionWizard";
import { PhaseRunsList } from "./PhaseRunsList";
import { RefineDiffModal } from "./RefineDiffModal"; import { RefineDiffModal } from "./RefineDiffModal";
const mono = const mono =
@@ -662,102 +663,7 @@ export function MissionCanvas({
: ""} : ""}
</span> </span>
)} )}
{(() => { <PhaseRunsList runs={runsByPhase.get(p.id) ?? []} />
const phaseRuns = runsByPhase.get(p.id) ?? [];
if (phaseRuns.length === 0) return null;
return (
<div
style={{
marginTop: 6,
display: "flex",
flexDirection: "column",
gap: 4,
}}
>
{phaseRuns.map((r) => {
const isFailed = r.status === "failed";
const isTerminal = ["completed", "failed", "cancelled"].includes(r.status);
return (
<div
key={r.id}
style={{
padding: "6px 9px",
borderRadius: 6,
border: `1px solid ${
isFailed
? "rgba(255,138,122,.35)"
: "rgba(255,255,255,.06)"
}`,
background: isFailed
? "rgba(255,138,122,.06)"
: "rgba(255,255,255,.02)",
fontFamily: mono,
fontSize: 11,
color: "#cfcfd5",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span
style={{
color:
r.status === "failed"
? "#ff8a7a"
: r.status === "completed"
? "#5fd08a"
: r.status === "running"
? "#5ec8d8"
: "#8a8a92",
textTransform: "uppercase",
letterSpacing: ".08em",
fontSize: 10,
}}
>
run · {r.status}
</span>
<span style={{ color: "#6a6a72", fontSize: 10 }}>
{r.id.slice(0, 8)}
</span>
{isTerminal && r.finished_at && (
<span style={{ color: "#6a6a72", fontSize: 10 }}>
{new Date(r.finished_at).toLocaleTimeString()}
</span>
)}
</div>
{isFailed && r.error && (
<details style={{ marginTop: 4 }}>
<summary
style={{
cursor: "pointer",
color: "#ff8a7a",
fontSize: 11,
}}
>
{r.error.split("\n")[0].slice(0, 180) || "error"}
</summary>
<pre
style={{
margin: "6px 0 0",
padding: 8,
borderRadius: 5,
background: "rgba(0,0,0,.35)",
color: "#e0d0cf",
fontSize: 10.5,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
maxHeight: 260,
overflow: "auto",
}}
>
{r.error}
</pre>
</details>
)}
</div>
);
})}
</div>
);
})()}
{mission.status === "running" || mission.status === "completed" ? ( {mission.status === "running" || mission.status === "completed" ? (
<div style={{ display: "flex", gap: 6, marginTop: 6 }}> <div style={{ display: "flex", gap: 6, marginTop: 6 }}>
{p.status === "failed" && mission.status === "running" && ( {p.status === "failed" && mission.status === "running" && (
@@ -0,0 +1,154 @@
"use client";
// Compact live event tail for a single topology_run — used inline on
// the mission phase card when a run is 'running'. Subscribes to
// /api/topology-runs/{id}/events (SSE), renders events as they arrive,
// closes when 'done' or on unmount.
//
// Reuses the same stream the mission's LIVE tab consumes; the
// difference here is scope (one run) + visual density (fits inside
// a phase card, not a full tab).
import { useEffect, useRef, useState } from "react";
const mono =
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
interface Event {
key: number;
ts: string;
kind: string;
text: string;
}
export function PhaseRunStream({ runId }: { runId: string }) {
const [events, setEvents] = useState<Event[]>([]);
const [terminal, setTerminal] = useState<string | null>(null);
const seq = useRef(0);
const scrollRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const es = new EventSource(`/api/topology-runs/${runId}/events`);
const push = (kind: string, text: string) => {
seq.current += 1;
setEvents((prev) =>
[
...prev,
{
key: seq.current,
ts: new Date().toISOString(),
kind,
text,
},
].slice(-120),
);
};
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 ??
data.reasoning ??
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 ?? "{}");
const label = data.status === "failed" ? "failed" : "done";
push(label, String(data.error ?? data.final_output ?? data.status ?? ""));
setTerminal(label);
} catch {
push("done", (ev as MessageEvent<string>).data ?? "");
setTerminal("done");
}
es.close();
});
es.onerror = () => {
// Auto-retry is built into EventSource; nothing to do here.
};
return () => {
es.close();
};
}, [runId]);
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
if (nearBottom) el.scrollTop = el.scrollHeight;
}, [events.length]);
return (
<div
ref={scrollRef}
style={{
marginTop: 6,
padding: 8,
borderRadius: 5,
background: "rgba(0,0,0,.35)",
color: "#cfcfd5",
fontSize: 10.5,
fontFamily: mono,
maxHeight: 260,
overflow: "auto",
display: "flex",
flexDirection: "column",
gap: 3,
}}
>
{events.length === 0 ? (
<span style={{ color: "#6a6a72" }}>
Waiting for events… (runs typically emit within a few seconds)
</span>
) : (
events.map((e) => (
<div key={e.key} style={{ display: "flex", gap: 6 }}>
<span style={{ color: "#6a6a72", flex: "none", width: 60 }}>
{e.ts.slice(11, 19)}
</span>
<span
style={{
color:
e.kind === "failed"
? "#ff8a7a"
: e.kind === "done"
? "#5fd08a"
: "#c9a0ff",
flex: "none",
width: 74,
textTransform: "uppercase",
letterSpacing: ".06em",
fontSize: 9,
}}
>
{e.kind}
</span>
<span
style={{
color: "#cfcfd5",
flex: 1,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{e.text}
</span>
</div>
))
)}
{terminal && (
<div style={{ color: "#6a6a72", marginTop: 4 }}>
stream closed ({terminal})
</div>
)}
</div>
);
}
@@ -0,0 +1,137 @@
"use client";
// Per-phase list of topology_run rows shown on the mission phase card.
// Extracted from MissionCanvas to keep that file under the 1250-line
// budget. Owns the "show/hide activity" toggle for running runs and
// mounts PhaseRunStream on demand.
import { useState } from "react";
import type { MissionRunSummary } from "@/lib/api/missions";
import { PhaseRunStream } from "./PhaseRunStream";
const mono =
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
export function PhaseRunsList({ runs }: { runs: MissionRunSummary[] }) {
const [expanded, setExpanded] = useState<Set<string>>(new Set());
if (runs.length === 0) return null;
return (
<div
style={{
marginTop: 6,
display: "flex",
flexDirection: "column",
gap: 4,
}}
>
{runs.map((r) => {
const isFailed = r.status === "failed";
const isTerminal = ["completed", "failed", "cancelled"].includes(r.status);
return (
<div
key={r.id}
style={{
padding: "6px 9px",
borderRadius: 6,
border: `1px solid ${
isFailed ? "rgba(255,138,122,.35)" : "rgba(255,255,255,.06)"
}`,
background: isFailed
? "rgba(255,138,122,.06)"
: "rgba(255,255,255,.02)",
fontFamily: mono,
fontSize: 11,
color: "#cfcfd5",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span
style={{
color:
r.status === "failed"
? "#ff8a7a"
: r.status === "completed"
? "#5fd08a"
: r.status === "running"
? "#5ec8d8"
: "#8a8a92",
textTransform: "uppercase",
letterSpacing: ".08em",
fontSize: 10,
}}
>
run · {r.status}
</span>
<span style={{ color: "#6a6a72", fontSize: 10 }}>
{r.id.slice(0, 8)}
</span>
{isTerminal && r.finished_at && (
<span style={{ color: "#6a6a72", fontSize: 10 }}>
{new Date(r.finished_at).toLocaleTimeString()}
</span>
)}
{r.status === "running" && (
<button
type="button"
onClick={() =>
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(r.id)) next.delete(r.id);
else next.add(r.id);
return next;
})
}
style={{
marginLeft: "auto",
background: "transparent",
border: "1px solid rgba(94,200,216,.35)",
color: "#5ec8d8",
fontSize: 10,
padding: "2px 8px",
borderRadius: 4,
cursor: "pointer",
fontFamily: mono,
}}
>
{expanded.has(r.id) ? "hide activity" : "show activity"}
</button>
)}
</div>
{r.status === "running" && expanded.has(r.id) && (
<PhaseRunStream runId={r.id} />
)}
{isFailed && r.error && (
<details style={{ marginTop: 4 }}>
<summary
style={{
cursor: "pointer",
color: "#ff8a7a",
fontSize: 11,
}}
>
{r.error.split("\n")[0].slice(0, 180) || "error"}
</summary>
<pre
style={{
margin: "6px 0 0",
padding: 8,
borderRadius: 5,
background: "rgba(0,0,0,.35)",
color: "#e0d0cf",
fontSize: 10.5,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
maxHeight: 260,
overflow: "auto",
}}
>
{r.error}
</pre>
</details>
)}
</div>
);
})}
</div>
);
}