[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:
Dotta 2026-07-07 07:22:42 -05:00 committed by GitHub
parent 88ce6d3575
commit f40a8fbf1e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 510 additions and 54 deletions

View File

@ -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);

View File

@ -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

View File

@ -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",
]);
});
});

View File

@ -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) });
}

View File

@ -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", () => {

View File

@ -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}

View File

@ -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",

View File

@ -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(() => {