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,18 @@
CREATE TABLE IF NOT EXISTS "inbox_dismissals" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"user_id" text NOT NULL,
"item_key" text NOT NULL,
"dismissed_at" timestamp with time zone DEFAULT now() NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "inbox_dismissals" ADD CONSTRAINT "inbox_dismissals_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "inbox_dismissals_company_user_idx" ON "inbox_dismissals" USING btree ("company_id","user_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "inbox_dismissals_company_item_idx" ON "inbox_dismissals" USING btree ("company_id","item_key");--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "inbox_dismissals_company_user_item_idx" ON "inbox_dismissals" USING btree ("company_id","user_id","item_key");

File diff suppressed because it is too large Load diff

View file

@ -372,6 +372,13 @@
"when": 1775571715162,
"tag": "0052_mushy_trauma",
"breakpoints": true
},
{
"idx": 53,
"version": "7",
"when": 1775604018515,
"tag": "0053_sharp_wild_child",
"breakpoints": true
}
]
}
}

View file

@ -0,0 +1,24 @@
import { pgTable, uuid, text, timestamp, index, uniqueIndex } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
export const inboxDismissals = pgTable(
"inbox_dismissals",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id),
userId: text("user_id").notNull(),
itemKey: text("item_key").notNull(),
dismissedAt: timestamp("dismissed_at", { withTimezone: true }).notNull().defaultNow(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companyUserIdx: index("inbox_dismissals_company_user_idx").on(table.companyId, table.userId),
companyItemIdx: index("inbox_dismissals_company_item_idx").on(table.companyId, table.itemKey),
companyUserItemUnique: uniqueIndex("inbox_dismissals_company_user_item_idx").on(
table.companyId,
table.userId,
table.itemKey,
),
}),
);

View file

@ -34,6 +34,7 @@ export { issueApprovals } from "./issue_approvals.js";
export { issueComments } from "./issue_comments.js";
export { issueExecutionDecisions } from "./issue_execution_decisions.js";
export { issueInboxArchives } from "./issue_inbox_archives.js";
export { inboxDismissals } from "./inbox_dismissals.js";
export { feedbackVotes } from "./feedback_votes.js";
export { feedbackExports } from "./feedback_exports.js";
export { issueReadStates } from "./issue_read_states.js";

View file

@ -288,6 +288,7 @@ export type {
DashboardSummary,
ActivityEvent,
SidebarBadges,
InboxDismissal,
CompanyMembership,
PrincipalPermissionGrant,
Invite,

View file

@ -0,0 +1,9 @@
export interface InboxDismissal {
id: string;
companyId: string;
userId: string;
itemKey: string;
dismissedAt: Date;
createdAt: Date;
updatedAt: Date;
}

View file

@ -164,6 +164,7 @@ export type { LiveEvent } from "./live.js";
export type { DashboardSummary } from "./dashboard.js";
export type { ActivityEvent } from "./activity.js";
export type { SidebarBadges } from "./sidebar-badges.js";
export type { InboxDismissal } from "./inbox-dismissal.js";
export type {
CompanyMembership,
PrincipalPermissionGrant,

View file

@ -0,0 +1,212 @@
import { randomUUID } from "node:crypto";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
agents,
approvals,
companies,
createDb,
heartbeatRuns,
inboxDismissals,
invites,
joinRequests,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { inboxDismissalService } from "../services/inbox-dismissals.ts";
import { sidebarBadgeService } from "../services/sidebar-badges.ts";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres inbox dismissal tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
describeEmbeddedPostgres("inbox dismissals", () => {
let db!: ReturnType<typeof createDb>;
let dismissalsSvc!: ReturnType<typeof inboxDismissalService>;
let badgesSvc!: ReturnType<typeof sidebarBadgeService>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-inbox-dismissals-");
db = createDb(tempDb.connectionString);
dismissalsSvc = inboxDismissalService(db);
badgesSvc = sidebarBadgeService(db);
}, 20_000);
afterEach(async () => {
await db.delete(inboxDismissals);
await db.delete(joinRequests);
await db.delete(invites);
await db.delete(heartbeatRuns);
await db.delete(approvals);
await db.delete(agents);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
it("upserts a single dismissal record per user and inbox item key", async () => {
const companyId = randomUUID();
const userId = "board-user";
const firstDismissedAt = new Date("2026-03-11T01:00:00.000Z");
const secondDismissedAt = new Date("2026-03-11T02:00:00.000Z");
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: "PAP",
requireBoardApprovalForNewAgents: false,
});
await dismissalsSvc.dismiss(companyId, userId, "approval:approval-1", firstDismissedAt);
await dismissalsSvc.dismiss(companyId, userId, "approval:approval-1", secondDismissedAt);
const dismissals = await dismissalsSvc.list(companyId, userId);
expect(dismissals).toHaveLength(1);
expect(dismissals[0]?.itemKey).toBe("approval:approval-1");
expect(new Date(dismissals[0]?.dismissedAt ?? 0).toISOString()).toBe(secondDismissedAt.toISOString());
});
it("honors dismissal timestamps and resurfaces approvals with newer activity", async () => {
const companyId = randomUUID();
const userId = "board-user";
const primaryAgentId = randomUUID();
const secondaryAgentId = randomUUID();
const hiddenApprovalId = randomUUID();
const resurfacedApprovalId = randomUUID();
const inviteId = randomUUID();
const hiddenJoinRequestId = randomUUID();
const hiddenRunId = randomUUID();
const visibleRunId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: "PAP",
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values([
{
id: primaryAgentId,
companyId,
name: "Primary",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
},
{
id: secondaryAgentId,
companyId,
name: "Secondary",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
},
]);
await db.insert(approvals).values([
{
id: hiddenApprovalId,
companyId,
type: "hire_agent",
status: "pending",
payload: {},
updatedAt: new Date("2026-03-11T01:00:00.000Z"),
},
{
id: resurfacedApprovalId,
companyId,
type: "hire_agent",
status: "revision_requested",
payload: {},
updatedAt: new Date("2026-03-11T03:00:00.000Z"),
},
]);
await db.insert(invites).values({
id: inviteId,
companyId,
inviteType: "company_join",
tokenHash: "hash-1",
allowedJoinTypes: "both",
expiresAt: new Date("2026-03-12T00:00:00.000Z"),
});
await db.insert(joinRequests).values({
id: hiddenJoinRequestId,
inviteId,
companyId,
requestType: "human",
status: "pending_approval",
requestIp: "127.0.0.1",
createdAt: new Date("2026-03-11T01:00:00.000Z"),
updatedAt: new Date("2026-03-11T01:00:00.000Z"),
});
await db.insert(heartbeatRuns).values([
{
id: hiddenRunId,
companyId,
agentId: primaryAgentId,
invocationSource: "assignment",
status: "failed",
createdAt: new Date("2026-03-11T01:00:00.000Z"),
updatedAt: new Date("2026-03-11T01:00:00.000Z"),
},
{
id: visibleRunId,
companyId,
agentId: secondaryAgentId,
invocationSource: "assignment",
status: "timed_out",
createdAt: new Date("2026-03-11T04:00:00.000Z"),
updatedAt: new Date("2026-03-11T04:00:00.000Z"),
},
]);
await dismissalsSvc.dismiss(companyId, userId, `approval:${hiddenApprovalId}`, new Date("2026-03-11T02:00:00.000Z"));
await dismissalsSvc.dismiss(companyId, userId, `approval:${resurfacedApprovalId}`, new Date("2026-03-11T02:00:00.000Z"));
await dismissalsSvc.dismiss(companyId, userId, `join:${hiddenJoinRequestId}`, new Date("2026-03-11T02:00:00.000Z"));
await dismissalsSvc.dismiss(companyId, userId, `run:${hiddenRunId}`, new Date("2026-03-11T02:00:00.000Z"));
const dismissedAtByKey = new Map(
(await dismissalsSvc.list(companyId, userId)).map((dismissal) => [
dismissal.itemKey,
new Date(dismissal.dismissedAt).getTime(),
]),
);
const badges = await badgesSvc.get(companyId, {
dismissals: dismissedAtByKey,
joinRequests: [{
id: hiddenJoinRequestId,
createdAt: new Date("2026-03-11T01:00:00.000Z"),
updatedAt: new Date("2026-03-11T01:00:00.000Z"),
}],
unreadTouchedIssues: 1,
});
expect(badges).toEqual({
inbox: 3,
approvals: 1,
failedRuns: 1,
joinRequests: 0,
});
});
});

View file

@ -24,6 +24,7 @@ import { costRoutes } from "./routes/costs.js";
import { activityRoutes } from "./routes/activity.js";
import { dashboardRoutes } from "./routes/dashboard.js";
import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js";
import { inboxDismissalRoutes } from "./routes/inbox-dismissals.js";
import { instanceSettingsRoutes } from "./routes/instance-settings.js";
import { llmRoutes } from "./routes/llms.js";
import { assetRoutes } from "./routes/assets.js";
@ -166,6 +167,7 @@ export async function createApp(
api.use(activityRoutes(db));
api.use(dashboardRoutes(db));
api.use(sidebarBadgeRoutes(db));
api.use(inboxDismissalRoutes(db));
api.use(instanceSettingsRoutes(db));
const hostServicesDisposers = new Map<string, () => void>();
const workerManager = createPluginWorkerManager();

View file

@ -0,0 +1,69 @@
import { Router } from "express";
import { z } from "zod";
import type { Db } from "@paperclipai/db";
import { validate } from "../middleware/validate.js";
import { assertCompanyAccess, getActorInfo } from "./authz.js";
import { inboxDismissalService, logActivity } from "../services/index.js";
const inboxDismissalSchema = z.object({
itemKey: z.string().trim().min(1).regex(/^(approval|join|run):.+$/, "Unsupported inbox item key"),
});
export function inboxDismissalRoutes(db: Db) {
const router = Router();
const svc = inboxDismissalService(db);
router.get("/companies/:companyId/inbox-dismissals", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
if (req.actor.type !== "board") {
res.status(403).json({ error: "Board authentication required" });
return;
}
if (!req.actor.userId) {
res.status(403).json({ error: "Board user context required" });
return;
}
const dismissals = await svc.list(companyId, req.actor.userId);
res.json(dismissals);
});
router.post(
"/companies/:companyId/inbox-dismissals",
validate(inboxDismissalSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
if (req.actor.type !== "board") {
res.status(403).json({ error: "Board authentication required" });
return;
}
if (!req.actor.userId) {
res.status(403).json({ error: "Board user context required" });
return;
}
const dismissal = await svc.dismiss(companyId, req.actor.userId, req.body.itemKey, new Date());
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "inbox.dismissed",
entityType: "company",
entityId: companyId,
details: {
userId: req.actor.userId,
itemKey: dismissal.itemKey,
dismissedAt: dismissal.dismissedAt,
},
});
res.status(201).json(dismissal);
},
);
return router;
}

View file

@ -12,6 +12,7 @@ export { costRoutes } from "./costs.js";
export { activityRoutes } from "./activity.js";
export { dashboardRoutes } from "./dashboard.js";
export { sidebarBadgeRoutes } from "./sidebar-badges.js";
export { inboxDismissalRoutes } from "./inbox-dismissals.js";
export { llmRoutes } from "./llms.js";
export { accessRoutes } from "./access.js";
export { instanceSettingsRoutes } from "./instance-settings.js";

View file

@ -1,12 +1,20 @@
import { Router } from "express";
import type { Db } from "@paperclipai/db";
import { and, eq, sql } from "drizzle-orm";
import { joinRequests } from "@paperclipai/db";
import { and, eq } from "drizzle-orm";
import { inboxDismissals, joinRequests } from "@paperclipai/db";
import { sidebarBadgeService } from "../services/sidebar-badges.js";
import { accessService } from "../services/access.js";
import { dashboardService } from "../services/dashboard.js";
import { assertCompanyAccess } from "./authz.js";
function buildDismissedAtByKey(
dismissals: Array<{ itemKey: string; dismissedAt: Date | string }>,
): Map<string, number> {
return new Map(
dismissals.map((dismissal) => [dismissal.itemKey, new Date(dismissal.dismissedAt).getTime()]),
);
}
export function sidebarBadgeRoutes(db: Db) {
const router = Router();
const svc = sidebarBadgeService(db);
@ -26,23 +34,36 @@ export function sidebarBadgeRoutes(db: Db) {
canApproveJoins = await access.hasPermission(companyId, "agent", req.actor.agentId, "joins:approve");
}
const joinRequestCount = canApproveJoins
const visibleJoinRequests = canApproveJoins
? await db
.select({ count: sql<number>`count(*)` })
.select({
id: joinRequests.id,
updatedAt: joinRequests.updatedAt,
createdAt: joinRequests.createdAt,
})
.from(joinRequests)
.where(and(eq(joinRequests.companyId, companyId), eq(joinRequests.status, "pending_approval")))
.then((rows) => Number(rows[0]?.count ?? 0))
: 0;
: [];
const dismissedAtByKey =
req.actor.type === "board" && req.actor.userId
? await db
.select({ itemKey: inboxDismissals.itemKey, dismissedAt: inboxDismissals.dismissedAt })
.from(inboxDismissals)
.where(and(eq(inboxDismissals.companyId, companyId), eq(inboxDismissals.userId, req.actor.userId)))
.then(buildDismissedAtByKey)
: new Map<string, number>();
const badges = await svc.get(companyId, {
joinRequests: joinRequestCount,
dismissals: dismissedAtByKey,
joinRequests: visibleJoinRequests,
});
const summary = await dashboard.summary(companyId);
const hasFailedRuns = badges.failedRuns > 0;
const alertsCount =
(summary.agents.error > 0 && !hasFailedRuns ? 1 : 0) +
(summary.costs.monthBudgetCents > 0 && summary.costs.monthUtilizationPercent >= 80 ? 1 : 0);
badges.inbox = badges.failedRuns + alertsCount + joinRequestCount + badges.approvals;
badges.inbox = badges.failedRuns + alertsCount + badges.joinRequests + badges.approvals;
res.json(badges);
});

View file

@ -0,0 +1,41 @@
import { and, desc, eq } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { inboxDismissals } from "@paperclipai/db";
export function inboxDismissalService(db: Db) {
return {
list: async (companyId: string, userId: string) =>
db
.select()
.from(inboxDismissals)
.where(and(eq(inboxDismissals.companyId, companyId), eq(inboxDismissals.userId, userId)))
.orderBy(desc(inboxDismissals.updatedAt)),
dismiss: async (
companyId: string,
userId: string,
itemKey: string,
dismissedAt: Date = new Date(),
) => {
const now = new Date();
const [row] = await db
.insert(inboxDismissals)
.values({
companyId,
userId,
itemKey,
dismissedAt,
updatedAt: now,
})
.onConflictDoUpdate({
target: [inboxDismissals.companyId, inboxDismissals.userId, inboxDismissals.itemKey],
set: {
dismissedAt,
updatedAt: now,
},
})
.returning();
return row;
},
};
}

View file

@ -19,6 +19,7 @@ export { financeService } from "./finance.js";
export { heartbeatService } from "./heartbeat.js";
export { dashboardService } from "./dashboard.js";
export { sidebarBadgeService } from "./sidebar-badges.js";
export { inboxDismissalService } from "./inbox-dismissals.js";
export { accessService } from "./access.js";
export { boardAuthService } from "./board-auth.js";
export { instanceSettingsService } from "./instance-settings.js";

View file

@ -1,4 +1,4 @@
import { and, desc, eq, inArray, not, sql } from "drizzle-orm";
import { and, desc, eq, inArray, not } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { agents, approvals, heartbeatRuns } from "@paperclipai/db";
import type { SidebarBadges } from "@paperclipai/shared";
@ -6,14 +6,34 @@ import type { SidebarBadges } from "@paperclipai/shared";
const ACTIONABLE_APPROVAL_STATUSES = ["pending", "revision_requested"];
const FAILED_HEARTBEAT_STATUSES = ["failed", "timed_out"];
function normalizeTimestamp(value: Date | string | null | undefined): number {
if (!value) return 0;
const timestamp = new Date(value).getTime();
return Number.isFinite(timestamp) ? timestamp : 0;
}
function isDismissed(
dismissedAtByKey: ReadonlyMap<string, number>,
itemKey: string,
activityAt: Date | string | null | undefined,
) {
const dismissedAt = dismissedAtByKey.get(itemKey);
if (dismissedAt == null) return false;
return dismissedAt >= normalizeTimestamp(activityAt);
}
export function sidebarBadgeService(db: Db) {
return {
get: async (
companyId: string,
extra?: { joinRequests?: number; unreadTouchedIssues?: number },
extra?: {
dismissals?: ReadonlyMap<string, number>;
joinRequests?: Array<{ id: string; updatedAt: Date | string | null; createdAt: Date | string }>;
unreadTouchedIssues?: number;
},
): Promise<SidebarBadges> => {
const actionableApprovals = await db
.select({ count: sql<number>`count(*)` })
.select({ id: approvals.id, updatedAt: approvals.updatedAt })
.from(approvals)
.where(
and(
@ -21,11 +41,15 @@ export function sidebarBadgeService(db: Db) {
inArray(approvals.status, ACTIONABLE_APPROVAL_STATUSES),
),
)
.then((rows) => Number(rows[0]?.count ?? 0));
.then((rows) =>
rows.filter((row) => !isDismissed(extra?.dismissals ?? new Map(), `approval:${row.id}`, row.updatedAt)).length
);
const latestRunByAgent = await db
.selectDistinctOn([heartbeatRuns.agentId], {
id: heartbeatRuns.id,
runStatus: heartbeatRuns.status,
createdAt: heartbeatRuns.createdAt,
})
.from(heartbeatRuns)
.innerJoin(agents, eq(heartbeatRuns.agentId, agents.id))
@ -39,10 +63,17 @@ export function sidebarBadgeService(db: Db) {
.orderBy(heartbeatRuns.agentId, desc(heartbeatRuns.createdAt));
const failedRuns = latestRunByAgent.filter((row) =>
FAILED_HEARTBEAT_STATUSES.includes(row.runStatus),
FAILED_HEARTBEAT_STATUSES.includes(row.runStatus)
&& !isDismissed(extra?.dismissals ?? new Map(), `run:${row.id}`, row.createdAt),
).length;
const joinRequests = extra?.joinRequests ?? 0;
const joinRequests = (extra?.joinRequests ?? []).filter((row) =>
!isDismissed(
extra?.dismissals ?? new Map(),
`join:${row.id}`,
row.updatedAt ?? row.createdAt,
)
).length;
const unreadTouchedIssues = extra?.unreadTouchedIssues ?? 0;
return {
inbox: actionableApprovals + failedRuns + joinRequests + unreadTouchedIssues,

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"
>