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}