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)} - +
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..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");