diff --git a/ui/src/components/KeyboardShortcutsCheatsheet.test.tsx b/ui/src/components/KeyboardShortcutsCheatsheet.test.tsx new file mode 100644 index 00000000000..b27b3831c10 --- /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 b7ebdc1230b..3300852422f 100644 --- a/ui/src/components/KeyboardShortcutsCheatsheet.tsx +++ b/ui/src/components/KeyboardShortcutsCheatsheet.tsx @@ -3,8 +3,20 @@ 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). +const META_KEY = + typeof navigator !== "undefined" && + /Mac|iPhone|iPad|iPod/.test(navigator.platform || navigator.userAgent || "") + ? "⌘" + : "Ctrl"; + interface ShortcutSection { title: string; shortcuts: ShortcutEntry[]; @@ -41,6 +53,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 +87,11 @@ export function KeyboardShortcutsCheatsheetContent() {
{shortcut.keys.map((key, i) => ( - {i > 0 && then} + {i > 0 && ( + + {shortcut.combo ? "+" : "then"} + + )} {key} ))} diff --git a/ui/src/components/Sidebar.test.tsx b/ui/src/components/Sidebar.test.tsx index 60401b41885..ef369842494 100644 --- a/ui/src/components/Sidebar.test.tsx +++ b/ui/src/components/Sidebar.test.tsx @@ -47,11 +47,17 @@ vi.mock("../context/CompanyContext", () => ({ }), })); +const mockSidebar = vi.hoisted(() => ({ + isMobile: false, + setSidebarOpen: vi.fn(), + collapsed: 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", () => ({ @@ -125,6 +131,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 +293,75 @@ 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("header toggle expands a collapsed rail", async () => { + mockSidebar.collapsed = true; + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false }); + const root = await renderSidebar(); + + const toggle = container.querySelector('button[aria-label="Expand sidebar"]'); + expect(toggle).not.toBeNull(); + expect(toggle?.getAttribute("aria-expanded")).toBe("false"); + + act(() => { + toggle?.click(); + }); + expect(mockSidebar.toggleCollapsed).toHaveBeenCalledTimes(1); + + 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 28e1e8880b6..59bf6457cda 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,6 +26,7 @@ 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"; @@ -35,6 +39,7 @@ import { SidebarCompanyMenu } from "./SidebarCompanyMenu"; export function Sidebar() { const { openNewIssue } = useDialogActions(); const { selectedCompanyId, selectedCompany } = useCompany(); + const { isMobile, collapsed, peeking, toggleCollapsed, setCollapsed } = useSidebar(); const inboxBadge = useInboxBadge(selectedCompanyId); const { data: experimentalSettings } = useQuery({ queryKey: queryKeys.instance.experimentalSettings, @@ -76,6 +81,36 @@ 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 ? ( + peeking ? ( + + ) : ( + + ) + ) : null}