mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 20:46:28 +01: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>
255 lines
8.4 KiB
TypeScript
255 lines
8.4 KiB
TypeScript
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),
|
|
queryKeys.issues.listTouchedByMe(companyId),
|
|
queryKeys.issues.listUnreadTouchedByMe(companyId),
|
|
] as const;
|
|
}
|
|
|
|
function resolveRestoreIndex(currentData: Issue[], previousData: Issue[], previousIndex: number) {
|
|
for (let index = previousIndex - 1; index >= 0; index -= 1) {
|
|
const beforeIndex = currentData.findIndex((issue) => issue.id === previousData[index]?.id);
|
|
if (beforeIndex >= 0) return beforeIndex + 1;
|
|
}
|
|
|
|
for (let index = previousIndex + 1; index < previousData.length; index += 1) {
|
|
const afterIndex = currentData.findIndex((issue) => issue.id === previousData[index]?.id);
|
|
if (afterIndex >= 0) return afterIndex;
|
|
}
|
|
|
|
return Math.min(previousIndex, currentData.length);
|
|
}
|
|
|
|
export async function cancelInboxIssueQueries(queryClient: QueryClient, companyId: string) {
|
|
await Promise.all(
|
|
inboxIssueQueryPrefixes(companyId).map((queryKey) =>
|
|
queryClient.cancelQueries({ queryKey }),
|
|
),
|
|
);
|
|
}
|
|
|
|
export function snapshotInboxIssueCaches(
|
|
queryClient: QueryClient,
|
|
companyId: string,
|
|
): InboxIssueCacheSnapshot {
|
|
return inboxIssueQueryPrefixes(companyId).flatMap((queryKey) =>
|
|
queryClient.getQueriesData<Issue[]>({ queryKey }),
|
|
);
|
|
}
|
|
|
|
export function removeIssueFromInboxCaches(
|
|
queryClient: QueryClient,
|
|
companyId: string,
|
|
issueId: string,
|
|
) {
|
|
for (const queryKey of inboxIssueQueryPrefixes(companyId)) {
|
|
queryClient.setQueriesData<Issue[]>(
|
|
{ queryKey },
|
|
(cached) => cached?.filter((issue) => issue.id !== issueId),
|
|
);
|
|
}
|
|
}
|
|
|
|
export function restoreIssueToInboxCaches(
|
|
queryClient: QueryClient,
|
|
snapshot: InboxIssueCacheSnapshot,
|
|
issueId: string,
|
|
) {
|
|
for (const [queryKey, previousData] of snapshot) {
|
|
if (!previousData) continue;
|
|
|
|
const previousIndex = previousData.findIndex((issue) => issue.id === issueId);
|
|
if (previousIndex < 0) continue;
|
|
|
|
const issueToRestore = previousData[previousIndex];
|
|
queryClient.setQueryData<Issue[]>(queryKey, (currentData) => {
|
|
if (currentData?.some((issue) => issue.id === issueId)) return currentData;
|
|
|
|
const nextData = [...(currentData ?? [])];
|
|
nextData.splice(resolveRestoreIndex(nextData, previousData, previousIndex), 0, issueToRestore);
|
|
return nextData;
|
|
});
|
|
}
|
|
}
|
|
|
|
export function invalidateInboxIssueQueries(queryClient: QueryClient, companyId: string) {
|
|
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";
|
|
}
|