Files
PaperClipAI/ui/src/components/IssueBlockedNotice.test.tsx
T
610bd160a8 feat(ui): render liveness-aware recovery as a recessed in-progress state
Recovery surfaces classified nearly every active action as `needed`, so a
task showed a prominent amber "Recovery needed" warning even while a
recovery owner was already running it.

Make the display derivation liveness-aware. `deriveRecoveryDisplayState`
now takes optional `RecoveryLivenessSignals` (recovery-owner run, source
live run, queued wake, live continuation) and resolves the five display
states against them:

- a live/queued run owned by the recovery owner is `in_progress`, which
  outranks escalation — the escalation already found an owner
- a newer durable source-owner run folds the action to `observe_only`
  rather than leaving a stale call to action
- prominent amber/red is kept only for `needed` (no owner/path) and
  `escalated` (a decision is genuinely required)

Recessed states render as `RecoveryProgressLine` — an expandable
"Recovery in progress · CTO" row that reveals the existing recovery card
on toggle, so evidence and resolve actions stay inspectable without a
second prominent surface competing for attention.

Applied across the recovery card, issue rows, active-agents panel,
blocker indicators, blocked inbox chips, the workspace recovery surface,
and both the classic and chat-style task threads.

Verified: 349 focused UI tests pass across 16 affected suites, tsc clean,
and `pnpm check:token-gates` reports all three gates clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-08-15 19:19:38 +00:00

763 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @vitest-environment jsdom
import { createRoot } from "react-dom/client";
import { flushSync } from "react-dom";
import type { AnchorHTMLAttributes, ReactElement, ReactNode } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter } from "react-router-dom";
import type { IssueRetryNowOutcome, IssueScheduledRetry } from "@paperclipai/shared";
import { IssueBlockedNotice } from "./IssueBlockedNotice";
import { ToastProvider } from "../context/ToastContext";
const retryNowMock = vi.hoisted(() => vi.fn());
vi.mock("@/lib/router", () => ({
Link: ({ children, to, ...props }: AnchorHTMLAttributes<HTMLAnchorElement> & { to: string }) => (
<a href={to} {...props}>{children}</a>
),
}));
vi.mock("../api/issues", () => ({
issuesApi: {
retryScheduledRetryNow: retryNowMock,
},
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
function act<T>(callback: () => T): T {
let result: T | undefined;
flushSync(() => {
result = callback();
});
const maybePromise = result as unknown as PromiseLike<unknown>;
if (result && typeof maybePromise.then === "function") {
throw new TypeError("This test act shim only supports synchronous callbacks.");
}
return result as T;
}
let root: ReturnType<typeof createRoot> | null = null;
let container: HTMLDivElement | null = null;
let dateNowSpy: ReturnType<typeof vi.spyOn> | null = null;
const SYSTEM_NOW = new Date("2026-04-18T20:00:00.000Z").getTime();
const baseRetry: IssueScheduledRetry = {
runId: "retry-run-1",
status: "scheduled_retry",
agentId: "agent-1",
agentName: "CodexCoder",
retryOfRunId: "source-run-1",
scheduledRetryAt: "2026-04-19T20:00:00.000Z",
scheduledRetryAttempt: 1,
scheduledRetryReason: "max_turns_continuation",
retryExhaustedReason: null,
error: null,
errorCode: null,
};
function buildRetryResponse(outcome: IssueRetryNowOutcome) {
return {
outcome,
message:
outcome === "promoted"
? "Promoted scheduled retry"
: outcome === "already_promoted"
? "Scheduled retry already promoted"
: outcome === "no_scheduled_retry"
? "No scheduled retry"
: "Promotion suppressed by gate",
scheduledRetry:
outcome === "promoted" || outcome === "already_promoted"
? { ...baseRetry, status: "queued" as const }
: null,
};
}
beforeEach(() => {
dateNowSpy = vi.spyOn(Date, "now").mockReturnValue(SYSTEM_NOW);
retryNowMock.mockReset();
});
afterEach(() => {
if (root) {
act(() => root?.unmount());
}
root = null;
container?.remove();
container = null;
dateNowSpy?.mockRestore();
dateNowSpy = null;
});
function withProviders(node: ReactNode) {
const client = new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0, staleTime: 0 },
mutations: { retry: false },
},
});
return (
<MemoryRouter>
<QueryClientProvider client={client}>
<ToastProvider>{node}</ToastProvider>
</QueryClientProvider>
</MemoryRouter>
);
}
function render(element: ReactElement) {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() => root?.render(withProviders(element)));
return container;
}
describe("IssueBlockedNotice", () => {
it("renders a successful-run next-step notice without requiring blockers", () => {
const node = render(
<IssueBlockedNotice
issueStatus="in_progress"
blockers={[]}
agentName="CodexCoder"
successfulRunHandoff={{
state: "required",
required: true,
hasLiveContinuation: false,
sourceRunId: "12345678-aaaa-bbbb-cccc-123456789abc",
correctiveRunId: null,
assigneeAgentId: "agent-1",
detectedProgressSummary: "Updated the plan and left follow-up work.",
createdAt: "2026-05-01T00:00:00.000Z",
}}
/>,
);
expect(node.querySelector('[data-successful-run-handoff="required"]')).not.toBeNull();
expect(node.textContent).toContain("This task still needs a next step.");
expect(node.textContent).toContain(
"A run finished successfully, but the task is still open. Paperclip needs someone to choose what happens next.",
);
expect(node.textContent).toContain("Mark it done or cancelled.");
expect(node.textContent).toContain("Send it for review or ask for input.");
expect(node.textContent).toContain("Record what is blocking it and who owns that blocker.");
expect(node.textContent).toContain("Delegate follow-up work or queue a continuation.");
expect(node.textContent).toContain("Asked CodexCoder to choose the next step");
expect(node.textContent).toContain("Detected progress: Updated the plan and left follow-up work.");
expect(node.querySelector('[data-testid="issue-next-step-retry-now"]')).toBeNull();
});
it("shows retry-now action for next-step notices with a scheduled retry", async () => {
retryNowMock.mockResolvedValue(buildRetryResponse("promoted"));
const node = render(
<IssueBlockedNotice
issueId="issue-1"
issueStatus="in_progress"
blockers={[]}
agentName="CodexCoder"
scheduledRetry={baseRetry}
successfulRunHandoff={{
state: "required",
required: true,
hasLiveContinuation: false,
sourceRunId: "12345678-aaaa-bbbb-cccc-123456789abc",
correctiveRunId: null,
assigneeAgentId: "agent-1",
detectedProgressSummary: null,
createdAt: "2026-05-01T00:00:00.000Z",
}}
/>,
);
const button = node.querySelector<HTMLButtonElement>('[data-testid="issue-next-step-retry-now"]');
expect(button).not.toBeNull();
expect(node.textContent).toContain("Retry now starts that follow-up immediately.");
act(() => {
button!.click();
});
await vi.waitFor(() => {
expect(retryNowMock).toHaveBeenCalledWith("issue-1");
expect(button!.disabled).toBe(true);
});
});
it("hides the next-step notice while a live continuation is running the issue", () => {
const node = render(
<IssueBlockedNotice
issueStatus="in_progress"
blockers={[]}
agentName="CodexCoder"
successfulRunHandoff={{
state: "required",
required: true,
hasLiveContinuation: true,
liveRunId: "87654321-dddd-eeee-ffff-123456789abc",
sourceRunId: "12345678-aaaa-bbbb-cccc-123456789abc",
correctiveRunId: null,
assigneeAgentId: "agent-1",
detectedProgressSummary: "Updated the plan and left follow-up work.",
createdAt: "2026-05-01T00:00:00.000Z",
}}
/>,
);
expect(node.querySelector('[data-successful-run-handoff="required"]')).toBeNull();
expect(node.textContent).toBe("");
});
it("hides the next-step notice when the live-run set includes this issue", () => {
const node = render(
<IssueBlockedNotice
issueId="issue-1"
issueStatus="in_progress"
blockers={[]}
liveIssueIds={new Set(["issue-1"])}
agentName="CodexCoder"
successfulRunHandoff={{
state: "required",
required: true,
hasLiveContinuation: false,
sourceRunId: "12345678-aaaa-bbbb-cccc-123456789abc",
correctiveRunId: null,
assigneeAgentId: "agent-1",
detectedProgressSummary: null,
createdAt: "2026-05-01T00:00:00.000Z",
}}
/>,
);
expect(node.querySelector('[data-successful-run-handoff="required"]')).toBeNull();
expect(node.textContent).toBe("");
});
it("keeps the next-step notice and retry-now when the only continuation is an unpromoted scheduled retry", () => {
const node = render(
<IssueBlockedNotice
issueId="issue-1"
issueStatus="in_progress"
blockers={[]}
agentName="CodexCoder"
scheduledRetry={baseRetry}
successfulRunHandoff={{
state: "required",
required: true,
hasLiveContinuation: true,
sourceRunId: "12345678-aaaa-bbbb-cccc-123456789abc",
correctiveRunId: null,
assigneeAgentId: "agent-1",
detectedProgressSummary: null,
createdAt: "2026-05-01T00:00:00.000Z",
}}
/>,
);
expect(node.querySelector('[data-successful-run-handoff="required"]')).not.toBeNull();
expect(node.querySelector('[data-testid="issue-next-step-retry-now"]')).not.toBeNull();
});
it("does not render when the issue is done even if a stale handoff state is required", () => {
const node = render(
<IssueBlockedNotice
issueStatus="done"
blockers={[]}
agentName="CodexCoder"
successfulRunHandoff={{
state: "required",
required: true,
hasLiveContinuation: false,
sourceRunId: "12345678-aaaa-bbbb-cccc-123456789abc",
correctiveRunId: null,
assigneeAgentId: "agent-1",
detectedProgressSummary: "Updated the plan and left follow-up work.",
createdAt: "2026-05-01T00:00:00.000Z",
}}
/>,
);
expect(node.textContent).toBe("");
});
it("does not render when the issue is cancelled even if blockers remain", () => {
const node = render(
<IssueBlockedNotice
issueStatus="cancelled"
blockers={[
{
id: "blocker-1",
identifier: "PAP-123",
title: "Blocker",
status: "in_progress",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
},
]}
/>,
);
expect(node.textContent).toBe("");
});
it("keeps the amber notice when a covered chain has no confirmed live blocker", () => {
const node = render(
<IssueBlockedNotice
issueStatus="blocked"
liveIssueIds={new Set(["unrelated-live"])}
blockerAttention={{
state: "covered",
reason: "active_dependency",
unresolvedBlockerCount: 1,
coveredBlockerCount: 1,
stalledBlockerCount: 0,
attentionBlockerCount: 0,
sampleBlockerIdentifier: "TASK-1",
sampleStalledBlockerIdentifier: null,
}}
blockers={[
{
id: "blocker-1",
identifier: "TASK-1",
title: "Dependency work",
status: "in_progress",
priority: "medium",
assigneeAgentId: "agent-1",
assigneeUserId: null,
},
]}
allBlockers={[
{
id: "blocker-1",
identifier: "TASK-1",
title: "Dependency work",
status: "in_progress",
priority: "medium",
assigneeAgentId: "agent-1",
assigneeUserId: null,
},
]}
/>,
);
expect(node.querySelector('[data-testid="issue-blocked-notice-live"]')).toBeNull();
// Rule C: a `blocked` issue with an unresolved blocker suppresses
// comment-driven reopening.
expect(node.querySelector('[data-blocker-attention-state="covered"]')).not.toBeNull();
expect(node.textContent).toContain("A message won’t restart this task yet");
});
it("sorts same-status live-work steps with numeric identifier ordering", () => {
const node = render(
<IssueBlockedNotice
issueStatus="blocked"
liveIssueIds={new Set(["blocker-11"])}
blockerAttention={{
state: "covered",
reason: "active_dependency",
unresolvedBlockerCount: 1,
coveredBlockerCount: 3,
stalledBlockerCount: 0,
attentionBlockerCount: 0,
sampleBlockerIdentifier: "TASK-11",
sampleStalledBlockerIdentifier: null,
}}
blockers={[
{
id: "blocker-11",
identifier: "TASK-11",
title: "Running work",
status: "in_progress",
priority: "medium",
assigneeAgentId: "agent-1",
assigneeUserId: null,
},
]}
allBlockers={[
{
id: "blocker-10",
identifier: "TASK-10",
title: "Tenth done step",
status: "done",
priority: "medium",
assigneeAgentId: "agent-1",
assigneeUserId: null,
},
{
id: "blocker-9",
identifier: "TASK-9",
title: "Ninth done step",
status: "done",
priority: "medium",
assigneeAgentId: "agent-1",
assigneeUserId: null,
},
{
id: "blocker-11",
identifier: "TASK-11",
title: "Running work",
status: "in_progress",
priority: "medium",
assigneeAgentId: "agent-1",
assigneeUserId: null,
},
]}
/>,
);
expect(node.textContent).toContain("Waiting on live work");
expect(node.textContent).toContain(
"This task resumes automatically when the chain is done.",
);
const stepLinks = Array.from(
node.querySelectorAll('[data-testid="issue-blocked-notice-steps"] a'),
).map((link) => link.textContent ?? "");
expect(stepLinks[0]).toContain("TASK-9");
expect(stepLinks[1]).toContain("TASK-10");
expect(stepLinks[2]).toContain("TASK-11");
const runningStep = node.querySelectorAll('[data-testid="issue-blocked-notice-steps"] a')[2];
if (!runningStep) throw new Error("Expected a running live-work step.");
expect(runningStep.querySelector('svg[aria-label="In Progress status"]')).not.toBeNull();
expect(node.querySelector('[data-testid="issue-blocked-notice-now-running"]')).toBeNull();
});
it("explains a human message won't reopen a blocked issue and names the unresolved leaf (Rule C)", () => {
const node = render(
<IssueBlockedNotice
issueStatus="blocked"
agentName="CodexCoder"
blockers={[
{
id: "blocker-1",
identifier: "PAP-500",
title: "Server work in flight",
status: "in_progress",
priority: "medium",
assigneeAgentId: "agent-1",
assigneeUserId: null,
},
]}
/>,
);
expect(node.textContent).toContain("A message won’t restart this task yet");
expect(node.textContent).toContain("Comments still notify CodexCoder for questions or triage");
const suppressed = node.querySelector('[data-testid="issue-blocked-notice-reopen-suppressed"]');
expect(suppressed).not.toBeNull();
expect(suppressed!.textContent).toContain("Still blocked by");
expect(suppressed!.textContent).toContain("PAP-500");
expect(suppressed!.textContent).toContain("(in progress)");
});
it("names the deepest unresolved terminal leaf, not the direct blocker (Rule C)", () => {
const node = render(
<IssueBlockedNotice
issueStatus="blocked"
blockers={[
{
id: "blocker-1",
identifier: "PAP-600",
title: "Waiting in review",
status: "in_review",
priority: "medium",
assigneeAgentId: "agent-1",
assigneeUserId: null,
terminalBlockers: [
{
id: "terminal-1",
identifier: "PAP-777",
title: "Actual work",
status: "in_progress",
priority: "medium",
assigneeAgentId: "agent-2",
assigneeUserId: null,
},
],
},
]}
/>,
);
const suppressed = node.querySelector('[data-testid="issue-blocked-notice-reopen-suppressed"]');
expect(suppressed).not.toBeNull();
expect(suppressed!.textContent).toContain("PAP-777");
expect(suppressed!.textContent).not.toContain("PAP-600");
});
it("names one leaf blocker when several keep a comment from reopening (Rule C)", () => {
const node = render(
<IssueBlockedNotice
issueStatus="blocked"
blockers={[
{
id: "blocker-1",
identifier: "PAP-501",
title: "First",
status: "in_progress",
priority: "medium",
assigneeAgentId: "agent-1",
assigneeUserId: null,
},
{
id: "blocker-2",
identifier: "PAP-502",
title: "Second",
status: "todo",
priority: "medium",
assigneeAgentId: "agent-1",
assigneeUserId: null,
},
]}
/>,
);
const suppressed = node.querySelector('[data-testid="issue-blocked-notice-reopen-suppressed"]');
expect(suppressed).not.toBeNull();
expect(suppressed!.textContent).toContain("PAP-501");
expect(suppressed!.textContent).toContain("and 1 other task");
expect(suppressed!.textContent).not.toContain("PAP-502");
});
it("does not suppress reopening when a blocked issue has no unresolved blockers (Rule B path)", () => {
const node = render(<IssueBlockedNotice issueStatus="blocked" blockers={[]} />);
expect(node.textContent).not.toBe("");
expect(node.querySelector('[data-testid="issue-blocked-notice-reopen-suppressed"]')).toBeNull();
});
it("shows external now-running blockers beneath the label on a separate line", () => {
const node = render(
<IssueBlockedNotice
issueStatus="blocked"
liveIssueIds={new Set(["terminal-live"])}
blockerAttention={{
state: "covered",
reason: "active_dependency",
unresolvedBlockerCount: 1,
coveredBlockerCount: 1,
stalledBlockerCount: 0,
attentionBlockerCount: 0,
sampleBlockerIdentifier: "TASK-99",
sampleStalledBlockerIdentifier: null,
}}
blockers={[
{
id: "blocker-1",
identifier: "TASK-1",
title: "Queued dependency",
status: "todo",
priority: "medium",
assigneeAgentId: "agent-1",
assigneeUserId: null,
terminalBlockers: [
{
id: "terminal-live",
identifier: "TASK-99",
title: "External running task",
status: "in_progress",
priority: "medium",
assigneeAgentId: "agent-1",
assigneeUserId: null,
},
],
},
]}
allBlockers={[
{
id: "blocker-1",
identifier: "TASK-1",
title: "Queued dependency",
status: "todo",
priority: "medium",
assigneeAgentId: "agent-1",
assigneeUserId: null,
},
]}
/>,
);
const nowRunning = node.querySelector('[data-testid="issue-blocked-notice-now-running"]');
expect(nowRunning).not.toBeNull();
expect(nowRunning!.children[0]?.textContent?.trim()).toBe("Now running");
expect(nowRunning!.children[1]?.querySelector("a")?.textContent).toContain("TASK-99");
const stepText = node.querySelector('[data-testid="issue-blocked-notice-steps"]')?.textContent;
expect(stepText).not.toContain("TASK-99");
});
it("renders a recovery indicator on a blocker chip when the blocker has an active recovery action", () => {
const node = render(
<IssueBlockedNotice
issueStatus="blocked"
blockers={[
{
id: "blocker-1",
identifier: "PAP-123",
title: "Build still red",
status: "in_progress",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
activeRecoveryAction: {
id: "rec-1",
companyId: "co-1",
sourceIssueId: "blocker-1",
recoveryIssueId: null,
kind: "missing_disposition",
status: "active",
ownerType: "agent",
ownerAgentId: "agent-cto",
ownerUserId: null,
previousOwnerAgentId: null,
returnOwnerAgentId: null,
cause: "successful_run_missing_state",
fingerprint: "fp-1",
evidence: {},
nextAction: "choose disposition",
wakePolicy: { type: "wake_owner" },
monitorPolicy: null,
attemptCount: 1,
maxAttempts: 3,
timeoutAt: null,
lastAttemptAt: null,
outcome: null,
resolutionNote: null,
resolvedAt: null,
createdAt: "2026-05-01T00:00:00.000Z",
updatedAt: "2026-05-01T00:00:00.000Z",
},
},
]}
/>,
);
const indicator = node.querySelector(
'[data-testid="issue-blocked-notice-recovery-indicator"]',
);
expect(indicator).not.toBeNull();
expect(indicator?.getAttribute("data-recovery-state")).toBe("needed");
expect(indicator?.getAttribute("data-recovery-kind")).toBe("missing_disposition");
expect(indicator?.textContent).toContain("Recovery needed");
});
it("recesses the blocker recovery indicator while the blocker itself is live", () => {
const node = render(
<IssueBlockedNotice
issueStatus="blocked"
liveIssueIds={new Set(["blocker-1"])}
blockers={[
{
id: "blocker-1",
identifier: "PAP-123",
title: "Build still red",
status: "in_progress",
priority: "medium",
assigneeAgentId: "agent-cto",
assigneeUserId: null,
activeRecoveryAction: {
id: "rec-1",
companyId: "co-1",
sourceIssueId: "blocker-1",
recoveryIssueId: null,
kind: "missing_disposition",
status: "active",
ownerType: "agent",
ownerAgentId: "agent-cto",
ownerUserId: null,
previousOwnerAgentId: null,
returnOwnerAgentId: null,
cause: "successful_run_missing_state",
fingerprint: "fp-1",
evidence: {},
nextAction: "choose disposition",
wakePolicy: { type: "wake_owner" },
monitorPolicy: null,
attemptCount: 1,
maxAttempts: 3,
timeoutAt: null,
lastAttemptAt: null,
outcome: null,
resolutionNote: null,
resolvedAt: null,
createdAt: "2026-05-01T00:00:00.000Z",
updatedAt: "2026-05-01T00:00:00.000Z",
},
},
]}
/>,
);
const indicator = node.querySelector(
'[data-testid="issue-blocked-notice-recovery-indicator"]',
);
expect(indicator?.getAttribute("data-recovery-state")).toBe("in_progress");
expect(indicator?.textContent).toContain("Recovery in progress");
expect(indicator?.textContent).not.toContain("Recovery needed");
expect(indicator?.className).not.toMatch(/amber|red/);
});
it("labels a workspace_validation blocker recovery distinctly", () => {
const node = render(
<IssueBlockedNotice
issueStatus="blocked"
blockers={[
{
id: "blocker-2",
identifier: "PAP-409",
title: "Workspace cwd lost git context",
status: "blocked",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
activeRecoveryAction: {
id: "rec-2",
companyId: "co-1",
sourceIssueId: "blocker-2",
recoveryIssueId: null,
kind: "workspace_validation",
status: "active",
ownerType: "agent",
ownerAgentId: "agent-cto",
ownerUserId: null,
previousOwnerAgentId: null,
returnOwnerAgentId: null,
cause: "workspace_validation_failed",
fingerprint: "fp-2",
evidence: {
latestRunErrorCode: "workspace_validation_failed",
},
nextAction:
"Repair the source issue workspace link, project workspace cwd, or git checkout before resuming adapter execution.",
wakePolicy: { type: "wake_owner" },
monitorPolicy: null,
attemptCount: 1,
maxAttempts: 3,
timeoutAt: null,
lastAttemptAt: null,
outcome: null,
resolutionNote: null,
resolvedAt: null,
createdAt: "2026-05-01T00:00:00.000Z",
updatedAt: "2026-05-01T00:00:00.000Z",
},
},
]}
/>,
);
const indicator = node.querySelector(
'[data-testid="issue-blocked-notice-recovery-indicator"]',
);
expect(indicator).not.toBeNull();
expect(indicator?.getAttribute("data-recovery-state")).toBe("needed");
expect(indicator?.getAttribute("data-recovery-kind")).toBe("workspace_validation");
expect(indicator?.textContent).toContain("Workspace recovery needed");
});
});