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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/web/src/routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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,
Expand All @@ -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";

Expand All @@ -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<string | null>(null);
Expand All @@ -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() {
Expand Down Expand Up @@ -95,69 +98,56 @@ export function NotificationsBand() {
}

return (
<section
className="panel-band panel-band-notifications"
aria-label="Notifications"
>
<section className="panel-band panel-band-activity" aria-label="Activity">
<h3 className="panel-band-heading">
Notifications
Activity
{pendingCount > 0 ? (
<Badge tone="info" className="panel-band-badge">
{pendingCount}
</Badge>
) : null}
</h3>
<QueryView query={rows} label="approvals">
{(items) =>
items.length === 0 ? (
<EmptyState
icon={<Bell />}
title="No notifications yet"
description="Approvals waiting on you — and mentions and mail-backed alerts once those sources are wired up — land here."
/>
) : (
<div className="notifications-list">
{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 (
<ApprovalCard
key={approval.id}
request={request}
onApprove={() => handleApprove(approval)}
onReject={() => setRejectTarget(approval)}
state={state}
error={
(approvingId === approval.id ||
rejectingId === approval.id) &&
actionError !== null
? actionError
: null
}
/>
);
})}
</div>
)
}
{(items) => (
<div className="activity-list">
{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 (
<ApprovalCard
key={approval.id}
request={request}
onApprove={() => handleApprove(approval)}
onReject={() => setRejectTarget(approval)}
state={state}
error={
(approvingId === approval.id ||
rejectingId === approval.id) &&
actionError !== null
? actionError
: null
}
/>
);
})}
</div>
)}
</QueryView>
<RejectDialog
approval={rejectTarget}
Expand Down Expand Up @@ -228,7 +218,7 @@ function RejectDialog({
);
}

// `ShieldCheck` is kept available for future non-empty notification sources
// `ShieldCheck` is kept available for future non-empty activity sources
// (approvals already render through `ApprovalCard`); re-exported so the icon
// import is not flagged unused while the only source is approvals.
void ShieldCheck;
1 change: 1 addition & 0 deletions apps/web/src/shell/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ export function AppShell({
canvasOpen={canvasState.open}
onToggleCanvas={() => setCanvasState(toggleCanvasColumn)}
canvasAllowed={canvasAllowed}
onOpenInCanvas={handleOpenInCanvas}
/>
</div>
</>
Expand Down
9 changes: 5 additions & 4 deletions apps/web/src/shell/contextual-panel.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
// Column 2: route-aware contextual panel with three bands.
// Column 2: route-aware contextual panel with four bands.
//
// 1. Page band — title, settings entry, quick actions, canvas toggle.
// 2. Global pins — user-curated, same on every page (hidden when empty).
// 3. Page-specific — contribution content for the current route (omitted when null).
// 3. Global Activity — needs-you approvals (hidden when empty).
// 4. Page-specific — contribution content for the current route (omitted when null).
//
// Live activity lives here (left), never in the right canvas. Clicking a
// list item navigates to the full surface for that entity.
Expand All @@ -12,7 +13,7 @@ import { Settings } from "lucide-react";
import { useState } from "react";

import { CanvasToggle } from "./canvas-column";
import { NotificationsBand } from "./notifications-band";
import { ActivityBand } from "./activity-band";
import { resolvePanelContribution } from "./panel-contribution";
import { ensurePanelContributions } from "./panel-contributions";
import { loadPins, type Pin } from "./pins";
Expand Down Expand Up @@ -113,7 +114,7 @@ export function ContextualPanel({
</section>
) : null}

<NotificationsBand />
<ActivityBand />

{pageSpecific !== null ? (
<section
Expand Down
15 changes: 8 additions & 7 deletions apps/web/test/contextual-panel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -154,10 +154,11 @@ describe("ContextualPanel", () => {
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: [
{
Expand Down Expand Up @@ -217,14 +218,14 @@ describe("ContextualPanel", () => {
</TestQueryProvider>,
);
});
// 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();
});
Expand Down
Loading