fix(ui): secondary sidebar hard-forces the app rail collapsed, preserving the user's pin (PAP-10694)

A secondary sidebar (company settings, plugin routeSidebar) now ALWAYS collapses
the app sidebar to its rail and hides its expand toggle — overriding the user's
pin while the route is active — but the force is ephemeral and never mutates the
persisted expanded/collapsed preference, so leaving the route restores exactly
what the user chose.

Previously the collapse was a soft route-request that the user pin won over, so a
user who pinned the sidebar expanded saw both sidebars (240+240) on settings/
plugin routes and could not get the intended rail.

- SidebarContext: new ephemeral `forceCollapsed` + `collapseLocked`; precedence
  becomes force > user pin > route request > default. `toggleCollapsed` is a
  no-op while forced (won't expand or write the pin).
- Layout: drives `setForceCollapsed(hasSecondarySidebar)` instead of the soft
  `setRouteRequestsCollapsed`.
- Sidebar: hides the expand/collapse affordance while `collapseLocked`.
- Plugin docs (SPEC, authoring guide, SDK README) updated to the hard-force rule.
- Tests: context forced-collapse precedence/lock/restore/mobile; Sidebar hidden
  toggle; Layout mock updated.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-06-09 15:56:14 +00:00
1 parent 9ca8cfd15d
commit 2dee4ba32e
9 files changed
+132 -31

No files matched your search

+5 -3
View File
@@ -346,9 +346,11 @@ route is active the host collapses the app `<Sidebar/>` to its 64px icon rail
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. The host restores the user's previous
sidebar state when they navigate away, and an explicit user pin (expanded or
collapsed) always takes precedence over the route-driven collapse.
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
+7 -3
View File
@@ -1144,9 +1144,13 @@ 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. Collapse precedence is: explicit user pin (expanded or
collapsed) > route-requested collapse > default expanded, so a user's pin always
wins over the route-driven collapse.
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`
+1 -1
View File
@@ -240,7 +240,7 @@ Adds a navigation-style entry to the main company sidebar navigation area, rende
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 `<Sidebar/>` 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. A user's explicit pin (expanded or collapsed) always wins over this route-driven collapse.
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`
+10 -7
View File
@@ -17,7 +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 mockSetRouteRequestsCollapsed = 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" },
@@ -173,11 +173,14 @@ vi.mock("../context/SidebarContext", () => ({
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: mockSetRouteRequestsCollapsed,
setRouteRequestsCollapsed: vi.fn(),
}),
}));
@@ -410,7 +413,7 @@ describe("Layout", () => {
expect(container.textContent).not.toContain("Instance sidebar");
expect(container.textContent).not.toContain("Plugin route sidebar");
// The route asks the host to collapse the app sidebar to its rail.
expect(mockSetRouteRequestsCollapsed).toHaveBeenCalledWith(true);
expect(mockSetForceCollapsed).toHaveBeenCalledWith(true);
await act(async () => {
root.unmount();
@@ -474,7 +477,7 @@ describe("Layout", () => {
expect(container.textContent).toContain("Main company nav");
expect(container.textContent).not.toContain("Company rail");
expect(container.textContent).not.toContain("Plugin route sidebar");
expect(mockSetRouteRequestsCollapsed).toHaveBeenCalledWith(true);
expect(mockSetForceCollapsed).toHaveBeenCalledWith(true);
await act(async () => {
root.unmount();
@@ -529,7 +532,7 @@ describe("Layout", () => {
expect(container.textContent).toContain("Main company nav");
expect(container.textContent).not.toContain("Company settings sidebar");
expect(container.textContent).not.toContain("Instance sidebar");
expect(mockSetRouteRequestsCollapsed).toHaveBeenCalledWith(true);
expect(mockSetForceCollapsed).toHaveBeenCalledWith(true);
await act(async () => {
root.unmount();
@@ -713,8 +716,8 @@ 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(mockSetRouteRequestsCollapsed).not.toHaveBeenCalledWith(true);
expect(mockSetRouteRequestsCollapsed).toHaveBeenCalledWith(false);
expect(mockSetForceCollapsed).not.toHaveBeenCalledWith(true);
expect(mockSetForceCollapsed).toHaveBeenCalledWith(false);
await act(async () => {
root.unmount();
+9 -8
View File
@@ -58,7 +58,7 @@ export function Layout() {
peeking,
setPeeking,
isMobile,
setRouteRequestsCollapsed,
setForceCollapsed,
} = useSidebar();
const { openNewIssue, openOnboarding } = useDialogActions();
const { togglePanelVisible } = usePanel();
@@ -143,14 +143,15 @@ export function Layout() {
queryFn: () => instanceSettingsApi.getGeneral(),
}).data?.keyboardShortcuts === true;
// While a secondary sidebar is shown, ask the app sidebar to default to its
// collapsed rail (still peek-able). An explicit user pin always wins — that
// precedence lives in SidebarContext. Clearing on cleanup restores the
// default when navigating off the takeover route (PAP-10695).
// 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).
useEffect(() => {
setRouteRequestsCollapsed(hasSecondarySidebar);
return () => setRouteRequestsCollapsed(false);
}, [hasSecondarySidebar, setRouteRequestsCollapsed]);
setForceCollapsed(hasSecondarySidebar);
return () => setForceCollapsed(false);
}, [hasSecondarySidebar, setForceCollapsed]);
useEffect(() => {
if (companiesLoading || onboardingTriggered.current) return;
+17
View File
@@ -52,6 +52,7 @@ const mockSidebar = vi.hoisted(() => ({
isMobile: false,
setSidebarOpen: vi.fn(),
collapsed: false,
collapseLocked: false,
peeking: false,
toggleCollapsed: vi.fn(),
setCollapsed: vi.fn(),
@@ -315,6 +316,22 @@ describe("Sidebar", () => {
});
});
it("hides the expand/collapse toggle while a secondary sidebar locks the rail", async () => {
// A secondary sidebar forces the rail; the user must not be able to expand
// the primary while it is shown (PAP-10694).
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false });
mockSidebar.collapseLocked = true;
const root = await renderSidebar();
expect(container.querySelector('button[aria-label="Collapse sidebar"]')).toBeNull();
expect(container.querySelector('button[aria-label="Expand sidebar"]')).toBeNull();
mockSidebar.collapseLocked = false;
flushSync(() => {
root.unmount();
});
});
it("keeps the collapsed rail top bar to just the company logo (no clipped search/toggle)", async () => {
// In the narrow rail the search/toggle controls don't fit beside the logo and
// would overflow/clip, shoving the logo out of the icon column (PAP-10676), so
+5 -3
View File
@@ -41,7 +41,7 @@ import { SidebarCompanyMenu } from "./SidebarCompanyMenu";
export function Sidebar() {
const { openNewIssue } = useDialogActions();
const { selectedCompanyId, selectedCompany } = useCompany();
const { isMobile, collapsed, peeking, toggleCollapsed, setCollapsed } = useSidebar();
const { isMobile, collapsed, collapseLocked, peeking, toggleCollapsed, setCollapsed } = useSidebar();
const rail = collapsed && !peeking;
const inboxBadge = useInboxBadge(selectedCompanyId);
const { data: experimentalSettings } = useQuery({
@@ -94,8 +94,10 @@ export function Sidebar() {
{/* Desktop-only collapse/expand affordance. While peeking (hover flyout
over the collapsed rail) it becomes a Pin that promotes the peek to a
pinned-expanded sidebar; otherwise it toggles the pinned rail. Mobile
uses the off-canvas drawer, so this control is hidden there. */}
{!isMobile ? (
uses the off-canvas drawer, so this control is hidden there. It is
also hidden while a secondary sidebar forces the rail (collapseLocked):
the user cannot expand the primary while a secondary sidebar is shown. */}
{!isMobile && !collapseLocked ? (
peeking ? (
<Button
variant="ghost"
+47
View File
@@ -131,6 +131,53 @@ describe("SidebarContext", () => {
});
});
describe("forced collapse (secondary sidebar): overrides the pin, preserves preference", () => {
it("forces collapsed even when the user pinned expanded, without mutating the pin", () => {
active = renderProvider();
// User prefers expanded site-wide.
act(() => capturedValue?.setCollapsed(false));
expect(capturedValue?.collapsed).toBe(false);
expect(localStorage.getItem(COLLAPSED_STORAGE_KEY)).toBe("0");
// Entering a secondary-sidebar route forces the rail and locks it.
act(() => capturedValue?.setForceCollapsed(true));
expect(capturedValue?.collapsed).toBe(true);
expect(capturedValue?.collapseLocked).toBe(true);
// The persisted preference is untouched.
expect(localStorage.getItem(COLLAPSED_STORAGE_KEY)).toBe("0");
});
it("restores the user's preference when the force is cleared (leaving the route)", () => {
active = renderProvider();
act(() => capturedValue?.setCollapsed(false));
act(() => capturedValue?.setForceCollapsed(true));
expect(capturedValue?.collapsed).toBe(true);
// Navigating away clears the force; the expanded preference returns.
act(() => capturedValue?.setForceCollapsed(false));
expect(capturedValue?.collapsed).toBe(false);
expect(capturedValue?.collapseLocked).toBe(false);
});
it("locks the toggle while forced: toggleCollapsed is a no-op and never writes the pin", () => {
active = renderProvider();
act(() => capturedValue?.setCollapsed(false));
act(() => capturedValue?.setForceCollapsed(true));
act(() => capturedValue?.toggleCollapsed());
expect(capturedValue?.collapsed).toBe(true); // still forced
expect(localStorage.getItem(COLLAPSED_STORAGE_KEY)).toBe("0"); // pin unchanged
});
it("never forces or locks on mobile", () => {
setViewport({ mobile: true });
active = renderProvider();
act(() => capturedValue?.setForceCollapsed(true));
expect(capturedValue?.collapsed).toBe(false);
expect(capturedValue?.collapseLocked).toBe(false);
});
});
describe("persistence round-trip", () => {
it("writes '1'/'0' to localStorage on setCollapsed", () => {
active = renderProvider();
+31 -6
View File
@@ -18,12 +18,23 @@ interface SidebarContextValue {
collapsed: boolean;
setCollapsed: (next: boolean) => void;
toggleCollapsed: () => void;
// True while a secondary sidebar forces the rail: the collapse is locked, so
// the expand/toggle affordance must be hidden/inert. Desktop-only.
collapseLocked: boolean;
// Ephemeral peek (hover flyout). Only meaningful on desktop, collapsed,
// hover-capable pointer. Never persisted.
peeking: boolean;
setPeeking: (next: boolean) => void;
// Route-requested collapse: a route may default the app sidebar to collapsed.
// Lower precedence than an explicit user pin. Wired by Layout/routes (Task 5).
// Hard, ephemeral collapse forced by an active secondary sidebar (settings,
// plugin `routeSidebar`, …). HIGHER precedence than the user pin — the rule
// is "a secondary sidebar always collapses the primary" — but it never
// mutates the persisted pin, so leaving the route restores the preference.
// Wired by Layout (PAP-10694).
forceCollapsed: boolean;
setForceCollapsed: (next: boolean) => void;
// Route-requested collapse: a route may *default* the app sidebar to
// collapsed. LOWER precedence than an explicit user pin. Wired by routes via
// RequestCollapsedSidebar.
routeRequestsCollapsed: boolean;
setRouteRequestsCollapsed: (next: boolean) => void;
}
@@ -84,6 +95,7 @@ export function SidebarProvider({ children }: { children: ReactNode }) {
// `null` = unpinned; an explicit user pin takes precedence over route request.
const [userCollapsed, setUserCollapsed] = useState<boolean | null>(() => readStoredCollapsed());
const [routeRequestsCollapsed, setRouteRequestsCollapsed] = useState(false);
const [forceCollapsed, setForceCollapsed] = useState(false);
const [rawPeeking, setRawPeeking] = useState(false);
const [pointerCanPeek, setPointerCanPeek] = useState(() => readPointerCanPeek());
@@ -105,11 +117,16 @@ export function SidebarProvider({ children }: { children: ReactNode }) {
return () => mql.removeEventListener("change", onChange);
}, []);
// Precedence (highest wins): explicit user pin > route request > default expanded.
const desktopCollapsed = userCollapsed !== null ? userCollapsed : routeRequestsCollapsed;
// Precedence (highest wins): forced (active secondary sidebar) > explicit user
// pin > route request > default expanded. The force is ephemeral and never
// touches the persisted pin, so dropping it restores the user's preference.
const pinnedOrRequested = userCollapsed !== null ? userCollapsed : routeRequestsCollapsed;
const desktopCollapsed = forceCollapsed || pinnedOrRequested;
// Collapsed/peek are desktop-only; mobile always uses the drawer. The user
// pin is preserved across the breakpoint and reapplies on the desktop side.
const collapsed = isMobile ? false : desktopCollapsed;
// While forced, the pin is locked: the expand/toggle affordance is inert.
const collapseLocked = !isMobile && forceCollapsed;
// Peek only applies when collapsed on a hover-capable pointer.
const peeking = rawPeeking && collapsed && pointerCanPeek;
@@ -119,8 +136,11 @@ export function SidebarProvider({ children }: { children: ReactNode }) {
}, []);
const toggleCollapsed = useCallback(() => {
setCollapsed(!desktopCollapsed);
}, [desktopCollapsed, setCollapsed]);
// While a secondary sidebar forces the rail, the toggle is locked: it must
// neither expand the rail nor mutate the persisted preference.
if (forceCollapsed) return;
setCollapsed(!pinnedOrRequested);
}, [forceCollapsed, pinnedOrRequested, setCollapsed]);
const setPeeking = useCallback((next: boolean) => {
setRawPeeking(next);
@@ -137,8 +157,11 @@ export function SidebarProvider({ children }: { children: ReactNode }) {
collapsed,
setCollapsed,
toggleCollapsed,
collapseLocked,
peeking,
setPeeking,
forceCollapsed,
setForceCollapsed,
routeRequestsCollapsed,
setRouteRequestsCollapsed,
}),
@@ -149,8 +172,10 @@ export function SidebarProvider({ children }: { children: ReactNode }) {
collapsed,
setCollapsed,
toggleCollapsed,
collapseLocked,
peeking,
setPeeking,
forceCollapsed,
routeRequestsCollapsed,
],
);