mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 13:24:55 +01:00
Add a visible New task button above Chat. Open the existing task form in expanded and collapsed sidebar states. Update the Storybook fixture and navigation map. Add tests for action order and dialog dispatch. Co-Authored-By: Paperclip <noreply@paperclip.ing>
197 lines
11 KiB
TypeScript
197 lines
11 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 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 pinnedPages = () => container.querySelector('[aria-label="Pinned pages"]');
|
|
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("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(<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();
|
|
});
|
|
});
|