// @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(), 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: vi.fn() }) })); 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 }) => {children}, NavLink: ({ to, children, className, state: _state, end: _end, ...props }: { to: string; children: ReactNode; className?: string | ((state: { isActive: boolean }) => string); state?: unknown; end?: boolean; }) => {children}, })); 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 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 pinnedPages = () => container.querySelector('[aria-label="Pinned pages"]'); async function click(label: string) { const button = document.querySelector(`button[aria-label="${label}"]`); expect(button).not.toBeNull(); await act(async () => button!.click()); await flush(); } it("pins from More without navigating, restores after remount, and unpins from the sidebar", async () => { await renderSidebar(); expect(pinnedPages()).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(pinnedPages()?.querySelector('a[href="/projects"]')?.textContent).toBe("Projects"); await act(async () => root.unmount()); root = createRoot(container); await renderSidebar(); expect(pinnedPages()?.querySelector('a[href="/projects"]')).not.toBeNull(); await click("Unpin Projects"); expect(pinnedPages()).toBeNull(); }); it("keeps pins personal and only renders known destinations", async () => { localStorage.setItem("paperclip.coordinator.pinned-nav.v1:reviewer", JSON.stringify(["/projects", "/unknown", "https://example.com"])); await renderSidebar(); expect(pinnedPages()?.querySelectorAll("a")).toHaveLength(1); fixture.userId = "other-user"; await renderSidebar(); expect(pinnedPages()).toBeNull(); fixture.userId = "reviewer"; await renderSidebar(); expect(pinnedPages()?.textContent).toContain("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(pinnedPages()?.querySelector('a[href="/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()); 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(); }); });