GUI: right-drawer restructure — Team panel, three primaries + More, board chip

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.
This commit is contained in:
Rohit C Prasad
2026-08-17 18:44:39 -07:00
parent aafb4f4291
commit ce3353dab3
16 changed files with 366 additions and 200 deletions
+5 -2
View File
@@ -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" });
+2
View File
@@ -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();
}
+22 -5
View File
@@ -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();
});
@@ -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();
+22 -18
View File
@@ -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<string, unknown> = {}) =>
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<typeof setInterval> | 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,
+1
View File
@@ -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();
+2
View File
@@ -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();
@@ -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();
+46 -24
View File
@@ -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);
});
+25 -1
View File
@@ -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 && (
<BoardOverlay
+17 -1
View File
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { Markdown, OPEN_ARTIFACT_EVENT } from "./Markdown";
import { Markdown, OPEN_ARTIFACT_EVENT, OPEN_BOARD_EVENT } from "./Markdown";
afterEach(cleanup);
@@ -35,4 +35,20 @@ describe("Markdown artifact links", () => {
render(<Markdown text="[](artifact:out/report.pdf)" />);
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(<Markdown text="Plan approved — [Board · 5 items](board:) if you want to watch." />);
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);
});
});
+28 -3
View File
@@ -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 (
<button
className="boardlink-chip"
data-testid="board-chip"
title="Open the board"
onClick={() => window.dispatchEvent(new CustomEvent(OPEN_BOARD_EVENT))}
>
<Icon name="table" size={12} />
<span>{label || "Board"}</span>
</button>
);
}
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 }) {
<div className="md">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
// artifact: is ours — keep it through the sanitizer (everything else gets the default
// http/https/mailto policy).
urlTransform={(url) => (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 <ArtifactChip path={href.slice("artifact:".length)} title={title} />;
}
if (href?.startsWith("board:")) {
const label = Array.isArray(children) ? children.join("") : String(children ?? "");
return <BoardChip label={label} />;
}
return (
<a href={href} {...props} target="_blank" rel="noreferrer">
{children}
+156 -38
View File
@@ -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<Record<Panel, boolean>>({
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<ArtifactInfo[]>([]);
const [journal, setJournal] = useState<JournalCase[]>([]);
const [selected, setSelected] = useState<ArtifactInfo | null>(null);
@@ -208,15 +244,20 @@ export function RightRail({
/>
) : (
<>
<RailSection title="Progress" open={open.progress} onToggle={() => setOpen({ ...open, progress: !open.progress })}>
<ProgressSummary running={running} toolNames={toolNames} todo={todo} />
</RailSection>
{/* Leads carry no Progress panel — the board IS the lead's progress surface. */}
{!isLead && (
<RailSection title="Progress" open={open.progress} onToggle={() => setOpen({ ...open, progress: !open.progress })}>
<ProgressSummary running={running} toolNames={toolNames} todo={todo} />
</RailSection>
)}
{/* 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 && (
<RailSection
title={`Board${boardSummary(board) ? ` · ${boardSummary(board)}` : ""}`}
title="Board"
count={boardChip(board).text}
countAttention={boardChip(board).attention}
open={open.board}
onToggle={() => setOpen({ ...open, board: !open.board })}
action={
@@ -241,27 +282,46 @@ export function RightRail({
</RailSection>
)}
{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 && (
<RailSection
title={`Journal (${journal.length})`}
open={open.journal}
onToggle={() => setOpen({ ...open, journal: !open.journal })}
title="Team"
open={open.team}
onToggle={() => setOpen({ ...open, team: !open.team })}
count={String(teamMembers.length)}
>
<div className="journal-list" data-testid="journal-list">
{journal.map((c) => (
<div className="journal-row" key={c.case}>
<Icon name="file" size={13} />
<span className="journal-case">{c.case}</span>
<span className="journal-count">{c.entries} entr{c.entries === 1 ? "y" : "ies"}</span>
</div>
<div className="rail-team" data-testid="team-panel">
{teamMembers.map((w) => (
<button
className="rail-team-row"
key={w.session_id}
data-testid={`team-row-${w.team?.actor || w.session_id}`}
onClick={() => onOpenWorker?.(w)}
title={`Open ${w.team?.actor || "worker"}'s session`}
>
<span className={"team-dot " + (w.team?.status || "idle")} />
<span className="rail-team-name">{w.team?.actor || w.agent}</span>
<span className="rail-team-item">{w.team?.current_item || "sleeping"}</span>
<span className="rail-team-open">open </span>
</button>
))}
{teamChatEnabled && onOpenTeamChat && (
<button className="rail-team-row rail-chat-row" data-testid="team-chat-row" onClick={onOpenTeamChat}>
<span className="team-hash">#</span>
<span className="rail-team-name">team chat</span>
{teamChatUnread > 0 && <span className="team-chat-badge">{teamChatUnread}</span>}
</button>
)}
</div>
</RailSection>
)}
{showArtifacts && (
<RailSection
title={`Artifacts${artifacts.length ? ` (${artifacts.length})` : ""}`}
title="Artifacts"
count={artifacts.length ? String(artifacts.length) : undefined}
open={open.artifacts}
onToggle={() => setOpen({ ...open, artifacts: !open.artifacts })}
action={
@@ -300,25 +360,70 @@ export function RightRail({
</RailSection>
)}
{/* Seventeenth pass: three primary sections max Journal and Access fold
behind a quiet "More" row. Deep links (Access openKey) unfold it. */}
<button
className="rail-more-row"
data-testid="rail-more-toggle"
onClick={() => setMoreOpen((v) => !v)}
>
<Icon name={moreOpen ? "chevronDown" : "chevronRight"} size={13} className="rail-chev" />
<span>More</span>
</button>
{moreOpen && board?.space && journal.length > 0 && (
<RailSection
title="Journal"
count={String(journal.length)}
open={open.journal}
onToggle={() => setOpen({ ...open, journal: !open.journal })}
>
<div className="journal-list" data-testid="journal-list">
{journal.map((c) => (
<div className="journal-row" key={c.case}>
<Icon name="file" size={13} />
<span className="journal-case">{c.case}</span>
<span className="journal-count">{c.entries} entr{c.entries === 1 ? "y" : "ies"}</span>
</div>
))}
</div>
</RailSection>
)}
{/* §32: Access the former Session-settings drawer, one section among peers.
key: its data ownership resets with the conversation, like the old row did. */}
<AccessSection
key={sessionId}
sessionId={sessionId}
personaId={personaId}
projectScoped={projectScoped}
workspace={workspace}
branch={branch}
scratchPrimary={scratchPrimary}
openKey={openAccessKey}
onOpenIntegrations={onOpenIntegrations}
/>
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. */}
<div style={moreOpen ? undefined : { display: "none" }}>
<AccessSection
key={sessionId}
sessionId={sessionId}
personaId={personaId}
projectScoped={projectScoped}
workspace={workspace}
branch={branch}
scratchPrimary={scratchPrimary}
openKey={openAccessKey}
onOpenIntegrations={onOpenIntegrations}
/>
</div>
</>
)}
</aside>
);
}
// 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<string, number> = {};
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 (
<section className="rail-section">
<div className="rail-section-head">
<button className="rail-section-toggle" onClick={onToggle}>
<button
className="rail-section-toggle"
data-testid={`rail-toggle-${title.toLowerCase()}`}
onClick={onToggle}
>
<Icon name={open ? "chevronDown" : "chevronRight"} size={14} className="rail-chev" />
<span>{title}</span>
{count && (
<span className={"rail-count" + (countAttention ? " attention" : "")}>{count}</span>
)}
</button>
{action}
</div>
+6 -107
View File
@@ -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<string, SessionInfo[]>();
for (const s of props.sessions) {
if (s.team?.role === "worker" && s.team.lead_session) {
const list = teamWorkers.get(s.team.lead_session) || [];
list.push(s);
teamWorkers.set(s.team.lead_session, list);
}
}
const [teamOpen, setTeamOpen] = useState<Set<string>>(new Set());
const toggleTeam = (id: string) =>
setTeamOpen((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
// Row actions live behind ONE ⋮ kebab per row (FB-011: four hover icons read as clutter) —
// the menu offers Rename · Pin/Unpin · Archive/Unarchive · Delete, with the two-step delete
// confirm kept inside it. Shared by BOTH row styles, so the chronological cardRow offers the
@@ -568,19 +548,6 @@ export function Sidebar(props: Props) {
}}
title={editing ? undefined : title}
>
{!editing && !!teamWorkers.get(s.session_id)?.length && (
<button
className="shrink-0 -ml-1 text-faint hover:text-ink"
data-testid={`team-toggle-${s.session_id}`}
onClick={(e) => {
e.stopPropagation();
toggleTeam(s.session_id);
}}
aria-label="Show team"
>
<Icon name={teamOpen.has(s.session_id) ? "chevronDown" : "chevronRight"} size={12} />
</button>
)}
{editing ? (
<input
className="flex-1 min-w-0 px-1.5 py-0.5 rounded-md bg-panel border border-accent text-[13px] text-ink outline-none"
@@ -654,21 +621,8 @@ export function Sidebar(props: Props) {
}}
>
{/* No leading glyph on session rows (Rohit's call 2026-07-07: the per-session icon
read as noise in both grouped and chronological) 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>
)}
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 ? (
<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"
@@ -706,61 +660,6 @@ 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>
))}
{s.team?.chat_enabled && props.onOpenTeamChat && (
<div
className="group flex items-center gap-2 px-2 py-1 rounded-lg cursor-pointer text-[12px] hover:bg-paper"
data-testid={`team-chat-row-${s.session_id}`}
onClick={() => props.onOpenTeamChat?.(s.team?.team_id || "")}
>
<span className="team-hash">#</span>
<span className="min-w-0 flex-1 truncate text-ink">team chat</span>
{(s.team?.chat_unread || 0) > 0 && (
<span className="team-chat-badge">{s.team?.chat_unread}</span>
)}
</div>
)}
</div>
);
};
// A row plus (when expanded) its team children — used by BOTH row styles so the
// expandable team entry works in the flat AND grouped layouts.
const withTeamChildren = (s: SessionInfo, row: ReturnType<typeof cardRow>) => {
if (!teamWorkers.get(s.session_id)?.length) return row;
return (
<div key={`team-${s.session_id}`}>
{row}
{teamOpen.has(s.session_id) && teamChildren(s)}
</div>
);
};
const teamAwareRow = (s: SessionInfo) => withTeamChildren(s, cardRow(s));
// The cross-persona Pinned band (manual pins only) — icon-free rows. Appears in BOTH layouts
// (flat list AND accordion), so it's factored here for reuse.
const pinnedBand = () =>
@@ -770,7 +669,7 @@ export function Sidebar(props: Props) {
Pinned
</div>
<div className="space-y-0.5">
{pinnedSessions.map((s) => teamAwareRow(s))}
{pinnedSessions.map((s) => cardRow(s))}
</div>
</div>
) : 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.
<div className="space-y-0.5 pl-[19px]">
{shown.map((s) => withTeamChildren(s, sessionRow(s, { showTime: true })))}
{shown.map((s) => sessionRow(s, { showTime: true }))}
{!showAll && list.length > peek && (
<button
className="px-2 py-1 text-[12px] text-faint hover:text-muted"
@@ -1061,7 +960,7 @@ export function Sidebar(props: Props) {
{(personaShowAll.has(browseKey)
? mine.filter(matches)
: mine.filter(matches).slice(0, peek)
).map((s) => withTeamChildren(s, sessionRow(s)))}
).map((s) => sessionRow(s))}
{!personaShowAll.has(browseKey) && mine.filter(matches).length > peek && (
<button
className="px-2 py-1 text-[12px] text-faint hover:text-muted"
@@ -1220,7 +1119,7 @@ export function Sidebar(props: Props) {
{(recentExpanded
? recentSessions
: recentSessions.slice(0, RECENT_PEEK)
).map((s) => teamAwareRow(s))}
).map((s) => cardRow(s))}
{recentSessions.length > RECENT_PEEK && (
<button
className="w-full text-left px-2 py-1.5 text-[12px] text-muted hover:text-ink"
+25
View File
@@ -558,6 +558,22 @@ button.btn.danger { color: var(--accent); }
.rail-mini-btn { width: 24px; height: 24px; display: grid; place-items: center; color: var(--muted); border: 0; border-radius: 7px; background: transparent; cursor: pointer; }
.rail-mini-btn:hover { color: var(--ink); background: var(--paper); }
.rail-section-body { padding-top: 10px; }
/* Seventeenth pass: a section header's maximum signal a small count chip, amber only
when it carries attention states (blocked/review). */
.rail-count { margin-left: auto; font-size: 11px; font-weight: 500; color: var(--muted); background: var(--paper); border: 1px solid var(--line); border-radius: 999px; padding: 1px 7px; flex: none; }
.rail-count.attention { background: var(--warn-soft); border-color: transparent; color: var(--warn-ink); }
/* Journal + Access fold behind this quiet row (three primary sections max). */
.rail-more-row { display: flex; gap: 8px; align-items: center; width: 100%; padding: 11px 0; border: 0; border-bottom: 1px solid var(--line); background: transparent; cursor: pointer; font: inherit; font-size: 12px; color: var(--faint); text-align: left; }
.rail-more-row:hover { color: var(--muted); }
/* The Team panel — member rows; click = the altitude-3 escape hatch into that worker's session. */
.rail-team { display: flex; flex-direction: column; gap: 1px; }
.rail-team-row { display: flex; gap: 9px; align-items: center; width: 100%; padding: 6px 6px; border: 0; border-radius: 8px; background: transparent; cursor: pointer; font: inherit; text-align: left; }
.rail-team-row:hover { background: var(--paper); }
.rail-team-row:hover .rail-team-open { opacity: 1; }
.rail-team-name { font-size: 13px; font-weight: 550; color: var(--ink); flex: none; }
.rail-team-item { font-size: 11.5px; color: var(--faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0; }
.rail-team-open { font-size: 11px; color: var(--accent); opacity: 0; flex: none; }
.rail-chat-row .rail-team-name { font-weight: 500; color: var(--muted); }
.rail-muted { font-size: 12px; color: var(--faint); line-height: 1.45; overflow-wrap: anywhere; }
.rail-error { font-size: 12px; color: var(--accent); overflow-wrap: anywhere; }
.rail-todo-list { display: flex; flex-direction: column; gap: 9px; }
@@ -1386,6 +1402,15 @@ html[data-theme="dark"] :not(.connector-badge) > .connector-icon[data-dark-mark]
.art-chip-meta span { font-size: 11px; color: var(--faint); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.art-chip-open { margin-left: 8px; flex: none; font-size: 11.5px; color: var(--accent); font-weight: 500; }
/* Seventeenth pass: [Board · N items](board:) the lead's one-time board mention, an
inline pill (smaller than the artifact chip: it opens a panel, not a document). */
.boardlink-chip {
display: inline-flex; align-items: center; gap: 6px; vertical-align: baseline;
padding: 2px 10px 2px 8px; border: 1px solid var(--line-strong); border-radius: 999px;
background: var(--panel); cursor: pointer; font: inherit; font-size: 12.5px; color: var(--ink);
}
.boardlink-chip:hover { border-color: var(--accent); color: var(--accent); }
/* ---- UX-027: Slack post-connect "how mentions reach you" card ----
Split-scene carousel: the Slack window is PINNED to modern light-Slack (aubergine
chrome, white pill for the active channel, purple top toolbar with the search