mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 19:28:46 +01:00
## Thinking Path > - Paperclip helps people manage AI agents and their work. > - The Mine inbox combines task rows with approval and failure items. > - It waits for parent captions before it reveals the initial list. > - It fetched rich diagnostics for every parent, including parents already in the task index. > - Parent captions only use fields that the index already provides. > - This change reuses loaded parents and fetches missing parent metadata through the index view. > - Failure items keep their rich interruption data and the initial reveal boundary stays in place. ## Linked Issues or Issue Description **What existing behavior does this improve?** The initial load of Mine and other inbox views that need parent task captions. **Subsystem affected** `ui/` collection reads. The API contract and database schema stay the same. **Current behavior** The inbox fetches all parent and failed-run task IDs with `view=rows` after the primary task request. This adds active-run, blocker, review, interruption and recovery work to the reveal path. The observed hosted case fetched four parents. One was already in the 31-row task index. The other three were completed parents. The serial context response took 1.0–2.9 seconds during the diagnostic reloads. **Proposed behavior** Reuse parents from the selected source index. Fetch missing parents with `view=index`. Keep `view=rows` for every failed-run task, including overlaps with parents or source rows. Keep both role-specific ID lists in the query key. Drop old placeholder rows when the new index supplies a parent. **Reason and benefit** This removes unnecessary diagnostic work from a required serial request. Five alternating same-four-parent requests on the hosted app had index times of 189, 162, 156, 128 and 169 ms, versus rich-row times of 282, 211, 329, 219 and 284 ms. All requests missed the response cache. All consumed parent fields matched. This isolates the projection cost; it is not a deployed UI speedup claim. **Breaking changes** None. Membership, ordering, nesting, archive state, authorization and failure presentation retain their owners and contracts. Refs: #18323 and #18492 for the prior collection and startup work. This change addresses the remaining parent-context read. ## What Changed - Partition missing parent metadata from failed-run context. Load the two batches concurrently, with at most two batch requests in flight. - Keep richer failure rows last in the context result. Filter placeholders by company and current requested IDs. Track completed failure reads, including missing tasks, so a parent placeholder cannot reveal an unverified failure card. Keep existing verified cards mounted through refresh. - Cover delayed captions, reuse of indexed parents, refreshed parent captions, failure overlaps, resolved failures and parent-to-failure cache transitions. - Add a real browser check for delayed parent metadata and stable geometry through deferred diagnostics. Give the test its own disposable company. - Add a reusable local timing harness and update the collection verification recipe. ## Verification - Baseline source: `0710c5684243a663c09ff907011ab88838173a02`. Final measured candidate: `c8c69b5af11966b4144707a3da21e840c73c6cca`. Production UI assets were built from the same checkout and dependencies, with only the Inbox module changed between variants. - Targeted regression failed on the old implementation and passed after the change. All 58 Inbox component tests pass, including existing archive/undo and order checks. - Full workspace typecheck passes. The final UI typecheck passes. Full build passes. Token gates, map checks and diff whitespace checks pass. - All 12 collection browser checks pass, including pagination and reading-position restoration. - The full CI workflow passes on `c8c69b5af11966b4144707a3da21e840c73c6cca`, including all server, chat, workspace, serialized, runner and E2E shards. [CI run](https://github.com/paperclipai/paperclip/actions/runs/41502803275). Greptile reports 5/5 on this commit with no unresolved threads. The separate Paperclip Review check is still queued. Its first attempt saved a text result but did not complete the GitHub assessment. Normal retry was rejected because that attempt already recorded a result. The current-head review request is queued. The review service is now unavailable, so this external gate remains open. - The duplicate local `pnpm test:run` was interrupted after the equivalent CI run passed. Its six chat shards passed 1,828 tests. The general-server phase was stopped. This local full-suite run is incomplete, not a passing result. - Local synthetic fixture: four parents and 30 children, with three completed parents archived. The fixture also has 30 archived child records. No agent or provider calls ran. Mac arm64, Node 24.22.2, Chromium, production assets, 1440×900, native CPU, loopback network, fresh HTTP/query caches for each cold sample. - Five alternating local pairs produced identical 31-row membership and order and no browser errors: | Journey | Baseline median (range), ms | Candidate median (range), ms | Median paired saving, ms | | --- | --- | --- | --- | | Cold first usable Mine rows | 310 (252–435) | 258 (250–261) | 50 | | Warm Recent → Mine | 60 (56–64) | 67 (61–71) | -7 | | Task → Back to Mine | 42 (40–47) | 43 (40–45) | 0 | The final cold result has a material control limitation. The median primary-request start was 259 ms for baseline and 210 ms for candidate. Much of the cold difference occurs before the changed request. The parent response medians were 19.3 and 13.5 ms, with fewer parent IDs requested. Do not attribute the full 52 ms difference between cold medians to this fix. Four cold pairs were faster and one was 6 ms slower. Warm Recent → Mine was about 7 ms slower at the median; Back was unchanged. These runs establish no warm-navigation gain. The earlier five-pair experiment showed the same startup confound and mixed warm/Back results. All samples remain in the local evidence. A hosted final-shape comparison timed out and was cancelled by closing its owned browser tab; it supplies no result. The candidate has not been deployed. Reproduce the contract and geometry check against an isolated production UI with `pnpm exec playwright test --config tests/perf/collections/playwright.config.ts parent-context.spec.ts`. Run `tests/perf/collections/measure-mine-context.ts` against baseline and candidate loopback production origins that share one disposable API, as documented in `tests/perf/collections/README.md`. Preserve upstream startup timings as controls. Required parent responses remain inside the reveal boundary. ## Risks - Explicit-ID `index` and `rows` requests use the same company, visibility and actor filters. They do not use the inbox archive filter. An archived parent is still returned by either projection. A deleted or unreadable parent is absent in both. A second index request for a failure-parent overlap would not restore missing caption data. - A failed-run task must never reuse parent-only metadata. The query key includes each projection role. Completed reads carry explicit failure-ID coverage. The tests cover overlaps, pending role changes, late heartbeat data, known absence, and transitions to empty views. - Old parent context must not override a fresh indexed parent. The current-ID placeholder filter and caption-refresh regression cover this case. - The hosted app still has unrelated server contention. This PR reduces parent request work. It does not claim to fix all hosted loading delay. Deployment acceptance should confirm the serving revision, compare matched Mine reloads and inspect errors and row stability. Revert this change if failure items or captions regress. ## Model Used OpenAI Codex, based on GPT-6, with reasoning, tool use, browser diagnostics and code execution. The exact runtime model identifier and context-window size are not exposed by this session. ## 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>
1987 lines
82 KiB
TypeScript
1987 lines
82 KiB
TypeScript
// @vitest-environment jsdom
|
||
|
||
import { CoordinatorPresentation } from "../components/coordinator/CoordinatorPresentation";
|
||
import { queryKeys } from "../lib/queryKeys";
|
||
import type { ComponentProps } from "react";
|
||
import { flushSync } from "react-dom";
|
||
import { createRoot } from "react-dom/client";
|
||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||
import type { Approval, HeartbeatRun, Issue } from "@paperclipai/shared";
|
||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||
import type { CompanyJoinRequest } from "../api/access";
|
||
import {
|
||
clearLocalInboxArchive,
|
||
getLocalInboxArchiveIssueIds,
|
||
} from "../lib/inboxArchiveCache";
|
||
import { taskCollectionPreferencesStorageKey } from "../lib/task-collection-preferences";
|
||
|
||
const routerMock = vi.hoisted(() => ({
|
||
location: { pathname: "/", search: "", hash: "" },
|
||
navigate: vi.fn(),
|
||
}));
|
||
const companyState = vi.hoisted(() => ({ selectedCompanyId: "company-1" as string | null }));
|
||
|
||
const externalObjectMocks = vi.hoisted(() => ({
|
||
summaries: new Map(),
|
||
error: null as Error | null,
|
||
retry: vi.fn(),
|
||
}));
|
||
|
||
const apiMocks = vi.hoisted(() => ({
|
||
approvalsList: vi.fn(),
|
||
joinRequestsList: vi.fn(),
|
||
userDirectoryList: vi.fn(),
|
||
authSession: vi.fn(),
|
||
dashboardSummary: vi.fn(),
|
||
executionWorkspaceSummaries: vi.fn(),
|
||
issuesList: vi.fn(),
|
||
issuesCount: vi.fn(),
|
||
issueLabels: vi.fn(),
|
||
retryTask: vi.fn(),
|
||
archiveFromInbox: vi.fn(),
|
||
unarchiveFromInbox: vi.fn(),
|
||
agentsList: vi.fn(),
|
||
heartbeatRunsList: vi.fn(),
|
||
liveRunsForCompany: vi.fn(),
|
||
experimentalSettings: vi.fn(),
|
||
projectsList: vi.fn(),
|
||
}));
|
||
|
||
vi.mock("../api/approvals", () => ({
|
||
approvalsApi: { list: apiMocks.approvalsList },
|
||
}));
|
||
|
||
vi.mock("../api/access", async () => {
|
||
const actual = await vi.importActual<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.each([true, false])("can select a company after a cold inbox mount (Streamlined UI=%s)", async streamlined => {
|
||
companyState.selectedCompanyId = null;
|
||
const settings = { enableStreamlinedUi: streamlined, enableIsolatedWorkspaces: false };
|
||
apiMocks.experimentalSettings.mockResolvedValue(settings);
|
||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
|
||
queryClient.setQueryData(queryKeys.instance.experimentalSettings, settings);
|
||
const root = createRoot(container);
|
||
const render = () => root.render(<QueryClientProvider client={queryClient}><Inbox /></QueryClientProvider>);
|
||
await act(render);
|
||
await vi.waitFor(() => expect(container.textContent).toContain(streamlined ? "Select an organization" : "Select a company"));
|
||
companyState.selectedCompanyId = "company-1";
|
||
apiMocks.issuesList.mockResolvedValue([createIssue({ title: "Cold company task" })]);
|
||
await act(render);
|
||
await vi.waitFor(() => expect(container.textContent).toContain("Cold company task"));
|
||
act(() => root.unmount());
|
||
});
|
||
|
||
it("loads only missing parent metadata before revealing Mine", async () => {
|
||
routerMock.location.pathname = "/inbox/mine";
|
||
localStorage.setItem(taskCollectionPreferencesStorageKey({ companyId: "company-1", collectionKey: "inbox" }), JSON.stringify({
|
||
version: 1, companyId: "company-1", collectionKey: "inbox", columns: ["parent"], viewState: {},
|
||
}));
|
||
const loadedParent = createIssue({ id: "loaded-parent", title: "Loaded parent", listProjection: "index" });
|
||
const missingParent = createIssue({ id: "missing-parent", title: "Missing parent", identifier: "PAP-2000", listProjection: "index" });
|
||
const rows = [loadedParent,
|
||
createIssue({ id: "child-a", parentId: loadedParent.id, title: "First child", listProjection: "index" }),
|
||
createIssue({ id: "child-b", parentId: missingParent.id, title: "Second child", listProjection: "index" }),
|
||
];
|
||
let releaseParent!: (rows: Issue[]) => void;
|
||
const pendingParent = new Promise<Issue[]>(resolve => { releaseParent = resolve; });
|
||
apiMocks.issuesList.mockImplementation((_company, filters) => {
|
||
if (!filters.ids) return Promise.resolve(rows);
|
||
if (filters.ids.includes(missingParent.id)) return pendingParent;
|
||
return Promise.resolve(rows.filter(row => filters.ids.includes(row.id)));
|
||
});
|
||
const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
|
||
const root = createRoot(container);
|
||
try {
|
||
await act(() => root.render(<QueryClientProvider client={client}><Inbox /></QueryClientProvider>));
|
||
await vi.waitFor(() => expect(apiMocks.issuesList.mock.calls.some(([, filters]) => filters.ids?.includes(missingParent.id))).toBe(true));
|
||
expect(container.querySelectorAll("[data-inbox-item]")).toHaveLength(0);
|
||
expect(apiMocks.issuesList).toHaveBeenCalledWith("company-1", expect.objectContaining({
|
||
ids: [missingParent.id], projection: "index",
|
||
}), expect.anything());
|
||
await act(() => releaseParent([missingParent]));
|
||
await vi.waitFor(() => expect(container.textContent).toContain("Second child"));
|
||
// Rich visible-row reads still happen after reveal, but never include a
|
||
// context-only parent or precede the structural metadata response.
|
||
expect(apiMocks.issuesList.mock.calls.filter(([, filters]) => filters.ids?.includes(missingParent.id))).toHaveLength(1);
|
||
expect(container.querySelectorAll("[data-inbox-item]")).toHaveLength(3);
|
||
expect(container.querySelector(`[title="${missingParent.title}"]`)?.textContent).toBe(missingParent.identifier);
|
||
} finally { await act(() => root.unmount()); client.clear(); }
|
||
});
|
||
|
||
it("discards old parent context when a refreshed index supplies the parent", async () => {
|
||
routerMock.location.pathname = "/inbox/mine";
|
||
localStorage.setItem("paperclip:inbox:nesting", "false");
|
||
localStorage.setItem(taskCollectionPreferencesStorageKey({ companyId: "company-1", collectionKey: "inbox" }), JSON.stringify({
|
||
version: 1, companyId: "company-1", collectionKey: "inbox", columns: ["parent"], viewState: {},
|
||
}));
|
||
const parent = createIssue({ id: "parent", title: "Old parent caption", identifier: "PAP-2000", listProjection: "index" });
|
||
const child = createIssue({ id: "child", parentId: parent.id, title: "Readable child", listProjection: "index" });
|
||
let source = [child];
|
||
apiMocks.issuesList.mockImplementation((_company, filters) => !filters.ids
|
||
? Promise.resolve(source) : Promise.resolve(filters.ids.includes(parent.id) ? [parent] : [child]));
|
||
const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity, gcTime: 0 } } });
|
||
const root = createRoot(container);
|
||
try {
|
||
await act(() => root.render(<QueryClientProvider client={client}><Inbox /></QueryClientProvider>));
|
||
await vi.waitFor(() => expect(container.querySelector(`[title="${parent.title}"]`)?.textContent).toBe(parent.identifier));
|
||
source = [{ ...parent, title: "Fresh indexed caption", identifier: "PAP-2001" }, child];
|
||
await act(() => { client.setQueriesData({ predicate: query => query.queryKey.includes("live-descendant-summary") }, source); });
|
||
await vi.waitFor(() => expect(container.querySelector('[title="Fresh indexed caption"]')?.textContent).toBe("PAP-2001"));
|
||
expect(container.querySelector(`[title="${parent.title}"]`)).toBeNull();
|
||
expect(apiMocks.issuesList.mock.calls.filter(([, filters]) => filters.projection === "index" && filters.ids?.includes(parent.id))).toHaveLength(1);
|
||
} finally { await act(() => root.unmount()); client.clear(); }
|
||
});
|
||
|
||
it.each([true, false])("waits for failure context even when its parent is already indexed=%s", async (indexed) => {
|
||
routerMock.location.pathname = "/inbox/mine";
|
||
const parent = createIssue({ id: "parent", title: "Parent metadata", listProjection: "index" });
|
||
const child = createIssue({ id: "child", parentId: parent.id, title: "Readable child", listProjection: "index" });
|
||
const failure = createFailedRun({ issueId: parent.id, responsibleUserId: "local-board" });
|
||
apiMocks.heartbeatRunsList.mockResolvedValue([failure]);
|
||
let releaseFailure!: (rows: Issue[]) => void;
|
||
const pendingFailure = new Promise<Issue[]>(resolve => { releaseFailure = resolve; });
|
||
apiMocks.issuesList.mockImplementation((_company, filters) => !filters.ids
|
||
? Promise.resolve(indexed ? [parent, child] : [child])
|
||
: filters.ids.includes(parent.id) ? pendingFailure : Promise.resolve([child]));
|
||
const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
|
||
const root = createRoot(container);
|
||
try {
|
||
await act(() => root.render(<QueryClientProvider client={client}><Inbox /></QueryClientProvider>));
|
||
await vi.waitFor(() => expect(apiMocks.issuesList).toHaveBeenCalledWith("company-1", expect.objectContaining({
|
||
ids: [parent.id], projection: "rows",
|
||
}), expect.anything()));
|
||
expect(container.querySelectorAll("[data-inbox-item]")).toHaveLength(0);
|
||
expect(apiMocks.issuesList.mock.calls.filter(([, filters]) => filters.ids && filters.projection === "index")).toHaveLength(0);
|
||
await act(() => releaseFailure([{ ...parent, interruption: {
|
||
intentId: "saved-input", responsibility: "system", state: "resolved", nextRetryAt: null,
|
||
canRetry: false, userAction: null, sourceRunIds: [failure.id], observedRunId: failure.id,
|
||
} }]));
|
||
await vi.waitFor(() => expect(container.textContent).toContain(child.title));
|
||
expect(container.querySelectorAll("[data-inbox-item]")).toHaveLength(indexed ? 2 : 1);
|
||
} finally { await act(() => root.unmount()); client.clear(); }
|
||
});
|
||
|
||
it("upgrades cached parent metadata when the same task becomes a failure context", async () => {
|
||
routerMock.location.pathname = "/inbox/mine";
|
||
const parent = createIssue({ id: "parent", title: "Parent metadata", listProjection: "index" });
|
||
const child = createIssue({ id: "child", parentId: parent.id, title: "Readable child", listProjection: "index" });
|
||
const failure = createFailedRun({ issueId: parent.id, responsibleUserId: "local-board" });
|
||
const resolved = { ...parent, listProjection: "rows" as const, interruption: {
|
||
intentId: "saved-input", responsibility: "system" as const, state: "resolved" as const,
|
||
nextRetryAt: null, canRetry: false, userAction: null, sourceRunIds: [failure.id], observedRunId: failure.id,
|
||
} };
|
||
let releaseFailure!: (rows: Issue[]) => void;
|
||
const pendingFailure = new Promise<Issue[]>(resolve => { releaseFailure = resolve; });
|
||
apiMocks.issuesList.mockImplementation((_company, filters) => {
|
||
if (!filters.ids) return Promise.resolve([child]);
|
||
if (filters.ids.includes(parent.id)) return filters.projection === "rows" ? pendingFailure : Promise.resolve([parent]);
|
||
return Promise.resolve([child]);
|
||
});
|
||
const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity, gcTime: 0 } } });
|
||
const root = createRoot(container);
|
||
try {
|
||
await act(() => root.render(<QueryClientProvider client={client}><Inbox /></QueryClientProvider>));
|
||
await vi.waitFor(() => expect(container.textContent).toContain(child.title));
|
||
// The union of context IDs is unchanged when this parent becomes a failed
|
||
// task. It must still fetch interruption state instead of reusing index.
|
||
await act(() => { client.setQueriesData({ queryKey: queryKeys.heartbeats("company-1") }, [failure]); });
|
||
await vi.waitFor(() => expect(apiMocks.issuesList).toHaveBeenCalledWith("company-1", expect.objectContaining({
|
||
ids: [parent.id], projection: "rows",
|
||
}), expect.anything()));
|
||
expect(container.querySelectorAll("[data-inbox-item]")).toHaveLength(1);
|
||
await act(() => releaseFailure([resolved]));
|
||
await vi.waitFor(() => expect(client.isFetching()).toBe(0));
|
||
expect(container.textContent).toContain(child.title);
|
||
expect(container.querySelectorAll("[data-inbox-item]")).toHaveLength(1);
|
||
} finally { await act(() => root.unmount()); client.clear(); }
|
||
});
|
||
|
||
it("reveals an empty view after leaving a Mine view with parent context", async () => {
|
||
routerMock.location.pathname = "/inbox/mine";
|
||
const parent = createIssue({ id: "parent", title: "Parent metadata", listProjection: "index" });
|
||
const child = createIssue({ id: "child", parentId: parent.id, title: "Mine child", listProjection: "index" });
|
||
apiMocks.issuesList.mockImplementation((_company, filters) => Promise.resolve(filters.ids
|
||
? [parent] : filters.inboxArchivedByUserId ? [child] : []));
|
||
const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
|
||
const root = createRoot(container);
|
||
try {
|
||
await act(() => root.render(<QueryClientProvider client={client}><Inbox tab="mine" /></QueryClientProvider>));
|
||
await vi.waitFor(() => expect(container.textContent).toContain(child.title));
|
||
routerMock.location.pathname = "/inbox/recent";
|
||
await act(() => root.render(<QueryClientProvider client={client}><Inbox tab="recent" /></QueryClientProvider>));
|
||
await vi.waitFor(() => expect(container.textContent).toContain("No recent inbox items."));
|
||
expect(container.querySelector('[aria-label="Loading tasks"]')).toBeNull();
|
||
} finally { await act(() => root.unmount()); client.clear(); }
|
||
});
|
||
|
||
it("waits for rich failure state when heartbeat data arrives after parent captions", async () => {
|
||
routerMock.location.pathname = "/inbox/mine";
|
||
const parent = createIssue({ id: "parent", title: "Parent metadata", listProjection: "index" });
|
||
const child = createIssue({ id: "child", parentId: parent.id, title: "Mine child", listProjection: "index" });
|
||
const failure = createFailedRun({ issueId: parent.id, responsibleUserId: "local-board" });
|
||
let releaseRuns!: (rows: HeartbeatRun[]) => void;
|
||
apiMocks.heartbeatRunsList.mockImplementation(() => new Promise(resolve => { releaseRuns = resolve; }));
|
||
let releaseFailure!: (rows: Issue[]) => void;
|
||
const pendingFailure = new Promise<Issue[]>(resolve => { releaseFailure = resolve; });
|
||
apiMocks.issuesList.mockImplementation((_company, filters) => !filters.ids ? Promise.resolve([child])
|
||
: filters.projection === "index" ? Promise.resolve([parent]) : filters.ids.includes(parent.id) ? pendingFailure : Promise.resolve([child]));
|
||
const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
|
||
const root = createRoot(container);
|
||
try {
|
||
await act(() => root.render(<QueryClientProvider client={client}><Inbox /></QueryClientProvider>));
|
||
await vi.waitFor(() => expect(client.isFetching()).toBe(1));
|
||
expect(container.querySelectorAll("[data-inbox-item]")).toHaveLength(0);
|
||
await act(() => releaseRuns([failure]));
|
||
await vi.waitFor(() => expect(apiMocks.issuesList).toHaveBeenCalledWith("company-1", expect.objectContaining({
|
||
ids: [parent.id], projection: "rows",
|
||
}), expect.anything()));
|
||
expect(container.querySelectorAll("[data-inbox-item]")).toHaveLength(0);
|
||
await act(() => releaseFailure([{ ...parent, interruption: {
|
||
intentId: "saved-input", responsibility: "system", state: "resolved", nextRetryAt: null,
|
||
canRetry: false, userAction: null, sourceRunIds: [failure.id], observedRunId: failure.id,
|
||
} }]));
|
||
await vi.waitFor(() => expect(container.textContent).toContain(child.title));
|
||
expect(container.querySelectorAll("[data-inbox-item]")).toHaveLength(1);
|
||
} finally { await act(() => root.unmount()); client.clear(); }
|
||
});
|
||
|
||
it("keeps known missing-task failures visible while new failure context is pending", async () => {
|
||
routerMock.location.pathname = "/inbox/mine";
|
||
const child = createIssue({ title: "Readable task", listProjection: "index" });
|
||
const first = createFailedRun({ id: "failure-a", issueId: "missing-a", agentId: "agent-a", responsibleUserId: "local-board" });
|
||
const second = createFailedRun({ id: "failure-b", issueId: "missing-b", agentId: "agent-b", responsibleUserId: "local-board" });
|
||
apiMocks.heartbeatRunsList.mockResolvedValue([first]);
|
||
let releaseFailures!: (rows: Issue[]) => void;
|
||
const pendingFailures = new Promise<Issue[]>(resolve => { releaseFailures = resolve; });
|
||
apiMocks.issuesList.mockImplementation((_company, filters) => !filters.ids ? Promise.resolve([child])
|
||
: filters.ids.includes(second.issueId) ? pendingFailures : Promise.resolve([]));
|
||
const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
|
||
const root = createRoot(container);
|
||
try {
|
||
await act(() => root.render(<QueryClientProvider client={client}><Inbox /></QueryClientProvider>));
|
||
await vi.waitFor(() => expect(container.querySelector('a[href="/issues/missing-a"]')).not.toBeNull());
|
||
const node = container.querySelector('a[href="/issues/missing-a"]');
|
||
await act(() => { client.setQueriesData({ queryKey: queryKeys.heartbeats("company-1") }, [first, second]); });
|
||
await vi.waitFor(() => expect(apiMocks.issuesList).toHaveBeenCalledWith("company-1", expect.objectContaining({
|
||
ids: [first.issueId, second.issueId], projection: "rows",
|
||
}), expect.anything()));
|
||
expect(container.querySelector('a[href="/issues/missing-a"]')).toBe(node);
|
||
expect(container.querySelector('a[href="/issues/missing-b"]')).toBeNull();
|
||
expect(container.textContent).toContain(child.title);
|
||
await act(() => releaseFailures([]));
|
||
await vi.waitFor(() => expect(container.querySelector('a[href="/issues/missing-b"]')).not.toBeNull());
|
||
expect(container.querySelectorAll("[data-inbox-item]")).toHaveLength(3);
|
||
} finally { await act(() => root.unmount()); client.clear(); }
|
||
});
|
||
|
||
it("keeps more than 25 description-only matches in Mine with archive actions", async () => {
|
||
routerMock.location.pathname = "/inbox/mine";
|
||
const rows = Array.from({ length: 30 }, (_, index) => createIssue({ id: `source-${index}`, title: `Source task ${index}`, identifier: `PAP-${index}`, listProjection: "index" }));
|
||
apiMocks.issuesList.mockImplementation((_company, filters) => Promise.resolve(filters.q && !filters.ids ? [] : rows));
|
||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
|
||
const root = createRoot(container);
|
||
await act(() => root.render(<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();
|
||
});
|
||
});
|
||
});
|
||
|
||
|
||
describe("Coordinator Inbox collection reads", () => {
|
||
it.each([true, false])("keeps optimized reads with Coordinator and Streamlined=%s", async (streamlined) => {
|
||
resetInboxApiMocks(); localStorage.clear();
|
||
routerMock.location.pathname = "/inbox/mine";
|
||
const settings = { enableIsolatedWorkspaces: false, enableStreamlinedUi: streamlined };
|
||
apiMocks.experimentalSettings.mockResolvedValue(settings);
|
||
const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity, gcTime: 0 } } });
|
||
client.setQueryData(queryKeys.instance.experimentalSettings, settings);
|
||
const container = document.createElement("div"); document.body.appendChild(container);
|
||
const root = createRoot(container);
|
||
try {
|
||
await act(async () => root.render(<QueryClientProvider client={client}><CoordinatorPresentation.Provider value={true}><Inbox /></CoordinatorPresentation.Provider></QueryClientProvider>));
|
||
await vi.waitFor(() => expect(apiMocks.heartbeatRunsList).toHaveBeenCalled());
|
||
await act(async () => { await new Promise(resolve => setTimeout(resolve, 30)); });
|
||
const sources = apiMocks.issuesList.mock.calls.filter(call => call[1]?.projection !== "rows").map(call => call[1]);
|
||
expect(sources).toHaveLength(1);
|
||
expect(sources.every(source => source.projection === "index")).toBe(true);
|
||
expect(apiMocks.dashboardSummary).toHaveBeenCalledTimes(0);
|
||
expect(apiMocks.heartbeatRunsList.mock.calls[0]?.[3]?.latestFailuresOnly === true).toBe(true);
|
||
} finally { await act(async () => root.unmount()); client.clear(); container.remove(); }
|
||
});
|
||
});
|