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