Files
PaperClipAI/ui/src/lib/inboxArchiveCache.ts
T
Dotta f40a8fbf1e [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.
2026-07-07 07:22:42 -05:00

87 lines
2.8 KiB
TypeScript

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