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 0000000000..00d65045eb
--- /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 fe570bd9dc..a0331a5af0 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 22996a476f..ff77b45043 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/constants.ts b/packages/shared/src/constants.ts
index 587d78d3cb..801019a47a 100644
--- a/packages/shared/src/constants.ts
+++ b/packages/shared/src/constants.ts
@@ -248,10 +248,12 @@ export const ISSUE_THREAD_INTERACTION_KINDS = [
"ask_user_questions",
"request_confirmation",
"request_checkbox_confirmation",
+ "request_item_verdicts",
] as const;
export type IssueThreadInteractionKind = (typeof ISSUE_THREAD_INTERACTION_KINDS)[number];
export const REQUEST_CHECKBOX_CONFIRMATION_OPTION_LIMIT = 200;
+export const REQUEST_ITEM_VERDICTS_ITEM_LIMIT = REQUEST_CHECKBOX_CONFIRMATION_OPTION_LIMIT;
export const ISSUE_THREAD_INTERACTION_STATUSES = [
"pending",
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index e78fccfcbf..9b90f1bb8a 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -49,6 +49,21 @@ export {
type ResponsibleUserDenialCopy,
type ResponsibleUserDenialTone,
} 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 {
PipelineAutomationRetryBlocker,
PipelineAutomationRetryCleanupOptions,
@@ -296,6 +311,7 @@ export {
type IssueThreadInteractionStatus,
type IssueThreadInteractionContinuationPolicy,
REQUEST_CHECKBOX_CONFIRMATION_OPTION_LIMIT,
+ REQUEST_ITEM_VERDICTS_ITEM_LIMIT,
type BuiltInIssueOriginKind,
type PluginIssueOriginKind,
type IssueOriginKind,
@@ -770,6 +786,11 @@ export type {
RequestCheckboxConfirmationOption,
RequestCheckboxConfirmationPayload,
RequestCheckboxConfirmationResult,
+ RequestItemVerdictValue,
+ RequestItemVerdictsItem,
+ RequestItemVerdictsPayload,
+ RequestItemVerdictsResultItem,
+ RequestItemVerdictsResult,
AcceptedPlanDecompositionStatus,
AcceptedPlanDecompositionChild,
AcceptedPlanDecomposition,
@@ -781,6 +802,7 @@ export type {
AskUserQuestionsInteraction,
RequestConfirmationInteraction,
RequestCheckboxConfirmationInteraction,
+ RequestItemVerdictsInteraction,
IssueThreadInteraction,
IssueThreadInteractionPayload,
IssueThreadInteractionResult,
@@ -857,6 +879,7 @@ export type {
SidebarBadges,
SidebarOrderPreference,
InboxDismissal,
+ InboxDismissalKind,
AccessUserProfile,
CompanyMemberRecord,
CompanyMembersResponse,
@@ -1284,11 +1307,17 @@ export {
requestCheckboxConfirmationOptionSchema,
requestCheckboxConfirmationPayloadSchema,
requestCheckboxConfirmationResultSchema,
+ requestItemVerdictValueSchema,
+ requestItemVerdictsItemSchema,
+ requestItemVerdictsPayloadSchema,
+ requestItemVerdictsResultItemSchema,
+ requestItemVerdictsResultSchema,
createIssueThreadInteractionSchema,
acceptIssueThreadInteractionSchema,
rejectIssueThreadInteractionSchema,
cancelIssueThreadInteractionSchema,
respondIssueThreadInteractionSchema,
+ submitIssueThreadInteractionVerdictsSchema,
linkIssueApprovalSchema,
createIssueAttachmentMetadataSchema,
createIssueWorkProductSchema,
@@ -1350,6 +1379,7 @@ export {
type RejectIssueThreadInteraction,
type CancelIssueThreadInteraction,
type RespondIssueThreadInteraction,
+ type SubmitIssueThreadInteractionVerdicts,
type LinkIssueApproval,
type CreateIssueAttachmentMetadata,
type CreateIssueWorkProduct,
diff --git a/packages/shared/src/issue-thread-interactions.test.ts b/packages/shared/src/issue-thread-interactions.test.ts
index fea5c7e592..5251dc0360 100644
--- a/packages/shared/src/issue-thread-interactions.test.ts
+++ b/packages/shared/src/issue-thread-interactions.test.ts
@@ -3,6 +3,7 @@ import {
acceptIssueThreadInteractionSchema,
askUserQuestionsResultSchema,
createIssueThreadInteractionSchema,
+ submitIssueThreadInteractionVerdictsSchema,
} from "./validators/issue.js";
describe("issue thread interaction schemas", () => {
@@ -306,4 +307,111 @@ describe("issue thread interaction schemas", () => {
selectedOptionIds: ["item-1", "item-1"],
})).toThrow("selectedOptionIds must be unique");
});
+
+ it("parses request_item_verdicts payloads with defaults", () => {
+ const parsed = createIssueThreadInteractionSchema.parse({
+ kind: "request_item_verdicts",
+ payload: {
+ version: 1,
+ prompt: "Review these generated items.",
+ items: [
+ { id: "api", label: "API route", description: "Server submit endpoint" },
+ { id: "docs", label: "Docs", previewMarkdown: "Document the route." },
+ ],
+ },
+ });
+
+ expect(parsed).toMatchObject({
+ kind: "request_item_verdicts",
+ continuationPolicy: "wake_assignee",
+ payload: {
+ verdicts: ["approve", "reject"],
+ requireReasonOn: ["reject"],
+ allowBulkApprove: true,
+ },
+ });
+ });
+
+ it("accepts request_item_verdicts defer when enabled explicitly", () => {
+ const parsed = createIssueThreadInteractionSchema.parse({
+ kind: "request_item_verdicts",
+ payload: {
+ version: 1,
+ prompt: "Review these generated items.",
+ items: [{ id: "api", label: "API route" }],
+ verdicts: ["approve", "reject", "defer"],
+ requireReasonOn: ["reject", "defer"],
+ },
+ });
+
+ expect(parsed).toMatchObject({
+ kind: "request_item_verdicts",
+ payload: {
+ verdicts: ["approve", "reject", "defer"],
+ requireReasonOn: ["reject", "defer"],
+ },
+ });
+ });
+
+ it("rejects invalid request_item_verdicts item and reason references", () => {
+ const base = {
+ kind: "request_item_verdicts",
+ payload: {
+ version: 1,
+ prompt: "Review these generated items.",
+ items: [
+ { id: "api", label: "API route" },
+ { id: "docs", label: "Docs" },
+ ],
+ },
+ } as const;
+
+ expect(() => createIssueThreadInteractionSchema.parse({
+ ...base,
+ payload: {
+ ...base.payload,
+ items: [],
+ },
+ })).toThrow();
+
+ expect(() => createIssueThreadInteractionSchema.parse({
+ ...base,
+ payload: {
+ ...base.payload,
+ items: [
+ { id: "api", label: "API route" },
+ { id: "api", label: "Duplicate" },
+ ],
+ },
+ })).toThrow("Item ids must be unique within one item verdict request");
+
+ expect(() => createIssueThreadInteractionSchema.parse({
+ ...base,
+ payload: {
+ ...base.payload,
+ items: Array.from({ length: 201 }, (_value, index) => ({
+ id: `item-${index}`,
+ label: `Item ${index}`,
+ })),
+ },
+ })).toThrow();
+
+ expect(() => createIssueThreadInteractionSchema.parse({
+ ...base,
+ payload: {
+ ...base.payload,
+ verdicts: ["approve", "reject"],
+ requireReasonOn: ["defer"],
+ },
+ })).toThrow("requireReasonOn must reference enabled verdicts");
+ });
+
+ it("rejects duplicate request_item_verdicts submit ids", () => {
+ expect(() => submitIssueThreadInteractionVerdictsSchema.parse({
+ verdicts: [
+ { id: "api", verdict: "approve" },
+ { id: "api", verdict: "reject", reason: "Needs revision" },
+ ],
+ })).toThrow("verdict item ids must be unique");
+ });
});
diff --git a/packages/shared/src/types/attention.ts b/packages/shared/src/types/attention.ts
new file mode 100644
index 0000000000..6e01956439
--- /dev/null
+++ b/packages/shared/src/types/attention.ts
@@ -0,0 +1,177 @@
+import type { InboxDismissalKind } from "./inbox-dismissal.js";
+
+export type AttentionSourceKind =
+ | "approval"
+ | "issue_thread_interaction"
+ | "join_request"
+ | "recovery_action"
+ | "productivity_review"
+ | "blocker_attention"
+ | "review"
+ | "failed_run"
+ | "budget_alert"
+ | "agent_error_alert";
+
+export type AttentionSubjectKind =
+ | "approval"
+ | "issue"
+ | "interaction"
+ | "join_request"
+ | "recovery_action"
+ | "run"
+ | "budget_incident"
+ | "agent";
+
+export type AttentionSeverity = "critical" | "high" | "medium" | "low";
+
+export interface AttentionSubject {
+ kind: AttentionSubjectKind;
+ id: string;
+ companyId: string;
+ title: string | null;
+ identifier: string | null;
+ status: string | null;
+ href: string | null;
+ metadata?: Record;
+}
+
+export interface AttentionDecisionVerb {
+ id: string;
+ label: string;
+ description: string | null;
+}
+
+export interface AttentionProjectRef {
+ id: string;
+ name: string;
+ urlKey: string;
+ color: string | null;
+ icon: string | null;
+}
+
+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;
+ sourceKind: AttentionSourceKind;
+ subject: AttentionSubject;
+ whyNow: string;
+ decisionVerbs: AttentionDecisionVerb[];
+ inlineResolvable: boolean;
+ entryRule: string;
+ 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 {
+ companyId: string;
+ generatedAt: string;
+ totalCount: number;
+ countsBySourceKind: Record;
+ items: AttentionItem[];
+}
diff --git a/packages/shared/src/types/inbox-dismissal.ts b/packages/shared/src/types/inbox-dismissal.ts
index 0c76ecc824..5d2c71ce44 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 c336dd262a..e117d04b5b 100644
--- a/packages/shared/src/types/index.ts
+++ b/packages/shared/src/types/index.ts
@@ -1,4 +1,18 @@
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,
EnvironmentDeleteBlastRadius,
@@ -400,6 +414,11 @@ export type {
RequestCheckboxConfirmationOption,
RequestCheckboxConfirmationPayload,
RequestCheckboxConfirmationResult,
+ RequestItemVerdictValue,
+ RequestItemVerdictsItem,
+ RequestItemVerdictsPayload,
+ RequestItemVerdictsResultItem,
+ RequestItemVerdictsResult,
AcceptedPlanDecompositionStatus,
AcceptedPlanDecompositionChild,
AcceptedPlanDecomposition,
@@ -411,6 +430,7 @@ export type {
AskUserQuestionsInteraction,
RequestConfirmationInteraction,
RequestCheckboxConfirmationInteraction,
+ RequestItemVerdictsInteraction,
IssueThreadInteraction,
IssueThreadInteractionPayload,
IssueThreadInteractionResult,
@@ -558,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/packages/shared/src/types/instance.ts b/packages/shared/src/types/instance.ts
index 3f426ce68f..655626cfd9 100644
--- a/packages/shared/src/types/instance.ts
+++ b/packages/shared/src/types/instance.ts
@@ -57,6 +57,7 @@ export interface InstanceExperimentalSettings {
enableCloudSync: boolean;
enableExternalObjects: boolean;
enableBuiltInAgents: boolean;
+ enableDecisions: boolean;
enableGoalsSidebarLink: boolean;
enableServerInfoDebugView: boolean;
autoRestartDevServerWhenIdle: boolean;
diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts
index f651e482df..d9c3324a47 100644
--- a/packages/shared/src/types/issue.ts
+++ b/packages/shared/src/types/issue.ts
@@ -1082,6 +1082,30 @@ export interface RequestCheckboxConfirmationPayload {
target?: RequestConfirmationTarget | null;
}
+export type RequestItemVerdictValue = "approve" | "reject" | "defer";
+
+export interface RequestItemVerdictsItem {
+ id: string;
+ label: string;
+ description?: string | null;
+ previewMarkdown?: string | null;
+ href?: string | null;
+ attachmentId?: string | null;
+}
+
+export interface RequestItemVerdictsPayload {
+ version: 1;
+ prompt: string;
+ detailsMarkdown?: string | null;
+ items: RequestItemVerdictsItem[];
+ verdicts?: RequestItemVerdictValue[];
+ requireReasonOn?: RequestItemVerdictValue[];
+ reasonLabel?: string | null;
+ allowBulkApprove?: boolean;
+ supersedeOnUserComment?: boolean;
+ target?: RequestConfirmationTarget | null;
+}
+
export interface RequestConfirmationResult {
version: 1;
outcome: "accepted" | "rejected" | "superseded_by_comment" | "stale_target";
@@ -1104,6 +1128,24 @@ export interface RequestCheckboxConfirmationResult extends RequestConfirmationRe
selectedOptionIds?: string[];
}
+export interface RequestItemVerdictsResultItem {
+ id: string;
+ verdict: RequestItemVerdictValue;
+ reason?: string | null;
+ resolvedByUserId: string;
+ resolvedAt: Date | string;
+ commentId?: string | null;
+}
+
+export interface RequestItemVerdictsResult {
+ version: 1;
+ outcome: "resolved" | "superseded_by_comment" | "stale_target" | "cancelled";
+ complete: boolean;
+ items: RequestItemVerdictsResultItem[];
+ commentId?: string | null;
+ staleTarget?: RequestConfirmationTarget | null;
+}
+
export interface IssueThreadInteractionBase extends IssueThreadInteractionActorFields {
id: string;
companyId: string;
@@ -1145,23 +1187,32 @@ export interface RequestCheckboxConfirmationInteraction extends IssueThreadInter
result?: RequestCheckboxConfirmationResult | null;
}
+export interface RequestItemVerdictsInteraction extends IssueThreadInteractionBase {
+ kind: "request_item_verdicts";
+ payload: RequestItemVerdictsPayload;
+ result?: RequestItemVerdictsResult | null;
+}
+
export type IssueThreadInteraction =
| SuggestTasksInteraction
| AskUserQuestionsInteraction
| RequestConfirmationInteraction
- | RequestCheckboxConfirmationInteraction;
+ | RequestCheckboxConfirmationInteraction
+ | RequestItemVerdictsInteraction;
export type IssueThreadInteractionPayload =
| SuggestTasksPayload
| AskUserQuestionsPayload
| RequestConfirmationPayload
- | RequestCheckboxConfirmationPayload;
+ | RequestCheckboxConfirmationPayload
+ | RequestItemVerdictsPayload;
export type IssueThreadInteractionResult =
| SuggestTasksResult
| AskUserQuestionsResult
| RequestConfirmationResult
- | RequestCheckboxConfirmationResult;
+ | RequestCheckboxConfirmationResult
+ | RequestItemVerdictsResult;
export interface IssueAttachment {
id: string;
diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts
index 8b14b7e62c..29a12a77db 100644
--- a/packages/shared/src/validators/index.ts
+++ b/packages/shared/src/validators/index.ts
@@ -364,11 +364,17 @@ export {
requestCheckboxConfirmationOptionSchema,
requestCheckboxConfirmationPayloadSchema,
requestCheckboxConfirmationResultSchema,
+ requestItemVerdictValueSchema,
+ requestItemVerdictsItemSchema,
+ requestItemVerdictsPayloadSchema,
+ requestItemVerdictsResultItemSchema,
+ requestItemVerdictsResultSchema,
createIssueThreadInteractionSchema,
acceptIssueThreadInteractionSchema,
rejectIssueThreadInteractionSchema,
cancelIssueThreadInteractionSchema,
respondIssueThreadInteractionSchema,
+ submitIssueThreadInteractionVerdictsSchema,
linkIssueApprovalSchema,
createIssueAttachmentMetadataSchema,
issueDocumentFormatSchema,
@@ -391,6 +397,7 @@ export {
type RejectIssueThreadInteraction,
type CancelIssueThreadInteraction,
type RespondIssueThreadInteraction,
+ type SubmitIssueThreadInteractionVerdicts,
type LinkIssueApproval,
type CreateIssueAttachmentMetadata,
type IssueDocumentFormat,
diff --git a/packages/shared/src/validators/instance.test.ts b/packages/shared/src/validators/instance.test.ts
index 9324f96248..6f41db4834 100644
--- a/packages/shared/src/validators/instance.test.ts
+++ b/packages/shared/src/validators/instance.test.ts
@@ -60,6 +60,22 @@ describe("instance experimental settings validators", () => {
});
});
+ it("defaults the decisions sidebar link off", () => {
+ const settings = instanceExperimentalSettingsSchema.parse({});
+
+ expect(settings.enableDecisions).toBe(false);
+ });
+
+ it("accepts decisions patches", () => {
+ expect(
+ patchInstanceExperimentalSettingsSchema.parse({
+ enableDecisions: true,
+ }),
+ ).toEqual({
+ enableDecisions: true,
+ });
+ });
+
it("accepts server info debug view patches", () => {
expect(
patchInstanceExperimentalSettingsSchema.parse({
diff --git a/packages/shared/src/validators/instance.ts b/packages/shared/src/validators/instance.ts
index e4a074b89b..aef68079d3 100644
--- a/packages/shared/src/validators/instance.ts
+++ b/packages/shared/src/validators/instance.ts
@@ -51,6 +51,7 @@ export const instanceExperimentalSettingsSchema = z.object({
enableCloudSync: z.boolean().default(false),
enableExternalObjects: z.boolean().default(false),
enableBuiltInAgents: z.boolean().default(false),
+ enableDecisions: z.boolean().default(false),
enableGoalsSidebarLink: z.boolean().default(false),
enableServerInfoDebugView: z.boolean().default(false),
autoRestartDevServerWhenIdle: z.boolean().default(false),
diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts
index a89116a38b..0726a0cf75 100644
--- a/packages/shared/src/validators/issue.ts
+++ b/packages/shared/src/validators/issue.ts
@@ -28,6 +28,7 @@ import {
ISSUE_WATCHDOG_DISCOVERY_KINDS,
MODEL_PROFILE_KEYS,
REQUEST_CHECKBOX_CONFIRMATION_OPTION_LIMIT,
+ REQUEST_ITEM_VERDICTS_ITEM_LIMIT,
} from "../constants.js";
import { multilineTextSchema } from "./text.js";
import { lowTrustReviewPresetPolicySchema, trustAuthorizationPolicySchema } from "./trust-policy.js";
@@ -894,6 +895,121 @@ export const requestCheckboxConfirmationResultSchema = requestConfirmationResult
}
});
+export const requestItemVerdictValueSchema = z.enum(["approve", "reject", "defer"]);
+
+export const requestItemVerdictsItemSchema = z.object({
+ id: z.string().trim().min(1).max(120),
+ label: z.string().trim().min(1).max(120),
+ description: z.string().trim().max(500).nullable().optional(),
+ previewMarkdown: z.string().max(20000).nullable().optional(),
+ href: requestConfirmationHrefSchema.nullable().optional(),
+ attachmentId: z.string().uuid().nullable().optional(),
+});
+
+export const requestItemVerdictsPayloadSchema = z.object({
+ version: z.literal(1),
+ prompt: z.string().trim().min(1).max(1000),
+ detailsMarkdown: z.string().max(20000).nullable().optional(),
+ items: z.array(requestItemVerdictsItemSchema)
+ .min(1)
+ .max(REQUEST_ITEM_VERDICTS_ITEM_LIMIT),
+ verdicts: z.array(requestItemVerdictValueSchema)
+ .min(2)
+ .max(3)
+ .optional()
+ .default(["approve", "reject"]),
+ requireReasonOn: z.array(requestItemVerdictValueSchema)
+ .max(3)
+ .optional()
+ .default(["reject"]),
+ reasonLabel: z.string().trim().min(1).max(160).nullable().optional(),
+ allowBulkApprove: z.boolean().optional().default(true),
+ supersedeOnUserComment: z.boolean().optional(),
+ target: requestConfirmationTargetSchema.nullable().optional(),
+}).superRefine((value, ctx) => {
+ const itemIds = new Set();
+ for (const [index, item] of value.items.entries()) {
+ if (itemIds.has(item.id)) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "Item ids must be unique within one item verdict request",
+ path: ["items", index, "id"],
+ });
+ }
+ itemIds.add(item.id);
+ }
+
+ const verdicts = new Set();
+ for (const [index, verdict] of value.verdicts.entries()) {
+ if (verdicts.has(verdict)) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "verdicts must be unique",
+ path: ["verdicts", index],
+ });
+ }
+ verdicts.add(verdict);
+ }
+ if (!verdicts.has("approve") || !verdicts.has("reject")) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "verdicts must include approve and reject; defer is optional",
+ path: ["verdicts"],
+ });
+ }
+
+ const reasonVerdicts = new Set();
+ for (const [index, verdict] of value.requireReasonOn.entries()) {
+ if (reasonVerdicts.has(verdict)) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "requireReasonOn must be unique",
+ path: ["requireReasonOn", index],
+ });
+ continue;
+ }
+ reasonVerdicts.add(verdict);
+ if (!verdicts.has(verdict)) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "requireReasonOn must reference enabled verdicts",
+ path: ["requireReasonOn", index],
+ });
+ }
+ }
+});
+
+export const requestItemVerdictsResultItemSchema = z.object({
+ id: z.string().trim().min(1).max(120),
+ verdict: requestItemVerdictValueSchema,
+ reason: z.string().trim().max(4000).nullable().optional(),
+ resolvedByUserId: z.string().trim().min(1).max(255),
+ resolvedAt: z.union([z.string().datetime(), z.date()]),
+ commentId: z.string().uuid().nullable().optional(),
+});
+
+export const requestItemVerdictsResultSchema = z.object({
+ version: z.literal(1),
+ outcome: z.enum(["resolved", "superseded_by_comment", "stale_target", "cancelled"]),
+ complete: z.boolean(),
+ items: z.array(requestItemVerdictsResultItemSchema)
+ .max(REQUEST_ITEM_VERDICTS_ITEM_LIMIT),
+ commentId: z.string().uuid().nullable().optional(),
+ staleTarget: requestConfirmationTargetSchema.nullable().optional(),
+}).superRefine((value, ctx) => {
+ const itemIds = new Set();
+ for (const [index, item] of value.items.entries()) {
+ if (itemIds.has(item.id)) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "result item ids must be unique",
+ path: ["items", index, "id"],
+ });
+ }
+ itemIds.add(item.id);
+ }
+});
+
export const createIssueThreadInteractionSchema = z.discriminatedUnion("kind", [
z.object({
kind: z.literal("suggest_tasks"),
@@ -935,6 +1051,16 @@ export const createIssueThreadInteractionSchema = z.discriminatedUnion("kind", [
continuationPolicy: issueThreadInteractionContinuationPolicySchema.optional().default("wake_assignee"),
payload: requestCheckboxConfirmationPayloadSchema,
}),
+ z.object({
+ kind: z.literal("request_item_verdicts"),
+ idempotencyKey: z.string().trim().max(255).nullable().optional(),
+ sourceCommentId: z.string().uuid().nullable().optional(),
+ sourceRunId: z.string().uuid().nullable().optional(),
+ title: z.string().trim().max(240).nullable().optional(),
+ summary: z.string().trim().max(1000).nullable().optional(),
+ continuationPolicy: issueThreadInteractionContinuationPolicySchema.optional().default("wake_assignee"),
+ payload: requestItemVerdictsPayloadSchema,
+ }),
]);
export type CreateIssueThreadInteraction = z.infer;
@@ -989,6 +1115,29 @@ export const respondIssueThreadInteractionSchema = z.object({
});
export type RespondIssueThreadInteraction = z.infer;
+export const submitIssueThreadInteractionVerdictsSchema = z.object({
+ verdicts: z.array(z.object({
+ id: z.string().trim().min(1).max(120),
+ verdict: requestItemVerdictValueSchema,
+ reason: z.string().trim().max(4000).nullable().optional(),
+ }))
+ .min(1)
+ .max(REQUEST_ITEM_VERDICTS_ITEM_LIMIT),
+}).superRefine((value, ctx) => {
+ const itemIds = new Set();
+ for (const [index, verdict] of value.verdicts.entries()) {
+ if (itemIds.has(verdict.id)) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "verdict item ids must be unique",
+ path: ["verdicts", index, "id"],
+ });
+ }
+ itemIds.add(verdict.id);
+ }
+});
+export type SubmitIssueThreadInteractionVerdicts = z.infer;
+
export const linkIssueApprovalSchema = z.object({
approvalId: z.string().uuid(),
});
diff --git a/scripts/screenshot-verdicts.mjs b/scripts/screenshot-verdicts.mjs
new file mode 100644
index 0000000000..85c295b1cc
--- /dev/null
+++ b/scripts/screenshot-verdicts.mjs
@@ -0,0 +1,53 @@
+#!/usr/bin/env node
+// Screenshot the C3 per-item verdict card stories (PAP-13249) at both
+// viewports and both themes against a served storybook-static (see PAP-13249).
+import fs from "node:fs/promises";
+import path from "node:path";
+import { chromium } from "@playwright/test";
+
+const BASE = "http://localhost:6102/iframe.html";
+const PREFIX = "chat-comments-issue-thread-interactions";
+const OUT_DIR = process.argv[2] || "screenshots/pap-13249";
+
+const stories = [
+ { id: "item-verdicts-pending", label: "s1-s2-pending" },
+ { id: "item-verdicts-partial", label: "s4-partial" },
+ { id: "item-verdicts-complete", label: "s5-complete" },
+ { id: "item-verdicts-superseded", label: "s6-superseded" },
+ { id: "item-verdicts-many-items", label: "s7-many" },
+];
+const viewports = [
+ { name: "desktop", width: 1440, height: 900 },
+ { name: "mobile", width: 390, height: 844 },
+];
+const themes = ["light", "dark"];
+
+await fs.mkdir(path.resolve(OUT_DIR), { recursive: true });
+const executablePath = process.env.CHROME_BIN || undefined;
+const browser = await chromium.launch({
+ headless: true,
+ executablePath,
+ args: ["--no-sandbox", "--headless=new"],
+});
+try {
+ for (const vp of viewports) {
+ for (const theme of themes) {
+ const ctx = await browser.newContext({
+ viewport: { width: vp.width, height: vp.height },
+ deviceScaleFactor: 2,
+ });
+ const page = await ctx.newPage();
+ for (const story of stories) {
+ const url = `${BASE}?id=${PREFIX}--${story.id}&viewMode=story&globals=theme:${theme}`;
+ await page.goto(url, { waitUntil: "networkidle" });
+ await page.waitForTimeout(800);
+ const out = path.join(OUT_DIR, `${story.label}_${vp.name}_${theme}.png`);
+ await page.screenshot({ path: out, fullPage: true });
+ console.log(`Wrote ${out}`);
+ }
+ await ctx.close();
+ }
+ }
+} finally {
+ await browser.close();
+}
diff --git a/server/src/__tests__/attention-service.test.ts b/server/src/__tests__/attention-service.test.ts
new file mode 100644
index 0000000000..694929f745
--- /dev/null
+++ b/server/src/__tests__/attention-service.test.ts
@@ -0,0 +1,961 @@
+import { randomUUID } from "node:crypto";
+import express from "express";
+import request from "supertest";
+import { eq } from "drizzle-orm";
+import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
+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,
+ startEmbeddedPostgresTestDatabase,
+} from "./helpers/embedded-postgres.js";
+import { errorHandler } from "../middleware/index.js";
+import { attentionRoutes } from "../routes/attention.js";
+import { attentionService } from "../services/attention.js";
+
+const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
+const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
+
+if (!embeddedPostgresSupport.supported) {
+ console.warn(
+ `Skipping embedded Postgres attention service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
+ );
+}
+
+describeEmbeddedPostgres("attention service", () => {
+ let db!: ReturnType;
+ let tempDb: Awaited> | null = null;
+
+ beforeAll(async () => {
+ tempDb = await startEmbeddedPostgresTestDatabase("paperclip-attention-service-");
+ db = createDb(tempDb.connectionString);
+ }, 30_000);
+
+ afterEach(async () => {
+ 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);
+ await db.delete(budgetPolicies);
+ await db.delete(joinRequests);
+ await db.delete(invites);
+ await db.delete(issueRecoveryActions);
+ await db.delete(issueRelations);
+ 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);
+ });
+
+ afterAll(async () => {
+ await tempDb?.cleanup();
+ });
+
+ async function seedCompany(prefix = "ATN") {
+ const companyId = randomUUID();
+ const workerId = randomUUID();
+ const reviewerId = randomUUID();
+ const errorAgentId = randomUUID();
+
+ await db.insert(companies).values({
+ id: companyId,
+ name: `${prefix} Co`,
+ issuePrefix: prefix,
+ requireBoardApprovalForNewAgents: false,
+ });
+ await db.insert(agents).values([
+ {
+ id: workerId,
+ companyId,
+ name: "Worker",
+ role: "engineer",
+ status: "idle",
+ adapterType: "codex_local",
+ adapterConfig: {},
+ runtimeConfig: {},
+ permissions: {},
+ },
+ {
+ id: reviewerId,
+ companyId,
+ name: "Reviewer",
+ role: "qa",
+ status: "idle",
+ adapterType: "codex_local",
+ adapterConfig: {},
+ runtimeConfig: {},
+ permissions: {},
+ },
+ {
+ id: errorAgentId,
+ companyId,
+ name: "Broken Agent",
+ role: "engineer",
+ status: "error",
+ errorReason: "adapter config missing",
+ adapterType: "codex_local",
+ adapterConfig: {},
+ runtimeConfig: {},
+ permissions: {},
+ },
+ ]);
+
+ return { companyId, workerId, reviewerId, errorAgentId, prefix };
+ }
+
+ async function insertIssue(input: {
+ companyId: string;
+ id?: string;
+ identifier: string;
+ title: string;
+ status: string;
+ priority?: string;
+ parentId?: string | null;
+ assigneeAgentId?: string | null;
+ assigneeUserId?: string | null;
+ originKind?: string;
+ originId?: string | null;
+ originFingerprint?: string;
+ projectId?: string | null;
+ projectWorkspaceId?: string | null;
+ executionState?: Record | null;
+ updatedAt?: Date;
+ createdAt?: Date;
+ }) {
+ const id = input.id ?? randomUUID();
+ await db.insert(issues).values({
+ id,
+ companyId: input.companyId,
+ identifier: input.identifier,
+ title: input.title,
+ 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",
+ originId: input.originId ?? null,
+ originFingerprint: input.originFingerprint ?? "default",
+ executionState: input.executionState ?? null,
+ createdAt: input.createdAt,
+ updatedAt: input.updatedAt,
+ });
+ return id;
+ }
+
+ function pendingUserExecutionState(userId = "board-user") {
+ return {
+ status: "pending",
+ currentStageId: null,
+ currentStageIndex: null,
+ currentStageType: "review",
+ currentParticipant: { type: "user", userId },
+ returnAssignee: null,
+ reviewRequest: null,
+ completedStageIds: [],
+ lastDecisionId: null,
+ lastDecisionOutcome: null,
+ monitor: null,
+ };
+ }
+
+ function pendingAgentExecutionState(agentId: string) {
+ return {
+ ...pendingUserExecutionState(),
+ currentParticipant: { type: "agent", agentId },
+ };
+ }
+
+ it("returns ranked decision-only items for every active source and excludes non-human or transient rows", async () => {
+ const { companyId, workerId, reviewerId } = await seedCompany("ATN");
+ const baseTime = new Date("2026-07-09T12:00:00.000Z");
+ const interactionIssueId = await insertIssue({
+ companyId,
+ identifier: "ATN-1",
+ title: "Needs interaction",
+ status: "in_progress",
+ assigneeAgentId: workerId,
+ updatedAt: baseTime,
+ });
+ const recoverySourceIssueId = await insertIssue({
+ companyId,
+ identifier: "ATN-2",
+ title: "Needs recovery",
+ status: "in_progress",
+ assigneeAgentId: workerId,
+ updatedAt: baseTime,
+ });
+ const agentRecoverySourceIssueId = await insertIssue({
+ companyId,
+ identifier: "ATN-21",
+ title: "Agent-owned recovery source",
+ status: "in_progress",
+ assigneeAgentId: workerId,
+ updatedAt: baseTime,
+ });
+ const productivitySourceIssueId = await insertIssue({
+ companyId,
+ identifier: "ATN-3",
+ title: "Needs productivity review source",
+ status: "in_progress",
+ assigneeAgentId: workerId,
+ updatedAt: baseTime,
+ });
+ const agentProductivitySourceIssueId = await insertIssue({
+ companyId,
+ identifier: "ATN-31",
+ title: "Agent productivity review source",
+ status: "in_progress",
+ assigneeAgentId: workerId,
+ updatedAt: baseTime,
+ });
+ const blockerParentId = await insertIssue({
+ companyId,
+ identifier: "ATN-4",
+ title: "Blocked parent",
+ status: "blocked",
+ updatedAt: new Date("2026-07-09T12:04:00.000Z"),
+ });
+ const blockerLeafId = await insertIssue({
+ companyId,
+ identifier: "ATN-5",
+ title: "Stalled review blocker",
+ status: "in_review",
+ assigneeAgentId: reviewerId,
+ updatedAt: new Date("2026-07-09T12:05:00.000Z"),
+ });
+ await db.insert(issueRelations).values({
+ companyId,
+ issueId: blockerLeafId,
+ relatedIssueId: blockerParentId,
+ type: "blocks",
+ });
+ const reviewUserIssueId = await insertIssue({
+ companyId,
+ identifier: "ATN-6",
+ title: "Human review",
+ status: "in_review",
+ executionState: pendingUserExecutionState(),
+ updatedAt: new Date("2026-07-09T12:06:00.000Z"),
+ });
+ await insertIssue({
+ companyId,
+ identifier: "ATN-7",
+ title: "Agent review excluded",
+ status: "in_review",
+ executionState: pendingAgentExecutionState(reviewerId),
+ updatedAt: new Date("2026-07-09T12:07:00.000Z"),
+ });
+
+ const pendingApprovalId = randomUUID();
+ await db.insert(approvals).values([
+ {
+ id: pendingApprovalId,
+ companyId,
+ type: "hire_agent",
+ status: "pending",
+ payload: { title: "Hire Designer" },
+ createdAt: new Date("2026-07-09T12:01:00.000Z"),
+ updatedAt: new Date("2026-07-09T12:01:00.000Z"),
+ },
+ {
+ id: randomUUID(),
+ companyId,
+ type: "hire_agent",
+ status: "revision_requested",
+ payload: { title: "Revision requested" },
+ createdAt: new Date("2026-07-09T12:02:00.000Z"),
+ updatedAt: new Date("2026-07-09T12:02:00.000Z"),
+ },
+ ]);
+
+ await db.insert(issueThreadInteractions).values([
+ {
+ id: randomUUID(),
+ companyId,
+ issueId: interactionIssueId,
+ kind: "ask_user_questions",
+ status: "pending",
+ continuationPolicy: "wake_assignee",
+ title: "Pick a launch date",
+ payload: { version: 1, questions: [] },
+ createdAt: new Date("2026-07-09T12:03:00.000Z"),
+ updatedAt: new Date("2026-07-09T12:03:00.000Z"),
+ },
+ {
+ id: randomUUID(),
+ companyId,
+ issueId: interactionIssueId,
+ kind: "request_confirmation",
+ status: "accepted",
+ continuationPolicy: "wake_assignee",
+ title: "Already accepted",
+ payload: { version: 1, prompt: "Already done" },
+ createdAt: new Date("2026-07-09T12:03:30.000Z"),
+ updatedAt: new Date("2026-07-09T12:03:30.000Z"),
+ },
+ ]);
+
+ const inviteId = randomUUID();
+ await db.insert(invites).values({
+ id: inviteId,
+ companyId,
+ tokenHash: `hash-${inviteId}`,
+ allowedJoinTypes: "both",
+ expiresAt: new Date("2026-07-10T00:00:00.000Z"),
+ });
+ await db.insert(joinRequests).values({
+ id: randomUUID(),
+ inviteId,
+ companyId,
+ requestType: "human",
+ status: "pending_approval",
+ requestIp: "127.0.0.1",
+ requestEmailSnapshot: "new@paperclip.test",
+ createdAt: new Date("2026-07-09T12:04:00.000Z"),
+ updatedAt: new Date("2026-07-09T12:04:00.000Z"),
+ });
+
+ await db.insert(issueRecoveryActions).values([
+ {
+ id: randomUUID(),
+ companyId,
+ sourceIssueId: recoverySourceIssueId,
+ kind: "missing_disposition",
+ status: "escalated",
+ ownerType: "board",
+ ownerAgentId: null,
+ ownerUserId: null,
+ cause: "missing_disposition",
+ fingerprint: "human-recovery",
+ evidence: {},
+ nextAction: "Choose the final disposition.",
+ createdAt: new Date("2026-07-09T12:05:00.000Z"),
+ updatedAt: new Date("2026-07-09T12:05:00.000Z"),
+ },
+ {
+ id: randomUUID(),
+ companyId,
+ sourceIssueId: agentRecoverySourceIssueId,
+ kind: "stranded_assigned_issue",
+ status: "active",
+ ownerType: "agent",
+ ownerAgentId: workerId,
+ ownerUserId: null,
+ cause: "stranded",
+ fingerprint: "agent-recovery",
+ evidence: {},
+ nextAction: "Agent should self-heal.",
+ createdAt: new Date("2026-07-09T12:05:30.000Z"),
+ updatedAt: new Date("2026-07-09T12:05:30.000Z"),
+ },
+ ]);
+
+ await insertIssue({
+ companyId,
+ identifier: "ATN-8",
+ title: "Human productivity review",
+ status: "todo",
+ priority: "high",
+ parentId: productivitySourceIssueId,
+ assigneeUserId: "board-user",
+ originKind: "issue_productivity_review",
+ originId: productivitySourceIssueId,
+ originFingerprint: `productivity-review:${productivitySourceIssueId}`,
+ updatedAt: new Date("2026-07-09T12:08:00.000Z"),
+ });
+ await insertIssue({
+ companyId,
+ identifier: "ATN-9",
+ title: "Agent productivity review excluded",
+ status: "todo",
+ priority: "high",
+ parentId: agentProductivitySourceIssueId,
+ assigneeAgentId: workerId,
+ originKind: "issue_productivity_review",
+ originId: agentProductivitySourceIssueId,
+ originFingerprint: `productivity-review-agent:${agentProductivitySourceIssueId}`,
+ updatedAt: new Date("2026-07-09T12:08:30.000Z"),
+ });
+
+ const exhaustedRunId = randomUUID();
+ const transientRunId = randomUUID();
+ await db.insert(heartbeatRuns).values([
+ {
+ id: exhaustedRunId,
+ companyId,
+ agentId: workerId,
+ invocationSource: "automation",
+ status: "failed",
+ error: "adapter failed",
+ errorCode: "adapter_failed",
+ contextSnapshot: { issueId: reviewUserIssueId },
+ scheduledRetryAttempt: 4,
+ scheduledRetryReason: "transient_failure",
+ createdAt: new Date("2026-07-09T12:09:00.000Z"),
+ updatedAt: new Date("2026-07-09T12:09:00.000Z"),
+ finishedAt: new Date("2026-07-09T12:09:00.000Z"),
+ },
+ {
+ id: transientRunId,
+ companyId,
+ agentId: reviewerId,
+ invocationSource: "automation",
+ status: "failed",
+ error: "will retry",
+ errorCode: "provider_quota",
+ contextSnapshot: { issueId: interactionIssueId },
+ createdAt: new Date("2026-07-09T12:09:30.000Z"),
+ updatedAt: new Date("2026-07-09T12:09:30.000Z"),
+ finishedAt: new Date("2026-07-09T12:09:30.000Z"),
+ },
+ ]);
+ await db.insert(heartbeatRunEvents).values({
+ companyId,
+ runId: exhaustedRunId,
+ agentId: workerId,
+ seq: 1,
+ eventType: "lifecycle",
+ message: "Bounded retry exhausted after 4 scheduled attempts; no further automatic retry will be queued",
+ payload: { retryReason: "transient_failure", maxAttempts: 4 },
+ createdAt: new Date("2026-07-09T12:09:01.000Z"),
+ });
+
+ const softPolicy85Id = randomUUID();
+ const softPolicy84Id = randomUUID();
+ const hardPolicyId = randomUUID();
+ await db.insert(budgetPolicies).values([
+ {
+ id: softPolicy85Id,
+ companyId,
+ scopeType: "company",
+ scopeId: companyId,
+ metric: "billed_cents",
+ windowKind: "calendar_month_utc",
+ amount: 100,
+ },
+ {
+ id: softPolicy84Id,
+ companyId,
+ scopeType: "company",
+ scopeId: companyId,
+ metric: "billed_cents",
+ windowKind: "lifetime",
+ amount: 100,
+ },
+ {
+ id: hardPolicyId,
+ companyId,
+ scopeType: "agent",
+ scopeId: workerId,
+ metric: "billed_cents",
+ windowKind: "calendar_month_utc",
+ amount: 100,
+ },
+ ]);
+ await db.insert(budgetIncidents).values([
+ {
+ companyId,
+ policyId: softPolicy85Id,
+ scopeType: "company",
+ scopeId: companyId,
+ metric: "billed_cents",
+ windowKind: "calendar_month_utc",
+ windowStart: new Date("2026-07-01T00:00:00.000Z"),
+ windowEnd: new Date("2026-08-01T00:00:00.000Z"),
+ thresholdType: "soft",
+ amountLimit: 100,
+ amountObserved: 85,
+ status: "open",
+ createdAt: new Date("2026-07-09T12:10:00.000Z"),
+ updatedAt: new Date("2026-07-09T12:10:00.000Z"),
+ },
+ {
+ companyId,
+ policyId: softPolicy84Id,
+ scopeType: "company",
+ scopeId: companyId,
+ metric: "billed_cents",
+ windowKind: "lifetime",
+ windowStart: new Date("1970-01-01T00:00:00.000Z"),
+ windowEnd: new Date("9999-01-01T00:00:00.000Z"),
+ thresholdType: "soft",
+ amountLimit: 100,
+ amountObserved: 84,
+ status: "open",
+ createdAt: new Date("2026-07-09T12:10:30.000Z"),
+ updatedAt: new Date("2026-07-09T12:10:30.000Z"),
+ },
+ {
+ companyId,
+ policyId: hardPolicyId,
+ scopeType: "agent",
+ scopeId: workerId,
+ metric: "billed_cents",
+ windowKind: "calendar_month_utc",
+ windowStart: new Date("2026-07-01T00:00:00.000Z"),
+ windowEnd: new Date("2026-08-01T00:00:00.000Z"),
+ thresholdType: "hard",
+ amountLimit: 100,
+ amountObserved: 100,
+ status: "open",
+ createdAt: new Date("2026-07-09T12:11:00.000Z"),
+ updatedAt: new Date("2026-07-09T12:11:00.000Z"),
+ },
+ ]);
+
+ const feed = await attentionService(db).list(companyId, { userId: "board-user" });
+
+ expect(feed.totalCount).toBe(11);
+ expect(feed.countsBySourceKind).toMatchObject({
+ approval: 1,
+ issue_thread_interaction: 1,
+ join_request: 1,
+ recovery_action: 1,
+ productivity_review: 1,
+ blocker_attention: 1,
+ review: 1,
+ failed_run: 1,
+ budget_alert: 2,
+ agent_error_alert: 1,
+ });
+ expect(feed.items.map((item) => item.sourceKind)).toEqual(expect.arrayContaining([
+ "approval",
+ "issue_thread_interaction",
+ "join_request",
+ "recovery_action",
+ "productivity_review",
+ "blocker_attention",
+ "review",
+ "failed_run",
+ "budget_alert",
+ "agent_error_alert",
+ ]));
+ for (const item of feed.items) {
+ expect(item.dedupKey).toBeTruthy();
+ expect(item.dismissalKey).toBe(`attention:${item.dedupKey}`);
+ expect(item.whyNow).toBeTruthy();
+ expect(item.entryRule).toBeTruthy();
+ expect(item.exitRule).toBeTruthy();
+ expect(item.decisionVerbs.length).toBeGreaterThan(0);
+ expect(item.rank).toBeGreaterThan(0);
+ }
+ expect(feed.items.some((item) => item.subject.title === "Revision requested")).toBe(false);
+ expect(feed.items.some((item) => item.subject.title === "Agent productivity review excluded")).toBe(false);
+ expect(feed.items.some((item) => item.subject.title === "Agent review excluded")).toBe(false);
+ expect(feed.items.some((item) =>
+ item.sourceKind === "failed_run" && item.subject.metadata?.errorCode === "provider_quota"
+ )).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("suppresses failed-run attention after a newer run for the same issue", async () => {
+ const { companyId, workerId } = await seedCompany("ATN");
+ const issueId = await insertIssue({
+ companyId,
+ identifier: "ATN-1",
+ title: "Recoverable task",
+ status: "in_progress",
+ });
+ const failedRunId = randomUUID();
+ const failedAt = new Date("2026-07-09T12:00:00.000Z");
+
+ await db.insert(heartbeatRuns).values([
+ {
+ id: failedRunId,
+ companyId,
+ agentId: workerId,
+ invocationSource: "automation",
+ status: "failed",
+ error: "adapter failed",
+ contextSnapshot: { issueId },
+ createdAt: failedAt,
+ updatedAt: failedAt,
+ finishedAt: failedAt,
+ },
+ {
+ id: randomUUID(),
+ companyId,
+ agentId: workerId,
+ invocationSource: "automation",
+ status: "succeeded",
+ contextSnapshot: { issueId },
+ createdAt: new Date("2026-07-09T12:01:00.000Z"),
+ updatedAt: new Date("2026-07-09T12:01:00.000Z"),
+ finishedAt: new Date("2026-07-09T12:01:00.000Z"),
+ },
+ ]);
+ await db.insert(heartbeatRunEvents).values({
+ companyId,
+ runId: failedRunId,
+ agentId: workerId,
+ seq: 1,
+ eventType: "lifecycle",
+ message: "Bounded retry exhausted after 4 scheduled attempts; no further automatic retry will be queued",
+ createdAt: new Date("2026-07-09T12:00:01.000Z"),
+ });
+
+ const feed = await attentionService(db).list(companyId, { userId: "board-user" });
+
+ expect(feed.items.filter((item) => item.sourceKind === "failed_run")).toEqual([]);
+ });
+
+ 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",
+ color: "#0f766e",
+ icon: "rocket",
+ });
+ 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?",
+ acceptLabel: "Approve plan",
+ rejectLabel: "Request changes",
+ 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?.decisionVerbs).toEqual([
+ expect.objectContaining({ id: "accept", label: "Approve plan" }),
+ expect.objectContaining({ id: "reject", label: "Request changes" }),
+ ]);
+ expect(planItem?.project).toMatchObject({
+ id: projectId,
+ name: "Attention Project",
+ color: "#0f766e",
+ icon: "rocket",
+ });
+ 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 () => {
+ const { companyId } = await seedCompany("ATD");
+ const approvalId = randomUUID();
+ await db.insert(approvals).values({
+ id: approvalId,
+ companyId,
+ type: "hire_agent",
+ status: "pending",
+ payload: { title: "Hire Writer" },
+ 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}`,
+ dismissedAt: new Date("2026-07-09T13:00:00.000Z"),
+ });
+
+ await expect(attentionService(db).list(companyId, { userId: "board-user" }))
+ .resolves.toMatchObject({ totalCount: 1 }); // agent_error_alert from seed
+ 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)
+ .set({ updatedAt: new Date("2026-07-09T14:00:00.000Z") })
+ .where(eq(approvals.id, approvalId));
+
+ const feed = await attentionService(db).list(companyId, { userId: "board-user" });
+ 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");
+
+ function app(actor: Record) {
+ const testApp = express();
+ testApp.use(express.json());
+ testApp.use((req, _res, next) => {
+ (req as any).actor = actor;
+ next();
+ });
+ testApp.use("/api", attentionRoutes(db));
+ testApp.use(errorHandler);
+ return testApp;
+ }
+
+ const board = {
+ type: "board",
+ source: "local_implicit",
+ userId: "board-user",
+ companyIds: [companyId],
+ isInstanceAdmin: false,
+ };
+ const agent = {
+ type: "agent",
+ source: "agent_key",
+ companyId,
+ agentId: randomUUID(),
+ runId: null,
+ };
+
+ await request(app(board)).get(`/api/companies/${companyId}/attention`).expect(200);
+ await request(app(agent)).get(`/api/companies/${companyId}/attention`).expect(403);
+ });
+});
diff --git a/server/src/__tests__/inbox-dismissals.test.ts b/server/src/__tests__/inbox-dismissals.test.ts
index c6360a219f..193c6f11a3 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/__tests__/instance-settings-service.test.ts b/server/src/__tests__/instance-settings-service.test.ts
index 802259c192..dbca0fa120 100644
--- a/server/src/__tests__/instance-settings-service.test.ts
+++ b/server/src/__tests__/instance-settings-service.test.ts
@@ -37,6 +37,7 @@ describe("instance settings service", () => {
enableTaskWatchdogs: true,
enableCloudSync: true,
enableBuiltInAgents: true,
+ enableDecisions: false,
enableGoalsSidebarLink: true,
enableServerInfoDebugView: true,
autoRestartDevServerWhenIdle: true,
@@ -83,6 +84,14 @@ describe("instance settings service", () => {
).toBe(false);
});
+ it("defaults enableDecisions to false for empty and legacy stored settings", () => {
+ expect(normalizeExperimentalSettings(undefined).enableDecisions).toBe(false);
+ expect(normalizeExperimentalSettings({}).enableDecisions).toBe(false);
+ expect(
+ normalizeExperimentalSettings({ enableStreamlinedLeftNavigation: true }).enableDecisions,
+ ).toBe(false);
+ });
+
it("defaults workspace branch repair settings to true for empty and legacy stored settings", () => {
expect(normalizeExperimentalSettings(undefined).enableWorkspaceBranchReconcileForward).toBe(true);
expect(normalizeExperimentalSettings({}).enableWorkspaceBranchReconcileForward).toBe(true);
diff --git a/server/src/__tests__/issue-thread-interaction-routes.test.ts b/server/src/__tests__/issue-thread-interaction-routes.test.ts
index c0ab044906..fb08b98f1c 100644
--- a/server/src/__tests__/issue-thread-interaction-routes.test.ts
+++ b/server/src/__tests__/issue-thread-interaction-routes.test.ts
@@ -18,6 +18,7 @@ const mockInteractionService = vi.hoisted(() => ({
rejectSuggestedTasks: vi.fn(),
expireRequestConfirmationsSupersededByHistoricalComments: vi.fn(),
answerQuestions: vi.fn(),
+ submitItemVerdicts: vi.fn(),
cancelQuestions: vi.fn(),
}));
@@ -284,6 +285,48 @@ describe.sequential("issue thread interaction routes", () => {
updatedAt: "2026-04-20T12:06:00.000Z",
resolvedAt: "2026-04-20T12:06:00.000Z",
});
+ mockInteractionService.submitItemVerdicts.mockResolvedValue({
+ interaction: {
+ id: "interaction-verdicts",
+ companyId: "company-1",
+ issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
+ kind: "request_item_verdicts",
+ status: "pending",
+ continuationPolicy: "wake_assignee",
+ idempotencyKey: null,
+ sourceCommentId: "comment-verdicts",
+ sourceRunId: "run-verdicts",
+ payload: {
+ version: 1,
+ prompt: "Review generated artifacts.",
+ items: [
+ { id: "api", label: "API route" },
+ { id: "docs", label: "Docs" },
+ ],
+ verdicts: ["approve", "reject"],
+ requireReasonOn: ["reject"],
+ allowBulkApprove: true,
+ },
+ result: {
+ version: 1,
+ outcome: "resolved",
+ complete: false,
+ items: [
+ {
+ id: "docs",
+ verdict: "reject",
+ reason: "Missing examples",
+ resolvedByUserId: "local-board",
+ resolvedAt: "2026-04-20T12:06:00.000Z",
+ },
+ ],
+ },
+ createdAt: "2026-04-20T12:00:00.000Z",
+ updatedAt: "2026-04-20T12:06:00.000Z",
+ resolvedAt: null,
+ },
+ newlyResolvedItemIds: ["docs"],
+ });
mockInteractionService.cancelQuestions.mockResolvedValue({
id: "interaction-2",
companyId: "company-1",
@@ -469,6 +512,68 @@ describe.sequential("issue thread interaction routes", () => {
);
});
+ it("submits item verdicts and emits one continuation wake with resolved item ids", async () => {
+ const app = await createApp();
+
+ const res = await request(app)
+ .post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-verdicts/verdicts")
+ .send({
+ verdicts: [{ id: "docs", verdict: "reject", reason: "Missing examples" }],
+ });
+
+ expect(res.status).toBe(200);
+ expect(mockInteractionService.submitItemVerdicts).toHaveBeenCalledWith(
+ expect.objectContaining({ id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" }),
+ "interaction-verdicts",
+ { verdicts: [{ id: "docs", verdict: "reject", reason: "Missing examples" }] },
+ expect.objectContaining({ userId: "local-board" }),
+ );
+ expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1);
+ expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
+ ASSIGNEE_AGENT_ID,
+ expect.objectContaining({
+ reason: "issue_commented",
+ idempotencyKey: expect.stringMatching(
+ /^request_item_verdicts:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa:interaction-verdicts:/,
+ ),
+ payload: expect.objectContaining({
+ interactionId: "interaction-verdicts",
+ interactionKind: "request_item_verdicts",
+ interactionStatus: "pending",
+ sourceCommentId: "comment-verdicts",
+ sourceRunId: "run-verdicts",
+ newlyResolvedItemIds: ["docs"],
+ itemVerdicts: {
+ newlyResolvedItemIds: ["docs"],
+ coalesceWindowMs: 2000,
+ },
+ }),
+ contextSnapshot: expect.objectContaining({
+ interactionId: "interaction-verdicts",
+ interactionKind: "request_item_verdicts",
+ interactionStatus: "pending",
+ newlyResolvedItemIds: ["docs"],
+ itemVerdicts: {
+ newlyResolvedItemIds: ["docs"],
+ coalesceWindowMs: 2000,
+ },
+ }),
+ }),
+ );
+ expect(mockLogActivity).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({
+ action: "issue.thread_interaction_item_verdicts_submitted",
+ details: expect.objectContaining({
+ interactionKind: "request_item_verdicts",
+ newlyResolvedItemCount: 1,
+ newlyResolvedItemIds: ["docs"],
+ complete: false,
+ }),
+ }),
+ );
+ });
+
it("cancels question interactions and emits a continuation wake", async () => {
const app = await createApp();
diff --git a/server/src/__tests__/issue-thread-interactions-service.test.ts b/server/src/__tests__/issue-thread-interactions-service.test.ts
index 9463c734e3..8a957a6b4e 100644
--- a/server/src/__tests__/issue-thread-interactions-service.test.ts
+++ b/server/src/__tests__/issue-thread-interactions-service.test.ts
@@ -1140,6 +1140,243 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
});
});
+ it("submits request_item_verdicts partially and completes when all items are resolved", async () => {
+ const { companyId, issueId } = await seedConfirmationIssue("Item verdict partial submit");
+
+ const created = await interactionsSvc.create({
+ id: issueId,
+ companyId,
+ }, {
+ kind: "request_item_verdicts",
+ payload: {
+ version: 1,
+ prompt: "Review generated artifacts.",
+ items: [
+ { id: "api", label: "API route" },
+ { id: "docs", label: "Docs" },
+ { id: "tests", label: "Tests" },
+ ],
+ },
+ }, {
+ userId: "local-board",
+ });
+
+ expect(created).toMatchObject({
+ kind: "request_item_verdicts",
+ status: "pending",
+ continuationPolicy: "wake_assignee",
+ payload: {
+ verdicts: ["approve", "reject"],
+ requireReasonOn: ["reject"],
+ allowBulkApprove: true,
+ supersedeOnUserComment: true,
+ },
+ });
+
+ const first = await interactionsSvc.submitItemVerdicts({
+ id: issueId,
+ companyId,
+ }, created.id, {
+ verdicts: [{ id: "docs", verdict: "reject", reason: "Missing examples" }],
+ }, {
+ userId: "local-board",
+ });
+
+ expect(first.newlyResolvedItemIds).toEqual(["docs"]);
+ expect(first.interaction).toMatchObject({
+ kind: "request_item_verdicts",
+ status: "pending",
+ result: {
+ version: 1,
+ outcome: "resolved",
+ complete: false,
+ items: [
+ {
+ id: "docs",
+ verdict: "reject",
+ reason: "Missing examples",
+ resolvedByUserId: "local-board",
+ },
+ ],
+ },
+ resolvedAt: null,
+ });
+
+ const duplicate = await interactionsSvc.submitItemVerdicts({
+ id: issueId,
+ companyId,
+ }, created.id, {
+ verdicts: [{ id: "docs", verdict: "reject" }],
+ }, {
+ userId: "local-board",
+ });
+
+ expect(duplicate.newlyResolvedItemIds).toEqual([]);
+ expect(duplicate.interaction).toMatchObject({
+ status: "pending",
+ result: {
+ complete: false,
+ items: [
+ {
+ id: "docs",
+ verdict: "reject",
+ reason: "Missing examples",
+ },
+ ],
+ },
+ });
+
+ const completed = await interactionsSvc.submitItemVerdicts({
+ id: issueId,
+ companyId,
+ }, created.id, {
+ verdicts: [
+ { id: "api", verdict: "approve" },
+ { id: "tests", verdict: "reject", reason: "No route coverage" },
+ ],
+ }, {
+ userId: "local-board",
+ });
+
+ expect(completed.newlyResolvedItemIds).toEqual(["api", "tests"]);
+ expect(completed.interaction).toMatchObject({
+ kind: "request_item_verdicts",
+ status: "answered",
+ result: {
+ version: 1,
+ outcome: "resolved",
+ complete: true,
+ items: [
+ { id: "api", verdict: "approve" },
+ { id: "docs", verdict: "reject", reason: "Missing examples" },
+ { id: "tests", verdict: "reject", reason: "No route coverage" },
+ ],
+ },
+ resolvedByUserId: "local-board",
+ });
+
+ const duplicateAfterComplete = await interactionsSvc.submitItemVerdicts({
+ id: issueId,
+ companyId,
+ }, created.id, {
+ verdicts: [{ id: "api", verdict: "approve" }],
+ }, {
+ userId: "local-board",
+ });
+ expect(duplicateAfterComplete.newlyResolvedItemIds).toEqual([]);
+ expect(duplicateAfterComplete.interaction.status).toBe("answered");
+ });
+
+ it("enforces request_item_verdicts ids, enabled verdicts, and required reasons", async () => {
+ const { companyId, issueId } = await seedConfirmationIssue("Item verdict validation");
+
+ const created = await interactionsSvc.create({
+ id: issueId,
+ companyId,
+ }, {
+ kind: "request_item_verdicts",
+ payload: {
+ version: 1,
+ prompt: "Review generated artifacts.",
+ items: [
+ { id: "api", label: "API route" },
+ { id: "docs", label: "Docs" },
+ ],
+ },
+ }, {
+ userId: "local-board",
+ });
+
+ await expect(interactionsSvc.submitItemVerdicts({
+ id: issueId,
+ companyId,
+ }, created.id, {
+ verdicts: [{ id: "missing", verdict: "approve" }],
+ }, {
+ userId: "local-board",
+ })).rejects.toThrow("Unknown item verdict id: missing");
+
+ await expect(interactionsSvc.submitItemVerdicts({
+ id: issueId,
+ companyId,
+ }, created.id, {
+ verdicts: [{ id: "api", verdict: "defer" }],
+ }, {
+ userId: "local-board",
+ })).rejects.toThrow("Verdict defer is not enabled");
+
+ await expect(interactionsSvc.submitItemVerdicts({
+ id: issueId,
+ companyId,
+ }, created.id, {
+ verdicts: [{ id: "docs", verdict: "reject" }],
+ }, {
+ userId: "local-board",
+ })).rejects.toThrow("A reason is required when verdict is reject");
+ });
+
+ it("preserves resolved request_item_verdicts items when a later user comment supersedes the pending remainder", async () => {
+ const { companyId, issueId } = await seedConfirmationIssue("Item verdict supersede");
+ const commentId = randomUUID();
+
+ const created = await interactionsSvc.create({
+ id: issueId,
+ companyId,
+ }, {
+ kind: "request_item_verdicts",
+ payload: {
+ version: 1,
+ prompt: "Review generated artifacts.",
+ items: [
+ { id: "api", label: "API route" },
+ { id: "docs", label: "Docs" },
+ ],
+ },
+ }, {
+ userId: "local-board",
+ });
+
+ await interactionsSvc.submitItemVerdicts({
+ id: issueId,
+ companyId,
+ }, created.id, {
+ verdicts: [{ id: "api", verdict: "approve" }],
+ }, {
+ userId: "local-board",
+ });
+
+ const expired = await interactionsSvc.expireRequestConfirmationsSupersededByComment({
+ id: issueId,
+ companyId,
+ }, {
+ id: commentId,
+ createdAt: new Date(new Date(created.createdAt).getTime() + 1_000),
+ authorUserId: "local-board",
+ }, {
+ userId: "local-board",
+ });
+
+ expect(expired).toHaveLength(1);
+ expect(expired[0]).toMatchObject({
+ id: created.id,
+ kind: "request_item_verdicts",
+ status: "expired",
+ result: {
+ version: 1,
+ outcome: "superseded_by_comment",
+ complete: false,
+ commentId,
+ items: [
+ {
+ id: "api",
+ verdict: "approve",
+ resolvedByUserId: "local-board",
+ },
+ ],
+ },
+ });
+ });
+
it("returns agent-authored request confirmations to the creating agent when a board user accepts", async () => {
const companyId = randomUUID();
const goalId = randomUUID();
@@ -1580,6 +1817,152 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
});
});
+ it("preserves resolved request_item_verdicts items when the watched issue document revision changes", async () => {
+ const companyId = randomUUID();
+ const goalId = randomUUID();
+ const issueId = randomUUID();
+ const documentId = randomUUID();
+ const revisionId = randomUUID();
+ const nextRevisionId = randomUUID();
+
+ await db.insert(companies).values({
+ id: companyId,
+ name: "Paperclip",
+ issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
+ requireBoardApprovalForNewAgents: false,
+ });
+ await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
+ await db.insert(goals).values({
+ id: goalId,
+ companyId,
+ title: "Document target verdicts",
+ level: "task",
+ status: "active",
+ });
+ await db.insert(issues).values({
+ id: issueId,
+ companyId,
+ goalId,
+ title: "Parent issue",
+ status: "in_progress",
+ priority: "medium",
+ });
+ await db.insert(documents).values({
+ id: documentId,
+ companyId,
+ title: "Plan",
+ format: "markdown",
+ latestBody: "v1",
+ latestRevisionId: revisionId,
+ latestRevisionNumber: 1,
+ });
+ await db.insert(issueDocuments).values({
+ companyId,
+ issueId,
+ documentId,
+ key: "plan",
+ });
+ await db.insert(documentRevisions).values({
+ id: revisionId,
+ companyId,
+ documentId,
+ revisionNumber: 1,
+ title: "Plan",
+ format: "markdown",
+ body: "v1",
+ });
+
+ const created = await interactionsSvc.create({
+ id: issueId,
+ companyId,
+ }, {
+ kind: "request_item_verdicts",
+ continuationPolicy: "wake_assignee",
+ payload: {
+ version: 1,
+ prompt: "Review generated artifacts.",
+ items: [
+ { id: "api", label: "API route" },
+ { id: "docs", label: "Docs" },
+ ],
+ target: {
+ type: "issue_document",
+ issueId,
+ documentId,
+ key: "plan",
+ revisionId,
+ revisionNumber: 1,
+ },
+ },
+ }, {
+ userId: "local-board",
+ });
+
+ await interactionsSvc.submitItemVerdicts({
+ id: issueId,
+ companyId,
+ }, created.id, {
+ verdicts: [{ id: "api", verdict: "approve" }],
+ }, {
+ userId: "local-board",
+ });
+
+ await db.insert(documentRevisions).values({
+ id: nextRevisionId,
+ companyId,
+ documentId,
+ revisionNumber: 2,
+ title: "Plan",
+ format: "markdown",
+ body: "v2",
+ });
+ await db.update(documents).set({
+ latestBody: "v2",
+ latestRevisionId: nextRevisionId,
+ latestRevisionNumber: 2,
+ });
+
+ const stale = await interactionsSvc.submitItemVerdicts({
+ id: issueId,
+ companyId,
+ }, created.id, {
+ verdicts: [{ id: "docs", verdict: "approve" }],
+ }, {
+ userId: "local-board",
+ });
+
+ expect(stale.newlyResolvedItemIds).toEqual([]);
+ expect(stale.interaction).toMatchObject({
+ id: created.id,
+ status: "expired",
+ payload: {
+ target: {
+ type: "issue_document",
+ key: "plan",
+ revisionId: nextRevisionId,
+ revisionNumber: 2,
+ },
+ },
+ result: {
+ version: 1,
+ outcome: "stale_target",
+ complete: false,
+ staleTarget: {
+ type: "issue_document",
+ key: "plan",
+ revisionId,
+ },
+ items: [
+ {
+ id: "api",
+ verdict: "approve",
+ resolvedByUserId: "local-board",
+ },
+ ],
+ },
+ });
+ });
+
describe("workspace_finalize accept gate", () => {
type AcceptGateInteractionKind = "request_confirmation" | "request_checkbox_confirmation";
diff --git a/server/src/__tests__/openapi-routes.test.ts b/server/src/__tests__/openapi-routes.test.ts
index 5eb8c8e87e..d743302307 100644
--- a/server/src/__tests__/openapi-routes.test.ts
+++ b/server/src/__tests__/openapi-routes.test.ts
@@ -15,6 +15,7 @@ const apiPrefixes: Record = {
"activity.ts": "/api",
"adapters.ts": "/api",
"agents.ts": "/api",
+ "attention.ts": "/api",
"approvals.ts": "/api",
"assets.ts": "/api",
"auth.ts": "/api/auth",
diff --git a/server/src/app.ts b/server/src/app.ts
index 75e0f1fe24..9cb4ad9f9d 100644
--- a/server/src/app.ts
+++ b/server/src/app.ts
@@ -33,6 +33,7 @@ import { secretRoutes } from "./routes/secrets.js";
import { costRoutes } from "./routes/costs.js";
import { activityRoutes } from "./routes/activity.js";
import { dashboardRoutes } from "./routes/dashboard.js";
+import { attentionRoutes } from "./routes/attention.js";
import { userProfileRoutes } from "./routes/user-profiles.js";
import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js";
import { sidebarPreferenceRoutes } from "./routes/sidebar-preferences.js";
@@ -253,6 +254,7 @@ export async function createApp(
api.use(costRoutes(db, { pluginWorkerManager: workerManager }));
api.use(activityRoutes(db));
api.use(dashboardRoutes(db));
+ api.use(attentionRoutes(db));
api.use(userProfileRoutes(db));
api.use(sidebarBadgeRoutes(db));
api.use(sidebarPreferenceRoutes(db));
diff --git a/server/src/routes/attention.ts b/server/src/routes/attention.ts
new file mode 100644
index 0000000000..a8ef20f0e0
--- /dev/null
+++ b/server/src/routes/attention.ts
@@ -0,0 +1,28 @@
+import { Router } from "express";
+import type { Db } from "@paperclipai/db";
+import { attentionService } from "../services/attention.js";
+import { assertBoard, assertCompanyAccess } from "./authz.js";
+
+export function attentionRoutes(db: Db) {
+ const router = Router();
+ const svc = attentionService(db);
+
+ router.get("/companies/:companyId/attention", async (req, res) => {
+ const companyId = req.params.companyId as string;
+ assertCompanyAccess(req, companyId);
+ assertBoard(req);
+ if (!req.actor.userId) {
+ res.status(403).json({ error: "Board user context required" });
+ return;
+ }
+
+ const includeDismissed = req.query.includeDismissed === "true";
+ const feed = await svc.list(companyId, {
+ userId: req.actor.userId,
+ includeDismissed,
+ });
+ res.json(feed);
+ });
+
+ return router;
+}
diff --git a/server/src/routes/inbox-dismissals.ts b/server/src/routes/inbox-dismissals.ts
index 1b51633e56..ceae79c19c 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):.+$/, "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/index.ts b/server/src/routes/index.ts
index c637677c9f..aef7f58901 100644
--- a/server/src/routes/index.ts
+++ b/server/src/routes/index.ts
@@ -15,6 +15,7 @@ export { secretRoutes } from "./secrets.js";
export { costRoutes } from "./costs.js";
export { activityRoutes } from "./activity.js";
export { dashboardRoutes } from "./dashboard.js";
+export { attentionRoutes } from "./attention.js";
export { sidebarBadgeRoutes } from "./sidebar-badges.js";
export { sidebarPreferenceRoutes } from "./sidebar-preferences.js";
export { resourceMembershipRoutes } from "./resource-memberships.js";
diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts
index 6dc7ab4e3f..391c5a25b3 100644
--- a/server/src/routes/issues.ts
+++ b/server/src/routes/issues.ts
@@ -53,6 +53,7 @@ import {
rejectIssueThreadInteractionSchema,
restoreIssueDocumentRevisionSchema,
respondIssueThreadInteractionSchema,
+ submitIssueThreadInteractionVerdictsSchema,
updateIssueWorkProductSchema,
updateDocumentAnnotationThreadSchema,
upsertIssueDocumentSchema,
@@ -1766,6 +1767,18 @@ function isAssigneeSelfCommentOnTerminalIssue(input: {
return input.actorId === input.assigneeAgentId;
}
+const REQUEST_ITEM_VERDICTS_WAKE_COALESCE_WINDOW_MS = 2_000;
+
+function buildRequestItemVerdictsWakeIdempotencyKey(args: {
+ issueId: string;
+ interactionId: string;
+ at?: Date;
+}) {
+ const now = args.at ?? new Date();
+ const bucket = Math.floor(now.getTime() / REQUEST_ITEM_VERDICTS_WAKE_COALESCE_WINDOW_MS);
+ return `request_item_verdicts:${args.issueId}:${args.interactionId}:${bucket}`;
+}
+
function queueResolvedInteractionContinuationWakeup(input: {
heartbeat: ReturnType;
issue: { id: string; assigneeAgentId: string | null; status: string };
@@ -1783,6 +1796,8 @@ function queueResolvedInteractionContinuationWakeup(input: {
source: string;
forceFreshSession?: boolean;
workspaceRefreshReason?: string | null;
+ newlyResolvedItemIds?: string[];
+ idempotencyKey?: string | null;
}) {
if (
input.interaction.continuationPolicy !== "wake_assignee"
@@ -1800,6 +1815,13 @@ function queueResolvedInteractionContinuationWakeup(input: {
const planTarget = readPlanConfirmationTargetForIssue(input.interaction.payload, input.issue.id);
const interactionResult = readConfirmationResultForWake(input.interaction.result);
const checkboxSelection = readCheckboxSelectionForWake(input.interaction);
+ const newlyResolvedItemIds = input.newlyResolvedItemIds?.filter((value) => value.length > 0) ?? [];
+ const itemVerdicts = newlyResolvedItemIds.length > 0
+ ? {
+ newlyResolvedItemIds,
+ coalesceWindowMs: REQUEST_ITEM_VERDICTS_WAKE_COALESCE_WINDOW_MS,
+ }
+ : null;
const planReviewInteraction =
planTarget && input.interaction.kind === "request_confirmation"
? {
@@ -1824,8 +1846,10 @@ function queueResolvedInteractionContinuationWakeup(input: {
sourceRunId: input.interaction.sourceRunId ?? null,
...(planReviewInteraction ? { planReviewInteraction } : {}),
...(checkboxSelection ? { checkboxSelection } : {}),
+ ...(itemVerdicts ? { itemVerdicts, newlyResolvedItemIds } : {}),
mutation: "interaction",
},
+ idempotencyKey: input.idempotencyKey ?? null,
requestedByActorType: input.actor.actorType,
requestedByActorId: input.actor.actorId,
contextSnapshot: {
@@ -1838,6 +1862,7 @@ function queueResolvedInteractionContinuationWakeup(input: {
sourceRunId: input.interaction.sourceRunId ?? null,
...(planReviewInteraction ? { planReviewInteraction } : {}),
...(checkboxSelection ? { checkboxSelection } : {}),
+ ...(itemVerdicts ? { itemVerdicts, newlyResolvedItemIds } : {}),
wakeReason: "issue_commented",
source: input.source,
...(forceFreshSession ? { forceFreshSession: true } : {}),
@@ -9181,6 +9206,76 @@ export function issueRoutes(
},
);
+ router.post(
+ "/issues/:id/interactions/:interactionId/verdicts",
+ validate(submitIssueThreadInteractionVerdictsSchema),
+ async (req, res) => {
+ const id = req.params.id as string;
+ const interactionId = req.params.interactionId as string;
+ const issue = await svc.getById(id);
+ if (!issue) {
+ res.status(404).json({ error: "Issue not found" });
+ return;
+ }
+ assertCompanyAccess(req, issue.companyId);
+ if (await rejectAgentIssueThreadInteractionResolution(req, res, issue)) return;
+ assertBoard(req);
+
+ const actor = getActorInfo(req);
+ const { interaction, newlyResolvedItemIds } = await issueThreadInteractionService(db).submitItemVerdicts(
+ issue,
+ interactionId,
+ req.body,
+ {
+ agentId: actor.agentId,
+ userId: actor.actorType === "user" ? actor.actorId : null,
+ },
+ );
+
+ await logActivity(db, {
+ companyId: issue.companyId,
+ actorType: actor.actorType,
+ actorId: actor.actorId,
+ agentId: actor.agentId,
+ runId: actor.runId,
+ action: interaction.status === "expired"
+ ? "issue.thread_interaction_expired"
+ : "issue.thread_interaction_item_verdicts_submitted",
+ entityType: "issue",
+ entityId: issue.id,
+ details: {
+ interactionId: interaction.id,
+ interactionKind: interaction.kind,
+ interactionStatus: interaction.status,
+ submittedVerdictCount: Array.isArray(req.body?.verdicts) ? req.body.verdicts.length : 0,
+ newlyResolvedItemCount: newlyResolvedItemIds.length,
+ newlyResolvedItemIds,
+ complete:
+ interaction.kind === "request_item_verdicts"
+ ? (interaction.result?.complete ?? false)
+ : false,
+ },
+ });
+
+ if (newlyResolvedItemIds.length > 0) {
+ queueResolvedInteractionContinuationWakeup({
+ heartbeat,
+ issue,
+ interaction,
+ actor,
+ source: "issue.interaction.verdicts",
+ newlyResolvedItemIds,
+ idempotencyKey: buildRequestItemVerdictsWakeIdempotencyKey({
+ issueId: issue.id,
+ interactionId: interaction.id,
+ }),
+ });
+ }
+
+ res.json(interaction);
+ },
+ );
+
router.post(
"/issues/:id/interactions/:interactionId/cancel",
validate(cancelIssueThreadInteractionSchema),
diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts
index 5f897a14f8..1c485cda35 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)
@@ -2813,6 +2814,15 @@ registry.registerPath({
responses: { 200: r.ok(), 401: r.unauthorized },
});
+registry.registerPath({
+ method: "get",
+ path: "/api/companies/{companyId}/attention",
+ tags: ["inbox"],
+ summary: "List decision-only attention feed items",
+ request: { params: z.object({ companyId: z.string() }) },
+ responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden },
+});
+
registry.registerPath({
method: "get",
path: "/api/sidebar-preferences/me",
@@ -2866,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):.+$/, "Unsupported inbox item key"),
+ 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 ────────────────────────────────────────────────────────
@@ -3433,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 8b4d1f2285..770bbc2847 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
new file mode 100644
index 0000000000..203a263cf8
--- /dev/null
+++ b/server/src/services/attention.ts
@@ -0,0 +1,1253 @@
+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";
+import { issueService } from "./issues.js";
+import { parseIssueExecutionState } from "./issue-execution-policy.js";
+
+const ATTENTION_SOURCE_KINDS: AttentionSourceKind[] = [
+ "approval",
+ "issue_thread_interaction",
+ "join_request",
+ "recovery_action",
+ "productivity_review",
+ "blocker_attention",
+ "review",
+ "failed_run",
+ "budget_alert",
+ "agent_error_alert",
+];
+
+const SEVERITY_RANK: Record = {
+ critical: 0,
+ high: 1,
+ medium: 2,
+ low: 3,
+};
+
+const SOURCE_RANK: Record = {
+ failed_run: 0,
+ recovery_action: 1,
+ blocker_attention: 2,
+ budget_alert: 3,
+ agent_error_alert: 4,
+ approval: 5,
+ issue_thread_interaction: 6,
+ review: 7,
+ productivity_review: 8,
+ join_request: 9,
+};
+
+const PENDING_INTERACTION_STATUSES = ["pending"] as const;
+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;
+ companyId: string;
+ identifier: string | null;
+ title: string;
+ status: string;
+ priority: string;
+ assigneeAgentId: string | null;
+ 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 = {
+ userId?: string | null;
+ includeDismissed?: boolean;
+};
+
+function emptyCounts(): Record {
+ return Object.fromEntries(ATTENTION_SOURCE_KINDS.map((kind) => [kind, 0])) as Record;
+}
+
+function toIso(value: Date | string | null | undefined): string {
+ if (!value) return new Date(0).toISOString();
+ return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
+}
+
+function timestamp(value: Date | string | null | undefined): number {
+ if (!value) return 0;
+ const time = new Date(value).getTime();
+ return Number.isFinite(time) ? time : 0;
+}
+
+function activeDismissalState(
+ dismissalByKey: ReadonlyMap,
+ dismissalKey: string,
+ activityAt: string,
+ now: number,
+) {
+ 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 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: IssueSubjectRow): AttentionSubject {
+ return {
+ kind: "issue",
+ id: issue.id,
+ companyId: issue.companyId,
+ title: issue.title,
+ identifier: issue.identifier,
+ status: issue.status,
+ href: issueHref(prefix, issue),
+ metadata: {
+ priority: issue.priority,
+ assigneeAgentId: issue.assigneeAgentId,
+ assigneeUserId: issue.assigneeUserId,
+ },
+ };
+}
+
+function itemId(sourceKind: AttentionSourceKind, dedupKey: string) {
+ return `${sourceKind}:${dedupKey}`;
+}
+
+function decisionVerbs(...verbs: AttentionDecisionVerb[]): AttentionDecisionVerb[] {
+ return verbs;
+}
+
+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;
+ return left.dedupKey.localeCompare(right.dedupKey);
+}
+
+function betterDuplicate(left: AttentionItem, right: AttentionItem) {
+ return compareAttentionItems(left, right) <= 0 ? left : right;
+}
+
+function approvalTitle(type: string, payload: Record) {
+ const title = typeof payload.title === "string" ? payload.title.trim() : "";
+ if (title) return title;
+ const summary = typeof payload.summary === "string" ? payload.summary.trim() : "";
+ if (summary) return summary;
+ return type.replaceAll("_", " ");
+}
+
+function interactionLabel(kind: string) {
+ switch (kind) {
+ case "request_confirmation":
+ return "Confirmation requested";
+ case "request_checkbox_confirmation":
+ return "Selection confirmation requested";
+ case "ask_user_questions":
+ 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";
+ }
+}
+
+function interactionVerbs(kind: string, payload: Record) {
+ if (kind === "ask_user_questions") {
+ return decisionVerbs({
+ id: "respond",
+ label: "Respond",
+ description: "Submit answers to the pending questions.",
+ });
+ }
+ if (kind === "request_confirmation") {
+ const acceptLabel = typeof payload.acceptLabel === "string" && payload.acceptLabel.trim()
+ ? payload.acceptLabel.trim()
+ : "Confirm";
+ const rejectLabel = typeof payload.rejectLabel === "string" && payload.rejectLabel.trim()
+ ? payload.rejectLabel.trim()
+ : "Decline";
+ return decisionVerbs(
+ {
+ id: "accept",
+ label: acceptLabel,
+ description: "Accept the pending confirmation.",
+ },
+ {
+ id: "reject",
+ label: rejectLabel,
+ description: "Decline the pending confirmation.",
+ },
+ );
+ }
+ return decisionVerbs(
+ {
+ id: "accept",
+ label: "Accept",
+ description: "Accept the pending interaction.",
+ },
+ {
+ id: "reject",
+ label: "Reject",
+ description: "Reject the pending interaction and provide a reason when required.",
+ },
+ );
+}
+
+function budgetObservedPercent(amountObserved: number, amountLimit: number) {
+ return amountLimit > 0 ? Math.round((amountObserved / amountLimit) * 10_000) / 100 : 0;
+}
+
+async function companyPrefix(db: Db, companyId: string) {
+ const row = await db
+ .select({ issuePrefix: companies.issuePrefix })
+ .from(companies)
+ .where(eq(companies.id, companyId))
+ .then((rows) => rows[0] ?? null);
+ return row?.issuePrefix ?? "PAP";
+}
+
+async function dismissalByKey(db: Db, companyId: string, userId: string | null | undefined) {
+ if (!userId) return new Map();
+ const rows = await db
+ .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, {
+ kind: row.kind,
+ dismissedAt: row.dismissedAt,
+ snoozedUntil: row.snoozedUntil,
+ }]));
+}
+
+async function issueSummaryMap(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({
+ id: issues.id,
+ companyId: issues.companyId,
+ identifier: issues.identifier,
+ title: issues.title,
+ status: issues.status,
+ priority: issues.priority,
+ assigneeAgentId: issues.assigneeAgentId,
+ assigneeUserId: issues.assigneeUserId,
+ createdAt: issues.createdAt,
+ updatedAt: issues.updatedAt,
+ projectId: projects.id,
+ projectName: projects.name,
+ projectColor: projects.color,
+ projectIcon: projects.icon,
+ workspaceId: projectWorkspaces.id,
+ workspaceName: projectWorkspaces.name,
+ })
+ .from(issues)
+ .leftJoin(projects, and(eq(issues.projectId, projects.id), eq(projects.companyId, companyId)))
+ .leftJoin(projectWorkspaces, and(
+ eq(issues.projectWorkspaceId, projectWorkspaces.id),
+ eq(projectWorkspaces.companyId, companyId),
+ ))
+ .where(and(eq(issues.companyId, companyId), inArray(issues.id, ids), isNull(issues.hiddenAt)));
+ 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),
+ color: row.projectColor,
+ icon: row.projectIcon,
+ } : 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) {
+ const issueId = contextSnapshot?.issueId ?? contextSnapshot?.taskId;
+ return typeof issueId === "string" && issueId.length > 0 ? issueId : null;
+}
+
+export function attentionService(db: Db) {
+ return {
+ list: async (companyId: string, options: AttentionListOptions = {}): Promise => {
+ const prefix = await companyPrefix(db, companyId);
+ const dismissals = await dismissalByKey(db, companyId, options.userId);
+ const includeDismissed = options.includeDismissed === true;
+ const now = Date.now();
+ const collected: AttentionItem[] = [];
+
+ const add = (item: AttentionItem) => {
+ const dismissal = activeDismissalState(dismissals, item.dismissalKey, item.activityAt, now);
+ if (!includeDismissed && dismissal?.isActive) return;
+ collected.push({ ...item, dismissal });
+ };
+
+ const pendingApprovals = await db
+ .select({
+ id: approvals.id,
+ type: approvals.type,
+ status: approvals.status,
+ requestedByAgentId: approvals.requestedByAgentId,
+ requestedByUserId: approvals.requestedByUserId,
+ payload: approvals.payload,
+ createdAt: approvals.createdAt,
+ updatedAt: approvals.updatedAt,
+ })
+ .from(approvals)
+ .where(and(eq(approvals.companyId, companyId), eq(approvals.status, "pending")))
+ .orderBy(desc(approvals.updatedAt), desc(approvals.id));
+
+ for (const approval of pendingApprovals) {
+ const dedupKey = `approval:${approval.id}`;
+ const title = approvalTitle(approval.type, approval.payload);
+ add(createItem({
+ companyId,
+ sourceKind: "approval",
+ subject: {
+ kind: "approval",
+ id: approval.id,
+ companyId,
+ title,
+ identifier: null,
+ status: approval.status,
+ href: `/${prefix}/approvals/${approval.id}`,
+ metadata: {
+ type: approval.type,
+ requestedByAgentId: approval.requestedByAgentId,
+ requestedByUserId: approval.requestedByUserId,
+ },
+ },
+ whyNow: "Approval is pending a board decision.",
+ decisionVerbs: decisionVerbs(
+ { id: "approve", label: "Approve", description: "Approve the request." },
+ { id: "reject", label: "Reject", description: "Reject the request." },
+ { id: "request_revision", label: "Request revision", description: "Send the request back for changes." },
+ ),
+ inlineResolvable: approval.type !== "request_board_approval",
+ entryRule: "approvals.status = 'pending'",
+ exitRule: "Approval leaves pending status.",
+ dedupKey,
+ severity: "medium",
+ activityAt: toIso(approval.updatedAt),
+ createdAt: toIso(approval.createdAt),
+ updatedAt: toIso(approval.updatedAt),
+ relatedIssue: null,
+ detail: approvalDetail(approval.type, approval.payload),
+ }));
+ }
+
+ const interactionRows = await db
+ .select({
+ id: issueThreadInteractions.id,
+ issueId: issueThreadInteractions.issueId,
+ kind: issueThreadInteractions.kind,
+ status: issueThreadInteractions.status,
+ title: issueThreadInteractions.title,
+ summary: issueThreadInteractions.summary,
+ payload: issueThreadInteractions.payload,
+ createdAt: issueThreadInteractions.createdAt,
+ updatedAt: issueThreadInteractions.updatedAt,
+ })
+ .from(issueThreadInteractions)
+ .where(and(
+ eq(issueThreadInteractions.companyId, companyId),
+ inArray(issueThreadInteractions.status, [...PENDING_INTERACTION_STATUSES]),
+ ))
+ .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,
+ sourceKind: "issue_thread_interaction",
+ subject: {
+ kind: "interaction",
+ id: interaction.id,
+ companyId,
+ 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,
+ isPlanTarget,
+ targetDocumentKey: isPlanTarget ? "plan" : null,
+ },
+ },
+ whyNow: `${interactionLabel(interaction.kind)} on an issue thread.`,
+ decisionVerbs: interactionVerbs(interaction.kind, payload),
+ inlineResolvable: true,
+ entryRule: "issue_thread_interactions.status = 'pending'",
+ exitRule: "Interaction resolves, expires, fails, or is cancelled.",
+ dedupKey,
+ severity: "medium",
+ activityAt: toIso(interaction.updatedAt),
+ createdAt: toIso(interaction.createdAt),
+ updatedAt: toIso(interaction.updatedAt),
+ relatedIssue: issue ? issueSubject(prefix, issue) : null,
+ ...issueContext(issue),
+ detail,
+ }));
+ }
+
+ const pendingJoins = await db
+ .select({
+ id: joinRequests.id,
+ requestType: joinRequests.requestType,
+ status: joinRequests.status,
+ requestingUserId: joinRequests.requestingUserId,
+ requestEmailSnapshot: joinRequests.requestEmailSnapshot,
+ agentName: joinRequests.agentName,
+ adapterType: joinRequests.adapterType,
+ createdAt: joinRequests.createdAt,
+ updatedAt: joinRequests.updatedAt,
+ })
+ .from(joinRequests)
+ .innerJoin(invites, eq(joinRequests.inviteId, invites.id))
+ .where(and(
+ eq(joinRequests.companyId, companyId),
+ eq(invites.companyId, companyId),
+ eq(joinRequests.status, "pending_approval"),
+ ))
+ .orderBy(desc(joinRequests.updatedAt), desc(joinRequests.id));
+
+ for (const join of pendingJoins) {
+ const label = join.requestType === "agent"
+ ? join.agentName ?? "Agent join request"
+ : join.requestEmailSnapshot ?? join.requestingUserId ?? "Human join request";
+ const dedupKey = `join:${join.id}`;
+ add(createItem({
+ companyId,
+ sourceKind: "join_request",
+ subject: {
+ kind: "join_request",
+ id: join.id,
+ companyId,
+ title: label,
+ identifier: null,
+ status: join.status,
+ href: `/${prefix}/settings/access`,
+ metadata: {
+ requestType: join.requestType,
+ requestingUserId: join.requestingUserId,
+ adapterType: join.adapterType,
+ },
+ },
+ whyNow: "Join request is pending approval.",
+ decisionVerbs: decisionVerbs(
+ { id: "approve", label: "Approve", description: "Approve this join request." },
+ { id: "reject", label: "Reject", description: "Reject this join request." },
+ ),
+ inlineResolvable: true,
+ entryRule: "join_requests.status = 'pending_approval'",
+ exitRule: "Join request is approved or rejected.",
+ dedupKey,
+ severity: "medium",
+ activityAt: toIso(join.updatedAt),
+ createdAt: toIso(join.createdAt),
+ updatedAt: toIso(join.updatedAt),
+ relatedIssue: null,
+ detail: genericDetail(label, []),
+ }));
+ }
+
+ const recoveryRows = await db
+ .select()
+ .from(issueRecoveryActions)
+ .where(and(
+ eq(issueRecoveryActions.companyId, companyId),
+ inArray(issueRecoveryActions.status, [...OPEN_RECOVERY_STATUSES]),
+ inArray(issueRecoveryActions.ownerType, [...HUMAN_RECOVERY_OWNER_TYPES]),
+ ))
+ .orderBy(desc(issueRecoveryActions.updatedAt), desc(issueRecoveryActions.id));
+ const recoveryIssueMap = await issueSummaryMap(
+ 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;
+ const recoveryIssue = recovery.recoveryIssueId ? recoveryIssueMap.get(recovery.recoveryIssueId) ?? null : null;
+ const dedupKey = `recovery:${recovery.kind}:${recovery.sourceIssueId}:${recovery.cause}:${recovery.fingerprint}`;
+ add(createItem({
+ companyId,
+ sourceKind: "recovery_action",
+ subject: {
+ kind: "recovery_action",
+ id: recovery.id,
+ companyId,
+ title: recovery.nextAction,
+ identifier: null,
+ status: recovery.status,
+ href: recoveryIssue ? issueHref(prefix, recoveryIssue) : sourceIssue ? issueHref(prefix, sourceIssue) : null,
+ metadata: {
+ kind: recovery.kind,
+ cause: recovery.cause,
+ ownerType: recovery.ownerType,
+ ownerUserId: recovery.ownerUserId,
+ sourceIssueId: recovery.sourceIssueId,
+ recoveryIssueId: recovery.recoveryIssueId,
+ },
+ },
+ whyNow: recovery.status === "escalated"
+ ? "Recovery action escalated to a human owner."
+ : "Recovery action is assigned to a human owner.",
+ decisionVerbs: decisionVerbs(
+ { id: "resolve", label: "Resolve", description: "Record the recovery outcome." },
+ { id: "reassign", label: "Reassign", description: "Move the recovery to another owner." },
+ { id: "cancel", label: "Cancel", description: "Cancel the recovery action." },
+ ),
+ inlineResolvable: false,
+ entryRule: "issue_recovery_actions.status in ('active','escalated') and owner_type in ('user','board')",
+ exitRule: "Recovery action resolves, is cancelled, or moves back to an agent/system owner.",
+ dedupKey,
+ severity: recovery.status === "escalated" ? "high" : "medium",
+ activityAt: toIso(recovery.updatedAt),
+ createdAt: toIso(recovery.createdAt),
+ updatedAt: toIso(recovery.updatedAt),
+ relatedIssue: sourceIssue ? issueSubject(prefix, sourceIssue) : null,
+ ...issueContext(sourceIssue),
+ detail: genericDetail(recovery.nextAction, issueImages(recoveryImageMap, recovery.sourceIssueId)),
+ }));
+ }
+
+ const productivityRows = await db
+ .select({
+ id: issues.id,
+ companyId: issues.companyId,
+ identifier: issues.identifier,
+ title: issues.title,
+ status: issues.status,
+ priority: issues.priority,
+ originId: issues.originId,
+ originFingerprint: issues.originFingerprint,
+ assigneeAgentId: issues.assigneeAgentId,
+ assigneeUserId: issues.assigneeUserId,
+ createdAt: issues.createdAt,
+ updatedAt: issues.updatedAt,
+ })
+ .from(issues)
+ .where(and(
+ eq(issues.companyId, companyId),
+ eq(issues.originKind, PRODUCTIVITY_REVIEW_ORIGIN_KIND),
+ isNull(issues.hiddenAt),
+ isNotNull(issues.assigneeUserId),
+ notInArray(issues.status, [...PRODUCTIVITY_REVIEW_TERMINAL_STATUSES]),
+ ))
+ .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 = 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({
+ companyId,
+ sourceKind: "productivity_review",
+ subject: issueSubject(prefix, reviewIssue),
+ whyNow: "Productivity review is awaiting a human decision.",
+ decisionVerbs: decisionVerbs(
+ { id: "resolve", label: "Resolve", description: "Record a productivity review outcome." },
+ { id: "dismiss", label: "Dismiss", description: "Dismiss this review for now." },
+ { id: "reassign", label: "Reassign", description: "Move the review to another owner." },
+ ),
+ inlineResolvable: false,
+ entryRule: "Open issue_productivity_review issue assigned to a user.",
+ exitRule: "Review issue is done/cancelled or no longer assigned to a user.",
+ dedupKey,
+ severity: review.priority === "critical" ? "critical" : review.priority === "high" ? "high" : "medium",
+ activityAt: toIso(review.updatedAt),
+ 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 });
+ 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, 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." },
+ { id: "reassign", label: "Reassign", description: "Assign the stalled blocker to a live owner." },
+ { id: "nudge", label: "Nudge", description: "Wake or prompt the current owner." },
+ ),
+ inlineResolvable: false,
+ entryRule: "blocked issue has blockerAttention.state = 'stalled'",
+ exitRule: "Blocker chain is no longer stalled or the issue leaves blocked status.",
+ dedupKey,
+ severity: "high",
+ activityAt: toIso(issue.updatedAt),
+ createdAt: toIso(issue.createdAt),
+ updatedAt: toIso(issue.updatedAt),
+ relatedIssue: null,
+ ...issueContext(issueSummary),
+ detail: { kind: "blocker", blockingIssue, images: issueImages(blockedImageMap, issue.id) },
+ }));
+ }
+
+ const reviewRows = await db
+ .select({
+ id: issues.id,
+ companyId: issues.companyId,
+ identifier: issues.identifier,
+ title: issues.title,
+ status: issues.status,
+ priority: issues.priority,
+ assigneeAgentId: issues.assigneeAgentId,
+ assigneeUserId: issues.assigneeUserId,
+ executionState: issues.executionState,
+ createdAt: issues.createdAt,
+ updatedAt: issues.updatedAt,
+ })
+ .from(issues)
+ .where(and(eq(issues.companyId, companyId), eq(issues.status, "in_review"), isNull(issues.hiddenAt)))
+ .orderBy(desc(issues.updatedAt), desc(issues.id));
+ const reviewIssueIds = reviewRows.map((row) => row.id);
+ const pendingReviewApprovalRows = reviewIssueIds.length === 0
+ ? []
+ : await db
+ .select({ issueId: issueApprovals.issueId, approvalId: approvals.id })
+ .from(issueApprovals)
+ .innerJoin(approvals, eq(issueApprovals.approvalId, approvals.id))
+ .where(and(
+ eq(issueApprovals.companyId, companyId),
+ eq(approvals.companyId, companyId),
+ inArray(issueApprovals.issueId, reviewIssueIds),
+ 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);
+ const currentParticipant = state?.status === "pending" ? state.currentParticipant : null;
+ const hasHumanParticipant = currentParticipant?.type === "user";
+ const pendingApprovalId = pendingApprovalByIssueId.get(review.id) ?? null;
+ if (!hasHumanParticipant && !review.assigneeUserId && !pendingApprovalId) continue;
+ const issue = reviewIssueMap.get(review.id);
+ if (!issue) continue;
+ const dedupKey = `review:${review.id}`;
+ add(createItem({
+ companyId,
+ sourceKind: "review",
+ subject: issueSubject(prefix, issue),
+ whyNow: pendingApprovalId
+ ? "Issue is in review with a linked pending approval."
+ : hasHumanParticipant
+ ? "Issue is in review and the current execution participant is a user."
+ : "Issue is in review and assigned to a user.",
+ decisionVerbs: decisionVerbs(
+ { id: "approve", label: "Approve", description: "Approve the review and advance the issue." },
+ { id: "request_changes", label: "Request changes", description: "Return the issue to the assignee with changes requested." },
+ ),
+ inlineResolvable: false,
+ entryRule: "issues.status = 'in_review' and human reviewer, user assignee, or linked pending approval exists.",
+ exitRule: "Issue leaves in_review or the human review path resolves.",
+ dedupKey,
+ severity: "medium",
+ activityAt: toIso(review.updatedAt),
+ createdAt: toIso(review.createdAt),
+ updatedAt: toIso(review.updatedAt),
+ relatedIssue: null,
+ ...issueContext(issue),
+ detail: genericDetail(review.title, issueImages(reviewImageMap, review.id)),
+ }));
+ }
+
+ const exhaustedRunRows = await db
+ .select({
+ id: heartbeatRuns.id,
+ companyId: heartbeatRuns.companyId,
+ agentId: heartbeatRuns.agentId,
+ agentName: agents.name,
+ status: heartbeatRuns.status,
+ error: heartbeatRuns.error,
+ errorCode: heartbeatRuns.errorCode,
+ contextSnapshot: heartbeatRuns.contextSnapshot,
+ createdAt: heartbeatRuns.createdAt,
+ updatedAt: heartbeatRuns.updatedAt,
+ finishedAt: heartbeatRuns.finishedAt,
+ exhaustionMessage: heartbeatRunEvents.message,
+ })
+ .from(heartbeatRuns)
+ .innerJoin(agents, eq(heartbeatRuns.agentId, agents.id))
+ .innerJoin(heartbeatRunEvents, eq(heartbeatRunEvents.runId, heartbeatRuns.id))
+ .where(and(
+ eq(heartbeatRuns.companyId, companyId),
+ eq(agents.companyId, companyId),
+ notInArray(agents.status, ["terminated"]),
+ inArray(heartbeatRuns.status, [...FAILED_RUN_STATUSES]),
+ eq(heartbeatRunEvents.companyId, companyId),
+ eq(heartbeatRunEvents.eventType, "lifecycle"),
+ sql`${heartbeatRunEvents.message} like 'Bounded retry exhausted%'`,
+ ))
+ .orderBy(desc(heartbeatRuns.createdAt), desc(heartbeatRunEvents.id));
+
+ const latestExhaustedByRunId = new Map();
+ for (const row of exhaustedRunRows) {
+ 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,
+ failedIssueIds,
+ );
+ const failedImageMap = await issueImageMap(db, companyId, failedIssueIds);
+ const failedAgentIds = [...new Set(failedRows.map((row) => row.agentId))];
+ const oldestFailedRunCreatedAt = failedRows.reduce((oldest, row) => {
+ if (!oldest || row.createdAt < oldest) return row.createdAt;
+ return oldest;
+ }, null);
+ const latestRunCreatedAtByKey = new Map();
+ if (oldestFailedRunCreatedAt && failedAgentIds.length > 0) {
+ const newerRuns = await db
+ .select({
+ agentId: heartbeatRuns.agentId,
+ createdAt: heartbeatRuns.createdAt,
+ contextSnapshot: heartbeatRuns.contextSnapshot,
+ })
+ .from(heartbeatRuns)
+ .where(and(
+ eq(heartbeatRuns.companyId, companyId),
+ inArray(heartbeatRuns.agentId, failedAgentIds),
+ gt(heartbeatRuns.createdAt, oldestFailedRunCreatedAt),
+ ));
+ for (const newerRun of newerRuns) {
+ const newerRunKey = `${newerRun.agentId}:${readRunIssueId(newerRun.contextSnapshot) ?? ""}`;
+ const latestCreatedAt = latestRunCreatedAtByKey.get(newerRunKey);
+ if (!latestCreatedAt || newerRun.createdAt > latestCreatedAt) {
+ latestRunCreatedAtByKey.set(newerRunKey, newerRun.createdAt);
+ }
+ }
+ }
+ for (const run of failedRows) {
+ const issueId = readRunIssueId(run.contextSnapshot);
+ const runKey = `${run.agentId}:${issueId ?? ""}`;
+ const hasNewerRun = (latestRunCreatedAtByKey.get(runKey)?.getTime() ?? 0) > run.createdAt.getTime();
+ if (hasNewerRun) continue;
+
+ const issue = issueId ? failedIssueMap.get(issueId) ?? null : null;
+ const dedupKey = `run:${run.id}`;
+ add(createItem({
+ companyId,
+ sourceKind: "failed_run",
+ subject: {
+ kind: "run",
+ id: run.id,
+ companyId,
+ title: `${run.agentName} run ${run.status}`,
+ identifier: null,
+ status: run.status,
+ href: `/${prefix}/agents/${run.agentId}/runs/${run.id}`,
+ metadata: {
+ agentId: run.agentId,
+ agentName: run.agentName,
+ issueId,
+ errorCode: run.errorCode,
+ error: run.error,
+ retryExhaustedReason: run.exhaustionMessage,
+ },
+ },
+ whyNow: "Run failed after automatic retries were exhausted.",
+ decisionVerbs: decisionVerbs(
+ { id: "retry", label: "Retry", description: "Retry the failed run or issue." },
+ { id: "reassign", label: "Reassign", description: "Move the work to another owner." },
+ { id: "dismiss", label: "Dismiss", description: "Dismiss this failed-run attention row." },
+ ),
+ inlineResolvable: true,
+ entryRule: "latest failed/timed_out run has a Bounded retry exhausted lifecycle event.",
+ exitRule: "A newer run exists for the same issue/agent pair or the row is dismissed.",
+ dedupKey,
+ severity: "high",
+ activityAt: toIso(run.finishedAt ?? run.updatedAt ?? run.createdAt),
+ 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),
+ },
+ }));
+ }
+
+ const budgetOverview = await budgetService(db).overview(companyId);
+ for (const incident of budgetOverview.activeIncidents) {
+ const observedPercent = budgetObservedPercent(incident.amountObserved, incident.amountLimit);
+ if (incident.thresholdType !== "hard" && observedPercent < 85) continue;
+ const dedupKey = `budget:${incident.policyId}:${toIso(incident.windowStart)}:${incident.thresholdType}`;
+ add(createItem({
+ companyId,
+ sourceKind: "budget_alert",
+ subject: {
+ kind: "budget_incident",
+ id: incident.id,
+ companyId,
+ title: `${incident.scopeName} budget ${incident.thresholdType === "hard" ? "hard stop" : "warning"}`,
+ identifier: null,
+ status: incident.status,
+ href: `/${prefix}/costs`,
+ metadata: {
+ policyId: incident.policyId,
+ scopeType: incident.scopeType,
+ scopeId: incident.scopeId,
+ thresholdType: incident.thresholdType,
+ amountObserved: incident.amountObserved,
+ amountLimit: incident.amountLimit,
+ observedPercent,
+ approvalId: incident.approvalId,
+ approvalStatus: incident.approvalStatus,
+ },
+ },
+ whyNow: incident.thresholdType === "hard"
+ ? "Budget hard stop was reached."
+ : "Budget crossed the 85% warning threshold.",
+ decisionVerbs: decisionVerbs(
+ { id: "raise_budget_and_resume", label: "Raise budget", description: "Raise the budget and resume paused work." },
+ { id: "keep_paused", label: "Keep paused", description: "Dismiss or keep the budget stop in place." },
+ ),
+ inlineResolvable: true,
+ entryRule: "open budget incident is hard, or soft with observed spend >= 85% of limit.",
+ exitRule: "Budget incident is resolved or dismissed.",
+ dedupKey,
+ severity: incident.thresholdType === "hard" ? "high" : "medium",
+ activityAt: toIso(incident.updatedAt),
+ createdAt: toIso(incident.createdAt),
+ updatedAt: toIso(incident.updatedAt),
+ relatedIssue: null,
+ detail: {
+ kind: "budget",
+ observedPercent,
+ amountObserved: incident.amountObserved,
+ amountLimit: incident.amountLimit,
+ images: [],
+ },
+ }));
+ }
+
+ const erroredAgents = await db
+ .select({
+ id: agents.id,
+ companyId: agents.companyId,
+ name: agents.name,
+ role: agents.role,
+ status: agents.status,
+ errorReason: agents.errorReason,
+ createdAt: agents.createdAt,
+ updatedAt: agents.updatedAt,
+ })
+ .from(agents)
+ .where(and(eq(agents.companyId, companyId), eq(agents.status, "error")))
+ .orderBy(desc(agents.updatedAt), desc(agents.id));
+
+ for (const agent of erroredAgents) {
+ const dedupKey = `agent_error:${agent.id}`;
+ add(createItem({
+ companyId,
+ sourceKind: "agent_error_alert",
+ subject: {
+ kind: "agent",
+ id: agent.id,
+ companyId,
+ title: agent.name,
+ identifier: null,
+ status: agent.status,
+ href: `/${prefix}/agents/${agent.id}`,
+ metadata: { role: agent.role, errorReason: agent.errorReason },
+ },
+ whyNow: "Agent is in error status and needs operator action or dismissal.",
+ decisionVerbs: decisionVerbs(
+ { id: "inspect", label: "Inspect", description: "Inspect the agent error." },
+ { id: "dismiss", label: "Dismiss", description: "Dismiss this alert." },
+ ),
+ inlineResolvable: true,
+ entryRule: "agents.status = 'error'",
+ exitRule: "Agent leaves error status or the row is dismissed.",
+ dedupKey,
+ severity: "high",
+ activityAt: toIso(agent.updatedAt),
+ createdAt: toIso(agent.createdAt),
+ updatedAt: toIso(agent.updatedAt),
+ relatedIssue: null,
+ detail: {
+ kind: "agent_error",
+ agentName: agent.name,
+ failureReasonExcerpt: excerpt(agent.errorReason),
+ images: [],
+ },
+ }));
+ }
+
+ const deduped = new Map();
+ for (const item of collected) {
+ const current = deduped.get(item.dedupKey);
+ deduped.set(item.dedupKey, current ? betterDuplicate(current, item) : item);
+ }
+
+ const items = [...deduped.values()]
+ .sort(compareAttentionItems)
+ .map((item, index) => ({ ...item, rank: index + 1 }));
+ const countsBySourceKind = emptyCounts();
+ for (const item of items) countsBySourceKind[item.sourceKind] += 1;
+
+ return {
+ companyId,
+ generatedAt: new Date().toISOString(),
+ totalCount: items.length,
+ countsBySourceKind,
+ items,
+ };
+ },
+ };
+}
diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts
index 32432a830d..7d6992f853 100644
--- a/server/src/services/heartbeat.ts
+++ b/server/src/services/heartbeat.ts
@@ -3951,6 +3951,8 @@ const INTERACTION_CONTINUATION_CONTEXT_KEYS = [
"interactionStatus",
"continuationPolicy",
"checkboxSelection",
+ "itemVerdicts",
+ "newlyResolvedItemIds",
] as const;
function isInteractionResolutionWakePayload(payload: Record | null | undefined) {
diff --git a/server/src/services/inbox-dismissals.ts b/server/src/services/inbox-dismissals.ts
index 68032c69dc..7b940f95e7 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;
},
};
}
diff --git a/server/src/services/index.ts b/server/src/services/index.ts
index 43524a5642..a1e15e48e4 100644
--- a/server/src/services/index.ts
+++ b/server/src/services/index.ts
@@ -58,6 +58,7 @@ export {
export { goalService } from "./goals.js";
export { activityService, type ActivityFilters } from "./activity.js";
export { workTimelineService, normalizeTimelineWindow } from "./work-timeline.js";
+export { attentionService } from "./attention.js";
export type {
WorkTimelineActor,
WorkTimelineEdge,
diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts
index 5b8498bdf0..4f0cdb3419 100644
--- a/server/src/services/instance-settings.ts
+++ b/server/src/services/instance-settings.ts
@@ -214,6 +214,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
enableCloudSync: parsed.data.enableCloudSync ?? false,
enableExternalObjects: parsed.data.enableExternalObjects ?? false,
enableBuiltInAgents: parsed.data.enableBuiltInAgents ?? false,
+ enableDecisions: parsed.data.enableDecisions ?? false,
enableGoalsSidebarLink: parsed.data.enableGoalsSidebarLink ?? false,
enableServerInfoDebugView: parsed.data.enableServerInfoDebugView ?? false,
autoRestartDevServerWhenIdle: parsed.data.autoRestartDevServerWhenIdle ?? false,
@@ -242,6 +243,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
enableCloudSync: false,
enableExternalObjects: false,
enableBuiltInAgents: false,
+ enableDecisions: false,
enableGoalsSidebarLink: false,
enableServerInfoDebugView: false,
autoRestartDevServerWhenIdle: false,
diff --git a/server/src/services/issue-thread-interactions.ts b/server/src/services/issue-thread-interactions.ts
index 62550db0dd..5a2a3ae19d 100644
--- a/server/src/services/issue-thread-interactions.ts
+++ b/server/src/services/issue-thread-interactions.ts
@@ -21,10 +21,14 @@ import type {
RequestCheckboxConfirmationInteraction,
RequestConfirmationInteraction,
RequestConfirmationTarget,
+ RequestItemVerdictsInteraction,
+ RequestItemVerdictsResult,
+ RequestItemVerdictsResultItem,
RejectIssueThreadInteraction,
RespondIssueThreadInteraction,
SuggestTasksInteraction,
SuggestTasksResultCreatedTask,
+ SubmitIssueThreadInteractionVerdicts,
} from "@paperclipai/shared";
import {
acceptIssueThreadInteractionSchema,
@@ -37,8 +41,11 @@ import {
requestCheckboxConfirmationResultSchema,
requestConfirmationPayloadSchema,
requestConfirmationResultSchema,
+ requestItemVerdictsPayloadSchema,
+ requestItemVerdictsResultSchema,
suggestTasksPayloadSchema,
suggestTasksResultSchema,
+ submitIssueThreadInteractionVerdictsSchema,
} from "@paperclipai/shared";
import { conflict, notFound, unprocessable } from "../errors.js";
import { getTelemetryClient } from "../telemetry.js";
@@ -85,19 +92,32 @@ type RequestConfirmationLikeInteraction =
| RequestConfirmationInteraction
| RequestCheckboxConfirmationInteraction;
-const USER_COMMENT_SUPERSEDABLE_INTERACTION_KINDS = [
+const TARGET_BOUND_INTERACTION_KINDS = [
...REQUEST_CONFIRMATION_INTERACTION_KINDS,
+ "request_item_verdicts",
+] as const;
+type TargetBoundInteractionKind = (typeof TARGET_BOUND_INTERACTION_KINDS)[number];
+type TargetBoundInteraction =
+ | RequestConfirmationLikeInteraction
+ | RequestItemVerdictsInteraction;
+
+const USER_COMMENT_SUPERSEDABLE_INTERACTION_KINDS = [
+ ...TARGET_BOUND_INTERACTION_KINDS,
"ask_user_questions",
] as const;
type UserCommentSupersedableKind = (typeof USER_COMMENT_SUPERSEDABLE_INTERACTION_KINDS)[number];
type UserCommentSupersedableInteraction =
- | RequestConfirmationLikeInteraction
+ | TargetBoundInteraction
| AskUserQuestionsInteraction;
function isRequestConfirmationLikeKind(kind: string): kind is RequestConfirmationLikeKind {
return (REQUEST_CONFIRMATION_INTERACTION_KINDS as readonly string[]).includes(kind);
}
+function isTargetBoundInteractionKind(kind: string): kind is TargetBoundInteractionKind {
+ return (TARGET_BOUND_INTERACTION_KINDS as readonly string[]).includes(kind);
+}
+
function isUserCommentSupersedableKind(kind: string): kind is UserCommentSupersedableKind {
return (USER_COMMENT_SUPERSEDABLE_INTERACTION_KINDS as readonly string[]).includes(kind);
}
@@ -167,6 +187,13 @@ function hydrateInteraction(
payload: requestCheckboxConfirmationPayloadSchema.parse(row.payload),
result: row.result ? requestCheckboxConfirmationResultSchema.parse(row.result) : null,
} satisfies RequestCheckboxConfirmationInteraction;
+ case "request_item_verdicts":
+ return {
+ ...base,
+ kind: "request_item_verdicts",
+ payload: requestItemVerdictsPayloadSchema.parse(row.payload),
+ result: row.result ? requestItemVerdictsResultSchema.parse(row.result) : null,
+ } satisfies RequestItemVerdictsInteraction;
default:
throw unprocessable(`Unknown interaction kind: ${row.kind}`);
}
@@ -227,6 +254,14 @@ function normalizeCreateInteractionInput(input: CreateIssueThreadInteraction): C
supersedeOnUserComment: input.payload.supersedeOnUserComment ?? true,
},
};
+ case "request_item_verdicts":
+ return {
+ ...input,
+ payload: {
+ ...input.payload,
+ supersedeOnUserComment: input.payload.supersedeOnUserComment ?? true,
+ },
+ };
default:
return input;
}
@@ -243,6 +278,17 @@ function buildSupersededByCommentResult(row: IssueThreadInteractionRow, commentI
} as const;
}
+ if (row.kind === "request_item_verdicts") {
+ const interaction = hydrateInteraction(row) as RequestItemVerdictsInteraction;
+ return {
+ version: 1,
+ outcome: "superseded_by_comment",
+ complete: false,
+ items: interaction.result?.items ?? [],
+ commentId,
+ } satisfies RequestItemVerdictsResult;
+ }
+
return {
version: 1,
outcome: "superseded_by_comment",
@@ -250,6 +296,28 @@ function buildSupersededByCommentResult(row: IssueThreadInteractionRow, commentI
} as const;
}
+function buildStaleTargetResult(
+ row: IssueThreadInteractionRow,
+ staleTarget: RequestConfirmationTarget | null,
+) {
+ if (row.kind === "request_item_verdicts") {
+ const interaction = hydrateInteraction(row) as RequestItemVerdictsInteraction;
+ return {
+ version: 1,
+ outcome: "stale_target",
+ complete: false,
+ items: interaction.result?.items ?? [],
+ staleTarget,
+ } satisfies RequestItemVerdictsResult;
+ }
+
+ return {
+ version: 1,
+ outcome: "stale_target",
+ staleTarget,
+ } as const;
+}
+
function resolveActorKind(interaction: Pick) {
if (interaction.resolvedByAgentId) return "agent";
if (interaction.resolvedByUserId) return "user";
@@ -263,10 +331,14 @@ function resolveCreatorKind(interaction: Pick option.id);
}
+function resolveRequestItemVerdictSubmissions(args: {
+ interaction: RequestItemVerdictsInteraction;
+ input: SubmitIssueThreadInteractionVerdicts;
+ actor: InteractionActor;
+ now: Date;
+}) {
+ if (!args.actor.userId) {
+ throw unprocessable("request_item_verdicts submissions require a user actor");
+ }
+
+ const existingItems = args.interaction.result?.items ?? [];
+ const existingById = new Map(existingItems.map((item) => [item.id, item] as const));
+ const payloadItemIds = new Set(args.interaction.payload.items.map((item) => item.id));
+ const enabledVerdicts = new Set(args.interaction.payload.verdicts ?? ["approve", "reject"]);
+ const requireReasonOn = new Set(args.interaction.payload.requireReasonOn ?? ["reject"]);
+ const newlyResolvedById = new Map();
+ const newlyResolvedItemIds: string[] = [];
+
+ for (const submitted of args.input.verdicts) {
+ if (!payloadItemIds.has(submitted.id)) {
+ throw unprocessable(`Unknown item verdict id: ${submitted.id}`);
+ }
+ if (existingById.has(submitted.id)) {
+ continue;
+ }
+ if (!enabledVerdicts.has(submitted.verdict)) {
+ throw unprocessable(`Verdict ${submitted.verdict} is not enabled for this item verdict request`);
+ }
+
+ const reason = submitted.reason?.trim() ?? "";
+ if (requireReasonOn.has(submitted.verdict) && reason.length === 0) {
+ throw unprocessable(`A reason is required when verdict is ${submitted.verdict}`);
+ }
+
+ if (newlyResolvedById.has(submitted.id)) {
+ continue;
+ }
+ newlyResolvedById.set(submitted.id, {
+ id: submitted.id,
+ verdict: submitted.verdict,
+ ...(reason ? { reason } : {}),
+ resolvedByUserId: args.actor.userId,
+ resolvedAt: args.now,
+ });
+ newlyResolvedItemIds.push(submitted.id);
+ }
+
+ const nextById = new Map(existingItems.map((item) => [item.id, item]));
+ for (const [id, item] of newlyResolvedById) {
+ nextById.set(id, item);
+ }
+ const items = args.interaction.payload.items
+ .map((item) => nextById.get(item.id))
+ .filter((item): item is RequestItemVerdictsResultItem => Boolean(item));
+
+ return {
+ items,
+ complete: items.length === args.interaction.payload.items.length,
+ newlyResolvedItemIds,
+ };
+}
+
function normalizeQuestionAnswers(args: {
questions: AskUserQuestionsInteraction["payload"]["questions"];
answers: RespondIssueThreadInteraction["answers"];
@@ -653,8 +801,8 @@ async function expireStaleRequestConfirmationTarget(db: Db | any, args: {
row: IssueThreadInteractionRow;
actor: InteractionActor;
}): Promise {
- if (!isRequestConfirmationLikeKind(args.row.kind) || args.row.status !== "pending") return null;
- const interaction = hydrateInteraction(args.row) as RequestConfirmationLikeInteraction;
+ if (!isTargetBoundInteractionKind(args.row.kind) || args.row.status !== "pending") return null;
+ const interaction = hydrateInteraction(args.row) as TargetBoundInteraction;
const target = interaction.payload.target ?? null;
if (!target) return null;
if (target.type !== "issue_document") return null;
@@ -686,9 +834,7 @@ async function expireStaleRequestConfirmationTarget(db: Db | any, args: {
}
: interaction.payload,
result: {
- version: 1,
- outcome: "stale_target",
- staleTarget: target,
+ ...buildStaleTargetResult(args.row, target),
},
resolvedByAgentId: args.actor.agentId ?? null,
resolvedByUserId: args.actor.userId ?? null,
@@ -1002,7 +1148,11 @@ export function issueThreadInteractionService(db: Db) {
}
}
- if (data.kind === "request_confirmation" || data.kind === "request_checkbox_confirmation") {
+ if (
+ data.kind === "request_confirmation"
+ || data.kind === "request_checkbox_confirmation"
+ || data.kind === "request_item_verdicts"
+ ) {
await assertRequestConfirmationTargetIsCurrent(db, {
companyId: issue.companyId,
issueId: issue.id,
@@ -1274,6 +1424,106 @@ export function issueThreadInteractionService(db: Db) {
}
},
+ submitItemVerdicts: async (
+ issue: { id: string; companyId: string },
+ interactionId: string,
+ input: SubmitIssueThreadInteractionVerdicts,
+ actor: InteractionActor,
+ ): Promise<{ interaction: IssueThreadInteraction; newlyResolvedItemIds: string[] }> => {
+ const data = submitIssueThreadInteractionVerdictsSchema.parse(input);
+ const submission = await db.transaction(async (tx) => {
+ const current = await tx
+ .select()
+ .from(issueThreadInteractions)
+ .where(eq(issueThreadInteractions.id, interactionId))
+ .for("update")
+ .then((rows) => rows[0] ?? null);
+
+ if (!current) throw notFound("Interaction not found");
+ if (current.companyId !== issue.companyId || current.issueId !== issue.id) {
+ throw notFound("Interaction not found");
+ }
+ if (current.kind !== "request_item_verdicts") {
+ throw unprocessable("Only request_item_verdicts interactions can receive item verdicts");
+ }
+
+ const interaction = hydrateInteraction(current) as RequestItemVerdictsInteraction;
+ if (current.status !== "pending") {
+ if (current.status === "answered") {
+ const resolvedIds = new Set(interaction.result?.items.map((item) => item.id) ?? []);
+ const payloadIds = new Set(interaction.payload.items.map((item) => item.id));
+ for (const submitted of data.verdicts) {
+ if (!payloadIds.has(submitted.id)) {
+ throw unprocessable(`Unknown item verdict id: ${submitted.id}`);
+ }
+ if (!resolvedIds.has(submitted.id)) {
+ throw conflict("Interaction has already been resolved");
+ }
+ }
+ return { interaction, newlyResolvedItemIds: [], resolved: false };
+ }
+ throw conflict("Interaction has already been resolved");
+ }
+
+ const expired = await expireStaleRequestConfirmationTarget(tx, {
+ row: current,
+ actor,
+ });
+ if (expired) {
+ return { interaction: expired, newlyResolvedItemIds: [], resolved: false };
+ }
+
+ const now = new Date();
+ const { items, complete, newlyResolvedItemIds } = resolveRequestItemVerdictSubmissions({
+ interaction,
+ input: data,
+ actor,
+ now,
+ });
+ if (newlyResolvedItemIds.length === 0) {
+ return { interaction, newlyResolvedItemIds: [], resolved: false };
+ }
+
+ const result = {
+ version: 1,
+ outcome: "resolved",
+ complete,
+ items,
+ } satisfies RequestItemVerdictsResult;
+ const [updated] = await tx
+ .update(issueThreadInteractions)
+ .set({
+ status: complete ? "answered" : "pending",
+ result,
+ resolvedByAgentId: complete ? actor.agentId ?? null : null,
+ resolvedByUserId: complete ? actor.userId ?? null : null,
+ resolvedAt: complete ? now : null,
+ updatedAt: now,
+ })
+ .where(and(
+ eq(issueThreadInteractions.id, interactionId),
+ eq(issueThreadInteractions.status, "pending"),
+ ))
+ .returning();
+
+ if (!updated) {
+ throw conflict("Interaction has already been resolved");
+ }
+
+ await touchIssue(tx, issue.id);
+ return {
+ interaction: hydrateInteraction(updated),
+ newlyResolvedItemIds,
+ resolved: complete,
+ };
+ });
+
+ if (submission.resolved) {
+ await emitInteractionResolvedTelemetry(db, submission.interaction);
+ }
+ return submission;
+ },
+
rejectSuggestedTasks: async (
issue: { id: string; companyId: string },
interactionId: string,
@@ -1447,6 +1697,8 @@ export function issueThreadInteractionService(db: Db) {
const confirmationRowIds = commentRows
.filter((row) => isRequestConfirmationLikeKind(row.kind))
.map((row) => row.id);
+ const itemVerdictRows = commentRows
+ .filter((row) => row.kind === "request_item_verdicts");
if (questionRowIds.length > 0) {
const sampleQuestionRow = commentRows.find((row) => row.kind === "ask_user_questions");
@@ -1489,6 +1741,25 @@ export function issueThreadInteractionService(db: Db) {
.returning();
expired.push(...updatedRows.map(hydrateInteraction));
}
+
+ for (const row of itemVerdictRows) {
+ const [updated] = await db
+ .update(issueThreadInteractions)
+ .set({
+ status: "expired",
+ result: buildSupersededByCommentResult(row, comment.id),
+ resolvedByAgentId: null,
+ resolvedByUserId: comment.authorUserId,
+ resolvedAt: now,
+ updatedAt: now,
+ })
+ .where(and(
+ eq(issueThreadInteractions.id, row.id),
+ eq(issueThreadInteractions.status, "pending"),
+ ))
+ .returning();
+ if (updated) expired.push(hydrateInteraction(updated));
+ }
}
if (expired.length > 0) {
@@ -1509,12 +1780,12 @@ export function issueThreadInteractionService(db: Db) {
.where(and(
eq(issueThreadInteractions.companyId, issue.companyId),
eq(issueThreadInteractions.issueId, issue.id),
- inArray(issueThreadInteractions.kind, [...REQUEST_CONFIRMATION_INTERACTION_KINDS]),
+ inArray(issueThreadInteractions.kind, [...TARGET_BOUND_INTERACTION_KINDS]),
eq(issueThreadInteractions.status, "pending"),
));
const staleRows = rows.filter((row) => {
- const interaction = hydrateInteraction(row) as RequestConfirmationLikeInteraction;
+ const interaction = hydrateInteraction(row) as TargetBoundInteraction;
const target = interaction.payload.target;
if (!target || target.type !== "issue_document") return false;
const targetIssueId = target.issueId ?? issue.id;
@@ -1533,7 +1804,7 @@ export function issueThreadInteractionService(db: Db) {
const now = new Date();
const expired: IssueThreadInteraction[] = [];
for (const row of staleRows) {
- const interaction = hydrateInteraction(row) as RequestConfirmationLikeInteraction;
+ const interaction = hydrateInteraction(row) as TargetBoundInteraction;
const target = interaction.payload.target ?? null;
const currentTarget = buildIssueDocumentTargetFromDocument({
issueId: issue.id,
@@ -1549,11 +1820,7 @@ export function issueThreadInteractionService(db: Db) {
target: currentTarget,
}
: interaction.payload,
- result: {
- version: 1,
- outcome: "stale_target",
- staleTarget: target,
- },
+ result: buildStaleTargetResult(row, target),
resolvedByAgentId: actor.agentId ?? null,
resolvedByUserId: actor.userId ?? null,
resolvedAt: now,
diff --git a/skills/paperclip/SKILL.md b/skills/paperclip/SKILL.md
index bb7f746341..bc26b123e9 100644
--- a/skills/paperclip/SKILL.md
+++ b/skills/paperclip/SKILL.md
@@ -200,20 +200,21 @@ POST /api/companies/{companyId}/approvals
Issue-thread interactions are first-class cards that render in the issue thread and capture a typed board/user response. Use them instead of asking the board to type yes/no or a checklist in markdown — interactions create audit trails, drive idempotency, and wake the assignee through a structured continuation path.
-Four kinds are supported. Pick the smallest kind that fits the decision shape:
+Five kinds are supported. Pick the smallest kind that fits the decision shape:
| Kind | When to use | When **not** to use |
| ------------------------------- | -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| `request_confirmation` | Single yes/no decision bound to a target (e.g. accept a plan revision, approve a launch). | Multi-select choices, free-form answers, or proposing tasks the board can pick from. |
| `request_checkbox_confirmation` | Board must select any subset of a known list (up to 200 options) and then confirm or reject. | Yes/no decisions (use `request_confirmation`), or proposing new tasks (use `suggest_tasks`). |
+| `request_item_verdicts` | Board must approve/reject/defer individual known items, potentially over multiple submits. | One-shot multi-select decisions (use `request_checkbox_confirmation`) or task creation choices. |
| `ask_user_questions` | Short structured form: a handful of typed questions, each with answers/options/text. | Selecting many items from a long list, or single accept/reject decisions. |
| `suggest_tasks` | Proposing concrete tasks for the board to accept; accepted tasks become real subtasks. | Asking the board to confirm a plan or arbitrary selection. Tasks are the unit; not arbitrary ids. |
Key shared semantics:
-- **Continuation policy.** `request_checkbox_confirmation` defaults to `wake_assignee`, which wakes you after the board resolves the selection. `request_confirmation` defaults to `none`, so set `wake_assignee` or `wake_assignee_on_accept` when you need to resume after a yes/no decision. `none` never wakes you — only use it when you truly do not need to resume.
-- **Target binding and staleness.** `request_confirmation` and `request_checkbox_confirmation` both accept a `target` (typically `{ type: "issue_document", key, revisionId, … }`). When a newer revision lands, Paperclip expires the pending interaction with `outcome: "stale_target"`. Rebuild against the latest revision and create a fresh interaction.
-- **Supersede on user comment.** Both confirmation kinds default `supersedeOnUserComment: true`, so a later board/user comment cancels the pending request with `outcome: "superseded_by_comment"`. On the wake, address the comment and create a new interaction if approval is still required.
+- **Continuation policy.** `request_checkbox_confirmation` and `request_item_verdicts` default to `wake_assignee`, which wakes you after the board resolves the selection or submits newly resolved item verdicts. `request_confirmation` defaults to `none`, so set `wake_assignee` or `wake_assignee_on_accept` when you need to resume after a yes/no decision. `none` never wakes you — only use it when you truly do not need to resume.
+- **Target binding and staleness.** `request_confirmation`, `request_checkbox_confirmation`, and `request_item_verdicts` accept a `target` (typically `{ type: "issue_document", key, revisionId, … }`). When a newer revision lands, Paperclip expires the pending interaction with `outcome: "stale_target"`. Rebuild against the latest revision and create a fresh interaction.
+- **Supersede on user comment.** Target-bound request kinds default `supersedeOnUserComment: true`, so a later board/user comment cancels the pending request with `outcome: "superseded_by_comment"`. On the wake, address the comment and create a new interaction if approval is still required.
- **Idempotency.** Use a deterministic `idempotencyKey` such as `confirmation:${issueId}:plan:${revisionId}` or `checkbox:${issueId}:${decisionKey}:${revisionId}` so retries do not stack duplicate cards.
- **Source issue posture.** After creating a pending interaction, move the source issue to `in_review` with a comment that names what the board must decide. The pending interaction is the explicit waiting path.
@@ -257,6 +258,35 @@ When the board accepts, your wake delivers `result.selectedOptionIds` — the op
For full payload schemas, validation limits (option count, label lengths, min/max rules), accept/reject route bodies, and result fields, see `references/api-reference.md` -> **Checkbox confirmations**.
+Create `request_item_verdicts` when each known item needs its own verdict:
+
+```json
+POST /api/issues/{issueId}/interactions
+{
+ "kind": "request_item_verdicts",
+ "idempotencyKey": "verdicts:{issueId}:generated-artifacts:{planRevisionId}",
+ "continuationPolicy": "wake_assignee",
+ "payload": {
+ "version": 1,
+ "prompt": "Review each generated artifact.",
+ "items": [
+ { "id": "api", "label": "API route", "description": "Partial submit endpoint." },
+ { "id": "docs", "label": "Docs update" }
+ ],
+ "verdicts": ["approve", "reject", "defer"],
+ "requireReasonOn": ["reject"],
+ "target": {
+ "type": "issue_document",
+ "issueId": "{issueId}",
+ "key": "plan",
+ "revisionId": "{latestPlanRevisionId}"
+ }
+ }
+}
+```
+
+The board submits verdicts with `POST /api/issues/{issueId}/interactions/{interactionId}/verdicts`. Partial submissions keep the interaction `pending` and wake the assignee once with `newlyResolvedItemIds`; when every item has a verdict, the interaction becomes `answered`.
+
## Niche Workflow Pointers
Load `references/workflows.md` when the task matches one of these:
diff --git a/skills/paperclip/references/api-reference.md b/skills/paperclip/references/api-reference.md
index 57ed100dea..1b26b59aef 100644
--- a/skills/paperclip/references/api-reference.md
+++ b/skills/paperclip/references/api-reference.md
@@ -960,6 +960,116 @@ Best practice:
- After creating a pending checkbox confirmation, move the source issue to `in_review` with a comment that names exactly what the board must decide. Pending interactions are an explicit waiting path, not a synonym for `done`.
- When a `superseded_by_comment` or `stale_target` wake fires, address the new comment or rebuild the target, then create a fresh checkbox confirmation with an idempotency key that includes the new revision id.
+### Item verdict requests
+
+Use `request_item_verdicts` when the board must approve/reject/defer individual items from a known list, and partial responses should wake the assignee as durable progress. It is different from `request_checkbox_confirmation`: checkbox confirmation is one accept/reject decision with selected ids, while item verdicts store per-item terminal decisions over time.
+
+Create an item-verdict request:
+
+```json
+POST /api/issues/{issueId}/interactions
+{
+ "kind": "request_item_verdicts",
+ "idempotencyKey": "verdicts:{issueId}:generated-artifacts:{planRevisionId}",
+ "title": "Review generated artifacts",
+ "continuationPolicy": "wake_assignee",
+ "payload": {
+ "version": 1,
+ "prompt": "Review each generated artifact.",
+ "detailsMarkdown": "Approve artifacts that are ready. Reject items that need another pass.",
+ "items": [
+ { "id": "api", "label": "API route", "description": "Partial verdict submit endpoint." },
+ { "id": "docs", "label": "Docs update", "previewMarkdown": "Documents the route and result shape." }
+ ],
+ "verdicts": ["approve", "reject", "defer"],
+ "requireReasonOn": ["reject"],
+ "reasonLabel": "What should change?",
+ "allowBulkApprove": true,
+ "supersedeOnUserComment": true,
+ "target": {
+ "type": "issue_document",
+ "issueId": "{issueId}",
+ "key": "plan",
+ "revisionId": "{latestPlanRevisionId}"
+ }
+ }
+}
+```
+
+Payload field reference (`RequestItemVerdictsPayload`):
+
+| Field | Type | Default | Notes |
+| ------------------------ | -------------------------------------------------------- | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
+| `version` | `1` | required | Versioned for forward compatibility. |
+| `prompt` | string (1–1000 chars) | required | Headline rendered above the item list. |
+| `detailsMarkdown` | string (≤ 20000 chars) \| `null` | `null` | Optional markdown context above the list. |
+| `items` | `[{ id, label, description?, previewMarkdown?, href?, attachmentId? }]` | required, 1–200 entries | Item `id` and `label` are 1–120 chars. Item ids must be unique. `href` must be safe: root-relative, fragment, or http(s). |
+| `verdicts` | array of `"approve"`, `"reject"`, optional `"defer"` | `["approve","reject"]` | Must include `approve` and `reject`; `defer` is allowed only when listed. |
+| `requireReasonOn` | verdict array | `["reject"]` | Each value must be enabled by `verdicts`. Pending submissions with those verdicts require a non-empty `reason`. |
+| `reasonLabel` | string (1–160) \| `null` | `null` | Field label for the verdict reason. |
+| `allowBulkApprove` | boolean | `true` | UI hint for bulk-approve affordances. Server still validates each submitted item id. |
+| `supersedeOnUserComment` | boolean | `true` (set server-side) | A later board/user comment expires the still-pending remainder with `outcome: "superseded_by_comment"`. |
+| `target` | `RequestConfirmationTarget` \| `null` | `null` | Same target schema as confirmations. Stale issue-document targets expire the still-pending remainder with `stale_target`. |
+
+Submit item verdicts (board action, requires board/user role; agents creating the interaction cannot submit verdicts):
+
+```json
+POST /api/issues/{issueId}/interactions/{interactionId}/verdicts
+{
+ "verdicts": [
+ { "id": "api", "verdict": "approve" },
+ { "id": "docs", "verdict": "reject", "reason": "Needs install instructions." }
+ ]
+}
+```
+
+Server behavior:
+
+- Unknown item ids return 422.
+- A verdict not listed in `payload.verdicts` returns 422.
+- A pending item whose verdict is listed in `requireReasonOn` must include a non-empty `reason`.
+- Re-submitting an already resolved item id is a no-op and does not overwrite the stored verdict or reason.
+- Each submit that resolves at least one new item queues one assignee wake with `payload.newlyResolvedItemIds` and `payload.itemVerdicts.newlyResolvedItemIds`. Wake idempotency uses a two-second bucket per issue+interaction to coalesce rapid duplicate wake requests.
+
+Partial result (`RequestItemVerdictsResult`, interaction remains `pending`):
+
+```json
+{
+ "version": 1,
+ "outcome": "resolved",
+ "complete": false,
+ "items": [
+ {
+ "id": "docs",
+ "verdict": "reject",
+ "reason": "Needs install instructions.",
+ "resolvedByUserId": "local-board",
+ "resolvedAt": "2026-07-09T12:00:00.000Z"
+ }
+ ]
+}
+```
+
+Complete result (interaction becomes `answered`):
+
+```json
+{
+ "version": 1,
+ "outcome": "resolved",
+ "complete": true,
+ "items": [
+ { "id": "api", "verdict": "approve", "resolvedByUserId": "local-board", "resolvedAt": "2026-07-09T12:00:00.000Z" },
+ { "id": "docs", "verdict": "reject", "reason": "Needs install instructions.", "resolvedByUserId": "local-board", "resolvedAt": "2026-07-09T12:00:00.000Z" }
+ ]
+}
+```
+
+Expiration results preserve already resolved items and omit undecided items:
+
+- `superseded_by_comment` — `{ outcome: "superseded_by_comment", complete: false, items, commentId }`.
+- `stale_target` — `{ outcome: "stale_target", complete: false, items, staleTarget }`.
+- `cancelled` is reserved for future explicit cancellation flows.
+
### Checking approval status
```
@@ -1069,10 +1179,11 @@ Terminal states: `done`, `cancelled`
| GET | `/api/issues/:issueId/comments/:commentId` | Get a specific comment by ID |
| POST | `/api/issues/:issueId/comments` | Add comment (@-mentions trigger wakeups) |
| GET | `/api/issues/:issueId/interactions` | List issue-thread interactions |
-| POST | `/api/issues/:issueId/interactions` | Create issue-thread interaction (`suggest_tasks`, `ask_user_questions`, `request_confirmation`, `request_checkbox_confirmation`) |
+| POST | `/api/issues/:issueId/interactions` | Create issue-thread interaction (`suggest_tasks`, `ask_user_questions`, `request_confirmation`, `request_checkbox_confirmation`, `request_item_verdicts`) |
| POST | `/api/issues/:issueId/interactions/:interactionId/accept` | Accept suggested tasks or confirmation (body: `selectedClientKeys` for `suggest_tasks`; `selectedOptionIds` for `request_checkbox_confirmation`) |
| POST | `/api/issues/:issueId/interactions/:interactionId/reject` | Reject suggested tasks or confirmation |
| POST | `/api/issues/:issueId/interactions/:interactionId/respond` | Respond to structured questions |
+| POST | `/api/issues/:issueId/interactions/:interactionId/verdicts` | Submit partial item verdicts for `request_item_verdicts` |
| GET | `/api/issues/:issueId/documents` | List issue documents |
| GET | `/api/issues/:issueId/documents/:key` | Get issue document by key |
| PUT | `/api/issues/:issueId/documents/:key` | Create or update issue document (send `baseRevisionId` when updating) |
diff --git a/ui/src/App.tsx b/ui/src/App.tsx
index 076484b302..46dd51a26c 100644
--- a/ui/src/App.tsx
+++ b/ui/src/App.tsx
@@ -37,6 +37,7 @@ import { ApprovalDetail } from "./pages/ApprovalDetail";
import { Costs } from "./pages/Costs";
import { Activity } from "./pages/Activity";
import { Inbox } from "./pages/Inbox";
+import { WhatNeedsMe } from "./pages/WhatNeedsMe";
import { BoardChat } from "./pages/BoardChat";
import { CompanySettings } from "./pages/CompanySettings";
import { CompanyEnvironments } from "./pages/CompanyEnvironments";
@@ -214,6 +215,7 @@ function boardRoutes() {
} />
} />
+ } />
} />
} />
} />
@@ -464,6 +466,7 @@ export function App() {
} />
} />
} />
+ } />
} />
} />
} />
diff --git a/ui/src/api/attention.ts b/ui/src/api/attention.ts
new file mode 100644
index 0000000000..b76f25baa6
--- /dev/null
+++ b/ui/src/api/attention.ts
@@ -0,0 +1,14 @@
+import type { AttentionFeed } from "@paperclipai/shared";
+import { api } from "./client";
+
+export const attentionApi = {
+ /**
+ * Fetch the ranked Decisions attention feed for a company. The server
+ * unions every attention source (approvals, interactions, recovery, reviews,
+ * failures, budget…) into one ranked queue with the §0 contract.
+ */
+ list: (companyId: string, options: { includeDismissed?: boolean } = {}) =>
+ api.get(
+ `/companies/${companyId}/attention${options.includeDismissed ? "?includeDismissed=true" : ""}`,
+ ),
+};
diff --git a/ui/src/api/inboxDismissals.ts b/ui/src/api/inboxDismissals.ts
index f80d3aef96..e8de87dfd1 100644
--- a/ui/src/api/inboxDismissals.ts
+++ b/ui/src/api/inboxDismissals.ts
@@ -5,4 +5,12 @@ export const inboxDismissalsApi = {
list: (companyId: string) => api.get(`/companies/${companyId}/inbox-dismissals`),
dismiss: (companyId: string, itemKey: string) =>
api.post(`/companies/${companyId}/inbox-dismissals`, { itemKey }),
+ snooze: (companyId: string, itemKey: string, snoozedUntil: string) =>
+ api.post(`/companies/${companyId}/inbox-dismissals`, {
+ itemKey,
+ kind: "snooze",
+ snoozedUntil,
+ }),
+ restore: (companyId: string, itemKey: string) =>
+ api.delete(`/companies/${companyId}/inbox-dismissals/${encodeURIComponent(itemKey)}`),
};
diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts
index 2fd1d1cebb..73e6798b89 100644
--- a/ui/src/api/issues.ts
+++ b/ui/src/api/issues.ts
@@ -252,6 +252,12 @@ export const issuesApi = {
data: { answers: AskUserQuestionsAnswer[]; summaryMarkdown?: string | null },
) =>
api.post(`/issues/${id}/interactions/${interactionId}/respond`, data),
+ submitInteractionVerdicts: (
+ id: string,
+ interactionId: string,
+ verdicts: { id: string; verdict: "approve" | "reject" | "defer"; reason?: string | null }[],
+ ) =>
+ api.post(`/issues/${id}/interactions/${interactionId}/verdicts`, { verdicts }),
getComment: (id: string, commentId: string) =>
api.get(`/issues/${id}/comments/${commentId}`),
listFeedbackVotes: (id: string) => api.get(`/issues/${id}/feedback-votes`),
diff --git a/ui/src/components/AttentionInteractionResolver.tsx b/ui/src/components/AttentionInteractionResolver.tsx
new file mode 100644
index 0000000000..8176f534d8
--- /dev/null
+++ b/ui/src/components/AttentionInteractionResolver.tsx
@@ -0,0 +1,143 @@
+import { useMemo } from "react";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { Loader2 } from "lucide-react";
+import type { Agent } from "@paperclipai/shared";
+import { issuesApi } from "../api/issues";
+import { queryKeys } from "../lib/queryKeys";
+import {
+ isIssueThreadInteraction,
+ type AskUserQuestionsAnswer,
+ type AskUserQuestionsInteraction,
+ type IssueThreadInteraction,
+ type RequestCheckboxConfirmationInteraction,
+ type RequestConfirmationInteraction,
+ type RequestItemVerdictsInteraction,
+ type RequestItemVerdictValue,
+ type SuggestTasksInteraction,
+} from "../lib/issue-thread-interactions";
+import { IssueThreadInteractionCard } from "./IssueThreadInteractionCard";
+
+interface AttentionInteractionResolverProps {
+ companyId: string;
+ issueId: string;
+ interactionId: string;
+ agentMap?: Map;
+ currentUserId?: string | null;
+ userLabelMap?: ReadonlyMap | null;
+ /** Called after a resolution so the parent can refresh the feed. */
+ onResolved?: () => void;
+}
+
+/**
+ * Lazily fetches the full issue-thread interaction referenced by an attention
+ * row and renders the existing {@link IssueThreadInteractionCard} inline, so
+ * confirmations and questions are answerable in-row without leaving the queue
+ * (converged PAP-12628). Reviews never reach here — they deep-link.
+ */
+export function AttentionInteractionResolver({
+ companyId,
+ issueId,
+ interactionId,
+ agentMap,
+ currentUserId,
+ userLabelMap,
+ onResolved,
+}: AttentionInteractionResolverProps) {
+ const queryClient = useQueryClient();
+
+ const { data: interactions, isLoading, error } = useQuery({
+ queryKey: queryKeys.issues.interactions(issueId),
+ queryFn: () => issuesApi.listInteractions(issueId),
+ enabled: !!issueId,
+ });
+
+ const interaction = useMemo(() => {
+ const match = (interactions ?? []).find((entry) => entry.id === interactionId);
+ return match && isIssueThreadInteraction(match) ? match : null;
+ }, [interactions, interactionId]);
+
+ const invalidate = () => {
+ queryClient.invalidateQueries({ queryKey: queryKeys.issues.interactions(issueId) });
+ queryClient.invalidateQueries({ queryKey: queryKeys.attention(companyId) });
+ onResolved?.();
+ };
+
+ const acceptMutation = useMutation({
+ mutationFn: (input: {
+ interaction: SuggestTasksInteraction | RequestConfirmationInteraction | RequestCheckboxConfirmationInteraction;
+ selectedClientKeys?: string[];
+ selectedOptionIds?: string[];
+ }) =>
+ issuesApi.acceptInteraction(issueId, input.interaction.id, {
+ selectedClientKeys: input.selectedClientKeys,
+ selectedOptionIds: input.selectedOptionIds,
+ }),
+ onSuccess: invalidate,
+ });
+
+ const rejectMutation = useMutation({
+ mutationFn: (input: { interactionId: string; reason?: string }) =>
+ issuesApi.rejectInteraction(issueId, input.interactionId, input.reason),
+ onSuccess: invalidate,
+ });
+
+ const respondMutation = useMutation({
+ mutationFn: (input: { interactionId: string; answers: AskUserQuestionsAnswer[] }) =>
+ issuesApi.respondToInteraction(issueId, input.interactionId, { answers: input.answers }),
+ onSuccess: invalidate,
+ });
+
+ const cancelMutation = useMutation({
+ mutationFn: (input: { interactionId: string }) =>
+ issuesApi.cancelInteraction(issueId, input.interactionId),
+ onSuccess: invalidate,
+ });
+
+ const verdictsMutation = useMutation({
+ mutationFn: (input: {
+ interactionId: string;
+ verdicts: { id: string; verdict: RequestItemVerdictValue; reason?: string }[];
+ }) => issuesApi.submitInteractionVerdicts(issueId, input.interactionId, input.verdicts),
+ onSuccess: invalidate,
+ });
+
+ if (isLoading) {
+ return (
+
+ Loading decision…
+
+ );
+ }
+
+ if (error || !interaction) {
+ return (
+
+ This decision is no longer available — it may have been resolved elsewhere.
+
+ );
+ }
+
+ return (
+
+ acceptMutation.mutateAsync({ interaction: target, selectedClientKeys, selectedOptionIds }).then(() => undefined)
+ }
+ onRejectInteraction={(target, reason) =>
+ rejectMutation.mutateAsync({ interactionId: target.id, reason }).then(() => undefined)
+ }
+ onSubmitInteractionAnswers={(target: AskUserQuestionsInteraction, answers) =>
+ respondMutation.mutateAsync({ interactionId: target.id, answers }).then(() => undefined)
+ }
+ onCancelInteraction={(target: AskUserQuestionsInteraction) =>
+ cancelMutation.mutateAsync({ interactionId: target.id }).then(() => undefined)
+ }
+ onSubmitInteractionVerdicts={(target: RequestItemVerdictsInteraction, verdicts) =>
+ verdictsMutation.mutateAsync({ interactionId: target.id, verdicts }).then(() => undefined)
+ }
+ />
+ );
+}
diff --git a/ui/src/components/AttentionQueueRow.test.tsx b/ui/src/components/AttentionQueueRow.test.tsx
new file mode 100644
index 0000000000..09dfdf0ca0
--- /dev/null
+++ b/ui/src/components/AttentionQueueRow.test.tsx
@@ -0,0 +1,456 @@
+// @vitest-environment jsdom
+
+import { createRoot } from "react-dom/client";
+import { flushSync } from "react-dom";
+import type { AnchorHTMLAttributes, ReactElement } from "react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import type { AttentionItem, AttentionSourceKind } from "@paperclipai/shared";
+import { approvalsApi } from "../api/approvals";
+import { issuesApi } from "../api/issues";
+import { ToastViewport } from "./ToastViewport";
+import { ToastProvider } from "../context/ToastContext";
+import { AttentionQueueRow } from "./AttentionQueueRow";
+
+vi.mock("@/lib/router", () => ({
+ Link: ({ children, to, ...props }: AnchorHTMLAttributes & { to: string }) => (
+ {children}
+ ),
+}));
+
+vi.mock("../api/approvals", () => ({
+ approvalsApi: {
+ approve: vi.fn(),
+ reject: vi.fn(),
+ requestRevision: vi.fn(),
+ },
+}));
+
+vi.mock("../api/issues", () => ({
+ issuesApi: {
+ acceptInteraction: vi.fn(),
+ rejectInteraction: vi.fn(),
+ },
+}));
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
+
+function act(cb: () => T): T {
+ let result: T | undefined;
+ flushSync(() => {
+ result = cb();
+ });
+ return result as T;
+}
+
+let root: ReturnType | null = null;
+let container: HTMLDivElement | null = null;
+
+afterEach(() => {
+ if (root) act(() => root?.unmount());
+ root = null;
+ container?.remove();
+ container = null;
+ vi.clearAllMocks();
+});
+
+function render(element: ReactElement) {
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ root = createRoot(container);
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ act(() =>
+ root?.render(
+
+
+ {element}
+
+
+ ,
+ ),
+ );
+ return container;
+}
+
+function buildItem(overrides: Partial = {}): AttentionItem {
+ return {
+ id: "a1",
+ companyId: "c1",
+ sourceKind: "approval",
+ subject: {
+ kind: "approval",
+ id: "approval-1",
+ companyId: "c1",
+ title: "Hire agent: Research Analyst",
+ identifier: null,
+ status: "pending",
+ href: "/PAP/approvals/approval-1",
+ metadata: {},
+ },
+ whyNow: "Approval is pending a board decision.",
+ decisionVerbs: [],
+ inlineResolvable: true,
+ entryRule: "",
+ exitRule: "",
+ dedupKey: "approval:approval-1",
+ dismissalKey: "attention:approval:approval-1",
+ severity: "high",
+ rank: 0,
+ activityAt: "2026-07-09T12:00:00Z",
+ createdAt: "2026-07-09T12:00:00Z",
+ updatedAt: "2026-07-09T12:00:00Z",
+ relatedIssue: null,
+ project: null,
+ workspace: null,
+ detail: null,
+ dismissal: null,
+ ...overrides,
+ };
+}
+
+const noop = () => {};
+
+describe("AttentionQueueRow", () => {
+ it("renders an inline approval resolver when expanded", () => {
+ const el = render(
+ ,
+ );
+ expect(el.textContent).toContain("Approve");
+ expect(el.textContent).toContain("Request revision");
+ expect(el.textContent).toContain("Reject");
+ // Inline rows show an expand chevron, not an "Open" deep-link.
+ expect(el.textContent).not.toContain("Open");
+ });
+
+ it("does not inline a review — it deep-links instead", () => {
+ const el = render(
+ ,
+ );
+ expect(el.textContent).toContain("Open");
+ // No approval buttons should render for a review row.
+ expect(el.textContent).not.toContain("Request revision");
+ });
+
+ it("fires onDismiss from the row menu action", () => {
+ const onDismiss = vi.fn();
+ const item = buildItem();
+ render(
+ ,
+ );
+ // The dropdown trigger + item live in a portal; invoke the handler contract
+ // directly via the rendered menu after opening is environment-flaky in
+ // jsdom, so assert the wiring by locating the trigger exists.
+ const trigger = container?.querySelector('[aria-label="Row actions"]');
+ expect(trigger).toBeTruthy();
+ });
+
+ it("toggles expand when the collapsed header of an inline row is clicked", () => {
+ const onToggleExpand = vi.fn();
+ render(
+ ,
+ );
+ const header = container?.querySelector('[role="button"][aria-expanded]');
+ expect(header).toBeTruthy();
+ expect(header?.getAttribute("aria-expanded")).toBe("false");
+ act(() => {
+ header?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+ expect(onToggleExpand).toHaveBeenCalledTimes(1);
+ });
+
+ it("does not navigate on title click — the title is plain text, not a link", () => {
+ render(
+ ,
+ );
+ const links = Array.from(container?.querySelectorAll("a") ?? []);
+ // No anchor should carry the subject title (only the identifier link, absent here).
+ expect(links.some((a) => a.textContent?.includes("Hire agent: Research Analyst"))).toBe(false);
+ });
+
+ it("renders project identity once without a filter button", () => {
+ render(
+ ,
+ );
+
+ const projectMeta = container?.querySelector('[data-testid="attention-project-meta"]');
+ expect(projectMeta?.textContent).toBe("Alpha");
+ expect(projectMeta?.querySelector("button")).toBeNull();
+ expect(projectMeta?.getAttribute("class")).not.toContain("border");
+ expect(projectMeta?.getAttribute("class")).not.toContain("bg-");
+ expect(container?.querySelector('button[title="Filter by Alpha"]')).toBeNull();
+ expect(container?.textContent?.match(/Alpha/g)).toHaveLength(1);
+ });
+
+ it("places the timestamp beside the row menu without a clock icon", () => {
+ render(
+ ,
+ );
+
+ const menu = container?.querySelector('[aria-label="Row actions"]');
+ const menuArea = menu?.closest('[data-attention-menu="true"]');
+ expect(menuArea?.textContent).not.toBe("");
+ expect(container?.querySelector("svg.lucide-clock")).toBeNull();
+ });
+
+ it("uses square row edges and can show a keyboard selection ring", () => {
+ render(
+ ,
+ );
+
+ const row = container?.querySelector("[data-attention-row]");
+ expect(row?.getAttribute("class")).not.toContain("rounded");
+ expect(row?.getAttribute("class")).toContain("ring-ring");
+ });
+
+ it("renders collapsed inline decision verbs in the right-side action area with semantic variants", () => {
+ render(
+ ,
+ );
+
+ const header = container?.querySelector('[role="button"][aria-expanded]');
+ expect(header?.textContent).not.toContain("Approve");
+ expect(header?.textContent).not.toContain("Reject");
+
+ const decisionActions = container?.querySelector('[aria-label="Decision actions"]');
+ expect(decisionActions?.textContent).toContain("Approve");
+ expect(decisionActions?.textContent).toContain("Reject");
+
+ const actionArea = decisionActions?.closest('[data-attention-actions="true"]');
+ expect(actionArea?.getAttribute("class")).toContain("mt-auto");
+
+ const controls = decisionActions?.closest('[data-attention-controls="true"]');
+ expect(controls?.getAttribute("class")).toContain("self-stretch");
+ expect(controls?.getAttribute("class")).toContain("justify-between");
+
+ const rowMenu = container?.querySelector('[aria-label="Row actions"]');
+ expect(rowMenu?.closest('[data-attention-menu="true"]')).toBeTruthy();
+ expect(rowMenu?.closest('[data-attention-actions="true"]')).toBeNull();
+
+ const buttons = Array.from(decisionActions?.querySelectorAll("button") ?? []);
+ expect(buttons.find((button) => button.textContent === "Approve")?.getAttribute("data-variant")).toBe(
+ "default",
+ );
+ expect(buttons.find((button) => button.textContent === "Reject")?.getAttribute("data-variant")).toBe(
+ "destructive",
+ );
+ });
+
+ it("submits a compact approval without expanding the card and confirms it", async () => {
+ const onToggleExpand = vi.fn();
+ vi.mocked(approvalsApi.approve).mockResolvedValue({} as never);
+ render(
+ ,
+ );
+
+ const approve = Array.from(container?.querySelectorAll("button") ?? []).find(
+ (button) => button.textContent === "Approve",
+ );
+ act(() => approve?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
+
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ expect(approvalsApi.approve).toHaveBeenCalledWith("approval-1");
+ expect(onToggleExpand).not.toHaveBeenCalled();
+ expect(container?.textContent).toContain("Approval approved");
+ });
+
+ it("renders configured confirmation labels and accepts from the compact action area", async () => {
+ const onToggleExpand = vi.fn();
+ vi.mocked(issuesApi.acceptInteraction).mockResolvedValue({} as never);
+ render(
+ ,
+ );
+
+ const decisionActions = container?.querySelector('[aria-label="Decision actions"]');
+ expect(decisionActions?.textContent).toContain("Approve plan");
+ expect(decisionActions?.textContent).toContain("Request changes");
+ expect(Array.from(decisionActions?.querySelectorAll("button") ?? []).find((button) => button.textContent === "Approve plan")?.getAttribute("data-variant")).toBe("default");
+ expect(Array.from(decisionActions?.querySelectorAll("button") ?? []).find((button) => button.textContent === "Request changes")?.getAttribute("data-variant")).toBe("outline");
+
+ const approve = Array.from(decisionActions?.querySelectorAll("button") ?? []).find(
+ (button) => button.textContent === "Approve plan",
+ );
+ act(() => approve?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
+
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ expect(issuesApi.acceptInteraction).toHaveBeenCalledWith("issue-1", "interaction-1");
+ expect(onToggleExpand).not.toHaveBeenCalled();
+ });
+
+ it("opens the matching confirmation form when requesting changes from a compact action", () => {
+ const onToggleExpand = vi.fn();
+ render(
+ ,
+ );
+
+ const requestChanges = Array.from(container?.querySelectorAll("button") ?? []).find(
+ (button) => button.textContent === "Request changes",
+ );
+ act(() => requestChanges?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
+
+ expect(onToggleExpand).toHaveBeenCalledOnce();
+ expect(issuesApi.rejectInteraction).not.toHaveBeenCalled();
+ });
+
+ it("centers thumbnails beside the full card text stack", () => {
+ render(
+ ,
+ );
+
+ const image = container?.querySelector('img[alt="Screenshot"]');
+ expect(image?.getAttribute("src")).toBe("/api/assets/asset-1/content");
+
+ const thumbnailStack = image?.parentElement?.parentElement;
+ expect(thumbnailStack?.getAttribute("class")).toContain("items-center");
+ expect(thumbnailStack?.parentElement?.getAttribute("class")).toContain("items-center");
+ });
+
+ it("does not expose a toggle button for non-inline rows", () => {
+ render(
+ ,
+ );
+ expect(container?.querySelector('[role="button"][aria-expanded]')).toBeNull();
+ });
+});
diff --git a/ui/src/components/AttentionQueueRow.tsx b/ui/src/components/AttentionQueueRow.tsx
new file mode 100644
index 0000000000..1ead76c45d
--- /dev/null
+++ b/ui/src/components/AttentionQueueRow.tsx
@@ -0,0 +1,638 @@
+import { useState, type KeyboardEvent } from "react";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import {
+ AlarmClock,
+ ChevronDown,
+ ChevronRight,
+ ExternalLink,
+ Loader2,
+ MoreHorizontal,
+ RotateCcw,
+ X,
+} from "lucide-react";
+import type { Agent, AttentionDetailImage, AttentionItem } from "@paperclipai/shared";
+import { Link } from "@/lib/router";
+import { accessApi } from "../api/access";
+import { approvalsApi } from "../api/approvals";
+import { issuesApi } from "../api/issues";
+import { useToastActions } from "../context/ToastContext";
+import { queryKeys } from "../lib/queryKeys";
+import {
+ attentionDetailImages,
+ attentionDetailLine,
+ attentionImageUrl,
+ attentionToneStyle,
+ isInlineResolvable,
+ severityBadge,
+ sourceMeta,
+} from "../lib/attention";
+import { cn, relativeTime } from "../lib/utils";
+import { Button } from "./ui/button";
+import { Textarea } from "./ui/textarea";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuTrigger,
+} from "./ui/dropdown-menu";
+import { AttentionInteractionResolver } from "./AttentionInteractionResolver";
+import { ProjectTile } from "./ProjectTile";
+
+const HOUR_MS = 60 * 60 * 1000;
+const DAY_MS = 24 * HOUR_MS;
+
+/** Tomorrow at 9am local time. */
+function tomorrowMorningIso(): string {
+ const d = new Date();
+ d.setDate(d.getDate() + 1);
+ d.setHours(9, 0, 0, 0);
+ return d.toISOString();
+}
+
+/** Snooze presets, resolved to a future ISO timestamp at click time. */
+const SNOOZE_PRESETS: ReadonlyArray<{ label: string; resolve: () => string }> = [
+ { label: "1 hour", resolve: () => new Date(Date.now() + HOUR_MS).toISOString() },
+ { label: "4 hours", resolve: () => new Date(Date.now() + 4 * HOUR_MS).toISOString() },
+ { label: "Tomorrow morning", resolve: tomorrowMorningIso },
+ { label: "Next week", resolve: () => new Date(Date.now() + 7 * DAY_MS).toISOString() },
+];
+
+interface AttentionQueueRowProps {
+ item: AttentionItem;
+ companyId: string;
+ expanded: boolean;
+ onToggleExpand: () => void;
+ onDismiss: (item: AttentionItem) => void;
+ onSnooze?: (item: AttentionItem, snoozedUntil: string) => void;
+ /** Restore a snoozed/dismissed row (curtain variant only). */
+ onRestore?: (item: AttentionItem) => void;
+ /** "active" renders the live queue row; "hidden" renders a curtain row. */
+ variant?: "active" | "hidden";
+ agentMap?: Map;
+ currentUserId?: string | null;
+ userLabelMap?: ReadonlyMap | null;
+ selected?: boolean;
+}
+
+export function AttentionQueueRow({
+ item,
+ companyId,
+ expanded,
+ onToggleExpand,
+ onDismiss,
+ onSnooze,
+ onRestore,
+ variant = "active",
+ agentMap,
+ currentUserId,
+ userLabelMap,
+ selected = false,
+}: AttentionQueueRowProps) {
+ const meta = sourceMeta(item.sourceKind);
+ const tone = attentionToneStyle(item);
+ const sevBadge = severityBadge(item.severity);
+ const Icon = meta.icon;
+ const isHidden = variant === "hidden";
+ const inline = !isHidden && isInlineResolvable(item);
+ const href = item.subject.href;
+ const snoozedUntil = item.dismissal?.kind === "snooze" ? item.dismissal.snoozedUntil : null;
+ const detailLine = attentionDetailLine(item) ?? item.whyNow;
+ const images = attentionDetailImages(item);
+ // Only inline-resolvable active rows can expand; that's the only case where a
+ // whole-header click has somewhere to go (plan §5). Non-inline rows keep the
+ // explicit Open button and never toggle on a stray click.
+ const expandable = inline;
+
+ const activate = () => {
+ if (expandable) onToggleExpand();
+ };
+ const onHeaderKeyDown = (e: KeyboardEvent) => {
+ if (!expandable) return;
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ onToggleExpand();
+ }
+ };
+
+ return (
+
+ {/* Type accent bar (canonical color map — never severity). */}
+
+
+
+ {/* Clickable header region: toggles expand for inline rows (plan §2/§5). */}
+
+ {/* Expand affordance / source icon */}
+ {expandable ? (
+
+ {expanded ? : }
+
+ ) : (
+
+
+
+ )}
+
+
+
+
+
+
+ {meta.label}
+
+ {sevBadge && (
+
+ {sevBadge.label}
+
+ )}
+ {item.relatedIssue?.identifier && (
+ e.stopPropagation()}
+ >
+ {item.relatedIssue.identifier}
+
+ )}
+
+
+
+
+ {item.subject.title ?? meta.label}
+
+
{detailLine}
+
+ {item.project && (
+
+ )}
+
+
+
+ {images.length > 0 &&
}
+
+
+
+ {/* Controls: kept as siblings (not inside the clickable header) so they
+ never toggle expand and stay valid interactive targets. */}
+
+
+ {isHidden && snoozedUntil ? (
+
+ Reappears {reappearLabel(snoozedUntil)}
+
+ ) : (
+ {relativeTime(item.activityAt)}
+ )}
+ {!isHidden && (
+
+
+
+
+
+ {onSnooze && onSnooze(item, iso)} />}
+ onDismiss(item)}>
+
+ Dismiss
+
+ {href && (
+ <>
+
+
+ Open source
+
+ >
+ )}
+
+
+ )}
+
+
+
+ {!expanded &&
}
+
+
+ {!inline && href && (
+
+ )}
+
+ {isHidden && onRestore && (
+
+ )}
+
+
+
+
+
+ {inline && expanded && (
+
+
+
+ )}
+
+ );
+}
+
+type CompactDecisionAction = "accept" | "approve" | "reject" | "request_revision";
+
+function compactDecisionAction(item: AttentionItem, verbId: string): CompactDecisionAction | null {
+ if (item.sourceKind === "approval" && (verbId === "approve" || verbId === "reject" || verbId === "request_revision")) {
+ return verbId;
+ }
+ if (item.sourceKind === "join_request" && (verbId === "approve" || verbId === "reject")) {
+ return verbId;
+ }
+ if (
+ item.sourceKind === "issue_thread_interaction"
+ && item.subject.metadata?.kind === "request_confirmation"
+ && (verbId === "accept" || verbId === "reject")
+ ) {
+ return verbId;
+ }
+ return null;
+}
+
+function CompactDecisionActions({
+ item,
+ companyId,
+ onOpen,
+}: {
+ item: AttentionItem;
+ companyId: string;
+ onOpen: () => void;
+}) {
+ const queryClient = useQueryClient();
+ const { pushToast } = useToastActions();
+ const actions = item.decisionVerbs
+ .slice(0, 3)
+ .flatMap((verb) => {
+ const action = compactDecisionAction(item, verb.id);
+ return action ? [{ action, label: verb.label, id: verb.id }] : [];
+ });
+
+ const decision = useMutation({
+ mutationFn: (action: CompactDecisionAction) => {
+ if (item.sourceKind === "approval") {
+ if (action === "approve") return approvalsApi.approve(item.subject.id);
+ if (action === "reject") return approvalsApi.reject(item.subject.id);
+ return approvalsApi.requestRevision(item.subject.id);
+ }
+ if (item.sourceKind === "join_request") {
+ return action === "approve"
+ ? accessApi.approveJoinRequest(companyId, item.subject.id)
+ : accessApi.rejectJoinRequest(companyId, item.subject.id);
+ }
+ if (item.sourceKind === "issue_thread_interaction") {
+ const issueId = item.subject.metadata?.issueId;
+ if (typeof issueId !== "string") throw new Error("Missing issue reference for this decision.");
+ if (action === "accept") return issuesApi.acceptInteraction(issueId, item.subject.id);
+ return issuesApi.rejectInteraction(issueId, item.subject.id);
+ }
+ throw new Error("This decision must be completed from its detail view.");
+ },
+ onSuccess: (_result, action) => {
+ queryClient.invalidateQueries({ queryKey: queryKeys.attention(companyId) });
+ if (item.sourceKind === "approval") {
+ queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(companyId) });
+ } else {
+ queryClient.invalidateQueries({ queryKey: queryKeys.access.joinRequests(companyId) });
+ }
+ pushToast({
+ title: compactDecisionSuccessLabel(item.sourceKind, action),
+ tone: "success",
+ });
+ },
+ onError: (error, action) => {
+ pushToast({
+ title: `Could not ${decisionLabel(action)}`,
+ body: error instanceof Error ? error.message : "Please try again.",
+ tone: "error",
+ });
+ },
+ });
+
+ if (actions.length === 0) return null;
+
+ return (
+
+ {actions.map(({ action, id, label }) => (
+
+ ))}
+
+ );
+}
+
+function decisionLabel(action: CompactDecisionAction): string {
+ if (action === "request_revision") return "sent for revision";
+ if (action === "accept" || action === "approve") return "approved";
+ return "rejected";
+}
+
+function compactDecisionSuccessLabel(sourceKind: AttentionItem["sourceKind"], action: CompactDecisionAction): string {
+ if (sourceKind === "approval") return `Approval ${decisionLabel(action)}`;
+ if (sourceKind === "join_request") return `Join request ${decisionLabel(action)}`;
+ return action === "accept" ? "Confirmation accepted" : "Confirmation declined";
+}
+
+function decisionVerbVariant(verb: AttentionItem["decisionVerbs"][number]): "default" | "outline" | "destructive" {
+ const text = `${verb.label} ${verb.description ?? ""}`.toLowerCase();
+ if (/\b(reject|decline|deny|delete|remove)\b/.test(text)) return "destructive";
+ if (/\b(accept|approve|confirm|apply)\b/.test(text)) return "default";
+ return "outline";
+}
+
+/** Inline project identity keeps useful context without a competing badge. */
+function ProjectMeta({ project }: { project: NonNullable }) {
+ return (
+
+
+ {project.name}
+
+ );
+}
+
+/** Square screenshot thumbnails at the right of the description (plan §10). */
+function ThumbnailStack({ images }: { images: AttentionDetailImage[] }) {
+ const visible = images.slice(0, 3);
+ const extra = images.length - visible.length;
+ return (
+
+
+ {visible.map((img, i) => (
+
})
+ ))}
+
+ {extra > 0 && (
+
+ +{extra}
+
+ )}
+
+ );
+}
+
+/** Snooze submenu: presets + a custom date-time (plan §6). */
+function SnoozeSubmenu({ onSnooze }: { onSnooze: (snoozedUntil: string) => void }) {
+ const [customValue, setCustomValue] = useState("");
+ const applyCustom = () => {
+ if (!customValue) return;
+ const ts = new Date(customValue);
+ if (Number.isNaN(ts.getTime())) return;
+ onSnooze(ts.toISOString());
+ };
+ return (
+
+
+
+ Snooze
+
+
+ {SNOOZE_PRESETS.map((preset) => (
+ onSnooze(preset.resolve())}>
+ {preset.label}
+
+ ))}
+
+ {/* Custom picker: a non-menu-item region so interacting with the input
+ doesn't close the menu (guard keydown/select against Radix typeahead). */}
+ e.stopPropagation()}
+ onClick={(e) => e.stopPropagation()}
+ >
+
+ Custom
+
+ setCustomValue(e.target.value)}
+ className="w-full rounded-sm border border-border bg-background px-2 py-1 text-xs"
+ />
+
+
+
+
+ );
+}
+
+/** Compact "when does this snooze end" label, e.g. `in 2h`, `in 3d`. */
+function reappearLabel(snoozedUntil: string): string {
+ const diffMs = new Date(snoozedUntil).getTime() - Date.now();
+ if (!Number.isFinite(diffMs) || diffMs <= 0) return "soon";
+ const diffMin = Math.round(diffMs / 60000);
+ if (diffMin < 60) return `in ${diffMin}m`;
+ const diffHr = Math.round(diffMin / 60);
+ if (diffHr < 24) return `in ${diffHr}h`;
+ const diffDay = Math.round(diffHr / 24);
+ return `in ${diffDay}d`;
+}
+
+function InlineResolver({
+ item,
+ companyId,
+ agentMap,
+ currentUserId,
+ userLabelMap,
+}: {
+ item: AttentionItem;
+ companyId: string;
+ agentMap?: Map;
+ currentUserId?: string | null;
+ userLabelMap?: ReadonlyMap | null;
+}) {
+ if (item.sourceKind === "issue_thread_interaction") {
+ const issueId = (item.subject.metadata?.issueId as string | undefined) ?? item.relatedIssue?.id;
+ if (!issueId) {
+ return Missing issue reference for this decision.
;
+ }
+ return (
+
+ );
+ }
+
+ if (item.sourceKind === "approval") {
+ return ;
+ }
+
+ if (item.sourceKind === "join_request") {
+ return ;
+ }
+
+ return null;
+}
+
+function ApprovalResolver({ item, companyId }: { item: AttentionItem; companyId: string }) {
+ const queryClient = useQueryClient();
+ const [note, setNote] = useState("");
+ const invalidate = () => {
+ queryClient.invalidateQueries({ queryKey: queryKeys.attention(companyId) });
+ queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(companyId) });
+ };
+ const approve = useMutation({
+ mutationFn: () => approvalsApi.approve(item.subject.id, note.trim() || undefined),
+ onSuccess: invalidate,
+ });
+ const reject = useMutation({
+ mutationFn: () => approvalsApi.reject(item.subject.id, note.trim() || undefined),
+ onSuccess: invalidate,
+ });
+ const revise = useMutation({
+ mutationFn: () => approvalsApi.requestRevision(item.subject.id, note.trim() || undefined),
+ onSuccess: invalidate,
+ });
+ const pending = approve.isPending || reject.isPending || revise.isPending;
+
+ return (
+
+ );
+}
+
+function JoinRequestResolver({ item, companyId }: { item: AttentionItem; companyId: string }) {
+ const queryClient = useQueryClient();
+ const invalidate = () => {
+ queryClient.invalidateQueries({ queryKey: queryKeys.attention(companyId) });
+ queryClient.invalidateQueries({ queryKey: queryKeys.access.joinRequests(companyId) });
+ };
+ const approve = useMutation({
+ mutationFn: () => accessApi.approveJoinRequest(companyId, item.subject.id),
+ onSuccess: invalidate,
+ });
+ const reject = useMutation({
+ mutationFn: () => accessApi.rejectJoinRequest(companyId, item.subject.id),
+ onSuccess: invalidate,
+ });
+ const pending = approve.isPending || reject.isPending;
+
+ return (
+
+
+
+
+ );
+}
diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx
index d01a5762dd..c7a954d41c 100644
--- a/ui/src/components/IssueChatThread.tsx
+++ b/ui/src/components/IssueChatThread.tsx
@@ -65,6 +65,8 @@ import type {
IssueThreadInteraction,
RequestCheckboxConfirmationInteraction,
RequestConfirmationInteraction,
+ RequestItemVerdictsInteraction,
+ RequestItemVerdictValue,
SuggestTasksInteraction,
} from "../lib/issue-thread-interactions";
import { buildIssueThreadInteractionSummary, isIssueThreadInteraction } from "../lib/issue-thread-interactions";
@@ -221,6 +223,10 @@ interface IssueChatMessageContext {
onCancelInteraction?: (
interaction: AskUserQuestionsInteraction,
) => Promise | void;
+ onSubmitInteractionVerdicts?: (
+ interaction: RequestItemVerdictsInteraction,
+ verdicts: { id: string; verdict: RequestItemVerdictValue; reason?: string }[],
+ ) => Promise | void;
onUploadImage?: (file: File) => Promise;
issueStatus?: string;
successfulRunHandoff?: SuccessfulRunHandoffState | null;
@@ -517,6 +523,10 @@ interface IssueChatThreadProps {
onCancelInteraction?: (
interaction: AskUserQuestionsInteraction,
) => Promise | void;
+ onSubmitInteractionVerdicts?: (
+ interaction: RequestItemVerdictsInteraction,
+ verdicts: { id: string; verdict: RequestItemVerdictValue; reason?: string }[],
+ ) => Promise | void;
composerRef?: Ref;
issueWorkMode?: IssueWorkMode;
/**
@@ -2724,6 +2734,7 @@ function IssueChatSystemMessage({ message }: { message: ThreadMessage }) {
onRejectInteraction,
onSubmitInteractionAnswers,
onCancelInteraction,
+ onSubmitInteractionVerdicts,
onUploadImage,
externalReferences,
} = useContext(IssueChatCtx);
@@ -2782,6 +2793,7 @@ function IssueChatSystemMessage({ message }: { message: ThreadMessage }) {
onRejectInteraction={onRejectInteraction}
onSubmitInteractionAnswers={onSubmitInteractionAnswers}
onCancelInteraction={onCancelInteraction}
+ onSubmitInteractionVerdicts={onSubmitInteractionVerdicts}
onUploadImage={onUploadImage}
externalReferences={externalReferences}
/>
@@ -4236,6 +4248,7 @@ export function IssueChatThread({
onRejectInteraction,
onSubmitInteractionAnswers,
onCancelInteraction,
+ onSubmitInteractionVerdicts,
composerRef,
issueWorkMode,
onWorkModeChange,
@@ -4768,6 +4781,7 @@ export function IssueChatThread({
const stableOnRejectInteraction = useStableEvent(onRejectInteraction);
const stableOnSubmitInteractionAnswers = useStableEvent(onSubmitInteractionAnswers);
const stableOnCancelInteraction = useStableEvent(onCancelInteraction);
+ const stableOnSubmitInteractionVerdicts = useStableEvent(onSubmitInteractionVerdicts);
const stableOnUploadImage = useStableEvent(imageUploadHandler);
const chatCtx = useMemo(
@@ -4792,6 +4806,7 @@ export function IssueChatThread({
onRejectInteraction: stableOnRejectInteraction,
onSubmitInteractionAnswers: stableOnSubmitInteractionAnswers,
onCancelInteraction: stableOnCancelInteraction,
+ onSubmitInteractionVerdicts: stableOnSubmitInteractionVerdicts,
onUploadImage: stableOnUploadImage,
issueStatus,
successfulRunHandoff,
@@ -4819,6 +4834,7 @@ export function IssueChatThread({
stableOnRejectInteraction,
stableOnSubmitInteractionAnswers,
stableOnCancelInteraction,
+ stableOnSubmitInteractionVerdicts,
stableOnUploadImage,
issueStatus,
successfulRunHandoff,
diff --git a/ui/src/components/IssueThreadInteractionCard.test.tsx b/ui/src/components/IssueThreadInteractionCard.test.tsx
index 7e5957b94c..add03fe096 100644
--- a/ui/src/components/IssueThreadInteractionCard.test.tsx
+++ b/ui/src/components/IssueThreadInteractionCard.test.tsx
@@ -15,7 +15,10 @@ import {
failedRequestConfirmationInteraction,
pendingRequestConfirmationInteraction,
planApprovalResumeFailedRequestConfirmationInteraction,
+ pendingRequestItemVerdictsInteraction,
pendingSuggestedTasksInteraction,
+ completeRequestItemVerdictsInteraction,
+ supersededRequestItemVerdictsInteraction,
staleTargetRequestConfirmationInteraction,
rejectedSuggestedTasksInteraction,
} from "../fixtures/issueThreadInteractionFixtures";
@@ -498,4 +501,88 @@ describe("IssueThreadInteractionCard", () => {
"",
);
});
+
+ it("submits an approve verdict once a draft is marked and applied", async () => {
+ const onSubmitInteractionVerdicts = vi.fn(async () => undefined);
+ const host = renderCard({
+ interaction: pendingRequestItemVerdictsInteraction,
+ onSubmitInteractionVerdicts,
+ });
+
+ const firstItemId = pendingRequestItemVerdictsInteraction.payload.items[0]!.id;
+ const approveButton = Array.from(
+ host.querySelectorAll(`[data-item-id="${firstItemId}"] button[data-verdict="approve"]`),
+ )[0];
+ expect(approveButton).toBeTruthy();
+ // 44px minimum target (a11y).
+ expect(approveButton?.className).toContain("min-h-11");
+
+ await act(async () => {
+ approveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+
+ const applyButton = Array.from(host.querySelectorAll("button")).find((button) =>
+ button.textContent?.includes("Apply 1 decision"),
+ );
+ expect(applyButton).toBeTruthy();
+
+ await act(async () => {
+ applyButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+
+ expect(onSubmitInteractionVerdicts).toHaveBeenCalledWith(
+ expect.objectContaining({ kind: "request_item_verdicts" }),
+ [{ id: firstItemId, verdict: "approve", reason: undefined }],
+ );
+ });
+
+ it("blocks apply for a rejected item until a reason is entered", async () => {
+ const onSubmitInteractionVerdicts = vi.fn(async () => undefined);
+ const host = renderCard({
+ interaction: pendingRequestItemVerdictsInteraction,
+ onSubmitInteractionVerdicts,
+ });
+
+ const firstItemId = pendingRequestItemVerdictsInteraction.payload.items[0]!.id;
+ const rejectButton = Array.from(
+ host.querySelectorAll(`[data-item-id="${firstItemId}"] button[data-verdict="reject"]`),
+ )[0];
+ await act(async () => {
+ rejectButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+
+ // Reject reveals a required reason field.
+ const reasonField = host.querySelector(
+ `textarea[id="${pendingRequestItemVerdictsInteraction.id}-${firstItemId}-reason"]`,
+ );
+ expect(reasonField).toBeTruthy();
+
+ const applyButton = Array.from(host.querySelectorAll("button")).find((button) =>
+ button.textContent?.includes("Apply 1 decision"),
+ );
+ // Attempting to apply without a reason does not submit.
+ await act(async () => {
+ applyButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+ expect(onSubmitInteractionVerdicts).not.toHaveBeenCalled();
+ expect(host.textContent).toContain("A reason is required to reject this item.");
+ });
+
+ it("renders resolved verdicts as terminal chips with reason echo", () => {
+ const host = renderCard({ interaction: completeRequestItemVerdictsInteraction });
+ expect(host.textContent).toContain("Approved");
+ expect(host.textContent).toContain("Rejected");
+ expect(host.textContent).toContain("Tone is off-brand");
+ // S5 summary chip.
+ expect(host.textContent).toContain("3 approved");
+ // No actionable verdict buttons once terminal.
+ expect(host.querySelector("button[data-verdict]")).toBeNull();
+ });
+
+ it("shows an already-applied, cannot-revert notice when superseded", () => {
+ const host = renderCard({ interaction: supersededRequestItemVerdictsInteraction });
+ expect(host.textContent).toContain("expired after a later comment");
+ expect(host.textContent).toContain("cannot be");
+ expect(host.textContent?.toLowerCase()).toContain("revert");
+ });
});
diff --git a/ui/src/components/IssueThreadInteractionCard.tsx b/ui/src/components/IssueThreadInteractionCard.tsx
index a66b1e08ca..50231ed9f1 100644
--- a/ui/src/components/IssueThreadInteractionCard.tsx
+++ b/ui/src/components/IssueThreadInteractionCard.tsx
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from "react";
import type { Agent } from "@paperclipai/shared";
-import { AlertTriangle, CheckCircle2, ChevronRight, CircleDashed, FileText, GitBranch, ImagePlus, Loader2, MessageSquareQuote, X, XCircle } from "lucide-react";
+import { AlertTriangle, ArrowUpRight, Check, CheckCircle2, ChevronRight, CircleDashed, ExternalLink, FileText, GitBranch, ImagePlus, Loader2, MessageSquareQuote, MinusCircle, ThumbsUp, X, XCircle } from "lucide-react";
import { Link } from "@/lib/router";
import { formatAssigneeUserLabel } from "../lib/assignees";
import {
@@ -8,13 +8,19 @@ import {
collectSuggestedTaskClientKeys,
countSuggestedTaskNodes,
getCheckboxConfirmationSelectedLabels,
+ getItemVerdictProgress,
getQuestionAnswerLabels,
+ normalizeRequestConfirmationTargetHref,
type AskUserQuestionsAnswer,
type AskUserQuestionsInteraction,
type IssueThreadInteraction,
type RequestCheckboxConfirmationInteraction,
type RequestConfirmationInteraction,
type RequestConfirmationTarget,
+ type RequestItemVerdictsInteraction,
+ type RequestItemVerdictsItem,
+ type RequestItemVerdictsResultItem,
+ type RequestItemVerdictValue,
type SuggestTasksInteraction,
type SuggestTasksResultCreatedTask,
type SuggestedTaskDraft,
@@ -58,6 +64,10 @@ interface IssueThreadInteractionCardProps {
onCancelInteraction?: (
interaction: AskUserQuestionsInteraction,
) => Promise | void;
+ onSubmitInteractionVerdicts?: (
+ interaction: RequestItemVerdictsInteraction,
+ verdicts: { id: string; verdict: RequestItemVerdictValue; reason?: string }[],
+ ) => Promise | void;
onUploadImage?: (file: File) => Promise;
externalReferences?: MarkdownExternalReferenceMap;
}
@@ -110,6 +120,8 @@ function interactionKindLabel(kind: IssueThreadInteraction["kind"]) {
return "Confirmation";
case "request_checkbox_confirmation":
return "Checkbox confirmation";
+ case "request_item_verdicts":
+ return "Item verdicts";
default:
return kind;
}
@@ -1939,6 +1951,495 @@ function RequestCheckboxConfirmationCard({
);
}
+// --- Per-item verdicts (C3) ---------------------------------------------
+
+const VERDICT_LABEL: Record = {
+ approve: "Approve",
+ reject: "Reject",
+ defer: "Defer",
+};
+
+/** Present-tense past-participle label for a resolved verdict chip. */
+const VERDICT_RESOLVED_LABEL: Record = {
+ approve: "Approved",
+ reject: "Rejected",
+ defer: "Deferred",
+};
+
+function verdictChipClasses(verdict: RequestItemVerdictValue) {
+ switch (verdict) {
+ case "approve":
+ return "border-emerald-500/60 bg-emerald-500/10 text-emerald-900 dark:bg-emerald-500/15 dark:text-emerald-100";
+ case "reject":
+ return "border-rose-500/60 bg-rose-500/10 text-rose-900 dark:bg-rose-500/15 dark:text-rose-100";
+ default:
+ return "border-border/70 bg-muted/40 text-muted-foreground";
+ }
+}
+
+function VerdictConsequenceChip({ verdict }: { verdict: RequestItemVerdictValue }) {
+ const Icon = verdict === "approve" ? CheckCircle2 : verdict === "reject" ? XCircle : MinusCircle;
+ return (
+
+
+ {VERDICT_RESOLVED_LABEL[verdict]}
+
+ );
+}
+
+function ItemVerdictDeepLink({ item }: { item: RequestItemVerdictsItem }) {
+ const href = item.href ? normalizeRequestConfirmationTargetHref(item.href) : null;
+ if (!href) return null;
+ const isInternal = href.startsWith("/") || href.startsWith("#");
+ const className =
+ "inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1";
+ const label = (
+ <>
+ Open
+ {isInternal ? : }
+ >
+ );
+ if (isInternal) {
+ return (
+
+ {label}
+
+ );
+ }
+ return (
+
+ {label}
+
+ );
+}
+
+function ItemVerdictSegmentedControl({
+ itemId,
+ verdicts,
+ value,
+ disabled,
+ onSelect,
+}: {
+ itemId: string;
+ verdicts: RequestItemVerdictValue[];
+ value: RequestItemVerdictValue | null;
+ disabled: boolean;
+ onSelect: (verdict: RequestItemVerdictValue) => void;
+}) {
+ return (
+
+ {verdicts.map((verdict) => {
+ const active = value === verdict;
+ const variant = verdict === "reject"
+ ? (active ? "destructive" : "outline")
+ : verdict === "approve"
+ ? (active ? "default" : "outline")
+ : (active ? "secondary" : "outline");
+ const Icon = verdict === "approve" ? Check : verdict === "reject" ? X : MinusCircle;
+ return (
+
+ );
+ })}
+
+ );
+}
+
+interface VerdictDraft {
+ verdict: RequestItemVerdictValue;
+ reason: string;
+}
+
+function RequestItemVerdictsCard({
+ interaction,
+ onSubmitInteractionVerdicts,
+ externalReferences,
+}: {
+ interaction: RequestItemVerdictsInteraction;
+ onSubmitInteractionVerdicts?: (
+ interaction: RequestItemVerdictsInteraction,
+ verdicts: { id: string; verdict: RequestItemVerdictValue; reason?: string }[],
+ ) => Promise | void;
+ externalReferences?: MarkdownExternalReferenceMap;
+}) {
+ const payload = interaction.payload;
+ const items = payload.items;
+ const enabledVerdicts = useMemo(
+ () => payload.verdicts ?? ["approve", "reject"],
+ [payload.verdicts],
+ );
+ const requireReasonOn = useMemo(
+ () => new Set(payload.requireReasonOn ?? ["reject"]),
+ [payload.requireReasonOn],
+ );
+ const allowBulkApprove = payload.allowBulkApprove !== false && enabledVerdicts.includes("approve");
+ const reasonLabel = payload.reasonLabel ?? "Reason";
+
+ const resolvedById = useMemo(
+ () => new Map((interaction.result?.items ?? []).map((item) => [item.id, item])),
+ [interaction.result],
+ );
+
+ const [drafts, setDrafts] = useState
)}
- {toast.action && (
- onDismiss(toast.id)}
- className="mt-2 inline-flex text-xs font-medium underline underline-offset-4 hover:opacity-90"
- >
- {toast.action.label}
-
- )}
+ {toast.action &&
+ (toast.action.onClick ? (
+
+ ) : toast.action.href ? (
+ onDismiss(toast.id)}
+ className="mt-2 inline-flex text-xs font-medium underline underline-offset-4 hover:opacity-90"
+ >
+ {toast.action.label}
+
+ ) : null)}