import { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import type { Agent } from "@paperclipai/shared"; import { AlertTriangle, ArrowUpRight, Bot, Check, CheckCircle2, ChevronDown, ChevronRight, CircleDashed, Clock, ExternalLink, FileText, GitBranch, ImagePlus, KeyRound, Loader2, MessageSquareQuote, MinusCircle, ShieldAlert, ThumbsUp, TriangleAlert, Wrench, X, XCircle } from "lucide-react"; import { Link } from "@/lib/router"; import { formatAssigneeUserLabel } from "../lib/assignees"; import { describeInteractionAudience, type InteractionAudienceDescription } from "../lib/interaction-audience"; import { interactionResolutionErrorMessage } from "../lib/interaction-resolution-error"; import { isInteractionPreparingApproval, buildSuggestedTaskTree, collectSuggestedTaskClientKeys, countSuggestedTaskNodes, getCheckboxConfirmationSelectedLabels, getItemVerdictProgress, getQuestionAnswerLabels, shouldHideInteractionCard, normalizeRequestConfirmationTargetHref, type AskUserQuestionsAnswer, type AskUserQuestionsInteraction, type IssueThreadInteraction, type RequestCheckboxConfirmationInteraction, type RequestConfirmationInteraction, type RequestConfirmationTarget, type RequestItemVerdictsInteraction, type RequestItemVerdictsItem, type RequestItemVerdictsResultItem, type RequestItemVerdictValue, type SuggestTasksInteraction, type SuggestTasksResultCreatedTask, type SuggestedTaskDraft, type SuggestedTaskTreeNode, } from "../lib/issue-thread-interactions"; import { cn, formatDateTime, formatShortDate } from "../lib/utils"; import { InteractionPreparationNotice } from "./InteractionPreparationNotice"; import { InteractionAudienceLine } from "./InteractionAudienceLine"; import { MarkdownBody, type MarkdownExternalReferenceMap } from "./MarkdownBody"; import { Button } from "./ui/button"; import { Checkbox } from "./ui/checkbox"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible"; import { PriorityIcon } from "./PriorityIcon"; import { SHOW_TASK_PRIORITY_UI } from "../lib/ui-flags"; import { Textarea } from "./ui/textarea"; import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; import { Badge } from "@/components/ui/badge"; import { ProposalJustification } from "../pages/secrets/proposal-review"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "./ui/dropdown-menu"; import { AppLogo } from "@/pages/apps/AppLogo"; import { ConnectionIntentInteractionBody } from "@/features/connections/ConnectionIntentInteractionBody"; import { QuestionForm } from "./task-chat/QuestionForm"; const OTHER_ANSWER_ID = "__paperclip_other__"; /** * The card's server-evaluated audience, shared with the per-kind subcards below * (PAP-17287). A subcard that catches a rejected resolution needs to name who * *can* respond, and re-deriving the audience per subcard would let two parts of * the same card describe one policy differently. */ const InteractionAudienceContext = createContext(null); /** * Turns a rejected resolution into copy for the inline error region: the * server's own denial reason, plus who can respond when the denial is an * audience refusal. Never invites a retry that policy will refuse again. */ function useResolutionErrorMessage() { const audience = useContext(InteractionAudienceContext); return (error: unknown) => interactionResolutionErrorMessage(error, audience); } /** * The inline resolution error. Announced through an `aria-live` region because a * denial is the only feedback a failed decision gets — the row stays put and no * toast fires on the attention surface. * * The live region is the *outer* wrapper, mounted whether or not there is a * message: a region has to be in the accessibility tree before its content * changes for the change to be announced. The styled inner div deliberately * carries no `role="alert"` — `alert` is itself an assertive live region, and * nesting one inside another makes some screen reader / browser pairs announce * the same denial twice (PAP-17289). */ function InteractionActionError({ message }: { message: string | null }) { return (
{message ? (
{message}
) : null}
); } interface IssueThreadInteractionCardProps { interaction: IssueThreadInteraction; agentMap?: Map; currentUserId?: string | null; userLabelMap?: ReadonlyMap | null; onAcceptInteraction?: ( interaction: | SuggestTasksInteraction | RequestConfirmationInteraction | RequestCheckboxConfirmationInteraction, selectedClientKeys?: string[], selectedOptionIds?: string[], rememberAction?: boolean, ) => Promise | void; onRejectInteraction?: ( interaction: | SuggestTasksInteraction | RequestConfirmationInteraction | RequestCheckboxConfirmationInteraction, reason?: string, ) => Promise | void; onSubmitInteractionAnswers?: ( interaction: AskUserQuestionsInteraction, answers: AskUserQuestionsAnswer[], ) => Promise | void; onCancelInteraction?: ( interaction: AskUserQuestionsInteraction, ) => Promise | void; /** Render confirmation CTAs with the primary action rightmost (task-chat grammar). */ primaryActionOnRight?: boolean; onSubmitInteractionVerdicts?: ( interaction: RequestItemVerdictsInteraction, verdicts: { id: string; verdict: RequestItemVerdictValue; reason?: string }[], ) => Promise | void; onUploadImage?: (file: File) => Promise; externalReferences?: MarkdownExternalReferenceMap; } function resolveActorLabel(args: { agentId?: string | null; userId?: string | null; agentMap?: Map; currentUserId?: string | null; userLabelMap?: ReadonlyMap | null; }) { const { agentId, userId, agentMap, currentUserId, userLabelMap } = args; if (agentId) { return agentMap?.get(agentId)?.name ?? agentId.slice(0, 8); } if (userId) { return formatAssigneeUserLabel(userId, currentUserId, userLabelMap) ?? "Board"; } return "Unknown"; } /** * Administrative terminal outcomes (P1): an interaction that was withdrawn by * its board/agent, or auto-expired when its issue reached a terminal state. * Both are stored as `status="cancelled"|"expired"` with the distinguishing * fact carried on `result.outcome` (there is no dedicated `withdrawn` status). */ function getAdministrativeOutcome( interaction: IssueThreadInteraction, ): "withdrawn" | "issue_closed" | null { const result = interaction.result; if (result && typeof result === "object" && "outcome" in result) { const outcome = (result as { outcome?: string | null }).outcome; if (outcome === "withdrawn" || outcome === "issue_closed") return outcome; } return null; } function getAdministrativeReason(interaction: IssueThreadInteraction): string | null { const result = interaction.result; if (result && typeof result === "object" && "reason" in result) { const reason = (result as { reason?: string | null }).reason; if (typeof reason === "string" && reason.trim().length > 0) return reason.trim(); } return null; } function statusLabel(status: IssueThreadInteraction["status"]) { switch (status) { case "pending": return "Pending"; case "accepted": return "Accepted"; case "rejected": return "Rejected"; case "answered": return "Answered"; case "cancelled": return "Cancelled"; case "expired": return "Expired"; case "failed": return "Failed"; default: return status; } } function interactionKindLabel(kind: IssueThreadInteraction["kind"]) { switch (kind) { case "suggest_tasks": return "Suggested tasks"; case "ask_user_questions": return "Ask user questions"; case "request_confirmation": return "Confirmation"; case "request_checkbox_confirmation": return "Checkbox confirmation"; case "request_item_verdicts": return "Item verdicts"; case "connection_intent": return "Connection request"; default: return kind; } } function statusIcon(status: IssueThreadInteraction["status"]) { switch (status) { case "accepted": case "answered": return CheckCircle2; case "rejected": case "cancelled": case "failed": return XCircle; case "expired": return AlertTriangle; default: return CircleDashed; } } function statusClasses(status: IssueThreadInteraction["status"]) { switch (status) { case "accepted": case "answered": return { shell: "border-emerald-400/70 bg-transparent", badge: "border-emerald-500/60 bg-emerald-500/10 text-emerald-900 dark:bg-emerald-500/15 dark:text-emerald-100", }; case "rejected": case "cancelled": return { shell: "border-rose-400/70 bg-transparent", badge: "border-rose-500/60 bg-rose-500/10 text-rose-900 dark:bg-rose-500/15 dark:text-rose-100", }; case "failed": case "expired": return { shell: "border-amber-400/70 bg-transparent", badge: "border-amber-500/60 bg-amber-500/10 text-amber-900 dark:bg-amber-500/15 dark:text-amber-100", }; default: return { shell: "border-sky-500/70 bg-transparent", badge: "border-sky-500/70 bg-sky-500/10 text-sky-900 dark:bg-sky-500/15 dark:text-sky-100", }; } } /** * A confirmation that targets the issue's `plan` document renders as a distinct * plan card (PAP-95g): a full state-coloured outline — violet while in review, * green once approved, red when changes are requested (PAP-75 palette) — with no * left stripe, so plans stand out from comments and status rows. */ function isPlanConfirmation(interaction: IssueThreadInteraction): boolean { if (interaction.kind !== "request_confirmation") return false; const target = interaction.payload.target; return target?.type === "issue_document" && target?.key === "plan"; } function requestConfirmationResumeFailure(interaction: IssueThreadInteraction) { if (interaction.kind !== "request_confirmation" && interaction.kind !== "request_checkbox_confirmation") return null; return interaction.result?.resumeFailure ?? null; } function planStatusClasses( status: IssueThreadInteraction["status"], resumeFailure?: ReturnType, outcome?: string | null, ) { switch (status) { case "accepted": case "answered": if (resumeFailure) { return { shell: "border-2 border-amber-500/70 bg-transparent", badge: "border-amber-500/60 bg-amber-500/10 text-amber-900 dark:bg-amber-500/15 dark:text-amber-100", label: "Approved — agent resume failed", Icon: AlertTriangle, }; } return { shell: "border-2 border-green-500/80 bg-transparent", badge: "border-green-500/60 bg-green-500/10 text-green-900 dark:bg-green-500/15 dark:text-green-100", label: "Approved", Icon: CheckCircle2, }; case "rejected": case "cancelled": return { shell: "border-2 border-red-500/80 bg-transparent", badge: "border-red-500/60 bg-red-500/10 text-red-900 dark:bg-red-500/15 dark:text-red-100", label: outcome === "withdrawn" ? "Withdrawn" : "Changes requested", Icon: XCircle, }; case "failed": case "expired": return { shell: "border-2 border-amber-500/70 bg-transparent", badge: "border-amber-500/60 bg-amber-500/10 text-amber-900 dark:bg-amber-500/15 dark:text-amber-100", label: "Expired", Icon: AlertTriangle, }; default: return { shell: "border-2 border-violet-500/80 bg-transparent", badge: "border-violet-500/60 bg-violet-500/10 text-violet-900 dark:bg-violet-500/15 dark:text-violet-100", label: "In review", Icon: FileText, }; } } /** * A `request_confirmation` that carries a `payload.toolAction` block gates a * write/destructive MCP tool call (PAP-13726 §D1). It renders as a dedicated * tool-approval card (PAP-13745) instead of the generic confirmation rendering. * The governing rule: approve = run, so the card never terminally reads * "Accepted" — terminal states are Executed / Failed / Declined / Expired. */ function toolActionPayload( interaction: IssueThreadInteraction, ): NonNullable | null { if (interaction.kind !== "request_confirmation") return null; return interaction.payload.toolAction ?? null; } function isToolActionConfirmation(interaction: IssueThreadInteraction): boolean { return toolActionPayload(interaction) != null; } function secretProposalPayload( interaction: IssueThreadInteraction, ): NonNullable | null { if (interaction.kind !== "request_confirmation") return null; return interaction.payload.secretProposal ?? null; } function isSecretProposalConfirmation(interaction: IssueThreadInteraction): boolean { return secretProposalPayload(interaction) != null; } /** * A `request_confirmation` carrying `payload.connectionAuthorization` is asking * one person to connect their own account so an agent can act as them * (PAP-17835). It keeps the interaction kind and the server-addressed audience; * only the presentation differs, and it reads that presentation from the payload * rather than parsing the title string. */ function connectionAuthorizationPayload( interaction: IssueThreadInteraction, ): NonNullable | null { if (interaction.kind !== "request_confirmation") return null; return interaction.payload.connectionAuthorization ?? null; } /** * The five states the connection-authorization card can be read in (PAP-17859). * * `actionable` and `waiting` are the *same* pending row seen by two different * readers: consent belongs to the addressed person alone, so who is looking * changes what may be offered — not merely whether a button is greyed out. */ type ConnectionAuthorizationCardState = | "actionable" | "waiting" | "connected" | "declined" | "expired"; function connectionAuthorizationCardState({ interaction, isAddressee, }: { interaction: RequestConfirmationInteraction; isAddressee: boolean; }): ConnectionAuthorizationCardState { if (interaction.status === "accepted") return "connected"; if (interaction.status === "rejected") return "declined"; if (interaction.status === "pending") return isAddressee ? "actionable" : "waiting"; // expired / cancelled / failed all mean the same thing to a reader here: the // authorization run this card carried is over. return "expired"; } /** * True only when the signed-in reader *is* the person the server addressed. * * Deliberately strict: an unknown viewer (`currentUserId` not loaded yet) is * never treated as the addressee, so the Connect action cannot flash into view * for someone who may not consent. The server re-authorizes the callback * regardless; this only decides what the card offers. */ function isConnectionAuthorizationAddressee({ interaction, currentUserId, }: { interaction: RequestConfirmationInteraction; currentUserId?: string | null; }): boolean { const addressee = interaction.addresseeUserId; if (!addressee || !currentUserId) return false; return addressee === currentUserId; } /** * The authorization URL this card may open, or `null`. * * A target is offered **only while the interaction is pending**. The server * re-upserts this row with a freshly minted `state` every time it starts a new * authorization run, so a pending card always carries a live target and a * resolved/declined/expired one always carries a spent one. Gating on the * status is therefore how "never reuse an expired OAuth URL" is enforced — * there is no client-visible expiry on the payload to check instead. */ function connectionAuthorizationHref(interaction: RequestConfirmationInteraction): string | null { if (interaction.status !== "pending") return null; const href = interaction.payload.target?.href; if (!href) return null; return normalizeRequestConfirmationTargetHref(href); } type ToolActionCardState = | "pending" | "running" | "executed" | "failed" | "cancelled" | "declined" | "expired"; /** * Derives the visible lifecycle state from the interaction status plus the * `result.toolAction.status` written back by the gateway. The card must render * the resolved state without polling — the lifecycle metadata is authoritative, * so an optimistic "running…" reconciles to the server's terminal state. */ function toolActionCardState( interaction: RequestConfirmationInteraction, ): ToolActionCardState { const execStatus = interaction.result?.toolAction?.status ?? null; if (interaction.status === "pending") return "pending"; if (interaction.status === "rejected") return "declined"; if (interaction.status === "cancelled") return "cancelled"; if (interaction.status === "expired") return "expired"; // Terminal execution outcomes take precedence over the coarse interaction // status so a self-resolving "running…" advances to its real result. if (execStatus === "executed") return "executed"; if (execStatus === "failed") return "failed"; if (execStatus === "expired") return "expired"; if (interaction.status === "failed") return "failed"; // accepted + approved/executing/unknown → the transient running state. return "running"; } function toolActionStatusClasses(state: ToolActionCardState): { shell: string; badge: string; label: string; Icon: typeof CheckCircle2; spin?: boolean; dimmed?: boolean; } { switch (state) { case "running": return { shell: "border-2 border-amber-500/70 bg-transparent", badge: "border-amber-500/60 bg-amber-500/10 text-amber-900 dark:bg-amber-500/15 dark:text-amber-100", label: "Running…", Icon: Loader2, spin: true, }; case "executed": return { shell: "border-2 border-green-500/80 bg-transparent", badge: "border-green-500/60 bg-green-500/10 text-green-900 dark:bg-green-500/15 dark:text-green-100", label: "Executed", Icon: CheckCircle2, }; case "failed": return { shell: "border-2 border-amber-500/70 bg-transparent", badge: "border-amber-500/60 bg-amber-500/10 text-amber-900 dark:bg-amber-500/15 dark:text-amber-100", label: "Failed", Icon: XCircle, }; case "declined": return { shell: "border-2 border-red-500/80 bg-transparent", badge: "border-red-500/60 bg-red-500/10 text-red-900 dark:bg-red-500/15 dark:text-red-100", label: "Declined", Icon: XCircle, dimmed: true, }; case "cancelled": return { shell: "border-2 border-border bg-transparent", badge: "border-border bg-muted text-muted-foreground", label: "Cancelled", Icon: XCircle, dimmed: true }; case "expired": return { shell: "border-2 border-border bg-transparent", badge: "border-border bg-muted/60 text-muted-foreground", label: "Expired", Icon: Clock, dimmed: true, }; default: return { shell: "border-2 border-violet-500/80 bg-transparent", badge: "border-violet-500/60 bg-violet-500/10 text-violet-900 dark:bg-violet-500/15 dark:text-violet-100", label: "Awaiting approval", Icon: ShieldAlert, }; } } function TaskField({ label, value, tone = "default", }: { label: string; value: string; tone?: "default" | "subtle"; }) { return ( {label}: {value} ); } function createdTaskMap( createdTasks: readonly SuggestTasksResultCreatedTask[] | undefined, ) { return new Map( (createdTasks ?? []).map((entry) => [entry.clientKey, entry] as const), ); } function TaskTreeNode({ node, createdByClientKey, agentMap, currentUserId, userLabelMap, depth = 0, selectedClientKeys, skippedClientKeys, showSelection, onToggleSelection, }: { node: SuggestedTaskTreeNode; createdByClientKey: ReadonlyMap; agentMap?: Map; currentUserId?: string | null; userLabelMap?: ReadonlyMap | null; depth?: number; selectedClientKeys?: ReadonlySet; skippedClientKeys?: ReadonlySet; showSelection?: boolean; onToggleSelection?: (node: SuggestedTaskTreeNode, checked: boolean) => void; }) { const visibleChildren = node.children.filter((child) => !child.task.hiddenInPreview); const hiddenChildCount = node.children .filter((child) => child.task.hiddenInPreview) .reduce((sum, child) => sum + countSuggestedTaskNodes(child), 0); const createdTask = createdByClientKey.get(node.task.clientKey); const isSelected = selectedClientKeys?.has(node.task.clientKey) ?? false; const isSkipped = skippedClientKeys?.has(node.task.clientKey) ?? false; const assigneeLabel = resolveActorLabel({ agentId: node.task.assigneeAgentId, userId: node.task.assigneeUserId, agentMap, currentUserId, userLabelMap, }); const hasExplicitAssignee = Boolean( node.task.assigneeAgentId || node.task.assigneeUserId, ); const labels = node.task.labels ?? []; const hasMetadata = hasExplicitAssignee || Boolean(node.task.billingCode) || Boolean(node.task.projectId) || labels.length > 0; return ( <>
0 && "before:absolute before:left-3 before:top-0 before:h-full before:w-px before:bg-border/70", )} style={depth > 0 ? { paddingLeft: `${depth * 24 + 12}px` } : undefined} >
{showSelection ? ( onToggleSelection?.(node, checked === true)} aria-label={`Include ${node.task.title}`} className="mt-0.5" /> ) : null}
{/* PAP-411: priority UI hidden behind SHOW_TASK_PRIORITY_UI. */} {SHOW_TASK_PRIORITY_UI && node.task.priority ? ( ) : null}
{node.task.title}
{depth > 0 ? (
Child task
) : null} {node.task.description ? (

{node.task.description}

) : null}
{createdTask?.issueId ? ( {createdTask.identifier ?? createdTask.issueId.slice(0, 8)} ) : isSkipped ? ( Skipped ) : null}
{hasMetadata ? (
{hasExplicitAssignee ? ( ) : null} {node.task.billingCode ? ( ) : null} {node.task.projectId ? ( ) : null} {labels.map((label) => ( ))}
) : null} {hiddenChildCount > 0 ? (
{hiddenChildCount === 1 ? "1 follow-on task hidden in preview" : `${hiddenChildCount} follow-on tasks hidden in preview`}
) : null}
{visibleChildren.length > 0 ? ( <> {visibleChildren.map((child) => ( ))} ) : null} ); } function SuggestTasksCard({ interaction, agentMap, currentUserId, userLabelMap, onAcceptInteraction, onRejectInteraction, }: { interaction: SuggestTasksInteraction; agentMap?: Map; currentUserId?: string | null; userLabelMap?: ReadonlyMap | null; onAcceptInteraction?: ( interaction: SuggestTasksInteraction, selectedClientKeys?: string[], ) => Promise | void; onRejectInteraction?: ( interaction: SuggestTasksInteraction, reason?: string, ) => Promise | void; }) { const [rejecting, setRejecting] = useState(false); const [working, setWorking] = useState<"accept" | "reject" | null>(null); const [rejectReason, setRejectReason] = useState( interaction.result?.rejectionReason ?? "", ); useEffect(() => { setRejectReason(interaction.result?.rejectionReason ?? ""); if (interaction.status !== "pending") { setRejecting(false); setWorking(null); } }, [interaction.result?.rejectionReason, interaction.status]); const roots = useMemo( () => buildSuggestedTaskTree(interaction.payload.tasks).filter( (node) => !node.task.hiddenInPreview, ), [interaction.payload.tasks], ); const createdByClientKey = useMemo( () => createdTaskMap(interaction.result?.createdTasks), [interaction.result?.createdTasks], ); const skippedClientKeys = useMemo( () => new Set(interaction.result?.skippedClientKeys ?? []), [interaction.result?.skippedClientKeys], ); const totalTasks = interaction.payload.tasks.length; const [selectedClientKeys, setSelectedClientKeys] = useState>( () => new Set(interaction.payload.tasks.map((task) => task.clientKey)), ); const taskSelectionSeed = useMemo( () => interaction.payload.tasks.map((task) => task.clientKey).join("\n"), [interaction.payload.tasks], ); useEffect(() => { setSelectedClientKeys(new Set(interaction.payload.tasks.map((task) => task.clientKey))); }, [interaction.id, interaction.status, taskSelectionSeed]); const taskByClientKey = useMemo( () => new Map(interaction.payload.tasks.map((task) => [task.clientKey, task] as const)), [interaction.payload.tasks], ); const selectedCount = selectedClientKeys.size; const [actionError, setActionError] = useState(null); const resolutionErrorMessage = useResolutionErrorMessage(); const createdCount = interaction.result?.createdTasks?.length ?? 0; const skippedCount = interaction.result?.skippedClientKeys?.length ?? 0; async function handleAccept() { if (!onAcceptInteraction) return; setWorking("accept"); setActionError(null); try { await onAcceptInteraction(interaction, [...selectedClientKeys]); } catch (error) { setActionError(resolutionErrorMessage(error)); } finally { setWorking(null); } } async function handleReject() { if (!onRejectInteraction) return; setWorking("reject"); setActionError(null); try { await onRejectInteraction(interaction, rejectReason.trim() || undefined); setRejecting(false); } catch (error) { setActionError(resolutionErrorMessage(error)); } finally { setWorking(null); } } function handleToggleSelection(node: SuggestedTaskTreeNode, checked: boolean) { const subtreeClientKeys = collectSuggestedTaskClientKeys(node); setSelectedClientKeys((current) => { const next = new Set(current); if (!checked) { for (const clientKey of subtreeClientKeys) { next.delete(clientKey); } return next; } for (const clientKey of subtreeClientKeys) { next.add(clientKey); } let parentClientKey = taskByClientKey.get(node.task.clientKey)?.parentClientKey ?? null; while (parentClientKey) { next.add(parentClientKey); parentClientKey = taskByClientKey.get(parentClientKey)?.parentClientKey ?? null; } return next; }); } return (
{totalTasks === 1 ? "1 draft issue" : `${totalTasks} draft issues`} {interaction.payload.defaultParentId ? ( ) : null}
{roots.map((root) => ( ))}
{interaction.status === "accepted" ? (
Resolution summary

{skippedCount > 0 ? `Created ${createdCount} draft ${createdCount === 1 ? "issue" : "issues"} and skipped ${skippedCount} during review.` : `Created all ${createdCount} draft ${createdCount === 1 ? "issue" : "issues"}.`}

) : null} {interaction.status === "rejected" ? (
Rejection reason

{interaction.result?.rejectionReason || "No reason provided."}

) : null} {interaction.status === "pending" ? (
{selectedCount === totalTasks ? `All ${totalTasks} draft ${totalTasks === 1 ? "issue" : "issues"} selected` : `${selectedCount} of ${totalTasks} draft ${totalTasks === 1 ? "issue" : "issues"} selected`} {selectedCount < totalTasks ? ( {totalTasks - selectedCount} will be skipped if you accept this interaction. ) : null}
{selectedCount < totalTasks ? ( ) : null}
{rejecting ? (