import { memo, useEffect, useMemo, useRef, useState, type ChangeEvent } from "react"; import { Link, useLocation } from "react-router-dom"; import type { Agent, 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 { StatusBadge } from "./StatusBadge"; 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[]; feedbackVotes?: FeedbackVote[]; feedbackDataSharingPreference?: FeedbackDataSharingPreference; feedbackTermsUrl?: string | null; linkedRuns?: LinkedRunItem[]; timelineEvents?: IssueTimelineEvent[]; companyId?: string | null; projectId?: string | 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"; } } function CopyMarkdownButton({ text }: { text: string }) { const [copied, setCopied] = useState(false); 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: "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, feedbackVoteByTargetId, feedbackDataSharingPreference = "prompt", feedbackTermsUrl = null, onVote, votingTargetId, highlightCommentId, }: { timeline: TimelineItem[]; agentMap?: Map; currentUserId?: string | null; companyId?: string | null; projectId?: string | 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 === "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 function CommentThread({ comments, queuedComments = [], feedbackVotes = [], feedbackDataSharingPreference = "prompt", feedbackTermsUrl = null, linkedRuns = [], timelineEvents = [], companyId, projectId, onVote, onAdd, agentMap, currentUserId, imageUploadHandler, onAttachImage, draftKey, liveRunSlot, enableReassign = false, reassignOptions = [], currentAssigneeValue = "", suggestedAssigneeValue, mentions: providedMentions, onInterruptQueued, interruptingQueuedRunId = null, composerDisabledReason = null, }: CommentThreadProps) { const [body, setBody] = useState(""); const [reopen, setReopen] = useState(true); const [submitting, setSubmitting] = useState(false); const [attaching, setAttaching] = useState(false); const effectiveSuggestedAssigneeValue = suggestedAssigneeValue ?? currentAssigneeValue; const [reassignTarget, setReassignTarget] = useState(effectiveSuggestedAssigneeValue); const [highlightCommentId, setHighlightCommentId] = useState(null); const [votingTargetId, setVotingTargetId] = useState(null); const editorRef = useRef(null); const attachInputRef = useRef(null); const draftTimer = useRef | null>(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 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, ...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, comment: 1, run: 2, } as const; return kindOrder[a.kind] - kindOrder[b.kind]; }); }, [comments, 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]); 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]); // 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]); 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(effectiveSuggestedAssigneeValue); } catch { setBody((current) => restoreSubmittedCommentDraft({ currentBody: current, submittedBody, }), ); // Parent mutation handlers surface the failure and the draft is restored for retry. } 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 = ""; } } async function handleFeedbackVote( commentId: string, vote: FeedbackVoteValue, options?: { allowSharing?: boolean; reason?: string }, ) { if (!onVote) return; setVotingTargetId(commentId); try { await onVote(commentId, vote, options); } finally { setVotingTargetId(null); } } const canSubmit = !submitting && !!body.trim(); return (

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

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

Queued Comments ({queuedComments.length})

{onInterruptQueued && queuedComments[0]?.queueTargetRunId ? ( ) : null}
{queuedComments.map((comment) => ( ))}
)} {composerDisabledReason ? (
{composerDisabledReason}
) : (
{(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} ); }} /> )}
)}
); }