missions: multi-team model — pick research + development teams
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user