mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-11 14:50:14 +00:00
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:
@@ -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" });
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user