mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 17:45:53 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Task messages enter through the shared task composer. > - The task page waits for a separate tree-control query to find active pause holds. > - The old loading guard disables the composer until that request settles. A slow or stalled request blocks messages on both desktop and mobile. > - This PR allows sends while that query is pending. The server still rejects paused board messages before saving a comment or waking an agent. > - Query failures and known pause holds still block the composer. > - Regression tests cover pending sends, successful responses, query failures, and late root or inherited pauses. ## Linked Issues or Issue Description Fixes: #13561 Related: #13569 adds pending/error coverage for the same fix. This PR now covers those cases and the late-pause transitions. The report overstates two details: `isPending` clears after a successful response, and the shared loading guard also affects mobile. The reproduction holds the request pending. It does not prove why the reporter's request remained unresolved. ## What Changed - Preserve the original two-line fix that removes the pending-state composer block. - Add eight page regression cases using a real QueryClient and a controlled API promise. Cover desktop and mobile, submission before the query completes, successful resolution, rejected resolution, and late root/inherited pause responses. - Repair an existing server CI failure in a separate commit. Reuse the shared unique-violation helper so Drizzle-wrapped duplicate inserts become retryable document conflicts. Add a deterministic regression and preserve unrelated database errors. - Repair an existing Inbox test race in a separate commit. Wait for workspace metadata, which resolves independently of the task list. ## Verification - Red: restore the pre-fix `IssueDetail.tsx` and run the new `composer tree control` cases. Both desktop and mobile pending-send cases fail with `Checking task status…`. The other six cases pass. - Green: restore the original PR fix. All 343 tests in IssueDetail, TaskChatThread, and TaskChatComposer pass. - The server comment/reopen and artifact-review suites pass (179 tests). They include POST and PATCH pause checks that return 409 before any comment, task mutation, or wakeup. - The document error regression fails before the shared-helper fix and passes after it. The document, artifact-review, and database-error suites pass (31 tests). The handler matches only the issue-document key constraint; revision and other constraint errors retain their original identity. - The Inbox suite passes (27 tests). - `pnpm check:token-gates` passes. - `pnpm -r typecheck` and `pnpm build` pass locally. The full CI matrix passes on `5af1ed0b43269247aaba406cf4fd4d7fe1a22e75`: 54 successful checks and two opt-in Storybook skips, including all general/serialized tests, runner checks, release checks, and eight browser shards. - One server shard initially hit an unrelated `EADDRINUSE` on test port 52000. Its single rerun passed without code changes. - Greptile completed successfully on that exact commit with 5/5 and no outstanding findings or review threads. - The serial local `pnpm test:run` was started, then stopped after the complete parallel CI matrix passed. It is not claimed as a completed local full-suite run. The focused local suites above did finish successfully. For a manual reproduction, delay the task's `/tree-control-state` response, open the task, and enter a message. Send should remain available during the delay. Resolve the response with an active pause hold and confirm that the pause takeover replaces the composer. Reject the request and confirm that the error blocks sends. ## Risks A user can attempt a send before the pause response arrives. The server remains authoritative and returns 409 for a paused task before saving or waking work. The known pause takeover and query-error block remain. There are no schema, API, or styling changes. The document change restores the existing conflict/retry behavior for wrapped database errors. It does not retry unrelated database failures. The Inbox change affects test synchronization only. ## Model Used - Original fix: Anthropic Claude Sonnet 4.6 (`claude-sonnet-4-6`), 200k context, tool use and code editing, as reported by the author. - Review, regression tests, and CI repairs: OpenAI GPT-6 (`gpt-6-astra`) through Codex, with reasoning, tool use, and code execution. The session does not expose its context-window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: austinpilz <austinpilz@users.noreply.github.com> Co-authored-by: Dotta <bippadotta@protonmail.com>
1580 lines
53 KiB
TypeScript
1580 lines
53 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import type { ComponentProps } from "react";
|
|
import { flushSync } from "react-dom";
|
|
import { createRoot } from "react-dom/client";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import type { Approval, HeartbeatRun, Issue } from "@paperclipai/shared";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { CompanyJoinRequest } from "../api/access";
|
|
import {
|
|
clearLocalInboxArchive,
|
|
getLocalInboxArchiveIssueIds,
|
|
} from "../lib/inboxArchiveCache";
|
|
import { taskCollectionPreferencesStorageKey } from "../lib/task-collection-preferences";
|
|
|
|
const routerMock = vi.hoisted(() => ({
|
|
location: { pathname: "/", search: "", hash: "" },
|
|
navigate: vi.fn(),
|
|
}));
|
|
|
|
const externalObjectMocks = vi.hoisted(() => ({
|
|
summaries: new Map(),
|
|
}));
|
|
|
|
const apiMocks = vi.hoisted(() => ({
|
|
approvalsList: vi.fn(),
|
|
joinRequestsList: vi.fn(),
|
|
userDirectoryList: vi.fn(),
|
|
authSession: vi.fn(),
|
|
dashboardSummary: vi.fn(),
|
|
executionWorkspaceSummaries: vi.fn(),
|
|
issuesList: vi.fn(),
|
|
issuesCount: vi.fn(),
|
|
issueLabels: vi.fn(),
|
|
archiveFromInbox: vi.fn(),
|
|
unarchiveFromInbox: vi.fn(),
|
|
agentsList: vi.fn(),
|
|
heartbeatRunsList: vi.fn(),
|
|
liveRunsForCompany: vi.fn(),
|
|
experimentalSettings: vi.fn(),
|
|
projectsList: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("../api/approvals", () => ({
|
|
approvalsApi: { list: apiMocks.approvalsList },
|
|
}));
|
|
|
|
vi.mock("../api/access", async () => {
|
|
const actual = await vi.importActual<typeof import("../api/access")>("../api/access");
|
|
return {
|
|
...actual,
|
|
accessApi: {
|
|
listJoinRequests: apiMocks.joinRequestsList,
|
|
listUserDirectory: apiMocks.userDirectoryList,
|
|
},
|
|
};
|
|
});
|
|
|
|
vi.mock("../api/auth", () => ({
|
|
authApi: { getSession: apiMocks.authSession },
|
|
}));
|
|
|
|
vi.mock("../api/dashboard", () => ({
|
|
dashboardApi: { summary: apiMocks.dashboardSummary },
|
|
}));
|
|
|
|
vi.mock("../api/execution-workspaces", () => ({
|
|
executionWorkspacesApi: { listSummaries: apiMocks.executionWorkspaceSummaries },
|
|
}));
|
|
|
|
vi.mock("../api/issues", () => ({
|
|
issuesApi: {
|
|
list: apiMocks.issuesList,
|
|
listCompact: apiMocks.issuesList,
|
|
count: apiMocks.issuesCount,
|
|
listLabels: apiMocks.issueLabels,
|
|
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: () => ({ selectedCompanyId: "company-1" }),
|
|
}));
|
|
|
|
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 }),
|
|
}));
|
|
|
|
const generalSettingsMock = { keyboardShortcutsEnabled: false };
|
|
vi.mock("../context/GeneralSettingsContext", () => ({
|
|
useGeneralSettings: () => generalSettingsMock,
|
|
}));
|
|
|
|
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: true,
|
|
}),
|
|
}));
|
|
|
|
import {
|
|
FailedRunInboxRow,
|
|
Inbox,
|
|
InboxGroupHeader,
|
|
InboxIssueMetaLeading,
|
|
InboxIssueTrailingColumns,
|
|
formatJoinRequestInboxLabel,
|
|
} from "./Inbox";
|
|
|
|
vi.mock("@/lib/router", () => ({
|
|
Link: ({ children, className, ...props }: ComponentProps<"a">) => (
|
|
<a className={className} {...props}>{children}</a>
|
|
),
|
|
useLocation: () => routerMock.location,
|
|
useNavigate: () => routerMock.navigate,
|
|
}));
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
|
|
|
async function act(callback: () => void | Promise<void>) {
|
|
let result: void | Promise<void> = undefined;
|
|
flushSync(() => {
|
|
result = callback();
|
|
});
|
|
await result;
|
|
}
|
|
|
|
// jsdom doesn't implement scrollIntoView; the inbox calls it from a passive effect.
|
|
if (typeof Element !== "undefined" && !Element.prototype.scrollIntoView) {
|
|
Element.prototype.scrollIntoView = () => {};
|
|
}
|
|
|
|
function createIssue(overrides: Partial<Issue> = {}): Issue {
|
|
return {
|
|
id: "issue-1",
|
|
identifier: "PAP-904",
|
|
companyId: "company-1",
|
|
projectId: null,
|
|
projectWorkspaceId: null,
|
|
goalId: null,
|
|
parentId: null,
|
|
title: "Inbox item",
|
|
description: null,
|
|
status: "todo",
|
|
priority: "medium",
|
|
reviewPolicy: null,
|
|
assigneeAgentId: null,
|
|
assigneeUserId: null,
|
|
responsibleUserId: null,
|
|
createdByAgentId: null,
|
|
createdByUserId: null,
|
|
issueNumber: 904,
|
|
requestDepth: 0,
|
|
billingCode: null,
|
|
assigneeAdapterOverrides: null,
|
|
executionWorkspaceId: null,
|
|
executionWorkspacePreference: null,
|
|
executionWorkspaceSettings: null,
|
|
checkoutRunId: null,
|
|
executionRunId: null,
|
|
executionAgentNameKey: null,
|
|
executionLockedAt: null,
|
|
startedAt: null,
|
|
completedAt: null,
|
|
cancelledAt: null,
|
|
hiddenAt: null,
|
|
createdAt: new Date("2026-03-11T00:00:00.000Z"),
|
|
updatedAt: new Date("2026-03-11T00:00:00.000Z"),
|
|
labels: [],
|
|
labelIds: [],
|
|
myLastTouchAt: null,
|
|
lastExternalCommentAt: null,
|
|
lastActivityAt: new Date("2026-03-11T00:00:00.000Z"),
|
|
isUnreadForMe: false,
|
|
...overrides,
|
|
workMode: overrides.workMode ?? "standard",
|
|
};
|
|
}
|
|
|
|
function createDeferred<T>() {
|
|
let resolve!: (value: T) => void;
|
|
let reject!: (reason?: unknown) => void;
|
|
const promise = new Promise<T>((innerResolve, innerReject) => {
|
|
resolve = innerResolve;
|
|
reject = innerReject;
|
|
});
|
|
return { promise, resolve, reject };
|
|
}
|
|
|
|
function createJoinRequest(
|
|
overrides: Partial<CompanyJoinRequest> = {},
|
|
): CompanyJoinRequest {
|
|
return {
|
|
id: "join-1",
|
|
inviteId: "invite-1",
|
|
companyId: "company-1",
|
|
requestType: "human",
|
|
status: "pending_approval",
|
|
requestIp: "127.0.0.1",
|
|
requestingUserId: "user-1",
|
|
requestEmailSnapshot: "joiner@example.com",
|
|
agentName: null,
|
|
adapterType: null,
|
|
capabilities: null,
|
|
agentDefaultsPayload: null,
|
|
claimSecretExpiresAt: null,
|
|
claimSecretConsumedAt: null,
|
|
createdAgentId: null,
|
|
approvedByUserId: null,
|
|
approvedAt: null,
|
|
rejectedByUserId: null,
|
|
rejectedAt: null,
|
|
createdAt: new Date("2026-03-11T00:00:00.000Z"),
|
|
updatedAt: new Date("2026-03-11T00:00:00.000Z"),
|
|
requesterUser: {
|
|
id: "user-1",
|
|
name: "Jordan Example",
|
|
email: "joiner@example.com",
|
|
image: null,
|
|
},
|
|
approvedByUser: null,
|
|
rejectedByUser: null,
|
|
invite: null,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function createApproval(overrides: Partial<Approval> = {}): Approval {
|
|
return {
|
|
id: "approval-1",
|
|
companyId: "company-1",
|
|
type: "hire_agent",
|
|
requestedByAgentId: null,
|
|
requestedByUserId: "local-board",
|
|
status: "pending",
|
|
payload: { name: "New teammate" },
|
|
decisionNote: null,
|
|
decidedByUserId: null,
|
|
decidedAt: null,
|
|
createdAt: new Date("2026-03-11T00:00:00.000Z"),
|
|
updatedAt: new Date("2026-03-11T00:00:00.000Z"),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function createFailedRun(overrides: Partial<HeartbeatRun> = {}): HeartbeatRun {
|
|
return {
|
|
id: "run-1",
|
|
companyId: "company-1",
|
|
agentId: "agent-1",
|
|
responsibleUserId: null,
|
|
invocationSource: "assignment",
|
|
triggerDetail: null,
|
|
status: "failed",
|
|
error: "boom",
|
|
wakeupRequestId: null,
|
|
exitCode: null,
|
|
signal: null,
|
|
usageJson: null,
|
|
resultJson: null,
|
|
sessionIdBefore: null,
|
|
sessionIdAfter: null,
|
|
logStore: null,
|
|
logRef: null,
|
|
logBytes: null,
|
|
logSha256: null,
|
|
logCompressed: false,
|
|
stdoutExcerpt: null,
|
|
stderrExcerpt: null,
|
|
errorCode: null,
|
|
externalRunId: null,
|
|
processPid: null,
|
|
processGroupId: null,
|
|
processStartedAt: null,
|
|
lastOutputAt: null,
|
|
lastOutputSeq: 0,
|
|
lastOutputStream: null,
|
|
lastOutputBytes: null,
|
|
retryOfRunId: null,
|
|
processLossRetryCount: 0,
|
|
livenessState: null,
|
|
livenessReason: null,
|
|
continuationAttempt: 0,
|
|
lastUsefulActionAt: null,
|
|
nextAction: null,
|
|
contextSnapshot: null,
|
|
startedAt: new Date("2026-03-11T00:00:00.000Z"),
|
|
finishedAt: new Date("2026-03-11T00:01:00.000Z"),
|
|
createdAt: new Date("2026-03-11T00:00:00.000Z"),
|
|
updatedAt: new Date("2026-03-11T00:01:00.000Z"),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function resetInboxApiMocks() {
|
|
for (const mock of Object.values(apiMocks)) mock.mockReset();
|
|
externalObjectMocks.summaries.clear();
|
|
routerMock.location.pathname = "/";
|
|
routerMock.location.search = "";
|
|
routerMock.location.hash = "";
|
|
routerMock.navigate.mockReset();
|
|
apiMocks.approvalsList.mockResolvedValue([]);
|
|
apiMocks.joinRequestsList.mockResolvedValue([]);
|
|
apiMocks.userDirectoryList.mockResolvedValue({ users: [] });
|
|
apiMocks.authSession.mockResolvedValue({
|
|
user: { id: "local-board" },
|
|
session: { userId: "local-board" },
|
|
});
|
|
apiMocks.dashboardSummary.mockResolvedValue({
|
|
agents: { error: 0 },
|
|
costs: { monthBudgetCents: 0, monthUtilizationPercent: 0 },
|
|
});
|
|
apiMocks.executionWorkspaceSummaries.mockResolvedValue([]);
|
|
apiMocks.issuesList.mockResolvedValue([]);
|
|
apiMocks.issuesCount.mockResolvedValue({ count: 0 });
|
|
apiMocks.issueLabels.mockResolvedValue([]);
|
|
apiMocks.archiveFromInbox.mockResolvedValue({ id: "issue-1", archivedAt: new Date() });
|
|
apiMocks.unarchiveFromInbox.mockResolvedValue({ id: "issue-1", archivedAt: new Date() });
|
|
apiMocks.agentsList.mockResolvedValue([]);
|
|
apiMocks.heartbeatRunsList.mockResolvedValue([]);
|
|
apiMocks.liveRunsForCompany.mockResolvedValue([]);
|
|
apiMocks.experimentalSettings.mockResolvedValue({
|
|
enableIsolatedWorkspaces: false,
|
|
enableStreamlinedUi: true,
|
|
});
|
|
apiMocks.projectsList.mockResolvedValue([]);
|
|
}
|
|
|
|
describe("Inbox toolbar", () => {
|
|
let container: HTMLDivElement;
|
|
|
|
beforeEach(() => {
|
|
localStorage.clear();
|
|
resetInboxApiMocks();
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
});
|
|
|
|
afterEach(() => {
|
|
for (const issueId of getLocalInboxArchiveIssueIds("company-1")) {
|
|
clearLocalInboxArchive("company-1", issueId);
|
|
}
|
|
container.remove();
|
|
});
|
|
|
|
it("restores the legacy toolbar and issue-row presentation when Streamlined UI is off", async () => {
|
|
routerMock.location.pathname = "/inbox/mine";
|
|
apiMocks.experimentalSettings.mockResolvedValue({
|
|
enableIsolatedWorkspaces: false,
|
|
enableStreamlinedUi: false,
|
|
});
|
|
apiMocks.issuesList.mockResolvedValue([
|
|
createIssue({ id: "legacy-row", identifier: "PAP-1904", title: "Legacy inbox task" }),
|
|
]);
|
|
localStorage.setItem(
|
|
taskCollectionPreferencesStorageKey({ companyId: "company-1", collectionKey: "inbox" }),
|
|
JSON.stringify({
|
|
version: 1,
|
|
companyId: "company-1",
|
|
collectionKey: "inbox",
|
|
viewState: {},
|
|
columns: [],
|
|
}),
|
|
);
|
|
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } },
|
|
});
|
|
const root = createRoot(container);
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Inbox />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
await vi.waitFor(() => expect(container.textContent).toContain("Legacy inbox task"));
|
|
|
|
expect(container.querySelector('[data-slot="collection-toolbar"]')).toBeNull();
|
|
expect(container.querySelector('[role="toolbar"][aria-label="Inbox controls"]')).toBeNull();
|
|
expect(container.textContent).toContain("Mine");
|
|
expect(container.querySelector('[data-slot="task-row"]')).toBeNull();
|
|
const issueLink = container.querySelector('[data-inbox-issue-link]');
|
|
// Production reads the legacy Inbox column key and must ignore the
|
|
// Streamlined collection envelope above when the experiment is disabled.
|
|
expect(issueLink?.parentElement?.textContent).toContain("PAP-1904");
|
|
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("does not render external-object summaries in inbox rows", async () => {
|
|
routerMock.location.pathname = "/inbox/mine";
|
|
const issue = createIssue({ title: "Inbox row without external object column" });
|
|
apiMocks.issuesList.mockResolvedValue([issue]);
|
|
externalObjectMocks.summaries.set(issue.id, {
|
|
total: 1,
|
|
highestSeverity: "failed",
|
|
byStatusCategory: { failed: 1 },
|
|
objects: [],
|
|
});
|
|
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } },
|
|
});
|
|
const root = createRoot(container);
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Inbox />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
await vi.waitFor(() => {
|
|
expect(container.textContent).toContain(issue.title);
|
|
});
|
|
|
|
expect(container.querySelector('[aria-label^="External objects:"]')).toBeNull();
|
|
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("keeps archive hover actions and swipe targets on every unread non-task Mine row", async () => {
|
|
routerMock.location.pathname = "/inbox/mine";
|
|
localStorage.setItem("paperclip:inbox:group-by", "none");
|
|
apiMocks.approvalsList.mockResolvedValue([createApproval()]);
|
|
apiMocks.heartbeatRunsList.mockResolvedValue([createFailedRun()]);
|
|
apiMocks.joinRequestsList.mockResolvedValue([createJoinRequest()]);
|
|
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } },
|
|
});
|
|
const root = createRoot(container);
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Inbox />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
await vi.waitFor(() => {
|
|
expect(container.textContent).toContain("Hire Agent: New teammate");
|
|
expect(container.textContent).toContain("Failed run");
|
|
expect(container.textContent).toContain("Jordan Example");
|
|
});
|
|
|
|
const rowFor = (text: string) =>
|
|
[...container.querySelectorAll("[data-inbox-item]")]
|
|
.find((row) => row.textContent?.includes(text));
|
|
|
|
for (const text of ["Hire Agent: New teammate", "Failed run", "Jordan Example"]) {
|
|
const row = rowFor(text);
|
|
expect(row, `missing inbox row for ${text}`).toBeDefined();
|
|
expect(row?.querySelector('button[aria-label="Mark as read"]')).not.toBeNull();
|
|
const archiveButton = row?.querySelector<HTMLButtonElement>('button[aria-label="Archive"]');
|
|
expect(archiveButton).not.toBeNull();
|
|
expect(archiveButton?.className).toContain("opacity-0");
|
|
expect(archiveButton?.className).toContain("group-hover:opacity-100");
|
|
expect(row?.querySelector("[data-inbox-row-surface]")).not.toBeNull();
|
|
}
|
|
|
|
const approvalRow = rowFor("Hire Agent: New teammate");
|
|
const approvalActions = [...(approvalRow?.querySelectorAll("button") ?? [])]
|
|
.filter((button) => button.textContent === "Approve" || button.textContent === "Reject");
|
|
expect(approvalActions.length).toBeGreaterThanOrEqual(2);
|
|
approvalActions.forEach((button) => {
|
|
expect(button.className).toContain("h-8");
|
|
expect(button.className).toContain("min-w-(--sz-64px)");
|
|
expect(button.className).toContain("justify-center");
|
|
});
|
|
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("restores folded and unfolded sub-tasks across remounts", async () => {
|
|
routerMock.location.pathname = "/inbox/mine";
|
|
const storageKey = "paperclip:inbox:collapsed-parents:company-1";
|
|
localStorage.removeItem(storageKey);
|
|
|
|
const parent = createIssue({
|
|
id: "parent-issue",
|
|
identifier: "PAP-1001",
|
|
title: "Parent inbox task",
|
|
});
|
|
const child = createIssue({
|
|
id: "child-issue",
|
|
identifier: "PAP-1002",
|
|
parentId: parent.id,
|
|
title: "Nested inbox task",
|
|
});
|
|
const grandchild = createIssue({
|
|
id: "grandchild-issue",
|
|
identifier: "PAP-1003",
|
|
parentId: child.id,
|
|
title: "Deeply nested inbox task",
|
|
});
|
|
apiMocks.issuesList.mockResolvedValue([parent, child, grandchild]);
|
|
|
|
const mountInbox = async () => {
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } },
|
|
});
|
|
const root = createRoot(container);
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Inbox />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
await vi.waitFor(() => {
|
|
expect(container.textContent).toContain(parent.title);
|
|
});
|
|
return root;
|
|
};
|
|
const parentToggle = () => {
|
|
const parentRow = Array.from(container.querySelectorAll("[data-inbox-item]"))
|
|
.find((row) => row.textContent?.includes(parent.title));
|
|
return parentRow?.querySelector<HTMLButtonElement>('button[data-slot="icon-button"]') ?? null;
|
|
};
|
|
|
|
let root = await mountInbox();
|
|
try {
|
|
expect(container.textContent).toContain(child.title);
|
|
expect(container.textContent).toContain(grandchild.title);
|
|
const taskRowFor = (title: string) =>
|
|
Array.from(container.querySelectorAll('[data-slot="task-row"]'))
|
|
.find((row) => row.textContent?.includes(title));
|
|
const childTaskRow = taskRowFor(child.title);
|
|
const grandchildTaskRow = taskRowFor(grandchild.title);
|
|
expect(childTaskRow?.querySelectorAll('[data-slot="task-row-tree-guide"]')).toHaveLength(1);
|
|
expect(childTaskRow?.querySelector('button[aria-label="Collapse sub-tasks"]')).not.toBeNull();
|
|
expect(grandchildTaskRow?.querySelectorAll('[data-slot="task-row-tree-guide"]')).toHaveLength(2);
|
|
expect(grandchildTaskRow?.querySelector('[data-slot="task-row-disclosure-spacer"]')).not.toBeNull();
|
|
|
|
await act(async () => {
|
|
parentToggle()?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
await vi.waitFor(() => {
|
|
expect(container.textContent).not.toContain(child.title);
|
|
expect(container.textContent).not.toContain(grandchild.title);
|
|
});
|
|
expect(JSON.parse(localStorage.getItem(storageKey) ?? "[]")).toEqual([parent.id]);
|
|
|
|
act(() => root.unmount());
|
|
root = await mountInbox();
|
|
expect(container.textContent).not.toContain(child.title);
|
|
|
|
await act(async () => {
|
|
parentToggle()?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
await vi.waitFor(() => {
|
|
expect(container.textContent).toContain(child.title);
|
|
expect(container.textContent).toContain(grandchild.title);
|
|
});
|
|
expect(JSON.parse(localStorage.getItem(storageKey) ?? "[]")).toEqual([]);
|
|
|
|
act(() => root.unmount());
|
|
root = await mountInbox();
|
|
expect(container.textContent).toContain(child.title);
|
|
expect(container.textContent).toContain(grandchild.title);
|
|
} finally {
|
|
localStorage.removeItem(storageKey);
|
|
act(() => root.unmount());
|
|
}
|
|
});
|
|
|
|
it("shows blocked toolbar controls on the Blocked tab", async () => {
|
|
routerMock.location.pathname = "/inbox/blocked";
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } },
|
|
});
|
|
const root = createRoot(container);
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Inbox />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
|
|
expect(container.querySelector('input[placeholder="Search inbox…"]')).not.toBeNull();
|
|
expect(container.querySelector('[data-testid="inbox-blocked-tab-badge"]')).toBeNull();
|
|
expect(container.querySelector('button[title="Filter"]')).not.toBeNull();
|
|
expect(container.querySelector('button[title="Group"]')).not.toBeNull();
|
|
expect(container.querySelector('button[title="Columns"]')).not.toBeNull();
|
|
expect(container.querySelector('button[title="Sort"]')).not.toBeNull();
|
|
expect(container.querySelector('button[title="Enable parent-child nesting"]')).toBeNull();
|
|
expect(container.textContent).not.toContain("Mark all as read");
|
|
|
|
act(() => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("keeps Mine, Recent, Unread, Blocked, and All in the shared toolbar geometry", async () => {
|
|
for (const tab of ["mine", "recent", "unread", "blocked", "all"] as const) {
|
|
routerMock.location.pathname = `/inbox/${tab}`;
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } },
|
|
});
|
|
const root = createRoot(container);
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Inbox />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
|
|
const toolbar = container.querySelector('[data-slot="collection-toolbar"]');
|
|
expect(toolbar?.getAttribute("aria-label")).toBe("Inbox controls");
|
|
expect(toolbar?.querySelector('[data-slot="collection-toolbar-context"]')).not.toBeNull();
|
|
expect(toolbar?.querySelector('[data-slot="collection-toolbar-search"] input[placeholder="Search inbox…"]')).not.toBeNull();
|
|
expect(toolbar?.querySelector('[data-slot="collection-toolbar-controls"]')).not.toBeNull();
|
|
expect(container.querySelectorAll('input[placeholder="Search inbox…"]')).toHaveLength(1);
|
|
|
|
act(() => root.unmount());
|
|
container.replaceChildren();
|
|
}
|
|
});
|
|
|
|
it("explains that live-run filtering is different from active task statuses", async () => {
|
|
routerMock.location.pathname = "/inbox/mine";
|
|
localStorage.setItem("paperclip:inbox:filters:company-1", JSON.stringify({
|
|
allCategoryFilter: "everything",
|
|
allApprovalFilter: "all",
|
|
issueFilters: { liveOnly: true },
|
|
}));
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } },
|
|
});
|
|
const root = createRoot(container);
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Inbox />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
|
|
expect(container.querySelector('[data-testid="inbox-filter-scope-feedback"]')?.textContent)
|
|
.toBe("Live runs only — tasks currently connected to an agent run.");
|
|
const migrated = JSON.parse(
|
|
localStorage.getItem("paperclip:task-collection:v1:company-1:inbox") ?? "null",
|
|
) as { companyId?: string; collectionKey?: string } | null;
|
|
expect(migrated).toMatchObject({ companyId: "company-1", collectionKey: "inbox" });
|
|
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("groups ungrouped attention items by Today, Yesterday, and Earlier", async () => {
|
|
routerMock.location.pathname = "/inbox/mine";
|
|
const now = new Date();
|
|
const localNoon = (daysAgo: number) =>
|
|
new Date(now.getFullYear(), now.getMonth(), now.getDate() - daysAgo, 12, 0, 0);
|
|
apiMocks.issuesList.mockResolvedValue([
|
|
createIssue({ id: "today", title: "Today task", lastActivityAt: localNoon(0) }),
|
|
createIssue({ id: "yesterday", title: "Yesterday task", lastActivityAt: localNoon(1) }),
|
|
createIssue({ id: "earlier", title: "Earlier task", lastActivityAt: localNoon(3) }),
|
|
]);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } },
|
|
});
|
|
const root = createRoot(container);
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Inbox />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
await vi.waitFor(() => expect(container.textContent).toContain("Earlier task"));
|
|
|
|
const separators = [...container.querySelectorAll('[data-testid="inbox-date-group"]')];
|
|
expect(separators.map((node) => node.textContent?.trim()))
|
|
.toEqual(["Today", "Yesterday", "Earlier"]);
|
|
expect(separators.every((separator) => (
|
|
separator.querySelectorAll("[data-date-group-rule]").length === 2
|
|
))).toBe(true);
|
|
expect(separators.every((separator) => (
|
|
separator.querySelector("[data-date-group-label]")?.classList.contains("text-muted-foreground/70")
|
|
))).toBe(true);
|
|
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("honors the saved Columns option for hiding date group separators", async () => {
|
|
routerMock.location.pathname = "/inbox/mine";
|
|
localStorage.setItem(
|
|
taskCollectionPreferencesStorageKey({
|
|
companyId: "company-1",
|
|
collectionKey: "inbox",
|
|
}),
|
|
JSON.stringify({
|
|
version: 1,
|
|
companyId: "company-1",
|
|
collectionKey: "inbox",
|
|
viewState: { showDateGroupSeparators: false },
|
|
columns: ["status", "id", "updated"],
|
|
}),
|
|
);
|
|
const now = new Date();
|
|
const localNoon = (daysAgo: number) =>
|
|
new Date(now.getFullYear(), now.getMonth(), now.getDate() - daysAgo, 12, 0, 0);
|
|
apiMocks.issuesList.mockResolvedValue([
|
|
createIssue({ id: "today", title: "Today task", lastActivityAt: localNoon(0) }),
|
|
createIssue({ id: "earlier", title: "Earlier task", lastActivityAt: localNoon(3) }),
|
|
]);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } },
|
|
});
|
|
const root = createRoot(container);
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Inbox />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
await vi.waitFor(() => expect(container.textContent).toContain("Earlier task"));
|
|
|
|
expect(container.querySelector('[data-testid="inbox-date-group"]')).toBeNull();
|
|
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("shows the resolved isolated workspace name in canonical task metadata", async () => {
|
|
routerMock.location.pathname = "/inbox/mine";
|
|
localStorage.setItem("paperclip:inbox:issue-columns", JSON.stringify(["status", "id", "workspace", "updated"]));
|
|
apiMocks.experimentalSettings.mockResolvedValue({ enableIsolatedWorkspaces: true });
|
|
apiMocks.executionWorkspaceSummaries.mockResolvedValue([{
|
|
id: "execution-workspace-1",
|
|
name: "Workspace Aurora",
|
|
mode: "isolated_workspace",
|
|
projectWorkspaceId: "project-workspace-1",
|
|
}]);
|
|
apiMocks.projectsList.mockResolvedValue([{
|
|
id: "project-1",
|
|
name: "Launch",
|
|
color: null,
|
|
workspaces: [{ id: "project-workspace-1", name: "Main" }],
|
|
executionWorkspacePolicy: { defaultProjectWorkspaceId: "project-workspace-1" },
|
|
primaryWorkspace: null,
|
|
}]);
|
|
apiMocks.issuesList.mockResolvedValue([createIssue({
|
|
projectId: "project-1",
|
|
executionWorkspaceId: "execution-workspace-1",
|
|
title: "Workspace-aware task",
|
|
})]);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } },
|
|
});
|
|
const root = createRoot(container);
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Inbox />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
// Workspace metadata resolves independently of the task list.
|
|
await vi.waitFor(() => {
|
|
expect(container.querySelector('[data-slot="task-row"]')?.textContent).toContain("Workspace Aurora");
|
|
});
|
|
|
|
const taskRow = container.querySelector('[data-slot="task-row"]');
|
|
const identifier = taskRow?.querySelector('[data-slot="task-row-identifier"]');
|
|
const timestamp = taskRow?.querySelector('[data-slot="task-row-timestamp"]');
|
|
expect(taskRow?.textContent).toContain("Workspace Aurora");
|
|
expect(identifier?.textContent).toBe("PAP-904");
|
|
expect(timestamp).not.toBeNull();
|
|
if (!identifier || !timestamp) throw new Error("Expected canonical identifier and timestamp columns");
|
|
expect(identifier.compareDocumentPosition(timestamp) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0);
|
|
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("hides workspace grouping when isolated workspaces are disabled", async () => {
|
|
routerMock.location.pathname = "/inbox/mine";
|
|
apiMocks.experimentalSettings.mockResolvedValue({ enableIsolatedWorkspaces: false });
|
|
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } },
|
|
});
|
|
const root = createRoot(container);
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Inbox />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
|
|
const groupButton = container.querySelector<HTMLButtonElement>('button[title="Group"]');
|
|
expect(groupButton).not.toBeNull();
|
|
|
|
await act(async () => {
|
|
groupButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
|
|
const groupOptions = Array.from(document.body.querySelectorAll("button")).map((button) => button.textContent);
|
|
expect(groupOptions).not.toContain("Workspace");
|
|
|
|
act(() => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("requests live descendant summaries for issue rows", async () => {
|
|
routerMock.location.pathname = "/inbox/mine";
|
|
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } },
|
|
});
|
|
const root = createRoot(container);
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Inbox />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
|
|
await vi.waitFor(() => {
|
|
expect(apiMocks.issuesList).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
expect(apiMocks.issuesList.mock.calls.map((call) => call[1]?.includeLiveDescendantSummary)).toEqual([
|
|
true,
|
|
true,
|
|
true,
|
|
]);
|
|
expect(apiMocks.issuesList.mock.calls.map((call) => call[1]?.limit)).toEqual([
|
|
500,
|
|
500,
|
|
500,
|
|
]);
|
|
|
|
act(() => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("paints row hover via CSS only, without moving React selection state", async () => {
|
|
routerMock.location.pathname = "/inbox/mine";
|
|
const issueA = createIssue({ id: "issue-a", identifier: "PAP-1001", title: "First inbox row" });
|
|
const issueB = createIssue({ id: "issue-b", identifier: "PAP-1002", title: "Second inbox row" });
|
|
apiMocks.issuesList.mockResolvedValue([issueA, issueB]);
|
|
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } },
|
|
});
|
|
const root = createRoot(container);
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Inbox />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
await vi.waitFor(() => {
|
|
expect(container.querySelectorAll("[data-inbox-item]").length).toBeGreaterThanOrEqual(2);
|
|
});
|
|
|
|
const rows = container.querySelectorAll("[data-inbox-item]");
|
|
|
|
// The hover wash lives on the IssueRow root band (the overlay link's
|
|
// parent), not the overlay link itself.
|
|
const bandOf = (row: Element): HTMLElement | null =>
|
|
row.querySelector<HTMLAnchorElement>("a[data-inbox-issue-link]")?.parentElement ?? null;
|
|
|
|
// Nothing selected before hover — both rows show the hover-accent class.
|
|
expect(bandOf(rows[0]!)?.className).toContain("hover:bg-accent/50");
|
|
expect(bandOf(rows[1]!)?.className).toContain("hover:bg-accent/50");
|
|
|
|
// Hovering paints via CSS `:hover` only — it must NOT flip a row into the
|
|
// state-selected band (which would swap to hover:bg-transparent). Coupling
|
|
// hover to React state was the per-hover re-render storm behind the lag;
|
|
// scrubbing the list must not touch selection state. (Keyboard nav that
|
|
// continues from the hovered row is exercised in live/e2e verification —
|
|
// this unit mocks keyboardShortcutsEnabled off.)
|
|
await act(async () => {
|
|
rows[1]!.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
|
|
rows[1]!.dispatchEvent(new MouseEvent("mouseenter", { bubbles: false }));
|
|
});
|
|
expect(bandOf(rows[0]!)?.className).toContain("hover:bg-accent/50");
|
|
expect(bandOf(rows[1]!)?.className).toContain("hover:bg-accent/50");
|
|
expect(bandOf(rows[1]!)?.className).not.toContain("hover:bg-transparent");
|
|
|
|
act(() => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("does not indent unread rows: the mark-read dot overlays the shared task-row gutter", async () => {
|
|
routerMock.location.pathname = "/inbox/mine";
|
|
// Two sibling leaf rows, one unread and one read, so their leading columns
|
|
// are directly comparable.
|
|
const unread = createIssue({
|
|
id: "issue-unread",
|
|
identifier: "PAP-2001",
|
|
title: "Unread inbox row",
|
|
isUnreadForMe: true,
|
|
});
|
|
const read = createIssue({
|
|
id: "issue-read",
|
|
identifier: "PAP-2002",
|
|
title: "Read inbox row",
|
|
isUnreadForMe: false,
|
|
});
|
|
apiMocks.issuesList.mockResolvedValue([unread, read]);
|
|
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } },
|
|
});
|
|
const root = createRoot(container);
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Inbox />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
await vi.waitFor(() => {
|
|
expect(container.textContent).toContain("Unread inbox row");
|
|
expect(container.textContent).toContain("Read inbox row");
|
|
});
|
|
|
|
const rows = Array.from(container.querySelectorAll("[data-inbox-item]"));
|
|
const rowFor = (text: string) => rows.find((row) => row.textContent?.includes(text));
|
|
const markReadButton = (row: Element) => row.querySelector('button[aria-label="Mark as read"]');
|
|
// The canonical spacer reserves the disclosure column on every leaf row.
|
|
const hasLeadingSpacer = (row: Element) =>
|
|
!!row.querySelector('[data-slot="task-row-disclosure-spacer"]');
|
|
// The overlay anchor is present on read AND unread Inbox rows without
|
|
// consuming a layout column.
|
|
const dotSlot = (row: Element) =>
|
|
row.querySelector('[data-testid="issue-row-unread-slot"]');
|
|
|
|
const unreadRow = rowFor("Unread inbox row")!;
|
|
const readRow = rowFor("Read inbox row")!;
|
|
|
|
// Both rows share the canonical task-row geometry. The dot is absolutely
|
|
// positioned in the row gutter, so Inbox does not gain a column that Tasks
|
|
// lacks and unread state cannot shift status/title alignment.
|
|
const unreadSlot = dotSlot(unreadRow);
|
|
const readSlot = dotSlot(readRow);
|
|
expect(unreadSlot).not.toBeNull();
|
|
expect(readSlot).not.toBeNull();
|
|
expect(unreadSlot?.className).toContain("absolute");
|
|
// Only the unread row carries the dot button; the read slot is empty.
|
|
expect(markReadButton(unreadSlot!)).not.toBeNull();
|
|
expect(readSlot?.querySelector('button[aria-label="Mark as read"]')).toBeNull();
|
|
expect(hasLeadingSpacer(unreadRow)).toBe(true);
|
|
|
|
// Read rows keep the same spacer, so both rows line up.
|
|
expect(hasLeadingSpacer(readRow)).toBe(true);
|
|
|
|
act(() => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("keeps hover→j/k selection in sync after the list reshapes (PAP-9679)", async () => {
|
|
routerMock.location.pathname = "/inbox/mine";
|
|
generalSettingsMock.keyboardShortcutsEnabled = true;
|
|
const issueA = createIssue({ id: "issue-a", identifier: "PAP-2001", title: "Sync row A" });
|
|
const issueB = createIssue({ id: "issue-b", identifier: "PAP-2002", title: "Sync row B" });
|
|
const issueC = createIssue({ id: "issue-c", identifier: "PAP-2003", title: "Sync row C" });
|
|
apiMocks.issuesList.mockResolvedValue([issueA, issueB, issueC]);
|
|
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } },
|
|
});
|
|
const root = createRoot(container);
|
|
|
|
// Canonical task rows put the selected wash on the root band (the overlay
|
|
// link's parent); find the row with the standalone selected utility.
|
|
const bandOf = (row: Element): HTMLElement | null =>
|
|
row.querySelector<HTMLAnchorElement>("a[data-inbox-issue-link]")?.parentElement ?? null;
|
|
const selectedRowIndex = () =>
|
|
[...container.querySelectorAll("[data-inbox-item]")].findIndex((row) =>
|
|
bandOf(row)?.className.split(/\s+/).includes("bg-accent/50"),
|
|
);
|
|
|
|
try {
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Inbox />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
await vi.waitFor(() => {
|
|
expect(container.querySelectorAll("[data-inbox-item]").length).toBeGreaterThanOrEqual(3);
|
|
});
|
|
|
|
// Pointer physically moves, then hovers the middle row (index 1).
|
|
await act(async () => {
|
|
window.dispatchEvent(new MouseEvent("mousemove", { bubbles: true }));
|
|
const rows = container.querySelectorAll("[data-inbox-item]");
|
|
rows[1]!.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
|
|
rows[1]!.dispatchEvent(new MouseEvent("mouseenter", { bubbles: false }));
|
|
});
|
|
|
|
// A poll reshapes the list (row B's title changes → new nav array) before
|
|
// the keypress. This is what used to null the hovered index and strand
|
|
// j/k back at the top.
|
|
apiMocks.issuesList.mockResolvedValue([issueA, { ...issueB, title: "Sync row B (updated)" }, issueC]);
|
|
await act(async () => {
|
|
await queryClient.invalidateQueries();
|
|
});
|
|
await vi.waitFor(() => {
|
|
expect(container.textContent).toContain("Sync row B (updated)");
|
|
});
|
|
|
|
// j must continue from the hovered row (index 1) → index 2, not jump to
|
|
// the top of the list.
|
|
await act(async () => {
|
|
document.body.dispatchEvent(new KeyboardEvent("keydown", { key: "j", bubbles: true }));
|
|
});
|
|
expect(selectedRowIndex()).toBe(2);
|
|
} finally {
|
|
generalSettingsMock.keyboardShortcutsEnabled = false;
|
|
act(() => {
|
|
root.unmount();
|
|
});
|
|
}
|
|
});
|
|
|
|
it("holds the inbox order across a reordering poll, then re-sorts at an attention boundary (PAP-16015)", async () => {
|
|
routerMock.location.pathname = "/inbox/mine";
|
|
const base = new Date("2026-03-11T00:00:00.000Z").getTime();
|
|
const issueA = createIssue({
|
|
id: "issue-a",
|
|
identifier: "PAP-3001",
|
|
title: "Pin row A",
|
|
lastActivityAt: new Date(base + 3000),
|
|
});
|
|
const issueB = createIssue({
|
|
id: "issue-b",
|
|
identifier: "PAP-3002",
|
|
title: "Pin row B",
|
|
lastActivityAt: new Date(base + 2000),
|
|
});
|
|
const issueC = createIssue({
|
|
id: "issue-c",
|
|
identifier: "PAP-3003",
|
|
title: "Pin row C",
|
|
lastActivityAt: new Date(base + 1000),
|
|
});
|
|
apiMocks.issuesList.mockResolvedValue([issueA, issueB, issueC]);
|
|
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } },
|
|
});
|
|
const root = createRoot(container);
|
|
|
|
// Collapse each displayed row to its A/B/C identity so we can assert order.
|
|
const orderOf = () =>
|
|
[...container.querySelectorAll("[data-inbox-item]")].flatMap((row) => {
|
|
const text = row.textContent ?? "";
|
|
if (text.includes("Pin row A")) return ["A"];
|
|
if (text.includes("Pin row B")) return ["B"];
|
|
if (text.includes("Pin row C")) return ["C"];
|
|
return [];
|
|
});
|
|
|
|
const visibilityDescriptor = Object.getOwnPropertyDescriptor(document, "visibilityState");
|
|
const setVisibility = (state: DocumentVisibilityState) => {
|
|
Object.defineProperty(document, "visibilityState", { configurable: true, get: () => state });
|
|
};
|
|
let nowValue = base + 1_000_000;
|
|
const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => nowValue);
|
|
|
|
try {
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Inbox />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
await vi.waitFor(() => {
|
|
expect(container.querySelectorAll("[data-inbox-item]").length).toBeGreaterThanOrEqual(3);
|
|
});
|
|
expect(orderOf()).toEqual(["A", "B", "C"]);
|
|
|
|
// A poll makes row C the most-recently-active: the fresh sort is now [C, A, B].
|
|
apiMocks.issuesList.mockResolvedValue([
|
|
{ ...issueA },
|
|
{ ...issueB },
|
|
{ ...issueC, lastActivityAt: new Date(base + 9000) },
|
|
]);
|
|
await act(async () => {
|
|
await queryClient.invalidateQueries();
|
|
});
|
|
await vi.waitFor(() => {
|
|
expect(container.textContent).toContain("Pin row C");
|
|
});
|
|
|
|
// No attention boundary has fired, so the displayed order is held, not reshuffled.
|
|
expect(orderOf()).toEqual(["A", "B", "C"]);
|
|
|
|
// The tab is hidden long enough to lose attention, then regains focus: that
|
|
// visibility boundary is a commit point, so the inbox adopts the fresh order.
|
|
await act(async () => {
|
|
setVisibility("hidden");
|
|
document.dispatchEvent(new Event("visibilitychange"));
|
|
});
|
|
nowValue += 31_000;
|
|
await act(async () => {
|
|
setVisibility("visible");
|
|
document.dispatchEvent(new Event("visibilitychange"));
|
|
});
|
|
await vi.waitFor(() => {
|
|
expect(orderOf()).toEqual(["C", "A", "B"]);
|
|
});
|
|
} finally {
|
|
nowSpy.mockRestore();
|
|
if (visibilityDescriptor) {
|
|
Object.defineProperty(document, "visibilityState", visibilityDescriptor);
|
|
} else {
|
|
setVisibility("visible");
|
|
}
|
|
act(() => {
|
|
root.unmount();
|
|
});
|
|
}
|
|
});
|
|
|
|
it("keeps other issue archive controls enabled while one archive is pending", async () => {
|
|
routerMock.location.pathname = "/inbox/mine";
|
|
const issueA = createIssue({ id: "issue-a", identifier: "PAP-1001", title: "First inbox row" });
|
|
const issueB = createIssue({ id: "issue-b", identifier: "PAP-1002", title: "Second inbox row" });
|
|
apiMocks.issuesList.mockResolvedValue([issueA, issueB]);
|
|
const archiveA = createDeferred<{ id: string; archivedAt: Date }>();
|
|
apiMocks.archiveFromInbox.mockImplementation((id: string) =>
|
|
id === "issue-a" ? archiveA.promise : Promise.resolve({ id, archivedAt: new Date() }),
|
|
);
|
|
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } },
|
|
});
|
|
const root = createRoot(container);
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Inbox />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
await vi.waitFor(() => {
|
|
expect(container.textContent).toContain("First inbox row");
|
|
expect(container.textContent).toContain("Second inbox row");
|
|
});
|
|
|
|
const initialArchiveButtons = Array.from(
|
|
container.querySelectorAll<HTMLButtonElement>('button[aria-label="Archive"]'),
|
|
);
|
|
expect(initialArchiveButtons.length).toBeGreaterThanOrEqual(2);
|
|
|
|
await act(async () => {
|
|
initialArchiveButtons[0]!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
|
});
|
|
|
|
await vi.waitFor(() => {
|
|
expect(apiMocks.archiveFromInbox).toHaveBeenCalledWith("issue-a");
|
|
expect(container.textContent).not.toContain("First inbox row");
|
|
expect(container.textContent).toContain("Second inbox row");
|
|
});
|
|
|
|
const remainingArchiveButton = container.querySelector<HTMLButtonElement>(
|
|
'button[aria-label="Archive"]',
|
|
);
|
|
expect(remainingArchiveButton).not.toBeNull();
|
|
expect(remainingArchiveButton?.disabled).toBe(false);
|
|
|
|
await act(async () => {
|
|
remainingArchiveButton!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
|
});
|
|
|
|
await vi.waitFor(() => {
|
|
expect(apiMocks.archiveFromInbox).toHaveBeenCalledWith("issue-b");
|
|
});
|
|
|
|
await act(async () => {
|
|
archiveA.resolve({ id: "issue-a", archivedAt: new Date() });
|
|
});
|
|
|
|
act(() => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("keeps a successful archive hidden when stale query data arrives", async () => {
|
|
routerMock.location.pathname = "/inbox/mine";
|
|
const archivedIssue = createIssue({
|
|
id: "issue-a",
|
|
identifier: "PAP-1001",
|
|
title: "Archived inbox row",
|
|
});
|
|
apiMocks.issuesList.mockResolvedValue([archivedIssue]);
|
|
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } },
|
|
});
|
|
const root = createRoot(container);
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Inbox />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
await vi.waitFor(() => {
|
|
expect(container.textContent).toContain("Archived inbox row");
|
|
});
|
|
|
|
const archiveButton = container.querySelector<HTMLButtonElement>('button[aria-label="Archive"]');
|
|
expect(archiveButton).not.toBeNull();
|
|
|
|
await act(async () => {
|
|
archiveButton!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
|
});
|
|
await vi.waitFor(() => {
|
|
expect(apiMocks.archiveFromInbox).toHaveBeenCalledWith("issue-a");
|
|
expect(container.textContent).not.toContain("Archived inbox row");
|
|
});
|
|
|
|
await act(async () => {
|
|
queryClient.setQueriesData<Issue[]>(
|
|
{ queryKey: ["issues", "company-1", "mine-by-me"] },
|
|
[archivedIssue],
|
|
);
|
|
});
|
|
|
|
expect(container.textContent).not.toContain("Archived inbox row");
|
|
|
|
act(() => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("restores a locally hidden archive when undo is pressed", async () => {
|
|
generalSettingsMock.keyboardShortcutsEnabled = true;
|
|
routerMock.location.pathname = "/inbox/mine";
|
|
const archivedIssue = createIssue({
|
|
id: "issue-a",
|
|
identifier: "PAP-1001",
|
|
title: "Undoable inbox row",
|
|
});
|
|
apiMocks.issuesList.mockResolvedValue([archivedIssue]);
|
|
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } },
|
|
});
|
|
const root = createRoot(container);
|
|
|
|
try {
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Inbox />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
await vi.waitFor(() => {
|
|
expect(container.textContent).toContain("Undoable inbox row");
|
|
});
|
|
|
|
const archiveButton = container.querySelector<HTMLButtonElement>('button[aria-label="Archive"]');
|
|
expect(archiveButton).not.toBeNull();
|
|
await act(async () => {
|
|
archiveButton!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
|
});
|
|
await vi.waitFor(() => {
|
|
expect(container.textContent).not.toContain("Undoable inbox row");
|
|
expect(queryClient.isMutating()).toBe(0);
|
|
});
|
|
|
|
await act(async () => {
|
|
document.body.dispatchEvent(new KeyboardEvent("keydown", { key: "u", bubbles: true }));
|
|
});
|
|
await vi.waitFor(() => {
|
|
expect(apiMocks.unarchiveFromInbox).toHaveBeenCalledWith("issue-a");
|
|
expect(container.textContent).toContain("Undoable inbox row");
|
|
});
|
|
} finally {
|
|
generalSettingsMock.keyboardShortcutsEnabled = false;
|
|
act(() => root.unmount());
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("FailedRunInboxRow", () => {
|
|
let container: HTMLDivElement;
|
|
|
|
beforeEach(() => {
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
});
|
|
|
|
afterEach(() => {
|
|
container.remove();
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|
|
});
|