missions: live activity stream per running run on phase cards
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:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user