import { useCallback, useMemo, useState } from 'react'; import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; import { ReplyQuoteBlock } from '@renderer/components/team/activity/ReplyQuoteBlock'; import { MemberBadge } from '@renderer/components/team/MemberBadge'; import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; import { useMarkCommentsRead } from '@renderer/hooks/useMarkCommentsRead'; import { useStore } from '@renderer/store'; import { buildReplyBlock, parseMessageReply } from '@renderer/utils/agentMessageFormatting'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { getModifierKeyName } from '@renderer/utils/keyboardUtils'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { formatDistanceToNow } from 'date-fns'; import { CheckCircle2, ChevronDown, ChevronUp, Eye, MessageSquare, Reply, Send, X, } from 'lucide-react'; import type { MentionSuggestion } from '@renderer/types/mention'; import type { ResolvedTeamMember, TaskComment } from '@shared/types'; /** * Convert literal backslash-n sequences to real newlines. * CLI tools (teamctl.js) 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 MAX_COMMENT_LENGTH = 2000; 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; } /** Convert `#` in plain text to markdown links with task:// protocol. */ function linkifyTaskIdsInMarkdown(text: string): string { return text.replace(/#(\d+)/g, '[#$1](task://$1)'); } export const TaskCommentsSection = ({ teamName, taskId, comments, members, hideHeader = false, hideInput = false, onReply, onTaskIdClick, }: TaskCommentsSectionProps): React.JSX.Element => { const addTaskComment = useStore((s) => s.addTaskComment); const addingComment = useStore((s) => s.addingComment); const commentsRef = useMarkCommentsRead(teamName, taskId, comments); const [replyTo, setReplyTo] = useState<{ author: string; text: string } | null>(null); const [expandedCommentIds, setExpandedCommentIds] = useState>(new Set()); const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_COMMENTS); // Reset local state when team/task changes (React-recommended pattern for // adjusting state based on props without using effects or refs during render) const currentKey = teamIdKey(teamName, taskId); const [prevKey, setPrevKey] = useState(currentKey); if (prevKey !== currentKey) { setPrevKey(currentKey); setVisibleCount(INITIAL_VISIBLE_COMMENTS); setExpandedCommentIds(new Set()); setReplyTo(null); } const toggleCommentExpanded = useCallback((commentId: string) => { setExpandedCommentIds((prev) => { const next = new Set(prev); if (next.has(commentId)) next.delete(commentId); else next.add(commentId); return next; }); }, []); const draft = useDraftPersistence({ key: `taskComment:${teamName}:${taskId}` }); const colorMap = useMemo(() => buildMemberColorMap(members), [members]); 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 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 = draft.value.trim(); const remaining = MAX_COMMENT_LENGTH - trimmed.length; const canSubmit = trimmed.length > 0 && trimmed.length <= MAX_COMMENT_LENGTH && !addingComment; const handleSubmit = useCallback(async () => { if (!canSubmit) return; try { const text = replyTo ? buildReplyBlock(replyTo.author, replyTo.text, trimmed) : trimmed; await addTaskComment(teamName, taskId, text); draft.clearDraft(); setReplyTo(null); } catch { // Error is stored in addCommentError via store } }, [canSubmit, addTaskComment, teamName, taskId, trimmed, draft, replyTo]); 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) => (
{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)); const needsExpandCollapse = displayText.includes('\n'); const expanded = expandedCommentIds.has(comment.id); const collapsedHeight = 'max-h-[120px]'; const showCollapsed = needsExpandCollapse && !expanded; const showExpandedButton = needsExpandCollapse && expanded; return (
{reply ? ( ) : ( { const link = (e.target as HTMLElement).closest( 'a[href^="task://"]' ); if (link) { e.preventDefault(); e.stopPropagation(); const id = link.getAttribute('href')?.replace('task://', ''); if (id) onTaskIdClick(id); } } : undefined } > )} {showCollapsed && ( <>
)}
{showExpandedButton && (
)}
); })()}
))} {sortedComments.length > visibleComments.length ? (
) : null}
) : null} {!hideInput && ( <> {replyTo ? (
Replying to
{replyTo.text}
Cancel reply
) : null}
void handleSubmit()} > Comment } footerRight={
{remaining < 200 ? ( {remaining} chars left ) : null} {draft.isSaved ? ( Draft saved ) : null}
} />
)}
); }; function teamIdKey(teamName: string, taskId: string): string { return `${teamName}::${taskId}`; }