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,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>
);
}