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