From f54aba63fc5c82f4feb7edf53420c73bfe989714 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 9 May 2026 15:05:03 -0500 Subject: [PATCH] Surface source-scoped recovery actions in the UI Add the IssueRecoveryActionCard so source issues render their active recovery state (cause, owner, return owner, runs, evidence, next action, wake/monitor, attempts, timeout, resolution) in the chat thread slot the successful-run handoff bullet list previously occupied. Surface a matching Recovery chip on inbox rows, render an info banner on legacy recovery issues that links to their source, and add the issue.recovery_action_opened/_resolved/_escalated activity verbs. Co-Authored-By: Paperclip --- scripts/screenshot-recovery-card.cjs | 77 +++ ui/src/components/IssueChatThread.tsx | 54 +- .../IssueRecoveryActionCard.test.tsx | 166 ++++++ ui/src/components/IssueRecoveryActionCard.tsx | 552 ++++++++++++++++++ ui/src/components/IssueRow.tsx | 68 ++- ui/src/lib/activity-format.ts | 6 + ui/src/pages/IssueDetail.tsx | 58 ++ .../stories/source-issue-recovery.stories.tsx | 201 +++++++ 8 files changed, 1179 insertions(+), 3 deletions(-) create mode 100644 scripts/screenshot-recovery-card.cjs create mode 100644 ui/src/components/IssueRecoveryActionCard.test.tsx create mode 100644 ui/src/components/IssueRecoveryActionCard.tsx create mode 100644 ui/storybook/stories/source-issue-recovery.stories.tsx diff --git a/scripts/screenshot-recovery-card.cjs b/scripts/screenshot-recovery-card.cjs new file mode 100644 index 00000000000..acc875c4334 --- /dev/null +++ b/scripts/screenshot-recovery-card.cjs @@ -0,0 +1,77 @@ +#!/usr/bin/env node +/* eslint-disable no-console */ +const path = require("path"); +const fs = require("fs"); + +const repoRoot = path.resolve(__dirname, ".."); +const playwright = require(path.join( + repoRoot, + "node_modules/.pnpm/playwright@1.58.2/node_modules/playwright/index.js", +)); + +const STORYBOOK_BASE = process.env.STORYBOOK_URL ?? "http://localhost:6006"; +const OUT_DIR = path.resolve(repoRoot, "tmp/pap-9134-recovery-screens"); + +const VIEWPORTS = [ + { name: "desktop", width: 1440, height: 900 }, + { name: "mobile", width: 390, height: 844 }, +]; + +const STORIES = [ + { + id: "paperclip-source-issue-recovery--recovery-action-card-states", + label: "card-states", + }, + { + id: "paperclip-source-issue-recovery--inbox-row-chips", + label: "inbox-rows", + }, +]; + +const THEMES = [ + { name: "light", apply: () => document.documentElement.classList.remove("dark") }, + { name: "dark", apply: () => document.documentElement.classList.add("dark") }, +]; + +(async () => { + fs.mkdirSync(OUT_DIR, { recursive: true }); + const browser = await playwright.chromium.launch(); + try { + for (const viewport of VIEWPORTS) { + const context = await browser.newContext({ + viewport: { width: viewport.width, height: viewport.height }, + deviceScaleFactor: 2, + }); + for (const story of STORIES) { + for (const theme of THEMES) { + const page = await context.newPage(); + const url = `${STORYBOOK_BASE}/iframe.html?id=${story.id}&viewMode=story`; + await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30_000 }); + await page.waitForLoadState("networkidle", { timeout: 30_000 }).catch(() => {}); + await page.evaluate((darkTheme) => { + const html = document.documentElement; + if (darkTheme) { + html.classList.add("dark"); + } else { + html.classList.remove("dark"); + } + }, theme.name === "dark"); + await page.waitForTimeout(400); + const outPath = path.join( + OUT_DIR, + `${story.label}_${viewport.name}_${theme.name}.png`, + ); + await page.screenshot({ path: outPath, fullPage: true }); + console.log(`Saved ${outPath}`); + await page.close(); + } + } + await context.close(); + } + } finally { + await browser.close(); + } +})().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index 36cf2eb742e..180e879edcc 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -36,6 +36,7 @@ import type { FeedbackVoteValue, IssueAttachment, IssueBlockerAttention, + IssueRecoveryAction, IssueRelationIssueSummary, SuccessfulRunHandoffState, IssueWorkMode, @@ -134,6 +135,7 @@ import { Textarea } from "@/components/ui/textarea"; import { AlertTriangle, ArrowRight, Brain, Check, ChevronDown, ClipboardList, Copy, Hammer, Loader2, MoreHorizontal, Paperclip, PauseCircle, Search, Square, ThumbsDown, ThumbsUp } from "lucide-react"; import { IssueBlockedNotice } from "./IssueBlockedNotice"; import { IssueAssignedBacklogNotice } from "./IssueAssignedBacklogNotice"; +import { IssueRecoveryActionCard, type RecoveryResolveOutcome } from "./IssueRecoveryActionCard"; interface IssueChatMessageContext { feedbackDataSharingPreference: FeedbackDataSharingPreference; @@ -297,6 +299,14 @@ interface IssueChatThreadProps { blockedBy?: IssueRelationIssueSummary[]; blockerAttention?: IssueBlockerAttention | null; successfulRunHandoff?: SuccessfulRunHandoffState | null; + recoveryAction?: IssueRecoveryAction | null; + onResolveRecoveryAction?: (outcome: RecoveryResolveOutcome) => void; + canCancelRecoveryAction?: boolean; + legacyRecoverySourceIssue?: { + identifier: string | null; + href: string; + title?: string | null; + } | null; assigneeUserId?: string | null; onResumeFromBacklog?: () => Promise | void; resumeFromBacklogPending?: boolean; @@ -3609,6 +3619,10 @@ export function IssueChatThread({ blockedBy = [], blockerAttention = null, successfulRunHandoff = null, + recoveryAction = null, + onResolveRecoveryAction, + canCancelRecoveryAction = false, + legacyRecoverySourceIssue = null, companyId, projectId, issueStatus, @@ -4244,11 +4258,49 @@ export function IssueChatThread({ onResume={onResumeFromBacklog} resuming={resumeFromBacklogPending} /> + {recoveryAction ? ( + + ) : null} + {legacyRecoverySourceIssue ? ( + + Legacy recovery issue. Newer recovery actions live on the source issue + {legacyRecoverySourceIssue.identifier ? ( + <> + {" — "} + + {legacyRecoverySourceIssue.identifier} + {legacyRecoverySourceIssue.title ? ( + + {" "} + — {legacyRecoverySourceIssue.title} + + ) : null} + + + ) : ( + "." + )} + + } + /> + ) : null} ({ + Link: ({ children, to, ...props }: AnchorHTMLAttributes & { to: string }) => ( + {children} + ), +})); + +// 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; + +afterEach(() => { + if (root) { + act(() => root?.unmount()); + } + root = null; + container?.remove(); + container = null; +}); + +function render(element: ReactElement) { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + act(() => root?.render(element)); + return container; +} + +const ownerAgent: Agent = { + id: "11111111-1111-1111-1111-111111111111", + companyId: "company-1", + name: "ClaudeCoder", + role: "engineer", + status: "idle", + adapterType: "claude_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + urlKey: "claudecoder", +} as unknown as Agent; + +const returnAgent: Agent = { + ...ownerAgent, + id: "22222222-2222-2222-2222-222222222222", + name: "CodexCoder", + urlKey: "codexcoder", +} as Agent; + +function buildAction(overrides: Partial = {}): IssueRecoveryAction { + return { + id: "00000000-0000-0000-0000-0000000000aa", + companyId: "company-1", + sourceIssueId: "00000000-0000-0000-0000-0000000000ff", + recoveryIssueId: null, + kind: "missing_disposition", + status: "active", + ownerType: "agent", + ownerAgentId: ownerAgent.id, + ownerUserId: null, + previousOwnerAgentId: returnAgent.id, + returnOwnerAgentId: returnAgent.id, + cause: "missing_disposition", + fingerprint: "fp", + evidence: { + summary: "Run finished but no disposition was chosen.", + sourceRunId: "7accd7a4-c9ca-4db2-9233-3228a037cc09", + }, + nextAction: "Choose and record a valid issue disposition.", + wakePolicy: { type: "wake_owner" }, + monitorPolicy: null, + attemptCount: 1, + maxAttempts: 3, + timeoutAt: null, + lastAttemptAt: "2026-05-09T19:30:00.000Z", + outcome: null, + resolutionNote: null, + resolvedAt: null, + createdAt: "2026-05-09T19:30:00.000Z", + updatedAt: "2026-05-09T19:30:00.000Z", + ...overrides, + }; +} + +describe("deriveRecoveryCardState", () => { + it("maps active missing_disposition to needed", () => { + expect(deriveRecoveryCardState(buildAction())).toBe("needed"); + }); + + it("maps active_run_watchdog to observe_only", () => { + expect(deriveRecoveryCardState(buildAction({ kind: "active_run_watchdog" }))).toBe("observe_only"); + }); + + it("maps escalated status to escalated", () => { + expect(deriveRecoveryCardState(buildAction({ status: "escalated" }))).toBe("escalated"); + }); + + it("maps resolved/cancelled to resolved", () => { + expect(deriveRecoveryCardState(buildAction({ status: "resolved" }))).toBe("resolved"); + expect(deriveRecoveryCardState(buildAction({ status: "cancelled" }))).toBe("resolved"); + }); +}); + +describe("IssueRecoveryActionCard", () => { + it("renders required fields and an aria-label naming the state", () => { + const node = render( + {}} + />, + ); + const section = node.querySelector("section[aria-label]"); + expect(section?.getAttribute("aria-label")).toBe("Recovery action: needed"); + expect(node.textContent).toContain("RECOVERY NEEDED"); + expect(node.textContent).toContain("missing_disposition"); + expect(node.textContent).toContain("This issue's run finished, but no next step was chosen."); + expect(node.textContent).toContain("ClaudeCoder"); + expect(node.textContent).toContain("CodexCoder"); + expect(node.textContent).toContain("Choose and record a valid issue disposition."); + expect(node.textContent).toContain("Corrective wake queued"); + }); + + it("falls back to em dash when wake policy is absent", () => { + const node = render( + , + ); + expect(node.textContent).toContain("—"); + }); + + it("renders observe_only tone for active_run_watchdog", () => { + const node = render( + , + ); + const section = node.querySelector("section[aria-label]"); + expect(section?.getAttribute("aria-label")).toBe("Recovery action: observing active run"); + expect(node.textContent).toContain("OBSERVING ACTIVE RUN"); + }); + + it("renders the resolved label and outcome when resolved", () => { + const node = render( + , + ); + expect(node.textContent).toContain("RECOVERY RESOLVED"); + expect(node.textContent).toContain("Resolved as restored"); + }); + + it("hides false_positive option unless canCancelRecovery is set", () => { + const node = render( + {}} />, + ); + expect(node.querySelector("[data-testid='recovery-action-resolve-trigger']")).not.toBeNull(); + }); +}); diff --git a/ui/src/components/IssueRecoveryActionCard.tsx b/ui/src/components/IssueRecoveryActionCard.tsx new file mode 100644 index 00000000000..384c3597cdb --- /dev/null +++ b/ui/src/components/IssueRecoveryActionCard.tsx @@ -0,0 +1,552 @@ +import { useMemo } from "react"; +import type { + Agent, + IssueRecoveryAction, + IssueRecoveryActionKind, + IssueRecoveryActionOutcome, + IssueRecoveryActionStatus, +} from "@paperclipai/shared"; +import { Eye, OctagonAlert, RefreshCw, Sparkles, TriangleAlert } from "lucide-react"; +import { Link } from "@/lib/router"; +import { Button } from "@/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { agentUrl } from "@/lib/utils"; +import { cn } from "@/lib/utils"; + +export type RecoveryCardCardState = + | "needed" + | "in_progress" + | "observe_only" + | "escalated" + | "resolved"; + +export type RecoveryResolveOutcome = + | "done" + | "in_review" + | "blocked" + | "delegated" + | "false_positive"; + +export interface IssueRecoveryActionCardProps { + action: IssueRecoveryAction; + agentMap?: ReadonlyMap; + /** Preferred state hint (e.g. observe_only when watchdog tone is requested). Falls back to derived state. */ + forcedState?: RecoveryCardCardState; + /** Optional click handler for resolve menu actions. If omitted, the buttons are not rendered. */ + onResolve?: (outcome: RecoveryResolveOutcome) => void; + /** Whether the viewer can run destructive board-only actions (e.g. cancel recovery). */ + canCancelRecovery?: boolean; + className?: string; +} + +const KIND_LABEL: Record = { + missing_disposition: "missing_disposition", + stranded_assigned_issue: "stranded_assigned_issue", + active_run_watchdog: "active_run_watchdog", + issue_graph_liveness: "issue_graph_liveness", +}; + +const KIND_HEADLINE: Record = { + missing_disposition: "This issue's run finished, but no next step was chosen.", + stranded_assigned_issue: + "Paperclip retried this issue's last run and it still has no live execution path.", + active_run_watchdog: + "The active run has been silent. Recovery is observing without interrupting it.", + issue_graph_liveness: + "Paperclip detected this issue lost a live action path. A recovery owner needs to act.", +}; + +const STATE_TONE: Record = { + needed: { + label: "RECOVERY NEEDED", + containerClass: + "border-amber-300/70 bg-amber-50/85 text-amber-950 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100", + iconWrapClass: "bg-amber-100 text-amber-800 dark:bg-amber-500/20 dark:text-amber-200", + iconClass: "text-amber-700 dark:text-amber-300", + labelClass: "text-amber-900 dark:text-amber-200", + Icon: TriangleAlert, + divider: "border-amber-300/60 dark:border-amber-500/30", + }, + in_progress: { + label: "RECOVERY IN PROGRESS", + containerClass: + "border-sky-300/70 bg-sky-50/80 text-sky-950 dark:border-sky-500/40 dark:bg-sky-500/10 dark:text-sky-100", + iconWrapClass: "bg-sky-100 text-sky-800 dark:bg-sky-500/20 dark:text-sky-200", + iconClass: "text-sky-700 dark:text-sky-300", + labelClass: "text-sky-900 dark:text-sky-200", + Icon: RefreshCw, + divider: "border-sky-300/60 dark:border-sky-500/30", + }, + observe_only: { + label: "OBSERVING ACTIVE RUN", + containerClass: + "border-border bg-muted/40 text-foreground dark:bg-muted/20", + iconWrapClass: "bg-muted text-foreground/70", + iconClass: "text-muted-foreground", + labelClass: "text-muted-foreground", + Icon: Eye, + divider: "border-border/70", + }, + escalated: { + label: "RECOVERY ESCALATED", + containerClass: + "border-red-400/60 bg-red-50/85 text-red-950 dark:border-red-500/40 dark:bg-red-500/10 dark:text-red-100", + iconWrapClass: "bg-red-100 text-red-800 dark:bg-red-500/20 dark:text-red-200", + iconClass: "text-red-700 dark:text-red-300", + labelClass: "text-red-900 dark:text-red-200", + Icon: OctagonAlert, + divider: "border-red-400/50 dark:border-red-500/30", + }, + resolved: { + label: "RECOVERY RESOLVED", + containerClass: + "border-emerald-300/70 bg-emerald-50/80 text-emerald-950 dark:border-emerald-500/40 dark:bg-emerald-500/10 dark:text-emerald-100", + iconWrapClass: "bg-emerald-100 text-emerald-800 dark:bg-emerald-500/20 dark:text-emerald-200", + iconClass: "text-emerald-700 dark:text-emerald-300", + labelClass: "text-emerald-900 dark:text-emerald-200", + Icon: Sparkles, + divider: "border-emerald-300/60 dark:border-emerald-500/30", + }, +}; + +export function deriveRecoveryCardState( + action: Pick, +): RecoveryCardCardState { + if (action.status === "resolved") return "resolved"; + if (action.status === "escalated") return "escalated"; + if (action.status === "cancelled") return "resolved"; + if (action.kind === "active_run_watchdog") return "observe_only"; + if (action.outcome === "delegated") return "in_progress"; + return "needed"; +} + +const OUTCOME_LABEL: Record = { + restored: "restored", + delegated: "delegated to follow-up", + false_positive: "false positive", + blocked: "blocked", + escalated: "escalated", + cancelled: "cancelled", +}; + +function readEvidenceString(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + if (!trimmed) return null; + return trimmed.length > 240 ? `${trimmed.slice(0, 237)}…` : trimmed; +} + +function pickEvidenceSummary(action: IssueRecoveryAction): string | null { + const evidence = action.evidence ?? {}; + const candidates = [ + "summary", + "detectedProgressSummary", + "missingDisposition", + "retryReason", + "latestRunErrorCode", + "latestRunStatus", + "latestIssueStatus", + ] as const; + for (const key of candidates) { + const next = readEvidenceString(evidence[key]); + if (next) return next; + } + return null; +} + +function readEvidenceRunId(action: IssueRecoveryAction, key: "sourceRunId" | "correctiveRunId" | "latestRunId") { + const evidence = action.evidence ?? {}; + const next = readEvidenceString(evidence[key]); + return next; +} + +function readWakePolicySummary(action: IssueRecoveryAction): string | null { + const policy = action.wakePolicy; + if (!policy) return null; + const type = readEvidenceString(policy.type); + if (!type) return null; + if (type === "wake_owner") return "Corrective wake queued"; + if (type === "board_escalation") return "Escalated to board"; + if (type === "manual") return "Manual"; + if (type === "monitor") { + const interval = readEvidenceString(policy.intervalLabel); + return interval ? `Monitor scheduled · ${interval}` : "Monitor scheduled"; + } + return type.replaceAll("_", " "); +} + +function formatTimeShort(value: string | Date | null | undefined): string | null { + if (!value) return null; + try { + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) return null; + const now = Date.now(); + const diffMs = date.getTime() - now; + const absMin = Math.round(Math.abs(diffMs) / 60_000); + if (absMin < 60) { + return diffMs >= 0 ? `in ${absMin}m` : `${absMin}m ago`; + } + return date.toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }); + } catch { + return null; + } +} + +function shortenRunId(runId: string | null | undefined) { + if (!runId) return null; + if (runId.length <= 12) return runId; + return runId.slice(0, 8); +} + +function MetadataRow({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( +
+
+ {label} +
+
{children}
+
+ ); +} + +function MissingValue() { + return —; +} + +function AgentLink({ + agentId, + agentMap, + fallback, +}: { + agentId: string | null | undefined; + agentMap?: ReadonlyMap; + fallback?: string | null; +}) { + if (!agentId) { + return fallback ? {fallback} : ; + } + const agent = agentMap?.get(agentId); + const label = agent?.name ?? `agent ${agentId.slice(0, 8)}`; + if (agent) { + return ( + + {label} + + ); + } + return {label}; +} + +function RunChip({ + runId, + agentId, + status, +}: { + runId: string | null; + agentId: string | null | undefined; + status?: string | null; +}) { + if (!runId) return ; + const short = shortenRunId(runId); + const inner = ( + <> + + run {short} + + {status ? ( + {status} + ) : null} + + ); + if (agentId) { + return ( + + {inner} + + ); + } + return {inner}; +} + +const RESOLVE_OPTIONS: Array<{ + outcome: RecoveryResolveOutcome; + label: string; + description: string; + destructive?: boolean; + boardOnly?: boolean; +}> = [ + { + outcome: "done", + label: "Mark issue done", + description: "Restore by recording the requested work as complete.", + }, + { + outcome: "in_review", + label: "Send for review", + description: "Hand off to a reviewer with a real review path.", + }, + { + outcome: "blocked", + label: "Mark blocked…", + description: "Block on a real follow-up issue.", + }, + { + outcome: "delegated", + label: "Delegate follow-up issue…", + description: "Create a child issue and block this one on it.", + }, + { + outcome: "false_positive", + label: "Mark false positive", + description: "Cancel recovery — only the board may do this.", + destructive: true, + boardOnly: true, + }, +]; + +export function IssueRecoveryActionCard({ + action, + agentMap, + forcedState, + onResolve, + canCancelRecovery = false, + className, +}: IssueRecoveryActionCardProps) { + const cardState: RecoveryCardCardState = forcedState ?? deriveRecoveryCardState(action); + const tone = STATE_TONE[cardState]; + const ToneIcon = tone.Icon; + + const headline = useMemo(() => { + if (cardState === "resolved" && action.outcome) { + return `Recovery resolved as ${OUTCOME_LABEL[action.outcome] ?? action.outcome}.`; + } + return KIND_HEADLINE[action.kind] ?? KIND_HEADLINE.missing_disposition; + }, [action.kind, action.outcome, cardState]); + + const wakeSummary = readWakePolicySummary(action); + const evidenceSummary = pickEvidenceSummary(action); + const sourceRunId = readEvidenceRunId(action, "sourceRunId") ?? readEvidenceRunId(action, "latestRunId"); + const correctiveRunId = readEvidenceRunId(action, "correctiveRunId"); + const showAttempt = action.attemptCount > 1 && action.maxAttempts !== null; + const showTimeoutInline = (() => { + if (!action.timeoutAt) return false; + try { + const date = action.timeoutAt instanceof Date ? action.timeoutAt : new Date(action.timeoutAt); + const diffMs = date.getTime() - Date.now(); + return diffMs > 0 && diffMs < 60 * 60 * 1000; + } catch { + return false; + } + })(); + const updatedAtLabel = formatTimeShort(action.updatedAt); + + const ariaState = ({ + needed: "needed", + in_progress: "in progress", + observe_only: "observing active run", + escalated: "escalated", + resolved: "resolved", + } satisfies Record)[cardState]; + + const showResolveActions = onResolve !== undefined && cardState !== "resolved"; + const visibleResolveOptions = RESOLVE_OPTIONS.filter((option) => { + if (option.boardOnly && !canCancelRecovery) return false; + return true; + }); + + return ( +
+
+ + + +
+
+ {tone.label} + · + + {KIND_LABEL[action.kind] ?? action.kind} + + {updatedAtLabel ? ( + <> + · + + {updatedAtLabel} + + + ) : null} +
+

{headline}

+
+
+
+ + + {action.ownerType === "agent" && action.ownerAgentId ? ( + <> + Recovery: + + + ) : action.ownerType === "board" ? ( + Board + ) : action.ownerType === "user" && action.ownerUserId ? ( + user {action.ownerUserId.slice(0, 6)} + ) : action.ownerType === "system" ? ( + System + ) : ( + unassigned — pick one to wake them + )} + {action.returnOwnerAgentId ? ( + <> + → Returns to: + + + ) : null} + + + + + + {correctiveRunId ? ( + + + + ) : null} + + {evidenceSummary ? ( + {evidenceSummary} + ) : ( + + )} + + + {action.nextAction ? {action.nextAction} : } + + + + {wakeSummary ? {wakeSummary} : } + {showAttempt ? ( + + attempt {action.attemptCount} of {action.maxAttempts} + + ) : null} + {showTimeoutInline ? ( + + Times out {formatTimeShort(action.timeoutAt) ?? "soon"} + + ) : null} + + + {cardState === "resolved" && action.outcome ? ( + + + Resolved as {OUTCOME_LABEL[action.outcome]} + {action.resolvedAt ? ` · ${formatTimeShort(action.resolvedAt) ?? ""}` : ""} + + + ) : null} +
+ {showResolveActions ? ( +
+ + + + + +
+ Resolve recovery +
+
+ {visibleResolveOptions.map((option) => ( + + ))} +
+
+
+ {cardState === "observe_only" ? ( + + Recovery is observing without interrupting the live run. + + ) : ( + + The card stays open until an explicit decision is recorded. + + )} +
+ ) : null} +
+ ); +} + +export type { IssueRecoveryActionStatus }; + +export default IssueRecoveryActionCard; diff --git a/ui/src/components/IssueRow.tsx b/ui/src/components/IssueRow.tsx index 7ca9827dc60..96613a1c6f2 100644 --- a/ui/src/components/IssueRow.tsx +++ b/ui/src/components/IssueRow.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from "react"; -import type { Issue } from "@paperclipai/shared"; +import type { Issue, IssueRecoveryAction } from "@paperclipai/shared"; import { Link } from "@/lib/router"; -import { Eye, Flag, X } from "lucide-react"; +import { Eye, Flag, OctagonAlert, RefreshCw, TriangleAlert, X } from "lucide-react"; import { createIssueDetailPath, rememberIssueDetailLocationState, @@ -92,6 +92,8 @@ export function IssueRow({ Planning ) : null; + const recoveryAction = issue.activeRecoveryAction ?? null; + const recoveryIndicator = recoveryAction ? renderRecoveryChip(recoveryAction, selected) : null; const parkedBlockerIndicator = hasAssignedBacklogBlocker(issue.blockedBy) ? ( @@ -151,6 +154,7 @@ export function IssueRow({ {planningModeIndicator} {parkedBlockerIndicator} + {recoveryIndicator} )} {mobileMeta ? ( @@ -230,3 +234,63 @@ export function IssueRow({ ); } + +const RECOVERY_CHIP_TONE: Record< + "needed" | "in_progress" | "observe_only" | "escalated", + { className: string; icon: typeof TriangleAlert; label: string } +> = { + needed: { + className: + "border-amber-500/60 bg-amber-500/15 text-amber-700 dark:text-amber-300", + icon: TriangleAlert, + label: "Recovery needed", + }, + in_progress: { + className: + "border-sky-500/60 bg-sky-500/15 text-sky-700 dark:text-sky-300", + icon: RefreshCw, + label: "Recovery in progress", + }, + observe_only: { + className: "border-border bg-muted text-muted-foreground", + icon: Eye, + label: "Observing active run", + }, + escalated: { + className: "border-red-500/60 bg-red-500/15 text-red-700 dark:text-red-300", + icon: OctagonAlert, + label: "Recovery escalated", + }, +}; + +function rowRecoveryStateFor(action: IssueRecoveryAction): keyof typeof RECOVERY_CHIP_TONE | null { + if (action.status === "resolved" || action.status === "cancelled") return null; + if (action.status === "escalated") return "escalated"; + if (action.kind === "active_run_watchdog") return "observe_only"; + if (action.outcome === "delegated") return "in_progress"; + return "needed"; +} + +function renderRecoveryChip(action: IssueRecoveryAction, selected: boolean): ReactNode { + const state = rowRecoveryStateFor(action); + if (!state) return null; + const tone = RECOVERY_CHIP_TONE[state]; + const Icon = tone.icon; + return ( + + + {tone.label} + + ); +} diff --git a/ui/src/lib/activity-format.ts b/ui/src/lib/activity-format.ts index d5013c779c4..d1cd2b48adc 100644 --- a/ui/src/lib/activity-format.ts +++ b/ui/src/lib/activity-format.ts @@ -46,6 +46,9 @@ const ACTIVITY_ROW_VERBS: Record = { "issue.successful_run_handoff_required": "flagged missing next step on", "issue.successful_run_handoff_resolved": "recorded next step chosen on", "issue.successful_run_handoff_escalated": "escalated missing next step on", + "issue.recovery_action_opened": "opened a recovery action on", + "issue.recovery_action_resolved": "resolved the recovery action on", + "issue.recovery_action_escalated": "escalated the recovery action on", "agent.created": "created", "agent.updated": "updated", "agent.paused": "paused", @@ -98,6 +101,9 @@ const ISSUE_ACTIVITY_LABELS: Record = { "issue.successful_run_handoff_required": "Run finished without a clear next step", "issue.successful_run_handoff_resolved": "Next step chosen", "issue.successful_run_handoff_escalated": "Run finished without a next step - recovery escalated", + "issue.recovery_action_opened": "Opened a source-scoped recovery action", + "issue.recovery_action_resolved": "Resolved the recovery action", + "issue.recovery_action_escalated": "Escalated the recovery action", "agent.created": "created an agent", "agent.updated": "updated the agent", "agent.paused": "paused the agent", diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 3663b4c88cb..33ff7dc5466 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -598,6 +598,14 @@ type IssueDetailChatTabProps = { blockedBy: Issue["blockedBy"]; blockerAttention: Issue["blockerAttention"] | null; successfulRunHandoff: Issue["successfulRunHandoff"] | null; + recoveryAction: Issue["activeRecoveryAction"]; + onResolveRecoveryAction?: (outcome: import("../components/IssueRecoveryActionCard").RecoveryResolveOutcome) => void; + canCancelRecoveryAction?: boolean; + legacyRecoverySourceIssue?: { + identifier: string | null; + href: string; + title?: string | null; + } | null; comments: IssueDetailComment[]; locallyQueuedCommentRunIds: ReadonlyMap; interactions: IssueThreadInteraction[]; @@ -661,6 +669,10 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ blockedBy, blockerAttention, successfulRunHandoff, + recoveryAction, + onResolveRecoveryAction, + canCancelRecoveryAction, + legacyRecoverySourceIssue, comments, locallyQueuedCommentRunIds, interactions, @@ -867,6 +879,10 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ blockedBy={blockedBy ?? []} blockerAttention={blockerAttention} successfulRunHandoff={successfulRunHandoff} + recoveryAction={recoveryAction ?? null} + onResolveRecoveryAction={onResolveRecoveryAction} + canCancelRecoveryAction={canCancelRecoveryAction} + legacyRecoverySourceIssue={legacyRecoverySourceIssue ?? null} companyId={companyId} projectId={projectId} issueStatus={issueStatus} @@ -2909,6 +2925,28 @@ export function IssueDetail() { const handleResumeFromBacklog = useCallback(async () => { await updateIssue.mutateAsync({ status: "todo" }); }, [updateIssue.mutateAsync]); + const handleResolveRecoveryAction = useCallback( + (outcome: import("../components/IssueRecoveryActionCard").RecoveryResolveOutcome) => { + switch (outcome) { + case "done": + void updateIssue.mutateAsync({ status: "done" }); + return; + case "in_review": + void updateIssue.mutateAsync({ status: "in_review" }); + return; + case "blocked": + void updateIssue.mutateAsync({ status: "blocked" }); + return; + case "delegated": + if (issue) openNewIssue(buildSubIssueDefaultsForViewer(issue, currentUserId)); + return; + case "false_positive": + void updateIssue.mutateAsync({ status: "cancelled" }); + return; + } + }, + [currentUserId, issue, openNewIssue, updateIssue.mutateAsync], + ); const treePreviewAffectedIssues = useMemo( () => (treeControlPreview?.issues ?? []).filter((candidate) => !candidate.skipped), @@ -2970,6 +3008,22 @@ export function IssueDetail() { // Ancestors are returned oldest-first from the server (root at end, immediate parent at start) const ancestors = issue.ancestors ?? []; + const legacyRecoverySourceIssue = (() => { + if ( + issue.originKind !== "stranded_issue_recovery" && + issue.originKind !== "stale_active_run_evaluation" + ) { + return null; + } + const parent = ancestors.length > 0 ? ancestors[0] : null; + if (!parent) return null; + const ref = parent.identifier ?? parent.id; + return { + identifier: parent.identifier ?? null, + title: parent.title ?? null, + href: createIssueDetailPath(ref), + }; + })(); const handleFilePicked = async (evt: ChangeEvent) => { const files = evt.target.files; if (!files || files.length === 0) return; @@ -3787,6 +3841,10 @@ export function IssueDetail() { blockedBy={issue.blockedBy ?? []} blockerAttention={issue.blockerAttention ?? null} successfulRunHandoff={issue.successfulRunHandoff ?? null} + recoveryAction={issue.activeRecoveryAction ?? null} + onResolveRecoveryAction={handleResolveRecoveryAction} + canCancelRecoveryAction={canManageTreeControl} + legacyRecoverySourceIssue={legacyRecoverySourceIssue} comments={threadComments} locallyQueuedCommentRunIds={locallyQueuedCommentRunIds} interactions={interactions} diff --git a/ui/storybook/stories/source-issue-recovery.stories.tsx b/ui/storybook/stories/source-issue-recovery.stories.tsx new file mode 100644 index 00000000000..137289f11a6 --- /dev/null +++ b/ui/storybook/stories/source-issue-recovery.stories.tsx @@ -0,0 +1,201 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { ReactNode } from "react"; +import type { IssueRecoveryAction } from "@paperclipai/shared"; +import { IssueRecoveryActionCard } from "@/components/IssueRecoveryActionCard"; +import { IssueRow } from "@/components/IssueRow"; +import { storybookAgentMap, storybookAgents, createIssue } from "../fixtures/paperclipData"; + +const claudeAgent = storybookAgents.find((agent) => agent.name.toLowerCase().startsWith("claude")) ?? storybookAgents[0]!; +const codexAgent = storybookAgents.find((agent) => agent.name.toLowerCase().startsWith("codex")) ?? storybookAgents[0]!; + +function StoryFrame({ title, description, children }: { title: string; description?: string; children: ReactNode }) { + return ( +
+
+
+
+ Source-issue recovery +
+

{title}

+ {description ? ( +

{description}

+ ) : null} +
+ {children} +
+
+ ); +} + +function buildAction(overrides: Partial = {}): IssueRecoveryAction { + return { + id: "00000000-0000-0000-0000-0000000000aa", + companyId: "company-storybook", + sourceIssueId: "00000000-0000-0000-0000-0000000000ff", + recoveryIssueId: null, + kind: "missing_disposition", + status: "active", + ownerType: "agent", + ownerAgentId: claudeAgent.id, + ownerUserId: null, + previousOwnerAgentId: codexAgent.id, + returnOwnerAgentId: codexAgent.id, + cause: "missing_disposition", + fingerprint: "fp", + evidence: { + summary: "Run finished without picking a disposition. The PR has tests passing on CI.", + sourceRunId: "7accd7a4-c9ca-4db2-9233-3228a037cc09", + correctiveRunId: "2606404d-3859-4142-ba37-3228a037cc09", + }, + nextAction: "Choose and record a valid issue disposition without copying transcript content.", + wakePolicy: { type: "wake_owner" }, + monitorPolicy: null, + attemptCount: 1, + maxAttempts: 3, + timeoutAt: null, + lastAttemptAt: "2026-04-20T11:55:00.000Z", + outcome: null, + resolutionNote: null, + resolvedAt: null, + createdAt: "2026-04-20T11:55:00.000Z", + updatedAt: "2026-04-20T11:55:00.000Z", + ...overrides, + }; +} + +function CardPanel({ caption, action, forcedState, canCancelRecovery }: { + caption: string; + action: IssueRecoveryAction; + forcedState?: React.ComponentProps["forcedState"]; + canCancelRecovery?: boolean; +}) { + return ( +
+
+ {caption} +
+ {}} + canCancelRecovery={canCancelRecovery} + /> +
+ ); +} + +function AllStatesPanel() { + return ( +
+ + + + + +
+ ); +} + +function InboxRowPanel() { + const baseIssue = createIssue(); + return ( +
+ + + +
+ ); +} + +const meta = { + title: "Paperclip/Source Issue Recovery", + component: AllStatesPanel, + parameters: { layout: "fullscreen" }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const RecoveryActionCardStates: Story = { + render: () => ( + + + + ), +}; + +export const InboxRowChips: Story = { + render: () => ( + + + + ), +};