diff --git a/ui/src/App.tsx b/ui/src/App.tsx index c6a1d4be..d94f622b 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -184,6 +184,11 @@ function boardRoutes() { } /> } /> } /> + } /> + } /> + } /> + } /> + } /> } /> } /> } /> @@ -213,6 +218,7 @@ function boardRoutes() { } /> } /> } /> + } /> } /> } /> } /> @@ -399,6 +405,11 @@ export function App() { } /> } /> } /> + } /> + } /> + } /> + } /> + } /> } /> } /> }> diff --git a/ui/src/components/frame/IconRail.test.tsx b/ui/src/components/frame/IconRail.test.tsx index eb0ab014..8ad33152 100644 --- a/ui/src/components/frame/IconRail.test.tsx +++ b/ui/src/components/frame/IconRail.test.tsx @@ -7,9 +7,9 @@ import { MemoryRouter } from "@/lib/router"; import { IconRail } from "./IconRail"; // The real @/lib/router Link wrapper calls useCompany(), which throws -// outside a CompanyProvider. The IconRail doesn't depend on the company -// context for building URLs (it receives companyPrefix as a prop), so we -// stub useCompany() to return a minimal value so Link can render in tests. +// outside a CompanyProvider. The IconRail also calls useCompany() directly +// (for the gate-indicator wiring added after Phase 11). We stub the context +// so tests can render without a full provider tree. vi.mock("@/context/CompanyContext", () => ({ useCompany: () => ({ companies: [], @@ -26,6 +26,21 @@ vi.mock("@/context/CompanyContext", () => ({ }), })); +// useGateIndicator uses @tanstack/react-query's useQuery under the hood, +// which requires a QueryClientProvider. We stub the hook with a vi.fn() +// spy so per-test overrides can call mockReturnValueOnce() to flip the +// `hasPendingGates` state without dynamic imports. +vi.mock("../../hooks/useGateIndicator", () => ({ + useGateIndicator: vi.fn(() => ({ + hasPendingGates: false, + count: 0, + loading: false, + error: null, + })), +})); + +import { useGateIndicator } from "../../hooks/useGateIndicator"; + // Tell React this environment uses act() for event flushing. // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; @@ -115,4 +130,68 @@ describe("IconRail", () => { const { getLinkByLabel } = renderRail("/instance/settings/general"); expect(getLinkByLabel("Settings")?.getAttribute("aria-current")).toBe("page"); }); + + it("does not render the pending-gate dot when the gate indicator is empty", () => { + // Default mock returns hasPendingGates: false + renderRail("/NEX/assistant"); + expect(container.querySelector("[data-testid='icon-rail-assistant-dot']")).toBeNull(); + }); +}); + +describe("IconRail with pending gates", () => { + let container: HTMLDivElement; + let root: ReturnType | null = null; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = null; + // Override the default mock for this suite: gates are pending. + vi.mocked(useGateIndicator).mockReturnValue({ + hasPendingGates: true, + count: 2, + loading: false, + error: null, + }); + }); + + afterEach(() => { + if (root) { + act(() => { + root!.unmount(); + }); + root = null; + } + if (container.parentNode) { + container.remove(); + } + // Reset the mock so the sibling suite gets the default no-gates behavior. + vi.mocked(useGateIndicator).mockReset(); + vi.mocked(useGateIndicator).mockReturnValue({ + hasPendingGates: false, + count: 0, + loading: false, + error: null, + }); + }); + + it("renders a volt dot on the Assistant destination when hasPendingGates is true", () => { + root = createRoot(container); + act(() => { + root!.render( + + + , + ); + }); + + const dot = container.querySelector("[data-testid='icon-rail-assistant-dot']"); + expect(dot).not.toBeNull(); + + // The Assistant link's accessible name should surface the pending state. + const assistantLink = container.querySelector( + "nav[aria-label='Primary'] a[aria-current='page']", + ); + expect(assistantLink?.getAttribute("aria-label")).toBe("Assistant (pending gates)"); + }); }); diff --git a/ui/src/components/frame/IconRail.tsx b/ui/src/components/frame/IconRail.tsx index 7c83dfc1..50729b1b 100644 --- a/ui/src/components/frame/IconRail.tsx +++ b/ui/src/components/frame/IconRail.tsx @@ -1,6 +1,8 @@ import { MessageCircle, Sparkles, FolderKanban, Settings } from "lucide-react"; import { Link, useLocation } from "@/lib/router"; import { cn } from "@/lib/utils"; +import { useCompany } from "../../context/CompanyContext"; +import { useGateIndicator } from "../../hooks/useGateIndicator"; interface IconRailProps { /** @@ -80,6 +82,12 @@ const DESTINATIONS: Destination[] = [ export function IconRail({ companyPrefix }: IconRailProps) { const { pathname } = useLocation(); + // Phase 11 integration: pending gates → volt dot on the Assistant destination. + // The hook reads the approvals endpoint scoped to the currently-selected + // company. Phase 14 will globalize notifications further; for now, this is + // the only notification surface in the app per spec §10.4. + const { selectedCompanyId } = useCompany(); + const { hasPendingGates } = useGateIndicator(selectedCompanyId); return (