[codex] Add blocked inbox attention view (#5603)

## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies through
company-scoped issues, comments, approvals, and execution workspaces.
> - Operators need the Inbox to show not only active work, but also
blocked work that may need human or agent attention.
> - The existing inbox experience did not have a dedicated blocked-work
surface, so blocked tasks were harder to triage and resume deliberately.
> - Backend consumers also needed a compact attention signal that
distinguishes actionable blockers from covered or waiting blocker
states.
> - This pull request adds a Blocked Inbox tab backed by issue
blocker-attention metadata, shared validators, and UI helpers.
> - The benefit is a clearer triage path for stalled or blocked
Paperclip work without exposing external wait internals in the
operator-facing UI.

## What Changed

- Added shared issue blocker-attention types, validators, and exports
for the API/UI contract.
- Added backend blocker-attention computation and issue route support
for blocked inbox data.
- Added the Blocked Inbox tab, blocked reason chips, filtering/search
UI, responsive layouts, and Storybook stories.
- Updated inbox helpers and page behavior so toolbar controls only
appear where they apply.
- Added coverage for shared validators, server blocker-attention
behavior, blocked inbox UI helpers/components, and the Inbox page.
- Added a screenshot helper script for the blocked inbox Storybook
stories.
- Addressed Greptile feedback by making urgency sorting deterministic
for null stop times, avoiding full blocked-inbox list enrichment for
counts, and hardening the screenshot helper.

## Verification

- Rebased the branch cleanly onto `public-gh/master`.
- Confirmed the diff does not include `pnpm-lock.yaml`.
- Confirmed the diff does not include database migration files.
- Ran `pnpm exec vitest run packages/shared/src/validators/issue.test.ts
server/src/__tests__/issue-blocker-attention.test.ts
ui/src/components/BlockedInboxView.test.tsx
ui/src/components/BlockedReasonChip.test.tsx
ui/src/lib/blockedInbox.test.ts ui/src/lib/inbox.test.ts
ui/src/pages/Inbox.test.tsx`.
- Ran `pnpm --filter @paperclipai/shared typecheck && pnpm --filter
@paperclipai/server typecheck && pnpm --filter @paperclipai/ui
typecheck`.
- Checked `ROADMAP.md`; this is scoped inbox/operator triage work and
does not duplicate a listed roadmap feature.
- Greptile Review is green on the latest head and all four Greptile
review threads are resolved.
- GitHub PR checks are green on the latest head: policy, security/snyk,
e2e, verify, Canary Dry Run, Greptile Review, and serialized server
suites 1/4 through 4/4.

## Risks

- Medium review surface because this touches the shared issue contract,
server issue services, and the Inbox UI together.
- Blocker-attention classification may need product tuning after
operators use it on real blocked queues.
- UI screenshots were not attached in this PR-opening pass; the branch
includes `scripts/screenshot-blocked-inbox.mjs` and Storybook stories
for visual capture.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI Codex, GPT-5-based coding agent with shell, git, GitHub CLI,
GitHub connector, and Paperclip API tool use. Reasoning mode: medium.
Context window: not exposed by the runtime.

## 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 run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta
2026-05-13 16:41:36 -05:00
committed by GitHub
parent d1a8c873b2
commit 4142559c37
24 changed files with 3737 additions and 115 deletions
+14
View File
@@ -379,6 +379,15 @@ export type {
IssueBlockerAttention,
IssueBlockerAttentionReason,
IssueBlockerAttentionState,
IssueInboxAttentionKind,
IssueBlockedInboxAction,
IssueBlockedInboxAttention,
IssueBlockedInboxIssueRef,
IssueBlockedInboxOwner,
IssueBlockedInboxOwnerType,
IssueBlockedInboxReason,
IssueBlockedInboxSeverity,
IssueBlockedInboxState,
IssueProductivityReview,
IssueProductivityReviewTrigger,
IssueRecoveryAction,
@@ -761,6 +770,11 @@ export {
createChildIssueSchema,
resolveCreateIssueStatusDefault,
createIssueLabelSchema,
issueBlockedInboxAttentionSchema,
issueBlockedInboxIssueRefSchema,
issueBlockedInboxReasonSchema,
issueBlockedInboxSeveritySchema,
issueBlockedInboxStateSchema,
updateIssueSchema,
issueExecutionPolicySchema,
issueExecutionStateSchema,
+9
View File
@@ -149,6 +149,15 @@ export type {
IssueBlockerAttention,
IssueBlockerAttentionReason,
IssueBlockerAttentionState,
IssueInboxAttentionKind,
IssueBlockedInboxAction,
IssueBlockedInboxAttention,
IssueBlockedInboxIssueRef,
IssueBlockedInboxOwner,
IssueBlockedInboxOwnerType,
IssueBlockedInboxReason,
IssueBlockedInboxSeverity,
IssueBlockedInboxState,
IssueProductivityReview,
IssueProductivityReviewTrigger,
IssueRecoveryAction,
+70
View File
@@ -158,6 +158,75 @@ export interface IssueBlockerAttention {
sampleStalledBlockerIdentifier: string | null;
}
export type IssueInboxAttentionKind = "blocked";
export type IssueBlockedInboxState =
| "needs_attention"
| "awaiting_decision"
| "external_wait"
| "recovery_open"
| "missing_disposition";
export type IssueBlockedInboxSeverity = "critical" | "high" | "medium" | "low";
export type IssueBlockedInboxReason =
| "blocked_by_unassigned_issue"
| "blocked_by_assigned_backlog_issue"
| "blocked_by_uninvokable_assignee"
| "blocked_by_cancelled_issue"
| "blocked_chain_stalled"
| "invalid_review_participant"
| "in_review_without_action_path"
| "missing_successful_run_disposition"
| "pending_board_decision"
| "pending_user_decision"
| "external_owner_action"
| "open_recovery_issue";
export type IssueBlockedInboxOwnerType = "agent" | "user" | "board" | "external" | "unknown";
export interface IssueBlockedInboxIssueRef {
id: string;
identifier: string | null;
title: string;
status: IssueStatus;
priority: IssuePriority;
assigneeAgentId: string | null;
assigneeUserId: string | null;
}
export interface IssueBlockedInboxOwner {
type: IssueBlockedInboxOwnerType;
agentId: string | null;
userId: string | null;
label: string | null;
}
export interface IssueBlockedInboxAction {
label: string;
detail: string | null;
}
export interface IssueBlockedInboxAttention {
kind: IssueInboxAttentionKind;
state: IssueBlockedInboxState;
reason: IssueBlockedInboxReason;
severity: IssueBlockedInboxSeverity;
stoppedSinceAt: string | null;
owner: IssueBlockedInboxOwner;
action: IssueBlockedInboxAction;
sourceIssue: IssueBlockedInboxIssueRef | null;
leafIssue: IssueBlockedInboxIssueRef | null;
recoveryIssue: IssueBlockedInboxIssueRef | null;
approvalId: string | null;
interactionId: string | null;
sampleIssueIdentifier: string | null;
redaction: {
externalDetailsRedacted: boolean;
secretFieldsOmitted: true;
};
}
export type IssueProductivityReviewTrigger =
| "no_comment_streak"
| "long_active_duration"
@@ -405,6 +474,7 @@ export interface Issue {
blockedBy?: IssueRelationIssueSummary[];
blocks?: IssueRelationIssueSummary[];
blockerAttention?: IssueBlockerAttention;
blockedInboxAttention?: IssueBlockedInboxAttention | null;
productivityReview?: IssueProductivityReview | null;
activeRecoveryAction?: IssueRecoveryAction | null;
successfulRunHandoff?: SuccessfulRunHandoffState | null;
+5
View File
@@ -153,6 +153,11 @@ export {
createChildIssueSchema,
resolveCreateIssueStatusDefault,
createIssueLabelSchema,
issueBlockedInboxAttentionSchema,
issueBlockedInboxIssueRefSchema,
issueBlockedInboxReasonSchema,
issueBlockedInboxSeveritySchema,
issueBlockedInboxStateSchema,
updateIssueSchema,
issueExecutionPolicySchema,
issueExecutionStateSchema,
@@ -3,6 +3,7 @@ import { MAX_ISSUE_REQUEST_DEPTH } from "../index.js";
import {
addIssueCommentSchema,
createIssueSchema,
issueBlockedInboxAttentionSchema,
resolveIssueRecoveryActionSchema,
respondIssueThreadInteractionSchema,
suggestedTaskDraftSchema,
@@ -218,6 +219,50 @@ describe("issue validators", () => {
}).workMode).toBe("planning");
});
it("validates blocked inbox attention payloads and requires redacted secret fields", () => {
const parsed = issueBlockedInboxAttentionSchema.parse({
kind: "blocked",
state: "needs_attention",
reason: "blocked_by_unassigned_issue",
severity: "critical",
stoppedSinceAt: "2026-05-09T12:00:00.000Z",
owner: { type: "unknown", agentId: null, userId: null, label: null },
action: { label: "Assign blocker", detail: "Assign the leaf blocker." },
sourceIssue: {
id: "11111111-1111-4111-8111-111111111111",
identifier: "PAP-1",
title: "Blocked source",
status: "blocked",
priority: "high",
assigneeAgentId: null,
assigneeUserId: null,
},
leafIssue: {
id: "22222222-2222-4222-8222-222222222222",
identifier: "PAP-2",
title: "Unassigned leaf",
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
},
recoveryIssue: null,
approvalId: null,
interactionId: null,
sampleIssueIdentifier: "PAP-2",
redaction: {
externalDetailsRedacted: false,
secretFieldsOmitted: true,
},
});
expect(parsed.redaction.secretFieldsOmitted).toBe(true);
expect(issueBlockedInboxAttentionSchema.safeParse({
...parsed,
redaction: { externalDetailsRedacted: false, secretFieldsOmitted: false },
}).success).toBe(false);
});
it("rejects unknown issue work modes", () => {
expect(createIssueSchema.safeParse({ title: "Plan first", workMode: "normal" }).success).toBe(false);
expect(suggestedTaskDraftSchema.safeParse({
+63
View File
@@ -28,6 +28,69 @@ import {
} from "../constants.js";
import { multilineTextSchema } from "./text.js";
export const issueBlockedInboxStateSchema = z.enum([
"needs_attention",
"awaiting_decision",
"external_wait",
"recovery_open",
"missing_disposition",
]);
export const issueBlockedInboxSeveritySchema = z.enum(["critical", "high", "medium", "low"]);
export const issueBlockedInboxReasonSchema = z.enum([
"blocked_by_unassigned_issue",
"blocked_by_assigned_backlog_issue",
"blocked_by_uninvokable_assignee",
"blocked_by_cancelled_issue",
"blocked_chain_stalled",
"invalid_review_participant",
"in_review_without_action_path",
"missing_successful_run_disposition",
"pending_board_decision",
"pending_user_decision",
"external_owner_action",
"open_recovery_issue",
]);
export const issueBlockedInboxIssueRefSchema = z.object({
id: z.string().uuid(),
identifier: z.string().nullable(),
title: z.string(),
status: z.enum(ISSUE_STATUSES),
priority: z.enum(ISSUE_PRIORITIES),
assigneeAgentId: z.string().uuid().nullable(),
assigneeUserId: z.string().nullable(),
}).strict();
export const issueBlockedInboxAttentionSchema = z.object({
kind: z.literal("blocked"),
state: issueBlockedInboxStateSchema,
reason: issueBlockedInboxReasonSchema,
severity: issueBlockedInboxSeveritySchema,
stoppedSinceAt: z.string().datetime().nullable(),
owner: z.object({
type: z.enum(["agent", "user", "board", "external", "unknown"]),
agentId: z.string().uuid().nullable(),
userId: z.string().nullable(),
label: z.string().nullable(),
}).strict(),
action: z.object({
label: z.string().trim().min(1),
detail: z.string().nullable(),
}).strict(),
sourceIssue: issueBlockedInboxIssueRefSchema.nullable(),
leafIssue: issueBlockedInboxIssueRefSchema.nullable(),
recoveryIssue: issueBlockedInboxIssueRefSchema.nullable(),
approvalId: z.string().uuid().nullable(),
interactionId: z.string().uuid().nullable(),
sampleIssueIdentifier: z.string().nullable(),
redaction: z.object({
externalDetailsRedacted: z.boolean(),
secretFieldsOmitted: z.literal(true),
}).strict(),
}).strict();
export const ISSUE_EXECUTION_WORKSPACE_PREFERENCES = [
"inherit",
"shared_workspace",