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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user