import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { AttachmentPreviewList } from '@renderer/components/team/attachments/AttachmentPreviewList'; import { DropZoneOverlay } from '@renderer/components/team/attachments/DropZoneOverlay'; import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea'; import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { getTeamColorSet } from '@renderer/constants/teamColors'; import { useAttachments } from '@renderer/hooks/useAttachments'; import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; import { cn } from '@renderer/lib/utils'; import { useStore } from '@renderer/store'; import { serializeChipsWithText } from '@renderer/types/inlineChip'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { getModifierKeyName } from '@renderer/utils/keyboardUtils'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { AlertCircle, Check, ChevronDown, ImagePlus, Send } from 'lucide-react'; import type { MentionSuggestion } from '@renderer/types/mention'; import type { AttachmentPayload, ResolvedTeamMember } from '@shared/types'; interface MessageComposerProps { teamName: string; members: ResolvedTeamMember[]; isTeamAlive?: boolean; sending: boolean; sendError: string | null; onSend: ( recipient: string, text: string, summary?: string, attachments?: AttachmentPayload[] ) => void; } const MAX_MESSAGE_LENGTH = 4000; export const MessageComposer = ({ teamName, members, isTeamAlive, sending, sendError, onSend, }: MessageComposerProps): React.JSX.Element => { const [recipient, setRecipient] = useState(() => { const lead = members.find((m) => m.role === 'lead' || m.name === 'team-lead'); return lead?.name ?? members[0]?.name ?? ''; }); const [recipientOpen, setRecipientOpen] = useState(false); const [isDragOver, setIsDragOver] = useState(false); const dragCounterRef = useRef(0); const fileInputRef = useRef(null); // Members load async with team data; keep recipient stable if valid, otherwise default to lead/first. useEffect(() => { if (recipient && members.some((m) => m.name === recipient)) { return; } const lead = members.find((m) => m.role === 'lead' || m.name === 'team-lead'); const next = lead?.name ?? members[0]?.name ?? ''; if (next && next !== recipient) { setRecipient(next); } }, [members, recipient]); const projectPath = useStore((s) => s.selectedTeamData?.config.projectPath ?? null); const draft = useDraftPersistence({ key: `compose:${teamName}` }); const chipDraft = useChipDraftPersistence(`compose:${teamName}:chips`); const { attachments, error: attachmentError, canAddMore, addFiles, removeAttachment, clearAttachments, handlePaste, handleDrop, } = useAttachments({ persistenceKey: `compose:${teamName}:attachments` }); const colorMap = useMemo(() => buildMemberColorMap(members), [members]); 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 canSend = recipient.length > 0 && trimmed.length > 0 && trimmed.length <= MAX_MESSAGE_LENGTH && !sending; const selectedMember = members.find((m) => m.name === recipient); const selectedResolvedColor = selectedMember ? colorMap.get(selectedMember.name) : undefined; const selectedColorSet = selectedResolvedColor ? getTeamColorSet(selectedResolvedColor) : null; const isLeadRecipient = selectedMember?.role === 'lead' || selectedMember?.name === 'team-lead'; const canAttach = isLeadRecipient && isTeamAlive && canAddMore; // Track whether we initiated a send — clear draft only on confirmed success const pendingSendRef = useRef(false); const handleSend = useCallback(() => { if (!canSend) return; pendingSendRef.current = true; const serialized = serializeChipsWithText(trimmed, chipDraft.chips); onSend(recipient, serialized, serialized, attachments.length > 0 ? attachments : undefined); }, [canSend, recipient, trimmed, onSend, attachments, chipDraft.chips]); // Clear draft only after send completes successfully (sending: true → false, no error) useEffect(() => { if (!sending && pendingSendRef.current) { pendingSendRef.current = false; if (!sendError) { draft.clearDraft(); chipDraft.clearChipDraft(); clearAttachments(); } } // eslint-disable-next-line react-hooks/exhaustive-deps -- clearChipDraft is stable (useCallback with []) }, [sending, sendError, draft, clearAttachments, chipDraft.clearChipDraft]); const handleKeyDownCapture = useCallback( (e: React.KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); handleSend(); } }, [handleSend] ); const handleFileInputChange = useCallback( (e: React.ChangeEvent) => { const input = e.target; if (input.files?.length) { void addFiles(input.files); } input.value = ''; }, [addFiles] ); const handleDragEnter = useCallback((e: React.DragEvent) => { e.preventDefault(); dragCounterRef.current += 1; if (dragCounterRef.current === 1) setIsDragOver(true); }, []); const handleDragLeave = useCallback((e: React.DragEvent) => { e.preventDefault(); dragCounterRef.current -= 1; if (dragCounterRef.current <= 0) { dragCounterRef.current = 0; setIsDragOver(false); } }, []); const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); }, []); const handleDropWrapper = useCallback( (e: React.DragEvent) => { dragCounterRef.current = 0; setIsDragOver(false); if (canAttach) handleDrop(e); }, [canAttach, handleDrop] ); const handlePasteWrapper = useCallback( (e: React.ClipboardEvent) => { if (canAttach) handlePaste(e); }, [canAttach, handlePaste] ); const remaining = MAX_MESSAGE_LENGTH - trimmed.length; return (
{members.map((m) => { const resolvedColor = colorMap.get(m.name); const colorSet = resolvedColor ? getTeamColorSet(resolvedColor) : null; const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType); const isSelected = m.name === recipient; return ( ); })}
{isLeadRecipient ? ( <> {!isTeamAlive ? 'Team must be online to attach images' : !canAddMore ? 'Maximum attachments reached' : 'Attach images (paste or drag & drop)'} ) : null} {!isTeamAlive ? ( Team offline ) : null}
Send } footerRight={
{sendError ? ( {sendError} ) : null} {remaining < 200 ? ( {remaining} chars left ) : null} {draft.isSaved ? ( Draft saved ) : null}
} />
); };