import { useCallback, useMemo, useRef, useState } from 'react'; import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { getTeamColorSet } from '@renderer/constants/teamColors'; 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 { buildReplyBlock } from '@renderer/utils/agentMessageFormatting'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { serializeChipsWithText } from '@renderer/types/inlineChip'; import { MAX_TEXT_LENGTH } from '@shared/constants'; import { ImagePlus, Mic, Send, Trash2, X } from 'lucide-react'; import { ImageLightbox } from '@renderer/components/team/attachments/ImageLightbox'; 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 ACCEPTED_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']); 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 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 = 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((files: FileList | File[]) => { setAttachError(null); const fileArray = Array.from(files); for (const file of fileArray) { if (!ACCEPTED_TYPES.has(file.type)) { setAttachError(`Unsupported type: ${file.type}`); continue; } if (file.size > MAX_FILE_SIZE) { setAttachError(`File too large: ${(file.size / (1024 * 1024)).toFixed(1)} MB (max 20 MB)`); continue; } const reader = new FileReader(); reader.onload = () => { const result = reader.result as string; const base64 = result.split(',')[1]; if (!base64) return; 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: file.type, base64Data: base64, previewUrl: result, size: file.size, }, ]; }); }; reader.readAsDataURL(file); } }, []); 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 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); 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, ]); // Handle paste from MentionableTextarea area const handlePaste = useCallback( (e: React.ClipboardEvent) => { const items = e.clipboardData?.items; if (!items) return; const imageFiles: File[] = []; for (const item of Array.from(items)) { if (item.kind === 'file' && ACCEPTED_TYPES.has(item.type)) { const file = item.getAsFile(); if (file) imageFiles.push(file); } } if (imageFiles.length > 0) { e.preventDefault(); addFiles(imageFiles); } }, [addFiles] ); return (
{replyTo ? (
Replying to{' '} { const rc = colorMap.get(replyTo.author); return rc ? getTeamColorSet(rc).text : 'var(--color-text-secondary)'; })(), }} > @{replyTo.author}
{replyTo.text}
Cancel reply
) : null} {/* Pending attachment previews */} {pendingAttachments.length > 0 ? (
{pendingAttachments.map((att, idx) => (
setLightboxIndex(idx)} > {att.filename}
))}
) : null} {lightboxIndex !== null && pendingAttachments.length > 0 ? ( setLightboxIndex(null)} slides={pendingAttachments.map((att) => ({ src: att.previewUrl, alt: att.filename, title: att.filename, }))} index={lightboxIndex} showCounter={pendingAttachments.length > 1} /> ) : null} {attachError ?

{attachError}

: null}
{ if (e.target.files) addFiles(e.target.files); // eslint-disable-next-line no-param-reassign -- reset file input to allow re-selecting same file e.target.value = ''; }} /> void handleSubmit()} minRows={2} maxRows={8} maxLength={MAX_TEXT_LENGTH} disabled={addingComment} cornerAction={
Attach image (or paste) Voice to text
} footerRight={
{remaining < 200 ? ( {remaining} chars left ) : null} {draft.isSaved ? ( Draft saved ) : null}
} />
); };