diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index a93a686920c..43d2c9bbd05 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -48,7 +48,7 @@ function getCompanyRouteSegment(pathname: string, companyPrefix: string | undefi } export function Layout() { - const { sidebarOpen, setSidebarOpen, toggleSidebar, isMobile } = useSidebar(); + const { sidebarOpen, setSidebarOpen, toggleSidebar, toggleCollapsed, isMobile } = useSidebar(); const { openNewIssue, openOnboarding } = useDialogActions(); const { togglePanelVisible } = usePanel(); const { @@ -178,6 +178,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", @@ -194,6 +203,7 @@ export function Layout() { onNewIssue: () => openNewIssue(), onSearch: openSearch, onToggleSidebar: toggleSidebar, + onToggleCollapse: toggleCollapse, onTogglePanel: togglePanel, onShowShortcuts: () => setShortcutsOpen(true), }); diff --git a/ui/src/context/SidebarContext.test.tsx b/ui/src/context/SidebarContext.test.tsx new file mode 100644 index 00000000000..7a12c1e3512 --- /dev/null +++ b/ui/src/context/SidebarContext.test.tsx @@ -0,0 +1,215 @@ +// @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 { SidebarProvider, useSidebar } from "./SidebarContext"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +const COLLAPSED_STORAGE_KEY = "paperclip.sidebar.collapsed"; + +// Mutable media state driving the matchMedia mock. +const mediaState = { mobile: false, hoverFine: true }; + +function setViewport({ mobile, hoverFine }: { mobile?: boolean; hoverFine?: boolean }) { + if (typeof mobile === "boolean") mediaState.mobile = mobile; + if (typeof hoverFine === "boolean") mediaState.hoverFine = hoverFine; + Object.defineProperty(window, "innerWidth", { + configurable: true, + writable: true, + value: mediaState.mobile ? 500 : 1280, + }); +} + +let capturedValue: ReturnType | null = null; + +function Capture() { + capturedValue = useSidebar(); + return null; +} + +function renderProvider(): { root: Root; host: HTMLDivElement } { + const host = document.createElement("div"); + document.body.appendChild(host); + const root = createRoot(host); + act(() => { + root.render( + + + , + ); + }); + return { root, host }; +} + +describe("SidebarContext", () => { + let active: { root: Root; host: HTMLDivElement } | null = null; + + beforeEach(() => { + localStorage.clear(); + capturedValue = null; + setViewport({ mobile: false, hoverFine: true }); + Object.defineProperty(window, "matchMedia", { + configurable: true, + writable: true, + value: vi.fn().mockImplementation((query: string) => { + const isHoverQuery = query.includes("(hover: hover)"); + const matches = isHoverQuery ? mediaState.hoverFine : mediaState.mobile; + return { + matches, + 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(); + }); + + describe("precedence: user pin > route request > default", () => { + it("defaults to expanded (collapsed=false) with no pin and no route request", () => { + active = renderProvider(); + expect(capturedValue?.collapsed).toBe(false); + }); + + it("uses the route request when there is no user pin", () => { + active = renderProvider(); + act(() => capturedValue?.setRouteRequestsCollapsed(true)); + expect(capturedValue?.collapsed).toBe(true); + }); + + it("lets an explicit user pin override the route request", () => { + active = renderProvider(); + act(() => capturedValue?.setRouteRequestsCollapsed(true)); + expect(capturedValue?.collapsed).toBe(true); + + // User pins expanded — this must win over the route's collapse request. + act(() => capturedValue?.setCollapsed(false)); + expect(capturedValue?.collapsed).toBe(false); + + // And pinning collapsed wins too. + act(() => capturedValue?.setCollapsed(true)); + expect(capturedValue?.collapsed).toBe(true); + }); + + it("toggleCollapsed flips the effective mode and records a pin", () => { + active = renderProvider(); + expect(capturedValue?.collapsed).toBe(false); + + act(() => capturedValue?.toggleCollapsed()); + expect(capturedValue?.collapsed).toBe(true); + expect(localStorage.getItem(COLLAPSED_STORAGE_KEY)).toBe("1"); + + act(() => capturedValue?.toggleCollapsed()); + expect(capturedValue?.collapsed).toBe(false); + expect(localStorage.getItem(COLLAPSED_STORAGE_KEY)).toBe("0"); + }); + + it("toggleCollapsed pins expanded when only a route request is active", () => { + active = renderProvider(); + act(() => capturedValue?.setRouteRequestsCollapsed(true)); + expect(capturedValue?.collapsed).toBe(true); + + // Effective is collapsed (via route); toggling should flip to expanded. + act(() => capturedValue?.toggleCollapsed()); + expect(capturedValue?.collapsed).toBe(false); + expect(localStorage.getItem(COLLAPSED_STORAGE_KEY)).toBe("0"); + }); + }); + + describe("persistence round-trip", () => { + it("writes '1'/'0' to localStorage on setCollapsed", () => { + active = renderProvider(); + act(() => capturedValue?.setCollapsed(true)); + expect(localStorage.getItem(COLLAPSED_STORAGE_KEY)).toBe("1"); + act(() => capturedValue?.setCollapsed(false)); + expect(localStorage.getItem(COLLAPSED_STORAGE_KEY)).toBe("0"); + }); + + it("reads the persisted pin synchronously on first paint (no flash)", () => { + localStorage.setItem(COLLAPSED_STORAGE_KEY, "1"); + active = renderProvider(); + // Collapsed is already true on the very first captured render. + expect(capturedValue?.collapsed).toBe(true); + }); + + it("treats a missing/garbage value as unpinned", () => { + localStorage.setItem(COLLAPSED_STORAGE_KEY, "yes"); + active = renderProvider(); + expect(capturedValue?.collapsed).toBe(false); + // Unpinned, so a route request still applies. + act(() => capturedValue?.setRouteRequestsCollapsed(true)); + expect(capturedValue?.collapsed).toBe(true); + }); + }); + + describe("mobile gating", () => { + it("never reports collapsed on mobile even with a collapsed pin", () => { + localStorage.setItem(COLLAPSED_STORAGE_KEY, "1"); + setViewport({ mobile: true }); + active = renderProvider(); + expect(capturedValue?.isMobile).toBe(true); + expect(capturedValue?.collapsed).toBe(false); + }); + + it("never reports peeking on mobile", () => { + localStorage.setItem(COLLAPSED_STORAGE_KEY, "1"); + setViewport({ mobile: true }); + active = renderProvider(); + act(() => capturedValue?.setPeeking(true)); + expect(capturedValue?.peeking).toBe(false); + }); + }); + + describe("peek gating", () => { + it("peeks when collapsed on a hover-capable pointer", () => { + localStorage.setItem(COLLAPSED_STORAGE_KEY, "1"); + setViewport({ mobile: false, hoverFine: true }); + active = renderProvider(); + expect(capturedValue?.collapsed).toBe(true); + act(() => capturedValue?.setPeeking(true)); + expect(capturedValue?.peeking).toBe(true); + }); + + it("does not peek when expanded", () => { + setViewport({ mobile: false, hoverFine: true }); + active = renderProvider(); + expect(capturedValue?.collapsed).toBe(false); + act(() => capturedValue?.setPeeking(true)); + expect(capturedValue?.peeking).toBe(false); + }); + + it("does not peek on a coarse/non-hover pointer", () => { + localStorage.setItem(COLLAPSED_STORAGE_KEY, "1"); + setViewport({ mobile: false, hoverFine: false }); + active = renderProvider(); + expect(capturedValue?.collapsed).toBe(true); + act(() => capturedValue?.setPeeking(true)); + expect(capturedValue?.peeking).toBe(false); + }); + }); + + describe("back-compat", () => { + it("retains sidebarOpen/toggleSidebar for the drawer", () => { + active = renderProvider(); + const initial = capturedValue?.sidebarOpen; + expect(typeof initial).toBe("boolean"); + act(() => capturedValue?.toggleSidebar()); + expect(capturedValue?.sidebarOpen).toBe(!initial); + }); + }); +}); diff --git a/ui/src/context/SidebarContext.tsx b/ui/src/context/SidebarContext.tsx index c563ae00ef2..917bb8e5c9f 100644 --- a/ui/src/context/SidebarContext.tsx +++ b/ui/src/context/SidebarContext.tsx @@ -1,20 +1,92 @@ -import { createContext, useCallback, useContext, useState, useEffect, type ReactNode } from "react"; +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; interface SidebarContextValue { + // Mobile drawer + back-compat (existing behavior, unchanged). sidebarOpen: boolean; setSidebarOpen: (open: boolean) => void; toggleSidebar: () => void; isMobile: boolean; + // Pinned desktop mode: expanded | collapsed. Desktop-only. + collapsed: boolean; + setCollapsed: (next: boolean) => void; + toggleCollapsed: () => void; + // 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). + routeRequestsCollapsed: boolean; + setRouteRequestsCollapsed: (next: boolean) => void; } const SidebarContext = createContext(null); const MOBILE_BREAKPOINT = 768; +const COLLAPSED_STORAGE_KEY = "paperclip.sidebar.collapsed"; +const PEEK_POINTER_QUERY = "(hover: hover) and (pointer: fine)"; + +// Tri-state read of the persisted user pin: +// true → pinned collapsed ("1") +// false → pinned expanded ("0") +// null → no pin (fall through to route request, then global default) +// Read synchronously in the state initializer so first paint matches the +// persisted mode (mirrors the `paperclip.sidebar.width` pattern in +// ResizableSidebarPane and avoids an expand→collapse flash). +function readStoredCollapsed(): boolean | null { + if (typeof window === "undefined") return null; + + try { + const stored = window.localStorage.getItem(COLLAPSED_STORAGE_KEY); + if (stored === "1") return true; + if (stored === "0") return false; + return null; + } catch { + return null; + } +} + +function writeStoredCollapsed(value: boolean) { + if (typeof window === "undefined") return; + + try { + window.localStorage.setItem(COLLAPSED_STORAGE_KEY, value ? "1" : "0"); + } catch { + // Storage can be unavailable in private contexts; pinning should still + // work for the current session. + } +} + +function readPointerCanPeek(): boolean { + if (typeof window === "undefined" || typeof window.matchMedia !== "function") { + return false; + } + + try { + return window.matchMedia(PEEK_POINTER_QUERY).matches; + } catch { + return false; + } +} export function SidebarProvider({ children }: { children: ReactNode }) { const [isMobile, setIsMobile] = useState(() => window.innerWidth < MOBILE_BREAKPOINT); const [sidebarOpen, setSidebarOpen] = useState(() => window.innerWidth >= MOBILE_BREAKPOINT); + // `null` = unpinned; an explicit user pin takes precedence over route request. + const [userCollapsed, setUserCollapsed] = useState(() => readStoredCollapsed()); + const [routeRequestsCollapsed, setRouteRequestsCollapsed] = useState(false); + const [rawPeeking, setRawPeeking] = useState(false); + const [pointerCanPeek, setPointerCanPeek] = useState(() => readPointerCanPeek()); + useEffect(() => { const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`); const onChange = (e: MediaQueryListEvent) => { @@ -25,13 +97,65 @@ export function SidebarProvider({ children }: { children: ReactNode }) { return () => mql.removeEventListener("change", onChange); }, []); + useEffect(() => { + if (typeof window.matchMedia !== "function") return; + const mql = window.matchMedia(PEEK_POINTER_QUERY); + const onChange = (e: MediaQueryListEvent) => setPointerCanPeek(e.matches); + mql.addEventListener("change", onChange); + return () => mql.removeEventListener("change", onChange); + }, []); + + // Precedence (highest wins): explicit user pin > route request > default expanded. + const desktopCollapsed = userCollapsed !== null ? userCollapsed : routeRequestsCollapsed; + // 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; + // Peek only applies when collapsed on a hover-capable pointer. + const peeking = rawPeeking && collapsed && pointerCanPeek; + + const setCollapsed = useCallback((next: boolean) => { + setUserCollapsed(next); + writeStoredCollapsed(next); + }, []); + + const toggleCollapsed = useCallback(() => { + setCollapsed(!desktopCollapsed); + }, [desktopCollapsed, setCollapsed]); + + const setPeeking = useCallback((next: boolean) => { + setRawPeeking(next); + }, []); + const toggleSidebar = useCallback(() => setSidebarOpen((v) => !v), []); - return ( - - {children} - + const value = useMemo( + () => ({ + sidebarOpen, + setSidebarOpen, + toggleSidebar, + isMobile, + collapsed, + setCollapsed, + toggleCollapsed, + peeking, + setPeeking, + routeRequestsCollapsed, + setRouteRequestsCollapsed, + }), + [ + sidebarOpen, + toggleSidebar, + isMobile, + collapsed, + setCollapsed, + toggleCollapsed, + peeking, + setPeeking, + routeRequestsCollapsed, + ], ); + + return {children}; } export function useSidebar() { diff --git a/ui/src/hooks/useKeyboardShortcuts.test.tsx b/ui/src/hooks/useKeyboardShortcuts.test.tsx index ae98193d94c..f2279ec24a4 100644 --- a/ui/src/hooks/useKeyboardShortcuts.test.tsx +++ b/ui/src/hooks/useKeyboardShortcuts.test.tsx @@ -11,14 +11,17 @@ import { useKeyboardShortcuts } from "./useKeyboardShortcuts"; function TestHarness({ onNewIssue, onSearch, + onToggleCollapse, }: { onNewIssue: () => void; onSearch?: () => void; + onToggleCollapse?: () => void; }) { useKeyboardShortcuts({ enabled: true, onNewIssue, onSearch, + onToggleCollapse, }); return
keyboard shortcuts test
; @@ -106,4 +109,53 @@ describe("useKeyboardShortcuts", () => { root.unmount(); }); }); + + it("fires onToggleCollapse on Cmd/Ctrl+B", () => { + const root = createRoot(container); + const onToggleCollapse = vi.fn(); + + act(() => { + root.render(); + }); + + document.dispatchEvent(new KeyboardEvent("keydown", { + key: "b", + metaKey: true, + bubbles: true, + cancelable: true, + })); + expect(onToggleCollapse).toHaveBeenCalledTimes(1); + + document.dispatchEvent(new KeyboardEvent("keydown", { + key: "b", + ctrlKey: true, + bubbles: true, + cancelable: true, + })); + expect(onToggleCollapse).toHaveBeenCalledTimes(2); + + act(() => { + root.unmount(); + }); + }); + + it("does not fire onToggleCollapse for a bare 'b' keypress", () => { + const root = createRoot(container); + const onToggleCollapse = vi.fn(); + + act(() => { + root.render(); + }); + + document.dispatchEvent(new KeyboardEvent("keydown", { + key: "b", + bubbles: true, + cancelable: true, + })); + expect(onToggleCollapse).not.toHaveBeenCalled(); + + act(() => { + root.unmount(); + }); + }); }); diff --git a/ui/src/hooks/useKeyboardShortcuts.ts b/ui/src/hooks/useKeyboardShortcuts.ts index 7788b8c359c..2bc51a2ec75 100644 --- a/ui/src/hooks/useKeyboardShortcuts.ts +++ b/ui/src/hooks/useKeyboardShortcuts.ts @@ -10,6 +10,7 @@ interface ShortcutHandlers { onNewIssue?: () => void; onSearch?: () => void; onToggleSidebar?: () => void; + onToggleCollapse?: () => void; onTogglePanel?: () => void; onShowShortcuts?: () => void; } @@ -19,6 +20,7 @@ export function useKeyboardShortcuts({ onNewIssue, onSearch, onToggleSidebar, + onToggleCollapse, onTogglePanel, onShowShortcuts, }: ShortcutHandlers) { @@ -67,6 +69,12 @@ export function useKeyboardShortcuts({ onToggleSidebar?.(); } + // Cmd/Ctrl+B → Collapse/expand sidebar (desktop) or toggle drawer (mobile) + if ((e.key === "b" || e.key === "B") && (e.metaKey || e.ctrlKey) && !e.altKey) { + e.preventDefault(); + onToggleCollapse?.(); + } + // ] → Toggle Panel if (e.key === "]" && !e.metaKey && !e.ctrlKey) { e.preventDefault(); @@ -76,5 +84,5 @@ export function useKeyboardShortcuts({ document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); - }, [enabled, onNewIssue, onSearch, onToggleSidebar, onTogglePanel, onShowShortcuts]); + }, [enabled, onNewIssue, onSearch, onToggleSidebar, onToggleCollapse, onTogglePanel, onShowShortcuts]); }