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
+1
View File
@@ -459,6 +459,7 @@ pub fn router(state: AppState) -> Router {
"/api/missions/{id}/description", "/api/missions/{id}/description",
patch(routes::missions::set_description), patch(routes::missions::set_description),
) )
.route("/api/missions/{id}/runs", get(routes::missions::list_runs))
.route( .route(
"/api/missions/{id}/benchmark", "/api/missions/{id}/benchmark",
post(routes::missions::trigger_benchmark), post(routes::missions::trigger_benchmark),
+15
View File
@@ -410,6 +410,21 @@ pub async fn herdr_dispatch(
})) }))
} }
/// GET /api/missions/{id}/runs — topology_runs bound to this mission,
/// newest first. Used by the Live tab to subscribe to per-run SSE.
pub async fn list_runs(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
) -> Result<Json<Value>, ApiError> {
// Scope check — 404 if the mission doesn't belong to this workspace.
let _ = cm_db::repo::missions::get(&state.pool, id, user.workspace_id.as_uuid())
.await?
.ok_or(ApiError::NotFound)?;
let runs = cm_db::repo::topology_runs::list_by_mission(&state.pool, id, 50).await?;
Ok(Json(serde_json::json!({ "runs": runs })))
}
pub async fn set_status( pub async fn set_status(
State(state): State<AppState>, State(state): State<AppState>,
Authed(user): Authed, Authed(user): Authed,
+34
View File
@@ -12,12 +12,15 @@ use crate::DbError;
/// A row summary for the recent-runs list. `finished_at` is populated for /// A row summary for the recent-runs list. `finished_at` is populated for
/// terminal runs; `None` for compares or still-in-flight runs. /// terminal runs; `None` for compares or still-in-flight runs.
#[derive(Debug, Clone, serde::Serialize)]
pub struct TopologyRunSummary { pub struct TopologyRunSummary {
pub id: Uuid, pub id: Uuid,
pub task: String, pub task: String,
pub status: String, pub status: String,
pub kind: String, pub kind: String,
#[serde(with = "time::serde::rfc3339")]
pub created_at: OffsetDateTime, pub created_at: OffsetDateTime,
#[serde(with = "time::serde::rfc3339::option")]
pub finished_at: Option<OffsetDateTime>, pub finished_at: Option<OffsetDateTime>,
} }
@@ -366,6 +369,37 @@ pub async fn list_recent(
.collect()) .collect())
} }
/// All topology_runs bound to a mission (via topology_runs.mission_id
/// added in migration 0051). Newest first — the mission canvas Live
/// tab uses this to subscribe to each active run's SSE.
pub async fn list_by_mission(
pool: &PgPool,
mission_id: Uuid,
limit: i64,
) -> Result<Vec<TopologyRunSummary>, DbError> {
use sqlx::Row;
let rows = sqlx::query(
"SELECT id, task, status, kind, created_at, finished_at
FROM topology_runs
WHERE mission_id = $1 ORDER BY created_at DESC LIMIT $2",
)
.bind(mission_id)
.bind(limit)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|r| TopologyRunSummary {
id: r.get("id"),
task: r.get("task"),
status: r.get("status"),
kind: r.get("kind"),
created_at: r.get("created_at"),
finished_at: r.try_get("finished_at").ok(),
})
.collect())
}
/// A single saved comparison/run result, scoped to its workspace. /// A single saved comparison/run result, scoped to its workspace.
pub async fn get( pub async fn get(
pool: &PgPool, pool: &PgPool,
@@ -842,6 +842,10 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
setMissionsSel(null); setMissionsSel(null);
setMissionsRefresh((n) => n + 1); setMissionsRefresh((n) => n + 1);
}} }}
onOpenClaw={(clawId) => {
setAgentId(clawId);
setTier("claw");
}}
/> />
) : isRepos ? ( ) : isRepos ? (
<RepoCanvas selectedId={repoSel} refreshKey={repoRefresh} /> <RepoCanvas selectedId={repoSel} refreshKey={repoRefresh} />
@@ -29,7 +29,9 @@ import {
} from "@/lib/api/missions"; } from "@/lib/api/missions";
import { EditMissionModal } from "./EditMissionModal"; import { EditMissionModal } from "./EditMissionModal";
import { MarkdownBlock } from "./MarkdownBlock"; import { MarkdownBlock } from "./MarkdownBlock";
import { MissionLiveEvents } from "./MissionLiveEvents";
import { MissionLivePane } from "./MissionLivePane"; import { MissionLivePane } from "./MissionLivePane";
import { MissionTeamTab } from "./MissionTeamTab";
import { MissionWizard } from "./MissionWizard"; import { MissionWizard } from "./MissionWizard";
import { RefineDiffModal } from "./RefineDiffModal"; import { RefineDiffModal } from "./RefineDiffModal";
@@ -72,7 +74,15 @@ const TEMPLATE_LABEL: Record<TemplateKind, string> = {
custom: "Custom", custom: "Custom",
}; };
type Tab = "overview" | "phases" | "tasks" | "artifacts" | "benchmarks" | "pane"; type Tab =
| "overview"
| "phases"
| "tasks"
| "team"
| "live"
| "artifacts"
| "benchmarks"
| "pane";
export function MissionCanvas({ export function MissionCanvas({
selectedId, selectedId,
@@ -80,12 +90,15 @@ export function MissionCanvas({
onChanged, onChanged,
onSelect, onSelect,
onDeleted, onDeleted,
onOpenClaw,
}: { }: {
selectedId: string | null; selectedId: string | null;
refreshKey: number; refreshKey: number;
onChanged: () => void; onChanged: () => void;
onSelect?: (id: string) => void; onSelect?: (id: string) => void;
onDeleted?: () => void; onDeleted?: () => void;
/** Cross-tier navigation — jumps to AGENT tier with this claw selected. */
onOpenClaw?: (clawId: string) => void;
}) { }) {
const [mission, setMission] = useState<MissionDetail | null>(null); const [mission, setMission] = useState<MissionDetail | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -123,6 +136,17 @@ export function MissionCanvas({
void load(); void load();
}, [load, refreshKey]); }, [load, refreshKey]);
// Auto-refresh every 3s while the mission is running so Phases +
// Tasks + Artifacts tabs surface progress without hitting Refresh.
// Stops polling once the mission reaches a terminal state.
useEffect(() => {
if (mission?.status !== "running") return;
const t = setInterval(() => {
void load();
}, 3000);
return () => clearInterval(t);
}, [mission?.status, load]);
const refine = useCallback(async () => { const refine = useCallback(async () => {
if (!mission) return; if (!mission) return;
setRefining(true); setRefining(true);
@@ -452,6 +476,8 @@ export function MissionCanvas({
"overview", "overview",
"phases", "phases",
"tasks", "tasks",
"team",
"live",
"artifacts", "artifacts",
"benchmarks", "benchmarks",
...(mission.runtime_kind === "local_herdr" ? (["pane"] as const) : []), ...(mission.runtime_kind === "local_herdr" ? (["pane"] as const) : []),
@@ -708,6 +734,14 @@ export function MissionCanvas({
</div> </div>
)} )}
{tab === "team" && (
<MissionTeamTab teamId={mission.team_id} onOpenClaw={onOpenClaw} />
)}
{tab === "live" && (
<MissionLiveEvents missionId={mission.id} visible={tab === "live"} />
)}
{tab === "artifacts" && ( {tab === "artifacts" && (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}> <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{mission.artifacts.length === 0 ? ( {mission.artifacts.length === 0 ? (
@@ -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>
);
}
@@ -0,0 +1,194 @@
"use client";
// Team roster for a mission. Fetches the mission's team (via
// /api/teams/{id}) and lists each claw with its role + a link that
// hands the operator over to AGENT tier with that claw selected —
// where the existing WorkingOnNow / ReasoningStream / metrics live.
import { useEffect, useState } from "react";
import { ArrowUpRight, User } from "lucide-react";
const mono =
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
interface Member {
claw_id: string;
role: string;
node_id?: string;
}
interface TeamDetail {
name?: string;
members: Member[];
}
interface Claw {
id: string;
name: string;
job_title?: string | null;
}
export function MissionTeamTab({
teamId,
onOpenClaw,
}: {
teamId: string | null;
onOpenClaw?: (clawId: string) => void;
}) {
const [team, setTeam] = useState<TeamDetail | null>(null);
const [claws, setClaws] = useState<Record<string, Claw>>({});
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!teamId) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setTeam(null);
return;
}
let alive = true;
(async () => {
try {
const [tRes, cRes] = await Promise.all([
fetch(`/api/teams/${teamId}`),
fetch(`/api/team/claws`),
]);
if (!tRes.ok) throw new Error(`team ${tRes.status}`);
const detail = (await tRes.json()) as TeamDetail;
const clawList = cRes.ok
? ((await cRes.json()) as Claw[])
: [];
if (!alive) return;
setTeam(detail);
setClaws(Object.fromEntries(clawList.map((c) => [c.id, c])));
} catch (e) {
if (alive) setError(e instanceof Error ? e.message : "load failed");
}
})();
return () => {
alive = false;
};
}, [teamId]);
if (!teamId) {
return (
<div style={{ padding: 24, color: "#8a8a92", fontSize: 13 }}>
No team yet. Launch the mission to materialize a team from the picked
template.
</div>
);
}
if (error) {
return <div style={{ padding: 24, color: "#ff8a7a", fontSize: 12 }}>{error}</div>;
}
if (!team) {
return (
<div style={{ padding: 24, color: "#5ec8d8", fontFamily: mono, fontSize: 12 }}>
Loading…
</div>
);
}
return (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<div
style={{
fontFamily: mono,
fontSize: 10,
letterSpacing: ".14em",
color: "#7cd6e0",
textTransform: "uppercase",
marginBottom: 4,
}}
>
{team.name ?? "Team"} · {team.members.length} member
{team.members.length === 1 ? "" : "s"}
</div>
{team.members.length === 0 ? (
<div style={{ padding: 16, color: "#8a8a92", fontSize: 12 }}>
Team has no members yet (orchestrator may still be materializing).
</div>
) : (
team.members.map((m) => {
const claw = claws[m.claw_id];
return (
<div
key={m.claw_id}
style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: "11px 12px",
borderRadius: 10,
border: "1px solid rgba(255,255,255,.07)",
background: "#101014",
}}
>
<span
style={{
width: 30,
height: 30,
borderRadius: 8,
background: "rgba(255,111,97,.1)",
border: "1px solid rgba(255,111,97,.25)",
color: "#ff8a7a",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
flex: "none",
}}
>
<User size={14} />
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: 13,
color: "#f3f3f5",
fontWeight: 500,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{claw?.name ?? `claw ${m.claw_id.slice(0, 8)}`}
</div>
<div
style={{
fontFamily: mono,
fontSize: 10.5,
letterSpacing: ".08em",
color: "#8a8a92",
textTransform: "uppercase",
marginTop: 2,
}}
>
{m.role}
</div>
</div>
{onOpenClaw && (
<button
type="button"
onClick={() => onOpenClaw(m.claw_id)}
title="Open this claw in the AGENT tier"
style={{
padding: "5px 10px",
borderRadius: 6,
border: "1px solid rgba(94,200,216,.4)",
background: "transparent",
color: "#5ec8d8",
fontSize: 11,
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
gap: 4,
}}
>
Open
<ArrowUpRight size={11} />
</button>
)}
</div>
);
})
)}
</div>
);
}
+12
View File
@@ -209,6 +209,18 @@ export interface RefineResult {
export const refineMission = (id: string) => export const refineMission = (id: string) =>
api<RefineResult>(`/api/missions/${id}/refine`, { method: "POST" }); api<RefineResult>(`/api/missions/${id}/refine`, { method: "POST" });
export interface MissionRunSummary {
id: string;
task: string;
status: "queued" | "running" | "completed" | "failed" | "cancelled";
kind: string;
created_at: string;
finished_at: string | null;
}
export const listMissionRuns = (id: string) =>
api<{ runs: MissionRunSummary[] }>(`/api/missions/${id}/runs`);
export interface UpdateMissionMetaRequest { export interface UpdateMissionMetaRequest {
title?: string; title?: string;
description?: string; description?: string;