From f40a8fbf1e5882aabc7332b1c532a2f6f1aec820 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Tue, 7 Jul 2026 07:22:42 -0500 Subject: [PATCH] [codex] Fix iOS inbox archive gestures (#9154) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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. --- ui/src/components/SwipeToArchive.test.tsx | 113 +++++++++++++++++++++- ui/src/components/SwipeToArchive.tsx | 75 +++++++++++--- ui/src/lib/inboxArchiveCache.test.ts | 48 +++++++++ ui/src/lib/inboxArchiveCache.ts | 86 ++++++++++++++++ ui/src/pages/Inbox.test.tsx | 83 +++++++++++++++- ui/src/pages/Inbox.tsx | 58 +++++------ ui/src/pages/IssueDetail.test.tsx | 73 +++++++++++++- ui/src/pages/IssueDetail.tsx | 28 +++++- 8 files changed, 510 insertions(+), 54 deletions(-) create mode 100644 ui/src/lib/inboxArchiveCache.test.ts create mode 100644 ui/src/lib/inboxArchiveCache.ts diff --git a/ui/src/components/SwipeToArchive.test.tsx b/ui/src/components/SwipeToArchive.test.tsx index 8a3c07f329..d46d3514bc 100644 --- a/ui/src/components/SwipeToArchive.test.tsx +++ b/ui/src/components/SwipeToArchive.test.tsx @@ -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( + + + , + ); + }); + + 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( + + + , + ); + }); + + 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( + + + , + ); + }); + + 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); diff --git a/ui/src/components/SwipeToArchive.tsx b/ui/src/components/SwipeToArchive.tsx index f22fdd62dc..b177f98774 100644 --- a/ui/src/components/SwipeToArchive.tsx +++ b/ui/src/components/SwipeToArchive.tsx @@ -24,8 +24,10 @@ export function SwipeToArchive({ const containerRef = useRef(null); const startPointRef = useRef<{ x: number; y: number } | null>(null); const widthRef = useRef(0); - const timeoutRef = useRef(null); + const archiveTimeoutRef = useRef(null); + const suppressClickTimeoutRef = useRef(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) => { - 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(); }} >
{ + 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(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(queryKey)?.map((cachedIssue) => cachedIssue.id)).toEqual([ + "issue-a", + "issue-c", + ]); + + restoreIssueToInboxCaches(queryClient, archiveBSnapshot, "issue-b"); + + expect(queryClient.getQueryData(queryKey)?.map((cachedIssue) => cachedIssue.id)).toEqual([ + "issue-a", + "issue-b", + "issue-c", + ]); + }); +}); diff --git a/ui/src/lib/inboxArchiveCache.ts b/ui/src/lib/inboxArchiveCache.ts new file mode 100644 index 0000000000..38c8078721 --- /dev/null +++ b/ui/src/lib/inboxArchiveCache.ts @@ -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; + +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({ queryKey }), + ); +} + +export function removeIssueFromInboxCaches( + queryClient: QueryClient, + companyId: string, + issueId: string, +) { + for (const queryKey of inboxIssueQueryPrefixes(companyId)) { + queryClient.setQueriesData( + { 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(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) }); +} diff --git a/ui/src/pages/Inbox.test.tsx b/ui/src/pages/Inbox.test.tsx index 864bf3cf37..c83d0f9eec 100644 --- a/ui/src/pages/Inbox.test.tsx +++ b/ui/src/pages/Inbox.test.tsx @@ -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 { }; } +function createDeferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((innerResolve, innerReject) => { + resolve = innerResolve; + reject = innerReject; + }); + return { promise, resolve, reject }; +} + function createJoinRequest( overrides: Partial = {}, ): 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( + + + , + ); + }); + await vi.waitFor(() => { + expect(container.textContent).toContain("First inbox row"); + expect(container.textContent).toContain("Second inbox row"); + }); + + const initialArchiveButtons = Array.from( + container.querySelectorAll('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( + '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", () => { diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index c624483395..753232a61e 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -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(-1); const listRef = useRef(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 ? ( archiveIssueMutation.mutate(child.id)} > {childRow} @@ -2789,7 +2779,7 @@ export function Inbox() { archiveIssueMutation.mutate(issue.id)} > {parentRow} diff --git a/ui/src/pages/IssueDetail.test.tsx b/ui/src/pages/IssueDetail.test.tsx index f4414694ad..ab71a82b23 100644 --- a/ui/src/pages/IssueDetail.test.tsx +++ b/ui/src/pages/IssueDetail.test.tsx @@ -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) => ( {children} ), - 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(mineKey, [issue, otherIssue]); + queryClient.setQueryData(touchedKey, [issue, otherIssue]); + queryClient.setQueryData(unreadKey, [issue, otherIssue]); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + await flushReact(); + + const archiveButton = container.querySelector( + '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(mineKey)?.map((item) => item.id)).toEqual(["issue-2"]); + expect(queryClient.getQueryData(touchedKey)?.map((item) => item.id)).toEqual(["issue-2"]); + expect(queryClient.getQueryData(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", diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 6ffd4f6c3f..951eca1d41 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -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(() => {