[codex] Fix iOS inbox archive gestures (#9154)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The board inbox is a high-frequency operator surface, especially on mobile where row gestures are the primary way to dismiss handled work. > - The existing swipe archive control shared one pending-state gate across the inbox, so one archive request could make other inbox archive controls feel stuck. > - The touch handler also kept click suppression and archive timers coupled too loosely, which made cancelled or partial iOS touch sequences behave unpredictably. > - This pull request keeps archive state per issue, makes swipe/cancel cleanup explicit, and removes archived issues from every relevant inbox query cache before navigation/refetch. > - The benefit is that iOS users can archive from inbox/detail views without the inbox getting into a weird pending or stale-cache state. ## Linked Issues or Issue Description Bug report: - Summary: On iOS/mobile, inbox swipe/archive interactions can leave the inbox feeling stuck or stale after archiving a task. - Expected: Archiving one inbox item should remove that item optimistically, keep other archive controls usable, and recover cleanly from partial or cancelled touch gestures. - Actual: A pending archive globally disabled other archive controls, touch cancellation could share the touch-end path, and issue-detail archive navigation could happen before related inbox query caches were cleaned up. - Root cause: Archive pending state and inbox cache updates were handled locally in multiple places instead of through reusable per-company cache helpers, and the swipe component did not independently track live offset, archive timers, and click-suppression timers. ## What Changed - Split `SwipeToArchive` archive timeout handling from temporary click suppression and added explicit touch-cancel/reset cleanup. - Track swipe offset in a ref so commit threshold checks use the latest touch position rather than potentially stale React state. - Added shared inbox archive cache helpers for cancelling, snapshotting, optimistic removal, rollback, and invalidation across inbox query variants. - Updated inbox archive mutations to disable only the row being archived instead of every row while any archive request is pending. - Updated issue-detail archive-from-inbox to remove the archived issue from cached inbox variants before navigating back. - Added focused tests for partial drags, cancelled touches, unmount cleanup, per-row pending archive behavior, and detail-page cache removal before navigation. ## Verification - `pnpm exec vitest run ui/src/components/SwipeToArchive.test.tsx ui/src/pages/Inbox.test.tsx ui/src/pages/IssueDetail.test.tsx` - 3 test files passed - 53 tests passed ## Risks Low-to-medium risk. This changes mobile archive gesture state and optimistic inbox cache handling, so the main risk is cache invalidation missing an inbox query variant. The helper uses prefix-based React Query APIs for the known inbox variants and still invalidates those variants plus sidebar badges on settle. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex, GPT-5 based coding agent, with repository tool use and local command execution. ## 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 Related PR search performed with `gh search prs "inbox archive iOS swipe" --repo paperclipai/paperclip` and `gh search prs "SwipeToArchive" --repo paperclipai/paperclip`; existing related work includes #1857 and #1860, but no duplicate of this regression fix was found.
This commit is contained in:
parent
88ce6d3575
commit
f40a8fbf1e
|
|
@ -1,6 +1,6 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SwipeToArchive } from "./SwipeToArchive";
|
||||
|
|
@ -9,9 +9,13 @@ import { SwipeToArchive } from "./SwipeToArchive";
|
|||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
function act(callback: () => void) {
|
||||
flushSync(callback);
|
||||
}
|
||||
|
||||
function dispatchTouchEvent(
|
||||
node: Element,
|
||||
type: "touchstart" | "touchmove" | "touchend",
|
||||
type: "touchstart" | "touchmove" | "touchend" | "touchcancel",
|
||||
coords: { x: number; y: number },
|
||||
) {
|
||||
const event = new Event(type, { bubbles: true, cancelable: true });
|
||||
|
|
@ -19,7 +23,7 @@ function dispatchTouchEvent(
|
|||
|
||||
Object.defineProperty(event, "touches", {
|
||||
configurable: true,
|
||||
value: type === "touchend" ? [] : [touchPoint],
|
||||
value: type === "touchend" || type === "touchcancel" ? [] : [touchPoint],
|
||||
});
|
||||
Object.defineProperty(event, "changedTouches", {
|
||||
configurable: true,
|
||||
|
|
@ -123,6 +127,109 @@ describe("SwipeToArchive", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("does not keep suppressing clicks after a partial horizontal drag", () => {
|
||||
const onArchive = vi.fn();
|
||||
const onClick = vi.fn();
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<SwipeToArchive onArchive={onArchive}>
|
||||
<button type="button" onClick={onClick}>
|
||||
Open issue
|
||||
</button>
|
||||
</SwipeToArchive>,
|
||||
);
|
||||
});
|
||||
|
||||
const wrapper = container.firstElementChild as HTMLDivElement;
|
||||
const button = container.querySelector("button");
|
||||
expect(button).not.toBeNull();
|
||||
|
||||
Object.defineProperty(wrapper, "offsetWidth", { configurable: true, value: 200 });
|
||||
Object.defineProperty(wrapper, "offsetHeight", { configurable: true, value: 48 });
|
||||
|
||||
act(() => {
|
||||
dispatchTouchEvent(wrapper, "touchstart", { x: 180, y: 20 });
|
||||
dispatchTouchEvent(wrapper, "touchmove", { x: 150, y: 21 });
|
||||
dispatchTouchEvent(wrapper, "touchend", { x: 150, y: 21 });
|
||||
});
|
||||
|
||||
act(() => {
|
||||
button!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
});
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(350);
|
||||
button!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
});
|
||||
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
expect(onArchive).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("cancels instead of archiving when the touch sequence is cancelled", () => {
|
||||
const onArchive = vi.fn();
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<SwipeToArchive onArchive={onArchive}>
|
||||
<button type="button">Open issue</button>
|
||||
</SwipeToArchive>,
|
||||
);
|
||||
});
|
||||
|
||||
const wrapper = container.firstElementChild as HTMLDivElement;
|
||||
Object.defineProperty(wrapper, "offsetWidth", { configurable: true, value: 200 });
|
||||
Object.defineProperty(wrapper, "offsetHeight", { configurable: true, value: 48 });
|
||||
|
||||
act(() => {
|
||||
dispatchTouchEvent(wrapper, "touchstart", { x: 180, y: 20 });
|
||||
dispatchTouchEvent(wrapper, "touchmove", { x: 60, y: 22 });
|
||||
dispatchTouchEvent(wrapper, "touchcancel", { x: 60, y: 22 });
|
||||
vi.advanceTimersByTime(140);
|
||||
});
|
||||
|
||||
expect(onArchive).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("clears a pending archive timeout when unmounted", () => {
|
||||
const onArchive = vi.fn();
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<SwipeToArchive onArchive={onArchive}>
|
||||
<button type="button">Open issue</button>
|
||||
</SwipeToArchive>,
|
||||
);
|
||||
});
|
||||
|
||||
const wrapper = container.firstElementChild as HTMLDivElement;
|
||||
Object.defineProperty(wrapper, "offsetWidth", { configurable: true, value: 200 });
|
||||
Object.defineProperty(wrapper, "offsetHeight", { configurable: true, value: 48 });
|
||||
|
||||
act(() => {
|
||||
dispatchTouchEvent(wrapper, "touchstart", { x: 180, y: 20 });
|
||||
dispatchTouchEvent(wrapper, "touchmove", { x: 60, y: 22 });
|
||||
dispatchTouchEvent(wrapper, "touchend", { x: 60, y: 22 });
|
||||
root.unmount();
|
||||
vi.advanceTimersByTime(140);
|
||||
});
|
||||
|
||||
expect(onArchive).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders the selected inbox treatment on the swipe surface", () => {
|
||||
const root = createRoot(container);
|
||||
|
||||
|
|
|
|||
|
|
@ -24,8 +24,10 @@ export function SwipeToArchive({
|
|||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const startPointRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const widthRef = useRef(0);
|
||||
const timeoutRef = useRef<number | null>(null);
|
||||
const archiveTimeoutRef = useRef<number | null>(null);
|
||||
const suppressClickTimeoutRef = useRef<number | null>(null);
|
||||
const suppressClickRef = useRef(false);
|
||||
const offsetXRef = useRef(0);
|
||||
const [offsetX, setOffsetX] = useState(0);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isCollapsing, setIsCollapsing] = useState(false);
|
||||
|
|
@ -33,39 +35,77 @@ export function SwipeToArchive({
|
|||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current !== null) {
|
||||
window.clearTimeout(timeoutRef.current);
|
||||
}
|
||||
if (archiveTimeoutRef.current !== null) window.clearTimeout(archiveTimeoutRef.current);
|
||||
if (suppressClickTimeoutRef.current !== null) window.clearTimeout(suppressClickTimeoutRef.current);
|
||||
archiveTimeoutRef.current = null;
|
||||
suppressClickTimeoutRef.current = null;
|
||||
startPointRef.current = null;
|
||||
suppressClickRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const clearArchiveTimeout = () => {
|
||||
if (archiveTimeoutRef.current === null) return;
|
||||
window.clearTimeout(archiveTimeoutRef.current);
|
||||
archiveTimeoutRef.current = null;
|
||||
};
|
||||
|
||||
const clearClickSuppressionTimeout = () => {
|
||||
if (suppressClickTimeoutRef.current === null) return;
|
||||
window.clearTimeout(suppressClickTimeoutRef.current);
|
||||
suppressClickTimeoutRef.current = null;
|
||||
};
|
||||
|
||||
const clearClickSuppression = () => {
|
||||
clearClickSuppressionTimeout();
|
||||
suppressClickRef.current = false;
|
||||
};
|
||||
|
||||
const releaseClickSuppressionSoon = () => {
|
||||
clearClickSuppressionTimeout();
|
||||
suppressClickTimeoutRef.current = window.setTimeout(() => {
|
||||
suppressClickRef.current = false;
|
||||
suppressClickTimeoutRef.current = null;
|
||||
}, 350);
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
startPointRef.current = null;
|
||||
offsetXRef.current = 0;
|
||||
setIsDragging(false);
|
||||
setIsCollapsing(false);
|
||||
setLockedHeight(null);
|
||||
setOffsetX(0);
|
||||
};
|
||||
|
||||
const commitArchive = () => {
|
||||
clearArchiveTimeout();
|
||||
const node = containerRef.current;
|
||||
if (!node) {
|
||||
onArchive();
|
||||
return;
|
||||
}
|
||||
startPointRef.current = null;
|
||||
setIsDragging(false);
|
||||
setLockedHeight(node.offsetHeight);
|
||||
setOffsetX(-Math.max(widthRef.current, node.offsetWidth));
|
||||
const commitOffset = -Math.max(widthRef.current, node.offsetWidth);
|
||||
offsetXRef.current = commitOffset;
|
||||
setOffsetX(commitOffset);
|
||||
window.requestAnimationFrame(() => {
|
||||
window.requestAnimationFrame(() => {
|
||||
setIsCollapsing(true);
|
||||
});
|
||||
});
|
||||
timeoutRef.current = window.setTimeout(() => {
|
||||
archiveTimeoutRef.current = window.setTimeout(() => {
|
||||
archiveTimeoutRef.current = null;
|
||||
onArchive();
|
||||
}, COMMIT_DELAY_MS);
|
||||
};
|
||||
|
||||
const handleTouchStart = (event: React.TouchEvent<HTMLDivElement>) => {
|
||||
if (disabled || event.touches.length !== 1) return;
|
||||
if (disabled || isCollapsing || archiveTimeoutRef.current !== null || event.touches.length !== 1) return;
|
||||
clearArchiveTimeout();
|
||||
clearClickSuppression();
|
||||
const touch = event.touches[0];
|
||||
const node = containerRef.current;
|
||||
widthRef.current = node?.offsetWidth ?? 0;
|
||||
|
|
@ -96,6 +136,7 @@ export function SwipeToArchive({
|
|||
if (deltaX >= 0) {
|
||||
event.preventDefault();
|
||||
setIsDragging(true);
|
||||
offsetXRef.current = 0;
|
||||
setOffsetX(0);
|
||||
return;
|
||||
}
|
||||
|
|
@ -103,17 +144,27 @@ export function SwipeToArchive({
|
|||
const maxSwipe = widthRef.current > 0 ? widthRef.current * MAX_SWIPE : Number.POSITIVE_INFINITY;
|
||||
event.preventDefault();
|
||||
setIsDragging(true);
|
||||
setOffsetX(Math.max(deltaX, -maxSwipe));
|
||||
const nextOffset = Math.max(deltaX, -maxSwipe);
|
||||
offsetXRef.current = nextOffset;
|
||||
setOffsetX(nextOffset);
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
if (disabled || isCollapsing) return;
|
||||
if (disabled || isCollapsing || archiveTimeoutRef.current !== null) return;
|
||||
const shouldCommit =
|
||||
widthRef.current > 0 && Math.abs(offsetX) >= widthRef.current * COMMIT_THRESHOLD;
|
||||
widthRef.current > 0 && Math.abs(offsetXRef.current) >= widthRef.current * COMMIT_THRESHOLD;
|
||||
if (shouldCommit) {
|
||||
releaseClickSuppressionSoon();
|
||||
commitArchive();
|
||||
return;
|
||||
}
|
||||
if (isDragging) releaseClickSuppressionSoon();
|
||||
reset();
|
||||
};
|
||||
|
||||
const handleTouchCancel = () => {
|
||||
if (disabled || isCollapsing || archiveTimeoutRef.current !== null) return;
|
||||
clearClickSuppression();
|
||||
reset();
|
||||
};
|
||||
|
||||
|
|
@ -131,12 +182,12 @@ export function SwipeToArchive({
|
|||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onTouchCancel={handleTouchEnd}
|
||||
onTouchCancel={handleTouchCancel}
|
||||
onClickCapture={(event) => {
|
||||
if (!suppressClickRef.current) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
suppressClickRef.current = false;
|
||||
clearClickSuppression();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
import { QueryClient } from "@tanstack/react-query";
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
removeIssueFromInboxCaches,
|
||||
restoreIssueToInboxCaches,
|
||||
snapshotInboxIssueCaches,
|
||||
} from "./inboxArchiveCache";
|
||||
import { queryKeys } from "./queryKeys";
|
||||
|
||||
function issue(id: string): Issue {
|
||||
return { id } as Issue;
|
||||
}
|
||||
|
||||
describe("inboxArchiveCache", () => {
|
||||
it("restores only the failed archive during overlapping optimistic removals", () => {
|
||||
const companyId = "company-1";
|
||||
const queryClient = new QueryClient();
|
||||
const queryKey = [...queryKeys.issues.listMineByMe(companyId), "with-routine-executions"] as const;
|
||||
|
||||
queryClient.setQueryData<Issue[]>(queryKey, [
|
||||
issue("issue-a"),
|
||||
issue("issue-b"),
|
||||
issue("issue-c"),
|
||||
]);
|
||||
|
||||
const archiveASnapshot = snapshotInboxIssueCaches(queryClient, companyId);
|
||||
removeIssueFromInboxCaches(queryClient, companyId, "issue-a");
|
||||
|
||||
const archiveBSnapshot = snapshotInboxIssueCaches(queryClient, companyId);
|
||||
removeIssueFromInboxCaches(queryClient, companyId, "issue-b");
|
||||
|
||||
restoreIssueToInboxCaches(queryClient, archiveASnapshot, "issue-a");
|
||||
|
||||
expect(queryClient.getQueryData<Issue[]>(queryKey)?.map((cachedIssue) => cachedIssue.id)).toEqual([
|
||||
"issue-a",
|
||||
"issue-c",
|
||||
]);
|
||||
|
||||
restoreIssueToInboxCaches(queryClient, archiveBSnapshot, "issue-b");
|
||||
|
||||
expect(queryClient.getQueryData<Issue[]>(queryKey)?.map((cachedIssue) => cachedIssue.id)).toEqual([
|
||||
"issue-a",
|
||||
"issue-b",
|
||||
"issue-c",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
import type { QueryClient, QueryKey } from "@tanstack/react-query";
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
import { queryKeys } from "./queryKeys";
|
||||
|
||||
export type InboxIssueCacheSnapshot = Array<readonly [QueryKey, Issue[] | undefined]>;
|
||||
|
||||
function inboxIssueQueryPrefixes(companyId: string) {
|
||||
return [
|
||||
queryKeys.issues.listMineByMe(companyId),
|
||||
queryKeys.issues.listTouchedByMe(companyId),
|
||||
queryKeys.issues.listUnreadTouchedByMe(companyId),
|
||||
] as const;
|
||||
}
|
||||
|
||||
function resolveRestoreIndex(currentData: Issue[], previousData: Issue[], previousIndex: number) {
|
||||
for (let index = previousIndex - 1; index >= 0; index -= 1) {
|
||||
const beforeIndex = currentData.findIndex((issue) => issue.id === previousData[index]?.id);
|
||||
if (beforeIndex >= 0) return beforeIndex + 1;
|
||||
}
|
||||
|
||||
for (let index = previousIndex + 1; index < previousData.length; index += 1) {
|
||||
const afterIndex = currentData.findIndex((issue) => issue.id === previousData[index]?.id);
|
||||
if (afterIndex >= 0) return afterIndex;
|
||||
}
|
||||
|
||||
return Math.min(previousIndex, currentData.length);
|
||||
}
|
||||
|
||||
export async function cancelInboxIssueQueries(queryClient: QueryClient, companyId: string) {
|
||||
await Promise.all(
|
||||
inboxIssueQueryPrefixes(companyId).map((queryKey) =>
|
||||
queryClient.cancelQueries({ queryKey }),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function snapshotInboxIssueCaches(
|
||||
queryClient: QueryClient,
|
||||
companyId: string,
|
||||
): InboxIssueCacheSnapshot {
|
||||
return inboxIssueQueryPrefixes(companyId).flatMap((queryKey) =>
|
||||
queryClient.getQueriesData<Issue[]>({ queryKey }),
|
||||
);
|
||||
}
|
||||
|
||||
export function removeIssueFromInboxCaches(
|
||||
queryClient: QueryClient,
|
||||
companyId: string,
|
||||
issueId: string,
|
||||
) {
|
||||
for (const queryKey of inboxIssueQueryPrefixes(companyId)) {
|
||||
queryClient.setQueriesData<Issue[]>(
|
||||
{ queryKey },
|
||||
(cached) => cached?.filter((issue) => issue.id !== issueId),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function restoreIssueToInboxCaches(
|
||||
queryClient: QueryClient,
|
||||
snapshot: InboxIssueCacheSnapshot,
|
||||
issueId: string,
|
||||
) {
|
||||
for (const [queryKey, previousData] of snapshot) {
|
||||
if (!previousData) continue;
|
||||
|
||||
const previousIndex = previousData.findIndex((issue) => issue.id === issueId);
|
||||
if (previousIndex < 0) continue;
|
||||
|
||||
const issueToRestore = previousData[previousIndex];
|
||||
queryClient.setQueryData<Issue[]>(queryKey, (currentData) => {
|
||||
if (currentData?.some((issue) => issue.id === issueId)) return currentData;
|
||||
|
||||
const nextData = [...(currentData ?? [])];
|
||||
nextData.splice(resolveRestoreIndex(nextData, previousData, previousIndex), 0, issueToRestore);
|
||||
return nextData;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function invalidateInboxIssueQueries(queryClient: QueryClient, companyId: string) {
|
||||
for (const queryKey of inboxIssueQueryPrefixes(companyId)) {
|
||||
queryClient.invalidateQueries({ queryKey });
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(companyId) });
|
||||
}
|
||||
|
|
@ -23,6 +23,8 @@ const apiMocks = vi.hoisted(() => ({
|
|||
issuesList: vi.fn(),
|
||||
issuesCount: vi.fn(),
|
||||
issueLabels: vi.fn(),
|
||||
archiveFromInbox: vi.fn(),
|
||||
unarchiveFromInbox: vi.fn(),
|
||||
agentsList: vi.fn(),
|
||||
heartbeatRunsList: vi.fn(),
|
||||
liveRunsForCompany: vi.fn(),
|
||||
|
|
@ -64,8 +66,8 @@ vi.mock("../api/issues", () => ({
|
|||
listLabels: apiMocks.issueLabels,
|
||||
markRead: vi.fn(),
|
||||
markUnread: vi.fn(),
|
||||
archiveFromInbox: vi.fn(),
|
||||
unarchiveFromInbox: vi.fn(),
|
||||
archiveFromInbox: apiMocks.archiveFromInbox,
|
||||
unarchiveFromInbox: apiMocks.unarchiveFromInbox,
|
||||
},
|
||||
}));
|
||||
|
||||
|
|
@ -197,6 +199,16 @@ function createIssue(overrides: Partial<Issue> = {}): Issue {
|
|||
};
|
||||
}
|
||||
|
||||
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 {
|
||||
|
|
@ -256,6 +268,8 @@ function resetInboxApiMocks() {
|
|||
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([]);
|
||||
|
|
@ -425,6 +439,71 @@ describe("Inbox toolbar", () => {
|
|||
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="Dismiss from inbox"]'),
|
||||
);
|
||||
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="Dismiss from inbox"]',
|
||||
);
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("FailedRunInboxRow", () => {
|
||||
|
|
|
|||
|
|
@ -53,6 +53,14 @@ import {
|
|||
resolveInboxIssueBlockerAttention,
|
||||
resolveIssueLiveDescendantCount,
|
||||
} from "../lib/inbox-live-descendants";
|
||||
import {
|
||||
cancelInboxIssueQueries,
|
||||
invalidateInboxIssueQueries,
|
||||
removeIssueFromInboxCaches,
|
||||
restoreIssueToInboxCaches,
|
||||
snapshotInboxIssueCaches,
|
||||
type InboxIssueCacheSnapshot,
|
||||
} from "../lib/inboxArchiveCache";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { IssueGroupHeader } from "../components/IssueGroupHeader";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
|
|
@ -1529,12 +1537,9 @@ export function Inbox() {
|
|||
const [selectedIndex, setSelectedIndex] = useState<number>(-1);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const invalidateInboxIssueQueries = () => {
|
||||
const invalidateInboxIssueQueryCaches = () => {
|
||||
if (!selectedCompanyId) return;
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listMineByMe(selectedCompanyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listTouchedByMe(selectedCompanyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listUnreadTouchedByMe(selectedCompanyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(selectedCompanyId) });
|
||||
invalidateInboxIssueQueries(queryClient, selectedCompanyId);
|
||||
};
|
||||
|
||||
const archiveIssueMutation = useMutation({
|
||||
|
|
@ -1543,24 +1548,11 @@ export function Inbox() {
|
|||
setActionError(null);
|
||||
setArchivingIssueIds((prev) => new Set(prev).add(id));
|
||||
|
||||
// Cancel in-flight refetches so they don't overwrite our optimistic update
|
||||
const queryKeys_ = [
|
||||
[...queryKeys.issues.listMineByMe(selectedCompanyId!), "with-routine-executions"],
|
||||
[...queryKeys.issues.listTouchedByMe(selectedCompanyId!), "with-routine-executions"],
|
||||
queryKeys.issues.listUnreadTouchedByMe(selectedCompanyId!),
|
||||
];
|
||||
await Promise.all(queryKeys_.map((qk) => queryClient.cancelQueries({ queryKey: qk })));
|
||||
if (!selectedCompanyId) return { previousData: [] as InboxIssueCacheSnapshot };
|
||||
|
||||
// Snapshot previous data for rollback
|
||||
const previousData = queryKeys_.map((qk) => [qk, queryClient.getQueryData(qk)] as const);
|
||||
|
||||
// Optimistically remove the issue from all inbox query caches
|
||||
for (const qk of queryKeys_) {
|
||||
queryClient.setQueryData(qk, (old: unknown) => {
|
||||
if (!Array.isArray(old)) return old;
|
||||
return old.filter((issue: { id: string }) => issue.id !== id);
|
||||
});
|
||||
}
|
||||
await cancelInboxIssueQueries(queryClient, selectedCompanyId);
|
||||
const previousData = snapshotInboxIssueCaches(queryClient, selectedCompanyId);
|
||||
removeIssueFromInboxCaches(queryClient, selectedCompanyId, id);
|
||||
|
||||
return { previousData };
|
||||
},
|
||||
|
|
@ -1571,11 +1563,9 @@ export function Inbox() {
|
|||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
// Restore previous query data on failure
|
||||
// Restore only this failed archive so overlapping archive mutations stay removed.
|
||||
if (context?.previousData) {
|
||||
for (const [qk, data] of context.previousData) {
|
||||
queryClient.setQueryData(qk, data);
|
||||
}
|
||||
restoreIssueToInboxCaches(queryClient, context.previousData, id);
|
||||
}
|
||||
},
|
||||
onSettled: (_data, _error, id) => {
|
||||
|
|
@ -1585,7 +1575,7 @@ export function Inbox() {
|
|||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
invalidateInboxIssueQueries();
|
||||
invalidateInboxIssueQueryCaches();
|
||||
},
|
||||
onSuccess: (_data, id) => {
|
||||
setUndoableArchiveIssueIds((prev) => [...prev.filter((issueId) => issueId !== id), id]);
|
||||
|
|
@ -1613,7 +1603,7 @@ export function Inbox() {
|
|||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
invalidateInboxIssueQueries();
|
||||
invalidateInboxIssueQueryCaches();
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -1623,7 +1613,7 @@ export function Inbox() {
|
|||
setFadingOutIssues((prev) => new Set(prev).add(id));
|
||||
},
|
||||
onSuccess: () => {
|
||||
invalidateInboxIssueQueries();
|
||||
invalidateInboxIssueQueryCaches();
|
||||
},
|
||||
onSettled: (_data, _error, id) => {
|
||||
setTimeout(() => {
|
||||
|
|
@ -1648,7 +1638,7 @@ export function Inbox() {
|
|||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
invalidateInboxIssueQueries();
|
||||
invalidateInboxIssueQueryCaches();
|
||||
},
|
||||
onSettled: (_data, _error, issueIds) => {
|
||||
setTimeout(() => {
|
||||
|
|
@ -1664,7 +1654,7 @@ export function Inbox() {
|
|||
const markUnreadMutation = useMutation({
|
||||
mutationFn: (id: string) => issuesApi.markUnread(id),
|
||||
onSuccess: () => {
|
||||
invalidateInboxIssueQueries();
|
||||
invalidateInboxIssueQueryCaches();
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -2472,7 +2462,7 @@ export function Inbox() {
|
|||
unreadState={isUnread ? "visible" : isFading ? "fading" : "hidden"}
|
||||
onMarkRead={() => markReadMutation.mutate(issue.id)}
|
||||
onArchive={allowArchive ? () => archiveIssueMutation.mutate(issue.id) : undefined}
|
||||
archiveDisabled={isArchiving || archiveIssueMutation.isPending}
|
||||
archiveDisabled={isArchiving}
|
||||
desktopTrailing={
|
||||
visibleTrailingIssueColumns.length > 0 ? (
|
||||
<InboxIssueTrailingColumns
|
||||
|
|
@ -2761,7 +2751,7 @@ export function Inbox() {
|
|||
<SwipeToArchive
|
||||
key={`issue:${child.id}`}
|
||||
selected={isChildSelected}
|
||||
disabled={isChildArchiving || archiveIssueMutation.isPending}
|
||||
disabled={isChildArchiving}
|
||||
onArchive={() => archiveIssueMutation.mutate(child.id)}
|
||||
>
|
||||
{childRow}
|
||||
|
|
@ -2789,7 +2779,7 @@ export function Inbox() {
|
|||
<SwipeToArchive
|
||||
key={`issue:${issue.id}`}
|
||||
selected={isSelected}
|
||||
disabled={archivingIssueIds.has(issue.id) || archiveIssueMutation.isPending}
|
||||
disabled={archivingIssueIds.has(issue.id)}
|
||||
onArchive={() => archiveIssueMutation.mutate(issue.id)}
|
||||
>
|
||||
{parentRow}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
shouldScrollIssueDetailToTopOnNavigation,
|
||||
} from "./IssueDetail";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb";
|
||||
|
||||
const mockIssuesApi = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
|
|
@ -72,6 +73,12 @@ const mockInstanceSettingsApi = vi.hoisted(() => ({
|
|||
}));
|
||||
|
||||
const mockNavigate = vi.hoisted(() => vi.fn());
|
||||
const mockLocation = vi.hoisted(() => ({
|
||||
pathname: "/issues/PAP-1",
|
||||
search: "",
|
||||
hash: "",
|
||||
state: null as unknown,
|
||||
}));
|
||||
const mockOpenPanel = vi.hoisted(() => vi.fn());
|
||||
const mockClosePanel = vi.hoisted(() => vi.fn());
|
||||
const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
|
||||
|
|
@ -148,7 +155,7 @@ vi.mock("@/lib/router", () => ({
|
|||
} & AnchorHTMLAttributes<HTMLAnchorElement>) => (
|
||||
<a href={to} {...props}>{children}</a>
|
||||
),
|
||||
useLocation: () => ({ pathname: "/issues/PAP-1", search: "", hash: "", state: null }),
|
||||
useLocation: () => mockLocation,
|
||||
useNavigate: () => mockNavigate,
|
||||
useNavigationType: () => "PUSH",
|
||||
useParams: () => ({ issueId: "PAP-1" }),
|
||||
|
|
@ -944,6 +951,7 @@ describe("IssueDetail", () => {
|
|||
mockIssuesApi.listWorkProducts.mockResolvedValue([]);
|
||||
mockIssuesApi.listFeedbackVotes.mockResolvedValue([]);
|
||||
mockIssuesApi.markRead.mockResolvedValue({ id: "issue-1", lastReadAt: new Date().toISOString() });
|
||||
mockIssuesApi.archiveFromInbox.mockResolvedValue({ id: "issue-1", archivedAt: new Date() });
|
||||
mockIssuesApi.getTreeControlState.mockResolvedValue({ activePauseHold: null });
|
||||
mockIssuesApi.listTreeHolds.mockResolvedValue([]);
|
||||
mockActivityApi.forIssue.mockResolvedValue([]);
|
||||
|
|
@ -976,6 +984,11 @@ describe("IssueDetail", () => {
|
|||
mockIssueChatThreadRender.mockClear();
|
||||
mockImageGalleryRender.mockClear();
|
||||
mockIssueWorkspaceCardRender.mockClear();
|
||||
mockNavigate.mockClear();
|
||||
mockLocation.pathname = "/issues/PAP-1";
|
||||
mockLocation.search = "";
|
||||
mockLocation.hash = "";
|
||||
mockLocation.state = null;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
|
|
@ -1013,6 +1026,64 @@ describe("IssueDetail", () => {
|
|||
).toBe(false);
|
||||
});
|
||||
|
||||
it("removes an inbox-origin archived issue from cached inbox variants before navigating back", async () => {
|
||||
const issue = createIssue({ id: "issue-1", identifier: "PAP-1", title: "Archive me from detail" });
|
||||
const otherIssue = createIssue({ id: "issue-2", identifier: "PAP-2", title: "Keep me in inbox" });
|
||||
const archiveRequest = createDeferred<{ id: string; archivedAt: Date }>();
|
||||
mockLocation.state = createIssueDetailLocationState("Inbox", "/inbox/mine", "inbox");
|
||||
mockIssuesApi.get.mockResolvedValue(issue);
|
||||
mockIssuesApi.archiveFromInbox.mockReturnValue(archiveRequest.promise);
|
||||
|
||||
const mineKey = [
|
||||
...queryKeys.issues.listMineByMe("company-1"),
|
||||
"with-routine-executions",
|
||||
"live-descendant-summary",
|
||||
] as const;
|
||||
const touchedKey = [
|
||||
...queryKeys.issues.listTouchedByMe("company-1"),
|
||||
"with-routine-executions",
|
||||
"live-descendant-summary",
|
||||
] as const;
|
||||
const unreadKey = queryKeys.issues.listUnreadTouchedByMe("company-1");
|
||||
queryClient.setQueryData<Issue[]>(mineKey, [issue, otherIssue]);
|
||||
queryClient.setQueryData<Issue[]>(touchedKey, [issue, otherIssue]);
|
||||
queryClient.setQueryData<Issue[]>(unreadKey, [issue, otherIssue]);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<IssueDetail />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
const archiveButton = container.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Archive from inbox"]',
|
||||
);
|
||||
expect(archiveButton).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
archiveButton!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
});
|
||||
|
||||
await waitForAssertion(() => {
|
||||
expect(queryClient.getQueryData<Issue[]>(mineKey)?.map((item) => item.id)).toEqual(["issue-2"]);
|
||||
expect(queryClient.getQueryData<Issue[]>(touchedKey)?.map((item) => item.id)).toEqual(["issue-2"]);
|
||||
expect(queryClient.getQueryData<Issue[]>(unreadKey)?.map((item) => item.id)).toEqual(["issue-2"]);
|
||||
expect(mockNavigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
archiveRequest.resolve({ id: "issue-1", archivedAt: new Date() });
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/inbox/mine", { replace: true });
|
||||
expect(mockPushToast).toHaveBeenCalledWith({ title: "Task archived from inbox", tone: "success" });
|
||||
});
|
||||
|
||||
it("shows assignee and originating avatars in the issue header metadata", async () => {
|
||||
mockIssuesApi.get.mockResolvedValue(createIssue({
|
||||
assigneeAgentId: "agent-1",
|
||||
|
|
|
|||
|
|
@ -34,6 +34,14 @@ import {
|
|||
} from "../lib/issueDetailBreadcrumb";
|
||||
import { resolveIssueActiveRun, shouldTrackIssueActiveRun } from "../lib/issueActiveRun";
|
||||
import { getIssueDetailQueryOptions } from "../lib/issueDetailCache";
|
||||
import {
|
||||
cancelInboxIssueQueries,
|
||||
invalidateInboxIssueQueries,
|
||||
removeIssueFromInboxCaches,
|
||||
restoreIssueToInboxCaches,
|
||||
snapshotInboxIssueCaches,
|
||||
type InboxIssueCacheSnapshot,
|
||||
} from "../lib/inboxArchiveCache";
|
||||
import {
|
||||
hasBlockingShortcutDialog,
|
||||
resolveIssueDetailGoKeyAction,
|
||||
|
|
@ -2952,18 +2960,34 @@ export function IssueDetail() {
|
|||
|
||||
const archiveFromInbox = useMutation({
|
||||
mutationFn: (id: string) => issuesApi.archiveFromInbox(id),
|
||||
onSuccess: () => {
|
||||
onMutate: async (id) => {
|
||||
if (!selectedCompanyId) return { previousData: [] as InboxIssueCacheSnapshot };
|
||||
await cancelInboxIssueQueries(queryClient, selectedCompanyId);
|
||||
const previousData = snapshotInboxIssueCaches(queryClient, selectedCompanyId);
|
||||
removeIssueFromInboxCaches(queryClient, selectedCompanyId, id);
|
||||
return { previousData };
|
||||
},
|
||||
onSuccess: (_data, id) => {
|
||||
if (selectedCompanyId) {
|
||||
removeIssueFromInboxCaches(queryClient, selectedCompanyId, id);
|
||||
}
|
||||
invalidateIssueCollections();
|
||||
navigate(sourceBreadcrumb.href.startsWith("/inbox") ? sourceBreadcrumb.href : "/inbox", { replace: true });
|
||||
pushToast({ title: "Task archived from inbox", tone: "success" });
|
||||
},
|
||||
onError: (err) => {
|
||||
onError: (err, id, context) => {
|
||||
if (context?.previousData) {
|
||||
restoreIssueToInboxCaches(queryClient, context.previousData, id);
|
||||
}
|
||||
pushToast({
|
||||
title: "Archive failed",
|
||||
body: err instanceof Error ? err.message : "Unable to archive this task from the inbox",
|
||||
tone: "error",
|
||||
});
|
||||
},
|
||||
onSettled: () => {
|
||||
if (selectedCompanyId) invalidateInboxIssueQueries(queryClient, selectedCompanyId);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue