diff --git a/tests/e2e/sidebar-takeover.spec.ts b/tests/e2e/sidebar-takeover.spec.ts
index b5a4457c7a9..9223721775f 100644
--- a/tests/e2e/sidebar-takeover.spec.ts
+++ b/tests/e2e/sidebar-takeover.spec.ts
@@ -97,6 +97,28 @@ test.describe("Sidebar takeover (collapse + secondary pane)", () => {
await expect(page.getByLabel(APP_SIDEBAR_EXPANDED_MARKER)).toHaveCount(0);
});
+ test("renders the secondary pane nav labels at full width despite the app rail collapse", async ({ page }) => {
+ // Regression (PAP-10700): the secondary pane is 240px wide, but its
+ // SidebarNavItem children read the *global* collapsed state and used to
+ // render icon-only (label `w-0 text-transparent`), making the settings nav
+ // unreadable in the default takeover state. The pane must force full labels.
+ await page.goto(`/${prefix}/company/settings`);
+
+ const secondary = page.locator("[data-secondary-sidebar]");
+ await expect(secondary).toBeVisible();
+
+ // App sidebar is collapsed to its rail (default unpinned takeover state)...
+ await expect(page.getByLabel(APP_SIDEBAR_EXPANDED_MARKER)).toHaveCount(0);
+
+ // ...yet a settings nav label renders at its full text width, not clipped to
+ // zero. "Environments" is unique to the company-settings nav.
+ const envLabel = secondary.getByText("Environments", { exact: true });
+ await expect(envLabel).toBeVisible();
+ const labelBox = await envLabel.boundingBox();
+ expect(labelBox).not.toBeNull();
+ expect(labelBox!.width).toBeGreaterThan(20);
+ });
+
test("an explicit expanded pin overrides the route-driven collapse", async ({ page }) => {
// User has pinned the sidebar expanded ("0"). The settings route still
// requests a collapse, but the pin must win (pin > route request > default).
diff --git a/ui/src/components/SecondarySidebar.tsx b/ui/src/components/SecondarySidebar.tsx
index 3936a6de382..20b41c17dcb 100644
--- a/ui/src/components/SecondarySidebar.tsx
+++ b/ui/src/components/SecondarySidebar.tsx
@@ -1,5 +1,6 @@
import { type ReactNode } from "react";
import { cn } from "@/lib/utils";
+import { SidebarNavExpandedProvider } from "./SidebarNavItem";
/**
* Fixed-width contextual pane that sits between the collapsed app rail and the
@@ -12,6 +13,10 @@ import { cn } from "@/lib/utils";
* It is a dumb container — fixed `w-60`, full-height, non-shrinking, with a
* right border and its own vertical scroll — so callers just drop the
* contextual nav (or a plugin slot mount) inside as `children`.
+ *
+ * Because the pane is always 240px wide it forces the full-label presentation
+ * on any `SidebarNavItem` inside it, so the contextual nav stays readable even
+ * while the app sidebar is collapsed to its rail (PAP-10700).
*/
export function SecondarySidebar({
children,
@@ -28,7 +33,7 @@ export function SecondarySidebar({
className,
)}
>
- {children}
+ {children}
);
}
diff --git a/ui/src/components/SidebarNavItem.test.tsx b/ui/src/components/SidebarNavItem.test.tsx
index 715c3658123..4111cc82041 100644
--- a/ui/src/components/SidebarNavItem.test.tsx
+++ b/ui/src/components/SidebarNavItem.test.tsx
@@ -5,7 +5,7 @@ import type { ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Inbox } from "lucide-react";
-import { SidebarNavItem } from "./SidebarNavItem";
+import { SidebarNavItem, SidebarNavExpandedProvider } from "./SidebarNavItem";
import { TooltipProvider } from "@/components/ui/tooltip";
const sidebarState = vi.hoisted(() => ({
@@ -113,6 +113,26 @@ describe("SidebarNavItem", () => {
expect(link().getAttribute("aria-label")).toBeNull();
});
+ it("forces the full label inside an expanded contextual pane even when globally collapsed", () => {
+ // The takeover model collapses the global sidebar (collapsed=true) while the
+ // 240px SecondarySidebar still needs readable labels (PAP-10700). The
+ // provider must override the global rail collapse.
+ sidebarState.collapsed = true;
+ render(
+
+
+ ,
+ );
+
+ const label = Array.from(container.querySelectorAll("span")).find((el) => el.textContent === "Inbox");
+ expect(label?.className).not.toContain("w-0");
+ expect(label?.className).toContain("flex-1");
+ // Full numeric badge, no rail aria-label, no tooltip wrapper.
+ expect(container.textContent).toContain("28");
+ expect(link().getAttribute("aria-label")).toBeNull();
+ expect(link().parentElement?.getAttribute("data-slot")).not.toBe("tooltip-trigger");
+ });
+
it("surfaces the live count in the rail aria-label", () => {
sidebarState.collapsed = true;
render();
diff --git a/ui/src/components/SidebarNavItem.tsx b/ui/src/components/SidebarNavItem.tsx
index f6e97c68717..4dc2a7bc0f3 100644
--- a/ui/src/components/SidebarNavItem.tsx
+++ b/ui/src/components/SidebarNavItem.tsx
@@ -1,3 +1,4 @@
+import { createContext, useContext, type ReactNode } from "react";
import { NavLink } from "@/lib/router";
import { SIDEBAR_SCROLL_RESET_STATE } from "../lib/navigation-scroll";
import { cn, SIDEBAR_RAIL_HIDDEN_LABEL } from "../lib/utils";
@@ -5,6 +6,27 @@ import { useSidebar } from "../context/SidebarContext";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import type { LucideIcon } from "lucide-react";
+/**
+ * Forces the full-label (non-rail) presentation for any `SidebarNavItem`
+ * rendered beneath it, regardless of the global `useSidebar().collapsed` state.
+ *
+ * Takeover routes (PAP-10695) collapse the app `` to its 64px rail
+ * and render the contextual nav in a fixed 240px `SecondarySidebar`. That pane
+ * is always wide enough for labels, but its `SidebarNavItem` children still
+ * read the *global* `collapsed=true` and would otherwise render icon-only —
+ * leaving the settings nav unreadable (PAP-10700). Wrapping the pane in this
+ * provider decouples its items from the global rail collapse.
+ */
+const SidebarNavExpandedContext = createContext(false);
+
+export function SidebarNavExpandedProvider({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
interface SidebarNavItemProps {
to: string;
label: string;
@@ -39,9 +61,13 @@ export function SidebarNavItem({
liveCount,
}: SidebarNavItemProps) {
const { isMobile, setSidebarOpen, collapsed, peeking } = useSidebar();
+ // A fixed-width contextual pane (SecondarySidebar) forces full labels even
+ // when the global app sidebar is collapsed to its rail (PAP-10700).
+ const forceExpanded = useContext(SidebarNavExpandedContext);
// The icon-only rail presentation only applies when pinned collapsed and not
- // peeking; a peek/expanded panel restores the full label + badge.
- const rail = collapsed && !peeking;
+ // peeking; a peek/expanded panel — or an expanded contextual pane — restores
+ // the full label + badge.
+ const rail = collapsed && !peeking && !forceExpanded;
const hasBadge = badge != null && badge > 0;
const hasLive = liveCount != null && liveCount > 0;