feat(ui): sidebar toggle affordances — header button, peek pin, Cmd+B cheatsheet (PAP-10647)

Task 4 of the collapsible sidebar plan (PAP-10638, plan §6).

Add the user-facing controls for the collapse/peek state model from
PAP-10642, on top of the rail+peek shell from PAP-10644.

Sidebar header control (desktop only; mobile keeps the off-canvas drawer):
- Expanded → a PanelLeftClose toggle (aria-expanded=true, "Collapse sidebar")
  that calls toggleCollapsed(); the pin persists via setCollapsed's localStorage
  write.
- Collapsed rail at rest → a PanelLeftOpen toggle (aria-expanded=false,
  "Expand sidebar").
- While peeking (hover/focus flyout over the rail) the toggle is replaced by a
  Pin button ("Keep sidebar expanded") that promotes the peek to pinned-expanded
  via setCollapsed(false).

Keyboard cheatsheet: surface the re-pointed Cmd/Ctrl+B (Task 1) in the Global
section, rendered as a "+" chord rather than a "then" sequence, with a
platform-appropriate ⌘/Ctrl cap.

Deferred (optional per scope): the small expand control in BreadcrumbBar.

Tests cover collapse, expand, peek→pin promotion, the mobile hide, and the
cheatsheet chord entry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 4.8 committed 2026-06-08 16:54:50 +00:00
1 parent 47bebe8bb8
commit ce548a367f
4 files changed
+181 -5

No files matched your search

@@ -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(<KeyboardShortcutsCheatsheetContent />);
});
// 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();
});
});
});
@@ -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() {
<div className="flex items-center gap-1">
{shortcut.keys.map((key, i) => (
<span key={key} className="flex items-center gap-1">
{i > 0 && <span className="text-xs text-muted-foreground">then</span>}
{i > 0 && (
<span className="text-xs text-muted-foreground">
{shortcut.combo ? "+" : "then"}
</span>
)}
<KeyCap>{key}</KeyCap>
</span>
))}
+84 -4
View File
@@ -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<HTMLButtonElement>('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<HTMLButtonElement>('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<HTMLButtonElement>('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();
});
});
});
+35
View File
@@ -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() {
<Search className="h-4 w-4" />
</NavLink>
</Button>
{/* 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 ? (
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground shrink-0"
aria-label="Keep sidebar expanded"
title="Keep sidebar expanded"
onClick={() => setCollapsed(false)}
>
<Pin className="h-4 w-4" />
</Button>
) : (
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground shrink-0"
aria-expanded={!collapsed}
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
title={collapsed ? "Expand sidebar" : "Collapse sidebar"}
onClick={() => toggleCollapsed()}
>
{collapsed ? <PanelLeftOpen className="h-4 w-4" /> : <PanelLeftClose className="h-4 w-4" />}
</Button>
)
) : null}
</div>
<nav className="flex-1 min-h-0 overflow-y-auto scrollbar-auto-hide flex flex-col gap-4 pointer-coarse:gap-3 px-3 py-2">