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 (
))}
@@ -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({
)}
+ {/* Pending-gate indicator — Phase 11 spec §10.4 */}
+ {showPendingDot && (
+
+ )}
);
}