mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-06 14:02:36 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Task lists and inboxes are the main entry points to that work. > - Cold opens fetch overlapping task lists, task descriptions, and run details before useful rows settle. > - Independent responses can insert rows above content that a person has started to read. > - This pull request loads a small task index first and adds details only for visible rows. > - It keeps row order and geometry stable while those details arrive. ## Linked Issues or Issue Description **What existing behavior does this improve?** Cold opens of Tasks and Inbox, including the shared navigation badge. **Subsystem affected** Server list reads, shared response contracts, and the React UI. **Current behavior** The badge fetches full task bodies. Mine fetches several overlapping lists. Historical diagnostics and row metadata add work before the first useful list. A fast failed-run response can appear before task rows, then move down when tasks arrive. **Proposed behavior** Load the selected inbox source and its required context together. Use an index without task descriptions or recovery diagnostics. Fetch diagnostic details for up to 50 visible rows. Keep skeletons, recovery indicators, external status, and live metadata in fixed space. **Reason and benefit** Reduce work on a cold open and prevent loading-induced row movement. On the same 100 local fixture tasks, the compact response was 262,441 decoded bytes and the index was 110,909 bytes. This comparison excludes compression and is not a page latency benchmark. **Breaking changes** Existing full and compact responses remain available. New index/row projections are opt-in and marked as partial data. Mine keeps its existing most-recent-100 display limit, now selected on the server. Existing filters and group preferences remain in the client. No database migration. **Additional context** Refs: #17593. Related: #13095, #17428, #13803. This change addresses collection overfetch and layout stability. It does not replace the blocked-inbox query work or the database activity-index work. ## What Changed - Add authorized index and row projections, with bounded task and parent ID batches. - Replace badge list downloads with unread IDs and aggregate counts. Preserve actor privacy, dismissals, and local archive behavior. - Load only the selected inbox source, plus bounded parent and failed-run context. Return only relevant failures from the bounded run window. - Batch hierarchy reads, including at most one authorized child per collapsed parent in one request. Collapsed markers do not consume the expanded row budget, and partial descendant counts are not shown as totals. Enrich visible rows without replacing membership, ordering, titles, or read state. - Reserve row geometry on desktop and mobile. Reveal the initial inbox only after its sources settle. Preserve legacy task-list reads. - Start collection module downloads during access checks. Keep partial rows out of the task-detail cache. - Keep ETags and concurrent request sharing for the new projections, but skip the short server response TTL so archive undo sees fresh state. ## Verification - On final head `ef6afb2a56`, the full CI workflow passed, including build, typecheck, server/workspace/runner suites, and all eight browser shards. A server shard reported **1,489 passing tests** before runner shutdown canceled the job; its single infrastructure retry passed. Both Greptile and Paperclip Review are **5/5**, with zero unresolved review threads. - The optional external **Entire Gates** check remains `unassessed` because it has no active assessed Change for this comparison. Entire is not set up in this checkout. The repository ruleset requires `ci / verify` and `ci / e2e`; both passed. No gate configuration was changed. - Passed workspace typecheck after the final API batching change. The full production build also passed on the final commit; the exact-retry E2E run built the shipped UI. - Passed token gates and map validation. The final batch-preview pass has 91 passing route/hierarchy/component tests, including privacy, request bounds, 30 collapsed parents in one read, and a 120-child parent alongside a sibling. - Passed the 166-test focused UI/inbox/badge run, 47 additional UI regression tests, and 34 issue-list route tests. The latest regressions cover low-trust agent badge IDs, cold company selection, 30 description-only Mine matches with archive actions, retrying optional row details, and collapsed/expanded siblings. - The full local `pnpm test:run` spanned the rebase and reported failures in five suites before it was stopped; it is not claimed as a complete pass. A fresh serial rerun of all five suites passed **452/452** tests (tool gateway, native resume, project repositories, workspace recovery, and runner selection). Full current-commit coverage is checked by CI. - Real browser: delayed index and row-detail responses on Tasks and Mine. The first placeholder and first row had the same origin and 44-pixel height. Twelve rows on each page had zero position, height, or title-width change after recovery details arrived. - Real browser: reviewed long titles and recovery status at a 390 by 844 viewport. Task text stays on one line and recovery uses a compact indicator. - Three fresh document loads per page on final commit `260893bf92`, with HTTP cache retained on a warm local development server: Tasks 1.031–1.190 seconds; Mine 0.999–1.193 seconds. Browser-control overhead is included. These values must not be compared directly with staging latency or the earlier local samples taken during a full test run. - The browser archive test exposed stale results after Undo. The new projections now bypass the server TTL; immediate-mutation regression tests pass. The final browser retry passed: Archive followed by Undo restored exactly one row and the badge count. - CI exposed a hook-order crash when company selection resolved after the first inbox render. The hook now runs before the empty-company return. All seven affected exact-retry and legacy-continuation browser scenarios pass locally after the fix. - Real browser review regressions: 97 description-only source matches retained Archive actions; deliberately failed optional row reads displayed a retry notice while all 12 observed row positions and heights stayed unchanged. Retry restored the recovery indicator. Network fault injection was removed. - The final production build and workspace typecheck passed. Final focused verification passed 153 UI/helper tests and 26 heartbeat privacy route tests. Final review regressions cover newer diagnostics arriving during the index read, reloading diagnostics when the same task ID changes version, optional source failure/retry without hiding available rows, and required external-status filter failure. The latest-failures route test verifies bounded selection, agent filtering, success suppressing an older failure, and private-run redaction. - Real browser: deliberately failed the run source, then retried it. Twelve available task rows kept the same position and 44-pixel height before, during, and after retry. No loading skeleton replaced them. Fault injection was removed. - Hierarchy review decision: the cold-open barrier stays because inserting ancestors or expanded children after revealing index rows would cause layout shifts. Existing hierarchy context now survives subsequent requests, with a regression proving a new ancestor read does not blank the list. The bounded recursive tree read can still add latency for unusually deep expanded trees. - Tasks now handles a failed external-status filter with an explicit error and Retry, suppressing a false empty state. All 52 Tasks component tests pass, including failure-to-success retry. One existing large-tree test timed out while workspace typechecking ran concurrently; the whole component suite passed when rerun without that competing work. Workspace typecheck, the full production build, and token gates passed for this fix. ## Risks - Saved filters and grouping still operate on the existing bounded collection. This is not a new global server pagination model. - Fixed row slots reduce available title width. Long titles remain accessible through task navigation. - Recovery and related diagnostics arrive after core rows. Their responses cannot replace ordering fields, and stale versions are rejected. - Index/row requests now revalidate against current data instead of a short server TTL. They still share concurrent reads and use ETags. - This has not been deployed by this pull request. Staging latency must be measured after deployment under comparable conditions. ## Model Used OpenAI Codex, GPT-6 family. The exact runtime model ID and context-window size are not exposed in this session. Used code editing, terminal execution, and real-browser diagnostics. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
1755 lines
65 KiB
TypeScript
1755 lines
65 KiB
TypeScript
// @vitest-environment jsdom
|
||
|
||
import type { ComponentProps } from "react";
|
||
import { flushSync } from "react-dom";
|
||
import { createRoot } from "react-dom/client";
|
||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||
import type { Approval, HeartbeatRun, Issue } from "@paperclipai/shared";
|
||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||
import type { CompanyJoinRequest } from "../api/access";
|
||
import {
|
||
clearLocalInboxArchive,
|
||
getLocalInboxArchiveIssueIds,
|
||
} from "../lib/inboxArchiveCache";
|
||
import { taskCollectionPreferencesStorageKey } from "../lib/task-collection-preferences";
|
||
|
||
const routerMock = vi.hoisted(() => ({
|
||
location: { pathname: "/", search: "", hash: "" },
|
||
navigate: vi.fn(),
|
||
}));
|
||
const 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<typeof import("../api/access")>("../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 }) => (
|
||
<a className={className} href={to} {...props}>{children}</a>
|
||
),
|
||
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<void>) {
|
||
let result: void | Promise<void> = 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> = {}): 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<T>() {
|
||
let resolve!: (value: T) => void;
|
||
let reject!: (reason?: unknown) => void;
|
||
const promise = new Promise<T>((innerResolve, innerReject) => {
|
||
resolve = innerResolve;
|
||
reject = innerReject;
|
||
});
|
||
return { promise, resolve, reject };
|
||
}
|
||
|
||
function createJoinRequest(
|
||
overrides: Partial<CompanyJoinRequest> = {},
|
||
): 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> = {}): 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> = {}): 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(<QueryClientProvider client={queryClient}><Inbox /></QueryClientProvider>);
|
||
});
|
||
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(
|
||
<QueryClientProvider client={queryClient}>
|
||
<Inbox />
|
||
</QueryClientProvider>,
|
||
);
|
||
});
|
||
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(
|
||
<QueryClientProvider client={queryClient}>
|
||
<Inbox />
|
||
</QueryClientProvider>,
|
||
);
|
||
});
|
||
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(
|
||
<QueryClientProvider client={queryClient}>
|
||
<Inbox />
|
||
</QueryClientProvider>,
|
||
);
|
||
});
|
||
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<HTMLButtonElement>('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(
|
||
<QueryClientProvider client={queryClient}>
|
||
<Inbox />
|
||
</QueryClientProvider>,
|
||
);
|
||
});
|
||
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<HTMLButtonElement>('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(
|
||
<QueryClientProvider client={queryClient}>
|
||
<Inbox />
|
||
</QueryClientProvider>,
|
||
);
|
||
});
|
||
|
||
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(
|
||
<QueryClientProvider client={queryClient}>
|
||
<Inbox />
|
||
</QueryClientProvider>,
|
||
);
|
||
});
|
||
|
||
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(
|
||
<QueryClientProvider client={queryClient}>
|
||
<Inbox />
|
||
</QueryClientProvider>,
|
||
);
|
||
});
|
||
|
||
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(
|
||
<QueryClientProvider client={queryClient}>
|
||
<Inbox />
|
||
</QueryClientProvider>,
|
||
);
|
||
});
|
||
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(
|
||
<QueryClientProvider client={queryClient}>
|
||
<Inbox />
|
||
</QueryClientProvider>,
|
||
);
|
||
});
|
||
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(
|
||
<QueryClientProvider client={queryClient}>
|
||
<Inbox />
|
||
</QueryClientProvider>,
|
||
);
|
||
});
|
||
// 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(
|
||
<QueryClientProvider client={queryClient}>
|
||
<Inbox />
|
||
</QueryClientProvider>,
|
||
);
|
||
});
|
||
|
||
const groupButton = container.querySelector<HTMLButtonElement>('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<Issue[]>((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(<QueryClientProvider client={queryClient}><Inbox /></QueryClientProvider>));
|
||
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("can select a company after a cold inbox mount", async () => {
|
||
companyState.selectedCompanyId = null;
|
||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
|
||
const root = createRoot(container);
|
||
const render = () => root.render(<QueryClientProvider client={queryClient}><Inbox /></QueryClientProvider>);
|
||
await act(render);
|
||
await vi.waitFor(() => expect(container.textContent).toContain("Select an organization"));
|
||
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("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(<QueryClientProvider client={queryClient}><Inbox /></QueryClientProvider>));
|
||
await vi.waitFor(() => expect(container.querySelectorAll('[data-inbox-item]')).toHaveLength(30));
|
||
await act(() => {
|
||
const input = container.querySelector<HTMLInputElement>('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(<QueryClientProvider client={queryClient}><Inbox /></QueryClientProvider>));
|
||
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(<QueryClientProvider client={queryClient}><Inbox /></QueryClientProvider>));
|
||
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(<QueryClientProvider client={queryClient}><Inbox /></QueryClientProvider>));
|
||
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(<QueryClientProvider client={queryClient}><Inbox /></QueryClientProvider>));
|
||
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(
|
||
<QueryClientProvider client={queryClient}>
|
||
<Inbox />
|
||
</QueryClientProvider>,
|
||
);
|
||
});
|
||
|
||
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(
|
||
<QueryClientProvider client={queryClient}>
|
||
<Inbox />
|
||
</QueryClientProvider>,
|
||
);
|
||
});
|
||
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<HTMLAnchorElement>("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(
|
||
<QueryClientProvider client={queryClient}>
|
||
<Inbox />
|
||
</QueryClientProvider>,
|
||
);
|
||
});
|
||
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<HTMLAnchorElement>("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(
|
||
<QueryClientProvider client={queryClient}>
|
||
<Inbox />
|
||
</QueryClientProvider>,
|
||
);
|
||
});
|
||
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(
|
||
<QueryClientProvider client={queryClient}>
|
||
<Inbox />
|
||
</QueryClientProvider>,
|
||
);
|
||
});
|
||
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(
|
||
<QueryClientProvider client={queryClient}>
|
||
<Inbox />
|
||
</QueryClientProvider>,
|
||
);
|
||
});
|
||
await vi.waitFor(() => {
|
||
expect(container.textContent).toContain("First inbox row");
|
||
expect(container.textContent).toContain("Second inbox row");
|
||
});
|
||
|
||
const initialArchiveButtons = Array.from(
|
||
container.querySelectorAll<HTMLButtonElement>('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<HTMLButtonElement>(
|
||
'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(
|
||
<QueryClientProvider client={queryClient}>
|
||
<Inbox />
|
||
</QueryClientProvider>,
|
||
);
|
||
});
|
||
await vi.waitFor(() => {
|
||
expect(container.textContent).toContain("Archived inbox row");
|
||
});
|
||
|
||
const archiveButton = container.querySelector<HTMLButtonElement>('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<Issue[]>(
|
||
{ 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(
|
||
<QueryClientProvider client={queryClient}>
|
||
<Inbox />
|
||
</QueryClientProvider>,
|
||
);
|
||
});
|
||
await vi.waitFor(() => {
|
||
expect(container.textContent).toContain("Undoable inbox row");
|
||
});
|
||
|
||
const archiveButton = container.querySelector<HTMLButtonElement>('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(<FailedRunInboxRow run={createFailedRun({ issueId: issue.id, error: "Internal process identity missing" })}
|
||
issueById={new Map([[issue.id, issue]])} agentName="Worker" issueLinkState={null}
|
||
onDismiss={() => {}} 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(
|
||
<FailedRunInboxRow
|
||
run={run}
|
||
issueById={new Map()}
|
||
agentName="Agent"
|
||
issueLinkState={null}
|
||
onDismiss={() => {}}
|
||
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(<InboxIssueMetaLeading issue={createIssue()} isLive />);
|
||
});
|
||
|
||
// The status glyph is an <svg> 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(
|
||
<InboxIssueTrailingColumns
|
||
issue={createIssue({ labels: [], labelIds: [] })}
|
||
columns={["labels"]}
|
||
projectName={null}
|
||
projectColor={null}
|
||
workspaceName={null}
|
||
assigneeName={null}
|
||
currentUserId={null}
|
||
parentIdentifier={null}
|
||
parentTitle={null}
|
||
/>,
|
||
);
|
||
});
|
||
|
||
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(
|
||
<InboxIssueTrailingColumns
|
||
issue={createIssue()}
|
||
columns={["workspace"]}
|
||
projectName={null}
|
||
projectColor={null}
|
||
workspaceName={null}
|
||
assigneeName={null}
|
||
currentUserId={null}
|
||
parentIdentifier={null}
|
||
parentTitle={null}
|
||
/>,
|
||
);
|
||
});
|
||
|
||
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(<InboxGroupHeader label="Primary workspace (default)" collapsible collapsed={false} />);
|
||
});
|
||
|
||
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(<InboxGroupHeader label="Feature Branch" collapsible collapsed />);
|
||
});
|
||
|
||
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();
|
||
});
|
||
});
|
||
});
|