From f640d166074c94f988f344d02da72b933c4027bb Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 18 May 2026 13:52:38 -0500 Subject: [PATCH] Add retry-now action to next-step notice Co-Authored-By: Paperclip --- ui/src/components/IssueBlockedNotice.test.tsx | 104 +++++++++++++++++- ui/src/components/IssueBlockedNotice.tsx | 87 ++++++++++++++- ui/src/components/IssueChatThread.tsx | 7 ++ ui/src/pages/IssueDetail.tsx | 5 + 4 files changed, 199 insertions(+), 4 deletions(-) diff --git a/ui/src/components/IssueBlockedNotice.test.tsx b/ui/src/components/IssueBlockedNotice.test.tsx index 4dad91b1a89..27f93a0b8ed 100644 --- a/ui/src/components/IssueBlockedNotice.test.tsx +++ b/ui/src/components/IssueBlockedNotice.test.tsx @@ -3,10 +3,14 @@ import { act } from "react"; import { createRoot } from "react-dom/client"; import type { AnchorHTMLAttributes, ReactElement, ReactNode } from "react"; -import { afterEach, describe, expect, it, vi } from "vitest"; +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 & { to: string }) => ( @@ -14,11 +18,57 @@ vi.mock("@/lib/router", () => ({ ), })); +vi.mock("../api/issues", () => ({ + issuesApi: { + retryScheduledRetryNow: retryNowMock, + }, +})); + // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; let root: ReturnType | null = null; let container: HTMLDivElement | null = null; +let dateNowSpy: ReturnType | 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) { @@ -27,13 +77,22 @@ afterEach(() => { 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 } } }); + const client = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0, staleTime: 0 }, + mutations: { retry: false }, + }, + }); return ( - {node} + + {node} + ); } @@ -68,10 +127,49 @@ describe("IssueBlockedNotice", () => { expect(node.textContent).toContain("This issue 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 issue 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( + , + ); + + expect(node.textContent).toContain("Corrective wake scheduled in 1d"); + const button = node.querySelector('[data-testid="issue-next-step-retry-now"]'); + expect(button).not.toBeNull(); + expect(button!.textContent ?? "").toContain("Retry now"); + + await act(async () => { + button!.click(); + await Promise.resolve(); + }); + + 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( +
+
+ Corrective wake {scheduleLabel}. Retry now starts the same recovery path immediately. +
+ +
+ { + retryNow.reset(); + retryNow.mutate(); + }} + /> + + ); +} + export function IssueBlockedNotice({ + issueId, issueStatus, blockers, blockerAttention, successfulRunHandoff, + scheduledRetry, agentName, }: { + issueId?: string | null; issueStatus?: string; blockers: IssueRelationIssueSummary[]; blockerAttention?: IssueBlockerAttention | null; successfulRunHandoff?: SuccessfulRunHandoffState | null; + scheduledRetry?: IssueScheduledRetry | null; agentName?: string | null; }) { if (issueStatus === "done" || issueStatus === "cancelled") return null; const showSuccessfulRunHandoff = successfulRunHandoff?.required === true; if (!showSuccessfulRunHandoff && blockers.length === 0 && issueStatus !== "blocked") return null; + const successfulRunRetryNow = showSuccessfulRunHandoff + && issueId + && scheduledRetry?.status === "scheduled_retry" + ? { issueId, scheduledRetry } + : null; const blockerLabel = blockers.length === 1 ? "the linked issue" : "the linked issues"; const terminalBlockers = blockers @@ -162,6 +241,12 @@ export function IssueBlockedNotice({ Detected progress: {successfulRunHandoff.detectedProgressSummary}

) : null} + {successfulRunRetryNow ? ( + + ) : null} ) : null} {showSuccessfulRunHandoff && (blockers.length > 0 || issueStatus === "blocked") ? ( diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index b25eb39fd40..a08c58e57aa 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -38,6 +38,7 @@ import type { IssueBlockerAttention, IssueRecoveryAction, IssueRelationIssueSummary, + IssueScheduledRetry, SuccessfulRunHandoffState, IssueWorkMode, } from "@paperclipai/shared"; @@ -296,9 +297,11 @@ interface IssueChatThreadProps { timelineEvents?: IssueTimelineEvent[]; liveRuns?: LiveRunForIssue[]; activeRun?: ActiveRunForIssue | null; + issueId?: string | null; blockedBy?: IssueRelationIssueSummary[]; blockerAttention?: IssueBlockerAttention | null; successfulRunHandoff?: SuccessfulRunHandoffState | null; + scheduledRetry?: IssueScheduledRetry | null; recoveryAction?: IssueRecoveryAction | null; onResolveRecoveryAction?: (outcome: RecoveryResolveOutcome) => void; canFalsePositiveRecoveryAction?: boolean; @@ -3617,9 +3620,11 @@ export function IssueChatThread({ timelineEvents = [], liveRuns = [], activeRun = null, + issueId = null, blockedBy = [], blockerAttention = null, successfulRunHandoff = null, + scheduledRetry = null, recoveryAction = null, onResolveRecoveryAction, canFalsePositiveRecoveryAction = false, @@ -4299,10 +4304,12 @@ export function IssueChatThread({ /> ) : null} void; canFalsePositiveRecoveryAction?: boolean; @@ -689,6 +690,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ blockedBy, blockerAttention, successfulRunHandoff, + scheduledRetry, recoveryAction, onResolveRecoveryAction, canFalsePositiveRecoveryAction, @@ -897,9 +899,11 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ timelineEvents={timelineEvents} liveRuns={resolvedLiveRuns} activeRun={resolvedActiveRun} + issueId={issueId} blockedBy={blockedBy ?? []} blockerAttention={blockerAttention} successfulRunHandoff={successfulRunHandoff} + scheduledRetry={scheduledRetry} recoveryAction={recoveryAction ?? null} onResolveRecoveryAction={onResolveRecoveryAction} canFalsePositiveRecoveryAction={canFalsePositiveRecoveryAction} @@ -3914,6 +3918,7 @@ export function IssueDetail() { blockedBy={issue.blockedBy ?? []} blockerAttention={issue.blockerAttention ?? null} successfulRunHandoff={issue.successfulRunHandoff ?? null} + scheduledRetry={issue.scheduledRetry ?? null} recoveryAction={issue.activeRecoveryAction ?? null} onResolveRecoveryAction={handleResolveRecoveryAction} canFalsePositiveRecoveryAction={canResolveBoardRecoveryAction}