From 1b7e3d44fef8786016dcfb37a25e6edb97fa514a Mon Sep 17 00:00:00 2001 From: Nexus Dev Date: Sat, 11 Apr 2026 12:32:56 +0000 Subject: [PATCH] refactor(nexus): wire wave 2 routing and icon rail gate indicator Controller integration pass after the three wave 2 subagents (phases 9, 10, 11) completed their phase implementations. Three changes in one commit because they're a single coordinated post-dispatch step: 1. App.tsx routing - Adds 5 new per-project builder tab routes for phase 11: projects/:projectId/agents projects/:projectId/gates projects/:projectId/costs projects/:projectId/activity projects/:projectId/org plus their unprefixed UnprefixedBoardRedirect variants so direct nav and deep links resolve through the same fallback chain as /overview and /issues. - Adds content-studio/:workshopSlug as a sibling route for phase 10's workshop detail view. Without this, clicking a workshop card hit the * fallback NotFoundPage because the existing content-studio route was an exact match and the ContentStudio-internal pathname workaround couldn't fire. - Does NOT rename the legacy /convert route. ConvertPage still renders directly at /convert for backwards compat; Studio's Convert workshop reuses the ConvertPanel body inside its own detail shell. 2. IconRail volt-dot indicator - Imports useCompany from CompanyContext and useGateIndicator from the new phase 11 hook. - When selectedCompanyId resolves to a company with at least one pending approval (displayed as "gates" per phase 11's display rename), renders a 6px volt dot overlay in the top-right of the Assistant destination icon and updates the link's aria-label to "Assistant (pending gates)". - This is the single global notification surface specified by spec section 10.4 - no badge counts, no inbox icons, no toasts. 3. IconRail.test.tsx - Mocks useGateIndicator at module scope so tests don't need a QueryClientProvider for the rail's useQuery-backed data. - Replaces the plain function mock with a vi.fn() spy so per-suite overrides can flip hasPendingGates without dynamic imports. - Adds a sibling describe block that verifies the volt dot renders and the aria-label updates when hasPendingGates is true. - 7 original tests pass; 2 new tests cover dot-absent and dot-present cases. 9 tests total. Verification: 211/211 tests passing across 22 files in the combined frame + assistant + studio + projects suites; tsc clean on every wave 1 and wave 2 file plus App.tsx. Co-Authored-By: Claude Opus 4.6 (1M context) --- ui/src/App.tsx | 11 +++ ui/src/components/frame/IconRail.test.tsx | 85 ++++++++++++++++++++++- ui/src/components/frame/IconRail.tsx | 23 +++++- 3 files changed, 115 insertions(+), 4 deletions(-) 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 (