mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-12 23:29:55 +00:00
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:
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -198,6 +198,10 @@ class PersonaRegistry:
|
|||||||
workspace=m.workspace,
|
workspace=m.workspace,
|
||||||
tools=list(m.tools),
|
tools=list(m.tools),
|
||||||
manifest=m,
|
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:
|
def _load_installed(self) -> None:
|
||||||
|
|||||||
@@ -1461,8 +1461,48 @@ class SessionManager:
|
|||||||
)
|
)
|
||||||
if role == "lead":
|
if role == "lead":
|
||||||
tools.append(self._steer_tool(session_id))
|
tools.append(self._steer_tool(session_id))
|
||||||
|
tools.append(self._team_options_tool())
|
||||||
return tools
|
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:
|
def _steer_tool(self, lead_session_id: str) -> Any:
|
||||||
"""The lead's downward steering verb. Text lands in the worker's session
|
"""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
|
attributed [Lead] — queued into a live turn, or a fresh background turn when
|
||||||
|
|||||||
@@ -629,6 +629,20 @@ export async function mockApi(page: import("@playwright/test").Page) {
|
|||||||
});
|
});
|
||||||
return; // suspended on the approval
|
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
|
// Agent teams (OPE-96): a decomposition turn — the plan was approved in
|
||||||
// conversation (plan-approval flow); the agent files the items and the
|
// conversation (plan-approval flow); the agent files the items and the
|
||||||
// board rail appears on the next board fetch.
|
// 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("assistant_message", { text: `Done via ${pendingTool} [decision=${msg.decision}]` });
|
||||||
}
|
}
|
||||||
send("turn_done");
|
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") {
|
} else if (msg.type === "tool_response") {
|
||||||
// Either way the turn continues — the point of the contract is that declining
|
// Either way the turn continues — the point of the contract is that declining
|
||||||
// degrades the report openly instead of dropping the check.
|
// degrades the report openly instead of dropping the check.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -76,6 +76,7 @@ import { ToolRequestCard } from "./components/ToolRequestCard";
|
|||||||
import { DirectoryRequestCard } from "./components/DirectoryRequestCard";
|
import { DirectoryRequestCard } from "./components/DirectoryRequestCard";
|
||||||
import { PlanCard } from "./components/PlanCard";
|
import { PlanCard } from "./components/PlanCard";
|
||||||
import { BoardOverlay } from "./components/BoardPanel";
|
import { BoardOverlay } from "./components/BoardPanel";
|
||||||
|
import { TeamRequestCard } from "./components/TeamRequestCard";
|
||||||
import { WorkspaceTrustPrompt } from "./components/WorkspaceTrustPrompt";
|
import { WorkspaceTrustPrompt } from "./components/WorkspaceTrustPrompt";
|
||||||
|
|
||||||
const newId = () =>
|
const newId = () =>
|
||||||
@@ -756,6 +757,19 @@ export function App() {
|
|||||||
if (unattendedRef.current) break;
|
if (unattendedRef.current) break;
|
||||||
setItems((p) => [...p, { kind: "planreq", plan: d.plan || "" }]);
|
setItems((p) => [...p, { kind: "planreq", plan: d.plan || "" }]);
|
||||||
break;
|
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":
|
case "question_requested":
|
||||||
// ask_user in an attended session — answered inline (not routed to the Inbox).
|
// ask_user in an attended session — answered inline (not routed to the Inbox).
|
||||||
setItems((p) => [
|
setItems((p) => [
|
||||||
@@ -1016,6 +1030,11 @@ export function App() {
|
|||||||
sessionRef.current?.respondPlan(approved, mode, feedback);
|
sessionRef.current?.respondPlan(approved, mode, feedback);
|
||||||
if (approved && mode) setMode(mode); // the server flips the live engine to this mode
|
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) => {
|
const respondDirectory = (granted: boolean, path?: string, writable?: boolean) => {
|
||||||
setItems((p) => resolveLastDirReq(p, granted ? "granted" : "denied"));
|
setItems((p) => resolveLastDirReq(p, granted ? "granted" : "denied"));
|
||||||
dropSessionInbox("directory");
|
dropSessionInbox("directory");
|
||||||
@@ -1389,6 +1408,7 @@ export function App() {
|
|||||||
const pendingDirReq = [...items].reverse().find((i) => i.kind === "dirreq" && !i.resolved);
|
const pendingDirReq = [...items].reverse().find((i) => i.kind === "dirreq" && !i.resolved);
|
||||||
const pendingToolReq = [...items].reverse().find((i) => i.kind === "toolreq" && !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 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);
|
const pendingQuestion = [...items].reverse().find((i) => i.kind === "question" && !i.resolved);
|
||||||
// Facts subtitle (§22): the session's FIXED facts, not controls — model (+ the
|
// 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;
|
// 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.
|
// parked in the Inbox and surfaced via the answer-in-context card below.
|
||||||
!unattended && pendingPlan?.kind === "planreq" ? (
|
!unattended && pendingPlan?.kind === "planreq" ? (
|
||||||
<PlanCard item={pendingPlan} onRespond={respondPlan} />
|
<PlanCard item={pendingPlan} onRespond={respondPlan} />
|
||||||
|
) : !unattended && pendingTeam?.kind === "teamreq" ? (
|
||||||
|
<TeamRequestCard item={pendingTeam} onRespond={respondTeam} />
|
||||||
) : !unattended && pendingToolReq?.kind === "toolreq" ? (
|
) : !unattended && pendingToolReq?.kind === "toolreq" ? (
|
||||||
<ToolRequestCard item={pendingToolReq} onRespond={respondTool} />
|
<ToolRequestCard item={pendingToolReq} onRespond={respondTool} />
|
||||||
) : !unattended && pendingDirReq?.kind === "dirreq" ? (
|
) : !unattended && pendingDirReq?.kind === "dirreq" ? (
|
||||||
@@ -2107,6 +2129,18 @@ function resolveLastPlan(items: Item[], resolved: "approved" | "rejected"): Item
|
|||||||
return copy;
|
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[] {
|
function resolveLastQuestion(items: Item[], answer: string): Item[] {
|
||||||
const copy = [...items];
|
const copy = [...items];
|
||||||
for (let i = copy.length - 1; i >= 0; i--) {
|
for (let i = copy.length - 1; i >= 0; i--) {
|
||||||
|
|||||||
@@ -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).
|
// Answer a live `ask_user` prompt (attended sessions; unattended ones answer via the Inbox).
|
||||||
respondQuestion(answer: string) {
|
respondQuestion(answer: string) {
|
||||||
this.send({ type: "question_response", answer });
|
this.send({ type: "question_response", answer });
|
||||||
|
|||||||
@@ -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
|
// 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
|
// 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).
|
// 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 mine = all.filter((s) => !s.archived && !s.pinned);
|
||||||
const archived = all.filter((s) => s.archived);
|
const archived = all.filter((s) => s.archived);
|
||||||
// Only PROJECT-SCOPED personas group sessions by project (git-bound Code, project-bound Ops).
|
// 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
|
// 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.
|
// (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]
|
const recentSessions = [...props.sessions]
|
||||||
.filter((s) => !s.archived && !s.session_id.startsWith("__") && !s.pinned)
|
.filter((s) => !s.archived && !s.session_id.startsWith("__") && !s.pinned)
|
||||||
|
.filter((s) => s.team?.role !== "worker")
|
||||||
.filter((s) => personaVisible(s.agent))
|
.filter((s) => personaVisible(s.agent))
|
||||||
.filter(matches)
|
.filter(matches)
|
||||||
.sort((a, b) => (b.updated_at || "").localeCompare(a.updated_at || ""));
|
.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) —
|
// 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
|
// 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
|
// 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}
|
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 ? (
|
{editing ? (
|
||||||
<input
|
<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"
|
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
|
{/* 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 ? (
|
{editing ? (
|
||||||
<input
|
<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"
|
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
|
// 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.
|
// (flat list AND accordion), so it's factored here for reuse.
|
||||||
const pinnedBand = () =>
|
const pinnedBand = () =>
|
||||||
@@ -661,7 +755,7 @@ export function Sidebar(props: Props) {
|
|||||||
Pinned
|
Pinned
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{pinnedSessions.map((s) => cardRow(s))}
|
{pinnedSessions.map((s) => teamAwareRow(s))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null;
|
) : null;
|
||||||
@@ -823,7 +917,12 @@ export function Sidebar(props: Props) {
|
|||||||
// persona from New Session, never orphan its conversations).
|
// persona from New Session, never orphan its conversations).
|
||||||
const agentsWithSessions = new Set(
|
const agentsWithSessions = new Set(
|
||||||
props.sessions
|
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),
|
.map((s) => s.agent),
|
||||||
);
|
);
|
||||||
const visibleSurfaces = (
|
const visibleSurfaces = (
|
||||||
@@ -916,7 +1015,7 @@ export function Sidebar(props: Props) {
|
|||||||
// pl-[19px] aligns each session's name under the folder NAME (folder icon
|
// 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.
|
// 15 + gap 6 + row px 6 − session px 8 = 19), per Rohit's clean-column ask.
|
||||||
<div className="space-y-0.5 pl-[19px]">
|
<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 && (
|
{!showAll && list.length > peek && (
|
||||||
<button
|
<button
|
||||||
className="px-2 py-1 text-[12px] text-faint hover:text-muted"
|
className="px-2 py-1 text-[12px] text-faint hover:text-muted"
|
||||||
@@ -947,7 +1046,7 @@ export function Sidebar(props: Props) {
|
|||||||
{(personaShowAll.has(browseKey)
|
{(personaShowAll.has(browseKey)
|
||||||
? mine.filter(matches)
|
? mine.filter(matches)
|
||||||
: mine.filter(matches).slice(0, peek)
|
: mine.filter(matches).slice(0, peek)
|
||||||
).map((s) => sessionRow(s))}
|
).map((s) => withTeamChildren(s, sessionRow(s)))}
|
||||||
{!personaShowAll.has(browseKey) && mine.filter(matches).length > peek && (
|
{!personaShowAll.has(browseKey) && mine.filter(matches).length > peek && (
|
||||||
<button
|
<button
|
||||||
className="px-2 py-1 text-[12px] text-faint hover:text-muted"
|
className="px-2 py-1 text-[12px] text-faint hover:text-muted"
|
||||||
@@ -1106,7 +1205,7 @@ export function Sidebar(props: Props) {
|
|||||||
{(recentExpanded
|
{(recentExpanded
|
||||||
? recentSessions
|
? recentSessions
|
||||||
: recentSessions.slice(0, RECENT_PEEK)
|
: recentSessions.slice(0, RECENT_PEEK)
|
||||||
).map((s) => cardRow(s))}
|
).map((s) => teamAwareRow(s))}
|
||||||
{recentSessions.length > RECENT_PEEK && (
|
{recentSessions.length > RECENT_PEEK && (
|
||||||
<button
|
<button
|
||||||
className="w-full text-left px-2 py-1.5 text-[12px] text-muted hover:text-ink"
|
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 & 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 & start
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1714,3 +1714,23 @@ html[data-platform="linux"] ::-webkit-scrollbar-thumb:hover { background-color:
|
|||||||
.journal-count { font-size: 11px; color: var(--faint); }
|
.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); }
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export type EventType =
|
|||||||
| "tool_requested"
|
| "tool_requested"
|
||||||
| "question_requested"
|
| "question_requested"
|
||||||
| "plan_proposed"
|
| "plan_proposed"
|
||||||
|
| "team_proposed"
|
||||||
| "tool_started"
|
| "tool_started"
|
||||||
| "tool_finished"
|
| "tool_finished"
|
||||||
| "iteration_end"
|
| "iteration_end"
|
||||||
@@ -83,6 +84,17 @@ export interface SessionInfo {
|
|||||||
// "From Slack" group and the row's platform icon.
|
// "From Slack" group and the row's platform icon.
|
||||||
origin?: string;
|
origin?: string;
|
||||||
origin_label?: 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.
|
// Attachments (images, PDFs, text files) sent with a user message.
|
||||||
@@ -144,6 +156,14 @@ export type Item =
|
|||||||
plan: string;
|
plan: string;
|
||||||
resolved?: "approved" | "rejected";
|
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).
|
// A live ask_user prompt (attended sessions answer inline; unattended ones route to the Inbox).
|
||||||
kind: "question";
|
kind: "question";
|
||||||
|
|||||||
@@ -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
|
# 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.
|
# stays default-hidden via the surfaced axis. Installed personas remain opt-in.
|
||||||
assert ids[0] == "cowork"
|
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
|
assert sidebar[0]["default"] is True
|
||||||
# An explicit disable removes a builtin from the picker.
|
# An explicit disable removes a builtin from the picker.
|
||||||
reg.set_enabled("code", False)
|
reg.set_enabled("code", False)
|
||||||
|
|||||||
@@ -65,7 +65,11 @@ def test_agents_and_memory_rest(tmp_path):
|
|||||||
# ship in the picker out of the box.
|
# ship in the picker out of the box.
|
||||||
names = [a["name"] for a in agents]
|
names = [a["name"] for a in agents]
|
||||||
assert names[0] == "cowork"
|
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)
|
assert "skills" in client.get("/v1/skills").json() # catalog (may be empty)
|
||||||
|
|
||||||
added = client.post("/v1/memory", json={"content": "prefer pathlib"}).json()
|
added = client.post("/v1/memory", json={"content": "prefer pathlib"}).json()
|
||||||
|
|||||||
@@ -223,3 +223,11 @@ def test_staleness_digest_is_role_scoped(manager, monkeypatch):
|
|||||||
assert "1 open" in digest
|
assert "1 open" in digest
|
||||||
assert "no assignee" in digest
|
assert "no assignee" in digest
|
||||||
_ = item
|
_ = 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
|
||||||
|
|||||||
Reference in New Issue
Block a user