SW team: staffing gate UI, expandable team entry, four team personas (OPE-97/98)

swe-lead (minimal tools, coordination verbs) + swe/design/test workers with the shared worker contract; workers never surface in the picker — they're staffed, not started.
Staffing card rides the approval slot; workers nest under the lead's ONE expandable RECENT entry in both sidebar layouts.
This commit is contained in:
Rohit C Prasad
2026-08-16 08:53:26 -07:00
committed by Rohit P
parent 3e4fafead0
commit 13f9c0b6c0
17 changed files with 582 additions and 9 deletions
+63
View File
@@ -629,6 +629,20 @@ export async function mockApi(page: import("@playwright/test").Page) {
});
return; // suspended on the approval
}
// Agent teams (OPE-97): the staffing gate — the lead proposes a roster and
// SUSPENDS until the team_response verdict arrives.
if (/staff the team/i.test(msg.text)) {
send("team_proposed", {
members: [
{ persona: "swe-worker", model: "anthropic:claude-opus-4-8", reason: "implementation" },
{ persona: "design-worker", reason: "UI polish" },
{ persona: "test-worker", reason: "verifies against acceptance criteria" },
],
enable_chat: false,
note: "Three workers cover the plan; test-worker verifies before anything closes.",
});
return; // suspended on the staffing decision
}
// Agent teams (OPE-96): a decomposition turn — the plan was approved in
// conversation (plan-approval flow); the agent files the items and the
// board rail appears on the next board fetch.
@@ -827,6 +841,55 @@ export async function mockApi(page: import("@playwright/test").Page) {
send("assistant_message", { text: `Done via ${pendingTool} [decision=${msg.decision}]` });
}
send("turn_done");
} else if (msg.type === "team_response") {
if (msg.approved) {
// Server-side create_team pre-spawned the workers; surface them in the
// sessions fixture so the sidebar's expandable entry has children.
const lead = sessions.find((s) => s.session_id === "sess-lead") || {
session_id: "sess-lead",
title: "Build the statements page",
workspace: "/Users/test/OpenWorker/launch-note",
// The fixture keeps the lead on the default persona so it renders inside
// the already-open accordion; the expandable entry is what's under test.
agent: "cowork",
model: "m",
mode: "interactive",
updated_at: new Date().toISOString(),
messages: 2,
team: { role: "lead", team_id: "t1" },
};
if (!sessions.includes(lead)) sessions.unshift(lead);
for (const [actor, status, item] of [
["swe-worker", "in_progress", "#1 in progress"],
["design-worker", "idle", "idle"],
["test-worker", "blocked", "#4 blocked"],
] as const) {
sessions.push({
session_id: `sess-${actor}`,
title: actor,
workspace: "/Users/test/OpenWorker/launch-note",
agent: actor,
model: "m",
mode: "interactive",
updated_at: new Date().toISOString(),
messages: 0,
team: {
role: "worker",
team_id: "t1",
lead_session: "sess-lead",
actor,
status,
current_item: item,
},
});
}
send("assistant_message", {
text: "Team created — swe-worker, design-worker and test-worker are standing by. Assigning items now.",
});
} else {
send("assistant_message", { text: "Understood — tell me how to change the roster." });
}
send("turn_done");
} else if (msg.type === "tool_response") {
// Either way the turn continues — the point of the contract is that declining
// degrades the report openly instead of dropping the check.
+57
View File
@@ -0,0 +1,57 @@
// Agent teams (OPE-97): the staffing gate + the sidebar's expandable team entry.
// The fake lead proposes a roster on "staff the team" and suspends; approval
// "pre-spawns" workers (the fixture mirrors create_team by adding worker sessions),
// which then nest under the lead's ONE expandable RECENT entry.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
async function proposeTeam(page: import("@playwright/test").Page) {
await page.goto("/");
await page.getByPlaceholder(/Ask the coworker/).fill("staff the team");
await page.getByRole("button", { name: "Send" }).click();
await expect(page.getByTestId("teamreq-card")).toBeVisible();
}
test("the staffing gate shows the roster and the grant sentence", async ({ page }) => {
await proposeTeam(page);
const card = page.getByTestId("teamreq-card");
await expect(card).toContainText("Proposed team — 3 workers");
await expect(card).toContainText("swe-worker");
await expect(card).toContainText("implementation");
await expect(card).toContainText("test-worker");
await expect(card).toContainText(
"Approving grants the lead create, assign & steer — this team only, revocable.",
);
});
test("declining the roster returns the turn to the lead", async ({ page }) => {
await proposeTeam(page);
await page.getByRole("button", { name: "Not now" }).click();
await expect(page.getByText(/tell me how to change the roster/)).toBeVisible();
await expect(page.getByTestId("teamreq-card")).toHaveCount(0);
});
test("approval creates the team; workers nest under the lead's expandable entry", async ({
page,
}) => {
await proposeTeam(page);
await page.getByTestId("teamreq-approve").click();
await expect(page.getByText(/Team created/)).toBeVisible();
// The workers exist as sessions now — but never as top-level RECENT rows.
// (The sidebar refreshes on its 5s poll, so allow one full cycle.)
await expect(page.getByTestId("team-toggle-sess-lead")).toBeVisible({ timeout: 12_000 });
await expect(page.getByText("Build the statements page")).toBeVisible();
await expect(page.getByTestId("team-children-sess-lead")).toHaveCount(0);
await page.getByTestId("team-toggle-sess-lead").click();
const children = page.getByTestId("team-children-sess-lead");
await expect(children).toBeVisible();
await expect(children).toContainText("swe-worker · #1 in progress");
await expect(children).toContainText("design-worker · idle");
await expect(children).toContainText("test-worker · #4 blocked");
// Collapse hides them again — the team is one entry, not a panel.
await page.getByTestId("team-toggle-sess-lead").click();
await expect(page.getByTestId("team-children-sess-lead")).toHaveCount(0);
});
+34
View File
@@ -76,6 +76,7 @@ import { ToolRequestCard } from "./components/ToolRequestCard";
import { DirectoryRequestCard } from "./components/DirectoryRequestCard";
import { PlanCard } from "./components/PlanCard";
import { BoardOverlay } from "./components/BoardPanel";
import { TeamRequestCard } from "./components/TeamRequestCard";
import { WorkspaceTrustPrompt } from "./components/WorkspaceTrustPrompt";
const newId = () =>
@@ -756,6 +757,19 @@ export function App() {
if (unattendedRef.current) break;
setItems((p) => [...p, { kind: "planreq", plan: d.plan || "" }]);
break;
case "team_proposed":
// The staffing gate (agent teams) — approval pre-spawns the worker sessions.
if (unattendedRef.current) break;
setItems((p) => [
...p,
{
kind: "teamreq",
members: Array.isArray(d.members) ? d.members : [],
enable_chat: !!d.enable_chat,
note: d.note || "",
},
]);
break;
case "question_requested":
// ask_user in an attended session — answered inline (not routed to the Inbox).
setItems((p) => [
@@ -1016,6 +1030,11 @@ export function App() {
sessionRef.current?.respondPlan(approved, mode, feedback);
if (approved && mode) setMode(mode); // the server flips the live engine to this mode
};
const respondTeam = (approved: boolean, feedback?: string) => {
setItems((p) => resolveLastTeam(p, approved ? "approved" : "rejected"));
dropSessionInbox("plan"); // the gate parks as a plan-kind Inbox item
sessionRef.current?.respondTeam(approved, feedback);
};
const respondDirectory = (granted: boolean, path?: string, writable?: boolean) => {
setItems((p) => resolveLastDirReq(p, granted ? "granted" : "denied"));
dropSessionInbox("directory");
@@ -1389,6 +1408,7 @@ export function App() {
const pendingDirReq = [...items].reverse().find((i) => i.kind === "dirreq" && !i.resolved);
const pendingToolReq = [...items].reverse().find((i) => i.kind === "toolreq" && !i.resolved);
const pendingPlan = [...items].reverse().find((i) => i.kind === "planreq" && !i.resolved);
const pendingTeam = [...items].reverse().find((i) => i.kind === "teamreq" && !i.resolved);
const pendingQuestion = [...items].reverse().find((i) => i.kind === "question" && !i.resolved);
// Facts subtitle (§22): the session's FIXED facts, not controls — model (+ the
// workspace folder for project-scoped sessions). Renders only once the session has history;
@@ -1904,6 +1924,8 @@ export function App() {
// parked in the Inbox and surfaced via the answer-in-context card below.
!unattended && pendingPlan?.kind === "planreq" ? (
<PlanCard item={pendingPlan} onRespond={respondPlan} />
) : !unattended && pendingTeam?.kind === "teamreq" ? (
<TeamRequestCard item={pendingTeam} onRespond={respondTeam} />
) : !unattended && pendingToolReq?.kind === "toolreq" ? (
<ToolRequestCard item={pendingToolReq} onRespond={respondTool} />
) : !unattended && pendingDirReq?.kind === "dirreq" ? (
@@ -2107,6 +2129,18 @@ function resolveLastPlan(items: Item[], resolved: "approved" | "rejected"): Item
return copy;
}
function resolveLastTeam(items: Item[], resolved: "approved" | "rejected"): Item[] {
const copy = [...items];
for (let i = copy.length - 1; i >= 0; i--) {
const it = copy[i];
if (it.kind === "teamreq" && !it.resolved) {
copy[i] = { ...it, resolved };
break;
}
}
return copy;
}
function resolveLastQuestion(items: Item[], answer: string): Item[] {
const copy = [...items];
for (let i = copy.length - 1; i >= 0; i--) {
+8
View File
@@ -2189,6 +2189,14 @@ export class Session {
});
}
respondTeam(approved: boolean, feedback?: string) {
this.send({
type: "team_response",
approved,
...(feedback ? { feedback } : {}),
});
}
// Answer a live `ask_user` prompt (attended sessions; unattended ones answer via the Inbox).
respondQuestion(answer: string) {
this.send({ type: "question_response", answer });
+106 -7
View File
@@ -402,7 +402,12 @@ export function Sidebar(props: Props) {
// Body data is keyed to the BROWSED persona (only one body renders at a time). Pinned sessions are
// EXCLUDED here: they live in the cross-persona Pinned band only, so they don't repeat inside the
// persona group / project list (matching the flat layout's Recent, which also drops pinned).
const all = props.sessions.filter((s) => s.agent === browseKey && !s.session_id.startsWith("__"));
const all = props.sessions.filter(
(s) =>
s.agent === browseKey &&
!s.session_id.startsWith("__") &&
s.team?.role !== "worker", // workers nest under their lead, never top-level
);
const mine = all.filter((s) => !s.archived && !s.pinned);
const archived = all.filter((s) => s.archived);
// Only PROJECT-SCOPED personas group sessions by project (git-bound Code, project-bound Ops).
@@ -418,12 +423,32 @@ export function Sidebar(props: Props) {
// Recent = every non-pinned, non-archived, real session across ALL personas, newest first
// (by updated_at; missing timestamps keep store order), search-filtered. Drives the flat layout.
// Team workers never appear top-level: they nest under their lead's ONE expandable entry.
const recentSessions = [...props.sessions]
.filter((s) => !s.archived && !s.session_id.startsWith("__") && !s.pinned)
.filter((s) => s.team?.role !== "worker")
.filter((s) => personaVisible(s.agent))
.filter(matches)
.sort((a, b) => (b.updated_at || "").localeCompare(a.updated_at || ""));
// Agent teams (UX-030): lead session id → its worker sessions. The team is ONE
// expandable entry in RECENT — plain sessions never expand.
const teamWorkers = new Map<string, SessionInfo[]>();
for (const s of props.sessions) {
if (s.team?.role === "worker" && s.team.lead_session) {
const list = teamWorkers.get(s.team.lead_session) || [];
list.push(s);
teamWorkers.set(s.team.lead_session, list);
}
}
const [teamOpen, setTeamOpen] = useState<Set<string>>(new Set());
const toggleTeam = (id: string) =>
setTeamOpen((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
// Row actions live behind ONE ⋮ kebab per row (FB-011: four hover icons read as clutter) —
// the menu offers Rename · Pin/Unpin · Archive/Unarchive · Delete, with the two-step delete
// confirm kept inside it. Shared by BOTH row styles, so the chronological cardRow offers the
@@ -541,6 +566,19 @@ export function Sidebar(props: Props) {
}}
title={editing ? undefined : title}
>
{!editing && !!teamWorkers.get(s.session_id)?.length && (
<button
className="shrink-0 -ml-1 text-faint hover:text-ink"
data-testid={`team-toggle-${s.session_id}`}
onClick={(e) => {
e.stopPropagation();
toggleTeam(s.session_id);
}}
aria-label="Show team"
>
<Icon name={teamOpen.has(s.session_id) ? "chevronDown" : "chevronRight"} size={12} />
</button>
)}
{editing ? (
<input
className="flex-1 min-w-0 px-1.5 py-0.5 rounded-md bg-panel border border-accent text-[13px] text-ink outline-none"
@@ -614,7 +652,21 @@ export function Sidebar(props: Props) {
}}
>
{/* No leading glyph on session rows (Rohit's call 2026-07-07: the per-session icon
read as noise in both grouped and chronological). */}
read as noise in both grouped and chronological) except a chevron on TEAM
leads, whose entry expands to the worker rows. */}
{!editing && teamWorkers.has(s.session_id) && (
<button
className="shrink-0 -ml-1 text-faint hover:text-ink"
data-testid={`team-toggle-${s.session_id}`}
onClick={(e) => {
e.stopPropagation();
toggleTeam(s.session_id);
}}
aria-label="Show team"
>
<Icon name={teamOpen.has(s.session_id) ? "chevronDown" : "chevronRight"} size={12} />
</button>
)}
{editing ? (
<input
className="flex-1 min-w-0 px-1.5 py-0.5 rounded-md bg-panel border border-accent text-[13px] text-ink outline-none"
@@ -652,6 +704,48 @@ export function Sidebar(props: Props) {
);
};
// A lead's worker rows — status dot + current item. Clicking a worker opens its
// session: the user's altitude-3 escape hatch.
const teamChildren = (s: SessionInfo) => {
const workers = teamWorkers.get(s.session_id) || [];
return (
<div className="team-child space-y-0.5" data-testid={`team-children-${s.session_id}`}>
{workers.map((w) => (
<div
key={w.session_id}
className={
"group flex items-center gap-2 px-2 py-1 rounded-lg cursor-pointer text-[12px] " +
(w.session_id === props.activeSession ? "bg-ink/[0.055]" : "hover:bg-paper")
}
onClick={() => props.onSelectSession(w.session_id, w.workspace, w.agent)}
title={w.team?.actor}
>
<span className={"team-dot " + (w.team?.status || "idle")} />
<span className="min-w-0 flex-1 truncate text-ink">
{w.team?.actor || w.agent}
<span className="team-item"> · {w.team?.current_item || "idle"}</span>
</span>
<LiveDot state={w.liveness} />
</div>
))}
</div>
);
};
// A row plus (when expanded) its team children — used by BOTH row styles so the
// expandable team entry works in the flat AND grouped layouts.
const withTeamChildren = (s: SessionInfo, row: ReturnType<typeof cardRow>) => {
if (!teamWorkers.get(s.session_id)?.length) return row;
return (
<div key={`team-${s.session_id}`}>
{row}
{teamOpen.has(s.session_id) && teamChildren(s)}
</div>
);
};
const teamAwareRow = (s: SessionInfo) => withTeamChildren(s, cardRow(s));
// The cross-persona Pinned band (manual pins only) — icon-free rows. Appears in BOTH layouts
// (flat list AND accordion), so it's factored here for reuse.
const pinnedBand = () =>
@@ -661,7 +755,7 @@ export function Sidebar(props: Props) {
Pinned
</div>
<div className="space-y-0.5">
{pinnedSessions.map((s) => cardRow(s))}
{pinnedSessions.map((s) => teamAwareRow(s))}
</div>
</div>
) : null;
@@ -823,7 +917,12 @@ export function Sidebar(props: Props) {
// persona from New Session, never orphan its conversations).
const agentsWithSessions = new Set(
props.sessions
.filter((s) => !s.archived && !s.session_id.startsWith("__"))
.filter(
(s) =>
!s.archived &&
!s.session_id.startsWith("__") &&
s.team?.role !== "worker",
)
.map((s) => s.agent),
);
const visibleSurfaces = (
@@ -916,7 +1015,7 @@ export function Sidebar(props: Props) {
// pl-[19px] aligns each session's name under the folder NAME (folder icon
// 15 + gap 6 + row px 6 session px 8 = 19), per Rohit's clean-column ask.
<div className="space-y-0.5 pl-[19px]">
{shown.map((s) => sessionRow(s, { showTime: true }))}
{shown.map((s) => withTeamChildren(s, sessionRow(s, { showTime: true })))}
{!showAll && list.length > peek && (
<button
className="px-2 py-1 text-[12px] text-faint hover:text-muted"
@@ -947,7 +1046,7 @@ export function Sidebar(props: Props) {
{(personaShowAll.has(browseKey)
? mine.filter(matches)
: mine.filter(matches).slice(0, peek)
).map((s) => sessionRow(s))}
).map((s) => withTeamChildren(s, sessionRow(s)))}
{!personaShowAll.has(browseKey) && mine.filter(matches).length > peek && (
<button
className="px-2 py-1 text-[12px] text-faint hover:text-muted"
@@ -1106,7 +1205,7 @@ export function Sidebar(props: Props) {
{(recentExpanded
? recentSessions
: recentSessions.slice(0, RECENT_PEEK)
).map((s) => cardRow(s))}
).map((s) => teamAwareRow(s))}
{recentSessions.length > RECENT_PEEK && (
<button
className="w-full text-left px-2 py-1.5 text-[12px] text-muted hover:text-ink"
@@ -0,0 +1,52 @@
// The staffing gate (agent teams, UX-030): a lead proposes its worker roster.
// Visible layer = the decisions (who, on what model, why); approving grants the lead
// create/assign/steer for this board — standing, revocable — and PRE-SPAWNS the
// worker sessions. No in-card reply surface: editing happens by replying.
import type { Item } from "../types";
import { Icon } from "./Icon";
export function TeamRequestCard({
item,
onRespond,
}: {
item: Extract<Item, { kind: "teamreq" }>;
onRespond: (approved: boolean, feedback?: string) => void;
}) {
return (
<div className="dirreq-card teamreq-card" data-testid="teamreq-card">
<div className="teamreq-head">
<Icon name="diamond" size={15} />
<span className="teamreq-title">
Proposed team {item.members.length} worker{item.members.length === 1 ? "" : "s"}
</span>
</div>
{item.note && <div className="teamreq-note">{item.note}</div>}
{item.members.map((m, i) => (
<div className="teamreq-row" key={i}>
<span className="teamreq-diamond"></span>
<span className="teamreq-body">
<code>{m.persona}</code>
{m.model && <span className="teamreq-model"> · {m.model}</span>}
{m.reason && <span className="teamreq-reason"> {m.reason}</span>}
</span>
</div>
))}
<div className="dirreq-actions">
<span className="teamreq-grant">
Approving grants the lead create, assign &amp; steer this team only, revocable.
</span>
<span className="spacer" />
<button className="btn" onClick={() => onRespond(false)}>
Not now
</button>
<button
className="btn primary"
data-testid="teamreq-approve"
onClick={() => onRespond(true)}
>
Create team &amp; start
</button>
</div>
</div>
);
}
+20
View File
@@ -1714,3 +1714,23 @@ html[data-platform="linux"] ::-webkit-scrollbar-thumb:hover { background-color:
.journal-count { font-size: 11px; color: var(--faint); }
/* Staffing gate (agent teams) */
.teamreq-card { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
.teamreq-head { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
.teamreq-title { font-weight: 600; font-size: 13px; color: var(--ink); }
.teamreq-note { font-size: 12px; color: var(--muted); margin-bottom: 4px; }
.teamreq-row { display: flex; gap: 9px; padding: 7px 0; border-top: 1px solid var(--line); font-size: 12.5px; }
.teamreq-diamond { color: var(--faint); font-size: 11px; padding-top: 1px; }
.teamreq-body code { background: var(--paper); border-radius: 5px; padding: 1px 6px; font-size: 12px; }
.teamreq-model { color: var(--muted); }
.teamreq-reason { color: var(--faint); }
.teamreq-grant { font-size: 11.5px; color: var(--faint); }
/* Sidebar: the expandable team entry (workers nest under their lead) */
.team-child { margin-left: 18px; }
.team-child .team-item { font-size: 11px; color: var(--faint); margin-left: 4px; }
.team-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; background: var(--faint); }
.team-dot.in_progress { background: var(--ok-dot); }
.team-dot.blocked { background: var(--danger); }
.team-dot.review { background: var(--warn-ink); }
+20
View File
@@ -11,6 +11,7 @@ export type EventType =
| "tool_requested"
| "question_requested"
| "plan_proposed"
| "team_proposed"
| "tool_started"
| "tool_finished"
| "iteration_end"
@@ -83,6 +84,17 @@ export interface SessionInfo {
// "From Slack" group and the row's platform icon.
origin?: string;
origin_label?: string;
// Agent teams: {} / absent for plain sessions. Workers carry role/lead_session
// (+ a computed current-item line); leads carry role/team_id. Drives the sidebar's
// ONE expandable team entry (workers nest under their lead; plain rows never expand).
team?: {
role?: "lead" | "worker" | string;
team_id?: string;
lead_session?: string;
actor?: string;
current_item?: string;
status?: string;
};
}
// Attachments (images, PDFs, text files) sent with a user message.
@@ -144,6 +156,14 @@ export type Item =
plan: string;
resolved?: "approved" | "rejected";
}
| {
// The staffing gate (agent teams): a lead proposes its worker roster.
kind: "teamreq";
members: { persona: string; model?: string; reason?: string }[];
enable_chat?: boolean;
note?: string;
resolved?: "approved" | "rejected";
}
| {
// A live ask_user prompt (attended sessions answer inline; unattended ones route to the Inbox).
kind: "question";