mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 01:41:42 +02:00
## 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>
276 lines
9.1 KiB
TypeScript
276 lines
9.1 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { accessApi } from "../api/access";
|
|
import { ApiError } from "../api/client";
|
|
import { inboxDismissalsApi } from "../api/inboxDismissals";
|
|
import { approvalsApi } from "../api/approvals";
|
|
import { authApi } from "../api/auth";
|
|
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,
|
|
computeInboxBadgeData,
|
|
getRecentTouchedIssues,
|
|
loadDismissedInboxAlerts,
|
|
saveDismissedInboxAlerts,
|
|
loadReadInboxItems,
|
|
saveReadInboxItems,
|
|
READ_ITEMS_KEY,
|
|
} from "../lib/inbox";
|
|
|
|
const INBOX_ISSUE_STATUSES = "backlog,todo,in_progress,in_review,blocked,done";
|
|
const INBOX_BADGE_ISSUE_LIMIT = 500;
|
|
const INBOX_BADGE_HEARTBEAT_RUN_LIMIT = 200;
|
|
const INBOX_BADGE_HOT_PATH_STALE_MS = 30_000;
|
|
|
|
export function useDismissedInboxAlerts() {
|
|
const [dismissed, setDismissed] = useState<Set<string>>(loadDismissedInboxAlerts);
|
|
|
|
useEffect(() => {
|
|
const handleStorage = (event: StorageEvent) => {
|
|
if (event.key !== "paperclip:inbox:dismissed") return;
|
|
setDismissed(loadDismissedInboxAlerts());
|
|
};
|
|
window.addEventListener("storage", handleStorage);
|
|
return () => window.removeEventListener("storage", handleStorage);
|
|
}, []);
|
|
|
|
const dismiss = (id: string) => {
|
|
setDismissed((prev) => {
|
|
const next = new Set(prev);
|
|
next.add(id);
|
|
saveDismissedInboxAlerts(next);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
return { dismissed, dismiss };
|
|
}
|
|
|
|
export function useInboxDismissals(companyId: string | null | undefined) {
|
|
const queryClient = useQueryClient();
|
|
const queryKey = companyId
|
|
? queryKeys.inboxDismissals(companyId)
|
|
: ["inbox-dismissals", "__disabled__"] as const;
|
|
|
|
const { data: dismissals = [] } = useQuery({
|
|
queryKey,
|
|
queryFn: () => inboxDismissalsApi.list(companyId!),
|
|
enabled: !!companyId,
|
|
});
|
|
|
|
const dismissMutation = useMutation({
|
|
mutationFn: ({ itemKey }: { itemKey: string }) => inboxDismissalsApi.dismiss(companyId!, itemKey),
|
|
onMutate: async ({ itemKey }) => {
|
|
if (!companyId) return { previous: [] as typeof dismissals };
|
|
await queryClient.cancelQueries({ queryKey });
|
|
const previous = queryClient.getQueryData<typeof dismissals>(queryKey) ?? [];
|
|
const now = new Date();
|
|
queryClient.setQueryData(queryKey, [
|
|
{
|
|
id: `optimistic:${itemKey}`,
|
|
companyId,
|
|
userId: "me",
|
|
itemKey,
|
|
dismissedAt: now,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
},
|
|
...previous.filter((dismissal) => dismissal.itemKey !== itemKey),
|
|
]);
|
|
return { previous };
|
|
},
|
|
onError: (_error, _variables, context) => {
|
|
if (!context) return;
|
|
queryClient.setQueryData(queryKey, context.previous);
|
|
},
|
|
onSettled: () => {
|
|
if (!companyId) return;
|
|
invalidateDismissalConsumers();
|
|
},
|
|
});
|
|
|
|
function invalidateDismissalConsumers() {
|
|
if (!companyId) return;
|
|
queryClient.invalidateQueries({ queryKey });
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(companyId) });
|
|
// The attention feed derives its rows from server-side dismissals, so any
|
|
// dismiss/snooze/restore must re-pull it to keep the queue and curtains in sync.
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.attention(companyId) });
|
|
}
|
|
|
|
const snoozeMutation = useMutation({
|
|
mutationFn: ({ itemKey, snoozedUntil }: { itemKey: string; snoozedUntil: string }) =>
|
|
inboxDismissalsApi.snooze(companyId!, itemKey, snoozedUntil),
|
|
onSettled: invalidateDismissalConsumers,
|
|
});
|
|
|
|
const restoreMutation = useMutation({
|
|
mutationFn: ({ itemKey }: { itemKey: string }) => inboxDismissalsApi.restore(companyId!, itemKey),
|
|
onSettled: invalidateDismissalConsumers,
|
|
});
|
|
|
|
const dismissedAtByKey = useMemo(
|
|
() => buildInboxDismissedAtByKey(dismissals),
|
|
[dismissals],
|
|
);
|
|
|
|
// Stable identities (react-query keeps `mutate` referentially stable) so
|
|
// consumers can hand these to memoized rows without breaking memoization.
|
|
const dismissMutate = dismissMutation.mutate;
|
|
const snoozeMutate = snoozeMutation.mutate;
|
|
const restoreMutate = restoreMutation.mutate;
|
|
const dismiss = useCallback((itemKey: string) => dismissMutate({ itemKey }), [dismissMutate]);
|
|
const snooze = useCallback(
|
|
(itemKey: string, snoozedUntil: string) => snoozeMutate({ itemKey, snoozedUntil }),
|
|
[snoozeMutate],
|
|
);
|
|
const restore = useCallback((itemKey: string) => restoreMutate({ itemKey }), [restoreMutate]);
|
|
|
|
return {
|
|
dismissals,
|
|
dismissedAtByKey,
|
|
dismiss,
|
|
snooze,
|
|
restore,
|
|
isPending: dismissMutation.isPending || snoozeMutation.isPending || restoreMutation.isPending,
|
|
};
|
|
}
|
|
|
|
export function useReadInboxItems() {
|
|
const [readItems, setReadItems] = useState<Set<string>>(loadReadInboxItems);
|
|
|
|
useEffect(() => {
|
|
const handleStorage = (event: StorageEvent) => {
|
|
if (event.key !== READ_ITEMS_KEY) return;
|
|
setReadItems(loadReadInboxItems());
|
|
};
|
|
window.addEventListener("storage", handleStorage);
|
|
return () => window.removeEventListener("storage", handleStorage);
|
|
}, []);
|
|
|
|
const markRead = (id: string) => {
|
|
setReadItems((prev) => {
|
|
const next = new Set(prev);
|
|
next.add(id);
|
|
saveReadInboxItems(next);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const markUnread = (id: string) => {
|
|
setReadItems((prev) => {
|
|
const next = new Set(prev);
|
|
next.delete(id);
|
|
saveReadInboxItems(next);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
return { readItems, markRead, markUnread };
|
|
}
|
|
|
|
export function useInboxBadge(companyId: string | null | undefined) {
|
|
const locallyArchivedIssueIds = useLocalInboxArchiveIssueIds(companyId);
|
|
const { dismissed: dismissedAlerts } = useDismissedInboxAlerts();
|
|
const { dismissedAtByKey } = useInboxDismissals(companyId);
|
|
const { data: session } = useQuery({
|
|
queryKey: queryKeys.auth.session,
|
|
queryFn: () => authApi.getSession(),
|
|
});
|
|
|
|
const { data: approvals = [] } = useQuery({
|
|
queryKey: queryKeys.approvals.list(companyId!),
|
|
queryFn: () => approvalsApi.list(companyId!),
|
|
enabled: !!companyId,
|
|
});
|
|
|
|
const { data: joinRequests = [] } = useQuery({
|
|
queryKey: queryKeys.access.joinRequests(companyId!),
|
|
queryFn: async () => {
|
|
try {
|
|
return await accessApi.listJoinRequests(companyId!, "pending_approval");
|
|
} catch (err) {
|
|
if (err instanceof ApiError && (err.status === 401 || err.status === 403)) {
|
|
return [];
|
|
}
|
|
throw err;
|
|
}
|
|
},
|
|
enabled: !!companyId,
|
|
retry: false,
|
|
});
|
|
|
|
const dashboardQueryKey = queryKeys.dashboard(companyId!);
|
|
const sharedDashboard = useSharedPollingQuery({
|
|
companyId,
|
|
resourceKey: "dashboard",
|
|
queryKey: dashboardQueryKey,
|
|
enabled: !!companyId,
|
|
});
|
|
const { data: dashboard, dataUpdatedAt: dashboardUpdatedAt } = useQuery({
|
|
queryKey: dashboardQueryKey,
|
|
queryFn: () => dashboardApi.summary(companyId!),
|
|
enabled: !!companyId,
|
|
});
|
|
usePublishSharedQueryData(sharedDashboard, dashboard, dashboardUpdatedAt);
|
|
|
|
const mineIssuesQueryKey = queryKeys.issues.listMineByMe(companyId!);
|
|
const sharedMineIssues = useSharedPollingQuery({
|
|
companyId,
|
|
resourceKey: "inbox-badge:mine-issues",
|
|
queryKey: mineIssuesQueryKey,
|
|
enabled: !!companyId,
|
|
});
|
|
const { data: mineIssuesRaw = [], dataUpdatedAt: mineIssuesUpdatedAt } = useQuery({
|
|
queryKey: mineIssuesQueryKey,
|
|
queryFn: () =>
|
|
issuesApi.list(companyId!, {
|
|
touchedByUserId: "me",
|
|
inboxArchivedByUserId: "me",
|
|
status: INBOX_ISSUE_STATUSES,
|
|
limit: INBOX_BADGE_ISSUE_LIMIT,
|
|
}),
|
|
enabled: !!companyId,
|
|
refetchOnWindowFocus: false,
|
|
staleTime: INBOX_BADGE_HOT_PATH_STALE_MS,
|
|
});
|
|
usePublishSharedQueryData(sharedMineIssues, mineIssuesRaw, mineIssuesUpdatedAt);
|
|
|
|
const mineIssues = useMemo(
|
|
() => getRecentTouchedIssues(filterLocalInboxArchivedIssues(companyId, mineIssuesRaw)),
|
|
[companyId, locallyArchivedIssueIds, mineIssuesRaw],
|
|
);
|
|
const currentUserId = session?.user.id ?? session?.session.userId ?? null;
|
|
|
|
const { data: heartbeatRuns = [] } = useQuery({
|
|
queryKey: [...queryKeys.heartbeats(companyId!), "limit", INBOX_BADGE_HEARTBEAT_RUN_LIMIT],
|
|
queryFn: () => heartbeatsApi.list(companyId!, undefined, INBOX_BADGE_HEARTBEAT_RUN_LIMIT, { summary: true }),
|
|
enabled: !!companyId,
|
|
refetchOnWindowFocus: false,
|
|
staleTime: INBOX_BADGE_HOT_PATH_STALE_MS,
|
|
});
|
|
|
|
return useMemo(
|
|
() =>
|
|
computeInboxBadgeData({
|
|
approvals,
|
|
joinRequests,
|
|
dashboard,
|
|
heartbeatRuns,
|
|
mineIssues,
|
|
dismissedAlerts,
|
|
dismissedAtByKey,
|
|
currentUserId,
|
|
}),
|
|
[approvals, joinRequests, dashboard, heartbeatRuns, mineIssues, dismissedAlerts, dismissedAtByKey, currentUserId],
|
|
);
|
|
}
|