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 ( { - 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/src/bench-switcher.tsx b/packages/bench-ui/src/bench-switcher.tsx index 2c67e4741..db1321379 100644 --- a/packages/bench-ui/src/bench-switcher.tsx +++ b/packages/bench-ui/src/bench-switcher.tsx @@ -114,9 +114,14 @@ export function BenchSwitcher({ const [createSubmitting, setCreateSubmitting] = useState(false); const [createError, setCreateError] = useState(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); } diff --git a/packages/bench-ui/test/components.test.tsx b/packages/bench-ui/test/components.test.tsx index d917a0ea7..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 { 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 +114,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(