Files
PaperClipAI/ui/src/pages/Inbox.test.tsx
T
DottaandPaperclip b40e6edaea perf(inbox): combine startup reads and reduce refetch pressure (#18790)
## Thinking Path

> - Paperclip helps people manage AI agents and their work.
> - Mine shows the work and failures that need the viewing user's
attention.
> - A reload waits for account access, task rows, parent captions and
failure state.
> - Those reads form several browser request stages before useful rows
appear.
> - Live invalidations can also restart the same task-list request more
than once.
> - This change supplies startup data together and removes redundant
refresh work.
> - It preserves current authority, failure presentation, archive state
and row stability.

## Linked Issues or Issue Description

Refs #18645. Related: #18642. This change implements the read-receipt
behavior independently on current master. It does not use that PR's
branch or patch.

**What happened?**

Mine starts its primary request after the session, company and
board-access reads. It then requests parent captions and rich failure
context. Four observed loads spent another 0.45–0.70 seconds after the
primary rows arrived. One slower load waited 1.71 seconds for parent
context. The same run observed two HTTP 429 responses. These timings
identify request waits; they do not attribute the delay to SQL or Node
CPU.

Read receipts also invalidate general task collections and dashboards.
Overlapping parent and child invalidations can start two requests and
abort the first.

**Expected behavior**

The initial page should use one account-safe startup projection and one
complete Mine response. A read receipt should refresh the affected
viewer's inbox state. Each invalidation batch should refresh a covered
query once. Required captions and current failure state must be ready
before first reveal. Existing rows should remain usable during a
transient refresh error.

**Steps to reproduce**

1. Open a populated Mine inbox with parent tasks outside the visible
index and a current failed run.
2. Reload with browser HTTP cache retained and application query state
empty.
3. Inspect the session, company, board access, task-index and context
request sequence.
4. Open or mark a task read and inspect the unrelated collection
refetches.

**Paperclip version or commit**

Baseline: `44a9a043b80c18314d8dd89556f444dae696d3d6`.

**Deployment mode**

Authenticated board UI. Local proof uses the real APIs and database with
a synthetic session resolver. No hosted candidate deployment is claimed.

## What Changed

- Add optional, same-user `boardBootstrap` data to the existing session
response. Seed account-keyed company and board-access reads from it.
Keep ordinary endpoint reads for fallback, retry and live
reconciliation. A returning account cannot reuse revoked cached access.
Cache collection and later real refetches remain effective.
- Add the board-only Mine snapshot endpoint. Reuse the canonical issue
selector, private-task filtering, run-read decisions, secret redaction
and interruption/recovery presentation. Assemble the fixed 100-task
page, parent captions and bounded 200-run failure window on the server.
Start independent reads together. Optional source errors keep successful
task rows usable.
- Keep array-shaped source caches for archive and undo. Key Mine source,
context and reveal state by account. Validate the response's user stamp
before committing it. Remove array-only cross-tab Mine broadcasts, which
cannot carry its paired failure context. The server retains
authenticated in-flight coalescing.
- Scope read-receipt invalidation to the affected viewer's inbox, badges
and chat unread state. Keep company and task audit views fresh. Preserve
genuine task mutations, run updates and subscription-gap reconciliation.
- Remove covered scalar-prefix invalidations within a batch. Preserve
predicates, exact matches and different query/refetch constraints. A
later event can still refresh again.
- Retire Mine in-flight reuse after local company activity or run
lifecycle changes. A post-archive read starts fresh. Old work keeps its
admission slot until it settles, even if every HTTP client aborts. The
eight-compute limit stays in place.
- Add API, component and browser regressions. Extend the production
timing harness with matching API/UI artifact receipts, retained-cache
reloads, request status counts, layout checks and optional controlled
network latency. Update API and verification documentation.

## Verification

- Full `pnpm -r typecheck` passes.
- Full `pnpm build` passes.
- Token gates, map checks and diff whitespace checks pass.
- 72 Inbox component tests pass. Startup-specific checks pass, including
account switching, revoked cached access, malformed/absent bootstrap
data and cache collection. The canonical authenticated bootstrap route
also passes against real PostgreSQL.
- Six real PostgreSQL Mine route cases pass, including full equivalence
with legacy authorized projections, privacy, fixed bounds, membership
revocation, coalescing, archive freshness and aborted-client admission.
Four focused legacy coordinator controls pass. Six assembly tests pass.
- Refetch and invalidation regressions pass. Meaningful pre-fix tests
observed two request starts and one abort for overlapping invalidations;
the fix produces one start and no abort. The archive race test observed
the post-archive GET joining the old body before the fix.
- All 11 existing startup browser checks pass, including Tasks/Mine code
loading, desktop/mobile bounds, non-streamlined Coordinator, task
navigation, failed preloading and Back position. The nine focused
startup, fallback, partial-source error, rate-limit recovery,
archive/undo and geometry cases also pass. The 429 cases use the
inherited retry window. Archive and keyboard undo are corroborated
through the real API and reload. Authentication and failure fault
injection in these cases are explicitly simulated.
- Matched production before/after timings are complete. Full aggregate
Vitest coverage is left to CI. At 15:15 UTC, the final-head runner gate
and `review` workflow pass. The 45 build/test/policy jobs are queued;
the Build job has no `ubuntu-latest` runner assigned and has executed no
steps, so remote aggregate results remain unverified. Paperclip Review
and Superagent Security Scan are in progress. Superseded runs for this
PR were cancelled. No current-head check has reported a failure; the CI
checklist remains open. The remaining step is runner allocation and
completion of those external checks; persistent queueing needs the
repository CI owner's attention.
- Greptile completed on final head
`ecde295565309275c4f2fae903caf6892f1eaafd` at 5/5. The map-scope finding
is fixed and resolved; there are no open review threads.

### Matched browser timings

Five alternating baseline/candidate pairs per network scenario, measured
serially
in headful Chrome 156.0.8078.80 on Apple silicon with Node 24.22.2 and a
1440×900
viewport. Both production UIs use their matching real API revision
against one
fixed disposable PostgreSQL fixture: 34 tasks, 31 visible task rows, one
paused
agent and one terminal failure. No active agent/provider work. HTTP
cache is
retained; each initial sample reloads the document and clears
application query
state. Warm navigation and Back retain query state. CPU and throughput
are native.
The 100ms scenario uses CDP minimum request-to-response-header latency
without
request interception. It is a controlled request-dependency experiment,
not a
staging reproduction.

Values are median milliseconds with min–max in parentheses. Paired
saving is
baseline minus candidate for each alternating pair; its median differs
from the
difference of arm medians. Positive saving favors the candidate.

| Network / journey | Baseline | Candidate | Median paired saving |
| --- | ---: | ---: | ---: |
| Native / initial rows | 187.6 (128.9–208.5) | 191.7 (130.3–210.9) |
−23.3 |
| Native / Recent → Mine | 100.17 (99.90–101.87) | 100.07 (99.88–101.37)
| −0.16 |
| Native / task → Back | 70.45 (56.63–72.02) | 66.64 (56.52–67.71) |
+4.94 |
| 100ms / initial rows | 930.3 (914.1–1015.7) | 799.0 (697.1–826.0) |
+211.8 |
| 100ms / Recent → Mine | 100.09 (97.73–100.16) | 100.18 (96.65–100.98)
| −0.02 |
| 100ms / task → Back | 74.97 (62.77–75.82) | 75.47 (61.51–76.19) |
+0.35 |

The controlled-latency initial pairs all improve, saving 108.3–217.0ms.
Native
initial pairs have mixed signs (−67.5 to +78.1ms), so they establish no
reliable
native page-speed improvement. Warm and Back timings are effectively
unchanged.
Median primary-request start is 110.4→135.2ms natively and 497.8→480.9ms
with
100ms latency; these runs do not establish a large startup-only timing
gain.

API requests through the two-second post-reveal window fall from median
32
(32–37) to 27 (25–28) natively, and 37 (33–38) to 30 (25–32) with 100ms
latency.
All 20 samples preserve the same ordered 31 task links, first-row DOM
identity
and geometry. All recorded API responses are HTTP 200; zero unfinished
requests,
429s or JavaScript errors. Lower 429 incidence on staging remains
unproven.

Measured source: baseline `44a9a043b80c18314d8dd89556f444dae696d3d6`,
candidate
`4c12b2ef0ecf0754676e99bd6b9399e84408646c`. Final head
`ecde295565309275c4f2fae903caf6892f1eaafd` changes only two feature-map
files
and existing browser test expectations since that measured candidate;
production executable inputs are identical to the measured candidate.
Asset hashes, API
health revision receipts and every raw sample were retained. These
results do
not claim hosted-page improvement or deployment of this PR.

## Risks

- Startup enrichment adds work to the session read. Failure of this
optional projection falls back to the ordinary company and access
endpoints. It does not invalidate a valid session or replace per-request
server authorization.
- Account changes, stale cache entries and same-company navigation can
expose old presentation data. Account keys, principal stamps, reveal
scopes and canonical bootstrap replacement have dedicated negative
cases. Permission failures hide cached task rows; transient network, 429
and 5xx failures preserve them.
- Current failure state can differ from the historical failed run. The
snapshot uses the existing canonical rich projection and verified
missing-ID coverage. Partial context failures retain an explicit retry
path.
- Mutation retirement follows events delivered to the current server
process. This adds no cross-replica invalidation protocol or distributed
read-after-write guarantee.
- New UI assets require the matching API. Older UI/session consumers
retain their existing response and endpoint behavior. There is no
database migration, provider call or deployment change.

## Model Used

OpenAI Codex, based on GPT-6, with reasoning, code execution, browser
automation and delegated source review. The exact runtime model ID 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
- [ ] 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>
2026-11-03 09:28:57 -06:00

2284 lines
100 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @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, CompactIssue, HeartbeatRun, InboxMineSnapshot, Issue } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CompanyJoinRequest } from "../api/access";
import { ApiError } from "../api/client";
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(),
inboxMine: 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,
inboxMine: apiMocks.inboxMine,
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 = () => {};
}
type InboxIssueFixture = Issue & CompactIssue;
function createIssue(overrides: Partial<Issue> = {}): InboxIssueFixture {
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",
activeRecoveryAction: overrides.activeRecoveryAction ?? null,
successfulRunHandoff: overrides.successfulRunHandoff ?? null,
};
}
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,
};
}
let mineFixture: InboxMineSnapshot;
function createMineSnapshot(overrides: Partial<InboxMineSnapshot> = {}): InboxMineSnapshot {
return { userId: "local-board", issues: [], contextIssues: [], failureRuns: [], failureIssueIds: [], ...overrides };
}
// Mine's API response is a complete server-owned snapshot. These fixture helpers
// also populate the independent legacy/Recent readers without calling them from
// the aggregate mock, so request-budget assertions observe actual client reads.
function setIssueRows(rows: InboxIssueFixture[]) {
apiMocks.issuesList.mockResolvedValue(rows);
mineFixture = { ...mineFixture, issues: rows };
}
function setFailureRuns(runs: HeartbeatRun[]) {
apiMocks.heartbeatRunsList.mockResolvedValue(runs);
mineFixture = { ...mineFixture, failureRuns: runs };
}
function resetInboxApiMocks() {
companyState.selectedCompanyId = "company-1";
for (const mock of Object.values(apiMocks)) mock.mockReset();
mineFixture = createMineSnapshot();
apiMocks.inboxMine.mockImplementation(async () => mineFixture);
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([]);
setIssueRows([]);
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([]);
setFailureRuns([]);
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);
mineFixture.userId = userId ?? "local-board";
setFailureRuns([
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(() => {
if (tab === "mine" && userId) expect(apiMocks.inboxMine).toHaveBeenCalledOnce();
if (tab !== "mine") expect(apiMocks.heartbeatRunsList).toHaveBeenCalled();
expect(queryClient.isFetching()).toBe(0);
if (tab === "mine" && !userId) {
expect(apiMocks.inboxMine).not.toHaveBeenCalled();
expect(container.querySelector('[aria-label="Loading tasks"]')).toBeNull();
}
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,
});
setIssueRows([
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" });
setIssueRows([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()]);
setFailureRuns([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",
});
setIssueRows([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);
setIssueRows([
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);
setIssueRows([
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,
}]);
setIssueRows([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 complete Mine bundle before revealing tasks or failed runs", async () => {
routerMock.location.pathname = "/inbox/mine";
const bundle = createDeferred<InboxMineSnapshot>();
apiMocks.inboxMine.mockReturnValue(bundle.promise);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
const root = createRoot(container);
try {
await act(() => root.render(<QueryClientProvider client={queryClient}><Inbox /></QueryClientProvider>));
await vi.waitFor(() => expect(apiMocks.inboxMine).toHaveBeenCalledOnce());
expect(container.querySelector('[aria-label="Loading tasks"]')).not.toBeNull();
expect(container.querySelector('[data-inbox-item]')).toBeNull();
expect(apiMocks.issuesList).not.toHaveBeenCalled();
expect(apiMocks.heartbeatRunsList).not.toHaveBeenCalled();
await act(() => bundle.resolve(createMineSnapshot({
issues: [createIssue({ title: "Bundled task" })], failureRuns: [createFailedRun()],
})));
await vi.waitFor(() => {
expect(container.textContent).toContain("Bundled task");
expect(container.textContent).toContain("Failed run");
});
expect(container.querySelector('[aria-label="Loading tasks"]')).toBeNull();
} finally { await act(() => root.unmount()); queryClient.clear(); }
});
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";
setIssueRows([createIssue({ title: "Cold company task" })]);
await act(render);
await vi.waitFor(() => expect(container.textContent).toContain("Cold company task"));
act(() => root.unmount());
});
it("uses bundled parent and failure context without separate startup reads", async () => {
routerMock.location.pathname = "/inbox/mine";
const parent = createIssue({ id: "context-parent", title: "Bundled parent", listProjection: "index" });
const task = createIssue({ id: "task", title: "Bundled child", parentId: parent.id, listProjection: "index" });
const resolvedRun = createFailedRun({ id: "resolved-run", issueId: parent.id });
const missingRun = createFailedRun({ id: "missing-run", issueId: "deleted-task", agentId: "missing-agent" });
mineFixture = createMineSnapshot({
issues: [task],
contextIssues: [{ ...parent, listProjection: "rows", interruption: {
intentId: "resolved-input", responsibility: "system", state: "resolved", nextRetryAt: null,
canRetry: false, userAction: null, sourceRunIds: [resolvedRun.id], observedRunId: resolvedRun.id,
} }],
failureRuns: [resolvedRun, missingRun],
failureIssueIds: [parent.id, "deleted-task"],
});
apiMocks.issuesList.mockImplementation((_company, filters) => Promise.resolve(
filters.ids?.includes(task.id) ? [task] : [],
));
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.textContent).toContain(task.title);
expect(container.querySelector('a[href="/issues/deleted-task"]')).not.toBeNull();
expect(client.isFetching()).toBe(0);
});
expect(container.querySelectorAll("[data-inbox-item]")).toHaveLength(2);
expect(apiMocks.inboxMine).toHaveBeenCalledOnce();
expect(apiMocks.heartbeatRunsList).not.toHaveBeenCalled();
// Post-reveal diagnostics may read the visible task, but neither a primary
// index nor parent/failure context is fetched by the Mine client.
for (const [, filters] of apiMocks.issuesList.mock.calls) {
expect(filters).toMatchObject({ projection: "rows", ids: [task.id] });
}
} finally { await act(() => root.unmount()); client.clear(); }
});
it("rejects a Mine bundle stamped for another account before caching its rows or context", async () => {
routerMock.location.pathname = "/inbox/mine";
mineFixture = createMineSnapshot({ userId: "other-account", issues: [createIssue({ title: "Private other-account task" })] });
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.textContent).toContain("Your account changed. Refresh the inbox."));
expect(container.textContent).not.toContain("Private other-account task");
expect(container.querySelector('[aria-label="Loading tasks"]')).toBeNull();
expect(client.getQueryData([...queryKeys.issues.list("company-1"), "mine-context", "local-board"])).toBeUndefined();
expect(apiMocks.issuesList).not.toHaveBeenCalled();
} finally { await act(() => root.unmount()); client.clear(); }
});
it.each(["network", 429, 503] as const)("keeps cached Mine rows and their DOM nodes during a %s aggregate refresh failure", async status => {
routerMock.location.pathname = "/inbox/mine";
setIssueRows([createIssue({ title: "Cached readable task" })]);
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.textContent).toContain("Cached readable task"));
const row = container.querySelector("[data-inbox-item]");
apiMocks.inboxMine.mockRejectedValue(status === "network" ? new Error("Refresh unavailable") : new ApiError("Refresh unavailable", status, null));
await act(async () => { await client.invalidateQueries({ queryKey: queryKeys.issues.listMineByMe("company-1") }); });
await vi.waitFor(() => expect(document.body.textContent).toContain("Some inbox items couldn’t load."));
expect(container.querySelector("[data-inbox-item]")).toBe(row);
expect(container.textContent).toContain("Cached readable task");
expect(container.querySelector('[aria-label="Loading tasks"]')).toBeNull();
expect(apiMocks.inboxMine).toHaveBeenCalledTimes(2);
} finally { await act(() => root.unmount()); client.clear(); }
});
it.each([401, 403])("hides cached Mine rows after a %s access denial", async status => {
routerMock.location.pathname = "/inbox/mine";
setIssueRows([createIssue({ title: "Previously accessible private task" })]);
const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
const root = createRoot(container);
const message = `Company access denied (${status})`;
try {
await act(() => root.render(<QueryClientProvider client={client}><Inbox /></QueryClientProvider>));
await vi.waitFor(() => expect(container.textContent).toContain("Previously accessible private task"));
apiMocks.inboxMine.mockRejectedValue(new ApiError(message, status, null));
await act(async () => { await client.invalidateQueries({ queryKey: queryKeys.issues.listMineByMe("company-1") }); });
await vi.waitFor(() => expect(container.textContent).toContain(message));
expect(container.textContent).not.toContain("Previously accessible private task");
expect(container.querySelectorAll("[data-inbox-item]")).toHaveLength(0);
expect(container.querySelector('[aria-label="Loading tasks"]')).toBeNull();
expect(apiMocks.inboxMine).toHaveBeenCalledTimes(2);
expect(apiMocks.inboxMine).toHaveBeenLastCalledWith("company-1", expect.anything());
} finally { await act(() => root.unmount()); client.clear(); }
});
it("fails closed when a refresh returns another account's bundle despite cached Mine rows", async () => {
routerMock.location.pathname = "/inbox/mine";
setIssueRows([createIssue({ title: "Previous authenticated task" })]);
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.textContent).toContain("Previous authenticated task"));
apiMocks.inboxMine.mockResolvedValue(createMineSnapshot({
userId: "another-account", issues: [createIssue({ title: "Another account secret" })],
}));
await act(async () => { await client.invalidateQueries({ queryKey: queryKeys.issues.listMineByMe("company-1") }); });
await vi.waitFor(() => {
expect(container.textContent).toContain("Your account changed. Refresh the inbox.");
expect(apiMocks.authSession).toHaveBeenCalledTimes(2);
});
expect(container.textContent).not.toContain("Previous authenticated task");
expect(container.textContent).not.toContain("Another account secret");
expect(client.getQueryData<InboxMineSnapshot>([...queryKeys.issues.list("company-1"), "mine-context", "local-board"])?.userId).toBe("local-board");
} finally { await act(() => root.unmount()); client.clear(); }
});
it("isolates an account switch from a late response for the previous account", async () => {
routerMock.location.pathname = "/inbox/mine";
const previous = createDeferred<InboxMineSnapshot>();
const next = createDeferred<InboxMineSnapshot>();
apiMocks.inboxMine.mockReturnValueOnce(previous.promise).mockReturnValueOnce(next.promise);
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.inboxMine).toHaveBeenCalledOnce());
await act(() => { client.setQueryData(queryKeys.auth.session, { user: { id: "next-account" }, session: { userId: "next-account" } }); });
await vi.waitFor(() => expect(apiMocks.inboxMine).toHaveBeenCalledTimes(2));
await act(() => next.resolve(createMineSnapshot({ userId: "next-account", issues: [createIssue({ title: "Next account task" })] })));
await vi.waitFor(() => expect(container.textContent).toContain("Next account task"));
await act(() => previous.resolve(createMineSnapshot({ issues: [createIssue({ title: "Previous account secret" })] })));
await vi.waitFor(() => expect(client.isFetching()).toBe(0));
expect(container.textContent).toContain("Next account task");
expect(container.textContent).not.toContain("Previous account secret");
expect(client.getQueryData<InboxMineSnapshot>([...queryKeys.issues.list("company-1"), "mine-context", "next-account"])?.userId).toBe("next-account");
} finally { await act(() => root.unmount()); client.clear(); }
});
it("holds the initial reveal barrier for a new account in the same company", async () => {
routerMock.location.pathname = "/inbox/mine";
setIssueRows([createIssue({ title: "First account task" })]);
const next = createDeferred<InboxMineSnapshot>();
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.textContent).toContain("First account task"));
apiMocks.inboxMine.mockReturnValue(next.promise);
await act(() => { client.setQueryData(queryKeys.auth.session, { user: { id: "next-account" }, session: { userId: "next-account" } }); });
await vi.waitFor(() => expect(apiMocks.inboxMine).toHaveBeenCalledTimes(2));
expect(container.querySelector('[aria-label="Loading tasks"]')).not.toBeNull();
expect(container.querySelectorAll("[data-inbox-item]")).toHaveLength(0);
expect(container.textContent).not.toContain("First account task");
expect(container.textContent).not.toContain("Inbox zero.");
await act(() => next.resolve(createMineSnapshot({
userId: "next-account", issues: [createIssue({ title: "Second account task" })],
})));
await vi.waitFor(() => expect(container.textContent).toContain("Second account task"));
expect(container.querySelector('[aria-label="Loading tasks"]')).toBeNull();
} finally { await act(() => root.unmount()); client.clear(); }
});
it("keeps aggregate context scoped to the selected company", async () => {
routerMock.location.pathname = "/inbox/mine";
apiMocks.inboxMine.mockImplementation(async companyId => createMineSnapshot({
issues: [createIssue({ id: `task-${companyId}`, companyId, title: `Task for ${companyId}` })],
}));
const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
const root = createRoot(container);
const render = () => root.render(<QueryClientProvider client={client}><Inbox /></QueryClientProvider>);
try {
await act(render);
await vi.waitFor(() => expect(container.textContent).toContain("Task for company-1"));
companyState.selectedCompanyId = "company-2";
await act(render);
await vi.waitFor(() => expect(container.textContent).toContain("Task for company-2"));
expect(container.textContent).not.toContain("Task for company-1");
expect(apiMocks.inboxMine).toHaveBeenCalledWith("company-2", expect.objectContaining({ signal: expect.any(AbortSignal) }));
expect(client.getQueryData<InboxMineSnapshot>([...queryKeys.issues.list("company-2"), "mine-context", "local-board"])?.issues[0]?.companyId).toBe("company-2");
} finally { await act(() => root.unmount()); client.clear(); }
});
it("cannot reuse another account's private parent caption in a cached Mine fallback", 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: "private-parent", title: "First account private caption", identifier: "PAP-SECRET", listProjection: "index" });
const child = createIssue({ id: "shared-child", title: "Cached fallback child", parentId: parent.id, listProjection: "index" });
const pendingParent = createDeferred<Issue[]>();
const pendingBundle = createDeferred<InboxMineSnapshot>();
let switched = false;
apiMocks.inboxMine.mockRejectedValueOnce(new Error("Aggregate temporarily unavailable")).mockReturnValueOnce(pendingBundle.promise);
apiMocks.issuesList.mockImplementation((_company, filters) => {
if (filters.ids?.includes(parent.id)) return switched ? pendingParent.promise : Promise.resolve([parent]);
return Promise.resolve([child]);
});
const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: Infinity } } });
client.setQueryData(queryKeys.auth.session, { user: { id: "local-board" }, session: { userId: "local-board" } });
const sourceKey = (userId: string) => [...queryKeys.issues.listMineByMe("company-1"), "index", "with-routine-executions", "live-descendant-summary", 100, userId];
client.setQueryData(sourceKey("local-board"), [child]);
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));
switched = true;
client.setQueryData(sourceKey("next-account"), [child]);
await act(() => { client.setQueryData(queryKeys.auth.session, { user: { id: "next-account" }, session: { userId: "next-account" } }); });
await vi.waitFor(() => expect(apiMocks.inboxMine).toHaveBeenCalledTimes(2));
expect(apiMocks.issuesList.mock.calls.filter(([, filters]) => filters.ids?.includes(parent.id))).toHaveLength(1);
expect(container.querySelector(`[title="${parent.title}"]`)).toBeNull();
await act(() => pendingBundle.reject(new Error("New account aggregate temporarily unavailable")));
await vi.waitFor(() => expect(apiMocks.issuesList.mock.calls.filter(([, filters]) => filters.ids?.includes(parent.id))).toHaveLength(2));
expect(container.querySelector(`[title="${parent.title}"]`)).toBeNull();
expect(container.textContent).not.toContain(parent.identifier);
await act(() => pendingParent.resolve([]));
await vi.waitFor(() => expect(container.textContent).toContain(child.title));
expect(container.querySelector(`[title="${parent.title}"]`)).toBeNull();
expect(container.textContent).not.toContain(parent.identifier);
} finally { await act(() => root.unmount()); client.clear(); }
});
it("cannot reuse another account's resolved rich state for a fallback failure", async () => {
routerMock.location.pathname = "/inbox/recent";
const task = createIssue({ id: "private-failure-task", title: "First account failure context", listProjection: "rows" });
const failure = createFailedRun({ issueId: task.id });
setFailureRuns([failure]);
const pendingContext = createDeferred<Issue[]>();
let switched = false;
apiMocks.issuesList.mockImplementation((_company, filters) => {
if (!filters.ids?.includes(task.id)) return Promise.resolve([]);
return switched ? pendingContext.promise : Promise.resolve([{ ...task, interruption: {
intentId: "private-resolved", responsibility: "system", state: "resolved", nextRetryAt: null,
canRetry: false, userAction: null, sourceRunIds: [failure.id], observedRunId: failure.id,
} }]);
});
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(client.isFetching()).toBe(0));
expect(container.querySelector(`a[href="/issues/${task.id}"]`)).toBeNull();
switched = true;
await act(() => { client.setQueryData(queryKeys.auth.session, { user: { id: "next-account" }, session: { userId: "next-account" } }); });
await vi.waitFor(() => expect(apiMocks.issuesList.mock.calls.filter(([, filters]) => filters.ids?.includes(task.id))).toHaveLength(2));
expect(container.textContent).not.toContain(task.title);
await act(() => pendingContext.resolve([]));
await vi.waitFor(() => expect(container.querySelector(`a[href="/issues/${task.id}"]`)).not.toBeNull());
expect(container.textContent).not.toContain(task.title);
} finally { await act(() => root.unmount()); client.clear(); }
});
it("loads only missing parent metadata before revealing Recent", async () => {
routerMock.location.pathname = "/inbox/recent";
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/recent";
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/recent";
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" });
setFailureRuns([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/recent";
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" });
mineFixture = createMineSnapshot({ issues: [child], contextIssues: [parent] });
apiMocks.issuesList.mockImplementation((_company, filters) => Promise.resolve(filters.ids ? [parent] : []));
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/recent";
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/recent";
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" });
setFailureRuns([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" }));
mineFixture = createMineSnapshot({ issues: rows });
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;
mineFixture = createMineSnapshot({ issues: [createIssue({ title: "Readable task" })] });
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" });
setIssueRows([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) };
setIssueRows([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", "failureError", "contextError"] as const)(
"keeps available tasks visible when %s fails and during retry", async (source) => {
routerMock.location.pathname = "/inbox/mine";
setIssueRows([createIssue({ title: "Available task" })]);
const bundled = source === "failureError" || source === "contextError";
if (bundled) mineFixture = { ...mineFixture, [source]: { status: 503, message: "Optional source unavailable" } };
else apiMocks[source].mockRejectedValue(new Error("Optional source unavailable"));
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
const root = createRoot(container);
try {
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.");
const row = container.querySelector("[data-inbox-item]");
const retry = createDeferred<InboxMineSnapshot | []>();
if (bundled) apiMocks.inboxMine.mockReturnValue(retry.promise);
else apiMocks[source].mockReturnValue(retry.promise);
await act(() => Array.from(document.body.querySelectorAll("button")).find(button => button.textContent === "Retry missing items")!.click());
await vi.waitFor(() => expect(bundled ? apiMocks.inboxMine : apiMocks[source]).toHaveBeenCalledTimes(2));
expect(container.querySelector("[data-inbox-item]")).toBe(row);
expect(container.querySelector('[aria-label="Loading tasks"]')).toBeNull();
await act(() => retry.resolve(bundled ? createMineSnapshot({ issues: mineFixture.issues }) : []));
await vi.waitFor(() => expect(document.body.textContent).not.toContain("Some inbox items couldn’t load."));
if (bundled) expect(apiMocks.heartbeatRunsList).not.toHaveBeenCalled();
} finally { await act(() => root.unmount()); queryClient.clear(); }
},
);
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 });
setIssueRows([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 Recent issue rows", async () => {
routerMock.location.pathname = "/inbox/recent";
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", 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" });
setIssueRows([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,
});
setIssueRows([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" });
setIssueRows([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.
setIssueRows([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),
});
setIssueRows([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].
setIssueRows([
{ ...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" });
setIssueRows([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",
});
setIssueRows([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",
});
setIssueRows([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.inboxMine).toHaveBeenCalledOnce());
await vi.waitFor(() => expect(client.isFetching()).toBe(0));
expect(apiMocks.issuesList).not.toHaveBeenCalled();
expect(apiMocks.heartbeatRunsList).not.toHaveBeenCalled();
expect(apiMocks.dashboardSummary).not.toHaveBeenCalled();
} finally { await act(async () => root.unmount()); client.clear(); container.remove(); }
});
});