Files
PaperClipAI/ui/src/components/Sidebar.test.tsx
T
DottaandPaperclip 9077748894 Remove the Pipelines experiment and preserve its data (#16834)
## Thinking Path

> - Paperclip manages AI agents and their work.
> - Pipelines was an unused experiment with its own UI, API, CLI, and
automation.
> - Keeping it leaves unsupported product surfaces and extra task-detail
queries.
> - Removing the experiment must preserve data that users already saved.
> - This pull request removes the feature and keeps its database schema
and migration history.
> - Cases and Routines remain available as separate features.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

Remove the unused Pipelines experiment across the application.

**Current behavior**

The experiment includes pipeline boards, stage settings, case
automation, review queues, learnings, permissions, and CLI commands.
Task detail also queries pipeline links.

**Proposed behavior**

Remove all these product surfaces and their implementation. Preserve
existing tables, records, and documents. Ignore the retired boolean in
older managed configurations so upgrades still start.

**Breaking changes**

Pipeline URLs, API endpoints, CLI commands, and `pipelines:write` are
removed. Task detail no longer includes the old `linkedCases` field. The
independent Cases API remains available. No database migration or
cleanup is included.

Related feature work: #7907, #9110, #10905, #11375, #13004, #14671,
#16133. Related issues: #9663, #10310, #10312. This removal supersedes
the feature; it does not implement those pending enhancements or delete
their saved data.

## What Changed

- Remove Pipeline UI pages, navigation, gates, clients, query keys,
helpers, and unused design tokens.
- Remove Pipeline REST routes, services, automation dispatch,
task-detail queries, and OpenAPI discovery.
- Remove the CLI commands, shared contracts, experimental flag, catalog
entry, and permission defaults.
- Remove feature-specific tests, tutorials, smoke scripts, maps, and
Storybook examples.
- Preserve database schema, migration history, and the reserved document
key used by old records.
- Keep Cases routes independent and cover obsolete stored and managed
settings.

## Verification

- Passed: `pnpm build` and `pnpm -r typecheck`.
- Passed: targeted settings, managed configuration, sidebar, settings
UI, routing, and feature-catalog tests. A first sidebar run caught a
shared Workspaces icon import; the import was restored and the sidebar
rerun passed.
- Passed: 40 tests covering Cases routes, OpenAPI discovery, retained
Pipeline table constraints, and schema snapshot drift.
- Passed: token gates, map validation, generated API metadata check,
Storybook catalog validation, and 28 map/catalog tests.
- Passed: isolated local browser verification of navigation and
Experimental settings with Pipelines absent; Cases and Routines still
open normally. Live GET/POST requests to retired Pipeline endpoints
return 404, and the retired setting is absent. CLI help has no Pipeline
commands.
- Passed: 22 access-service and agent-default-grant tests after
restoring unrelated permission assertions flagged in review.
- Local full run: `pnpm test:run` reported one failure in the unchanged
workspace-runtime port-allocation test. That test passed in an isolated
rerun. The long local run was stopped after full CI passed; it is not
reported as a complete local pass.
- Passed: all 56 checks on `def9181f3a4491d95fca1d38e9739f5b821aae3e` (2
conditional checks skipped), including build, typecheck, test shards,
and browser suites. Eleven interrupted CI jobs reported “The operation
was canceled”; their one-time retry passed. [CI
run](https://github.com/paperclipai/paperclip/actions/runs/39255211006).
- Greptile: 5/5 on the final commit, with the only review finding fixed
and resolved.
- Sandbox socket restrictions affected the first build/API test
attempts. The same commands ran with local sockets available.

## Risks

- Existing Pipeline clients must stop using the removed endpoints and
commands.
- Existing data remains in the database and backups, but the removed UI
no longer displays it.
- Historical boolean `enablePipelines` values are ignored. They cannot
restore the feature.
- Cases and Routines must retain their separate behavior. Targeted route
tests cover this boundary.

## Model Used

OpenAI Codex, GPT-6, with repository inspection, editing, shell
execution, and browser verification. The exact deployed model ID and
context-window size are not exposed in this session.

## 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-10-19 08:09:46 -05:00

684 lines
25 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,
}));
const mockInboxBadge = vi.hoisted(() => ({ inbox: 0, failedRuns: 0 }));
vi.mock("../hooks/useInboxBadge", () => ({
useInboxBadge: () => mockInboxBadge,
}));
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)}>
Active agents
</div>
),
}));
vi.mock("./SidebarProjects", () => ({
SidebarProjects: () => <div data-testid="sidebar-projects">Classic projects</div>,
}));
vi.mock("./SidebarStarredProjects", () => ({
SidebarStarredProjects: () => <div data-testid="sidebar-starred-projects" />,
}));
// Stubbed so the "no agent names in the primary nav" assertion would catch a
// regression that mounts the old per-agent chat rows alongside the Chat rail.
vi.mock("./SidebarRecentTasks", () => ({
SidebarRecentTasks: () => <div data-testid="sidebar-recent-tasks">Recent Tasks</div>,
}));
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.collapseLocked = false;
mockSidebar.peeking = false;
mockInboxBadge.inbox = 0;
mockInboxBadge.failedRuns = 0;
});
afterEach(() => {
container.remove();
document.body.innerHTML = "";
vi.clearAllMocks();
});
it("keeps the default sidebar edge borderless", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false });
const root = await renderSidebar();
const sidebar = container.querySelector("aside");
expect(sidebar?.classList).not.toContain("border-r");
expect(sidebar?.classList).not.toContain("border-border");
expect(sidebar?.classList).toContain("primary-sidebar-surface");
flushSync(() => {
root.unmount();
});
});
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("uses the simplified work navigation with one Agents destination", 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");
const agentLinks = [...container.querySelectorAll('a[href="/agents"]')];
expect(agentLinks).toHaveLength(1);
expect([...container.querySelectorAll('a[href="/activity"]')]).toHaveLength(1);
expect(navLabels).toContain("Audit");
expect(navLabels).not.toContain("Settings");
expect(navLabels).not.toContain("Activity");
expect(navLabels).not.toContain("Costs");
expect(container.querySelector('[data-testid="sidebar-recent-tasks"]')).not.toBeNull();
flushSync(() => {
root.unmount();
});
});
it("keeps the simplified 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(navLabels).toContain("Agents");
expect(container.textContent).not.toContain("Organization");
expect(container.textContent).not.toContain("Workspaces");
expect([...container.querySelectorAll("nav a")].map((a) => a.textContent?.trim())).not.toContain("Goals");
expect(container.querySelector('[data-testid="sidebar-goals-placeholder"]')).not.toBeNull();
expect(container.textContent).not.toContain("Conference Room");
expect(container.textContent).not.toContain("Pipelines");
flushSync(() => {
root.unmount();
});
});
it("ignores the retired streamlined navigation opt-out", 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-starred-projects"]')).not.toBeNull();
expect(navLabels).toContain("Agents");
flushSync(() => {
root.unmount();
});
});
it("restores legacy agent and organization navigation when Streamlined UI is off", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableStreamlinedUi: false,
enableApps: true,
});
const root = await renderSidebar();
const labels = [...container.querySelectorAll("nav a")].map((anchor) => anchor.textContent?.trim());
expect(container.querySelector('[data-testid="sidebar-recent-tasks"]')).toBeNull();
expect(container.querySelector('[data-testid="sidebar-projects"]')).not.toBeNull();
expect(container.querySelector('[data-testid="sidebar-agents"]')?.getAttribute("data-streamlined")).toBe("undefined");
expect(container.textContent).toContain("Organization");
expect(labels).toEqual(expect.arrayContaining(["Org", "Connectors", "Timeline", "Costs", "Activity", "Settings"]));
expect(labels).not.toContain("Audit");
expect(labels).not.toContain("Projects");
expect(container.querySelector('a[href="/agents"]')).toBeNull();
expect(container.querySelector("aside")?.classList).toContain("border-r");
flushSync(() => {
root.unmount();
});
});
it("locks the legacy collapse control while a secondary sidebar forces the rail", async () => {
mockSidebar.collapseLocked = true;
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableStreamlinedUi: false });
const root = await renderSidebar();
expect(container.querySelector('button[aria-label="Collapse sidebar"]')).toBeNull();
expect(container.querySelector('button[aria-label="Expand sidebar"]')).toBeNull();
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("keeps the Inbox row, Workspaces and no chat surfaces while both PAP-670 flags are off", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true });
const root = await renderSidebar();
expect([...container.querySelectorAll("a")].some((anchor) => anchor.getAttribute("href") === "/chats")).toBe(false);
expect([...container.querySelectorAll("a")].some((anchor) => anchor.getAttribute("href") === "/inbox")).toBe(true);
expect([...container.querySelectorAll("a")].some((anchor) => anchor.getAttribute("href") === "/workspaces")).toBe(true);
flushSync(() => {
root.unmount();
});
});
describe("with Agent Chat on (PAP-670)", () => {
it("leads the Work group with a single Chat row and keeps agent names out of the primary nav", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableAgentChat: true });
const root = await renderSidebar();
const chatLink = [...container.querySelectorAll("a")].find((anchor) => anchor.getAttribute("href") === "/chats");
expect(chatLink?.textContent).toContain("Chat");
const workSection = chatLink?.closest("div")?.parentElement?.parentElement;
const workText = workSection?.textContent ?? "";
expect(workText.indexOf("Chat")).toBeLessThan(workText.indexOf("Tasks"));
flushSync(() => {
root.unmount();
});
});
it("needs nothing else: the Inbox row stays while Combined Inbox + Task List is off", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableAgentChat: true });
const root = await renderSidebar();
expect([...container.querySelectorAll("a")].some((anchor) => anchor.getAttribute("href") === "/chats")).toBe(true);
expect([...container.querySelectorAll("a")].some((anchor) => anchor.getAttribute("href") === "/inbox")).toBe(true);
flushSync(() => {
root.unmount();
});
});
it("does not render Workspaces anywhere in the nav, even with isolated workspaces on", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableAgentChat: true, enableIsolatedWorkspaces: true });
const root = await renderSidebar();
expect(container.textContent).not.toContain("Workspaces");
expect([...container.querySelectorAll("a")].some((anchor) => anchor.getAttribute("href") === "/workspaces")).toBe(false);
flushSync(() => {
root.unmount();
});
});
it("keeps Chat in the top group in the legacy shell, which has no Work-group Chat row", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableAgentChat: true, enableStreamlinedUi: false });
const root = await renderSidebar();
const chatLinks = [...container.querySelectorAll("a")].filter((anchor) => anchor.getAttribute("href") === "/chats");
expect(chatLinks).toHaveLength(1);
expect(chatLinks[0]?.closest("[class*=\"group/sidebar-section\"]")).toBeNull();
flushSync(() => {
root.unmount();
});
});
});
describe("with Combined Inbox + Task List on (PAP-670)", () => {
it("keeps the Inbox row in the legacy shell, which has no merged Tasks page", async () => {
mockInboxBadge.inbox = 7;
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableCombinedInboxTasks: true, enableStreamlinedUi: false });
const root = await renderSidebar();
const inboxLink = [...container.querySelectorAll("a")].find((anchor) => anchor.getAttribute("href") === "/inbox");
expect(inboxLink?.textContent).toContain("7");
const tasksLink = [...container.querySelectorAll("a")].find((anchor) => anchor.getAttribute("href") === "/issues");
expect(tasksLink?.textContent).not.toContain("7");
flushSync(() => {
root.unmount();
});
});
it("drops the Inbox row and moves its unread badge onto Tasks", async () => {
mockInboxBadge.inbox = 7;
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableCombinedInboxTasks: true });
const root = await renderSidebar();
expect([...container.querySelectorAll("a")].some((anchor) => anchor.getAttribute("href") === "/inbox")).toBe(false);
const tasksLink = [...container.querySelectorAll("a")].find((anchor) => anchor.getAttribute("href") === "/issues");
expect(tasksLink?.textContent).toContain("Tasks");
expect(tasksLink?.textContent).toContain("7");
flushSync(() => {
root.unmount();
});
});
it("adds no chat surfaces while Agent Chat is off", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableCombinedInboxTasks: true, enableAgentChat: false });
const root = await renderSidebar();
expect([...container.querySelectorAll("a")].some((anchor) => anchor.getAttribute("href") === "/chats")).toBe(false);
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("groups and orders the streamlined Work and Org navigation", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableIsolatedWorkspaces: false,
enableApps: true,
});
const root = await renderSidebar();
const sections = [...container.querySelectorAll("nav > div")];
const workSection = sections.find((section) => section.textContent?.startsWith("Work"));
const orgSection = sections.find((section) => section.textContent?.startsWith("Org"));
const labels = (section: Element | undefined) => [...(section?.querySelectorAll("a") ?? [])]
.map((anchor) => anchor.textContent?.trim());
expect(labels(workSection)).toEqual(["Tasks", "Projects", "Routines", "Artifacts"]);
expect(labels(orgSection)).toEqual(["Agents", "Skills", "Connectors", "Audit"]);
expect(sections.indexOf(workSection!)).toBeLessThan(sections.indexOf(orgSection!));
expect(
workSection?.querySelector('a[href="/issues"] svg')?.classList.contains("lucide-circle-check"),
).toBe(true);
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("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("Artifacts")).toBeLessThan(navText.indexOf("Goals"));
flushSync(() => {
root.unmount();
});
});
it("keeps Timeline out of the global navigation", 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"));
expect(workSection?.textContent).toContain("Projects");
expect(workSection?.textContent).not.toContain("Timeline");
expect(container.querySelector('a[href="/timeline"]')).toBeNull();
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("always shows Connectors in the Org section", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableApps: false });
const root = await renderSidebar();
const links = [...container.querySelectorAll("a")];
const link = links.find((anchor) => anchor.textContent === "Connectors");
expect(link?.getAttribute("href")).toBe("/apps");
expect(link?.querySelector("svg")?.classList).toContain("lucide-unplug");
expect(links.findIndex((anchor) => anchor.textContent === "Connectors")).toBeGreaterThan(
links.findIndex((anchor) => anchor.textContent === "Skills"),
);
expect(links.findIndex((anchor) => anchor.textContent === "Connectors")).toBeLessThan(
links.findIndex((anchor) => anchor.textContent === "Audit"),
);
flushSync(() => {
root.unmount();
});
});
it("ignores the retired Pipelines flag in old settings responses", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableIsolatedWorkspaces: false,
enablePipelines: true,
});
const root = await renderSidebar();
expect(container.textContent).not.toContain("Pipelines");
expect(container.querySelector('a[href="/pipelines"]')).toBeNull();
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("does not render a global navigation collapse affordance", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false });
const root = await renderSidebar();
expect(container.querySelector('button[aria-label="Collapse sidebar"]')).toBeNull();
expect(container.querySelector('button[aria-label="Expand sidebar"]')).toBeNull();
expect(container.querySelector('button[aria-label="Keep sidebar expanded"]')).toBeNull();
expect(container.textContent).toContain("Company menu");
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();
});
});
});