Files
PaperClipAI/ui/src/components/Sidebar.test.tsx
T
DottaandPaperclip 5858ccb981 feat: make in-app features cloud-aware (#10850)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Operators use the same board application in self-hosted and
Paperclip Cloud deployments.
> - A Cloud tenant contains one company, so an in-app company switch
does not change the active Cloud stack.
> - Cloud operators need the sidebar and company surfaces to use the
signed-in user's stack portfolio.
> - The server must derive Cloud identity and links from trusted
instance context instead of client input.
> - This pull request adds canonical Cloud context, a trusted stack
portfolio proxy, and Cloud-aware navigation.
> - The benefit is consistent stack switching on Cloud while self-hosted
company behavior stays unchanged.

## Linked Issues or Issue Description

**Subsystem affected**

Cross-cutting: server REST routes and the React board UI.

**Problem or motivation**

A Cloud-managed instance contains one company. The existing company
switcher could only switch records inside that tenant. It could not move
the operator to another Cloud stack. The existing header also gave long
organization names too little width.

**Proposed solution**

Expose a canonical public Cloud context in health data. Add a trusted
server proxy for the current user's stack portfolio. Use that data in
the board UI to switch stacks with top-level navigation. Keep the
existing company behavior on self-hosted instances. Move search into the
navigation and keep long organization names inside the sidebar panel.

**Alternatives considered**

An in-app `/stacks` route was rejected because Cloud tenant hosts
reserve that path and stack selection must wake or authenticate another
tenant. Client-supplied user identity was rejected because the server
can derive the trusted Cloud actor.

**Roadmap alignment**

This change advances the Cloud deployments milestone. It keeps the
product local-first and Cloud-ready without changing the self-hosted
mental model.

## What Changed

- Added canonical Cloud instance context and public health metadata.
- Added a Cloud-only stack portfolio proxy with trusted actor forwarding
and per-user caching.
- Prevented normal company creation on Cloud-managed instances.
- Switched the sidebar and Companies page from company actions to stack
actions on Cloud.
- Added full-page stack navigation and Cloud create-stack links.
- Moved search into the sidebar navigation so the organization name
keeps more width.
- Added truncation and hover recovery for long organization and stack
names.
- Added server and UI regression coverage for Cloud and self-hosted
behavior.
- Updated the implementation specification for the Cloud contracts.

## Verification

- `node scripts/check-token-gates.mjs` passed. All three token gates are
clean.
- `pnpm --dir server exec vitest run src/__tests__/health.test.ts
src/__tests__/cloud-instance.test.ts src/__tests__/cloud-routes.test.ts
src/__tests__/company-cloud-floor.test.ts
src/__tests__/company-portability-routes.test.ts` passed: 5 files and 66
tests.
- `pnpm --dir ui exec vitest run
src/components/SidebarCompanyMenu.test.tsx` passed: 1 file and 11 tests.
- Pre-PR QA report `7da87ca7` passed all 8 acceptance criteria with real
HTTP route factories and real Chromium screenshots in Cloud and
self-hosted modes.
- Security reviews passed for the canonical Cloud context and stack
portfolio proxy.

## Risks

- Cloud stack switching depends on the configured Cloud application and
tenant portfolio URLs.
- The new health `cloud` block is public by design, but it contains only
canonical public instance metadata.
- The stack proxy fails closed on self-hosted instances and derives the
user identity from the trusted actor.
- Self-hosted navigation and company creation retain their existing
paths and behavior.

> 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, model `gpt-5`. The run used reasoning, repository tools,
shell execution, and GitHub integration. The deployment did not expose
its context-window size.

## 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 not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] 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: Paperclip <noreply@paperclip.ing>
2026-08-04 23:00:14 -05:00

634 lines
22 KiB
TypeScript

// @vitest-environment jsdom
import { type ReactNode } from "react";
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Sidebar } from "./Sidebar";
import { TooltipProvider } from "@/components/ui/tooltip";
const mockHeartbeatsApi = vi.hoisted(() => ({
liveRunsForCompany: vi.fn(),
}));
const mockAttentionApi = vi.hoisted(() => ({
list: vi.fn(),
}));
const mockInstanceSettingsApi = vi.hoisted(() => ({
getExperimental: vi.fn(),
}));
vi.mock("@/lib/router", () => ({
NavLink: ({ to, children, className, ...props }: {
to: string;
children: ReactNode;
className?: string | ((state: { isActive: boolean }) => string);
}) => (
<a
href={to}
className={typeof className === "function" ? className({ isActive: false }) : className}
{...props}
>
{children}
</a>
),
}));
vi.mock("../context/DialogContext", () => ({
useDialog: () => ({
openNewIssue: vi.fn(),
}),
useDialogActions: () => ({
openNewIssue: vi.fn(),
}),
}));
vi.mock("../context/CompanyContext", () => ({
useCompany: () => ({
selectedCompanyId: "company-1",
selectedCompany: { id: "company-1", issuePrefix: "PAP", name: "Paperclip" },
}),
}));
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", () => ({
useSidebar: () => mockSidebar,
}));
vi.mock("../api/heartbeats", () => ({
heartbeatsApi: mockHeartbeatsApi,
}));
vi.mock("../api/attention", () => ({
attentionApi: mockAttentionApi,
}));
vi.mock("../api/instanceSettings", () => ({
instanceSettingsApi: mockInstanceSettingsApi,
}));
vi.mock("../hooks/useInboxBadge", () => ({
useInboxBadge: () => ({ inbox: 0, failedRuns: 0 }),
}));
vi.mock("@/plugins/slots", () => ({
PluginSlotOutlet: ({ slotTypes }: { slotTypes: string[] }) => (
<div data-plugin-slot-types={slotTypes.join(",")}>Plugin slot outlet</div>
),
}));
vi.mock("@/plugins/launchers", () => ({
PluginLauncherOutlet: ({ placementZones }: { placementZones: string[] }) => (
<div data-plugin-launcher-zone={placementZones.join(",")}>Plugin launcher outlet</div>
),
}));
vi.mock("./SidebarCompanyMenu", () => ({
SidebarCompanyMenu: () => <div>Company menu</div>,
}));
vi.mock("./SidebarAgents", () => ({
SidebarAgents: ({ streamlined }: { streamlined?: boolean }) => (
<div data-testid="sidebar-agents" data-streamlined={String(streamlined)} />
),
}));
vi.mock("./SidebarProjects", () => ({
SidebarProjects: () => <div data-testid="sidebar-projects">Projects collapsible</div>,
}));
vi.mock("./SidebarStarredProjects", () => ({
SidebarStarredProjects: () => <div data-testid="sidebar-starred-projects" />,
}));
async function flushReact() {
for (let index = 0; index < 5; index += 1) {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
}
flushSync(() => {});
}
describe("Sidebar", () => {
let container: HTMLDivElement;
async function renderSidebar() {
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
flushSync(() => {
root.render(
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<Sidebar />
</TooltipProvider>
</QueryClientProvider>,
);
});
await flushReact();
return root;
}
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([]);
mockAttentionApi.list.mockResolvedValue({ items: [] });
mockSidebar.isMobile = false;
mockSidebar.collapsed = false;
mockSidebar.peeking = false;
});
afterEach(() => {
container.remove();
document.body.innerHTML = "";
vi.clearAllMocks();
});
it("shows Search as a nav item instead of a header icon", async () => {
// The header's spare width goes to the workspace name (which otherwise
// truncates at ~78px), so search lives in the nav list — still
// exactly one pointer affordance, just relocated.
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false });
const root = await renderSidebar();
expect(container.querySelector('a[aria-label="Open search"]')).toBeNull();
const navSearchLink = [...container.querySelectorAll("nav a")]
.find((anchor) => anchor.textContent?.trim() === "Search");
expect(navSearchLink?.getAttribute("href")).toBe("/search");
flushSync(() => {
root.unmount();
});
});
it("renders plugin sidebar launchers inside the Work section", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableIsolatedWorkspaces: false,
enableStreamlinedLeftNavigation: true,
});
const root = await renderSidebar();
const workSection = [...container.querySelectorAll("nav [data-plugin-launcher-zone]")]
.find((node) => node.getAttribute("data-plugin-launcher-zone") === "sidebar");
expect(workSection?.textContent).toContain("Plugin launcher outlet");
// The Work section is a Collapsible now (one extra wrapper level), so
// resolve the section root by walking up until the header label appears.
let workSectionContainer = workSection?.parentElement ?? null;
while (workSectionContainer && !workSectionContainer.textContent?.includes("Work")) {
workSectionContainer = workSectionContainer.parentElement;
}
expect(workSectionContainer?.textContent).toContain("Work");
expect(workSectionContainer?.textContent).toContain("Tasks");
expect(workSectionContainer?.textContent).not.toContain("Goals");
flushSync(() => {
root.unmount();
});
});
it("streamlined (flag ON): keeps Task wording, top-level Projects link, no per-project collapsible", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableIsolatedWorkspaces: false,
enableStreamlinedLeftNavigation: true,
});
const root = await renderSidebar();
expect(container.textContent).toContain("New Task");
expect(container.textContent).not.toContain("New Issue");
const navLabels = [...container.querySelectorAll("nav a")].map((a) => a.textContent?.trim());
expect(navLabels).toContain("Tasks");
expect(navLabels).not.toContain("Issues");
const projectsLink = [...container.querySelectorAll("nav a")].find((a) => a.textContent?.trim() === "Projects");
expect(projectsLink?.getAttribute("href")).toBe("/projects");
expect(container.querySelector('[data-testid="sidebar-projects"]')).toBeNull();
expect(
container.querySelector('[data-testid="sidebar-agents"]')?.getAttribute("data-streamlined"),
).toBe("true");
flushSync(() => {
root.unmount();
});
});
it("defaults to streamlined navigation while experimental settings are loading", async () => {
mockInstanceSettingsApi.getExperimental.mockImplementation(() => new Promise(() => {}));
const root = await renderSidebar();
const navLabels = [...container.querySelectorAll("nav a")].map((a) => a.textContent?.trim());
expect(navLabels).toContain("Projects");
expect(container.querySelector('[data-testid="sidebar-projects"]')).toBeNull();
expect(
container.querySelector('[data-testid="sidebar-agents"]')?.getAttribute("data-streamlined"),
).toBe("true");
flushSync(() => {
root.unmount();
});
});
it("streamlined is now standard: a stale enableStreamlinedLeftNavigation=false opt-out is ignored", async () => {
// PAP-12472 retired the experimental opt-out; the streamlined sidebar is the
// only path, so an old `false` setting no longer restores classic mode.
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableIsolatedWorkspaces: false,
enableStreamlinedLeftNavigation: false,
});
const root = await renderSidebar();
const navLabels = [...container.querySelectorAll("nav a")].map((a) => a.textContent?.trim());
expect(navLabels).toContain("Tasks");
// Top-level Projects link + starred children stay, per-project collapsible gone.
expect(navLabels).toContain("Projects");
expect(container.querySelector('[data-testid="sidebar-projects"]')).toBeNull();
expect(container.querySelector('[data-testid="sidebar-starred-projects"]')).not.toBeNull();
expect(
container.querySelector('[data-testid="sidebar-agents"]')?.getAttribute("data-streamlined"),
).toBe("true");
flushSync(() => {
root.unmount();
});
});
it("renders plugin sidebar slots in Work below Workspaces", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true });
const root = await renderSidebar();
const sidebarSlot = [...container.querySelectorAll("nav [data-plugin-slot-types]")]
.find((node) => node.getAttribute("data-plugin-slot-types") === "sidebar");
expect(sidebarSlot?.textContent).toContain("Plugin slot outlet");
const workSectionContainer = sidebarSlot?.parentElement?.parentElement;
const workText = workSectionContainer?.textContent ?? "";
expect(workText).toContain("Work");
expect(workText).toContain("Workspaces");
expect(workText.indexOf("Workspaces")).toBeLessThan(workText.indexOf("Plugin slot outlet"));
const primaryNavText = container.querySelector("nav > div:first-child")?.textContent ?? "";
expect(primaryNavText).toContain("Inbox");
expect(primaryNavText).not.toContain("Plugin slot outlet");
flushSync(() => {
root.unmount();
});
});
it("does not flash the Workspaces link while experimental settings are loading", async () => {
mockInstanceSettingsApi.getExperimental.mockImplementation(() => new Promise(() => {}));
const root = await renderSidebar();
expect(container.textContent).not.toContain("Workspaces");
flushSync(() => {
root.unmount();
});
});
it("does not poll attention until Decisions is enabled", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableDecisions: false });
const root = await renderSidebar();
expect(mockAttentionApi.list).not.toHaveBeenCalled();
flushSync(() => {
root.unmount();
});
});
it("shows Status directly below Decisions in primary navigation", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableDecisions: true,
enableStatusCards: true,
});
const root = await renderSidebar();
const primaryNavLinks = [...container.querySelectorAll("nav > div:first-child a")];
const decisionsLink = primaryNavLinks.find(
(anchor) => anchor.textContent?.trim() === "Decisions",
);
const statusLink = primaryNavLinks.find((anchor) => anchor.getAttribute("href") === "/status");
expect(statusLink?.textContent).toContain("Status");
expect(statusLink?.textContent).toContain("beta");
expect(statusLink?.textContent).not.toContain("exp");
expect(statusLink?.textContent).not.toContain("cards");
expect(primaryNavLinks.indexOf(statusLink!)).toBe(primaryNavLinks.indexOf(decisionsLink!) + 1);
flushSync(() => {
root.unmount();
});
});
it("shows Skills directly below Artifacts in Work", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false });
const root = await renderSidebar();
const artifactsLink = [...container.querySelectorAll("a")].find(
(anchor) => anchor.textContent === "Artifacts",
);
expect(artifactsLink?.getAttribute("href")).toBe("/artifacts");
const navText = container.querySelector("nav")?.textContent ?? "";
expect(navText).toContain("Artifacts");
expect(navText).toContain("Skills");
expect(navText.indexOf("Artifacts")).toBeLessThan(navText.indexOf("Skills"));
const sections = [...container.querySelectorAll("nav > div")];
const workSection = sections.find((section) => section.textContent?.startsWith("Work"));
const companySection = sections.find((section) => section.textContent?.startsWith("Company"));
expect(workSection?.textContent).toContain("Skills");
expect(companySection?.textContent).not.toContain("Skills");
flushSync(() => {
root.unmount();
});
});
it("hides the Goals nav item by default", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableIsolatedWorkspaces: false,
enableGoalsSidebarLink: false,
});
const root = await renderSidebar();
expect([...container.querySelectorAll("nav a")].map((a) => a.textContent?.trim())).not.toContain("Goals");
flushSync(() => {
root.unmount();
});
});
it("reserves the Goals nav slot while experimental settings are loading", async () => {
mockInstanceSettingsApi.getExperimental.mockImplementation(() => new Promise(() => {}));
const root = await renderSidebar();
expect([...container.querySelectorAll("nav a")].map((a) => a.textContent?.trim())).not.toContain("Goals");
expect(container.querySelector('[data-testid="sidebar-goals-placeholder"]')).not.toBeNull();
flushSync(() => {
root.unmount();
});
});
it("shows the Goals nav item when the experimental setting is enabled", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableIsolatedWorkspaces: false,
enableGoalsSidebarLink: true,
});
const root = await renderSidebar();
const link = [...container.querySelectorAll("a")].find((anchor) => anchor.textContent === "Goals");
expect(link?.getAttribute("href")).toBe("/goals");
const navText = container.querySelector("nav")?.textContent ?? "";
expect(navText.indexOf("Goals")).toBeLessThan(navText.indexOf("Artifacts"));
flushSync(() => {
root.unmount();
});
});
it("places Timeline in the Company section", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false });
const root = await renderSidebar();
const sections = [...container.querySelectorAll("nav > div")];
const workSection = sections.find((section) => section.textContent?.startsWith("Work"));
const companySection = sections.find((section) => section.textContent?.startsWith("Company"));
expect(workSection?.textContent).not.toContain("Timeline");
expect(companySection?.textContent).toContain("Timeline");
const timelineLink = [...container.querySelectorAll("a")].find((anchor) => anchor.textContent === "Timeline");
expect(timelineLink?.getAttribute("href")).toBe("/timeline");
flushSync(() => {
root.unmount();
});
});
it("shows the Conference Room nav item when conference room chat is enabled (PAP-137)", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableIsolatedWorkspaces: false,
enableConferenceRoomChat: true,
});
const root = await renderSidebar();
const link = [...container.querySelectorAll("nav a")].find(
(anchor) => anchor.textContent?.trim() === "Conference Room",
);
expect(link?.getAttribute("href")).toBe("/board-chat");
flushSync(() => {
root.unmount();
});
});
it("hides the Conference Room nav item when conference room chat is off (PAP-137)", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableIsolatedWorkspaces: false,
enableConferenceRoomChat: false,
});
const root = await renderSidebar();
expect(container.textContent).not.toContain("Conference Room");
flushSync(() => {
root.unmount();
});
});
it("does not flash the Conference Room item while experimental settings are loading (PAP-137)", async () => {
mockInstanceSettingsApi.getExperimental.mockImplementation(() => new Promise(() => {}));
const root = await renderSidebar();
expect(container.textContent).not.toContain("Conference Room");
flushSync(() => {
root.unmount();
});
});
it("hides the Pipelines nav item when pipelines are disabled", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableIsolatedWorkspaces: false,
enablePipelines: false,
});
const root = await renderSidebar();
expect(container.textContent).not.toContain("Pipelines");
flushSync(() => {
root.unmount();
});
});
it("hides the Apps nav item unless experimental apps are enabled", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableApps: false });
const disabledRoot = await renderSidebar();
expect([...container.querySelectorAll("a")].some((anchor) => anchor.textContent === "Apps")).toBe(false);
flushSync(() => {
disabledRoot.unmount();
});
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableApps: true });
const enabledRoot = await renderSidebar();
const link = [...container.querySelectorAll("a")].find((anchor) => anchor.textContent === "Apps");
expect(link?.getAttribute("href")).toBe("/apps");
flushSync(() => {
enabledRoot.unmount();
});
});
it("shows the Pipelines nav item when pipelines are enabled", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableIsolatedWorkspaces: false,
enablePipelines: true,
});
const root = await renderSidebar();
const link = [...container.querySelectorAll("a")].find((anchor) => anchor.textContent === "Pipelines");
expect(link?.getAttribute("href")).toBe("/pipelines");
flushSync(() => {
root.unmount();
});
});
it("does not flash the Pipelines nav item while experimental settings are loading", async () => {
mockInstanceSettingsApi.getExperimental.mockImplementation(() => new Promise(() => {}));
const root = await renderSidebar();
expect(container.textContent).not.toContain("Pipelines");
flushSync(() => {
root.unmount();
});
});
it("shows the Workspaces link when isolated workspaces are enabled", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true });
const root = await renderSidebar();
const link = [...container.querySelectorAll("a")].find((anchor) => anchor.textContent === "Workspaces");
expect(link?.getAttribute("href")).toBe("/workspaces");
flushSync(() => {
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");
flushSync(() => {
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 toggle)", async () => {
// In the narrow rail the collapse toggle doesn't fit beside the logo and
// would overflow/clip, shoving the logo out of the icon column (PAP-10676), so
// it is dropped in the rail. Expansion stays reachable via hover-peek + Pin
// and Cmd/Ctrl+B. The toggle returns 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();
// The company menu (company switcher / logo) is still present in the rail.
expect(container.textContent).toContain("Company menu");
// Search survives the rail as an icon-only nav item — the old
// header icon was dropped entirely here, leaving the rail with no visible
// search affordance.
const railSearchLink = [...container.querySelectorAll("nav a")]
.find((anchor) => anchor.getAttribute("href") === "/search");
expect(railSearchLink).toBeTruthy();
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();
flushSync(() => {
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();
});
});
});