import { AssistantRuntimeProvider, ActionBarPrimitive, ChainOfThoughtPrimitive, MessagePrimitive, ThreadPrimitive, useAui, useMessage, } from "@assistant-ui/react"; import { createContext, useContext, useEffect, useMemo, useRef, useState, type ChangeEvent } from "react"; import { Link, useLocation } from "@/lib/router"; import type { Agent, FeedbackDataSharingPreference, FeedbackVote, FeedbackVoteValue, } from "@paperclipai/shared"; import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats"; import { useLiveRunTranscripts } from "./transcript/useLiveRunTranscripts"; import { usePaperclipIssueRuntime, type PaperclipIssueRuntimeReassignment } from "../hooks/usePaperclipIssueRuntime"; import { buildIssueChatMessages, type IssueChatComment, type IssueChatLinkedRun, type IssueChatTranscriptEntry, } from "../lib/issue-chat-messages"; import type { IssueTimelineAssignee, IssueTimelineEvent } from "../lib/issue-timeline-events"; import { Button } from "@/components/ui/button"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { MarkdownBody } from "./MarkdownBody"; import { MarkdownEditor, type MentionOption, type MarkdownEditorRef } from "./MarkdownEditor"; import { Identity } from "./Identity"; import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector"; import { AgentIcon } from "./AgentIconPicker"; import { restoreSubmittedCommentDraft } from "../lib/comment-submit-draft"; import { formatAssigneeUserLabel } from "../lib/assignees"; import { timeAgo } from "../lib/timeAgo"; import { cn, formatDateTime, formatShortDate } from "../lib/utils"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Textarea } from "@/components/ui/textarea"; import { ArrowRight, Check, ChevronDown, Copy, Loader2, MoreHorizontal, Paperclip, ThumbsDown, ThumbsUp } from "lucide-react"; interface IssueChatMessageContext { feedbackVoteByTargetId: Map; feedbackDataSharingPreference: FeedbackDataSharingPreference; feedbackTermsUrl: string | null; agentMap?: Map; currentUserId?: string | null; onVote?: ( commentId: string, vote: FeedbackVoteValue, options?: { allowSharing?: boolean; reason?: string }, ) => Promise; onInterruptQueued?: (runId: string) => Promise; interruptingQueuedRunId?: string | null; } const IssueChatCtx = createContext({ feedbackVoteByTargetId: new Map(), feedbackDataSharingPreference: "prompt", feedbackTermsUrl: null, }); interface CommentReassignment { assigneeAgentId: string | null; assigneeUserId: string | null; } interface IssueChatThreadProps { comments: IssueChatComment[]; feedbackVotes?: FeedbackVote[]; feedbackDataSharingPreference?: FeedbackDataSharingPreference; feedbackTermsUrl?: string | null; linkedRuns?: IssueChatLinkedRun[]; timelineEvents?: IssueTimelineEvent[]; liveRuns?: LiveRunForIssue[]; activeRun?: ActiveRunForIssue | null; companyId?: string | null; projectId?: string | null; issueStatus?: string; agentMap?: Map; currentUserId?: string | null; onVote?: ( commentId: string, vote: FeedbackVoteValue, options?: { allowSharing?: boolean; reason?: string }, ) => Promise; onAdd: (body: string, reopen?: boolean, reassignment?: CommentReassignment) => Promise; onCancelRun?: () => Promise; imageUploadHandler?: (file: File) => Promise; onAttachImage?: (file: File) => Promise; draftKey?: string; enableReassign?: boolean; reassignOptions?: InlineEntityOption[]; currentAssigneeValue?: string; suggestedAssigneeValue?: string; mentions?: MentionOption[]; composerDisabledReason?: string | null; showComposer?: boolean; enableLiveTranscriptPolling?: boolean; transcriptsByRunId?: ReadonlyMap; hasOutputForRun?: (runId: string) => boolean; onInterruptQueued?: (runId: string) => Promise; interruptingQueuedRunId?: string | null; } const DRAFT_DEBOUNCE_MS = 800; function toIsoString(value: string | Date | null | undefined): string | null { if (!value) return null; return typeof value === "string" ? value : value.toISOString(); } function loadDraft(draftKey: string): string { try { return localStorage.getItem(draftKey) ?? ""; } catch { return ""; } } function saveDraft(draftKey: string, value: string) { try { if (value.trim()) { localStorage.setItem(draftKey, value); } else { localStorage.removeItem(draftKey); } } catch { // Ignore localStorage failures. } } function clearDraft(draftKey: string) { try { localStorage.removeItem(draftKey); } catch { // Ignore localStorage failures. } } function parseReassignment(target: string): PaperclipIssueRuntimeReassignment | null { if (!target || target === "__none__") { return { assigneeAgentId: null, assigneeUserId: null }; } if (target.startsWith("agent:")) { const assigneeAgentId = target.slice("agent:".length); return assigneeAgentId ? { assigneeAgentId, assigneeUserId: null } : null; } if (target.startsWith("user:")) { const assigneeUserId = target.slice("user:".length); return assigneeUserId ? { assigneeAgentId: null, assigneeUserId } : null; } return null; } function IssueChatTextPart({ text }: { text: string }) { return {text}; } function humanizeValue(value: string | null) { if (!value) return "None"; return value.replace(/_/g, " "); } function formatTimelineAssigneeLabel( assignee: IssueTimelineAssignee, agentMap?: Map, currentUserId?: string | null, ) { if (assignee.agentId) { return agentMap?.get(assignee.agentId)?.name ?? assignee.agentId.slice(0, 8); } if (assignee.userId) { return formatAssigneeUserLabel(assignee.userId, currentUserId) ?? "Board"; } return "Unassigned"; } function initialsForName(name: string) { const parts = name.trim().split(/\s+/); if (parts.length >= 2) { return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); } return name.slice(0, 2).toUpperCase(); } function formatRunStatusLabel(status: string) { switch (status) { case "timed_out": return "timed out"; default: return status.replace(/_/g, " "); } } function runStatusClass(status: string) { switch (status) { case "succeeded": return "text-green-700 dark:text-green-300"; case "failed": case "error": return "text-red-700 dark:text-red-300"; case "timed_out": return "text-orange-700 dark:text-orange-300"; case "running": return "text-cyan-700 dark:text-cyan-300"; case "queued": case "pending": return "text-amber-700 dark:text-amber-300"; case "cancelled": return "text-muted-foreground"; default: return "text-foreground"; } } function IssueChatChainOfThought() { return ( Thinking
, tools: { Fallback: ({ toolName, argsText, result, isError }) => ( ), }, Layout: ({ children }) =>
{children}
, }} />
); } function IssueChatReasoningPart({ text }: { text: string }) { return (
{text}
); } function IssueChatToolPart({ toolName, argsText, result, isError, }: { toolName: string; argsText: string; result?: unknown; isError?: boolean; }) { const [open, setOpen] = useState(false); const resultText = typeof result === "string" ? result : result === undefined ? "" : JSON.stringify(result, null, 2); return (
{open ? (
{argsText ? (
Input
{argsText}
) : null} {result !== undefined ? (
Result
{resultText}
) : null}
) : null}
); } function IssueChatUserMessage() { const { onInterruptQueued, interruptingQueuedRunId } = useContext(IssueChatCtx); const message = useMessage(); const custom = message.metadata.custom as Record; const anchorId = typeof custom.anchorId === "string" ? custom.anchorId : undefined; const queued = custom.queueState === "queued" || custom.clientStatus === "queued"; const pending = custom.clientStatus === "pending"; const queueTargetRunId = typeof custom.queueTargetRunId === "string" ? custom.queueTargetRunId : null; const [copied, setCopied] = useState(false); return (
{queued ? (
Queued {queueTargetRunId && onInterruptQueued ? ( ) : null}
) : null} {pending ?
Sending...
: null}
, }} />
{message.createdAt ? formatShortDate(message.createdAt) : ""} {message.createdAt ? formatDateTime(message.createdAt) : ""}
); } function IssueChatAssistantMessage() { const { feedbackVoteByTargetId, feedbackDataSharingPreference, feedbackTermsUrl, onVote, agentMap, } = useContext(IssueChatCtx); const message = useMessage(); const custom = message.metadata.custom as Record; const anchorId = typeof custom.anchorId === "string" ? custom.anchorId : undefined; const authorName = typeof custom.authorName === "string" ? custom.authorName : typeof custom.runAgentName === "string" ? custom.runAgentName : "Agent"; const runId = typeof custom.runId === "string" ? custom.runId : null; const runAgentId = typeof custom.runAgentId === "string" ? custom.runAgentId : null; const runAgentIcon = runAgentId ? agentMap?.get(runAgentId)?.icon : undefined; const commentId = typeof custom.commentId === "string" ? custom.commentId : null; const notices = Array.isArray(custom.notices) ? custom.notices.filter((notice): notice is string => typeof notice === "string" && notice.length > 0) : []; const waitingText = typeof custom.waitingText === "string" ? custom.waitingText : ""; const isRunning = message.role === "assistant" && message.status?.type === "running"; const runHref = runId && runAgentId ? `/agents/${runAgentId}/runs/${runId}` : null; const handleVote = async ( vote: FeedbackVoteValue, options?: { allowSharing?: boolean; reason?: string }, ) => { if (!commentId || !onVote) return; await onVote(commentId, vote, options); }; const activeVote = commentId ? feedbackVoteByTargetId.get(commentId) ?? null : null; return (
{runAgentId ? ( {runAgentIcon ? ( ) : ( {initialsForName(authorName)} )} ) : null} {authorName} {isRunning ? ( Running ) : null}
, ChainOfThought: IssueChatChainOfThought, }} /> {message.content.length === 0 && waitingText ? (
{waitingText}
) : null} {notices.length > 0 ? (
{notices.map((notice, index) => (
{notice}
))}
) : null}
{commentId && onVote ? ( ) : null}
); } function IssueChatFeedbackButtons({ activeVote, sharingPreference = "prompt", termsUrl, onVote, }: { activeVote: FeedbackVoteValue | null; sharingPreference: FeedbackDataSharingPreference; termsUrl: string | null; onVote: (vote: FeedbackVoteValue, options?: { allowSharing?: boolean; reason?: string }) => Promise; }) { const [isSaving, setIsSaving] = useState(false); const [optimisticVote, setOptimisticVote] = useState(null); const [reasonOpen, setReasonOpen] = useState(false); const [downvoteReason, setDownvoteReason] = useState(""); const [pendingSharingDialog, setPendingSharingDialog] = useState<{ vote: FeedbackVoteValue; reason?: string; } | null>(null); const visibleVote = optimisticVote ?? activeVote ?? null; useEffect(() => { if (optimisticVote && activeVote === optimisticVote) setOptimisticVote(null); }, [activeVote, optimisticVote]); async function doVote( vote: FeedbackVoteValue, options?: { allowSharing?: boolean; reason?: string }, ) { setIsSaving(true); try { await onVote(vote, options); } catch { setOptimisticVote(null); } finally { setIsSaving(false); } } function handleVote(vote: FeedbackVoteValue, reason?: string) { setOptimisticVote(vote); if (sharingPreference === "prompt") { setPendingSharingDialog({ vote, ...(reason ? { reason } : {}) }); return; } const allowSharing = sharingPreference === "allowed"; void doVote(vote, { ...(allowSharing ? { allowSharing: true } : {}), ...(reason ? { reason } : {}), }); } function handleThumbsUp() { handleVote("up"); } function handleThumbsDown() { setOptimisticVote("down"); setReasonOpen(true); // Submit the initial down vote right away handleVote("down"); } function handleSubmitReason() { if (!downvoteReason.trim()) return; // Re-submit with reason attached if (sharingPreference === "prompt") { setPendingSharingDialog({ vote: "down", reason: downvoteReason }); } else { const allowSharing = sharingPreference === "allowed"; void doVote("down", { ...(allowSharing ? { allowSharing: true } : {}), reason: downvoteReason, }); } setReasonOpen(false); setDownvoteReason(""); } return ( <>
What could have been better?