From 45dfb183b6245f866edb3f55effb5a90d6193925 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Tue, 11 Aug 2026 14:13:06 -0400 Subject: [PATCH] fix(ui): add undo action to inbox archive toast (#11220) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip helps operators supervise AI-agent work. > - The Mine inbox keeps tasks that need an operator's attention in one place. > - Operators can archive a task from its detail page after they finish triage. > - That action is easy to select accidentally and did not offer immediate recovery. > - This pull request adds Undo to the archive success toast and keeps inbox caches consistent. > - The benefit is fast recovery without searching for or reopening the task. ## Linked Issues or Issue Description **What happened?** Archiving a task from the Mine inbox removed it and showed a success toast with no recovery action. **Expected behavior** The success toast should offer Undo. Selecting Undo should restore the task through the existing unarchive API while preserving a consistent inbox view. **Steps to reproduce** 1. Open a task from the Mine inbox. 2. Select the archive action. 3. Observe that the task leaves the inbox and the success toast has no Undo action. **Paperclip version or commit** Reproduced on `master` before this change. **Deployment mode** Local dev, built from source. Related prior work: #9931 and #10668. ## What Changed - Add an Undo action to the successful inbox archive toast. - Optimistically restore the task in captured inbox query caches before the unarchive request completes. - Cancel in-flight inbox fetches and clear the local archive guard so stale responses cannot hide the restored task. - Reapply the archive guard and remove the cached task if the unarchive request fails. - Add regression tests for successful Undo, the in-flight cache race, and failed Undo rollback behavior. ## Verification - `pnpm exec vitest run ui/src/pages/IssueDetail.test.tsx ui/src/lib/inboxArchiveCache.test.ts` — 51 tests passed on the final head. - `pnpm check:token-gates` — passed. - `pnpm -r typecheck` — passed. - `pnpm build` — passed. - `pnpm test:run` — the general server and UI groups passed. One CLI doctor assertion detected injected host AWS credentials and passed all 8 tests with those unrelated variables unset. A task-watchdog scheduler test also passed all 18 tests in isolation after one full-suite timing failure. ## Risks Low risk. The change uses the existing unarchive endpoint and inbox cache helpers. Undo failure returns the task to its archived state, shows an error toast, and invalidates the inbox queries for server reconciliation. > 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, model ID GPT-5. The service manages the context window. Reasoning, tool use, and code execution were enabled. ## 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 --------- Co-authored-by: Paperclip --- ui/src/lib/inboxArchiveCache.ts | 1 + ui/src/pages/IssueDetail.test.tsx | 108 +++++++++++++++++++++++++++++- ui/src/pages/IssueDetail.tsx | 44 +++++++++++- 3 files changed, 149 insertions(+), 4 deletions(-) diff --git a/ui/src/lib/inboxArchiveCache.ts b/ui/src/lib/inboxArchiveCache.ts index 1f8dd12ffa..9ce2a50767 100644 --- a/ui/src/lib/inboxArchiveCache.ts +++ b/ui/src/lib/inboxArchiveCache.ts @@ -154,6 +154,7 @@ export function filterLocalInboxArchivedQueryData(queryKey: QueryKey, dat function inboxIssueQueryPrefixes(companyId: string) { return [ + [...queryKeys.issues.list(companyId), "compact"], queryKeys.issues.listMineByMe(companyId), queryKeys.issues.listTouchedByMe(companyId), queryKeys.issues.listUnreadTouchedByMe(companyId), diff --git a/ui/src/pages/IssueDetail.test.tsx b/ui/src/pages/IssueDetail.test.tsx index 6758db200f..4e189cd580 100644 --- a/ui/src/pages/IssueDetail.test.tsx +++ b/ui/src/pages/IssueDetail.test.tsx @@ -34,6 +34,7 @@ const mockIssuesApi = vi.hoisted(() => ({ createTreeHold: vi.fn(), releaseTreeHold: vi.fn(), archiveFromInbox: vi.fn(), + unarchiveFromInbox: vi.fn(), addComment: vi.fn(), cancelComment: vi.fn(), upsertFeedbackVote: vi.fn(), @@ -1038,6 +1039,7 @@ describe("IssueDetail", () => { mockIssuesApi.listFeedbackVotes.mockResolvedValue([]); mockIssuesApi.markRead.mockResolvedValue({ id: "issue-1", lastReadAt: new Date().toISOString() }); mockIssuesApi.archiveFromInbox.mockResolvedValue({ id: "issue-1", archivedAt: new Date() }); + mockIssuesApi.unarchiveFromInbox.mockResolvedValue({ ok: true }); mockIssuesApi.getTreeControlState.mockResolvedValue({ activePauseHold: null }); mockIssuesApi.listTreeHolds.mockResolvedValue([]); mockActivityApi.forIssue.mockResolvedValue([]); @@ -1192,7 +1194,7 @@ describe("IssueDetail", () => { mockIssuesApi.update.mockReset(); }); - it("removes an inbox-origin archived issue from cached inbox variants before navigating back", async () => { + it("removes an inbox-origin archived issue and restores it when the toast Undo action is pressed", 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 }>(); @@ -1205,6 +1207,12 @@ describe("IssueDetail", () => { "with-routine-executions", "live-descendant-summary", ] as const; + const compactKey = [ + ...queryKeys.issues.list("company-1"), + "compact", + "with-routine-executions", + "live-descendant-summary", + ] as const; const touchedKey = [ ...queryKeys.issues.listTouchedByMe("company-1"), "with-routine-executions", @@ -1212,6 +1220,7 @@ describe("IssueDetail", () => { ] as const; const unreadKey = queryKeys.issues.listUnreadTouchedByMe("company-1"); queryClient.setQueryData(mineKey, [issue, otherIssue]); + queryClient.setQueryData(compactKey, [issue, otherIssue]); queryClient.setQueryData(touchedKey, [issue, otherIssue]); queryClient.setQueryData(unreadKey, [issue, otherIssue]); @@ -1236,6 +1245,7 @@ describe("IssueDetail", () => { await waitForAssertion(() => { expect(queryClient.getQueryData(mineKey)?.map((item) => item.id)).toEqual(["issue-2"]); + expect(queryClient.getQueryData(compactKey)?.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(); @@ -1247,7 +1257,101 @@ describe("IssueDetail", () => { await flushReact(); expect(mockNavigate).toHaveBeenCalledWith("/inbox/mine", { replace: true }); - expect(mockPushToast).toHaveBeenCalledWith({ title: "Task archived from inbox", tone: "success" }); + const archiveToast = mockPushToast.mock.calls + .map(([toast]) => toast) + .find((toast) => toast.title === "Task archived from inbox"); + expect(archiveToast).toMatchObject({ + title: "Task archived from inbox", + tone: "success", + action: { label: "Undo" }, + }); + expect(archiveToast?.action?.onClick).toEqual(expect.any(Function)); + + const staleInboxFetch = createDeferred(); + const staleInboxRequest = queryClient.fetchQuery({ + queryKey: mineKey, + queryFn: () => staleInboxFetch.promise, + }).catch(() => undefined); + const staleCompactFetch = createDeferred(); + const staleCompactRequest = queryClient.fetchQuery({ + queryKey: compactKey, + queryFn: () => staleCompactFetch.promise, + }).catch(() => undefined); + await waitForAssertion(() => { + expect(queryClient.isFetching({ queryKey: mineKey })).toBe(1); + expect(queryClient.isFetching({ queryKey: compactKey })).toBe(1); + }); + + await act(async () => { + archiveToast.action.onClick(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + }); + staleInboxFetch.resolve([otherIssue]); + staleCompactFetch.resolve([otherIssue]); + await staleInboxRequest; + await staleCompactRequest; + await waitForAssertion(() => { + expect(mockIssuesApi.unarchiveFromInbox).toHaveBeenCalledWith("issue-1"); + expect(queryClient.getQueryData(mineKey)?.map((item) => item.id)).toEqual(["issue-1", "issue-2"]); + expect(queryClient.getQueryData(compactKey)?.map((item) => item.id)).toEqual(["issue-1", "issue-2"]); + expect(queryClient.getQueryData(touchedKey)?.map((item) => item.id)).toEqual(["issue-1", "issue-2"]); + expect(queryClient.getQueryData(unreadKey)?.map((item) => item.id)).toEqual(["issue-1", "issue-2"]); + expect(mockPushToast).toHaveBeenCalledWith({ title: "Task restored to inbox", tone: "success" }); + }); + }); + + it("keeps an archived task hidden and reports an error when toast Undo fails", async () => { + const issue = createIssue({ id: "issue-1", identifier: "PAP-1", title: "Archive me from detail" }); + mockLocation.state = createIssueDetailLocationState("Inbox", "/inbox/mine", "inbox"); + mockIssuesApi.get.mockResolvedValue(issue); + mockIssuesApi.unarchiveFromInbox.mockRejectedValue(new Error("Inbox policy denied")); + + const mineKey = [ + ...queryKeys.issues.listMineByMe("company-1"), + "with-routine-executions", + "live-descendant-summary", + ] as const; + queryClient.setQueryData(mineKey, [issue]); + + 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)).toEqual([]); + }); + + const archiveToast = mockPushToast.mock.calls + .map(([toast]) => toast) + .find((toast) => toast.title === "Task archived from inbox"); + expect(archiveToast?.action?.onClick).toEqual(expect.any(Function)); + + await act(async () => { + archiveToast.action.onClick(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + }); + await waitForAssertion(() => { + expect(mockIssuesApi.unarchiveFromInbox).toHaveBeenCalledWith("issue-1"); + expect(queryClient.getQueryData(mineKey)).toEqual([]); + expect(mockPushToast).toHaveBeenCalledWith({ + title: "Undo failed", + body: "Inbox policy denied", + tone: "error", + }); + }); }); it("shows assignee and originating avatars in the issue header metadata", async () => { diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 408a0bf3e1..5696c9f015 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -2172,6 +2172,37 @@ export function IssueDetail() { queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(selectedCompanyId) }); } }, [queryClient, selectedCompanyId]); + const undoInboxArchive = useCallback(async ( + id: string, + companyId: string | undefined, + previousData: InboxIssueCacheSnapshot, + ) => { + if (companyId) { + await cancelInboxIssueQueries(queryClient, companyId); + clearLocalInboxArchive(companyId, id); + restoreIssueToInboxCaches(queryClient, previousData, id); + } + + try { + await issuesApi.unarchiveFromInbox(id); + pushToast({ title: "Task restored to inbox", tone: "success" }); + } catch (error) { + if (companyId) { + beginLocalInboxArchive(companyId, id); + removeIssueFromInboxCaches(queryClient, companyId, id); + boundLocalInboxArchive(companyId, id); + } + pushToast({ + title: "Undo failed", + body: error instanceof Error ? error.message : "Unable to restore this task to the inbox", + tone: "error", + }); + } finally { + if (companyId) { + await invalidateInboxIssueQueries(queryClient, companyId); + } + } + }, [pushToast, queryClient]); const upsertInteractionInCache = useCallback((interaction: IssueThreadInteraction) => { queryClient.setQueryData( queryKeys.issues.interactions(issueId!), @@ -3204,13 +3235,22 @@ export function IssueDetail() { removeIssueFromInboxCaches(queryClient, selectedCompanyId, id); return { companyId: selectedCompanyId, previousData }; }, - onSuccess: (_data, id) => { + onSuccess: (_data, id, context) => { 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" }); + pushToast({ + title: "Task archived from inbox", + tone: "success", + action: { + label: "Undo", + onClick: () => { + void undoInboxArchive(id, context?.companyId, context?.previousData ?? []); + }, + }, + }); }, onError: (err, id, context) => { if (context?.companyId) clearLocalInboxArchive(context.companyId, id);