// @vitest-environment jsdom import { CoordinatorPresentation } from "../components/coordinator/CoordinatorPresentation"; import { queryKeys } from "../lib/queryKeys"; 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 companyState = vi.hoisted(() => ({ selectedCompanyId: "company-1" as string | null })); const externalObjectMocks = vi.hoisted(() => ({ summaries: new Map(), error: null as Error | null, retry: vi.fn(), })); 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(), retryTask: 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, retryTask: apiMocks.retryTask, 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: () => companyState, })); 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: !externalObjectMocks.error, error: externalObjectMocks.error, refetch: externalObjectMocks.retry, }), })); import { FailedRunInboxRow, Inbox, InboxGroupHeader, InboxIssueMetaLeading, InboxIssueTrailingColumns, formatJoinRequestInboxLabel, } from "./Inbox"; vi.mock("@/lib/router", () => ({ Link: ({ children, className, to, ...props }: ComponentProps<"a"> & { to?: string }) => ( {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() { companyState.selectedCompanyId = "company-1"; for (const mock of Object.values(apiMocks)) mock.mockReset(); externalObjectMocks.summaries.clear(); externalObjectMocks.error = null; externalObjectMocks.retry.mockReset(); 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.retryTask.mockReset().mockResolvedValue({ accepted: true, requestId: "retry-intent", presentation: null }); 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[href$="/runs/${id}"]`)).not.toBeNull(); for (const id of hidden) expect(container.querySelector(`a[href$="/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("waits for the task index before revealing faster failed-run rows", async () => { routerMock.location.pathname = "/inbox/mine"; let release: (rows: Issue[]) => void = () => undefined; apiMocks.issuesList.mockImplementation((_company, filters) => filters.projection === "index" ? new Promise((resolve) => { release = resolve; }) : Promise.resolve([])); apiMocks.heartbeatRunsList.mockResolvedValue([createFailedRun()]); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); const root = createRoot(container); await act(async () => root.render()); await vi.waitFor(() => expect(apiMocks.heartbeatRunsList).toHaveBeenCalled()); expect(container.querySelector('[aria-label="Loading tasks"]')).not.toBeNull(); expect(container.querySelector('[data-inbox-item]')).toBeNull(); await act(async () => release([createIssue({ title: "Delayed task index row" })])); await vi.waitFor(() => expect(container.textContent).toContain("Delayed task index row")); expect(container.querySelector('[aria-label="Loading tasks"]')).toBeNull(); act(() => root.unmount()); }); it.each([true, false])("can select a company after a cold inbox mount (Streamlined UI=%s)", async streamlined => { companyState.selectedCompanyId = null; const settings = { enableStreamlinedUi: streamlined, enableIsolatedWorkspaces: false }; apiMocks.experimentalSettings.mockResolvedValue(settings); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); queryClient.setQueryData(queryKeys.instance.experimentalSettings, settings); const root = createRoot(container); const render = () => root.render(); await act(render); await vi.waitFor(() => expect(container.textContent).toContain(streamlined ? "Select an organization" : "Select a company")); companyState.selectedCompanyId = "company-1"; apiMocks.issuesList.mockResolvedValue([createIssue({ title: "Cold company task" })]); await act(render); await vi.waitFor(() => expect(container.textContent).toContain("Cold company task")); act(() => root.unmount()); }); it("loads only missing parent metadata before revealing Mine", async () => { routerMock.location.pathname = "/inbox/mine"; localStorage.setItem(taskCollectionPreferencesStorageKey({ companyId: "company-1", collectionKey: "inbox" }), JSON.stringify({ version: 1, companyId: "company-1", collectionKey: "inbox", columns: ["parent"], viewState: {}, })); const loadedParent = createIssue({ id: "loaded-parent", title: "Loaded parent", listProjection: "index" }); const missingParent = createIssue({ id: "missing-parent", title: "Missing parent", identifier: "PAP-2000", listProjection: "index" }); const rows = [loadedParent, createIssue({ id: "child-a", parentId: loadedParent.id, title: "First child", listProjection: "index" }), createIssue({ id: "child-b", parentId: missingParent.id, title: "Second child", listProjection: "index" }), ]; let releaseParent!: (rows: Issue[]) => void; const pendingParent = new Promise(resolve => { releaseParent = resolve; }); apiMocks.issuesList.mockImplementation((_company, filters) => { if (!filters.ids) return Promise.resolve(rows); if (filters.ids.includes(missingParent.id)) return pendingParent; return Promise.resolve(rows.filter(row => filters.ids.includes(row.id))); }); const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); const root = createRoot(container); try { await act(() => root.render()); await vi.waitFor(() => expect(apiMocks.issuesList.mock.calls.some(([, filters]) => filters.ids?.includes(missingParent.id))).toBe(true)); expect(container.querySelectorAll("[data-inbox-item]")).toHaveLength(0); expect(apiMocks.issuesList).toHaveBeenCalledWith("company-1", expect.objectContaining({ ids: [missingParent.id], projection: "index", }), expect.anything()); await act(() => releaseParent([missingParent])); await vi.waitFor(() => expect(container.textContent).toContain("Second child")); // Rich visible-row reads still happen after reveal, but never include a // context-only parent or precede the structural metadata response. expect(apiMocks.issuesList.mock.calls.filter(([, filters]) => filters.ids?.includes(missingParent.id))).toHaveLength(1); expect(container.querySelectorAll("[data-inbox-item]")).toHaveLength(3); expect(container.querySelector(`[title="${missingParent.title}"]`)?.textContent).toBe(missingParent.identifier); } finally { await act(() => root.unmount()); client.clear(); } }); it("discards old parent context when a refreshed index supplies the parent", async () => { routerMock.location.pathname = "/inbox/mine"; localStorage.setItem("paperclip:inbox:nesting", "false"); localStorage.setItem(taskCollectionPreferencesStorageKey({ companyId: "company-1", collectionKey: "inbox" }), JSON.stringify({ version: 1, companyId: "company-1", collectionKey: "inbox", columns: ["parent"], viewState: {}, })); const parent = createIssue({ id: "parent", title: "Old parent caption", identifier: "PAP-2000", listProjection: "index" }); const child = createIssue({ id: "child", parentId: parent.id, title: "Readable child", listProjection: "index" }); let source = [child]; apiMocks.issuesList.mockImplementation((_company, filters) => !filters.ids ? Promise.resolve(source) : Promise.resolve(filters.ids.includes(parent.id) ? [parent] : [child])); const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity, gcTime: 0 } } }); const root = createRoot(container); try { await act(() => root.render()); await vi.waitFor(() => expect(container.querySelector(`[title="${parent.title}"]`)?.textContent).toBe(parent.identifier)); source = [{ ...parent, title: "Fresh indexed caption", identifier: "PAP-2001" }, child]; await act(() => { client.setQueriesData({ predicate: query => query.queryKey.includes("live-descendant-summary") }, source); }); await vi.waitFor(() => expect(container.querySelector('[title="Fresh indexed caption"]')?.textContent).toBe("PAP-2001")); expect(container.querySelector(`[title="${parent.title}"]`)).toBeNull(); expect(apiMocks.issuesList.mock.calls.filter(([, filters]) => filters.projection === "index" && filters.ids?.includes(parent.id))).toHaveLength(1); } finally { await act(() => root.unmount()); client.clear(); } }); it.each([true, false])("waits for failure context even when its parent is already indexed=%s", async (indexed) => { routerMock.location.pathname = "/inbox/mine"; const parent = createIssue({ id: "parent", title: "Parent metadata", listProjection: "index" }); const child = createIssue({ id: "child", parentId: parent.id, title: "Readable child", listProjection: "index" }); const failure = createFailedRun({ issueId: parent.id, responsibleUserId: "local-board" }); apiMocks.heartbeatRunsList.mockResolvedValue([failure]); let releaseFailure!: (rows: Issue[]) => void; const pendingFailure = new Promise(resolve => { releaseFailure = resolve; }); apiMocks.issuesList.mockImplementation((_company, filters) => !filters.ids ? Promise.resolve(indexed ? [parent, child] : [child]) : filters.ids.includes(parent.id) ? pendingFailure : Promise.resolve([child])); const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); const root = createRoot(container); try { await act(() => root.render()); await vi.waitFor(() => expect(apiMocks.issuesList).toHaveBeenCalledWith("company-1", expect.objectContaining({ ids: [parent.id], projection: "rows", }), expect.anything())); expect(container.querySelectorAll("[data-inbox-item]")).toHaveLength(0); expect(apiMocks.issuesList.mock.calls.filter(([, filters]) => filters.ids && filters.projection === "index")).toHaveLength(0); await act(() => releaseFailure([{ ...parent, interruption: { intentId: "saved-input", responsibility: "system", state: "resolved", nextRetryAt: null, canRetry: false, userAction: null, sourceRunIds: [failure.id], observedRunId: failure.id, } }])); await vi.waitFor(() => expect(container.textContent).toContain(child.title)); expect(container.querySelectorAll("[data-inbox-item]")).toHaveLength(indexed ? 2 : 1); } finally { await act(() => root.unmount()); client.clear(); } }); it("upgrades cached parent metadata when the same task becomes a failure context", async () => { routerMock.location.pathname = "/inbox/mine"; const parent = createIssue({ id: "parent", title: "Parent metadata", listProjection: "index" }); const child = createIssue({ id: "child", parentId: parent.id, title: "Readable child", listProjection: "index" }); const failure = createFailedRun({ issueId: parent.id, responsibleUserId: "local-board" }); const resolved = { ...parent, listProjection: "rows" as const, interruption: { intentId: "saved-input", responsibility: "system" as const, state: "resolved" as const, nextRetryAt: null, canRetry: false, userAction: null, sourceRunIds: [failure.id], observedRunId: failure.id, } }; let releaseFailure!: (rows: Issue[]) => void; const pendingFailure = new Promise(resolve => { releaseFailure = resolve; }); apiMocks.issuesList.mockImplementation((_company, filters) => { if (!filters.ids) return Promise.resolve([child]); if (filters.ids.includes(parent.id)) return filters.projection === "rows" ? pendingFailure : Promise.resolve([parent]); return Promise.resolve([child]); }); const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity, gcTime: 0 } } }); const root = createRoot(container); try { await act(() => root.render()); await vi.waitFor(() => expect(container.textContent).toContain(child.title)); // The union of context IDs is unchanged when this parent becomes a failed // task. It must still fetch interruption state instead of reusing index. await act(() => { client.setQueriesData({ queryKey: queryKeys.heartbeats("company-1") }, [failure]); }); await vi.waitFor(() => expect(apiMocks.issuesList).toHaveBeenCalledWith("company-1", expect.objectContaining({ ids: [parent.id], projection: "rows", }), expect.anything())); expect(container.querySelectorAll("[data-inbox-item]")).toHaveLength(1); await act(() => releaseFailure([resolved])); await vi.waitFor(() => expect(client.isFetching()).toBe(0)); expect(container.textContent).toContain(child.title); expect(container.querySelectorAll("[data-inbox-item]")).toHaveLength(1); } finally { await act(() => root.unmount()); client.clear(); } }); it("reveals an empty view after leaving a Mine view with parent context", async () => { routerMock.location.pathname = "/inbox/mine"; const parent = createIssue({ id: "parent", title: "Parent metadata", listProjection: "index" }); const child = createIssue({ id: "child", parentId: parent.id, title: "Mine child", listProjection: "index" }); apiMocks.issuesList.mockImplementation((_company, filters) => Promise.resolve(filters.ids ? [parent] : filters.inboxArchivedByUserId ? [child] : [])); const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); const root = createRoot(container); try { await act(() => root.render()); await vi.waitFor(() => expect(container.textContent).toContain(child.title)); routerMock.location.pathname = "/inbox/recent"; await act(() => root.render()); await vi.waitFor(() => expect(container.textContent).toContain("No recent inbox items.")); expect(container.querySelector('[aria-label="Loading tasks"]')).toBeNull(); } finally { await act(() => root.unmount()); client.clear(); } }); it("waits for rich failure state when heartbeat data arrives after parent captions", async () => { routerMock.location.pathname = "/inbox/mine"; const parent = createIssue({ id: "parent", title: "Parent metadata", listProjection: "index" }); const child = createIssue({ id: "child", parentId: parent.id, title: "Mine child", listProjection: "index" }); const failure = createFailedRun({ issueId: parent.id, responsibleUserId: "local-board" }); let releaseRuns!: (rows: HeartbeatRun[]) => void; apiMocks.heartbeatRunsList.mockImplementation(() => new Promise(resolve => { releaseRuns = resolve; })); let releaseFailure!: (rows: Issue[]) => void; const pendingFailure = new Promise(resolve => { releaseFailure = resolve; }); apiMocks.issuesList.mockImplementation((_company, filters) => !filters.ids ? Promise.resolve([child]) : filters.projection === "index" ? Promise.resolve([parent]) : filters.ids.includes(parent.id) ? pendingFailure : Promise.resolve([child])); const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); const root = createRoot(container); try { await act(() => root.render()); await vi.waitFor(() => expect(client.isFetching()).toBe(1)); expect(container.querySelectorAll("[data-inbox-item]")).toHaveLength(0); await act(() => releaseRuns([failure])); await vi.waitFor(() => expect(apiMocks.issuesList).toHaveBeenCalledWith("company-1", expect.objectContaining({ ids: [parent.id], projection: "rows", }), expect.anything())); expect(container.querySelectorAll("[data-inbox-item]")).toHaveLength(0); await act(() => releaseFailure([{ ...parent, interruption: { intentId: "saved-input", responsibility: "system", state: "resolved", nextRetryAt: null, canRetry: false, userAction: null, sourceRunIds: [failure.id], observedRunId: failure.id, } }])); await vi.waitFor(() => expect(container.textContent).toContain(child.title)); expect(container.querySelectorAll("[data-inbox-item]")).toHaveLength(1); } finally { await act(() => root.unmount()); client.clear(); } }); it("keeps known missing-task failures visible while new failure context is pending", async () => { routerMock.location.pathname = "/inbox/mine"; const child = createIssue({ title: "Readable task", listProjection: "index" }); const first = createFailedRun({ id: "failure-a", issueId: "missing-a", agentId: "agent-a", responsibleUserId: "local-board" }); const second = createFailedRun({ id: "failure-b", issueId: "missing-b", agentId: "agent-b", responsibleUserId: "local-board" }); apiMocks.heartbeatRunsList.mockResolvedValue([first]); let releaseFailures!: (rows: Issue[]) => void; const pendingFailures = new Promise(resolve => { releaseFailures = resolve; }); apiMocks.issuesList.mockImplementation((_company, filters) => !filters.ids ? Promise.resolve([child]) : filters.ids.includes(second.issueId) ? pendingFailures : Promise.resolve([])); const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); const root = createRoot(container); try { await act(() => root.render()); await vi.waitFor(() => expect(container.querySelector('a[href="/issues/missing-a"]')).not.toBeNull()); const node = container.querySelector('a[href="/issues/missing-a"]'); await act(() => { client.setQueriesData({ queryKey: queryKeys.heartbeats("company-1") }, [first, second]); }); await vi.waitFor(() => expect(apiMocks.issuesList).toHaveBeenCalledWith("company-1", expect.objectContaining({ ids: [first.issueId, second.issueId], projection: "rows", }), expect.anything())); expect(container.querySelector('a[href="/issues/missing-a"]')).toBe(node); expect(container.querySelector('a[href="/issues/missing-b"]')).toBeNull(); expect(container.textContent).toContain(child.title); await act(() => releaseFailures([])); await vi.waitFor(() => expect(container.querySelector('a[href="/issues/missing-b"]')).not.toBeNull()); expect(container.querySelectorAll("[data-inbox-item]")).toHaveLength(3); } finally { await act(() => root.unmount()); client.clear(); } }); it("keeps more than 25 description-only matches in Mine with archive actions", async () => { routerMock.location.pathname = "/inbox/mine"; const rows = Array.from({ length: 30 }, (_, index) => createIssue({ id: `source-${index}`, title: `Source task ${index}`, identifier: `PAP-${index}`, listProjection: "index" })); apiMocks.issuesList.mockImplementation((_company, filters) => Promise.resolve(filters.q && !filters.ids ? [] : rows)); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); const root = createRoot(container); await act(() => root.render()); await vi.waitFor(() => expect(container.querySelectorAll('[data-inbox-item]')).toHaveLength(30)); await act(() => { const input = container.querySelector('input[type="search"]')!; Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, "only-in-description"); input.dispatchEvent(new Event("input", { bubbles: true })); }); await vi.waitFor(() => expect(apiMocks.issuesList).toHaveBeenCalledWith("company-1", expect.objectContaining({ q: "only-in-description", ids: expect.arrayContaining(rows.map((row) => row.id)), projection: "index", limit: 100 }), expect.anything())); await vi.waitFor(() => expect(container.querySelectorAll('[data-inbox-item]')).toHaveLength(30)); expect(container.querySelectorAll('button[aria-label="Archive"]')).toHaveLength(30); expect(container.textContent).not.toContain("Other matching tasks"); act(() => root.unmount()); }); it("keeps rows readable and offers retry when optional details fail", async () => { routerMock.location.pathname = "/inbox/mine"; let failDetails = true; apiMocks.issuesList.mockImplementation((_company, filters) => filters.projection === "rows" && failDetails ? Promise.reject(new Error("Details unavailable")) : Promise.resolve([createIssue({ title: "Readable task" })])); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); const root = createRoot(container); await act(() => root.render()); await vi.waitFor(() => expect(document.body.textContent).toContain("Some task details couldn’t load.")); expect(container.textContent).toContain("Readable task"); failDetails = false; await act(() => Array.from(document.body.querySelectorAll('button')).find((button) => button.textContent === "Retry details")!.click()); await vi.waitFor(() => expect(document.body.textContent).not.toContain("Some task details couldn’t load.")); expect(container.textContent).toContain("Readable task"); act(() => root.unmount()); }); it("refreshes diagnostics when a visible index row changes without changing its ID", async () => { routerMock.location.pathname = "/inbox/mine"; const row = createIssue({ title: "Live task", listProjection: "index" }); apiMocks.issuesList.mockResolvedValue([row]); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); const root = createRoot(container); await act(() => root.render()); const detailsReads = () => apiMocks.issuesList.mock.calls.filter(([, filters]) => filters.projection === "rows").length; await vi.waitFor(() => expect(detailsReads()).toBe(1)); const updated = { ...row, updatedAt: new Date(new Date(row.updatedAt).getTime() + 1000) }; apiMocks.issuesList.mockResolvedValue([updated]); await act(() => { queryClient.setQueriesData({ predicate: (query) => query.queryKey.includes("index") }, [updated]); }); await vi.waitFor(() => expect(detailsReads()).toBe(2)); expect(container.textContent).toContain("Live task"); act(() => root.unmount()); }); it.each(["approvalsList", "joinRequestsList", "heartbeatRunsList"] as const)( "keeps available tasks visible when %s fails and during retry", async (source) => { routerMock.location.pathname = "/inbox/mine"; apiMocks.issuesList.mockResolvedValue([createIssue({ title: "Available task" })]); apiMocks[source].mockRejectedValue(new Error("Optional source unavailable")); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); const root = createRoot(container); await act(() => root.render()); await vi.waitFor(() => expect(container.textContent).toContain("Available task")); expect(document.body.textContent).toContain("Some inbox items couldn’t load."); let resolveRetry: (rows: []) => void = () => undefined; apiMocks[source].mockImplementation(() => new Promise((resolve) => { resolveRetry = resolve; })); await act(() => Array.from(document.body.querySelectorAll('button')).find((button) => button.textContent === "Retry missing items")!.click()); await vi.waitFor(() => expect(apiMocks[source]).toHaveBeenCalledTimes(2)); expect(container.textContent).toContain("Available task"); expect(container.querySelector('[aria-label="Loading tasks"]')).toBeNull(); await act(() => resolveRetry([])); await vi.waitFor(() => expect(document.body.textContent).not.toContain("Some inbox items couldn’t load.")); act(() => root.unmount()); }, ); it("shows an error and retry instead of an endless skeleton when a required external status filter fails", async () => { routerMock.location.pathname = "/inbox/mine"; localStorage.setItem(taskCollectionPreferencesStorageKey({ companyId: "company-1", collectionKey: "inbox" }), JSON.stringify({ version: 1, companyId: "company-1", collectionKey: "inbox", columns: [], viewState: { issueFilters: { externalObjectStatuses: ["failed"] } }, })); apiMocks.experimentalSettings.mockResolvedValue({ enableStreamlinedUi: true, enableExternalObjects: true }); apiMocks.issuesList.mockResolvedValue([createIssue()]); externalObjectMocks.error = new Error("External statuses unavailable"); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); const root = createRoot(container); await act(() => root.render()); await vi.waitFor(() => expect(container.textContent).toContain("External statuses unavailable")); expect(container.querySelector('[aria-label="Loading tasks"]')).toBeNull(); await act(() => Array.from(container.querySelectorAll('button')).find((button) => button.textContent === "Retry")!.click()); expect(externalObjectMocks.retry).toHaveBeenCalledOnce(); 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(1)); expect(apiMocks.issuesList).toHaveBeenCalledWith("company-1", expect.objectContaining({ touchedByUserId: "me", inboxArchivedByUserId: "me", projection: "index", includeLiveDescendantSummary: true, limit: 100, sortField: "updated", sortDir: "desc", })); 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("uses task presentation instead of infrastructure errors and keeps Retry in the task", () => { const root = createRoot(container); const issue = createIssue({ interruption: { intentId: "saved-input", responsibility: "system", state: "failed", nextRetryAt: null, canRetry: true, userAction: null, sourceRunIds: ["run-1"], observedRunId: "run-1" } }); act(() => root.render( {}} onRetry={() => {}} isRetrying={false} />)); expect(container.textContent).toContain("Couldn’t finish this attempt. Your message is saved."); expect(container.textContent).not.toContain("Internal process identity missing"); expect(container.querySelector("a")?.getAttribute("href")).toBe(`/issues/${issue.id}`); expect(container.textContent).toContain("Retry"); act(() => root.unmount()); }); 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(); }); }); }); describe("Coordinator Inbox collection reads", () => { it.each([true, false])("keeps optimized reads with Coordinator and Streamlined=%s", async (streamlined) => { resetInboxApiMocks(); localStorage.clear(); routerMock.location.pathname = "/inbox/mine"; const settings = { enableIsolatedWorkspaces: false, enableStreamlinedUi: streamlined }; apiMocks.experimentalSettings.mockResolvedValue(settings); const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity, gcTime: 0 } } }); client.setQueryData(queryKeys.instance.experimentalSettings, settings); const container = document.createElement("div"); document.body.appendChild(container); const root = createRoot(container); try { await act(async () => root.render()); await vi.waitFor(() => expect(apiMocks.heartbeatRunsList).toHaveBeenCalled()); await act(async () => { await new Promise(resolve => setTimeout(resolve, 30)); }); const sources = apiMocks.issuesList.mock.calls.filter(call => call[1]?.projection !== "rows").map(call => call[1]); expect(sources).toHaveLength(1); expect(sources.every(source => source.projection === "index")).toBe(true); expect(apiMocks.dashboardSummary).toHaveBeenCalledTimes(0); expect(apiMocks.heartbeatRunsList.mock.calls[0]?.[3]?.latestFailuresOnly === true).toBe(true); } finally { await act(async () => root.unmount()); client.clear(); container.remove(); } }); });