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

## Thinking Path

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

## What Changed

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

## Verification

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

## Risks

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

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

## Model Used

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

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta
2026-05-13 16:41:36 -05:00
committed by GitHub
parent d1a8c873b2
commit 4142559c37
24 changed files with 3737 additions and 115 deletions
+1
View File
@@ -128,6 +128,7 @@ function boardRoutes() {
<Route path="inbox/mine" element={<Inbox />} />
<Route path="inbox/recent" element={<Inbox />} />
<Route path="inbox/unread" element={<Inbox />} />
<Route path="inbox/blocked" element={<Inbox />} />
<Route path="inbox/all" element={<Inbox />} />
<Route path="inbox/requests" element={<JoinRequestQueue />} />
<Route path="inbox/new" element={<Navigate to="/inbox/mine" replace />} />
+26
View File
@@ -37,6 +37,7 @@ export const issuesApi = {
list: (
companyId: string,
filters?: {
attention?: "blocked";
status?: string;
projectId?: string;
parentId?: string;
@@ -55,12 +56,14 @@ export const issuesApi = {
descendantOf?: string;
includeRoutineExecutions?: boolean;
includeBlockedBy?: boolean;
includeBlockedInboxAttention?: boolean;
q?: string;
limit?: number;
offset?: number;
},
) => {
const params = new URLSearchParams();
if (filters?.attention) params.set("attention", filters.attention);
if (filters?.status) params.set("status", filters.status);
if (filters?.projectId) params.set("projectId", filters.projectId);
if (filters?.parentId) params.set("parentId", filters.parentId);
@@ -79,12 +82,35 @@ export const issuesApi = {
if (filters?.descendantOf) params.set("descendantOf", filters.descendantOf);
if (filters?.includeRoutineExecutions) params.set("includeRoutineExecutions", "true");
if (filters?.includeBlockedBy) params.set("includeBlockedBy", "true");
if (filters?.includeBlockedInboxAttention) params.set("includeBlockedInboxAttention", "true");
if (filters?.q) params.set("q", filters.q);
if (filters?.limit) params.set("limit", String(filters.limit));
if (filters?.offset !== undefined) params.set("offset", String(filters.offset));
const qs = params.toString();
return api.get<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`),
createLabel: (companyId: string, data: { name: string; color: string }) =>
api.post<IssueLabel>(`/companies/${companyId}/labels`, data),
+329
View File
@@ -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());
});
});
+386
View File
@@ -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();
});
});
});
+82
View File
@@ -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>
);
}
+275
View File
@@ -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");
});
});
+275
View File
@@ -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`;
}
+6
View File
@@ -1002,6 +1002,12 @@ describe("inbox helpers", () => {
expect(loadLastInboxTab()).toBe("all");
});
it("persists the blocked inbox tab", () => {
localStorage.clear();
saveLastInboxTab("blocked");
expect(loadLastInboxTab()).toBe("blocked");
});
it("persists inbox filters per company", () => {
saveInboxFilterPreferences("company-1", {
allCategoryFilter: "approvals",
+8 -2
View File
@@ -25,7 +25,7 @@ export const INBOX_NESTING_KEY = "paperclip:inbox:nesting";
export const INBOX_GROUP_BY_KEY = "paperclip:inbox:group-by";
export const INBOX_FILTER_PREFERENCES_KEY_PREFIX = "paperclip:inbox:filters";
export const INBOX_COLLAPSED_GROUPS_KEY_PREFIX = "paperclip:inbox:collapsed-groups";
export type InboxTab = "mine" | "recent" | "unread" | "all";
export type InboxTab = "mine" | "recent" | "unread" | "blocked" | "all";
export type InboxCategoryFilter =
| "everything"
| "issues_i_touched"
@@ -630,7 +630,13 @@ export function resolveInboxNestingEnabled(preferenceEnabled: boolean, isMobile:
export function loadLastInboxTab(): InboxTab {
try {
const raw = localStorage.getItem(INBOX_LAST_TAB_KEY);
if (raw === "all" || raw === "unread" || raw === "recent" || raw === "mine") return raw;
if (
raw === "all"
|| raw === "unread"
|| raw === "recent"
|| raw === "mine"
|| raw === "blocked"
) return raw;
if (raw === "new") return "mine";
return "mine";
} catch {
+2
View File
@@ -38,6 +38,8 @@ export const queryKeys = {
listMineByMe: (companyId: string) => ["issues", companyId, "mine-by-me"] as const,
listTouchedByMe: (companyId: string) => ["issues", companyId, "touched-by-me"] as const,
listUnreadTouchedByMe: (companyId: string) => ["issues", companyId, "unread-touched-by-me"] as const,
listBlockedAttention: (companyId: string) => ["issues", companyId, "blocked-attention"] as const,
countBlockedAttention: (companyId: string) => ["issues", companyId, "blocked-attention", "count"] as const,
labels: (companyId: string) => ["issues", companyId, "labels"] as const,
listByProject: (companyId: string, projectId: string) =>
["issues", companyId, "project", projectId] as const,
+185 -2
View File
@@ -3,11 +3,124 @@
import { act } from "react";
import type { ComponentProps } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Issue } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CompanyJoinRequest } from "../api/access";
const routerMock = vi.hoisted(() => ({
location: { pathname: "/", search: "", hash: "" },
navigate: vi.fn(),
}));
const apiMocks = vi.hoisted(() => ({
approvalsList: vi.fn(),
joinRequestsList: vi.fn(),
userDirectoryList: vi.fn(),
authSession: vi.fn(),
dashboardSummary: vi.fn(),
executionWorkspaceSummaries: vi.fn(),
issuesList: vi.fn(),
issuesCount: vi.fn(),
issueLabels: vi.fn(),
agentsList: vi.fn(),
heartbeatRunsList: vi.fn(),
liveRunsForCompany: vi.fn(),
experimentalSettings: vi.fn(),
projectsList: vi.fn(),
}));
vi.mock("../api/approvals", () => ({
approvalsApi: { list: apiMocks.approvalsList },
}));
vi.mock("../api/access", async () => {
const actual = await vi.importActual<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 {
FailedRunInboxRow,
Inbox,
InboxGroupHeader,
InboxIssueMetaLeading,
InboxIssueTrailingColumns,
@@ -18,8 +131,8 @@ vi.mock("@/lib/router", () => ({
Link: ({ children, className, ...props }: ComponentProps<"a">) => (
<a className={className} {...props}>{children}</a>
),
useLocation: () => ({ pathname: "/", search: "", hash: "" }),
useNavigate: () => () => {},
useLocation: () => routerMock.location,
useNavigate: () => routerMock.navigate,
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -108,6 +221,76 @@ function createJoinRequest(
};
}
function resetInboxApiMocks() {
routerMock.location.pathname = "/";
routerMock.location.search = "";
routerMock.location.hash = "";
routerMock.navigate.mockReset();
apiMocks.approvalsList.mockResolvedValue([]);
apiMocks.joinRequestsList.mockResolvedValue([]);
apiMocks.userDirectoryList.mockResolvedValue({ users: [] });
apiMocks.authSession.mockResolvedValue({
user: { id: "local-board" },
session: { userId: "local-board" },
});
apiMocks.dashboardSummary.mockResolvedValue({
agents: { error: 0 },
costs: { monthBudgetCents: 0, monthUtilizationPercent: 0 },
});
apiMocks.executionWorkspaceSummaries.mockResolvedValue([]);
apiMocks.issuesList.mockResolvedValue([]);
apiMocks.issuesCount.mockResolvedValue({ count: 0 });
apiMocks.issueLabels.mockResolvedValue([]);
apiMocks.agentsList.mockResolvedValue([]);
apiMocks.heartbeatRunsList.mockResolvedValue([]);
apiMocks.liveRunsForCompany.mockResolvedValue([]);
apiMocks.experimentalSettings.mockResolvedValue({ enableIsolatedWorkspaces: false });
apiMocks.projectsList.mockResolvedValue([]);
}
describe("Inbox toolbar", () => {
let container: HTMLDivElement;
beforeEach(() => {
resetInboxApiMocks();
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
container.remove();
});
it("shows blocked toolbar controls on the Blocked tab", async () => {
routerMock.location.pathname = "/inbox/blocked";
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } },
});
const root = createRoot(container);
await act(async () => {
root.render(
<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", () => {
let container: HTMLDivElement;
+227 -102
View File
@@ -13,6 +13,12 @@ import { agentsApi } from "../api/agents";
import { heartbeatsApi } from "../api/heartbeats";
import { instanceSettingsApi } from "../api/instanceSettings";
import { projectsApi } from "../api/projects";
import {
BLOCKED_GROUP_OPTIONS,
BLOCKED_SORT_OPTIONS,
type BlockedInboxGroupBy,
type BlockedInboxSort,
} from "../lib/blockedInbox";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useGeneralSettings } from "../context/GeneralSettingsContext";
@@ -54,6 +60,7 @@ import {
} from "../components/IssueColumns";
import { IssueFiltersPopover } from "../components/IssueFiltersPopover";
import { IssueRow } from "../components/IssueRow";
import { BlockedInboxView } from "../components/BlockedInboxView";
import { SwipeToArchive } from "../components/SwipeToArchive";
import { StatusIcon } from "../components/StatusIcon";
@@ -85,6 +92,7 @@ import {
AlertTriangle,
Check,
ChevronRight,
ArrowUpDown,
Layers,
Plus,
XCircle,
@@ -674,6 +682,8 @@ export function Inbox() {
() => loadInboxFilterPreferences(selectedCompanyId),
);
const [groupBy, setGroupBy] = useState<InboxWorkItemGroupBy>(() => loadInboxWorkItemGroupBy());
const [blockedGroupBy, setBlockedGroupBy] = useState<BlockedInboxGroupBy>("none");
const [blockedSortBy, setBlockedSortBy] = useState<BlockedInboxSort>("most_recent");
const [visibleIssueColumns, setVisibleIssueColumns] = useState<InboxIssueColumn[]>(loadInboxIssueColumns);
const { dismissed: dismissedAlerts, dismiss: dismissAlert } = useDismissedInboxAlerts();
const { dismissedAtByKey, dismiss: dismissInboxItem } = useInboxDismissals(selectedCompanyId);
@@ -682,7 +692,11 @@ export function Inbox() {
const pathSegment = location.pathname.split("/").pop() ?? "mine";
const tab: InboxTab =
pathSegment === "mine" || pathSegment === "recent" || pathSegment === "all" || pathSegment === "unread"
pathSegment === "mine"
|| pathSegment === "recent"
|| pathSegment === "all"
|| pathSegment === "unread"
|| pathSegment === "blocked"
? pathSegment
: "mine";
const canArchiveFromTab = isMineInboxTab(tab);
@@ -824,7 +838,6 @@ export function Inbox() {
queryFn: () => heartbeatsApi.list(selectedCompanyId!, undefined, INBOX_HEARTBEAT_RUN_LIMIT),
enabled: !!selectedCompanyId,
});
const { data: liveRuns } = useQuery({
queryKey: queryKeys.liveRuns(selectedCompanyId!),
queryFn: () => heartbeatsApi.liveRunsForCompany(selectedCompanyId!),
@@ -1902,6 +1915,7 @@ export function Inbox() {
.map((issue) => issue.id);
const canMarkAllRead = unreadIssueIds.length > 0;
const activeIssueFilterCount = countActiveIssueFilters(issueFilters, true);
const showGeneralIssueToolbarControls = tab !== "blocked";
return (
<div className="space-y-6">
<div className="space-y-2">
@@ -1947,6 +1961,7 @@ export function Inbox() {
label: "Recent",
},
{ value: "unread", label: "Unread" },
{ value: "blocked", label: "Blocked" },
{ value: "all", label: "All" },
]}
/>
@@ -1981,112 +1996,203 @@ export function Inbox() {
data-page-search-target="true"
/>
</div>
<Button
type="button"
variant="outline"
size="icon"
className={cn("hidden h-8 w-8 shrink-0 sm:inline-flex", nestingEnabled && "bg-accent")}
onClick={toggleNesting}
title={nestingEnabled ? "Disable parent-child nesting" : "Enable parent-child nesting"}
>
<ListTree className="h-3.5 w-3.5" />
</Button>
<IssueFiltersPopover
state={issueFilters}
onChange={updateIssueFilters}
activeFilterCount={activeIssueFilterCount}
agents={agents}
creators={creatorOptions}
projects={projects?.map((project) => ({ id: project.id, name: project.name }))}
labels={labels?.map((label) => ({ id: label.id, name: label.name, color: label.color }))}
currentUserId={currentUserId}
enableRoutineVisibilityFilter
buttonVariant="outline"
iconOnly
workspaces={isolatedWorkspacesEnabled ? executionWorkspaces.filter((w) => w.mode === "isolated_workspace").map((w) => ({ id: w.id, name: w.name })) : undefined}
/>
<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}
{tab === "blocked" ? (
<>
<IssueFiltersPopover
state={issueFilters}
onChange={updateIssueFilters}
activeFilterCount={activeIssueFilterCount}
agents={agents}
creators={creatorOptions}
projects={projects?.map((project) => ({ id: project.id, name: project.name }))}
labels={labels?.map((label) => ({ id: label.id, name: label.name, color: label.color }))}
currentUserId={currentUserId}
enableRoutineVisibilityFilter
buttonVariant="outline"
iconOnly
workspaces={isolatedWorkspacesEnabled ? executionWorkspaces.filter((w) => w.mode === "isolated_workspace").map((w) => ({ id: w.id, name: w.name })) : undefined}
/>
<Popover>
<PopoverTrigger asChild>
<Button
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)}
variant="outline"
size="icon"
className={cn("h-8 w-8 shrink-0", blockedGroupBy !== "none" && "bg-accent")}
title="Group"
>
<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 && (
<Layers className="h-3.5 w-3.5" />
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-44 p-0">
<div className="space-y-0.5 p-2">
{BLOCKED_GROUP_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",
blockedGroupBy === value ? "bg-accent/50 text-foreground" : "text-muted-foreground hover:bg-accent/50",
)}
onClick={() => setBlockedGroupBy(value)}
>
<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
type="button"
variant="outline"
size="sm"
className="h-8 shrink-0"
onClick={() => setShowMarkAllReadConfirm(true)}
disabled={markAllReadMutation.isPending}
size="icon"
className={cn("hidden h-8 w-8 shrink-0 sm:inline-flex", nestingEnabled && "bg-accent")}
onClick={toggleNesting}
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>
<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>
<IssueFiltersPopover
state={issueFilters}
onChange={updateIssueFilters}
activeFilterCount={activeIssueFilterCount}
agents={agents}
creators={creatorOptions}
projects={projects?.map((project) => ({ id: project.id, name: project.name }))}
labels={labels?.map((label) => ({ id: label.id, name: label.name, color: label.color }))}
currentUserId={currentUserId}
enableRoutineVisibilityFilter
buttonVariant="outline"
iconOnly
workspaces={isolatedWorkspacesEnabled ? executionWorkspaces.filter((w) => w.mode === "isolated_workspace").map((w) => ({ id: w.id, name: w.name })) : undefined}
/>
<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"
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>
@@ -2131,11 +2237,30 @@ export function Inbox() {
{approvalsError && <p className="text-sm text-destructive">{approvalsError.message}</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" />
)}
{allLoaded && visibleSections.length === 0 && (
{tab !== "blocked" && allLoaded && visibleSections.length === 0 && (
<EmptyState
icon={searchQuery.trim() ? Search : InboxIcon}
message={
@@ -2152,7 +2277,7 @@ export function Inbox() {
/>
)}
{showWorkItemsSection && (
{tab !== "blocked" && showWorkItemsSection && (
<>
{showSeparatorBefore("work_items") && <Separator />}
<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 />,
};