mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
Persist non-issue inbox dismissals
This commit is contained in:
parent
1de5fb9316
commit
5640d29ab0
23 changed files with 13623 additions and 54 deletions
18
packages/db/src/migrations/0053_sharp_wild_child.sql
Normal file
18
packages/db/src/migrations/0053_sharp_wild_child.sql
Normal 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");
|
||||
12979
packages/db/src/migrations/meta/0053_snapshot.json
Normal file
12979
packages/db/src/migrations/meta/0053_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
24
packages/db/src/schema/inbox_dismissals.ts
Normal file
24
packages/db/src/schema/inbox_dismissals.ts
Normal 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,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -288,6 +288,7 @@ export type {
|
|||
DashboardSummary,
|
||||
ActivityEvent,
|
||||
SidebarBadges,
|
||||
InboxDismissal,
|
||||
CompanyMembership,
|
||||
PrincipalPermissionGrant,
|
||||
Invite,
|
||||
|
|
|
|||
9
packages/shared/src/types/inbox-dismissal.ts
Normal file
9
packages/shared/src/types/inbox-dismissal.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export interface InboxDismissal {
|
||||
id: string;
|
||||
companyId: string;
|
||||
userId: string;
|
||||
itemKey: string;
|
||||
dismissedAt: Date;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
212
server/src/__tests__/inbox-dismissals.test.ts
Normal file
212
server/src/__tests__/inbox-dismissals.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
69
server/src/routes/inbox-dismissals.ts
Normal file
69
server/src/routes/inbox-dismissals.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
41
server/src/services/inbox-dismissals.ts
Normal file
41
server/src/services/inbox-dismissals.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
8
ui/src/api/inboxDismissals.ts
Normal file
8
ui/src/api/inboxDismissals.ts
Normal 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 }),
|
||||
};
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)];
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue