mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 19:28:46 +01:00
## 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.
87 lines
2.8 KiB
TypeScript
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) });
|
|
}
|