mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-19 10:36:26 +02:00
feat: add attention queue and Decisions surface (#9380)
## Thinking Path > - Paperclip is the control plane for autonomous AI companies, where operators need a reliable way to find and act on work awaiting their input. > - The attention and issue-thread interaction subsystems expose those decision points across server APIs and the board UI. > - The previous navigation and interaction presentation left these actions fragmented and did not offer a controlled rollout for the Decisions surface. > - This branch adds the attention feed, richer interaction cards, grouping, dismiss/snooze behavior, and a gated Decisions sidebar entry. > - It also keeps experimental settings and API contracts synchronized, with an idempotent migration for the new dismissal state. > - This pull request delivers the complete, tested attention/Decisions experience as one reviewable unit. ## Linked Issues or Issue Description - Adds an operator-focused attention queue and Decisions experience: grouped decision cards, semantic interaction actions, dismiss/snooze handling, resilient interaction states, and an experimental flag to control the Decisions navigation entry. ## Feature Context ### Problem or Motivation Operators currently have to hunt across approvals, interactions, failed runs, and budget alerts to find decisions that need their action. ### Proposed Solution Provide a gated Decisions attention queue that groups actionable items, supports direct resolution, and preserves operator control through dismiss and snooze actions. ### Alternatives Considered Keep separate, source-specific views only; this leaves cross-cutting operator decisions fragmented and harder to prioritize. ### Roadmap Alignment This improves the V1 control-plane operator workflow by making pending governed actions discoverable in one company-scoped surface. ## What Changed - Added server attention-feed services, routes, interaction handling, dismiss/snooze support, and an idempotent `0145` inbox-dismissal migration. - Added shared attention, inbox-dismissal, and experimental-settings contracts. - Added Decisions/attention UI, interaction-card states, sidebar badge/navigation integration, grouping, keyboard support, and Storybook coverage. - Added tests for attention behavior, thread interactions, settings normalization, dismissals, and API behavior. - Removed generated screenshots from the final PR diff and rebased the branch onto current `master`. ## Verification - `pnpm check:token-gates` — passed. - `pnpm exec vitest run packages/shared/src/issue-thread-interactions.test.ts server/src/__tests__/attention-service.test.ts server/src/__tests__/inbox-dismissals.test.ts server/src/__tests__/issue-thread-interactions-service.test.ts server/src/__tests__/issue-thread-interaction-routes.test.ts ui/src/lib/attention.test.ts ui/src/components/AttentionQueueRow.test.tsx ui/src/components/IssueThreadInteractionCard.test.tsx ui/src/pages/InstanceExperimentalSettings.test.tsx` — passed: 158 tests across 9 focused files. - GitHub Actions for `ad636f560`: build and typecheck/release-registry have passed; remaining general-server and Greptile checks are in progress. ## Risks - Moderate: this is a cross-layer attention/interaction feature with a new migration and navigation behavior. - The `enableDecisions` experimental setting defaults to off, limiting rollout impact. - Existing dismissal data is backfilled to `dismiss`; the migration is idempotent and uses guarded constraint creation. > ROADMAP.md was checked; no duplicate planned core feature was identified. Related open pull requests were searched before opening this PR. ## Model Used - OpenAI GPT-5.5 via Codex CLI, with tool use and local code execution. Context-window size unavailable in this environment. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My public PR branch name describes the change and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally; focused tests pass and the remaining unrelated AWS test failure is documented above - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
70 files changed
+9067
-90
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<string>();
|
||||
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<string>();
|
||||
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<string>();
|
||||
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<string>();
|
||||
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<typeof createIssueThreadInteractionSchema>;
|
||||
@@ -989,6 +1115,29 @@ export const respondIssueThreadInteractionSchema = z.object({
|
||||
});
|
||||
export type RespondIssueThreadInteraction = z.infer<typeof respondIssueThreadInteractionSchema>;
|
||||
|
||||
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<string>();
|
||||
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<typeof submitIssueThreadInteractionVerdictsSchema>;
|
||||
|
||||
export const linkIssueApprovalSchema = z.object({
|
||||
approvalId: z.string().uuid(),
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user