Files
paperclip/ui/src/components/IssueBlockedNotice.test.tsx
T
Dotta 2e74d32871 PAP-10430: split Issue-to-Task copy migration (#7651)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The board UI is the operator surface where users create, assign,
monitor, and review work items.
> - The product language is moving toward "tasks" for user-facing work
items while the internal API and database still use "issues".
> - PR #7543 bundled this copy migration with broader
information-architecture work, which made the branch too large for
Greptile review.
> - This pull request peels the Issue-to-Task copy migration into a
smaller, independently reviewable change.
> - The benefit is clearer user-facing terminology, less agent confusion
via the Paperclip skill note, and a smaller PR that Greptile can review.

## Linked Issues or Issue Description

Refs #7645
Refs #7543
Refs PAP-10430

This PR was split out of #7543 so the Issue-to-Task copy migration can
be reviewed separately and the remaining IA PR can fall under Greptile's
file limit.

## What Changed

- Preserves Scott Tong's original `PAP-57` copy-only commit, with author
and co-author credit intact, to rename user-facing "Issues" copy to
"Tasks" across the UI while keeping routes/API/internal symbols as
`issue`.
- Updates onboarding and release-smoke browser selectors from `Create &
Open Issue` to `Create & Open Task`.
- Adds a terminology note to `skills/paperclip/SKILL.md` clarifying that
task and issue refer to the same Paperclip work item.
- Resolves the only cherry-pick conflict by keeping current search
artifacts support and changing visible search copy to "tasks".

## Verification

- `pnpm --filter @paperclipai/ui build` passed.
- `NODE_ENV=test pnpm exec vitest run
ui/src/components/IssuesList.test.tsx ui/src/components/Sidebar.test.tsx
ui/src/components/NewIssueDialog.test.tsx
ui/src/pages/IssueDetail.test.tsx` passed: 4 files, 66 tests.
- `git diff --check origin/master...HEAD` passed.
- Diff is 80 files, below Greptile's 100-file limit.
- Before/after UI copy examples: "Issues" -> "Tasks", "New Issue" ->
"New Task", "Create & Open Issue" -> "Create & Open Task".

## Risks

- Medium copy-risk: this intentionally changes user-facing terminology
broadly while keeping internal issue identifiers and routes unchanged.
- Some docs and APIs still say `issue`; the skill note clarifies this so
agents do not treat task and issue as separate entities.
- Browser-level visual validation is expected from CI because this local
container is missing usable browser dependencies.

> 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

Scott Tong authored the original `PAP-57` copy migration, assisted by
Claude Opus 4.8 and Paperclip agents per the preserved commit metadata.
Codex / GPT-5-class coding agent with shell, GitHub CLI, and repository
access performed the PR split, conflict resolution, skill note, and
verification.

## 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 run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] 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] 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: scotttong <scott.tong@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-06-06 10:10:01 -05:00

339 lines
11 KiB
TypeScript

// @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,
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).toContain("This task still needs a next step.");
expect(node.textContent).toContain("Corrective wake queued for CodexCoder");
expect(node.textContent).toContain("Detected progress: Updated the plan");
expect(node.textContent).not.toContain("Retry now");
expect(node.textContent).not.toContain("Work on this task is blocked until");
expect(node.querySelector('[data-successful-run-handoff="required"]')).not.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,
sourceRunId: "12345678-aaaa-bbbb-cccc-123456789abc",
correctiveRunId: null,
assigneeAgentId: "agent-1",
detectedProgressSummary: null,
createdAt: "2026-05-01T00:00:00.000Z",
}}
/>,
);
expect(node.textContent).toContain("Corrective wake scheduled in 1d");
const button = node.querySelector<HTMLButtonElement>('[data-testid="issue-next-step-retry-now"]');
expect(button).not.toBeNull();
expect(button!.textContent ?? "").toContain("Retry now");
act(() => {
button!.click();
});
await vi.waitFor(() => {
expect(retryNowMock).toHaveBeenCalledWith("issue-1");
expect(button!.textContent ?? "").toContain("Promoted");
expect(button!.disabled).toBe(true);
});
});
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,
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("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("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");
});
});