import { useEffect, useMemo, useState } from "react"; import type { Agent } from "@paperclipai/shared"; import { AlertTriangle, CheckCircle2, ChevronRight, CircleDashed, GitBranch, ListChecks, Loader2, MessageSquareQuote, Sparkles, 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 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 { 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, selectedClientKeys?: string[], ) => Promise | void; onRejectInteraction?: ( interaction: SuggestTasksInteraction, reason?: string, ) => Promise | void; onSubmitInteractionAnswers?: ( interaction: AskUserQuestionsInteraction, answers: AskUserQuestionsAnswer[], ) => 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 "expired": return "Expired"; case "failed": return "Failed"; default: return status; } } function statusIcon(status: IssueThreadInteraction["status"]) { switch (status) { case "accepted": case "answered": return CheckCircle2; case "rejected": 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-300/80 bg-card", badge: "border-emerald-400/60 bg-emerald-100/80 text-emerald-950", }; case "rejected": return { shell: "border-rose-300/80 bg-card", badge: "border-rose-400/60 bg-rose-100/85 text-rose-950", }; case "failed": case "expired": return { shell: "border-amber-300/80 bg-card", badge: "border-amber-400/60 bg-amber-100/85 text-amber-950", }; default: return { shell: "border-sky-300/80 bg-card", badge: "border-sky-400/60 bg-sky-100/85 text-sky-950", }; } } 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, ); return (
0 && "relative pl-5")}> {depth > 0 ? (
); } 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 (
Suggested tasks {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 ? (