Files
PaperClipAI/ui/src/components/BlockedInboxView.test.tsx
T
DottaandPaperclip bec04da2c2 fix(ui): make task header status and priority controls interactive (#10526)
<!-- Write all pull request text in Simplified Technical English
(ASD-STE100): short sentences, one instruction per sentence, simple
approved vocabulary, and the active voice. -->

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Operators use the task page header to read and change task state
> - The status and priority icons already had picker logic, but the
compact triggers were not semantic controls
> - This made pointer and keyboard interaction unreliable in the task
header
> - This pull request makes both compact icon triggers real buttons and
keeps the existing picker behavior
> - The benefit is that operators can change status and priority
directly from the header with pointer or keyboard input

## Linked Issues or Issue Description

### Subsystem affected

`ui/` — React + Vite board UI.

### Problem or motivation

The compact status and priority icons can receive change handlers, but
their popover triggers are plain icon elements. They do not provide a
reliable click target, keyboard focus, or control label. Operators need
to change both values directly from the task header.

### Proposed solution

Use semantic button triggers in the shared status and priority
components. Keep the existing API update wiring and picker options. Keep
task-row navigation links separate from editable row controls. An
operator can select either icon, open its picker, and choose a new task
status or priority.

### Alternatives considered

A task-page-only wrapper would duplicate control behavior. Moving the
controls would also change the page layout. The shared components
already own the picker behavior, so a shared trigger fix is smaller and
more consistent.

### Roadmap alignment

This is a focused board UI usability and accessibility fix. It does not
duplicate a planned roadmap feature.

### Additional context

The task page already passes change handlers to these shared components.
This change makes that existing path interactive and accessible.

## What Changed

- Added semantic button triggers for compact and labeled status
controls.
- Added semantic button triggers for compact and labeled priority
controls.
- Added accessible current-state labels and keyboard focus styles.
- Separated issue-row navigation links from row controls to avoid nested
interactive elements.
- Added real popover interaction coverage and row semantics regressions.
- Added task-page regression tests for update requests.

## Verification

- Focused task-header and row suites passed with 131 tests.
- The final row, inbox, and picker regression suites passed with 83
tests.
- `pnpm --filter @paperclipai/ui exec tsc -b --force` passed.
- `pnpm run typecheck:build-gaps` reproduced the CI typecheck before the
fix. The forced UI build passed after the fix.
- `pnpm check:token-gates` passed.
- `pnpm -r typecheck` passed.
- `pnpm build` passed.
- `pnpm test:run` completed the server and UI partitions. One unrelated
CLI AWS doctor assertion saw injected static AWS credentials and
returned `warn` instead of `pass`. The exact test passed after those two
environment variables were removed.
- `git diff --check origin/master...HEAD` passed.
- All latest-head GitHub checks passed, including both e2e shards.
- Greptile passed at the required threshold with zero open review
threads.

## Risks

- The shared issue-row DOM now uses a full-area navigation link beside
native action buttons.
- Existing visual layout, pointer navigation, keyboard navigation, and
action behavior remain covered by row, inbox, and list tests.
- Read-only status and priority icon uses are unchanged.

> 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 with the `gpt-5` model. The runtime did not expose its
context window size. The agent used reasoning, repository tools, code
execution, and GitHub CLI integration.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` /
`Closes: #` / `Refs: #` OR (b) described the issue in-PR following the
relevant issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change and contains no internal
Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-07-31 10:49:35 -07:00

368 lines
11 KiB
TypeScript

// @vitest-environment jsdom
import { flushSync } from "react-dom";
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;
function act(callback: () => void | Promise<void>) {
let result: void | Promise<void> | undefined;
flushSync(() => {
result = callback();
});
return result;
}
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>(),
subtreeLiveCounts: new Map<string, number>(),
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")?.parentElement?.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("uses loaded live descendants when blocked inbox rows do not have a server summary", async () => {
mockIssuesApi.list.mockResolvedValue([
{
...makeIssue(
"blocked-parent",
"PAP-77",
"Blocked parent with active child",
attention({ reason: "blocked_chain_stalled" }),
),
status: "blocked",
blockerAttention: null,
liveDescendantCount: undefined,
} as unknown as Issue,
]);
const { root } = renderWithClient(
<BlockedInboxView
{...blockedViewProps}
subtreeLiveCounts={new Map([["blocked-parent", 1]])}
/>,
container,
);
await waitFor(() => container.querySelector("a") !== null);
expect(container.querySelector('[aria-label="Blocked · waiting on 1 active sub-task"]')).not.toBeNull();
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());
});
});