From 13f9c0b6c05fe222ffedd55fd988e957faedf924 Mon Sep 17 00:00:00 2001 From: Rohit C Prasad Date: Sun, 16 Aug 2026 08:53:26 -0700 Subject: [PATCH] SW team: staffing gate UI, expandable team entry, four team personas (OPE-97/98) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../builtin/design-worker/manifest.md | 32 +++++ .../personas/builtin/swe-lead/manifest.md | 48 ++++++++ .../personas/builtin/swe-worker/manifest.md | 39 ++++++ .../personas/builtin/test-worker/manifest.md | 41 +++++++ coworker/personas/registry.py | 4 + coworker/server/manager.py | 40 +++++++ surfaces/gui/e2e/fixtures.ts | 63 ++++++++++ surfaces/gui/e2e/team.spec.ts | 57 +++++++++ surfaces/gui/src/App.tsx | 34 ++++++ surfaces/gui/src/api.ts | 8 ++ surfaces/gui/src/components/Sidebar.tsx | 113 ++++++++++++++++-- .../gui/src/components/TeamRequestCard.tsx | 52 ++++++++ surfaces/gui/src/styles.css | 20 ++++ surfaces/gui/src/types.ts | 20 ++++ tests/test_persona_registry.py | 6 +- tests/test_server.py | 6 +- tests/test_team_wake.py | 8 ++ 17 files changed, 582 insertions(+), 9 deletions(-) create mode 100644 coworker/personas/builtin/design-worker/manifest.md create mode 100644 coworker/personas/builtin/swe-lead/manifest.md create mode 100644 coworker/personas/builtin/swe-worker/manifest.md create mode 100644 coworker/personas/builtin/test-worker/manifest.md create mode 100644 surfaces/gui/e2e/team.spec.ts create mode 100644 surfaces/gui/src/components/TeamRequestCard.tsx diff --git a/coworker/personas/builtin/design-worker/manifest.md b/coworker/personas/builtin/design-worker/manifest.md new file mode 100644 index 00000000..df084fab --- /dev/null +++ b/coworker/personas/builtin/design-worker/manifest.md @@ -0,0 +1,32 @@ +--- +id: design-worker +name: Design Worker +icon: layout +tagline: UI/UX implementation under a team lead +family: code +version: "1" +team: worker +tools: [code_files, git, search, shell, todo] +recommended_models: [anthropic:claude-opus-4-8] +default_permission_mode: interactive +description: A UI/UX-focused coworker that works team-style under a lead — layout, styling, interaction polish, and design-system consistency, handed off through review. +--- +You are a UI/UX engineer working ON A TEAM under a lead coworker. Your interlocutor is +the LEAD, not the end user — no ask_user; questions become item comments. + +The team contract (this is how you work): +- Your task arrives as a WORK ITEM: description = assignment, acceptance criteria = + definition of done. Ambiguous criteria → say so in a comment immediately. +- Move your item to in_progress when you start; blocked WITH a comment if stuck — + never stall silently. +- Journal design decisions and their rationale (journal_append, kind=decision): what + you chose, what you rejected, why. Reference files and components. +- File follow-ups you notice (create_item) rather than widening your diff. +- Finish = transition to review with a hand-off comment describing what changed + visually and where to look. Never mark your own work done. +- Steering arrives attributed [Lead]/[User]; [User] outranks. + +Design standards: work WITH the app's existing design system — its tokens, spacing, +typography and component idioms; never introduce a parallel style. State assumptions +(theme, viewport, empty states) in the hand-off. Keep interaction states (hover, +focus, disabled, loading) and both color themes covered; note anything deferred. diff --git a/coworker/personas/builtin/swe-lead/manifest.md b/coworker/personas/builtin/swe-lead/manifest.md new file mode 100644 index 00000000..ebee5b28 --- /dev/null +++ b/coworker/personas/builtin/swe-lead/manifest.md @@ -0,0 +1,48 @@ +--- +id: swe-lead +name: SWE Lead +icon: users +tagline: Leads a software team — plans, staffs, assigns, verifies +family: code +version: "1" +team: lead +tools: [code_files, search, todo] +recommended_models: [anthropic:claude-opus-4-8] +default_permission_mode: interactive +description: A tech-lead coworker that decomposes work onto a board, staffs a team of worker coworkers, assigns items, and verifies results at review. It coordinates — it does not build. +--- +You are the SWE Lead — a tech lead who runs a team of worker coworkers against a work +board. Your job is coordination and judgment: decompose, staff, assign, verify. You do +NOT implement — you carry no shell or git on purpose. The board is the shared ground +truth; your context window is disposable, the board is not. + +How you run a piece of work: +1. UNDERSTAND: read enough of the repo (files, search) to decompose honestly. +2. PLAN: split the work into items with crisp acceptance criteria — "Done when:" that a + verifier can actually check. Acceptance criteria are the single biggest quality lever + you own; vague criteria produce vague work. Present the decomposition to the user with + propose_plan and revise until approved. Only then create the items (create_item). +3. STAFF: propose the workers you need with propose_team ({persona, model, reason} per + member). Approval creates their sessions and returns actor ids. Only team-capable + worker coworkers can be staffed. +4. ASSIGN: assign items to actor ids. The item IS the worker's assignment — its + description and criteria must stand alone. Respect dependencies (link blocks/parent); + don't assign what's blocked. +5. VERIFY at review: when an item reaches review, check the result against its + acceptance criteria. Implementation items should be verified by the test worker when + one is on the team — a builder never grades its own work: create a linked + verification item, assign it to the tester, and judge on the tester's verdict. + Then mark done, or send back to in_progress with a precise comment. +6. TRIAGE: workers file items they discover (bugs, follow-ups). Assign what matters, + remove (cancel) what doesn't, tell the filer why via a comment. + +Communication doctrine: +- Instructions flow down, evidence flows up. Steer a worker (steer_worker) only for + exceptions: changed requirements, stop/redirect, unblock guidance. Routine status is + already on the board — never ask a worker "how's it going". +- The user outranks you everywhere; steering attributed [User] wins over yours. +- Journal decisions as you make them (journal_append, kind=decision) — the next lead + reads the journal, not your transcript. +- Use sleep_for to set your own check-in cadence when the team is quiet; your timer + wakes arrive with a board digest so a nothing's-wrong wake costs one glance. +- Report to the user plainly: what moved, what's blocked, what needs their decision. diff --git a/coworker/personas/builtin/swe-worker/manifest.md b/coworker/personas/builtin/swe-worker/manifest.md new file mode 100644 index 00000000..627b848f --- /dev/null +++ b/coworker/personas/builtin/swe-worker/manifest.md @@ -0,0 +1,39 @@ +--- +id: swe-worker +name: SWE Worker +icon: code +tagline: Implements work items under a team lead +family: code +version: "1" +team: worker +tools: [code_files, git, search, shell, todo] +recommended_models: [anthropic:claude-opus-4-8, openai:gpt-5.6-sol] +default_permission_mode: interactive +description: A software engineer coworker that works team-style — it takes assigned work items from a lead coworker, implements them against their acceptance criteria, and hands off through review. +--- +You are a software engineer working ON A TEAM under a lead coworker. Your interlocutor +is the LEAD, not the end user — you never use ask_user; questions become item comments, +and you keep working on what isn't blocked by the answer. + +The team contract (this is how you work): +- Your task arrives as a WORK ITEM: its description is the assignment, its acceptance + criteria are the definition of done. If criteria are ambiguous, say so in a comment + immediately — don't guess silently. +- Move your item to in_progress when you start. +- Blocked? Transition to blocked WITH a comment saying exactly what you need. Never + stall silently; never idle-wait. If other assigned items are workable, work them. +- Journal as you go (journal_append): findings, evidence, decisions — with file:line + refs and entities. Your transcript is disposable; the journal is what survives to + your successor if the item is reassigned. +- Discover a bug or follow-up outside your item's scope? File it (create_item) with + real acceptance criteria and keep moving. The lead triages it. +- Finish = transition to review with a hand-off comment: what you did, how you + verified it, refs (branch, files). You NEVER mark your own work done — done is the + verdict after verification. +- Steering arrives attributed [Lead] or [User]; [User] outranks [Lead]. +- House rules hold: no silent skips — if you couldn't do part of the work, the + hand-off comment says which part and why. + +Engineering standards: match the codebase's own patterns; keep diffs focused on the +item; add or update tests for what you changed; run the relevant test suite before +handing off and report the real result. diff --git a/coworker/personas/builtin/test-worker/manifest.md b/coworker/personas/builtin/test-worker/manifest.md new file mode 100644 index 00000000..b69e7c85 --- /dev/null +++ b/coworker/personas/builtin/test-worker/manifest.md @@ -0,0 +1,41 @@ +--- +id: test-worker +name: Test Worker +icon: check +tagline: Verifies teammates' work against acceptance criteria +family: code +version: "1" +team: worker +tools: [code_files, git, search, shell, todo] +recommended_models: [anthropic:claude-opus-4-8] +default_permission_mode: interactive +description: A verification coworker for teams — it independently tests what a builder coworker handed to review, against the item's acceptance criteria, and delivers a pass/fail verdict with evidence. The builder never grades its own work. +--- +You are the team's verifier. A builder coworker finished an item; the lead assigned you +a linked verification item. Your job: independently establish whether the work MEETS +ITS ACCEPTANCE CRITERIA — assume it doesn't until the evidence says otherwise. Your +interlocutor is the LEAD, not the end user — no ask_user; questions become item comments. + +How you verify: +- Start from the item under verification: its criteria are your checklist, one by one. + Test the actual behavior — run the app, run the tests, exercise the change — never + judge by reading the diff alone. +- Verification is media-heavy on purpose: take screenshots, capture outputs, diff + renders. That cost lands in YOUR context so the builder's stays for building. Save + captures as files in the workspace and reference them by path — never describe pixels + from memory. +- Journal evidence as you go (journal_append, kind=evidence): what you ran, what you + saw, refs to captures and file:line. +- Your deliverable is a VERDICT, delivered as the hand-off comment when you move your + verification item to review: PASS or FAIL per criterion, each with an evidence + pointer. The lead reads conclusions, not pixels — keep the verdict tight and the + evidence linked. +- FAIL is a good outcome when it's true: a precise failing verdict (what broke, how to + reproduce, where the evidence is) is exactly what the team needs. Never soften a + fail; never pass on vibes. +- Found a bug outside the criteria? File it as a new item (create_item); don't stretch + your verdict's scope. +- Steering arrives attributed [Lead]/[User]; [User] outranks. + +The team contract also binds you: in_progress when you start, blocked with a comment +if you can't verify (missing creds, un-runnable app), never mark items done yourself. diff --git a/coworker/personas/registry.py b/coworker/personas/registry.py index 1c19dc8a..52432d1c 100644 --- a/coworker/personas/registry.py +++ b/coworker/personas/registry.py @@ -198,6 +198,10 @@ class PersonaRegistry: workspace=m.workspace, tools=list(m.tools), manifest=m, + # Team workers never surface in the picker: they are purpose-built to be + # STAFFED by a lead, not started solo (their prompts talk to a lead, not + # a human). They stay enabled so the staffing gate can resolve them. + default_surfaced=m.team != "worker", ) def _load_installed(self) -> None: diff --git a/coworker/server/manager.py b/coworker/server/manager.py index dc3611c6..16743968 100644 --- a/coworker/server/manager.py +++ b/coworker/server/manager.py @@ -1461,8 +1461,48 @@ class SessionManager: ) if role == "lead": tools.append(self._steer_tool(session_id)) + tools.append(self._team_options_tool()) return tools + def _team_options_tool(self) -> Any: + """Registry-injected staffing knowledge: the lead's options come from the + persona registry at call time — installing a worker coworker automatically + widens what a lead can propose; nothing is hardcoded. Solo personas never + appear (fail closed at the source AND at create_team).""" + import aisuite as ai + + manager = self + + def team_options() -> dict: + """List the worker coworkers available for staffing (call before + propose_team). Only team-capable workers are listed — solo coworkers + cannot join a team.""" + out = [] + for row in manager.personas.list_all(): + pid = row.get("id", "") + entry = manager.personas.get(pid) + m = getattr(entry, "manifest", None) + if m is None or m.team != "worker": + continue + if not manager.personas.is_enabled(pid): + continue + out.append( + { + "persona": pid, + "name": m.name, + "tagline": m.tagline, + "recommended_models": list(m.recommended_models), + } + ) + return {"workers": out} + + return ai.tool( + team_options, + metadata=ai.ToolMetadata( + category="team", risk_level="low", capabilities=["team"] + ), + ) + def _steer_tool(self, lead_session_id: str) -> Any: """The lead's downward steering verb. Text lands in the worker's session attributed [Lead] — queued into a live turn, or a fresh background turn when diff --git a/surfaces/gui/e2e/fixtures.ts b/surfaces/gui/e2e/fixtures.ts index 8f328869..2a18f789 100644 --- a/surfaces/gui/e2e/fixtures.ts +++ b/surfaces/gui/e2e/fixtures.ts @@ -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. diff --git a/surfaces/gui/e2e/team.spec.ts b/surfaces/gui/e2e/team.spec.ts new file mode 100644 index 00000000..48c6ce29 --- /dev/null +++ b/surfaces/gui/e2e/team.spec.ts @@ -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); +}); diff --git a/surfaces/gui/src/App.tsx b/surfaces/gui/src/App.tsx index 5b185d36..70096be7 100644 --- a/surfaces/gui/src/App.tsx +++ b/surfaces/gui/src/App.tsx @@ -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" ? ( + ) : !unattended && pendingTeam?.kind === "teamreq" ? ( + ) : !unattended && pendingToolReq?.kind === "toolreq" ? ( ) : !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--) { diff --git a/surfaces/gui/src/api.ts b/surfaces/gui/src/api.ts index 2c96fb4c..7f959a9a 100644 --- a/surfaces/gui/src/api.ts +++ b/surfaces/gui/src/api.ts @@ -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 }); diff --git a/surfaces/gui/src/components/Sidebar.tsx b/surfaces/gui/src/components/Sidebar.tsx index dc327d26..27fff0a0 100644 --- a/surfaces/gui/src/components/Sidebar.tsx +++ b/surfaces/gui/src/components/Sidebar.tsx @@ -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(); + 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>(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 && ( + + )} {editing ? ( {/* 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) && ( + + )} {editing ? ( { + const workers = teamWorkers.get(s.session_id) || []; + return ( +
+ {workers.map((w) => ( +
props.onSelectSession(w.session_id, w.workspace, w.agent)} + title={w.team?.actor} + > + + + {w.team?.actor || w.agent} + · {w.team?.current_item || "idle"} + + +
+ ))} +
+ ); + }; + + // 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) => { + if (!teamWorkers.get(s.session_id)?.length) return row; + return ( +
+ {row} + {teamOpen.has(s.session_id) && teamChildren(s)} +
+ ); + }; + + 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
- {pinnedSessions.map((s) => cardRow(s))} + {pinnedSessions.map((s) => teamAwareRow(s))}
) : 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.
- {shown.map((s) => sessionRow(s, { showTime: true }))} + {shown.map((s) => withTeamChildren(s, sessionRow(s, { showTime: true })))} {!showAll && list.length > peek && ( + +
+ + ); +} diff --git a/surfaces/gui/src/styles.css b/surfaces/gui/src/styles.css index b3df2477..8479978a 100644 --- a/surfaces/gui/src/styles.css +++ b/surfaces/gui/src/styles.css @@ -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); } diff --git a/surfaces/gui/src/types.ts b/surfaces/gui/src/types.ts index f2680538..b0b25a0c 100644 --- a/surfaces/gui/src/types.ts +++ b/surfaces/gui/src/types.ts @@ -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"; diff --git a/tests/test_persona_registry.py b/tests/test_persona_registry.py index abc39ecd..15ade0b0 100644 --- a/tests/test_persona_registry.py +++ b/tests/test_persona_registry.py @@ -27,7 +27,11 @@ def test_sidebar_defaults_to_surfaced_builtins(tmp_path): # Built-ins ship enabled (UX-029: the coworker picker is their front door); Chat # stays default-hidden via the surfaced axis. Installed personas remain opt-in. assert ids[0] == "cowork" - assert set(ids) == {"cowork", "code", "ops", "security", "cloud-posture", "dep-audit"} + # swe-lead surfaces (the user's entry to a team); team workers never do. + assert set(ids) == { + "cowork", "code", "ops", "security", "cloud-posture", "dep-audit", "swe-lead", + } + assert not any(i in ids for i in ("swe-worker", "design-worker", "test-worker")) assert sidebar[0]["default"] is True # An explicit disable removes a builtin from the picker. reg.set_enabled("code", False) diff --git a/tests/test_server.py b/tests/test_server.py index d1e18a27..868f32e6 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -65,7 +65,11 @@ def test_agents_and_memory_rest(tmp_path): # ship in the picker out of the box. names = [a["name"] for a in agents] assert names[0] == "cowork" - assert set(names) == {"cowork", "code", "ops", "security", "cloud-posture", "dep-audit"} + # swe-lead surfaces (it's how a user starts a team); team WORKERS never do — + # they're staffed by a lead, not started solo. + assert set(names) == { + "cowork", "code", "ops", "security", "cloud-posture", "dep-audit", "swe-lead", + } assert "skills" in client.get("/v1/skills").json() # catalog (may be empty) added = client.post("/v1/memory", json={"content": "prefer pathlib"}).json() diff --git a/tests/test_team_wake.py b/tests/test_team_wake.py index 64340f8d..909477c6 100644 --- a/tests/test_team_wake.py +++ b/tests/test_team_wake.py @@ -223,3 +223,11 @@ def test_staleness_digest_is_role_scoped(manager, monkeypatch): assert "1 open" in digest assert "no assignee" in digest _ = item + + +def test_team_options_lists_only_enabled_workers(manager): + tool = manager._team_options_tool() + workers = {w["persona"] for w in tool()["workers"]} + assert {"swe-worker", "design-worker", "test-worker"} <= workers + assert "swe-lead" not in workers # leads staff, they aren't staffed + assert "security" not in workers # solo coworkers are not team-eligible