import { useEffect, useMemo, useState } from "react"; import type { Agent } from "@paperclipai/shared"; import { AlertTriangle, CheckCircle2, ChevronRight, CircleDashed, GitBranch, ListChecks, Loader2, MessageSquareQuote, XCircle } from "lucide-react"; import { Link } from "@/lib/router"; import { formatAssigneeUserLabel } from "../lib/assignees"; import { buildSuggestedTaskTree, collectSuggestedTaskClientKeys, countSuggestedTaskNodes, getQuestionAnswerLabels, type AskUserQuestionsAnswer, type AskUserQuestionsInteraction, type IssueThreadInteraction, type RequestConfirmationInteraction, type RequestConfirmationTarget, type SuggestTasksInteraction, type SuggestTasksResultCreatedTask, type SuggestedTaskDraft, type SuggestedTaskTreeNode, } from "../lib/issue-thread-interactions"; import { cn, formatDateTime, formatShortDate } from "../lib/utils"; import { MarkdownBody } from "./MarkdownBody"; import { Button } from "./ui/button"; import { Checkbox } from "./ui/checkbox"; import { PriorityIcon } from "./PriorityIcon"; import { Textarea } from "./ui/textarea"; import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; interface IssueThreadInteractionCardProps { interaction: IssueThreadInteraction; agentMap?: Map; currentUserId?: string | null; userLabelMap?: ReadonlyMap | null; onAcceptInteraction?: ( interaction: SuggestTasksInteraction | RequestConfirmationInteraction, selectedClientKeys?: string[], ) => Promise | void; onRejectInteraction?: ( interaction: SuggestTasksInteraction | RequestConfirmationInteraction, reason?: string, ) => Promise | void; onSubmitInteractionAnswers?: ( interaction: AskUserQuestionsInteraction, answers: AskUserQuestionsAnswer[], ) => Promise | void; onCancelInteraction?: ( interaction: AskUserQuestionsInteraction, ) => Promise | void; } 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"; } 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"; 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", }; } } 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}
{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 createdCount = interaction.result?.createdTasks?.length ?? 0; const skippedCount = interaction.result?.skippedClientKeys?.length ?? 0; async function handleAccept() { if (!onAcceptInteraction) return; setWorking("accept"); try { await onAcceptInteraction(interaction, [...selectedClientKeys]); } finally { setWorking(null); } } async function handleReject() { if (!onRejectInteraction) return; setWorking("reject"); try { await onRejectInteraction(interaction, rejectReason.trim() || undefined); setRejecting(false); } 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 ? (