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"; import { deriveRecoveryDisplayState, type RecoveryDisplayState, } from "@/lib/recovery-display"; export type RecoveryCardCardState = RecoveryDisplayState; export const deriveRecoveryCardState = deriveRecoveryDisplayState; export type RecoveryResolveOutcome = | "done" | "in_review" | "false_positive_done" | "false_positive_in_review"; 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. false-positive dismissal). */ canFalsePositive?: boolean; className?: string; } const KIND_LABEL: Record = { missing_disposition: "Missing Disposition", stranded_assigned_issue: "Stranded Issue", active_run_watchdog: "Active Watchdog", issue_graph_liveness: "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", }, }; 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: "false_positive_done", label: "False positive, done", description: "Dismiss recovery and mark the source issue complete.", destructive: true, boardOnly: true, }, { outcome: "false_positive_in_review", label: "False positive, review", description: "Dismiss recovery and send the source issue for review.", destructive: true, boardOnly: true, }, ]; export function IssueRecoveryActionCard({ action, agentMap, forcedState, onResolve, canFalsePositive = 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 && !canFalsePositive) 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;