import { AssistantRuntimeProvider, MessagePrimitive, ThreadPrimitive, useAui, useAuiState, useMessage, } from "@assistant-ui/react"; import { 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, } from "../lib/issue-chat-messages"; import type { IssueTimelineEvent } from "../lib/issue-timeline-events"; import { Button } from "@/components/ui/button"; import { MarkdownBody } from "./MarkdownBody"; import { MarkdownEditor, type MentionOption, type MarkdownEditorRef } from "./MarkdownEditor"; import { Identity } from "./Identity"; import { OutputFeedbackButtons } from "./OutputFeedbackButtons"; import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector"; import { AgentIcon } from "./AgentIconPicker"; import { StatusBadge } from "./StatusBadge"; import { restoreSubmittedCommentDraft } from "../lib/comment-submit-draft"; import { cn, formatDateTime } from "../lib/utils"; import { Check, Copy, Loader2, Paperclip, Square } from "lucide-react"; 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; } 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 CopyMarkdownButton({ text }: { text: string }) { const [copied, setCopied] = useState(false); return ( ); } function IssueChatTextPart({ text }: { text: string }) { return {text}; } function IssueChatReasoningPart({ text }: { text: string }) { return (
Thinking
{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({ companyId, projectId, }: { companyId?: string | null; projectId?: string | null; }) { 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 : "You"; const body = message.content .filter((part): part is Extract<(typeof message.content)[number], { type: "text" }> => part.type === "text") .map((part) => part.text) .join("\n"); const queued = custom.queueState === "queued" || custom.clientStatus === "queued"; const pending = custom.clientStatus === "pending"; return (
{queued ? ( Queued ) : null} {pending ? Sending... : null}
{formatDateTime(message.createdAt)}
, }} />
{companyId && typeof custom.commentId === "string" ? null : null} {projectId ? null : null}
); } function IssueChatAssistantMessage({ feedbackVoteByTargetId, feedbackDataSharingPreference, feedbackTermsUrl, onVote, }: { feedbackVoteByTargetId: Map; feedbackDataSharingPreference?: FeedbackDataSharingPreference; feedbackTermsUrl?: string | null; onVote?: ( commentId: string, vote: FeedbackVoteValue, options?: { allowSharing?: boolean; reason?: string }, ) => Promise; }) { 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 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 handleVote = async ( vote: FeedbackVoteValue, options?: { allowSharing?: boolean; reason?: string }, ) => { if (!commentId || !onVote) return; await onVote(commentId, vote, options); }; return (
{isRunning ? ( Running ) : null}
{formatDateTime(message.createdAt)}
, Reasoning: ({ text }) => , tools: { Override: ({ toolName, argsText, result, isError }) => ( ), }, }} /> {message.content.length === 0 && waitingText ? (
{waitingText}
) : null} {notices.length > 0 ? (
{notices.map((notice, index) => (
{notice}
))}
) : null}
{runId ? ( runAgentId ? ( run {runId.slice(0, 8)} ) : ( run {runId.slice(0, 8)} ) ) : null} {commentId && onVote ? ( ) : null}
); } function IssueChatSystemMessage() { const message = useMessage(); const custom = message.metadata.custom as Record; const text = message.content .filter((part): part is Extract<(typeof message.content)[number], { type: "text" }> => part.type === "text") .map((part) => part.text) .join("\n"); const anchorId = typeof custom.anchorId === "string" ? custom.anchorId : undefined; const runId = typeof custom.runId === "string" ? custom.runId : null; const runAgentId = typeof custom.runAgentId === "string" ? custom.runAgentId : null; const runStatus = typeof custom.runStatus === "string" ? custom.runStatus : null; return (
{text} {runStatus ? : null} {runId && runAgentId ? ( {runId.slice(0, 8)} ) : null}
); } function IssueChatComposer({ onImageUpload, onAttachImage, draftKey, enableReassign = false, reassignOptions = [], currentAssigneeValue = "", suggestedAssigneeValue, mentions = [], agentMap, composerDisabledReason = null, issueStatus, onCancelRun, }: { 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; onCancelRun?: (() => Promise) | undefined; }) { const api = useAui(); const isRunning = useAuiState((state) => state.thread.isRunning); const [body, setBody] = useState(""); const [reopen, setReopen] = useState(issueStatus === "done" || issueStatus === "cancelled"); const [submitting, setSubmitting] = useState(false); const [attaching, setAttaching] = useState(false); const [cancelling, setCancelling] = useState(false); const effectiveSuggestedAssigneeValue = suggestedAssigneeValue ?? currentAssigneeValue; const [reassignTarget, setReassignTarget] = useState(effectiveSuggestedAssigneeValue); const attachInputRef = useRef(null); const editorRef = useRef(null); const draftTimer = useRef | null>(null); useEffect(() => { if (!draftKey) return; setBody(loadDraft(draftKey)); }, [draftKey]); useEffect(() => { if (!draftKey) return; if (draftTimer.current) clearTimeout(draftTimer.current); draftTimer.current = setTimeout(() => { saveDraft(draftKey, body); }, DRAFT_DEBOUNCE_MS); }, [body, draftKey]); useEffect(() => { return () => { if (draftTimer.current) clearTimeout(draftTimer.current); }; }, []); useEffect(() => { setReassignTarget(effectiveSuggestedAssigneeValue); }, [effectiveSuggestedAssigneeValue]); async function handleSubmit() { const trimmed = body.trim(); if (!trimmed || submitting) return; const hasReassignment = enableReassign && reassignTarget !== currentAssigneeValue; const reassignment = hasReassignment ? parseReassignment(reassignTarget) : undefined; const submittedBody = trimmed; setSubmitting(true); setBody(""); try { await api.thread().append({ role: "user", content: [{ type: "text", text: submittedBody }], metadata: { custom: {} }, attachments: [], runConfig: { custom: { ...(reopen ? { reopen: true } : {}), ...(reassignment ? { reassignment } : {}), }, }, }); if (draftKey) clearDraft(draftKey); setReopen(issueStatus === "done" || issueStatus === "cancelled"); setReassignTarget(effectiveSuggestedAssigneeValue); } catch { setBody((current) => restoreSubmittedCommentDraft({ currentBody: current, submittedBody, }), ); } finally { setSubmitting(false); } } async function handleAttachFile(evt: ChangeEvent) { const file = evt.target.files?.[0]; if (!file) return; setAttaching(true); try { if (onImageUpload) { const url = await onImageUpload(file); const safeName = file.name.replace(/[[\]]/g, "\\$&"); const markdown = `![${safeName}](${url})`; setBody((prev) => prev ? `${prev}\n\n${markdown}` : markdown); } else if (onAttachImage) { await onAttachImage(file); } } finally { setAttaching(false); if (attachInputRef.current) attachInputRef.current.value = ""; } } async function handleCancelRun() { if (!onCancelRun || cancelling) return; setCancelling(true); try { await onCancelRun(); } finally { setCancelling(false); } } const canSubmit = !submitting && !!body.trim(); if (composerDisabledReason) { return (
{composerDisabledReason}
); } return (
{isRunning ? (
Messages sent now queue behind the active run. {onCancelRun ? ( ) : null}
) : null}
{(onImageUpload || onAttachImage) ? (
) : null} {enableReassign && reassignOptions.length > 0 ? ( { if (!option) return Assignee; const agentId = option.id.startsWith("agent:") ? option.id.slice("agent:".length) : null; const agent = agentId ? agentMap?.get(agentId) : null; return ( <> {agent ? ( ) : null} {option.label} ); }} renderOption={(option) => { if (!option.id) return {option.label}; const agentId = option.id.startsWith("agent:") ? option.id.slice("agent:".length) : null; const agent = agentId ? agentMap?.get(agentId) : null; return ( <> {agent ? ( ) : null} {option.label} ); }} /> ) : null}
); } export function IssueChatThread({ comments, feedbackVotes = [], feedbackDataSharingPreference = "prompt", feedbackTermsUrl = null, linkedRuns = [], timelineEvents = [], liveRuns = [], activeRun = null, companyId, projectId, issueStatus, agentMap, currentUserId, onVote, onAdd, onCancelRun, imageUploadHandler, onAttachImage, draftKey, enableReassign = false, reassignOptions = [], currentAssigneeValue = "", suggestedAssigneeValue, mentions = [], composerDisabledReason = null, }: IssueChatThreadProps) { const location = useLocation(); const hasScrolledRef = useRef(false); const displayLiveRuns = useMemo(() => { const deduped = new Map(); for (const run of liveRuns) { deduped.set(run.id, run); } if (activeRun) { deduped.set(activeRun.id, { id: activeRun.id, status: activeRun.status, invocationSource: activeRun.invocationSource, triggerDetail: activeRun.triggerDetail, startedAt: toIsoString(activeRun.startedAt), finishedAt: toIsoString(activeRun.finishedAt), createdAt: toIsoString(activeRun.createdAt) ?? new Date().toISOString(), agentId: activeRun.agentId, agentName: activeRun.agentName, adapterType: activeRun.adapterType, }); } return [...deduped.values()].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); }, [activeRun, liveRuns]); const { transcriptByRun, hasOutputForRun } = useLiveRunTranscripts({ runs: displayLiveRuns, companyId }); const messages = useMemo( () => buildIssueChatMessages({ comments, timelineEvents, linkedRuns, liveRuns, activeRun, transcriptsByRunId: transcriptByRun, hasOutputForRun, companyId, projectId, agentMap, currentUserId, }), [ comments, timelineEvents, linkedRuns, liveRuns, activeRun, transcriptByRun, hasOutputForRun, companyId, projectId, agentMap, currentUserId, ], ); const isRunning = displayLiveRuns.some((run) => run.status === "queued" || run.status === "running"); const feedbackVoteByTargetId = useMemo(() => { const map = new Map(); for (const feedbackVote of feedbackVotes) { if (feedbackVote.targetType !== "issue_comment") continue; map.set(feedbackVote.targetId, feedbackVote.vote); } return map; }, [feedbackVotes]); const runtime = usePaperclipIssueRuntime({ messages, isRunning, onSend: ({ body, reopen, reassignment }) => onAdd(body, reopen, reassignment), onCancel: onCancelRun, }); useEffect(() => { const hash = location.hash; if (!(hash.startsWith("#comment-") || hash.startsWith("#activity-") || hash.startsWith("#run-"))) return; if (messages.length === 0 || hasScrolledRef.current) return; const targetId = hash.slice(1); const element = document.getElementById(targetId); if (!element) return; hasScrolledRef.current = true; element.scrollIntoView({ behavior: "smooth", block: "center" }); }, [location.hash, messages]); const components = useMemo( () => ({ UserMessage: () => , AssistantMessage: () => ( ), SystemMessage: () => , }), [ companyId, projectId, feedbackVoteByTargetId, feedbackDataSharingPreference, feedbackTermsUrl, onVote, ], ); return (

Chat ({messages.length})

Jump to latest
This issue conversation is empty. Start with a message below.
); }