// @vitest-environment jsdom import type { ComponentProps } from "react"; import { flushSync } from "react-dom"; import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { Approval, HeartbeatRun, Issue } from "@paperclipai/shared"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { CompanyJoinRequest } from "../api/access"; import { clearLocalInboxArchive, getLocalInboxArchiveIssueIds, } from "../lib/inboxArchiveCache"; import { taskCollectionPreferencesStorageKey } from "../lib/task-collection-preferences"; const routerMock = vi.hoisted(() => ({ location: { pathname: "/", search: "", hash: "" }, navigate: vi.fn(), })); const externalObjectMocks = vi.hoisted(() => ({ summaries: new Map(), })); const apiMocks = vi.hoisted(() => ({ approvalsList: vi.fn(), joinRequestsList: vi.fn(), userDirectoryList: vi.fn(), authSession: vi.fn(), dashboardSummary: vi.fn(), executionWorkspaceSummaries: vi.fn(), issuesList: vi.fn(), issuesCount: vi.fn(), issueLabels: vi.fn(), archiveFromInbox: vi.fn(), unarchiveFromInbox: vi.fn(), agentsList: vi.fn(), heartbeatRunsList: vi.fn(), liveRunsForCompany: vi.fn(), experimentalSettings: vi.fn(), projectsList: vi.fn(), })); vi.mock("../api/approvals", () => ({ approvalsApi: { list: apiMocks.approvalsList }, })); vi.mock("../api/access", async () => { const actual = await vi.importActual("../api/access"); return { ...actual, accessApi: { listJoinRequests: apiMocks.joinRequestsList, listUserDirectory: apiMocks.userDirectoryList, }, }; }); vi.mock("../api/auth", () => ({ authApi: { getSession: apiMocks.authSession }, })); vi.mock("../api/dashboard", () => ({ dashboardApi: { summary: apiMocks.dashboardSummary }, })); vi.mock("../api/execution-workspaces", () => ({ executionWorkspacesApi: { listSummaries: apiMocks.executionWorkspaceSummaries }, })); vi.mock("../api/issues", () => ({ issuesApi: { list: apiMocks.issuesList, listCompact: apiMocks.issuesList, count: apiMocks.issuesCount, listLabels: apiMocks.issueLabels, markRead: vi.fn(), markUnread: vi.fn(), archiveFromInbox: apiMocks.archiveFromInbox, unarchiveFromInbox: apiMocks.unarchiveFromInbox, }, })); vi.mock("../api/agents", () => ({ agentsApi: { list: apiMocks.agentsList }, })); vi.mock("../api/heartbeats", () => ({ heartbeatsApi: { list: apiMocks.heartbeatRunsList, liveRunsForCompany: apiMocks.liveRunsForCompany, }, })); vi.mock("../api/instanceSettings", () => ({ instanceSettingsApi: { getExperimental: apiMocks.experimentalSettings }, })); vi.mock("../api/projects", () => ({ projectsApi: { list: apiMocks.projectsList }, })); vi.mock("../context/CompanyContext", () => ({ useCompany: () => ({ selectedCompanyId: "company-1" }), })); vi.mock("../context/BreadcrumbContext", () => ({ useBreadcrumbs: () => ({ setBreadcrumbs: vi.fn() }), })); vi.mock("../context/ToastContext", () => ({ useToastActions: () => ({ pushToast: vi.fn() }), })); vi.mock("../context/DialogContext", () => ({ useDialogActions: () => ({ openNewIssue: vi.fn() }), })); vi.mock("../context/SidebarContext", () => ({ useSidebar: () => ({ isMobile: false }), })); vi.mock("../hooks/useInboxBadge", () => ({ useDismissedInboxAlerts: () => ({ dismissed: new Set(), dismiss: vi.fn() }), useInboxDismissals: () => ({ dismissedAtByKey: new Map(), dismiss: vi.fn() }), useReadInboxItems: () => ({ readItems: new Set(), markRead: vi.fn(), markUnread: vi.fn(), }), })); vi.mock("../hooks/useIssueExternalObjects", () => ({ useIssueExternalObjectSummaries: () => ({ summaries: externalObjectMocks.summaries, isLoading: false, isReady: true, }), })); import { FailedRunInboxRow, Inbox, InboxGroupHeader, InboxIssueMetaLeading, InboxIssueTrailingColumns, formatJoinRequestInboxLabel, } from "./Inbox"; vi.mock("@/lib/router", () => ({ Link: ({ children, className, ...props }: ComponentProps<"a">) => ( {children} ), useLocation: () => routerMock.location, useNavigate: () => routerMock.navigate, })); // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; async function act(callback: () => void | Promise) { let result: void | Promise = undefined; flushSync(() => { result = callback(); }); await result; } // jsdom doesn't implement scrollIntoView; the inbox calls it from a passive effect. if (typeof Element !== "undefined" && !Element.prototype.scrollIntoView) { Element.prototype.scrollIntoView = () => {}; } function createIssue(overrides: Partial = {}): Issue { return { id: "issue-1", identifier: "PAP-904", companyId: "company-1", projectId: null, projectWorkspaceId: null, goalId: null, parentId: null, title: "Inbox item", description: null, status: "todo", priority: "medium", reviewPolicy: null, assigneeAgentId: null, assigneeUserId: null, responsibleUserId: null, createdByAgentId: null, createdByUserId: null, issueNumber: 904, requestDepth: 0, billingCode: null, assigneeAdapterOverrides: null, executionWorkspaceId: null, executionWorkspacePreference: null, executionWorkspaceSettings: null, checkoutRunId: null, executionRunId: null, executionAgentNameKey: null, executionLockedAt: null, startedAt: null, completedAt: null, cancelledAt: null, hiddenAt: null, createdAt: new Date("2026-03-11T00:00:00.000Z"), updatedAt: new Date("2026-03-11T00:00:00.000Z"), labels: [], labelIds: [], myLastTouchAt: null, lastExternalCommentAt: null, lastActivityAt: new Date("2026-03-11T00:00:00.000Z"), isUnreadForMe: false, ...overrides, workMode: overrides.workMode ?? "standard", }; } function createDeferred() { let resolve!: (value: T) => void; let reject!: (reason?: unknown) => void; const promise = new Promise((innerResolve, innerReject) => { resolve = innerResolve; reject = innerReject; }); return { promise, resolve, reject }; } function createJoinRequest( overrides: Partial = {}, ): CompanyJoinRequest { return { id: "join-1", inviteId: "invite-1", companyId: "company-1", requestType: "human", status: "pending_approval", requestIp: "127.0.0.1", requestingUserId: "user-1", requestEmailSnapshot: "joiner@example.com", agentName: null, adapterType: null, capabilities: null, agentDefaultsPayload: null, claimSecretExpiresAt: null, claimSecretConsumedAt: null, createdAgentId: null, approvedByUserId: null, approvedAt: null, rejectedByUserId: null, rejectedAt: null, createdAt: new Date("2026-03-11T00:00:00.000Z"), updatedAt: new Date("2026-03-11T00:00:00.000Z"), requesterUser: { id: "user-1", name: "Jordan Example", email: "joiner@example.com", image: null, }, approvedByUser: null, rejectedByUser: null, invite: null, ...overrides, }; } function createApproval(overrides: Partial = {}): Approval { return { id: "approval-1", companyId: "company-1", type: "hire_agent", requestedByAgentId: null, requestedByUserId: "local-board", status: "pending", payload: { name: "New teammate" }, decisionNote: null, decidedByUserId: null, decidedAt: null, createdAt: new Date("2026-03-11T00:00:00.000Z"), updatedAt: new Date("2026-03-11T00:00:00.000Z"), ...overrides, }; } function createFailedRun(overrides: Partial = {}): HeartbeatRun { return { id: "run-1", issueId: null, scopeKind: "company", companyId: "company-1", agentId: "agent-1", responsibleUserId: null, invocationSource: "assignment", triggerDetail: null, status: "failed", error: "boom", wakeupRequestId: null, exitCode: null, signal: null, usageJson: null, resultJson: null, sessionIdBefore: null, sessionIdAfter: null, logStore: null, logRef: null, logBytes: null, logSha256: null, logCompressed: false, stdoutExcerpt: null, stderrExcerpt: null, errorCode: null, externalRunId: null, processPid: null, processGroupId: null, processStartedAt: null, lastOutputAt: null, lastOutputSeq: 0, lastOutputStream: null, lastOutputBytes: null, retryOfRunId: null, processLossRetryCount: 0, livenessState: null, livenessReason: null, continuationAttempt: 0, lastUsefulActionAt: null, nextAction: null, contextSnapshot: null, startedAt: new Date("2026-03-11T00:00:00.000Z"), finishedAt: new Date("2026-03-11T00:01:00.000Z"), createdAt: new Date("2026-03-11T00:00:00.000Z"), updatedAt: new Date("2026-03-11T00:01:00.000Z"), ...overrides, }; } function resetInboxApiMocks() { for (const mock of Object.values(apiMocks)) mock.mockReset(); externalObjectMocks.summaries.clear(); routerMock.location.pathname = "/"; routerMock.location.search = ""; routerMock.location.hash = ""; routerMock.navigate.mockReset(); apiMocks.approvalsList.mockResolvedValue([]); apiMocks.joinRequestsList.mockResolvedValue([]); apiMocks.userDirectoryList.mockResolvedValue({ users: [] }); apiMocks.authSession.mockResolvedValue({ user: { id: "local-board" }, session: { userId: "local-board" }, }); apiMocks.dashboardSummary.mockResolvedValue({ agents: { error: 0 }, costs: { monthBudgetCents: 0, monthUtilizationPercent: 0 }, }); apiMocks.executionWorkspaceSummaries.mockResolvedValue([]); apiMocks.issuesList.mockResolvedValue([]); apiMocks.issuesCount.mockResolvedValue({ count: 0 }); apiMocks.issueLabels.mockResolvedValue([]); apiMocks.archiveFromInbox.mockResolvedValue({ id: "issue-1", archivedAt: new Date() }); apiMocks.unarchiveFromInbox.mockResolvedValue({ id: "issue-1", archivedAt: new Date() }); apiMocks.agentsList.mockResolvedValue([]); apiMocks.heartbeatRunsList.mockResolvedValue([]); apiMocks.liveRunsForCompany.mockResolvedValue([]); apiMocks.experimentalSettings.mockResolvedValue({ enableIsolatedWorkspaces: false, enableStreamlinedUi: true, }); apiMocks.projectsList.mockResolvedValue([]); } describe("Inbox toolbar", () => { let container: HTMLDivElement; beforeEach(() => { localStorage.clear(); resetInboxApiMocks(); container = document.createElement("div"); document.body.appendChild(container); }); afterEach(() => { for (const issueId of getLocalInboxArchiveIssueIds("company-1")) { clearLocalInboxArchive("company-1", issueId); } container.remove(); }); it.each([ { tab: "mine", userId: "user-1", visible: ["own-failure"], hidden: ["other-failure", "unowned-failure"] }, { tab: "mine", userId: "user-2", visible: ["other-failure"], hidden: ["own-failure", "unowned-failure"] }, { tab: "mine", userId: "local-board", visible: ["unowned-failure"], hidden: ["own-failure", "other-failure"] }, { tab: "mine", userId: null, visible: [], hidden: ["own-failure", "other-failure", "unowned-failure"] }, { tab: "all", userId: "user-1", visible: ["own-failure", "other-failure", "unowned-failure"], hidden: [] }, ].flatMap((scenario) => [true, false].map((streamlined) => ({ ...scenario, streamlined }))))( "scopes failed runs on $tab for $userId (streamlined=$streamlined)", async ({ tab, userId, visible, hidden, streamlined }) => { apiMocks.experimentalSettings.mockResolvedValue({ enableIsolatedWorkspaces: false, enableStreamlinedUi: streamlined }); routerMock.location.pathname = `/inbox/${tab}`; apiMocks.authSession.mockResolvedValue(userId ? { user: { id: userId }, session: { userId } } : null); apiMocks.heartbeatRunsList.mockResolvedValue([ createFailedRun({ id: "own-failure", agentId: "agent-1", responsibleUserId: "user-1" }), createFailedRun({ id: "other-failure", agentId: "agent-2", responsibleUserId: "user-2", status: "timed_out" }), createFailedRun({ id: "unowned-failure", agentId: "agent-3" }), ]); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity, gcTime: 0 } }, }); const root = createRoot(container); try { await act(async () => { root.render(); }); await vi.waitFor(() => { expect(apiMocks.heartbeatRunsList).toHaveBeenCalled(); expect(queryClient.isFetching()).toBe(0); for (const id of visible) expect(container.querySelector(`a[to$="/runs/${id}"]`)).not.toBeNull(); for (const id of hidden) expect(container.querySelector(`a[to$="/runs/${id}"]`)).toBeNull(); }); } finally { act(() => root.unmount()); queryClient.clear(); } }, ); it("restores the legacy toolbar and issue-row presentation when Streamlined UI is off", async () => { routerMock.location.pathname = "/inbox/mine"; apiMocks.experimentalSettings.mockResolvedValue({ enableIsolatedWorkspaces: false, enableStreamlinedUi: false, }); apiMocks.issuesList.mockResolvedValue([ createIssue({ id: "legacy-row", identifier: "PAP-1904", title: "Legacy inbox task" }), ]); localStorage.setItem( taskCollectionPreferencesStorageKey({ companyId: "company-1", collectionKey: "inbox" }), JSON.stringify({ version: 1, companyId: "company-1", collectionKey: "inbox", viewState: {}, columns: [], }), ); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } }, }); const root = createRoot(container); await act(async () => { root.render( , ); }); await vi.waitFor(() => expect(container.textContent).toContain("Legacy inbox task")); expect(container.querySelector('[data-slot="collection-toolbar"]')).toBeNull(); expect(container.querySelector('[role="toolbar"][aria-label="Inbox controls"]')).toBeNull(); expect(container.textContent).toContain("Mine"); expect(container.querySelector('[data-slot="task-row"]')).toBeNull(); const issueLink = container.querySelector('[data-inbox-issue-link]'); // Production reads the legacy Inbox column key and must ignore the // Streamlined collection envelope above when the experiment is disabled. expect(issueLink?.parentElement?.textContent).toContain("PAP-1904"); act(() => root.unmount()); }); it("does not render external-object summaries in inbox rows", async () => { routerMock.location.pathname = "/inbox/mine"; const issue = createIssue({ title: "Inbox row without external object column" }); apiMocks.issuesList.mockResolvedValue([issue]); externalObjectMocks.summaries.set(issue.id, { total: 1, highestSeverity: "failed", byStatusCategory: { failed: 1 }, objects: [], }); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } }, }); const root = createRoot(container); await act(async () => { root.render( , ); }); await vi.waitFor(() => { expect(container.textContent).toContain(issue.title); }); expect(container.querySelector('[aria-label^="External objects:"]')).toBeNull(); act(() => root.unmount()); }); it("keeps archive hover actions and swipe targets on every unread non-task Mine row", async () => { routerMock.location.pathname = "/inbox/mine"; localStorage.setItem("paperclip:inbox:group-by", "none"); apiMocks.approvalsList.mockResolvedValue([createApproval()]); apiMocks.heartbeatRunsList.mockResolvedValue([createFailedRun()]); apiMocks.joinRequestsList.mockResolvedValue([createJoinRequest()]); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } }, }); const root = createRoot(container); await act(async () => { root.render( , ); }); await vi.waitFor(() => { expect(container.textContent).toContain("Hire Agent: New teammate"); expect(container.textContent).toContain("Failed run"); expect(container.textContent).toContain("Jordan Example"); }); const rowFor = (text: string) => [...container.querySelectorAll("[data-inbox-item]")] .find((row) => row.textContent?.includes(text)); for (const text of ["Hire Agent: New teammate", "Failed run", "Jordan Example"]) { const row = rowFor(text); expect(row, `missing inbox row for ${text}`).toBeDefined(); expect(row?.querySelector('button[aria-label="Mark as read"]')).not.toBeNull(); const archiveButton = row?.querySelector('button[aria-label="Archive"]'); expect(archiveButton).not.toBeNull(); expect(archiveButton?.className).toContain("opacity-0"); expect(archiveButton?.className).toContain("group-hover:opacity-100"); expect(row?.querySelector("[data-inbox-row-surface]")).not.toBeNull(); } const approvalRow = rowFor("Hire Agent: New teammate"); const approvalActions = [...(approvalRow?.querySelectorAll("button") ?? [])] .filter((button) => button.textContent === "Approve" || button.textContent === "Reject"); expect(approvalActions.length).toBeGreaterThanOrEqual(2); approvalActions.forEach((button) => { expect(button.className).toContain("h-8"); expect(button.className).toContain("min-w-(--sz-64px)"); expect(button.className).toContain("justify-center"); }); act(() => root.unmount()); }); it("restores folded and unfolded sub-tasks across remounts", async () => { routerMock.location.pathname = "/inbox/mine"; const storageKey = "paperclip:inbox:collapsed-parents:company-1"; localStorage.removeItem(storageKey); const parent = createIssue({ id: "parent-issue", identifier: "PAP-1001", title: "Parent inbox task", }); const child = createIssue({ id: "child-issue", identifier: "PAP-1002", parentId: parent.id, title: "Nested inbox task", }); const grandchild = createIssue({ id: "grandchild-issue", identifier: "PAP-1003", parentId: child.id, title: "Deeply nested inbox task", }); apiMocks.issuesList.mockResolvedValue([parent, child, grandchild]); const mountInbox = async () => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } }, }); const root = createRoot(container); await act(async () => { root.render( , ); }); await vi.waitFor(() => { expect(container.textContent).toContain(parent.title); }); return root; }; const parentToggle = () => { const parentRow = Array.from(container.querySelectorAll("[data-inbox-item]")) .find((row) => row.textContent?.includes(parent.title)); return parentRow?.querySelector('button[data-slot="icon-button"]') ?? null; }; let root = await mountInbox(); try { expect(container.textContent).toContain(child.title); expect(container.textContent).toContain(grandchild.title); const taskRowFor = (title: string) => Array.from(container.querySelectorAll('[data-slot="task-row"]')) .find((row) => row.textContent?.includes(title)); const childTaskRow = taskRowFor(child.title); const grandchildTaskRow = taskRowFor(grandchild.title); expect(childTaskRow?.querySelectorAll('[data-slot="task-row-tree-guide"]')).toHaveLength(1); expect(childTaskRow?.querySelector('button[aria-label="Collapse sub-tasks"]')).not.toBeNull(); expect(grandchildTaskRow?.querySelectorAll('[data-slot="task-row-tree-guide"]')).toHaveLength(2); expect(grandchildTaskRow?.querySelector('[data-slot="task-row-disclosure-spacer"]')).not.toBeNull(); await act(async () => { parentToggle()?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await vi.waitFor(() => { expect(container.textContent).not.toContain(child.title); expect(container.textContent).not.toContain(grandchild.title); }); expect(JSON.parse(localStorage.getItem(storageKey) ?? "[]")).toEqual([parent.id]); act(() => root.unmount()); root = await mountInbox(); expect(container.textContent).not.toContain(child.title); await act(async () => { parentToggle()?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await vi.waitFor(() => { expect(container.textContent).toContain(child.title); expect(container.textContent).toContain(grandchild.title); }); expect(JSON.parse(localStorage.getItem(storageKey) ?? "[]")).toEqual([]); act(() => root.unmount()); root = await mountInbox(); expect(container.textContent).toContain(child.title); expect(container.textContent).toContain(grandchild.title); } finally { localStorage.removeItem(storageKey); act(() => root.unmount()); } }); it("shows blocked toolbar controls on the Blocked tab", async () => { routerMock.location.pathname = "/inbox/blocked"; const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } }, }); const root = createRoot(container); await act(async () => { root.render( , ); }); expect(container.querySelector('input[placeholder="Search inbox…"]')).not.toBeNull(); expect(container.querySelector('[data-testid="inbox-blocked-tab-badge"]')).toBeNull(); expect(container.querySelector('button[title="Filter"]')).not.toBeNull(); expect(container.querySelector('button[title="Group"]')).not.toBeNull(); expect(container.querySelector('button[title="Columns"]')).not.toBeNull(); expect(container.querySelector('button[title="Sort"]')).not.toBeNull(); expect(container.querySelector('button[title="Enable parent-child nesting"]')).toBeNull(); expect(container.textContent).not.toContain("Mark all as read"); act(() => { root.unmount(); }); }); it("keeps Mine, Recent, Unread, Blocked, and All in the shared toolbar geometry", async () => { for (const tab of ["mine", "recent", "unread", "blocked", "all"] as const) { routerMock.location.pathname = `/inbox/${tab}`; const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } }, }); const root = createRoot(container); await act(async () => { root.render( , ); }); const toolbar = container.querySelector('[data-slot="collection-toolbar"]'); expect(toolbar?.getAttribute("aria-label")).toBe("Inbox controls"); expect(toolbar?.querySelector('[data-slot="collection-toolbar-context"]')).not.toBeNull(); expect(toolbar?.querySelector('[data-slot="collection-toolbar-search"] input[placeholder="Search inbox…"]')).not.toBeNull(); expect(toolbar?.querySelector('[data-slot="collection-toolbar-controls"]')).not.toBeNull(); expect(container.querySelectorAll('input[placeholder="Search inbox…"]')).toHaveLength(1); act(() => root.unmount()); container.replaceChildren(); } }); it("explains that live-run filtering is different from active task statuses", async () => { routerMock.location.pathname = "/inbox/mine"; localStorage.setItem("paperclip:inbox:filters:company-1", JSON.stringify({ allCategoryFilter: "everything", allApprovalFilter: "all", issueFilters: { liveOnly: true }, })); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } }, }); const root = createRoot(container); await act(async () => { root.render( , ); }); expect(container.querySelector('[data-testid="inbox-filter-scope-feedback"]')?.textContent) .toBe("Live runs only — tasks currently connected to an agent run."); const migrated = JSON.parse( localStorage.getItem("paperclip:task-collection:v1:company-1:inbox") ?? "null", ) as { companyId?: string; collectionKey?: string } | null; expect(migrated).toMatchObject({ companyId: "company-1", collectionKey: "inbox" }); act(() => root.unmount()); }); it("groups ungrouped attention items by Today, Yesterday, and Earlier", async () => { routerMock.location.pathname = "/inbox/mine"; const now = new Date(); const localNoon = (daysAgo: number) => new Date(now.getFullYear(), now.getMonth(), now.getDate() - daysAgo, 12, 0, 0); apiMocks.issuesList.mockResolvedValue([ createIssue({ id: "today", title: "Today task", lastActivityAt: localNoon(0) }), createIssue({ id: "yesterday", title: "Yesterday task", lastActivityAt: localNoon(1) }), createIssue({ id: "earlier", title: "Earlier task", lastActivityAt: localNoon(3) }), ]); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } }, }); const root = createRoot(container); await act(async () => { root.render( , ); }); await vi.waitFor(() => expect(container.textContent).toContain("Earlier task")); const separators = [...container.querySelectorAll('[data-testid="inbox-date-group"]')]; expect(separators.map((node) => node.textContent?.trim())) .toEqual(["Today", "Yesterday", "Earlier"]); expect(separators.every((separator) => ( separator.querySelectorAll("[data-date-group-rule]").length === 2 ))).toBe(true); expect(separators.every((separator) => ( separator.querySelector("[data-date-group-label]")?.classList.contains("text-muted-foreground/70") ))).toBe(true); act(() => root.unmount()); }); it("honors the saved Columns option for hiding date group separators", async () => { routerMock.location.pathname = "/inbox/mine"; localStorage.setItem( taskCollectionPreferencesStorageKey({ companyId: "company-1", collectionKey: "inbox", }), JSON.stringify({ version: 1, companyId: "company-1", collectionKey: "inbox", viewState: { showDateGroupSeparators: false }, columns: ["status", "id", "updated"], }), ); const now = new Date(); const localNoon = (daysAgo: number) => new Date(now.getFullYear(), now.getMonth(), now.getDate() - daysAgo, 12, 0, 0); apiMocks.issuesList.mockResolvedValue([ createIssue({ id: "today", title: "Today task", lastActivityAt: localNoon(0) }), createIssue({ id: "earlier", title: "Earlier task", lastActivityAt: localNoon(3) }), ]); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } }, }); const root = createRoot(container); await act(async () => { root.render( , ); }); await vi.waitFor(() => expect(container.textContent).toContain("Earlier task")); expect(container.querySelector('[data-testid="inbox-date-group"]')).toBeNull(); act(() => root.unmount()); }); it("shows the resolved isolated workspace name in canonical task metadata", async () => { routerMock.location.pathname = "/inbox/mine"; localStorage.setItem("paperclip:inbox:issue-columns", JSON.stringify(["status", "id", "workspace", "updated"])); apiMocks.experimentalSettings.mockResolvedValue({ enableIsolatedWorkspaces: true }); apiMocks.executionWorkspaceSummaries.mockResolvedValue([{ id: "execution-workspace-1", name: "Workspace Aurora", mode: "isolated_workspace", projectWorkspaceId: "project-workspace-1", }]); apiMocks.projectsList.mockResolvedValue([{ id: "project-1", name: "Launch", color: null, workspaces: [{ id: "project-workspace-1", name: "Main" }], executionWorkspacePolicy: { defaultProjectWorkspaceId: "project-workspace-1" }, primaryWorkspace: null, }]); apiMocks.issuesList.mockResolvedValue([createIssue({ projectId: "project-1", executionWorkspaceId: "execution-workspace-1", title: "Workspace-aware task", })]); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } }, }); const root = createRoot(container); await act(async () => { root.render( , ); }); // Workspace metadata resolves independently of the task list. await vi.waitFor(() => { expect(container.querySelector('[data-slot="task-row"]')?.textContent).toContain("Workspace Aurora"); }); const taskRow = container.querySelector('[data-slot="task-row"]'); const identifier = taskRow?.querySelector('[data-slot="task-row-identifier"]'); const timestamp = taskRow?.querySelector('[data-slot="task-row-timestamp"]'); expect(taskRow?.textContent).toContain("Workspace Aurora"); expect(identifier?.textContent).toBe("PAP-904"); expect(timestamp).not.toBeNull(); if (!identifier || !timestamp) throw new Error("Expected canonical identifier and timestamp columns"); expect(identifier.compareDocumentPosition(timestamp) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0); act(() => root.unmount()); }); it("hides workspace grouping when isolated workspaces are disabled", async () => { routerMock.location.pathname = "/inbox/mine"; apiMocks.experimentalSettings.mockResolvedValue({ enableIsolatedWorkspaces: false }); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } }, }); const root = createRoot(container); await act(async () => { root.render( , ); }); const groupButton = container.querySelector('button[title="Group"]'); expect(groupButton).not.toBeNull(); await act(async () => { groupButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); const groupOptions = Array.from(document.body.querySelectorAll("button")).map((button) => button.textContent); expect(groupOptions).not.toContain("Workspace"); act(() => { root.unmount(); }); }); it("requests live descendant summaries for issue rows", async () => { routerMock.location.pathname = "/inbox/mine"; const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } }, }); const root = createRoot(container); await act(async () => { root.render( , ); }); await vi.waitFor(() => { expect(apiMocks.issuesList).toHaveBeenCalledTimes(3); }); expect(apiMocks.issuesList.mock.calls.map((call) => call[1]?.includeLiveDescendantSummary)).toEqual([ true, true, true, ]); expect(apiMocks.issuesList.mock.calls.map((call) => call[1]?.limit)).toEqual([ 500, 500, 500, ]); act(() => { root.unmount(); }); }); it("paints row hover via CSS only, without moving React selection state", async () => { routerMock.location.pathname = "/inbox/mine"; const issueA = createIssue({ id: "issue-a", identifier: "PAP-1001", title: "First inbox row" }); const issueB = createIssue({ id: "issue-b", identifier: "PAP-1002", title: "Second inbox row" }); apiMocks.issuesList.mockResolvedValue([issueA, issueB]); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } }, }); const root = createRoot(container); await act(async () => { root.render( , ); }); await vi.waitFor(() => { expect(container.querySelectorAll("[data-inbox-item]").length).toBeGreaterThanOrEqual(2); }); const rows = container.querySelectorAll("[data-inbox-item]"); // The hover wash lives on the IssueRow root band (the overlay link's // parent), not the overlay link itself. const bandOf = (row: Element): HTMLElement | null => row.querySelector("a[data-inbox-issue-link]")?.parentElement ?? null; // Nothing selected before hover — both rows show the hover-accent class. expect(bandOf(rows[0]!)?.className).toContain("hover:bg-accent/50"); expect(bandOf(rows[1]!)?.className).toContain("hover:bg-accent/50"); // Hovering paints via CSS `:hover` only — it must NOT flip a row into the // state-selected band (which would swap to hover:bg-transparent). Coupling // hover to React state was the per-hover re-render storm behind the lag; // scrubbing the list must not touch selection state. (Keyboard nav that // continues from the hovered row is exercised in live/e2e verification.) await act(async () => { rows[1]!.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); rows[1]!.dispatchEvent(new MouseEvent("mouseenter", { bubbles: false })); }); expect(bandOf(rows[0]!)?.className).toContain("hover:bg-accent/50"); expect(bandOf(rows[1]!)?.className).toContain("hover:bg-accent/50"); expect(bandOf(rows[1]!)?.className).not.toContain("hover:bg-transparent"); act(() => { root.unmount(); }); }); it("does not indent unread rows: the mark-read dot overlays the shared task-row gutter", async () => { routerMock.location.pathname = "/inbox/mine"; // Two sibling leaf rows, one unread and one read, so their leading columns // are directly comparable. const unread = createIssue({ id: "issue-unread", identifier: "PAP-2001", title: "Unread inbox row", isUnreadForMe: true, }); const read = createIssue({ id: "issue-read", identifier: "PAP-2002", title: "Read inbox row", isUnreadForMe: false, }); apiMocks.issuesList.mockResolvedValue([unread, read]); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } }, }); const root = createRoot(container); await act(async () => { root.render( , ); }); await vi.waitFor(() => { expect(container.textContent).toContain("Unread inbox row"); expect(container.textContent).toContain("Read inbox row"); }); const rows = Array.from(container.querySelectorAll("[data-inbox-item]")); const rowFor = (text: string) => rows.find((row) => row.textContent?.includes(text)); const markReadButton = (row: Element) => row.querySelector('button[aria-label="Mark as read"]'); // The canonical spacer reserves the disclosure column on every leaf row. const hasLeadingSpacer = (row: Element) => !!row.querySelector('[data-slot="task-row-disclosure-spacer"]'); // The overlay anchor is present on read AND unread Inbox rows without // consuming a layout column. const dotSlot = (row: Element) => row.querySelector('[data-testid="issue-row-unread-slot"]'); const unreadRow = rowFor("Unread inbox row")!; const readRow = rowFor("Read inbox row")!; // Both rows share the canonical task-row geometry. The dot is absolutely // positioned in the row gutter, so Inbox does not gain a column that Tasks // lacks and unread state cannot shift status/title alignment. const unreadSlot = dotSlot(unreadRow); const readSlot = dotSlot(readRow); expect(unreadSlot).not.toBeNull(); expect(readSlot).not.toBeNull(); expect(unreadSlot?.className).toContain("absolute"); // Only the unread row carries the dot button; the read slot is empty. expect(markReadButton(unreadSlot!)).not.toBeNull(); expect(readSlot?.querySelector('button[aria-label="Mark as read"]')).toBeNull(); expect(hasLeadingSpacer(unreadRow)).toBe(true); // Read rows keep the same spacer, so both rows line up. expect(hasLeadingSpacer(readRow)).toBe(true); act(() => { root.unmount(); }); }); it("keeps hover→j/k selection in sync after the list reshapes (PAP-9679)", async () => { routerMock.location.pathname = "/inbox/mine"; const issueA = createIssue({ id: "issue-a", identifier: "PAP-2001", title: "Sync row A" }); const issueB = createIssue({ id: "issue-b", identifier: "PAP-2002", title: "Sync row B" }); const issueC = createIssue({ id: "issue-c", identifier: "PAP-2003", title: "Sync row C" }); apiMocks.issuesList.mockResolvedValue([issueA, issueB, issueC]); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } }, }); const root = createRoot(container); // Canonical task rows put the selected wash on the root band (the overlay // link's parent); find the row with the standalone selected utility. const bandOf = (row: Element): HTMLElement | null => row.querySelector("a[data-inbox-issue-link]")?.parentElement ?? null; const selectedRowIndex = () => [...container.querySelectorAll("[data-inbox-item]")].findIndex((row) => bandOf(row)?.className.split(/\s+/).includes("bg-accent/50"), ); try { await act(async () => { root.render( , ); }); await vi.waitFor(() => { expect(container.querySelectorAll("[data-inbox-item]").length).toBeGreaterThanOrEqual(3); }); // Pointer physically moves, then hovers the middle row (index 1). await act(async () => { window.dispatchEvent(new MouseEvent("mousemove", { bubbles: true })); const rows = container.querySelectorAll("[data-inbox-item]"); rows[1]!.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); rows[1]!.dispatchEvent(new MouseEvent("mouseenter", { bubbles: false })); }); // A poll reshapes the list (row B's title changes → new nav array) before // the keypress. This is what used to null the hovered index and strand // j/k back at the top. apiMocks.issuesList.mockResolvedValue([issueA, { ...issueB, title: "Sync row B (updated)" }, issueC]); await act(async () => { await queryClient.invalidateQueries(); }); await vi.waitFor(() => { expect(container.textContent).toContain("Sync row B (updated)"); }); // j must continue from the hovered row (index 1) → index 2, not jump to // the top of the list. await act(async () => { document.body.dispatchEvent(new KeyboardEvent("keydown", { key: "j", bubbles: true })); }); expect(selectedRowIndex()).toBe(2); } finally { act(() => { root.unmount(); }); } }); it("holds the inbox order across a reordering poll, then re-sorts at an attention boundary (PAP-16015)", async () => { routerMock.location.pathname = "/inbox/mine"; const base = new Date("2026-03-11T00:00:00.000Z").getTime(); const issueA = createIssue({ id: "issue-a", identifier: "PAP-3001", title: "Pin row A", lastActivityAt: new Date(base + 3000), }); const issueB = createIssue({ id: "issue-b", identifier: "PAP-3002", title: "Pin row B", lastActivityAt: new Date(base + 2000), }); const issueC = createIssue({ id: "issue-c", identifier: "PAP-3003", title: "Pin row C", lastActivityAt: new Date(base + 1000), }); apiMocks.issuesList.mockResolvedValue([issueA, issueB, issueC]); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } }, }); const root = createRoot(container); // Collapse each displayed row to its A/B/C identity so we can assert order. const orderOf = () => [...container.querySelectorAll("[data-inbox-item]")].flatMap((row) => { const text = row.textContent ?? ""; if (text.includes("Pin row A")) return ["A"]; if (text.includes("Pin row B")) return ["B"]; if (text.includes("Pin row C")) return ["C"]; return []; }); const visibilityDescriptor = Object.getOwnPropertyDescriptor(document, "visibilityState"); const setVisibility = (state: DocumentVisibilityState) => { Object.defineProperty(document, "visibilityState", { configurable: true, get: () => state }); }; let nowValue = base + 1_000_000; const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => nowValue); try { await act(async () => { root.render( , ); }); await vi.waitFor(() => { expect(container.querySelectorAll("[data-inbox-item]").length).toBeGreaterThanOrEqual(3); }); expect(orderOf()).toEqual(["A", "B", "C"]); // A poll makes row C the most-recently-active: the fresh sort is now [C, A, B]. apiMocks.issuesList.mockResolvedValue([ { ...issueA }, { ...issueB }, { ...issueC, lastActivityAt: new Date(base + 9000) }, ]); await act(async () => { await queryClient.invalidateQueries(); }); await vi.waitFor(() => { expect(container.textContent).toContain("Pin row C"); }); // No attention boundary has fired, so the displayed order is held, not reshuffled. expect(orderOf()).toEqual(["A", "B", "C"]); // The tab is hidden long enough to lose attention, then regains focus: that // visibility boundary is a commit point, so the inbox adopts the fresh order. await act(async () => { setVisibility("hidden"); document.dispatchEvent(new Event("visibilitychange")); }); nowValue += 31_000; await act(async () => { setVisibility("visible"); document.dispatchEvent(new Event("visibilitychange")); }); await vi.waitFor(() => { expect(orderOf()).toEqual(["C", "A", "B"]); }); } finally { nowSpy.mockRestore(); if (visibilityDescriptor) { Object.defineProperty(document, "visibilityState", visibilityDescriptor); } else { setVisibility("visible"); } act(() => { root.unmount(); }); } }); it("keeps other issue archive controls enabled while one archive is pending", async () => { routerMock.location.pathname = "/inbox/mine"; const issueA = createIssue({ id: "issue-a", identifier: "PAP-1001", title: "First inbox row" }); const issueB = createIssue({ id: "issue-b", identifier: "PAP-1002", title: "Second inbox row" }); apiMocks.issuesList.mockResolvedValue([issueA, issueB]); const archiveA = createDeferred<{ id: string; archivedAt: Date }>(); apiMocks.archiveFromInbox.mockImplementation((id: string) => id === "issue-a" ? archiveA.promise : Promise.resolve({ id, archivedAt: new Date() }), ); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } }, }); const root = createRoot(container); await act(async () => { root.render( , ); }); await vi.waitFor(() => { expect(container.textContent).toContain("First inbox row"); expect(container.textContent).toContain("Second inbox row"); }); const initialArchiveButtons = Array.from( container.querySelectorAll('button[aria-label="Archive"]'), ); expect(initialArchiveButtons.length).toBeGreaterThanOrEqual(2); await act(async () => { initialArchiveButtons[0]!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); }); await vi.waitFor(() => { expect(apiMocks.archiveFromInbox).toHaveBeenCalledWith("issue-a"); expect(container.textContent).not.toContain("First inbox row"); expect(container.textContent).toContain("Second inbox row"); }); const remainingArchiveButton = container.querySelector( 'button[aria-label="Archive"]', ); expect(remainingArchiveButton).not.toBeNull(); expect(remainingArchiveButton?.disabled).toBe(false); await act(async () => { remainingArchiveButton!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); }); await vi.waitFor(() => { expect(apiMocks.archiveFromInbox).toHaveBeenCalledWith("issue-b"); }); await act(async () => { archiveA.resolve({ id: "issue-a", archivedAt: new Date() }); }); act(() => { root.unmount(); }); }); it("keeps a successful archive hidden when stale query data arrives", async () => { routerMock.location.pathname = "/inbox/mine"; const archivedIssue = createIssue({ id: "issue-a", identifier: "PAP-1001", title: "Archived inbox row", }); apiMocks.issuesList.mockResolvedValue([archivedIssue]); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } }, }); const root = createRoot(container); await act(async () => { root.render( , ); }); await vi.waitFor(() => { expect(container.textContent).toContain("Archived inbox row"); }); const archiveButton = container.querySelector('button[aria-label="Archive"]'); expect(archiveButton).not.toBeNull(); await act(async () => { archiveButton!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); }); await vi.waitFor(() => { expect(apiMocks.archiveFromInbox).toHaveBeenCalledWith("issue-a"); expect(container.textContent).not.toContain("Archived inbox row"); }); await act(async () => { queryClient.setQueriesData( { queryKey: ["issues", "company-1", "mine-by-me"] }, [archivedIssue], ); }); expect(container.textContent).not.toContain("Archived inbox row"); act(() => { root.unmount(); }); }); it("restores a locally hidden archive when undo is pressed", async () => { routerMock.location.pathname = "/inbox/mine"; const archivedIssue = createIssue({ id: "issue-a", identifier: "PAP-1001", title: "Undoable inbox row", }); apiMocks.issuesList.mockResolvedValue([archivedIssue]); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } }, }); const root = createRoot(container); try { await act(async () => { root.render( , ); }); await vi.waitFor(() => { expect(container.textContent).toContain("Undoable inbox row"); }); const archiveButton = container.querySelector('button[aria-label="Archive"]'); expect(archiveButton).not.toBeNull(); await act(async () => { archiveButton!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); }); await vi.waitFor(() => { expect(container.textContent).not.toContain("Undoable inbox row"); expect(queryClient.isMutating()).toBe(0); }); await act(async () => { document.body.dispatchEvent(new KeyboardEvent("keydown", { key: "u", bubbles: true })); }); await vi.waitFor(() => { expect(apiMocks.unarchiveFromInbox).toHaveBeenCalledWith("issue-a"); expect(container.textContent).toContain("Undoable inbox row"); }); } finally { act(() => root.unmount()); } }); }); describe("FailedRunInboxRow", () => { let container: HTMLDivElement; beforeEach(() => { container = document.createElement("div"); document.body.appendChild(container); }); afterEach(() => { container.remove(); }); it("suppresses accent hover styling when selected", () => { const root = createRoot(container); const run = createFailedRun(); act(() => { root.render( {}} onRetry={() => {}} isRetrying={false} selected />, ); }); const link = container.querySelector("a"); expect(link).not.toBeNull(); expect(link?.className).toContain("hover:bg-transparent"); expect(link?.className).not.toContain("hover:bg-accent/50"); act(() => { root.unmount(); }); }); }); describe("InboxIssueMetaLeading", () => { let container: HTMLDivElement; beforeEach(() => { container = document.createElement("div"); document.body.appendChild(container); }); afterEach(() => { container.remove(); }); it("keeps status and live accents visible", () => { const root = createRoot(container); act(() => { root.render(); }); // The status glyph is an coloured from its --status-task-icon-* var. const statusIcon = Array.from(container.querySelectorAll("svg")).find((svg) => (svg.getAttribute("style") ?? "").includes("--status-task-icon"), ); const liveBadge = container.querySelector('span[class*="px-1.5"][class*="bg-blue-500/10"]'); const liveBadgeLabel = Array.from(container.querySelectorAll("span")).find( // The pill chassis is a Badge (itself a span with textContent "Live"); // the label is the inner span without the rounded-full chassis class. (node) => node.textContent === "Live" && node.className.includes("text-") && !node.className.includes("rounded-full"), ); const liveDot = container.querySelector('span[class*="bg-blue-500"]'); const pulseRing = container.querySelector('span[class*="animate-pulse"]'); expect(statusIcon).not.toBeUndefined(); // Status accent stays visible — not neutralized to muted. expect(statusIcon?.getAttribute("class") ?? "").not.toContain("!text-muted-foreground"); expect(liveBadge).not.toBeNull(); expect(liveBadge?.className).toContain("bg-blue-500/10"); expect(liveBadgeLabel).not.toBeNull(); expect(liveBadgeLabel?.className).toContain("text-blue-600"); expect(liveDot).not.toBeNull(); expect(pulseRing).not.toBeNull(); act(() => { root.unmount(); }); }); }); describe("InboxIssueTrailingColumns", () => { let container: HTMLDivElement; beforeEach(() => { container = document.createElement("div"); document.body.appendChild(container); }); afterEach(() => { container.remove(); }); it("renders an empty tags cell when an issue has no labels", () => { const root = createRoot(container); act(() => { root.render( , ); }); expect(container.textContent).toBe(""); act(() => { root.unmount(); }); }); it("leaves the workspace cell blank when no explicit workspace label should be shown", () => { const root = createRoot(container); act(() => { root.render( , ); }); expect(container.textContent).toBe(""); act(() => { root.unmount(); }); }); }); describe("formatJoinRequestInboxLabel", () => { it("shows the human requester's name and email when available", () => { expect(formatJoinRequestInboxLabel(createJoinRequest())).toBe( "Jordan Example (joiner@example.com)", ); }); it("falls back to the email snapshot when the requester profile is missing", () => { expect( formatJoinRequestInboxLabel( createJoinRequest({ requesterUser: null, requestEmailSnapshot: "snapshot@example.com", requestingUserId: null, }), ), ).toBe("snapshot@example.com"); }); }); describe("InboxGroupHeader", () => { let container: HTMLDivElement; beforeEach(() => { container = document.createElement("div"); document.body.appendChild(container); }); afterEach(() => { container.remove(); }); it("shows a left caret and expanded state for collapsible mobile headers", () => { const root = createRoot(container); act(() => { root.render(); }); const button = container.querySelector("button"); expect(button).not.toBeNull(); expect(button?.getAttribute("aria-expanded")).toBe("true"); expect(button?.textContent).toContain("Primary workspace (default)"); const caret = container.querySelector("svg"); expect(caret?.className.baseVal).toContain("rotate-90"); act(() => { root.unmount(); }); }); it("keeps the caret collapsed when the mobile group is hidden", () => { const root = createRoot(container); act(() => { root.render(); }); const button = container.querySelector("button"); expect(button?.getAttribute("aria-expanded")).toBe("false"); const caret = container.querySelector("svg"); expect(caret?.className.baseVal).not.toContain("rotate-90"); act(() => { root.unmount(); }); }); });