feat(ui): add collapsible sidebar rail and takeover panes (#7824)

## Thinking Path

> - Paperclip is the open source control plane people use to manage AI
agents, work, and company context.
> - The board UI sidebar is the main way operators keep orientation
across companies, projects, agents, issues, and settings.
> - The existing fixed expanded sidebar competes with route-specific
navigation, especially company settings and plugin routes that bring
their own contextual sidebar.
> - A collapsible primary rail preserves global navigation while giving
contextual pages more horizontal room.
> - This pull request adds a persisted collapsed rail, hover/focus peek,
keyboard toggle, and a secondary sidebar takeover model for settings and
plugin `routeSidebar` surfaces.
> - The benefit is a denser board shell that keeps the app rail
available without replacing it when a route needs its own navigation.

## Linked Issues or Issue Description

Paperclip issue: PAP-10638 Create collapsible sidebar branch.

Related GitHub PR found during duplicate search: #3838
(`feat/collapsible-sidebar`) covers a similar sidebar area but is a
different head branch and implementation. This PR intentionally packages
the work from `PAP-10638-collapsable-sidebar` into one reviewable
branch.

Problem description:

The board shell needs a first-class collapsed sidebar mode. Contextual
surfaces such as company settings and plugin route sidebars should not
replace the global app sidebar; they should collapse the app sidebar to
a rail and render their contextual navigation beside it.

## What Changed

- Added desktop collapsed/sidebar-peek state to `SidebarContext`,
including persisted user pins, route collapse requests, and forced
collapse for secondary-sidebar routes.
- Replaced the old resizable sidebar pane with `SidebarShell`, which
supports a fixed 64px rail, persisted expanded width, keyboard/pointer
resizing, and hover/focus peek overlay behavior.
- Updated `Sidebar`, sidebar nav items, project/agent sections, badges,
and account/company menu presentation for expanded, collapsed, and
peeking states.
- Added `RequestCollapsedSidebar` and `SecondarySidebar` so routes and
plugin `routeSidebar` slots can request contextual sidebar layouts
without replacing the primary app sidebar.
- Wired company settings and plugin route sidebars into the
secondary-pane takeover model.
- Added focused Vitest coverage for sidebar state precedence, shell
sizing, nav item rail rendering, keyboard shortcuts, layout takeover
behavior, and route collapse requests.
- Updated plugin authoring docs/spec references for route sidebar
behavior.

## Verification

Targeted local verification passed:

```sh
NODE_ENV=test pnpm run preflight:workspace-links && NODE_ENV=test pnpm exec vitest run ui/src/context/SidebarContext.test.tsx ui/src/components/SidebarShell.test.tsx ui/src/components/Sidebar.test.tsx ui/src/components/Layout.test.tsx ui/src/components/RequestCollapsedSidebar.test.tsx ui/src/components/SidebarNavItem.test.tsx ui/src/components/SidebarAgents.test.tsx ui/src/components/SidebarProjects.test.tsx ui/src/components/KeyboardShortcutsCheatsheet.test.tsx ui/src/hooks/useKeyboardShortcuts.test.tsx
```

Result: 10 test files passed, 88 tests passed.

Additional follow-up verification passed after review fixes:

```sh
NODE_ENV=test pnpm run preflight:workspace-links && NODE_ENV=test pnpm exec vitest run ui/src/components/Layout.test.tsx ui/src/context/SidebarContext.test.tsx && pnpm --filter /ui typecheck
```

Result: 2 test files passed, 28 tests passed, and UI typecheck passed.

Latest PR-head remote checks: Paperclip PR workflow, Snyk, Socket, and
Greptile are green; commitperclip `review` is cancelled in its
security-gate step after filing a non-blocking neutral `security-review`
check.

Notes:

- A direct run without `NODE_ENV=test` loads React's production build in
this workspace, where `act` is unavailable; the command above matches
the repo stable runner's test environment.
- I did not run Playwright/browser e2e or full workspace build/typecheck
in this PR-creation heartbeat.
- QA screenshots are attached in
https://github.com/paperclipai/paperclip/pull/7824#issuecomment-4661968387
for expanded, collapsed rail, hover peek, and settings secondary-sidebar
states.

## Risks

- Medium UI layout risk: this changes the board shell and primary
sidebar composition across many routes.
- Local storage migration risk is low: new collapsed state uses a new
key and existing width storage remains scoped to the sidebar width.
- Plugin route risk: plugin `routeSidebar` slots now render as secondary
panes on desktop, so plugin authors should confirm their route sidebar
content fits a 240px contextual pane.
- Mobile risk appears low because mobile keeps the drawer model and
gates collapsed/peek behavior to desktop.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI Codex coding agent based on GPT-5, with local shell/git/GitHub
CLI tool use. Exact service-side model identifier and context window
were not exposed in this runtime.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta
2026-06-09 13:25:17 -05:00
committed by GitHub
parent 0a2230b2ec
commit 50bff3b274
33 changed files with 2458 additions and 449 deletions
+16
View File
@@ -336,6 +336,22 @@ Mount surfaces currently wired in the host include:
- `commentAnnotation` - `commentAnnotation`
- `commentContextMenuItem` - `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 `<Sidebar/>` 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 ## Shared host components
Use shared components from `@paperclipai/plugin-sdk/ui` when the plugin needs a Use shared components from `@paperclipai/plugin-sdk/ui` when the plugin needs a
+21
View File
@@ -1131,6 +1131,27 @@ Plugins may add sidebar links to:
- global plugin settings - global plugin settings
- company-context plugin pages - 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 `<Sidebar/>`
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` ## 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. 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.
+3 -1
View File
@@ -238,7 +238,9 @@ Adds a navigation-style entry to the main company sidebar navigation area, rende
#### `routeSidebar` #### `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 `<Sidebar/>` 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` #### `sidebarPanel`
+161
View File
@@ -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 `<Sidebar/>` 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();
});
});
@@ -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,22 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/u
interface ShortcutEntry { interface ShortcutEntry {
keys: string[]; keys: string[];
label: 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 { interface ShortcutSection {
title: string; title: string;
shortcuts: ShortcutEntry[]; shortcuts: ShortcutEntry[];
@@ -41,6 +55,7 @@ const sections: ShortcutSection[] = [
{ keys: ["/"], label: "Search current page or quick search" }, { keys: ["/"], label: "Search current page or quick search" },
{ keys: ["c"], label: "New task" }, { keys: ["c"], label: "New task" },
{ keys: ["["], label: "Toggle sidebar" }, { keys: ["["], label: "Toggle sidebar" },
{ keys: [META_KEY, "B"], label: "Collapse or expand sidebar", combo: true },
{ keys: ["]"], label: "Toggle panel" }, { keys: ["]"], label: "Toggle panel" },
{ keys: ["?"], label: "Show keyboard shortcuts" }, { keys: ["?"], label: "Show keyboard shortcuts" },
], ],
@@ -74,7 +89,11 @@ export function KeyboardShortcutsCheatsheetContent() {
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{shortcut.keys.map((key, i) => ( {shortcut.keys.map((key, i) => (
<span key={key} className="flex items-center gap-1"> <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> <KeyCap>{key}</KeyCap>
</span> </span>
))} ))}
+106 -5
View File
@@ -17,6 +17,7 @@ const mockInstanceSettingsApi = vi.hoisted(() => ({
const mockNavigate = vi.hoisted(() => vi.fn()); const mockNavigate = vi.hoisted(() => vi.fn());
const mockSetSelectedCompanyId = vi.hoisted(() => vi.fn()); const mockSetSelectedCompanyId = vi.hoisted(() => vi.fn());
const mockSetSidebarOpen = vi.hoisted(() => vi.fn()); const mockSetSidebarOpen = vi.hoisted(() => vi.fn());
const mockSetForceCollapsed = vi.hoisted(() => vi.fn());
const mockCompanyState = vi.hoisted(() => ({ const mockCompanyState = vi.hoisted(() => ({
companies: [{ id: "company-1", issuePrefix: "PAP", name: "Paperclip" }], companies: [{ id: "company-1", issuePrefix: "PAP", name: "Paperclip" }],
selectedCompany: { 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 mockUsePluginSlots = vi.hoisted(() => vi.fn());
const mockPluginSlotContexts = vi.hoisted(() => [] as Array<Record<string, unknown>>); const mockPluginSlotContexts = vi.hoisted(() => [] as Array<Record<string, unknown>>);
const mockSetPeeking = vi.hoisted(() => vi.fn());
const mockSidebarState = vi.hoisted(() => ({ const mockSidebarState = vi.hoisted(() => ({
sidebarOpen: true, sidebarOpen: true,
isMobile: false, isMobile: false,
collapsed: false,
peeking: false,
})); }));
let currentPathname = "/PAP/dashboard"; let currentPathname = "/PAP/dashboard";
@@ -167,7 +171,16 @@ vi.mock("../context/SidebarContext", () => ({
sidebarOpen: mockSidebarState.sidebarOpen, sidebarOpen: mockSidebarState.sidebarOpen,
setSidebarOpen: mockSetSidebarOpen, setSidebarOpen: mockSetSidebarOpen,
toggleSidebar: vi.fn(), toggleSidebar: vi.fn(),
toggleCollapsed: vi.fn(),
collapsed: mockSidebarState.collapsed,
collapseLocked: false,
peeking: mockSidebarState.peeking,
setPeeking: mockSetPeeking,
isMobile: mockSidebarState.isMobile, isMobile: mockSidebarState.isMobile,
forceCollapsed: false,
setForceCollapsed: mockSetForceCollapsed,
routeRequestsCollapsed: false,
setRouteRequestsCollapsed: vi.fn(),
}), }),
})); }));
@@ -236,6 +249,9 @@ describe("Layout", () => {
mockPluginSlotContexts.length = 0; mockPluginSlotContexts.length = 0;
mockSidebarState.sidebarOpen = true; mockSidebarState.sidebarOpen = true;
mockSidebarState.isMobile = false; mockSidebarState.isMobile = false;
mockSidebarState.collapsed = false;
mockSidebarState.peeking = false;
mockSetPeeking.mockClear();
}); });
afterEach(() => { 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(
<QueryClientProvider client={queryClient}>
<Layout />
</QueryClientProvider>,
);
});
await flushReact();
};
// The SidebarShell overlay panel carries the peek mouse handlers.
const panel = () =>
[...container.querySelectorAll<HTMLElement>("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(
<QueryClientProvider client={queryClient}>
<Layout />
</QueryClientProvider>,
);
});
await flushReact();
const panel = [...container.querySelectorAll<HTMLElement>("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"; currentPathname = "/PAP/company/settings/access";
mockPluginSlots.slots = [ mockPluginSlots.slots = [
{ {
@@ -315,11 +405,15 @@ describe("Layout", () => {
await flushReact(); await flushReact();
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("Company settings sidebar");
expect(container.textContent).toContain("Main company nav");
expect(container.textContent).not.toContain("Company rail"); expect(container.textContent).not.toContain("Company rail");
expect(container.textContent).not.toContain("Instance sidebar"); expect(container.textContent).not.toContain("Instance sidebar");
expect(container.textContent).not.toContain("Main company nav");
expect(container.textContent).not.toContain("Plugin route sidebar"); 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 () => { await act(async () => {
root.unmount(); root.unmount();
@@ -380,9 +474,10 @@ describe("Layout", () => {
await flushReact(); await flushReact();
expect(container.textContent).toContain("Company settings sidebar"); 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("Company rail");
expect(container.textContent).not.toContain("Main company nav");
expect(container.textContent).not.toContain("Plugin route sidebar"); expect(container.textContent).not.toContain("Plugin route sidebar");
expect(mockSetForceCollapsed).toHaveBeenCalledWith(true);
await act(async () => { await act(async () => {
root.unmount(); root.unmount();
@@ -430,11 +525,14 @@ describe("Layout", () => {
await flushReact(); await flushReact();
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.textContent).toContain("Plugin route sidebar: Wiki Sidebar");
expect(container.querySelector("[data-plugin-slot-class='h-full w-full']")).not.toBeNull(); 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("Company settings sidebar");
expect(container.textContent).not.toContain("Instance sidebar"); expect(container.textContent).not.toContain("Instance sidebar");
expect(mockSetForceCollapsed).toHaveBeenCalledWith(true);
await act(async () => { await act(async () => {
root.unmount(); root.unmount();
@@ -489,7 +587,7 @@ describe("Layout", () => {
}), }),
); );
expect(container.textContent).toContain("Plugin route sidebar: Wiki Sidebar"); 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 () => { await act(async () => {
root.unmount(); root.unmount();
@@ -617,6 +715,9 @@ describe("Layout", () => {
expect(container.textContent).toContain("Main company nav"); expect(container.textContent).toContain("Main company nav");
expect(container.textContent).not.toContain("Plugin route sidebar"); 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 () => { await act(async () => {
root.unmount(); root.unmount();
+156 -21
View File
@@ -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 { useQuery } from "@tanstack/react-query";
import { Outlet, useLocation, useNavigate, useNavigationType, useParams } from "@/lib/router"; import { Outlet, useLocation, useNavigate, useNavigationType, useParams } from "@/lib/router";
import { Sidebar } from "./Sidebar"; import { Sidebar } from "./Sidebar";
@@ -17,7 +17,8 @@ import { MobileBottomNav } from "./MobileBottomNav";
import { WorktreeBanner } from "./WorktreeBanner"; import { WorktreeBanner } from "./WorktreeBanner";
import { DevRestartBanner } from "./DevRestartBanner"; import { DevRestartBanner } from "./DevRestartBanner";
import { StandaloneBrowserControls } from "./StandaloneBrowserControls"; import { StandaloneBrowserControls } from "./StandaloneBrowserControls";
import { ResizableSidebarPane } from "./ResizableSidebarPane"; import { SidebarShell } from "./SidebarShell";
import { SecondarySidebar } from "./SecondarySidebar";
import { SidebarAccountMenu } from "./SidebarAccountMenu"; import { SidebarAccountMenu } from "./SidebarAccountMenu";
import { useDialogActions } from "../context/DialogContext"; import { useDialogActions } from "../context/DialogContext";
import { GeneralSettingsProvider } from "../context/GeneralSettingsContext"; import { GeneralSettingsProvider } from "../context/GeneralSettingsContext";
@@ -48,7 +49,17 @@ function getCompanyRouteSegment(pathname: string, companyPrefix: string | undefi
} }
export function Layout() { 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 { openNewIssue, openOnboarding } = useDialogActions();
const { togglePanelVisible } = usePanel(); const { togglePanelVisible } = usePanel();
const { const {
@@ -102,16 +113,21 @@ export function Layout() {
}), }),
[routeSidebarCompanyId, routeSidebarCompanyPrefix], [routeSidebarCompanyId, routeSidebarCompanyPrefix],
); );
const companySidebar = routeSidebarSlot ? ( // Takeover routes (company settings, plugin `routeSidebar`) no longer replace
// the app `<Sidebar/>`. 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 ? (
<CompanySettingsSidebar />
) : routeSidebarSlot ? (
<PluginSlotMount <PluginSlotMount
slot={routeSidebarSlot} slot={routeSidebarSlot}
context={sidebarContext} context={sidebarContext}
className="h-full w-full" className="h-full w-full"
missingBehavior="placeholder" missingBehavior="placeholder"
/> />
) : ( ) : null;
<Sidebar /> const hasSecondarySidebar = secondarySidebar != null;
);
const { data: health } = useQuery({ const { data: health } = useQuery({
queryKey: queryKeys.health, queryKey: queryKeys.health,
queryFn: () => healthApi.get(), queryFn: () => healthApi.get(),
@@ -127,6 +143,16 @@ export function Layout() {
queryFn: () => instanceSettingsApi.getGeneral(), queryFn: () => instanceSettingsApi.getGeneral(),
}).data?.keyboardShortcuts === true; }).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(() => { useEffect(() => {
if (companiesLoading || onboardingTriggered.current) return; if (companiesLoading || onboardingTriggered.current) return;
if (health?.deploymentMode === "authenticated") return; if (health?.deploymentMode === "authenticated") return;
@@ -178,6 +204,15 @@ export function Layout() {
]); ]);
const togglePanel = togglePanelVisible; 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(() => { const openSearch = useCallback(() => {
document.dispatchEvent(new KeyboardEvent("keydown", { document.dispatchEvent(new KeyboardEvent("keydown", {
key: "k", 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<number | null>(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 <main>) 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 <main> 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(); useCompanyPageMemory();
useKeyboardShortcuts({ useKeyboardShortcuts({
@@ -194,6 +325,7 @@ export function Layout() {
onNewIssue: () => openNewIssue(), onNewIssue: () => openNewIssue(),
onSearch: openSearch, onSearch: openSearch,
onToggleSidebar: toggleSidebar, onToggleSidebar: toggleSidebar,
onToggleCollapse: toggleCollapse,
onTogglePanel: togglePanel, onTogglePanel: togglePanel,
onShowShortcuts: () => setShortcutsOpen(true), onShowShortcuts: () => setShortcutsOpen(true),
}); });
@@ -350,11 +482,7 @@ export function Layout() {
> >
<div className="flex flex-1 min-h-0 overflow-hidden"> <div className="flex flex-1 min-h-0 overflow-hidden">
<div className="w-60 shrink-0 overflow-hidden"> <div className="w-60 shrink-0 overflow-hidden">
{isCompanySettingsRoute ? ( {hasSecondarySidebar ? secondarySidebar : <Sidebar />}
<CompanySettingsSidebar />
) : (
companySidebar
)}
</div> </div>
</div> </div>
<SidebarAccountMenu <SidebarAccountMenu
@@ -363,23 +491,30 @@ export function Layout() {
/> />
</div> </div>
) : ( ) : (
<div className="flex h-full flex-col shrink-0"> <SidebarShell
open={sidebarOpen}
collapsed={collapsed}
peeking={peeking}
resizable
onPanelMouseEnter={handlePanelPointerEnter}
onPanelMouseLeave={handlePanelPointerLeave}
onPanelFocusCapture={collapsed ? handlePanelFocus : undefined}
onPanelBlurCapture={collapsed ? handlePanelBlur : undefined}
>
<div className="flex flex-1 min-h-0"> <div className="flex flex-1 min-h-0">
<ResizableSidebarPane open={sidebarOpen} resizable className="h-full shrink-0"> <Sidebar />
{isCompanySettingsRoute ? (
<CompanySettingsSidebar />
) : (
companySidebar
)}
</ResizableSidebarPane>
</div> </div>
<SidebarAccountMenu <SidebarAccountMenu
deploymentMode={health?.deploymentMode} deploymentMode={health?.deploymentMode}
version={health?.version} version={health?.version}
/> />
</div> </SidebarShell>
)} )}
{!isMobile && hasSecondarySidebar ? (
<SecondarySidebar>{secondarySidebar}</SecondarySidebar>
) : null}
<div className={cn("flex min-w-0 flex-col", isMobile ? "w-full" : "h-full flex-1")}> <div className={cn("flex min-w-0 flex-col", isMobile ? "w-full" : "h-full flex-1")}>
<div <div
className={cn( className={cn(
@@ -0,0 +1,114 @@
// @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 "../context/SidebarContext";
import { RequestCollapsedSidebar } from "./RequestCollapsedSidebar";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
const COLLAPSED_STORAGE_KEY = "paperclip.sidebar.collapsed";
let capturedValue: ReturnType<typeof useSidebar> | 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 <RequestCollapsedSidebar/>; flipping it to false models
// navigating away to a route that does not request a collapse.
function Harness({ onRoute }: { onRoute: boolean }) {
return (
<SidebarProvider>
<Capture />
{onRoute ? <RequestCollapsedSidebar /> : null}
</SidebarProvider>
);
}
function render(onRoute: boolean): { root: Root; host: HTMLDivElement } {
const host = document.createElement("div");
document.body.appendChild(host);
const root = createRoot(host);
act(() => root.render(<Harness onRoute={onRoute} />));
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 <RequestCollapsedSidebar/>) unmounts.
act(() => active!.root.render(<Harness onRoute={false} />));
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(<Harness onRoute={false} />));
// Route request gone, but the explicit collapsed pin still applies.
expect(capturedValue?.routeRequestsCollapsed).toBe(false);
expect(capturedValue?.collapsed).toBe(true);
});
});
@@ -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 (
* <>
* <RequestCollapsedSidebar />
*
* </>
* );
* }
* ```
*/
export function RequestCollapsedSidebar() {
const { setRouteRequestsCollapsed } = useSidebar();
useEffect(() => {
setRouteRequestsCollapsed(true);
return () => setRouteRequestsCollapsed(false);
}, [setRouteRequestsCollapsed]);
return 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(
<ResizableSidebarPane open resizable storageKey="test.sidebar.width">
<div>Sidebar</div>
</ResizableSidebarPane>,
);
});
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(
<ResizableSidebarPane open resizable storageKey="test.sidebar.width">
<div>Sidebar</div>
</ResizableSidebarPane>,
);
});
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(
<ResizableSidebarPane open resizable storageKey="test.sidebar.width">
<div>Sidebar</div>
</ResizableSidebarPane>,
);
});
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(
<ResizableSidebarPane open resizable={false}>
<div>Sidebar</div>
</ResizableSidebarPane>,
);
});
expect(handle()).toBeNull();
expect(pane().style.width).toBe("240px");
});
});
-176
View File
@@ -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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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 (
<div
className={cn(
"relative overflow-hidden",
!isResizing && "transition-[width] duration-100 ease-out",
className,
)}
style={paneStyle}
>
{children}
{resizable && open ? (
<div
role="separator"
aria-label="Resize sidebar"
aria-orientation="vertical"
aria-valuemin={MIN_SIDEBAR_WIDTH}
aria-valuemax={MAX_SIDEBAR_WIDTH}
aria-valuenow={width}
tabIndex={0}
className={cn(
"absolute inset-y-0 right-0 z-20 w-3 cursor-col-resize touch-none outline-none",
"before:absolute before:inset-y-0 before:left-1/2 before:w-px before:-translate-x-1/2 before:bg-transparent before:transition-colors",
"hover:before:bg-border focus-visible:before:bg-ring",
isResizing && "before:bg-ring",
)}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={endResize}
onPointerCancel={endResize}
onLostPointerCapture={endResize}
onKeyDown={handleKeyDown}
/>
) : null}
</div>
);
}
+39
View File
@@ -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 `<Sidebar/>`:
* 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 (
<div
data-secondary-sidebar=""
className={cn(
"h-full w-60 shrink-0 overflow-y-auto border-r border-border bg-background",
className,
)}
>
<SidebarNavExpandedProvider>{children}</SidebarNavExpandedProvider>
</div>
);
}
+106 -5
View File
@@ -6,6 +6,7 @@ import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Sidebar } from "./Sidebar"; import { Sidebar } from "./Sidebar";
import { TooltipProvider } from "@/components/ui/tooltip";
const mockHeartbeatsApi = vi.hoisted(() => ({ const mockHeartbeatsApi = vi.hoisted(() => ({
liveRunsForCompany: vi.fn(), 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", () => ({ vi.mock("../context/SidebarContext", () => ({
useSidebar: () => ({ useSidebar: () => mockSidebar,
isMobile: false,
setSidebarOpen: vi.fn(),
}),
})); }));
vi.mock("../api/heartbeats", () => ({ vi.mock("../api/heartbeats", () => ({
@@ -112,7 +120,9 @@ describe("Sidebar", () => {
flushSync(() => { flushSync(() => {
root.render( root.render(
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<Sidebar /> <TooltipProvider>
<Sidebar />
</TooltipProvider>
</QueryClientProvider>, </QueryClientProvider>,
); );
}); });
@@ -125,6 +135,9 @@ describe("Sidebar", () => {
container = document.createElement("div"); container = document.createElement("div");
document.body.appendChild(container); document.body.appendChild(container);
mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([]); mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([]);
mockSidebar.isMobile = false;
mockSidebar.collapsed = false;
mockSidebar.peeking = false;
}); });
afterEach(() => { afterEach(() => {
@@ -284,4 +297,92 @@ describe("Sidebar", () => {
root.unmount(); 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("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<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();
});
});
}); });
+83 -20
View File
@@ -14,6 +14,9 @@ import {
Package, Package,
Settings, Settings,
FolderOpen, FolderOpen,
PanelLeftClose,
PanelLeftOpen,
Pin,
} from "lucide-react"; } from "lucide-react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { NavLink } from "@/lib/router"; import { NavLink } from "@/lib/router";
@@ -23,11 +26,14 @@ import { SidebarAgents } from "./SidebarAgents";
import { SidebarProjects } from "./SidebarProjects"; import { SidebarProjects } from "./SidebarProjects";
import { useDialogActions } from "../context/DialogContext"; import { useDialogActions } from "../context/DialogContext";
import { useCompany } from "../context/CompanyContext"; import { useCompany } from "../context/CompanyContext";
import { useSidebar } from "../context/SidebarContext";
import { heartbeatsApi } from "../api/heartbeats"; import { heartbeatsApi } from "../api/heartbeats";
import { instanceSettingsApi } from "../api/instanceSettings"; import { instanceSettingsApi } from "../api/instanceSettings";
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
import { useInboxBadge } from "../hooks/useInboxBadge"; import { useInboxBadge } from "../hooks/useInboxBadge";
import { Button } from "@/components/ui/button"; 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 { PluginSlotOutlet } from "@/plugins/slots";
import { PluginLauncherOutlet } from "@/plugins/launchers"; import { PluginLauncherOutlet } from "@/plugins/launchers";
import { SidebarCompanyMenu } from "./SidebarCompanyMenu"; import { SidebarCompanyMenu } from "./SidebarCompanyMenu";
@@ -35,6 +41,8 @@ import { SidebarCompanyMenu } from "./SidebarCompanyMenu";
export function Sidebar() { export function Sidebar() {
const { openNewIssue } = useDialogActions(); const { openNewIssue } = useDialogActions();
const { selectedCompanyId, selectedCompany } = useCompany(); const { selectedCompanyId, selectedCompany } = useCompany();
const { isMobile, collapsed, collapseLocked, peeking, toggleCollapsed, setCollapsed } = useSidebar();
const rail = collapsed && !peeking;
const inboxBadge = useInboxBadge(selectedCompanyId); const inboxBadge = useInboxBadge(selectedCompanyId);
const { data: experimentalSettings } = useQuery({ const { data: experimentalSettings } = useQuery({
queryKey: queryKeys.instance.experimentalSettings, queryKey: queryKeys.instance.experimentalSettings,
@@ -64,37 +72,92 @@ export function Sidebar() {
{/* Top bar: Company name (bold) + Search — aligned with top sections (no visible border) */} {/* Top bar: Company name (bold) + Search — aligned with top sections (no visible border) */}
<div className="flex items-center gap-1 px-3 h-12 shrink-0"> <div className="flex items-center gap-1 px-3 h-12 shrink-0">
<SidebarCompanyMenu /> <SidebarCompanyMenu />
<Button {/* In the collapsed rail the search/toggle controls don't fit beside the
asChild logo keeping them would overflow the 64px rail and squeeze the logo
variant="ghost" out of alignment with the icon column below it (PAP-10676). They return
size="icon-sm" as soon as the panel is expanded (pinned) or peeking. Expansion in the
className="text-muted-foreground shrink-0" rail is still reachable via hover-peek + Pin and Cmd/Ctrl+B. */}
aria-label="Open search" {!rail ? (
title="Open search" <>
> <Button
<NavLink to="/search"> asChild
<Search className="h-4 w-4" /> variant="ghost"
</NavLink> size="icon-sm"
</Button> className="text-muted-foreground shrink-0"
aria-label="Open search"
title="Open search"
>
<NavLink to="/search">
<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. 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 ? (
<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}
</>
) : null}
</div> </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"> <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">
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
{/* New Task button aligned with nav items */} {/* New Task button aligned with nav items */}
<button {(() => {
onClick={() => openNewIssue()} const newTaskButton = (
data-slot="icon-button" <button
className="flex items-center gap-2.5 px-3 py-2 pointer-coarse:py-1.5 text-[13px] font-medium text-foreground/80 hover:bg-accent/50 hover:text-foreground transition-colors" onClick={() => openNewIssue()}
> data-slot="icon-button"
<SquarePen className="h-4 w-4 shrink-0" /> aria-label={rail ? "New Task" : undefined}
<span className="truncate">New Task</span> className="flex items-center gap-2.5 px-3 py-2 pointer-coarse:py-1.5 text-[13px] font-medium text-foreground/80 hover:bg-accent/50 hover:text-foreground transition-colors"
</button> >
<SquarePen className="h-4 w-4 shrink-0" />
<span className={rail ? SIDEBAR_RAIL_HIDDEN_LABEL : "truncate"}>New Task</span>
</button>
);
return rail ? (
<Tooltip>
<TooltipTrigger asChild>{newTaskButton}</TooltipTrigger>
<TooltipContent side="right">New Task</TooltipContent>
</Tooltip>
) : (
newTaskButton
);
})()}
<SidebarNavItem to="/dashboard" label="Dashboard" icon={LayoutDashboard} liveCount={liveRunCount} /> <SidebarNavItem to="/dashboard" label="Dashboard" icon={LayoutDashboard} liveCount={liveRunCount} />
<SidebarNavItem <SidebarNavItem
to="/inbox" to="/inbox"
label="Inbox" label="Inbox"
icon={Inbox} icon={Inbox}
badge={inboxBadge.inbox} badge={inboxBadge.inbox}
badgeLabel="unread"
badgeTone={inboxBadge.failedRuns > 0 ? "danger" : "default"} badgeTone={inboxBadge.failedRuns > 0 ? "danger" : "default"}
alert={inboxBadge.failedRuns > 0} alert={inboxBadge.failedRuns > 0}
/> />
+4 -3
View File
@@ -17,7 +17,7 @@ import { useSidebar } from "../context/SidebarContext";
import { useTheme } from "../context/ThemeContext"; import { useTheme } from "../context/ThemeContext";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { cn } from "../lib/utils"; import { cn, SIDEBAR_RAIL_HIDDEN_LABEL } from "../lib/utils";
const PROFILE_SETTINGS_PATH = "/company/settings/instance/profile"; const PROFILE_SETTINGS_PATH = "/company/settings/instance/profile";
const DOCS_URL = "https://docs.paperclip.ing/"; const DOCS_URL = "https://docs.paperclip.ing/";
@@ -107,7 +107,8 @@ export function SidebarAccountMenu({
}: SidebarAccountMenuProps) { }: SidebarAccountMenuProps) {
const [internalOpen, setInternalOpen] = useState(false); const [internalOpen, setInternalOpen] = useState(false);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { isMobile, setSidebarOpen } = useSidebar(); const { isMobile, setSidebarOpen, collapsed, peeking } = useSidebar();
const rail = collapsed && !peeking;
const { theme, toggleTheme } = useTheme(); const { theme, toggleTheme } = useTheme();
const open = controlledOpen ?? internalOpen; const open = controlledOpen ?? internalOpen;
const setOpen = onOpenChange ?? setInternalOpen; const setOpen = onOpenChange ?? setInternalOpen;
@@ -150,7 +151,7 @@ export function SidebarAccountMenu({
{session?.user.image ? <AvatarImage src={session.user.image} alt={displayName} /> : null} {session?.user.image ? <AvatarImage src={session.user.image} alt={displayName} /> : null}
<AvatarFallback>{initials}</AvatarFallback> <AvatarFallback>{initials}</AvatarFallback>
</Avatar> </Avatar>
<span className="min-w-0 flex-1 truncate">{displayName}</span> <span className={cn("min-w-0 flex-1 truncate", rail && SIDEBAR_RAIL_HIDDEN_LABEL)}>{displayName}</span>
</button> </button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <PopoverContent
+43
View File
@@ -7,6 +7,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Agent, ResourceMemberships } from "@paperclipai/shared"; import type { Agent, ResourceMemberships } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SidebarAgents } from "./SidebarAgents"; import { SidebarAgents } from "./SidebarAgents";
import { TooltipProvider } from "@/components/ui/tooltip";
const mockAgentsApi = vi.hoisted(() => ({ const mockAgentsApi = vi.hoisted(() => ({
list: vi.fn(), list: vi.fn(),
@@ -30,6 +31,7 @@ const mockResourceMembershipsApi = vi.hoisted(() => ({
const mockOpenNewAgent = vi.hoisted(() => vi.fn()); const mockOpenNewAgent = vi.hoisted(() => vi.fn());
const mockPushToast = vi.hoisted(() => vi.fn()); const mockPushToast = vi.hoisted(() => vi.fn());
const mockSetSidebarOpen = vi.hoisted(() => vi.fn()); const mockSetSidebarOpen = vi.hoisted(() => vi.fn());
const mockSidebarState = vi.hoisted(() => ({ collapsed: false, peeking: false }));
vi.mock("@/lib/router", () => ({ vi.mock("@/lib/router", () => ({
Link: ({ children, to, ...props }: { children: ReactNode; to: string }) => ( Link: ({ children, to, ...props }: { children: ReactNode; to: string }) => (
@@ -75,6 +77,8 @@ vi.mock("../context/SidebarContext", () => ({
useSidebar: () => ({ useSidebar: () => ({
isMobile: false, isMobile: false,
setSidebarOpen: mockSetSidebarOpen, setSidebarOpen: mockSetSidebarOpen,
collapsed: mockSidebarState.collapsed,
peeking: mockSidebarState.peeking,
}), }),
})); }));
@@ -206,6 +210,8 @@ describe("SidebarAgents", () => {
let memberships: ResourceMemberships; let memberships: ResourceMemberships;
beforeEach(() => { beforeEach(() => {
mockSidebarState.collapsed = false;
mockSidebarState.peeking = false;
container = document.createElement("div"); container = document.createElement("div");
document.body.appendChild(container); document.body.appendChild(container);
root = null; root = null;
@@ -286,6 +292,43 @@ describe("SidebarAgents", () => {
await flushReact(); await flushReact();
} }
async function renderRailSidebarAgents() {
mockSidebarState.collapsed = true;
const currentRoot = createRoot(container);
root = currentRoot;
await act(async () => {
currentRoot.render(
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<SidebarAgents streamlined />
</TooltipProvider>
</QueryClientProvider>,
);
});
await flushReact();
}
it("renders icon-only agent rows with tooltips and no row actions in the rail", async () => {
mockAgentsApi.list.mockResolvedValue([makeAgent({ id: "agent-a", name: "Alpha", urlKey: "alpha" })]);
await renderRailSidebarAgents();
// The agent name is preserved in the a11y tree but kept in flow (zero-width,
// clipped) so the row stays 1:1 tall with the expanded state (PAP-10676); the
// row links become tooltip triggers and the per-row actions dropdown is dropped.
const nameSpan = Array.from(container.querySelectorAll("span")).find((el) => el.textContent === "Alpha");
expect(nameSpan?.className).not.toContain("sr-only");
expect(nameSpan?.className).toContain("w-0");
expect(nameSpan?.className).toContain("overflow-hidden");
const agentLink = container.querySelector('a[href^="/agents/"]:not([href="/agents/all"])');
expect(agentLink?.parentElement?.getAttribute("data-slot")).toBe("tooltip-trigger");
expect(container.querySelector('button[aria-label="Open actions for Alpha"]')).toBeNull();
// The section header collapses to a divider (no caret / section menu).
expect(container.querySelector('button[aria-label="Agents section actions"]')).toBeNull();
});
it("keeps top mode in stored org-aware order", async () => { it("keeps top mode in stored org-aware order", async () => {
localStorage.setItem("paperclip.agentOrder:company-1:user-1", JSON.stringify(["agent-b", "agent-a", "agent-c"])); localStorage.setItem("paperclip.agentOrder:company-1:user-1", JSON.stringify(["agent-b", "agent-a", "agent-c"]));
mockAgentsApi.list.mockResolvedValue([ mockAgentsApi.list.mockResolvedValue([
+88 -52
View File
@@ -21,7 +21,7 @@ import { authApi } from "../api/auth";
import { heartbeatsApi } from "../api/heartbeats"; import { heartbeatsApi } from "../api/heartbeats";
import { SIDEBAR_SCROLL_RESET_STATE } from "../lib/navigation-scroll"; import { SIDEBAR_SCROLL_RESET_STATE } from "../lib/navigation-scroll";
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
import { cn, agentRouteRef, agentUrl } from "../lib/utils"; import { cn, agentRouteRef, agentUrl, SIDEBAR_RAIL_HIDDEN_LABEL } from "../lib/utils";
import { useAgentOrder } from "../hooks/useAgentOrder"; import { useAgentOrder } from "../hooks/useAgentOrder";
import { resourceMembershipState, useResourceMembershipMutation, useResourceMemberships } from "../hooks/useResourceMemberships"; import { resourceMembershipState, useResourceMembershipMutation, useResourceMemberships } from "../hooks/useResourceMemberships";
import { import {
@@ -43,6 +43,7 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import type { Agent } from "@paperclipai/shared"; import type { Agent } from "@paperclipai/shared";
/** /**
@@ -95,6 +96,7 @@ function SidebarAgentItem({
leaving, leaving,
onLeaveAgent, onLeaveAgent,
onPauseResume, onPauseResume,
rail,
runCount, runCount,
setSidebarOpen, setSidebarOpen,
}: { }: {
@@ -106,6 +108,7 @@ function SidebarAgentItem({
leaving: boolean; leaving: boolean;
onLeaveAgent: (agent: Agent) => void; onLeaveAgent: (agent: Agent) => void;
onPauseResume: (agent: Agent, action: "pause" | "resume") => void; onPauseResume: (agent: Agent, action: "pause" | "resume") => void;
rail: boolean;
runCount: number; runCount: number;
setSidebarOpen: (open: boolean) => void; setSidebarOpen: (open: boolean) => void;
}) { }) {
@@ -126,46 +129,65 @@ function SidebarAgentItem({
? "Invalid org chain" ? "Invalid org chain"
: pauseResumeLabel; : pauseResumeLabel;
const link = (
<NavLink
to={href}
state={SIDEBAR_SCROLL_RESET_STATE}
onClick={() => {
if (isMobile) setSidebarOpen(false);
}}
className={cn(
"flex min-w-0 flex-1 items-center gap-2.5 px-3 py-1.5 pointer-coarse:py-1 pr-8 text-[13px] font-medium transition-colors",
isActive
? "bg-accent text-foreground"
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground"
)}
>
<AgentIcon icon={agent.icon} className="shrink-0 h-3.5 w-3.5 text-muted-foreground" />
<span className={rail ? SIDEBAR_RAIL_HIDDEN_LABEL : "flex-1 truncate"}>{agent.name}</span>
{!rail && hasInvalidOrgChain ? (
<AlertTriangle className="h-3.5 w-3.5 shrink-0 text-amber-500" aria-label="Invalid reporting chain" />
) : null}
{!rail && (agent.pauseReason === "budget" || runCount > 0) && (
<span className="ml-auto flex items-center gap-1.5 shrink-0">
{agent.pauseReason === "budget" ? (
<BudgetSidebarMarker title="Agent paused by budget" />
) : null}
{runCount > 0 ? (
<span className="relative flex h-2 w-2">
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
</span>
) : null}
{runCount > 0 ? (
<span className="text-[11px] font-medium text-blue-600 dark:text-blue-400">
{runCount} live
</span>
) : null}
</span>
)}
</NavLink>
);
return ( return (
<div className="group/agent relative flex items-center"> <div className="group/agent relative flex items-center">
<NavLink {rail ? (
to={href} // Anchor the tooltip to a wrapper, not the NavLink: Radix `asChild` (Slot)
state={SIDEBAR_SCROLL_RESET_STATE} // drops React Router's function className, which would strip `flex` off the
onClick={() => { // <a> and let the in-flow label stack under the icon, growing the row.
if (isMobile) setSidebarOpen(false); // Keeping the <a> out of Slot preserves a 1:1 row height with the expanded
}} // state so the icon never moves (PAP-10676).
className={cn( <Tooltip>
"flex min-w-0 flex-1 items-center gap-2.5 px-3 py-1.5 pointer-coarse:py-1 pr-8 text-[13px] font-medium transition-colors", <TooltipTrigger asChild>
isActive <div className="min-w-0 flex-1">{link}</div>
? "bg-accent text-foreground" </TooltipTrigger>
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground" <TooltipContent side="right">{agent.name}</TooltipContent>
)} </Tooltip>
> ) : (
<AgentIcon icon={agent.icon} className="shrink-0 h-3.5 w-3.5 text-muted-foreground" /> link
<span className="flex-1 truncate">{agent.name}</span> )}
{hasInvalidOrgChain ? (
<AlertTriangle className="h-3.5 w-3.5 shrink-0 text-amber-500" aria-label="Invalid reporting chain" />
) : null}
{(agent.pauseReason === "budget" || runCount > 0) && (
<span className="ml-auto flex items-center gap-1.5 shrink-0">
{agent.pauseReason === "budget" ? (
<BudgetSidebarMarker title="Agent paused by budget" />
) : null}
{runCount > 0 ? (
<span className="relative flex h-2 w-2">
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
</span>
) : null}
{runCount > 0 ? (
<span className="text-[11px] font-medium text-blue-600 dark:text-blue-400">
{runCount} live
</span>
) : null}
</span>
)}
</NavLink>
{!rail && (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
@@ -219,6 +241,7 @@ function SidebarAgentItem({
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
)}
</div> </div>
); );
} }
@@ -229,7 +252,8 @@ export function SidebarAgents({ streamlined = false }: { streamlined?: boolean }
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { selectedCompanyId } = useCompany(); const { selectedCompanyId } = useCompany();
const { openNewAgent } = useDialogActions(); const { openNewAgent } = useDialogActions();
const { isMobile, setSidebarOpen } = useSidebar(); const { isMobile, setSidebarOpen, collapsed, peeking } = useSidebar();
const rail = collapsed && !peeking;
const { pushToast } = useToastActions(); const { pushToast } = useToastActions();
const location = useLocation(); const location = useLocation();
@@ -451,24 +475,36 @@ export function SidebarAgents({ streamlined = false }: { streamlined?: boolean }
leaving={agentLeaving(agent)} leaving={agentLeaving(agent)}
onLeaveAgent={leaveAgent} onLeaveAgent={leaveAgent}
onPauseResume={(targetAgent, action) => pauseResumeAgent.mutate({ agent: targetAgent, action })} onPauseResume={(targetAgent, action) => pauseResumeAgent.mutate({ agent: targetAgent, action })}
rail={rail}
runCount={runCount} runCount={runCount}
setSidebarOpen={setSidebarOpen} setSidebarOpen={setSidebarOpen}
/> />
); );
})} })}
{showSeeAllLink && ( {showSeeAllLink && (() => {
<Link const seeAllLink = (
to="/agents/all" <Link
state={SIDEBAR_SCROLL_RESET_STATE} to="/agents/all"
onClick={() => { state={SIDEBAR_SCROLL_RESET_STATE}
if (isMobile) setSidebarOpen(false); aria-label={rail ? "See all agents" : undefined}
}} onClick={() => {
className="flex items-center gap-2.5 px-3 py-1.5 pointer-coarse:py-1 text-[13px] font-medium text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground" if (isMobile) setSidebarOpen(false);
> }}
<Users className="shrink-0 h-3.5 w-3.5" /> className="flex items-center gap-2.5 px-3 py-1.5 pointer-coarse:py-1 text-[13px] font-medium text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
<span>See all agents</span> >
</Link> <Users className="shrink-0 h-3.5 w-3.5" />
)} <span className={rail ? SIDEBAR_RAIL_HIDDEN_LABEL : undefined}>See all agents</span>
</Link>
);
return rail ? (
<Tooltip>
<TooltipTrigger asChild>{seeAllLink}</TooltipTrigger>
<TooltipContent side="right">See all agents</TooltipContent>
</Tooltip>
) : (
seeAllLink
);
})()}
</SidebarSection> </SidebarSection>
); );
} }
+11 -5
View File
@@ -36,7 +36,7 @@ import { useCompany } from "@/context/CompanyContext";
import { useDialogActions } from "@/context/DialogContext"; import { useDialogActions } from "@/context/DialogContext";
import { useCompanyOrder } from "@/hooks/useCompanyOrder"; import { useCompanyOrder } from "@/hooks/useCompanyOrder";
import { queryKeys } from "@/lib/queryKeys"; import { queryKeys } from "@/lib/queryKeys";
import { cn } from "@/lib/utils"; import { cn, SIDEBAR_RAIL_HIDDEN_LABEL } from "@/lib/utils";
import { useSidebar } from "../context/SidebarContext"; import { useSidebar } from "../context/SidebarContext";
import { CompanyPatternIcon } from "./CompanyPatternIcon"; import { CompanyPatternIcon } from "./CompanyPatternIcon";
@@ -134,7 +134,8 @@ export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: Sideb
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { companies, selectedCompany, setSelectedCompanyId } = useCompany(); const { companies, selectedCompany, setSelectedCompanyId } = useCompany();
const { openOnboarding } = useDialogActions(); const { openOnboarding } = useDialogActions();
const { isMobile, setSidebarOpen } = useSidebar(); const { isMobile, setSidebarOpen, collapsed, peeking } = useSidebar();
const rail = collapsed && !peeking;
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const open = controlledOpen ?? internalOpen; const open = controlledOpen ?? internalOpen;
@@ -224,16 +225,21 @@ export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: Sideb
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"
className="h-9 flex-1 justify-start gap-2 px-2 text-left" // `px-3` (not px-2) so the logo's left edge lines up with the nav icon
// column (nav px-3 + item px-3) and, crucially, stays put between states:
// the Button's default size adds `has-[>svg]:px-3`, so with the chevron
// svg present (expanded) it was already 12px but without it (rail) it fell
// back to 8px — a 4px horizontal jump on collapse (PAP-10676).
className="h-9 flex-1 justify-start gap-2 px-3 text-left"
aria-label={selectedCompany ? `Open ${selectedCompany.name} workspace switcher` : "Open workspace switcher"} aria-label={selectedCompany ? `Open ${selectedCompany.name} workspace switcher` : "Open workspace switcher"}
> >
<span className="flex min-w-0 flex-1 items-center gap-2"> <span className="flex min-w-0 flex-1 items-center gap-2">
{selectedCompany ? <WorkspaceIcon company={selectedCompany} /> : null} {selectedCompany ? <WorkspaceIcon company={selectedCompany} /> : null}
<span className="truncate text-sm font-bold text-foreground"> <span className={cn("truncate text-sm font-bold text-foreground", rail && SIDEBAR_RAIL_HIDDEN_LABEL)}>
{selectedCompany?.name ?? "Select workspace"} {selectedCompany?.name ?? "Select workspace"}
</span> </span>
</span> </span>
<ChevronsUpDown className="size-3.5 shrink-0 text-muted-foreground" /> {!rail && <ChevronsUpDown className="size-3.5 shrink-0 text-muted-foreground" />}
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start" sideOffset={8} className="w-64 p-1"> <DropdownMenuContent align="start" sideOffset={8} className="w-64 p-1">
+142
View File
@@ -0,0 +1,142 @@
// @vitest-environment jsdom
import { act } from "react";
import type { ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Inbox } from "lucide-react";
import { SidebarNavItem, SidebarNavExpandedProvider } from "./SidebarNavItem";
import { TooltipProvider } from "@/components/ui/tooltip";
const sidebarState = vi.hoisted(() => ({
isMobile: false,
setSidebarOpen: () => {},
collapsed: false,
peeking: false,
}));
vi.mock("@/lib/router", () => ({
NavLink: ({ children, to, className, ...props }: {
children: ReactNode | ((state: { isActive: boolean }) => ReactNode);
to: string;
className?: string | ((state: { isActive: boolean }) => string);
}) => {
const resolvedClassName = typeof className === "function" ? className({ isActive: false }) : className;
const resolvedChildren = typeof children === "function" ? children({ isActive: false }) : children;
return (
<a href={to} className={resolvedClassName} {...props}>
{resolvedChildren}
</a>
);
},
}));
vi.mock("../context/SidebarContext", () => ({
useSidebar: () => sidebarState,
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
describe("SidebarNavItem", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
sidebarState.collapsed = false;
sidebarState.peeking = false;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => {
root.unmount();
});
container.remove();
vi.clearAllMocks();
});
function render(node: ReactNode) {
act(() => {
root.render(<TooltipProvider>{node}</TooltipProvider>);
});
}
function link() {
return container.querySelector("a") as HTMLAnchorElement;
}
it("shows the full label and numeric badge when expanded", () => {
render(<SidebarNavItem to="/inbox" label="Inbox" icon={Inbox} badge={28} badgeLabel="unread" />);
const label = Array.from(container.querySelectorAll("span")).find((el) => el.textContent === "Inbox");
expect(label?.className).not.toContain("sr-only");
// The numeric badge is rendered in full (not a dot) and no rail aria-label is set.
expect(container.textContent).toContain("28");
expect(link().getAttribute("aria-label")).toBeNull();
});
it("clips the label (kept in flow for 1:1 row height) and collapses the badge to a dot in the rail", () => {
sidebarState.collapsed = true;
render(<SidebarNavItem to="/inbox" label="Inbox" icon={Inbox} badge={28} badgeLabel="unread" />);
// The label stays in the DOM/a11y tree (not display:none) so screen readers
// still announce it. Unlike sr-only it is kept IN FLOW (zero-width, clipped,
// transparent) so it still contributes its line-height — that keeps the row
// exactly as tall as the expanded state, so the icon never shifts (PAP-10676).
const label = Array.from(container.querySelectorAll("span")).find((el) => el.textContent === "Inbox");
expect(label).toBeTruthy();
expect(label?.className).not.toContain("sr-only");
expect(label?.className).toContain("w-0");
expect(label?.className).toContain("overflow-hidden");
// The numeric count is no longer rendered as text; it is a dot with an
// accessible text equivalent on the link.
expect(container.textContent).not.toContain("28 ");
expect(link().getAttribute("aria-label")).toBe("Inbox, 28 unread");
// Tooltip wraps the row; the trigger is the wrapper element so the NavLink's
// own flex className is preserved (PAP-10676), with the <a> nested inside it.
expect(link().parentElement?.getAttribute("data-slot")).toBe("tooltip-trigger");
});
it("keeps the full presentation while peeking even when collapsed", () => {
sidebarState.collapsed = true;
sidebarState.peeking = true;
render(<SidebarNavItem to="/inbox" label="Inbox" icon={Inbox} badge={28} badgeLabel="unread" />);
const label = Array.from(container.querySelectorAll("span")).find((el) => el.textContent === "Inbox");
expect(label?.className).not.toContain("sr-only");
expect(container.textContent).toContain("28");
expect(link().getAttribute("aria-label")).toBeNull();
});
it("forces the full label inside an expanded contextual pane even when globally collapsed", () => {
// The takeover model collapses the global sidebar (collapsed=true) while the
// 240px SecondarySidebar still needs readable labels (PAP-10700). The
// provider must override the global rail collapse.
sidebarState.collapsed = true;
render(
<SidebarNavExpandedProvider>
<SidebarNavItem to="/inbox" label="Inbox" icon={Inbox} badge={28} badgeLabel="unread" />
</SidebarNavExpandedProvider>,
);
const label = Array.from(container.querySelectorAll("span")).find((el) => el.textContent === "Inbox");
expect(label?.className).not.toContain("w-0");
expect(label?.className).toContain("flex-1");
// Full numeric badge, no rail aria-label, no tooltip wrapper.
expect(container.textContent).toContain("28");
expect(link().getAttribute("aria-label")).toBeNull();
expect(link().parentElement?.getAttribute("data-slot")).not.toBe("tooltip-trigger");
});
it("surfaces the live count in the rail aria-label", () => {
sidebarState.collapsed = true;
render(<SidebarNavItem to="/dashboard" label="Dashboard" icon={Inbox} liveCount={3} />);
expect(link().getAttribute("aria-label")).toBe("Dashboard, 3 live");
});
});
+98 -8
View File
@@ -1,9 +1,36 @@
import { createContext, useContext, type ReactNode } from "react";
import { NavLink } from "@/lib/router"; import { NavLink } from "@/lib/router";
import { SIDEBAR_SCROLL_RESET_STATE } from "../lib/navigation-scroll"; import { SIDEBAR_SCROLL_RESET_STATE } from "../lib/navigation-scroll";
import { cn } from "../lib/utils"; import { cn, SIDEBAR_RAIL_HIDDEN_LABEL } from "../lib/utils";
import { useSidebar } from "../context/SidebarContext"; import { useSidebar } from "../context/SidebarContext";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
/**
* Forces the full-label (non-rail) presentation for any `SidebarNavItem`
* rendered beneath it, regardless of the global `useSidebar().collapsed` state.
*
* Takeover routes (PAP-10695) collapse the app `<Sidebar/>` to its 64px rail
* and render the contextual nav in a fixed 240px `SecondarySidebar`. That pane
* is always wide enough for labels, but its `SidebarNavItem` children still
* read the *global* `collapsed=true` and would otherwise render icon-only
* leaving the settings nav unreadable (PAP-10700). Wrapping the pane in this
* provider decouples its items from the global rail collapse.
*/
const SidebarNavExpandedContext = createContext(false);
export function SidebarNavExpandedProvider({ children }: { children: ReactNode }) {
return (
<SidebarNavExpandedContext.Provider value={true}>
{children}
</SidebarNavExpandedContext.Provider>
);
}
export function useSidebarNavExpanded() {
return useContext(SidebarNavExpandedContext);
}
interface SidebarNavItemProps { interface SidebarNavItemProps {
to: string; to: string;
label: string; label: string;
@@ -12,6 +39,11 @@ interface SidebarNavItemProps {
className?: string; className?: string;
badge?: number; badge?: number;
badgeTone?: "default" | "danger"; badgeTone?: "default" | "danger";
/**
* Accessible noun for the numeric badge when collapsed to the rail, where the
* count is rendered as a dot (e.g. `badgeLabel="unread"` "Inbox, 28 unread").
*/
badgeLabel?: string;
textBadge?: string; textBadge?: string;
textBadgeTone?: "default" | "amber"; textBadgeTone?: "default" | "amber";
alert?: boolean; alert?: boolean;
@@ -26,18 +58,42 @@ export function SidebarNavItem({
className, className,
badge, badge,
badgeTone = "default", badgeTone = "default",
badgeLabel,
textBadge, textBadge,
textBadgeTone = "default", textBadgeTone = "default",
alert = false, alert = false,
liveCount, liveCount,
}: SidebarNavItemProps) { }: SidebarNavItemProps) {
const { isMobile, setSidebarOpen } = useSidebar(); const { isMobile, setSidebarOpen, collapsed, peeking } = useSidebar();
// A fixed-width contextual pane (SecondarySidebar) forces full labels even
// when the global app sidebar is collapsed to its rail (PAP-10700).
const forceExpanded = useSidebarNavExpanded();
// The icon-only rail presentation only applies when pinned collapsed and not
// peeking; a peek/expanded panel — or an expanded contextual pane — restores
// the full label + badge.
const rail = collapsed && !peeking && !forceExpanded;
return ( const hasBadge = badge != null && badge > 0;
const hasLive = liveCount != null && liveCount > 0;
// Accessible text equivalent for the collapsed dot indicator. The visible
// label is `sr-only` in the rail, so the count must be surfaced here.
const railAriaLabel = !rail
? undefined
: hasLive
? `${label}, ${liveCount} live`
: hasBadge
? `${label}, ${badge}${badgeLabel ? ` ${badgeLabel}` : ""}`
: alert
? `${label}, attention needed`
: undefined;
const link = (
<NavLink <NavLink
to={to} to={to}
state={SIDEBAR_SCROLL_RESET_STATE} state={SIDEBAR_SCROLL_RESET_STATE}
end={end} end={end}
aria-label={railAriaLabel}
onClick={() => { if (isMobile) setSidebarOpen(false); }} onClick={() => { if (isMobile) setSidebarOpen(false); }}
className={({ isActive }) => className={({ isActive }) =>
cn( cn(
@@ -52,11 +108,28 @@ export function SidebarNavItem({
<span className="relative shrink-0"> <span className="relative shrink-0">
<Icon className="h-4 w-4" /> <Icon className="h-4 w-4" />
{alert && ( {alert && (
<span className="absolute -right-0.5 -top-0.5 h-2 w-2 rounded-full bg-red-500 shadow-[0_0_0_2px_hsl(var(--background))]" /> <span className="absolute -right-0.5 -top-0.5 h-2 w-2 rounded-full bg-red-500 shadow-[0_0_0_2px_hsl(var(--background))]" aria-hidden="true" />
)}
{/* Collapsed rail: numeric badge / live count collapse to a dot on the
icon. The icon markup is untouched so it stays pixel-aligned. */}
{rail && !alert && hasLive && (
<span className="absolute -right-0.5 -top-0.5 flex h-2 w-2" aria-hidden="true">
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-blue-500 shadow-[0_0_0_2px_hsl(var(--background))]" />
</span>
)}
{rail && !alert && !hasLive && hasBadge && (
<span
className={cn(
"absolute -right-0.5 -top-0.5 h-2 w-2 rounded-full shadow-[0_0_0_2px_hsl(var(--background))]",
badgeTone === "danger" ? "bg-red-600" : "bg-primary",
)}
aria-hidden="true"
/>
)} )}
</span> </span>
<span className="flex-1 truncate">{label}</span> <span className={rail ? SIDEBAR_RAIL_HIDDEN_LABEL : "flex-1 truncate"}>{label}</span>
{textBadge && ( {!rail && textBadge && (
<span <span
className={cn( className={cn(
"ml-auto rounded-full px-1.5 py-0.5 text-[10px] font-medium leading-none", "ml-auto rounded-full px-1.5 py-0.5 text-[10px] font-medium leading-none",
@@ -68,7 +141,7 @@ export function SidebarNavItem({
{textBadge} {textBadge}
</span> </span>
)} )}
{liveCount != null && liveCount > 0 && ( {!rail && hasLive && (
<span className="ml-auto flex items-center gap-1.5"> <span className="ml-auto flex items-center gap-1.5">
<span className="relative flex h-2 w-2"> <span className="relative flex h-2 w-2">
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" /> <span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
@@ -77,7 +150,7 @@ export function SidebarNavItem({
<span className="text-[11px] font-medium text-blue-600 dark:text-blue-400">{liveCount} live</span> <span className="text-[11px] font-medium text-blue-600 dark:text-blue-400">{liveCount} live</span>
</span> </span>
)} )}
{badge != null && badge > 0 && ( {!rail && hasBadge && (
<span <span
className={cn( className={cn(
"ml-auto rounded-full px-1.5 py-0.5 text-xs leading-none", "ml-auto rounded-full px-1.5 py-0.5 text-xs leading-none",
@@ -91,4 +164,21 @@ export function SidebarNavItem({
)} )}
</NavLink> </NavLink>
); );
if (!rail) return link;
// The tooltip wraps a plain block element rather than the NavLink directly:
// Radix `asChild` (Slot) drops React Router's *function* className, which would
// strip `flex` off the <a> and render it as a block — the in-flow label would
// then stack under the icon and the row would grow. Anchoring the tooltip to a
// wrapper keeps the <a> rendering normally (flex), so the row stays 1:1 with
// the expanded state and the icon never moves (PAP-10676).
return (
<Tooltip>
<TooltipTrigger asChild>
<div>{link}</div>
</TooltipTrigger>
<TooltipContent side="right">{label}</TooltipContent>
</Tooltip>
);
} }
+40 -1
View File
@@ -7,6 +7,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Project, ResourceMemberships } from "@paperclipai/shared"; import type { Project, ResourceMemberships } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SidebarProjects } from "./SidebarProjects"; import { SidebarProjects } from "./SidebarProjects";
import { TooltipProvider } from "@/components/ui/tooltip";
const mockProjectsApi = vi.hoisted(() => ({ const mockProjectsApi = vi.hoisted(() => ({
list: vi.fn(), list: vi.fn(),
@@ -25,7 +26,7 @@ const mockOpenNewProject = vi.hoisted(() => vi.fn());
const mockPushToast = vi.hoisted(() => vi.fn()); const mockPushToast = vi.hoisted(() => vi.fn());
const mockSetSidebarOpen = vi.hoisted(() => vi.fn()); const mockSetSidebarOpen = vi.hoisted(() => vi.fn());
const mockPersistOrder = vi.hoisted(() => vi.fn()); const mockPersistOrder = vi.hoisted(() => vi.fn());
const mockSidebarState = vi.hoisted(() => ({ isMobile: false })); const mockSidebarState = vi.hoisted(() => ({ isMobile: false, collapsed: false, peeking: false }));
const mockPointerState = vi.hoisted(() => ({ fine: true })); const mockPointerState = vi.hoisted(() => ({ fine: true }));
vi.mock("@/lib/router", () => ({ vi.mock("@/lib/router", () => ({
@@ -73,6 +74,8 @@ vi.mock("../context/SidebarContext", () => ({
useSidebar: () => ({ useSidebar: () => ({
isMobile: mockSidebarState.isMobile, isMobile: mockSidebarState.isMobile,
setSidebarOpen: mockSetSidebarOpen, setSidebarOpen: mockSetSidebarOpen,
collapsed: mockSidebarState.collapsed,
peeking: mockSidebarState.peeking,
}), }),
})); }));
@@ -232,6 +235,8 @@ describe("SidebarProjects", () => {
}); });
localStorage.clear(); localStorage.clear();
mockSidebarState.isMobile = false; mockSidebarState.isMobile = false;
mockSidebarState.collapsed = false;
mockSidebarState.peeking = false;
mockPointerState.fine = true; mockPointerState.fine = true;
Object.defineProperty(window, "matchMedia", { Object.defineProperty(window, "matchMedia", {
writable: true, writable: true,
@@ -326,6 +331,40 @@ describe("SidebarProjects", () => {
await flushReact(); await flushReact();
} }
async function renderRailSidebarProjects() {
mockSidebarState.collapsed = true;
const currentRoot = createRoot(container);
root = currentRoot;
await act(async () => {
currentRoot.render(
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<SidebarProjects />
</TooltipProvider>
</QueryClientProvider>,
);
});
await flushReact();
}
it("renders icon-only project rows with tooltips and no row actions in the rail", async () => {
await renderRailSidebarProjects();
// Project names stay in the a11y tree but kept in flow (zero-width, clipped)
// so rows stay 1:1 tall with the expanded state (PAP-10676); rows become
// tooltip triggers and the per-row actions dropdown is dropped.
const nameSpan = Array.from(container.querySelectorAll("span")).find((el) => el.textContent === "Bravo");
expect(nameSpan?.className).not.toContain("sr-only");
expect(nameSpan?.className).toContain("w-0");
expect(nameSpan?.className).toContain("overflow-hidden");
const projectLink = container.querySelector('a[href^="/projects/"]');
expect(projectLink?.parentElement?.getAttribute("data-slot")).toBe("tooltip-trigger");
expect(container.querySelector('button[aria-label="Open actions for Bravo"]')).toBeNull();
// The section header collapses to a divider (no section menu trigger).
expect(container.querySelector('button[aria-label="Projects section actions"]')).toBeNull();
});
it("keeps top mode in curated order and renders plugin project slots", async () => { it("keeps top mode in curated order and renders plugin project slots", async () => {
await renderSidebarProjects(); await renderSidebarProjects();
+50 -24
View File
@@ -19,7 +19,7 @@ import { authApi } from "../api/auth";
import { projectsApi } from "../api/projects"; import { projectsApi } from "../api/projects";
import { SIDEBAR_SCROLL_RESET_STATE } from "../lib/navigation-scroll"; import { SIDEBAR_SCROLL_RESET_STATE } from "../lib/navigation-scroll";
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
import { cn, projectRouteRef } from "../lib/utils"; import { cn, projectRouteRef, SIDEBAR_RAIL_HIDDEN_LABEL } from "../lib/utils";
import { useProjectOrder } from "../hooks/useProjectOrder"; import { useProjectOrder } from "../hooks/useProjectOrder";
import { resourceMembershipState, useResourceMembershipMutation, useResourceMemberships } from "../hooks/useResourceMemberships"; import { resourceMembershipState, useResourceMembershipMutation, useResourceMemberships } from "../hooks/useResourceMemberships";
import { BudgetSidebarMarker } from "./BudgetSidebarMarker"; import { BudgetSidebarMarker } from "./BudgetSidebarMarker";
@@ -32,6 +32,7 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { PluginSlotMount, usePluginSlots } from "@/plugins/slots"; import { PluginSlotMount, usePluginSlots } from "@/plugins/slots";
import { import {
getProjectSortModeStorageKey, getProjectSortModeStorageKey,
@@ -59,6 +60,7 @@ type ProjectItemProps = {
isMobile: boolean; isMobile: boolean;
project: Project; project: Project;
projectSidebarSlots: ProjectSidebarSlot[]; projectSidebarSlots: ProjectSidebarSlot[];
rail: boolean;
setSidebarOpen: (open: boolean) => void; setSidebarOpen: (open: boolean) => void;
onLeaveProject: (project: Project) => void; onLeaveProject: (project: Project) => void;
leaving?: boolean; leaving?: boolean;
@@ -113,6 +115,7 @@ function ProjectItem({
isMobile, isMobile,
project, project,
projectSidebarSlots, projectSidebarSlots,
rail,
setSidebarOpen, setSidebarOpen,
onLeaveProject, onLeaveProject,
leaving = false, leaving = false,
@@ -120,31 +123,50 @@ function ProjectItem({
}: ProjectItemProps) { }: ProjectItemProps) {
const routeRef = projectRouteRef(project); const routeRef = projectRouteRef(project);
const link = (
<NavLink
to={`/projects/${routeRef}/issues`}
state={SIDEBAR_SCROLL_RESET_STATE}
onClick={(e) => {
if (isDragging) {
e.preventDefault();
return;
}
if (isMobile) setSidebarOpen(false);
}}
className={cn(
"flex min-w-0 flex-1 items-center gap-2.5 px-3 py-1.5 pr-8 pointer-coarse:py-1 text-[13px] font-medium transition-colors",
activeProjectRef === routeRef || activeProjectRef === project.id
? "bg-accent text-foreground"
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground",
)}
>
<ProjectTile color={project.color ?? null} icon={project.icon ?? null} size="xs" />
<span className={rail ? SIDEBAR_RAIL_HIDDEN_LABEL : "flex-1 truncate"}>{project.name}</span>
{!rail && project.pauseReason === "budget" ? <BudgetSidebarMarker title="Project paused by budget" /> : null}
</NavLink>
);
return ( return (
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
<div className="group/project relative flex items-center"> <div className="group/project relative flex items-center">
<NavLink {rail ? (
to={`/projects/${routeRef}/issues`} // Anchor the tooltip to a wrapper, not the NavLink: Radix `asChild`
state={SIDEBAR_SCROLL_RESET_STATE} // (Slot) drops React Router's function className, which would strip
onClick={(e) => { // `flex` off the <a> and let the in-flow label stack under the icon,
if (isDragging) { // growing the row. Keeping the <a> out of Slot preserves a 1:1 row
e.preventDefault(); // height with the expanded state so the icon never moves (PAP-10676).
return; <Tooltip>
} <TooltipTrigger asChild>
if (isMobile) setSidebarOpen(false); <div className="min-w-0 flex-1">{link}</div>
}} </TooltipTrigger>
className={cn( <TooltipContent side="right">{project.name}</TooltipContent>
"flex min-w-0 flex-1 items-center gap-2.5 px-3 py-1.5 pr-8 pointer-coarse:py-1 text-[13px] font-medium transition-colors", </Tooltip>
activeProjectRef === routeRef || activeProjectRef === project.id ) : (
? "bg-accent text-foreground" link
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground", )}
)}
>
<ProjectTile color={project.color ?? null} icon={project.icon ?? null} size="xs" />
<span className="flex-1 truncate">{project.name}</span>
{project.pauseReason === "budget" ? <BudgetSidebarMarker title="Project paused by budget" /> : null}
</NavLink>
{!rail && (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
@@ -174,8 +196,9 @@ function ProjectItem({
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
)}
</div> </div>
{projectSidebarSlots.length > 0 && ( {!rail && projectSidebarSlots.length > 0 && (
<div className="ml-5 flex flex-col gap-0.5"> <div className="ml-5 flex flex-col gap-0.5">
{projectSidebarSlots.map((slot) => ( {projectSidebarSlots.map((slot) => (
<PluginSlotMount <PluginSlotMount
@@ -229,7 +252,8 @@ export function SidebarProjects() {
const [open, setOpen] = useState(true); const [open, setOpen] = useState(true);
const { selectedCompany, selectedCompanyId } = useCompany(); const { selectedCompany, selectedCompanyId } = useCompany();
const { openNewProject } = useDialogActions(); const { openNewProject } = useDialogActions();
const { isMobile, setSidebarOpen } = useSidebar(); const { isMobile, setSidebarOpen, collapsed, peeking } = useSidebar();
const rail = collapsed && !peeking;
const fineReorderPointer = useFineReorderPointer(); const fineReorderPointer = useFineReorderPointer();
const location = useLocation(); const location = useLocation();
@@ -373,6 +397,7 @@ export function SidebarProjects() {
isMobile={isMobile} isMobile={isMobile}
project={project} project={project}
projectSidebarSlots={projectSidebarSlots} projectSidebarSlots={projectSidebarSlots}
rail={rail}
setSidebarOpen={setSidebarOpen} setSidebarOpen={setSidebarOpen}
onLeaveProject={leaveProject} onLeaveProject={leaveProject}
leaving={projectLeaving(project)} leaving={projectLeaving(project)}
@@ -420,6 +445,7 @@ export function SidebarProjects() {
isMobile={isMobile} isMobile={isMobile}
project={project} project={project}
projectSidebarSlots={projectSidebarSlots} projectSidebarSlots={projectSidebarSlots}
rail={rail}
setSidebarOpen={setSidebarOpen} setSidebarOpen={setSidebarOpen}
onLeaveProject={leaveProject} onLeaveProject={leaveProject}
leaving={projectLeaving(project)} leaving={projectLeaving(project)}
+85
View File
@@ -5,16 +5,22 @@ import type { ReactNode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SidebarSection } from "./SidebarSection"; import { SidebarSection } from "./SidebarSection";
import { SidebarNavExpandedProvider } from "./SidebarNavItem";
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
const sidebarState = vi.hoisted(() => ({ const sidebarState = vi.hoisted(() => ({
isMobile: false, isMobile: false,
collapsed: false,
peeking: false,
})); }));
vi.mock("@/lib/router", () => ({ vi.mock("@/lib/router", () => ({
Link: ({ children, to, ...props }: { children: ReactNode; to: string }) => ( Link: ({ children, to, ...props }: { children: ReactNode; to: string }) => (
<a href={to} {...props}>{children}</a> <a href={to} {...props}>{children}</a>
), ),
NavLink: ({ children, to, ...props }: { children: ReactNode; to: string }) => (
<a href={to} {...props}>{children}</a>
),
})); }));
vi.mock("../context/SidebarContext", () => ({ vi.mock("../context/SidebarContext", () => ({
@@ -53,6 +59,8 @@ describe("SidebarSection", () => {
beforeEach(() => { beforeEach(() => {
sidebarState.isMobile = false; sidebarState.isMobile = false;
sidebarState.collapsed = false;
sidebarState.peeking = false;
container = document.createElement("div"); container = document.createElement("div");
document.body.appendChild(container); document.body.appendChild(container);
root = null; root = null;
@@ -155,6 +163,30 @@ describe("SidebarSection", () => {
expect(staticLabelControl?.getAttribute("class")).not.toContain("focus-visible:bg-accent/50"); expect(staticLabelControl?.getAttribute("class")).not.toContain("focus-visible:bg-accent/50");
}); });
it("keeps section labels visible inside an expanded contextual pane", async () => {
sidebarState.collapsed = true;
const currentRoot = createRoot(container);
root = currentRoot;
await act(async () => {
currentRoot.render(
<SidebarNavExpandedProvider>
<SidebarSection label="Settings">
<a href="/settings">General</a>
</SidebarSection>
</SidebarNavExpandedProvider>,
);
});
await flushReact();
const settingsLabel = Array.from(container.querySelectorAll("span"))
.find((element) => element.textContent === "Settings");
expect(settingsLabel).toBeTruthy();
expect(settingsLabel?.getAttribute("class")).toContain("uppercase");
expect(container.querySelector(".bg-border\\/60")).toBeNull();
});
it("keeps the header action outside the label menu hit area", async () => { it("keeps the header action outside the label menu hit area", async () => {
const onAction = vi.fn(); const onAction = vi.fn();
const currentRoot = createRoot(container); const currentRoot = createRoot(container);
@@ -259,6 +291,59 @@ describe("SidebarSection", () => {
expect(onAction).toHaveBeenCalledTimes(1); expect(onAction).toHaveBeenCalledTimes(1);
}); });
it("replaces the header with a divider and drops the caret in the collapsed rail", async () => {
sidebarState.collapsed = true;
const currentRoot = createRoot(container);
root = currentRoot;
await act(async () => {
currentRoot.render(
<SidebarSection
label="Projects"
collapsible={{ open: true, onOpenChange: vi.fn() }}
menu={{
ariaLabel: "Projects section actions",
actions: [{ type: "item", label: "Browse projects", href: "/projects" }],
}}
>
<a href="/projects">Projects</a>
</SidebarSection>,
);
});
await flushReact();
// The clipped header text is gone; the caret/menu trigger is not rendered.
expect(container.querySelector('button[aria-label="Collapse Projects"]')).toBeNull();
expect(container.querySelector('button[aria-label="Projects section actions"]')).toBeNull();
// The label is preserved in the a11y tree (sr-only) and the items still render.
const label = Array.from(container.querySelectorAll("span"))
.find((element) => element.textContent === "Projects");
expect(label?.className).toContain("sr-only");
expect(container.querySelector('a[href="/projects"]')).toBeTruthy();
});
it("restores the full header while peeking even when collapsed", async () => {
sidebarState.collapsed = true;
sidebarState.peeking = true;
const currentRoot = createRoot(container);
root = currentRoot;
await act(async () => {
currentRoot.render(
<SidebarSection label="Projects" collapsible={{ open: true, onOpenChange: vi.fn() }}>
<a href="/projects">Projects</a>
</SidebarSection>,
);
});
await flushReact();
const label = Array.from(container.querySelectorAll("span"))
.find((element) => element.textContent === "Projects");
expect(label?.className).not.toContain("sr-only");
expect(container.querySelector('button[aria-label="Collapse Projects"]')).toBeTruthy();
});
it("keeps section header controls visible on mobile", async () => { it("keeps section header controls visible on mobile", async () => {
sidebarState.isMobile = true; sidebarState.isMobile = true;
const currentRoot = createRoot(container); const currentRoot = createRoot(container);
+27
View File
@@ -18,6 +18,7 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useSidebarNavExpanded } from "./SidebarNavItem";
type SidebarSectionIcon = ComponentType<{ className?: string }>; type SidebarSectionIcon = ComponentType<{ className?: string }>;
@@ -189,8 +190,34 @@ export function SidebarSection({
menu, menu,
headerAction, headerAction,
}: SidebarSectionProps) { }: SidebarSectionProps) {
const { collapsed, peeking } = useSidebar();
const forceExpanded = useSidebarNavExpanded();
const rail = collapsed && !peeking && !forceExpanded;
const content = <div className="flex flex-col gap-0.5 mt-0.5">{children}</div>; const content = <div className="flex flex-col gap-0.5 mt-0.5">{children}</div>;
// Collapsed rail: the section header would only show a clipped sliver of its
// label, so replace it with a thin divider. The label stays in the a11y tree,
// per-section carets/menus are dropped (no room + no toggle target in the
// rail), and the items always render so their icons stay reachable.
//
// The header wrapper mirrors the expanded header's vertical footprint exactly
// (outer `px-3 py-1.5 pointer-coarse:py-1` + inner `min-h-6`) so item icons land
// at the identical y-position in both states — no movement on collapse/expand
// (PAP-10676). The divider is vertically centered within that same row.
if (rail) {
return (
<div>
<div className="px-3 py-1.5 pointer-coarse:py-1">
<div className="flex min-h-6 items-center">
<span className="sr-only">{label}</span>
<div className="h-px w-full bg-border/60" aria-hidden="true" />
</div>
</div>
{content}
</div>
);
}
if (collapsible) { if (collapsible) {
return ( return (
<Collapsible open={collapsible.open} onOpenChange={collapsible.onOpenChange}> <Collapsible open={collapsible.open} onOpenChange={collapsible.onOpenChange}>
+201
View File
@@ -0,0 +1,201 @@
// @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 { SidebarShell, SIDEBAR_RAIL_WIDTH } from "./SidebarShell";
// 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("SidebarShell", () => {
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();
});
// The in-flow spacer that reserves layout width.
function spacer() {
return container.firstElementChild as HTMLDivElement;
}
// The absolutely-positioned overlay panel that holds the sidebar content.
function panel() {
return spacer().firstElementChild as HTMLDivElement;
}
function handle() {
return container.querySelector('[role="separator"]') as HTMLDivElement | null;
}
it("uses a persisted width when expanded", () => {
window.localStorage.setItem("test.sidebar.width", "320");
act(() => {
root.render(
<SidebarShell open resizable storageKey="test.sidebar.width">
<div>Sidebar</div>
</SidebarShell>,
);
});
// Both the reserved spacer and the panel match the expanded width; no overlay.
expect(spacer().style.width).toBe("320px");
expect(panel().style.width).toBe("320px");
expect(panel().getAttribute("data-sidebar-overlay")).toBeNull();
expect(handle()?.getAttribute("aria-valuenow")).toBe("320");
});
it("resizes by dragging and persists the new width", () => {
act(() => {
root.render(
<SidebarShell open resizable storageKey="test.sidebar.width">
<div>Sidebar</div>
</SidebarShell>,
);
});
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(panel().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(
<SidebarShell open resizable storageKey="test.sidebar.width">
<div>Sidebar</div>
</SidebarShell>,
);
});
const separator = handle();
act(() => {
separator?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }));
});
expect(panel().style.width).toBe("256px");
expect(window.localStorage.getItem("test.sidebar.width")).toBe("256");
act(() => {
separator?.dispatchEvent(new KeyboardEvent("keydown", { key: "Home", bubbles: true }));
});
expect(panel().style.width).toBe("208px");
act(() => {
separator?.dispatchEvent(new KeyboardEvent("keydown", { key: "End", bubbles: true }));
});
expect(panel().style.width).toBe("420px");
});
it("can render without a resize handle", () => {
act(() => {
root.render(
<SidebarShell open resizable={false}>
<div>Sidebar</div>
</SidebarShell>,
);
});
expect(handle()).toBeNull();
expect(panel().style.width).toBe("240px");
});
it("reserves only the rail width when collapsed and hides the resize handle", () => {
window.localStorage.setItem("test.sidebar.width", "320");
act(() => {
root.render(
<SidebarShell open collapsed resizable storageKey="test.sidebar.width">
<div>Sidebar</div>
</SidebarShell>,
);
});
// Reserved spacer and panel both collapse to the rail; content never reflows
// beyond the rail, and the rail is not user-resizable.
expect(spacer().style.width).toBe(`${SIDEBAR_RAIL_WIDTH}px`);
expect(panel().style.width).toBe(`${SIDEBAR_RAIL_WIDTH}px`);
expect(panel().getAttribute("data-sidebar-overlay")).toBeNull();
expect(handle()).toBeNull();
});
it("hides all sidebar width when closed, even if pinned collapsed", () => {
window.localStorage.setItem("test.sidebar.width", "320");
act(() => {
root.render(
<SidebarShell open={false} collapsed resizable storageKey="test.sidebar.width">
<div>Sidebar</div>
</SidebarShell>,
);
});
expect(spacer().style.width).toBe("0px");
expect(panel().style.width).toBe("0px");
expect(panel().getAttribute("data-sidebar-overlay")).toBeNull();
expect(handle()).toBeNull();
});
it("overlays content while peeking without expanding the reserved spacer", () => {
window.localStorage.setItem("test.sidebar.width", "300");
act(() => {
root.render(
<SidebarShell open collapsed peeking storageKey="test.sidebar.width">
<div>Sidebar</div>
</SidebarShell>,
);
});
// The reserved spacer stays at the rail (no page reflow) while the panel
// grows to the expanded width and gets overlay styling.
expect(spacer().style.width).toBe(`${SIDEBAR_RAIL_WIDTH}px`);
expect(panel().style.width).toBe("300px");
expect(panel().getAttribute("data-sidebar-overlay")).toBe("");
expect(panel().className).toContain("shadow-lg");
expect(panel().className).toContain("z-30");
});
it("opens and closes instantly with no width transition (PAP-10676)", () => {
act(() => {
root.render(
<SidebarShell open>
<div>Sidebar</div>
</SidebarShell>,
);
});
// Open/close must be instant: the panel never animates its width, so neither
// the transition nor its reduced-motion fallback should be present.
expect(panel().className).not.toContain("transition-[width]");
expect(panel().className).not.toContain("motion-reduce:transition-none");
});
});
+244
View File
@@ -0,0 +1,244 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type FocusEventHandler,
type KeyboardEvent,
type MouseEventHandler,
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;
// Collapsed icon rail. Width is chosen so the icon is *centered* in the rail,
// which keeps the active/hover highlight symmetric around it (PAP-10676):
// the icon's left edge sits at nav px-3 (12) + item px-3 (12) = 24px, so a
// matching 24px trailing margin (12 item px-3 + 12 nav px-3) yields
// 24 + 16 (icon) + 24 = 64. The icon's left edge is unchanged from the expanded
// layout, so icons stay pixel-identical across states.
export const SIDEBAR_RAIL_WIDTH = 64;
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 SidebarShellProps = {
children: ReactNode;
/** Whether the sidebar occupies space at all (mobile back-compat / hidden). */
open: boolean;
/** Pinned collapsed (rail) mode. Desktop-only; the caller gates this. */
collapsed?: boolean;
/** Ephemeral hover/focus peek. Only meaningful while collapsed. */
peeking?: boolean;
resizable?: boolean;
storageKey?: string;
className?: string;
/** Forwarded to the overlay panel so Layout can wire peek triggers. */
onPanelMouseEnter?: MouseEventHandler<HTMLDivElement>;
onPanelMouseLeave?: MouseEventHandler<HTMLDivElement>;
onPanelFocusCapture?: FocusEventHandler<HTMLDivElement>;
onPanelBlurCapture?: FocusEventHandler<HTMLDivElement>;
};
/**
* Layout shell for the desktop sidebar. Owns the persisted expanded width and
* renders two layers:
* - an in-flow spacer that reserves `reservedWidth` (rail when collapsed,
* expanded width otherwise) so content to the right only reflows on pin, and
* - an absolutely-positioned panel of `panelWidth` (rail at rest, expanded
* while peeking) that overlays content with shadow/raised z-index when it
* is wider than the reserved spacer.
*
* The icon-alignment guarantee comes for free: collapsing is a pure width
* change with `overflow-hidden` clipping the labels; item padding/icon markup
* are never touched, so the left-aligned icon stays pixel-identical.
*
* Opening/closing is intentionally instant (no width transition): the pin toggle
* and peek snap between rail and expanded widths so the content never appears to
* slide. Resizing the drag handle is likewise direct.
*/
export function SidebarShell({
children,
open,
collapsed = false,
peeking = false,
resizable = false,
storageKey = "paperclip.sidebar.width",
className,
onPanelMouseEnter,
onPanelMouseLeave,
onPanelFocusCapture,
onPanelBlurCapture,
}: SidebarShellProps) {
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]);
// Unified width function (plan §5): one code path for every pinned state.
const expandedWidth = open ? width : 0;
const reservedWidth = !open ? 0 : collapsed ? SIDEBAR_RAIL_WIDTH : expandedWidth;
const panelWidth = !open ? 0 : collapsed && !peeking ? SIDEBAR_RAIL_WIDTH : expandedWidth;
const isOverlay = panelWidth > reservedWidth;
// The drag handle can only resize the expanded width, so it is disabled while
// collapsed (the rail width is a fixed constant, not user-resizable).
const canResize = resizable && open && !collapsed;
const reservedStyle = useMemo(
() => ({ width: `${reservedWidth}px` }),
[reservedWidth],
);
const panelStyle = useMemo(
() => ({ width: `${panelWidth}px` }),
[panelWidth],
);
const commitWidth = useCallback(
(nextWidth: number) => {
const clamped = clampSidebarWidth(nextWidth);
widthRef.current = clamped;
setWidth(clamped);
writeStoredSidebarWidth(storageKey, clamped);
},
[storageKey],
);
const handlePointerDown = useCallback(
(event: PointerEvent<HTMLDivElement>) => {
if (!canResize) return;
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
dragState.current = { startX: event.clientX, startWidth: widthRef.current };
setIsResizing(true);
},
[canResize],
);
const handlePointerMove = useCallback(
(event: PointerEvent<HTMLDivElement>) => {
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<HTMLDivElement>) => {
if (!canResize) 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);
}
},
[canResize, commitWidth, width],
);
return (
<div className={cn("relative h-full shrink-0", className)} style={reservedStyle}>
<div
className={cn(
"absolute inset-y-0 left-0 flex flex-col overflow-hidden",
// Open/close is instant (PAP-10676): no width transition so the rail and
// expanded states snap without any sliding motion.
// Overlay styling only while the panel is wider than its reserved
// spacer (i.e. peeking) so it floats above content without reflow.
isOverlay
? "z-30 border-r border-border bg-background shadow-lg"
: "z-0",
)}
style={panelStyle}
data-sidebar-overlay={isOverlay ? "" : undefined}
onMouseEnter={onPanelMouseEnter}
onMouseLeave={onPanelMouseLeave}
onFocusCapture={onPanelFocusCapture}
onBlurCapture={onPanelBlurCapture}
>
{children}
{canResize ? (
<div
role="separator"
aria-label="Resize sidebar"
aria-orientation="vertical"
aria-valuemin={MIN_SIDEBAR_WIDTH}
aria-valuemax={MAX_SIDEBAR_WIDTH}
aria-valuenow={width}
tabIndex={0}
className={cn(
"absolute inset-y-0 right-0 z-20 w-3 cursor-col-resize touch-none outline-none",
"before:absolute before:inset-y-0 before:left-1/2 before:w-px before:-translate-x-1/2 before:bg-transparent before:transition-colors",
"hover:before:bg-border focus-visible:before:bg-ring",
isResizing && "before:bg-ring",
)}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={endResize}
onPointerCancel={endResize}
onLostPointerCapture={endResize}
onKeyDown={handleKeyDown}
/>
) : null}
</div>
</div>
);
}
+262
View File
@@ -0,0 +1,262 @@
// @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<typeof useSidebar> | 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(
<SidebarProvider>
<Capture />
</SidebarProvider>,
);
});
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("forced collapse (secondary sidebar): overrides the pin, preserves preference", () => {
it("forces collapsed even when the user pinned expanded, without mutating the pin", () => {
active = renderProvider();
// User prefers expanded site-wide.
act(() => capturedValue?.setCollapsed(false));
expect(capturedValue?.collapsed).toBe(false);
expect(localStorage.getItem(COLLAPSED_STORAGE_KEY)).toBe("0");
// Entering a secondary-sidebar route forces the rail and locks it.
act(() => capturedValue?.setForceCollapsed(true));
expect(capturedValue?.collapsed).toBe(true);
expect(capturedValue?.collapseLocked).toBe(true);
// The persisted preference is untouched.
expect(localStorage.getItem(COLLAPSED_STORAGE_KEY)).toBe("0");
});
it("restores the user's preference when the force is cleared (leaving the route)", () => {
active = renderProvider();
act(() => capturedValue?.setCollapsed(false));
act(() => capturedValue?.setForceCollapsed(true));
expect(capturedValue?.collapsed).toBe(true);
// Navigating away clears the force; the expanded preference returns.
act(() => capturedValue?.setForceCollapsed(false));
expect(capturedValue?.collapsed).toBe(false);
expect(capturedValue?.collapseLocked).toBe(false);
});
it("locks the toggle while forced: toggleCollapsed is a no-op and never writes the pin", () => {
active = renderProvider();
act(() => capturedValue?.setCollapsed(false));
act(() => capturedValue?.setForceCollapsed(true));
act(() => capturedValue?.toggleCollapsed());
expect(capturedValue?.collapsed).toBe(true); // still forced
expect(localStorage.getItem(COLLAPSED_STORAGE_KEY)).toBe("0"); // pin unchanged
});
it("never forces or locks on mobile", () => {
setViewport({ mobile: true });
active = renderProvider();
act(() => capturedValue?.setForceCollapsed(true));
expect(capturedValue?.collapsed).toBe(false);
expect(capturedValue?.collapseLocked).toBe(false);
});
});
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);
});
});
});
+157 -5
View File
@@ -1,20 +1,104 @@
import { createContext, useCallback, useContext, useState, useEffect, type ReactNode } from "react"; import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
interface SidebarContextValue { interface SidebarContextValue {
// Mobile drawer + back-compat (existing behavior, unchanged).
sidebarOpen: boolean; sidebarOpen: boolean;
setSidebarOpen: (open: boolean) => void; setSidebarOpen: (open: boolean) => void;
toggleSidebar: () => void; toggleSidebar: () => void;
isMobile: boolean; isMobile: boolean;
// Pinned desktop mode: expanded | collapsed. Desktop-only.
collapsed: boolean;
setCollapsed: (next: boolean) => void;
toggleCollapsed: () => void;
// True while a secondary sidebar forces the rail: the collapse is locked, so
// the expand/toggle affordance must be hidden/inert. Desktop-only.
collapseLocked: boolean;
// Ephemeral peek (hover flyout). Only meaningful on desktop, collapsed,
// hover-capable pointer. Never persisted.
peeking: boolean;
setPeeking: (next: boolean) => void;
// Hard, ephemeral collapse forced by an active secondary sidebar (settings,
// plugin `routeSidebar`, …). HIGHER precedence than the user pin — the rule
// is "a secondary sidebar always collapses the primary" — but it never
// mutates the persisted pin, so leaving the route restores the preference.
// Wired by Layout (PAP-10694).
forceCollapsed: boolean;
setForceCollapsed: (next: boolean) => void;
// Route-requested collapse: a route may *default* the app sidebar to
// collapsed. LOWER precedence than an explicit user pin. Wired by routes via
// RequestCollapsedSidebar.
routeRequestsCollapsed: boolean;
setRouteRequestsCollapsed: (next: boolean) => void;
} }
const SidebarContext = createContext<SidebarContextValue | null>(null); const SidebarContext = createContext<SidebarContextValue | null>(null);
const MOBILE_BREAKPOINT = 768; 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 }) { export function SidebarProvider({ children }: { children: ReactNode }) {
const [isMobile, setIsMobile] = useState(() => window.innerWidth < MOBILE_BREAKPOINT); const [isMobile, setIsMobile] = useState(() => window.innerWidth < MOBILE_BREAKPOINT);
const [sidebarOpen, setSidebarOpen] = 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<boolean | null>(() => readStoredCollapsed());
const [routeRequestsCollapsed, setRouteRequestsCollapsed] = useState(false);
const [forceCollapsed, setForceCollapsed] = useState(false);
const [rawPeeking, setRawPeeking] = useState(false);
const [pointerCanPeek, setPointerCanPeek] = useState(() => readPointerCanPeek());
useEffect(() => { useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`); const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = (e: MediaQueryListEvent) => { const onChange = (e: MediaQueryListEvent) => {
@@ -25,13 +109,81 @@ export function SidebarProvider({ children }: { children: ReactNode }) {
return () => mql.removeEventListener("change", onChange); 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): forced (active secondary sidebar) > explicit user
// pin > route request > default expanded. The force is ephemeral and never
// touches the persisted pin, so dropping it restores the user's preference.
const pinnedOrRequested = userCollapsed !== null ? userCollapsed : routeRequestsCollapsed;
const desktopCollapsed = forceCollapsed || pinnedOrRequested;
// 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;
// While forced, the pin is locked: the expand/toggle affordance is inert.
const collapseLocked = !isMobile && forceCollapsed;
// 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(() => {
// While a secondary sidebar forces the rail, the toggle is locked: it must
// neither expand the rail nor mutate the persisted preference.
if (forceCollapsed) return;
setCollapsed(!pinnedOrRequested);
}, [forceCollapsed, pinnedOrRequested, setCollapsed]);
const setPeeking = useCallback((next: boolean) => {
setRawPeeking(next);
}, []);
const toggleSidebar = useCallback(() => setSidebarOpen((v) => !v), []); const toggleSidebar = useCallback(() => setSidebarOpen((v) => !v), []);
return ( const value = useMemo<SidebarContextValue>(
<SidebarContext.Provider value={{ sidebarOpen, setSidebarOpen, toggleSidebar, isMobile }}> () => ({
{children} sidebarOpen,
</SidebarContext.Provider> setSidebarOpen,
toggleSidebar,
isMobile,
collapsed,
setCollapsed,
toggleCollapsed,
collapseLocked,
peeking,
setPeeking,
forceCollapsed,
setForceCollapsed,
routeRequestsCollapsed,
setRouteRequestsCollapsed,
}),
[
sidebarOpen,
setSidebarOpen,
toggleSidebar,
isMobile,
collapsed,
setCollapsed,
toggleCollapsed,
collapseLocked,
peeking,
setPeeking,
forceCollapsed,
setForceCollapsed,
routeRequestsCollapsed,
setRouteRequestsCollapsed,
],
); );
return <SidebarContext.Provider value={value}>{children}</SidebarContext.Provider>;
} }
export function useSidebar() { export function useSidebar() {
@@ -11,14 +11,17 @@ import { useKeyboardShortcuts } from "./useKeyboardShortcuts";
function TestHarness({ function TestHarness({
onNewIssue, onNewIssue,
onSearch, onSearch,
onToggleCollapse,
}: { }: {
onNewIssue: () => void; onNewIssue: () => void;
onSearch?: () => void; onSearch?: () => void;
onToggleCollapse?: () => void;
}) { }) {
useKeyboardShortcuts({ useKeyboardShortcuts({
enabled: true, enabled: true,
onNewIssue, onNewIssue,
onSearch, onSearch,
onToggleCollapse,
}); });
return <div>keyboard shortcuts test</div>; return <div>keyboard shortcuts test</div>;
@@ -106,4 +109,53 @@ describe("useKeyboardShortcuts", () => {
root.unmount(); root.unmount();
}); });
}); });
it("fires onToggleCollapse on Cmd/Ctrl+B", () => {
const root = createRoot(container);
const onToggleCollapse = vi.fn();
act(() => {
root.render(<TestHarness onNewIssue={vi.fn()} onToggleCollapse={onToggleCollapse} />);
});
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(<TestHarness onNewIssue={vi.fn()} onToggleCollapse={onToggleCollapse} />);
});
document.dispatchEvent(new KeyboardEvent("keydown", {
key: "b",
bubbles: true,
cancelable: true,
}));
expect(onToggleCollapse).not.toHaveBeenCalled();
act(() => {
root.unmount();
});
});
}); });
+9 -1
View File
@@ -10,6 +10,7 @@ interface ShortcutHandlers {
onNewIssue?: () => void; onNewIssue?: () => void;
onSearch?: () => void; onSearch?: () => void;
onToggleSidebar?: () => void; onToggleSidebar?: () => void;
onToggleCollapse?: () => void;
onTogglePanel?: () => void; onTogglePanel?: () => void;
onShowShortcuts?: () => void; onShowShortcuts?: () => void;
} }
@@ -19,6 +20,7 @@ export function useKeyboardShortcuts({
onNewIssue, onNewIssue,
onSearch, onSearch,
onToggleSidebar, onToggleSidebar,
onToggleCollapse,
onTogglePanel, onTogglePanel,
onShowShortcuts, onShowShortcuts,
}: ShortcutHandlers) { }: ShortcutHandlers) {
@@ -67,6 +69,12 @@ export function useKeyboardShortcuts({
onToggleSidebar?.(); 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 // ] → Toggle Panel
if (e.key === "]" && !e.metaKey && !e.ctrlKey) { if (e.key === "]" && !e.metaKey && !e.ctrlKey) {
e.preventDefault(); e.preventDefault();
@@ -76,5 +84,5 @@ export function useKeyboardShortcuts({
document.addEventListener("keydown", handleKeyDown); document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown);
}, [enabled, onNewIssue, onSearch, onToggleSidebar, onTogglePanel, onShowShortcuts]); }, [enabled, onNewIssue, onSearch, onToggleSidebar, onToggleCollapse, onTogglePanel, onShowShortcuts]);
} }
+12
View File
@@ -7,6 +7,18 @@ export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs));
} }
/**
* Classes for a sidebar row label when the sidebar is collapsed to the icon rail
* (PAP-10676). Unlike `sr-only` (which is `position: absolute` and therefore
* removes the label from flow), this keeps the label in flow so it still
* contributes its line-height to the row. That guarantees a row is the *exact*
* same height collapsed as expanded, so the icons never shift vertically between
* states. The label is clipped to zero visible width and rendered transparent,
* but stays in the DOM and the a11y tree as the link's accessible name.
*/
export const SIDEBAR_RAIL_HIDDEN_LABEL =
"block w-0 min-w-0 overflow-hidden whitespace-nowrap text-transparent select-none";
export function asObject(value: unknown): Record<string, unknown> { export function asObject(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value) return typeof value === "object" && value !== null && !Array.isArray(value)
? value as Record<string, unknown> ? value as Record<string, unknown>
@@ -16,6 +16,7 @@ import { KeyboardShortcutsCheatsheetContent } from "@/components/KeyboardShortcu
import { MobileBottomNav } from "@/components/MobileBottomNav"; import { MobileBottomNav } from "@/components/MobileBottomNav";
import { PageTabBar } from "@/components/PageTabBar"; import { PageTabBar } from "@/components/PageTabBar";
import { Sidebar } from "@/components/Sidebar"; import { Sidebar } from "@/components/Sidebar";
import { PluginLauncherProvider } from "@/plugins/launchers";
import { SidebarAccountMenu } from "@/components/SidebarAccountMenu"; import { SidebarAccountMenu } from "@/components/SidebarAccountMenu";
import { SidebarCompanyMenu } from "@/components/SidebarCompanyMenu"; import { SidebarCompanyMenu } from "@/components/SidebarCompanyMenu";
import { StatusBadge } from "@/components/StatusBadge"; import { StatusBadge } from "@/components/StatusBadge";
@@ -349,3 +350,30 @@ export default meta;
type Story = StoryObj<typeof meta>; type Story = StoryObj<typeof meta>;
export const BoardChromeMatrix: Story = {}; export const BoardChromeMatrix: Story = {};
// PAP-10676 verification harness: renders the real Sidebar at a fixed width so a
// screenshot of the expanded state and a screenshot of the pinned-collapsed rail
// can be overlaid. The icon column must be pixel-identical between the two — the
// only difference should be the labels (sr-only in the rail). Playwright toggles
// collapse via the in-sidebar control, so this exercises the real context path.
function SidebarIconAlignmentHarness() {
return (
<PluginLauncherProvider>
<div className="paperclip-story">
<RouteSetter to="/PAP/projects/board-ui/issues" />
<div className="flex min-h-[760px] items-start justify-center bg-muted/30 p-8">
<div
data-testid="sidebar-align-frame"
className="h-[700px] w-60 overflow-hidden border border-border bg-background"
>
<Sidebar />
</div>
</div>
</div>
</PluginLauncherProvider>
);
}
export const SidebarIconAlignment: Story = {
render: () => <SidebarIconAlignmentHarness />,
};