From 4142559c378ed9d356b8fa73acb93c61f5813b18 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 13 May 2026 16:41:36 -0500 Subject: [PATCH] [codex] Add blocked inbox attention view (#5603) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- packages/shared/src/index.ts | 14 + packages/shared/src/types/index.ts | 9 + packages/shared/src/types/issue.ts | 70 ++ packages/shared/src/validators/index.ts | 5 + packages/shared/src/validators/issue.test.ts | 45 + packages/shared/src/validators/issue.ts | 63 ++ scripts/screenshot-blocked-inbox.mjs | 95 ++ .../__tests__/issue-blocker-attention.test.ts | 227 +++- server/src/routes/issues.ts | 49 + server/src/services/issues.ts | 979 +++++++++++++++++- ui/src/App.tsx | 1 + ui/src/api/issues.ts | 26 + ui/src/components/BlockedInboxView.test.tsx | 329 ++++++ ui/src/components/BlockedInboxView.tsx | 386 +++++++ ui/src/components/BlockedReasonChip.test.tsx | 85 ++ ui/src/components/BlockedReasonChip.tsx | 82 ++ ui/src/lib/blockedInbox.test.ts | 275 +++++ ui/src/lib/blockedInbox.ts | 275 +++++ ui/src/lib/inbox.test.ts | 6 + ui/src/lib/inbox.ts | 10 +- ui/src/lib/queryKeys.ts | 2 + ui/src/pages/Inbox.test.tsx | 187 +++- ui/src/pages/Inbox.tsx | 329 ++++-- .../stories/blocked-inbox.stories.tsx | 303 ++++++ 24 files changed, 3737 insertions(+), 115 deletions(-) create mode 100644 scripts/screenshot-blocked-inbox.mjs create mode 100644 ui/src/components/BlockedInboxView.test.tsx create mode 100644 ui/src/components/BlockedInboxView.tsx create mode 100644 ui/src/components/BlockedReasonChip.test.tsx create mode 100644 ui/src/components/BlockedReasonChip.tsx create mode 100644 ui/src/lib/blockedInbox.test.ts create mode 100644 ui/src/lib/blockedInbox.ts create mode 100644 ui/storybook/stories/blocked-inbox.stories.tsx diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 55b512de..8dd130c2 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 3e4c8f54..9cb46a2c 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -149,6 +149,15 @@ export type { IssueBlockerAttention, IssueBlockerAttentionReason, IssueBlockerAttentionState, + IssueInboxAttentionKind, + IssueBlockedInboxAction, + IssueBlockedInboxAttention, + IssueBlockedInboxIssueRef, + IssueBlockedInboxOwner, + IssueBlockedInboxOwnerType, + IssueBlockedInboxReason, + IssueBlockedInboxSeverity, + IssueBlockedInboxState, IssueProductivityReview, IssueProductivityReviewTrigger, IssueRecoveryAction, diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index b30c3bf7..7bbdde36 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -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; diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index c8f5432a..3380c553 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -153,6 +153,11 @@ export { createChildIssueSchema, resolveCreateIssueStatusDefault, createIssueLabelSchema, + issueBlockedInboxAttentionSchema, + issueBlockedInboxIssueRefSchema, + issueBlockedInboxReasonSchema, + issueBlockedInboxSeveritySchema, + issueBlockedInboxStateSchema, updateIssueSchema, issueExecutionPolicySchema, issueExecutionStateSchema, diff --git a/packages/shared/src/validators/issue.test.ts b/packages/shared/src/validators/issue.test.ts index fe962339..0d8374ec 100644 --- a/packages/shared/src/validators/issue.test.ts +++ b/packages/shared/src/validators/issue.test.ts @@ -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({ diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index 3adaadb4..b39fe7f8 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -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", diff --git a/scripts/screenshot-blocked-inbox.mjs b/scripts/screenshot-blocked-inbox.mjs new file mode 100644 index 00000000..277a6f8f --- /dev/null +++ b/scripts/screenshot-blocked-inbox.mjs @@ -0,0 +1,95 @@ +#!/usr/bin/env node +// Capture screenshots of the Blocked Inbox storybook stories. +// Usage: node scripts/screenshot-blocked-inbox.mjs + +import http from "node:http"; +import path from "node:path"; +import fs from "node:fs/promises"; +import { chromium } from "@playwright/test"; + +async function main() { + const [, , staticDir, outDir] = process.argv; + if (!staticDir || !outDir) { + console.error("usage: node scripts/screenshot-blocked-inbox.mjs "); + process.exit(1); + } + await fs.mkdir(outDir, { recursive: true }); + const absStaticDir = path.resolve(staticDir); + + const server = http.createServer(async (req, res) => { + try { + let urlPath = decodeURIComponent((req.url || "/").split("?")[0]); + if (urlPath.endsWith("/")) urlPath += "iframe.html"; + const filePath = path.resolve(absStaticDir, `.${urlPath}`); + if (!filePath.startsWith(absStaticDir + path.sep) && filePath !== absStaticDir) { + res.writeHead(403); + res.end("Forbidden"); + return; + } + const buf = await fs.readFile(filePath); + const ext = path.extname(filePath).toLowerCase(); + const mime = { + ".html": "text/html; charset=utf-8", + ".js": "application/javascript", + ".css": "text/css", + ".json": "application/json", + ".svg": "image/svg+xml", + ".png": "image/png", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".map": "application/json", + }[ext] || "application/octet-stream"; + res.writeHead(200, { "content-type": mime }); + res.end(buf); + } catch (err) { + res.writeHead(404); + res.end(String(err)); + } + }); + + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const port = server.address().port; + const baseUrl = `http://127.0.0.1:${port}/iframe.html`; + + const browser = await chromium.launch(); + try { + const stories = [ + { id: "product-inbox-blocked-tab--desktop-loaded", file: "01-desktop-loaded.png", width: 1440, height: 1100, dark: false }, + { id: "product-inbox-blocked-tab--desktop-loaded", file: "02-desktop-loaded-dark.png", width: 1440, height: 1100, dark: true }, + { id: "product-inbox-blocked-tab--desktop-with-search", file: "03-desktop-with-search.png", width: 1440, height: 800, dark: false }, + { id: "product-inbox-blocked-tab--mobile-layout", file: "04-mobile-layout.png", width: 390, height: 1100, dark: false }, + { id: "product-inbox-blocked-tab--reason-chip-catalog", file: "05-reason-chip-catalog.png", width: 900, height: 600, dark: false }, + { id: "product-inbox-blocked-tab--empty-state", file: "06-empty-state.png", width: 900, height: 500, dark: false }, + ]; + + for (const story of stories) { + const ctx = await browser.newContext({ + viewport: { width: story.width, height: story.height }, + deviceScaleFactor: 2, + colorScheme: story.dark ? "dark" : "light", + }); + const page = await ctx.newPage(); + const url = `${baseUrl}?id=${story.id}&viewMode=story`; + await page.goto(url, { waitUntil: "networkidle" }); + // Force light/dark class on for tailwind dark mode tokens + await page.evaluate((dark) => { + const html = document.documentElement; + html.classList.toggle("dark", dark); + html.style.colorScheme = dark ? "dark" : "light"; + }, story.dark); + await page.waitForTimeout(500); + const out = path.join(outDir, story.file); + await page.screenshot({ path: out, fullPage: true }); + console.log("wrote", out); + await ctx.close(); + } + } finally { + await browser.close(); + server.close(); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/server/src/__tests__/issue-blocker-attention.test.ts b/server/src/__tests__/issue-blocker-attention.test.ts index 71e66c80..7fdb91e5 100644 --- a/server/src/__tests__/issue-blocker-attention.test.ts +++ b/server/src/__tests__/issue-blocker-attention.test.ts @@ -2,12 +2,16 @@ import { randomUUID } from "node:crypto"; import { eq } from "drizzle-orm"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { + activityLog, agents, agentWakeupRequests, + approvals, companies, createDb, heartbeatRuns, + issueApprovals, issueRelations, + issueThreadInteractions, issues, } from "@paperclipai/db"; import { @@ -15,6 +19,7 @@ import { startEmbeddedPostgresTestDatabase, } from "./helpers/embedded-postgres.js"; import { issueService } from "../services/issues.js"; +import { buildIssueGraphLivenessIncidentKey } from "../services/recovery/origins.js"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; @@ -37,6 +42,10 @@ describeEmbeddedPostgres("issue blocker attention", () => { }, 20_000); afterEach(async () => { + await db.delete(issueThreadInteractions); + await db.delete(issueApprovals); + await db.delete(approvals); + await db.delete(activityLog); await db.delete(heartbeatRuns); await db.delete(agentWakeupRequests); await db.delete(issueRelations); @@ -52,20 +61,30 @@ describeEmbeddedPostgres("issue blocker attention", () => { async function createCompany(prefix = "PBA") { const companyId = randomUUID(); const agentId = randomUUID(); + const pausedAgentId = randomUUID(); await db.insert(companies).values({ id: companyId, name: `Company ${prefix}`, issuePrefix: prefix, requireBoardApprovalForNewAgents: false, }); - await db.insert(agents).values({ - id: agentId, - companyId, - name: `${prefix} Agent`, - role: "engineer", - status: "idle", - }); - return { companyId, agentId }; + await db.insert(agents).values([ + { + id: agentId, + companyId, + name: `${prefix} Agent`, + role: "engineer", + status: "idle", + }, + { + id: pausedAgentId, + companyId, + name: `${prefix} Paused`, + role: "engineer", + status: "paused", + }, + ]); + return { companyId, agentId, pausedAgentId }; } async function insertIssue(input: { @@ -80,6 +99,8 @@ describeEmbeddedPostgres("issue blocker attention", () => { originKind?: string | null; originId?: string | null; originFingerprint?: string | null; + executionState?: Record | null; + description?: string | null; }) { const id = input.id ?? randomUUID(); await db.insert(issues).values({ @@ -95,6 +116,8 @@ describeEmbeddedPostgres("issue blocker attention", () => { originKind: input.originKind ?? "manual", originId: input.originId ?? null, originFingerprint: input.originFingerprint ?? "default", + executionState: input.executionState ?? null, + description: input.description ?? null, }); return id; } @@ -483,4 +506,192 @@ describeEmbeddedPostgres("issue blocker attention", () => { sampleBlockerIdentifier: "PBY-2", }); }); + + it("returns blocked inbox attention for an unassigned blocker leaf and supports count/search", async () => { + const { companyId } = await createCompany("BIA"); + const parentId = await insertIssue({ companyId, identifier: "BIA-1", title: "Blocked source", status: "blocked" }); + const blockerId = await insertIssue({ + companyId, + identifier: "BIA-2", + title: "Unassigned leaf", + status: "todo", + }); + await block({ companyId, blockerIssueId: blockerId, blockedIssueId: parentId }); + + const rows = await svc.list(companyId, { attention: "blocked", q: "BIA-2" }); + + expect(rows).toHaveLength(1); + expect(rows[0]?.id).toBe(parentId); + expect(rows[0]?.blockedBy).toEqual([ + expect.objectContaining({ id: blockerId, identifier: "BIA-2" }), + ]); + expect(rows[0]?.blockedInboxAttention).toMatchObject({ + kind: "blocked", + state: "needs_attention", + reason: "blocked_by_unassigned_issue", + severity: "critical", + owner: { type: "unknown", agentId: null, userId: null }, + action: { label: "Assign blocker" }, + leafIssue: { id: blockerId, identifier: "BIA-2" }, + redaction: { secretFieldsOmitted: true }, + }); + await expect(svc.count(companyId, { attention: "blocked" })).resolves.toBe(1); + }); + + it("redacts external wait details from blocked inbox payloads and search", async () => { + const { companyId } = await createCompany("BIX"); + const owner = "Private Vendor Security Team"; + const action = "Send the confidential access token for customer Alpha"; + const issueId = await insertIssue({ + companyId, + identifier: "BIX-1", + title: "Blocked on vendor", + status: "blocked", + description: [ + "Public context stays visible.", + `external owner: ${owner}`, + `external action: ${action}`, + "Continue after the vendor confirms receipt.", + ].join("\n"), + }); + + const rows = await svc.list(companyId, { attention: "blocked" }); + const issue = rows.find((row) => row.id === issueId); + + expect(issue?.description).toContain("Public context stays visible."); + expect(issue?.description).toContain("Continue after the vendor confirms receipt."); + expect(issue?.description).not.toContain(owner); + expect(issue?.description).not.toContain(action); + expect(issue?.blockedInboxAttention).toMatchObject({ + state: "external_wait", + reason: "external_owner_action", + owner: { type: "external", label: null }, + action: { label: "External owner action", detail: null }, + redaction: { externalDetailsRedacted: true, secretFieldsOmitted: true }, + }); + expect(JSON.stringify(issue?.blockedInboxAttention)).not.toContain(owner); + expect(JSON.stringify(issue?.blockedInboxAttention)).not.toContain(action); + + await expect(svc.list(companyId, { attention: "blocked", q: owner })).resolves.toEqual([]); + await expect(svc.count(companyId, { attention: "blocked", q: action })).resolves.toBe(0); + await expect(svc.count(companyId, { attention: "blocked", q: "Public context" })).resolves.toBe(1); + }); + + it("excludes healthy active blockers from blocked inbox attention", async () => { + const { companyId, agentId } = await createCompany("BIB"); + const parentId = await insertIssue({ companyId, identifier: "BIB-1", title: "Blocked source", status: "blocked" }); + const blockerId = await insertIssue({ + companyId, + identifier: "BIB-2", + title: "Running leaf", + status: "todo", + assigneeAgentId: agentId, + }); + await block({ companyId, blockerIssueId: blockerId, blockedIssueId: parentId }); + await activeRun({ companyId, agentId, issueId: blockerId }); + + expect(await svc.list(companyId, { attention: "blocked" })).toEqual([]); + }); + + it("classifies assigned backlog and invalid review leaves for blocked inbox attention", async () => { + const { companyId, agentId, pausedAgentId } = await createCompany("BIC"); + const backlogParentId = await insertIssue({ companyId, identifier: "BIC-1", title: "Blocked by parked work", status: "blocked" }); + const backlogLeafId = await insertIssue({ + companyId, + identifier: "BIC-2", + title: "Parked blocker", + status: "backlog", + assigneeAgentId: agentId, + }); + await block({ companyId, blockerIssueId: backlogLeafId, blockedIssueId: backlogParentId }); + + const reviewId = await insertIssue({ + companyId, + identifier: "BIC-3", + title: "Invalid review", + status: "in_review", + assigneeAgentId: agentId, + executionState: { + status: "pending", + currentStageId: null, + currentStageIndex: null, + currentStageType: "review", + currentParticipant: { type: "agent", agentId: pausedAgentId }, + returnAssignee: null, + reviewRequest: null, + completedStageIds: [], + lastDecisionId: null, + lastDecisionOutcome: null, + }, + }); + + const rows = await svc.list(companyId, { attention: "blocked" }); + const byId = new Map(rows.map((row) => [row.id, row])); + + expect(byId.get(backlogParentId)?.blockedInboxAttention).toMatchObject({ + reason: "blocked_by_assigned_backlog_issue", + severity: "high", + owner: { type: "agent", agentId }, + leafIssue: { id: backlogLeafId }, + }); + expect(byId.get(reviewId)?.blockedInboxAttention).toMatchObject({ + reason: "invalid_review_participant", + severity: "critical", + action: { label: "Repair review participant" }, + }); + }); + + it("classifies recovery issues and missing successful-run dispositions", async () => { + const { companyId, agentId } = await createCompany("BID"); + const sourceId = await insertIssue({ companyId, identifier: "BID-1", title: "Stopped source", status: "blocked" }); + const leafId = await insertIssue({ companyId, identifier: "BID-2", title: "Stopped leaf", status: "todo" }); + const recoveryId = await insertIssue({ + companyId, + identifier: "BID-3", + title: "Recovery issue", + status: "todo", + assigneeAgentId: agentId, + originKind: "harness_liveness_escalation", + originId: buildIssueGraphLivenessIncidentKey({ + companyId, + issueId: sourceId, + state: "blocked_by_unassigned_issue", + blockerIssueId: leafId, + }), + }); + const handoffId = await insertIssue({ + companyId, + identifier: "BID-4", + title: "Needs disposition", + status: "in_progress", + assigneeAgentId: agentId, + }); + await db.insert(activityLog).values({ + companyId, + actorType: "system", + actorId: "system", + action: "issue.successful_run_handoff_required", + entityType: "issue", + entityId: handoffId, + agentId, + details: { sourceRunId: randomUUID(), detectedProgressSummary: "Progress was made" }, + }); + + const rows = await svc.list(companyId, { attention: "blocked" }); + const byId = new Map(rows.map((row) => [row.id, row])); + + expect(byId.get(recoveryId)?.blockedInboxAttention).toMatchObject({ + state: "recovery_open", + reason: "open_recovery_issue", + sourceIssue: { id: sourceId }, + leafIssue: { id: leafId }, + recoveryIssue: { id: recoveryId }, + }); + expect(byId.get(handoffId)?.blockedInboxAttention).toMatchObject({ + state: "missing_disposition", + reason: "missing_successful_run_disposition", + owner: { type: "agent", agentId }, + action: { label: "Choose disposition" }, + }); + }); }); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 27c49949..b0f61244 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -1444,6 +1444,7 @@ export function issueRoutes( const parsedOffset = rawOffset !== undefined && /^\d+$/.test(rawOffset) ? Number.parseInt(rawOffset, 10) : null; + const attention = req.query.attention as string | undefined; if (assigneeUserFilterRaw === "me" && (!assigneeUserId || req.actor.type !== "board")) { res.status(403).json({ error: "assigneeUserId=me requires board authentication" }); @@ -1461,6 +1462,10 @@ export function issueRoutes( res.status(403).json({ error: "unreadForUserId=me requires board authentication" }); return; } + if (attention !== undefined && attention !== "blocked") { + res.status(400).json({ error: "attention must be 'blocked' when provided" }); + return; + } if (rawLimit !== undefined && (parsedLimit === null || !Number.isInteger(parsedLimit) || parsedLimit <= 0)) { res.status(400).json({ error: `limit must be a positive integer up to ${ISSUE_LIST_MAX_LIMIT}` }); return; @@ -1472,6 +1477,7 @@ export function issueRoutes( const offset = parsedOffset ?? 0; const result = await svc.list(companyId, { + attention: attention === "blocked" ? "blocked" : undefined, status: req.query.status as string | undefined, assigneeAgentId: req.query.assigneeAgentId as string | undefined, participantAgentId: req.query.participantAgentId as string | undefined, @@ -1495,6 +1501,8 @@ export function issueRoutes( includePluginOperations: req.query.includePluginOperations === "true" || req.query.includePluginOperations === "1", includeBlockedBy: req.query.includeBlockedBy === "true" || req.query.includeBlockedBy === "1", + includeBlockedInboxAttention: + req.query.includeBlockedInboxAttention === "true" || req.query.includeBlockedInboxAttention === "1", q: req.query.q as string | undefined, limit, offset, @@ -1511,6 +1519,47 @@ export function issueRoutes( }))); }); + router.get("/companies/:companyId/issues/count", async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + const attention = req.query.attention as string | undefined; + if (attention !== "blocked") { + res.status(400).json({ error: "issues/count currently requires attention=blocked" }); + return; + } + if (req.query.limit !== undefined || req.query.offset !== undefined) { + res.status(400).json({ error: "issues/count does not accept limit or offset" }); + return; + } + + const count = await svc.count(companyId, { + attention: "blocked", + status: req.query.status as string | undefined, + assigneeAgentId: req.query.assigneeAgentId as string | undefined, + participantAgentId: req.query.participantAgentId as string | undefined, + assigneeUserId: req.query.assigneeUserId as string | undefined, + projectId: req.query.projectId as string | undefined, + workspaceId: req.query.workspaceId as string | undefined, + executionWorkspaceId: req.query.executionWorkspaceId as string | undefined, + parentId: req.query.parentId as string | undefined, + descendantOf: req.query.descendantOf as string | undefined, + labelId: req.query.labelId as string | undefined, + originKind: req.query.originKind as string | undefined, + originKindPrefix: req.query.originKindPrefix as string | undefined, + originId: req.query.originId as string | undefined, + includeRoutineExecutions: + req.query.includeRoutineExecutions === "true" || req.query.includeRoutineExecutions === "1", + excludeRoutineExecutions: + req.query.excludeRoutineExecutions === "true" || req.query.excludeRoutineExecutions === "1", + includePluginOperations: + req.query.includePluginOperations === "true" || req.query.includePluginOperations === "1", + includeBlockedBy: true, + includeBlockedInboxAttention: true, + q: req.query.q as string | undefined, + }); + res.json({ count }); + }); + router.get("/companies/:companyId/labels", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 7a708c28..e0a3f1a5 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -33,9 +33,12 @@ import type { IssueCommentMetadata, IssueCommentPresentation, IssueBlockerAttention, + IssueBlockedInboxAttention, + IssueBlockedInboxIssueRef, IssueProductivityReview, IssueProductivityReviewTrigger, IssueRelationIssueSummary, + SuccessfulRunHandoffState, } from "@paperclipai/shared"; import { clampIssueRequestDepth, @@ -60,6 +63,7 @@ import { mergeExecutionWorkspaceConfig } from "./execution-workspaces.js"; import { buildInitialIssueMonitorFields, normalizeIssueExecutionPolicy } from "./issue-execution-policy.js"; import { instanceSettingsService } from "./instance-settings.js"; import { redactCurrentUserText } from "../log-redaction.js"; +import { redactSensitiveText } from "../redaction.js"; import { resolveIssueGoalId, resolveNextIssueGoalId } from "./issue-goal-fallback.js"; import { getRunLogStore } from "./run-log-store.js"; import { getDefaultCompanyGoal } from "./goals.js"; @@ -69,6 +73,7 @@ import { type ActiveIssueTreePauseHoldGate, } from "./issue-tree-control.js"; import { parseIssueGraphLivenessIncidentKey } from "./recovery/origins.js"; +import { classifyIssueGraphLiveness, type IssueLivenessFinding } from "./recovery/issue-graph-liveness.js"; const ALL_ISSUE_STATUSES = ["backlog", "todo", "in_progress", "in_review", "blocked", "done", "cancelled"]; const MAX_ISSUE_COMMENT_PAGE_LIMIT = 500; @@ -205,6 +210,7 @@ export function deriveIssueCommentRunLogAttribution( } export interface IssueFilters { + attention?: "blocked"; status?: string; assigneeAgentId?: string; participantAgentId?: string; @@ -225,6 +231,7 @@ export interface IssueFilters { excludeRoutineExecutions?: boolean; includePluginOperations?: boolean; includeBlockedBy?: boolean; + includeBlockedInboxAttention?: boolean; q?: string; limit?: number; offset?: number; @@ -1777,6 +1784,925 @@ async function blockedByMapForIssues( return map; } +const BLOCKED_INBOX_TERMINAL_STATUSES = ["done", "cancelled"] as const; +const BLOCKED_INBOX_ACTIVE_RUN_STATUSES = ["queued", "running"] as const; +const BLOCKED_INBOX_ACTIVE_WAKE_STATUSES = ["queued", "deferred_issue_execution"] as const; +const BLOCKED_INBOX_PENDING_INTERACTION_STATUSES = ["pending"] as const; +const BLOCKED_INBOX_PENDING_APPROVAL_STATUSES = ["pending", "revision_requested"] as const; +const BLOCKED_INBOX_RECOVERY_ORIGIN_KINDS = ["harness_liveness_escalation", "stranded_issue_recovery"] as const; +const BLOCKED_INBOX_SUCCESSFUL_RUN_HANDOFF_ACTIONS = [ + "issue.successful_run_handoff_required", + "issue.successful_run_handoff_resolved", + "issue.successful_run_handoff_escalated", +] as const; + +type BlockedInboxIssueRow = IssueRow & { labels?: IssueLabelRow[]; labelIds?: string[] }; +type BlockedInboxInteractionRow = { + id: string; + issueId: string; + kind: string; + createdAt: Date; +}; +type BlockedInboxApprovalRow = { + approvalId: string; + issueId: string; + createdAt: Date; +}; + +function issueRef(row: Pick | null | undefined): IssueBlockedInboxIssueRef | null { + if (!row) return null; + return { + id: row.id, + identifier: row.identifier, + title: row.title, + status: row.status as IssueBlockedInboxIssueRef["status"], + priority: row.priority as IssueBlockedInboxIssueRef["priority"], + assigneeAgentId: row.assigneeAgentId, + assigneeUserId: row.assigneeUserId, + }; +} + +function isoDate(value: Date | string | null | undefined): string | null { + if (!value) return null; + const date = value instanceof Date ? value : new Date(value); + return Number.isNaN(date.getTime()) ? null : date.toISOString(); +} + +function attentionBase(input: { + state: IssueBlockedInboxAttention["state"]; + reason: IssueBlockedInboxAttention["reason"]; + severity: IssueBlockedInboxAttention["severity"]; + stoppedSinceAt: Date | string | null | undefined; + owner: IssueBlockedInboxAttention["owner"]; + action: IssueBlockedInboxAttention["action"]; + sourceIssue: IssueBlockedInboxIssueRef | null; + leafIssue?: IssueBlockedInboxIssueRef | null; + recoveryIssue?: IssueBlockedInboxIssueRef | null; + approvalId?: string | null; + interactionId?: string | null; + sampleIssueIdentifier?: string | null; + externalDetailsRedacted?: boolean; +}): IssueBlockedInboxAttention { + return { + kind: "blocked", + state: input.state, + reason: input.reason, + severity: input.severity, + stoppedSinceAt: isoDate(input.stoppedSinceAt), + owner: input.owner, + action: input.action, + sourceIssue: input.sourceIssue, + leafIssue: input.leafIssue ?? null, + recoveryIssue: input.recoveryIssue ?? null, + approvalId: input.approvalId ?? null, + interactionId: input.interactionId ?? null, + sampleIssueIdentifier: + input.sampleIssueIdentifier + ?? input.leafIssue?.identifier + ?? input.recoveryIssue?.identifier + ?? input.sourceIssue?.identifier + ?? null, + redaction: { + externalDetailsRedacted: input.externalDetailsRedacted ?? false, + secretFieldsOmitted: true, + }, + }; +} + +function readSuccessfulRunHandoffFromActivity(row: { + action: string; + agentId: string | null; + runId: string | null; + details: Record | null; + createdAt: Date; +}): SuccessfulRunHandoffState | null { + const details = row.details ?? {}; + const state = + row.action === "issue.successful_run_handoff_required" + ? "required" + : row.action === "issue.successful_run_handoff_resolved" + ? "resolved" + : row.action === "issue.successful_run_handoff_escalated" + ? "escalated" + : null; + if (!state) return null; + + const detectedProgressSummary = + readStringFromRecord(details, "detectedProgressSummary") + ?? readStringFromRecord(details, "detected_progress_summary") + ?? null; + + return { + state, + required: state === "required", + sourceRunId: + readStringFromRecord(details, "sourceRunId") + ?? readStringFromRecord(details, "source_run_id") + ?? readStringFromRecord(details, "resumeFromRunId") + ?? row.runId + ?? null, + correctiveRunId: + readStringFromRecord(details, "correctiveRunId") + ?? readStringFromRecord(details, "corrective_run_id") + ?? (state !== "required" ? row.runId : null), + assigneeAgentId: + readStringFromRecord(details, "assigneeAgentId") + ?? readStringFromRecord(details, "agentId") + ?? row.agentId + ?? null, + detectedProgressSummary: detectedProgressSummary ? redactSensitiveText(detectedProgressSummary) : null, + createdAt: row.createdAt, + }; +} + +async function listSuccessfulRunHandoffMapForIssues( + dbOrTx: any, + companyId: string, + issueIds: string[], +): Promise> { + const uniqueIssueIds = [...new Set(issueIds)]; + const states = new Map(); + if (uniqueIssueIds.length === 0) return states; + + for (const issueIdChunk of chunkList(uniqueIssueIds, ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) { + const rows = await dbOrTx + .select({ + entityId: activityLog.entityId, + action: activityLog.action, + agentId: activityLog.agentId, + runId: activityLog.runId, + details: activityLog.details, + createdAt: activityLog.createdAt, + }) + .from(activityLog) + .where(and( + eq(activityLog.companyId, companyId), + eq(activityLog.entityType, "issue"), + inArray(activityLog.entityId, issueIdChunk), + inArray(activityLog.action, [...BLOCKED_INBOX_SUCCESSFUL_RUN_HANDOFF_ACTIONS]), + )) + .orderBy(activityLog.entityId, desc(activityLog.createdAt), desc(activityLog.id)); + + for (const row of rows as Array<{ + entityId: string; + action: string; + agentId: string | null; + runId: string | null; + details: Record | null; + createdAt: Date; + }>) { + if (states.has(row.entityId)) continue; + const state = readSuccessfulRunHandoffFromActivity(row); + if (state) states.set(row.entityId, state); + } + } + + return states; +} + +function externalWaitFromDescription(description: string | null): { owner: string; action: string } | null { + if (!description) return null; + const owner = description.match(/^\s*external owner\s*:\s*(.+)$/im)?.[1]?.trim(); + const action = description.match(/^\s*external action\s*:\s*(.+)$/im)?.[1]?.trim(); + if (!owner || !action) return null; + return { + owner: owner.slice(0, 120), + action: action.slice(0, 240), + }; +} + +function escapeRegExp(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function redactExternalWaitDescription( + description: string | null | undefined, + external: { owner: string; action: string } | null, +) { + if (!description) return null; + let redacted = description + .split(/\r?\n/) + .filter((line) => !/^\s*external\s+(?:owner|action)\s*:/i.test(line)) + .join("\n"); + + for (const value of [external?.owner, external?.action]) { + if (!value) continue; + redacted = redacted.replace(new RegExp(escapeRegExp(value), "gi"), "[redacted external wait detail]"); + } + + redacted = redacted.replace(/\n{3,}/g, "\n\n").trim(); + return redacted.length > 0 ? redacted : null; +} + +function blockedInboxResponseDescription(attention: IssueBlockedInboxAttention, row: BlockedInboxIssueRow) { + if (!attention.redaction.externalDetailsRedacted) return row.description; + return redactExternalWaitDescription(row.description, externalWaitFromDescription(row.description)); +} + +function blockedInboxSearchText(attention: IssueBlockedInboxAttention, row: BlockedInboxIssueRow) { + return [ + row.identifier, + row.title, + blockedInboxResponseDescription(attention, row), + attention.sourceIssue?.identifier, + attention.sourceIssue?.title, + attention.leafIssue?.identifier, + attention.leafIssue?.title, + attention.recoveryIssue?.identifier, + attention.recoveryIssue?.title, + attention.action.label, + attention.action.detail, + ] + .filter((value): value is string => typeof value === "string" && value.length > 0) + .join(" ") + .toLowerCase(); +} + +function blockedInboxSeverityRank(severity: IssueBlockedInboxAttention["severity"]) { + switch (severity) { + case "critical": + return 0; + case "high": + return 1; + case "medium": + return 2; + case "low": + return 3; + } +} + +function issuePriorityRank(priority: string) { + switch (priority) { + case "critical": + return 0; + case "high": + return 1; + case "medium": + return 2; + case "low": + return 3; + default: + return 4; + } +} + +function compareBlockedInboxRows( + left: BlockedInboxIssueRow & { blockedInboxAttention: IssueBlockedInboxAttention; lastActivityAt?: Date | null }, + right: BlockedInboxIssueRow & { blockedInboxAttention: IssueBlockedInboxAttention; lastActivityAt?: Date | null }, +) { + const leftAttention = left.blockedInboxAttention; + const rightAttention = right.blockedInboxAttention; + const severity = blockedInboxSeverityRank(leftAttention.severity) + - blockedInboxSeverityRank(rightAttention.severity); + if (severity !== 0) return severity; + + const leftStopped = leftAttention.stoppedSinceAt + ? new Date(leftAttention.stoppedSinceAt).getTime() + : Number.POSITIVE_INFINITY; + const rightStopped = rightAttention.stoppedSinceAt + ? new Date(rightAttention.stoppedSinceAt).getTime() + : Number.POSITIVE_INFINITY; + if (leftStopped !== rightStopped) return leftStopped - rightStopped; + + const priority = issuePriorityRank(left.priority) - issuePriorityRank(right.priority); + if (priority !== 0) return priority; + + const leftActivity = left.lastActivityAt ? new Date(left.lastActivityAt).getTime() : new Date(left.updatedAt).getTime(); + const rightActivity = right.lastActivityAt ? new Date(right.lastActivityAt).getTime() : new Date(right.updatedAt).getTime(); + if (leftActivity !== rightActivity) return rightActivity - leftActivity; + + return right.id.localeCompare(left.id); +} + +async function listIssueBlockedInboxAttentionMap( + dbOrTx: any, + companyId: string, + issueRows: BlockedInboxIssueRow[], +): Promise> { + const rowIssueIds = [...new Set(issueRows.map((row) => row.id))]; + const result = new Map(); + if (rowIssueIds.length === 0) return result; + + const [graphIssueRows, graphRelationRows, companyAgentRows] = await Promise.all([ + dbOrTx + .select() + .from(issues) + .where(and( + eq(issues.companyId, companyId), + isNull(issues.hiddenAt), + notInArray(issues.status, [...BLOCKED_INBOX_TERMINAL_STATUSES]), + )), + dbOrTx + .select({ + companyId: issueRelations.companyId, + blockerIssueId: issueRelations.issueId, + blockedIssueId: issueRelations.relatedIssueId, + }) + .from(issueRelations) + .where(and(eq(issueRelations.companyId, companyId), eq(issueRelations.type, "blocks"))), + dbOrTx + .select({ + id: agents.id, + companyId: agents.companyId, + name: agents.name, + role: agents.role, + title: agents.title, + status: agents.status, + reportsTo: agents.reportsTo, + }) + .from(agents) + .where(eq(agents.companyId, companyId)), + ]); + + const graphIssues = graphIssueRows as IssueRow[]; + const graphRelations = graphRelationRows as Array<{ companyId: string; blockerIssueId: string; blockedIssueId: string }>; + const companyAgents = companyAgentRows as Array<{ + id: string; + companyId: string; + name: string; + role: string; + title: string | null; + status: string; + reportsTo: string | null; + }>; + const graphIssueIds = graphIssues.map((issue) => issue.id); + const issuesById = new Map(graphIssues.map((issue) => [issue.id, issue])); + + const [activeRunRows, wakeRows, scheduledRetryRows, interactionRows, approvalRows, handoffMap] = await Promise.all([ + graphIssueIds.length === 0 + ? Promise.resolve([]) + : dbOrTx + .select({ + companyId: heartbeatRuns.companyId, + issueId: sql`${heartbeatRuns.contextSnapshot} ->> 'issueId'`, + agentId: heartbeatRuns.agentId, + status: heartbeatRuns.status, + }) + .from(heartbeatRuns) + .where(and( + eq(heartbeatRuns.companyId, companyId), + inArray(heartbeatRuns.status, [...BLOCKED_INBOX_ACTIVE_RUN_STATUSES]), + inArray(sql`${heartbeatRuns.contextSnapshot} ->> 'issueId'`, graphIssueIds), + )), + graphIssueIds.length === 0 + ? Promise.resolve([]) + : dbOrTx + .select({ + companyId: agentWakeupRequests.companyId, + issueId: sql`${agentWakeupRequests.payload} ->> 'issueId'`, + agentId: agentWakeupRequests.agentId, + status: agentWakeupRequests.status, + }) + .from(agentWakeupRequests) + .where(and( + eq(agentWakeupRequests.companyId, companyId), + inArray(agentWakeupRequests.status, [...BLOCKED_INBOX_ACTIVE_WAKE_STATUSES]), + sql`${agentWakeupRequests.runId} is null`, + inArray(sql`${agentWakeupRequests.payload} ->> 'issueId'`, graphIssueIds), + )), + graphIssueIds.length === 0 + ? Promise.resolve([]) + : dbOrTx + .select({ + companyId: heartbeatRuns.companyId, + issueId: sql`${heartbeatRuns.contextSnapshot} ->> 'issueId'`, + agentId: heartbeatRuns.agentId, + status: heartbeatRuns.status, + }) + .from(heartbeatRuns) + .where(and( + eq(heartbeatRuns.companyId, companyId), + eq(heartbeatRuns.status, "scheduled_retry"), + inArray(sql`${heartbeatRuns.contextSnapshot} ->> 'issueId'`, graphIssueIds), + )), + graphIssueIds.length === 0 + ? Promise.resolve([]) + : dbOrTx + .select({ + id: issueThreadInteractions.id, + issueId: issueThreadInteractions.issueId, + kind: issueThreadInteractions.kind, + createdAt: issueThreadInteractions.createdAt, + }) + .from(issueThreadInteractions) + .where(and( + eq(issueThreadInteractions.companyId, companyId), + inArray(issueThreadInteractions.status, [...BLOCKED_INBOX_PENDING_INTERACTION_STATUSES]), + inArray(issueThreadInteractions.issueId, graphIssueIds), + )), + graphIssueIds.length === 0 + ? Promise.resolve([]) + : dbOrTx + .select({ + approvalId: approvals.id, + issueId: issueApprovals.issueId, + createdAt: approvals.createdAt, + }) + .from(issueApprovals) + .innerJoin(approvals, eq(issueApprovals.approvalId, approvals.id)) + .where(and( + eq(issueApprovals.companyId, companyId), + eq(approvals.companyId, companyId), + inArray(approvals.status, [...BLOCKED_INBOX_PENDING_APPROVAL_STATUSES]), + inArray(issueApprovals.issueId, graphIssueIds), + )), + listSuccessfulRunHandoffMapForIssues(dbOrTx, companyId, rowIssueIds), + ]); + + const pendingInteractions = (interactionRows as BlockedInboxInteractionRow[]).map((row) => ({ + companyId, + issueId: row.issueId, + status: "pending", + })); + const pendingApprovals = (approvalRows as BlockedInboxApprovalRow[]).map((row) => ({ + companyId, + issueId: row.issueId, + status: "pending", + })); + + const openRecoveryIssues = graphIssues + .filter((issue) => BLOCKED_INBOX_RECOVERY_ORIGIN_KINDS.includes(issue.originKind as typeof BLOCKED_INBOX_RECOVERY_ORIGIN_KINDS[number])) + .flatMap((issue) => { + const entries = [{ companyId, issueId: issue.id, status: issue.status }]; + if (issue.originKind === "harness_liveness_escalation") { + const parsed = parseIssueGraphLivenessIncidentKey(issue.originId); + if (parsed?.companyId === companyId) { + entries.push({ companyId, issueId: parsed.issueId, status: issue.status }); + entries.push({ companyId, issueId: parsed.leafIssueId, status: issue.status }); + } + } else if (issue.originKind === "stranded_issue_recovery" && issue.originId) { + entries.push({ companyId, issueId: issue.originId, status: issue.status }); + } + return entries; + }); + + const findings = classifyIssueGraphLiveness({ + issues: graphIssues.map((issue) => ({ + id: issue.id, + companyId: issue.companyId, + identifier: issue.identifier, + title: issue.title, + status: issue.status, + projectId: issue.projectId, + goalId: issue.goalId, + parentId: issue.parentId, + assigneeAgentId: issue.assigneeAgentId, + assigneeUserId: issue.assigneeUserId, + createdByAgentId: issue.createdByAgentId, + createdByUserId: issue.createdByUserId, + executionPolicy: issue.executionPolicy, + executionState: issue.executionState, + monitorNextCheckAt: issue.monitorNextCheckAt, + monitorAttemptCount: issue.monitorAttemptCount, + })), + relations: graphRelations, + agents: companyAgents, + activeRuns: (activeRunRows as Array<{ companyId: string; issueId: string | null; agentId: string | null; status: string }>) + .flatMap((row) => row.issueId + ? [{ companyId: row.companyId, issueId: row.issueId, agentId: row.agentId, status: row.status }] + : []), + queuedWakeRequests: [ + ...(wakeRows as Array<{ companyId: string; issueId: string | null; agentId: string | null; status: string }>), + ...(scheduledRetryRows as Array<{ companyId: string; issueId: string | null; agentId: string | null; status: string }>), + ] + .flatMap((row) => row.issueId + ? [{ companyId: row.companyId, issueId: row.issueId, agentId: row.agentId, status: row.status }] + : []), + pendingInteractions, + pendingApprovals, + openRecoveryIssues, + now: new Date(), + }); + const findingByIssueId = new Map(); + for (const finding of findings) { + if (!findingByIssueId.has(finding.issueId)) findingByIssueId.set(finding.issueId, finding); + } + + const interactionByIssueId = new Map(); + for (const row of interactionRows as BlockedInboxInteractionRow[]) { + if (!interactionByIssueId.has(row.issueId)) interactionByIssueId.set(row.issueId, row); + } + const approvalByIssueId = new Map(); + for (const row of approvalRows as BlockedInboxApprovalRow[]) { + if (!approvalByIssueId.has(row.issueId)) approvalByIssueId.set(row.issueId, row); + } + + for (const row of issueRows) { + if (row.companyId !== companyId || BLOCKED_INBOX_TERMINAL_STATUSES.includes(row.status as typeof BLOCKED_INBOX_TERMINAL_STATUSES[number]) || row.hiddenAt) { + continue; + } + const source = issueRef(row); + const handoff = handoffMap.get(row.id); + if (handoff && (handoff.required || handoff.state === "escalated")) { + result.set(row.id, attentionBase({ + state: "missing_disposition", + reason: "missing_successful_run_disposition", + severity: "high", + stoppedSinceAt: handoff.createdAt ?? row.updatedAt, + owner: { + type: row.assigneeAgentId ? "agent" : row.assigneeUserId ? "user" : "unknown", + agentId: row.assigneeAgentId, + userId: row.assigneeUserId, + label: null, + }, + action: { + label: "Choose disposition", + detail: "Choose exactly one final disposition: done, cancelled, review/input, blocked with owner, delegated follow-up, or queued continuation.", + }, + sourceIssue: source, + })); + continue; + } + + if (BLOCKED_INBOX_RECOVERY_ORIGIN_KINDS.includes(row.originKind as typeof BLOCKED_INBOX_RECOVERY_ORIGIN_KINDS[number])) { + let sourceIssue: IssueBlockedInboxIssueRef | null = null; + let leafIssue: IssueBlockedInboxIssueRef | null = null; + if (row.originKind === "harness_liveness_escalation") { + const parsed = parseIssueGraphLivenessIncidentKey(row.originId); + if (parsed?.companyId === companyId) { + sourceIssue = issueRef(issuesById.get(parsed.issueId)); + leafIssue = issueRef(issuesById.get(parsed.leafIssueId)); + } + } else if (row.originKind === "stranded_issue_recovery" && row.originId) { + sourceIssue = issueRef(issuesById.get(row.originId)); + } + result.set(row.id, attentionBase({ + state: "recovery_open", + reason: "open_recovery_issue", + severity: "high", + stoppedSinceAt: row.createdAt, + owner: { + type: row.assigneeAgentId ? "agent" : row.assigneeUserId ? "user" : "unknown", + agentId: row.assigneeAgentId, + userId: row.assigneeUserId, + label: null, + }, + action: { + label: "Resolve recovery", + detail: "Restore a live path for the source work or record why this recovery issue is a false positive.", + }, + sourceIssue: sourceIssue ?? source, + leafIssue, + recoveryIssue: source, + })); + continue; + } + + const interaction = interactionByIssueId.get(row.id); + if (interaction) { + const isUserQuestion = interaction.kind === "ask_user_questions" && Boolean(row.assigneeUserId); + result.set(row.id, attentionBase({ + state: "awaiting_decision", + reason: isUserQuestion ? "pending_user_decision" : "pending_board_decision", + severity: "medium", + stoppedSinceAt: interaction.createdAt, + owner: isUserQuestion + ? { type: "user", agentId: null, userId: row.assigneeUserId, label: null } + : { type: "board", agentId: null, userId: null, label: "Board" }, + action: { + label: isUserQuestion ? "Answer question" : "Answer confirmation", + detail: "Respond to the pending issue-thread interaction so the assignee has a live next action.", + }, + sourceIssue: source, + interactionId: interaction.id, + })); + continue; + } + + const approval = approvalByIssueId.get(row.id); + if (approval) { + result.set(row.id, attentionBase({ + state: "awaiting_decision", + reason: "pending_board_decision", + severity: "medium", + stoppedSinceAt: approval.createdAt, + owner: { type: "board", agentId: null, userId: null, label: "Board" }, + action: { + label: "Decide approval", + detail: "Approve, reject, or request revision on the linked approval.", + }, + sourceIssue: source, + approvalId: approval.approvalId, + })); + continue; + } + + const finding = findingByIssueId.get(row.id); + if (finding) { + const leaf = finding.dependencyPath.length > 1 + ? issuesById.get(finding.dependencyPath[finding.dependencyPath.length - 1]!.issueId) + : issuesById.get(finding.recoveryIssueId); + const ownerAgentId = finding.state === "blocked_by_unassigned_issue" + ? null + : finding.recommendedOwnerAgentId ?? row.assigneeAgentId ?? leaf?.assigneeAgentId ?? null; + result.set(row.id, attentionBase({ + state: "needs_attention", + reason: finding.state as IssueBlockedInboxAttention["reason"], + severity: finding.state === "blocked_by_assigned_backlog_issue" + || finding.state === "in_review_without_action_path" + ? "high" + : finding.severity === "critical" ? "critical" : "high", + stoppedSinceAt: leaf?.updatedAt ?? row.updatedAt, + owner: { + type: ownerAgentId ? "agent" : leaf?.assigneeUserId ? "user" : "unknown", + agentId: ownerAgentId, + userId: leaf?.assigneeUserId ?? null, + label: null, + }, + action: { + label: (() => { + switch (finding.state) { + case "blocked_by_unassigned_issue": + return "Assign blocker"; + case "blocked_by_assigned_backlog_issue": + return "Resume parked blocker"; + case "blocked_by_uninvokable_assignee": + return "Assign active owner"; + case "blocked_by_cancelled_issue": + return "Replace blocker"; + case "invalid_review_participant": + return "Repair review participant"; + case "in_review_without_action_path": + return "Choose review path"; + } + })(), + detail: finding.recommendedAction, + }, + sourceIssue: source, + leafIssue: issueRef(leaf), + recoveryIssue: issueRef(issuesById.get(finding.recoveryIssueId)), + sampleIssueIdentifier: leaf?.identifier ?? finding.identifier, + })); + continue; + } + + const hasMonitor = Boolean(row.monitorNextCheckAt && row.monitorNextCheckAt.getTime() > Date.now()); + const external = row.status === "blocked" && !hasMonitor ? externalWaitFromDescription(row.description) : null; + if (external) { + result.set(row.id, attentionBase({ + state: "external_wait", + reason: "external_owner_action", + severity: "medium", + stoppedSinceAt: row.updatedAt, + owner: { type: "external", agentId: null, userId: null, label: null }, + action: { + label: "External owner action", + detail: null, + }, + sourceIssue: source, + externalDetailsRedacted: true, + })); + continue; + } + + const blockerAttention = await listIssueBlockerAttentionMap(dbOrTx, companyId, [row]); + const blockerState = blockerAttention.get(row.id); + if (row.status === "blocked" && (blockerState?.state === "needs_attention" || blockerState?.state === "stalled")) { + result.set(row.id, attentionBase({ + state: "needs_attention", + reason: "blocked_chain_stalled", + severity: "high", + stoppedSinceAt: row.updatedAt, + owner: { type: "unknown", agentId: null, userId: null, label: null }, + action: { + label: "Inspect blocker chain", + detail: "Inspect the stalled blocker or review leaf and make the next owner/action explicit.", + }, + sourceIssue: source, + sampleIssueIdentifier: blockerState.sampleStalledBlockerIdentifier ?? blockerState.sampleBlockerIdentifier, + })); + } + } + + return result; +} + +async function blockedInboxIssueConditions( + dbOrTx: any, + companyId: string, + filters?: IssueFilters, +) { + const conditions = [ + eq(issues.companyId, companyId), + isNull(issues.hiddenAt), + notInArray(issues.status, [...BLOCKED_INBOX_TERMINAL_STATUSES]), + ]; + const touchedByUserId = filters?.touchedByUserId?.trim() || undefined; + const inboxArchivedByUserId = filters?.inboxArchivedByUserId?.trim() || undefined; + const unreadForUserId = filters?.unreadForUserId?.trim() || undefined; + const contextUserId = unreadForUserId ?? touchedByUserId ?? inboxArchivedByUserId; + + if (filters?.descendantOf) { + conditions.push(sql` + ${issues.id} IN ( + WITH RECURSIVE descendants(id) AS ( + SELECT ${issues.id} + FROM ${issues} + WHERE ${issues.companyId} = ${companyId} + AND ${issues.parentId} = ${filters.descendantOf} + UNION + SELECT ${issues.id} + FROM ${issues} + JOIN descendants ON ${issues.parentId} = descendants.id + WHERE ${issues.companyId} = ${companyId} + ) + SELECT id FROM descendants + ) + `); + } + if (filters?.status) { + const statuses = filters.status.split(",").map((status) => status.trim()).filter(Boolean); + if (statuses.length > 0) { + conditions.push(statuses.length === 1 ? eq(issues.status, statuses[0]!) : inArray(issues.status, statuses)); + } + } + if (filters?.assigneeAgentId) conditions.push(eq(issues.assigneeAgentId, filters.assigneeAgentId)); + if (filters?.participantAgentId) conditions.push(participatedByAgentCondition(companyId, filters.participantAgentId)); + if (filters?.assigneeUserId) conditions.push(eq(issues.assigneeUserId, filters.assigneeUserId)); + if (touchedByUserId) conditions.push(touchedByUserCondition(companyId, touchedByUserId)); + if (inboxArchivedByUserId) conditions.push(inboxVisibleForUserCondition(companyId, inboxArchivedByUserId)); + if (unreadForUserId) conditions.push(unreadForUserCondition(companyId, unreadForUserId)); + if (filters?.projectId) conditions.push(eq(issues.projectId, filters.projectId)); + if (filters?.workspaceId) { + conditions.push(or( + eq(issues.executionWorkspaceId, filters.workspaceId), + eq(issues.projectWorkspaceId, filters.workspaceId), + )!); + } + if (filters?.executionWorkspaceId) conditions.push(eq(issues.executionWorkspaceId, filters.executionWorkspaceId)); + if (filters?.parentId) conditions.push(eq(issues.parentId, filters.parentId)); + if (filters?.originKind) conditions.push(eq(issues.originKind, filters.originKind)); + if (filters?.originKindPrefix) conditions.push(like(issues.originKind, `${filters.originKindPrefix}%`)); + if (filters?.originId) conditions.push(eq(issues.originId, filters.originId)); + if (!shouldIncludePluginOperationIssues(filters)) conditions.push(nonPluginOperationIssueCondition()); + if (filters?.labelId) { + const labeledIssueIds = await dbOrTx + .select({ issueId: issueLabels.issueId }) + .from(issueLabels) + .where(and(eq(issueLabels.companyId, companyId), eq(issueLabels.labelId, filters.labelId))); + if (labeledIssueIds.length === 0) return { conditions: [sql`false`], contextUserId }; + conditions.push(inArray(issues.id, labeledIssueIds.map((row: { issueId: string }) => row.issueId))); + } + if (filters?.excludeRoutineExecutions && !filters?.originKind && !filters?.originId) { + conditions.push(ne(issues.originKind, "routine_execution")); + } + + return { conditions, contextUserId }; +} + +async function listBlockedInboxIssues( + dbOrTx: any, + companyId: string, + filters?: IssueFilters, +): Promise> { + const { conditions, contextUserId } = await blockedInboxIssueConditions(dbOrTx, companyId, filters); + + const rows = (await dbOrTx + .select(issueListSelect) + .from(issues) + .where(and(...conditions)) + .orderBy(desc(issueCanonicalLastActivityAtExpr(companyId)), desc(issues.updatedAt), desc(issues.id))) + .map((row: any) => ({ + ...row, + description: decodeDatabaseTextPreview(row.description, ISSUE_LIST_DESCRIPTION_MAX_CHARS), + })); + const withLabels = await withIssueLabels(dbOrTx, rows); + const withRuns = withActiveRuns(withLabels, await activeRunMapForIssues(dbOrTx, withLabels)); + if (withRuns.length === 0) return []; + + const issueIds = withRuns.map((row) => row.id); + const [ + statsRows, + readRows, + lastActivityRows, + blockedByMap, + blockerAttentionByIssueId, + productivityReviewByIssueId, + blockedInboxAttentionByIssueId, + ] = await Promise.all([ + contextUserId ? userCommentStatsForIssues(dbOrTx, companyId, contextUserId, issueIds) : Promise.resolve([]), + contextUserId ? userReadStatsForIssues(dbOrTx, companyId, contextUserId, issueIds) : Promise.resolve([]), + lastActivityStatsForIssues(dbOrTx, companyId, issueIds), + blockedByMapForIssues(dbOrTx, companyId, issueIds), + listIssueBlockerAttentionMap(dbOrTx, companyId, withRuns), + listIssueProductivityReviewMap(dbOrTx, companyId, issueIds), + listIssueBlockedInboxAttentionMap(dbOrTx, companyId, withRuns), + ]); + + const rawSearchInput = filters?.q?.trim() ?? ""; + const rawSearch = rawSearchInput.toLowerCase(); + const commentSearchMatchIssueIds = new Set(); + if (rawSearchInput) { + const containsPattern = `%${escapeLikePattern(rawSearchInput)}%`; + for (const issueIdChunk of chunkList(issueIds, ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) { + const rows = await dbOrTx + .select({ issueId: issueComments.issueId }) + .from(issueComments) + .where(and( + eq(issueComments.companyId, companyId), + inArray(issueComments.issueId, issueIdChunk), + sql`${issueComments.body} ILIKE ${containsPattern} ESCAPE '\\'`, + )); + for (const row of rows as Array<{ issueId: string }>) commentSearchMatchIssueIds.add(row.issueId); + } + } + const statsByIssueId = new Map(statsRows.map((row) => [row.issueId, row])); + const readByIssueId = new Map(readRows.map((row) => [row.issueId, row.myLastReadAt])); + const lastActivityByIssueId = new Map(lastActivityRows.map((row) => [row.issueId, row])); + + const enriched = withRuns.flatMap((row) => { + const blockedInboxAttention = blockedInboxAttentionByIssueId.get(row.id); + if (!blockedInboxAttention) return []; + if ( + rawSearch + && !blockedInboxSearchText(blockedInboxAttention, row).includes(rawSearch) + && !commentSearchMatchIssueIds.has(row.id) + ) return []; + + const activity = lastActivityByIssueId.get(row.id); + const lastActivityAt = latestIssueActivityAt( + row.updatedAt, + activity?.latestCommentAt ?? null, + activity?.latestLogAt ?? null, + ) ?? row.updatedAt; + return [{ + ...row, + description: blockedInboxResponseDescription(blockedInboxAttention, row), + blockedBy: blockedByMap.get(row.id) ?? [], + lastActivityAt, + ...(blockerAttentionByIssueId.has(row.id) ? { blockerAttention: blockerAttentionByIssueId.get(row.id) } : {}), + blockedInboxAttention, + ...(productivityReviewByIssueId.has(row.id) + ? { productivityReview: productivityReviewByIssueId.get(row.id) } + : {}), + ...(contextUserId + ? deriveIssueUserContext(row, contextUserId, { + myLastCommentAt: statsByIssueId.get(row.id)?.myLastCommentAt ?? null, + myLastReadAt: readByIssueId.get(row.id) ?? null, + lastExternalCommentAt: statsByIssueId.get(row.id)?.lastExternalCommentAt ?? null, + }) + : {}), + }]; + }).sort(compareBlockedInboxRows); + + const offset = typeof filters?.offset === "number" && Number.isFinite(filters.offset) + ? Math.max(0, Math.floor(filters.offset)) + : 0; + const limit = typeof filters?.limit === "number" && Number.isFinite(filters.limit) + ? Math.max(1, Math.floor(filters.limit)) + : undefined; + return limit === undefined ? enriched.slice(offset) : enriched.slice(offset, offset + limit); +} + +async function countBlockedInboxIssues(dbOrTx: any, companyId: string, filters?: IssueFilters): Promise { + const { conditions } = await blockedInboxIssueConditions(dbOrTx, companyId, filters); + const rows = (await dbOrTx + .select() + .from(issues) + .where(and(...conditions))) as IssueRow[]; + if (rows.length === 0) return 0; + + const blockedInboxAttentionByIssueId = await listIssueBlockedInboxAttentionMap(dbOrTx, companyId, rows); + const rawSearchInput = filters?.q?.trim() ?? ""; + const rawSearch = rawSearchInput.toLowerCase(); + const commentSearchMatchIssueIds = new Set(); + if (rawSearchInput) { + const issueIds = rows.map((row) => row.id); + const containsPattern = `%${escapeLikePattern(rawSearchInput)}%`; + for (const issueIdChunk of chunkList(issueIds, ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) { + const commentRows = await dbOrTx + .select({ issueId: issueComments.issueId }) + .from(issueComments) + .where(and( + eq(issueComments.companyId, companyId), + inArray(issueComments.issueId, issueIdChunk), + sql`${issueComments.body} ILIKE ${containsPattern} ESCAPE '\\'`, + )); + for (const row of commentRows as Array<{ issueId: string }>) commentSearchMatchIssueIds.add(row.issueId); + } + } + + return rows.reduce((count: number, row: IssueRow) => { + const attention = blockedInboxAttentionByIssueId.get(row.id); + if (!attention) return count; + if ( + rawSearch + && !blockedInboxSearchText(attention, row).includes(rawSearch) + && !commentSearchMatchIssueIds.has(row.id) + ) return count; + return count + 1; + }, 0); +} + export function issueService(db: Db) { const instanceSettings = instanceSettingsService(db); const treeControlSvc = issueTreeControlService(db); @@ -2447,6 +3373,14 @@ export function issueService(db: Db) { clearExecutionRunIfTerminal, list: async (companyId: string, filters?: IssueFilters) => { + if (filters?.attention === "blocked") { + return listBlockedInboxIssues(db, companyId, { + ...filters, + includeBlockedBy: true, + includeBlockedInboxAttention: true, + }); + } + const conditions = [eq(issues.companyId, companyId)]; const limit = typeof filters?.limit === "number" && Number.isFinite(filters.limit) ? Math.max(1, Math.floor(filters.limit)) @@ -2459,6 +3393,7 @@ export function issueService(db: Db) { const unreadForUserId = filters?.unreadForUserId?.trim() || undefined; const contextUserId = unreadForUserId ?? touchedByUserId ?? inboxArchivedByUserId; const includeBlockedBy = filters?.includeBlockedBy === true; + const includeBlockedInboxAttention = filters?.includeBlockedInboxAttention === true; const rawSearch = filters?.q?.trim() ?? ""; const hasSearch = rawSearch.length > 0; const escapedSearch = hasSearch ? escapeLikePattern(rawSearch) : ""; @@ -2611,9 +3546,16 @@ export function issueService(db: Db) { ]); const statsByIssueId = new Map(statsRows.map((row) => [row.issueId, row])); const lastActivityByIssueId = new Map(lastActivityRows.map((row) => [row.issueId, row])); - const [blockerAttentionByIssueId, productivityReviewByIssueId] = await Promise.all([ + const [ + blockerAttentionByIssueId, + productivityReviewByIssueId, + blockedInboxAttentionByIssueId, + ] = await Promise.all([ listIssueBlockerAttentionMap(db, companyId, withRuns), listIssueProductivityReviewMap(db, companyId, issueIds), + includeBlockedInboxAttention + ? listIssueBlockedInboxAttentionMap(db, companyId, withRuns) + : Promise.resolve(new Map()), ]); if (!contextUserId) { @@ -2629,6 +3571,7 @@ export function issueService(db: Db) { ...(includeBlockedBy ? { blockedBy: blockedByMap.get(row.id) ?? [] } : {}), lastActivityAt, ...(blockerAttentionByIssueId.has(row.id) ? { blockerAttention: blockerAttentionByIssueId.get(row.id) } : {}), + ...(includeBlockedInboxAttention ? { blockedInboxAttention: blockedInboxAttentionByIssueId.get(row.id) ?? null } : {}), ...(productivityReviewByIssueId.has(row.id) ? { productivityReview: productivityReviewByIssueId.get(row.id) } : {}), @@ -2650,6 +3593,7 @@ export function issueService(db: Db) { ...(includeBlockedBy ? { blockedBy: blockedByMap.get(row.id) ?? [] } : {}), lastActivityAt, ...(blockerAttentionByIssueId.has(row.id) ? { blockerAttention: blockerAttentionByIssueId.get(row.id) } : {}), + ...(includeBlockedInboxAttention ? { blockedInboxAttention: blockedInboxAttentionByIssueId.get(row.id) ?? null } : {}), ...(productivityReviewByIssueId.has(row.id) ? { productivityReview: productivityReviewByIssueId.get(row.id) } : {}), @@ -2662,6 +3606,39 @@ export function issueService(db: Db) { }); }, + count: async (companyId: string, filters?: IssueFilters) => { + if (filters?.attention === "blocked") { + return countBlockedInboxIssues(db, companyId, filters); + } + + const conditions = [eq(issues.companyId, companyId), isNull(issues.hiddenAt)]; + if (filters?.status) { + const statuses = filters.status.split(",").map((status) => status.trim()).filter(Boolean); + if (statuses.length === 1) conditions.push(eq(issues.status, statuses[0]!)); + else if (statuses.length > 1) conditions.push(inArray(issues.status, statuses)); + } + if (filters?.assigneeAgentId) conditions.push(eq(issues.assigneeAgentId, filters.assigneeAgentId)); + if (filters?.assigneeUserId) conditions.push(eq(issues.assigneeUserId, filters.assigneeUserId)); + if (filters?.projectId) conditions.push(eq(issues.projectId, filters.projectId)); + if (filters?.workspaceId) { + conditions.push(or( + eq(issues.executionWorkspaceId, filters.workspaceId), + eq(issues.projectWorkspaceId, filters.workspaceId), + )!); + } + if (filters?.executionWorkspaceId) conditions.push(eq(issues.executionWorkspaceId, filters.executionWorkspaceId)); + if (filters?.parentId) conditions.push(eq(issues.parentId, filters.parentId)); + if (filters?.originKind) conditions.push(eq(issues.originKind, filters.originKind)); + if (filters?.originKindPrefix) conditions.push(like(issues.originKind, `${filters.originKindPrefix}%`)); + if (filters?.originId) conditions.push(eq(issues.originId, filters.originId)); + if (!shouldIncludePluginOperationIssues(filters)) conditions.push(nonPluginOperationIssueCondition()); + const [row] = await db + .select({ count: sql`count(*)` }) + .from(issues) + .where(and(...conditions)); + return Number(row?.count ?? 0); + }, + countUnreadTouchedByUser: async (companyId: string, userId: string, status?: string) => { const conditions = [ eq(issues.companyId, companyId), diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 6e86c76d..09161d09 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -128,6 +128,7 @@ function boardRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index 23a86295..389699a4 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -37,6 +37,7 @@ export const issuesApi = { list: ( companyId: string, filters?: { + attention?: "blocked"; status?: string; projectId?: string; parentId?: string; @@ -55,12 +56,14 @@ export const issuesApi = { descendantOf?: string; includeRoutineExecutions?: boolean; includeBlockedBy?: boolean; + includeBlockedInboxAttention?: boolean; q?: string; limit?: number; offset?: number; }, ) => { const params = new URLSearchParams(); + if (filters?.attention) params.set("attention", filters.attention); if (filters?.status) params.set("status", filters.status); if (filters?.projectId) params.set("projectId", filters.projectId); if (filters?.parentId) params.set("parentId", filters.parentId); @@ -79,12 +82,35 @@ export const issuesApi = { if (filters?.descendantOf) params.set("descendantOf", filters.descendantOf); if (filters?.includeRoutineExecutions) params.set("includeRoutineExecutions", "true"); if (filters?.includeBlockedBy) params.set("includeBlockedBy", "true"); + if (filters?.includeBlockedInboxAttention) params.set("includeBlockedInboxAttention", "true"); if (filters?.q) params.set("q", filters.q); if (filters?.limit) params.set("limit", String(filters.limit)); if (filters?.offset !== undefined) params.set("offset", String(filters.offset)); const qs = params.toString(); return api.get(`/companies/${companyId}/issues${qs ? `?${qs}` : ""}`); }, + count: ( + companyId: string, + filters: { + attention: "blocked"; + status?: string; + assigneeAgentId?: string; + assigneeUserId?: string; + projectId?: string; + labelId?: string; + q?: string; + }, + ) => { + const params = new URLSearchParams(); + params.set("attention", filters.attention); + if (filters.status) params.set("status", filters.status); + if (filters.assigneeAgentId) params.set("assigneeAgentId", filters.assigneeAgentId); + if (filters.assigneeUserId) params.set("assigneeUserId", filters.assigneeUserId); + if (filters.projectId) params.set("projectId", filters.projectId); + if (filters.labelId) params.set("labelId", filters.labelId); + if (filters.q) params.set("q", filters.q); + return api.get<{ count: number }>(`/companies/${companyId}/issues/count?${params.toString()}`); + }, listLabels: (companyId: string) => api.get(`/companies/${companyId}/labels`), createLabel: (companyId: string, data: { name: string; color: string }) => api.post(`/companies/${companyId}/labels`, data), diff --git a/ui/src/components/BlockedInboxView.test.tsx b/ui/src/components/BlockedInboxView.test.tsx new file mode 100644 index 00000000..328ceb91 --- /dev/null +++ b/ui/src/components/BlockedInboxView.test.tsx @@ -0,0 +1,329 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { Issue, IssueBlockedInboxAttention } from "@paperclipai/shared"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mockIssuesApi = vi.hoisted(() => ({ + list: vi.fn(), + count: vi.fn(), +})); + +vi.mock("../api/issues", () => ({ + issuesApi: mockIssuesApi, +})); + +vi.mock("@/lib/router", () => ({ + Link: ({ + children, + className, + disableIssueQuicklook: _disableIssueQuicklook, + issuePrefetch: _issuePrefetch, + ...props + }: React.ComponentProps<"a"> & { disableIssueQuicklook?: boolean; issuePrefetch?: Issue | null }) => ( + + {children} + + ), +})); + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +import { BlockedInboxView } from "./BlockedInboxView"; +import { defaultIssueFilterState } from "../lib/issue-filters"; + +function attention( + overrides: Partial = {}, +): IssueBlockedInboxAttention { + return { + kind: "blocked", + state: "needs_attention", + reason: "blocked_chain_stalled", + severity: "medium", + stoppedSinceAt: "2026-05-08T10:00:00.000Z", + owner: { type: "agent", agentId: "agent-1", userId: null, label: null }, + action: { label: "Resolve PAP-77", detail: null }, + sourceIssue: null, + leafIssue: null, + recoveryIssue: null, + approvalId: null, + interactionId: null, + sampleIssueIdentifier: null, + redaction: { externalDetailsRedacted: false, secretFieldsOmitted: true }, + ...overrides, + }; +} + +function makeIssue( + id: string, + identifier: string, + title: string, + attentionPayload: IssueBlockedInboxAttention, +): Issue { + return { + id, + companyId: "company-1", + projectId: null, + projectWorkspaceId: null, + goalId: null, + parentId: null, + title, + description: null, + status: "in_progress", + workMode: "standard", + priority: "medium", + assigneeAgentId: "agent-1", + assigneeUserId: null, + checkoutRunId: null, + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + createdByAgentId: null, + createdByUserId: null, + issueNumber: 1, + identifier, + requestDepth: 0, + billingCode: null, + assigneeAdapterOverrides: null, + executionWorkspaceId: null, + executionWorkspacePreference: null, + executionWorkspaceSettings: null, + startedAt: null, + completedAt: null, + cancelledAt: null, + hiddenAt: null, + blockedInboxAttention: attentionPayload, + createdAt: new Date("2026-05-09T00:00:00.000Z"), + updatedAt: new Date("2026-05-09T00:00:00.000Z"), + } as Issue; +} + +function renderWithClient(node: React.ReactNode, container: HTMLDivElement) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } }, + }); + const root = createRoot(container); + act(() => { + root.render({node}); + }); + return { root, queryClient }; +} + +const blockedViewProps = { + companyId: "company-1", + searchQuery: "", + agentNameById: new Map(), + issueLinkState: null, + groupBy: "none" as const, + sortBy: "most_recent" as const, + issueFilters: defaultIssueFilterState, + currentUserId: "local-board", + liveIssueIds: new Set(), + workspaceFilterContext: {}, + showStatusColumn: true, + showIdentifierColumn: true, + showUpdatedColumn: true, +}; + +async function waitFor(predicate: () => boolean, attempts = 30): Promise { + for (let i = 0; i < attempts; i += 1) { + if (predicate()) return; + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 5)); + }); + } + throw new Error("waitFor predicate did not become true"); +} + +describe("BlockedInboxView", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + mockIssuesApi.list.mockReset(); + }); + + afterEach(() => { + container.remove(); + }); + + it("shows the empty state when no blocked issues are returned", async () => { + mockIssuesApi.list.mockResolvedValue([]); + const { root } = renderWithClient( + , + container, + ); + await waitFor(() => container.querySelector('[data-testid="blocked-inbox-empty"]') !== null); + expect(container.querySelector('[data-testid="blocked-inbox-empty"]')).not.toBeNull(); + act(() => root.unmount()); + }); + + it("defaults to no grouping and orders rows by most recent stopped item first", async () => { + const issues: Issue[] = [ + makeIssue( + "issue-low", + "PAP-1", + "External wait row", + attention({ reason: "external_owner_action", severity: "low" }), + ), + makeIssue( + "issue-stalled-high", + "PAP-2", + "Stalled chain row", + attention({ + reason: "blocked_chain_stalled", + severity: "high", + stoppedSinceAt: "2026-05-09T01:00:00.000Z", + action: { label: "Resolve PAP-9", detail: null }, + }), + ), + makeIssue( + "issue-stalled-critical", + "PAP-3", + "Critical stalled row", + attention({ + reason: "blocked_chain_stalled", + severity: "critical", + stoppedSinceAt: "2026-05-09T05:00:00.000Z", + action: { label: "Resolve PAP-10", detail: null }, + }), + ), + makeIssue( + "issue-decision", + "PAP-4", + "Pending board decision", + attention({ + reason: "pending_board_decision", + severity: "medium", + owner: { type: "board", agentId: null, userId: null, label: "Board" }, + action: { label: "Accept or reject", detail: null }, + }), + ), + ]; + mockIssuesApi.list.mockResolvedValue(issues); + + const { root } = renderWithClient( + , + container, + ); + await waitFor(() => container.querySelectorAll("a").length === 4); + + expect(container.querySelectorAll('[data-testid^="blocked-inbox-group-"]')).toHaveLength(0); + + const titles = Array.from(container.querySelectorAll("a")).map((a) => a.textContent ?? ""); + expect(titles[0]).toContain("Critical stalled row"); + expect(titles[1]).toContain("Stalled chain row"); + + expect(mockIssuesApi.list).toHaveBeenCalledWith("company-1", expect.objectContaining({ + attention: "blocked", + includeBlockedInboxAttention: true, + includeBlockedBy: true, + })); + + act(() => root.unmount()); + }); + + it("places blocker reason chips with the title before owner and timestamp metadata", async () => { + mockIssuesApi.list.mockResolvedValue([ + makeIssue( + "issue-decision", + "PAP-4", + "Pending board decision", + attention({ + reason: "pending_board_decision", + severity: "medium", + owner: { type: "board", agentId: null, userId: null, label: "Board" }, + action: { label: "Accept or reject", detail: null }, + }), + ), + ]); + + const { root } = renderWithClient( + , + container, + ); + await waitFor(() => container.querySelector("a") !== null); + + const rowText = container.querySelector("a")?.textContent ?? ""; + expect(rowText.indexOf("Pending board decision")).toBeGreaterThanOrEqual(0); + expect(rowText.indexOf("Needs decision")).toBeGreaterThan(rowText.indexOf("Pending board decision")); + expect(rowText.indexOf("Board")).toBeGreaterThan(rowText.indexOf("Needs decision")); + expect(rowText).not.toContain("Accept or reject"); + expect(container.querySelector('[data-testid="blocked-row-reason-column"]')?.textContent).toContain("Needs decision"); + + act(() => root.unmount()); + }); + + it("filters rows by search query against title, identifier, owner and action", async () => { + const issues: Issue[] = [ + makeIssue( + "issue-1", + "PAP-77", + "Resume parked work", + attention({ + reason: "blocked_by_assigned_backlog_issue", + owner: { type: "agent", agentId: null, userId: null, label: "Charlie" }, + action: { label: "Resume parked blocker", detail: null }, + }), + ), + makeIssue( + "issue-2", + "PAP-99", + "Other unrelated thing", + attention({ + reason: "external_owner_action", + owner: { type: "external", agentId: null, userId: null, label: "Vendor" }, + action: { label: "Awaiting Vendor", detail: null }, + }), + ), + ]; + mockIssuesApi.list.mockResolvedValue(issues); + + const { root } = renderWithClient( + , + container, + ); + await waitFor(() => container.querySelectorAll("a").length > 0); + + const links = container.querySelectorAll("a"); + const titles = Array.from(links).map((a) => a.textContent ?? ""); + expect(titles.some((t) => t.includes("Resume parked work"))).toBe(true); + expect(titles.some((t) => t.includes("Other unrelated thing"))).toBe(false); + + act(() => root.unmount()); + }); + + it("renders the visible error banner with retry when the query fails", async () => { + mockIssuesApi.list.mockRejectedValue(new Error("network down")); + + const { root } = renderWithClient( + , + container, + ); + await waitFor(() => + container.querySelector('[data-testid="blocked-inbox-error"]') !== null, + ); + + const banner = container.querySelector('[data-testid="blocked-inbox-error"]'); + expect(banner).not.toBeNull(); + expect(banner?.getAttribute("role")).toBe("alert"); + expect(banner?.textContent).toContain("Couldn't load the Blocked tab"); + + act(() => root.unmount()); + }); +}); diff --git a/ui/src/components/BlockedInboxView.tsx b/ui/src/components/BlockedInboxView.tsx new file mode 100644 index 00000000..39764efa --- /dev/null +++ b/ui/src/components/BlockedInboxView.tsx @@ -0,0 +1,386 @@ +import { useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { AlertTriangle, CheckCircle2 } from "lucide-react"; +import type { Issue } from "@paperclipai/shared"; +import { issuesApi } from "../api/issues"; +import { queryKeys } from "../lib/queryKeys"; +import { cn } from "../lib/utils"; +import { applyIssueFilters, type IssueFilterState, type IssueFilterWorkspaceContext } from "../lib/issue-filters"; +import { + blockedRowMatchesSearch, + buildBlockedInboxRows, + formatStoppedAge, + groupBlockedInboxRows, + sortBlockedInboxRows, + type BlockedInboxGroupBy, + type BlockedInboxIssueRow, + type BlockedInboxSort, +} from "../lib/blockedInbox"; +import { BlockedReasonChip } from "./BlockedReasonChip"; +import { IssueGroupHeader } from "./IssueGroupHeader"; +import { IssueRow } from "./IssueRow"; +import { Identity } from "./Identity"; +import { StatusIcon } from "./StatusIcon"; +import { Button } from "@/components/ui/button"; + +interface BlockedInboxViewProps { + companyId: string; + searchQuery: string; + agentNameById: ReadonlyMap; + userLabelById?: ReadonlyMap; + issueLinkState: unknown; + groupBy: BlockedInboxGroupBy; + sortBy: BlockedInboxSort; + issueFilters: IssueFilterState; + currentUserId: string | null; + liveIssueIds: ReadonlySet; + workspaceFilterContext: IssueFilterWorkspaceContext; + showStatusColumn: boolean; + showIdentifierColumn: boolean; + showUpdatedColumn: boolean; +} + +const BLOCKED_LIST_LIMIT = 200; + +export function BlockedInboxView({ + companyId, + searchQuery, + agentNameById, + userLabelById, + issueLinkState, + groupBy, + sortBy, + issueFilters, + currentUserId, + liveIssueIds, + workspaceFilterContext, + showStatusColumn, + showIdentifierColumn, + showUpdatedColumn, +}: BlockedInboxViewProps) { + const [collapsedVariants, setCollapsedVariants] = useState>(() => new Set()); + + const { + data: issues = [] as Issue[], + isLoading, + isFetching, + error, + refetch, + } = useQuery({ + queryKey: queryKeys.issues.listBlockedAttention(companyId), + queryFn: () => + issuesApi.list(companyId, { + attention: "blocked", + includeBlockedInboxAttention: true, + includeBlockedBy: true, + limit: BLOCKED_LIST_LIMIT, + }), + }); + + const allRows = useMemo(() => buildBlockedInboxRows(issues), [issues]); + const filteredRows = useMemo( + () => allRows.filter((row) => blockedRowMatchesSearch(row, searchQuery)), + [allRows, searchQuery], + ); + const issueFilteredRows = useMemo(() => { + const visibleIssueIds = new Set( + applyIssueFilters( + filteredRows.map((row) => row.issue), + issueFilters, + currentUserId, + true, + liveIssueIds, + workspaceFilterContext, + ).map((issue) => issue.id), + ); + return filteredRows.filter((row) => visibleIssueIds.has(row.issue.id)); + }, [currentUserId, filteredRows, issueFilters, liveIssueIds, workspaceFilterContext]); + const sortedRows = useMemo(() => sortBlockedInboxRows(issueFilteredRows, sortBy), [issueFilteredRows, sortBy]); + const groups = useMemo( + () => groupBlockedInboxRows(issueFilteredRows, sortBy), + [issueFilteredRows, sortBy], + ); + + const toggleVariant = (variant: string) => { + setCollapsedVariants((prev) => { + const next = new Set(prev); + if (next.has(variant)) next.delete(variant); + else next.add(variant); + return next; + }); + }; + + if (isLoading) { + return ( +
+ {Array.from({ length: 3 }).map((_, groupIdx) => ( +
+
+ {Array.from({ length: 2 }).map((__, rowIdx) => ( +
+
+
+
+
+
+
+ ))} +
+ ))} +
+ ); + } + + if (error) { + const message = + error instanceof Error ? error.message : "Couldn't load the Blocked tab."; + return ( +
+
+
+
+ ); + } + + if (allRows.length === 0) { + return ( +
+ + +
+

No work is stopped.

+

+ Issues that need a decision, recovery, or external action will appear here. +

+
+
+ ); + } + + if (groups.length === 0) { + return ( +
+
+ No stopped items match your search. +
+
+ ); + } + + return ( +
+
+ {groupBy === "none" ? ( + sortedRows.map((row) => ( + + )) + ) : ( + groups.map((group) => { + const isCollapsed = collapsedVariants.has(group.variant); + return ( +
+
+ toggleVariant(group.variant)} + /> +
+ {!isCollapsed && ( +
+ {group.rows.map((row) => ( + + ))} +
+ )} +
+ ); + }) + )} +
+
+ ); +} + +interface BlockedInboxRowProps { + row: BlockedInboxIssueRow; + issueLinkState: unknown; + agentNameById: ReadonlyMap; + userLabelById?: ReadonlyMap; + showStatusColumn: boolean; + showIdentifierColumn: boolean; + showUpdatedColumn: boolean; +} + +function resolveOwnerName( + row: BlockedInboxIssueRow, + agentNameById: ReadonlyMap, + userLabelById?: ReadonlyMap, +): { label: string | null; isAgent: boolean } { + const owner = row.attention.owner; + if (owner.label) return { label: owner.label, isAgent: owner.type === "agent" }; + if (owner.agentId) { + return { label: agentNameById.get(owner.agentId) ?? null, isAgent: true }; + } + if (owner.userId) { + return { label: userLabelById?.get(owner.userId) ?? null, isAgent: false }; + } + return { label: null, isAgent: false }; +} + +function BlockedInboxRow({ + row, + issueLinkState, + agentNameById, + userLabelById, + showStatusColumn, + showIdentifierColumn, + showUpdatedColumn, +}: BlockedInboxRowProps) { + const { label: ownerName, isAgent } = resolveOwnerName(row, agentNameById, userLabelById); + const stoppedAge = formatStoppedAge(row.attention.stoppedSinceAt); + + const desktopTrailing = ( + + + + + {ownerName ? ( + + + + ) : ( + + ); + + const mobileMeta = ( + + {stoppedAge} + {ownerName ? ( + <> + + + {ownerName} + + + ) : null} + + ); + + return ( + + } + mobileLeading={ + + + + } + titleSuffix={ + + } + mobileMeta={mobileMeta} + desktopTrailing={desktopTrailing} + /> + ); +} + +function BlockedRowDesktopMeta({ + row, + showStatusColumn, + showIdentifierColumn, +}: { + row: BlockedInboxIssueRow; + showStatusColumn: boolean; + showIdentifierColumn: boolean; +}) { + const identifier = row.issue.identifier ?? row.issue.id.slice(0, 8); + return ( + + {showStatusColumn ? : null} + {showIdentifierColumn ? {identifier} : null} + + ); +} diff --git a/ui/src/components/BlockedReasonChip.test.tsx b/ui/src/components/BlockedReasonChip.test.tsx new file mode 100644 index 00000000..f056914c --- /dev/null +++ b/ui/src/components/BlockedReasonChip.test.tsx @@ -0,0 +1,85 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { BlockedReasonChip } from "./BlockedReasonChip"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +describe("BlockedReasonChip", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + }); + + it("renders the canonical group label and exposes severity via aria-label", () => { + const root = createRoot(container); + act(() => { + root.render( + , + ); + }); + const chip = container.querySelector('[data-testid="blocked-reason-chip"]'); + expect(chip).not.toBeNull(); + expect(chip?.getAttribute("data-variant")).toBe("needs_decision"); + expect(chip?.getAttribute("data-severity")).toBe("high"); + expect(chip?.getAttribute("aria-label")).toBe("Reason: Needs decision, severity high"); + expect(chip?.textContent).toContain("Needs decision"); + act(() => { + root.unmount(); + }); + }); + + it("includes a severity dot for critical and high but not medium/low", () => { + const cases: Array<["critical" | "high" | "medium" | "low", boolean]> = [ + ["critical", true], + ["high", true], + ["medium", false], + ["low", false], + ]; + for (const [severity, hasDot] of cases) { + const local = document.createElement("div"); + document.body.appendChild(local); + const root = createRoot(local); + act(() => { + root.render(); + }); + const chip = local.querySelector('[data-testid="blocked-reason-chip"]'); + const dot = chip?.querySelector('[aria-hidden="true"]'); + if (hasDot) { + expect(dot).not.toBeNull(); + } else { + // The first inner span (icon) is always aria-hidden, but the dot is the first child. + // Distinguish by class name presence of bg-red-500/bg-orange-500. + const classy = chip?.querySelector('span[class*="bg-red-500"], span[class*="bg-orange-500"]'); + expect(classy).toBeNull(); + } + act(() => { + root.unmount(); + }); + local.remove(); + } + }); + + it("hides the icon when compact is true", () => { + const root = createRoot(container); + act(() => { + root.render( + , + ); + }); + const chip = container.querySelector('[data-testid="blocked-reason-chip"]'); + const svg = chip?.querySelector("svg"); + expect(svg).toBeNull(); + act(() => { + root.unmount(); + }); + }); +}); diff --git a/ui/src/components/BlockedReasonChip.tsx b/ui/src/components/BlockedReasonChip.tsx new file mode 100644 index 00000000..4efddc6d --- /dev/null +++ b/ui/src/components/BlockedReasonChip.tsx @@ -0,0 +1,82 @@ +import { AlertTriangle, Clock, Pause, User, Wrench } from "lucide-react"; +import type { ComponentType } from "react"; +import type { IssueBlockedInboxSeverity } from "@paperclipai/shared"; +import { cn } from "../lib/utils"; +import { + blockedReasonVariant, + blockedVariantLabel, + type BlockedReasonVariant, +} from "../lib/blockedInbox"; +import type { IssueBlockedInboxReason } from "@paperclipai/shared"; + +interface BlockedReasonChipProps { + reason: IssueBlockedInboxReason; + severity: IssueBlockedInboxSeverity; + compact?: boolean; + className?: string; +} + +type IconComponent = ComponentType<{ className?: string; "aria-hidden"?: boolean | "true" | "false" }>; + +const VARIANT_STYLES: Record = { + needs_decision: + "border-violet-300/70 bg-violet-50 text-violet-800 dark:border-violet-500/30 dark:bg-violet-500/10 dark:text-violet-300", + recovery_required: + "border-cyan-300/70 bg-cyan-50 text-cyan-800 dark:border-cyan-500/30 dark:bg-cyan-500/10 dark:text-cyan-300", + stalled: + "border-amber-400/70 bg-amber-100 text-amber-900 dark:border-amber-500/40 dark:bg-amber-500/15 dark:text-amber-200", + needs_attention: + "border-amber-300/70 bg-amber-50 text-amber-800 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-300", + external_wait: + "border-slate-300 bg-slate-50 text-slate-700 dark:border-slate-500/30 dark:bg-slate-500/15 dark:text-slate-300", + owner_paused: + "border-red-300/70 bg-red-50 text-red-800 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-300", +}; + +const VARIANT_ICONS: Record = { + needs_decision: Clock, + recovery_required: Wrench, + stalled: AlertTriangle, + needs_attention: AlertTriangle, + external_wait: User, + owner_paused: Pause, +}; + +const SEVERITY_DOT: Partial> = { + critical: "bg-red-500", + high: "bg-orange-500", +}; + +export function BlockedReasonChip({ + reason, + severity, + compact = false, + className, +}: BlockedReasonChipProps) { + const variant = blockedReasonVariant(reason); + const label = blockedVariantLabel(variant); + const Icon = VARIANT_ICONS[variant]; + const dotClass = SEVERITY_DOT[severity]; + return ( + + {dotClass ? ( + + ); +} diff --git a/ui/src/lib/blockedInbox.test.ts b/ui/src/lib/blockedInbox.test.ts new file mode 100644 index 00000000..33aa33cb --- /dev/null +++ b/ui/src/lib/blockedInbox.test.ts @@ -0,0 +1,275 @@ +// @vitest-environment node + +import { describe, expect, it } from "vitest"; +import type { + Issue, + IssueBlockedInboxAttention, + IssueBlockedInboxReason, + IssueBlockedInboxSeverity, +} from "@paperclipai/shared"; +import { + BLOCKED_REASON_VARIANT_ORDER, + blockedBadgeTone, + blockedReasonLabel, + blockedReasonVariant, + blockedRowMatchesSearch, + blockedSeverityRank, + blockedVariantLabel, + buildBlockedInboxRows, + compareBlockedAttention, + compareBlockedRows, + formatStoppedAge, + groupBlockedInboxRows, + sortBlockedInboxRows, + type BlockedInboxIssueRow, +} from "./blockedInbox"; + +function makeAttention( + overrides: Partial = {}, +): IssueBlockedInboxAttention { + return { + kind: "blocked", + state: "needs_attention", + reason: "blocked_chain_stalled", + severity: "medium", + stoppedSinceAt: "2026-05-08T12:00:00.000Z", + owner: { type: "agent", agentId: null, userId: null, label: "QA" }, + action: { label: "Resolve PAP-1", detail: null }, + sourceIssue: null, + leafIssue: null, + recoveryIssue: null, + approvalId: null, + interactionId: null, + sampleIssueIdentifier: null, + redaction: { externalDetailsRedacted: false, secretFieldsOmitted: true }, + ...overrides, + }; +} + +function makeIssue( + overrides: Partial & { id: string }, + attention: IssueBlockedInboxAttention | null = null, +): Issue { + const { id, ...rest } = overrides; + return { + id, + companyId: "company-1", + projectId: null, + projectWorkspaceId: null, + goalId: null, + parentId: null, + title: "Title", + description: null, + status: "in_progress", + workMode: "standard", + priority: "medium", + assigneeAgentId: null, + assigneeUserId: null, + checkoutRunId: null, + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + createdByAgentId: null, + createdByUserId: null, + issueNumber: 1, + identifier: "PAP-1", + requestDepth: 0, + billingCode: null, + assigneeAdapterOverrides: null, + executionWorkspaceId: null, + executionWorkspacePreference: null, + executionWorkspaceSettings: null, + startedAt: null, + completedAt: null, + cancelledAt: null, + hiddenAt: null, + blockedInboxAttention: attention, + createdAt: new Date("2026-05-09T00:00:00.000Z"), + updatedAt: new Date("2026-05-09T00:00:00.000Z"), + ...rest, + } as Issue; +} + +describe("blockedInbox", () => { + it("maps every reason to a known variant and label", () => { + const reasons: IssueBlockedInboxReason[] = [ + "pending_board_decision", + "pending_user_decision", + "missing_successful_run_disposition", + "blocked_chain_stalled", + "blocked_by_unassigned_issue", + "blocked_by_assigned_backlog_issue", + "blocked_by_cancelled_issue", + "blocked_by_uninvokable_assignee", + "in_review_without_action_path", + "invalid_review_participant", + "open_recovery_issue", + "external_owner_action", + ]; + for (const reason of reasons) { + const variant = blockedReasonVariant(reason); + expect(BLOCKED_REASON_VARIANT_ORDER).toContain(variant); + expect(blockedVariantLabel(variant)).toBeTruthy(); + expect(blockedReasonLabel(reason)).toBeTruthy(); + } + }); + + it("ranks severity critical first and low last", () => { + const order: IssueBlockedInboxSeverity[] = ["critical", "high", "medium", "low"]; + const ranks = order.map((s) => blockedSeverityRank(s)); + expect([...ranks].sort((a, b) => a - b)).toEqual(ranks); + }); + + it("compares by severity first, then stoppedSinceAt", () => { + const a = makeAttention({ + severity: "critical", + stoppedSinceAt: "2026-05-08T13:00:00.000Z", + }); + const b = makeAttention({ + severity: "high", + stoppedSinceAt: "2026-05-08T10:00:00.000Z", + }); + const c = makeAttention({ + severity: "high", + stoppedSinceAt: "2026-05-08T12:00:00.000Z", + }); + expect(compareBlockedAttention(a, b)).toBeLessThan(0); + // both 'high', earlier stoppedSinceAt sorts first + expect(compareBlockedAttention(b, c)).toBeLessThan(0); + }); + + it("keeps equal unstopped attention comparisons deterministic", () => { + const a = makeAttention({ severity: "high", stoppedSinceAt: null }); + const b = makeAttention({ severity: "high", stoppedSinceAt: null }); + expect(compareBlockedAttention(a, b)).toBe(0); + }); + + it("buildBlockedInboxRows skips issues without attention", () => { + const issues = [ + makeIssue({ id: "issue-1" }, makeAttention()), + makeIssue({ id: "issue-2" }, null), + ]; + const rows = buildBlockedInboxRows(issues); + expect(rows).toHaveLength(1); + expect(rows[0].issue.id).toBe("issue-1"); + }); + + it("groupBlockedInboxRows orders groups by canonical variant order and sorts within group", () => { + const issues = [ + makeIssue( + { id: "external-1" }, + makeAttention({ reason: "external_owner_action", severity: "low" }), + ), + makeIssue( + { id: "stalled-1" }, + makeAttention({ + reason: "blocked_chain_stalled", + severity: "high", + stoppedSinceAt: "2026-05-09T01:00:00.000Z", + }), + ), + makeIssue( + { id: "stalled-2" }, + makeAttention({ + reason: "blocked_chain_stalled", + severity: "critical", + stoppedSinceAt: "2026-05-09T05:00:00.000Z", + }), + ), + makeIssue( + { id: "decision-1" }, + makeAttention({ reason: "pending_board_decision", severity: "medium" }), + ), + ]; + const groups = groupBlockedInboxRows(buildBlockedInboxRows(issues)); + expect(groups.map((g) => g.variant)).toEqual([ + "needs_decision", + "stalled", + "external_wait", + ]); + const stalled = groups.find((g) => g.variant === "stalled")!; + expect(stalled.rows.map((r) => r.issue.id)).toEqual(["stalled-2", "stalled-1"]); + }); + + it("sortBlockedInboxRows supports recent and longest-stopped ordering", () => { + const rows = buildBlockedInboxRows([ + makeIssue( + { id: "old", title: "Old stopped" }, + makeAttention({ + severity: "low", + stoppedSinceAt: "2026-05-06T00:00:00.000Z", + }), + ), + makeIssue( + { id: "recent", title: "Recently stopped" }, + makeAttention({ + severity: "critical", + stoppedSinceAt: "2026-05-09T00:00:00.000Z", + }), + ), + makeIssue( + { id: "middle", title: "Middle stopped" }, + makeAttention({ + severity: "medium", + stoppedSinceAt: "2026-05-08T00:00:00.000Z", + }), + ), + ]); + + expect(sortBlockedInboxRows(rows, "most_recent").map((row) => row.issue.id)).toEqual([ + "recent", + "middle", + "old", + ]); + expect(sortBlockedInboxRows(rows, "longest_stopped").map((row) => row.issue.id)).toEqual([ + "old", + "middle", + "recent", + ]); + expect(compareBlockedRows(rows[0], rows[1], "most_recent")).toBeGreaterThan(0); + }); + + it("blockedRowMatchesSearch matches title, identifier, owner, action and reason", () => { + const issue = makeIssue( + { id: "issue-1", identifier: "PAP-77", title: "Resume parked work" }, + makeAttention({ + reason: "blocked_by_assigned_backlog_issue", + owner: { type: "agent", agentId: null, userId: null, label: "Charlie" }, + action: { label: "Resume parked blocker", detail: null }, + }), + ); + const row: BlockedInboxIssueRow = buildBlockedInboxRows([issue])[0]; + expect(blockedRowMatchesSearch(row, "")).toBe(true); + expect(blockedRowMatchesSearch(row, "pap-77")).toBe(true); + expect(blockedRowMatchesSearch(row, "parked")).toBe(true); + expect(blockedRowMatchesSearch(row, "charlie")).toBe(true); + expect(blockedRowMatchesSearch(row, "no match")).toBe(false); + }); + + it("blockedBadgeTone reflects the highest severity present", () => { + const empty: BlockedInboxIssueRow[] = []; + expect(blockedBadgeTone(empty)).toBe("muted"); + + const issues = [ + makeIssue({ id: "a" }, makeAttention({ severity: "low" })), + makeIssue({ id: "b" }, makeAttention({ severity: "high" })), + ]; + expect(blockedBadgeTone(buildBlockedInboxRows(issues))).toBe("amber"); + + const critical = [ + ...issues, + makeIssue({ id: "c" }, makeAttention({ severity: "critical" })), + ]; + expect(blockedBadgeTone(buildBlockedInboxRows(critical))).toBe("red"); + }); + + it("formatStoppedAge produces stable buckets", () => { + const now = new Date("2026-05-10T00:00:00.000Z").getTime(); + expect(formatStoppedAge(null)).toBe("stopped"); + expect(formatStoppedAge("2026-05-09T23:59:30.000Z", now)).toBe("stopped just now"); + expect(formatStoppedAge("2026-05-09T23:30:00.000Z", now)).toBe("stopped 30m"); + expect(formatStoppedAge("2026-05-09T20:00:00.000Z", now)).toBe("stopped 4h"); + expect(formatStoppedAge("2026-05-07T00:00:00.000Z", now)).toBe("stopped 3d"); + expect(formatStoppedAge("2026-04-15T00:00:00.000Z", now)).toBe("stopped 3w"); + }); +}); diff --git a/ui/src/lib/blockedInbox.ts b/ui/src/lib/blockedInbox.ts new file mode 100644 index 00000000..d14db461 --- /dev/null +++ b/ui/src/lib/blockedInbox.ts @@ -0,0 +1,275 @@ +import type { + Issue, + IssueBlockedInboxAttention, + IssueBlockedInboxReason, + IssueBlockedInboxSeverity, +} from "@paperclipai/shared"; + +export type BlockedReasonVariant = + | "needs_decision" + | "stalled" + | "needs_attention" + | "recovery_required" + | "external_wait" + | "owner_paused"; + +const VARIANT_BY_REASON: Record = { + pending_board_decision: "needs_decision", + pending_user_decision: "needs_decision", + missing_successful_run_disposition: "needs_decision", + blocked_chain_stalled: "stalled", + blocked_by_unassigned_issue: "needs_attention", + blocked_by_assigned_backlog_issue: "needs_attention", + blocked_by_cancelled_issue: "needs_attention", + in_review_without_action_path: "needs_attention", + invalid_review_participant: "needs_attention", + open_recovery_issue: "recovery_required", + external_owner_action: "external_wait", + blocked_by_uninvokable_assignee: "owner_paused", +}; + +export const BLOCKED_REASON_VARIANT_ORDER: BlockedReasonVariant[] = [ + "needs_decision", + "stalled", + "needs_attention", + "recovery_required", + "external_wait", + "owner_paused", +]; + +export const BLOCKED_VARIANT_LABELS: Record = { + needs_decision: "Needs decision", + stalled: "Blocked chain stalled", + needs_attention: "Needs attention", + recovery_required: "Recovery required", + external_wait: "External wait", + owner_paused: "Owner paused", +}; + +const REASON_LABELS: Record = { + pending_board_decision: "Pending board decision", + pending_user_decision: "Pending user decision", + missing_successful_run_disposition: "Pick disposition", + blocked_chain_stalled: "Blocked chain stalled", + blocked_by_unassigned_issue: "Unassigned blocker", + blocked_by_assigned_backlog_issue: "Parked blocker", + blocked_by_cancelled_issue: "Cancelled blocker", + in_review_without_action_path: "Review without action path", + invalid_review_participant: "Invalid review participant", + open_recovery_issue: "Recovery in progress", + external_owner_action: "External owner action", + blocked_by_uninvokable_assignee: "Owner paused", +}; + +const SEVERITY_RANK: Record = { + critical: 0, + high: 1, + medium: 2, + low: 3, +}; + +export type BlockedInboxBadgeTone = "muted" | "amber" | "red"; + +export function blockedReasonVariant(reason: IssueBlockedInboxReason): BlockedReasonVariant { + return VARIANT_BY_REASON[reason] ?? "needs_attention"; +} + +export function blockedReasonLabel(reason: IssueBlockedInboxReason): string { + return REASON_LABELS[reason] ?? "Stopped"; +} + +export function blockedVariantLabel(variant: BlockedReasonVariant): string { + return BLOCKED_VARIANT_LABELS[variant]; +} + +export function blockedSeverityRank(severity: IssueBlockedInboxSeverity): number { + return SEVERITY_RANK[severity] ?? 9; +} + +export function compareBlockedAttention( + a: IssueBlockedInboxAttention, + b: IssueBlockedInboxAttention, +): number { + const sevDiff = blockedSeverityRank(a.severity) - blockedSeverityRank(b.severity); + if (sevDiff !== 0) return sevDiff; + const aSince = a.stoppedSinceAt ? new Date(a.stoppedSinceAt).getTime() : Number.POSITIVE_INFINITY; + const bSince = b.stoppedSinceAt ? new Date(b.stoppedSinceAt).getTime() : Number.POSITIVE_INFINITY; + const sinceDiff = aSince - bSince; + return Number.isFinite(sinceDiff) ? sinceDiff : 0; +} + +export interface BlockedInboxIssueRow { + issue: Issue; + attention: IssueBlockedInboxAttention; + variant: BlockedReasonVariant; + reasonLabel: string; + stoppedAtMs: number | null; +} + +export type BlockedInboxGroupBy = "blocker_type" | "none"; +export type BlockedInboxSort = "urgency" | "most_recent" | "longest_stopped"; + +export const BLOCKED_GROUP_OPTIONS: readonly [BlockedInboxGroupBy, string][] = [ + ["blocker_type", "Blocker type"], + ["none", "None"], +]; + +export const BLOCKED_SORT_OPTIONS: readonly [BlockedInboxSort, string][] = [ + ["urgency", "Most urgent"], + ["most_recent", "Most recent"], + ["longest_stopped", "Longest stopped"], +]; + +export interface BlockedInboxGroup { + variant: BlockedReasonVariant; + label: string; + rows: BlockedInboxIssueRow[]; +} + +export function buildBlockedInboxRows(issues: readonly Issue[]): BlockedInboxIssueRow[] { + const rows: BlockedInboxIssueRow[] = []; + for (const issue of issues) { + const attention = issue.blockedInboxAttention; + if (!attention) continue; + rows.push({ + issue, + attention, + variant: blockedReasonVariant(attention.reason), + reasonLabel: blockedReasonLabel(attention.reason), + stoppedAtMs: attention.stoppedSinceAt ? new Date(attention.stoppedSinceAt).getTime() : null, + }); + } + return rows; +} + +function issueTimestampMs(value: Date | string | null | undefined): number | null { + if (!value) return null; + const timestamp = new Date(value).getTime(); + return Number.isFinite(timestamp) ? timestamp : null; +} + +function blockedRowRecencyMs(row: BlockedInboxIssueRow): number { + return row.stoppedAtMs ?? issueTimestampMs(row.issue.updatedAt) ?? 0; +} + +function compareBlockedRowsByTitle(a: BlockedInboxIssueRow, b: BlockedInboxIssueRow): number { + const byTitle = a.issue.title.localeCompare(b.issue.title); + if (byTitle !== 0) return byTitle; + return a.issue.id.localeCompare(b.issue.id); +} + +export function compareBlockedRows( + a: BlockedInboxIssueRow, + b: BlockedInboxIssueRow, + sort: BlockedInboxSort = "urgency", +): number { + if (sort === "most_recent") { + const recencyDiff = blockedRowRecencyMs(b) - blockedRowRecencyMs(a); + if (recencyDiff !== 0) return recencyDiff; + const attentionDiff = compareBlockedAttention(a.attention, b.attention); + if (attentionDiff !== 0) return attentionDiff; + return compareBlockedRowsByTitle(a, b); + } + + if (sort === "longest_stopped") { + const aStopped = a.stoppedAtMs ?? Number.POSITIVE_INFINITY; + const bStopped = b.stoppedAtMs ?? Number.POSITIVE_INFINITY; + const stoppedDiff = aStopped - bStopped; + if (stoppedDiff !== 0) return stoppedDiff; + const severityDiff = blockedSeverityRank(a.attention.severity) - blockedSeverityRank(b.attention.severity); + if (severityDiff !== 0) return severityDiff; + return compareBlockedRowsByTitle(a, b); + } + + const attentionDiff = compareBlockedAttention(a.attention, b.attention); + if (attentionDiff !== 0) return attentionDiff; + const recencyDiff = blockedRowRecencyMs(b) - blockedRowRecencyMs(a); + if (recencyDiff !== 0) return recencyDiff; + return compareBlockedRowsByTitle(a, b); +} + +export function sortBlockedInboxRows( + rows: readonly BlockedInboxIssueRow[], + sort: BlockedInboxSort = "urgency", +): BlockedInboxIssueRow[] { + return [...rows].sort((a, b) => compareBlockedRows(a, b, sort)); +} + +export function groupBlockedInboxRows( + rows: readonly BlockedInboxIssueRow[], + sort: BlockedInboxSort = "urgency", +): BlockedInboxGroup[] { + const buckets = new Map(); + for (const row of rows) { + const list = buckets.get(row.variant) ?? []; + list.push(row); + buckets.set(row.variant, list); + } + const groups: BlockedInboxGroup[] = []; + for (const variant of BLOCKED_REASON_VARIANT_ORDER) { + const list = buckets.get(variant); + if (!list || list.length === 0) continue; + const sorted = sortBlockedInboxRows(list, sort); + groups.push({ variant, label: BLOCKED_VARIANT_LABELS[variant], rows: sorted }); + } + return groups; +} + +export function blockedRowMatchesSearch(row: BlockedInboxIssueRow, query: string): boolean { + const q = query.trim().toLowerCase(); + if (!q) return true; + const haystack = [ + row.issue.title, + row.issue.identifier ?? "", + row.attention.owner.label ?? "", + row.attention.action.label, + row.attention.action.detail ?? "", + row.reasonLabel, + row.attention.leafIssue?.identifier ?? "", + row.attention.leafIssue?.title ?? "", + row.attention.recoveryIssue?.identifier ?? "", + row.attention.recoveryIssue?.title ?? "", + ] + .join(" ") + .toLowerCase(); + return haystack.includes(q); +} + +export function blockedBadgeTone(rows: readonly BlockedInboxIssueRow[]): BlockedInboxBadgeTone { + if (rows.length === 0) return "muted"; + let highest: IssueBlockedInboxSeverity = "low"; + for (const row of rows) { + if (blockedSeverityRank(row.attention.severity) < blockedSeverityRank(highest)) { + highest = row.attention.severity; + } + } + if (highest === "critical") return "red"; + if (highest === "high") return "amber"; + return "muted"; +} + +export function formatStoppedAge(stoppedSinceAt: string | null, now: number = Date.now()): string { + if (!stoppedSinceAt) return "stopped"; + const then = new Date(stoppedSinceAt).getTime(); + if (!Number.isFinite(then)) return "stopped"; + const seconds = Math.max(0, Math.round((now - then) / 1000)); + if (seconds < 60) return "stopped just now"; + if (seconds < 3600) { + const m = Math.floor(seconds / 60); + return `stopped ${m}m`; + } + if (seconds < 86_400) { + const h = Math.floor(seconds / 3600); + return `stopped ${h}h`; + } + if (seconds < 86_400 * 7) { + const d = Math.floor(seconds / 86_400); + return `stopped ${d}d`; + } + if (seconds < 86_400 * 30) { + const w = Math.floor(seconds / (86_400 * 7)); + return `stopped ${w}w`; + } + const mo = Math.floor(seconds / (86_400 * 30)); + return `stopped ${mo}mo`; +} diff --git a/ui/src/lib/inbox.test.ts b/ui/src/lib/inbox.test.ts index 97ba69bd..d0c11aff 100644 --- a/ui/src/lib/inbox.test.ts +++ b/ui/src/lib/inbox.test.ts @@ -1002,6 +1002,12 @@ describe("inbox helpers", () => { expect(loadLastInboxTab()).toBe("all"); }); + it("persists the blocked inbox tab", () => { + localStorage.clear(); + saveLastInboxTab("blocked"); + expect(loadLastInboxTab()).toBe("blocked"); + }); + it("persists inbox filters per company", () => { saveInboxFilterPreferences("company-1", { allCategoryFilter: "approvals", diff --git a/ui/src/lib/inbox.ts b/ui/src/lib/inbox.ts index 6d623513..e33d5053 100644 --- a/ui/src/lib/inbox.ts +++ b/ui/src/lib/inbox.ts @@ -25,7 +25,7 @@ export const INBOX_NESTING_KEY = "paperclip:inbox:nesting"; export const INBOX_GROUP_BY_KEY = "paperclip:inbox:group-by"; export const INBOX_FILTER_PREFERENCES_KEY_PREFIX = "paperclip:inbox:filters"; export const INBOX_COLLAPSED_GROUPS_KEY_PREFIX = "paperclip:inbox:collapsed-groups"; -export type InboxTab = "mine" | "recent" | "unread" | "all"; +export type InboxTab = "mine" | "recent" | "unread" | "blocked" | "all"; export type InboxCategoryFilter = | "everything" | "issues_i_touched" @@ -630,7 +630,13 @@ export function resolveInboxNestingEnabled(preferenceEnabled: boolean, isMobile: export function loadLastInboxTab(): InboxTab { try { const raw = localStorage.getItem(INBOX_LAST_TAB_KEY); - if (raw === "all" || raw === "unread" || raw === "recent" || raw === "mine") return raw; + if ( + raw === "all" + || raw === "unread" + || raw === "recent" + || raw === "mine" + || raw === "blocked" + ) return raw; if (raw === "new") return "mine"; return "mine"; } catch { diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 24aa097f..302199f6 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -38,6 +38,8 @@ export const queryKeys = { listMineByMe: (companyId: string) => ["issues", companyId, "mine-by-me"] as const, listTouchedByMe: (companyId: string) => ["issues", companyId, "touched-by-me"] as const, listUnreadTouchedByMe: (companyId: string) => ["issues", companyId, "unread-touched-by-me"] as const, + listBlockedAttention: (companyId: string) => ["issues", companyId, "blocked-attention"] as const, + countBlockedAttention: (companyId: string) => ["issues", companyId, "blocked-attention", "count"] as const, labels: (companyId: string) => ["issues", companyId, "labels"] as const, listByProject: (companyId: string, projectId: string) => ["issues", companyId, "project", projectId] as const, diff --git a/ui/src/pages/Inbox.test.tsx b/ui/src/pages/Inbox.test.tsx index dcac90ee..a23af8e7 100644 --- a/ui/src/pages/Inbox.test.tsx +++ b/ui/src/pages/Inbox.test.tsx @@ -3,11 +3,124 @@ import { act } from "react"; import type { ComponentProps } from "react"; import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { Issue } from "@paperclipai/shared"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { CompanyJoinRequest } from "../api/access"; + +const routerMock = vi.hoisted(() => ({ + location: { pathname: "/", search: "", hash: "" }, + navigate: vi.fn(), +})); + +const apiMocks = vi.hoisted(() => ({ + approvalsList: vi.fn(), + joinRequestsList: vi.fn(), + userDirectoryList: vi.fn(), + authSession: vi.fn(), + dashboardSummary: vi.fn(), + executionWorkspaceSummaries: vi.fn(), + issuesList: vi.fn(), + issuesCount: vi.fn(), + issueLabels: vi.fn(), + agentsList: vi.fn(), + heartbeatRunsList: vi.fn(), + liveRunsForCompany: vi.fn(), + experimentalSettings: vi.fn(), + projectsList: vi.fn(), +})); + +vi.mock("../api/approvals", () => ({ + approvalsApi: { list: apiMocks.approvalsList }, +})); + +vi.mock("../api/access", async () => { + const actual = await vi.importActual("../api/access"); + return { + ...actual, + accessApi: { + listJoinRequests: apiMocks.joinRequestsList, + listUserDirectory: apiMocks.userDirectoryList, + }, + }; +}); + +vi.mock("../api/auth", () => ({ + authApi: { getSession: apiMocks.authSession }, +})); + +vi.mock("../api/dashboard", () => ({ + dashboardApi: { summary: apiMocks.dashboardSummary }, +})); + +vi.mock("../api/execution-workspaces", () => ({ + executionWorkspacesApi: { listSummaries: apiMocks.executionWorkspaceSummaries }, +})); + +vi.mock("../api/issues", () => ({ + issuesApi: { + list: apiMocks.issuesList, + count: apiMocks.issuesCount, + listLabels: apiMocks.issueLabels, + markRead: vi.fn(), + markUnread: vi.fn(), + archiveFromInbox: vi.fn(), + unarchiveFromInbox: vi.fn(), + }, +})); + +vi.mock("../api/agents", () => ({ + agentsApi: { list: apiMocks.agentsList }, +})); + +vi.mock("../api/heartbeats", () => ({ + heartbeatsApi: { + list: apiMocks.heartbeatRunsList, + liveRunsForCompany: apiMocks.liveRunsForCompany, + }, +})); + +vi.mock("../api/instanceSettings", () => ({ + instanceSettingsApi: { getExperimental: apiMocks.experimentalSettings }, +})); + +vi.mock("../api/projects", () => ({ + projectsApi: { list: apiMocks.projectsList }, +})); + +vi.mock("../context/CompanyContext", () => ({ + useCompany: () => ({ selectedCompanyId: "company-1" }), +})); + +vi.mock("../context/BreadcrumbContext", () => ({ + useBreadcrumbs: () => ({ setBreadcrumbs: vi.fn() }), +})); + +vi.mock("../context/DialogContext", () => ({ + useDialogActions: () => ({ openNewIssue: vi.fn() }), +})); + +vi.mock("../context/SidebarContext", () => ({ + useSidebar: () => ({ isMobile: false }), +})); + +vi.mock("../context/GeneralSettingsContext", () => ({ + useGeneralSettings: () => ({ keyboardShortcutsEnabled: false }), +})); + +vi.mock("../hooks/useInboxBadge", () => ({ + useDismissedInboxAlerts: () => ({ dismissed: new Set(), dismiss: vi.fn() }), + useInboxDismissals: () => ({ dismissedAtByKey: new Map(), dismiss: vi.fn() }), + useReadInboxItems: () => ({ + readItems: new Set(), + markRead: vi.fn(), + markUnread: vi.fn(), + }), +})); + import { FailedRunInboxRow, + Inbox, InboxGroupHeader, InboxIssueMetaLeading, InboxIssueTrailingColumns, @@ -18,8 +131,8 @@ vi.mock("@/lib/router", () => ({ Link: ({ children, className, ...props }: ComponentProps<"a">) => ( {children} ), - useLocation: () => ({ pathname: "/", search: "", hash: "" }), - useNavigate: () => () => {}, + useLocation: () => routerMock.location, + useNavigate: () => routerMock.navigate, })); // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -108,6 +221,76 @@ function createJoinRequest( }; } +function resetInboxApiMocks() { + routerMock.location.pathname = "/"; + routerMock.location.search = ""; + routerMock.location.hash = ""; + routerMock.navigate.mockReset(); + apiMocks.approvalsList.mockResolvedValue([]); + apiMocks.joinRequestsList.mockResolvedValue([]); + apiMocks.userDirectoryList.mockResolvedValue({ users: [] }); + apiMocks.authSession.mockResolvedValue({ + user: { id: "local-board" }, + session: { userId: "local-board" }, + }); + apiMocks.dashboardSummary.mockResolvedValue({ + agents: { error: 0 }, + costs: { monthBudgetCents: 0, monthUtilizationPercent: 0 }, + }); + apiMocks.executionWorkspaceSummaries.mockResolvedValue([]); + apiMocks.issuesList.mockResolvedValue([]); + apiMocks.issuesCount.mockResolvedValue({ count: 0 }); + apiMocks.issueLabels.mockResolvedValue([]); + apiMocks.agentsList.mockResolvedValue([]); + apiMocks.heartbeatRunsList.mockResolvedValue([]); + apiMocks.liveRunsForCompany.mockResolvedValue([]); + apiMocks.experimentalSettings.mockResolvedValue({ enableIsolatedWorkspaces: false }); + apiMocks.projectsList.mockResolvedValue([]); +} + +describe("Inbox toolbar", () => { + let container: HTMLDivElement; + + beforeEach(() => { + resetInboxApiMocks(); + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + }); + + it("shows blocked toolbar controls on the Blocked tab", async () => { + routerMock.location.pathname = "/inbox/blocked"; + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } }, + }); + const root = createRoot(container); + + await act(async () => { + root.render( + + + , + ); + }); + + expect(container.querySelector('input[placeholder="Search inbox…"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="inbox-blocked-tab-badge"]')).toBeNull(); + expect(container.querySelector('button[title="Filter"]')).not.toBeNull(); + expect(container.querySelector('button[title="Group"]')).not.toBeNull(); + expect(container.querySelector('button[title="Columns"]')).not.toBeNull(); + expect(container.querySelector('button[title="Sort"]')).not.toBeNull(); + expect(container.querySelector('button[title="Enable parent-child nesting"]')).toBeNull(); + expect(container.textContent).not.toContain("Mark all as read"); + + act(() => { + root.unmount(); + }); + }); +}); + describe("FailedRunInboxRow", () => { let container: HTMLDivElement; diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index 0c8dbb3a..4c5a9574 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -13,6 +13,12 @@ import { agentsApi } from "../api/agents"; import { heartbeatsApi } from "../api/heartbeats"; import { instanceSettingsApi } from "../api/instanceSettings"; import { projectsApi } from "../api/projects"; +import { + BLOCKED_GROUP_OPTIONS, + BLOCKED_SORT_OPTIONS, + type BlockedInboxGroupBy, + type BlockedInboxSort, +} from "../lib/blockedInbox"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useGeneralSettings } from "../context/GeneralSettingsContext"; @@ -54,6 +60,7 @@ import { } from "../components/IssueColumns"; import { IssueFiltersPopover } from "../components/IssueFiltersPopover"; import { IssueRow } from "../components/IssueRow"; +import { BlockedInboxView } from "../components/BlockedInboxView"; import { SwipeToArchive } from "../components/SwipeToArchive"; import { StatusIcon } from "../components/StatusIcon"; @@ -85,6 +92,7 @@ import { AlertTriangle, Check, ChevronRight, + ArrowUpDown, Layers, Plus, XCircle, @@ -674,6 +682,8 @@ export function Inbox() { () => loadInboxFilterPreferences(selectedCompanyId), ); const [groupBy, setGroupBy] = useState(() => loadInboxWorkItemGroupBy()); + const [blockedGroupBy, setBlockedGroupBy] = useState("none"); + const [blockedSortBy, setBlockedSortBy] = useState("most_recent"); const [visibleIssueColumns, setVisibleIssueColumns] = useState(loadInboxIssueColumns); const { dismissed: dismissedAlerts, dismiss: dismissAlert } = useDismissedInboxAlerts(); const { dismissedAtByKey, dismiss: dismissInboxItem } = useInboxDismissals(selectedCompanyId); @@ -682,7 +692,11 @@ export function Inbox() { const pathSegment = location.pathname.split("/").pop() ?? "mine"; const tab: InboxTab = - pathSegment === "mine" || pathSegment === "recent" || pathSegment === "all" || pathSegment === "unread" + pathSegment === "mine" + || pathSegment === "recent" + || pathSegment === "all" + || pathSegment === "unread" + || pathSegment === "blocked" ? pathSegment : "mine"; const canArchiveFromTab = isMineInboxTab(tab); @@ -824,7 +838,6 @@ export function Inbox() { queryFn: () => heartbeatsApi.list(selectedCompanyId!, undefined, INBOX_HEARTBEAT_RUN_LIMIT), enabled: !!selectedCompanyId, }); - const { data: liveRuns } = useQuery({ queryKey: queryKeys.liveRuns(selectedCompanyId!), queryFn: () => heartbeatsApi.liveRunsForCompany(selectedCompanyId!), @@ -1902,6 +1915,7 @@ export function Inbox() { .map((issue) => issue.id); const canMarkAllRead = unreadIssueIds.length > 0; const activeIssueFilterCount = countActiveIssueFilters(issueFilters, true); + const showGeneralIssueToolbarControls = tab !== "blocked"; return (
@@ -1947,6 +1961,7 @@ export function Inbox() { label: "Recent", }, { value: "unread", label: "Unread" }, + { value: "blocked", label: "Blocked" }, { value: "all", label: "All" }, ]} /> @@ -1981,112 +1996,203 @@ export function Inbox() { data-page-search-target="true" />
- - ({ id: project.id, name: project.name }))} - labels={labels?.map((label) => ({ id: label.id, name: label.name, color: label.color }))} - currentUserId={currentUserId} - enableRoutineVisibilityFilter - buttonVariant="outline" - iconOnly - workspaces={isolatedWorkspacesEnabled ? executionWorkspaces.filter((w) => w.mode === "isolated_workspace").map((w) => ({ id: w.id, name: w.name })) : undefined} - /> - - - - - -
- {([ - ["none", "None"], - ["type", "Type"], - ["assignee", "Assignee"], - ["project", "Project"], - ...(isolatedWorkspacesEnabled ? ([["workspace", "Workspace"]] as const) : []), - ] as const).map(([value, label]) => ( - - ))} -
-
-
- setIssueColumns(DEFAULT_INBOX_ISSUE_COLUMNS)} - title="Choose which inbox columns stay visible" - iconOnly - /> - {canMarkAllRead && ( + + + + +
+ {BLOCKED_GROUP_OPTIONS.map(([value, label]) => ( + + ))} +
+
+ + setIssueColumns(DEFAULT_INBOX_ISSUE_COLUMNS)} + title="Choose which inbox columns stay visible" + iconOnly + /> + + + + + +
+ {BLOCKED_SORT_OPTIONS.map(([value, label]) => ( + + ))} +
+
+
+ + ) : showGeneralIssueToolbarControls ? ( <> - - - - Mark all as read? - - This will mark {unreadIssueIds.length} unread {unreadIssueIds.length === 1 ? "item" : "items"} as read. - - - - - - - - + ({ id: project.id, name: project.name }))} + labels={labels?.map((label) => ({ id: label.id, name: label.name, color: label.color }))} + currentUserId={currentUserId} + enableRoutineVisibilityFilter + buttonVariant="outline" + iconOnly + workspaces={isolatedWorkspacesEnabled ? executionWorkspaces.filter((w) => w.mode === "isolated_workspace").map((w) => ({ id: w.id, name: w.name })) : undefined} + /> + + + + + +
+ {([ + ["none", "None"], + ["type", "Type"], + ["assignee", "Assignee"], + ["project", "Project"], + ...(isolatedWorkspacesEnabled ? ([["workspace", "Workspace"]] as const) : []), + ] as const).map(([value, label]) => ( + + ))} +
+
+
+ setIssueColumns(DEFAULT_INBOX_ISSUE_COLUMNS)} + title="Choose which inbox columns stay visible" + iconOnly + /> + {canMarkAllRead && ( + <> + + + + + Mark all as read? + + This will mark {unreadIssueIds.length} unread {unreadIssueIds.length === 1 ? "item" : "items"} as read. + + + + + + + + + + )} - )} + ) : null}
@@ -2131,11 +2237,30 @@ export function Inbox() { {approvalsError &&

{approvalsError.message}

} {actionError &&

{actionError}

} - {!allLoaded && visibleSections.length === 0 && ( + {tab === "blocked" ? ( + + ) : null} + + {tab !== "blocked" && !allLoaded && visibleSections.length === 0 && ( )} - {allLoaded && visibleSections.length === 0 && ( + {tab !== "blocked" && allLoaded && visibleSections.length === 0 && ( )} - {showWorkItemsSection && ( + {tab !== "blocked" && showWorkItemsSection && ( <> {showSeparatorBefore("work_items") && }
diff --git a/ui/storybook/stories/blocked-inbox.stories.tsx b/ui/storybook/stories/blocked-inbox.stories.tsx new file mode 100644 index 00000000..031d1fb3 --- /dev/null +++ b/ui/storybook/stories/blocked-inbox.stories.tsx @@ -0,0 +1,303 @@ +import { useMemo } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { Issue, IssueBlockedInboxAttention } from "@paperclipai/shared"; +import { BlockedInboxView } from "@/components/BlockedInboxView"; +import { BlockedReasonChip } from "@/components/BlockedReasonChip"; +import { defaultIssueFilterState } from "@/lib/issue-filters"; +import { queryKeys } from "@/lib/queryKeys"; +import { storybookIssues } from "../fixtures/paperclipData"; + +const companyId = "company-storybook"; +const blockedViewDefaults = { + groupBy: "none" as const, + sortBy: "most_recent" as const, + issueFilters: defaultIssueFilterState, + currentUserId: "local-board", + liveIssueIds: new Set(), + workspaceFilterContext: {}, + showStatusColumn: true, + showIdentifierColumn: true, + showUpdatedColumn: true, +}; + +function attention( + overrides: Partial = {}, +): IssueBlockedInboxAttention { + return { + kind: "blocked", + state: "needs_attention", + reason: "blocked_chain_stalled", + severity: "medium", + stoppedSinceAt: new Date(Date.now() - 4 * 60 * 60 * 1000).toISOString(), + owner: { type: "agent", agentId: null, userId: null, label: "ClaudeCoder" }, + action: { label: "Resolve PAP-12", detail: null }, + sourceIssue: null, + leafIssue: null, + recoveryIssue: null, + approvalId: null, + interactionId: null, + sampleIssueIdentifier: null, + redaction: { externalDetailsRedacted: false, secretFieldsOmitted: true }, + ...overrides, + }; +} + +const baseIssue = storybookIssues[0]!; + +const fixtureIssues: Issue[] = [ + { + ...baseIssue, + id: "issue-decision-1", + identifier: "PAP-401", + title: "Approve plan: rewrite onboarding flow", + status: "in_review", + blockedInboxAttention: attention({ + reason: "pending_board_decision", + state: "awaiting_decision", + severity: "medium", + stoppedSinceAt: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(), + owner: { type: "board", agentId: null, userId: null, label: "Board" }, + action: { label: "Accept or reject", detail: null }, + }), + }, + { + ...baseIssue, + id: "issue-disposition-1", + identifier: "PAP-402", + title: "Pick disposition for completed migration", + status: "in_progress", + blockedInboxAttention: attention({ + reason: "missing_successful_run_disposition", + state: "missing_disposition", + severity: "medium", + stoppedSinceAt: new Date(Date.now() - 30 * 60 * 1000).toISOString(), + owner: { type: "agent", agentId: null, userId: null, label: "QA" }, + action: { label: "Pick disposition", detail: null }, + }), + }, + { + ...baseIssue, + id: "issue-stalled-critical", + identifier: "PAP-410", + title: "Ship invoice export — blocker is stalled", + status: "blocked", + blockedInboxAttention: attention({ + reason: "blocked_chain_stalled", + state: "needs_attention", + severity: "critical", + stoppedSinceAt: new Date(Date.now() - 36 * 60 * 60 * 1000).toISOString(), + owner: { type: "agent", agentId: null, userId: null, label: "CodexCoder" }, + action: { label: "Resolve PAP-411", detail: null }, + }), + }, + { + ...baseIssue, + id: "issue-stalled-high", + identifier: "PAP-412", + title: "Run nightly compaction", + status: "blocked", + blockedInboxAttention: attention({ + reason: "blocked_chain_stalled", + severity: "high", + stoppedSinceAt: new Date(Date.now() - 8 * 60 * 60 * 1000).toISOString(), + owner: { type: "agent", agentId: null, userId: null, label: "QA" }, + action: { label: "Resolve PAP-413", detail: null }, + }), + }, + { + ...baseIssue, + id: "issue-needs-attention", + identifier: "PAP-420", + title: "Resume parked permissions PR", + status: "blocked", + blockedInboxAttention: attention({ + reason: "blocked_by_assigned_backlog_issue", + severity: "medium", + stoppedSinceAt: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(), + owner: { type: "agent", agentId: null, userId: null, label: "ClaudeCoder" }, + action: { label: "Resume parked blocker", detail: null }, + }), + }, + { + ...baseIssue, + id: "issue-recovery", + identifier: "PAP-430", + title: "Recover failed deploy run", + status: "blocked", + blockedInboxAttention: attention({ + reason: "open_recovery_issue", + state: "recovery_open", + severity: "high", + stoppedSinceAt: new Date(Date.now() - 6 * 60 * 60 * 1000).toISOString(), + owner: { type: "agent", agentId: null, userId: null, label: "RecoveryAgent" }, + action: { label: "Resolve PAP-431", detail: null }, + }), + }, + { + ...baseIssue, + id: "issue-external", + identifier: "PAP-440", + title: "Awaiting upstream provider response", + status: "blocked", + blockedInboxAttention: attention({ + reason: "external_owner_action", + state: "external_wait", + severity: "low", + stoppedSinceAt: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(), + owner: { type: "external", agentId: null, userId: null, label: "Stripe" }, + action: { label: "Awaiting Stripe", detail: null }, + }), + }, + { + ...baseIssue, + id: "issue-paused", + identifier: "PAP-450", + title: "Owner paused — budget exceeded", + status: "blocked", + blockedInboxAttention: attention({ + reason: "blocked_by_uninvokable_assignee", + severity: "critical", + stoppedSinceAt: new Date(Date.now() - 12 * 60 * 60 * 1000).toISOString(), + owner: { type: "agent", agentId: null, userId: null, label: "PausedAgent" }, + action: { label: "Reassign or unblock budget", detail: null }, + }), + }, +]; + +function PrimeBlockedFixtures({ children }: { children: React.ReactNode }) { + const queryClient = useQueryClient(); + useMemo(() => { + queryClient.setQueryData(queryKeys.issues.listBlockedAttention(companyId), fixtureIssues); + }, [queryClient]); + return <>{children}; +} + +function BlockedTabSurface({ search = "" }: { search?: string }) { + return ( + +
+
+ Inbox / Blocked tab — desktop layout +
+
+ +
+
+
+ ); +} + +function BlockedTabSurfaceMobile() { + return ( +
+
+ Inbox / Blocked tab — 390px mobile width +
+
+ +
+
+ ); +} + +function BlockedReasonChipsCatalog() { + return ( +
+
+
+ Needs decision · medium +
+ +
+
+
+ Blocked chain stalled · critical +
+ +
+
+
+ Needs attention · high +
+ +
+
+
+ Recovery required · high +
+ +
+
+
+ External wait · low (no severity dot) +
+ +
+
+
+ Owner paused · critical +
+ +
+
+ ); +} + +function BlockedTabEmptyState() { + return ( +
+ +
+ ); +} + +const meta = { + title: "Product/Inbox/Blocked tab", + component: BlockedTabSurface, + parameters: { + docs: { + description: { + component: + "Stopped-work triage Inbox tab. Rows group by reason variant and sort by severity → stoppedSinceAt. The reason chip + owner + action combo sits next to the issue title. No quick archive on this tab.", + }, + }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const DesktopLoaded: Story = { + render: () => , +}; + +export const DesktopWithSearch: Story = { + render: () => , +}; + +export const MobileLayout: Story = { + parameters: { viewport: { defaultViewport: "mobile1" } }, + render: () => , +}; + +export const ReasonChipCatalog: Story = { + render: () => , +}; + +export const EmptyState: Story = { + render: () => , +};