diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index 3febbc05ae8..32512224550 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -45,6 +45,13 @@ import { type IssueChatTranscriptEntry, type SegmentTiming, } from "../lib/issue-chat-messages"; +import type { + AskUserQuestionsAnswer, + AskUserQuestionsInteraction, + IssueThreadInteraction, + SuggestTasksInteraction, +} from "../lib/issue-thread-interactions"; +import { isIssueThreadInteraction } from "../lib/issue-thread-interactions"; import { resolveIssueChatTranscriptRuns } from "../lib/issueChatTranscriptRuns"; import type { IssueTimelineAssignee, IssueTimelineEvent } from "../lib/issue-timeline-events"; import { Button } from "@/components/ui/button"; @@ -67,6 +74,7 @@ import { MarkdownBody } from "./MarkdownBody"; import { MarkdownEditor, type MentionOption, type MarkdownEditorRef } from "./MarkdownEditor"; import { Identity } from "./Identity"; import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector"; +import { IssueThreadInteractionCard } from "./IssueThreadInteractionCard"; import { AgentIcon } from "./AgentIconPicker"; import { restoreSubmittedCommentDraft } from "../lib/comment-submit-draft"; import { @@ -114,6 +122,15 @@ interface IssueChatMessageContext { onCancelQueued?: (commentId: string) => void; interruptingQueuedRunId?: string | null; onImageClick?: (src: string) => void; + onAcceptInteraction?: (interaction: SuggestTasksInteraction) => Promise | void; + onRejectInteraction?: ( + interaction: SuggestTasksInteraction, + reason?: string, + ) => Promise | void; + onSubmitInteractionAnswers?: ( + interaction: AskUserQuestionsInteraction, + answers: AskUserQuestionsAnswer[], + ) => Promise | void; } const IssueChatCtx = createContext({ @@ -211,6 +228,7 @@ interface IssueChatComposerProps { interface IssueChatThreadProps { comments: IssueChatComment[]; + interactions?: IssueThreadInteraction[]; feedbackVotes?: FeedbackVote[]; feedbackDataSharingPreference?: FeedbackDataSharingPreference; feedbackTermsUrl?: string | null; @@ -256,6 +274,15 @@ interface IssueChatThreadProps { interruptingQueuedRunId?: string | null; stoppingRunId?: string | null; onImageClick?: (src: string) => void; + onAcceptInteraction?: (interaction: SuggestTasksInteraction) => Promise | void; + onRejectInteraction?: ( + interaction: SuggestTasksInteraction, + reason?: string, + ) => Promise | void; + onSubmitInteractionAnswers?: ( + interaction: AskUserQuestionsInteraction, + answers: AskUserQuestionsAnswer[], + ) => Promise | void; composerRef?: Ref; } @@ -1698,7 +1725,14 @@ function IssueChatFeedbackButtons({ } function IssueChatSystemMessage({ message }: { message: ThreadMessage }) { - const { agentMap, currentUserId, userLabelMap } = useContext(IssueChatCtx); + const { + agentMap, + currentUserId, + userLabelMap, + onAcceptInteraction, + onRejectInteraction, + onSubmitInteractionAnswers, + } = useContext(IssueChatCtx); const custom = message.metadata.custom as Record; const anchorId = typeof custom.anchorId === "string" ? custom.anchorId : undefined; const runId = typeof custom.runId === "string" ? custom.runId : null; @@ -1717,6 +1751,27 @@ function IssueChatSystemMessage({ message }: { message: ThreadMessage }) { to: IssueTimelineAssignee; } : null; + const interaction = isIssueThreadInteraction(custom.interaction) + ? custom.interaction + : null; + + if (custom.kind === "interaction" && interaction) { + return ( +
+
+ +
+
+ ); + } if (custom.kind === "event" && actorName) { const isCurrentUser = actorType === "user" && !!currentUserId && actorId === currentUserId; @@ -2077,6 +2132,7 @@ const IssueChatComposer = forwardRef buildIssueChatMessages({ comments, + interactions, timelineEvents, linkedRuns, liveRuns, @@ -2188,6 +2248,7 @@ export function IssueChatThread({ }), [ comments, + interactions, timelineEvents, linkedRuns, liveRuns, @@ -2256,7 +2317,14 @@ export function IssueChatThread({ useEffect(() => { const hash = location.hash; - if (!(hash.startsWith("#comment-") || hash.startsWith("#activity-") || hash.startsWith("#run-"))) return; + if ( + !( + hash.startsWith("#comment-") + || hash.startsWith("#activity-") + || hash.startsWith("#run-") + || hash.startsWith("#interaction-") + ) + ) return; if (messages.length === 0 || hasScrolledRef.current) return; const targetId = hash.slice(1); const element = document.getElementById(targetId); @@ -2286,6 +2354,9 @@ export function IssueChatThread({ onCancelQueued, interruptingQueuedRunId, onImageClick, + onAcceptInteraction, + onRejectInteraction, + onSubmitInteractionAnswers, }), [ feedbackVoteByTargetId, @@ -2303,6 +2374,9 @@ export function IssueChatThread({ onCancelQueued, interruptingQueuedRunId, onImageClick, + onAcceptInteraction, + onRejectInteraction, + onSubmitInteractionAnswers, ], ); diff --git a/ui/src/components/IssueThreadInteractionCard.tsx b/ui/src/components/IssueThreadInteractionCard.tsx new file mode 100644 index 00000000000..0227fa939d9 --- /dev/null +++ b/ui/src/components/IssueThreadInteractionCard.tsx @@ -0,0 +1,743 @@ +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, + 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 { 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) => 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/70 bg-[linear-gradient(145deg,rgba(16,185,129,0.14),rgba(255,255,255,0.96)_42%,rgba(6,182,212,0.08))]", + badge: "border-emerald-400/60 bg-emerald-100/80 text-emerald-950", + }; + case "rejected": + return { + shell: "border-rose-300/70 bg-[linear-gradient(145deg,rgba(244,63,94,0.12),rgba(255,255,255,0.96)_42%,rgba(251,191,36,0.08))]", + badge: "border-rose-400/60 bg-rose-100/85 text-rose-950", + }; + case "failed": + case "expired": + return { + shell: "border-amber-300/70 bg-[linear-gradient(145deg,rgba(245,158,11,0.14),rgba(255,255,255,0.96)_42%,rgba(148,163,184,0.08))]", + badge: "border-amber-400/60 bg-amber-100/85 text-amber-950", + }; + default: + return { + shell: "border-sky-300/70 bg-[linear-gradient(145deg,rgba(14,165,233,0.10),rgba(255,255,255,0.96)_42%,rgba(250,204,21,0.08))]", + 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, +}: { + node: SuggestedTaskTreeNode; + createdByClientKey: ReadonlyMap; + agentMap?: Map; + currentUserId?: string | null; + userLabelMap?: ReadonlyMap | null; + depth?: number; +}) { + 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 assigneeLabel = resolveActorLabel({ + agentId: node.task.assigneeAgentId, + userId: node.task.assigneeUserId, + agentMap, + currentUserId, + userLabelMap, + }); + const hasExplicitAssignee = Boolean( + node.task.assigneeAgentId || node.task.assigneeUserId, + ); + + return ( +
+
0 && "ml-4 border-dashed shadow-none", + )} + > +
+
+
+ + + +
+
+ {node.task.title} +
+ {node.task.description ? ( +

+ {node.task.description} +

+ ) : null} +
+
+
+ + {createdTask?.issueId ? ( + + {createdTask.identifier ?? createdTask.issueId.slice(0, 8)} + + + ) : null} +
+ +
+ {node.task.priority ? ( + + ) : null} + {hasExplicitAssignee ? ( + + ) : null} + {node.task.billingCode ? ( + + ) : null} + {node.task.projectId ? ( + + ) : null} + {(node.task.labels ?? []).map((label) => ( + + ))} +
+ + {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) => 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 totalTasks = interaction.payload.tasks.length; + + async function handleAccept() { + if (!onAcceptInteraction) return; + setWorking("accept"); + try { + await onAcceptInteraction(interaction); + } finally { + setWorking(null); + } + } + + async function handleReject() { + if (!onRejectInteraction) return; + setWorking("reject"); + try { + await onRejectInteraction(interaction, rejectReason.trim() || undefined); + setRejecting(false); + } finally { + setWorking(null); + } + } + + return ( +
+
+ + + Suggested tasks + + {totalTasks === 1 ? "1 draft issue" : `${totalTasks} draft issues`} + {interaction.payload.defaultParentId ? ( + + ) : null} +
+ +
+ {roots.map((root) => ( + + ))} +
+ + {interaction.status === "rejected" && interaction.result?.rejectionReason ? ( +
+
+ Rejection reason +
+

{interaction.result.rejectionReason}

+
+ ) : null} + + {interaction.status === "pending" ? ( +
+
+ + +
+ + {rejecting ? ( +
+