import { memo, useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent } from "react"; import { Link, useLocation } from "react-router-dom"; import type { Agent, Approval, FeedbackDataSharingPreference, FeedbackVote, FeedbackVoteValue, IssueComment, } from "@paperclipai/shared"; import { Button } from "@/components/ui/button"; import { ArrowRight, Check, Copy, Paperclip } from "lucide-react"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Identity } from "./Identity"; import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector"; import { MarkdownBody } from "./MarkdownBody"; import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor"; import { OutputFeedbackButtons } from "./OutputFeedbackButtons"; import { ApprovalCard } from "./ApprovalCard"; import { AgentIcon } from "./AgentIconPicker"; import { formatAssigneeUserLabel } from "../lib/assignees"; import type { IssueTimelineAssignee, IssueTimelineEvent } from "../lib/issue-timeline-events"; import { timeAgo } from "../lib/timeAgo"; import { cn, formatDateTime } from "../lib/utils"; import { restoreSubmittedCommentDraft } from "../lib/comment-submit-draft"; import { PluginSlotOutlet } from "@/plugins/slots"; interface CommentWithRunMeta extends IssueComment { runId?: string | null; runAgentId?: string | null; clientId?: string; clientStatus?: "pending" | "queued"; queueState?: "queued"; queueTargetRunId?: string | null; } interface LinkedRunItem { runId: string; status: string; agentId: string; createdAt: Date | string; startedAt: Date | string | null; finishedAt?: Date | string | null; } interface CommentReassignment { assigneeAgentId: string | null; assigneeUserId: string | null; } interface CommentThreadProps { comments: CommentWithRunMeta[]; queuedComments?: CommentWithRunMeta[]; linkedApprovals?: Approval[]; feedbackVotes?: FeedbackVote[]; feedbackDataSharingPreference?: FeedbackDataSharingPreference; feedbackTermsUrl?: string | null; linkedRuns?: LinkedRunItem[]; timelineEvents?: IssueTimelineEvent[]; companyId?: string | null; projectId?: string | null; onApproveApproval?: (approvalId: string) => Promise; onRejectApproval?: (approvalId: string) => Promise; pendingApprovalAction?: { approvalId: string; action: "approve" | "reject"; } | null; onVote?: ( commentId: string, vote: FeedbackVoteValue, options?: { allowSharing?: boolean; reason?: string }, ) => Promise; onAdd: (body: string, reopen?: boolean, reassignment?: CommentReassignment) => Promise; issueStatus?: string; agentMap?: Map; currentUserId?: string | null; imageUploadHandler?: (file: File) => Promise; /** Callback to attach an image file to the parent issue (not inline in a comment). */ onAttachImage?: (file: File) => Promise; draftKey?: string; liveRunSlot?: React.ReactNode; enableReassign?: boolean; reassignOptions?: InlineEntityOption[]; currentAssigneeValue?: string; suggestedAssigneeValue?: string; mentions?: MentionOption[]; onInterruptQueued?: (runId: string) => Promise; interruptingQueuedRunId?: string | null; composerDisabledReason?: string | null; } const DRAFT_DEBOUNCE_MS = 800; 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): CommentReassignment | 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 humanizeValue(value: string | null): string { 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 formatTimelineActorName( actorType: IssueTimelineEvent["actorType"], actorId: string, agentMap?: Map, currentUserId?: string | null, ) { if (actorType === "agent") { return agentMap?.get(actorId)?.name ?? actorId.slice(0, 8); } if (actorType === "system") { return "System"; } return formatAssigneeUserLabel(actorId, currentUserId) ?? "Board"; } 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 runTimestamp(run: LinkedRunItem) { return run.finishedAt ?? run.startedAt ?? run.createdAt; } 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"; } } async function copyTextWithFallback(text: string) { if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(text); return; } const textarea = document.createElement("textarea"); textarea.value = text; textarea.style.position = "fixed"; textarea.style.left = "-9999px"; document.body.appendChild(textarea); try { textarea.select(); const success = document.execCommand("copy"); if (!success) throw new Error("execCommand copy failed"); } finally { document.body.removeChild(textarea); } } function CopyMarkdownButton({ text }: { text: string }) { const [status, setStatus] = useState<"idle" | "copied" | "failed">("idle"); const timeoutRef = useRef | null>(null); useEffect(() => () => { if (timeoutRef.current) { clearTimeout(timeoutRef.current); } }, []); const label = status === "copied" ? "Copied" : status === "failed" ? "Copy failed" : "Copy"; return ( ); } function CommentCard({ comment, agentMap, companyId, projectId, feedbackVote = null, feedbackDataSharingPreference = "prompt", feedbackTermsUrl = null, onVote, voting = false, highlightCommentId, queued = false, }: { comment: CommentWithRunMeta; agentMap?: Map; companyId?: string | null; projectId?: string | null; feedbackVote?: FeedbackVoteValue | null; feedbackDataSharingPreference?: FeedbackDataSharingPreference; feedbackTermsUrl?: string | null; onVote?: ( vote: FeedbackVoteValue, options?: { allowSharing?: boolean; reason?: string }, ) => Promise; voting?: boolean; highlightCommentId?: string | null; queued?: boolean; }) { const isHighlighted = highlightCommentId === comment.id; const isPending = comment.clientStatus === "pending"; const isQueued = queued || comment.queueState === "queued" || comment.clientStatus === "queued"; return (
{comment.authorAgentId ? ( ) : ( )} {isQueued ? ( Queued ) : null} {companyId && !isPending ? ( ) : null} {isPending ? ( {isQueued ? "Queueing..." : "Sending..."} ) : ( {formatDateTime(comment.createdAt)} )}
{comment.body} {companyId && !isPending ? (
) : null} {comment.authorAgentId && onVote && !isQueued && !isPending ? ( run {comment.runId.slice(0, 8)} ) : ( run {comment.runId.slice(0, 8)} ) ) : undefined} /> ) : null} {comment.runId && !isPending && !(comment.authorAgentId && onVote && !isQueued) ? (
{comment.runAgentId ? ( run {comment.runId.slice(0, 8)} ) : ( run {comment.runId.slice(0, 8)} )}
) : null}
); } type TimelineItem = | { kind: "comment"; id: string; createdAtMs: number; comment: CommentWithRunMeta } | { kind: "approval"; id: string; createdAtMs: number; approval: Approval } | { kind: "event"; id: string; createdAtMs: number; event: IssueTimelineEvent } | { kind: "run"; id: string; createdAtMs: number; run: LinkedRunItem }; function TimelineEventCard({ event, agentMap, currentUserId, }: { event: IssueTimelineEvent; agentMap?: Map; currentUserId?: string | null; }) { const actorName = formatTimelineActorName(event.actorType, event.actorId, agentMap, currentUserId); return (
{initialsForName(actorName)}
{actorName} updated this task {timeAgo(event.createdAt)}
{event.statusChange ? (
Status {humanizeValue(event.statusChange.from)} {humanizeValue(event.statusChange.to)}
) : null} {event.assigneeChange ? (
Assignee {formatTimelineAssigneeLabel(event.assigneeChange.from, agentMap, currentUserId)} {formatTimelineAssigneeLabel(event.assigneeChange.to, agentMap, currentUserId)}
) : null}
); } const TimelineList = memo(function TimelineList({ timeline, agentMap, currentUserId, companyId, projectId, onApproveApproval, onRejectApproval, pendingApprovalAction, feedbackVoteByTargetId, feedbackDataSharingPreference = "prompt", feedbackTermsUrl = null, onVote, votingTargetId, highlightCommentId, }: { timeline: TimelineItem[]; agentMap?: Map; currentUserId?: string | null; companyId?: string | null; projectId?: string | null; onApproveApproval?: (approvalId: string) => Promise; onRejectApproval?: (approvalId: string) => Promise; pendingApprovalAction?: { approvalId: string; action: "approve" | "reject"; } | null; feedbackVoteByTargetId?: Map; feedbackDataSharingPreference?: FeedbackDataSharingPreference; feedbackTermsUrl?: string | null; onVote?: ( commentId: string, vote: FeedbackVoteValue, options?: { allowSharing?: boolean; reason?: string }, ) => Promise; votingTargetId?: string | null; highlightCommentId?: string | null; }) { if (timeline.length === 0) { return

No timeline entries yet.

; } return (
{timeline.map((item) => { if (item.kind === "event") { return ( ); } if (item.kind === "approval") { const approval = item.approval; const isPending = pendingApprovalAction?.approvalId === approval.id; return (
void onApproveApproval(approval.id) : undefined} onReject={onRejectApproval ? () => void onRejectApproval(approval.id) : undefined} detailLink={`/approvals/${approval.id}`} isPending={isPending} pendingAction={isPending ? pendingApprovalAction?.action ?? null : null} />
); } if (item.kind === "run") { const run = item.run; const actorName = agentMap?.get(run.agentId)?.name ?? run.agentId.slice(0, 8); return (
{initialsForName(actorName)}
{actorName} run {run.runId.slice(0, 8)} {formatRunStatusLabel(run.status)} {timeAgo(runTimestamp(run))}
); } const comment = item.comment; return ( onVote(comment.id, vote, options) : undefined} voting={votingTargetId === comment.id} highlightCommentId={highlightCommentId} /> ); })}
); }); export const CommentThread = memo(function CommentThread({ comments, queuedComments = [], linkedApprovals = [], feedbackVotes = [], feedbackDataSharingPreference = "prompt", feedbackTermsUrl = null, linkedRuns = [], timelineEvents = [], companyId, projectId, onApproveApproval, onRejectApproval, pendingApprovalAction = null, onVote, onAdd, agentMap, currentUserId, imageUploadHandler, onAttachImage, draftKey, liveRunSlot, enableReassign = false, reassignOptions = [], currentAssigneeValue = "", suggestedAssigneeValue, mentions: providedMentions, onInterruptQueued, interruptingQueuedRunId = null, composerDisabledReason = null, }: CommentThreadProps) { const effectiveSuggestedAssigneeValue = suggestedAssigneeValue ?? currentAssigneeValue; const [highlightCommentId, setHighlightCommentId] = useState(null); const [votingTargetId, setVotingTargetId] = useState(null); const location = useLocation(); const hasScrolledRef = useRef(false); const timeline = useMemo(() => { const commentItems: TimelineItem[] = comments.map((comment) => ({ kind: "comment", id: comment.id, createdAtMs: new Date(comment.createdAt).getTime(), comment, })); const approvalItems: TimelineItem[] = linkedApprovals.map((approval) => ({ kind: "approval", id: approval.id, createdAtMs: new Date(approval.createdAt).getTime(), approval, })); const eventItems: TimelineItem[] = timelineEvents.map((event) => ({ kind: "event", id: event.id, createdAtMs: new Date(event.createdAt).getTime(), event, })); const runItems: TimelineItem[] = linkedRuns.map((run) => ({ kind: "run", id: run.runId, createdAtMs: new Date(runTimestamp(run)).getTime(), run, })); return [...commentItems, ...approvalItems, ...eventItems, ...runItems].sort((a, b) => { if (a.createdAtMs !== b.createdAtMs) return a.createdAtMs - b.createdAtMs; if (a.kind === b.kind) return a.id.localeCompare(b.id); const kindOrder = { event: 0, approval: 1, comment: 2, run: 3, } as const; return kindOrder[a.kind] - kindOrder[b.kind]; }); }, [comments, linkedApprovals, timelineEvents, linkedRuns]); 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]); // Build mention options from agent map (exclude terminated agents) const mentions = useMemo(() => { if (providedMentions) return providedMentions; if (!agentMap) return []; return Array.from(agentMap.values()) .filter((a) => a.status !== "terminated") .map((a) => ({ id: `agent:${a.id}`, name: a.name, kind: "agent", agentId: a.id, agentIcon: a.icon, })); }, [agentMap, providedMentions]); // Scroll to comment when URL hash matches #comment-{id} useEffect(() => { const hash = location.hash; if (!hash.startsWith("#comment-") || comments.length + queuedComments.length === 0) return; const commentId = hash.slice("#comment-".length); // Only scroll once per hash if (hasScrolledRef.current) return; const el = document.getElementById(`comment-${commentId}`); if (el) { hasScrolledRef.current = true; setHighlightCommentId(commentId); el.scrollIntoView({ behavior: "smooth", block: "center" }); // Clear highlight after animation const timer = setTimeout(() => setHighlightCommentId(null), 3000); return () => clearTimeout(timer); } }, [location.hash, comments, queuedComments]); const handleFeedbackVote = useCallback( async ( commentId: string, vote: FeedbackVoteValue, options?: { allowSharing?: boolean; reason?: string }, ) => { if (!onVote) return; setVotingTargetId(commentId); try { await onVote(commentId, vote, options); } finally { setVotingTargetId(null); } }, [onVote], ); const timelineSection = useMemo( () => ( ), [ timeline, agentMap, currentUserId, companyId, projectId, onApproveApproval, onRejectApproval, pendingApprovalAction, feedbackVoteByTargetId, feedbackDataSharingPreference, onVote, handleFeedbackVote, votingTargetId, highlightCommentId, feedbackTermsUrl, ], ); return (

Timeline ({timeline.length + queuedComments.length})

{timelineSection} {liveRunSlot} {queuedComments.length > 0 && (

Queued Comments ({queuedComments.length})

{onInterruptQueued && queuedComments[0]?.queueTargetRunId ? ( ) : null}
{queuedComments.map((comment) => ( ))}
)} {composerDisabledReason ? (
{composerDisabledReason}
) : ( )}
); }); CommentThread.displayName = "CommentThread"; /* ---- Isolated Composer (body state lives here, not in CommentThread) ---- */ interface CommentComposerProps { onAdd: (body: string, reopen?: boolean, reassignment?: CommentReassignment) => Promise; mentions: MentionOption[]; imageUploadHandler?: (file: File) => Promise; onAttachImage?: (file: File) => Promise; draftKey?: string; enableReassign: boolean; reassignOptions: InlineEntityOption[]; currentAssigneeValue: string; suggestedAssigneeValue: string; agentMap?: Map; } const CommentComposer = memo(function CommentComposer({ onAdd, mentions, imageUploadHandler, onAttachImage, draftKey, enableReassign, reassignOptions, currentAssigneeValue, suggestedAssigneeValue, agentMap, }: CommentComposerProps) { const [body, setBody] = useState(""); const [reopen, setReopen] = useState(true); const [submitting, setSubmitting] = useState(false); const [attaching, setAttaching] = useState(false); const [reassignTarget, setReassignTarget] = useState(suggestedAssigneeValue); const editorRef = useRef(null); const attachInputRef = 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(suggestedAssigneeValue); }, [suggestedAssigneeValue]); async function handleSubmit() { const trimmed = body.trim(); if (!trimmed) return; const hasReassignment = enableReassign && reassignTarget !== currentAssigneeValue; const reassignment = hasReassignment ? parseReassignment(reassignTarget) : null; const submittedBody = trimmed; setSubmitting(true); setBody(""); try { await onAdd(submittedBody, reopen ? true : undefined, reassignment ?? undefined); if (draftKey) clearDraft(draftKey); setReopen(true); setReassignTarget(suggestedAssigneeValue); } 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 (imageUploadHandler) { const url = await imageUploadHandler(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 = ""; } } const canSubmit = !submitting && !!body.trim(); return (
{(imageUploadHandler || onAttachImage) && (
)} {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} ); }} /> )}
); });