// @vitest-environment jsdom import { createRoot } from "react-dom/client"; import { flushSync } from "react-dom"; import type { AnchorHTMLAttributes, ReactNode } from "react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { Issue, Project } from "@paperclipai/shared"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { IssuesList, issueAgeBucket, issueAgeBucketsCrossed, issueAgeSeparatorLabel, } from "./IssuesList"; import { TooltipProvider } from "@/components/ui/tooltip"; import { useShowAgentCreatedTasks } from "../hooks/useShowAgentCreatedTasks"; import { taskCollectionPreferencesStorageKey } from "../lib/task-collection-preferences"; const companyState = vi.hoisted(() => ({ selectedCompanyId: "company-1", })); const dialogState = vi.hoisted(() => ({ openNewIssue: vi.fn(), })); const mockIssuesApi = vi.hoisted(() => ({ get: vi.fn(), listAll: vi.fn(), list: vi.fn(), listLabels: vi.fn(), })); const mockKanbanBoard = vi.hoisted(() => vi.fn()); const mockNavigate = vi.hoisted(() => vi.fn()); const mockAuthApi = vi.hoisted(() => ({ getSession: vi.fn(), })); const mockAccessApi = vi.hoisted(() => ({ listMembers: vi.fn(), listUserDirectory: vi.fn(), })); const mockExecutionWorkspacesApi = vi.hoisted(() => ({ list: vi.fn(), listSummaries: vi.fn(), })); const mockInstanceSettingsApi = vi.hoisted(() => ({ getExperimental: vi.fn(), })); const mockExternalObjectsApi = vi.hoisted(() => ({ getIssueSummaries: vi.fn(), })); vi.mock("../context/CompanyContext", () => ({ useCompany: () => companyState, })); vi.mock("../context/DialogContext", () => ({ useDialog: () => dialogState, useDialogActions: () => dialogState, })); vi.mock("@/lib/router", () => ({ useNavigate: () => mockNavigate, Link: ({ children, to, state: _state, issuePrefetch: _issuePrefetch, ...props }: AnchorHTMLAttributes & { to: string; state?: unknown; issuePrefetch?: unknown; }) => ( {children} ), })); vi.mock("../api/issues", () => ({ issuesApi: { ...mockIssuesApi, listCompact: mockIssuesApi.list, }, })); vi.mock("../api/auth", () => ({ authApi: mockAuthApi, })); vi.mock("../api/access", () => ({ accessApi: mockAccessApi, })); vi.mock("@/api/access", () => ({ accessApi: mockAccessApi, })); vi.mock("../api/execution-workspaces", () => ({ executionWorkspacesApi: mockExecutionWorkspacesApi, })); vi.mock("../api/instanceSettings", () => ({ instanceSettingsApi: mockInstanceSettingsApi, })); vi.mock("../api/externalObjects", () => ({ externalObjectsApi: mockExternalObjectsApi, })); // 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; } vi.mock("./IssueRow", () => ({ IssueRow: ({ issue, desktopMetaLeading, desktopTrailing, titleClassName, checklistStepNumber, checklistCurrentStep, checklistDependencyChips, checklistRowId, externalObjectSummary, presentation, leadingControl, treeGuides, showIdentifier, trailingMeta, }: { issue: Issue; desktopMetaLeading?: ReactNode; desktopTrailing?: ReactNode; titleClassName?: string; checklistStepNumber?: number | string | null; checklistCurrentStep?: boolean; checklistDependencyChips?: ReactNode; checklistRowId?: string; externalObjectSummary?: { total: number } | null; presentation?: "legacy" | "task"; leadingControl?: ReactNode; treeGuides?: number; showIdentifier?: boolean; trailingMeta?: ReactNode; }) => (
{issue.title} {leadingControl} {externalObjectSummary ? ( {externalObjectSummary.total} ) : null} {desktopMetaLeading} {desktopTrailing} {trailingMeta} {checklistDependencyChips}
), })); vi.mock("./KanbanBoard", () => ({ KANBAN_BOARD_HIGH_VOLUME_THRESHOLD: 100, KANBAN_COLD_STATUSES: ["backlog", "done", "cancelled"], KANBAN_COLUMN_DEFAULT_PAGE_SIZE: 10, KANBAN_COLUMN_PAGE_SIZE_OPTIONS: [10, 25, 50], KanbanBoard: (props: { issues: Issue[]; compactCards?: boolean; collapsedStatuses?: string[]; initialVisibleCount?: number; revealIncrement?: number; }) => { mockKanbanBoard(props); return (
{props.issues.map((issue) => ( {issue.title} ))}
); }, })); // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; function createIssue(overrides: Partial = {}): Issue { return { id: "issue-1", identifier: "PAP-1", companyId: "company-1", projectId: null, projectWorkspaceId: null, goalId: null, parentId: null, title: "Issue title", description: null, status: "todo", priority: "medium", reviewPolicy: null, assigneeAgentId: null, assigneeUserId: null, responsibleUserId: null, createdByAgentId: null, createdByUserId: "person", issueNumber: 1, 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-04-07T00:00:00.000Z"), updatedAt: new Date("2026-04-07T00:00:00.000Z"), labels: [], labelIds: [], myLastTouchAt: null, lastExternalCommentAt: null, lastActivityAt: null, isUnreadForMe: false, ...overrides, workMode: overrides.workMode ?? "standard", }; } async function flush() { await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); } async function flushAnimationFrame() { await act(async () => { await new Promise((resolve) => window.requestAnimationFrame(() => resolve())); await Promise.resolve(); }); } async function waitForAssertion(assertion: () => void, attempts = 20) { let lastError: unknown; for (let attempt = 0; attempt < attempts; attempt += 1) { try { assertion(); return; } catch (error) { lastError = error; await flush(); } } throw lastError; } async function waitForMicrotaskAssertion(assertion: () => void, attempts = 20) { let lastError: unknown; for (let attempt = 0; attempt < attempts; attempt += 1) { try { assertion(); return; } catch (error) { lastError = error; await act(async () => { await Promise.resolve(); }); } } throw lastError; } function setDocumentScrollMetrics({ innerHeight, scrollY, scrollHeight, }: { innerHeight: number; scrollY: number; scrollHeight: number; }) { Object.defineProperty(window, "innerHeight", { configurable: true, value: innerHeight }); Object.defineProperty(window, "scrollY", { configurable: true, value: scrollY }); Object.defineProperty(document.documentElement, "scrollHeight", { configurable: true, value: scrollHeight }); } function renderWithQueryClient(node: ReactNode, container: HTMLDivElement) { const root = createRoot(container); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, }, }, }); act(() => { root.render( {node} , ); }); return { root, queryClient }; } describe("IssuesList", () => { let container: HTMLDivElement; beforeEach(() => { container = document.createElement("div"); document.body.appendChild(container); dialogState.openNewIssue.mockReset(); mockKanbanBoard.mockReset(); mockNavigate.mockReset(); mockIssuesApi.list.mockReset(); mockIssuesApi.listLabels.mockReset(); mockAuthApi.getSession.mockReset(); mockAccessApi.listMembers.mockReset(); mockAccessApi.listUserDirectory.mockReset(); mockExecutionWorkspacesApi.list.mockReset(); mockExecutionWorkspacesApi.listSummaries.mockReset(); mockInstanceSettingsApi.getExperimental.mockReset(); mockExternalObjectsApi.getIssueSummaries.mockReset(); mockIssuesApi.list.mockResolvedValue([]); mockIssuesApi.listAll.mockResolvedValue([]); mockIssuesApi.listLabels.mockResolvedValue([]); mockAuthApi.getSession.mockResolvedValue({ user: null, session: null }); mockAccessApi.listMembers.mockResolvedValue({ members: [], access: {} }); mockAccessApi.listUserDirectory.mockResolvedValue({ users: [] }); mockExecutionWorkspacesApi.list.mockResolvedValue([]); mockExecutionWorkspacesApi.listSummaries.mockResolvedValue([]); mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false, enableExternalObjects: false, enableStreamlinedUi: true, }); setDocumentScrollMetrics({ innerHeight: 600, scrollY: 0, scrollHeight: 2400 }); mockExternalObjectsApi.getIssueSummaries.mockResolvedValue({ summaries: {} }); localStorage.clear(); }); afterEach(() => { vi.useRealTimers(); container.remove(); }); it("uses the master list and legacy persistence when Streamlined UI is off", async () => { mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false, enableExternalObjects: false, enableStreamlinedUi: false, }); localStorage.setItem( taskCollectionPreferencesStorageKey({ companyId: "company-1", collectionKey: "paperclip:test-issues", }), JSON.stringify({ version: 1, companyId: "company-1", collectionKey: "paperclip:test-issues", viewState: { viewMode: "board" }, columns: [], }), ); const { root } = renderWithQueryClient( undefined} />, container, ); await waitForAssertion(() => { const row = container.querySelector("[data-testid='issue-row']"); expect(row).not.toBeNull(); expect(row?.getAttribute("data-presentation")).toBeNull(); expect(container.querySelector("[data-testid='kanban-board']")).toBeNull(); }); act(() => root.unmount()); }); it("forwards external-object summaries into issue rows", async () => { mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false, enableExternalObjects: true, }); mockExternalObjectsApi.getIssueSummaries.mockResolvedValue({ summaries: { "issue-1": { total: 2, byStatusCategory: { failed: 1, succeeded: 1 }, byLiveness: { fresh: 2 }, highestSeverity: "danger", staleCount: 0, authRequiredCount: 0, unreachableCount: 0, objects: [], }, }, }); const { root } = renderWithQueryClient( undefined} />, container, ); await waitForAssertion(() => { expect(mockExternalObjectsApi.getIssueSummaries).toHaveBeenCalledWith("company-1", ["issue-1"]); expect(container.querySelector("[data-testid='external-object-summary']")?.textContent).toBe("2"); }); act(() => { root.unmount(); }); }); it("keeps the canonical task-row presentation opt in per collection", async () => { const { root } = renderWithQueryClient( undefined} />, container, ); await waitForAssertion(() => { const row = container.querySelector("[data-testid='issue-row']"); expect(row?.getAttribute("data-presentation")).toBe("task"); expect(row?.getAttribute("data-show-identifier")).toBe("true"); expect(row?.getAttribute("data-trailing-meta")).toMatch(/\S/); expect(row?.getAttribute("data-trailing-meta")).not.toContain("Updated"); expect(row?.getAttribute("data-has-desktop-trailing")).toBe("false"); expect(row?.querySelector('[data-slot="task-row-disclosure-spacer"]')).not.toBeNull(); }); act(() => root.unmount()); }); it("reserves a disclosure column after each ancestor guide in canonical task trees", async () => { const parent = createIssue({ id: "parent", identifier: "PAP-1", title: "Parent task" }); const child = createIssue({ id: "child", identifier: "PAP-2", parentId: parent.id, title: "Child task" }); const { root } = renderWithQueryClient( undefined} />, container, ); await waitForAssertion(() => { const rows = Array.from(container.querySelectorAll('[data-testid="issue-row"]')); const parentRow = rows.find((row) => row.textContent?.includes("Parent task")); const childRow = rows.find((row) => row.textContent?.includes("Child task")); expect(parentRow?.getAttribute("data-tree-guides")).toBe("0"); expect(parentRow?.querySelector('button[aria-label="Collapse sub-tasks"]')).not.toBeNull(); expect(childRow?.getAttribute("data-tree-guides")).toBe("1"); expect(childRow?.querySelector('[data-slot="task-row-disclosure-spacer"]')).not.toBeNull(); }); act(() => root.unmount()); }); it("keeps the shared collection toolbar opt in per surface", async () => { const { root } = renderWithQueryClient( undefined} />, container, ); await waitForAssertion(() => { expect(container.querySelector("[role='toolbar'][aria-label='Task controls']")).not.toBeNull(); }); act(() => root.unmount()); }); it("does not load external-object summaries when the experimental flag is disabled", async () => { const { root } = renderWithQueryClient( undefined} />, container, ); await waitForAssertion(() => { expect(mockInstanceSettingsApi.getExperimental).toHaveBeenCalled(); expect(container.querySelector("[data-testid='issue-row']")).not.toBeNull(); }); expect(mockExternalObjectsApi.getIssueSummaries).not.toHaveBeenCalled(); act(() => { root.unmount(); }); }); it("filters issue rows by external-object status summaries", async () => { mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false, enableExternalObjects: true, }); const failedIssue = createIssue({ id: "issue-failed", identifier: "PAP-10", title: "Failed external object" }); const freshIssue = createIssue({ id: "issue-fresh", identifier: "PAP-11", title: "Fresh external object" }); const noObjectIssue = createIssue({ id: "issue-none", identifier: "PAP-12", title: "No external object" }); localStorage.setItem("paperclip:test-issues:company-1", JSON.stringify({ externalObjectStatuses: ["failed"] })); mockExternalObjectsApi.getIssueSummaries.mockResolvedValue({ summaries: { "issue-failed": { total: 1, byStatusCategory: { failed: 1 }, byLiveness: { fresh: 1 }, highestSeverity: "danger", staleCount: 0, authRequiredCount: 0, unreachableCount: 0, objects: [], }, "issue-fresh": { total: 1, byStatusCategory: { succeeded: 1 }, byLiveness: { fresh: 1 }, highestSeverity: "success", staleCount: 0, authRequiredCount: 0, unreachableCount: 0, objects: [], }, }, }); const { root } = renderWithQueryClient( undefined} />, container, ); await waitForAssertion(() => { expect(mockExternalObjectsApi.getIssueSummaries).toHaveBeenCalledWith( "company-1", ["issue-failed", "issue-fresh", "issue-none"], ); expect(container.textContent).toContain("Failed external object"); expect(container.textContent).not.toContain("Fresh external object"); expect(container.textContent).not.toContain("No external object"); }); act(() => { root.unmount(); }); }); it("renders server search results instead of filtering the full issue list locally", async () => { const localIssue = createIssue({ id: "issue-local", identifier: "PAP-1", title: "Local issue" }); const serverIssue = createIssue({ id: "issue-server", identifier: "PAP-2", title: "Server result" }); mockIssuesApi.list.mockResolvedValue([serverIssue]); const { root } = renderWithQueryClient( undefined} />, container, ); await waitForAssertion(() => { expect(mockIssuesApi.list).toHaveBeenCalledWith("company-1", { q: "server", projectId: undefined, limit: 200, }, { signal: expect.any(AbortSignal) }); expect(container.textContent).toContain("Server result"); expect(container.textContent).not.toContain("Local issue"); }); act(() => { root.unmount(); }); }); it("keeps server-side search scoped to the provided parent issue filters", async () => { const localIssue = createIssue({ id: "issue-local", identifier: "PAP-1", title: "Local issue" }); const serverIssue = createIssue({ id: "issue-server", identifier: "PAP-2", title: "Server result" }); mockIssuesApi.list.mockResolvedValue([serverIssue]); const { root } = renderWithQueryClient( undefined} />, container, ); await waitForAssertion(() => { expect(mockIssuesApi.list).toHaveBeenCalledWith("company-1", { q: "server", projectId: undefined, parentId: "parent-1", limit: 200, }, { signal: expect.any(AbortSignal) }); expect(container.textContent).toContain("Server result"); expect(container.textContent).not.toContain("Local issue"); }); act(() => { root.unmount(); }); }); it("uses the supplied create defaults and label for sub-issue lists", async () => { const { root } = renderWithQueryClient( undefined} />, container, ); await waitForAssertion(() => { const button = Array.from(container.querySelectorAll("button")).find( (candidate) => candidate.textContent?.includes("New Sub-issue"), ); expect(button).not.toBeUndefined(); }); await act(async () => { const button = Array.from(container.querySelectorAll("button")).find( (candidate) => candidate.textContent?.includes("New Sub-issue"), ); button?.dispatchEvent(new MouseEvent("click", { bubbles: true })); await Promise.resolve(); }); expect(dialogState.openNewIssue).toHaveBeenCalledWith({ parentId: "parent-1", projectId: "project-1", }); act(() => { root.unmount(); }); }); it("uses workspace group defaults when creating an issue from a grouped section", async () => { localStorage.setItem( "paperclip:test-issues:company-1", JSON.stringify({ groupBy: "workspace", sortField: "updated", sortDir: "desc" }), ); mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true }); mockExecutionWorkspacesApi.listSummaries.mockResolvedValue([ { id: "execution-workspace-1", name: "Feature Branch", mode: "isolated_workspace", projectWorkspaceId: "project-workspace-1", }, ]); const issue = createIssue({ id: "issue-workspace", projectId: "project-1", projectWorkspaceId: "project-workspace-1", executionWorkspaceId: "execution-workspace-1", }); const project = { id: "project-1", name: "Paperclip App", color: null, workspaces: [{ id: "project-workspace-1", name: "Primary workspace" }], primaryWorkspace: { id: "project-workspace-1" }, executionWorkspacePolicy: { defaultProjectWorkspaceId: "project-workspace-1" }, } as Project; const { root } = renderWithQueryClient( undefined} />, container, ); await waitForAssertion(() => { const button = container.querySelector('button[aria-label="New task in Feature Branch"]'); expect(button).not.toBeNull(); }); await act(async () => { const button = container.querySelector('button[aria-label="New task in Feature Branch"]'); button?.dispatchEvent(new MouseEvent("click", { bubbles: true })); await Promise.resolve(); }); expect(dialogState.openNewIssue).toHaveBeenCalledWith({ executionWorkspaceId: "execution-workspace-1", executionWorkspaceMode: "reuse_existing", projectId: "project-1", projectWorkspaceId: "project-workspace-1", }); act(() => { root.unmount(); }); }); it("renders the opt-in sub-issue progress summary with workflow next-up linking", async () => { const doneIssue = createIssue({ id: "issue-done", identifier: "PAP-1", title: "Completed setup", status: "done", createdAt: new Date("2026-04-01T00:00:00.000Z"), }); const nextIssue = createIssue({ id: "issue-next", identifier: "PAP-2", title: "Implement next slice", status: "todo", createdAt: new Date("2026-04-02T00:00:00.000Z"), blockedBy: [{ id: "issue-done", identifier: "PAP-1", title: "Completed setup", status: "done", priority: "medium", assigneeAgentId: null, assigneeUserId: null, }], }); const blockedIssue = createIssue({ id: "issue-blocked", identifier: "PAP-3", title: "Blocked follow-up", status: "blocked", createdAt: new Date("2026-04-03T00:00:00.000Z"), }); const cancelledIssue = createIssue({ id: "issue-cancelled", identifier: "PAP-4", title: "Cancelled follow-up", status: "cancelled", createdAt: new Date("2026-04-04T00:00:00.000Z"), }); const { root } = renderWithQueryClient( undefined} />, container, ); await waitForAssertion(() => { const progress = container.querySelector('[role="progressbar"]'); expect(progress).not.toBeNull(); expect(progress?.getAttribute("aria-valuenow")).toBe("1"); expect(progress?.getAttribute("aria-valuemax")).toBe("3"); expect(container.textContent).toContain("1/3 done"); expect(container.textContent).toContain("0 in progress"); expect(container.textContent).toContain("1 blocked"); expect(container.textContent).not.toContain("Done 1"); expect(container.textContent).toContain("Next up"); const link = container.querySelector('a[href="/issues/PAP-2"]'); expect(link?.textContent).toContain("Implement next slice"); expect(container.querySelector('[title="Cancelled: 1"]')).toBeNull(); }); act(() => { root.unmount(); }); }); it("adds checklist affordances for workflow-sorted sub-issue lists", async () => { const issueDone = createIssue({ id: "issue-done", identifier: "PAP-1", title: "Done first", status: "done", createdAt: new Date("2026-04-01T00:00:00.000Z"), }); const issueBlocked = createIssue({ id: "issue-blocked", identifier: "PAP-2", title: "Blocked issue", status: "blocked", blockedBy: [{ id: "issue-active", identifier: "PAP-3", title: "Active blocker", status: "todo", priority: "medium", assigneeAgentId: null, assigneeUserId: null }], createdAt: new Date("2026-04-02T00:00:00.000Z"), }); const issueActive = createIssue({ id: "issue-active", identifier: "PAP-3", title: "Active blocker", status: "todo", createdAt: new Date("2026-04-03T00:00:00.000Z"), }); const { root } = renderWithQueryClient( undefined} />, container, ); await waitForAssertion(() => { const rows = Array.from(container.querySelectorAll('[data-testid="issue-row"]')); expect(rows).toHaveLength(3); expect(rows.map((row) => row.getAttribute("data-step"))).toEqual(["1", "2", "3"]); expect(container.textContent?.replace(/\s+/g, "")).toContain("1.PAP-1"); expect(container.textContent?.replace(/\s+/g, "")).toContain("2.PAP-3"); expect(rows.filter((row) => row.getAttribute("data-current-step") === "true")).toHaveLength(1); expect(rows.find((row) => row.textContent?.includes("Active blocker"))?.getAttribute("data-current-step")).toBe("true"); expect(rows.find((row) => row.textContent?.includes("Done first"))?.getAttribute("data-title-class")).toContain("text-muted-foreground"); expect(container.textContent).toContain("blocked by PAP-3 · step 2"); }); act(() => { root.unmount(); }); }); it("hides the Priority option from the Sort and Group menus while priority UI is off (PAP-411)", async () => { const { root } = renderWithQueryClient( undefined} />, container, ); await waitForAssertion(() => { expect(container.querySelectorAll('[data-testid="issue-row"]').length).toBeGreaterThan(0); }); const sortButton = Array.from(container.querySelectorAll("button")).find( (button) => button.getAttribute("title") === "Sort", ); expect(sortButton).toBeTruthy(); act(() => { sortButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await waitForAssertion(() => { const labels = Array.from(document.body.querySelectorAll("button")).map((b) => b.textContent ?? ""); // Status sort option renders, but the Priority option is gated off (PAP-411). expect(labels.some((text) => text.includes("Status"))).toBe(true); expect(labels.some((text) => text.includes("Priority"))).toBe(false); }); const groupButton = Array.from(container.querySelectorAll("button")).find( (button) => button.getAttribute("title") === "Group", ); expect(groupButton).toBeTruthy(); act(() => { groupButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await waitForAssertion(() => { const labels = Array.from(document.body.querySelectorAll("button")).map((b) => b.textContent ?? ""); expect(labels.some((text) => text.includes("Status"))).toBe(true); expect(labels.some((text) => text.includes("Priority"))).toBe(false); }); act(() => { root.unmount(); }); }); it("hides the workflow blocker chip when a sub-issue is blocked only by its previous sibling", async () => { const firstChild = createIssue({ id: "issue-first-child", identifier: "PAP-1", parentId: "issue-parent", title: "First child", status: "todo", createdAt: new Date("2026-04-01T00:00:00.000Z"), }); const secondChild = createIssue({ id: "issue-second-child", identifier: "PAP-2", parentId: "issue-parent", title: "Second child", status: "blocked", blockedBy: [ { id: "issue-first-child", identifier: "PAP-1", title: "First child", status: "todo", priority: "medium", assigneeAgentId: null, assigneeUserId: null, }, ], createdAt: new Date("2026-04-02T00:00:00.000Z"), }); const { root } = renderWithQueryClient( undefined} />, container, ); await waitForAssertion(() => { const rows = Array.from(container.querySelectorAll('[data-testid="issue-row"]')); expect(rows).toHaveLength(2); expect(rows.map((row) => row.getAttribute("data-step"))).toEqual(["1", "2"]); expect(container.textContent).not.toContain("blocked by PAP-1"); }); act(() => { root.unmount(); }); }); it("collapses multiple workflow blocker chips to the first blocker and a count", async () => { const issueDone = createIssue({ id: "issue-done", identifier: "PAP-1", title: "Done first", status: "done", createdAt: new Date("2026-04-01T00:00:00.000Z"), }); const firstBlocker = createIssue({ id: "issue-first-blocker", identifier: "PAP-2", title: "First blocker", status: "todo", createdAt: new Date("2026-04-02T00:00:00.000Z"), }); const secondBlocker = createIssue({ id: "issue-second-blocker", identifier: "PAP-3", title: "Second blocker", status: "todo", createdAt: new Date("2026-04-03T00:00:00.000Z"), }); const thirdBlocker = createIssue({ id: "issue-third-blocker", identifier: "PAP-4", title: "Third blocker", status: "todo", createdAt: new Date("2026-04-04T00:00:00.000Z"), }); const issueBlocked = createIssue({ id: "issue-blocked", identifier: "PAP-5", title: "Blocked issue", status: "blocked", blockedBy: [ { id: "issue-first-blocker", identifier: "PAP-2", title: "First blocker", status: "todo", priority: "medium", assigneeAgentId: null, assigneeUserId: null, }, { id: "issue-second-blocker", identifier: "PAP-3", title: "Second blocker", status: "todo", priority: "medium", assigneeAgentId: null, assigneeUserId: null, }, { id: "issue-third-blocker", identifier: "PAP-4", title: "Third blocker", status: "todo", priority: "medium", assigneeAgentId: null, assigneeUserId: null, }, ], createdAt: new Date("2026-04-05T00:00:00.000Z"), }); const { root } = renderWithQueryClient( undefined} />, container, ); await waitForAssertion(() => { expect(container.textContent).toContain("blocked by PAP-2"); expect(container.textContent).toContain("... and 2 more"); expect(container.textContent).not.toContain("blocked by PAP-3"); expect(container.textContent).not.toContain("blocked by PAP-4"); const blockerButtons = Array.from(container.querySelectorAll("button")) .filter((button) => button.textContent?.includes("blocked by")); expect(blockerButtons).toHaveLength(1); expect(blockerButtons[0]?.textContent).toBe("blocked by PAP-2 · step 2 ... and 2 more"); }); act(() => { root.unmount(); }); }); it("uses hierarchical checklist step numbers when nested rows render inline", async () => { const firstRoot = createIssue({ id: "issue-first-root", identifier: "PAP-1", title: "First root", status: "done", createdAt: new Date("2026-04-01T00:00:00.000Z"), }); const parent = createIssue({ id: "issue-parent", identifier: "PAP-2", title: "Parent slice", status: "todo", createdAt: new Date("2026-04-02T00:00:00.000Z"), }); const nextRoot = createIssue({ id: "issue-next-root", identifier: "PAP-3", title: "Next root", status: "todo", createdAt: new Date("2026-04-03T00:00:00.000Z"), }); const grandchild = createIssue({ id: "issue-grandchild", identifier: "PAP-4", title: "Nested cancelled cleanup", status: "cancelled", parentId: "issue-parent", createdAt: new Date("2026-04-04T00:00:00.000Z"), }); const { root } = renderWithQueryClient( undefined} />, container, ); await waitForAssertion(() => { const rows = Array.from(container.querySelectorAll('[data-testid="issue-row"]')); expect(rows).toHaveLength(4); expect(rows.map((row) => row.textContent)).toEqual([ expect.stringContaining("First root"), expect.stringContaining("Parent slice"), expect.stringContaining("Nested cancelled cleanup"), expect.stringContaining("Next root"), ]); expect(rows.map((row) => row.getAttribute("data-step"))).toEqual(["1", "2", "2.1", "3"]); }); act(() => { root.unmount(); }); }); it("hides the sub-issue progress summary unless it is enabled with multiple sub-issues", async () => { const { root } = renderWithQueryClient( undefined} />, container, ); await waitForAssertion(() => { expect(container.querySelector('[role="progressbar"]')).toBeNull(); }); act(() => { root.unmount(); }); }); it("shows waiting on blockers when every remaining sub-issue is blocked", async () => { const { root } = renderWithQueryClient( undefined} />, container, ); await waitForAssertion(() => { expect(container.textContent).toContain("Waiting on blockers"); const link = container.querySelector('a[href="/issues/PAP-2"]'); expect(link?.textContent).toContain("Blocked follow-up"); }); act(() => { root.unmount(); }); }); it("debounces search updates so typing does not notify the page on every keystroke", async () => { vi.useFakeTimers(); const onSearchChange = vi.fn(); const localIssue = createIssue({ id: "issue-local", identifier: "PAP-1", title: "Local issue" }); const { root } = renderWithQueryClient( undefined} />, container, ); const input = container.querySelector('input[aria-label="Search tasks"]') as HTMLInputElement | null; expect(input).not.toBeNull(); const valueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set; expect(valueSetter).toBeTypeOf("function"); act(() => { if (!input || !valueSetter) return; valueSetter.call(input, "a"); input.dispatchEvent(new Event("input", { bubbles: true })); valueSetter.call(input, "ab"); input.dispatchEvent(new Event("input", { bubbles: true })); }); expect(onSearchChange).not.toHaveBeenCalled(); act(() => { vi.advanceTimersByTime(249); }); expect(onSearchChange).not.toHaveBeenCalled(); await act(async () => { vi.advanceTimersByTime(1); await Promise.resolve(); }); expect(onSearchChange).toHaveBeenCalledTimes(1); expect(onSearchChange).toHaveBeenCalledWith("ab"); act(() => { root.unmount(); }); }); it("shows a refinement hint when search results hit the live search cap", async () => { const serverIssues = Array.from({ length: 200 }, (_, index) => createIssue({ id: `issue-${index + 1}`, identifier: `PAP-${index + 1}`, title: `Server result ${index + 1}`, }), ); localStorage.setItem( "paperclip:test-issues:company-1", JSON.stringify({ statuses: ["done"] }), ); mockIssuesApi.list.mockResolvedValue(serverIssues); const { root } = renderWithQueryClient( undefined} />, container, ); await waitForAssertion(() => { expect(container.textContent).toContain("Showing up to 200 matches. Refine the search to narrow further."); }); act(() => { root.unmount(); }); }, 10_000); it("loads board issues with a separate result limit for each status column", async () => { localStorage.setItem( "paperclip:test-issues:company-1", JSON.stringify({ viewMode: "board" }), ); const parentIssue = createIssue({ id: "issue-parent-total-limit", title: "Parent total-limited issue", status: "todo", }); const backlogIssue = createIssue({ id: "issue-backlog", title: "Backlog column issue", status: "backlog", }); const doneIssue = createIssue({ id: "issue-done", title: "Done column issue", status: "done", }); mockIssuesApi.list.mockImplementation((_companyId, filters) => { if (filters?.status === "backlog") return Promise.resolve([backlogIssue]); if (filters?.status === "done") return Promise.resolve([doneIssue]); return Promise.resolve([]); }); const { root } = renderWithQueryClient( undefined} />, container, ); await waitForAssertion(() => { expect(mockIssuesApi.list).toHaveBeenCalledWith("company-1", expect.objectContaining({ status: "backlog", limit: 200, includeRoutineExecutions: true, includeAgentCreatedTasks: false, }), { signal: expect.any(AbortSignal) }); expect(mockIssuesApi.list).toHaveBeenCalledWith("company-1", expect.objectContaining({ status: "done", limit: 200, includeRoutineExecutions: true, includeAgentCreatedTasks: false, }), { signal: expect.any(AbortSignal) }); expect(mockKanbanBoard).toHaveBeenLastCalledWith(expect.objectContaining({ issues: expect.arrayContaining([ expect.objectContaining({ id: "issue-backlog" }), expect.objectContaining({ id: "issue-done" }), ]), })); expect(container.textContent).toContain("Backlog column issue"); expect(container.textContent).toContain("Done column issue"); expect(container.textContent).not.toContain("Parent total-limited issue"); }); act(() => { root.unmount(); }); }); it("uses compact cards and collapsed cold lanes for high-volume boards", async () => { localStorage.setItem( "paperclip:test-issues:company-1", JSON.stringify({ viewMode: "board" }), ); const backlogIssues = Array.from({ length: 101 }, (_, index) => createIssue({ id: `issue-backlog-${index + 1}`, identifier: `PAP-${index + 1}`, title: `Backlog issue ${index + 1}`, status: "backlog", }), ); mockIssuesApi.list.mockImplementation((_companyId, filters) => { if (filters?.status === "backlog") return Promise.resolve(backlogIssues); return Promise.resolve([]); }); const { root } = renderWithQueryClient( undefined} />, container, ); await waitForAssertion(() => { expect(mockKanbanBoard).toHaveBeenLastCalledWith(expect.objectContaining({ compactCards: true, collapsedStatuses: expect.arrayContaining(["backlog", "done", "cancelled"]), initialVisibleCount: 10, revealIncrement: 10, })); }); act(() => { root.unmount(); }); }); it("lets board users choose the per-column page size", async () => { localStorage.setItem( "paperclip:test-issues:company-1", JSON.stringify({ viewMode: "board" }), ); const { root } = renderWithQueryClient( undefined} />, container, ); await waitForAssertion(() => { expect(mockKanbanBoard).toHaveBeenLastCalledWith(expect.objectContaining({ initialVisibleCount: 10, revealIncrement: 10, })); }); const pageSizeButton = Array.from(container.querySelectorAll("button")).find((button) => button.getAttribute("title") === "Cards per column", ); expect(pageSizeButton).toBeTruthy(); act(() => { pageSizeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); let option25: HTMLButtonElement | undefined; await waitForAssertion(() => { option25 = Array.from(document.body.querySelectorAll("button")).find((button) => button.textContent?.includes("25 per column"), ); expect(option25).toBeTruthy(); }); act(() => { option25?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await waitForAssertion(() => { expect(mockKanbanBoard).toHaveBeenLastCalledWith(expect.objectContaining({ initialVisibleCount: 25, revealIncrement: 25, })); }); expect(localStorage.getItem("paperclip:test-issues:company-1")).toContain("\"boardColumnPageSize\":25"); act(() => { root.unmount(); }); }); it("shows a refinement hint when a board column hits its server cap", async () => { localStorage.setItem( "paperclip:test-issues:company-1", JSON.stringify({ viewMode: "board" }), ); const cappedBacklogIssues = Array.from({ length: 200 }, (_, index) => createIssue({ id: `issue-backlog-${index + 1}`, identifier: `PAP-${index + 1}`, title: `Backlog issue ${index + 1}`, status: "backlog", }), ); mockIssuesApi.list.mockImplementation((_companyId, filters) => { if (filters?.status === "backlog") return Promise.resolve(cappedBacklogIssues); return Promise.resolve([]); }); const { root } = renderWithQueryClient( undefined} />, container, ); await waitForAssertion(() => { expect(container.textContent).toContain("Some board columns are showing up to 200 tasks. Refine filters or search to reveal the rest."); }); act(() => { root.unmount(); }); }); it("caps the first paint for large issue lists", async () => { const manyIssues = Array.from({ length: 220 }, (_, index) => createIssue({ id: `issue-${index + 1}`, identifier: `PAP-${index + 1}`, title: `Issue ${index + 1}`, }), ); const { root } = renderWithQueryClient( undefined} />, container, ); await waitForAssertion(() => { expect(container.querySelectorAll('[data-testid="issue-row"]')).toHaveLength(100); expect(container.textContent).toContain("Rendering 100 of 220 tasks"); }); act(() => { root.unmount(); }); }); it.each([true, false])("retains loaded context while revealing a large automated tree (streamlined=%s)", async (streamlined) => { mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableStreamlinedUi: streamlined }); const parent = createIssue({ id: "human-root", createdByUserId: "person", originKind: "manual" }); const children = Array.from({ length: 250 }, (_, index) => createIssue({ id: `automated-child-${index}`, title: `Automated child ${index}`, parentId: parent.id, createdByUserId: null, createdByAgentId: "agent", originKind: "manual", })); let holdNextRead = false; let releaseRead: (() => void) | undefined; mockIssuesApi.list.mockImplementation(async (_companyId, filters) => { if (filters.parentId !== parent.id) return []; const rows = children.slice(filters.offset ?? 0, (filters.offset ?? 0) + filters.limit); if (holdNextRead) { holdNextRead = false; return new Promise((resolve) => { releaseRead = () => resolve(rows); }); } return rows; }); const { root } = renderWithQueryClient( undefined} />, container, ); await waitForAssertion(() => expect(container.querySelectorAll("[data-testid='issue-row']")).toHaveLength(100)); holdNextRead = true; act(() => { setDocumentScrollMetrics({ innerHeight: 600, scrollY: 1500, scrollHeight: 2000 }); window.dispatchEvent(new Event("scroll")); }); await flushAnimationFrame(); await waitForAssertion(() => { expect(releaseRead).toBeDefined(); expect(container.querySelectorAll("[data-testid='issue-row']").length).toBeGreaterThanOrEqual(100); }); await act(async () => releaseRead!()); await waitForAssertion(() => { expect(container.querySelectorAll("[data-testid='issue-row']")).toHaveLength(251); expect(container.textContent).toContain("Automated child 249"); }, 60); act(() => root.unmount()); }); it("keeps rendering local issue batches while the user stays near the bottom", async () => { const manyIssues = Array.from({ length: 420 }, (_, index) => createIssue({ id: `issue-${index + 1}`, identifier: `PAP-${index + 1}`, title: `Issue ${index + 1}`, }), ); const { root } = renderWithQueryClient( undefined} />, container, ); await waitForAssertion(() => { expect(container.querySelectorAll('[data-testid="issue-row"]')).toHaveLength(100); }); await flush(); act(() => { setDocumentScrollMetrics({ innerHeight: 600, scrollY: 1500, scrollHeight: 2000 }); window.dispatchEvent(new Event("scroll")); }); await flushAnimationFrame(); await waitForAssertion(() => { expect(container.querySelectorAll('[data-testid="issue-row"]')).toHaveLength(250); expect(container.textContent).toContain("Rendering 250 of 420 tasks"); }); act(() => { root.unmount(); }); }); it("waits for the desktop main scroll container before rendering more local rows", async () => { const manyIssues = Array.from({ length: 120 }, (_, index) => createIssue({ id: `issue-${index + 1}`, identifier: `PAP-${index + 1}`, title: `Issue ${index + 1}`, }), ); const main = document.createElement("main"); main.id = "main-content"; main.style.overflowY = "auto"; document.body.appendChild(main); main.appendChild(container); Object.defineProperty(main, "clientHeight", { configurable: true, value: 600 }); Object.defineProperty(main, "scrollHeight", { configurable: true, value: 2000 }); Object.defineProperty(main, "scrollTop", { configurable: true, writable: true, value: 0 }); setDocumentScrollMetrics({ innerHeight: 600, scrollY: 0, scrollHeight: 600 }); const { root } = renderWithQueryClient( undefined} />, container, ); await waitForAssertion(() => { expect(container.querySelectorAll('[data-testid="issue-row"]')).toHaveLength(100); }); await flush(); await flush(); expect(container.querySelectorAll('[data-testid="issue-row"]')).toHaveLength(100); act(() => { main.scrollTop = 1500; main.dispatchEvent(new Event("scroll")); }); await flushAnimationFrame(); await waitForAssertion(() => { expect(container.querySelectorAll('[data-testid="issue-row"]').length).toBeGreaterThan(100); }); act(() => { root.unmount(); }); }); it("requests more server issues after scrolling past the rendered rows", async () => { const visibleIssues = Array.from({ length: 100 }, (_, index) => createIssue({ id: `issue-${index + 1}`, identifier: `PAP-${index + 1}`, title: `Issue ${index + 1}`, }), ); const onLoadMoreIssues = vi.fn(); setDocumentScrollMetrics({ innerHeight: 2000, scrollY: 0, scrollHeight: 1000 }); const { root } = renderWithQueryClient( undefined} />, container, ); await waitForAssertion(() => { expect(container.querySelectorAll('[data-testid="issue-row"]')).toHaveLength(100); }); await waitForAssertion(() => { expect(onLoadMoreIssues).toHaveBeenCalledTimes(1); }); await flush(); expect(onLoadMoreIssues).toHaveBeenCalledTimes(1); act(() => { setDocumentScrollMetrics({ innerHeight: 600, scrollY: 1500, scrollHeight: 2000 }); window.dispatchEvent(new Event("scroll")); }); await flushAnimationFrame(); await waitForAssertion(() => { expect(onLoadMoreIssues).toHaveBeenCalledTimes(2); }); act(() => { root.unmount(); }); }); it("skips deferred row sizing for expanded parent rows with visible children", async () => { const parentIssue = createIssue({ id: "issue-parent", identifier: "PAP-1", title: "Parent issue", }); const childIssue = createIssue({ id: "issue-child", identifier: "PAP-2", title: "Child issue", parentId: "issue-parent", }); const { root } = renderWithQueryClient( undefined} />, container, ); await waitForAssertion(() => { const rows = Array.from(container.querySelectorAll('[data-testid="issue-row"]')); const parentRow = rows.find((row) => row.textContent?.includes("Parent issue")); const childRow = rows.find((row) => row.textContent?.includes("Child issue")); expect(parentRow).not.toBeUndefined(); expect(childRow).not.toBeUndefined(); expect((parentRow?.parentElement as HTMLDivElement | null)?.style.contentVisibility).toBe(""); expect((parentRow?.parentElement as HTMLDivElement | null)?.style.containIntrinsicSize).toBe(""); expect((childRow?.parentElement as HTMLDivElement | null)?.style.contentVisibility).toBe("auto"); expect((childRow?.parentElement as HTMLDivElement | null)?.style.containIntrinsicSize).toBe("44px"); }); act(() => { root.unmount(); }); }); it("uses context-scoped persisted column visibility", async () => { localStorage.setItem("paperclip:test-issues:company-1:issue-columns", JSON.stringify(["id", "assignee"])); const assignedIssue = createIssue({ id: "issue-assigned", identifier: "PAP-9", title: "Assigned issue", assigneeAgentId: "agent-1", }); const { root } = renderWithQueryClient( undefined} />, container, ); await waitForAssertion(() => { const columnsButton = Array.from(document.body.querySelectorAll("button")).find( (button) => button.getAttribute("title") === "Columns", ); expect(columnsButton).not.toBeUndefined(); expect(container.textContent).toContain("PAP-9"); expect(container.textContent).toContain("Agent One"); expect(container.textContent).not.toContain("Updated"); }); act(() => { root.unmount(); }); }); it("shows human assignee names from company member profiles", async () => { localStorage.setItem("paperclip:test-issues:company-1:issue-columns", JSON.stringify(["id", "assignee"])); mockAccessApi.listUserDirectory.mockResolvedValue({ users: [ { principalId: "user-2", status: "active", user: { id: "user-2", name: "Jordan Lee", email: "jordan@example.com", image: "https://example.com/jordan.png", }, }, ], }); const assignedIssue = createIssue({ id: "issue-human", identifier: "PAP-12", title: "Human assigned issue", assigneeUserId: "user-2", }); const { root } = renderWithQueryClient( undefined} />, container, ); await waitForAssertion(() => { expect(container.textContent).toContain("Jordan Lee"); }); act(() => { root.unmount(); }); }); it("preserves stored grouping across refresh when initial assignees are applied", async () => { localStorage.setItem( "paperclip:test-issues:company-1", JSON.stringify({ groupBy: "status", sortField: "updated", sortDir: "desc" }), ); const todoIssue = createIssue({ id: "issue-todo", title: "Alpha", status: "todo", assigneeAgentId: "agent-1" }); const doneIssue = createIssue({ id: "issue-done", title: "Beta", status: "done", assigneeAgentId: "agent-1" }); const { root } = renderWithQueryClient( undefined} />, container, ); await waitForAssertion(() => { expect(container.textContent).toContain("Todo"); expect(container.textContent).toContain("Done"); expect(container.textContent).toContain("Alpha"); expect(container.textContent).toContain("Beta"); }); act(() => { root.unmount(); }); }); it("filters the list to a single workspace when a workspace name is clicked", async () => { localStorage.setItem("paperclip:test-issues:company-1:issue-columns", JSON.stringify(["id", "workspace"])); mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true }); mockExecutionWorkspacesApi.listSummaries.mockResolvedValue([ { id: "workspace-alpha", name: "Alpha", mode: "isolated_workspace", status: "active", projectWorkspaceId: null, }, { id: "workspace-beta", name: "Beta", mode: "isolated_workspace", status: "active", projectWorkspaceId: null, }, ]); const alphaIssue = createIssue({ id: "issue-alpha", identifier: "PAP-20", title: "Alpha issue", executionWorkspaceId: "workspace-alpha", }); const betaIssue = createIssue({ id: "issue-beta", identifier: "PAP-21", title: "Beta issue", executionWorkspaceId: "workspace-beta", }); const { root } = renderWithQueryClient( undefined} />, container, ); await waitForAssertion(() => { expect(container.textContent).toContain("Alpha issue"); expect(container.textContent).toContain("Beta issue"); const workspaceButton = Array.from(container.querySelectorAll("button")).find( (button) => button.textContent === "Alpha", ); expect(workspaceButton).not.toBeUndefined(); }); await act(async () => { const workspaceButton = Array.from(container.querySelectorAll("button")).find( (button) => button.textContent === "Alpha", ); workspaceButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); await Promise.resolve(); }); await waitForAssertion(() => { expect(container.textContent).toContain("Alpha issue"); expect(container.textContent).not.toContain("Beta issue"); }); act(() => { root.unmount(); }); }); it("ignores a saved workspace filter when the page address has no workspace query", async () => { localStorage.setItem( taskCollectionPreferencesStorageKey({ companyId: "company-1", collectionKey: "paperclip:test-issues", }), JSON.stringify({ version: 1, companyId: "company-1", collectionKey: "paperclip:test-issues", viewState: { workspaces: ["workspace-alpha"], }, columns: ["id"], }), ); const alphaIssue = createIssue({ id: "issue-alpha", identifier: "PAP-40", title: "Alpha issue", executionWorkspaceId: "workspace-alpha", }); const betaIssue = createIssue({ id: "issue-beta", identifier: "PAP-41", title: "Beta issue", executionWorkspaceId: "workspace-beta", }); const { root } = renderWithQueryClient( undefined} />, container, ); await waitForAssertion(() => { expect(container.textContent).toContain("Alpha issue"); expect(container.textContent).toContain("Beta issue"); }); act(() => { root.unmount(); }); }); it("applies an initial workspace filter from the issues URL state", async () => { const alphaIssue = createIssue({ id: "issue-alpha", identifier: "PAP-30", title: "Alpha issue", executionWorkspaceId: "workspace-alpha", }); const betaIssue = createIssue({ id: "issue-beta", identifier: "PAP-31", title: "Beta issue", executionWorkspaceId: "workspace-beta", }); const { root } = renderWithQueryClient( undefined} />, container, ); await waitForAssertion(() => { expect(container.textContent).toContain("Alpha issue"); expect(container.textContent).not.toContain("Beta issue"); }); act(() => { root.unmount(); }); }); it.each([true, false])("keeps visibility controls and fetching preferences synchronized (streamlined=%s)", async (streamlined) => { mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableStreamlinedUi: streamlined }); function PreferenceProbe() { const included = useShowAgentCreatedTasks("company-1", "paperclip:test-issues"); return {String(included)}; } const manualIssue = createIssue({ id: "issue-manual", identifier: "PAP-10", title: "Manual issue", createdByUserId: "person", originKind: "manual", }); const routineIssue = createIssue({ id: "issue-routine", identifier: "PAP-11", title: "Routine issue", originKind: "routine_execution", }); const { root } = renderWithQueryClient( <> undefined} />, container, ); await waitForAssertion(() => { expect(container.textContent).toContain("Manual issue"); expect(container.textContent).not.toContain("Routine issue"); }); await act(async () => { const filterButton = Array.from(document.body.querySelectorAll("button")).find( (button) => button.getAttribute("title") === "Filter", ); filterButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); await Promise.resolve(); }); await waitForAssertion(() => { const toggle = Array.from(document.body.querySelectorAll("label")).find( (label) => label.textContent?.includes("show agent-created tasks"), ); expect(toggle).not.toBeUndefined(); }); await act(async () => { const toggle = Array.from(document.body.querySelectorAll("label")).find( (label) => label.textContent?.includes("show agent-created tasks"), ); toggle?.dispatchEvent(new MouseEvent("click", { bubbles: true })); await Promise.resolve(); }); await waitForAssertion(() => { expect(container.textContent).toContain("Routine issue"); expect(container.querySelector("[data-testid='creation-preference']")?.textContent).toBe("true"); }); const preferenceKey = taskCollectionPreferencesStorageKey({ companyId: "company-1", collectionKey: "paperclip:test-issues" }); const envelope = JSON.parse(localStorage.getItem(preferenceKey)!); await act(async () => { localStorage.setItem(preferenceKey, JSON.stringify({ ...envelope, viewState: { ...envelope.viewState, showAgentCreatedTasks: false } })); window.dispatchEvent(new StorageEvent("storage", { key: preferenceKey })); }); await waitForAssertion(() => { expect(container.textContent).not.toContain("Routine issue"); expect(container.querySelector("[data-testid='creation-preference']")?.textContent).toBe("false"); const label = Array.from(document.body.querySelectorAll("label")).find((element) => element.textContent?.includes("show agent-created tasks")); const checkbox = label?.querySelector("[role='checkbox'], input[type='checkbox']"); expect(checkbox).not.toBeNull(); expect(checkbox instanceof HTMLInputElement ? checkbox.checked : checkbox?.getAttribute("aria-checked") === "true").toBe(false); }); act(() => root.unmount()); }); it("blurs the search input on Enter without clearing the query", async () => { const { root } = renderWithQueryClient( undefined} />, container, ); await waitForAssertion(() => { const input = container.querySelector('input[aria-label="Search tasks"]') as HTMLInputElement | null; expect(input).not.toBeNull(); input?.focus(); expect(document.activeElement).toBe(input); }); const input = container.querySelector('input[aria-label="Search tasks"]') as HTMLInputElement; act(() => { input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, })); }); expect(document.activeElement).not.toBe(input); expect(input.value).toBe("bug"); act(() => { root.unmount(); }); }); it("blurs the search input on Escape once the field is empty", async () => { const { root } = renderWithQueryClient( undefined} />, container, ); await waitForAssertion(() => { const input = container.querySelector('input[aria-label="Search tasks"]') as HTMLInputElement | null; expect(input).not.toBeNull(); input?.focus(); expect(document.activeElement).toBe(input); }); const input = container.querySelector('input[aria-label="Search tasks"]') as HTMLInputElement; act(() => { input.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true, })); }); expect(document.activeElement).not.toBe(input); act(() => { root.unmount(); }); }); it("uses workspace summaries instead of the full workspace list on the issues page", async () => { mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true }); mockExecutionWorkspacesApi.listSummaries.mockResolvedValue([]); const { root } = renderWithQueryClient( undefined} />, container, ); await waitForAssertion(() => { expect(mockExecutionWorkspacesApi.listSummaries).toHaveBeenCalledWith("company-1"); expect(mockExecutionWorkspacesApi.list).not.toHaveBeenCalled(); }); act(() => { root.unmount(); }); }); // Run 3 review (Jul 8) reversed PAP-243's lg enlargement: task rows in the // list and inbox standardize on md (16px). The live list always supplies its // own `statusSlot` (the PAP-246 slot-override gotcha), so assert the real // slot size here. it("renders the desktop row status glyph at md (16px)", async () => { const { root } = renderWithQueryClient( undefined} />, container, ); await waitForAssertion(() => { const glyphs = Array.from(container.querySelectorAll("svg")).filter( (svg) => svg.getAttribute("width") === "16" && svg.getAttribute("height") === "16", ); expect(glyphs.length).toBeGreaterThan(0); // No 20px (lg) status glyph should leak through from the list's slot. const lgGlyphs = Array.from(container.querySelectorAll("svg")).filter( (svg) => svg.getAttribute("width") === "20" && svg.getAttribute("height") === "20", ); expect(lgGlyphs.length).toBe(0); }); act(() => { root.unmount(); }); }); it("draws local-calendar separators between date-sorted rows", async () => { const now = new Date(); const hourAgo = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 12); const threeDaysAgo = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 3, 12); const tenDaysAgo = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 10, 12); const { root } = renderWithQueryClient( undefined} />, container, ); await waitForAssertion(() => { const separators = Array.from(container.querySelectorAll("[data-issues-date-separator]")); const labels = separators.map((el) => el.getAttribute("aria-label")); expect(labels).toEqual(["Today", "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("can hide date group separators from the persisted Columns option", async () => { const collectionKey = "paperclip:test-issues"; localStorage.setItem( taskCollectionPreferencesStorageKey({ companyId: "company-1", collectionKey, }), JSON.stringify({ version: 1, companyId: "company-1", collectionKey, viewState: { showDateGroupSeparators: false }, columns: ["status", "id", "updated"], }), ); const now = new Date(); const today = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 12); const threeDaysAgo = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 3, 12); const { root } = renderWithQueryClient( undefined} />, container, ); await waitForAssertion(() => { expect(container.textContent).toContain("Earlier task"); expect(container.querySelector("[data-issues-date-separator]")).toBeNull(); }); act(() => { root.unmount(); }); }); it("does not synthesize an empty Yesterday group", async () => { const now = new Date(); const today = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 12); const tenDaysAgo = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 10, 12); const { root } = renderWithQueryClient( undefined} />, container, ); await waitForAssertion(() => { const labels = Array.from(container.querySelectorAll("[data-issues-date-separator]")) .map((el) => el.getAttribute("aria-label")); expect(labels).toEqual(["Today", "Earlier"]); }); act(() => { root.unmount(); }); }); it("places separators around expanded nested rows in visible order", async () => { const now = new Date(); const today = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 12); const threeDaysAgo = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 3, 12); const tenDaysAgo = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 10, 12); const { root } = renderWithQueryClient( undefined} />, container, ); await waitForAssertion(() => { const visibleOrder = Array.from( container.querySelectorAll("[data-testid='issue-row'], [data-issues-date-separator]"), ).map((element) => element.getAttribute("aria-label") ?? element.firstElementChild?.textContent); expect(visibleOrder).toEqual([ "Today", "Recent parent", "Earlier", "Older child", "Old root", ]); }); act(() => { root.unmount(); }); }); it("emits one Today heading when all rows share today's calendar group", async () => { const now = new Date(); const todayMorning = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 9); const todayNoon = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 12); const { root } = renderWithQueryClient( undefined} />, container, ); await waitForAssertion(() => { expect(container.querySelector("[data-testid='issue-row']")).not.toBeNull(); }); expect(Array.from(container.querySelectorAll("[data-issues-date-separator]")) .map((element) => element.getAttribute("aria-label"))).toEqual(["Today"]); act(() => { root.unmount(); }); }); }); describe("legacy issue age separators", () => { const now = new Date("2026-04-10T12:00:00.000Z").getTime(); it("retains the rolling day and week boundaries used by the legacy list", () => { expect(issueAgeBucket(new Date(now - 60 * 60 * 1000), now)).toBe(0); expect(issueAgeBucket(new Date(now - 3 * 24 * 60 * 60 * 1000), now)).toBe(1); expect(issueAgeBucket(new Date(now - 10 * 24 * 60 * 60 * 1000), now)).toBe(2); expect(issueAgeSeparatorLabel(1)).toBe("Older than a day"); expect(issueAgeSeparatorLabel(2)).toBe("Older than a week"); expect(issueAgeBucketsCrossed(0, 2)).toEqual([1, 2]); }); });