Persist non-issue inbox dismissals

This commit is contained in:
dotta 2026-04-07 18:26:34 -05:00
parent 1de5fb9316
commit 5640d29ab0
23 changed files with 13623 additions and 54 deletions

View file

@ -0,0 +1,8 @@
import type { InboxDismissal } from "@paperclipai/shared";
import { api } from "./client";
export const inboxDismissalsApi = {
list: (companyId: string) => api.get<InboxDismissal[]>(`/companies/${companyId}/inbox-dismissals`),
dismiss: (companyId: string, itemKey: string) =>
api.post<InboxDismissal>(`/companies/${companyId}/inbox-dismissals`, { itemKey }),
};

View file

@ -15,4 +15,5 @@ export { dashboardApi } from "./dashboard";
export { heartbeatsApi } from "./heartbeats";
export { instanceSettingsApi } from "./instanceSettings";
export { sidebarBadgesApi } from "./sidebarBadges";
export { inboxDismissalsApi } from "./inboxDismissals";
export { companySkillsApi } from "./companySkills";

View file

@ -1,17 +1,19 @@
import { useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
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 { dashboardApi } from "../api/dashboard";
import { heartbeatsApi } from "../api/heartbeats";
import { issuesApi } from "../api/issues";
import { queryKeys } from "../lib/queryKeys";
import {
buildInboxDismissedAtByKey,
computeInboxBadgeData,
getRecentTouchedIssues,
loadDismissedInboxItems,
saveDismissedInboxItems,
loadDismissedInboxAlerts,
saveDismissedInboxAlerts,
loadReadInboxItems,
saveReadInboxItems,
READ_ITEMS_KEY,
@ -19,13 +21,13 @@ import {
const INBOX_ISSUE_STATUSES = "backlog,todo,in_progress,in_review,blocked,done";
export function useDismissedInboxItems() {
const [dismissed, setDismissed] = useState<Set<string>>(loadDismissedInboxItems);
export function useDismissedInboxAlerts() {
const [dismissed, setDismissed] = useState<Set<string>>(loadDismissedInboxAlerts);
useEffect(() => {
const handleStorage = (event: StorageEvent) => {
if (event.key !== "paperclip:inbox:dismissed") return;
setDismissed(loadDismissedInboxItems());
setDismissed(loadDismissedInboxAlerts());
};
window.addEventListener("storage", handleStorage);
return () => window.removeEventListener("storage", handleStorage);
@ -35,7 +37,7 @@ export function useDismissedInboxItems() {
setDismissed((prev) => {
const next = new Set(prev);
next.add(id);
saveDismissedInboxItems(next);
saveDismissedInboxAlerts(next);
return next;
});
};
@ -43,6 +45,63 @@ export function useDismissedInboxItems() {
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;
queryClient.invalidateQueries({ queryKey });
queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(companyId) });
},
});
const dismissedAtByKey = useMemo(
() => buildInboxDismissedAtByKey(dismissals),
[dismissals],
);
return {
dismissals,
dismissedAtByKey,
dismiss: (itemKey: string) => dismissMutation.mutate({ itemKey }),
isPending: dismissMutation.isPending,
};
}
export function useReadInboxItems() {
const [readItems, setReadItems] = useState<Set<string>>(loadReadInboxItems);
@ -77,7 +136,8 @@ export function useReadInboxItems() {
}
export function useInboxBadge(companyId: string | null | undefined) {
const { dismissed } = useDismissedInboxItems();
const { dismissed: dismissedAlerts } = useDismissedInboxAlerts();
const { dismissedAtByKey } = useInboxDismissals(companyId);
const { data: approvals = [] } = useQuery({
queryKey: queryKeys.approvals.list(companyId!),
@ -134,8 +194,9 @@ export function useInboxBadge(companyId: string | null | undefined) {
dashboard,
heartbeatRuns,
mineIssues,
dismissed,
dismissedAlerts,
dismissedAtByKey,
}),
[approvals, joinRequests, dashboard, heartbeatRuns, mineIssues, dismissed],
[approvals, joinRequests, dashboard, heartbeatRuns, mineIssues, dismissedAlerts, dismissedAtByKey],
);
}

View file

@ -12,6 +12,7 @@ import type {
} from "@paperclipai/shared";
import {
DEFAULT_INBOX_ISSUE_COLUMNS,
buildInboxDismissedAtByKey,
computeInboxBadgeData,
getAvailableInboxIssueColumns,
getApprovalsForTab,
@ -19,6 +20,7 @@ import {
getInboxKeyboardSelectionIndex,
getRecentTouchedIssues,
getUnreadTouchedIssues,
isInboxEntityDismissed,
isMineInboxTab,
loadInboxIssueColumns,
loadLastInboxTab,
@ -286,7 +288,8 @@ describe("inbox helpers", () => {
makeRun("run-other-agent", "failed", "2026-03-11T02:00:00.000Z", "agent-2"),
],
mineIssues: [makeIssue("1", true)],
dismissed: new Set<string>(),
dismissedAlerts: new Set<string>(),
dismissedAtByKey: new Map<string, number>(),
});
expect(result).toEqual({
@ -306,7 +309,8 @@ describe("inbox helpers", () => {
dashboard,
heartbeatRuns: [makeRun("run-1", "failed", "2026-03-11T00:00:00.000Z")],
mineIssues: [],
dismissed: new Set<string>(["run:run-1", "alert:budget", "alert:agent-errors"]),
dismissedAlerts: new Set<string>(["alert:budget", "alert:agent-errors"]),
dismissedAtByKey: new Map<string, number>([["run:run-1", new Date("2026-03-11T00:00:00.000Z").getTime()]]),
});
expect(result).toEqual({
@ -326,7 +330,7 @@ describe("inbox helpers", () => {
dashboard,
heartbeatRuns: [],
mineIssues: [makeIssue("1", false), makeIssue("2", false), makeIssue("3", true)],
dismissed: new Set<string>(),
dismissedAtByKey: new Map(),
});
expect(result.mineIssues).toBe(1);
@ -334,6 +338,35 @@ describe("inbox helpers", () => {
expect(result.inbox).toBe(3);
});
it("resurfaces non-issue items when they change after dismissal", () => {
const dismissedAtByKey = buildInboxDismissedAtByKey([
{
id: "dismissal-1",
companyId: "company-1",
userId: "user-1",
itemKey: "approval:approval-1",
dismissedAt: new Date("2026-03-11T01:00:00.000Z"),
createdAt: new Date("2026-03-11T01:00:00.000Z"),
updatedAt: new Date("2026-03-11T01:00:00.000Z"),
},
]);
expect(
isInboxEntityDismissed(
dismissedAtByKey,
"approval:approval-1",
new Date("2026-03-11T00:30:00.000Z"),
),
).toBe(true);
expect(
isInboxEntityDismissed(
dismissedAtByKey,
"approval:approval-1",
new Date("2026-03-11T01:30:00.000Z"),
),
).toBe(false);
});
it("keeps read issues in the touched list but excludes them from unread counts", () => {
const issues = [makeIssue("1", true), makeIssue("2", false)];

View file

@ -1,4 +1,11 @@
import type { Approval, DashboardSummary, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared";
import type {
Approval,
DashboardSummary,
HeartbeatRun,
InboxDismissal,
Issue,
JoinRequest,
} from "@paperclipai/shared";
export const RECENT_ISSUES_LIMIT = 100;
export const FAILED_RUN_STATUSES = new Set(["failed", "timed_out"]);
@ -43,16 +50,19 @@ export interface InboxBadgeData {
alerts: number;
}
export function loadDismissedInboxItems(): Set<string> {
export function loadDismissedInboxAlerts(): Set<string> {
try {
const raw = localStorage.getItem(DISMISSED_KEY);
return raw ? new Set(JSON.parse(raw)) : new Set();
if (!raw) return new Set();
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return new Set();
return new Set(parsed.filter((value): value is string => typeof value === "string" && value.startsWith("alert:")));
} catch {
return new Set();
}
}
export function saveDismissedInboxItems(ids: Set<string>) {
export function saveDismissedInboxAlerts(ids: Set<string>) {
try {
localStorage.setItem(DISMISSED_KEY, JSON.stringify([...ids]));
} catch {
@ -60,6 +70,22 @@ export function saveDismissedInboxItems(ids: Set<string>) {
}
}
export function buildInboxDismissedAtByKey(dismissals: InboxDismissal[]): Map<string, number> {
return new Map(
dismissals.map((dismissal) => [dismissal.itemKey, normalizeTimestamp(dismissal.dismissedAt)]),
);
}
export function isInboxEntityDismissed(
dismissedAtByKey: ReadonlyMap<string, number>,
itemKey: string,
activityAt: string | Date | null | undefined,
): boolean {
const dismissedAt = dismissedAtByKey.get(itemKey);
if (dismissedAt == null) return false;
return dismissedAt >= normalizeTimestamp(activityAt);
}
export function loadReadInboxItems(): Set<string> {
try {
const raw = localStorage.getItem(READ_ITEMS_KEY);
@ -342,25 +368,27 @@ export function computeInboxBadgeData({
dashboard,
heartbeatRuns,
mineIssues,
dismissed,
dismissedAlerts,
dismissedAtByKey,
}: {
approvals: Approval[];
joinRequests: JoinRequest[];
dashboard: DashboardSummary | undefined;
heartbeatRuns: HeartbeatRun[];
mineIssues: Issue[];
dismissed: Set<string>;
dismissedAlerts: Set<string>;
dismissedAtByKey: ReadonlyMap<string, number>;
}): InboxBadgeData {
const actionableApprovals = approvals.filter(
(approval) =>
ACTIONABLE_APPROVAL_STATUSES.has(approval.status) &&
!dismissed.has(`approval:${approval.id}`),
!isInboxEntityDismissed(dismissedAtByKey, `approval:${approval.id}`, approval.updatedAt),
).length;
const failedRuns = getLatestFailedRunsByAgent(heartbeatRuns).filter(
(run) => !dismissed.has(`run:${run.id}`),
(run) => !isInboxEntityDismissed(dismissedAtByKey, `run:${run.id}`, run.createdAt),
).length;
const visibleJoinRequests = joinRequests.filter(
(jr) => !dismissed.has(`join:${jr.id}`),
(jr) => !isInboxEntityDismissed(dismissedAtByKey, `join:${jr.id}`, jr.updatedAt ?? jr.createdAt),
).length;
const visibleMineIssues = mineIssues.filter((issue) => issue.isUnreadForMe).length;
const agentErrorCount = dashboard?.agents.error ?? 0;
@ -369,11 +397,11 @@ export function computeInboxBadgeData({
const showAggregateAgentError =
agentErrorCount > 0 &&
failedRuns === 0 &&
!dismissed.has("alert:agent-errors");
!dismissedAlerts.has("alert:agent-errors");
const showBudgetAlert =
monthBudgetCents > 0 &&
monthUtilizationPercent >= 80 &&
!dismissed.has("alert:budget");
!dismissedAlerts.has("alert:budget");
const alerts = Number(showAggregateAgentError) + Number(showBudgetAlert);
return {

View file

@ -105,6 +105,7 @@ export const queryKeys = {
},
dashboard: (companyId: string) => ["dashboard", companyId] as const,
sidebarBadges: (companyId: string) => ["sidebar-badges", companyId] as const,
inboxDismissals: (companyId: string) => ["inbox-dismissals", companyId] as const,
activity: (companyId: string) => ["activity", companyId] as const,
costs: (companyId: string, from?: string, to?: string) =>
["costs", companyId, from, to] as const,

View file

@ -86,6 +86,7 @@ import {
getInboxKeyboardSelectionIndex,
getLatestFailedRunsByAgent,
getRecentTouchedIssues,
isInboxEntityDismissed,
isMineInboxTab,
loadInboxIssueColumns,
normalizeInboxIssueColumns,
@ -99,7 +100,7 @@ import {
type InboxTab,
type InboxWorkItem,
} from "../lib/inbox";
import { useDismissedInboxItems, useReadInboxItems } from "../hooks/useInboxBadge";
import { useDismissedInboxAlerts, useInboxDismissals, useReadInboxItems } from "../hooks/useInboxBadge";
type InboxCategoryFilter =
| "everything"
@ -826,7 +827,8 @@ export function Inbox() {
const [allCategoryFilter, setAllCategoryFilter] = useState<InboxCategoryFilter>("everything");
const [allApprovalFilter, setAllApprovalFilter] = useState<InboxApprovalFilter>("all");
const [visibleIssueColumns, setVisibleIssueColumns] = useState<InboxIssueColumn[]>(loadInboxIssueColumns);
const { dismissed, dismiss } = useDismissedInboxItems();
const { dismissed: dismissedAlerts, dismiss: dismissAlert } = useDismissedInboxAlerts();
const { dismissedAtByKey, dismiss: dismissInboxItem } = useInboxDismissals(selectedCompanyId);
const { readItems, markRead: markItemRead, markUnread: markItemUnread } = useReadInboxItems();
const pathSegment = location.pathname.split("/").pop() ?? "mine";
@ -1033,8 +1035,11 @@ export function Inbox() {
const currentUserId = session?.user.id ?? session?.session.userId ?? null;
const failedRuns = useMemo(
() => getLatestFailedRunsByAgent(heartbeatRuns ?? []).filter((r) => !dismissed.has(`run:${r.id}`)),
[heartbeatRuns, dismissed],
() =>
getLatestFailedRunsByAgent(heartbeatRuns ?? []).filter(
(r) => !isInboxEntityDismissed(dismissedAtByKey, `run:${r.id}`, r.createdAt),
),
[heartbeatRuns, dismissedAtByKey],
);
const liveIssueIds = useMemo(() => {
const ids = new Set<string>();
@ -1049,10 +1054,12 @@ export function Inbox() {
const approvalsToRender = useMemo(() => {
let filtered = getApprovalsForTab(approvals ?? [], tab, allApprovalFilter);
if (tab === "mine") {
filtered = filtered.filter((a) => !dismissed.has(`approval:${a.id}`));
filtered = filtered.filter(
(a) => !isInboxEntityDismissed(dismissedAtByKey, `approval:${a.id}`, a.updatedAt),
);
}
return filtered;
}, [approvals, tab, allApprovalFilter, dismissed]);
}, [approvals, tab, allApprovalFilter, dismissedAtByKey]);
const showJoinRequestsCategory =
allCategoryFilter === "everything" || allCategoryFilter === "join_requests";
const showTouchedCategory =
@ -1069,9 +1076,13 @@ export function Inbox() {
const joinRequestsForTab = useMemo(() => {
if (tab === "all" && !showJoinRequestsCategory) return [];
if (tab === "mine") return joinRequests.filter((jr) => !dismissed.has(`join:${jr.id}`));
if (tab === "mine") {
return joinRequests.filter(
(jr) => !isInboxEntityDismissed(dismissedAtByKey, `join:${jr.id}`, jr.updatedAt ?? jr.createdAt),
);
}
return joinRequests;
}, [joinRequests, tab, showJoinRequestsCategory, dismissed]);
}, [joinRequests, tab, showJoinRequestsCategory, dismissedAtByKey]);
const workItemsToRender = useMemo(
() =>
@ -1385,14 +1396,18 @@ export function Inbox() {
const handleArchiveNonIssue = useCallback((key: string) => {
setArchivingNonIssueIds((prev) => new Set(prev).add(key));
setTimeout(() => {
dismiss(key);
if (key.startsWith("alert:")) {
dismissAlert(key);
} else {
dismissInboxItem(key);
}
setArchivingNonIssueIds((prev) => {
const next = new Set(prev);
next.delete(key);
return next;
});
}, 200);
}, [dismiss]);
}, [dismissAlert, dismissInboxItem]);
const nonIssueUnreadState = (key: string): NonIssueUnreadState => {
if (!canArchiveFromTab) return null;
@ -1575,12 +1590,16 @@ export function Inbox() {
}
const hasRunFailures = failedRuns.length > 0;
const showAggregateAgentError = !!dashboard && dashboard.agents.error > 0 && !hasRunFailures && !dismissed.has("alert:agent-errors");
const showAggregateAgentError =
!!dashboard &&
dashboard.agents.error > 0 &&
!hasRunFailures &&
!dismissedAlerts.has("alert:agent-errors");
const showBudgetAlert =
!!dashboard &&
dashboard.costs.monthBudgetCents > 0 &&
dashboard.costs.monthUtilizationPercent >= 80 &&
!dismissed.has("alert:budget");
!dismissedAlerts.has("alert:budget");
const hasAlerts = showAggregateAgentError || showBudgetAlert;
const showWorkItemsSection = filteredWorkItems.length > 0;
const showAlertsSection = shouldShowInboxSection({
@ -1891,7 +1910,7 @@ export function Inbox() {
issueById={issueById}
agentName={agentName(item.run.agentId)}
issueLinkState={issueLinkState}
onDismiss={() => dismiss(runKey)}
onDismiss={() => dismissInboxItem(runKey)}
onRetry={() => retryRunMutation.mutate(item.run)}
isRetrying={retryingRunIds.has(item.run.id)}
unreadState={nonIssueUnreadState(runKey)}
@ -2049,7 +2068,7 @@ export function Inbox() {
</Link>
<button
type="button"
onClick={() => dismiss("alert:agent-errors")}
onClick={() => dismissAlert("alert:agent-errors")}
className="rounded-md p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover/alert:opacity-100"
aria-label="Dismiss"
>
@ -2072,7 +2091,7 @@ export function Inbox() {
</Link>
<button
type="button"
onClick={() => dismiss("alert:budget")}
onClick={() => dismissAlert("alert:budget")}
className="rounded-md p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover/alert:opacity-100"
aria-label="Dismiss"
>