import { useCallback, useMemo, useRef, useState } from 'react'; import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; import { ImageLightbox } from '@renderer/components/team/attachments/ImageLightbox'; import { FileIcon } from '@renderer/components/team/editor/FileIcon'; import { MemberBadge } from '@renderer/components/team/MemberBadge'; 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 { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions'; import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions'; import { useStore } from '@renderer/store'; import { serializeChipsWithText } from '@renderer/types/inlineChip'; import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { extractTaskRefsFromText, stripEncodedTaskReferenceMetadata, } from '@renderer/utils/taskReferenceUtils'; import { MAX_TEXT_LENGTH } from '@shared/constants'; import { categorizeFile, getEffectiveMimeType, isImageMime } from '@shared/constants/attachments'; import { Mic, Paperclip, Send, Trash2, X } from 'lucide-react'; import type { MentionSuggestion } from '@renderer/types/mention'; import type { CommentAttachmentPayload, ResolvedTeamMember } from '@shared/types'; const MAX_ATTACHMENTS = 5; const MAX_FILE_SIZE = 20 * 1024 * 1024; const LONG_QUOTE_THRESHOLD = 200; interface TaskCommentInputProps { teamName: string; taskId: string; members: ResolvedTeamMember[]; replyTo: { author: string; text: string } | null; onClearReply: () => void; } interface PendingAttachment { id: string; filename: string; mimeType: string; base64Data: string; previewUrl: string; size: number; } export const TaskCommentInput = ({ teamName, taskId, members, replyTo, onClearReply, }: TaskCommentInputProps): 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 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 [pendingAttachments, setPendingAttachments] = useState([]); const [attachError, setAttachError] = useState(null); const [lightboxIndex, setLightboxIndex] = useState(null); const [quoteExpanded, setQuoteExpanded] = useState(false); const fileInputRef = useRef(null); 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 || pendingAttachments.length > 0) && trimmed.length <= MAX_TEXT_LENGTH && !addingComment; const addFiles = useCallback( async (files: FileList | File[]) => { setAttachError(null); const fileArray = Array.from(files); // 1. Separate unsupported files → path prepend const supported: File[] = []; for (const file of fileArray) { if (categorizeFile(file) === 'unsupported') { let filePath = ''; try { filePath = window.electronAPI.getPathForFile(file); } catch { // Clipboard files: no path available } if (filePath) { const current = draft.value; draft.setValue(current ? filePath + '\n' + current : filePath + '\n'); } continue; } if (file.size === 0) { setAttachError(`File "${file.name}" is empty`); continue; } if (file.size > MAX_FILE_SIZE) { setAttachError( `File too large: ${(file.size / (1024 * 1024)).toFixed(1)} MB (max 20 MB)` ); continue; } supported.push(file); } if (supported.length === 0) return; // 2. Read all files sequentially to avoid race condition with MAX_ATTACHMENTS for (const file of supported) { const result = await new Promise((resolve) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result as string); reader.onerror = () => resolve(null); reader.readAsDataURL(file); }); if (!result) continue; const base64 = result.split(',')[1]; if (!base64) continue; const id = crypto.randomUUID(); setPendingAttachments((prev) => { if (prev.length >= MAX_ATTACHMENTS) { setAttachError(`Maximum ${MAX_ATTACHMENTS} attachments per comment`); return prev; } return [ ...prev, { id, filename: file.name, mimeType: getEffectiveMimeType(file), base64Data: base64, previewUrl: result, size: file.size, }, ]; }); } }, [draft] ); const removeAttachment = useCallback((id: string) => { setPendingAttachments((prev) => prev.filter((a) => a.id !== id)); }, []); const handleSubmit = useCallback(async () => { if (!canSubmit) return; try { const serialized = serializeChipsWithText(trimmed, chipDraft.chips); const text = replyTo ? buildReplyBlock(replyTo.author, replyTo.text, serialized || '(image)') : serialized || '(image)'; const taskRefs = extractTaskRefsFromText(draft.value, taskSuggestions); const attachments: CommentAttachmentPayload[] | undefined = pendingAttachments.length > 0 ? pendingAttachments.map((a) => ({ id: a.id, filename: a.filename, mimeType: a.mimeType, base64Data: a.base64Data, })) : undefined; await addTaskComment(teamName, taskId, { text, attachments, taskRefs, }); draft.clearDraft(); chipDraft.clearChipDraft(); setPendingAttachments([]); setAttachError(null); onClearReply(); } catch { // Error is stored in addCommentError via store } }, [ canSubmit, addTaskComment, teamName, taskId, trimmed, draft, chipDraft, replyTo, onClearReply, pendingAttachments, taskSuggestions, ]); // Handle paste from MentionableTextarea area const handlePaste = useCallback( (e: React.ClipboardEvent) => { const items = e.clipboardData?.items; if (!items) return; const pastedFiles: File[] = []; for (const item of Array.from(items)) { if (item.kind === 'file') { const file = item.getAsFile(); if (file) pastedFiles.push(file); } } if (pastedFiles.length > 0) { e.preventDefault(); void addFiles(pastedFiles); } }, [addFiles] ); return (
{replyTo ? (
{/* Decorative quotation mark */} Cancel reply
Replying to
{replyTo.text.length > LONG_QUOTE_THRESHOLD ? ( ) : null}
) : null} {/* Pending attachment previews */} {pendingAttachments.length > 0 ? (
{pendingAttachments.map((att, idx) => { const isImage = isImageMime(att.mimeType); const lightboxIdx = isImage ? pendingAttachments.slice(0, idx).filter((a) => isImageMime(a.mimeType)).length : -1; return (
setLightboxIndex(lightboxIdx) : undefined} > {isImage ? ( {att.filename} ) : (
{att.filename}
)}
); })}
) : null} {lightboxIndex !== null && pendingAttachments.length > 0 ? ( setLightboxIndex(null)} slides={pendingAttachments .filter((att) => isImageMime(att.mimeType)) .map((att) => ({ src: att.previewUrl, alt: att.filename, title: att.filename, }))} index={lightboxIndex} showCounter={pendingAttachments.filter((a) => isImageMime(a.mimeType)).length > 1} /> ) : null} {attachError ?

{attachError}

: null}
{ if (e.target.files) void addFiles(e.target.files); e.target.value = ''; }} /> void handleSubmit()} minRows={2} maxRows={8} maxLength={MAX_TEXT_LENGTH} disabled={addingComment} cornerAction={
Attach file (or paste) Voice to text
} footerRight={
{remaining < 200 ? ( {remaining} chars left ) : null} {draft.isSaved ? ( Saved ) : null}
} />
); };