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:
parent
26890650b2
commit
59eee4829c
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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" }]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue