forked from farhoodlabs/paperclip
[codex] Add blocked inbox attention view (#5603)
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies through company-scoped issues, comments, approvals, and execution workspaces. > - Operators need the Inbox to show not only active work, but also blocked work that may need human or agent attention. > - The existing inbox experience did not have a dedicated blocked-work surface, so blocked tasks were harder to triage and resume deliberately. > - Backend consumers also needed a compact attention signal that distinguishes actionable blockers from covered or waiting blocker states. > - This pull request adds a Blocked Inbox tab backed by issue blocker-attention metadata, shared validators, and UI helpers. > - The benefit is a clearer triage path for stalled or blocked Paperclip work without exposing external wait internals in the operator-facing UI. ## What Changed - Added shared issue blocker-attention types, validators, and exports for the API/UI contract. - Added backend blocker-attention computation and issue route support for blocked inbox data. - Added the Blocked Inbox tab, blocked reason chips, filtering/search UI, responsive layouts, and Storybook stories. - Updated inbox helpers and page behavior so toolbar controls only appear where they apply. - Added coverage for shared validators, server blocker-attention behavior, blocked inbox UI helpers/components, and the Inbox page. - Added a screenshot helper script for the blocked inbox Storybook stories. - Addressed Greptile feedback by making urgency sorting deterministic for null stop times, avoiding full blocked-inbox list enrichment for counts, and hardening the screenshot helper. ## Verification - Rebased the branch cleanly onto `public-gh/master`. - Confirmed the diff does not include `pnpm-lock.yaml`. - Confirmed the diff does not include database migration files. - Ran `pnpm exec vitest run packages/shared/src/validators/issue.test.ts server/src/__tests__/issue-blocker-attention.test.ts ui/src/components/BlockedInboxView.test.tsx ui/src/components/BlockedReasonChip.test.tsx ui/src/lib/blockedInbox.test.ts ui/src/lib/inbox.test.ts ui/src/pages/Inbox.test.tsx`. - Ran `pnpm --filter @paperclipai/shared typecheck && pnpm --filter @paperclipai/server typecheck && pnpm --filter @paperclipai/ui typecheck`. - Checked `ROADMAP.md`; this is scoped inbox/operator triage work and does not duplicate a listed roadmap feature. - Greptile Review is green on the latest head and all four Greptile review threads are resolved. - GitHub PR checks are green on the latest head: policy, security/snyk, e2e, verify, Canary Dry Run, Greptile Review, and serialized server suites 1/4 through 4/4. ## Risks - Medium review surface because this touches the shared issue contract, server issue services, and the Inbox UI together. - Blocker-attention classification may need product tuning after operators use it on real blocked queues. - UI screenshots were not attached in this PR-opening pass; the branch includes `scripts/screenshot-blocked-inbox.mjs` and Storybook stories for visual capture. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex, GPT-5-based coding agent with shell, git, GitHub CLI, GitHub connector, and Paperclip API tool use. Reasoning mode: medium. Context window: not exposed by the runtime. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -379,6 +379,15 @@ export type {
|
|||||||
IssueBlockerAttention,
|
IssueBlockerAttention,
|
||||||
IssueBlockerAttentionReason,
|
IssueBlockerAttentionReason,
|
||||||
IssueBlockerAttentionState,
|
IssueBlockerAttentionState,
|
||||||
|
IssueInboxAttentionKind,
|
||||||
|
IssueBlockedInboxAction,
|
||||||
|
IssueBlockedInboxAttention,
|
||||||
|
IssueBlockedInboxIssueRef,
|
||||||
|
IssueBlockedInboxOwner,
|
||||||
|
IssueBlockedInboxOwnerType,
|
||||||
|
IssueBlockedInboxReason,
|
||||||
|
IssueBlockedInboxSeverity,
|
||||||
|
IssueBlockedInboxState,
|
||||||
IssueProductivityReview,
|
IssueProductivityReview,
|
||||||
IssueProductivityReviewTrigger,
|
IssueProductivityReviewTrigger,
|
||||||
IssueRecoveryAction,
|
IssueRecoveryAction,
|
||||||
@@ -761,6 +770,11 @@ export {
|
|||||||
createChildIssueSchema,
|
createChildIssueSchema,
|
||||||
resolveCreateIssueStatusDefault,
|
resolveCreateIssueStatusDefault,
|
||||||
createIssueLabelSchema,
|
createIssueLabelSchema,
|
||||||
|
issueBlockedInboxAttentionSchema,
|
||||||
|
issueBlockedInboxIssueRefSchema,
|
||||||
|
issueBlockedInboxReasonSchema,
|
||||||
|
issueBlockedInboxSeveritySchema,
|
||||||
|
issueBlockedInboxStateSchema,
|
||||||
updateIssueSchema,
|
updateIssueSchema,
|
||||||
issueExecutionPolicySchema,
|
issueExecutionPolicySchema,
|
||||||
issueExecutionStateSchema,
|
issueExecutionStateSchema,
|
||||||
|
|||||||
@@ -149,6 +149,15 @@ export type {
|
|||||||
IssueBlockerAttention,
|
IssueBlockerAttention,
|
||||||
IssueBlockerAttentionReason,
|
IssueBlockerAttentionReason,
|
||||||
IssueBlockerAttentionState,
|
IssueBlockerAttentionState,
|
||||||
|
IssueInboxAttentionKind,
|
||||||
|
IssueBlockedInboxAction,
|
||||||
|
IssueBlockedInboxAttention,
|
||||||
|
IssueBlockedInboxIssueRef,
|
||||||
|
IssueBlockedInboxOwner,
|
||||||
|
IssueBlockedInboxOwnerType,
|
||||||
|
IssueBlockedInboxReason,
|
||||||
|
IssueBlockedInboxSeverity,
|
||||||
|
IssueBlockedInboxState,
|
||||||
IssueProductivityReview,
|
IssueProductivityReview,
|
||||||
IssueProductivityReviewTrigger,
|
IssueProductivityReviewTrigger,
|
||||||
IssueRecoveryAction,
|
IssueRecoveryAction,
|
||||||
|
|||||||
@@ -158,6 +158,75 @@ export interface IssueBlockerAttention {
|
|||||||
sampleStalledBlockerIdentifier: string | null;
|
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 =
|
export type IssueProductivityReviewTrigger =
|
||||||
| "no_comment_streak"
|
| "no_comment_streak"
|
||||||
| "long_active_duration"
|
| "long_active_duration"
|
||||||
@@ -405,6 +474,7 @@ export interface Issue {
|
|||||||
blockedBy?: IssueRelationIssueSummary[];
|
blockedBy?: IssueRelationIssueSummary[];
|
||||||
blocks?: IssueRelationIssueSummary[];
|
blocks?: IssueRelationIssueSummary[];
|
||||||
blockerAttention?: IssueBlockerAttention;
|
blockerAttention?: IssueBlockerAttention;
|
||||||
|
blockedInboxAttention?: IssueBlockedInboxAttention | null;
|
||||||
productivityReview?: IssueProductivityReview | null;
|
productivityReview?: IssueProductivityReview | null;
|
||||||
activeRecoveryAction?: IssueRecoveryAction | null;
|
activeRecoveryAction?: IssueRecoveryAction | null;
|
||||||
successfulRunHandoff?: SuccessfulRunHandoffState | null;
|
successfulRunHandoff?: SuccessfulRunHandoffState | null;
|
||||||
|
|||||||
@@ -153,6 +153,11 @@ export {
|
|||||||
createChildIssueSchema,
|
createChildIssueSchema,
|
||||||
resolveCreateIssueStatusDefault,
|
resolveCreateIssueStatusDefault,
|
||||||
createIssueLabelSchema,
|
createIssueLabelSchema,
|
||||||
|
issueBlockedInboxAttentionSchema,
|
||||||
|
issueBlockedInboxIssueRefSchema,
|
||||||
|
issueBlockedInboxReasonSchema,
|
||||||
|
issueBlockedInboxSeveritySchema,
|
||||||
|
issueBlockedInboxStateSchema,
|
||||||
updateIssueSchema,
|
updateIssueSchema,
|
||||||
issueExecutionPolicySchema,
|
issueExecutionPolicySchema,
|
||||||
issueExecutionStateSchema,
|
issueExecutionStateSchema,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { MAX_ISSUE_REQUEST_DEPTH } from "../index.js";
|
|||||||
import {
|
import {
|
||||||
addIssueCommentSchema,
|
addIssueCommentSchema,
|
||||||
createIssueSchema,
|
createIssueSchema,
|
||||||
|
issueBlockedInboxAttentionSchema,
|
||||||
resolveIssueRecoveryActionSchema,
|
resolveIssueRecoveryActionSchema,
|
||||||
respondIssueThreadInteractionSchema,
|
respondIssueThreadInteractionSchema,
|
||||||
suggestedTaskDraftSchema,
|
suggestedTaskDraftSchema,
|
||||||
@@ -218,6 +219,50 @@ describe("issue validators", () => {
|
|||||||
}).workMode).toBe("planning");
|
}).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", () => {
|
it("rejects unknown issue work modes", () => {
|
||||||
expect(createIssueSchema.safeParse({ title: "Plan first", workMode: "normal" }).success).toBe(false);
|
expect(createIssueSchema.safeParse({ title: "Plan first", workMode: "normal" }).success).toBe(false);
|
||||||
expect(suggestedTaskDraftSchema.safeParse({
|
expect(suggestedTaskDraftSchema.safeParse({
|
||||||
|
|||||||
@@ -28,6 +28,69 @@ import {
|
|||||||
} from "../constants.js";
|
} from "../constants.js";
|
||||||
import { multilineTextSchema } from "./text.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 = [
|
export const ISSUE_EXECUTION_WORKSPACE_PREFERENCES = [
|
||||||
"inherit",
|
"inherit",
|
||||||
"shared_workspace",
|
"shared_workspace",
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Capture screenshots of the Blocked Inbox storybook stories.
|
||||||
|
// Usage: node scripts/screenshot-blocked-inbox.mjs <storybook-static-dir> <output-dir>
|
||||||
|
|
||||||
|
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 <storybook-static-dir> <output-dir>");
|
||||||
|
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 <html> 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);
|
||||||
|
});
|
||||||
@@ -2,12 +2,16 @@ import { randomUUID } from "node:crypto";
|
|||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
activityLog,
|
||||||
agents,
|
agents,
|
||||||
agentWakeupRequests,
|
agentWakeupRequests,
|
||||||
|
approvals,
|
||||||
companies,
|
companies,
|
||||||
createDb,
|
createDb,
|
||||||
heartbeatRuns,
|
heartbeatRuns,
|
||||||
|
issueApprovals,
|
||||||
issueRelations,
|
issueRelations,
|
||||||
|
issueThreadInteractions,
|
||||||
issues,
|
issues,
|
||||||
} from "@paperclipai/db";
|
} from "@paperclipai/db";
|
||||||
import {
|
import {
|
||||||
@@ -15,6 +19,7 @@ import {
|
|||||||
startEmbeddedPostgresTestDatabase,
|
startEmbeddedPostgresTestDatabase,
|
||||||
} from "./helpers/embedded-postgres.js";
|
} from "./helpers/embedded-postgres.js";
|
||||||
import { issueService } from "../services/issues.js";
|
import { issueService } from "../services/issues.js";
|
||||||
|
import { buildIssueGraphLivenessIncidentKey } from "../services/recovery/origins.js";
|
||||||
|
|
||||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||||
@@ -37,6 +42,10 @@ describeEmbeddedPostgres("issue blocker attention", () => {
|
|||||||
}, 20_000);
|
}, 20_000);
|
||||||
|
|
||||||
afterEach(async () => {
|
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(heartbeatRuns);
|
||||||
await db.delete(agentWakeupRequests);
|
await db.delete(agentWakeupRequests);
|
||||||
await db.delete(issueRelations);
|
await db.delete(issueRelations);
|
||||||
@@ -52,20 +61,30 @@ describeEmbeddedPostgres("issue blocker attention", () => {
|
|||||||
async function createCompany(prefix = "PBA") {
|
async function createCompany(prefix = "PBA") {
|
||||||
const companyId = randomUUID();
|
const companyId = randomUUID();
|
||||||
const agentId = randomUUID();
|
const agentId = randomUUID();
|
||||||
|
const pausedAgentId = randomUUID();
|
||||||
await db.insert(companies).values({
|
await db.insert(companies).values({
|
||||||
id: companyId,
|
id: companyId,
|
||||||
name: `Company ${prefix}`,
|
name: `Company ${prefix}`,
|
||||||
issuePrefix: prefix,
|
issuePrefix: prefix,
|
||||||
requireBoardApprovalForNewAgents: false,
|
requireBoardApprovalForNewAgents: false,
|
||||||
});
|
});
|
||||||
await db.insert(agents).values({
|
await db.insert(agents).values([
|
||||||
id: agentId,
|
{
|
||||||
companyId,
|
id: agentId,
|
||||||
name: `${prefix} Agent`,
|
companyId,
|
||||||
role: "engineer",
|
name: `${prefix} Agent`,
|
||||||
status: "idle",
|
role: "engineer",
|
||||||
});
|
status: "idle",
|
||||||
return { companyId, agentId };
|
},
|
||||||
|
{
|
||||||
|
id: pausedAgentId,
|
||||||
|
companyId,
|
||||||
|
name: `${prefix} Paused`,
|
||||||
|
role: "engineer",
|
||||||
|
status: "paused",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
return { companyId, agentId, pausedAgentId };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function insertIssue(input: {
|
async function insertIssue(input: {
|
||||||
@@ -80,6 +99,8 @@ describeEmbeddedPostgres("issue blocker attention", () => {
|
|||||||
originKind?: string | null;
|
originKind?: string | null;
|
||||||
originId?: string | null;
|
originId?: string | null;
|
||||||
originFingerprint?: string | null;
|
originFingerprint?: string | null;
|
||||||
|
executionState?: Record<string, unknown> | null;
|
||||||
|
description?: string | null;
|
||||||
}) {
|
}) {
|
||||||
const id = input.id ?? randomUUID();
|
const id = input.id ?? randomUUID();
|
||||||
await db.insert(issues).values({
|
await db.insert(issues).values({
|
||||||
@@ -95,6 +116,8 @@ describeEmbeddedPostgres("issue blocker attention", () => {
|
|||||||
originKind: input.originKind ?? "manual",
|
originKind: input.originKind ?? "manual",
|
||||||
originId: input.originId ?? null,
|
originId: input.originId ?? null,
|
||||||
originFingerprint: input.originFingerprint ?? "default",
|
originFingerprint: input.originFingerprint ?? "default",
|
||||||
|
executionState: input.executionState ?? null,
|
||||||
|
description: input.description ?? null,
|
||||||
});
|
});
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
@@ -483,4 +506,192 @@ describeEmbeddedPostgres("issue blocker attention", () => {
|
|||||||
sampleBlockerIdentifier: "PBY-2",
|
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" },
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1444,6 +1444,7 @@ export function issueRoutes(
|
|||||||
const parsedOffset = rawOffset !== undefined && /^\d+$/.test(rawOffset)
|
const parsedOffset = rawOffset !== undefined && /^\d+$/.test(rawOffset)
|
||||||
? Number.parseInt(rawOffset, 10)
|
? Number.parseInt(rawOffset, 10)
|
||||||
: null;
|
: null;
|
||||||
|
const attention = req.query.attention as string | undefined;
|
||||||
|
|
||||||
if (assigneeUserFilterRaw === "me" && (!assigneeUserId || req.actor.type !== "board")) {
|
if (assigneeUserFilterRaw === "me" && (!assigneeUserId || req.actor.type !== "board")) {
|
||||||
res.status(403).json({ error: "assigneeUserId=me requires board authentication" });
|
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" });
|
res.status(403).json({ error: "unreadForUserId=me requires board authentication" });
|
||||||
return;
|
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)) {
|
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}` });
|
res.status(400).json({ error: `limit must be a positive integer up to ${ISSUE_LIST_MAX_LIMIT}` });
|
||||||
return;
|
return;
|
||||||
@@ -1472,6 +1477,7 @@ export function issueRoutes(
|
|||||||
const offset = parsedOffset ?? 0;
|
const offset = parsedOffset ?? 0;
|
||||||
|
|
||||||
const result = await svc.list(companyId, {
|
const result = await svc.list(companyId, {
|
||||||
|
attention: attention === "blocked" ? "blocked" : undefined,
|
||||||
status: req.query.status as string | undefined,
|
status: req.query.status as string | undefined,
|
||||||
assigneeAgentId: req.query.assigneeAgentId as string | undefined,
|
assigneeAgentId: req.query.assigneeAgentId as string | undefined,
|
||||||
participantAgentId: req.query.participantAgentId as string | undefined,
|
participantAgentId: req.query.participantAgentId as string | undefined,
|
||||||
@@ -1495,6 +1501,8 @@ export function issueRoutes(
|
|||||||
includePluginOperations:
|
includePluginOperations:
|
||||||
req.query.includePluginOperations === "true" || req.query.includePluginOperations === "1",
|
req.query.includePluginOperations === "true" || req.query.includePluginOperations === "1",
|
||||||
includeBlockedBy: req.query.includeBlockedBy === "true" || req.query.includeBlockedBy === "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,
|
q: req.query.q as string | undefined,
|
||||||
limit,
|
limit,
|
||||||
offset,
|
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) => {
|
router.get("/companies/:companyId/labels", async (req, res) => {
|
||||||
const companyId = req.params.companyId as string;
|
const companyId = req.params.companyId as string;
|
||||||
assertCompanyAccess(req, companyId);
|
assertCompanyAccess(req, companyId);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -128,6 +128,7 @@ function boardRoutes() {
|
|||||||
<Route path="inbox/mine" element={<Inbox />} />
|
<Route path="inbox/mine" element={<Inbox />} />
|
||||||
<Route path="inbox/recent" element={<Inbox />} />
|
<Route path="inbox/recent" element={<Inbox />} />
|
||||||
<Route path="inbox/unread" element={<Inbox />} />
|
<Route path="inbox/unread" element={<Inbox />} />
|
||||||
|
<Route path="inbox/blocked" element={<Inbox />} />
|
||||||
<Route path="inbox/all" element={<Inbox />} />
|
<Route path="inbox/all" element={<Inbox />} />
|
||||||
<Route path="inbox/requests" element={<JoinRequestQueue />} />
|
<Route path="inbox/requests" element={<JoinRequestQueue />} />
|
||||||
<Route path="inbox/new" element={<Navigate to="/inbox/mine" replace />} />
|
<Route path="inbox/new" element={<Navigate to="/inbox/mine" replace />} />
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export const issuesApi = {
|
|||||||
list: (
|
list: (
|
||||||
companyId: string,
|
companyId: string,
|
||||||
filters?: {
|
filters?: {
|
||||||
|
attention?: "blocked";
|
||||||
status?: string;
|
status?: string;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
parentId?: string;
|
parentId?: string;
|
||||||
@@ -55,12 +56,14 @@ export const issuesApi = {
|
|||||||
descendantOf?: string;
|
descendantOf?: string;
|
||||||
includeRoutineExecutions?: boolean;
|
includeRoutineExecutions?: boolean;
|
||||||
includeBlockedBy?: boolean;
|
includeBlockedBy?: boolean;
|
||||||
|
includeBlockedInboxAttention?: boolean;
|
||||||
q?: string;
|
q?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
|
if (filters?.attention) params.set("attention", filters.attention);
|
||||||
if (filters?.status) params.set("status", filters.status);
|
if (filters?.status) params.set("status", filters.status);
|
||||||
if (filters?.projectId) params.set("projectId", filters.projectId);
|
if (filters?.projectId) params.set("projectId", filters.projectId);
|
||||||
if (filters?.parentId) params.set("parentId", filters.parentId);
|
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?.descendantOf) params.set("descendantOf", filters.descendantOf);
|
||||||
if (filters?.includeRoutineExecutions) params.set("includeRoutineExecutions", "true");
|
if (filters?.includeRoutineExecutions) params.set("includeRoutineExecutions", "true");
|
||||||
if (filters?.includeBlockedBy) params.set("includeBlockedBy", "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?.q) params.set("q", filters.q);
|
||||||
if (filters?.limit) params.set("limit", String(filters.limit));
|
if (filters?.limit) params.set("limit", String(filters.limit));
|
||||||
if (filters?.offset !== undefined) params.set("offset", String(filters.offset));
|
if (filters?.offset !== undefined) params.set("offset", String(filters.offset));
|
||||||
const qs = params.toString();
|
const qs = params.toString();
|
||||||
return api.get<Issue[]>(`/companies/${companyId}/issues${qs ? `?${qs}` : ""}`);
|
return api.get<Issue[]>(`/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<IssueLabel[]>(`/companies/${companyId}/labels`),
|
listLabels: (companyId: string) => api.get<IssueLabel[]>(`/companies/${companyId}/labels`),
|
||||||
createLabel: (companyId: string, data: { name: string; color: string }) =>
|
createLabel: (companyId: string, data: { name: string; color: string }) =>
|
||||||
api.post<IssueLabel>(`/companies/${companyId}/labels`, data),
|
api.post<IssueLabel>(`/companies/${companyId}/labels`, data),
|
||||||
|
|||||||
@@ -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 }) => (
|
||||||
|
<a className={className} {...props}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
(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> = {},
|
||||||
|
): 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(<QueryClientProvider client={queryClient}>{node}</QueryClientProvider>);
|
||||||
|
});
|
||||||
|
return { root, queryClient };
|
||||||
|
}
|
||||||
|
|
||||||
|
const blockedViewProps = {
|
||||||
|
companyId: "company-1",
|
||||||
|
searchQuery: "",
|
||||||
|
agentNameById: new Map<string, string>(),
|
||||||
|
issueLinkState: null,
|
||||||
|
groupBy: "none" as const,
|
||||||
|
sortBy: "most_recent" as const,
|
||||||
|
issueFilters: defaultIssueFilterState,
|
||||||
|
currentUserId: "local-board",
|
||||||
|
liveIssueIds: new Set<string>(),
|
||||||
|
workspaceFilterContext: {},
|
||||||
|
showStatusColumn: true,
|
||||||
|
showIdentifierColumn: true,
|
||||||
|
showUpdatedColumn: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
async function waitFor(predicate: () => boolean, attempts = 30): Promise<void> {
|
||||||
|
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(
|
||||||
|
<BlockedInboxView
|
||||||
|
{...blockedViewProps}
|
||||||
|
/>,
|
||||||
|
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(
|
||||||
|
<BlockedInboxView
|
||||||
|
{...blockedViewProps}
|
||||||
|
agentNameById={new Map([["agent-1", "ClaudeCoder"]])}
|
||||||
|
/>,
|
||||||
|
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(
|
||||||
|
<BlockedInboxView
|
||||||
|
{...blockedViewProps}
|
||||||
|
/>,
|
||||||
|
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(
|
||||||
|
<BlockedInboxView
|
||||||
|
{...blockedViewProps}
|
||||||
|
searchQuery="charlie"
|
||||||
|
/>,
|
||||||
|
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(
|
||||||
|
<BlockedInboxView
|
||||||
|
{...blockedViewProps}
|
||||||
|
/>,
|
||||||
|
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());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, string>;
|
||||||
|
userLabelById?: ReadonlyMap<string, string>;
|
||||||
|
issueLinkState: unknown;
|
||||||
|
groupBy: BlockedInboxGroupBy;
|
||||||
|
sortBy: BlockedInboxSort;
|
||||||
|
issueFilters: IssueFilterState;
|
||||||
|
currentUserId: string | null;
|
||||||
|
liveIssueIds: ReadonlySet<string>;
|
||||||
|
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<Set<string>>(() => 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 (
|
||||||
|
<div data-testid="blocked-inbox-loading" className="space-y-3" aria-busy="true">
|
||||||
|
{Array.from({ length: 3 }).map((_, groupIdx) => (
|
||||||
|
<div key={groupIdx} className="space-y-1">
|
||||||
|
<div className="h-4 w-40 animate-pulse rounded bg-muted/70" />
|
||||||
|
{Array.from({ length: 2 }).map((__, rowIdx) => (
|
||||||
|
<div
|
||||||
|
key={rowIdx}
|
||||||
|
className="flex items-center gap-3 border-b border-border/60 px-3 py-2.5 sm:px-4"
|
||||||
|
>
|
||||||
|
<div className="h-3.5 w-3.5 animate-pulse rounded-full bg-muted" />
|
||||||
|
<div className="h-4 w-16 animate-pulse rounded bg-muted/70" />
|
||||||
|
<div className="h-4 w-32 animate-pulse rounded-md bg-muted/70" />
|
||||||
|
<div className="h-4 flex-1 animate-pulse rounded bg-muted/60" />
|
||||||
|
<div className="hidden h-3 w-24 animate-pulse rounded bg-muted/60 sm:block" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : "Couldn't load the Blocked tab.";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="blocked-inbox-error"
|
||||||
|
role="alert"
|
||||||
|
className="flex flex-col gap-2 rounded-md border border-amber-300/70 bg-amber-50/90 p-4 text-amber-900 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-200"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
|
||||||
|
<div className="flex-1 space-y-1">
|
||||||
|
<p className="text-sm font-medium">Couldn't load the Blocked tab.</p>
|
||||||
|
<p className="text-xs opacity-80">
|
||||||
|
Other Inbox tabs still work. {message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 shrink-0 border-amber-400/70 bg-white/40 text-amber-900 hover:bg-white/70 dark:bg-amber-500/20 dark:text-amber-100"
|
||||||
|
onClick={() => void refetch()}
|
||||||
|
disabled={isFetching}
|
||||||
|
>
|
||||||
|
{isFetching ? "Trying…" : "Try again"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allRows.length === 0) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="blocked-inbox-empty"
|
||||||
|
className="flex flex-col items-center gap-3 rounded-lg border border-border/70 bg-card/40 px-6 py-10 text-center"
|
||||||
|
>
|
||||||
|
<span className="inline-flex h-10 w-10 items-center justify-center rounded-full bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300">
|
||||||
|
<CheckCircle2 className="h-5 w-5" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-medium text-foreground">No work is stopped.</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Issues that need a decision, recovery, or external action will appear here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (groups.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div
|
||||||
|
data-testid="blocked-inbox-no-search-results"
|
||||||
|
className="rounded-lg border border-border/70 bg-card/40 px-4 py-6 text-center text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
No stopped items match your search.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-testid="blocked-inbox" className="space-y-3">
|
||||||
|
<div className="overflow-hidden rounded-xl">
|
||||||
|
{groupBy === "none" ? (
|
||||||
|
sortedRows.map((row) => (
|
||||||
|
<BlockedInboxRow
|
||||||
|
key={row.issue.id}
|
||||||
|
row={row}
|
||||||
|
issueLinkState={issueLinkState}
|
||||||
|
agentNameById={agentNameById}
|
||||||
|
userLabelById={userLabelById}
|
||||||
|
showStatusColumn={showStatusColumn}
|
||||||
|
showIdentifierColumn={showIdentifierColumn}
|
||||||
|
showUpdatedColumn={showUpdatedColumn}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
groups.map((group) => {
|
||||||
|
const isCollapsed = collapsedVariants.has(group.variant);
|
||||||
|
return (
|
||||||
|
<div key={group.variant} data-testid={`blocked-inbox-group-${group.variant}`}>
|
||||||
|
<div className="px-3 sm:px-4">
|
||||||
|
<IssueGroupHeader
|
||||||
|
label={`${group.label} · ${group.rows.length}`}
|
||||||
|
collapsible
|
||||||
|
collapsed={isCollapsed}
|
||||||
|
onToggle={() => toggleVariant(group.variant)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{!isCollapsed && (
|
||||||
|
<div>
|
||||||
|
{group.rows.map((row) => (
|
||||||
|
<BlockedInboxRow
|
||||||
|
key={row.issue.id}
|
||||||
|
row={row}
|
||||||
|
issueLinkState={issueLinkState}
|
||||||
|
agentNameById={agentNameById}
|
||||||
|
userLabelById={userLabelById}
|
||||||
|
showStatusColumn={showStatusColumn}
|
||||||
|
showIdentifierColumn={showIdentifierColumn}
|
||||||
|
showUpdatedColumn={showUpdatedColumn}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BlockedInboxRowProps {
|
||||||
|
row: BlockedInboxIssueRow;
|
||||||
|
issueLinkState: unknown;
|
||||||
|
agentNameById: ReadonlyMap<string, string>;
|
||||||
|
userLabelById?: ReadonlyMap<string, string>;
|
||||||
|
showStatusColumn: boolean;
|
||||||
|
showIdentifierColumn: boolean;
|
||||||
|
showUpdatedColumn: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveOwnerName(
|
||||||
|
row: BlockedInboxIssueRow,
|
||||||
|
agentNameById: ReadonlyMap<string, string>,
|
||||||
|
userLabelById?: ReadonlyMap<string, string>,
|
||||||
|
): { 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 = (
|
||||||
|
<span className="flex shrink-0 items-center gap-3 text-xs">
|
||||||
|
<span
|
||||||
|
className="hidden w-[10.5rem] shrink-0 justify-start sm:inline-flex"
|
||||||
|
data-testid="blocked-row-reason-column"
|
||||||
|
>
|
||||||
|
<BlockedReasonChip
|
||||||
|
reason={row.attention.reason}
|
||||||
|
severity={row.attention.severity}
|
||||||
|
className="max-w-full"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
{ownerName ? (
|
||||||
|
<span className="hidden w-[150px] min-w-0 items-center text-muted-foreground sm:inline-flex">
|
||||||
|
<Identity
|
||||||
|
name={ownerName}
|
||||||
|
size="xs"
|
||||||
|
className="max-w-full"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="hidden w-[150px] shrink-0 sm:inline-flex" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
{showUpdatedColumn ? (
|
||||||
|
<span className="hidden w-[5.75rem] text-right text-muted-foreground sm:inline" data-testid="blocked-row-age">
|
||||||
|
{stoppedAge}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
const mobileMeta = (
|
||||||
|
<span className="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs text-muted-foreground">
|
||||||
|
<span data-testid="blocked-row-age-mobile">{stoppedAge}</span>
|
||||||
|
{ownerName ? (
|
||||||
|
<>
|
||||||
|
<span aria-hidden="true">·</span>
|
||||||
|
<span
|
||||||
|
className={cn(isAgent ? "font-medium text-foreground/90" : null)}
|
||||||
|
data-testid="blocked-row-owner-mobile"
|
||||||
|
>
|
||||||
|
{ownerName}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IssueRow
|
||||||
|
issue={row.issue}
|
||||||
|
issueLinkState={issueLinkState}
|
||||||
|
desktopMetaLeading={
|
||||||
|
<BlockedRowDesktopMeta
|
||||||
|
row={row}
|
||||||
|
showStatusColumn={showStatusColumn}
|
||||||
|
showIdentifierColumn={showIdentifierColumn}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
mobileLeading={
|
||||||
|
<span className="flex shrink-0 items-center gap-1.5 pt-px">
|
||||||
|
<StatusIcon status={row.issue.status} blockerAttention={row.issue.blockerAttention} />
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
titleSuffix={
|
||||||
|
<BlockedReasonChip
|
||||||
|
reason={row.attention.reason}
|
||||||
|
severity={row.attention.severity}
|
||||||
|
className="ml-2 max-w-[12rem] align-middle sm:hidden"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
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 (
|
||||||
|
<span className="hidden shrink-0 items-center gap-2 sm:inline-flex">
|
||||||
|
{showStatusColumn ? <StatusIcon status={row.issue.status} blockerAttention={row.issue.blockerAttention} /> : null}
|
||||||
|
{showIdentifierColumn ? <span className="font-mono text-xs text-muted-foreground">{identifier}</span> : null}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
<BlockedReasonChip reason="pending_board_decision" severity="high" />,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
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(<BlockedReasonChip reason="blocked_chain_stalled" severity={severity} />);
|
||||||
|
});
|
||||||
|
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(
|
||||||
|
<BlockedReasonChip reason="external_owner_action" severity="low" compact />,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const chip = container.querySelector('[data-testid="blocked-reason-chip"]');
|
||||||
|
const svg = chip?.querySelector("svg");
|
||||||
|
expect(svg).toBeNull();
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<BlockedReasonVariant, string> = {
|
||||||
|
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<BlockedReasonVariant, IconComponent> = {
|
||||||
|
needs_decision: Clock,
|
||||||
|
recovery_required: Wrench,
|
||||||
|
stalled: AlertTriangle,
|
||||||
|
needs_attention: AlertTriangle,
|
||||||
|
external_wait: User,
|
||||||
|
owner_paused: Pause,
|
||||||
|
};
|
||||||
|
|
||||||
|
const SEVERITY_DOT: Partial<Record<IssueBlockedInboxSeverity, string>> = {
|
||||||
|
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 (
|
||||||
|
<span
|
||||||
|
data-testid="blocked-reason-chip"
|
||||||
|
data-variant={variant}
|
||||||
|
data-severity={severity}
|
||||||
|
aria-label={`Reason: ${label}, severity ${severity}`}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex shrink-0 items-center gap-1 rounded-md border px-2 py-0.5 text-[10px] font-medium leading-tight sm:text-[11px]",
|
||||||
|
VARIANT_STYLES[variant],
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{dotClass ? (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn("inline-block h-1.5 w-1.5 shrink-0 rounded-full", dotClass)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{compact ? null : <Icon className="h-3 w-3 shrink-0" aria-hidden="true" />}
|
||||||
|
<span className="truncate">{label}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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> = {},
|
||||||
|
): 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<Issue> & { 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<IssueBlockedInboxReason, BlockedReasonVariant> = {
|
||||||
|
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<BlockedReasonVariant, string> = {
|
||||||
|
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<IssueBlockedInboxReason, string> = {
|
||||||
|
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<IssueBlockedInboxSeverity, number> = {
|
||||||
|
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<BlockedReasonVariant, BlockedInboxIssueRow[]>();
|
||||||
|
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`;
|
||||||
|
}
|
||||||
@@ -1002,6 +1002,12 @@ describe("inbox helpers", () => {
|
|||||||
expect(loadLastInboxTab()).toBe("all");
|
expect(loadLastInboxTab()).toBe("all");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("persists the blocked inbox tab", () => {
|
||||||
|
localStorage.clear();
|
||||||
|
saveLastInboxTab("blocked");
|
||||||
|
expect(loadLastInboxTab()).toBe("blocked");
|
||||||
|
});
|
||||||
|
|
||||||
it("persists inbox filters per company", () => {
|
it("persists inbox filters per company", () => {
|
||||||
saveInboxFilterPreferences("company-1", {
|
saveInboxFilterPreferences("company-1", {
|
||||||
allCategoryFilter: "approvals",
|
allCategoryFilter: "approvals",
|
||||||
|
|||||||
+8
-2
@@ -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_GROUP_BY_KEY = "paperclip:inbox:group-by";
|
||||||
export const INBOX_FILTER_PREFERENCES_KEY_PREFIX = "paperclip:inbox:filters";
|
export const INBOX_FILTER_PREFERENCES_KEY_PREFIX = "paperclip:inbox:filters";
|
||||||
export const INBOX_COLLAPSED_GROUPS_KEY_PREFIX = "paperclip:inbox:collapsed-groups";
|
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 =
|
export type InboxCategoryFilter =
|
||||||
| "everything"
|
| "everything"
|
||||||
| "issues_i_touched"
|
| "issues_i_touched"
|
||||||
@@ -630,7 +630,13 @@ export function resolveInboxNestingEnabled(preferenceEnabled: boolean, isMobile:
|
|||||||
export function loadLastInboxTab(): InboxTab {
|
export function loadLastInboxTab(): InboxTab {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(INBOX_LAST_TAB_KEY);
|
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";
|
if (raw === "new") return "mine";
|
||||||
return "mine";
|
return "mine";
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ export const queryKeys = {
|
|||||||
listMineByMe: (companyId: string) => ["issues", companyId, "mine-by-me"] as const,
|
listMineByMe: (companyId: string) => ["issues", companyId, "mine-by-me"] as const,
|
||||||
listTouchedByMe: (companyId: string) => ["issues", companyId, "touched-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,
|
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,
|
labels: (companyId: string) => ["issues", companyId, "labels"] as const,
|
||||||
listByProject: (companyId: string, projectId: string) =>
|
listByProject: (companyId: string, projectId: string) =>
|
||||||
["issues", companyId, "project", projectId] as const,
|
["issues", companyId, "project", projectId] as const,
|
||||||
|
|||||||
+185
-2
@@ -3,11 +3,124 @@
|
|||||||
import { act } from "react";
|
import { act } from "react";
|
||||||
import type { ComponentProps } from "react";
|
import type { ComponentProps } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import type { Issue } from "@paperclipai/shared";
|
import type { Issue } from "@paperclipai/shared";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { CompanyJoinRequest } from "../api/access";
|
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<typeof import("../api/access")>("../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 {
|
import {
|
||||||
FailedRunInboxRow,
|
FailedRunInboxRow,
|
||||||
|
Inbox,
|
||||||
InboxGroupHeader,
|
InboxGroupHeader,
|
||||||
InboxIssueMetaLeading,
|
InboxIssueMetaLeading,
|
||||||
InboxIssueTrailingColumns,
|
InboxIssueTrailingColumns,
|
||||||
@@ -18,8 +131,8 @@ vi.mock("@/lib/router", () => ({
|
|||||||
Link: ({ children, className, ...props }: ComponentProps<"a">) => (
|
Link: ({ children, className, ...props }: ComponentProps<"a">) => (
|
||||||
<a className={className} {...props}>{children}</a>
|
<a className={className} {...props}>{children}</a>
|
||||||
),
|
),
|
||||||
useLocation: () => ({ pathname: "/", search: "", hash: "" }),
|
useLocation: () => routerMock.location,
|
||||||
useNavigate: () => () => {},
|
useNavigate: () => routerMock.navigate,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// 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(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<Inbox />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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", () => {
|
describe("FailedRunInboxRow", () => {
|
||||||
let container: HTMLDivElement;
|
let container: HTMLDivElement;
|
||||||
|
|
||||||
|
|||||||
+227
-102
@@ -13,6 +13,12 @@ import { agentsApi } from "../api/agents";
|
|||||||
import { heartbeatsApi } from "../api/heartbeats";
|
import { heartbeatsApi } from "../api/heartbeats";
|
||||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||||
import { projectsApi } from "../api/projects";
|
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 { useCompany } from "../context/CompanyContext";
|
||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
import { useGeneralSettings } from "../context/GeneralSettingsContext";
|
import { useGeneralSettings } from "../context/GeneralSettingsContext";
|
||||||
@@ -54,6 +60,7 @@ import {
|
|||||||
} from "../components/IssueColumns";
|
} from "../components/IssueColumns";
|
||||||
import { IssueFiltersPopover } from "../components/IssueFiltersPopover";
|
import { IssueFiltersPopover } from "../components/IssueFiltersPopover";
|
||||||
import { IssueRow } from "../components/IssueRow";
|
import { IssueRow } from "../components/IssueRow";
|
||||||
|
import { BlockedInboxView } from "../components/BlockedInboxView";
|
||||||
import { SwipeToArchive } from "../components/SwipeToArchive";
|
import { SwipeToArchive } from "../components/SwipeToArchive";
|
||||||
|
|
||||||
import { StatusIcon } from "../components/StatusIcon";
|
import { StatusIcon } from "../components/StatusIcon";
|
||||||
@@ -85,6 +92,7 @@ import {
|
|||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Check,
|
Check,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
ArrowUpDown,
|
||||||
Layers,
|
Layers,
|
||||||
Plus,
|
Plus,
|
||||||
XCircle,
|
XCircle,
|
||||||
@@ -674,6 +682,8 @@ export function Inbox() {
|
|||||||
() => loadInboxFilterPreferences(selectedCompanyId),
|
() => loadInboxFilterPreferences(selectedCompanyId),
|
||||||
);
|
);
|
||||||
const [groupBy, setGroupBy] = useState<InboxWorkItemGroupBy>(() => loadInboxWorkItemGroupBy());
|
const [groupBy, setGroupBy] = useState<InboxWorkItemGroupBy>(() => loadInboxWorkItemGroupBy());
|
||||||
|
const [blockedGroupBy, setBlockedGroupBy] = useState<BlockedInboxGroupBy>("none");
|
||||||
|
const [blockedSortBy, setBlockedSortBy] = useState<BlockedInboxSort>("most_recent");
|
||||||
const [visibleIssueColumns, setVisibleIssueColumns] = useState<InboxIssueColumn[]>(loadInboxIssueColumns);
|
const [visibleIssueColumns, setVisibleIssueColumns] = useState<InboxIssueColumn[]>(loadInboxIssueColumns);
|
||||||
const { dismissed: dismissedAlerts, dismiss: dismissAlert } = useDismissedInboxAlerts();
|
const { dismissed: dismissedAlerts, dismiss: dismissAlert } = useDismissedInboxAlerts();
|
||||||
const { dismissedAtByKey, dismiss: dismissInboxItem } = useInboxDismissals(selectedCompanyId);
|
const { dismissedAtByKey, dismiss: dismissInboxItem } = useInboxDismissals(selectedCompanyId);
|
||||||
@@ -682,7 +692,11 @@ export function Inbox() {
|
|||||||
|
|
||||||
const pathSegment = location.pathname.split("/").pop() ?? "mine";
|
const pathSegment = location.pathname.split("/").pop() ?? "mine";
|
||||||
const tab: InboxTab =
|
const tab: InboxTab =
|
||||||
pathSegment === "mine" || pathSegment === "recent" || pathSegment === "all" || pathSegment === "unread"
|
pathSegment === "mine"
|
||||||
|
|| pathSegment === "recent"
|
||||||
|
|| pathSegment === "all"
|
||||||
|
|| pathSegment === "unread"
|
||||||
|
|| pathSegment === "blocked"
|
||||||
? pathSegment
|
? pathSegment
|
||||||
: "mine";
|
: "mine";
|
||||||
const canArchiveFromTab = isMineInboxTab(tab);
|
const canArchiveFromTab = isMineInboxTab(tab);
|
||||||
@@ -824,7 +838,6 @@ export function Inbox() {
|
|||||||
queryFn: () => heartbeatsApi.list(selectedCompanyId!, undefined, INBOX_HEARTBEAT_RUN_LIMIT),
|
queryFn: () => heartbeatsApi.list(selectedCompanyId!, undefined, INBOX_HEARTBEAT_RUN_LIMIT),
|
||||||
enabled: !!selectedCompanyId,
|
enabled: !!selectedCompanyId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: liveRuns } = useQuery({
|
const { data: liveRuns } = useQuery({
|
||||||
queryKey: queryKeys.liveRuns(selectedCompanyId!),
|
queryKey: queryKeys.liveRuns(selectedCompanyId!),
|
||||||
queryFn: () => heartbeatsApi.liveRunsForCompany(selectedCompanyId!),
|
queryFn: () => heartbeatsApi.liveRunsForCompany(selectedCompanyId!),
|
||||||
@@ -1902,6 +1915,7 @@ export function Inbox() {
|
|||||||
.map((issue) => issue.id);
|
.map((issue) => issue.id);
|
||||||
const canMarkAllRead = unreadIssueIds.length > 0;
|
const canMarkAllRead = unreadIssueIds.length > 0;
|
||||||
const activeIssueFilterCount = countActiveIssueFilters(issueFilters, true);
|
const activeIssueFilterCount = countActiveIssueFilters(issueFilters, true);
|
||||||
|
const showGeneralIssueToolbarControls = tab !== "blocked";
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -1947,6 +1961,7 @@ export function Inbox() {
|
|||||||
label: "Recent",
|
label: "Recent",
|
||||||
},
|
},
|
||||||
{ value: "unread", label: "Unread" },
|
{ value: "unread", label: "Unread" },
|
||||||
|
{ value: "blocked", label: "Blocked" },
|
||||||
{ value: "all", label: "All" },
|
{ value: "all", label: "All" },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
@@ -1981,112 +1996,203 @@ export function Inbox() {
|
|||||||
data-page-search-target="true"
|
data-page-search-target="true"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
{tab === "blocked" ? (
|
||||||
type="button"
|
<>
|
||||||
variant="outline"
|
<IssueFiltersPopover
|
||||||
size="icon"
|
state={issueFilters}
|
||||||
className={cn("hidden h-8 w-8 shrink-0 sm:inline-flex", nestingEnabled && "bg-accent")}
|
onChange={updateIssueFilters}
|
||||||
onClick={toggleNesting}
|
activeFilterCount={activeIssueFilterCount}
|
||||||
title={nestingEnabled ? "Disable parent-child nesting" : "Enable parent-child nesting"}
|
agents={agents}
|
||||||
>
|
creators={creatorOptions}
|
||||||
<ListTree className="h-3.5 w-3.5" />
|
projects={projects?.map((project) => ({ id: project.id, name: project.name }))}
|
||||||
</Button>
|
labels={labels?.map((label) => ({ id: label.id, name: label.name, color: label.color }))}
|
||||||
<IssueFiltersPopover
|
currentUserId={currentUserId}
|
||||||
state={issueFilters}
|
enableRoutineVisibilityFilter
|
||||||
onChange={updateIssueFilters}
|
buttonVariant="outline"
|
||||||
activeFilterCount={activeIssueFilterCount}
|
iconOnly
|
||||||
agents={agents}
|
workspaces={isolatedWorkspacesEnabled ? executionWorkspaces.filter((w) => w.mode === "isolated_workspace").map((w) => ({ id: w.id, name: w.name })) : undefined}
|
||||||
creators={creatorOptions}
|
/>
|
||||||
projects={projects?.map((project) => ({ id: project.id, name: project.name }))}
|
<Popover>
|
||||||
labels={labels?.map((label) => ({ id: label.id, name: label.name, color: label.color }))}
|
<PopoverTrigger asChild>
|
||||||
currentUserId={currentUserId}
|
<Button
|
||||||
enableRoutineVisibilityFilter
|
|
||||||
buttonVariant="outline"
|
|
||||||
iconOnly
|
|
||||||
workspaces={isolatedWorkspacesEnabled ? executionWorkspaces.filter((w) => w.mode === "isolated_workspace").map((w) => ({ id: w.id, name: w.name })) : undefined}
|
|
||||||
/>
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
className={cn("h-8 w-8 shrink-0", groupBy !== "none" && "bg-accent")}
|
|
||||||
title="Group"
|
|
||||||
>
|
|
||||||
<Layers className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent align="end" className="w-40 p-2">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
{([
|
|
||||||
["none", "None"],
|
|
||||||
["type", "Type"],
|
|
||||||
["assignee", "Assignee"],
|
|
||||||
["project", "Project"],
|
|
||||||
...(isolatedWorkspacesEnabled ? ([["workspace", "Workspace"]] as const) : []),
|
|
||||||
] as const).map(([value, label]) => (
|
|
||||||
<button
|
|
||||||
key={value}
|
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
variant="outline"
|
||||||
"flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-sm",
|
size="icon"
|
||||||
groupBy === value ? "bg-accent/50 text-foreground" : "text-muted-foreground hover:bg-accent/50",
|
className={cn("h-8 w-8 shrink-0", blockedGroupBy !== "none" && "bg-accent")}
|
||||||
)}
|
title="Group"
|
||||||
onClick={() => updateGroupBy(value)}
|
|
||||||
>
|
>
|
||||||
<span>{label}</span>
|
<Layers className="h-3.5 w-3.5" />
|
||||||
{groupBy === value ? <Check className="h-3.5 w-3.5" /> : null}
|
</Button>
|
||||||
</button>
|
</PopoverTrigger>
|
||||||
))}
|
<PopoverContent align="end" className="w-44 p-0">
|
||||||
</div>
|
<div className="space-y-0.5 p-2">
|
||||||
</PopoverContent>
|
{BLOCKED_GROUP_OPTIONS.map(([value, label]) => (
|
||||||
</Popover>
|
<button
|
||||||
<IssueColumnPicker
|
key={value}
|
||||||
availableColumns={availableIssueColumns}
|
type="button"
|
||||||
visibleColumnSet={visibleIssueColumnSet}
|
className={cn(
|
||||||
onToggleColumn={toggleIssueColumn}
|
"flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-sm",
|
||||||
onResetColumns={() => setIssueColumns(DEFAULT_INBOX_ISSUE_COLUMNS)}
|
blockedGroupBy === value ? "bg-accent/50 text-foreground" : "text-muted-foreground hover:bg-accent/50",
|
||||||
title="Choose which inbox columns stay visible"
|
)}
|
||||||
iconOnly
|
onClick={() => setBlockedGroupBy(value)}
|
||||||
/>
|
>
|
||||||
{canMarkAllRead && (
|
<span>{label}</span>
|
||||||
|
{blockedGroupBy === value ? <Check className="h-3.5 w-3.5" /> : null}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
<IssueColumnPicker
|
||||||
|
availableColumns={availableIssueColumns}
|
||||||
|
visibleColumnSet={visibleIssueColumnSet}
|
||||||
|
onToggleColumn={toggleIssueColumn}
|
||||||
|
onResetColumns={() => setIssueColumns(DEFAULT_INBOX_ISSUE_COLUMNS)}
|
||||||
|
title="Choose which inbox columns stay visible"
|
||||||
|
iconOnly
|
||||||
|
/>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 shrink-0"
|
||||||
|
title="Sort"
|
||||||
|
>
|
||||||
|
<ArrowUpDown className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent align="end" className="w-48 p-0">
|
||||||
|
<div className="space-y-0.5 p-2">
|
||||||
|
{BLOCKED_SORT_OPTIONS.map(([value, label]) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-sm",
|
||||||
|
blockedSortBy === value ? "bg-accent/50 text-foreground" : "text-muted-foreground hover:bg-accent/50",
|
||||||
|
)}
|
||||||
|
onClick={() => setBlockedSortBy(value)}
|
||||||
|
>
|
||||||
|
<span>{label}</span>
|
||||||
|
{blockedSortBy === value ? <Check className="h-3.5 w-3.5" /> : null}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</>
|
||||||
|
) : showGeneralIssueToolbarControls ? (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="icon"
|
||||||
className="h-8 shrink-0"
|
className={cn("hidden h-8 w-8 shrink-0 sm:inline-flex", nestingEnabled && "bg-accent")}
|
||||||
onClick={() => setShowMarkAllReadConfirm(true)}
|
onClick={toggleNesting}
|
||||||
disabled={markAllReadMutation.isPending}
|
title={nestingEnabled ? "Disable parent-child nesting" : "Enable parent-child nesting"}
|
||||||
>
|
>
|
||||||
{markAllReadMutation.isPending ? "Marking…" : "Mark all as read"}
|
<ListTree className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
<Dialog open={showMarkAllReadConfirm} onOpenChange={setShowMarkAllReadConfirm}>
|
<IssueFiltersPopover
|
||||||
<DialogContent className="sm:max-w-md">
|
state={issueFilters}
|
||||||
<DialogHeader>
|
onChange={updateIssueFilters}
|
||||||
<DialogTitle>Mark all as read?</DialogTitle>
|
activeFilterCount={activeIssueFilterCount}
|
||||||
<DialogDescription>
|
agents={agents}
|
||||||
This will mark {unreadIssueIds.length} unread {unreadIssueIds.length === 1 ? "item" : "items"} as read.
|
creators={creatorOptions}
|
||||||
</DialogDescription>
|
projects={projects?.map((project) => ({ id: project.id, name: project.name }))}
|
||||||
</DialogHeader>
|
labels={labels?.map((label) => ({ id: label.id, name: label.name, color: label.color }))}
|
||||||
<DialogFooter>
|
currentUserId={currentUserId}
|
||||||
<Button variant="outline" onClick={() => setShowMarkAllReadConfirm(false)}>
|
enableRoutineVisibilityFilter
|
||||||
Cancel
|
buttonVariant="outline"
|
||||||
</Button>
|
iconOnly
|
||||||
<Button
|
workspaces={isolatedWorkspacesEnabled ? executionWorkspaces.filter((w) => w.mode === "isolated_workspace").map((w) => ({ id: w.id, name: w.name })) : undefined}
|
||||||
onClick={() => {
|
/>
|
||||||
setShowMarkAllReadConfirm(false);
|
<Popover>
|
||||||
markAllReadMutation.mutate(unreadIssueIds);
|
<PopoverTrigger asChild>
|
||||||
}}
|
<Button
|
||||||
>
|
type="button"
|
||||||
Mark all as read
|
variant="outline"
|
||||||
</Button>
|
size="icon"
|
||||||
</DialogFooter>
|
className={cn("h-8 w-8 shrink-0", groupBy !== "none" && "bg-accent")}
|
||||||
</DialogContent>
|
title="Group"
|
||||||
</Dialog>
|
>
|
||||||
|
<Layers className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent align="end" className="w-40 p-2">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{([
|
||||||
|
["none", "None"],
|
||||||
|
["type", "Type"],
|
||||||
|
["assignee", "Assignee"],
|
||||||
|
["project", "Project"],
|
||||||
|
...(isolatedWorkspacesEnabled ? ([["workspace", "Workspace"]] as const) : []),
|
||||||
|
] as const).map(([value, label]) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-sm",
|
||||||
|
groupBy === value ? "bg-accent/50 text-foreground" : "text-muted-foreground hover:bg-accent/50",
|
||||||
|
)}
|
||||||
|
onClick={() => updateGroupBy(value)}
|
||||||
|
>
|
||||||
|
<span>{label}</span>
|
||||||
|
{groupBy === value ? <Check className="h-3.5 w-3.5" /> : null}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
<IssueColumnPicker
|
||||||
|
availableColumns={availableIssueColumns}
|
||||||
|
visibleColumnSet={visibleIssueColumnSet}
|
||||||
|
onToggleColumn={toggleIssueColumn}
|
||||||
|
onResetColumns={() => setIssueColumns(DEFAULT_INBOX_ISSUE_COLUMNS)}
|
||||||
|
title="Choose which inbox columns stay visible"
|
||||||
|
iconOnly
|
||||||
|
/>
|
||||||
|
{canMarkAllRead && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 shrink-0"
|
||||||
|
onClick={() => setShowMarkAllReadConfirm(true)}
|
||||||
|
disabled={markAllReadMutation.isPending}
|
||||||
|
>
|
||||||
|
{markAllReadMutation.isPending ? "Marking…" : "Mark all as read"}
|
||||||
|
</Button>
|
||||||
|
<Dialog open={showMarkAllReadConfirm} onOpenChange={setShowMarkAllReadConfirm}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Mark all as read?</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
This will mark {unreadIssueIds.length} unread {unreadIssueIds.length === 1 ? "item" : "items"} as read.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setShowMarkAllReadConfirm(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setShowMarkAllReadConfirm(false);
|
||||||
|
markAllReadMutation.mutate(unreadIssueIds);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Mark all as read
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2131,11 +2237,30 @@ export function Inbox() {
|
|||||||
{approvalsError && <p className="text-sm text-destructive">{approvalsError.message}</p>}
|
{approvalsError && <p className="text-sm text-destructive">{approvalsError.message}</p>}
|
||||||
{actionError && <p className="text-sm text-destructive">{actionError}</p>}
|
{actionError && <p className="text-sm text-destructive">{actionError}</p>}
|
||||||
|
|
||||||
{!allLoaded && visibleSections.length === 0 && (
|
{tab === "blocked" ? (
|
||||||
|
<BlockedInboxView
|
||||||
|
companyId={selectedCompanyId!}
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
agentNameById={agentById}
|
||||||
|
userLabelById={companyUserLabelMap}
|
||||||
|
issueLinkState={issueLinkState}
|
||||||
|
groupBy={blockedGroupBy}
|
||||||
|
sortBy={blockedSortBy}
|
||||||
|
issueFilters={issueFilters}
|
||||||
|
currentUserId={currentUserId}
|
||||||
|
liveIssueIds={liveIssueIds}
|
||||||
|
workspaceFilterContext={inboxWorkspaceGrouping}
|
||||||
|
showStatusColumn={visibleIssueColumnSet.has("status") && availableIssueColumnSet.has("status")}
|
||||||
|
showIdentifierColumn={visibleIssueColumnSet.has("id") && availableIssueColumnSet.has("id")}
|
||||||
|
showUpdatedColumn={visibleIssueColumnSet.has("updated") && availableIssueColumnSet.has("updated")}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{tab !== "blocked" && !allLoaded && visibleSections.length === 0 && (
|
||||||
<PageSkeleton variant="inbox" />
|
<PageSkeleton variant="inbox" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{allLoaded && visibleSections.length === 0 && (
|
{tab !== "blocked" && allLoaded && visibleSections.length === 0 && (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={searchQuery.trim() ? Search : InboxIcon}
|
icon={searchQuery.trim() ? Search : InboxIcon}
|
||||||
message={
|
message={
|
||||||
@@ -2152,7 +2277,7 @@ export function Inbox() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showWorkItemsSection && (
|
{tab !== "blocked" && showWorkItemsSection && (
|
||||||
<>
|
<>
|
||||||
{showSeparatorBefore("work_items") && <Separator />}
|
{showSeparatorBefore("work_items") && <Separator />}
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -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<string>(),
|
||||||
|
workspaceFilterContext: {},
|
||||||
|
showStatusColumn: true,
|
||||||
|
showIdentifierColumn: true,
|
||||||
|
showUpdatedColumn: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
function attention(
|
||||||
|
overrides: Partial<IssueBlockedInboxAttention> = {},
|
||||||
|
): 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 (
|
||||||
|
<PrimeBlockedFixtures>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
|
Inbox / Blocked tab — desktop layout
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-border bg-background p-4">
|
||||||
|
<BlockedInboxView
|
||||||
|
{...blockedViewDefaults}
|
||||||
|
companyId={companyId}
|
||||||
|
searchQuery={search}
|
||||||
|
agentNameById={new Map()}
|
||||||
|
issueLinkState={null}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PrimeBlockedFixtures>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BlockedTabSurfaceMobile() {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-[390px] space-y-3">
|
||||||
|
<div className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
|
Inbox / Blocked tab — 390px mobile width
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-border bg-background p-2">
|
||||||
|
<BlockedTabSurface />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BlockedReasonChipsCatalog() {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-3 p-6 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
|
Needs decision · medium
|
||||||
|
</div>
|
||||||
|
<BlockedReasonChip reason="pending_board_decision" severity="medium" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
|
Blocked chain stalled · critical
|
||||||
|
</div>
|
||||||
|
<BlockedReasonChip reason="blocked_chain_stalled" severity="critical" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
|
Needs attention · high
|
||||||
|
</div>
|
||||||
|
<BlockedReasonChip reason="blocked_by_assigned_backlog_issue" severity="high" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
|
Recovery required · high
|
||||||
|
</div>
|
||||||
|
<BlockedReasonChip reason="open_recovery_issue" severity="high" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
|
External wait · low (no severity dot)
|
||||||
|
</div>
|
||||||
|
<BlockedReasonChip reason="external_owner_action" severity="low" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
|
Owner paused · critical
|
||||||
|
</div>
|
||||||
|
<BlockedReasonChip reason="blocked_by_uninvokable_assignee" severity="critical" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BlockedTabEmptyState() {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-border bg-background p-4">
|
||||||
|
<BlockedInboxView
|
||||||
|
{...blockedViewDefaults}
|
||||||
|
companyId="company-empty"
|
||||||
|
searchQuery=""
|
||||||
|
agentNameById={new Map()}
|
||||||
|
issueLinkState={null}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<typeof BlockedTabSurface>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const DesktopLoaded: Story = {
|
||||||
|
render: () => <BlockedTabSurface />,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DesktopWithSearch: Story = {
|
||||||
|
render: () => <BlockedTabSurface search="parked" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MobileLayout: Story = {
|
||||||
|
parameters: { viewport: { defaultViewport: "mobile1" } },
|
||||||
|
render: () => <BlockedTabSurfaceMobile />,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ReasonChipCatalog: Story = {
|
||||||
|
render: () => <BlockedReasonChipsCatalog />,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EmptyState: Story = {
|
||||||
|
render: () => <BlockedTabEmptyState />,
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user