diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index a60da3e442..f238c96493 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -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 () => { diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 66e974649a..1510608dee 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -1314,36 +1314,6 @@ function myLastTouchAtExpr(companyId: string, userId: string) { `; } -function lastExternalCommentAtExpr(companyId: string, userId: string) { - return sql` - ( - 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` - 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` 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 + ) + ) + ) ) `; } diff --git a/ui/src/hooks/useInboxBadge.ts b/ui/src/hooks/useInboxBadge.ts index b44eb90fde..7b9e6dffb3 100644 --- a/ui/src/hooks/useInboxBadge.ts +++ b/ui/src/hooks/useInboxBadge.ts @@ -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({ diff --git a/ui/src/hooks/useSharedPolling.test.ts b/ui/src/hooks/useSharedPolling.test.ts index 71fbb8bc4d..cd2ff78267 100644 --- a/ui/src/hooks/useSharedPolling.test.ts +++ b/ui/src/hooks/useSharedPolling.test.ts @@ -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" }]); + }); }); diff --git a/ui/src/hooks/useSharedPolling.ts b/ui/src/hooks/useSharedPolling.ts index 094d40c69f..e58cefcb58 100644 --- a/ui/src/hooks/useSharedPolling.ts +++ b/ui/src/hooks/useSharedPolling.ts @@ -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( 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; } diff --git a/ui/src/lib/inboxArchiveCache.test.ts b/ui/src/lib/inboxArchiveCache.test.ts index 0c143bddc3..3d7b2588a5 100644 --- a/ui/src/lib/inboxArchiveCache.test.ts +++ b/ui/src/lib/inboxArchiveCache.test.ts @@ -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(queryKey, [issue("issue-a")]); + const observer = new QueryObserver(queryClient, { + queryKey, + queryFn: async () => [], + }); + const unsubscribe = observer.subscribe(() => undefined); + + expect(getIssuePresenceInActiveInboxCaches(queryClient, companyId, "issue-a")).toBe("present"); + queryClient.setQueryData(queryKey, [issue("issue-b")]); + expect(getIssuePresenceInActiveInboxCaches(queryClient, companyId, "issue-a")).toBe("absent"); + + unsubscribe(); + }); }); diff --git a/ui/src/lib/inboxArchiveCache.ts b/ui/src/lib/inboxArchiveCache.ts index 38c8078721..92b185f936 100644 --- a/ui/src/lib/inboxArchiveCache.ts +++ b/ui/src/lib/inboxArchiveCache.ts @@ -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; +const INBOX_ARCHIVE_CONFIRMATION_GRACE_MS = 5_000; +const INBOX_ARCHIVE_MAX_GUARD_MS = 30_000; +const EMPTY_ARCHIVED_ISSUE_IDS: ReadonlySet = new Set(); + +type InboxArchiveGuardState = { + issueIds: ReadonlySet; + listeners: Set<() => void>; + confirmationTimers: Map>; + maximumTimers: Map>; +}; + +const inboxArchiveGuards = new Map(); + +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) { + state.issueIds = issueIds.size > 0 ? issueIds : EMPTY_ARCHIVED_ISSUE_IDS; + for (const listener of state.listeners) listener(); +} + +function clearArchiveGuardTimer( + timers: Map>, + 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 { + if (!companyId) return EMPTY_ARCHIVED_ISSUE_IDS; + return inboxArchiveGuards.get(companyId)?.issueIds ?? EMPTY_ARCHIVED_ISSUE_IDS; +} + +export function useLocalInboxArchiveIssueIds(companyId: string | null | undefined): ReadonlySet { + 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(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"; } diff --git a/ui/src/pages/Inbox.test.tsx b/ui/src/pages/Inbox.test.tsx index eb0d3f3ea1..d388f9d5d8 100644 --- a/ui/src/pages/Inbox.test.tsx +++ b/ui/src/pages/Inbox.test.tsx @@ -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( + + + , + ); + }); + await vi.waitFor(() => { + expect(container.textContent).toContain("Archived inbox row"); + }); + + const archiveButton = container.querySelector('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( + { 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( + + + , + ); + }); + await vi.waitFor(() => { + expect(container.textContent).toContain("Undoable inbox row"); + }); + + const archiveButton = container.querySelector('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", () => { diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index ddd30587bc..2c8788f94a 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -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>(new Set()); + const [undoableArchiveIssueIds, setUndoableArchiveIssueIds] = useState([]); + const [unarchivingIssueIds, setUnarchivingIssueIds] = useState>(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>(new Set()); const [showMarkAllReadConfirm, setShowMarkAllReadConfirm] = useState(false); - const [archivingIssueIds, setArchivingIssueIds] = useState>(new Set()); - const [undoableArchiveIssueIds, setUndoableArchiveIssueIds] = useState([]); - const [unarchivingIssueIds, setUnarchivingIssueIds] = useState>(new Set()); const [fadingNonIssueItems, setFadingNonIssueItems] = useState>(new Set()); const [archivingNonIssueIds, setArchivingNonIssueIds] = useState>(new Set()); const [selectedIndex, setSelectedIndex] = useState(-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()); diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 38d8a1982f..3ea686cc35 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -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); + } }, });