import { createContext, useContext, useId, useRef, useState, type ReactNode } from "react"; import { Check, ChevronDown, Loader2, RotateCcw, TriangleAlert } from "lucide-react"; import type { Agent, Issue, IssueCommentMetadata, IssueRecoveryAction } from "@paperclipai/shared"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { timeAgo } from "@/lib/timeAgo"; export type DispositionRecoverySnapshot = NonNullable; export type DispositionRecoveryContextValue = { issue: Pick & { activeRecoveryAction?: Pick & { evidence?: IssueRecoveryAction["evidence"] } | null; }; agentMap?: ReadonlyMap>; hasPendingInteraction?: boolean; unavailableReason?: string | null; onRetry: (actionId: string) => Promise; }; const RecoveryContext = createContext(null); export function DispositionRecoveryProvider({ value, children }: { value: DispositionRecoveryContextValue; children: ReactNode }) { return {children}; } /** Older notices can be identified by their stored action/run IDs, never their copy. */ export function readDispositionRecoverySnapshot(metadata: IssueCommentMetadata | null | undefined, action?: DispositionRecoveryContextValue["issue"]["activeRecoveryAction"]): DispositionRecoverySnapshot | null { if (metadata?.recovery?.kind === "disposition_repair_escalated") return metadata.recovery; if (!metadata || !action || action.kind !== "deliberate_wait_without_target" || action.ownerType !== "board" || action.wakePolicy?.type !== "board_escalation") return null; const evidence = action.evidence; if (!metadata.sourceRunId || metadata.sourceRunId !== evidence?.latestRunId) return null; if (!metadata.sections.some(section => section.rows.some(row => row.type === "key_value" && row.value === action.id))) return null; if (typeof evidence.terminalReason !== "string" || typeof evidence.sourceAttemptCount !== "number" || !Number.isInteger(evidence.sourceAttemptCount) || evidence.sourceAttemptCount < 0 || typeof evidence.sourceMaxAttempts !== "number" || !Number.isInteger(evidence.sourceMaxAttempts) || evidence.sourceMaxAttempts <= 0) return null; return { kind: "disposition_repair_escalated", actionId: action.id, attemptCount: evidence.sourceAttemptCount, maxAttempts: evidence.sourceMaxAttempts, reason: evidence.terminalReason, assigneeAgentId: action.returnOwnerAgentId }; } export function useDispositionRecoverySnapshot(metadata: IssueCommentMetadata | null | undefined) { return readDispositionRecoverySnapshot(metadata, useContext(RecoveryContext)?.issue.activeRecoveryAction); } /** UI affordance only; the server rechecks the current action and all execution gates. */ export function dispositionRetryUnavailableReason(snapshot: DispositionRecoverySnapshot, context: DispositionRecoveryContextValue | null): string | null { if (!context) return "Open the task to review its current state."; const { issue } = context; const action = issue.activeRecoveryAction; if (!action || action.id !== snapshot.actionId || action.status !== "active") return "This recovery notice is no longer active."; if (issue.status !== "blocked") return "The task’s state has changed. Review its current state before retrying."; if (action.kind !== "deliberate_wait_without_target" || action.ownerType !== "board" || action.wakePolicy?.type !== "board_escalation") return "The task’s recovery state has changed. Refresh to see the current action."; if (!snapshot.assigneeAgentId || issue.assigneeAgentId !== snapshot.assigneeAgentId || action.returnOwnerAgentId !== snapshot.assigneeAgentId) return "The assigned agent has changed. Review the task before retrying."; if (context.unavailableReason) return context.unavailableReason; if (context.hasPendingInteraction) return "Respond to the pending question or confirmation before retrying."; if (issue.executionRunId || issue.checkoutRunId) return "The task already has an active run. Wait for it to finish."; if (issue.executionState?.status === "pending") return "The task is waiting for a review or approval."; if (issue.blockedBy?.some(blocker => blocker.status !== "done" && blocker.status !== "cancelled")) return "Resolve the task’s blockers before retrying."; const agent = context.agentMap?.get(snapshot.assigneeAgentId); if (agent?.status === "paused") return "The assigned agent is paused. Resume the agent before retrying."; if (agent?.status === "terminated") return "The assigned agent is no longer available."; return null; } function descriptionFor(snapshot: DispositionRecoverySnapshot) { const start = "The agent stopped without recording an outcome or a next step."; if (snapshot.reason === "owner_budget_blocked") return `${start} Automatic recovery stopped because a spending or pause limit prevents the agent from running.`; if (snapshot.reason === "owner_not_invokable") return `${start} Automatic recovery stopped because the assigned agent is unavailable.`; if (snapshot.reason === "unchanged_source_state_exhausted") { const attempts = snapshot.attemptCount === 2 ? "Two" : String(snapshot.attemptCount); return `${start} ${attempts} automatic ${snapshot.attemptCount === 1 ? "attempt" : "attempts"} to resolve this failed.`; } return `${start} Automatic recovery stopped. Review the details before trying again.`; } /** Same component in both task interfaces and Storybook; no prose controls actions. */ export function DispositionRecoveryNotice({ snapshot, createdAt, defaultExpanded = false }: { snapshot: DispositionRecoverySnapshot; createdAt?: string; defaultExpanded?: boolean; }) { const context = useContext(RecoveryContext); const [expanded, setExpanded] = useState(defaultExpanded); const [pending, setPending] = useState(false); const [requested, setRequested] = useState(false); const [error, setError] = useState(null); const inFlight = useRef(false); const detailsId = useId(); const titleId = useId(); const unavailableId = useId(); const unavailableReason = dispositionRetryUnavailableReason(snapshot, context); const historical = Boolean(context && context.issue.activeRecoveryAction?.id !== snapshot.actionId); const agentName = (snapshot.assigneeAgentId && context?.agentMap?.get(snapshot.assigneeAgentId)?.name) || "the assigned agent"; const HeadingIcon = requested || historical ? Check : TriangleAlert; async function retry() { if (!context || unavailableReason || inFlight.current) return; inFlight.current = true; setPending(true); setError(null); try { await context.onRetry(snapshot.actionId); setRequested(true); } catch (cause) { setError(cause instanceof Error ? cause.message : "Refresh the task to check its current state, then try again."); } finally { inFlight.current = false; setPending(false); } } return (
); }