diff --git a/doc/plugins/PLUGIN_AUTHORING_GUIDE.md b/doc/plugins/PLUGIN_AUTHORING_GUIDE.md
index dca3ec62..5f8380f7 100644
--- a/doc/plugins/PLUGIN_AUTHORING_GUIDE.md
+++ b/doc/plugins/PLUGIN_AUTHORING_GUIDE.md
@@ -336,6 +336,22 @@ Mount surfaces currently wired in the host include:
- `commentAnnotation`
- `commentContextMenuItem`
+### `routeSidebar` and the app sidebar
+
+A `routeSidebar` slot gives a plugin page route its own contextual navigation.
+It **coexists** with the main app sidebar rather than replacing it: while your
+route is active the host collapses the app `` to its 64px icon rail
+(still hover/peek-able) and renders your sidebar in a second pane, yielding
+`[ app rail ][ your sidebar ][ content ]`.
+
+Because the host drives this collapse, a plugin should **not** mount
+`RequestCollapsedSidebar` or otherwise try to collapse the app sidebar itself —
+doing so is redundant and fights the host. While your route is active the app
+rail is forced collapsed (its expand toggle is hidden), overriding any user pin
+— a secondary sidebar always collapses the primary. This force never changes the
+user's saved expanded/collapsed preference, so the host restores exactly what
+the user chose as soon as they navigate away.
+
## Shared host components
Use shared components from `@paperclipai/plugin-sdk/ui` when the plugin needs a
diff --git a/doc/plugins/PLUGIN_SPEC.md b/doc/plugins/PLUGIN_SPEC.md
index d1bd1034..ccd72f5e 100644
--- a/doc/plugins/PLUGIN_SPEC.md
+++ b/doc/plugins/PLUGIN_SPEC.md
@@ -1131,6 +1131,27 @@ Plugins may add sidebar links to:
- global plugin settings
- company-context plugin pages
+### 19.5.1 Route Sidebars (`routeSidebar`)
+
+A `routeSidebar` slot supplies a contextual sidebar for a plugin page route
+(matched by `routePath`). It **coexists** with the main app sidebar rather than
+replacing it: while the route is active the host collapses the app ``
+to its 64px icon rail (still hover/peek-able) and renders the plugin's
+`routeSidebar` in a secondary pane, producing the layout
+`[ app rail ][ route sidebar ][ content ]`. The same model applies to the
+host's own company-settings sidebar.
+
+The host owns the collapse. Plugins must not mount `RequestCollapsedSidebar` or
+otherwise attempt to collapse the app sidebar from a `routeSidebar` — the host
+applies the collapse while the route is mounted and restores the previous state
+on navigation away. The collapse is a **hard invariant**: while a secondary
+sidebar is shown the app rail is forced collapsed and its expand/toggle
+affordance is hidden, *overriding* any user pin. Crucially, this force is
+ephemeral — it never mutates the user's persisted expanded/collapsed preference,
+so navigating back to a normal route restores exactly what the user chose.
+Precedence is therefore: secondary-sidebar force > explicit user pin >
+route-requested collapse (`RequestCollapsedSidebar`) > default expanded.
+
## 19.6 Shared Components In `@paperclipai/plugin-sdk/ui`
The host SDK ships shared components that plugins can import to quickly build UIs that match the host's look and feel. These are convenience building blocks, not a requirement.
diff --git a/packages/plugins/sdk/README.md b/packages/plugins/sdk/README.md
index b9a02d5c..020b0b8d 100644
--- a/packages/plugins/sdk/README.md
+++ b/packages/plugins/sdk/README.md
@@ -238,7 +238,9 @@ Adds a navigation-style entry to the main company sidebar navigation area, rende
#### `routeSidebar`
-Replaces the normal company sidebar while the current route is a plugin page route with the same `routePath`. Use this for full-page plugin workspaces that need their own local navigation while keeping the company rail and account footer. Receives `PluginRouteSidebarProps` with `context.companyId` and `context.companyPrefix` set to the active company. Requires the `ui.sidebar.register` capability.
+A contextual sidebar shown while the current route is a plugin page route with the same `routePath`. Use this for full-page plugin workspaces that need their own local navigation. It does **not** replace the app sidebar: the host collapses the main `` to its 64px icon rail (still hover/peek-able) and renders your `routeSidebar` in a secondary pane beside it, producing `[ app rail ][ your sidebar ][ content ]`. Receives `PluginRouteSidebarProps` with `context.companyId` and `context.companyPrefix` set to the active company. Requires the `ui.sidebar.register` capability.
+
+Do **not** mount `RequestCollapsedSidebar` (or otherwise try to collapse the app sidebar) from a `routeSidebar` plugin — the host drives the collapse automatically while your route is active and restores the user's preference when they navigate away. The collapse is a hard invariant: a secondary sidebar always forces the app rail collapsed (hiding its expand toggle), overriding any user pin, but it never mutates the user's saved expanded/collapsed preference — that is restored as soon as they leave your route.
#### `sidebarPanel`
diff --git a/tests/e2e/sidebar-takeover.spec.ts b/tests/e2e/sidebar-takeover.spec.ts
new file mode 100644
index 00000000..7371b8ba
--- /dev/null
+++ b/tests/e2e/sidebar-takeover.spec.ts
@@ -0,0 +1,161 @@
+import { test, expect, request as pwRequest, type APIRequestContext } from "@playwright/test";
+
+/**
+ * E2E: Sidebar takeover model (PAP-10695).
+ *
+ * Takeover routes (company settings, plugin `routeSidebar`) no longer *replace*
+ * the main app sidebar. Instead the host collapses the app `` to its
+ * 64px rail (still peek-able) and renders the contextual sidebar in a second
+ * pane → `[ app rail ][ secondary ~240px ][ content ]`.
+ *
+ * These specs assert the rail + secondary pane coexist on a company settings
+ * route, and that an explicit user pin (expanded) wins over the route-driven
+ * collapse (pin precedence).
+ *
+ * The plugin `routeSidebar` half of this behavior shares the exact same Layout
+ * code path (one `secondarySidebar`/`hasSecondarySidebar` resolver drives both
+ * company-settings and plugin routes) and is covered by the unit tests in
+ * `ui/src/components/Layout.test.tsx`. A live plugin-route e2e requires the
+ * `plugin-llm-wiki` plugin to be installed in the throwaway e2e instance, which
+ * is out of scope for this default local_trusted run; visual QA of both panes
+ * is delegated to the QA child issue.
+ */
+
+const PORT = Number(process.env.PAPERCLIP_E2E_PORT ?? 3199);
+const BASE_URL = `http://127.0.0.1:${PORT}`;
+const COMPANY_NAME_PREFIX = "E2E-SidebarTakeover";
+const COLLAPSED_STORAGE_KEY = "paperclip.sidebar.collapsed";
+
+// The sidebar header's "Open search" control only renders when the app sidebar
+// is expanded (pinned or peeking); in the collapsed rail it is hidden to fit
+// the 64px width. Its presence/absence is therefore a stable proxy for the
+// app sidebar's collapsed state (see Sidebar.tsx).
+const APP_SIDEBAR_EXPANDED_MARKER = "Open search";
+
+async function createCompany(board: APIRequestContext): Promise<{ id: string; prefix: string }> {
+ const healthRes = await board.get(`${BASE_URL}/api/health`);
+ expect(healthRes.ok()).toBe(true);
+ const health = await healthRes.json();
+ expect(health.deploymentMode).toBe("local_trusted");
+
+ const companyRes = await board.post(`${BASE_URL}/api/companies`, {
+ data: { name: `${COMPANY_NAME_PREFIX}-${Date.now()}` },
+ });
+ if (!companyRes.ok()) {
+ throw new Error(`POST /api/companies → ${companyRes.status()}: ${await companyRes.text()}`);
+ }
+ const company = await companyRes.json();
+ return {
+ id: company.id,
+ prefix: company.issuePrefix ?? company.prefix ?? company.urlKey ?? "E2E",
+ };
+}
+
+test.describe("Sidebar takeover (collapse + secondary pane)", () => {
+ let board: APIRequestContext;
+ let companyId: string;
+ let prefix: string;
+
+ test.beforeAll(async () => {
+ board = await pwRequest.newContext({ baseURL: BASE_URL });
+ const company = await createCompany(board);
+ companyId = company.id;
+ prefix = company.prefix;
+ });
+
+ test.afterAll(async () => {
+ await board.delete(`${BASE_URL}/api/companies/${companyId}`).catch(() => {});
+ await board.dispose();
+ });
+
+ test.beforeEach(async ({ page }) => {
+ // Start each test from a clean (unpinned) sidebar state so the route-driven
+ // collapse is the only thing acting on it.
+ await page.addInitScript((key) => {
+ window.localStorage.removeItem(key);
+ }, COLLAPSED_STORAGE_KEY);
+ });
+
+ test("collapses the app sidebar to its rail and shows the settings sidebar beside it", async ({ page }) => {
+ await page.goto(`/${prefix}/company/settings`);
+
+ // The contextual (secondary) pane is present...
+ const secondary = page.locator("[data-secondary-sidebar]");
+ await expect(secondary).toBeVisible();
+ await expect(secondary).toHaveCount(1);
+
+ // ...and it is ~240px wide (w-60), distinct from the 64px app rail.
+ const secondaryBox = await secondary.boundingBox();
+ expect(secondaryBox).not.toBeNull();
+ expect(secondaryBox!.width).toBeGreaterThan(180);
+
+ // The app sidebar is NOT replaced — its company nav still renders...
+ await expect(page.getByRole("link", { name: "Dashboard" })).toBeVisible();
+
+ // ...but it is collapsed to its rail: the expanded-only "Open search"
+ // header control is hidden.
+ 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("settings force-collapse overrides an expanded pin without mutating it", async ({ page }) => {
+ // User has pinned the sidebar expanded ("0"). Company settings is a hard
+ // secondary-sidebar takeover route, so forceCollapsed wins while the route is
+ // active (force > pin > route request > default) but must not mutate the pin.
+ await page.addInitScript(
+ ({ key }) => {
+ window.localStorage.setItem(key, "0");
+ },
+ { key: COLLAPSED_STORAGE_KEY },
+ );
+
+ await page.goto(`/${prefix}/company/settings`);
+
+ // Secondary pane still shows on the takeover route.
+ await expect(page.locator("[data-secondary-sidebar]")).toBeVisible();
+
+ // The app sidebar is hard-collapsed despite the stored expanded pin.
+ await expect(page.getByLabel(APP_SIDEBAR_EXPANDED_MARKER)).toHaveCount(0);
+
+ await page.goto(`/${prefix}/dashboard`);
+
+ // Leaving the takeover route clears the force and restores the user's
+ // persisted expanded pin.
+ await expect(page.locator("[data-secondary-sidebar]")).toHaveCount(0);
+ await expect(page.getByLabel(APP_SIDEBAR_EXPANDED_MARKER)).toBeVisible();
+ });
+
+ test("leaving the takeover route removes the secondary pane and restores the sidebar", async ({ page }) => {
+ await page.goto(`/${prefix}/company/settings`);
+ await expect(page.locator("[data-secondary-sidebar]")).toBeVisible();
+ await expect(page.getByLabel(APP_SIDEBAR_EXPANDED_MARKER)).toHaveCount(0);
+
+ // Navigate to a plain (non-takeover) route.
+ await page.goto(`/${prefix}/dashboard`);
+
+ // No secondary pane, and the app sidebar is no longer force-collapsed.
+ await expect(page.locator("[data-secondary-sidebar]")).toHaveCount(0);
+ await expect(page.getByLabel(APP_SIDEBAR_EXPANDED_MARKER)).toBeVisible();
+ });
+});
diff --git a/ui/src/components/KeyboardShortcutsCheatsheet.test.tsx b/ui/src/components/KeyboardShortcutsCheatsheet.test.tsx
new file mode 100644
index 00000000..b27b3831
--- /dev/null
+++ b/ui/src/components/KeyboardShortcutsCheatsheet.test.tsx
@@ -0,0 +1,44 @@
+// @vitest-environment jsdom
+
+import { flushSync } from "react-dom";
+import { createRoot } from "react-dom/client";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { KeyboardShortcutsCheatsheetContent } from "./KeyboardShortcutsCheatsheet";
+
+describe("KeyboardShortcutsCheatsheet", () => {
+ let container: HTMLDivElement;
+
+ beforeEach(() => {
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ });
+
+ afterEach(() => {
+ container.remove();
+ document.body.innerHTML = "";
+ });
+
+ it("lists the re-pointed Cmd/Ctrl+B sidebar collapse shortcut as a chord", () => {
+ const root = createRoot(container);
+ flushSync(() => {
+ root.render();
+ });
+
+ // The collapse/expand row exists with its label.
+ const row = [...container.querySelectorAll("span")].find(
+ (node) => node.textContent?.trim() === "Collapse or expand sidebar",
+ )?.parentElement;
+ expect(row).toBeTruthy();
+
+ // Rendered as a "+" chord (B + a Cmd/Ctrl cap), not a "then" sequence.
+ const caps = [...(row?.querySelectorAll("kbd") ?? [])].map((kbd) => kbd.textContent);
+ expect(caps).toContain("B");
+ expect(caps.some((cap) => cap === "⌘" || cap === "Ctrl")).toBe(true);
+ expect(row?.textContent).toContain("+");
+ expect(row?.textContent).not.toContain("then");
+
+ flushSync(() => {
+ root.unmount();
+ });
+ });
+});
diff --git a/ui/src/components/KeyboardShortcutsCheatsheet.tsx b/ui/src/components/KeyboardShortcutsCheatsheet.tsx
index b7ebdc12..c6847e0e 100644
--- a/ui/src/components/KeyboardShortcutsCheatsheet.tsx
+++ b/ui/src/components/KeyboardShortcutsCheatsheet.tsx
@@ -3,8 +3,22 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/u
interface ShortcutEntry {
keys: string[];
label: string;
+ /** Render keys as a simultaneous chord (joined with "+") rather than a
+ * "then" sequence. */
+ combo?: boolean;
}
+// Platform-appropriate label for the Cmd/Ctrl modifier so the cheatsheet shows
+// the same key the user actually presses (re-pointed in the collapsible sidebar
+// work — Cmd/Ctrl+B toggles the rail).
+function getPlatformLabel() {
+ if (typeof navigator === "undefined") return "";
+ const nav = navigator as Navigator & { userAgentData?: { platform?: string } };
+ return nav.userAgentData?.platform || navigator.userAgent || "";
+}
+
+const META_KEY = /Mac|iPhone|iPad|iPod/.test(getPlatformLabel()) ? "⌘" : "Ctrl";
+
interface ShortcutSection {
title: string;
shortcuts: ShortcutEntry[];
@@ -41,6 +55,7 @@ const sections: ShortcutSection[] = [
{ keys: ["/"], label: "Search current page or quick search" },
{ keys: ["c"], label: "New task" },
{ keys: ["["], label: "Toggle sidebar" },
+ { keys: [META_KEY, "B"], label: "Collapse or expand sidebar", combo: true },
{ keys: ["]"], label: "Toggle panel" },
{ keys: ["?"], label: "Show keyboard shortcuts" },
],
@@ -74,7 +89,11 @@ export function KeyboardShortcutsCheatsheetContent() {
{shortcut.keys.map((key, i) => (
- {i > 0 && then}
+ {i > 0 && (
+
+ {shortcut.combo ? "+" : "then"}
+
+ )}
{key}
))}
diff --git a/ui/src/components/Layout.test.tsx b/ui/src/components/Layout.test.tsx
index 873ceba1..0a550ca9 100644
--- a/ui/src/components/Layout.test.tsx
+++ b/ui/src/components/Layout.test.tsx
@@ -17,6 +17,7 @@ const mockInstanceSettingsApi = vi.hoisted(() => ({
const mockNavigate = vi.hoisted(() => vi.fn());
const mockSetSelectedCompanyId = vi.hoisted(() => vi.fn());
const mockSetSidebarOpen = vi.hoisted(() => vi.fn());
+const mockSetForceCollapsed = vi.hoisted(() => vi.fn());
const mockCompanyState = vi.hoisted(() => ({
companies: [{ id: "company-1", issuePrefix: "PAP", name: "Paperclip" }],
selectedCompany: { id: "company-1", issuePrefix: "PAP", name: "Paperclip" },
@@ -27,9 +28,12 @@ const mockPluginSlots = vi.hoisted(() => ({
}));
const mockUsePluginSlots = vi.hoisted(() => vi.fn());
const mockPluginSlotContexts = vi.hoisted(() => [] as Array
>);
+const mockSetPeeking = vi.hoisted(() => vi.fn());
const mockSidebarState = vi.hoisted(() => ({
sidebarOpen: true,
isMobile: false,
+ collapsed: false,
+ peeking: false,
}));
let currentPathname = "/PAP/dashboard";
@@ -167,7 +171,16 @@ vi.mock("../context/SidebarContext", () => ({
sidebarOpen: mockSidebarState.sidebarOpen,
setSidebarOpen: mockSetSidebarOpen,
toggleSidebar: vi.fn(),
+ toggleCollapsed: vi.fn(),
+ collapsed: mockSidebarState.collapsed,
+ collapseLocked: false,
+ peeking: mockSidebarState.peeking,
+ setPeeking: mockSetPeeking,
isMobile: mockSidebarState.isMobile,
+ forceCollapsed: false,
+ setForceCollapsed: mockSetForceCollapsed,
+ routeRequestsCollapsed: false,
+ setRouteRequestsCollapsed: vi.fn(),
}),
}));
@@ -236,6 +249,9 @@ describe("Layout", () => {
mockPluginSlotContexts.length = 0;
mockSidebarState.sidebarOpen = true;
mockSidebarState.isMobile = false;
+ mockSidebarState.collapsed = false;
+ mockSidebarState.peeking = false;
+ mockSetPeeking.mockClear();
});
afterEach(() => {
@@ -274,7 +290,81 @@ describe("Layout", () => {
});
});
- it("renders the company settings sidebar on company settings routes", async () => {
+ it("collapses atomically when the pointer is still over the sidebar (no re-peek) — PAP-10676", async () => {
+ const root = createRoot(container);
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+
+ const renderLayout = async () => {
+ await act(async () => {
+ root.render(
+
+
+ ,
+ );
+ });
+ await flushReact();
+ };
+
+ // The SidebarShell overlay panel carries the peek mouse handlers.
+ const panel = () =>
+ [...container.querySelectorAll("div")].find(
+ (el) => el.className.includes("inset-y-0") && el.className.includes("overflow-hidden"),
+ );
+ const hover = (el: HTMLElement) => {
+ // React derives onMouseEnter from a mouseover crossing in from outside.
+ el.dispatchEvent(new MouseEvent("mouseover", { bubbles: true, relatedTarget: document.body }));
+ };
+
+ // Expanded, then hover the panel so the pointer is registered as inside.
+ await renderLayout();
+ const expandedPanel = panel();
+ expect(expandedPanel).toBeTruthy();
+ await act(async () => { hover(expandedPanel!); });
+
+ // Collapse while the pointer is still over the panel.
+ mockSidebarState.collapsed = true;
+ await renderLayout();
+ // The peek is cancelled atomically on collapse.
+ expect(mockSetPeeking).toHaveBeenCalledWith(false);
+
+ // A lingering/spurious hover while collapsed must NOT re-open the peek.
+ mockSetPeeking.mockClear();
+ const railPanel = panel();
+ await act(async () => { hover(railPanel!); });
+ await act(async () => { await new Promise((r) => setTimeout(r, 80)); });
+ expect(mockSetPeeking).not.toHaveBeenCalledWith(true);
+
+ await act(async () => { root.unmount(); });
+ });
+
+ it("opens the peek when hovering a collapsed rail (positive control for the hover sim)", async () => {
+ mockSidebarState.collapsed = true;
+ const root = createRoot(container);
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ await act(async () => {
+ root.render(
+
+
+ ,
+ );
+ });
+ await flushReact();
+
+ const panel = [...container.querySelectorAll("div")].find(
+ (el) => el.className.includes("inset-y-0") && el.className.includes("overflow-hidden"),
+ );
+ expect(panel).toBeTruthy();
+ await act(async () => {
+ panel!.dispatchEvent(new MouseEvent("mouseover", { bubbles: true, relatedTarget: document.body }));
+ });
+ await act(async () => { await new Promise((r) => setTimeout(r, 80)); });
+ // A normal collapsed-rail hover (not just-collapsed) opens the peek.
+ expect(mockSetPeeking).toHaveBeenCalledWith(true);
+
+ await act(async () => { root.unmount(); });
+ });
+
+ it("keeps the app sidebar and shows the settings sidebar in the secondary pane on settings routes", async () => {
currentPathname = "/PAP/company/settings/access";
mockPluginSlots.slots = [
{
@@ -315,11 +405,15 @@ describe("Layout", () => {
await flushReact();
await flushReact();
+ // Takeover model (PAP-10695): the app sidebar is kept (collapsed to its
+ // rail) AND the settings sidebar renders in the secondary pane.
expect(container.textContent).toContain("Company settings sidebar");
+ expect(container.textContent).toContain("Main company nav");
expect(container.textContent).not.toContain("Company rail");
expect(container.textContent).not.toContain("Instance sidebar");
- expect(container.textContent).not.toContain("Main company nav");
expect(container.textContent).not.toContain("Plugin route sidebar");
+ // The route asks the host to collapse the app sidebar to its rail.
+ expect(mockSetForceCollapsed).toHaveBeenCalledWith(true);
await act(async () => {
root.unmount();
@@ -380,9 +474,10 @@ describe("Layout", () => {
await flushReact();
expect(container.textContent).toContain("Company settings sidebar");
+ expect(container.textContent).toContain("Main company nav");
expect(container.textContent).not.toContain("Company rail");
- expect(container.textContent).not.toContain("Main company nav");
expect(container.textContent).not.toContain("Plugin route sidebar");
+ expect(mockSetForceCollapsed).toHaveBeenCalledWith(true);
await act(async () => {
root.unmount();
@@ -430,11 +525,14 @@ describe("Layout", () => {
await flushReact();
await flushReact();
+ // Takeover model (PAP-10695): the app sidebar coexists with the plugin's
+ // route sidebar, which renders in the secondary pane.
expect(container.textContent).toContain("Plugin route sidebar: Wiki Sidebar");
expect(container.querySelector("[data-plugin-slot-class='h-full w-full']")).not.toBeNull();
- expect(container.textContent).not.toContain("Main company nav");
+ expect(container.textContent).toContain("Main company nav");
expect(container.textContent).not.toContain("Company settings sidebar");
expect(container.textContent).not.toContain("Instance sidebar");
+ expect(mockSetForceCollapsed).toHaveBeenCalledWith(true);
await act(async () => {
root.unmount();
@@ -489,7 +587,7 @@ describe("Layout", () => {
}),
);
expect(container.textContent).toContain("Plugin route sidebar: Wiki Sidebar");
- expect(container.textContent).not.toContain("Main company nav");
+ expect(container.textContent).toContain("Main company nav");
await act(async () => {
root.unmount();
@@ -617,6 +715,9 @@ describe("Layout", () => {
expect(container.textContent).toContain("Main company nav");
expect(container.textContent).not.toContain("Plugin route sidebar");
+ // No secondary pane, so the route must not force the sidebar collapsed.
+ expect(mockSetForceCollapsed).not.toHaveBeenCalledWith(true);
+ expect(mockSetForceCollapsed).toHaveBeenCalledWith(false);
await act(async () => {
root.unmount();
diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx
index a93a6869..f6679d11 100644
--- a/ui/src/components/Layout.tsx
+++ b/ui/src/components/Layout.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Outlet, useLocation, useNavigate, useNavigationType, useParams } from "@/lib/router";
import { Sidebar } from "./Sidebar";
@@ -17,7 +17,8 @@ import { MobileBottomNav } from "./MobileBottomNav";
import { WorktreeBanner } from "./WorktreeBanner";
import { DevRestartBanner } from "./DevRestartBanner";
import { StandaloneBrowserControls } from "./StandaloneBrowserControls";
-import { ResizableSidebarPane } from "./ResizableSidebarPane";
+import { SidebarShell } from "./SidebarShell";
+import { SecondarySidebar } from "./SecondarySidebar";
import { SidebarAccountMenu } from "./SidebarAccountMenu";
import { useDialogActions } from "../context/DialogContext";
import { GeneralSettingsProvider } from "../context/GeneralSettingsContext";
@@ -48,7 +49,17 @@ function getCompanyRouteSegment(pathname: string, companyPrefix: string | undefi
}
export function Layout() {
- const { sidebarOpen, setSidebarOpen, toggleSidebar, isMobile } = useSidebar();
+ const {
+ sidebarOpen,
+ setSidebarOpen,
+ toggleSidebar,
+ toggleCollapsed,
+ collapsed,
+ peeking,
+ setPeeking,
+ isMobile,
+ setForceCollapsed,
+ } = useSidebar();
const { openNewIssue, openOnboarding } = useDialogActions();
const { togglePanelVisible } = usePanel();
const {
@@ -102,16 +113,21 @@ export function Layout() {
}),
[routeSidebarCompanyId, routeSidebarCompanyPrefix],
);
- const companySidebar = routeSidebarSlot ? (
+ // Takeover routes (company settings, plugin `routeSidebar`) no longer replace
+ // the app ``. Instead the host collapses it to its rail and renders
+ // the contextual sidebar in a second pane (PAP-10695). One resolver drives
+ // both desktop (SecondarySidebar) and mobile (off-canvas drawer).
+ const secondarySidebar = isCompanySettingsRoute ? (
+
+ ) : routeSidebarSlot ? (
- ) : (
-
- );
+ ) : null;
+ const hasSecondarySidebar = secondarySidebar != null;
const { data: health } = useQuery({
queryKey: queryKeys.health,
queryFn: () => healthApi.get(),
@@ -127,6 +143,16 @@ export function Layout() {
queryFn: () => instanceSettingsApi.getGeneral(),
}).data?.keyboardShortcuts === true;
+ // A secondary sidebar always collapses the app sidebar to its rail (still
+ // peek-able) — a hard invariant that overrides the user pin while the route
+ // is active, but does NOT mutate the persisted preference. Clearing the force
+ // on cleanup restores the user's expanded/collapsed choice when navigating
+ // off the takeover route (PAP-10694).
+ useLayoutEffect(() => {
+ setForceCollapsed(hasSecondarySidebar);
+ return () => setForceCollapsed(false);
+ }, [hasSecondarySidebar, setForceCollapsed]);
+
useEffect(() => {
if (companiesLoading || onboardingTriggered.current) return;
if (health?.deploymentMode === "authenticated") return;
@@ -178,6 +204,15 @@ export function Layout() {
]);
const togglePanel = togglePanelVisible;
+ // Cmd/Ctrl+B: collapse/expand the pinned rail on desktop; on mobile keep
+ // toggling the off-canvas drawer.
+ const toggleCollapse = useCallback(() => {
+ if (isMobile) {
+ toggleSidebar();
+ } else {
+ toggleCollapsed();
+ }
+ }, [isMobile, toggleSidebar, toggleCollapsed]);
const openSearch = useCallback(() => {
document.dispatchEvent(new KeyboardEvent("keydown", {
key: "k",
@@ -187,6 +222,102 @@ export function Layout() {
}));
}, []);
+ // Peek (hover flyout) triggers for the collapsed rail. Opening has a tiny
+ // delay so a pointer merely sweeping across the rail doesn't flash it open;
+ // closing is debounced to avoid flicker on the rail→overlay seam. Keyboard
+ // focus opens immediately so tabbing reaches the full nav. Context gates the
+ // effective `peeking` to desktop + collapsed + hover-capable pointers, so
+ // these handlers are inert otherwise.
+ const peekTimer = useRef(null);
+ // Whether the pointer is currently over the peek panel. Used to keep the peek
+ // open across focus changes (e.g. navigation steals focus to ) as long as
+ // the user is still hovering — it should only close when they actually mouse off
+ // (PAP-10676).
+ const pointerInsidePanel = useRef(false);
+ // When the user explicitly collapses while the pointer is still over the panel,
+ // suppress re-peeking until the pointer actually leaves — otherwise the lingering
+ // hover immediately re-expands the rail and the collapse "doesn't take" until the
+ // mouse moves away (PAP-10676). Re-armed on the next genuine pointer-leave.
+ const suppressPeekRef = useRef(false);
+ const clearPeekTimer = useCallback(() => {
+ if (peekTimer.current !== null) {
+ window.clearTimeout(peekTimer.current);
+ peekTimer.current = null;
+ }
+ }, []);
+ const openPeek = useCallback(() => {
+ clearPeekTimer();
+ peekTimer.current = window.setTimeout(() => setPeeking(true), 50);
+ }, [clearPeekTimer, setPeeking]);
+ const openPeekImmediate = useCallback(() => {
+ clearPeekTimer();
+ setPeeking(true);
+ }, [clearPeekTimer, setPeeking]);
+ const closePeek = useCallback(() => {
+ clearPeekTimer();
+ peekTimer.current = window.setTimeout(() => setPeeking(false), 120);
+ }, [clearPeekTimer, setPeeking]);
+ // Tracked even while expanded so that, at the moment of collapse, we know
+ // whether the pointer is over the panel and should suppress the re-peek.
+ const handlePanelPointerEnter = useCallback(() => {
+ pointerInsidePanel.current = true;
+ if (collapsed && !suppressPeekRef.current) openPeek();
+ }, [collapsed, openPeek]);
+ const handlePanelPointerLeave = useCallback(() => {
+ pointerInsidePanel.current = false;
+ suppressPeekRef.current = false; // pointer left — re-arm peek for the next hover
+ closePeek();
+ }, [closePeek]);
+ const handlePanelFocus = useCallback(() => {
+ if (suppressPeekRef.current) return;
+ openPeekImmediate();
+ }, [openPeekImmediate]);
+ // Close on focus leaving the panel only when the pointer isn't hovering it.
+ // Clicking a rail/peek nav item moves focus to on navigation; if the
+ // mouse is still over the flyout we keep it open until the pointer leaves.
+ const handlePanelBlur = useCallback(() => {
+ if (pointerInsidePanel.current) return;
+ closePeek();
+ }, [closePeek]);
+
+ // Tidy up any pending peek timer on unmount.
+ useEffect(() => clearPeekTimer, [clearPeekTimer]);
+
+ // An explicit collapse must be atomic: cancel any in-flight/active peek, and if
+ // the pointer is still over the panel suppress re-peeking until it leaves, so the
+ // rail doesn't immediately re-expand under the lingering hover (PAP-10676).
+ const wasCollapsed = useRef(collapsed);
+ useEffect(() => {
+ if (collapsed !== wasCollapsed.current) {
+ if (collapsed) {
+ clearPeekTimer();
+ setPeeking(false);
+ suppressPeekRef.current = pointerInsidePanel.current;
+ } else {
+ suppressPeekRef.current = false;
+ }
+ wasCollapsed.current = collapsed;
+ }
+ }, [collapsed, clearPeekTimer, setPeeking]);
+
+ // Intentionally do NOT close the peek on navigation: clicking a nav item means
+ // the pointer is still over the flyout, so it should stay open until the user
+ // actually mouses off (handled by onPanelMouseLeave) or blurs out / hits Escape
+ // (PAP-10676). Auto-closing here made the sidebar collapse on every page change.
+
+ // Escape closes an open peek without trapping the pointer.
+ useEffect(() => {
+ if (!peeking) return;
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "Escape") {
+ clearPeekTimer();
+ setPeeking(false);
+ }
+ };
+ window.addEventListener("keydown", onKeyDown);
+ return () => window.removeEventListener("keydown", onKeyDown);
+ }, [peeking, clearPeekTimer, setPeeking]);
+
useCompanyPageMemory();
useKeyboardShortcuts({
@@ -194,6 +325,7 @@ export function Layout() {
onNewIssue: () => openNewIssue(),
onSearch: openSearch,
onToggleSidebar: toggleSidebar,
+ onToggleCollapse: toggleCollapse,
onTogglePanel: togglePanel,
onShowShortcuts: () => setShortcutsOpen(true),
});
@@ -350,11 +482,7 @@ export function Layout() {
>
- {isCompanySettingsRoute ? (
-
- ) : (
- companySidebar
- )}
+ {hasSecondarySidebar ? secondarySidebar : }
) : (
-