From ce3353dab3e10461d9cd3912d9360963d3ed6f40 Mon Sep 17 00:00:00 2001 From: Rohit C Prasad Date: Mon, 17 Aug 2026 18:44:39 -0700 Subject: [PATCH] =?UTF-8?q?GUI:=20right-drawer=20restructure=20=E2=80=94?= =?UTF-8?q?=20Team=20panel,=20three=20primaries=20+=20More,=20board=20chip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Team members move from sidebar to a drawer panel; RECENT keeps one entry per team. All sections collapsed by default; Journal/Access fold behind More; leads drop Progress. Lead mentions the board once via a [.](board:) chip that opens the drawer. --- .../personas/builtin/swe-lead/manifest.md | 6 +- surfaces/gui/e2e/access-section.spec.ts | 7 +- surfaces/gui/e2e/artifacts.spec.ts | 2 + surfaces/gui/e2e/board.spec.ts | 27 ++- surfaces/gui/e2e/cloud-status-pending.spec.ts | 1 + surfaces/gui/e2e/fixtures.ts | 40 ++-- surfaces/gui/e2e/roots.spec.ts | 1 + surfaces/gui/e2e/slack-directory.spec.ts | 2 + surfaces/gui/e2e/sources-channels.spec.ts | 3 + surfaces/gui/e2e/team.spec.ts | 70 ++++--- surfaces/gui/src/App.tsx | 26 ++- surfaces/gui/src/components/Markdown.test.tsx | 18 +- surfaces/gui/src/components/Markdown.tsx | 31 ++- surfaces/gui/src/components/RightRail.tsx | 194 ++++++++++++++---- surfaces/gui/src/components/Sidebar.tsx | 113 +--------- surfaces/gui/src/styles.css | 25 +++ 16 files changed, 366 insertions(+), 200 deletions(-) diff --git a/coworker/personas/builtin/swe-lead/manifest.md b/coworker/personas/builtin/swe-lead/manifest.md index b1ce0616..f1fb994e 100644 --- a/coworker/personas/builtin/swe-lead/manifest.md +++ b/coworker/personas/builtin/swe-lead/manifest.md @@ -29,7 +29,11 @@ How you run a piece of work: it cannot pass/fail an essay. Present the decomposition with propose_work_items (works in any mode; approval creates the items on the board and returns their ids) and revise until the user approves. Use create_item only for - one-off additions after the plan is approved. + one-off additions after the plan is approved. Right after the items are created, + mention the board ONCE in your reply with a chip link — e.g. + "I've filed 5 items — [Board · 5 items](board:) if you want to watch." — then never + link it again; the side panel is the user's pull view, your conversation is the + push channel. 3. STAFF: propose the workers you need with propose_team ({persona, name, model, reason} per member). Give each a short callname (e.g. "nia", "webb", "checks") — it becomes their handle for assignment and @mentions, and lets you staff two of diff --git a/surfaces/gui/e2e/access-section.spec.ts b/surfaces/gui/e2e/access-section.spec.ts index 24f0bbaf..ef95c5f9 100644 --- a/surfaces/gui/e2e/access-section.spec.ts +++ b/surfaces/gui/e2e/access-section.spec.ts @@ -17,8 +17,9 @@ test("no topbar opener; the Access header IS the ambient glance; expanding edits await expect(page.getByRole("button", { name: "Open session settings" })).toHaveCount(0); await expect(page.getByTestId("session-settings-row")).toHaveCount(0); - // The trust surface is ambient: the collapsed header always shows the summary — and no - // nudge text ever renders at rest (§23's rule carried over). + // The trust surface is ambient once More is unfolded: the collapsed header always shows + // the summary — and no nudge text ever renders at rest (§23's rule carried over). + await page.getByTestId("rail-more-toggle").click(); const section = page.getByTestId("access-section"); await expect(section.getByTestId("access-summary")).toHaveText("Browser, Slack +1 · 1 folder"); await expect(section.getByText(/recommended/i)).toHaveCount(0); @@ -44,6 +45,7 @@ test("+ Add a source: full catalog on focus, filter as you type → connect-in-c }) => { await page.goto("/"); await page.getByText("Draft the launch note").first().click(); + await page.getByTestId("rail-more-toggle").click(); await page.getByTestId("access-toggle").click(); // Focusing the empty input shows the FULL catalog (FB-012) — every available connector @@ -86,6 +88,7 @@ test("per-session mute round-trips; the summary follows", async ({ page }) => { await page.goto("/"); await page.getByText("Draft the launch note").first().click(); + await page.getByTestId("rail-more-toggle").click(); const section = page.getByTestId("access-section"); await section.getByTestId("access-toggle").click(); const body = page.getByRole("region", { name: "Session access" }); diff --git a/surfaces/gui/e2e/artifacts.spec.ts b/surfaces/gui/e2e/artifacts.spec.ts index ebd18224..747195f2 100644 --- a/surfaces/gui/e2e/artifacts.spec.ts +++ b/surfaces/gui/e2e/artifacts.spec.ts @@ -10,6 +10,8 @@ async function openReport(page: import("@playwright/test").Page) { await page.goto("/"); await page.getByPlaceholder(/Ask the coworker/).fill("hello"); await page.getByRole("button", { name: "Send" }).click(); + // Seventeenth pass: sections start collapsed — expand Artifacts to reach the list. + await page.getByTestId("rail-toggle-artifacts").click(); await page.locator(".artifact-row", { hasText: "security-review.html" }).click(); } diff --git a/surfaces/gui/e2e/board.spec.ts b/surfaces/gui/e2e/board.spec.ts index 72d7d2d8..1aadfc60 100644 --- a/surfaces/gui/e2e/board.spec.ts +++ b/surfaces/gui/e2e/board.spec.ts @@ -15,27 +15,36 @@ async function planTheWork(page: import("@playwright/test").Page) { await expect(page.getByText(/filed 5 work items/)).toBeVisible(); } +// Seventeenth pass: every drawer section starts collapsed — expanding the Board +// section is now an explicit step wherever a test reads the rail's rows. +async function openBoardSection(page: import("@playwright/test").Page) { + await page.getByTestId("rail-toggle-board").click(); + await expect(page.getByTestId("board-rail")).toBeVisible(); +} + test("plain sessions carry zero board chrome", async ({ page }) => { await page.goto("/"); await page.getByPlaceholder(/Ask the coworker/).fill("hello"); await page.getByRole("button", { name: "Send" }).click(); await expect(page.getByText("Echo: hello")).toBeVisible(); await expect(page.getByTestId("board-rail")).toHaveCount(0); + await expect(page.getByTestId("rail-toggle-board")).toHaveCount(0); }); test("filed items appear grouped in the rail, blocked on top, queued items listed", async ({ page, }) => { await planTheWork(page); + // collapsed by default: the header chip is the maximum signal + await expect(page.getByTestId("board-rail")).toHaveCount(0); + await expect(page.getByTestId("rail-toggle-board")).toContainText("1 blocked · 1 review"); + await openBoardSection(page); const rail = page.getByTestId("board-rail"); await expect(rail).toBeVisible(); const groups = rail.locator(".board-group"); await expect(groups.first()).toHaveText("Blocked"); await expect(rail).toContainText("Queued"); await expect(rail.getByText("Secrets — git history, both repos")).toBeVisible(); - await expect( - page.getByRole("button", { name: /Board · 1 blocked · 1 review · 1 in progress · 2 open/ }), - ).toBeVisible(); }); test("the overlay lists raw-state sections; verdicts flow through the detail pane", async ({ @@ -72,6 +81,7 @@ test("the overlay lists raw-state sections; verdicts flow through the detail pan test("finished items leave the rail; a quiet toggle reveals them", async ({ page }) => { await planTheWork(page); + await openBoardSection(page); const rail = page.getByTestId("board-rail"); await expect(rail.getByText("Report rollup")).toBeVisible(); // review = active await page.getByTestId("board-expand").click(); @@ -92,6 +102,7 @@ test("item detail: timeline with attachment, worker link, request changes", asyn page, }) => { await planTheWork(page); + await openBoardSection(page); // a rail row deep-opens the overlay on that item's detail await page.getByTestId("board-rail").getByText("Report rollup").click(); const detail = page.getByTestId("board-detail"); @@ -122,6 +133,7 @@ test("Add a note is a pure append — it lands in the timeline, state untouched" page, }) => { await planTheWork(page); + await openBoardSection(page); await page.getByTestId("board-rail").getByText("Report rollup").click(); const detail = page.getByTestId("board-detail"); await expect(detail).toContainText("In review"); @@ -135,11 +147,16 @@ test("Add a note is a pure append — it lands in the timeline, state untouched" await expect(detail.getByRole("button", { name: "Mark done" })).toBeVisible(); }); -test("journal section lists cases once a board exists", async ({ page }) => { +test("journal folds behind More; expanding lists cases once a board exists", async ({ page }) => { await planTheWork(page); - await page.getByRole("button", { name: /Journal/ }).click(); + // Journal is not a primary section — it sits behind the quiet More row. + await expect(page.getByTestId("rail-toggle-journal")).toHaveCount(0); + await page.getByTestId("rail-more-toggle").click(); + await page.getByTestId("rail-toggle-journal").click(); const journal = page.getByTestId("journal-list"); await expect(journal).toBeVisible(); await expect(journal).toContainText("findings"); await expect(journal).toContainText("12 entries"); + // Access folds with it — the drawer keeps three primary sections. + await expect(page.getByTestId("access-section")).toBeVisible(); }); diff --git a/surfaces/gui/e2e/cloud-status-pending.spec.ts b/surfaces/gui/e2e/cloud-status-pending.spec.ts index 044fef37..f5298a8c 100644 --- a/surfaces/gui/e2e/cloud-status-pending.spec.ts +++ b/surfaces/gui/e2e/cloud-status-pending.spec.ts @@ -9,6 +9,7 @@ import { test } from "./fixtures"; const openGmailPane = async (page: import("@playwright/test").Page) => { await page.goto("/"); await page.getByText("Draft the launch note").first().click(); + await page.getByTestId("rail-more-toggle").click(); // Access folds behind More (17th pass) await page.getByTestId("access-toggle").click(); await page.getByTestId("access-add-source").click(); await page.getByTestId("access-add-gmail").click(); diff --git a/surfaces/gui/e2e/fixtures.ts b/surfaces/gui/e2e/fixtures.ts index a452a8cc..45740d17 100644 --- a/surfaces/gui/e2e/fixtures.ts +++ b/surfaces/gui/e2e/fixtures.ts @@ -618,6 +618,9 @@ export async function mockApi(page: import("@playwright/test").Page) { await page.routeWebSocket(/\/ws\/session\//, (ws) => { const send = (type: string, data: Record = {}) => ws.send(JSON.stringify({ type, data })); + // The page's session id, from the socket URL — team approval stamps THIS session + // as the lead (the active conversation IS the lead; workers hang off it). + const sid = ws.url().split("/ws/session/")[1]?.split("?")[0] || "sess-lead"; send("ready"); let pendingTool = "run_shell"; // which proposal the next approval decision resolves let epicTimer: ReturnType | null = null; // the slow stream, stoppable via interrupt @@ -905,7 +908,7 @@ export async function mockApi(page: import("@playwright/test").Page) { if (msg.approved) { seedBoard(); // "created on the board" — the board fetch now shows them send("assistant_message", { - text: "Items created on the board — staffing next.", + text: "Items created on the board — [Board · 5 items](board:) if you want to watch. Staffing next.", }); } else { send("assistant_message", { text: "Understood — reworking the split." }); @@ -913,22 +916,23 @@ export async function mockApi(page: import("@playwright/test").Page) { 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); + // Server-side create_team pre-spawned the workers. The ACTIVE session IS + // the lead (seventeenth pass): stamp it in the sessions list — RECENT keeps + // this ONE entry — and hang the workers off it for the drawer's Team panel. + let lead = sessions.find((s) => s.session_id === sid); + if (!lead) { + lead = { + session_id: sid, + workspace: "/Users/test/OpenWorker/launch-note", + agent: "cowork", + model: "m", + mode: "interactive", + messages: 2, + }; + sessions.unshift(lead); + } + lead.title = "Build the statements page"; + lead.updated_at = new Date().toISOString(); lead.team = { role: "lead", team_id: "t1", @@ -955,7 +959,7 @@ export async function mockApi(page: import("@playwright/test").Page) { team: { role: "worker", team_id: "t1", - lead_session: "sess-lead", + lead_session: sid, actor, status, current_item: item, diff --git a/surfaces/gui/e2e/roots.spec.ts b/surfaces/gui/e2e/roots.spec.ts index ee9ad25b..d30fed07 100644 --- a/surfaces/gui/e2e/roots.spec.ts +++ b/surfaces/gui/e2e/roots.spec.ts @@ -10,6 +10,7 @@ test("working directories: add folders with the read-only / read-write gate", as await page.getByText("Draft the launch note").first().click(); // Expand the rail's Access section. + await page.getByTestId("rail-more-toggle").click(); // Access folds behind More (17th pass) await page.getByTestId("access-toggle").click(); const dirs = page.getByTestId("drawer-directories"); await expect(dirs.getByText("Folders")).toBeVisible(); diff --git a/surfaces/gui/e2e/slack-directory.spec.ts b/surfaces/gui/e2e/slack-directory.spec.ts index 55d8295e..a8868e2d 100644 --- a/surfaces/gui/e2e/slack-directory.spec.ts +++ b/surfaces/gui/e2e/slack-directory.spec.ts @@ -50,6 +50,7 @@ test("channel typeahead: a NAME resolves to the workspace's id-address", async ( }) => { await page.goto("/"); await page.getByText("Draft the launch note").first().click(); + await page.getByTestId("rail-more-toggle").click(); // Access folds behind More (17th pass) await page.getByTestId("access-toggle").click(); await page.getByRole("button", { name: /Channels · 0/ }).click(); @@ -71,6 +72,7 @@ test("channel typeahead: a NAME resolves to the workspace's id-address", async ( test("channel typeahead: private and not-a-member states are honest", async ({ page }) => { await page.goto("/"); await page.getByText("Draft the launch note").first().click(); + await page.getByTestId("rail-more-toggle").click(); // Access folds behind More (17th pass) await page.getByTestId("access-toggle").click(); await page.getByRole("button", { name: /Channels · 0/ }).click(); diff --git a/surfaces/gui/e2e/sources-channels.spec.ts b/surfaces/gui/e2e/sources-channels.spec.ts index 1668913d..7e59a272 100644 --- a/surfaces/gui/e2e/sources-channels.spec.ts +++ b/surfaces/gui/e2e/sources-channels.spec.ts @@ -8,6 +8,7 @@ test("Slack channels drill-down: gating, add (auto-prefixed), remove", async ({ // Open the pinned cowork session, then expand the rail's Access section. await page.getByText("Draft the launch note").first().click(); + await page.getByTestId("rail-more-toggle").click(); // Access folds behind More (17th pass) await page.getByTestId("access-toggle").click(); const body = page.getByRole("region", { name: "Session access" }); @@ -42,6 +43,7 @@ test("Slack channels drill-down: gating, add (auto-prefixed), remove", async ({ test("recent channels popover: opens on focus, filters, picks", async ({ page }) => { await page.goto("/"); await page.getByText("Draft the launch note").first().click(); + await page.getByTestId("rail-more-toggle").click(); // Access folds behind More (17th pass) await page.getByTestId("access-toggle").click(); await page.getByRole("button", { name: /Channels · 0/ }).click(); @@ -80,6 +82,7 @@ test("channel add: link URLs resolve, bare #names are rejected with a hint", asy }) => { await page.goto("/"); await page.getByText("Draft the launch note").first().click(); + await page.getByTestId("rail-more-toggle").click(); // Access folds behind More (17th pass) await page.getByTestId("access-toggle").click(); await page.getByRole("button", { name: /Channels · 0/ }).click(); diff --git a/surfaces/gui/e2e/team.spec.ts b/surfaces/gui/e2e/team.spec.ts index 18efa244..2c6d406a 100644 --- a/surfaces/gui/e2e/team.spec.ts +++ b/surfaces/gui/e2e/team.spec.ts @@ -1,7 +1,8 @@ -// 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 +// Agent teams (OPE-97): the staffing gate + the drawer's Team panel (seventeenth +// pass). 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. +// which surface in the right drawer's Team section — the sidebar keeps ONE entry +// per team (the lead), with no expansion. import { expect } from "@playwright/test"; import { test } from "./fixtures"; @@ -37,6 +38,10 @@ test("the decomposition gate shows items with criteria; approval lands them on t await page.getByTestId("itemsreq-approve").click(); await expect(page.getByText(/Items created on the board/)).toBeVisible(); + // Sections start collapsed (a count chip is the maximum signal) — but the lead's + // one-time [Board · N items](board:) chip expands the drawer's Board section. + await expect(page.getByTestId("board-rail")).toHaveCount(0); + await page.getByTestId("board-chip").click(); await expect(page.getByTestId("board-rail")).toBeVisible(); }); @@ -115,8 +120,10 @@ test("enabling chat at the gate adds the # team chat row; posting works with men await page.getByTestId("teamreq-approve").click(); await expect(page.getByText(/Team created/)).toBeVisible(); - await page.getByTestId("team-toggle-sess-lead").click(); - const chatRow = page.getByTestId("team-chat-row-sess-lead"); + // The chat row lives in the drawer's Team panel now (sessions poll: allow a cycle). + await expect(page.getByTestId("rail-toggle-team")).toBeVisible({ timeout: 12_000 }); + await page.getByTestId("rail-toggle-team").click(); + const chatRow = page.getByTestId("team-chat-row"); await expect(chatRow).toBeVisible(); await expect(chatRow).toContainText("1"); // unread badge @@ -139,7 +146,7 @@ test("a sleeping lead shows the strip; Ask for a status wakes it", async ({ page await page.getByTestId("teamreq-approve").click(); await expect(page.getByText(/Team created/)).toBeVisible(); // open the lead's session — it set a check-in timer, so it's sleeping - await page.getByText("Build the statements page").click(); + await page.locator(".sidebar").getByText("Build the statements page").click(); const strip = page.getByTestId("sleep-strip"); await expect(strip).toBeVisible({ timeout: 12_000 }); await expect(strip).toContainText("Sleeping until"); @@ -152,9 +159,10 @@ test("with chat declined at the gate, no chat row renders", async ({ page }) => await proposeTeam(page); await page.getByTestId("teamreq-approve").click(); await expect(page.getByText(/Team created/)).toBeVisible(); - await page.getByTestId("team-toggle-sess-lead").click(); - await expect(page.getByTestId("team-children-sess-lead")).toBeVisible(); - await expect(page.getByTestId("team-chat-row-sess-lead")).toHaveCount(0); + await expect(page.getByTestId("rail-toggle-team")).toBeVisible({ timeout: 12_000 }); + await page.getByTestId("rail-toggle-team").click(); + await expect(page.getByTestId("team-panel")).toBeVisible(); + await expect(page.getByTestId("team-chat-row")).toHaveCount(0); }); test("declining the roster returns the turn to the lead", async ({ page }) => { @@ -164,27 +172,41 @@ test("declining the roster returns the turn to the lead", async ({ page }) => { await expect(page.getByTestId("teamreq-card")).toHaveCount(0); }); -test("approval creates the team; workers nest under the lead's expandable entry", async ({ +test("approval creates the team; members live in the drawer, RECENT keeps one 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); + // The drawer grows a collapsed Team section with a member-count chip. + // (Sessions poll every 5s, so allow one full cycle.) + const teamToggle = page.getByTestId("rail-toggle-team"); + await expect(teamToggle).toBeVisible({ timeout: 12_000 }); + await expect(teamToggle).toContainText("3"); + await expect(page.getByTestId("team-panel")).toHaveCount(0); // collapsed by default - await page.getByTestId("team-toggle-sess-lead").click(); - const children = page.getByTestId("team-children-sess-lead"); - await expect(children).toBeVisible(); - await expect(children).toContainText("nia · #1 in progress"); - await expect(children).toContainText("webb · idle"); - await expect(children).toContainText("checks · #4 blocked"); + // The lead is the SESSION — Progress yields its slot (the board is the lead's + // progress surface). + await expect(page.getByTestId("rail-toggle-progress")).toHaveCount(0); - // 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); + // Workers never appear as top-level RECENT rows — one entry per team, no expansion. + const sidebar = page.locator(".sidebar"); + await expect(sidebar.getByText("Build the statements page")).toBeVisible(); + await expect(sidebar.getByText("nia", { exact: true })).toHaveCount(0); + await expect(sidebar.locator("[data-testid^=team-toggle-]")).toHaveCount(0); + + // Expanding the Team panel shows member rows: dot + callname + current item. + await teamToggle.click(); + const panel = page.getByTestId("team-panel"); + await expect(panel).toBeVisible(); + await expect(panel.getByTestId("team-row-nia")).toContainText("#1 in progress"); + await expect(panel.getByTestId("team-row-webb")).toContainText("idle"); + await expect(panel.getByTestId("team-row-checks")).toContainText("#4 blocked"); + + // A member row is the escape hatch — clicking opens that worker's session, where + // the drawer is a plain worker drawer again (Progress back, no Team panel). + await panel.getByTestId("team-row-nia").click(); + await expect(page.getByTestId("rail-toggle-progress")).toBeVisible(); + await expect(page.getByTestId("rail-toggle-team")).toHaveCount(0); }); diff --git a/surfaces/gui/src/App.tsx b/surfaces/gui/src/App.tsx index 5b1f81f4..43fe271d 100644 --- a/surfaces/gui/src/App.tsx +++ b/surfaces/gui/src/App.tsx @@ -347,6 +347,17 @@ export function App() { window.addEventListener("ocw-open-artifact", show); return () => window.removeEventListener("ocw-open-artifact", show); }, []); + // Seventeenth pass: the lead's one-time [Board · N items](board:) chip — un-hide the + // rail and bump the key that expands its Board section. + const [boardRailKey, setBoardRailKey] = useState(0); + useEffect(() => { + const show = () => { + setRailHidden(false); + setBoardRailKey((k) => k + 1); + }; + window.addEventListener("ocw-open-board", show); + return () => window.removeEventListener("ocw-open-board", show); + }, []); // The command-palette search, openable from the collapsed-sidebar topbar cluster (§22). The // expanded sidebar owns its own instance; this one exists so search never disappears with it. const [searchOpen, setSearchOpen] = useState(false); @@ -1004,6 +1015,13 @@ export function App() { await refreshBoard(); }; + // Seventeenth pass: the drawer's Team panel — this session's staff (workers whose + // lead is the current session). The sidebar shows ONE entry per team; members live here. + const curSession = sessions.find((s) => s.session_id === sessionId); + const teamMembers = sessions.filter( + (s) => s.team?.role === "worker" && s.team.lead_session === sessionId, + ); + // Keep the active session's pending Inbox items fresh (answer-in-context card). Loads on session // change + after each turn, plus a slow poll so an unattended agent's new question surfaces. useEffect(() => { @@ -1625,7 +1643,6 @@ export function App() { sessions={sessions} projects={projects} activeSession={sessionId} - onOpenTeamChat={(teamId) => setChatTeam(teamId)} onSwitchAgent={switchAgent} onNewSession={startNewSession} onSelectSession={selectSession} @@ -2067,6 +2084,13 @@ export function App() { setBoardDetailId(id); setBoardOpen(true); }} + isLead={teamMembers.length > 0 || (!!curSession?.team && curSession.team.role !== "worker")} + teamMembers={teamMembers} + teamChatEnabled={!!curSession?.team?.chat_enabled} + teamChatUnread={curSession?.team?.chat_unread || 0} + onOpenTeamChat={() => setChatTeam(curSession?.team?.team_id || "")} + onOpenWorker={(w) => void selectSession(w.session_id, w.workspace, w.agent)} + openBoardKey={boardRailKey} /> {boardOpen && board && board.space && ( { render(); expect(screen.getByTestId("artifact-chip").textContent).toContain("report.pdf"); }); + + // Seventeenth pass: the lead's one-time board mention — [Board · 5 items](board:) + // renders as an inline pill that opens the drawer on its Board section. + it("renders a board: link as a pill and dispatches the open-board event", () => { + let fired = 0; + const listener = () => fired++; + window.addEventListener(OPEN_BOARD_EVENT, listener); + + render(); + const chip = screen.getByTestId("board-chip"); + expect(chip.textContent).toContain("Board · 5 items"); + fireEvent.click(chip); + expect(fired).toBe(1); + + window.removeEventListener(OPEN_BOARD_EVENT, listener); + }); }); diff --git a/surfaces/gui/src/components/Markdown.tsx b/surfaces/gui/src/components/Markdown.tsx index 33670427..ea8efeb4 100644 --- a/surfaces/gui/src/components/Markdown.tsx +++ b/surfaces/gui/src/components/Markdown.tsx @@ -9,6 +9,25 @@ import { Icon } from "./Icon"; // the session's artifact list, App un-hides the rail. export const OPEN_ARTIFACT_EVENT = "ocw-open-artifact"; +// Seventeenth pass: the lead mentions the board ONCE — [Board · 5 items](board:) — and the +// chip opens the drawer on its Board section. Same event plumbing as artifact chips: App +// un-hides the rail and bumps the key that expands the section. +export const OPEN_BOARD_EVENT = "ocw-open-board"; + +function BoardChip({ label }: { label: string }) { + return ( + + ); +} + function ArtifactChip({ path, title }: { path: string; title: string }) { const file = path.split("/").pop() || path; return ( @@ -40,15 +59,21 @@ export function Markdown({ text }: { text: string }) {
(url.startsWith("artifact:") ? url : defaultUrlTransform(url))} + // artifact:/board: are ours — keep them through the sanitizer (everything else gets + // the default http/https/mailto policy). + urlTransform={(url) => + url.startsWith("artifact:") || url.startsWith("board:") ? url : defaultUrlTransform(url) + } components={{ a: ({ node: _n, href, children, ...props }) => { if (href?.startsWith("artifact:")) { const title = Array.isArray(children) ? children.join("") : String(children ?? ""); return ; } + if (href?.startsWith("board:")) { + const label = Array.isArray(children) ? children.join("") : String(children ?? ""); + return ; + } return ( {children} diff --git a/surfaces/gui/src/components/RightRail.tsx b/surfaces/gui/src/components/RightRail.tsx index f22759b7..0f1c1dd0 100644 --- a/surfaces/gui/src/components/RightRail.tsx +++ b/surfaces/gui/src/components/RightRail.tsx @@ -11,13 +11,13 @@ import { type Board, type JournalCase, } from "../api"; -import type { TodoItem } from "../types"; +import type { SessionInfo, TodoItem } from "../types"; import { AccessSection } from "./AccessSection"; -import { BoardSection, boardSummary } from "./BoardPanel"; +import { BoardSection } from "./BoardPanel"; import { Icon } from "./Icon"; import { Markdown, OPEN_ARTIFACT_EVENT } from "./Markdown"; -type Panel = "progress" | "artifacts" | "board" | "journal"; +type Panel = "progress" | "artifacts" | "board" | "journal" | "team"; // Quiet file-type icons for the artifact list (the colored kind pills read as noisy). function kindIcon(kind: string): "file" | "fileCode" | "image" | "table" { @@ -66,6 +66,17 @@ interface Props { board?: Board | null; onExpandBoard?: () => void; onOpenBoardItem?: (id: number) => void; + // Drawer restructure (seventeenth pass): the team lives HERE, not in the sidebar — + // member rows + the # team chat row. `isLead` also suppresses Progress (the board + // is the lead's progress surface). + isLead?: boolean; + teamMembers?: SessionInfo[]; + teamChatEnabled?: boolean; + teamChatUnread?: number; + onOpenTeamChat?: () => void; + onOpenWorker?: (s: SessionInfo) => void; + // Bumped when a [.](board:) chip in the transcript is clicked — expands the Board section. + openBoardKey?: number; } export function RightRail({ @@ -87,22 +98,47 @@ export function RightRail({ board, onExpandBoard, onOpenBoardItem, + isLead = false, + teamMembers = [], + teamChatEnabled = false, + teamChatUnread = 0, + onOpenTeamChat, + onOpenWorker, + openBoardKey = 0, }: Props) { - // Progress starts collapsed (owner call 2026-08-16 — rail space goes to the - // board/artifacts); it still auto-opens the first time a live turn has todos. + // Seventeenth pass: every panel starts collapsed and nothing auto-expands — a count + // chip is the maximum signal. One exception survives (solo sessions only): Progress + // still auto-opens the first time a live turn has todos. const [open, setOpen] = useState>({ progress: false, - artifacts: true, - board: true, + artifacts: false, + board: false, journal: false, + team: false, }); + // Journal + Access sit behind a quiet "More" row (three primary sections max). + const [moreOpen, setMoreOpen] = useState(false); const autoOpenedProgress = useRef(false); useEffect(() => { - if (running && todo.length > 0 && !autoOpenedProgress.current) { + if (!isLead && running && todo.length > 0 && !autoOpenedProgress.current) { autoOpenedProgress.current = true; setOpen((prev) => ({ ...prev, progress: true })); } - }, [running, todo.length]); + }, [running, todo.length, isLead]); + // A board chip in the transcript deep-links here: expand the Board section. + const seenBoardKey = useRef(openBoardKey); + useEffect(() => { + if (openBoardKey === seenBoardKey.current) return; + seenBoardKey.current = openBoardKey; + setOpen((prev) => ({ ...prev, board: true })); + }, [openBoardKey]); + // Access deep links (intro "Configure ›" etc.) must survive the More fold. + const seenAccessKey = useRef(openAccessKey); + useEffect(() => { + if (openAccessKey === seenAccessKey.current) return; + seenAccessKey.current = openAccessKey; + setMoreOpen(true); + }, [openAccessKey]); const [artifacts, setArtifacts] = useState([]); const [journal, setJournal] = useState([]); const [selected, setSelected] = useState(null); @@ -208,15 +244,20 @@ export function RightRail({ /> ) : ( <> - setOpen({ ...open, progress: !open.progress })}> - - + {/* Leads carry no Progress panel — the board IS the lead's progress surface. */} + {!isLead && ( + setOpen({ ...open, progress: !open.progress })}> + + + )} {/* Agent teams (OPE-96): board summary — grouped by state, blocked on top. Hidden entirely until the workspace has items (no chrome for plain sessions). */} {board?.space && ( setOpen({ ...open, board: !open.board })} action={ @@ -241,27 +282,46 @@ export function RightRail({ )} - {board?.space && journal.length > 0 && ( + {/* The team panel: who's working, on what, and the way into their sessions — + the altitude-3 escape hatch, moved here from the sidebar (RECENT keeps ONE + entry per team: the lead). */} + {teamMembers.length > 0 && ( setOpen({ ...open, journal: !open.journal })} + title="Team" + open={open.team} + onToggle={() => setOpen({ ...open, team: !open.team })} + count={String(teamMembers.length)} > -
- {journal.map((c) => ( -
- - {c.case} - {c.entries} entr{c.entries === 1 ? "y" : "ies"} -
+
+ {teamMembers.map((w) => ( + ))} + {teamChatEnabled && onOpenTeamChat && ( + + )}
)} {showArtifacts && ( setOpen({ ...open, artifacts: !open.artifacts })} action={ @@ -300,25 +360,70 @@ export function RightRail({ )} + {/* Seventeenth pass: three primary sections max — Journal and Access fold + behind a quiet "More" row. Deep links (Access openKey) unfold it. */} + + {moreOpen && board?.space && journal.length > 0 && ( + setOpen({ ...open, journal: !open.journal })} + > +
+ {journal.map((c) => ( +
+ + {c.case} + {c.entries} entr{c.entries === 1 ? "y" : "ies"} +
+ ))} +
+
+ )} {/* §32: Access — the former Session-settings drawer, one section among peers. - key: its data ownership resets with the conversation, like the old row did. */} - + key: its data ownership resets with the conversation, like the old row did. + Stays MOUNTED behind the More fold (hidden, not unmounted) so its openKey + deep links (intro "Configure ›", onboarding "Start working") keep firing. */} +
+ +
)} ); } +// The Board section's header chip: the attention states (blocked/review) when present, +// otherwise a quiet active count. Full per-state summary stays on the topbar button. +function boardChip(board: Board): { text: string; attention: boolean } { + const counts: Record = {}; + for (const item of board.items) counts[item.state] = (counts[item.state] || 0) + 1; + const attn: string[] = []; + if (counts.blocked) attn.push(`${counts.blocked} blocked`); + if (counts.review) attn.push(`${counts.review} review`); + if (attn.length) return { text: attn.join(" · "), attention: true }; + const active = (counts.in_progress || 0) + (counts.open || 0); + return { text: active ? `${active} active` : "", attention: false }; +} + function ProgressSummary({ running, toolNames, todo }: { running: boolean; toolNames: string[]; todo: TodoItem[] }) { if (todo.length) { return ( @@ -357,19 +462,32 @@ function RailSection({ onToggle, children, action, + count, + countAttention, }: { title: string; open: boolean; onToggle: () => void; children: ReactNode; action?: ReactNode; + // The header's maximum signal: a small count chip; amber when it carries attention + // states (blocked/review). Panels never shout louder than this. + count?: string; + countAttention?: boolean; }) { return (
- {action}
diff --git a/surfaces/gui/src/components/Sidebar.tsx b/surfaces/gui/src/components/Sidebar.tsx index 3bcf2120..4ff389f3 100644 --- a/surfaces/gui/src/components/Sidebar.tsx +++ b/surfaces/gui/src/components/Sidebar.tsx @@ -121,8 +121,6 @@ interface Props { onSwitchAgent: (agent: string) => void; onNewSession: (agent: string) => void; onSelectSession: (id: string, workspace: string, agent: string) => void; - // Agent teams: opens the team's # team chat view (the row under the expandable entry). - onOpenTeamChat?: (teamId: string) => void; onNewProject: (persona: string) => void; onRenameSession: (id: string, title: string) => void; onDeleteSession: (id: string) => void; @@ -433,24 +431,6 @@ export function Sidebar(props: Props) { .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 @@ -568,19 +548,6 @@ 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) — except a chevron on TEAM - leads, whose entry expands to the worker rows. */} - {!editing && teamWorkers.has(s.session_id) && ( - - )} + read as noise in both grouped and chronological). Team leads are plain rows too — + worker rows live in the drawer's Team panel (seventeenth pass). */} {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"} - - -
- ))} - {s.team?.chat_enabled && props.onOpenTeamChat && ( -
props.onOpenTeamChat?.(s.team?.team_id || "")} - > - # - team chat - {(s.team?.chat_unread || 0) > 0 && ( - {s.team?.chat_unread} - )} -
- )} -
- ); - }; - - // 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 = () => @@ -770,7 +669,7 @@ export function Sidebar(props: Props) { Pinned
- {pinnedSessions.map((s) => teamAwareRow(s))} + {pinnedSessions.map((s) => cardRow(s))}
) : null; @@ -1030,7 +929,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) => withTeamChildren(s, sessionRow(s, { showTime: true })))} + {shown.map((s) => sessionRow(s, { showTime: true }))} {!showAll && list.length > peek && (