missions: multi-team model — pick research + development teams
ci / gates (push) Successful in 5s
ci / frontend (push) Successful in 37s
ci / rust (push) Failing after 1m41s
ci / e2e (push) Skipped
ci / publish (push) Skipped

Directly addresses "we want to pick one or more teams to assign to a
mission, first screen research teams, next screen dev teams." A
mission now materializes N teams, each tagged with a phase purpose.

Backend:
  - 0056_mission_teams.sql — new join table
    mission_teams(mission_id, team_id, purpose). team_id PK because a
    team belongs to one mission-purpose. missions.team_id kept as
    legacy pointer to the first minted team for single-team surfaces.
  - mission_orchestrator::on_launch — reads mission.config.phase_teams
    (JSONB shape { research: [tid,...], coding: [tid,...] }), mints
    one team per (purpose, template) pair, records each in
    mission_teams, binds the first to mission.team_id. Legacy fallback:
    if config.phase_teams is absent, uses missions.team_template_id.
    Hard error if both are absent.
  - GET /api/missions/{id}/teams — returns
    [{ team_id, purpose, team_name }], sorted by created_at asc.

Frontend wizard (step 3 rewrite):
  - researchTeamIds / devTeamIds — Set<string> multi-selects
  - Reusable TeamMultiSelect component (checkbox-style cards)
  - Panels rendered conditionally by preset:
    hasResearchPhase → "Research teams" panel
    hasCodingPhase → "Development teams" panel
    neither → "Teams" panel (bench/security-only missions)
  - canNext enforces at least one pick in every visible panel
  - submit builds config.phase_teams and passes it via CreateMissionRequest
  - Review step shows both selections by name

MissionTeamTab:
  - Fetches /api/missions/{id}/teams and groups by purpose
  - Each purpose renders a section with per-team cards
  - Falls back to a single "mission" pseudo-row for legacy missions
    that only have missions.team_id (no mission_teams rows)

CreateMissionRequest no longer sends team_template_id from the wizard
— the multi-team config.phase_teams path supersedes it. The backend
still accepts team_template_id for API callers.

Verified: cargo check --workspace + tsc + eslint --quiet all green.
This commit is contained in:
Omar Sobh
2026-07-20 19:25:07 -07:00
parent 0ee689f590
commit b8b8cb452e
7 changed files with 512 additions and 244 deletions
@@ -26,37 +26,56 @@ interface Claw {
job_title?: string | null;
}
interface MissionTeamRow {
team_id: string;
purpose: string;
team_name: string;
}
export function MissionTeamTab({
missionId,
teamId,
onOpenClaw,
}: {
missionId: string;
teamId: string | null;
onOpenClaw?: (clawId: string) => void;
}) {
const [team, setTeam] = useState<TeamDetail | null>(null);
const [rows, setRows] = useState<MissionTeamRow[]>([]);
const [teams, setTeams] = useState<Record<string, TeamDetail>>({});
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}`),
const [mtRes, cRes] = await Promise.all([
fetch(`/api/missions/${missionId}/teams`),
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 (!mtRes.ok) throw new Error(`teams ${mtRes.status}`);
const data = (await mtRes.json()) as { teams: MissionTeamRow[] };
// Fallback for legacy single-team missions: mission_teams empty
// but mission.team_id is set — synthesize one "mission" row so
// the UI doesn't look empty.
const list = data.teams.length > 0
? data.teams
: teamId
? [{ team_id: teamId, purpose: "mission", team_name: "Team" }]
: [];
const details = await Promise.all(
list.map(async (r) => {
const t = await fetch(`/api/teams/${r.team_id}`);
return t.ok
? ([r.team_id, (await t.json()) as TeamDetail] as const)
: null;
}),
);
const clawList = cRes.ok ? ((await cRes.json()) as Claw[]) : [];
if (!alive) return;
setTeam(detail);
setRows(list);
setTeams(Object.fromEntries(details.filter(Boolean) as (readonly [string, TeamDetail])[]));
setClaws(Object.fromEntries(clawList.map((c) => [c.id, c])));
} catch (e) {
if (alive) setError(e instanceof Error ? e.message : "load failed");
@@ -65,130 +84,159 @@ export function MissionTeamTab({
return () => {
alive = false;
};
}, [teamId]);
}, [missionId, teamId]);
if (!teamId) {
if (!teamId && rows.length === 0) {
return (
<div style={{ padding: 24, color: "#8a8a92", fontSize: 13 }}>
No team yet. Launch the mission to materialize a team from the picked
template.
No teams yet. Launch the mission to materialize teams from the picked
templates.
</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>
);
}
// Group rows by purpose so each section renders as one card.
const grouped = rows.reduce<Record<string, MissionTeamRow[]>>((acc, r) => {
(acc[r.purpose] = acc[r.purpose] ?? []).push(r);
return acc;
}, {});
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
<div style={{ display: "flex", flexDirection: "column", gap: 18 }}>
{Object.entries(grouped).map(([purpose, purposeRows]) => (
<div key={purpose} style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<div
style={{
fontFamily: mono,
fontSize: 10,
letterSpacing: ".14em",
color: "#7cd6e0",
textTransform: "uppercase",
}}
>
{purpose} · {purposeRows.length} team{purposeRows.length === 1 ? "" : "s"}
</div>
{purposeRows.map((r) => {
const detail = teams[r.team_id];
const members = detail?.members ?? [];
return (
<div
key={r.team_id}
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",
border: "1px solid rgba(255,255,255,.07)",
borderRadius: 10,
background: "#101014",
overflow: "hidden",
}}
>
<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",
padding: "8px 12px",
borderBottom: "1px solid rgba(255,255,255,.05)",
display: "flex",
alignItems: "center",
gap: 4,
gap: 8,
}}
>
Open
<ArrowUpRight size={11} />
</button>
)}
</div>
);
})
)}
<span style={{ fontSize: 13, color: "#f3f3f5", fontWeight: 600 }}>
{r.team_name}
</span>
<span style={{ marginLeft: "auto", fontFamily: mono, fontSize: 10, color: "#8a8a92" }}>
{members.length} member{members.length === 1 ? "" : "s"}
</span>
</div>
{members.length === 0 ? (
<div style={{ padding: 12, color: "#8a8a92", fontSize: 12 }}>
Materializing
</div>
) : (
members.map((m) => {
const claw = claws[m.claw_id];
return (
<div
key={m.claw_id}
style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: "9px 12px",
borderTop: "1px solid rgba(255,255,255,.04)",
}}
>
<span
style={{
width: 24,
height: 24,
borderRadius: 6,
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={12} />
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: 12.5,
color: "#f3f3f5",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{claw?.name ?? `claw ${m.claw_id.slice(0, 8)}`}
</div>
<div
style={{
fontFamily: mono,
fontSize: 10,
letterSpacing: ".08em",
color: "#8a8a92",
textTransform: "uppercase",
}}
>
{m.role}
</div>
</div>
{onOpenClaw && (
<button
type="button"
onClick={() => onOpenClaw(m.claw_id)}
title="Open this claw in the AGENT tier"
style={{
padding: "4px 9px",
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={10} />
</button>
)}
</div>
);
})
)}
</div>
);
})}
</div>
))}
</div>
);
}