From 11a720a1d957e73349d2ebba9ff459e5cfbef87d Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 9 Aug 2026 15:04:29 -0700 Subject: [PATCH 1/3] CL-5850: Rename notifications band to Activity and hide when empty The product name for the global needs-you surface is Activity. Rename the band, keep it mounted on every page, and drop the hollow empty state so the band only appears while loading or when items exist. --- apps/web/src/routes.tsx | 4 +- ...tifications-band.tsx => activity-band.tsx} | 123 +++++++++--------- apps/web/src/shell/contextual-panel.tsx | 9 +- apps/web/test/contextual-panel.test.tsx | 15 ++- 4 files changed, 74 insertions(+), 77 deletions(-) rename apps/web/src/shell/{notifications-band.tsx => activity-band.tsx} (65%) diff --git a/apps/web/src/routes.tsx b/apps/web/src/routes.tsx index 7e4f24c98..efe901c5e 100644 --- a/apps/web/src/routes.tsx +++ b/apps/web/src/routes.tsx @@ -4,7 +4,7 @@ // sidebar's identity dock, not the top nav — `NAV_ROUTES` is what the nav // list shows. Channel deep links (`/c/:channelId`) stay routable for the // main-pane fallback when the canvas column is not available; the rail no -// longer lists Chat. Approvals has no page — the notifications band owns them. +// longer lists Chat. Approvals has no page — the Activity band owns them. // `/` is the Myra land hop (ensure + open channel), not a Home dashboard. import { @@ -38,7 +38,7 @@ export const SETTINGS_PATH = "/settings"; /** Paths the rail lists — product nav; channels open in the canvas. * Home is not a rail destination (Myra land is `/` only as a redirect hop). - * Approvals has no route at all (notifications band owns its surface). */ + * Approvals has no route at all (Activity band owns its surface). */ const RAIL_NAV_PATHS = new Set([ "/routines", "/library", diff --git a/apps/web/src/shell/notifications-band.tsx b/apps/web/src/shell/activity-band.tsx similarity index 65% rename from apps/web/src/shell/notifications-band.tsx rename to apps/web/src/shell/activity-band.tsx index 44e03c3c8..47647d194 100644 --- a/apps/web/src/shell/notifications-band.tsx +++ b/apps/web/src/shell/activity-band.tsx @@ -1,7 +1,7 @@ -// The global notifications band — a permanent section of the contextual -// panel (shown on every page, like pins), not page-specific. Today its only -// source is "needs you" approvals: pending permission requests the signed-in -// user must approve or deny. The list reads +// The global activity band — a permanent section of the contextual panel +// (shown on every page, like pins), not page-specific. Today its only source +// is "needs you" approvals: pending permission requests the signed-in user +// must approve or deny. The list reads // `GET /api/tenants/:tenantId/approvals/needs-you` (`@corbits/approvals`), // which resolves each pending approval's agent and bench names so nothing // here renders a raw agent address or run id. Approve/reject post straight @@ -11,9 +11,9 @@ // and grant-scoped there. Approve only offers scope "once" — the hub rejects // "always" with a 400 — and reject collects an optional message. // -// This is the new home for approvals after the `/approvals` page was killed: -// the page left the rail long ago, now the deep link is gone too, and the -// actionable cards live inline here wherever the user happens to be. +// Per product, the band hides entirely once it resolves empty: no hollow +// empty-state. It stays mounted while loading (or once items arrive) so the +// user can resolve approvals without leaving the current page. import { ApprovalCard, @@ -26,10 +26,9 @@ import { DialogFooter, DialogHeader, DialogTitle, - EmptyState, } from "@corbits/react-ui"; import type { ApprovalRequest } from "@corbits/react-ui"; -import { Bell, ShieldCheck } from "lucide-react"; +import { ShieldCheck } from "lucide-react"; import { useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; @@ -44,7 +43,7 @@ import { useBench } from "../bench-context"; import { tenantKeys } from "../query-client"; import { QueryView } from "../query-view"; -export function NotificationsBand() { +export function ActivityBand() { const { selectedTenantId } = useBench(); const queryClient = useQueryClient(); const [approvingId, setApprovingId] = useState(null); @@ -65,6 +64,10 @@ export function NotificationsBand() { ? { kind: "ready", data: approvals.data.items } : approvals; + // Empty and resolved (with a tenant) hides the band entirely; loading and + // non-empty still render so approvals stay reachable mid-flight. + if (rows.kind === "ready" && rows.data.length === 0) return null; + const pendingCount = rows.kind === "ready" ? rows.data.length : 0; function reload() { @@ -96,11 +99,11 @@ export function NotificationsBand() { return (

- Notifications + Activity {pendingCount > 0 ? ( {pendingCount} @@ -108,56 +111,48 @@ export function NotificationsBand() { ) : null}

- {(items) => - items.length === 0 ? ( - } - title="No notifications yet" - description="Approvals waiting on you — and mentions and mail-backed alerts once those sources are wired up — land here." - /> - ) : ( -
- {items.map((approval) => { - const request: ApprovalRequest = { - id: approval.id, - headline: approval.headline, - requestedBy: `${approval.agentName} in ${approval.benchName}`, - details: Object.entries(approval.arguments).map( - ([label, value]) => ({ - label, - value: - typeof value === "string" - ? value - : JSON.stringify(value), - }), - ), - }; - const state = - approvingId === approval.id - ? "approving" - : rejectingId === approval.id - ? "rejecting" - : "idle"; - return ( - handleApprove(approval)} - onReject={() => setRejectTarget(approval)} - state={state} - error={ - (approvingId === approval.id || - rejectingId === approval.id) && - actionError !== null - ? actionError - : null - } - /> - ); - })} -
- ) - } + {(items) => ( +
+ {items.map((approval) => { + const request: ApprovalRequest = { + id: approval.id, + headline: approval.headline, + requestedBy: `${approval.agentName} in ${approval.benchName}`, + details: Object.entries(approval.arguments).map( + ([label, value]) => ({ + label, + value: + typeof value === "string" + ? value + : JSON.stringify(value), + }), + ), + }; + const state = + approvingId === approval.id + ? "approving" + : rejectingId === approval.id + ? "rejecting" + : "idle"; + return ( + handleApprove(approval)} + onReject={() => setRejectTarget(approval)} + state={state} + error={ + (approvingId === approval.id || + rejectingId === approval.id) && + actionError !== null + ? actionError + : null + } + /> + ); + })} +
+ )}
) : null} - + {pageSpecific !== null ? (
{ container.remove(); }); - test("notifications band is global and shows an honest empty state", async () => { - // The notifications band now lives on every page (approvals were killed - // as a route), so it renders at "/" — not just on a /approvals page. - // Needs a resolved bench (memberships) so the band can query needs-you. + test("activity band hides entirely once it resolves empty", async () => { + // The activity band lives on every page (approvals were killed as a + // route), so it renders at "/". Once the needs-you query resolves empty + // the whole band — heading included — is omitted per product: no hollow + // empty-state chrome. Needs a resolved bench so the band can query. const membership = { data: [ { @@ -217,14 +218,14 @@ describe("ContextualPanel", () => { , ); }); + // Let the needs-you query resolve, then settle. for (let i = 0; i < 40; i++) { await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); - if (container.innerHTML.includes("No notifications yet")) break; } - expect(container.innerHTML).toContain("No notifications yet"); - expect(container.innerHTML).toContain("Notifications"); + expect(container.innerHTML).not.toContain("panel-band-activity"); + expect(container.innerHTML).not.toContain(">Activity<"); root.unmount(); container.remove(); }); From 2786b7d7f0f6ad4b85dabbeb69379be06af5f8f8 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 9 Aug 2026 16:25:57 -0700 Subject: [PATCH 2/3] Format with Prettier for CI --- apps/web/src/shell/activity-band.tsx | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/apps/web/src/shell/activity-band.tsx b/apps/web/src/shell/activity-band.tsx index 47647d194..59e96ded9 100644 --- a/apps/web/src/shell/activity-band.tsx +++ b/apps/web/src/shell/activity-band.tsx @@ -98,10 +98,7 @@ export function ActivityBand() { } return ( -
+

Activity {pendingCount > 0 ? ( @@ -122,9 +119,7 @@ export function ActivityBand() { ([label, value]) => ({ label, value: - typeof value === "string" - ? value - : JSON.stringify(value), + typeof value === "string" ? value : JSON.stringify(value), }), ), }; From a4ca6a34663128b7c69eb47595df6ecedd55a595 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 9 Aug 2026 18:08:57 -0700 Subject: [PATCH 3/3] Pass onOpenInCanvas into the narrow drawer panel The responsive drawer path rendered ContextualPanel without the canvas open handler, which fails typecheck after that prop became required. --- apps/web/src/shell/app-shell.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/src/shell/app-shell.tsx b/apps/web/src/shell/app-shell.tsx index 505dcdf93..ae64a5e23 100644 --- a/apps/web/src/shell/app-shell.tsx +++ b/apps/web/src/shell/app-shell.tsx @@ -143,6 +143,7 @@ export function AppShell({ canvasOpen={canvasState.open} onToggleCanvas={() => setCanvasState(toggleCanvasColumn)} canvasAllowed={canvasAllowed} + onOpenInCanvas={handleOpenInCanvas} />