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:
parent
1e85565765
commit
1b7e3d44fe
3 changed files with 115 additions and 4 deletions
|
|
@ -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 />}>
|
||||
|
|
|
|||
|
|
@ -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)");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue