import { useCallback, useEffect, useMemo, useState } from 'react'; import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; import { CopyButton } from '@renderer/components/common/CopyButton'; import { AnimatedHeightReveal } from '@renderer/components/team/activity/AnimatedHeightReveal'; import { ReplyQuoteBlock } from '@renderer/components/team/activity/ReplyQuoteBlock'; import { useNewItemKeys } from '@renderer/components/team/activity/useNewItemKeys'; import { ImageLightbox } from '@renderer/components/team/attachments/ImageLightbox'; import { MemberBadge } from '@renderer/components/team/MemberBadge'; import { ExpandableContent } from '@renderer/components/ui/ExpandableContent'; import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; import { useMarkCommentsRead } from '@renderer/hooks/useMarkCommentsRead'; import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions'; import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions'; import { useStore } from '@renderer/store'; import { serializeChipsWithText } from '@renderer/types/inlineChip'; import { buildReplyBlock, parseMessageReply } from '@renderer/utils/agentMessageFormatting'; import { isImageMimeType } from '@renderer/utils/attachmentUtils'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify'; import { extractTaskRefsFromText, linkifyTaskIdsInMarkdown, parseTaskLinkHref, stripEncodedTaskReferenceMetadata, } from '@renderer/utils/taskReferenceUtils'; import { MAX_TEXT_LENGTH } from '@shared/constants'; import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { formatDistanceToNow } from 'date-fns'; import { CheckCircle2, Eye, File, Loader2, MessageSquare, Reply, Send, X } from 'lucide-react'; import type { MentionSuggestion } from '@renderer/types/mention'; import type { ResolvedTeamMember, TaskAttachmentMeta, TaskComment } from '@shared/types'; /** * Convert literal backslash-n sequences to real newlines. * Historical CLI-produced comments may store `\n` as literal text * when shell double-quotes don't interpret escape sequences. */ function normalizeLiteralNewlines(text: string): string { return text.replace(/\\n/g, '\n').replace(/\\t/g, '\t'); } const INITIAL_VISIBLE_COMMENTS = 30; const VISIBLE_COMMENTS_STEP = 50; const MAX_COMMENTS_TO_RENDER = 2000; interface TaskCommentsSectionProps { teamName: string; taskId: string; comments: TaskComment[]; members: ResolvedTeamMember[]; /** When true, the "Comments" header is not rendered (e.g. inside a collapsible section). */ hideHeader?: boolean; /** When true, the comment input area is not rendered (useful when input is rendered externally). */ hideInput?: boolean; /** Called when the user clicks Reply on a comment (used when input is rendered externally). */ onReply?: (author: string, text: string) => void; /** Called when a task ID link (e.g. #10) is clicked in comment text. */ onTaskIdClick?: (taskId: string) => void; /** Extra className on the outer comments container (e.g. negative margins for edge-to-edge). */ containerClassName?: string; /** Snapshot of unread comment IDs captured when the dialog opened. Blue dot is shown for these. */ unreadCommentIds?: Set; /** * Ref callback factory from useViewportCommentRead. * When provided, each comment element is registered for viewport-based read tracking. */ registerCommentForViewport?: (commentId: string) => (el: HTMLElement | null) => void; } export const TaskCommentsSection = ({ teamName, taskId, comments, members, hideHeader = false, hideInput = false, onReply, onTaskIdClick, containerClassName, unreadCommentIds, registerCommentForViewport, }: TaskCommentsSectionProps): React.JSX.Element => { const addTaskComment = useStore((s) => s.addTaskComment); const addingComment = useStore((s) => s.addingComment); const projectPath = useStore((s) => s.selectedTeamData?.config.projectPath ?? null); const commentsRef = useMarkCommentsRead(teamName, taskId, comments); const [replyTo, setReplyTo] = useState<{ author: string; text: string } | null>(null); const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_COMMENTS); const [previewImageUrl, setPreviewImageUrl] = useState(null); // Reset local UI state when team/task changes using the // "adjust state during render" pattern (no effect needed). // See: https://react.dev/reference/react/useState#storing-information-from-previous-renders const resetKey = `${teamName}:${taskId}`; const [prevResetKey, setPrevResetKey] = useState(resetKey); if (resetKey !== prevResetKey) { setPrevResetKey(resetKey); setVisibleCount(INITIAL_VISIBLE_COMMENTS); setReplyTo(null); setPreviewImageUrl(null); } const draft = useDraftPersistence({ key: `taskComment:${teamName}:${taskId}` }); const chipDraft = useChipDraftPersistence(`taskCommentChips:${teamName}:${taskId}`); const colorMap = useMemo(() => buildMemberColorMap(members), [members]); const { suggestions: teamMentionSuggestions } = useTeamSuggestions(teamName); const { suggestions: taskSuggestions } = useTaskSuggestions(teamName); const teamNamesForLinkify = useMemo( () => teamMentionSuggestions.map((t) => t.name), [teamMentionSuggestions] ); const cappedComments = useMemo(() => { if (comments.length <= MAX_COMMENTS_TO_RENDER) return comments; // In extreme cases, rendering thousands of markdown blocks can freeze the renderer. // Keep the UI responsive by showing only the most recent subset. return comments.slice(-MAX_COMMENTS_TO_RENDER); }, [comments]); const sortedComments = useMemo(() => { const list = [...cappedComments]; list.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); return list; }, [cappedComments]); const visibleComments = useMemo( () => sortedComments.slice(0, Math.min(visibleCount, sortedComments.length)), [sortedComments, visibleCount] ); const visibleCommentIds = useMemo( () => visibleComments.map((comment) => comment.id), [visibleComments] ); const newCommentIds = useNewItemKeys({ itemKeys: visibleCommentIds, paginationKey: visibleCount, resetKey: `${teamName}:${taskId}`, }); const mentionSuggestions = useMemo( () => members.map((m) => ({ id: m.name, name: m.name, subtitle: formatAgentRole(m.role) ?? formatAgentRole(m.agentType) ?? undefined, color: colorMap.get(m.name), })), [members, colorMap] ); const trimmed = stripEncodedTaskReferenceMetadata(draft.value).trim(); const remaining = MAX_TEXT_LENGTH - trimmed.length; const canSubmit = (trimmed.length > 0 || chipDraft.chips.length > 0) && trimmed.length <= MAX_TEXT_LENGTH && !addingComment; const handleSubmit = useCallback(async () => { if (!canSubmit) return; try { const serialized = serializeChipsWithText(trimmed, chipDraft.chips); const text = replyTo ? buildReplyBlock(replyTo.author, replyTo.text, serialized) : serialized; const taskRefs = extractTaskRefsFromText(draft.value, taskSuggestions); await addTaskComment(teamName, taskId, { text, taskRefs }); draft.clearDraft(); chipDraft.clearChipDraft(); setReplyTo(null); } catch { // Error is stored in addCommentError via store } }, [ canSubmit, addTaskComment, teamName, taskId, trimmed, draft, chipDraft, replyTo, taskSuggestions, ]); return (
{!hideHeader ? (
Comments {comments.length > 0 ? ( {comments.length} ) : null}
) : null} {comments.length > 0 ? (
{comments.length > MAX_COMMENTS_TO_RENDER ? (
Showing the most recent {MAX_COMMENTS_TO_RENDER.toLocaleString()} comments to keep the UI responsive.
) : null}
{visibleComments.map((comment, index) => (
{unreadCommentIds?.has(comment.id) ? ( ) : null} {comment.type === 'review_approved' ? ( Approved ) : comment.type === 'review_request' ? ( Review requested ) : null} {(() => { const date = new Date(comment.createdAt); return isNaN(date.getTime()) ? 'unknown time' : formatDistanceToNow(date, { addSuffix: true }); })()} Reply to comment
{(() => { const reply = parseMessageReply(comment.text); const rawForDisplay = reply ? reply.replyText : comment.text; const displayText = normalizeLiteralNewlines(stripAgentBlocks(rawForDisplay)); return ( {reply ? ( ) : ( { const link = ( e.target as HTMLElement ).closest('a[href^="task://"]'); if (link) { e.preventDefault(); e.stopPropagation(); const href = link.getAttribute('href'); const parsed = href ? parseTaskLinkHref(href) : null; if (parsed?.taskId) onTaskIdClick(parsed.taskId); } } : undefined } > { let t = linkifyTaskIdsInMarkdown(displayText, comment.taskRefs); if (colorMap.size > 0 || teamNamesForLinkify.length > 0) t = linkifyAllMentionsInMarkdown( t, colorMap, teamNamesForLinkify ); return t; })()} maxHeight="max-h-none" bare /> )} ); })()} {comment.attachments && comment.attachments.length > 0 ? ( ) : null}
))}
{sortedComments.length > visibleComments.length ? (
) : null}
) : null} {/* Image lightbox */} {previewImageUrl ? ( setPreviewImageUrl(null)} src={previewImageUrl} alt="Attachment preview" /> ) : null} {!hideInput && ( <> {replyTo ? (
Replying to
{replyTo.text}
Cancel reply
) : null}
void handleSubmit()} minRows={2} maxRows={8} maxLength={MAX_TEXT_LENGTH} disabled={addingComment} cornerAction={ } footerRight={
{remaining < 200 ? ( {remaining} chars left ) : null} {draft.isSaved ? ( Saved ) : null}
} />
)}
); }; // --------------------------------------------------------------------------- // Comment attachment thumbnail (read-only, no delete) // --------------------------------------------------------------------------- interface CommentAttachmentThumbnailProps { attachment: TaskAttachmentMeta; teamName: string; taskId: string; onPreview: (dataUrl: string) => void; } const CommentAttachmentThumbnail = ({ attachment, teamName, taskId, onPreview, }: CommentAttachmentThumbnailProps): React.JSX.Element => { const getTaskAttachmentData = useStore((s) => s.getTaskAttachmentData); const [thumbUrl, setThumbUrl] = useState(null); const [downloading, setDownloading] = useState(false); const [downloadError, setDownloadError] = useState(null); useEffect(() => { let cancelled = false; void (async () => { try { if (!isImageMimeType(attachment.mimeType)) return; const base64 = await getTaskAttachmentData( teamName, taskId, attachment.id, attachment.mimeType ); if (!cancelled && base64) { setThumbUrl(`data:${attachment.mimeType};base64,${base64}`); } } catch { // ignore — thumbnail simply won't render } })(); return () => { cancelled = true; }; }, [teamName, taskId, attachment.id, attachment.mimeType, getTaskAttachmentData]); return (
{ if (isImageMimeType(attachment.mimeType)) { if (thumbUrl) onPreview(thumbUrl); return; } void (async () => { setDownloading(true); setDownloadError(null); try { const base64 = await getTaskAttachmentData( teamName, taskId, attachment.id, attachment.mimeType ); if (!base64) return; const mime = attachment.mimeType && typeof attachment.mimeType === 'string' ? attachment.mimeType : 'application/octet-stream'; const dataUrl = `data:${mime};base64,${base64}`; const blob = await fetch(dataUrl).then((r) => r.blob()); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = attachment.filename || 'attachment'; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); } catch (err) { setDownloadError(err instanceof Error ? err.message : 'Download failed'); } finally { setDownloading(false); } })(); }} > {isImageMimeType(attachment.mimeType) ? ( thumbUrl ? ( {attachment.filename} ) : ( ) ) : downloading ? ( ) : ( )}
{attachment.filename}
{downloadError ? ( {downloadError} ) : ( {attachment.filename} )}
); }; // --------------------------------------------------------------------------- // Comment attachments grid // --------------------------------------------------------------------------- interface CommentAttachmentsProps { attachments: TaskAttachmentMeta[]; teamName: string; taskId: string; onPreview: (dataUrl: string) => void; } const CommentAttachments = ({ attachments, teamName, taskId, onPreview, }: CommentAttachmentsProps): React.JSX.Element => (
{attachments.map((att) => ( ))}
);