import { AssistantRuntimeProvider, ActionBarPrimitive, MessagePrimitive, ThreadPrimitive, useAui, useAuiState, useMessage, } from "@assistant-ui/react"; import type { ToolCallMessagePart } from "@assistant-ui/react"; import { createContext, forwardRef, useContext, useEffect, useImperativeHandle, useMemo, useRef, useState, type ChangeEvent, type Ref, } 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, formatDurationWords, type IssueChatComment, type IssueChatLinkedRun, type IssueChatTranscriptEntry, type SegmentTiming, } 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 { describeToolInput, displayToolName, formatToolPayload, isCommandTool, parseToolPayload, summarizeToolInput, summarizeToolResult, } from "../lib/transcriptPresentation"; 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, Brain, Check, ChevronDown, Copy, Hammer, Loader2, MoreHorizontal, Paperclip, Search, 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; onImageClick?: (src: string) => void; } const IssueChatCtx = createContext({ feedbackVoteByTargetId: new Map(), feedbackDataSharingPreference: "prompt", feedbackTermsUrl: null, }); export function resolveAssistantMessageFoldedState(args: { messageId: string; currentFolded: boolean; isFoldable: boolean; previousMessageId: string | null; previousIsFoldable: boolean; }) { const { messageId, currentFolded, isFoldable, previousMessageId, previousIsFoldable, } = args; if (messageId !== previousMessageId) return isFoldable; if (!isFoldable) return false; if (!previousIsFoldable) return true; return currentFolded; } function findCoTSegmentIndex( messageParts: ReadonlyArray<{ type: string }>, cotParts: ReadonlyArray<{ type: string }>, ): number { if (cotParts.length === 0) return -1; const firstPart = cotParts[0]; let segIdx = -1; let inCoT = false; for (const part of messageParts) { if (part.type === "reasoning" || part.type === "tool-call") { if (!inCoT) { segIdx++; inCoT = true; } if (part === firstPart) return segIdx; } else { inCoT = false; } } return -1; } function useLiveElapsed(startMs: number | null | undefined, active: boolean): string | null { const [, rerender] = useState(0); useEffect(() => { if (!active || !startMs) return; const interval = setInterval(() => rerender((n) => n + 1), 1000); return () => clearInterval(interval); }, [active, startMs]); if (!active || !startMs) return null; return formatDurationWords(Date.now() - startMs); } interface CommentReassignment { assigneeAgentId: string | null; assigneeUserId: string | null; } export interface IssueChatComposerHandle { focus: () => void; } interface IssueChatComposerProps { onImageUpload?: (file: File) => Promise; onAttachImage?: (file: File) => Promise; draftKey?: string; enableReassign?: boolean; reassignOptions?: InlineEntityOption[]; currentAssigneeValue?: string; suggestedAssigneeValue?: string; mentions?: MentionOption[]; agentMap?: Map; composerDisabledReason?: string | null; issueStatus?: string; } 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; showJumpToLatest?: boolean; emptyMessage?: string; variant?: "full" | "embedded"; enableLiveTranscriptPolling?: boolean; transcriptsByRunId?: ReadonlyMap; hasOutputForRun?: (runId: string) => boolean; includeSucceededRunsWithoutOutput?: boolean; onInterruptQueued?: (runId: string) => Promise; interruptingQueuedRunId?: string | null; onImageClick?: (src: string) => void; composerRef?: Ref; } const DRAFT_DEBOUNCE_MS = 800; const COMPOSER_FOCUS_SCROLL_PADDING_PX = 96; 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; } const WEEK_MS = 7 * 24 * 60 * 60 * 1000; function commentDateLabel(date: Date | string | undefined): string { if (!date) return ""; const then = new Date(date).getTime(); if (Date.now() - then < WEEK_MS) return timeAgo(date); return formatShortDate(date); } function IssueChatTextPart({ text, recessed }: { text: string; recessed?: boolean }) { const { onImageClick } = useContext(IssueChatCtx); 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 toolCountSummary(toolParts: ToolCallMessagePart[]): string | null { if (toolParts.length === 0) return null; let commands = 0; let other = 0; for (const tool of toolParts) { if (isCommandTool(tool.toolName, tool.args)) commands++; else other++; } const parts: string[] = []; if (commands > 0) parts.push(`ran ${commands} command${commands === 1 ? "" : "s"}`); if (other > 0) parts.push(`called ${other} tool${other === 1 ? "" : "s"}`); return parts.join(", "); } function cleanToolDisplayText(tool: ToolCallMessagePart): string { const name = displayToolName(tool.toolName, tool.args); if (isCommandTool(tool.toolName, tool.args)) return name; const summary = tool.result === undefined ? summarizeToolInput(tool.toolName, tool.args) : null; return summary ? `${name} ${summary}` : name; } function IssueChatChainOfThought() { const { agentMap } = useContext(IssueChatCtx); const message = useMessage(); const custom = message.metadata.custom as Record; const runAgentId = typeof custom.runAgentId === "string" ? custom.runAgentId : null; const authorAgentId = typeof custom.authorAgentId === "string" ? custom.authorAgentId : null; const agentId = authorAgentId ?? runAgentId; const agentIcon = agentId ? agentMap?.get(agentId)?.icon : undefined; const isMessageRunning = message.role === "assistant" && message.status?.type === "running"; const cotParts = useAuiState((s) => s.chainOfThought?.parts ?? []) as ReadonlyArray<{ type: string; text?: string; toolName?: string; toolCallId?: string; args?: unknown; argsText?: string; result?: unknown; isError?: boolean }>; const myIndex = useMemo( () => findCoTSegmentIndex(message.content, cotParts), [message.content, cotParts], ); const allReasoningText = cotParts .filter((p): p is { type: "reasoning"; text: string } => p.type === "reasoning" && !!p.text) .map((p) => p.text) .join("\n"); const toolParts = cotParts.filter( (p): p is ToolCallMessagePart => p.type === "tool-call", ); const hasActiveTool = toolParts.some((t) => t.result === undefined); const isActive = isMessageRunning && hasActiveTool; const [expanded, setExpanded] = useState(isActive); const rawSegments = Array.isArray(custom.chainOfThoughtSegments) ? (custom.chainOfThoughtSegments as SegmentTiming[]) : []; const segmentTiming = myIndex >= 0 ? rawSegments[myIndex] ?? null : null; const liveElapsed = useLiveElapsed(segmentTiming?.startMs, isActive); useEffect(() => { if (isActive) setExpanded(true); }, [isActive]); let headerVerb: string; let headerSuffix: string | null = null; if (isActive) { headerVerb = "Working"; if (liveElapsed) headerSuffix = `for ${liveElapsed}`; } else if (segmentTiming) { const durationMs = segmentTiming.endMs - segmentTiming.startMs; const durationText = formatDurationWords(durationMs); headerVerb = "Worked"; if (durationText) headerSuffix = `for ${durationText}`; } else { headerVerb = "Worked"; } const toolSummary = toolCountSummary(toolParts); const hasContent = allReasoningText.trim().length > 0 || toolParts.length > 0; return (
{expanded && hasContent ? (
{isActive ? ( <> {allReasoningText ? : null} {toolParts.length > 0 ? : null} ) : ( <> {allReasoningText ? : null} {toolParts.map((tool) => ( ))} )}
) : null}
); } function IssueChatReasoningPart({ text }: { text: string }) { const lines = text.split("\n").filter((l) => l.trim()); const lastLine = lines[lines.length - 1] ?? text.slice(-200); const prevRef = useRef(lastLine); const [ticker, setTicker] = useState<{ key: number; current: string; exiting: string | null; }>({ key: 0, current: lastLine, exiting: null }); useEffect(() => { if (lastLine !== prevRef.current) { const prev = prevRef.current; prevRef.current = lastLine; setTicker((t) => ({ key: t.key + 1, current: lastLine, exiting: prev })); } }, [lastLine]); return (
{ticker.exiting !== null && ( setTicker((t) => ({ ...t, exiting: null }))} > {ticker.exiting} )} 0 && "cot-line-enter", )} > {ticker.current}
); } function IssueChatRollingToolPart({ toolParts }: { toolParts: ToolCallMessagePart[] }) { const latest = toolParts[toolParts.length - 1]; if (!latest) return null; const fullText = cleanToolDisplayText(latest); const prevRef = useRef(fullText); const [ticker, setTicker] = useState<{ key: number; current: string; exiting: string | null; }>({ key: 0, current: fullText, exiting: null }); useEffect(() => { if (fullText !== prevRef.current) { const prev = prevRef.current; prevRef.current = fullText; setTicker((t) => ({ key: t.key + 1, current: fullText, exiting: prev })); } }, [fullText]); const ToolIcon = getToolIcon(latest.toolName); const isRunning = latest.result === undefined; return (
{isRunning ? ( ) : ( )}
{ticker.exiting !== null && ( setTicker((t) => ({ ...t, exiting: null }))} > {ticker.exiting} )} 0 && "cot-line-enter", )} > {ticker.current}
); } function CopyablePreBlock({ children, className }: { children: string; className?: string }) { const [copied, setCopied] = useState(false); return (
{children}
); } const TOOL_ICON_MAP: Record> = { // Extend with specific tool icons as they become known }; function getToolIcon(toolName: string): React.ComponentType<{ className?: string }> { return TOOL_ICON_MAP[toolName] ?? Hammer; } function IssueChatToolPart({ toolName, args, argsText, result, isError, }: { toolName: string; args?: unknown; argsText?: string; result?: unknown; isError?: boolean; }) { const [open, setOpen] = useState(false); const rawArgsText = argsText ?? ""; const parsedArgs = args ?? parseToolPayload(rawArgsText); const resultText = typeof result === "string" ? result : result === undefined ? "" : formatToolPayload(result); const inputDetails = describeToolInput(toolName, parsedArgs); const displayName = displayToolName(toolName, parsedArgs); const isCommand = isCommandTool(toolName, parsedArgs); const summary = isCommand ? null : result === undefined ? summarizeToolInput(toolName, parsedArgs) : summarizeToolResult(resultText, false); const ToolIcon = getToolIcon(toolName); const intentDetail = inputDetails.find((d) => d.label === "Intent"); const title = intentDetail?.value ?? displayName; const nonIntentDetails = inputDetails.filter((d) => d.label !== "Intent"); return (
{open ?
: null}
{open ? (
{nonIntentDetails.length > 0 ? (
Input
{nonIntentDetails.map((detail) => (
{detail.label}
{detail.value}
))}
) : rawArgsText ? (
Input
{rawArgsText}
) : 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 ? commentDateLabel(message.createdAt) : ""} {message.createdAt ? formatDateTime(message.createdAt) : ""}
You
); } 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 authorAgentId = typeof custom.authorAgentId === "string" ? custom.authorAgentId : null; const runId = typeof custom.runId === "string" ? custom.runId : null; const runAgentId = typeof custom.runAgentId === "string" ? custom.runAgentId : null; const agentId = authorAgentId ?? runAgentId; const agentIcon = agentId ? agentMap?.get(agentId)?.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 chainOfThoughtLabel = typeof custom.chainOfThoughtLabel === "string" ? custom.chainOfThoughtLabel : null; const hasCoT = message.content.some((p) => p.type === "reasoning" || p.type === "tool-call"); const isFoldable = !isRunning && !!chainOfThoughtLabel; const [folded, setFolded] = useState(isFoldable); const [prevFoldKey, setPrevFoldKey] = useState({ messageId: message.id, isFoldable }); // Derive fold state synchronously during render (not in useEffect) so the // browser never paints the un-folded intermediate state — prevents the // visible "jump" when loading a page with already-folded work sections. if (message.id !== prevFoldKey.messageId || isFoldable !== prevFoldKey.isFoldable) { const nextFolded = resolveAssistantMessageFoldedState({ messageId: message.id, currentFolded: folded, isFoldable, previousMessageId: prevFoldKey.messageId, previousIsFoldable: prevFoldKey.isFoldable, }); setPrevFoldKey({ messageId: message.id, isFoldable }); if (nextFolded !== folded) { setFolded(nextFolded); } } 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 (
{agentIcon ? ( ) : ( {initialsForName(authorName)} )}
{isFoldable ? ( ) : (
{authorName} {isRunning ? ( Running ) : null}
)} {!folded ? ( <>
, ChainOfThought: IssueChatChainOfThought, }} /> {message.content.length === 0 && waitingText ? (
{agentIcon ? ( ) : ( )} {waitingText}
) : null} {notices.length > 0 ? (
{notices.map((notice, index) => (
{notice}
))}
) : null}
{commentId && onVote ? ( ) : null} {message.createdAt ? commentDateLabel(message.createdAt) : ""} {message.createdAt ? formatDateTime(message.createdAt) : ""} { const text = message.content .filter((p): p is { type: "text"; text: string } => p.type === "text") .map((p) => p.text) .join("\n\n"); void navigator.clipboard.writeText(text); }} > Copy message {runHref ? ( View run ) : null}
) : 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?