fix(inbox): stop archived items from resurfacing (#9931)

## Thinking Path

> - Paperclip is the control plane operators use to coordinate AI-agent
companies and review work needing attention.
> - The Inbox is the operator-facing surface that aggregates tasks
requiring attention across server state and shared client polling.
> - Archiving a task optimistically removed it, but ordinary background
activity and stale polling responses could make it reappear seconds
later.
> - The server therefore needs to distinguish genuine user-attention
events from routine agent/system activity.
> - The client also needs a bounded local archive guard across every
Inbox query path while the server mutation and in-flight polls settle.
> - This pull request fixes both resurrection paths and adds
race-focused regression coverage.
> - The benefit is stable archive behavior without hiding a genuine
archive failure after reconciliation or reload.

## Linked Issues or Issue Description

### Pre-submission checklist

- [x] Searched existing open and closed issues and pull requests; no
duplicate implementation was found.
- [x] Reproduced on `master` before this branch.
- [x] Confirmed this is a Paperclip core bug, not adapter or provider
behavior.

### What happened?

Archiving an Inbox task hid it optimistically, then background refresh
activity could insert it back into the list seconds later.

### Expected behavior

A successfully archived task remains hidden during normal polling. A
genuine failed archive may become visible again after reconciliation or
reload.

### Steps to reproduce

1. Open Inbox with a visible task.
2. Archive the task.
3. Wait for shared polling or routine agent activity to refresh task
data.
4. Observe the archived task reappear without a hard page refresh.

### Paperclip version or commit

`master` before this branch.

### Deployment mode

Built from source using the local development application.

### Installation method

Built from source (`pnpm`).

### Agent adapter(s) involved

Not adapter-specific; this is a core Inbox bug.

### Database mode

Not database-mode-specific.

### Access context

Board (human operator).

### Additional context

The failure had independent server and client causes: routine activity
could resurface archived rows server-side, while stale shared-poll
responses could bypass optimistic client removal.

## What Changed

- Restrict server-side Inbox resurfacing to explicit user-attention
events rather than any issue activity write.
- Add a bounded client-side archive guard with confirmation, failure
restoration, and cache reconciliation behavior.
- Apply the guard to Inbox rendering, badge counts, optimistic cache
updates, and shared-poll result application.
- Classify the generic compact Inbox query so stale shared-poll data
cannot bypass the guard.
- Add server visibility-matrix tests and UI race-condition regression
tests.

## Verification

- `pnpm --filter @paperclipai/ui exec vitest run
src/hooks/useSharedPolling.test.ts src/lib/inboxArchiveCache.test.ts
src/pages/Inbox.test.tsx` — 25 passed.
- `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/issues-service.test.ts` — 107 passed.
- Branch rebased cleanly onto current `origin/master` before push.

## Risks

- Low-to-moderate behavioral risk: resurfacing is intentionally
narrower, so the server tests cover human comments, mentions,
interactions, and status transitions that must still regain attention.
- The client guard is bounded and cleared on mutation failure, limiting
the risk of hiding a task whose archive did not persist.
- No schema, migration, public API, workflow, dependency-lock, or
visual-token changes.

> 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

- Anthropic Claude via Claude Code (`claude_local`; prior
implementation/review run, exact underlying model ID and context window
were not retained in the handoff metadata), with repository tool use and
test execution.
- OpenAI `gpt-5.5` via Codex CLI for final review repair and PR
preparation, with reasoning, repository editing, GitHub tooling, and
code 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/described the result 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 and contains no internal
Paperclip task identifier
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation where needed; no
documentation change is required for this bug fix
- [x] I have considered and documented risks above
- [x] All Paperclip-authored commits include the required co-author
trailer

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-07-21 08:13:14 -05:00 committed by GitHub
parent 26890650b2
commit 59eee4829c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 676 additions and 93 deletions

View File

@ -304,6 +304,7 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
afterEach(async () => {
await db.delete(issueComments);
await db.delete(issueThreadInteractions);
await db.delete(issueRelations);
await db.delete(issueDocuments);
await db.delete(issueInboxArchives);
@ -1753,10 +1754,11 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
]));
});
it("resurfaces archived issue when status/updatedAt changes after archiving", async () => {
it("resurfaces archived issues only for user-attention events", async () => {
const companyId = randomUUID();
const userId = "user-1";
const otherUserId = "user-2";
const agentId = randomUUID();
await db.insert(companies).values({
id: companyId,
@ -1765,59 +1767,175 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
requireBoardApprovalForNewAgents: false,
});
const issueId = randomUUID();
await db.insert(issues).values({
id: issueId,
await db.insert(agents).values({
id: agentId,
companyId,
title: "Issue with old comment then status change",
name: "Worker",
role: "engineer",
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
});
const issueIds = {
updatedAt: randomUUID(),
agentComment: randomUUID(),
derivedAgentComment: randomUUID(),
systemComment: randomUUID(),
suggestTasks: randomUUID(),
askQuestions: randomUUID(),
requestConfirmation: randomUUID(),
inReview: randomUUID(),
blocked: randomUUID(),
done: randomUUID(),
humanComment: randomUUID(),
mention: randomUUID(),
unarchived: randomUUID(),
};
await db.insert(issues).values(Object.entries(issueIds).map(([title, id]) => ({
id,
companyId,
title,
status: "todo",
priority: "medium",
priority: "medium" as const,
createdByUserId: userId,
createdAt: new Date("2026-03-26T10:00:00.000Z"),
updatedAt: new Date("2026-03-26T10:00:00.000Z"),
});
})));
// Old external comment before archiving
await db.insert(issueComments).values({
companyId,
issueId,
authorUserId: otherUserId,
body: "Old comment before archive",
createdAt: new Date("2026-03-26T11:00:00.000Z"),
updatedAt: new Date("2026-03-26T11:00:00.000Z"),
});
const archivedAt = new Date("2026-03-26T12:00:00.000Z");
for (const issueId of Object.values(issueIds)) {
await svc.archiveInbox(companyId, issueId, userId, archivedAt);
}
// Archive after seeing the comment
await svc.archiveInbox(
companyId,
issueId,
userId,
new Date("2026-03-26T12:00:00.000Z"),
);
// Verify it's archived
const afterArchive = await svc.list(companyId, {
const listVisibleIds = async () => new Set((await svc.list(companyId, {
touchedByUserId: userId,
inboxArchivedByUserId: userId,
});
expect(afterArchive.map((i) => i.id)).not.toContain(issueId);
})).map((issue) => issue.id));
await expect(listVisibleIds()).resolves.toEqual(new Set());
// Status/work update changes updatedAt (no new comment)
await db
.update(issues)
.set({
status: "in_progress",
updatedAt: new Date("2026-03-26T13:00:00.000Z"),
})
.where(eq(issues.id, issueId));
.set({ status: "in_progress", updatedAt: new Date("2026-03-26T13:00:00.000Z") })
.where(eq(issues.id, issueIds.updatedAt));
// Should resurface because updatedAt > archivedAt
const afterUpdate = await svc.list(companyId, {
touchedByUserId: userId,
inboxArchivedByUserId: userId,
await db.insert(issueComments).values([
{
companyId,
issueId: issueIds.agentComment,
authorAgentId: agentId,
body: "Agent progress update",
createdAt: new Date("2026-03-26T13:00:00.000Z"),
updatedAt: new Date("2026-03-26T13:00:00.000Z"),
},
{
companyId,
issueId: issueIds.derivedAgentComment,
authorUserId: "local-board",
derivedAuthorAgentId: agentId,
derivedAuthorSource: "run_log_comment_post",
body: "Legacy agent-attributed progress update",
createdAt: new Date("2026-03-26T13:00:00.000Z"),
updatedAt: new Date("2026-03-26T13:00:00.000Z"),
},
{
companyId,
issueId: issueIds.systemComment,
body: "System lifecycle update",
createdAt: new Date("2026-03-26T13:00:00.000Z"),
updatedAt: new Date("2026-03-26T13:00:00.000Z"),
},
{
companyId,
issueId: issueIds.humanComment,
authorUserId: otherUserId,
body: "A human needs your attention",
createdAt: new Date("2026-03-26T13:00:00.000Z"),
updatedAt: new Date("2026-03-26T13:00:00.000Z"),
},
{
companyId,
issueId: issueIds.mention,
authorAgentId: agentId,
body: "Please review this, [Viewer](user://user-1)",
createdAt: new Date("2026-03-26T13:00:00.000Z"),
updatedAt: new Date("2026-03-26T13:00:00.000Z"),
},
]);
await db.insert(issueThreadInteractions).values({
companyId,
issueId: issueIds.suggestTasks,
kind: "suggest_tasks",
payload: { version: 1, tasks: [{ clientKey: "follow-up", title: "Follow up" }] },
createdByAgentId: agentId,
createdAt: new Date("2026-03-26T13:00:00.000Z"),
updatedAt: new Date("2026-03-26T13:00:00.000Z"),
});
expect(afterUpdate.map((i) => i.id)).toContain(issueId);
await db.insert(issueThreadInteractions).values({
companyId,
issueId: issueIds.askQuestions,
kind: "ask_user_questions",
payload: {
version: 1,
questions: [{ id: "scope", prompt: "Which scope?", selectionMode: "single", options: [] }],
},
createdByAgentId: agentId,
createdAt: new Date("2026-03-26T13:00:00.000Z"),
updatedAt: new Date("2026-03-26T13:00:00.000Z"),
});
await db.insert(issueThreadInteractions).values({
companyId,
issueId: issueIds.requestConfirmation,
kind: "request_confirmation",
payload: { version: 1, prompt: "Proceed?" },
createdByAgentId: agentId,
createdAt: new Date("2026-03-26T13:00:00.000Z"),
updatedAt: new Date("2026-03-26T13:00:00.000Z"),
});
await db.insert(activityLog).values([
[issueIds.inReview, "in_review"],
[issueIds.blocked, "blocked"],
[issueIds.done, "done"],
].map(([issueId, status]) => ({
companyId,
actorType: "agent",
actorId: agentId,
agentId,
action: "issue.updated",
entityType: "issue",
entityId: issueId,
details: { status, _previous: { status: "in_progress" } },
createdAt: new Date("2026-03-26T13:00:00.000Z"),
})));
await svc.unarchiveInbox(companyId, issueIds.unarchived, userId);
const expectedVisible = new Set([
issueIds.suggestTasks,
issueIds.askQuestions,
issueIds.requestConfirmation,
issueIds.inReview,
issueIds.blocked,
issueIds.done,
issueIds.humanComment,
issueIds.mention,
issueIds.unarchived,
]);
await expect(listVisibleIds()).resolves.toEqual(expectedVisible);
await svc.archiveInbox(
companyId,
issueIds.humanComment,
userId,
new Date("2026-03-26T14:00:00.000Z"),
);
expectedVisible.delete(issueIds.humanComment);
await expect(listVisibleIds()).resolves.toEqual(expectedVisible);
});
it("sorts and exposes last activity from comments and non-local issue activity logs", async () => {

View File

@ -1314,36 +1314,6 @@ function myLastTouchAtExpr(companyId: string, userId: string) {
`;
}
function lastExternalCommentAtExpr(companyId: string, userId: string) {
return sql<Date | null>`
(
SELECT MAX(${issueComments.createdAt})
FROM ${issueComments}
WHERE ${issueComments.issueId} = ${issues.id}
AND ${issueComments.companyId} = ${companyId}
AND (
${issueComments.authorUserId} IS NULL
OR ${issueComments.authorUserId} <> ${userId}
)
)
`;
}
function issueLastActivityAtExpr(companyId: string, userId: string) {
const lastExternalCommentAt = lastExternalCommentAtExpr(companyId, userId);
const myLastTouchAt = myLastTouchAtExpr(companyId, userId);
return sql<Date>`
GREATEST(
COALESCE(${lastExternalCommentAt}, to_timestamp(0)),
CASE
WHEN ${issues.updatedAt} > COALESCE(${myLastTouchAt}, to_timestamp(0))
THEN ${issues.updatedAt}
ELSE to_timestamp(0)
END
)
`;
}
const ISSUE_LOCAL_INBOX_ACTIVITY_ACTIONS = [
"issue.read_marked",
"issue.read_unmarked",
@ -1412,7 +1382,6 @@ function unreadForUserCondition(companyId: string, userId: string) {
}
function inboxVisibleForUserCondition(companyId: string, userId: string) {
const issueLastActivityAt = issueLastActivityAtExpr(companyId, userId);
return sql<boolean>`
NOT EXISTS (
SELECT 1
@ -1420,7 +1389,49 @@ function inboxVisibleForUserCondition(companyId: string, userId: string) {
WHERE ${issueInboxArchives.issueId} = ${issues.id}
AND ${issueInboxArchives.companyId} = ${companyId}
AND ${issueInboxArchives.userId} = ${userId}
AND ${issueInboxArchives.archivedAt} >= ${issueLastActivityAt}
AND NOT (
EXISTS (
SELECT 1
FROM ${issueThreadInteractions}
WHERE ${issueThreadInteractions.issueId} = ${issues.id}
AND ${issueThreadInteractions.companyId} = ${companyId}
AND ${issueThreadInteractions.kind} IN (
'suggest_tasks',
'ask_user_questions',
'request_confirmation'
)
AND ${issueThreadInteractions.createdAt} > ${issueInboxArchives.archivedAt}
)
OR EXISTS (
SELECT 1
FROM ${activityLog}
WHERE ${activityLog.companyId} = ${companyId}
AND ${activityLog.entityType} = 'issue'
AND ${activityLog.entityId} = ${issues.id}::text
AND ${activityLog.action} = 'issue.updated'
AND ${activityLog.createdAt} > ${issueInboxArchives.archivedAt}
AND ${activityLog.details}->>'status' IN ('in_review', 'blocked', 'done')
AND ${activityLog.details}->'_previous'->>'status'
IS DISTINCT FROM ${activityLog.details}->>'status'
)
OR EXISTS (
SELECT 1
FROM ${issueComments}
WHERE ${issueComments.issueId} = ${issues.id}
AND ${issueComments.companyId} = ${companyId}
AND ${issueComments.createdAt} > ${issueInboxArchives.archivedAt}
AND ${issueComments.deletedAt} IS NULL
AND (
(
${issueComments.authorUserId} IS NOT NULL
AND ${issueComments.authorUserId} <> ${userId}
AND ${issueComments.authorAgentId} IS NULL
AND ${issueComments.derivedAuthorAgentId} IS NULL
)
OR POSITION(${`](user://${userId})`} IN ${issueComments.body}) > 0
)
)
)
)
`;
}

View File

@ -9,6 +9,10 @@ import { dashboardApi } from "../api/dashboard";
import { heartbeatsApi } from "../api/heartbeats";
import { issuesApi } from "../api/issues";
import { queryKeys } from "../lib/queryKeys";
import {
filterLocalInboxArchivedIssues,
useLocalInboxArchiveIssueIds,
} from "../lib/inboxArchiveCache";
import { usePublishSharedQueryData, useSharedPollingQuery } from "./useSharedPolling";
import {
buildInboxDismissedAtByKey,
@ -174,6 +178,7 @@ export function useReadInboxItems() {
}
export function useInboxBadge(companyId: string | null | undefined) {
const locallyArchivedIssueIds = useLocalInboxArchiveIssueIds(companyId);
const { dismissed: dismissedAlerts } = useDismissedInboxAlerts();
const { dismissedAtByKey } = useInboxDismissals(companyId);
const { data: session } = useQuery({
@ -239,7 +244,10 @@ export function useInboxBadge(companyId: string | null | undefined) {
});
usePublishSharedQueryData(sharedMineIssues, mineIssuesRaw, mineIssuesUpdatedAt);
const mineIssues = useMemo(() => getRecentTouchedIssues(mineIssuesRaw), [mineIssuesRaw]);
const mineIssues = useMemo(
() => getRecentTouchedIssues(filterLocalInboxArchivedIssues(companyId, mineIssuesRaw)),
[companyId, locallyArchivedIssueIds, mineIssuesRaw],
);
const currentUserId = session?.user.id ?? session?.session.userId ?? null;
const { data: heartbeatRuns = [] } = useQuery({

View File

@ -1,8 +1,16 @@
import { QueryClient } from "@tanstack/react-query";
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it } from "vitest";
import {
beginLocalInboxArchive,
clearLocalInboxArchive,
} from "../lib/inboxArchiveCache";
import { applySharedPollingResult } from "./useSharedPolling";
describe("applySharedPollingResult", () => {
afterEach(() => {
clearLocalInboxArchive("company-1", "issue-archived");
});
it("drops result messages that are older than local query state", () => {
const queryClient = new QueryClient();
const queryKey = ["live-runs", "company-1"];
@ -40,4 +48,47 @@ describe("applySharedPollingResult", () => {
expect(queryClient.getQueryData(queryKey)).toEqual([{ id: "run-1", lastEventAt: "newer" }]);
expect(queryClient.getQueryState(queryKey)?.dataUpdatedAt).toBe(3_000);
});
it("filters locally archived issues from newer inbox broadcasts", () => {
const queryClient = new QueryClient();
const queryKey = ["issues", "company-1", "mine-by-me"];
beginLocalInboxArchive("company-1", "issue-archived");
const applied = applySharedPollingResult(queryClient, queryKey, {
type: "result",
key: "company:inbox",
from: "leader",
at: 4_000,
dataUpdatedAt: 3_000,
data: [{ id: "issue-archived" }, { id: "issue-visible" }],
});
expect(applied).toBe(true);
expect(queryClient.getQueryData(queryKey)).toEqual([{ id: "issue-visible" }]);
});
it("filters locally archived issues from the generic compact inbox broadcast", () => {
const queryClient = new QueryClient();
const queryKey = [
"issues",
"company-1",
"compact",
"with-routine-executions",
"live-descendant-summary",
250,
];
beginLocalInboxArchive("company-1", "issue-archived");
const applied = applySharedPollingResult(queryClient, queryKey, {
type: "result",
key: "company:inbox:issues",
from: "leader",
at: 4_000,
dataUpdatedAt: 3_000,
data: [{ id: "issue-archived" }, { id: "issue-visible" }],
});
expect(applied).toBe(true);
expect(queryClient.getQueryData(queryKey)).toEqual([{ id: "issue-visible" }]);
});
});

View File

@ -5,6 +5,7 @@ import {
type SharedMessage,
type SharedPollingSnapshot,
} from "../lib/cross-tab-poll";
import { filterLocalInboxArchivedQueryData } from "../lib/inboxArchiveCache";
type RefetchInterval = number | false;
@ -72,7 +73,8 @@ export function applySharedPollingResult<TData>(
if (incomingUpdatedAt <= 0) return false;
const localUpdatedAt = queryClient.getQueryState(queryKey)?.dataUpdatedAt ?? 0;
if (localUpdatedAt >= incomingUpdatedAt) return false;
queryClient.setQueryData(queryKey, message.data as TData, { updatedAt: incomingUpdatedAt });
const data = filterLocalInboxArchivedQueryData(queryKey, message.data as TData);
queryClient.setQueryData(queryKey, data, { updatedAt: incomingUpdatedAt });
return true;
}

View File

@ -1,7 +1,14 @@
import { QueryClient } from "@tanstack/react-query";
import { QueryClient, QueryObserver } from "@tanstack/react-query";
import type { Issue } from "@paperclipai/shared";
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
beginLocalInboxArchive,
boundLocalInboxArchive,
clearLocalInboxArchive,
confirmLocalInboxArchive,
filterLocalInboxArchivedIssues,
getIssuePresenceInActiveInboxCaches,
getLocalInboxArchiveIssueIds,
removeIssueFromInboxCaches,
restoreIssueToInboxCaches,
snapshotInboxIssueCaches,
@ -13,6 +20,13 @@ function issue(id: string): Issue {
}
describe("inboxArchiveCache", () => {
afterEach(() => {
vi.useRealTimers();
for (const issueId of getLocalInboxArchiveIssueIds("company-1")) {
clearLocalInboxArchive("company-1", issueId);
}
});
it("restores only the failed archive during overlapping optimistic removals", () => {
const companyId = "company-1";
const queryClient = new QueryClient();
@ -45,4 +59,55 @@ describe("inboxArchiveCache", () => {
"issue-c",
]);
});
it("filters locally archived issues until confirmed grace expires", () => {
vi.useFakeTimers();
const issues = [issue("issue-a"), issue("issue-b")];
beginLocalInboxArchive("company-1", "issue-a");
expect(filterLocalInboxArchivedIssues("company-1", issues)).toEqual([issue("issue-b")]);
confirmLocalInboxArchive("company-1", "issue-a");
vi.advanceTimersByTime(4_999);
expect(filterLocalInboxArchivedIssues("company-1", issues)).toEqual([issue("issue-b")]);
vi.advanceTimersByTime(1);
expect(filterLocalInboxArchivedIssues("company-1", issues)).toEqual(issues);
});
it("does not expire an in-flight archive before post-settle bounding starts", () => {
vi.useFakeTimers();
beginLocalInboxArchive("company-1", "issue-a");
vi.advanceTimersByTime(30_000);
expect(getLocalInboxArchiveIssueIds("company-1").has("issue-a")).toBe(true);
boundLocalInboxArchive("company-1", "issue-a");
vi.advanceTimersByTime(29_999);
expect(getLocalInboxArchiveIssueIds("company-1").has("issue-a")).toBe(true);
vi.advanceTimersByTime(1);
expect(getLocalInboxArchiveIssueIds("company-1").has("issue-a")).toBe(false);
});
it("distinguishes present, absent, and unavailable active inbox data", () => {
const companyId = "company-1";
const queryClient = new QueryClient();
const queryKey = [...queryKeys.issues.listMineByMe(companyId), "with-routine-executions"] as const;
expect(getIssuePresenceInActiveInboxCaches(queryClient, companyId, "issue-a")).toBe("unknown");
queryClient.setQueryData<Issue[]>(queryKey, [issue("issue-a")]);
const observer = new QueryObserver<Issue[]>(queryClient, {
queryKey,
queryFn: async () => [],
});
const unsubscribe = observer.subscribe(() => undefined);
expect(getIssuePresenceInActiveInboxCaches(queryClient, companyId, "issue-a")).toBe("present");
queryClient.setQueryData<Issue[]>(queryKey, [issue("issue-b")]);
expect(getIssuePresenceInActiveInboxCaches(queryClient, companyId, "issue-a")).toBe("absent");
unsubscribe();
});
});

View File

@ -1,9 +1,157 @@
import { useSyncExternalStore } from "react";
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]>;
const INBOX_ARCHIVE_CONFIRMATION_GRACE_MS = 5_000;
const INBOX_ARCHIVE_MAX_GUARD_MS = 30_000;
const EMPTY_ARCHIVED_ISSUE_IDS: ReadonlySet<string> = new Set();
type InboxArchiveGuardState = {
issueIds: ReadonlySet<string>;
listeners: Set<() => void>;
confirmationTimers: Map<string, ReturnType<typeof setTimeout>>;
maximumTimers: Map<string, ReturnType<typeof setTimeout>>;
};
const inboxArchiveGuards = new Map<string, InboxArchiveGuardState>();
function pruneInboxArchiveGuard(companyId: string, state: InboxArchiveGuardState) {
if (
state.issueIds.size === 0
&& state.listeners.size === 0
&& state.confirmationTimers.size === 0
&& state.maximumTimers.size === 0
&& inboxArchiveGuards.get(companyId) === state
) {
inboxArchiveGuards.delete(companyId);
}
}
function getInboxArchiveGuard(companyId: string): InboxArchiveGuardState {
const existing = inboxArchiveGuards.get(companyId);
if (existing) return existing;
const created: InboxArchiveGuardState = {
issueIds: EMPTY_ARCHIVED_ISSUE_IDS,
listeners: new Set(),
confirmationTimers: new Map(),
maximumTimers: new Map(),
};
inboxArchiveGuards.set(companyId, created);
return created;
}
function publishInboxArchiveGuard(state: InboxArchiveGuardState, issueIds: Set<string>) {
state.issueIds = issueIds.size > 0 ? issueIds : EMPTY_ARCHIVED_ISSUE_IDS;
for (const listener of state.listeners) listener();
}
function clearArchiveGuardTimer(
timers: Map<string, ReturnType<typeof setTimeout>>,
issueId: string,
) {
const timer = timers.get(issueId);
if (timer) clearTimeout(timer);
timers.delete(issueId);
}
export function beginLocalInboxArchive(companyId: string, issueId: string) {
const state = getInboxArchiveGuard(companyId);
clearArchiveGuardTimer(state.confirmationTimers, issueId);
clearArchiveGuardTimer(state.maximumTimers, issueId);
const issueIds = new Set(state.issueIds);
issueIds.add(issueId);
publishInboxArchiveGuard(state, issueIds);
}
export function boundLocalInboxArchive(companyId: string, issueId: string) {
const state = inboxArchiveGuards.get(companyId);
if (!state?.issueIds.has(issueId)) return;
clearArchiveGuardTimer(state.maximumTimers, issueId);
state.maximumTimers.set(issueId, setTimeout(() => {
clearLocalInboxArchive(companyId, issueId);
}, INBOX_ARCHIVE_MAX_GUARD_MS));
}
export function confirmLocalInboxArchive(companyId: string, issueId: string) {
const state = inboxArchiveGuards.get(companyId);
if (!state?.issueIds.has(issueId)) return;
clearArchiveGuardTimer(state.confirmationTimers, issueId);
state.confirmationTimers.set(issueId, setTimeout(() => {
clearLocalInboxArchive(companyId, issueId);
}, INBOX_ARCHIVE_CONFIRMATION_GRACE_MS));
}
export function clearLocalInboxArchive(companyId: string, issueId: string) {
const state = inboxArchiveGuards.get(companyId);
if (!state) return;
clearArchiveGuardTimer(state.confirmationTimers, issueId);
clearArchiveGuardTimer(state.maximumTimers, issueId);
if (!state.issueIds.has(issueId)) {
pruneInboxArchiveGuard(companyId, state);
return;
}
const issueIds = new Set(state.issueIds);
issueIds.delete(issueId);
publishInboxArchiveGuard(state, issueIds);
pruneInboxArchiveGuard(companyId, state);
}
export function getLocalInboxArchiveIssueIds(companyId: string | null | undefined): ReadonlySet<string> {
if (!companyId) return EMPTY_ARCHIVED_ISSUE_IDS;
return inboxArchiveGuards.get(companyId)?.issueIds ?? EMPTY_ARCHIVED_ISSUE_IDS;
}
export function useLocalInboxArchiveIssueIds(companyId: string | null | undefined): ReadonlySet<string> {
return useSyncExternalStore(
(listener) => {
if (!companyId) return () => undefined;
const state = getInboxArchiveGuard(companyId);
state.listeners.add(listener);
return () => {
state.listeners.delete(listener);
pruneInboxArchiveGuard(companyId, state);
};
},
() => getLocalInboxArchiveIssueIds(companyId),
() => EMPTY_ARCHIVED_ISSUE_IDS,
);
}
export function filterLocalInboxArchivedIssues(
companyId: string | null | undefined,
issues: Issue[],
): Issue[] {
const issueIds = getLocalInboxArchiveIssueIds(companyId);
if (issueIds.size === 0) return issues;
return issues.filter((issue) => !issueIds.has(issue.id));
}
function inboxIssueCompanyIdFromQueryKey(queryKey: QueryKey): string | null {
const inboxQueryKind = String(queryKey[2]);
if (
queryKey[0] !== "issues"
|| typeof queryKey[1] !== "string"
|| !["compact", "mine-by-me", "touched-by-me", "unread-touched-by-me"].includes(inboxQueryKind)
) {
return null;
}
return queryKey[1];
}
export function filterLocalInboxArchivedQueryData<TData>(queryKey: QueryKey, data: TData): TData {
const companyId = inboxIssueCompanyIdFromQueryKey(queryKey);
if (!companyId || !Array.isArray(data)) return data;
return filterLocalInboxArchivedIssues(companyId, data as Issue[]) as TData;
}
function inboxIssueQueryPrefixes(companyId: string) {
return [
queryKeys.issues.listMineByMe(companyId),
@ -79,8 +227,28 @@ export function restoreIssueToInboxCaches(
}
export function invalidateInboxIssueQueries(queryClient: QueryClient, companyId: string) {
for (const queryKey of inboxIssueQueryPrefixes(companyId)) {
queryClient.invalidateQueries({ queryKey });
}
queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(companyId) });
return Promise.all([
...inboxIssueQueryPrefixes(companyId).map((queryKey) =>
queryClient.invalidateQueries({ queryKey }),
),
queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(companyId) }),
]);
}
export function getIssuePresenceInActiveInboxCaches(
queryClient: QueryClient,
companyId: string,
issueId: string,
): "absent" | "present" | "unknown" {
const activeQueries = inboxIssueQueryPrefixes(companyId).flatMap((queryKey) =>
queryClient.getQueryCache().findAll({ queryKey })
.filter((query) => query.getObserversCount() > 0),
);
if (activeQueries.length === 0) return "unknown";
const isPresent = activeQueries.some((query) => {
const data = query.state.data;
return Array.isArray(data) && data.some((issue) => (issue as Issue).id === issueId);
});
return isPresent ? "present" : "absent";
}

View File

@ -7,6 +7,10 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Issue } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CompanyJoinRequest } from "../api/access";
import {
clearLocalInboxArchive,
getLocalInboxArchiveIssueIds,
} from "../lib/inboxArchiveCache";
const routerMock = vi.hoisted(() => ({
location: { pathname: "/", search: "", hash: "" },
@ -289,6 +293,9 @@ describe("Inbox toolbar", () => {
});
afterEach(() => {
for (const issueId of getLocalInboxArchiveIssueIds("company-1")) {
clearLocalInboxArchive("company-1", issueId);
}
container.remove();
});
@ -644,6 +651,106 @@ describe("Inbox toolbar", () => {
root.unmount();
});
});
it("keeps a successful archive hidden when stale query data arrives", async () => {
routerMock.location.pathname = "/inbox/mine";
const archivedIssue = createIssue({
id: "issue-a",
identifier: "PAP-1001",
title: "Archived inbox row",
});
apiMocks.issuesList.mockResolvedValue([archivedIssue]);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } },
});
const root = createRoot(container);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<Inbox />
</QueryClientProvider>,
);
});
await vi.waitFor(() => {
expect(container.textContent).toContain("Archived inbox row");
});
const archiveButton = container.querySelector<HTMLButtonElement>('button[aria-label="Archive"]');
expect(archiveButton).not.toBeNull();
await act(async () => {
archiveButton!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
});
await vi.waitFor(() => {
expect(apiMocks.archiveFromInbox).toHaveBeenCalledWith("issue-a");
expect(container.textContent).not.toContain("Archived inbox row");
});
await act(async () => {
queryClient.setQueriesData<Issue[]>(
{ queryKey: ["issues", "company-1", "mine-by-me"] },
[archivedIssue],
);
});
expect(container.textContent).not.toContain("Archived inbox row");
act(() => {
root.unmount();
});
});
it("restores a locally hidden archive when undo is pressed", async () => {
generalSettingsMock.keyboardShortcutsEnabled = true;
routerMock.location.pathname = "/inbox/mine";
const archivedIssue = createIssue({
id: "issue-a",
identifier: "PAP-1001",
title: "Undoable inbox row",
});
apiMocks.issuesList.mockResolvedValue([archivedIssue]);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } },
});
const root = createRoot(container);
try {
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<Inbox />
</QueryClientProvider>,
);
});
await vi.waitFor(() => {
expect(container.textContent).toContain("Undoable inbox row");
});
const archiveButton = container.querySelector<HTMLButtonElement>('button[aria-label="Archive"]');
expect(archiveButton).not.toBeNull();
await act(async () => {
archiveButton!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
});
await vi.waitFor(() => {
expect(container.textContent).not.toContain("Undoable inbox row");
expect(queryClient.isMutating()).toBe(0);
});
await act(async () => {
document.body.dispatchEvent(new KeyboardEvent("keydown", { key: "u", bubbles: true }));
});
await vi.waitFor(() => {
expect(apiMocks.unarchiveFromInbox).toHaveBeenCalledWith("issue-a");
expect(container.textContent).toContain("Undoable inbox row");
});
} finally {
generalSettingsMock.keyboardShortcutsEnabled = false;
act(() => root.unmount());
}
});
});
describe("FailedRunInboxRow", () => {

View File

@ -55,12 +55,18 @@ import {
resolveIssueLiveDescendantCount,
} from "../lib/inbox-live-descendants";
import {
beginLocalInboxArchive,
boundLocalInboxArchive,
cancelInboxIssueQueries,
clearLocalInboxArchive,
confirmLocalInboxArchive,
invalidateInboxIssueQueries,
getIssuePresenceInActiveInboxCaches,
removeIssueFromInboxCaches,
restoreIssueToInboxCaches,
snapshotInboxIssueCaches,
type InboxIssueCacheSnapshot,
useLocalInboxArchiveIssueIds,
} from "../lib/inboxArchiveCache";
import { EmptyState } from "../components/EmptyState";
import { IssueGroupHeader } from "../components/IssueGroupHeader";
@ -940,6 +946,17 @@ export function Inbox() {
enabled: !!selectedCompanyId,
});
const currentUserId = session?.user.id ?? session?.session.userId ?? null;
const [archivingIssueIds, setArchivingIssueIds] = useState<Set<string>>(new Set());
const [undoableArchiveIssueIds, setUndoableArchiveIssueIds] = useState<string[]>([]);
const [unarchivingIssueIds, setUnarchivingIssueIds] = useState<Set<string>>(new Set());
const guardedArchiveIssueIds = useLocalInboxArchiveIssueIds(selectedCompanyId);
const locallyArchivedIssueIds = useMemo(() => {
const issueIds = new Set(guardedArchiveIssueIds);
for (const issueId of undoableArchiveIssueIds) issueIds.add(issueId);
for (const issueId of archivingIssueIds) issueIds.add(issueId);
for (const issueId of unarchivingIssueIds) issueIds.delete(issueId);
return issueIds;
}, [archivingIssueIds, guardedArchiveIssueIds, undoableArchiveIssueIds, unarchivingIssueIds]);
const companyUserLabelMap = useMemo(
() => buildCompanyUserLabelMap(companyMembers?.users),
@ -950,8 +967,14 @@ export function Inbox() {
[companyMembers?.users],
);
const mineIssues = useMemo(() => getRecentTouchedIssues(mineIssuesRaw), [mineIssuesRaw]);
const touchedIssues = useMemo(() => getRecentTouchedIssues(touchedIssuesRaw), [touchedIssuesRaw]);
const mineIssues = useMemo(
() => getRecentTouchedIssues(mineIssuesRaw).filter((issue) => !locallyArchivedIssueIds.has(issue.id)),
[locallyArchivedIssueIds, mineIssuesRaw],
);
const touchedIssues = useMemo(
() => getRecentTouchedIssues(touchedIssuesRaw).filter((issue) => !locallyArchivedIssueIds.has(issue.id)),
[locallyArchivedIssueIds, touchedIssuesRaw],
);
const shouldUseIssueSearchSupplement =
!!selectedCompanyId
&& normalizedSearchQuery.length > 0;
@ -1606,9 +1629,6 @@ export function Inbox() {
const [fadingOutIssues, setFadingOutIssues] = useState<Set<string>>(new Set());
const [showMarkAllReadConfirm, setShowMarkAllReadConfirm] = useState(false);
const [archivingIssueIds, setArchivingIssueIds] = useState<Set<string>>(new Set());
const [undoableArchiveIssueIds, setUndoableArchiveIssueIds] = useState<string[]>([]);
const [unarchivingIssueIds, setUnarchivingIssueIds] = useState<Set<string>>(new Set());
const [fadingNonIssueItems, setFadingNonIssueItems] = useState<Set<string>>(new Set());
const [archivingNonIssueIds, setArchivingNonIssueIds] = useState<Set<string>>(new Set());
const [selectedIndex, setSelectedIndex] = useState<number>(-1);
@ -1654,15 +1674,17 @@ export function Inbox() {
setArchivingIssueIds((prev) => new Set(prev).add(id));
if (!selectedCompanyId) return { previousData: [] as InboxIssueCacheSnapshot };
beginLocalInboxArchive(selectedCompanyId, id);
await cancelInboxIssueQueries(queryClient, selectedCompanyId);
const previousData = snapshotInboxIssueCaches(queryClient, selectedCompanyId);
removeIssueFromInboxCaches(queryClient, selectedCompanyId, id);
return { previousData };
return { companyId: selectedCompanyId, previousData };
},
onError: (err, id, context) => {
setActionError(err instanceof Error ? err.message : "Failed to archive task");
if (context?.companyId) clearLocalInboxArchive(context.companyId, id);
setArchivingIssueIds((prev) => {
const next = new Set(prev);
next.delete(id);
@ -1673,14 +1695,20 @@ export function Inbox() {
restoreIssueToInboxCaches(queryClient, context.previousData, id);
}
},
onSettled: (_data, _error, id) => {
onSettled: async (_data, error, id, context) => {
// Clean up archiving state and refetch to sync with server
setArchivingIssueIds((prev) => {
const next = new Set(prev);
next.delete(id);
return next;
});
invalidateInboxIssueQueryCaches();
if (!context?.companyId) return;
if (!error) boundLocalInboxArchive(context.companyId, id);
await invalidateInboxIssueQueries(queryClient, context.companyId);
if (!error) {
const presence = getIssuePresenceInActiveInboxCaches(queryClient, context.companyId, id);
if (presence !== "unknown") confirmLocalInboxArchive(context.companyId, id);
}
},
onSuccess: (_data, id) => {
setUndoableArchiveIssueIds((prev) => [...prev.filter((issueId) => issueId !== id), id]);
@ -1692,9 +1720,15 @@ export function Inbox() {
onMutate: (id) => {
setActionError(null);
setUnarchivingIssueIds((prev) => new Set(prev).add(id));
if (selectedCompanyId) clearLocalInboxArchive(selectedCompanyId, id);
return { companyId: selectedCompanyId };
},
onError: (err) => {
onError: (err, id, context) => {
setActionError(err instanceof Error ? err.message : "Failed to undo inbox archive");
if (context?.companyId) {
beginLocalInboxArchive(context.companyId, id);
boundLocalInboxArchive(context.companyId, id);
}
},
onSuccess: (_data, id) => {
setUndoableArchiveIssueIds((prev) => {
@ -1828,6 +1862,12 @@ export function Inbox() {
selectedNavKeyRef.current = selectedIndex >= 0 ? navEntryKey(flatNavItems[selectedIndex]) : null;
}, [flatNavItems, selectedIndex]);
useEffect(() => {
setUndoableArchiveIssueIds((prev) =>
prev.filter((issueId) => guardedArchiveIssueIds.has(issueId) || unarchivingIssueIds.has(issueId)),
);
}, [guardedArchiveIssueIds, unarchivingIssueIds]);
useEffect(() => {
setUndoableArchiveIssueIds([]);
setUnarchivingIssueIds(new Set());

View File

@ -41,8 +41,13 @@ import {
import { resolveIssueActiveRun, shouldTrackIssueActiveRun } from "../lib/issueActiveRun";
import { getIssueDetailQueryOptions } from "../lib/issueDetailCache";
import {
beginLocalInboxArchive,
boundLocalInboxArchive,
cancelInboxIssueQueries,
clearLocalInboxArchive,
confirmLocalInboxArchive,
invalidateInboxIssueQueries,
getIssuePresenceInActiveInboxCaches,
removeIssueFromInboxCaches,
restoreIssueToInboxCaches,
snapshotInboxIssueCaches,
@ -3068,10 +3073,11 @@ export function IssueDetail() {
mutationFn: (id: string) => issuesApi.archiveFromInbox(id),
onMutate: async (id) => {
if (!selectedCompanyId) return { previousData: [] as InboxIssueCacheSnapshot };
beginLocalInboxArchive(selectedCompanyId, id);
await cancelInboxIssueQueries(queryClient, selectedCompanyId);
const previousData = snapshotInboxIssueCaches(queryClient, selectedCompanyId);
removeIssueFromInboxCaches(queryClient, selectedCompanyId, id);
return { previousData };
return { companyId: selectedCompanyId, previousData };
},
onSuccess: (_data, id) => {
if (selectedCompanyId) {
@ -3082,6 +3088,7 @@ export function IssueDetail() {
pushToast({ title: "Task archived from inbox", tone: "success" });
},
onError: (err, id, context) => {
if (context?.companyId) clearLocalInboxArchive(context.companyId, id);
if (context?.previousData) {
restoreIssueToInboxCaches(queryClient, context.previousData, id);
}
@ -3091,8 +3098,14 @@ export function IssueDetail() {
tone: "error",
});
},
onSettled: () => {
if (selectedCompanyId) invalidateInboxIssueQueries(queryClient, selectedCompanyId);
onSettled: async (_data, error, id, context) => {
if (!context?.companyId) return;
if (!error) boundLocalInboxArchive(context.companyId, id);
await invalidateInboxIssueQueries(queryClient, context.companyId);
if (!error) {
const presence = getIssuePresenceInActiveInboxCaches(queryClient, context.companyId, id);
if (presence !== "unknown") confirmLocalInboxArchive(context.companyId, id);
}
},
});