Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/web/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
23 changes: 23 additions & 0 deletions apps/web/src/pages/insights-page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<PageShell width="full" className="page-fill">
<RichEmptyState
icon={<ChartColumn />}
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."
/>
</PageShell>
);
}

export function InsightsRoute() {
return <InsightsPage />;
}
37 changes: 28 additions & 9 deletions apps/web/src/routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,27 @@
// 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";

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";
Expand All @@ -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;
Expand Down Expand Up @@ -71,7 +84,7 @@ export const APP_ROUTES: readonly AppRoute[] = [
{
path: "/routines",
label: "Routines",
icon: <Clock />,
icon: <Workflow />,
render: (path: string, navigate: (to: string) => void) => (
<RoutinesRoute path={path} navigate={navigate} />
),
Expand All @@ -91,9 +104,15 @@ export const APP_ROUTES: readonly AppRoute[] = [
{
path: "/skills",
label: "Skills",
icon: <Sparkles />,
icon: <Wand2 />,
render: () => <SkillsRoute />,
},
{
path: "/insights",
label: "Insights",
icon: <ChartColumn />,
render: () => <InsightsRoute />,
},
{
path: "/approvals",
label: "Approvals",
Expand All @@ -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),
);
22 changes: 3 additions & 19 deletions apps/web/src/shell/rail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 ? (
<Badge tone="accent">{needsYouCount}</Badge>
) : undefined;

return (
<SidebarRail
Expand All @@ -56,7 +41,6 @@ export function Rail({
id: route.path,
label: route.label,
icon: route.icon,
...badgeProp(route.path === APPROVALS_PATH ? needsYouBadge : undefined),
}))}
onSelect={onNavigate}
footer={
Expand Down
18 changes: 14 additions & 4 deletions apps/web/test/rail.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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/,
);
});

Expand Down
32 changes: 27 additions & 5 deletions apps/web/test/routes.test.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -54,19 +54,33 @@ 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",
"/routines",
"/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", () => {
Expand All @@ -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}</span>`,
),
);
}
});
}
Expand Down
66 changes: 24 additions & 42 deletions apps/web/test/shell-contextual-panel.test.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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();
Expand All @@ -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;
}
Expand All @@ -84,19 +66,12 @@ async function renderRail(): Promise<HTMLDivElement> {
<TestQueryProvider>
<NavigationProvider navigate={noop}>
<BenchProvider>
<Rail
path="/approvals"
onNavigate={noop}
user={user}
onSignOut={noop}
/>
<Rail path="/" onNavigate={noop} user={user} onSignOut={noop} />
</BenchProvider>
</NavigationProvider>
</TestQueryProvider>,
);
});
// 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));
Expand All @@ -106,21 +81,28 @@ async function renderRail(): Promise<HTMLDivElement> {
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");
});
});
Loading
Loading