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}