From 8f6b8cb8b1fe375dacfa42cc2fc4fbd6c1e1e276 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 9 Aug 2026 08:40:13 -0700 Subject: [PATCH 1/3] Add tests for rail cleanup: trimmed nav and bench switcher display Assert the rail lists Home/Routines/Library/Agents/Skills/Insights only, Chat and Approvals stay deep-linkable off-rail, and the bench switcher surface goes through membershipDisplay for the active name. --- apps/web/test/rail.test.tsx | 18 +++-- apps/web/test/routes.test.tsx | 32 +++++++-- apps/web/test/shell-contextual-panel.test.tsx | 66 +++++++------------ packages/bench-ui/test/components.test.tsx | 19 +++++- 4 files changed, 83 insertions(+), 52 deletions(-) diff --git a/apps/web/test/rail.test.tsx b/apps/web/test/rail.test.tsx index 6ca7a7d3c..6f141a57b 100644 --- a/apps/web/test/rail.test.tsx +++ b/apps/web/test/rail.test.tsx @@ -33,7 +33,7 @@ function renderRail(path: string): string { } describe("Rail", () => { - test("shows every page's label as visible text, not tooltip-only", () => { + test("shows every rail page's label as visible text, not tooltip-only", () => { const markup = renderRail("/"); // `SidebarRail` (`showLabels`) renders each caption in a // `sidebar-rail-item-label` slot; tooltip-only mode has no such span, so @@ -47,13 +47,23 @@ describe("Rail", () => { } }); + test("does not list Chat or Approvals on the rail", () => { + const markup = renderRail("/"); + expect(markup).not.toMatch( + /data-slot="sidebar-rail-item-label"[^>]*>Chat<\/span>/, + ); + expect(markup).not.toMatch( + /data-slot="sidebar-rail-item-label"[^>]*>Approvals<\/span>/, + ); + }); + test("marks the active page and no other", () => { - const markup = renderRail("/chat"); + const markup = renderRail("/routines"); const currentCount = (markup.match(/aria-current="page"/g) ?? []).length; - // One for the active page item, one for the (inactive) settings link. + // One for the active page item (settings is only current on /settings). expect(currentCount).toBe(1); expect(markup).toMatch( - /data-slot="sidebar-rail-item" aria-current="page"[^>]*>[\s\S]*?Chat/, + /data-slot="sidebar-rail-item" aria-current="page"[^>]*>[\s\S]*?Routines/, ); }); diff --git a/apps/web/test/routes.test.tsx b/apps/web/test/routes.test.tsx index d0a8ce610..6d058d8be 100644 --- a/apps/web/test/routes.test.tsx +++ b/apps/web/test/routes.test.tsx @@ -1,14 +1,14 @@ // Rendering here uses react-dom/server, so effects never run and every // screen shows its pre-fetch state — which is exactly what these tests // assert: each route mounts, names itself in the contextual panel, and -// marks itself in the rail. Page identity lives in the panel page band -// (h2.panel-page-title), not a per-page TopBar. +// rail-listed pages mark themselves in the rail. Page identity lives in the +// panel page band (h2.panel-page-title), not a per-page TopBar. import { describe, expect, test } from "bun:test"; import { renderToStaticMarkup } from "react-dom/server"; import { App } from "../src/app"; -import { APP_ROUTES, SETTINGS_PATH } from "../src/routes"; +import { APP_ROUTES, NAV_ROUTES, SETTINGS_PATH } from "../src/routes"; import type { SessionState } from "../src/session"; const noNavigate = () => undefined; @@ -54,8 +54,10 @@ function activeRailLabel(markup: string): string | undefined { return label?.[1]; } +const NAV_PATHS = new Set(NAV_ROUTES.map((route) => route.path)); + describe("route table", () => { - test("covers the eight screens", () => { + test("covers every screen the app can route to", () => { expect(APP_ROUTES.map((route) => route.path)).toEqual([ "/", "/chat", @@ -63,10 +65,22 @@ describe("route table", () => { "/library", "/agents", "/skills", + "/insights", "/approvals", "/settings", ]); }); + + test("rail nav is Home, Routines, Library, Agents, Skills, Insights", () => { + expect(NAV_ROUTES.map((route) => route.label)).toEqual([ + "Home", + "Routines", + "Library", + "Agents", + "Skills", + "Insights", + ]); + }); }); describe("routes render", () => { @@ -78,8 +92,16 @@ describe("routes render", () => { // Settings has no page-nav entry in the rail — it is reached from // the rail's own identity dock instead. expect(markup).toMatch(/aria-current="page"[^>]*href="\/settings"/); - } else { + } else if (NAV_PATHS.has(route.path)) { expect(activeRailLabel(markup)).toBe(route.label); + } else { + // Chat and Approvals stay deep-linkable but leave the rail. + expect(activeRailLabel(markup)).toBeUndefined(); + expect(markup).not.toMatch( + new RegExp( + `data-slot="sidebar-rail-item-label"[^>]*>${route.label}`, + ), + ); } }); } diff --git a/apps/web/test/shell-contextual-panel.test.tsx b/apps/web/test/shell-contextual-panel.test.tsx index ce5f68586..521db4b84 100644 --- a/apps/web/test/shell-contextual-panel.test.tsx +++ b/apps/web/test/shell-contextual-panel.test.tsx @@ -1,9 +1,6 @@ -// The "needs you" count only means anything if it actually reaches the -// screen. After the page list moved onto the rail, the Approvals badge is -// `SidebarRailItem.badge` — not a `SidebarItemRow` `meta` slot on the -// contextual panel. This test renders the real rail tree against a live DOM -// and a mocked hub, so a wrong prop name shows up as a missing count in the -// rendered text, not just a type that happens to check. +// After Chat and Approvals leave the rail, the dock that still needs a live +// DOM check is the bench switcher: it must show the server-resolved bench +// name (via membershipDisplay) once memberships resolve — never a tenant id. import { afterEach, describe, expect, test } from "bun:test"; import { act } from "react"; @@ -36,10 +33,7 @@ afterEach(() => { globalThis.fetch = originalFetch; }); -/** Stubs the two hub reads the rail triggers: bench membership (so - * `BenchProvider` resolves a selected tenant) and this tenant's needs-you - * list (so the Approvals item has something to badge). */ -function stubFetch(needsYouItemCount: number): void { +function stubMemberships(): void { originalFetch = globalThis.fetch; globalThis.fetch = (async (input: RequestInfo | URL) => { const url = typeof input === "string" ? input : input.toString(); @@ -59,18 +53,6 @@ function stubFetch(needsYouItemCount: number): void { nextCursor: null, }); } - if (url === "/api/tenants/tnt_1/approvals/needs-you") { - const items = Array.from({ length: needsYouItemCount }, (_, i) => ({ - id: `apr_${i}`, - agentName: "Outreach Composer", - benchName: "Growth Team Bench", - headline: "send_email", - arguments: {}, - status: "pending", - createdAt: "2026-01-01T00:00:00.000Z", - })); - return jsonResponse({ items }); - } throw new Error(`unexpected fetch in test: ${url}`); }) as typeof fetch; } @@ -84,19 +66,12 @@ async function renderRail(): Promise { - + , ); }); - // Principals then needs-you are sequential TQ queries. Drain each settle - // under act so React commits the badge before assertions. for (let i = 0; i < 20; i++) { await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); @@ -106,21 +81,28 @@ async function renderRail(): Promise { return container; } -describe("Rail's Approvals item", () => { - test("badges the item with the real pending count once needs-you resolves", async () => { - stubFetch(3); +describe("Rail bench switcher", () => { + test("shows the membership display name once benches resolve", async () => { + stubMemberships(); const el = await renderRail(); - expect(el.textContent).toContain("Approvals"); - expect(el.textContent).toContain("3"); + expect(el.textContent).toContain("Growth Team Bench"); + expect(el.textContent).not.toContain("tnt_1"); }); - test("carries no badge when nothing is pending", async () => { - stubFetch(0); + test("lists the trimmed product nav, not Chat or Approvals", async () => { + stubMemberships(); const el = await renderRail(); - expect(el.textContent).toContain("Approvals"); - // Every other item's label is a bare word with no digits; the absence of - // any digit anywhere in the rail is the honest way to assert "no badge" - // without hard-coding the badge's own markup shape. - expect(el.textContent).not.toMatch(/[0-9]/); + for (const label of [ + "Home", + "Routines", + "Library", + "Agents", + "Skills", + "Insights", + ]) { + expect(el.textContent).toContain(label); + } + expect(el.textContent).not.toContain("Chat"); + expect(el.textContent).not.toContain("Approvals"); }); }); diff --git a/packages/bench-ui/test/components.test.tsx b/packages/bench-ui/test/components.test.tsx index d917a0ea7..e8e3d5e66 100644 --- a/packages/bench-ui/test/components.test.tsx +++ b/packages/bench-ui/test/components.test.tsx @@ -6,7 +6,7 @@ import { describe, expect, test } from "bun:test"; import { renderToStaticMarkup } from "react-dom/server"; import type { BenchMember, BenchMembership } from "../src/api"; -import { BenchSwitcherList, BenchSwitcherTrigger } from "../src/bench-switcher"; +import { BenchSwitcher, BenchSwitcherList, BenchSwitcherTrigger } from "../src/bench-switcher"; import { canInviteMember } from "../src/invite-member-dialog"; import { canCreateBench, deriveBenchSlug } from "../src/membership"; import { MemberList } from "../src/member-list"; @@ -110,6 +110,23 @@ describe("BenchSwitcherList", () => { }); }); +describe("BenchSwitcher", () => { + test("active trigger name goes through membershipDisplay, never a raw id", () => { + const markup = renderToStaticMarkup( + , + ); + expect(markup).toContain("Acme Labs"); + expect(markup).not.toMatch(RAW_ID_PATTERN); + }); +}); + describe("MemberList", () => { test("renders a member's name, roles, and status, never a raw id", () => { const markup = renderToStaticMarkup( From 7fae402a0a1927d21f76ac2d14e74a41932ccbeb Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 9 Aug 2026 08:40:13 -0700 Subject: [PATCH 2/3] =?UTF-8?q?CL-5778:=20Rail=20cleanup=20=E2=80=94=20leg?= =?UTF-8?q?ible=20bench=20switcher,=20icons,=20trimmed=20nav?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nav is Home, Routines, Library, Agents, Skills, Insights with a coherent lucide set. Chat and Approvals remain routable for deep links but leave the rail. Bench switcher trigger is larger and labeled via membershipDisplay; Insights gets an honest stub until the analytics wave. --- apps/web/src/app.css | 2 +- apps/web/src/pages/insights-page.tsx | 23 +++++++++++++++ apps/web/src/routes.tsx | 37 ++++++++++++++++++------ apps/web/src/shell/rail.tsx | 22 ++------------ packages/bench-ui/src/bench-switcher.tsx | 13 ++++++--- packages/bench-ui/src/styles.css | 19 +++++++----- 6 files changed, 76 insertions(+), 40 deletions(-) create mode 100644 apps/web/src/pages/insights-page.tsx diff --git a/apps/web/src/app.css b/apps/web/src/app.css index 5574b6dd7..66ae7296a 100644 --- a/apps/web/src/app.css +++ b/apps/web/src/app.css @@ -223,7 +223,7 @@ carry local CSS here. */ .shell-bench-dock { width: 100%; - padding: 0 0.375rem; + padding: 0.25rem 0.5rem 0.5rem; } .shell-rail-identity { diff --git a/apps/web/src/pages/insights-page.tsx b/apps/web/src/pages/insights-page.tsx new file mode 100644 index 000000000..8096e778c --- /dev/null +++ b/apps/web/src/pages/insights-page.tsx @@ -0,0 +1,23 @@ +import { PageShell, RichEmptyState } from "@corbits/react-ui"; +import { ChartColumn } from "lucide-react"; + +/** + * Honest stub for the Insights nav target. The analytics surface is a later + * wave ticket; the rail already needs the path so product navigation matches + * the accepted nav set (Home, Routines, Library, Agents, Skills, Insights). + */ +export function InsightsPage() { + return ( + + } + title="Insights aren't built yet" + description="Insights will show usage, run history, and an audit trail for this bench. There is no analytics surface wired yet, so this page has nothing real to chart." + /> + + ); +} + +export function InsightsRoute() { + return ; +} diff --git a/apps/web/src/routes.tsx b/apps/web/src/routes.tsx index 588af4f2f..55a9f05e3 100644 --- a/apps/web/src/routes.tsx +++ b/apps/web/src/routes.tsx @@ -2,17 +2,19 @@ // icon) and the route switch (render), so navigation and pages cannot drift // apart. Settings renders like any other route but is reached from the // sidebar's identity dock, not the top nav — `NAV_ROUTES` is what the nav -// list shows. +// list shows. Chat and Approvals stay routable for deep links but leave the +// rail (channel surface and notifications tickets own their next homes). import { Bot, - Clock, + ChartColumn, Home, Library, MessageSquare, Settings, ShieldCheck, - Sparkles, + Wand2, + Workflow, } from "lucide-react"; import type { ReactElement, ReactNode } from "react"; @@ -20,6 +22,7 @@ import { AgentsRoute } from "./pages/agents-page"; import { ApprovalsRoute } from "./pages/approvals-page"; import { ChatPage } from "./pages/chat-page"; import { HomeRoute } from "./pages/home-page"; +import { InsightsRoute } from "./pages/insights-page"; import { LibraryRoute } from "./pages/library-page"; import { RoutinesRoute } from "./pages/routines-page"; import { SettingsRoute } from "./pages/settings-page"; @@ -33,6 +36,16 @@ export const ONBOARDING_PATH = "/onboarding"; /** Settings lives in the sidebar's identity dock, not the top nav. */ export const SETTINGS_PATH = "/settings"; +/** Paths the rail lists — product nav after Chat/Approvals leave the rail. */ +const RAIL_NAV_PATHS = new Set([ + "/", + "/routines", + "/library", + "/agents", + "/skills", + "/insights", +]); + export type AppRoute = { readonly path: string; readonly label: string; @@ -71,7 +84,7 @@ export const APP_ROUTES: readonly AppRoute[] = [ { path: "/routines", label: "Routines", - icon: , + icon: , render: (path: string, navigate: (to: string) => void) => ( ), @@ -91,9 +104,15 @@ export const APP_ROUTES: readonly AppRoute[] = [ { path: "/skills", label: "Skills", - icon: , + icon: , render: () => , }, + { + path: "/insights", + label: "Insights", + icon: , + render: () => , + }, { path: "/approvals", label: "Approvals", @@ -108,8 +127,8 @@ export const APP_ROUTES: readonly AppRoute[] = [ }, ]; -/** What the sidebar's top nav lists: every route except Settings, which - * the identity dock owns. */ -export const NAV_ROUTES: readonly AppRoute[] = APP_ROUTES.filter( - (route) => route.path !== SETTINGS_PATH, +/** What the rail lists: product pages only. Settings is the identity dock; + * Chat and Approvals stay deep-linkable but off the rail. */ +export const NAV_ROUTES: readonly AppRoute[] = APP_ROUTES.filter((route) => + RAIL_NAV_PATHS.has(route.path), ); diff --git a/apps/web/src/shell/rail.tsx b/apps/web/src/shell/rail.tsx index 5633ad0f0..b568552b0 100644 --- a/apps/web/src/shell/rail.tsx +++ b/apps/web/src/shell/rail.tsx @@ -8,17 +8,12 @@ // The footer still composes the bench switcher and identity docks the rail // needs below the page icons. -import { Badge, SidebarRail } from "@corbits/react-ui"; +import { SidebarRail } from "@corbits/react-ui"; -import { useNeedsYouCount } from "../api"; -import { useBench } from "../bench-context"; -import { badgeProp } from "../optional-props"; import { NAV_ROUTES, matchesRoute, type AppRoute } from "../routes"; import type { SessionUser } from "../session"; import { BenchDock, RailIdentity } from "./docks"; -const APPROVALS_PATH = "/approvals"; - export function Rail({ path, onNavigate, @@ -31,21 +26,11 @@ export function Rail({ readonly onSignOut: () => void; }) { // `SidebarRail` flags the item whose id equals `activeId`; the nav routes - // own prefix matching (e.g. /chat/:channelId lights the Chat item), so the - // active id is resolved here rather than left to an exact path compare. + // own prefix matching (e.g. /routines/:id lights Routines), so the active + // id is resolved here rather than left to an exact path compare. const activeRoute = NAV_ROUTES.find((route) => matchesRoute(route.path, path), ); - const { selectedTenantId } = useBench(); - // Which running workflows are parked waiting on this bench's approval — - // Interchange's own "needs you" state, read through `@corbits/approvals`. - // After the page list moved onto the rail, the count badges the Approvals - // icon itself (`SidebarRailItem.badge`), not a contextual-panel row. - const needsYouCount = useNeedsYouCount(selectedTenantId); - const needsYouBadge = - needsYouCount !== null && needsYouCount > 0 ? ( - {needsYouCount} - ) : undefined; return ( (null); - const active = memberships.find( - (membership) => membership.tenantId === activeTenantId, - ); + const active = + activeTenantId === null + ? undefined + : memberships.find( + (membership) => membership.tenantId === activeTenantId, + ); + const activeName = + active !== undefined ? membershipDisplay(active).name : null; function handleCreate(name: string) { setCreateSubmitting(true); @@ -136,7 +141,7 @@ export function BenchSwitcher({ return (
setOpen((value) => !value)} /> diff --git a/packages/bench-ui/src/styles.css b/packages/bench-ui/src/styles.css index 1e62641ae..e803baea4 100644 --- a/packages/bench-ui/src/styles.css +++ b/packages/bench-ui/src/styles.css @@ -9,23 +9,28 @@ .bench-switcher-trigger { display: flex; width: 100%; + min-height: 2.75rem; align-items: center; gap: 0.625rem; - border: none; + border: 1px solid var(--border); border-radius: 10px; - padding: 0.5rem 0.625rem; - background: transparent; + padding: 0.625rem 0.75rem; + background: var(--muted); font: inherit; - font-size: 0.875rem; - color: var(--muted-foreground); + font-size: 0.9375rem; + font-weight: 600; + line-height: 1.25; + color: var(--foreground); cursor: pointer; transition: color 150ms, - background-color 150ms; + background-color 150ms, + border-color 150ms; } .bench-switcher-trigger:hover { - background: var(--muted); + background: var(--background); + border-color: var(--foreground); color: var(--foreground); } From 1fdb9a098ab3dbae580f09ed4b264853c8d7c5f4 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 9 Aug 2026 08:43:47 -0700 Subject: [PATCH 3/3] Format bench-ui component tests for Prettier CI gate --- packages/bench-ui/test/components.test.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/bench-ui/test/components.test.tsx b/packages/bench-ui/test/components.test.tsx index e8e3d5e66..ab0633de3 100644 --- a/packages/bench-ui/test/components.test.tsx +++ b/packages/bench-ui/test/components.test.tsx @@ -6,7 +6,11 @@ import { describe, expect, test } from "bun:test"; import { renderToStaticMarkup } from "react-dom/server"; import type { BenchMember, BenchMembership } from "../src/api"; -import { BenchSwitcher, BenchSwitcherList, BenchSwitcherTrigger } from "../src/bench-switcher"; +import { + BenchSwitcher, + BenchSwitcherList, + BenchSwitcherTrigger, +} from "../src/bench-switcher"; import { canInviteMember } from "../src/invite-member-dialog"; import { canCreateBench, deriveBenchSlug } from "../src/membership"; import { MemberList } from "../src/member-list";