diff --git a/apps/web/README.md b/apps/web/README.md
index fb8cf7ef3..724fef4c9 100644
--- a/apps/web/README.md
+++ b/apps/web/README.md
@@ -8,14 +8,21 @@ serves from its own origin (`vite build`, then point `HUB_STATIC_DIR` at
## Layout
Every signed-in screen renders inside the same four-column shell
-(`src/shell/`), assembled from `@corbits/react-ui`'s sidebar rail and
-sidebar panel pieces:
+(`src/shell/`), built from `@corbits/react-ui`'s sidebar panel pieces plus a
+workbench-composed rail:
-1. **Rail** — the global page icons, one per screen, each carrying its page
- name as its accessible name and hover/focus tooltip.
-2. **Contextual column** — the active page's own options: channels,
- routines, the page list in full labels, with the bench switcher and the
- signed-in account pinned to the bottom.
+1. **Rail** — global and stable: it never changes with navigation or with
+ the selected bench. One icon per screen with its name captioned
+ underneath (not tooltip-only), plus the bench switcher and the
+ signed-in account's settings/sign-out at the bottom. Answers "where am I
+ in the product, and which bench am I in". Fixed width at every
+ breakpoint — it never joins the columns that withdraw as the viewport
+ narrows.
+2. **Contextual column** — bench-scoped and live: channels, chats, running
+ routines, and notifications for the _currently selected_ bench. It
+ refetches when the bench changes, not when the route does, so its
+ contents can persist or travel across page navigation rather than being
+ a per-page list. Answers "what is happening in this bench right now".
3. **Main pane** — whatever the route renders, taking all remaining width.
4. **Canvas** — an optional fourth column for running agents, live
workflow walkthroughs and analytics. Collapsed by default and collapsed
@@ -29,8 +36,15 @@ chat dock squeezing the content area resizes the columns instead of
clipping them.
A new page needs one entry in `NAV_ROUTES` (`src/routes.tsx`) — the rail's
-icon, the contextual column's labeled row, and the route switch all read
-from that single table, so a page cannot appear in one without the other.
+icon and the route switch both read from that single table, so a page
+cannot appear in one without the other. The contextual column no longer
+reads `NAV_ROUTES` at all: it has nothing to do with which pages exist.
+
+Running routines in the contextual column are sourced today from
+`@corbits/chat-ui`'s workflow-run listing (`src/shell/routine-activity.ts`)
+rather than a dedicated routines package, which isn't published yet — the
+column depends only on that file's `RoutineActivityItem` shape, so swapping
+in a real `@corbits/routines` listing later touches nothing else.
## Screens
diff --git a/apps/web/src/app.css b/apps/web/src/app.css
index 0118b7b99..ee1ed7615 100644
--- a/apps/web/src/app.css
+++ b/apps/web/src/app.css
@@ -110,25 +110,24 @@
`prefers-reduced-motion` in its theme.css), and these transitions inherit
that like any other. */
-/* Contextual panel footer: bench switcher above the identity row. */
-.shell-contextual-footer {
- display: flex;
- flex-direction: column;
-}
-
+/* Column 1's footer docks: the bench switcher and identity row. The rail
+ itself is `@corbits/react-ui`'s `SidebarRail` (showLabels), styled in its
+ own stylesheet; only the footer parts composed into its `footer` slot
+ carry local CSS here. */
.shell-bench-dock {
- padding: 0.25rem 0.25rem 0.5rem;
+ width: 100%;
+ padding: 0 0.375rem;
}
-.shell-identity-dock {
- display: grid;
- grid-template-columns: auto auto 1fr;
+.shell-rail-identity {
+ display: flex;
+ flex-direction: column;
align-items: center;
- gap: 0.5rem;
- padding: 0.5rem 0.25rem 0.25rem;
+ gap: 0.25rem;
+ padding-bottom: 0.25rem;
}
-.shell-identity-avatar {
+.shell-rail-identity-avatar {
display: grid;
height: 28px;
width: 28px;
@@ -141,7 +140,7 @@
color: var(--primary-foreground);
}
-.shell-identity-settings {
+.shell-rail-identity-settings {
display: grid;
height: 28px;
width: 28px;
@@ -151,18 +150,14 @@
transition: color 150ms;
}
-.shell-identity-settings:hover,
-.shell-identity-settings[aria-current="page"] {
+.shell-rail-identity-settings:hover,
+.shell-rail-identity-settings[aria-current="page"] {
color: var(--foreground);
}
-.shell-identity-email {
- grid-column: 1 / -1;
- overflow: hidden;
- font-size: 0.75rem;
- color: var(--muted-foreground);
- text-overflow: ellipsis;
- white-space: nowrap;
+.shell-activity-skeleton {
+ height: 6rem;
+ width: 100%;
}
/* Auth screen — the two-column corbits.dev sign-in shell. The right panel is
diff --git a/apps/web/src/shell/app-shell.tsx b/apps/web/src/shell/app-shell.tsx
index c2eea4458..00781291a 100644
--- a/apps/web/src/shell/app-shell.tsx
+++ b/apps/web/src/shell/app-shell.tsx
@@ -40,14 +40,14 @@ export function AppShell({
return (
-
+
{contextualPanelVisible(layoutMode) && (
-
+
)}
{canvasAllowed && (
diff --git a/apps/web/src/shell/bench-activity.ts b/apps/web/src/shell/bench-activity.ts
new file mode 100644
index 000000000..1d6be49cb
--- /dev/null
+++ b/apps/web/src/shell/bench-activity.ts
@@ -0,0 +1,62 @@
+// The second column's one data source: everything happening in the
+// currently selected bench right now. Channels and chats come from
+// `@corbits/chat-ui`'s own validated fetches; running routines come through
+// the seam in `./routine-activity.ts`. Notifications have no backing feature
+// in the hub yet, so they are not fetched here at all — the column renders
+// an honest empty state for that section instead of a query with nowhere
+// to point.
+
+import { useEffect, useState } from "react";
+import { listChannels } from "@corbits/chat-ui";
+import type { Channel } from "@corbits/chat-ui";
+
+import { listRoutineActivity } from "./routine-activity";
+import type { RoutineActivityItem } from "./routine-activity";
+
+export type BenchActivityQuery =
+ | { readonly kind: "loading" }
+ | { readonly kind: "empty" }
+ | { readonly kind: "error"; readonly message: string }
+ | {
+ readonly kind: "ready";
+ readonly channels: readonly Channel[];
+ readonly chats: readonly Channel[];
+ readonly routines: readonly RoutineActivityItem[];
+ };
+
+/** Bench-scoped live activity for the second column, refetched whenever the
+ * selected bench changes — nothing here is page-scoped, so a route change
+ * alone never triggers a refetch. */
+export function useBenchActivity(tenantId: string | null): BenchActivityQuery {
+ const [state, setState] = useState
({ kind: "loading" });
+
+ useEffect(() => {
+ if (tenantId === null) {
+ setState({ kind: "empty" });
+ return;
+ }
+ let cancelled = false;
+ setState({ kind: "loading" });
+ Promise.all([
+ listChannels(tenantId, "channel"),
+ listChannels(tenantId, "chat"),
+ listRoutineActivity(tenantId),
+ ])
+ .then(([channels, chats, routines]) => {
+ if (cancelled) return;
+ setState({ kind: "ready", channels, chats, routines });
+ })
+ .catch((cause: unknown) => {
+ if (cancelled) return;
+ setState({
+ kind: "error",
+ message: cause instanceof Error ? cause.message : String(cause),
+ });
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [tenantId]);
+
+ return state;
+}
diff --git a/apps/web/src/shell/contextual-panel.tsx b/apps/web/src/shell/contextual-panel.tsx
index b8927b4e5..e5c5ca8e1 100644
--- a/apps/web/src/shell/contextual-panel.tsx
+++ b/apps/web/src/shell/contextual-panel.tsx
@@ -1,75 +1,181 @@
-// Column 2: the contextual panel. Slack-esque — the active page's name up
-// top, the page list as full-label rows (the rail only ever shows an icon),
-// and the bench/identity docks pinned to the bottom. Built from
-// `@corbits/react-ui`'s `SidebarPanel` family; this file only supplies the
-// workbench-specific content that fills those slots.
+// Column 2: the bench-scoped live activity rail. Answers "what is
+// happening in this bench right now" — channels, chats, and running
+// routines for the currently selected bench, plus a slot for notifications
+// once the hub has something to send. Nothing here is a page list: it
+// refetches on bench changes, never on route changes, so items can persist
+// or travel across page navigation exactly as live activity should.
import {
+ EmptyState,
+ Skeleton,
SidebarItemRow,
SidebarPanel,
SidebarPanelBody,
- SidebarPanelFooter,
SidebarPanelHeader,
SidebarPanelSection,
useSidebarPanel,
} from "@corbits/react-ui";
+import type { Channel } from "@corbits/chat-ui";
+import { Bell, Hash, MessageSquare, Workflow } from "lucide-react";
-import { NAV_ROUTES, matchesRoute } from "../routes";
-import { BenchDock, IdentityDock } from "./docks";
-import type { SessionUser } from "../session";
+import { useBench } from "../bench-context";
+import { useBenchActivity } from "./bench-activity";
+import type { RoutineActivityItem } from "./routine-activity";
-const PAGES_SECTION_ID = "pages";
+const CHANNELS_SECTION_ID = "channels";
+const CHATS_SECTION_ID = "chats";
+const ROUTINES_SECTION_ID = "routines";
+const NOTIFICATIONS_SECTION_ID = "notifications";
+const CHAT_PATH_PREFIX = "/chat";
+
+function activeChatChannelId(path: string): string | null {
+ if (!path.startsWith(`${CHAT_PATH_PREFIX}/`)) return null;
+ const rest = path.slice(CHAT_PATH_PREFIX.length + 1);
+ return rest === "" ? null : decodeURIComponent(rest);
+}
+
+function ChannelRow({
+ channel,
+ active,
+ onNavigate,
+}: {
+ readonly channel: Channel;
+ readonly active: boolean;
+ readonly onNavigate: (to: string) => void;
+}) {
+ return (
+
+ onNavigate(`${CHAT_PATH_PREFIX}/${encodeURIComponent(channel.id)}`)
+ }
+ />
+ );
+}
+
+function RoutineRow({ routine }: { readonly routine: RoutineActivityItem }) {
+ return ;
+}
export function ContextualPanel({
path,
onNavigate,
- user,
- onSignOut,
}: {
readonly path: string;
readonly onNavigate: (to: string) => void;
- readonly user: SessionUser;
- readonly onSignOut: () => void;
}) {
- const active = NAV_ROUTES.find((route) => matchesRoute(route.path, path));
- const activePageId = active?.path ?? path;
- // Section folding and the page-swap animation come from the panel's own
- // hook; its `selectedId` deliberately does not, because the URL already
- // says which page is selected and a second answer to that question is how
- // the two drift apart.
+ const { selectedTenantId } = useBench();
+ const activity = useBenchActivity(selectedTenantId);
+ const activeChannelId = activeChatChannelId(path);
const {
isSectionCollapsed,
toggleSection,
panelKey,
panelTransitionClassName,
- } = useSidebarPanel({ activePageId });
+ } = useSidebarPanel({ activePageId: selectedTenantId ?? "" });
+
return (
-
+
- toggleSection(PAGES_SECTION_ID)}
- >
- {NAV_ROUTES.map((route) => (
- onNavigate(route.path)}
- />
- ))}
-
+ {activity.kind === "loading" && (
+
+ )}
+ {activity.kind === "empty" && (
+ }
+ title="No bench selected"
+ description="Choose a bench from the rail to see its channels, chats, and running routines."
+ />
+ )}
+ {activity.kind === "error" && (
+ }
+ title="Couldn't load bench activity"
+ description={activity.message}
+ />
+ )}
+ {activity.kind === "ready" && (
+ <>
+ toggleSection(CHANNELS_SECTION_ID)}
+ >
+ {activity.channels.length === 0 ? (
+ }
+ title="No channels yet"
+ description="Channels created in this bench appear here."
+ />
+ ) : (
+ activity.channels.map((channel) => (
+
+ ))
+ )}
+
+ toggleSection(CHATS_SECTION_ID)}
+ >
+ {activity.chats.length === 0 ? (
+ }
+ title="No chats yet"
+ description="Direct chats with an agent in this bench appear here."
+ />
+ ) : (
+ activity.chats.map((channel) => (
+
+ ))
+ )}
+
+ toggleSection(ROUTINES_SECTION_ID)}
+ >
+ {activity.routines.length === 0 ? (
+ }
+ title="Nothing running"
+ description="A routine running in this bench shows up here while it executes."
+ />
+ ) : (
+ activity.routines.map((routine) => (
+
+ ))
+ )}
+
+ toggleSection(NOTIFICATIONS_SECTION_ID)}
+ >
+ }
+ title="No notifications yet"
+ description="This bench has no notification source wired up yet — mentions and mail-backed alerts will land here once it does."
+ />
+
+ >
+ )}
-
-
-
-
);
}
diff --git a/apps/web/src/shell/docks.tsx b/apps/web/src/shell/docks.tsx
index 5dd4aeabe..6aa81cd55 100644
--- a/apps/web/src/shell/docks.tsx
+++ b/apps/web/src/shell/docks.tsx
@@ -50,9 +50,10 @@ export function BenchDock() {
);
}
-/** Bottom dock B: who is signed in (initials avatar + email — never an
- * id), with settings and sign-out. */
-export function IdentityDock({
+/** Rail footer: who is signed in (initials avatar, tooltip-only email —
+ * never an id) plus settings and sign-out, stacked to fit the narrow rail
+ * rather than the wide row the contextual panel used to have room for. */
+export function RailIdentity({
path,
user,
onSignOut,
@@ -64,20 +65,24 @@ export function IdentityDock({
const navigate = useNavigate();
const settingsActive = matchesRoute(SETTINGS_PATH, path);
return (
-
-
- {initialsOf(user.name, user.email)}
-
+
);
}
diff --git a/apps/web/src/shell/rail.tsx b/apps/web/src/shell/rail.tsx
index 7343afc56..686318728 100644
--- a/apps/web/src/shell/rail.tsx
+++ b/apps/web/src/shell/rail.tsx
@@ -1,35 +1,54 @@
-// Column 1: the global button rail. A thin wrapper around
-// `@corbits/react-ui`'s `SidebarRail` — the rail's anatomy (56px, icon-only
-// buttons with a hover/focus tooltip carrying the accessible label) is the
-// library's, not ours; this file only turns the route table into the
-// `SidebarRailItem[]` shape the rail expects.
+// Column 1: the global rail. Answers "where am I in the product, and which
+// bench am I in" — the page icons never change with navigation or with the
+// selected bench, and neither does this column's width.
+//
+// The caption-under-icon rail landed in `@corbits/react-ui`'s `SidebarRail`
+// as its `showLabels` option, so the rail is now the library component with
+// labels on — the hand-rolled item markup it temporarily mirrored is gone.
+// The footer still composes the bench switcher and identity docks the rail
+// needs below the page icons.
import { SidebarRail } from "@corbits/react-ui";
import { NAV_ROUTES, matchesRoute, type AppRoute } from "../routes";
-
-function railItemId(route: AppRoute): string {
- return route.path;
-}
+import type { SessionUser } from "../session";
+import { BenchDock, RailIdentity } from "./docks";
export function Rail({
path,
onNavigate,
+ user,
+ onSignOut,
}: {
readonly path: string;
readonly onNavigate: (to: string) => void;
+ readonly user: SessionUser;
+ readonly onSignOut: () => void;
}) {
- const active = NAV_ROUTES.find((route) => matchesRoute(route.path, path));
+ // `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.
+ const activeRoute = NAV_ROUTES.find((route) =>
+ matchesRoute(route.path, path),
+ );
+
return (
({
- id: railItemId(route),
+ showLabels
+ activeId={activeRoute?.path ?? ""}
+ items={NAV_ROUTES.map((route: AppRoute) => ({
+ id: route.path,
label: route.label,
icon: route.icon,
}))}
- activeId={active === undefined ? "" : railItemId(active)}
onSelect={onNavigate}
+ footer={
+ <>
+
+
+ >
+ }
/>
);
}
diff --git a/apps/web/src/shell/routine-activity.ts b/apps/web/src/shell/routine-activity.ts
new file mode 100644
index 000000000..1218b878b
--- /dev/null
+++ b/apps/web/src/shell/routine-activity.ts
@@ -0,0 +1,32 @@
+// A seam for `@corbits/routines`, which is not on `main` yet: the second
+// column's "Running" section depends only on `RoutineActivityItem` and
+// `listRoutineActivity`, never on where the data actually comes from. Today
+// it is filled from `@corbits/chat-ui`'s `listRuns` — the workflow-instance
+// listing every routine run already executes as — so the section shows real,
+// bench-scoped activity rather than nothing. Once `@corbits/routines`
+// publishes its own richer listing, only this file's body changes.
+
+import { listRuns, runDisplayName } from "@corbits/chat-ui";
+import type { Run } from "@corbits/chat-ui";
+
+export type RoutineActivityItem = {
+ readonly id: string;
+ readonly name: string;
+ readonly status: string;
+ readonly startedAt: string;
+};
+
+function toRoutineActivityItem(run: Run): RoutineActivityItem {
+ return {
+ id: run.id,
+ name: runDisplayName(run),
+ status: run.status,
+ startedAt: run.createdAt,
+ };
+}
+
+export function listRoutineActivity(
+ tenantId: string,
+): Promise {
+ return listRuns(tenantId).then((runs) => runs.map(toRoutineActivityItem));
+}
diff --git a/apps/web/test/bench-activity.test.tsx b/apps/web/test/bench-activity.test.tsx
new file mode 100644
index 000000000..98f4eca47
--- /dev/null
+++ b/apps/web/test/bench-activity.test.tsx
@@ -0,0 +1,85 @@
+// `useBenchActivity` is the second column's one data source: it refetches
+// on bench changes, not on route changes, and reports "empty" rather than
+// fetching anything when there is no bench selected yet.
+
+import { afterEach, describe, expect, test } from "bun:test";
+import { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+
+import { useBenchActivity } from "../src/shell/bench-activity";
+import type { BenchActivityQuery } from "../src/shell/bench-activity";
+
+const realFetch = globalThis.fetch;
+
+afterEach(() => {
+ globalThis.fetch = realFetch;
+});
+
+const json = (body: unknown) =>
+ new Response(JSON.stringify(body), {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ });
+
+function stubTenantFetch(calls: string[]): void {
+ globalThis.fetch = ((input: RequestInfo | URL) => {
+ const path = typeof input === "string" ? input : String(input);
+ calls.push(path);
+ if (path.includes("/workflows/instances")) return Promise.resolve(json([]));
+ return Promise.resolve(json({ items: [] }));
+ }) as typeof fetch;
+}
+
+async function mountHook(tenantId: string | null): Promise<{
+ readonly latest: () => BenchActivityQuery;
+ readonly root: Root;
+ readonly container: HTMLDivElement;
+}> {
+ let latest: BenchActivityQuery = { kind: "loading" };
+ function Probe({ tenantId }: { readonly tenantId: string | null }) {
+ latest = useBenchActivity(tenantId);
+ return null;
+ }
+ const container = document.createElement("div");
+ document.body.appendChild(container);
+ const root = createRoot(container);
+ await act(async () => {
+ root.render();
+ });
+ return { latest: () => latest, root, container };
+}
+
+describe("useBenchActivity", () => {
+ test("reports empty with no bench selected, fetching nothing", async () => {
+ const calls: string[] = [];
+ stubTenantFetch(calls);
+ const { latest, root, container } = await mountHook(null);
+ expect(latest()).toEqual({ kind: "empty" });
+ expect(calls).toEqual([]);
+ root.unmount();
+ container.remove();
+ });
+
+ test("fetches channels, chats, and running routines for the selected bench", async () => {
+ const calls: string[] = [];
+ stubTenantFetch(calls);
+ const { latest, root, container } = await mountHook("tnt_1");
+ await act(async () => {
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+ expect(latest()).toEqual({
+ kind: "ready",
+ channels: [],
+ chats: [],
+ routines: [],
+ });
+ expect(calls.some((path) => path.includes("kind=channel"))).toBe(true);
+ expect(calls.some((path) => path.includes("kind=chat"))).toBe(true);
+ expect(calls.some((path) => path.includes("/workflows/instances"))).toBe(
+ true,
+ );
+ root.unmount();
+ container.remove();
+ });
+});
diff --git a/apps/web/test/contextual-panel.test.tsx b/apps/web/test/contextual-panel.test.tsx
new file mode 100644
index 000000000..29296e017
--- /dev/null
+++ b/apps/web/test/contextual-panel.test.tsx
@@ -0,0 +1,113 @@
+// Column 2 is bench-scoped live activity now, not a page list: it never
+// mentions the page routes, and its notifications section is an honest
+// empty state — there is no notification feature in the hub yet, so this
+// must never render a fabricated sample entry.
+
+import { afterEach, describe, expect, test } from "bun:test";
+import { act } from "react";
+import { createRoot } from "react-dom/client";
+import { renderToStaticMarkup } from "react-dom/server";
+
+import { BenchProvider } from "../src/bench-context";
+import { ContextualPanel } from "../src/shell/contextual-panel";
+
+const noop = () => undefined;
+const realFetch = globalThis.fetch;
+
+afterEach(() => {
+ globalThis.fetch = realFetch;
+});
+
+function renderPanel(path: string): string {
+ return renderToStaticMarkup(
+
+
+ ,
+ );
+}
+
+const emptyMemberships = new Response(
+ JSON.stringify({ data: [], nextCursor: null }),
+ { status: 200, headers: { "content-type": "application/json" } },
+);
+
+describe("ContextualPanel", () => {
+ test("never renders a page-nav list", () => {
+ const markup = renderPanel("/");
+ expect(markup).not.toContain("shell-rail-item");
+ expect(markup).not.toContain(">Pages<");
+ });
+
+ test("shows an honest empty state once no bench resolves", async () => {
+ globalThis.fetch = ((_input: RequestInfo | URL, _init?: RequestInit) =>
+ Promise.resolve(emptyMemberships.clone())) as typeof fetch;
+ const container = document.createElement("div");
+ document.body.appendChild(container);
+ const root = createRoot(container);
+ await act(async () => {
+ root.render(
+
+
+ ,
+ );
+ });
+ expect(container.innerHTML).toContain("No bench selected");
+ root.unmount();
+ container.remove();
+ });
+
+ test("the notifications section is an honest empty state, never a fabricated entry", async () => {
+ const membership = {
+ data: [
+ {
+ principalId: "prn_1",
+ tenantId: "tnt_1",
+ tenantName: "Corbits Bench",
+ tenantSlug: "corbits-bench",
+ kind: "user",
+ status: "active",
+ roles: [],
+ },
+ ],
+ nextCursor: null,
+ };
+ globalThis.fetch = ((input: RequestInfo | URL) => {
+ const path = typeof input === "string" ? input : String(input);
+ if (path.includes("/api/me/principals")) {
+ return Promise.resolve(
+ new Response(JSON.stringify(membership), {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ }),
+ );
+ }
+ const body = path.includes("/workflows/instances") ? [] : { items: [] };
+ return Promise.resolve(
+ new Response(JSON.stringify(body), {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ }),
+ );
+ }) as typeof fetch;
+ const container = document.createElement("div");
+ document.body.appendChild(container);
+ const root = createRoot(container);
+ await act(async () => {
+ root.render(
+
+
+ ,
+ );
+ });
+ await act(async () => {
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+ expect(container.innerHTML).toContain("No notifications yet");
+ expect(container.innerHTML).toContain(
+ "mentions and mail-backed alerts will land here",
+ );
+ root.unmount();
+ container.remove();
+ });
+});
diff --git a/apps/web/test/rail.test.tsx b/apps/web/test/rail.test.tsx
new file mode 100644
index 000000000..86121ed06
--- /dev/null
+++ b/apps/web/test/rail.test.tsx
@@ -0,0 +1,68 @@
+// The far-left rail: global page nav with a visible label under each icon
+// (not tooltip-only), plus settings and the bench switcher at the bottom —
+// the two things the product correction moved out of the contextual panel.
+
+import { describe, expect, test } from "bun:test";
+import { renderToStaticMarkup } from "react-dom/server";
+
+import { BenchProvider } from "../src/bench-context";
+import { NavigationProvider } from "../src/navigation";
+import { NAV_ROUTES, SETTINGS_PATH } from "../src/routes";
+import { Rail } from "../src/shell/rail";
+
+const noop = () => undefined;
+const user = { id: "user_1", name: "Ada Lovelace", email: "ada@example.com" };
+
+// Rendered with react-dom/server: effects (and so `BenchProvider`'s own
+// fetch) never run, which is exactly what these markup assertions want —
+// `useBench` still needs a provider in the tree, it just never resolves.
+globalThis.fetch = ((_input: RequestInfo | URL, _init?: RequestInit) =>
+ Promise.reject(new Error("no network in static rail tests"))) as typeof fetch;
+
+function renderRail(path: string): string {
+ return renderToStaticMarkup(
+
+
+
+
+ ,
+ );
+}
+
+describe("Rail", () => {
+ test("shows every 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
+ // its presence is what makes the label visible rather than hover-gated.
+ for (const route of NAV_ROUTES) {
+ expect(markup).toMatch(
+ new RegExp(
+ `data-slot="sidebar-rail-item-label"[^>]*>${route.label}`,
+ ),
+ );
+ }
+ });
+
+ test("marks the active page and no other", () => {
+ const markup = renderRail("/chat");
+ const currentCount = (markup.match(/aria-current="page"/g) ?? []).length;
+ // One for the active page item, one for the (inactive) settings link.
+ expect(currentCount).toBe(1);
+ expect(markup).toMatch(
+ /data-slot="sidebar-rail-item" aria-current="page"[^>]*>[\s\S]*?Chat/,
+ );
+ });
+
+ test("carries the settings link and the bench switcher in its footer", () => {
+ const markup = renderRail("/");
+ expect(markup).toContain(`href="${SETTINGS_PATH}"`);
+ // The footer is `SidebarRail`'s `footer` slot; the identity dock it holds
+ // is what carries the settings link, so its class marks the footer present.
+ expect(markup).toContain("shell-rail-identity");
+ });
+
+ test("never shows the account id", () => {
+ expect(renderRail("/")).not.toContain("user_1");
+ });
+});
diff --git a/apps/web/test/routes.test.tsx b/apps/web/test/routes.test.tsx
index f02b43924..60f711eb5 100644
--- a/apps/web/test/routes.test.tsx
+++ b/apps/web/test/routes.test.tsx
@@ -34,17 +34,21 @@ function pageHeading(markup: string): string | undefined {
return /]*>(.*?)<\/h1>/.exec(markup)?.[1];
}
-/** The rail marks exactly one page active at a time (an icon button, not a
- * link — see `shell/rail.tsx`); this resolves its tooltip text so a test can
- * confirm it is the *right* page, not merely that some page is active. */
+/** The rail marks exactly one page active at a time (an icon+label button,
+ * not a link — see `shell/rail.tsx`); this resolves its visible caption so a
+ * test can confirm it is the *right* page, not merely that some page is
+ * active. The caption lives in `SidebarRail`'s `sidebar-rail-item-label`
+ * slot (its `showLabels` mode), keyed off the active item's `data-slot`. */
function activeRailLabel(markup: string): string | undefined {
const active =
- /data-slot="sidebar-rail-item"[^>]*aria-current="page"[^>]*aria-describedby="([^"]+)"/.exec(
+ /data-slot="sidebar-rail-item"[^>]*aria-current="page"[^>]*>[\s\S]*?<\/button>/.exec(
markup,
);
if (active === null) return undefined;
- const tooltip = new RegExp(`id="${active[1]}"[^>]*>([^<]*)<`).exec(markup);
- return tooltip?.[1];
+ const label = /data-slot="sidebar-rail-item-label"[^>]*>([^<]*)<\/span>/.exec(
+ active[0],
+ );
+ return label?.[1];
}
describe("route table", () => {
@@ -68,8 +72,8 @@ describe("routes render", () => {
const markup = renderApp(route.path);
expect(pageHeading(markup)).toBe(route.label);
if (route.path === SETTINGS_PATH) {
- // Settings has no rail entry — it is reached from the contextual
- // panel's identity dock instead.
+ // 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 {
expect(activeRailLabel(markup)).toBe(route.label);
diff --git a/apps/web/test/routine-activity.test.ts b/apps/web/test/routine-activity.test.ts
new file mode 100644
index 000000000..7617b442b
--- /dev/null
+++ b/apps/web/test/routine-activity.test.ts
@@ -0,0 +1,53 @@
+// The seam standing in for `@corbits/routines` (not on `main` yet): today it
+// maps `@corbits/chat-ui`'s workflow-run listing into `RoutineActivityItem`,
+// so the second column gets real bench-scoped activity instead of nothing.
+
+import { afterEach, describe, expect, test } from "bun:test";
+
+import { listRoutineActivity } from "../src/shell/routine-activity";
+
+const realFetch = globalThis.fetch;
+
+afterEach(() => {
+ globalThis.fetch = realFetch;
+});
+
+function stubRunsFetch(runs: readonly unknown[]): void {
+ globalThis.fetch = ((_input: RequestInfo | URL, _init?: RequestInit) =>
+ Promise.resolve(
+ new Response(JSON.stringify(runs), {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ }),
+ )) as typeof fetch;
+}
+
+describe("listRoutineActivity", () => {
+ test("maps a workflow run into a routine activity item", async () => {
+ stubRunsFetch([
+ {
+ id: "run_1",
+ tenantId: "tnt_1",
+ definitionAssetId: "researcher/workflow.json",
+ status: "running",
+ createdAt: "2026-01-01T00:00:00.000Z",
+ },
+ ]);
+
+ const items = await listRoutineActivity("tnt_1");
+
+ expect(items).toEqual([
+ {
+ id: "run_1",
+ name: "workflow",
+ status: "running",
+ startedAt: "2026-01-01T00:00:00.000Z",
+ },
+ ]);
+ });
+
+ test("an empty run list is an empty routine list", async () => {
+ stubRunsFetch([]);
+ expect(await listRoutineActivity("tnt_1")).toEqual([]);
+ });
+});
diff --git a/apps/web/test/shell-docks.test.tsx b/apps/web/test/shell-docks.test.tsx
index 5e4f498cf..cc70152de 100644
--- a/apps/web/test/shell-docks.test.tsx
+++ b/apps/web/test/shell-docks.test.tsx
@@ -1,12 +1,12 @@
-// The sidebar's bottom docks: the identity row shows the signed-in
-// human as initials + email (never an id, never a network-fetched
-// avatar), and the initials derivation holds up against thin accounts.
+// The rail's bottom identity dock shows the signed-in human as initials
+// (never an id, never a network-fetched avatar) with the email as a
+// tooltip, and the initials derivation holds up against thin accounts.
import { describe, expect, test } from "bun:test";
import { renderToStaticMarkup } from "react-dom/server";
import { NavigationProvider } from "../src/navigation";
-import { IdentityDock, initialsOf } from "../src/shell/docks";
+import { RailIdentity, initialsOf } from "../src/shell/docks";
const noNavigate = () => undefined;
const noop = () => undefined;
@@ -14,7 +14,7 @@ const noop = () => undefined;
function renderDock(path: string): string {
return renderToStaticMarkup(
- {
});
});
-describe("IdentityDock", () => {
+describe("RailIdentity", () => {
test("shows the avatar initials, the email, and the settings link", () => {
const markup = renderDock("/");
expect(markup).toContain("AL");