Files
PaperClipAI/ui/src/components/CoordinatorSidebar.test.tsx
T
Dotta 89703c3ff7 fix(ui): place pinned sidebar pages above More (#18915)
## Thinking Path

> - Paperclip helps people manage AI agents and their work.
> - The Coordinator sidebar gives people access to workspace pages.
> - People can pin pages from the More menu.
> - The sidebar puts these links below More in a separate Pinned
section.
> - This change puts pinned links above More in the main navigation.
> - People can reach their saved pages without an extra section heading.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

The placement of pinned pages in the Coordinator sidebar.

**Subsystem affected**

ui/ — React board UI.

**Current behavior**

Pinned pages appear below More with a Pinned heading and extra space.

**Proposed behavior**

Pinned pages appear after Needs you and before More. They use the same
row spacing as the main links.

**Reason and benefit**

Saved pages belong with the main navigation. This placement keeps them
visible above the menu that contains the other pages.

**Breaking changes**

The Pinned section and its heading are removed. Saved pin data, link
order, routes, and pin controls use the existing code.

## What Changed

- Move pinned links into the primary navigation above More.
- Remove the separate section, heading, and extra space.
- Correct the feature guide to state that pinned pages appear above
More.
- Update sidebar tests to check placement, saved order, account changes,
restore, and pin removal.

## Verification

- `pnpm exec vitest run ui/src/components/CoordinatorSidebar.test.tsx`:
15 tests pass.
- `pnpm check:token-gates`: pass.
- `node scripts/check-maps.mjs`: pass after the guide correction.
- `pnpm --filter @paperclipai/ui typecheck`: pass.
- Browser checks pass in Chromium against the production Coordinator
sidebar in Storybook with fixture data. Screenshots cover saved pins and
pinning from More. No page errors occurred. This does not test a live
API.
- The full workspace typecheck and build stop because this host has no
Rust `cargo` command.
- The first UI build was killed and the browser crashed while checks ran
together. The host recorded OOM kills at its 8 GB limit. The browser
check then passed after the full-suite process was stopped. The UI
production build passed after the Storybook server was stopped.
- `NODE_OPTIONS=--max-old-space-size=4096 pnpm --filter @paperclipai/ui
build`: pass.
- `pnpm test:run`: stopped during the first chat integration shard after
memory exhaustion affected other checks. It has no full-suite result.
The CI run passed the test lanes on the latest head.

- [CI
run](https://github.com/paperclipai/paperclip/actions/runs/41768881396)
passed on `a836fc1320c363e0fbb5591312c6726026b6f151`. The required `ci /
verify` and `ci / e2e` checks pass. Build, typecheck, Rust checks, test
lanes, and all eight browser shards pass.
- Greptile: 5/5 on this head. The documentation finding is fixed and its
thread is resolved.
- The additional Paperclip review is paused and queued. It is not a
required branch check.

## Risks

Low risk. This change moves existing links. It does not change storage
or API behavior. The tests check expanded and collapsed rendering, the
empty state, stored order, unknown routes, user changes, and changes
from another browser tab.

## Model Used

OpenAI GPT-6 through the Codex agent. The session supports reasoning,
tool use, code edits, and command execution. The runtime does not expose
a more specific model ID or 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
2026-11-04 13:10:32 -06:00

217 lines
12 KiB
TypeScript

// @vitest-environment jsdom
import { act, type ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AttentionFeed, AttentionItem } from "@paperclipai/shared";
import { CoordinatorSidebar } from "./CoordinatorSidebar";
import { TooltipProvider } from "./ui/tooltip";
import { coordinatorAttentionQueryKey, coordinatorAttentionSummaryQueryKey } from "@/lib/coordinator-attention";
import { queryKeys } from "@/lib/queryKeys";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const fixture = vi.hoisted(() => ({ attention: vi.fn(), summary: vi.fn(), openNewIssue: vi.fn(), pathname: "/COO/issues", collapsed: false, userId: "reviewer", chats: [] as Array<{ isUnreadForMe: boolean }> }));
vi.mock("@/hooks/useAgentChatNavigation", () => ({ useAgentChatNavigation: () => ({
companyId: "company", userId: fixture.userId, agents: { data: [] }, chats: { data: fixture.chats }, session: { isSuccess: true },
}) }));
vi.mock("@/hooks/useRecentTasks", () => ({ useRecentTasks: () => ({ entries: [] }) }));
vi.mock("@/context/DialogContext", () => ({ useDialogActions: () => ({ openNewIssue: fixture.openNewIssue }) }));
vi.mock("@/context/SidebarContext", () => ({ useSidebar: () => ({ collapsed: fixture.collapsed, peeking: false, isMobile: false, setSidebarOpen: vi.fn() }) }));
vi.mock("@/api/attention", () => ({ attentionApi: { list: fixture.attention, summary: fixture.summary } }));
vi.mock("@/api/projects", () => ({ projectsApi: { list: async () => [] } }));
vi.mock("./SidebarCompanyMenu", () => ({ SidebarCompanyMenu: () => null }));
vi.mock("@/lib/router", () => ({
useLocation: () => ({ pathname: fixture.pathname, search: "" }),
Link: ({ to, children, ...props }: { to: string; children: ReactNode }) => <a href={to} {...props}>{children}</a>,
NavLink: ({ to, children, className, state: _state, end: _end, ...props }: {
to: string; children: ReactNode; className?: string | ((state: { isActive: boolean }) => string); state?: unknown; end?: boolean;
}) => <a href={to} className={typeof className === "function" ? className({ isActive: false }) : className} {...props}>{children}</a>,
}));
const pending = (id: string) => ({ id, sourceKind: "issue_thread_interaction", subject: { id, kind: "interaction", status: "pending" },
createdAt: "2026-10-20T12:00:00Z", activityAt: "2026-10-20T12:00:00Z" }) as AttentionItem;
function feed(items: AttentionItem[]): AttentionFeed {
return { companyId: "company", generatedAt: "2026-10-27T12:00:00Z", totalCount: items.length, deskBadgeCount: 0,
nextCursor: null, countsBySourceKind: {} as AttentionFeed["countsBySourceKind"], items };
}
function summary(items: AttentionItem[]) {
const { items: _items, nextCursor: _cursor, ...counts } = feed(items);
return counts;
}
let root: Root, container: HTMLDivElement, client: QueryClient;
beforeEach(() => {
vi.clearAllMocks(); localStorage.clear(); fixture.collapsed = false; fixture.userId = "reviewer"; fixture.pathname = "/COO/issues";
fixture.chats = [];
fixture.summary.mockResolvedValue(summary([pending("question"), pending("review")]));
container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container);
client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
});
describe("Coordinator task creation", () => {
it.each([false, true])("opens global search from the organization header when collapsed is %s", async collapsed => {
fixture.collapsed = collapsed;
await renderSidebar();
const listener = vi.fn();
document.addEventListener("keydown", listener);
try {
const button = container.querySelector<HTMLButtonElement>('.design-sidebar-brand button[aria-label="Global search"]');
expect(button).not.toBeNull();
await act(async () => button!.click());
expect(listener).toHaveBeenCalledWith(expect.objectContaining({ key: "k", metaKey: true }));
} finally { document.removeEventListener("keydown", listener); }
});
it.each([false, true])("opens the task form from New task above Chat when collapsed is %s", async (collapsed) => {
fixture.collapsed = collapsed;
await renderSidebar();
const button = container.querySelector<HTMLButtonElement>('button[aria-label="New task"]');
const chat = container.querySelector('a[href="/chats"]');
expect(button).not.toBeNull();
expect(chat).not.toBeNull();
expect(button!.compareDocumentPosition(chat!) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0);
expect(fixture.openNewIssue).not.toHaveBeenCalled();
await act(async () => button!.click());
expect(fixture.openNewIssue).toHaveBeenCalledOnce();
});
});
describe("Coordinator pinned pages", () => {
it("announces unread chat totals in expanded and collapsed navigation", async () => {
fixture.chats = [{ isUnreadForMe: true }, { isUnreadForMe: false }, { isUnreadForMe: true }];
await renderSidebar();
expect(container.querySelector('a[href="/chats"] [aria-label="2 unread chats"]')).not.toBeNull();
fixture.collapsed = true;
await renderSidebar();
expect(container.querySelector('a[href="/chats"]')?.getAttribute("aria-label")).toBe("Chat, 2 unread chats");
});
const navigation = () => container.querySelector('nav[aria-label="Workspace navigation"]')!;
const pinnedPage = (to: string) => navigation().querySelector(`a[href="${to}"]`);
async function click(label: string) {
const button = document.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`);
expect(button).not.toBeNull();
await act(async () => button!.click());
await flush();
}
it.each([false, true])("keeps pins above More without a separate section, restores and unpins when collapsed is %s", async collapsed => {
fixture.collapsed = collapsed;
await renderSidebar();
expect(pinnedPage("/projects")).toBeNull();
await click("More workspace pages");
expect(document.activeElement).toBe(document.querySelector('[data-slot="popover-content"]'));
await click("Pin Projects");
expect(document.querySelector('[role="dialog"]')).not.toBeNull();
expect(pinnedPage("/projects")?.textContent).toBe("Projects");
expect(navigation().querySelector('[aria-label="Pinned pages"]')).toBeNull();
expect(navigation().querySelector('[data-slot="sidebar-group-label"]')).toBeNull();
const more = navigation().querySelector('button[aria-label="More workspace pages"]')!;
expect(pinnedPage("/projects")!.compareDocumentPosition(more) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0);
await act(async () => root.unmount());
root = createRoot(container);
await renderSidebar();
expect(pinnedPage("/projects")).not.toBeNull();
if (collapsed) await click("More workspace pages");
await click("Unpin Projects");
expect(pinnedPage("/projects")).toBeNull();
});
it("keeps pins personal and only renders known destinations", async () => {
localStorage.setItem("paperclip.coordinator.pinned-nav.v1:reviewer", JSON.stringify(["/routines", "/projects", "/unknown", "https://example.com"]));
await renderSidebar();
expect(Array.from(navigation().querySelectorAll("a")).map(link => link.getAttribute("href")))
.toEqual(["/chats", "/issues", "/decisions", "/routines", "/projects"]);
fixture.userId = "other-user";
await renderSidebar();
expect(pinnedPage("/projects")).toBeNull();
fixture.userId = "reviewer";
await renderSidebar();
expect(pinnedPage("/projects")?.textContent).toBe("Projects");
});
it("reflects pins changed in another browser tab", async () => {
await renderSidebar();
const key = "paperclip.coordinator.pinned-nav.v1:reviewer";
await act(async () => {
localStorage.setItem(key, JSON.stringify(["/agents/all"]));
window.dispatchEvent(new StorageEvent("storage", { key }));
});
expect(pinnedPage("/agents/all")?.textContent).toBe("Agents");
});
});
afterEach(async () => { await act(async () => root.unmount()); client.clear(); container.remove(); });
async function flush() { await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); }
async function renderSidebar() {
await act(async () => root.render(<QueryClientProvider client={client}><TooltipProvider><CoordinatorSidebar /></TooltipProvider></QueryClientProvider>));
await flush();
}
const decisionLink = () => container.querySelector('a[href="/decisions"]')!;
describe("Coordinator Needs You badge", () => {
it("counts every canonical outstanding decision even when none arrived today", async () => {
// Personal page filters and completed receipts are separate cache entries.
client.setQueryData([...coordinatorAttentionQueryKey("company"), { search: "no match" }], feed([]));
client.setQueryData([...queryKeys.attention("company"), "history", "recent"], { items: [{ id: "answered-question" }] });
await renderSidebar();
expect(fixture.attention).not.toHaveBeenCalled();
expect(client.getQueryData(coordinatorAttentionQueryKey("company"))).toBeUndefined();
expect(fixture.summary).toHaveBeenCalledWith("company");
expect(decisionLink().querySelector('[data-slot="badge"]')?.textContent).toBe("2");
});
it("updates after canonical attention refreshes and removes the badge once every decision is resolved", async () => {
await renderSidebar();
fixture.summary.mockResolvedValue(summary([pending("review")]));
await act(async () => { await client.invalidateQueries({ queryKey: queryKeys.attention("company") }); });
await flush();
expect(decisionLink().querySelector('[data-slot="badge"]')?.textContent).toBe("1");
fixture.summary.mockResolvedValue(summary([]));
client.setQueryData([...queryKeys.attention("company"), "history", "recent"], { items: [{ id: "answered-question" }, { id: "accepted-review" }] });
await act(async () => { await client.invalidateQueries({ queryKey: queryKeys.attention("company") }); });
await flush();
expect(decisionLink().querySelector('[data-slot="badge"]')).toBeNull();
expect(decisionLink().textContent).toBe("Needs you");
});
it("announces the full outstanding count when the sidebar is collapsed", async () => {
fixture.collapsed = true;
await renderSidebar();
expect(decisionLink().getAttribute("aria-label")).toBe("Needs you, 2 decisions");
});
});
describe("Coordinator collection reads", () => {
it("does not replace a resolved count with an older summary that arrives later", async () => {
const key = coordinatorAttentionSummaryQueryKey("company");
client.setQueryDefaults(key, { staleTime: Infinity });
client.setQueryData(key, { ...summary([pending("old")]), generatedAt: "2026-10-27T12:00:00Z" }, { updatedAt: 20 });
client.setQueryData(coordinatorAttentionQueryKey("company"), { ...feed([]), generatedAt: "2026-10-27T12:01:00Z" }, { updatedAt: 10 });
await renderSidebar();
expect(decisionLink().querySelector('[data-slot="badge"]')).toBeNull();
});
it("keeps a newer resolved count when returning to a cached navigation summary", async () => {
const key = coordinatorAttentionSummaryQueryKey("company");
client.setQueryDefaults(key, { staleTime: Infinity });
client.setQueryData(key, summary([pending("old")]), { updatedAt: 1 });
client.setQueryData(coordinatorAttentionQueryKey("company"), feed([]), { updatedAt: 2 });
await renderSidebar();
expect(decisionLink().querySelector('[data-slot="badge"]')).toBeNull();
expect(fixture.summary).not.toHaveBeenCalled();
expect(fixture.attention).not.toHaveBeenCalled();
});
it("reuses the page-owned complete feed on Needs You without a second summary read", async () => {
fixture.pathname = "/COO/decisions";
client.setQueryData(coordinatorAttentionQueryKey("company"), feed([pending("review")]));
await renderSidebar();
expect(fixture.summary).not.toHaveBeenCalled();
expect(fixture.attention).not.toHaveBeenCalled();
expect(decisionLink().querySelector('[data-slot="badge"]')?.textContent).toBe("1");
await act(async () => client.setQueryData(coordinatorAttentionQueryKey("company"), feed([])));
await flush();
expect(decisionLink().querySelector('[data-slot="badge"]')).toBeNull();
});
});