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) <noreply@anthropic.com>
This commit is contained in:
Nexus Dev 2026-04-11 12:32:56 +00:00
parent 1e85565765
commit 1b7e3d44fe
3 changed files with 115 additions and 4 deletions

View file

@ -184,6 +184,11 @@ function boardRoutes() {
<Route path="projects/:projectId/overview" element={<ProjectDetail />} />
<Route path="projects/:projectId/issues" element={<ProjectDetail />} />
<Route path="projects/:projectId/issues/:filter" element={<ProjectDetail />} />
<Route path="projects/:projectId/agents" element={<ProjectDetail />} />
<Route path="projects/:projectId/gates" element={<ProjectDetail />} />
<Route path="projects/:projectId/costs" element={<ProjectDetail />} />
<Route path="projects/:projectId/activity" element={<ProjectDetail />} />
<Route path="projects/:projectId/org" element={<ProjectDetail />} />
<Route path="projects/:projectId/configuration" element={<ProjectDetail />} />
<Route path="projects/:projectId/budget" element={<ProjectDetail />} />
<Route path="issues" element={<Issues />} />
@ -213,6 +218,7 @@ function boardRoutes() {
<Route path="assistant" element={<PersonalAssistant />} />
<Route path="assistant/:conversationId" element={<PersonalAssistant />} />
<Route path="content-studio" element={<ContentStudio />} />
<Route path="content-studio/:workshopSlug" element={<ContentStudio />} />
<Route path="convert" element={<ConvertPage />} />
<Route path="convert/:sourceFormat" element={<ConvertPage />} />
<Route path="convert/:sourceFormat/:targetFormat" element={<ConvertPage />} />
@ -399,6 +405,11 @@ export function App() {
<Route path="projects/:projectId/overview" element={<UnprefixedBoardRedirect />} />
<Route path="projects/:projectId/issues" element={<UnprefixedBoardRedirect />} />
<Route path="projects/:projectId/issues/:filter" element={<UnprefixedBoardRedirect />} />
<Route path="projects/:projectId/agents" element={<UnprefixedBoardRedirect />} />
<Route path="projects/:projectId/gates" element={<UnprefixedBoardRedirect />} />
<Route path="projects/:projectId/costs" element={<UnprefixedBoardRedirect />} />
<Route path="projects/:projectId/activity" element={<UnprefixedBoardRedirect />} />
<Route path="projects/:projectId/org" element={<UnprefixedBoardRedirect />} />
<Route path="projects/:projectId/configuration" element={<UnprefixedBoardRedirect />} />
<Route path="tests/ux/runs" element={<UnprefixedBoardRedirect />} />
<Route path=":companyPrefix" element={<Layout />}>

View file

@ -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<typeof createRoot> | 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(
<MemoryRouter initialEntries={["/NEX/assistant"]}>
<IconRail companyPrefix="NEX" />
</MemoryRouter>,
);
});
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)");
});
});

View file

@ -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 (
<nav
@ -102,6 +110,7 @@ export function IconRail({ companyPrefix }: IconRailProps) {
destination={dest}
companyPrefix={companyPrefix}
pathname={pathname}
showPendingDot={dest.key === "assistant" && hasPendingGates}
/>
))}
</ul>
@ -121,10 +130,12 @@ function DestinationLink({
destination,
companyPrefix,
pathname,
showPendingDot = false,
}: {
destination: Destination;
companyPrefix: string | null;
pathname: string;
showPendingDot?: boolean;
}) {
const Icon = destination.icon;
const active = destination.isActive(pathname);
@ -134,7 +145,9 @@ function DestinationLink({
<li className="relative">
<Link
to={href}
aria-label={destination.label}
aria-label={
showPendingDot ? `${destination.label} (pending gates)` : destination.label
}
aria-current={active ? "page" : undefined}
title={destination.label}
className={cn(
@ -153,6 +166,14 @@ function DestinationLink({
className="absolute right-0 top-1/2 h-5 w-[2px] -translate-y-1/2 bg-primary"
/>
)}
{/* Pending-gate indicator — Phase 11 spec §10.4 */}
{showPendingDot && (
<span
data-testid="icon-rail-assistant-dot"
aria-hidden="true"
className="absolute right-1 top-1 h-1.5 w-1.5 rounded-full bg-primary"
/>
)}
</li>
);
}