From 4e1688f072d1c9086e6c702a37c2750aec3c3519 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 15:47:21 -0700 Subject: [PATCH 1/5] Add tests for the rail/contextual-column split The rail becomes global page nav plus settings and the bench switcher; the contextual column becomes a bench-scoped live activity list (channels, chats, running routines, notifications) instead of a page list. Covers the labelled rail markup, the bench-activity data hook's empty/loading/ready states, the routine-activity seam mapping workflow runs into routine items, and an honest empty state for notifications. --- apps/web/test/bench-activity.test.tsx | 85 ++++++++++++++++++ apps/web/test/contextual-panel.test.tsx | 113 ++++++++++++++++++++++++ apps/web/test/rail.test.tsx | 61 +++++++++++++ apps/web/test/routes.test.tsx | 19 ++-- apps/web/test/routine-activity.test.ts | 53 +++++++++++ apps/web/test/shell-docks.test.tsx | 12 +-- 6 files changed, 329 insertions(+), 14 deletions(-) create mode 100644 apps/web/test/bench-activity.test.tsx create mode 100644 apps/web/test/contextual-panel.test.tsx create mode 100644 apps/web/test/rail.test.tsx create mode 100644 apps/web/test/routine-activity.test.ts 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..4951d69e2 --- /dev/null +++ b/apps/web/test/rail.test.tsx @@ -0,0 +1,61 @@ +// 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("/"); + for (const route of NAV_ROUTES) { + expect(markup).toContain( + `${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}"`); + expect(markup).toContain("shell-rail-footer"); + }); + + 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..9ead818f7 100644 --- a/apps/web/test/routes.test.tsx +++ b/apps/web/test/routes.test.tsx @@ -34,17 +34,20 @@ 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. */ 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 = /([^<]*)<\/span>/.exec( + active[0], + ); + return label?.[1]; } describe("route table", () => { @@ -68,8 +71,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"); From b3f5f84b60acdccb2595d29fd844301ae318a72f Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 15:47:28 -0700 Subject: [PATCH 2/5] Rail: labelled global nav plus settings and bench selector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The far-left column is global and stable now — it no longer changes with navigation or the selected bench. Page icons carry a visible caption underneath instead of tooltip-only text, and the bench switcher and the signed-in account's settings/sign-out move here from the contextual column's footer, since they answer "where am I in the product, and which bench am I in" rather than anything bench-local. --- apps/web/src/app.css | 107 ++++++++++++++++++++++++++++------- apps/web/src/shell/docks.tsx | 22 ++++--- apps/web/src/shell/rail.tsx | 60 ++++++++++++++------ 3 files changed, 143 insertions(+), 46 deletions(-) diff --git a/apps/web/src/app.css b/apps/web/src/app.css index 0118b7b99..f2b755581 100644 --- a/apps/web/src/app.css +++ b/apps/web/src/app.css @@ -110,25 +110,98 @@ `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 { +/* Column 1: the global rail. Fixed width at every breakpoint — it is + already as narrow as the shell gets, so it never joins the columns that + withdraw as the viewport shrinks. */ +.shell-rail { display: flex; + height: 100%; + width: 4.5rem; + flex-shrink: 0; + flex-direction: column; + align-items: center; + border-right: 1px solid var(--border); + background: var(--card); + padding: 0.5rem 0; +} + +.shell-rail-nav { + display: flex; + width: 100%; + flex-direction: column; + align-items: center; + gap: 0.25rem; + margin: 0; + padding: 0; + list-style: none; +} + +.shell-rail-item { + display: flex; + width: 3.75rem; flex-direction: column; + align-items: center; + gap: 0.25rem; + border: none; + border-radius: 10px; + padding: 0.5rem 0.25rem; + background: transparent; + color: var(--muted-foreground); + cursor: pointer; + transition: + color 150ms, + background-color 150ms; +} + +.shell-rail-item:hover { + background: var(--muted); + color: var(--foreground); +} + +.shell-rail-item[aria-current="page"] { + background: color-mix(in srgb, var(--primary) 10%, transparent); + color: var(--primary-emphasis); +} + +.shell-rail-item-icon svg { + height: 1.25rem; + width: 1.25rem; +} + +.shell-rail-item-label { + max-width: 100%; + overflow: hidden; + font-size: 0.625rem; + font-weight: 500; + line-height: 1; + text-overflow: ellipsis; + white-space: nowrap; +} + +.shell-rail-footer { + display: flex; + width: 100%; + flex-direction: column; + align-items: center; + gap: 0.25rem; + margin-top: auto; + padding-top: 0.5rem; } .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 +214,7 @@ color: var(--primary-foreground); } -.shell-identity-settings { +.shell-rail-identity-settings { display: grid; height: 28px; width: 28px; @@ -151,18 +224,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/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)} - +
handleLinkClick(event, SETTINGS_PATH, navigate)} > + + {initialsOf(user.name, user.email)} + - {user.email}
); } diff --git a/apps/web/src/shell/rail.tsx b/apps/web/src/shell/rail.tsx index 7343afc56..d78a751ca 100644 --- a/apps/web/src/shell/rail.tsx +++ b/apps/web/src/shell/rail.tsx @@ -1,12 +1,18 @@ -// 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. - -import { SidebarRail } from "@corbits/react-ui"; +// 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. +// +// `@corbits/react-ui`'s `SidebarRail` is icon-only by design (see its own +// doc comment), so a caption-under-icon rail isn't available from the +// published version pinned in `package.json` yet — the item markup here +// intentionally mirrors that component's anatomy (`data-slot` values +// included, since `focus-rescue.ts` reaches for `sidebar-rail-item` by that +// contract) so a future bump to a version that grows a `showLabels` option +// is a drop-in swap, not a rewrite. import { NAV_ROUTES, matchesRoute, type AppRoute } from "../routes"; +import type { SessionUser } from "../session"; +import { BenchDock, RailIdentity } from "./docks"; function railItemId(route: AppRoute): string { return route.path; @@ -15,21 +21,39 @@ function railItemId(route: AppRoute): string { 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)); return ( - ({ - id: railItemId(route), - label: route.label, - icon: route.icon, - }))} - activeId={active === undefined ? "" : railItemId(active)} - onSelect={onNavigate} - /> + ); } From 480d38e28a1acd4cadcc0587221b73b8f557e2d9 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 15:47:36 -0700 Subject: [PATCH 3/5] Contextual column: bench-scoped live activity replaces the page list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The second column now answers "what is happening in this bench right now" — channels, chats, running routines, and a notifications slot for the current bench — instead of duplicating the rail's page list. It refetches when the selected bench changes, not when the route does, so its contents persist across page navigation rather than being page-scoped. Running routines are sourced from the existing workflow-run listing behind a small seam (routine-activity.ts) so a dedicated routines package can replace it later without touching the column itself. Notifications have no backing feature in the hub yet, so that section is an honest empty state rather than sample data. --- apps/web/src/shell/app-shell.tsx | 14 +- apps/web/src/shell/bench-activity.ts | 62 ++++++++ apps/web/src/shell/contextual-panel.tsx | 188 ++++++++++++++++++------ apps/web/src/shell/routine-activity.ts | 32 ++++ 4 files changed, 248 insertions(+), 48 deletions(-) create mode 100644 apps/web/src/shell/bench-activity.ts create mode 100644 apps/web/src/shell/routine-activity.ts 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/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)); +} From 251e384d0922f922b9776e011105410daaecbe1e Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 15:47:45 -0700 Subject: [PATCH 4/5] Update docs: rail and contextual column now mean different things The Layout section describes the rail as global/stable and the contextual column as bench-scoped/live, matches the routine-activity seam standing in for a routines package, and drops the stale claim that the contextual column reads the page route table. --- apps/web/README.md | 32 +++++++++++++++++++++++--------- 1 file changed, 23 insertions(+), 9 deletions(-) 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 From 7d8044117cf99d6e677b40d6da0de004d994e087 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 18:16:34 -0700 Subject: [PATCH 5/5] Swap the hand-rolled rail for the library SidebarRail with labels The caption-under-icon rail now lives in @corbits/react-ui's SidebarRail showLabels option, so the local item markup and CSS can drop. Footer docks still compose into the library footer's slot. --- apps/web/src/app.css | 82 ++--------------------------------- apps/web/src/shell/rail.tsx | 67 +++++++++++++--------------- apps/web/test/rail.test.tsx | 13 ++++-- apps/web/test/routes.test.tsx | 5 ++- 4 files changed, 48 insertions(+), 119 deletions(-) diff --git a/apps/web/src/app.css b/apps/web/src/app.css index f2b755581..ee1ed7615 100644 --- a/apps/web/src/app.css +++ b/apps/web/src/app.css @@ -110,84 +110,10 @@ `prefers-reduced-motion` in its theme.css), and these transitions inherit that like any other. */ -/* Column 1: the global rail. Fixed width at every breakpoint — it is - already as narrow as the shell gets, so it never joins the columns that - withdraw as the viewport shrinks. */ -.shell-rail { - display: flex; - height: 100%; - width: 4.5rem; - flex-shrink: 0; - flex-direction: column; - align-items: center; - border-right: 1px solid var(--border); - background: var(--card); - padding: 0.5rem 0; -} - -.shell-rail-nav { - display: flex; - width: 100%; - flex-direction: column; - align-items: center; - gap: 0.25rem; - margin: 0; - padding: 0; - list-style: none; -} - -.shell-rail-item { - display: flex; - width: 3.75rem; - flex-direction: column; - align-items: center; - gap: 0.25rem; - border: none; - border-radius: 10px; - padding: 0.5rem 0.25rem; - background: transparent; - color: var(--muted-foreground); - cursor: pointer; - transition: - color 150ms, - background-color 150ms; -} - -.shell-rail-item:hover { - background: var(--muted); - color: var(--foreground); -} - -.shell-rail-item[aria-current="page"] { - background: color-mix(in srgb, var(--primary) 10%, transparent); - color: var(--primary-emphasis); -} - -.shell-rail-item-icon svg { - height: 1.25rem; - width: 1.25rem; -} - -.shell-rail-item-label { - max-width: 100%; - overflow: hidden; - font-size: 0.625rem; - font-weight: 500; - line-height: 1; - text-overflow: ellipsis; - white-space: nowrap; -} - -.shell-rail-footer { - display: flex; - width: 100%; - flex-direction: column; - align-items: center; - gap: 0.25rem; - margin-top: auto; - padding-top: 0.5rem; -} - +/* 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 { width: 100%; padding: 0 0.375rem; diff --git a/apps/web/src/shell/rail.tsx b/apps/web/src/shell/rail.tsx index d78a751ca..686318728 100644 --- a/apps/web/src/shell/rail.tsx +++ b/apps/web/src/shell/rail.tsx @@ -2,22 +2,18 @@ // bench am I in" — the page icons never change with navigation or with the // selected bench, and neither does this column's width. // -// `@corbits/react-ui`'s `SidebarRail` is icon-only by design (see its own -// doc comment), so a caption-under-icon rail isn't available from the -// published version pinned in `package.json` yet — the item markup here -// intentionally mirrors that component's anatomy (`data-slot` values -// included, since `focus-rescue.ts` reaches for `sidebar-rail-item` by that -// contract) so a future bump to a version that grows a `showLabels` option -// is a drop-in swap, not a rewrite. +// 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"; import type { SessionUser } from "../session"; import { BenchDock, RailIdentity } from "./docks"; -function railItemId(route: AppRoute): string { - return route.path; -} - export function Rail({ path, onNavigate, @@ -29,31 +25,30 @@ export function Rail({ readonly user: SessionUser; 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. + const activeRoute = NAV_ROUTES.find((route) => + matchesRoute(route.path, path), + ); + return ( - + ({ + id: route.path, + label: route.label, + icon: route.icon, + }))} + onSelect={onNavigate} + footer={ + <> + + + + } + /> ); } diff --git a/apps/web/test/rail.test.tsx b/apps/web/test/rail.test.tsx index 4951d69e2..86121ed06 100644 --- a/apps/web/test/rail.test.tsx +++ b/apps/web/test/rail.test.tsx @@ -32,9 +32,14 @@ function renderRail(path: string): string { 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).toContain( - `${route.label}`, + expect(markup).toMatch( + new RegExp( + `data-slot="sidebar-rail-item-label"[^>]*>${route.label}`, + ), ); } }); @@ -52,7 +57,9 @@ describe("Rail", () => { test("carries the settings link and the bench switcher in its footer", () => { const markup = renderRail("/"); expect(markup).toContain(`href="${SETTINGS_PATH}"`); - expect(markup).toContain("shell-rail-footer"); + // 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", () => { diff --git a/apps/web/test/routes.test.tsx b/apps/web/test/routes.test.tsx index 9ead818f7..60f711eb5 100644 --- a/apps/web/test/routes.test.tsx +++ b/apps/web/test/routes.test.tsx @@ -37,14 +37,15 @@ function pageHeading(markup: string): string | undefined { /** 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. */ + * 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"[^>]*>[\s\S]*?<\/button>/.exec( markup, ); if (active === null) return undefined; - const label = /([^<]*)<\/span>/.exec( + const label = /data-slot="sidebar-rail-item-label"[^>]*>([^<]*)<\/span>/.exec( active[0], ); return label?.[1];