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 (
+
+ );
+ };
+
+ // 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 (
+