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 : }
) : ( -
+
- - {isCompanySettingsRoute ? ( - - ) : ( - companySidebar - )} - +
-
+ )} + {!isMobile && hasSecondarySidebar ? ( + {secondarySidebar} + ) : null} +
| null = null; + +function Capture() { + capturedValue = useSidebar(); + return null; +} + +// A tiny stand-in for "a route that brings its own sidebar". When `onRoute` +// is true it mounts ; flipping it to false models +// navigating away to a route that does not request a collapse. +function Harness({ onRoute }: { onRoute: boolean }) { + return ( + + + {onRoute ? : null} + + ); +} + +function render(onRoute: boolean): { root: Root; host: HTMLDivElement } { + const host = document.createElement("div"); + document.body.appendChild(host); + const root = createRoot(host); + act(() => root.render()); + return { root, host }; +} + +describe("RequestCollapsedSidebar", () => { + let active: { root: Root; host: HTMLDivElement } | null = null; + + beforeEach(() => { + localStorage.clear(); + capturedValue = null; + Object.defineProperty(window, "innerWidth", { + configurable: true, + writable: true, + value: 1280, // desktop: collapsed/peek are meaningful + }); + Object.defineProperty(window, "matchMedia", { + configurable: true, + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + // Desktop, hover-capable pointer. + matches: query.includes("(hover: hover)"), + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); + }); + + afterEach(() => { + if (active) { + act(() => active!.root.unmount()); + active.host.remove(); + active = null; + } + localStorage.clear(); + }); + + it("requests collapsed while mounted when there is no user pin", () => { + active = render(true); + expect(capturedValue?.routeRequestsCollapsed).toBe(true); + expect(capturedValue?.collapsed).toBe(true); + }); + + it("lets an explicit user pin override the route request", () => { + active = render(true); + expect(capturedValue?.collapsed).toBe(true); + + // User explicitly pins expanded — must win over the route's request. + act(() => capturedValue?.setCollapsed(false)); + expect(capturedValue?.routeRequestsCollapsed).toBe(true); + expect(capturedValue?.collapsed).toBe(false); + }); + + it("clears the request on unmount, restoring the global default", () => { + active = render(true); + expect(capturedValue?.collapsed).toBe(true); + + // Navigate away: the route (and its ) unmounts. + act(() => active!.root.render()); + expect(capturedValue?.routeRequestsCollapsed).toBe(false); + expect(capturedValue?.collapsed).toBe(false); + }); + + it("keeps a user pin after navigating away (pin persists, request cleared)", () => { + active = render(true); + act(() => capturedValue?.setCollapsed(true)); + expect(localStorage.getItem(COLLAPSED_STORAGE_KEY)).toBe("1"); + + act(() => active!.root.render()); + // Route request gone, but the explicit collapsed pin still applies. + expect(capturedValue?.routeRequestsCollapsed).toBe(false); + expect(capturedValue?.collapsed).toBe(true); + }); +}); diff --git a/ui/src/components/RequestCollapsedSidebar.tsx b/ui/src/components/RequestCollapsedSidebar.tsx new file mode 100644 index 00000000..259de29a --- /dev/null +++ b/ui/src/components/RequestCollapsedSidebar.tsx @@ -0,0 +1,36 @@ +import { useEffect } from "react"; +import { useSidebar } from "../context/SidebarContext"; + +/** + * Effect-only component (renders nothing). While mounted, it asks the app + * sidebar to default to **collapsed** (still peek-able) — the generic + * "this route brings its own sidebar, keep the app one out of the way" case. + * + * Precedence is enforced by {@link SidebarContext}: an explicit user pin + * (collapsed *or* expanded) always wins over this route request. When the + * component unmounts — e.g. navigating away from the route that rendered it — + * the cleanup clears the request and the global default is restored. + * + * Drop it anywhere inside a route's element tree: + * + * ```tsx + * function MyPluginPage() { + * return ( + * <> + * + * … + * + * ); + * } + * ``` + */ +export function RequestCollapsedSidebar() { + const { setRouteRequestsCollapsed } = useSidebar(); + + useEffect(() => { + setRouteRequestsCollapsed(true); + return () => setRouteRequestsCollapsed(false); + }, [setRouteRequestsCollapsed]); + + return null; +} diff --git a/ui/src/components/ResizableSidebarPane.test.tsx b/ui/src/components/ResizableSidebarPane.test.tsx deleted file mode 100644 index cb635d81..00000000 --- a/ui/src/components/ResizableSidebarPane.test.tsx +++ /dev/null @@ -1,121 +0,0 @@ -// @vitest-environment jsdom - -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { ResizableSidebarPane } from "./ResizableSidebarPane"; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; - -function pointerEvent(type: string, clientX: number) { - const event = new MouseEvent(type, { bubbles: true, clientX }); - Object.defineProperty(event, "pointerId", { value: 1 }); - return event; -} - -describe("ResizableSidebarPane", () => { - let container: HTMLDivElement; - let root: Root; - - beforeEach(() => { - window.localStorage.clear(); - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - }); - - afterEach(() => { - act(() => { - root.unmount(); - }); - container.remove(); - window.localStorage.clear(); - }); - - function pane() { - return container.firstElementChild as HTMLDivElement; - } - - function handle() { - return container.querySelector('[role="separator"]') as HTMLDivElement | null; - } - - it("uses a persisted width when open", () => { - window.localStorage.setItem("test.sidebar.width", "320"); - - act(() => { - root.render( - -
Sidebar
-
, - ); - }); - - expect(pane().style.width).toBe("320px"); - expect(handle()?.getAttribute("aria-valuenow")).toBe("320"); - }); - - it("resizes by dragging and persists the new width", () => { - act(() => { - root.render( - -
Sidebar
-
, - ); - }); - - const separator = handle(); - expect(separator).not.toBeNull(); - separator!.setPointerCapture = vi.fn(); - - act(() => { - separator!.dispatchEvent(pointerEvent("pointerdown", 240)); - separator!.dispatchEvent(pointerEvent("pointermove", 320)); - separator!.dispatchEvent(pointerEvent("pointerup", 320)); - }); - - expect(pane().style.width).toBe("320px"); - expect(window.localStorage.getItem("test.sidebar.width")).toBe("320"); - }); - - it("supports keyboard resizing and clamps to the configured bounds", () => { - act(() => { - root.render( - -
Sidebar
-
, - ); - }); - - const separator = handle(); - act(() => { - separator?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })); - }); - expect(pane().style.width).toBe("256px"); - expect(window.localStorage.getItem("test.sidebar.width")).toBe("256"); - - act(() => { - separator?.dispatchEvent(new KeyboardEvent("keydown", { key: "Home", bubbles: true })); - }); - expect(pane().style.width).toBe("208px"); - - act(() => { - separator?.dispatchEvent(new KeyboardEvent("keydown", { key: "End", bubbles: true })); - }); - expect(pane().style.width).toBe("420px"); - }); - - it("can render without a resize handle", () => { - act(() => { - root.render( - -
Sidebar
-
, - ); - }); - - expect(handle()).toBeNull(); - expect(pane().style.width).toBe("240px"); - }); -}); diff --git a/ui/src/components/ResizableSidebarPane.tsx b/ui/src/components/ResizableSidebarPane.tsx deleted file mode 100644 index 09f9c70a..00000000 --- a/ui/src/components/ResizableSidebarPane.tsx +++ /dev/null @@ -1,176 +0,0 @@ -import { - useCallback, - useEffect, - useMemo, - useRef, - useState, - type KeyboardEvent, - type PointerEvent, - type ReactNode, -} from "react"; -import { cn } from "@/lib/utils"; - -const DEFAULT_SIDEBAR_WIDTH = 240; -const MIN_SIDEBAR_WIDTH = 208; -const MAX_SIDEBAR_WIDTH = 420; -const SIDEBAR_WIDTH_STEP = 16; - -function clampSidebarWidth(width: number) { - return Math.min(MAX_SIDEBAR_WIDTH, Math.max(MIN_SIDEBAR_WIDTH, width)); -} - -function readStoredSidebarWidth(storageKey: string) { - if (typeof window === "undefined") return DEFAULT_SIDEBAR_WIDTH; - - try { - const stored = window.localStorage.getItem(storageKey); - if (!stored) return DEFAULT_SIDEBAR_WIDTH; - const parsed = Number.parseInt(stored, 10); - if (!Number.isFinite(parsed)) return DEFAULT_SIDEBAR_WIDTH; - return clampSidebarWidth(parsed); - } catch { - return DEFAULT_SIDEBAR_WIDTH; - } -} - -function writeStoredSidebarWidth(storageKey: string, width: number) { - if (typeof window === "undefined") return; - - try { - window.localStorage.setItem(storageKey, String(clampSidebarWidth(width))); - } catch { - // Storage can be unavailable in private contexts; resizing should still work. - } -} - -type ResizableSidebarPaneProps = { - children: ReactNode; - open: boolean; - resizable?: boolean; - storageKey?: string; - className?: string; -}; - -export function ResizableSidebarPane({ - children, - open, - resizable = false, - storageKey = "paperclip.sidebar.width", - className, -}: ResizableSidebarPaneProps) { - const [width, setWidth] = useState(() => readStoredSidebarWidth(storageKey)); - const [isResizing, setIsResizing] = useState(false); - const widthRef = useRef(width); - const dragState = useRef<{ startX: number; startWidth: number } | null>(null); - - useEffect(() => { - const storedWidth = readStoredSidebarWidth(storageKey); - widthRef.current = storedWidth; - setWidth(storedWidth); - }, [storageKey]); - - const visibleWidth = open ? width : 0; - const paneStyle = useMemo( - () => ({ width: `${visibleWidth}px` }), - [visibleWidth], - ); - - const commitWidth = useCallback( - (nextWidth: number) => { - const clamped = clampSidebarWidth(nextWidth); - widthRef.current = clamped; - setWidth(clamped); - writeStoredSidebarWidth(storageKey, clamped); - }, - [storageKey], - ); - - const handlePointerDown = useCallback( - (event: PointerEvent) => { - if (!open || !resizable) return; - - event.preventDefault(); - event.currentTarget.setPointerCapture(event.pointerId); - dragState.current = { startX: event.clientX, startWidth: widthRef.current }; - setIsResizing(true); - }, - [open, resizable], - ); - - const handlePointerMove = useCallback( - (event: PointerEvent) => { - if (!dragState.current) return; - - const nextWidth = dragState.current.startWidth + event.clientX - dragState.current.startX; - const clamped = clampSidebarWidth(nextWidth); - widthRef.current = clamped; - setWidth(clamped); - }, - [], - ); - - const endResize = useCallback(() => { - if (!dragState.current) return; - - dragState.current = null; - setIsResizing(false); - writeStoredSidebarWidth(storageKey, widthRef.current); - }, [storageKey]); - - const handleKeyDown = useCallback( - (event: KeyboardEvent) => { - if (!open || !resizable) return; - - if (event.key === "ArrowLeft") { - event.preventDefault(); - commitWidth(width - SIDEBAR_WIDTH_STEP); - } else if (event.key === "ArrowRight") { - event.preventDefault(); - commitWidth(width + SIDEBAR_WIDTH_STEP); - } else if (event.key === "Home") { - event.preventDefault(); - commitWidth(MIN_SIDEBAR_WIDTH); - } else if (event.key === "End") { - event.preventDefault(); - commitWidth(MAX_SIDEBAR_WIDTH); - } - }, - [commitWidth, open, resizable, width], - ); - - return ( -
- {children} - {resizable && open ? ( -
- ) : null} -
- ); -} diff --git a/ui/src/components/SecondarySidebar.tsx b/ui/src/components/SecondarySidebar.tsx new file mode 100644 index 00000000..20b41c17 --- /dev/null +++ b/ui/src/components/SecondarySidebar.tsx @@ -0,0 +1,39 @@ +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 + * main content on takeover routes (company settings, plugin `routeSidebar`). + * + * The takeover model (PAP-10695) no longer *replaces* the app ``: + * the host collapses it to its 64px rail (still peek-able) and renders the + * contextual sidebar here, yielding `[ rail 64px ][ secondary ~240px ][ content ]`. + * + * 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, + className, +}: { + children: ReactNode; + className?: string; +}) { + return ( +
+ {children} +
+ ); +} diff --git a/ui/src/components/Sidebar.test.tsx b/ui/src/components/Sidebar.test.tsx index 60401b41..c711b02a 100644 --- a/ui/src/components/Sidebar.test.tsx +++ b/ui/src/components/Sidebar.test.tsx @@ -6,6 +6,7 @@ import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { Sidebar } from "./Sidebar"; +import { TooltipProvider } from "@/components/ui/tooltip"; const mockHeartbeatsApi = vi.hoisted(() => ({ liveRunsForCompany: vi.fn(), @@ -47,11 +48,18 @@ vi.mock("../context/CompanyContext", () => ({ }), })); +const mockSidebar = vi.hoisted(() => ({ + isMobile: false, + setSidebarOpen: vi.fn(), + collapsed: false, + collapseLocked: false, + peeking: false, + toggleCollapsed: vi.fn(), + setCollapsed: vi.fn(), +})); + vi.mock("../context/SidebarContext", () => ({ - useSidebar: () => ({ - isMobile: false, - setSidebarOpen: vi.fn(), - }), + useSidebar: () => mockSidebar, })); vi.mock("../api/heartbeats", () => ({ @@ -112,7 +120,9 @@ describe("Sidebar", () => { flushSync(() => { root.render( - + + + , ); }); @@ -125,6 +135,9 @@ describe("Sidebar", () => { container = document.createElement("div"); document.body.appendChild(container); mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([]); + mockSidebar.isMobile = false; + mockSidebar.collapsed = false; + mockSidebar.peeking = false; }); afterEach(() => { @@ -284,4 +297,92 @@ describe("Sidebar", () => { root.unmount(); }); }); + + it("header toggle collapses an expanded sidebar (aria-expanded reflects state)", async () => { + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false }); + const root = await renderSidebar(); + + const toggle = container.querySelector('button[aria-label="Collapse sidebar"]'); + expect(toggle).not.toBeNull(); + expect(toggle?.getAttribute("aria-expanded")).toBe("true"); + + act(() => { + toggle?.click(); + }); + expect(mockSidebar.toggleCollapsed).toHaveBeenCalledTimes(1); + + flushSync(() => { + root.unmount(); + }); + }); + + 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 + // they are dropped in the rail. Expansion stays reachable via hover-peek + Pin + // and Cmd/Ctrl+B. The full controls return as soon as the panel is expanded or + // peeking (covered by the other top-bar tests). + mockSidebar.collapsed = true; + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false }); + const root = await renderSidebar(); + + expect(container.querySelector('button[aria-label="Expand sidebar"]')).toBeNull(); + expect(container.querySelector('a[aria-label="Open search"]')).toBeNull(); + // The company menu (workspace switcher / logo) is still present in the rail. + expect(container.textContent).toContain("Company menu"); + + flushSync(() => { + root.unmount(); + }); + }); + + it("peek header shows a pin that promotes the peek to pinned-expanded", async () => { + mockSidebar.collapsed = true; + mockSidebar.peeking = true; + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false }); + const root = await renderSidebar(); + + // The collapse toggle is replaced by the pin while peeking. + expect(container.querySelector('button[aria-label="Expand sidebar"]')).toBeNull(); + const pin = container.querySelector('button[aria-label="Keep sidebar expanded"]'); + expect(pin).not.toBeNull(); + + act(() => { + pin?.click(); + }); + expect(mockSidebar.setCollapsed).toHaveBeenCalledWith(false); + + flushSync(() => { + root.unmount(); + }); + }); + + it("hides the collapse affordance on mobile (drawer handles it)", async () => { + mockSidebar.isMobile = true; + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false }); + const root = await renderSidebar(); + + expect(container.querySelector('button[aria-label="Collapse sidebar"]')).toBeNull(); + expect(container.querySelector('button[aria-label="Keep sidebar expanded"]')).toBeNull(); + + flushSync(() => { + root.unmount(); + }); + }); }); diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx index 28e1e888..592888ad 100644 --- a/ui/src/components/Sidebar.tsx +++ b/ui/src/components/Sidebar.tsx @@ -14,6 +14,9 @@ import { Package, Settings, FolderOpen, + PanelLeftClose, + PanelLeftOpen, + Pin, } from "lucide-react"; import { useQuery } from "@tanstack/react-query"; import { NavLink } from "@/lib/router"; @@ -23,11 +26,14 @@ import { SidebarAgents } from "./SidebarAgents"; import { SidebarProjects } from "./SidebarProjects"; import { useDialogActions } from "../context/DialogContext"; import { useCompany } from "../context/CompanyContext"; +import { useSidebar } from "../context/SidebarContext"; import { heartbeatsApi } from "../api/heartbeats"; import { instanceSettingsApi } from "../api/instanceSettings"; import { queryKeys } from "../lib/queryKeys"; import { useInboxBadge } from "../hooks/useInboxBadge"; import { Button } from "@/components/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { cn, SIDEBAR_RAIL_HIDDEN_LABEL } from "../lib/utils"; import { PluginSlotOutlet } from "@/plugins/slots"; import { PluginLauncherOutlet } from "@/plugins/launchers"; import { SidebarCompanyMenu } from "./SidebarCompanyMenu"; @@ -35,6 +41,8 @@ import { SidebarCompanyMenu } from "./SidebarCompanyMenu"; export function Sidebar() { const { openNewIssue } = useDialogActions(); const { selectedCompanyId, selectedCompany } = useCompany(); + const { isMobile, collapsed, collapseLocked, peeking, toggleCollapsed, setCollapsed } = useSidebar(); + const rail = collapsed && !peeking; const inboxBadge = useInboxBadge(selectedCompanyId); const { data: experimentalSettings } = useQuery({ queryKey: queryKeys.instance.experimentalSettings, @@ -64,37 +72,92 @@ export function Sidebar() { {/* Top bar: Company name (bold) + Search — aligned with top sections (no visible border) */}
- + {/* In the collapsed rail the search/toggle controls don't fit beside the + logo — keeping them would overflow the 64px rail and squeeze the logo + out of alignment with the icon column below it (PAP-10676). They return + as soon as the panel is expanded (pinned) or peeking. Expansion in the + rail is still reachable via hover-peek + Pin and Cmd/Ctrl+B. */} + {!rail ? ( + <> + + {/* 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. 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 ? ( + + ) : ( + + ) + ) : null} + + ) : null}