import { useMemo, useState, type ReactNode } from "react"; import { AlertTriangle, ArrowRight, Ban, CheckCircle2, Clock, ExternalLink, Loader2, MinusCircle, ShieldAlert, XCircle, } from "lucide-react"; import { decisionEffectTargetIssueIds, type DecisionEffect, type DecisionOption } from "@paperclipai/shared"; import type { Decision, DecisionEffectExecution, DecisionTargetSnapshot, } from "../api/decisions"; import { cn } from "../lib/utils"; import { Button } from "./ui/button"; import { Input } from "./ui/input"; import { Textarea } from "./ui/textarea"; import { MarkdownBody } from "./MarkdownBody"; /** * Presentational card for a single Decisions-v1 decision (PAP-14966 / PAP-14939 * §4), modeled on {@link IssueThreadInteractionCard}. It renders every state — * pending / stale-target / destructive cancel-tree / decided / partial / failed * / expired / cancelled / dismissed — and delegates the actual decide / dismiss * mutations to the parent (the feed's `DecisionResolver`, or the history view). * It never fetches: issue labels + the cancel-tree preview arrive via resolvers * so the same component drives the live feed and the screenshot harness. */ export interface DecisionIssueRef { id: string; identifier: string | null; title: string | null; href: string; status?: string | null; } export interface DecisionCardProps { decision: Decision; /** Per-effect execution rows (present once decided). */ executions?: DecisionEffectExecution[] | null; /** Which target issues drifted since the snapshot (open decisions only). */ targetChanged?: Record | null; /** Resolve an issue id to a display ref (identifier / title / link). */ resolveIssue?: (issueId: string) => DecisionIssueRef | null; /** Full sub-tree that a `cancel_issue_tree` option would cancel. */ cancelTreePreview?: (targetIssueId: string) => DecisionIssueRef[] | null; originAgentName?: string | null; originIssue?: DecisionIssueRef | null; runHref?: string | null; busy?: boolean; errorMessage?: string | null; onDecide?: (optionId: string, inputValues: Record) => void; onDismiss?: (reason?: string) => void; className?: string; } // --- small helpers ---------------------------------------------------------- function humanStatus(status: string | null | undefined): string { if (!status) return "unknown"; return status.replaceAll("_", " "); } function issueLabel(ref: DecisionIssueRef | null, fallbackId: string): string { if (ref?.identifier) return ref.identifier; if (ref?.title) return ref.title; return `issue ${fallbackId.slice(0, 8)}`; } function pluralize(count: number, singular: string): string { return `${count} ${count === 1 ? singular : `${singular}s`}`; } function isDestructiveOption(option: DecisionOption): boolean { return option.style === "destructive" || option.effects.some((effect) => effect.type === "cancel_issue_tree"); } function cancelTreeEffect(option: DecisionOption): Extract | null { return (option.effects.find((effect) => effect.type === "cancel_issue_tree") ?? null) as | Extract | null; } /** One-line feed-forward preview of what an effect will do, before you commit. */ function effectSummary( effect: DecisionEffect, resolve: (id: string) => DecisionIssueRef | null, snapshots: Record, ): string { const target = issueLabel(resolve(effect.targetIssueId), effect.targetIssueId); switch (effect.type) { case "comment_on_issue": return `Comment on ${target}`; case "create_issue": { const parent = effect.draft.parentId ? issueLabel(resolve(effect.draft.parentId), effect.draft.parentId) : target; return `Create issue “${effect.draft.title}” under ${parent}`; } case "update_issue_status": return `Set ${target} to ${humanStatus(effect.status)}`; case "assign_issue": return `Reassign ${target}`; case "resolve_blocker": return `Unblock ${target} — remove ${pluralize(effect.removeBlockedByIssueIds.length, "blocker")}`; case "cancel_issue_tree": { const snapshot = snapshots[effect.targetIssueId]; const descendantCount = snapshot?.descendantCount ?? snapshot?.descendantIds?.length ?? snapshot?.childCount ?? 0; return `Cancel ${target} and its sub-tree (${pluralize(descendantCount + 1, "issue")})`; } default: return "Apply effect"; } } const FAILURE_CAUSE: Record = { deny_decision_intersection: "blocked by the permission boundary (fail-closed)", invalid_effect_reference: "a referenced issue no longer exists", target_changed: "the target changed since this was proposed", effect_execution_failed: "the effect errored while running", }; interface ResultRow { key: string; status: DecisionEffectExecution["status"]; summary: string; link: DecisionIssueRef | null; } function executionRow( execution: DecisionEffectExecution, resolve: (id: string) => DecisionIssueRef | null, ): ResultRow { const targetRef = resolve(execution.targetIssueId); const target = issueLabel(targetRef, execution.targetIssueId); const result = execution.result ?? {}; if (execution.status === "skipped") { return { key: execution.id, status: "skipped", summary: `Skipped ${target} — target changed since proposal`, link: targetRef }; } if (execution.status === "failed") { const cause = FAILURE_CAUSE[execution.error ?? ""] ?? execution.error ?? "the effect could not run"; return { key: execution.id, status: "failed", summary: `Failed on ${target} — ${cause}`, link: targetRef }; } if (execution.status === "claimed") { return { key: execution.id, status: "claimed", summary: `Running on ${target}…`, link: targetRef }; } // executed switch (execution.effectType) { case "comment_on_issue": return { key: execution.id, status: "executed", summary: `Commented on ${target}`, link: targetRef }; case "create_issue": { const createdId = typeof result.issueId === "string" ? result.issueId : null; const created = createdId ? resolve(createdId) : null; return { key: execution.id, status: "executed", summary: `Created ${created ? issueLabel(created, createdId!) : "a new issue"}`, link: created ?? targetRef, }; } case "update_issue_status": return { key: execution.id, status: "executed", summary: `Set ${target} to ${humanStatus(typeof result.status === "string" ? result.status : null)}`, link: targetRef }; case "assign_issue": return { key: execution.id, status: "executed", summary: `Reassigned ${target}`, link: targetRef }; case "resolve_blocker": { const removed = Array.isArray(result.removedBlockedByIssueIds) ? result.removedBlockedByIssueIds.length : 0; return { key: execution.id, status: "executed", summary: `Removed ${pluralize(removed, "blocker")} from ${target}`, link: targetRef }; } case "cancel_issue_tree": { const cancelled = Array.isArray(result.cancelledIssueIds) ? result.cancelledIssueIds.length : 0; return { key: execution.id, status: "executed", summary: `Cancelled ${pluralize(cancelled, "issue")} under ${target}`, link: targetRef }; } default: return { key: execution.id, status: "executed", summary: `Applied effect on ${target}`, link: targetRef }; } } // --- shell / badge palette (matches IssueThreadInteractionCard) -------------- type CardTone = "pending" | "destructive" | "success" | "partial" | "failed" | "neutral"; const SHELL: Record = { pending: "border-sky-500/70", destructive: "border-2 border-rose-500/80", success: "border-emerald-400/70", partial: "border-amber-400/70", failed: "border-rose-400/70", neutral: "border-border/60", }; const BADGE: Record = { pending: "border-sky-500/60 bg-sky-500/10 text-sky-900 dark:bg-sky-500/15 dark:text-sky-100", destructive: "border-rose-500/60 bg-rose-500/10 text-rose-800 dark:bg-rose-500/15 dark:text-rose-100", success: "border-emerald-500/60 bg-emerald-500/10 text-emerald-900 dark:bg-emerald-500/15 dark:text-emerald-100", partial: "border-amber-500/60 bg-amber-500/10 text-amber-900 dark:bg-amber-500/15 dark:text-amber-100", failed: "border-rose-500/60 bg-rose-500/10 text-rose-800 dark:bg-rose-500/15 dark:text-rose-100", neutral: "border-border/70 bg-muted/50 text-muted-foreground", }; function IssueLink({ ref: link }: { ref: DecisionIssueRef | null }) { if (!link) return null; return ( {issueLabel(link, link.id)} ); } const RESULT_ICON: Record = { executed: , failed: , skipped: , claimed: , }; export function DecisionCard({ decision, executions, targetChanged, resolveIssue = () => null, cancelTreePreview, originAgentName, originIssue, runHref, busy = false, errorMessage, onDecide, onDismiss, className, }: DecisionCardProps) { const [inputValues, setInputValues] = useState>({}); const [confirmOptionId, setConfirmOptionId] = useState(null); const [confirmText, setConfirmText] = useState(""); const open = decision.status === "open"; const dismissed = decision.chosenOptionId === "dismissed" || (decision.metadata as { dismissed?: boolean } | null)?.dismissed === true; const snapshots = (decision.targetSnapshots ?? {}) as Record; const staleTargetIds = useMemo( () => (open ? Object.entries(targetChanged ?? {}).filter(([, changed]) => changed).map(([id]) => id) : []), [open, targetChanged], ); const staleTargetIdSet = useMemo(() => new Set(staleTargetIds), [staleTargetIds]); const isStale = staleTargetIds.length > 0; const hasCancelTree = decision.options.some((option) => option.effects.some((effect) => effect.type === "cancel_issue_tree")); const tone: CardTone = open ? hasCancelTree ? "destructive" : "pending" : decision.status !== "decided" ? "neutral" // expired / cancelled : dismissed ? "neutral" : decision.executionStatus === "succeeded" ? "success" : decision.executionStatus === "partial" ? "partial" : "failed"; const badgeLabel = open ? "Pending" : decision.status === "expired" ? "Expired" : decision.status === "cancelled" ? "Cancelled" : dismissed ? "Dismissed" : decision.executionStatus === "succeeded" ? "Decided" : decision.executionStatus === "partial" ? "Partial" : "Failed"; const requiredUnmet = (decision.inputs ?? []).some( (field) => field.required && !(inputValues[field.id] ?? "").trim(), ); const runOption = (option: DecisionOption) => { if (busy) return; const cancelTree = cancelTreeEffect(option); if (cancelTree && confirmOptionId !== option.id) { setConfirmOptionId(option.id); setConfirmText(""); return; } onDecide?.(option.id, inputValues); }; const dimmed = decision.status === "expired" || decision.status === "cancelled"; const expiredReason = (decision.metadata as { expiredReason?: string } | null)?.expiredReason; // The issues this decision acts on. The origin issue is only where the agent // was running when it proposed the decision — the queue links there, but the // decision may target a different issue entirely, so name the targets // explicitly or the card is undiscoverable from the issue it applies to. const targetRefs = useMemo(() => { const ids = new Set(); for (const option of decision.options) { for (const effect of option.effects) { for (const id of decisionEffectTargetIssueIds(effect)) ids.add(id); } } if (originIssue?.id) ids.delete(originIssue.id); return [...ids].map((id) => ({ id, ref: resolveIssue(id) })); }, [decision.options, originIssue?.id, resolveIssue]); return (
{/* Header */}

{decision.title}

{open && hasCancelTree && ( Destructive )} {badgeLabel}
{/* Provenance */}

Proposed by {originAgentName ?? "an agent"} {originIssue && ( <> {" "}while running{" "} {issueLabel(originIssue, originIssue.id)} )} {targetRefs.length > 0 && ( <> {" · applies to "} {targetRefs.map(({ id, ref }, index) => ( {index > 0 && ", "} {ref ? ( {issueLabel(ref, id)} ) : ( {issueLabel(null, id)} )} ))} )} {runHref && ( <> {" · "} view run )}

{/* Body */} {decision.body?.trim() && (
{decision.body}
)} {/* Stale-target warning (open only) */} {open && isStale && (
{pluralize(staleTargetIds.length, "target")} changed since this was proposed
    {staleTargetIds.map((id) => { const ref = resolveIssue(id); const from = snapshots[id]; return (
  • {issueLabel(ref, id)}: {humanStatus(from?.status)} {humanStatus(ref?.status) || "changed"}
  • ); })}

Options that require an unchanged target are disabled below.

)} {/* Inputs (open only) */} {open && (decision.inputs ?? []).length > 0 && (
{(decision.inputs ?? []).map((field) => (