From 71b3e304ebf0bf74e098efa782e5c7bd08d7d71f Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 6 Jul 2026 13:33:20 +0000 Subject: [PATCH] feat(recovery): add "Ask CEO to fix" delegated recovery to the recovery card Implements the recovery-card UI integration for PAP-12752, wiring the delegated recovery backend contract into the issue detail experience. - issuesApi.delegateRecoveryAction() calls POST /issues/:id/recovery-actions/delegate. - IssueRecoveryActionCard offers "Ask CEO to fix" (outcome delegate_ceo) first in the Resolve popover, with a Recommended pill. It is backend-authoritative and shown only when wakePolicy.type === "manual_repair_required", the state is not resolved/observe_only, and it has not already been delegated. A persistent "Recovery task" link row surfaces the created recovery issue. - IssueDetail wires delegate_ceo through a mutation: on success it refreshes the source issue (now blocked on the recovery task) and shows a success toast linking to the created task; on failure it surfaces the API error without mutating local recovery state. False-positive actions stay board-only. - Tests cover menu rendering + placement, API invocation, success toast/link, and failure handling. Storybook states show the delegate menu and delegated card. Co-Authored-By: Claude Opus 4.8 Co-Authored-By: Paperclip --- ui/src/api/issues.test.ts | 15 ++ ui/src/api/issues.ts | 9 ++ .../IssueRecoveryActionCard.test.tsx | 76 ++++++++++ ui/src/components/IssueRecoveryActionCard.tsx | 45 ++++++ ui/src/pages/IssueDetail.test.tsx | 131 ++++++++++++++++++ ui/src/pages/IssueDetail.tsx | 40 +++++- .../stories/source-issue-recovery.stories.tsx | 20 ++- 7 files changed, 331 insertions(+), 5 deletions(-) diff --git a/ui/src/api/issues.test.ts b/ui/src/api/issues.test.ts index 83ee4e927a3..46fe50593f9 100644 --- a/ui/src/api/issues.test.ts +++ b/ui/src/api/issues.test.ts @@ -109,4 +109,19 @@ describe("issuesApi.list", () => { }, ); }); + + it("posts delegated recovery to the source issue delegate endpoint", async () => { + await issuesApi.delegateRecoveryAction("issue-1", { + actionId: "00000000-0000-0000-0000-0000000000aa", + target: "ceo", + }); + + expect(mockApi.post).toHaveBeenCalledWith( + "/issues/issue-1/recovery-actions/delegate", + { + actionId: "00000000-0000-0000-0000-0000000000aa", + target: "ceo", + }, + ); + }); }); diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index 73e6798b890..a75034b2877 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -37,6 +37,13 @@ export type ResolveRecoveryActionResponse = { recoveryAction: IssueRecoveryAction; }; +export type DelegateRecoveryActionResponse = { + issue: Issue; + recoveryIssue: Issue; + recoveryAction: IssueRecoveryAction; + reused: boolean; +}; + export type IssueListFilters = { attention?: "blocked"; status?: string; @@ -170,6 +177,8 @@ export const issuesApi = { resolutionNote?: string | null; }, ) => api.post(`/issues/${id}/recovery-actions/resolve`, data), + delegateRecoveryAction: (id: string, data: { actionId: string; target: "ceo" }) => + api.post(`/issues/${id}/recovery-actions/delegate`, data), previewTreeControl: (id: string, data: PreviewIssueTreeControl) => api.post(`/issues/${id}/tree-control/preview`, data), createTreeHold: (id: string, data: CreateIssueTreeHold) => diff --git a/ui/src/components/IssueRecoveryActionCard.test.tsx b/ui/src/components/IssueRecoveryActionCard.test.tsx index 317f0cce500..e375b8b2c0f 100644 --- a/ui/src/components/IssueRecoveryActionCard.test.tsx +++ b/ui/src/components/IssueRecoveryActionCard.test.tsx @@ -296,6 +296,82 @@ describe("IssueRecoveryActionCard", () => { expect(document.body.textContent).not.toContain("Mark blocked"); }); + it("offers 'Ask CEO to fix' first, with a Recommended pill, for manual-repair recovery", () => { + const onResolve = vi.fn(); + const node = render( + , + ); + click(node.querySelector("[data-testid='recovery-action-resolve-trigger']")); + + const delegate = document.body.querySelector("[data-testid='recovery-action-delegate-ceo']"); + expect(delegate?.textContent).toContain("Ask CEO to fix"); + expect(delegate?.textContent).toContain("Recommended"); + expect(delegate?.textContent).toContain("Create a new task for the CEO instead of retrying."); + + // It must render before the standard "Try again" option. + const buttons = [...document.body.querySelectorAll("button")]; + const delegateIdx = buttons.findIndex((b) => b.getAttribute("data-testid") === "recovery-action-delegate-ceo"); + const tryAgainIdx = buttons.findIndex((b) => b.textContent?.includes("Try again")); + expect(delegateIdx).toBeGreaterThanOrEqual(0); + expect(delegateIdx).toBeLessThan(tryAgainIdx); + + click(delegate); + expect(onResolve).toHaveBeenCalledWith("delegate_ceo"); + }); + + it("hides 'Ask CEO to fix' when the wake policy is not manual_repair_required", () => { + const node = render( + {}} + />, + ); + click(node.querySelector("[data-testid='recovery-action-resolve-trigger']")); + expect(document.body.querySelector("[data-testid='recovery-action-delegate-ceo']")).toBeNull(); + expect(document.body.textContent).not.toContain("Ask CEO to fix"); + }); + + it("hides 'Ask CEO to fix' once the action has already been delegated", () => { + const node = render( + {}} + />, + ); + click(node.querySelector("[data-testid='recovery-action-resolve-trigger']")); + expect(document.body.querySelector("[data-testid='recovery-action-delegate-ceo']")).toBeNull(); + }); + + it("renders a persistent recovery-task link when recoveryIssueId is present", () => { + const node = render( + , + ); + expect(node.textContent).toContain("Recovery task"); + const link = [...node.querySelectorAll("a")].find((a) => + a.getAttribute("href") === "/issues/00000000-0000-0000-0000-0000000000cc", + ); + expect(link?.textContent).toContain("View recovery task"); + }); + it("hides false-positive options unless canFalsePositive is set", () => { const first = render( {}} />, diff --git a/ui/src/components/IssueRecoveryActionCard.tsx b/ui/src/components/IssueRecoveryActionCard.tsx index 6ce46fc2a75..61914c4b4eb 100644 --- a/ui/src/components/IssueRecoveryActionCard.tsx +++ b/ui/src/components/IssueRecoveryActionCard.tsx @@ -40,6 +40,7 @@ export type RecoveryCardCardState = RecoveryDisplayState; export const deriveRecoveryCardState = deriveRecoveryDisplayState; export type RecoveryResolveOutcome = + | "delegate_ceo" | "todo" | "done" | "in_review" @@ -897,6 +898,12 @@ const RESOLVE_OPTIONS: Array<{ }, ]; +const DELEGATE_CEO_OPTION = { + outcome: "delegate_ceo" as const, + label: "Ask CEO to fix", + description: "Create a new task for the CEO instead of retrying.", +}; + export function IssueRecoveryActionCard({ action, agentMap, @@ -956,6 +963,12 @@ export function IssueRecoveryActionCard({ if (option.boardOnly && !canFalsePositive) return false; return true; }); + const showDelegateOption = + showResolveActions && + cardState !== "observe_only" && + readEvidenceString(action.wakePolicy?.type) === "manual_repair_required" && + !action.recoveryIssueId && + action.outcome !== "delegated"; const reissueBaseRef = divergence?.reissueBaseRef ?? null; const showReissueAction = onReissueIsolated !== undefined && @@ -1107,6 +1120,17 @@ export function IssueRecoveryActionCard({ ) : null} + {action.recoveryIssueId ? ( + + + View recovery task + → + + + ) : null} {cardState === "resolved" && action.outcome ? ( @@ -1142,6 +1166,27 @@ export function IssueRecoveryActionCard({ Resolve recovery
+ {showDelegateOption ? ( + + ) : null} {visibleResolveOptions.map((option) => (