import { useEffect, useMemo, useState, type ComponentProps, type ReactNode, } from "react"; import { Check, CheckCircle2, ChevronDown, ChevronLeft, ChevronRight, Circle, CircleHelp, GitBranch, ListChecks, Loader2, MessageSquareQuote, Plug, Search, X, } from "lucide-react"; import type { IssueDocument } from "@paperclipai/shared"; import type { PaperclipQuestionResponse, PaperclipQuestionSet, } from "@paperclipai/adapter-utils"; import { InteractionPreparationNotice } from "@/components/InteractionPreparationNotice"; import { IssueThreadInteractionCard } from "@/components/IssueThreadInteractionCard"; import { ConnectionIntentInteractionBody } from "@/features/connections/ConnectionIntentInteractionBody"; import { MarkdownBody } from "@/components/MarkdownBody"; import type { MentionOption } from "@/components/MarkdownEditor"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { Textarea } from "@/components/ui/textarea"; import { Input } from "@/components/ui/input"; import { describeInteractionAudience } from "@/lib/interaction-audience"; import { interactionResolutionErrorMessage } from "@/lib/interaction-resolution-error"; import { clearDraft, loadStructuredDraft, saveStructuredDraft, } from "@/lib/composer-draft"; import { isInteractionPreparingApproval, buildIssueThreadInteractionSummary, buildSuggestedTaskTree, collectSuggestedTaskClientKeys, normalizeRequestConfirmationTargetHref, type AskUserQuestionsAnswer, type AskUserQuestionsInteraction, type IssueThreadInteraction, type RequestCheckboxConfirmationInteraction, type RequestConfirmationInteraction, type RequestItemVerdictsInteraction, type RequestItemVerdictValue, type SuggestTasksInteraction, type SuggestedTaskTreeNode, } from "@/lib/issue-thread-interactions"; import { cn } from "@/lib/utils"; import { QuestionForm } from "./QuestionForm"; import { TaskChatComposerTakeoverControls, TaskChatComposerTakeoverHeader, useTaskChatComposerTakeoverActions, } from "./TaskChatComposerTakeoverContext"; import { TaskChatPlanPreviewCard } from "./TaskChatPlanPreviewCard"; import { TaskChatRichInput } from "./TaskChatRichInput"; const TAKEOVER_TEXTAREA_CLASS = "min-h-16 resize-y border-0 bg-muted/35 text-sm shadow-none focus-visible:ring-0 focus-visible:ring-offset-0"; type SharedInteractionProps = Omit< ComponentProps, "interaction" | "primaryActionOnRight" >; export interface TaskChatCompactInteractionCardProps extends SharedInteractionProps { interaction: IssueThreadInteraction; planDocument?: IssueDocument | null; showPlanPreview?: boolean; presentation?: "timeline" | "takeover"; draftKey?: string; mentions?: MentionOption[]; } const KIND_COPY = { suggest_tasks: { fallbackTitle: "Suggested tasks", label: "Tasks", icon: GitBranch, }, ask_user_questions: { fallbackTitle: "Questions", label: "Questions", icon: CircleHelp, }, request_confirmation: { fallbackTitle: "Confirmation", label: "Confirmation", icon: CheckCircle2, }, request_checkbox_confirmation: { fallbackTitle: "Choose options", label: "Selection", icon: ListChecks, }, request_item_verdicts: { fallbackTitle: "Review items", label: "Review", icon: MessageSquareQuote, }, connection_intent: { fallbackTitle: "Connect service", label: "Connection", icon: Plug, }, } as const; function statusLabel(status: IssueThreadInteraction["status"]): string { return status.replaceAll("_", " "); } function statusTone(status: IssueThreadInteraction["status"]): string { if (status === "pending") return "text-(--status-agent-running)"; if (status === "accepted" || status === "answered") return "text-(--status-task-icon-done)"; if (status === "rejected" || status === "failed") return "text-destructive"; return "text-muted-foreground"; } function StatusIcon({ status }: { status: IssueThreadInteraction["status"] }) { if (status === "pending") { return ( ); } if (status === "accepted" || status === "answered") { return ( ); } if (status === "rejected" || status === "failed") { return ; } return ( ); } function InteractionShell({ interaction, audienceLabel, presentation, children, }: { interaction: IssueThreadInteraction; audienceLabel?: string | null; presentation: "timeline" | "takeover"; children: ReactNode; }) { const kind = KIND_COPY[interaction.kind]; const Icon = kind.icon; const title = (
{interaction.title ?? kind.fallbackTitle} {interaction.kind === "request_confirmation" && interaction.payload.target?.type === "issue_document" && interaction.payload.target.key === "plan" ? ( ) : null} {interaction.status !== "pending" ? ( {statusLabel(interaction.status)} ) : null}
); return (
{presentation === "takeover" ? ( {title} ) : (
{title} {audienceLabel ? (

{audienceLabel}

) : null}
)}
{children}
); } function InteractionActionError({ message }: { message: string | null }) { return (
{message ? (
{message}
) : null}
); } function Details({ children }: { children?: ReactNode }) { if (!children) return null; return (
Details
{children}
); } function ActionRow({ children, hint, }: { children: ReactNode; hint?: string | null; }) { return (
{hint ? ( {hint} ) : ( )} {children}
); } function targetLabel( interaction: | RequestConfirmationInteraction | RequestCheckboxConfirmationInteraction | RequestItemVerdictsInteraction, ) { const target = interaction.payload.target; if (!target) return null; // A custom target key is a machine identifier for idempotency and stale // detection (e.g. `github:org/repo:pull:16`), not display copy. Only show a // custom target when the agent supplied a human label. if (target.type === "custom" && !target.label) return null; const label = target.label ?? target.key; const revision = target.revisionNumber == null ? null : `v${target.revisionNumber}`; return [label, revision].filter(Boolean).join(" · "); } function CompactTarget({ interaction, }: { interaction: | RequestConfirmationInteraction | RequestCheckboxConfirmationInteraction | RequestItemVerdictsInteraction; }) { const target = interaction.payload.target; const label = targetLabel(interaction); if (!target || !label) return null; const href = target.href ? normalizeRequestConfirmationTargetHref(target.href) : null; if (href) { return ( {label} ); } return ( {label} ); } function PlanReviewPreview({ interaction, planDocument, }: { interaction: RequestConfirmationInteraction; planDocument?: IssueDocument | null; }) { const target = interaction.payload.target; if (target?.type !== "issue_document" || target.key !== "plan") return null; const targetRevisionId = target.revisionId ?? null; const targetRevision = target.revisionNumber ?? null; const documentMatchesTarget = Boolean( planDocument && (targetRevisionId != null ? planDocument.latestRevisionId === targetRevisionId : targetRevision == null || planDocument.latestRevisionNumber === targetRevision), ); return ( ); } function receiptReason(interaction: IssueThreadInteraction): string | null { const result = interaction.result; if (!result) return null; if ("reason" in result && typeof result.reason === "string") return result.reason; if (interaction.kind === "ask_user_questions") { return ( interaction.result?.cancellationReason ?? interaction.result?.expirationReason?.replaceAll("_", " ") ?? null ); } return null; } function ReceiptDisclosure({ interaction, externalReferences, compact = false, }: { interaction: IssueThreadInteraction; externalReferences?: SharedInteractionProps["externalReferences"]; compact?: boolean; }) { const reason = receiptReason(interaction); const answerReason = reason ?? (interaction.kind === "suggest_tasks" ? (interaction.result?.rejectionReason ?? null) : null); let request: ReactNode; if (interaction.kind === "ask_user_questions") { const questionSet = questionSetForInteraction(interaction); const response = questionResponseForInteraction(interaction, questionSet); request = (
{questionSet.description ? (

{questionSet.description}

) : null} {questionSet.questions.map((question) => { const answer = response?.answers[question.id]; const selected = (answer?.selectedOptionIds ?? []).map( (optionId) => question.options?.find((option) => option.id === optionId) ?.label ?? optionId, ); const values = [...selected, answer?.text, answer?.customText].filter( (value): value is string => Boolean(value), ); return (
{question.header ? (

{question.header}

) : null}

{question.prompt}

{question.helpText ? (

{question.helpText}

) : null}

Answer: {values.length > 0 ? values.join(", ") : "No answer"}

); })}
); } else if (interaction.kind === "request_checkbox_confirmation") { const selected = new Set(interaction.result?.selectedOptionIds ?? []); const selectedLabels = interaction.payload.options .filter((option) => selected.has(option.id)) .map((option) => option.label); request = (

{interaction.payload.prompt}

{interaction.status === "accepted" ? (

Answer:{" "} {selectedLabels.length > 0 ? selectedLabels.join(", ") : "No options selected"}

) : null} {interaction.payload.detailsMarkdown ? (
{interaction.payload.detailsMarkdown}
) : null}
); } else if (interaction.kind === "request_item_verdicts") { const verdicts = new Map( (interaction.result?.items ?? []).map((item) => [item.id, item]), ); const decidedItems = interaction.payload.items.filter((item) => verdicts.has(item.id), ); request = (

{interaction.payload.prompt}

{decidedItems.length > 0 ? (
    {decidedItems.map((item) => { const verdict = verdicts.get(item.id); return (
  • {item.label} ·{" "} {verdict?.verdict} {verdict?.reason ? ` — ${verdict.reason}` : ""}
  • ); })}
) : (

No verdicts submitted

)} {interaction.payload.detailsMarkdown ? (
{interaction.payload.detailsMarkdown}
) : null}
); } else if (interaction.kind === "suggest_tasks") { const created = new Set( interaction.result?.createdTasks?.map((task) => task.clientKey) ?? [], ); const createdTasks = interaction.payload.tasks.filter((task) => created.has(task.clientKey), ); request = (
{createdTasks.length > 0 ? (
    {createdTasks.map((task) => (
  • {task.title}
  • ))}
) : interaction.status === "accepted" ? (

No tasks created

) : null}
); } else if (interaction.kind === "connection_intent") { request = (

{interaction.payload.requestingAgentName} requested access to{" "} {interaction.payload.serviceName}.

); } else { request = (

{interaction.payload.prompt}

{interaction.payload.detailsMarkdown ? (
{interaction.payload.detailsMarkdown}
) : null} {interaction.payload.toolAction ? (

{interaction.payload.toolAction.toolDisplayName}{" "} · {interaction.payload.toolAction.risk} risk

Expires{" "} {new Date( interaction.payload.toolAction.expiresAt, ).toLocaleString()}

{interaction.payload.toolAction.previewMarkdown}
              {interaction.payload.toolAction.argumentsSummaryJson}
            

{interaction.payload.toolAction.argumentsHash}

) : null} {interaction.payload.secretProposal ? (
Secret
{interaction.payload.secretProposal.sourceSecretLabel}
Binding
{interaction.payload.secretProposal.configPath} →{" "} {interaction.payload.secretProposal.targetAgentName}
Why
{interaction.payload.secretProposal.justification}
Expires
{new Date( interaction.payload.secretProposal.expiresAt, ).toLocaleString()}
) : null}
); } const detail = (
{request} {answerReason ? (

Reason: {answerReason}

) : null}
); if (compact) { const Icon = KIND_COPY[interaction.kind].icon; const summary = interaction.kind === "ask_user_questions" && interaction.status === "answered" ? "Questions answered" : buildIssueThreadInteractionSummary(interaction); return (
{summary} {detail}
); } return (
View original request and resolution {detail}
); } function questionSetForInteraction( interaction: AskUserQuestionsInteraction, ): PaperclipQuestionSet { if (interaction.payload.questionSet) return interaction.payload.questionSet; return { schema: "paperclip.question_set.v1", ...(interaction.title ? { title: interaction.title } : {}), ...(interaction.payload.submitLabel ? { submitLabel: interaction.payload.submitLabel } : {}), questions: interaction.payload.questions.map((question) => { const freeText = question.options.find( (option) => option.freeText === true, ); return { id: question.id, prompt: question.prompt, ...(question.helpText ? { helpText: question.helpText } : {}), required: question.required === true, answerMode: question.selectionMode === "multi" ? ("multi_select" as const) : ("single_select" as const), options: question.options .filter((option) => option.freeText !== true) .map((option) => ({ id: option.id, label: option.label, ...(option.description ? { description: option.description } : {}), })), ...(freeText ? { customAnswer: { enabled: true as const, label: freeText.label, ...(freeText.description ? { placeholder: freeText.description } : {}), }, } : {}), }; }), }; } function questionResponseForInteraction( interaction: AskUserQuestionsInteraction, questionSet: PaperclipQuestionSet, ): PaperclipQuestionResponse | null { if (!interaction.result?.answers) return null; return { schema: "paperclip.question_response.v1", answers: Object.fromEntries( interaction.result.answers.map((answer) => { const question = questionSet.questions.find( (candidate) => candidate.id === answer.questionId, ); return [ answer.questionId, question?.answerMode === "text" ? { ...(answer.otherText ? { text: answer.otherText } : {}) } : { selectedOptionIds: answer.optionIds, ...(answer.otherText ? { customText: answer.otherText } : {}), }, ]; }), ), }; } function AskUserQuestionsCard({ interaction, onSubmitInteractionAnswers, onCancelInteraction, errorMessage, draftKey, onUploadImage, mentions, }: { interaction: AskUserQuestionsInteraction; onSubmitInteractionAnswers?: SharedInteractionProps["onSubmitInteractionAnswers"]; onCancelInteraction?: SharedInteractionProps["onCancelInteraction"]; errorMessage: (error: unknown) => string; draftKey?: string; onUploadImage?: SharedInteractionProps["onUploadImage"]; mentions?: MentionOption[]; }) { const questionSet = questionSetForInteraction(interaction); const initialResponse = questionResponseForInteraction( interaction, questionSet, ); return ( { const answers: AskUserQuestionsAnswer[] = questionSet.questions.map( (question) => { const answer = response.answers[question.id]; const otherText = question.answerMode === "text" ? (interaction.payload.questionSet ? answer?.text : answer?.text?.trim()) : answer?.customText?.trim(); return { questionId: question.id, optionIds: question.answerMode === "text" ? [] : (answer?.selectedOptionIds ?? []), ...(otherText ? { otherText } : {}), }; }, ); try { await onSubmitInteractionAnswers?.(interaction, answers); } catch (error) { throw new Error(errorMessage(error)); } }} onCancel={ onCancelInteraction ? async () => { try { await onCancelInteraction(interaction); } catch (error) { throw new Error(errorMessage(error)); } } : undefined } /> ); } function ConfirmationCard({ interaction, planDocument, onAcceptInteraction, onRejectInteraction, externalReferences, errorMessage, draftKey, showPlanPreview, onUploadImage, mentions, }: { interaction: RequestConfirmationInteraction; planDocument?: IssueDocument | null; onAcceptInteraction?: SharedInteractionProps["onAcceptInteraction"]; onRejectInteraction?: SharedInteractionProps["onRejectInteraction"]; externalReferences?: SharedInteractionProps["externalReferences"]; errorMessage: (error: unknown) => string; draftKey?: string; showPlanPreview: boolean; onUploadImage?: SharedInteractionProps["onUploadImage"]; mentions?: MentionOption[]; }) { const preparingApproval = isInteractionPreparingApproval(interaction); const [rejecting, setRejecting] = useState(false); const [reason, setReason] = useState(() => draftKey ? loadStructuredDraft(draftKey, "") : "", ); const [working, setWorking] = useState<"accept" | "reject" | null>(null); const [actionError, setActionError] = useState(null); const [revisionUploading, setRevisionUploading] = useState(false); const collectsRejectReason = Boolean( interaction.payload.rejectRequiresReason || interaction.payload.allowDeclineReason || interaction.payload.declineReasonPlaceholder, ); const isPlanConfirmation = interaction.payload.target?.type === "issue_document" && interaction.payload.target.key === "plan"; useEffect(() => { if (draftKey) saveStructuredDraft(draftKey, reason); }, [draftKey, reason]); async function resolve(action: "accept" | "reject") { if ( action === "reject" && interaction.payload.rejectRequiresReason && !reason.trim() ) return; setWorking(action); setActionError(null); try { if (action === "accept") await onAcceptInteraction?.(interaction); else await onRejectInteraction?.(interaction, reason.trim() || undefined); if (draftKey) clearDraft(draftKey); } catch (error) { setActionError(errorMessage(error)); } finally { setWorking(null); } } return (
{isPlanConfirmation && showPlanPreview ? ( ) : isPlanConfirmation && rejecting ? null : (

{isPlanConfirmation ? "Do you accept this plan?" : interaction.payload.prompt}

{!isPlanConfirmation ? ( ) : null}
)} {!isPlanConfirmation && interaction.payload.detailsMarkdown ? (
{interaction.payload.detailsMarkdown}
) : null} {interaction.payload.toolAction ? (
{interaction.payload.toolAction.toolDisplayName} {interaction.payload.toolAction.risk} risk Expires{" "} {new Date( interaction.payload.toolAction.expiresAt, ).toLocaleString()}
{interaction.payload.toolAction.previewMarkdown}
Arguments and audit hash
              {interaction.payload.toolAction.argumentsSummaryJson}
            

{interaction.payload.toolAction.argumentsHash}

) : null} {interaction.payload.secretProposal ? (
Secret
{interaction.payload.secretProposal.sourceSecretLabel}
Binding
{interaction.payload.secretProposal.configPath} →{" "} {interaction.payload.secretProposal.targetAgentName}
Why
{interaction.payload.secretProposal.justification}
Expires
{new Date( interaction.payload.secretProposal.expiresAt, ).toLocaleString()}
) : null} {rejecting ? (

{interaction.payload.rejectReasonLabel ?? "What should change?"} {interaction.payload.rejectRequiresReason ? "" : " (optional)"}

{isPlanConfirmation ? ( { if (!revisionUploading) void resolve("reject"); }} ariaLabelledBy={`${interaction.id}-reject-reason-label`} testId="plan-revision-composer" showImageAttachControls={false} /> ) : (