import { memo, useEffect, useMemo, useRef, useState, type ChangeEvent } from "react"; import { Link, useLocation } from "react-router-dom"; import type { IssueComment, Agent } from "@paperclipai/shared"; import { Button } from "@/components/ui/button"; import { Check, Copy, Paperclip } from "lucide-react"; import { Identity } from "./Identity"; import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector"; import { MarkdownBody } from "./MarkdownBody"; import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor"; import { StatusBadge } from "./StatusBadge"; import { AgentIcon } from "./AgentIconPicker"; import { formatDateTime } from "../lib/utils"; import { PluginSlotOutlet } from "@/plugins/slots"; interface CommentWithRunMeta extends IssueComment { runId?: string | null; runAgentId?: string | null; } interface LinkedRunItem { runId: string; status: string; agentId: string; createdAt: Date | string; startedAt: Date | string | null; } interface CommentReassignment { assigneeAgentId: string | null; assigneeUserId: string | null; } interface CommentThreadProps { comments: CommentWithRunMeta[]; linkedRuns?: LinkedRunItem[]; companyId?: string | null; projectId?: string | null; onAdd: (body: string, reopen?: boolean, reassignment?: CommentReassignment) => Promise; issueStatus?: string; agentMap?: Map; 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[]; } 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 CopyMarkdownButton({ text }: { text: string }) { const [copied, setCopied] = useState(false); return ( ); } type TimelineItem = | { kind: "comment"; id: string; createdAtMs: number; comment: CommentWithRunMeta } | { kind: "run"; id: string; createdAtMs: number; run: LinkedRunItem }; const TimelineList = memo(function TimelineList({ timeline, agentMap, companyId, projectId, highlightCommentId, }: { timeline: TimelineItem[]; agentMap?: Map; companyId?: string | null; projectId?: string | null; highlightCommentId?: string | null; }) { if (timeline.length === 0) { return

No comments or runs yet.

; } return (
{timeline.map((item) => { if (item.kind === "run") { const run = item.run; return (
{formatDateTime(run.startedAt ?? run.createdAt)}
Run {run.runId.slice(0, 8)}
); } const comment = item.comment; const isHighlighted = highlightCommentId === comment.id; return (
{comment.authorAgentId ? ( ) : ( )} {companyId ? ( ) : null} {formatDateTime(comment.createdAt)}
{comment.body} {companyId ? (
) : null} {comment.runId && (
{comment.runAgentId ? ( run {comment.runId.slice(0, 8)} ) : ( run {comment.runId.slice(0, 8)} )}
)}
); })}
); }); export function CommentThread({ comments, linkedRuns = [], companyId, projectId, onAdd, agentMap, imageUploadHandler, onAttachImage, draftKey, liveRunSlot, enableReassign = false, reassignOptions = [], currentAssigneeValue = "", suggestedAssigneeValue, mentions: providedMentions, }: 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 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 runItems: TimelineItem[] = linkedRuns.map((run) => ({ kind: "run", id: run.runId, createdAtMs: new Date(run.startedAt ?? run.createdAt).getTime(), run, })); return [...commentItems, ...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); return a.kind === "comment" ? -1 : 1; }); }, [comments, linkedRuns]); // 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: a.id, name: a.name, })); }, [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 === 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]); async function handleSubmit() { const trimmed = body.trim(); if (!trimmed) return; const hasReassignment = enableReassign && reassignTarget !== currentAssigneeValue; const reassignment = hasReassignment ? parseReassignment(reassignTarget) : null; setSubmitting(true); try { await onAdd(trimmed, reopen ? true : undefined, reassignment ?? undefined); setBody(""); if (draftKey) clearDraft(draftKey); setReopen(true); setReassignTarget(effectiveSuggestedAssigneeValue); } 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 (

Comments & Runs ({timeline.length})

{liveRunSlot}
{(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} ); }} /> )}
); }