diff --git a/packages/db/src/migrations/0145_inbox_dismissal_snooze_kind.sql b/packages/db/src/migrations/0145_inbox_dismissal_snooze_kind.sql new file mode 100644 index 00000000000..00d65045ebe --- /dev/null +++ b/packages/db/src/migrations/0145_inbox_dismissal_snooze_kind.sql @@ -0,0 +1,15 @@ +ALTER TABLE "inbox_dismissals" ADD COLUMN IF NOT EXISTS "kind" text;--> statement-breakpoint +UPDATE "inbox_dismissals" SET "kind" = 'dismiss' WHERE "kind" IS NULL;--> statement-breakpoint +ALTER TABLE "inbox_dismissals" ALTER COLUMN "kind" SET DEFAULT 'dismiss';--> statement-breakpoint +ALTER TABLE "inbox_dismissals" ALTER COLUMN "kind" SET NOT NULL;--> statement-breakpoint +ALTER TABLE "inbox_dismissals" ADD COLUMN IF NOT EXISTS "snoozed_until" timestamp with time zone;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "inbox_dismissals" ADD CONSTRAINT "inbox_dismissals_kind_check" CHECK ("kind" IN ('dismiss', 'snooze')); +EXCEPTION + WHEN duplicate_object THEN null; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "inbox_dismissals" ADD CONSTRAINT "inbox_dismissals_kind_snooze_until_check" CHECK (("kind" = 'dismiss' AND "snoozed_until" IS NULL) OR ("kind" = 'snooze' AND "snoozed_until" IS NOT NULL)); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index fe570bd9dc9..a0331a5af0e 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -1002,6 +1002,13 @@ "when": 1783520000000, "tag": "0144_case_document_annotations", "breakpoints": true + }, + { + "idx": 145, + "version": "7", + "when": 1783641600000, + "tag": "0145_inbox_dismissal_snooze_kind", + "breakpoints": true } ] } diff --git a/packages/db/src/schema/inbox_dismissals.ts b/packages/db/src/schema/inbox_dismissals.ts index 22996a476f3..ff77b45043f 100644 --- a/packages/db/src/schema/inbox_dismissals.ts +++ b/packages/db/src/schema/inbox_dismissals.ts @@ -8,7 +8,9 @@ export const inboxDismissals = pgTable( companyId: uuid("company_id").notNull().references(() => companies.id), userId: text("user_id").notNull(), itemKey: text("item_key").notNull(), + kind: text("kind").$type<"dismiss" | "snooze">().notNull().default("dismiss"), dismissedAt: timestamp("dismissed_at", { withTimezone: true }).notNull().defaultNow(), + snoozedUntil: timestamp("snoozed_until", { withTimezone: true }), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), }, diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index fe8b4fecc4c..9b90f1bb8ac 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -51,12 +51,17 @@ export { } from "./responsible-user-denial.js"; export type { AttentionDecisionVerb, + AttentionDetailImage, AttentionFeed, AttentionItem, + AttentionItemDetail, + AttentionItemDismissal, + AttentionProjectRef, AttentionSeverity, AttentionSourceKind, AttentionSubject, AttentionSubjectKind, + AttentionWorkspaceRef, } from "./types/attention.js"; export type { @@ -874,6 +879,7 @@ export type { SidebarBadges, SidebarOrderPreference, InboxDismissal, + InboxDismissalKind, AccessUserProfile, CompanyMemberRecord, CompanyMembersResponse, diff --git a/packages/shared/src/types/attention.ts b/packages/shared/src/types/attention.ts index 60d4bd44413..c9c505a24b2 100644 --- a/packages/shared/src/types/attention.ts +++ b/packages/shared/src/types/attention.ts @@ -1,3 +1,5 @@ +import type { InboxDismissalKind } from "./inbox-dismissal.js"; + export type AttentionSourceKind = | "approval" | "issue_thread_interaction" @@ -39,6 +41,107 @@ export interface AttentionDecisionVerb { description: string | null; } +export interface AttentionProjectRef { + id: string; + name: string; + urlKey: string; +} + +export interface AttentionWorkspaceRef { + id: string; + name: string; +} + +export interface AttentionDetailImage { + assetId: string; + alt?: string | null; +} + +export interface AttentionItemDismissal { + kind: InboxDismissalKind; + dismissedAt: string; + snoozedUntil: string | null; + isActive: boolean; +} + +export type AttentionItemDetail = + | { + kind: "approval"; + approvalType: string; + summaryExcerpt: string | null; + images: AttentionDetailImage[]; + } + | { + kind: "plan_approval"; + issueTitle: string | null; + planTitle: string | null; + summaryExcerpt: string | null; + images: AttentionDetailImage[]; + } + | { + kind: "confirmation"; + promptExcerpt: string | null; + isPlanTarget: false; + images: AttentionDetailImage[]; + } + | { + kind: "questions"; + questionCount: number; + firstQuestionText: string | null; + images: AttentionDetailImage[]; + } + | { + kind: "suggested_tasks"; + taskCount: number; + firstTaskTitle: string | null; + images: AttentionDetailImage[]; + } + | { + kind: "checkbox_confirmation"; + optionCount: number; + promptExcerpt: string | null; + images: AttentionDetailImage[]; + } + | { + kind: "item_verdicts"; + itemCount: number; + promptExcerpt: string | null; + images: AttentionDetailImage[]; + } + | { + kind: "failed_run"; + agentName: string | null; + failureReasonExcerpt: string | null; + images: AttentionDetailImage[]; + } + | { + kind: "blocker"; + blockingIssue: { + id: string | null; + identifier: string | null; + title: string | null; + } | null; + images: AttentionDetailImage[]; + } + | { + kind: "budget"; + observedPercent: number; + amountObserved: number; + amountLimit: number; + images: AttentionDetailImage[]; + } + | { + kind: "agent_error"; + agentName: string | null; + failureReasonExcerpt: string | null; + images: AttentionDetailImage[]; + } + | { + kind: "generic"; + summaryExcerpt: string | null; + images: AttentionDetailImage[]; + }; + export interface AttentionItem { id: string; companyId: string; @@ -51,12 +154,16 @@ export interface AttentionItem { exitRule: string; dedupKey: string; dismissalKey: string; + dismissal: AttentionItemDismissal | null; severity: AttentionSeverity; rank: number; activityAt: string; createdAt: string; updatedAt: string; relatedIssue: AttentionSubject | null; + project: AttentionProjectRef | null; + workspace: AttentionWorkspaceRef | null; + detail: AttentionItemDetail | null; } export interface AttentionFeed { diff --git a/packages/shared/src/types/inbox-dismissal.ts b/packages/shared/src/types/inbox-dismissal.ts index 0c76ecc824c..5d2c71ce44a 100644 --- a/packages/shared/src/types/inbox-dismissal.ts +++ b/packages/shared/src/types/inbox-dismissal.ts @@ -1,9 +1,13 @@ +export type InboxDismissalKind = "dismiss" | "snooze"; + export interface InboxDismissal { id: string; companyId: string; userId: string; itemKey: string; + kind: InboxDismissalKind; dismissedAt: Date; + snoozedUntil: Date | null; createdAt: Date; updatedAt: Date; } diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 14c9abe627f..e117d04b5bf 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -1,12 +1,17 @@ export type { Company } from "./company.js"; export type { AttentionDecisionVerb, + AttentionDetailImage, AttentionFeed, AttentionItem, + AttentionItemDetail, + AttentionItemDismissal, + AttentionProjectRef, AttentionSeverity, AttentionSourceKind, AttentionSubject, AttentionSubjectKind, + AttentionWorkspaceRef, } from "./attention.js"; export type { Environment, @@ -573,7 +578,7 @@ export type { UpdateResourceMembership, } from "./resource-memberships.js"; export { RESOURCE_MEMBERSHIP_STATES } from "./resource-memberships.js"; -export type { InboxDismissal } from "./inbox-dismissal.js"; +export type { InboxDismissal, InboxDismissalKind } from "./inbox-dismissal.js"; export type { AccessUserProfile, CompanyMemberRecord, diff --git a/screenshots/pap-13249/s1-s2-pending_desktop_dark.png b/screenshots/pap-13249/s1-s2-pending_desktop_dark.png deleted file mode 100644 index e518a1c741c..00000000000 Binary files a/screenshots/pap-13249/s1-s2-pending_desktop_dark.png and /dev/null differ diff --git a/screenshots/pap-13249/s1-s2-pending_desktop_light.png b/screenshots/pap-13249/s1-s2-pending_desktop_light.png deleted file mode 100644 index 817a85952e4..00000000000 Binary files a/screenshots/pap-13249/s1-s2-pending_desktop_light.png and /dev/null differ diff --git a/screenshots/pap-13249/s1-s2-pending_mobile_dark.png b/screenshots/pap-13249/s1-s2-pending_mobile_dark.png deleted file mode 100644 index 143a976cd62..00000000000 Binary files a/screenshots/pap-13249/s1-s2-pending_mobile_dark.png and /dev/null differ diff --git a/screenshots/pap-13249/s1-s2-pending_mobile_light.png b/screenshots/pap-13249/s1-s2-pending_mobile_light.png deleted file mode 100644 index 639cf3bbbaa..00000000000 Binary files a/screenshots/pap-13249/s1-s2-pending_mobile_light.png and /dev/null differ diff --git a/screenshots/pap-13249/s4-partial_desktop_dark.png b/screenshots/pap-13249/s4-partial_desktop_dark.png deleted file mode 100644 index 523d772ffd6..00000000000 Binary files a/screenshots/pap-13249/s4-partial_desktop_dark.png and /dev/null differ diff --git a/screenshots/pap-13249/s4-partial_desktop_light.png b/screenshots/pap-13249/s4-partial_desktop_light.png deleted file mode 100644 index e07dc119d24..00000000000 Binary files a/screenshots/pap-13249/s4-partial_desktop_light.png and /dev/null differ diff --git a/screenshots/pap-13249/s4-partial_mobile_dark.png b/screenshots/pap-13249/s4-partial_mobile_dark.png deleted file mode 100644 index 0e958271be9..00000000000 Binary files a/screenshots/pap-13249/s4-partial_mobile_dark.png and /dev/null differ diff --git a/screenshots/pap-13249/s4-partial_mobile_light.png b/screenshots/pap-13249/s4-partial_mobile_light.png deleted file mode 100644 index 3b01af2f8d4..00000000000 Binary files a/screenshots/pap-13249/s4-partial_mobile_light.png and /dev/null differ diff --git a/screenshots/pap-13249/s5-complete_desktop_dark.png b/screenshots/pap-13249/s5-complete_desktop_dark.png deleted file mode 100644 index 0b0eb5c4fad..00000000000 Binary files a/screenshots/pap-13249/s5-complete_desktop_dark.png and /dev/null differ diff --git a/screenshots/pap-13249/s5-complete_desktop_light.png b/screenshots/pap-13249/s5-complete_desktop_light.png deleted file mode 100644 index 9ee24c74ef1..00000000000 Binary files a/screenshots/pap-13249/s5-complete_desktop_light.png and /dev/null differ diff --git a/screenshots/pap-13249/s5-complete_mobile_dark.png b/screenshots/pap-13249/s5-complete_mobile_dark.png deleted file mode 100644 index c906eeda413..00000000000 Binary files a/screenshots/pap-13249/s5-complete_mobile_dark.png and /dev/null differ diff --git a/screenshots/pap-13249/s5-complete_mobile_light.png b/screenshots/pap-13249/s5-complete_mobile_light.png deleted file mode 100644 index f129c4e6182..00000000000 Binary files a/screenshots/pap-13249/s5-complete_mobile_light.png and /dev/null differ diff --git a/screenshots/pap-13249/s6-superseded_desktop_dark.png b/screenshots/pap-13249/s6-superseded_desktop_dark.png deleted file mode 100644 index a967627a764..00000000000 Binary files a/screenshots/pap-13249/s6-superseded_desktop_dark.png and /dev/null differ diff --git a/screenshots/pap-13249/s6-superseded_desktop_light.png b/screenshots/pap-13249/s6-superseded_desktop_light.png deleted file mode 100644 index 4623a9b56b7..00000000000 Binary files a/screenshots/pap-13249/s6-superseded_desktop_light.png and /dev/null differ diff --git a/screenshots/pap-13249/s6-superseded_mobile_dark.png b/screenshots/pap-13249/s6-superseded_mobile_dark.png deleted file mode 100644 index 672a195c558..00000000000 Binary files a/screenshots/pap-13249/s6-superseded_mobile_dark.png and /dev/null differ diff --git a/screenshots/pap-13249/s6-superseded_mobile_light.png b/screenshots/pap-13249/s6-superseded_mobile_light.png deleted file mode 100644 index 512202fe1d9..00000000000 Binary files a/screenshots/pap-13249/s6-superseded_mobile_light.png and /dev/null differ diff --git a/screenshots/pap-13249/s7-many_desktop_dark.png b/screenshots/pap-13249/s7-many_desktop_dark.png deleted file mode 100644 index 8342a3691c1..00000000000 Binary files a/screenshots/pap-13249/s7-many_desktop_dark.png and /dev/null differ diff --git a/screenshots/pap-13249/s7-many_desktop_light.png b/screenshots/pap-13249/s7-many_desktop_light.png deleted file mode 100644 index 0e844efa548..00000000000 Binary files a/screenshots/pap-13249/s7-many_desktop_light.png and /dev/null differ diff --git a/screenshots/pap-13249/s7-many_mobile_dark.png b/screenshots/pap-13249/s7-many_mobile_dark.png deleted file mode 100644 index 50bc9b42ef4..00000000000 Binary files a/screenshots/pap-13249/s7-many_mobile_dark.png and /dev/null differ diff --git a/screenshots/pap-13249/s7-many_mobile_light.png b/screenshots/pap-13249/s7-many_mobile_light.png deleted file mode 100644 index 95f56a9347d..00000000000 Binary files a/screenshots/pap-13249/s7-many_mobile_light.png and /dev/null differ diff --git a/server/src/__tests__/attention-service.test.ts b/server/src/__tests__/attention-service.test.ts index c731b3ca56c..32d8352e07f 100644 --- a/server/src/__tests__/attention-service.test.ts +++ b/server/src/__tests__/attention-service.test.ts @@ -7,20 +7,26 @@ import { activityLog, agents, approvals, + assets, budgetIncidents, budgetPolicies, companies, createDb, + documents, heartbeatRunEvents, heartbeatRuns, inboxDismissals, invites, issueApprovals, + issueAttachments, + issueDocuments, issueRecoveryActions, issueRelations, issueThreadInteractions, issues, joinRequests, + projects, + projectWorkspaces, } from "@paperclipai/db"; import { getEmbeddedPostgresTestSupport, @@ -52,6 +58,8 @@ describeEmbeddedPostgres("attention service", () => { await db.delete(inboxDismissals); await db.delete(issueThreadInteractions); await db.delete(issueApprovals); + await db.delete(issueAttachments); + await db.delete(issueDocuments); await db.delete(heartbeatRunEvents); await db.delete(heartbeatRuns); await db.delete(budgetIncidents); @@ -63,6 +71,10 @@ describeEmbeddedPostgres("attention service", () => { await db.delete(activityLog); await db.delete(approvals); await db.delete(issues); + await db.delete(assets); + await db.delete(documents); + await db.delete(projectWorkspaces); + await db.delete(projects); await db.delete(agents); await db.delete(companies); }); @@ -136,6 +148,8 @@ describeEmbeddedPostgres("attention service", () => { originKind?: string; originId?: string | null; originFingerprint?: string; + projectId?: string | null; + projectWorkspaceId?: string | null; executionState?: Record | null; updatedAt?: Date; createdAt?: Date; @@ -149,6 +163,8 @@ describeEmbeddedPostgres("attention service", () => { status: input.status, priority: input.priority ?? "medium", parentId: input.parentId ?? null, + projectId: input.projectId ?? null, + projectWorkspaceId: input.projectWorkspaceId ?? null, assigneeAgentId: input.assigneeAgentId ?? null, assigneeUserId: input.assigneeUserId ?? null, originKind: input.originKind ?? "manual", @@ -564,9 +580,208 @@ describeEmbeddedPostgres("attention service", () => { expect(feed.items.some((item) => item.sourceKind === "failed_run" && item.subject.metadata?.errorCode === "provider_quota" )).toBe(false); - expect(feed.items.some((item) => - item.sourceKind === "budget_alert" && item.subject.metadata?.observedPercent === 84 - )).toBe(false); + expect(feed.items.find((item) => item.sourceKind === "approval")?.detail).toMatchObject({ + kind: "approval", + approvalType: "hire_agent", + summaryExcerpt: "Hire Designer", + }); + expect(feed.items.find((item) => item.sourceKind === "issue_thread_interaction")?.detail).toMatchObject({ + kind: "questions", + questionCount: 0, + }); + expect(feed.items.find((item) => item.sourceKind === "blocker_attention")?.detail).toMatchObject({ + kind: "blocker", + blockingIssue: { identifier: "ATN-5", title: "Stalled review blocker" }, + }); + expect(feed.items.find((item) => item.sourceKind === "failed_run")?.detail).toMatchObject({ + kind: "failed_run", + agentName: "Worker", + failureReasonExcerpt: "adapter failed", + }); + expect(feed.items.find((item) => + item.sourceKind === "budget_alert" && item.detail?.kind === "budget" && item.detail.observedPercent === 100 + )).toBeTruthy(); + expect(feed.items.find((item) => item.sourceKind === "agent_error_alert")?.detail).toMatchObject({ + kind: "agent_error", + agentName: "Broken Agent", + failureReasonExcerpt: "adapter config missing", + }); + }); + + it("enriches interaction details with project, workspace, plan metadata, and images", async () => { + const { companyId, workerId } = await seedCompany("ATE"); + const projectId = randomUUID(); + const workspaceId = randomUUID(); + const issueId = randomUUID(); + const planDocumentId = randomUUID(); + const planRevisionId = randomUUID(); + const imageAssetIds = [randomUUID(), randomUUID(), randomUUID(), randomUUID()]; + + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Attention Project", + status: "in_progress", + }); + await db.insert(projectWorkspaces).values({ + id: workspaceId, + companyId, + projectId, + name: "Preview workspace", + sourceType: "local_path", + isPrimary: true, + }); + await insertIssue({ + id: issueId, + companyId, + identifier: "ATE-1", + title: "Approve launch plan", + status: "in_progress", + assigneeAgentId: workerId, + projectId, + projectWorkspaceId: workspaceId, + updatedAt: new Date("2026-07-09T12:00:00.000Z"), + }); + await db.insert(documents).values({ + id: planDocumentId, + companyId, + title: "Launch Plan", + format: "markdown", + latestBody: "# Summary\n\nThis plan explains the launch checklist, rollout owner, QA gates, and risk controls for the homepage release.", + latestRevisionId: planRevisionId, + latestRevisionNumber: 2, + }); + await db.insert(issueDocuments).values({ + companyId, + issueId, + documentId: planDocumentId, + key: "plan", + }); + await db.insert(assets).values([ + { id: imageAssetIds[0], companyId, provider: "local_disk", objectKey: "img-1", contentType: "image/png", byteSize: 10, sha256: "a".repeat(64), originalFilename: "one.png" }, + { id: imageAssetIds[1], companyId, provider: "local_disk", objectKey: "img-2", contentType: "image/jpeg", byteSize: 10, sha256: "b".repeat(64), originalFilename: "two.jpg" }, + { id: imageAssetIds[2], companyId, provider: "local_disk", objectKey: "img-3", contentType: "image/gif", byteSize: 10, sha256: "c".repeat(64), originalFilename: "three.gif" }, + { id: imageAssetIds[3], companyId, provider: "local_disk", objectKey: "img-4", contentType: "image/png", byteSize: 10, sha256: "d".repeat(64), originalFilename: "four.png" }, + ]); + await db.insert(issueAttachments).values(imageAssetIds.map((assetId, index) => ({ + companyId, + issueId, + assetId, + createdAt: new Date(`2026-07-09T12:0${index}:30.000Z`), + updatedAt: new Date(`2026-07-09T12:0${index}:30.000Z`), + }))); + + const planInteractionId = randomUUID(); + const questionsInteractionId = randomUUID(); + const tasksInteractionId = randomUUID(); + const checkboxInteractionId = randomUUID(); + const verdictInteractionId = randomUUID(); + await db.insert(issueThreadInteractions).values([ + { + id: planInteractionId, + companyId, + issueId, + kind: "request_confirmation", + status: "pending", + continuationPolicy: "wake_assignee", + title: "Approve the plan", + payload: { version: 1, prompt: "Approve plan?", target: { type: "issue_document", issueId, key: "plan", revisionId: planRevisionId } }, + createdAt: new Date("2026-07-09T12:01:00.000Z"), + updatedAt: new Date("2026-07-09T12:01:00.000Z"), + }, + { + id: questionsInteractionId, + companyId, + issueId, + kind: "ask_user_questions", + status: "pending", + continuationPolicy: "wake_assignee", + title: "Questions", + payload: { + version: 1, + questions: [ + { id: "q1", prompt: "Which auth provider should we use?", selectionMode: "single", options: [] }, + { id: "q2", prompt: "Should we add a fallback?", selectionMode: "single", options: [] }, + ], + }, + createdAt: new Date("2026-07-09T12:02:00.000Z"), + updatedAt: new Date("2026-07-09T12:02:00.000Z"), + }, + { + id: tasksInteractionId, + companyId, + issueId, + kind: "suggest_tasks", + status: "pending", + continuationPolicy: "wake_assignee", + title: "Tasks", + payload: { version: 1, tasks: [{ clientKey: "t1", title: "Build API" }, { clientKey: "t2", title: "Wire UI" }] }, + createdAt: new Date("2026-07-09T12:03:00.000Z"), + updatedAt: new Date("2026-07-09T12:03:00.000Z"), + }, + { + id: checkboxInteractionId, + companyId, + issueId, + kind: "request_checkbox_confirmation", + status: "pending", + continuationPolicy: "wake_assignee", + title: "Checkbox", + payload: { version: 1, prompt: "Select rollout regions", options: [{ id: "us", label: "US" }, { id: "eu", label: "EU" }] }, + createdAt: new Date("2026-07-09T12:04:00.000Z"), + updatedAt: new Date("2026-07-09T12:04:00.000Z"), + }, + { + id: verdictInteractionId, + companyId, + issueId, + kind: "request_item_verdicts", + status: "pending", + continuationPolicy: "wake_assignee", + title: "Verdicts", + payload: { version: 1, prompt: "Approve these screenshots", items: [{ id: "one", label: "One" }, { id: "two", label: "Two" }] }, + createdAt: new Date("2026-07-09T12:05:00.000Z"), + updatedAt: new Date("2026-07-09T12:05:00.000Z"), + }, + ]); + + const feed = await attentionService(db).list(companyId, { userId: "board-user" }); + const interactionItems = feed.items.filter((item) => item.sourceKind === "issue_thread_interaction"); + const detailsByKind = new Map(interactionItems.map((item) => [item.detail?.kind, item])); + + const planItem = detailsByKind.get("plan_approval"); + expect(planItem?.subject.title).toBe("Plan approval - Approve launch plan"); + expect(planItem?.subject.metadata).toMatchObject({ isPlanTarget: true, targetDocumentKey: "plan" }); + expect(planItem?.project).toMatchObject({ id: projectId, name: "Attention Project" }); + expect(planItem?.project?.urlKey).toEqual(expect.any(String)); + expect(planItem?.workspace).toEqual({ id: workspaceId, name: "Preview workspace" }); + expect(planItem?.detail).toMatchObject({ + kind: "plan_approval", + issueTitle: "Approve launch plan", + planTitle: "Launch Plan", + summaryExcerpt: expect.stringContaining("launch checklist"), + images: imageAssetIds.slice(0, 3).map((assetId) => ({ assetId, alt: expect.any(String) })), + }); + expect(detailsByKind.get("questions")?.detail).toMatchObject({ + kind: "questions", + questionCount: 2, + firstQuestionText: "Which auth provider should we use?", + }); + expect(detailsByKind.get("suggested_tasks")?.detail).toMatchObject({ + kind: "suggested_tasks", + taskCount: 2, + firstTaskTitle: "Build API", + }); + expect(detailsByKind.get("checkbox_confirmation")?.detail).toMatchObject({ + kind: "checkbox_confirmation", + optionCount: 2, + promptExcerpt: "Select rollout regions", + }); + expect(detailsByKind.get("item_verdicts")?.detail).toMatchObject({ + kind: "item_verdicts", + itemCount: 2, + promptExcerpt: "Approve these screenshots", + }); }); it("uses inbox_dismissals with attention-prefixed dedup keys and resurfaces newer activity", async () => { @@ -590,8 +805,10 @@ describeEmbeddedPostgres("attention service", () => { await expect(attentionService(db).list(companyId, { userId: "board-user" })) .resolves.toMatchObject({ totalCount: 1 }); // agent_error_alert from seed - await expect(attentionService(db).list(companyId, { userId: "board-user", includeDismissed: true })) - .resolves.toMatchObject({ totalCount: 2 }); + const includeDismissedFeed = await attentionService(db).list(companyId, { userId: "board-user", includeDismissed: true }); + expect(includeDismissedFeed.totalCount).toBe(2); + expect(includeDismissedFeed.items.find((item) => item.dedupKey === `approval:${approvalId}`)?.dismissal) + .toMatchObject({ kind: "dismiss", isActive: true, snoozedUntil: null }); await db .update(approvals) @@ -602,6 +819,44 @@ describeEmbeddedPostgres("attention service", () => { expect(feed.items.some((item) => item.dedupKey === `approval:${approvalId}`)).toBe(true); }); + it("hides snoozed attention rows until snoozedUntil passes, then returns them unconditionally", async () => { + const { companyId } = await seedCompany("ATS"); + const approvalId = randomUUID(); + await db.insert(approvals).values({ + id: approvalId, + companyId, + type: "hire_agent", + status: "pending", + payload: { title: "Hire Researcher" }, + createdAt: new Date("2026-07-09T12:00:00.000Z"), + updatedAt: new Date("2026-07-09T12:00:00.000Z"), + }); + await db.insert(inboxDismissals).values({ + companyId, + userId: "board-user", + itemKey: `attention:approval:${approvalId}`, + kind: "snooze", + dismissedAt: new Date("2099-01-01T00:00:00.000Z"), + snoozedUntil: new Date("2099-01-02T00:00:00.000Z"), + }); + + await expect(attentionService(db).list(companyId, { userId: "board-user" })) + .resolves.toMatchObject({ totalCount: 1 }); // agent_error_alert from seed + const hiddenFeed = await attentionService(db).list(companyId, { userId: "board-user", includeDismissed: true }); + expect(hiddenFeed.items.find((item) => item.dedupKey === `approval:${approvalId}`)?.dismissal) + .toMatchObject({ kind: "snooze", isActive: true, snoozedUntil: "2099-01-02T00:00:00.000Z" }); + + await db + .update(inboxDismissals) + .set({ snoozedUntil: new Date("2020-01-01T00:00:00.000Z") }) + .where(eq(inboxDismissals.itemKey, `attention:approval:${approvalId}`)); + + const visibleFeed = await attentionService(db).list(companyId, { userId: "board-user" }); + const visibleApproval = visibleFeed.items.find((item) => item.dedupKey === `approval:${approvalId}`); + expect(visibleApproval?.dismissal).toMatchObject({ kind: "snooze", isActive: false }); + expect(visibleApproval).toBeTruthy(); + }); + it("serves the route for board users and rejects agent callers", async () => { const { companyId } = await seedCompany("ATR"); diff --git a/server/src/__tests__/inbox-dismissals.test.ts b/server/src/__tests__/inbox-dismissals.test.ts index c6360a219ff..193c6f11a38 100644 --- a/server/src/__tests__/inbox-dismissals.test.ts +++ b/server/src/__tests__/inbox-dismissals.test.ts @@ -1,6 +1,9 @@ import { randomUUID } from "node:crypto"; +import express from "express"; +import request from "supertest"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { + activityLog, agents, approvals, companies, @@ -14,6 +17,8 @@ import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase, } from "./helpers/embedded-postgres.js"; +import { errorHandler } from "../middleware/index.js"; +import { inboxDismissalRoutes } from "../routes/inbox-dismissals.js"; import { inboxDismissalService } from "../services/inbox-dismissals.ts"; import { sidebarBadgeService } from "../services/sidebar-badges.ts"; @@ -43,6 +48,7 @@ describeEmbeddedPostgres("inbox dismissals", () => { await db.delete(inboxDismissals); await db.delete(joinRequests); await db.delete(invites); + await db.delete(activityLog); await db.delete(heartbeatRuns); await db.delete(approvals); await db.delete(agents); @@ -73,9 +79,63 @@ describeEmbeddedPostgres("inbox dismissals", () => { expect(dismissals).toHaveLength(1); expect(dismissals[0]?.itemKey).toBe("approval:approval-1"); + expect(dismissals[0]?.kind).toBe("dismiss"); + expect(dismissals[0]?.snoozedUntil).toBeNull(); expect(new Date(dismissals[0]?.dismissedAt ?? 0).toISOString()).toBe(secondDismissedAt.toISOString()); }); + it("snoozes and restores dismissal records through the route", async () => { + const companyId = randomUUID(); + const userId = "board-user"; + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: "PAP", + requireBoardApprovalForNewAgents: false, + }); + + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = { + type: "board", + source: "local_implicit", + userId, + companyIds: [companyId], + isInstanceAdmin: false, + }; + next(); + }); + app.use("/api", inboxDismissalRoutes(db)); + app.use(errorHandler); + + await request(app) + .post(`/api/companies/${companyId}/inbox-dismissals`) + .send({ itemKey: "attention:approval:old", kind: "snooze", snoozedUntil: "2020-01-01T00:00:00.000Z" }) + .expect(400); + + const snoozedUntil = "2099-01-01T00:00:00.000Z"; + const createRes = await request(app) + .post(`/api/companies/${companyId}/inbox-dismissals`) + .send({ itemKey: "attention:approval:approval-1", kind: "snooze", snoozedUntil }) + .expect(201); + + expect(createRes.body).toMatchObject({ + companyId, + userId, + itemKey: "attention:approval:approval-1", + kind: "snooze", + snoozedUntil, + }); + + await request(app) + .delete(`/api/companies/${companyId}/inbox-dismissals/${encodeURIComponent("attention:approval:approval-1")}`) + .expect(204); + + await expect(dismissalsSvc.list(companyId, userId)).resolves.toEqual([]); + }); + it("honors dismissal timestamps and resurfaces approvals with newer activity", async () => { const companyId = randomUUID(); const userId = "board-user"; diff --git a/server/src/routes/inbox-dismissals.ts b/server/src/routes/inbox-dismissals.ts index 1557a2b3cdf..ceae79c19c4 100644 --- a/server/src/routes/inbox-dismissals.ts +++ b/server/src/routes/inbox-dismissals.ts @@ -1,14 +1,50 @@ -import { Router } from "express"; +import { Router, type Request, type Response } 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 ITEM_KEY_RE = /^(approval|join|run|attention):.+$/; + const inboxDismissalSchema = z.object({ - itemKey: z.string().trim().min(1).regex(/^(approval|join|run|attention):.+$/, "Unsupported inbox item key"), + itemKey: z.string().trim().min(1).regex(ITEM_KEY_RE, "Unsupported inbox item key"), + kind: z.enum(["dismiss", "snooze"]).default("dismiss"), + snoozedUntil: z.string().trim().optional(), +}).superRefine((value, ctx) => { + if (value.kind === "dismiss") { + if (value.snoozedUntil != null) { + ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["snoozedUntil"], message: "Dismissals must not include snoozedUntil" }); + } + return; + } + + if (!value.snoozedUntil) { + ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["snoozedUntil"], message: "Snooze requires snoozedUntil" }); + return; + } + const timestamp = new Date(value.snoozedUntil).getTime(); + if (!Number.isFinite(timestamp)) { + ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["snoozedUntil"], message: "snoozedUntil must be an ISO timestamp" }); + return; + } + if (timestamp <= Date.now()) { + ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["snoozedUntil"], message: "snoozedUntil must be in the future" }); + } }); +function requireBoardUser(req: Request, res: Response) { + if (req.actor.type !== "board") { + res.status(403).json({ error: "Board authentication required" }); + return null; + } + if (!req.actor.userId) { + res.status(403).json({ error: "Board user context required" }); + return null; + } + return req.actor.userId; +} + export function inboxDismissalRoutes(db: Db) { const router = Router(); const svc = inboxDismissalService(db); @@ -16,15 +52,10 @@ export function inboxDismissalRoutes(db: 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); + const userId = requireBoardUser(req, res); + if (!userId) return; + + const dismissals = await svc.list(companyId, userId); res.json(dismissals); }); @@ -34,16 +65,12 @@ export function inboxDismissalRoutes(db: Db) { 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 userId = requireBoardUser(req, res); + if (!userId) return; - const dismissal = await svc.dismiss(companyId, req.actor.userId, req.body.itemKey, new Date()); + const dismissal = req.body.kind === "snooze" + ? await svc.snooze(companyId, userId, req.body.itemKey, new Date(req.body.snoozedUntil)) + : await svc.dismiss(companyId, userId, req.body.itemKey, new Date()); const actor = getActorInfo(req); await logActivity(db, { companyId, @@ -51,13 +78,15 @@ export function inboxDismissalRoutes(db: Db) { actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, - action: "inbox.dismissed", + action: dismissal.kind === "snooze" ? "inbox.snoozed" : "inbox.dismissed", entityType: "company", entityId: companyId, details: { - userId: req.actor.userId, + userId, itemKey: dismissal.itemKey, + kind: dismissal.kind, dismissedAt: dismissal.dismissedAt, + snoozedUntil: dismissal.snoozedUntil, }, }); @@ -65,5 +94,42 @@ export function inboxDismissalRoutes(db: Db) { }, ); + router.delete("/companies/:companyId/inbox-dismissals/:itemKey", async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + const userId = requireBoardUser(req, res); + if (!userId) return; + + const itemKey = req.params.itemKey as string; + if (!ITEM_KEY_RE.test(itemKey)) { + res.status(400).json({ error: "Unsupported inbox item key" }); + return; + } + + const restored = await svc.restore(companyId, userId, itemKey); + if (restored) { + const actor = getActorInfo(req); + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "inbox.restored", + entityType: "company", + entityId: companyId, + details: { + userId, + itemKey: restored.itemKey, + kind: restored.kind, + dismissedAt: restored.dismissedAt, + snoozedUntil: restored.snoozedUntil, + }, + }); + } + + res.status(204).send(); + }); + return router; } diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 5f3a105d475..1c485cda35c 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -111,6 +111,7 @@ import { acceptIssueThreadInteractionSchema, rejectIssueThreadInteractionSchema, respondIssueThreadInteractionSchema, + submitIssueThreadInteractionVerdictsSchema, // Auth / profile updateCurrentUserProfileSchema, // Company portability (legacy routes) @@ -2875,14 +2876,25 @@ registry.registerPath({ method: "post", path: "/api/companies/{companyId}/inbox-dismissals", tags: ["inbox"], - summary: "Create an inbox dismissal", + summary: "Create an inbox dismissal or snooze", request: { params: z.object({ companyId: z.string() }), body: jsonBody(z.object({ itemKey: z.string().trim().min(1).regex(/^(approval|join|run|attention):.+$/, "Unsupported inbox item key"), + kind: z.enum(["dismiss", "snooze"]).optional(), + snoozedUntil: z.string().datetime().optional(), })), }, - responses: { 200: r.ok(), 401: r.unauthorized }, + responses: { 201: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "delete", + path: "/api/companies/{companyId}/inbox-dismissals/{itemKey}", + tags: ["inbox"], + summary: "Restore an inbox dismissal or snooze", + request: { params: z.object({ companyId: z.string(), itemKey: z.string() }) }, + responses: { 204: r.ok(), 400: r.badRequest, 401: r.unauthorized }, }); // ─── Instance settings ──────────────────────────────────────────────────────── @@ -3442,6 +3454,18 @@ registry.registerPath({ responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 404: r.notFound }, }); +registry.registerPath({ + method: "post", + path: "/api/issues/{id}/interactions/{interactionId}/verdicts", + tags: ["issues"], + summary: "Submit item verdicts on an issue thread interaction", + request: { + params: z.object({ id: z.string(), interactionId: z.string() }), + body: jsonBody(submitIssueThreadInteractionVerdictsSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 404: r.notFound }, +}); + registry.registerPath({ method: "post", path: "/api/issues/{id}/children", diff --git a/server/src/routes/sidebar-badges.ts b/server/src/routes/sidebar-badges.ts index 8b4d1f22852..770bbc28479 100644 --- a/server/src/routes/sidebar-badges.ts +++ b/server/src/routes/sidebar-badges.ts @@ -9,11 +9,19 @@ import { collapseDuplicatePendingHumanJoinRequests } from "../lib/join-request-d import { assertCompanyAccess } from "./authz.js"; function buildDismissedAtByKey( - dismissals: Array<{ itemKey: string; dismissedAt: Date | string }>, + dismissals: Array<{ itemKey: string; kind: string; dismissedAt: Date | string; snoozedUntil: Date | string | null }>, ): Map { - return new Map( - dismissals.map((dismissal) => [dismissal.itemKey, new Date(dismissal.dismissedAt).getTime()]), - ); + const now = Date.now(); + const entries: Array<[string, number]> = []; + for (const dismissal of dismissals) { + if (dismissal.kind === "snooze") { + const snoozedUntil = dismissal.snoozedUntil ? new Date(dismissal.snoozedUntil).getTime() : 0; + if (Number.isFinite(snoozedUntil) && snoozedUntil > now) entries.push([dismissal.itemKey, Number.MAX_SAFE_INTEGER]); + continue; + } + entries.push([dismissal.itemKey, new Date(dismissal.dismissedAt).getTime()]); + } + return new Map(entries); } export function sidebarBadgeRoutes(db: Db) { @@ -59,7 +67,12 @@ export function sidebarBadgeRoutes(db: Db) { const dismissedAtByKey = req.actor.type === "board" && req.actor.userId ? await db - .select({ itemKey: inboxDismissals.itemKey, dismissedAt: inboxDismissals.dismissedAt }) + .select({ + itemKey: inboxDismissals.itemKey, + kind: inboxDismissals.kind, + dismissedAt: inboxDismissals.dismissedAt, + snoozedUntil: inboxDismissals.snoozedUntil, + }) .from(inboxDismissals) .where(and(eq(inboxDismissals.companyId, companyId), eq(inboxDismissals.userId, req.actor.userId))) .then(buildDismissedAtByKey) diff --git a/server/src/services/attention.ts b/server/src/services/attention.ts index ef240dedb9b..618cb7d4180 100644 --- a/server/src/services/attention.ts +++ b/server/src/services/attention.ts @@ -1,26 +1,38 @@ -import { and, desc, eq, gt, inArray, isNotNull, isNull, notInArray, sql } from "drizzle-orm"; +import { and, asc, desc, eq, gt, inArray, isNotNull, isNull, notInArray, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { agents, approvals, + assets, companies, heartbeatRunEvents, heartbeatRuns, inboxDismissals, invites, issueApprovals, + issueAttachments, + issueDocuments, issueRecoveryActions, + issueRelations, issueThreadInteractions, issues, joinRequests, + documents, + projects, + projectWorkspaces, } from "@paperclipai/db"; +import { deriveProjectUrlKey } from "@paperclipai/shared"; import type { AttentionDecisionVerb, AttentionFeed, + AttentionDetailImage, AttentionItem, + AttentionItemDetail, + AttentionProjectRef, AttentionSeverity, AttentionSourceKind, AttentionSubject, + AttentionWorkspaceRef, } from "@paperclipai/shared"; import { PRODUCTIVITY_REVIEW_ORIGIN_KIND } from "./productivity-review.js"; import { budgetService } from "./budgets.js"; @@ -65,6 +77,8 @@ const OPEN_RECOVERY_STATUSES = ["active", "escalated"] as const; const HUMAN_RECOVERY_OWNER_TYPES = ["user", "board"] as const; const PRODUCTIVITY_REVIEW_TERMINAL_STATUSES = ["done", "cancelled"] as const; const FAILED_RUN_STATUSES = ["failed", "timed_out"] as const; +const DETAIL_EXCERPT_LENGTH = 160; +const DETAIL_IMAGE_LIMIT = 3; type IssueSummaryRow = { id: string; @@ -77,6 +91,27 @@ type IssueSummaryRow = { assigneeUserId: string | null; createdAt: Date; updatedAt: Date; + project: AttentionProjectRef | null; + workspace: AttentionWorkspaceRef | null; +}; + +type IssueSubjectRow = Omit; + +type DismissalState = { + kind: "dismiss" | "snooze"; + dismissedAt: Date; + snoozedUntil: Date | null; +}; + +type PlanDocumentSummary = { + title: string | null; + body: string; +}; + +type BlockingIssueSummary = { + id: string | null; + identifier: string | null; + title: string | null; }; type AttentionListOptions = { @@ -99,20 +134,158 @@ function timestamp(value: Date | string | null | undefined): number { return Number.isFinite(time) ? time : 0; } -function isDismissed( - dismissedAtByKey: ReadonlyMap, +function activeDismissalState( + dismissalByKey: ReadonlyMap, dismissalKey: string, activityAt: string, + now: number, ) { - const dismissedAt = dismissedAtByKey.get(dismissalKey); - return dismissedAt != null && dismissedAt >= timestamp(activityAt); + const dismissal = dismissalByKey.get(dismissalKey); + if (!dismissal) return null; + + const dismissedAt = toIso(dismissal.dismissedAt); + const snoozedUntil = dismissal.snoozedUntil ? toIso(dismissal.snoozedUntil) : null; + const isActive = dismissal.kind === "snooze" + ? dismissal.snoozedUntil != null && timestamp(dismissal.snoozedUntil) > now + : timestamp(dismissal.dismissedAt) >= timestamp(activityAt); + + return { + kind: dismissal.kind, + dismissedAt, + snoozedUntil, + isActive, + }; } -function issueHref(prefix: string, issue: Pick) { +function stripMarkdown(value: string) { + return value + .replace(/```[\s\S]*?```/g, " ") + .replace(/`([^`]+)`/g, "$1") + .replace(/!\[[^\]]*\]\([^)]*\)/g, " ") + .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1") + .replace(/^#{1,6}\s+/gm, "") + .replace(/[>*_~#-]+/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +function excerpt(value: unknown, maxLength = DETAIL_EXCERPT_LENGTH) { + if (typeof value !== "string") return null; + const cleaned = stripMarkdown(value); + if (!cleaned) return null; + if (cleaned.length <= maxLength) return cleaned; + return `${cleaned.slice(0, Math.max(0, maxLength - 1)).trimEnd()}...`; +} + +function readRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +} + +function readArray(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +} + +function readString(value: unknown) { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function isPlanDocumentTarget(payload: Record) { + const target = readRecord(payload.target); + return target.type === "issue_document" && target.key === "plan"; +} + +function issueContext(issue: IssueSummaryRow | null | undefined) { + return { + project: issue?.project ?? null, + workspace: issue?.workspace ?? null, + }; +} + +function issueImages(imageMap: ReadonlyMap, issueId: string | null | undefined) { + return issueId ? imageMap.get(issueId) ?? [] : []; +} + +function genericDetail(summary: unknown, images: AttentionDetailImage[]): AttentionItemDetail { + return { kind: "generic", summaryExcerpt: excerpt(summary), images }; +} + +function approvalDetail(type: string, payload: Record): AttentionItemDetail { + return { + kind: "approval", + approvalType: type, + summaryExcerpt: excerpt(payload.summary ?? payload.title ?? payload.recommendedAction), + images: [], + }; +} + +function interactionDetail(input: { + kind: string; + payload: Record; + issue: IssueSummaryRow | null; + planDocument: PlanDocumentSummary | null; + images: AttentionDetailImage[]; +}): AttentionItemDetail { + if (input.kind === "request_confirmation" && isPlanDocumentTarget(input.payload)) { + return { + kind: "plan_approval", + issueTitle: input.issue?.title ?? null, + planTitle: input.planDocument?.title ?? "Plan", + summaryExcerpt: excerpt(input.planDocument?.body ?? input.payload.detailsMarkdown ?? input.payload.prompt), + images: input.images, + }; + } + + if (input.kind === "ask_user_questions") { + const questions = readArray(input.payload.questions).map(readRecord); + return { + kind: "questions", + questionCount: questions.length, + firstQuestionText: readString(questions[0]?.prompt), + images: input.images, + }; + } + + if (input.kind === "suggest_tasks") { + const tasks = readArray(input.payload.tasks).map(readRecord); + return { + kind: "suggested_tasks", + taskCount: tasks.length, + firstTaskTitle: readString(tasks[0]?.title), + images: input.images, + }; + } + + if (input.kind === "request_checkbox_confirmation") { + return { + kind: "checkbox_confirmation", + optionCount: readArray(input.payload.options).length, + promptExcerpt: excerpt(input.payload.prompt), + images: input.images, + }; + } + + if (input.kind === "request_item_verdicts") { + return { + kind: "item_verdicts", + itemCount: readArray(input.payload.items).length, + promptExcerpt: excerpt(input.payload.prompt), + images: input.images, + }; + } + + return { + kind: "confirmation", + promptExcerpt: excerpt(input.payload.prompt ?? input.payload.detailsMarkdown), + isPlanTarget: false, + images: input.images, + }; +} + +function issueHref(prefix: string, issue: Pick) { return `/${prefix}/issues/${issue.identifier ?? issue.id}`; } -function issueSubject(prefix: string, issue: IssueSummaryRow): AttentionSubject { +function issueSubject(prefix: string, issue: IssueSubjectRow): AttentionSubject { return { kind: "issue", id: issue.id, @@ -137,22 +310,32 @@ function decisionVerbs(...verbs: AttentionDecisionVerb[]): AttentionDecisionVerb return verbs; } -function createItem(input: Omit): AttentionItem { +type CreateAttentionItemInput = Omit & { + project?: AttentionProjectRef | null; + workspace?: AttentionWorkspaceRef | null; + detail?: AttentionItemDetail | null; +}; + +function createItem(input: CreateAttentionItemInput): AttentionItem { return { ...input, id: itemId(input.sourceKind, input.dedupKey), dismissalKey: `attention:${input.dedupKey}`, + dismissal: null, + project: input.project ?? null, + workspace: input.workspace ?? null, + detail: input.detail ?? null, rank: 0, }; } function compareAttentionItems(left: AttentionItem, right: AttentionItem) { + const timeDiff = timestamp(right.activityAt) - timestamp(left.activityAt); + if (timeDiff !== 0) return timeDiff; const severityDiff = SEVERITY_RANK[left.severity] - SEVERITY_RANK[right.severity]; if (severityDiff !== 0) return severityDiff; const sourceDiff = SOURCE_RANK[left.sourceKind] - SOURCE_RANK[right.sourceKind]; if (sourceDiff !== 0) return sourceDiff; - const timeDiff = timestamp(right.activityAt) - timestamp(left.activityAt); - if (timeDiff !== 0) return timeDiff; return left.dedupKey.localeCompare(right.dedupKey); } @@ -178,6 +361,8 @@ function interactionLabel(kind: string) { return "Questions need answers"; case "suggest_tasks": return "Suggested tasks need a decision"; + case "request_item_verdicts": + return "Item verdicts need a decision"; default: return "Interaction needs a decision"; } @@ -218,13 +403,22 @@ async function companyPrefix(db: Db, companyId: string) { return row?.issuePrefix ?? "PAP"; } -async function dismissedAtByKey(db: Db, companyId: string, userId: string | null | undefined) { - if (!userId) return new Map(); +async function dismissalByKey(db: Db, companyId: string, userId: string | null | undefined) { + if (!userId) return new Map(); const rows = await db - .select({ itemKey: inboxDismissals.itemKey, dismissedAt: inboxDismissals.dismissedAt }) + .select({ + itemKey: inboxDismissals.itemKey, + kind: inboxDismissals.kind, + dismissedAt: inboxDismissals.dismissedAt, + snoozedUntil: inboxDismissals.snoozedUntil, + }) .from(inboxDismissals) .where(and(eq(inboxDismissals.companyId, companyId), eq(inboxDismissals.userId, userId))); - return new Map(rows.map((row) => [row.itemKey, timestamp(row.dismissedAt)])); + return new Map(rows.map((row) => [row.itemKey, { + kind: row.kind, + dismissedAt: row.dismissedAt, + snoozedUntil: row.snoozedUntil, + }])); } async function issueSummaryMap(db: Db, companyId: string, issueIds: Array) { @@ -242,10 +436,117 @@ async function issueSummaryMap(db: Db, companyId: string, issueIds: Array [row.id, row])); + return new Map(rows.map((row) => [row.id, { + id: row.id, + companyId: row.companyId, + identifier: row.identifier, + title: row.title, + status: row.status, + priority: row.priority, + assigneeAgentId: row.assigneeAgentId, + assigneeUserId: row.assigneeUserId, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + project: row.projectId && row.projectName ? { + id: row.projectId, + name: row.projectName, + urlKey: deriveProjectUrlKey(row.projectName, row.projectId), + } : null, + workspace: row.workspaceId && row.workspaceName ? { + id: row.workspaceId, + name: row.workspaceName, + } : null, + }])); +} + +async function issueImageMap(db: Db, companyId: string, issueIds: Array) { + const ids = [...new Set(issueIds.filter((value): value is string => Boolean(value)))]; + if (ids.length === 0) return new Map(); + const rows = await db + .select({ + issueId: issueAttachments.issueId, + assetId: issueAttachments.assetId, + originalFilename: assets.originalFilename, + }) + .from(issueAttachments) + .innerJoin(assets, eq(issueAttachments.assetId, assets.id)) + .where(and( + eq(issueAttachments.companyId, companyId), + eq(assets.companyId, companyId), + inArray(issueAttachments.issueId, ids), + sql`${assets.contentType} like 'image/%'`, + )) + .orderBy(asc(issueAttachments.issueId), asc(issueAttachments.createdAt), asc(issueAttachments.id)); + + const map = new Map(); + for (const row of rows) { + const images = map.get(row.issueId) ?? []; + if (images.length >= DETAIL_IMAGE_LIMIT) continue; + images.push({ assetId: row.assetId, alt: row.originalFilename ?? null }); + map.set(row.issueId, images); + } + return map; +} + +async function planDocumentMap(db: Db, companyId: string, issueIds: Array) { + const ids = [...new Set(issueIds.filter((value): value is string => Boolean(value)))]; + if (ids.length === 0) return new Map(); + const rows = await db + .select({ + issueId: issueDocuments.issueId, + title: documents.title, + body: documents.latestBody, + }) + .from(issueDocuments) + .innerJoin(documents, eq(issueDocuments.documentId, documents.id)) + .where(and( + eq(issueDocuments.companyId, companyId), + eq(documents.companyId, companyId), + eq(issueDocuments.key, "plan"), + inArray(issueDocuments.issueId, ids), + )); + return new Map(rows.map((row) => [row.issueId, { title: row.title, body: row.body }])); +} + +async function blockingIssueMap(db: Db, companyId: string, blockedIssueIds: Array) { + const ids = [...new Set(blockedIssueIds.filter((value): value is string => Boolean(value)))]; + if (ids.length === 0) return new Map(); + const rows = await db + .select({ + blockedIssueId: issueRelations.relatedIssueId, + id: issues.id, + identifier: issues.identifier, + title: issues.title, + }) + .from(issueRelations) + .innerJoin(issues, eq(issueRelations.issueId, issues.id)) + .where(and( + eq(issueRelations.companyId, companyId), + eq(issues.companyId, companyId), + eq(issueRelations.type, "blocks"), + inArray(issueRelations.relatedIssueId, ids), + isNull(issues.hiddenAt), + )) + .orderBy(asc(issueRelations.relatedIssueId), asc(issueRelations.createdAt), asc(issueRelations.id)); + const map = new Map(); + for (const row of rows) { + if (!map.has(row.blockedIssueId)) { + map.set(row.blockedIssueId, { id: row.id, identifier: row.identifier, title: row.title }); + } + } + return map; } function readRunIssueId(contextSnapshot: Record | null) { @@ -257,13 +558,15 @@ export function attentionService(db: Db) { return { list: async (companyId: string, options: AttentionListOptions = {}): Promise => { const prefix = await companyPrefix(db, companyId); - const dismissed = await dismissedAtByKey(db, companyId, options.userId); + const dismissals = await dismissalByKey(db, companyId, options.userId); const includeDismissed = options.includeDismissed === true; + const now = Date.now(); const collected: AttentionItem[] = []; const add = (item: AttentionItem) => { - if (!includeDismissed && isDismissed(dismissed, item.dismissalKey, item.activityAt)) return; - collected.push(item); + const dismissal = activeDismissalState(dismissals, item.dismissalKey, item.activityAt, now); + if (!includeDismissed && dismissal?.isActive) return; + collected.push({ ...item, dismissal }); }; const pendingApprovals = await db @@ -316,6 +619,7 @@ export function attentionService(db: Db) { createdAt: toIso(approval.createdAt), updatedAt: toIso(approval.updatedAt), relatedIssue: null, + detail: approvalDetail(approval.type, approval.payload), })); } @@ -327,6 +631,7 @@ export function attentionService(db: Db) { status: issueThreadInteractions.status, title: issueThreadInteractions.title, summary: issueThreadInteractions.summary, + payload: issueThreadInteractions.payload, createdAt: issueThreadInteractions.createdAt, updatedAt: issueThreadInteractions.updatedAt, }) @@ -337,9 +642,20 @@ export function attentionService(db: Db) { )) .orderBy(desc(issueThreadInteractions.updatedAt), desc(issueThreadInteractions.id)); const interactionIssueMap = await issueSummaryMap(db, companyId, interactionRows.map((row) => row.issueId)); + const interactionImageMap = await issueImageMap(db, companyId, interactionRows.map((row) => row.issueId)); + const interactionPlanDocumentMap = await planDocumentMap(db, companyId, interactionRows.map((row) => row.issueId)); for (const interaction of interactionRows) { const issue = interactionIssueMap.get(interaction.issueId) ?? null; + const payload = readRecord(interaction.payload); + const detail = interactionDetail({ + kind: interaction.kind, + payload, + issue, + planDocument: interactionPlanDocumentMap.get(interaction.issueId) ?? null, + images: issueImages(interactionImageMap, interaction.issueId), + }); + const isPlanTarget = detail.kind === "plan_approval"; const dedupKey = `interaction:${interaction.id}`; add(createItem({ companyId, @@ -348,11 +664,16 @@ export function attentionService(db: Db) { kind: "interaction", id: interaction.id, companyId, - title: interaction.title ?? interaction.summary ?? interactionLabel(interaction.kind), + title: isPlanTarget && issue ? `Plan approval - ${issue.title}` : interaction.title ?? interaction.summary ?? interactionLabel(interaction.kind), identifier: null, status: interaction.status, href: issue ? `${issueHref(prefix, issue)}#interaction-${interaction.id}` : null, - metadata: { kind: interaction.kind, issueId: interaction.issueId }, + metadata: { + kind: interaction.kind, + issueId: interaction.issueId, + isPlanTarget, + targetDocumentKey: isPlanTarget ? "plan" : null, + }, }, whyNow: `${interactionLabel(interaction.kind)} on an issue thread.`, decisionVerbs: interactionVerbs(interaction.kind), @@ -365,6 +686,8 @@ export function attentionService(db: Db) { createdAt: toIso(interaction.createdAt), updatedAt: toIso(interaction.updatedAt), relatedIssue: issue ? issueSubject(prefix, issue) : null, + ...issueContext(issue), + detail, })); } @@ -425,6 +748,7 @@ export function attentionService(db: Db) { createdAt: toIso(join.createdAt), updatedAt: toIso(join.updatedAt), relatedIssue: null, + detail: genericDetail(label, []), })); } @@ -442,6 +766,7 @@ export function attentionService(db: Db) { companyId, recoveryRows.flatMap((row) => [row.sourceIssueId, row.recoveryIssueId]), ); + const recoveryImageMap = await issueImageMap(db, companyId, recoveryRows.map((row) => row.sourceIssueId)); for (const recovery of recoveryRows) { const sourceIssue = recoveryIssueMap.get(recovery.sourceIssueId) ?? null; @@ -484,6 +809,8 @@ export function attentionService(db: Db) { createdAt: toIso(recovery.createdAt), updatedAt: toIso(recovery.updatedAt), relatedIssue: sourceIssue ? issueSubject(prefix, sourceIssue) : null, + ...issueContext(sourceIssue), + detail: genericDetail(recovery.nextAction, issueImages(recoveryImageMap, recovery.sourceIssueId)), })); } @@ -512,9 +839,12 @@ export function attentionService(db: Db) { )) .orderBy(desc(issues.updatedAt), desc(issues.id)); const productivitySourceMap = await issueSummaryMap(db, companyId, productivityRows.map((row) => row.originId)); + const productivityReviewMap = await issueSummaryMap(db, companyId, productivityRows.map((row) => row.id)); + const productivityImageMap = await issueImageMap(db, companyId, productivityRows.map((row) => row.id)); for (const review of productivityRows) { - const reviewIssue: IssueSummaryRow = review; + const reviewIssue = productivityReviewMap.get(review.id); + if (!reviewIssue) continue; const sourceIssue = review.originId ? productivitySourceMap.get(review.originId) ?? null : null; const dedupKey = `productivity_review:${review.originFingerprint ?? review.originId ?? review.id}`; add(createItem({ @@ -536,19 +866,27 @@ export function attentionService(db: Db) { createdAt: toIso(review.createdAt), updatedAt: toIso(review.updatedAt), relatedIssue: sourceIssue ? issueSubject(prefix, sourceIssue) : null, + ...issueContext(reviewIssue), + detail: genericDetail(sourceIssue?.title ?? review.title, issueImages(productivityImageMap, review.id)), })); } const blockedIssues = await issueService(db).list(companyId, { status: "blocked", includeBlockedBy: true }); - for (const issue of blockedIssues as Array) { + const blockedIssueSummaries = await issueSummaryMap(db, companyId, blockedIssues.map((issue) => issue.id)); + const blockedImageMap = await issueImageMap(db, companyId, blockedIssues.map((issue) => issue.id)); + const blockingIssues = await blockingIssueMap(db, companyId, blockedIssues.map((issue) => issue.id)); + for (const issue of blockedIssues as Array) { const blockerAttention = issue.blockerAttention; if (blockerAttention?.state !== "stalled") continue; + const issueSummary = blockedIssueSummaries.get(issue.id) ?? null; + const summarizedIssue = issueSummary ?? issue; const sample = blockerAttention.sampleStalledBlockerIdentifier ?? blockerAttention.sampleBlockerIdentifier ?? issue.identifier ?? issue.id; + const blockingIssue = blockingIssues.get(issue.id) ?? { id: null, identifier: sample, title: null }; const dedupKey = `blocker:${issue.id}:${sample}`; add(createItem({ companyId, sourceKind: "blocker_attention", - subject: issueSubject(prefix, issue), + subject: issueSubject(prefix, summarizedIssue), whyNow: "Blocked dependency chain is stalled and needs a human to choose the next owner or action.", decisionVerbs: decisionVerbs( { id: "unblock", label: "Unblock", description: "Repair or replace the stalled blocker path." }, @@ -564,6 +902,8 @@ export function attentionService(db: Db) { createdAt: toIso(issue.createdAt), updatedAt: toIso(issue.updatedAt), relatedIssue: null, + ...issueContext(issueSummary), + detail: { kind: "blocker", blockingIssue, images: issueImages(blockedImageMap, issue.id) }, })); } @@ -598,6 +938,8 @@ export function attentionService(db: Db) { eq(approvals.status, "pending"), )); const pendingApprovalByIssueId = new Map(pendingReviewApprovalRows.map((row) => [row.issueId, row.approvalId])); + const reviewIssueMap = await issueSummaryMap(db, companyId, reviewIssueIds); + const reviewImageMap = await issueImageMap(db, companyId, reviewIssueIds); for (const review of reviewRows) { const state = parseIssueExecutionState(review.executionState); @@ -605,7 +947,8 @@ export function attentionService(db: Db) { const hasHumanParticipant = currentParticipant?.type === "user"; const pendingApprovalId = pendingApprovalByIssueId.get(review.id) ?? null; if (!hasHumanParticipant && !review.assigneeUserId && !pendingApprovalId) continue; - const issue: IssueSummaryRow = review; + const issue = reviewIssueMap.get(review.id); + if (!issue) continue; const dedupKey = `review:${review.id}`; add(createItem({ companyId, @@ -629,6 +972,8 @@ export function attentionService(db: Db) { createdAt: toIso(review.createdAt), updatedAt: toIso(review.updatedAt), relatedIssue: null, + ...issueContext(issue), + detail: genericDetail(review.title, issueImages(reviewImageMap, review.id)), })); } @@ -666,11 +1011,13 @@ export function attentionService(db: Db) { if (!latestExhaustedByRunId.has(row.id)) latestExhaustedByRunId.set(row.id, row); } const failedRows = [...latestExhaustedByRunId.values()]; + const failedIssueIds = failedRows.map((row) => readRunIssueId(row.contextSnapshot)); const failedIssueMap = await issueSummaryMap( db, companyId, - failedRows.map((row) => readRunIssueId(row.contextSnapshot)), + failedIssueIds, ); + const failedImageMap = await issueImageMap(db, companyId, failedIssueIds); for (const run of failedRows) { const issueId = readRunIssueId(run.contextSnapshot); const newer = await db @@ -722,6 +1069,13 @@ export function attentionService(db: Db) { createdAt: toIso(run.createdAt), updatedAt: toIso(run.updatedAt), relatedIssue: issue ? issueSubject(prefix, issue) : null, + ...issueContext(issue), + detail: { + kind: "failed_run", + agentName: run.agentName, + failureReasonExcerpt: excerpt(run.error ?? run.exhaustionMessage ?? run.errorCode), + images: issueImages(failedImageMap, issueId), + }, })); } @@ -769,6 +1123,13 @@ export function attentionService(db: Db) { createdAt: toIso(incident.createdAt), updatedAt: toIso(incident.updatedAt), relatedIssue: null, + detail: { + kind: "budget", + observedPercent, + amountObserved: incident.amountObserved, + amountLimit: incident.amountLimit, + images: [], + }, })); } @@ -816,6 +1177,12 @@ export function attentionService(db: Db) { createdAt: toIso(agent.createdAt), updatedAt: toIso(agent.updatedAt), relatedIssue: null, + detail: { + kind: "agent_error", + agentName: agent.name, + failureReasonExcerpt: excerpt(agent.errorReason), + images: [], + }, })); } diff --git a/server/src/services/inbox-dismissals.ts b/server/src/services/inbox-dismissals.ts index 68032c69dc1..7b940f95e76 100644 --- a/server/src/services/inbox-dismissals.ts +++ b/server/src/services/inbox-dismissals.ts @@ -1,8 +1,42 @@ import { and, desc, eq } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { inboxDismissals } from "@paperclipai/db"; +import type { InboxDismissalKind } from "@paperclipai/shared"; export function inboxDismissalService(db: Db) { + async function upsert( + companyId: string, + userId: string, + itemKey: string, + input: { kind: InboxDismissalKind; dismissedAt?: Date; snoozedUntil?: Date | null }, + ) { + const now = new Date(); + const dismissedAt = input.dismissedAt ?? now; + const snoozedUntil = input.kind === "snooze" ? input.snoozedUntil ?? null : null; + const [row] = await db + .insert(inboxDismissals) + .values({ + companyId, + userId, + itemKey, + kind: input.kind, + dismissedAt, + snoozedUntil, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: [inboxDismissals.companyId, inboxDismissals.userId, inboxDismissals.itemKey], + set: { + kind: input.kind, + dismissedAt, + snoozedUntil, + updatedAt: now, + }, + }) + .returning(); + return row; + } + return { list: async (companyId: string, userId: string) => db @@ -16,26 +50,26 @@ export function inboxDismissalService(db: Db) { userId: string, itemKey: string, dismissedAt: Date = new Date(), - ) => { - const now = new Date(); + ) => upsert(companyId, userId, itemKey, { kind: "dismiss", dismissedAt }), + + snooze: async ( + companyId: string, + userId: string, + itemKey: string, + snoozedUntil: Date, + dismissedAt: Date = new Date(), + ) => upsert(companyId, userId, itemKey, { kind: "snooze", dismissedAt, snoozedUntil }), + + restore: async (companyId: string, userId: string, itemKey: string) => { 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, - }, - }) + .delete(inboxDismissals) + .where(and( + eq(inboxDismissals.companyId, companyId), + eq(inboxDismissals.userId, userId), + eq(inboxDismissals.itemKey, itemKey), + )) .returning(); - return row; + return row ?? null; }, }; }