import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; import { AttachmentPreviewList } from '@renderer/components/team/attachments/AttachmentPreviewList'; import { DropZoneOverlay } from '@renderer/components/team/attachments/DropZoneOverlay'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from '@renderer/components/ui/dialog'; import { Label } from '@renderer/components/ui/label'; import { MemberSelect } from '@renderer/components/ui/MemberSelect'; import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { useAttachments } from '@renderer/hooks/useAttachments'; import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; import { useStore } from '@renderer/store'; import { chipToken, serializeChipsWithText } from '@renderer/types/inlineChip'; import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting'; import { removeChipTokenFromText } from '@renderer/utils/chipUtils'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { MAX_TEXT_LENGTH } from '@shared/constants'; import { AlertCircle, ImagePlus, Send, X } from 'lucide-react'; import { MemberBadge } from '../MemberBadge'; import type { InlineChip } from '@renderer/types/inlineChip'; import type { MentionSuggestion } from '@renderer/types/mention'; import type { AttachmentPayload, ResolvedTeamMember, SendMessageResult } from '@shared/types'; interface QuotedMessage { from: string; text: string; } interface SendMessageDialogProps { open: boolean; teamName: string; members: ResolvedTeamMember[]; defaultRecipient?: string; /** Pre-filled message text (e.g. from editor selection action) */ defaultText?: string; /** Pre-filled inline code chip (from editor selection action) */ defaultChip?: InlineChip; quotedMessage?: QuotedMessage; isTeamAlive?: boolean; sending: boolean; sendError: string | null; lastResult: SendMessageResult | null; onSend: ( member: string, text: string, summary?: string, attachments?: AttachmentPayload[] ) => void; onClose: () => void; } export const SendMessageDialog = ({ open, teamName, members, defaultRecipient, defaultText, defaultChip, quotedMessage, isTeamAlive, sending, sendError, lastResult, onSend, onClose, }: SendMessageDialogProps): React.JSX.Element => { const colorMap = useMemo(() => buildMemberColorMap(members), [members]); const projectPath = useStore((s) => s.selectedTeamData?.config.projectPath ?? null); const [quote, setQuote] = useState(undefined); const [quoteExpanded, setQuoteExpanded] = useState(false); const [member, setMember] = useState(''); const textDraft = useDraftPersistence({ key: `sendMessage:${teamName}:text` }); const chipDraft = useChipDraftPersistence(`sendMessage:${teamName}:chips`); const prevOpenRef = useRef(false); const prevResultRef = useRef(null); const [isDragOver, setIsDragOver] = useState(false); const dragCounterRef = useRef(0); const fileInputRef = useRef(null); const { attachments, error: attachmentError, canAddMore, addFiles, removeAttachment, clearAttachments, handlePaste, handleDrop, } = useAttachments({ persistenceKey: `sendMessage:${teamName}:attachments` }); const selectedMember = members.find((m) => m.name === member); const isLeadRecipient = selectedMember?.role === 'lead' || selectedMember?.name === 'team-lead'; const supportsAttachments = isLeadRecipient && !!isTeamAlive; const canAttach = supportsAttachments && canAddMore; const [pendingAutoClose, setPendingAutoClose] = useState(false); // Reset form on open transition (avoid setState in render) useEffect(() => { if (open && !prevOpenRef.current) { setMember(defaultRecipient ?? ''); setQuote(quotedMessage); setQuoteExpanded(false); prevResultRef.current = lastResult; if (defaultChip) { const token = chipToken(defaultChip); textDraft.setValue(token + '\n'); chipDraft.setChips([defaultChip]); } else if (defaultText) { textDraft.setValue(defaultText); } } prevOpenRef.current = open; }, [ open, defaultRecipient, defaultText, defaultChip, quotedMessage, lastResult, textDraft, chipDraft, ]); // Track whether auto-close is needed (avoid setState in render) useEffect(() => { if (!open) return; if (lastResult && lastResult !== prevResultRef.current) { prevResultRef.current = lastResult; setMember(''); setPendingAutoClose(true); } }, [open, lastResult]); // Side effects (onClose mutates parent state) must run in useEffect, not render phase useEffect(() => { if (pendingAutoClose) { textDraft.clearDraft(); chipDraft.clearChipDraft(); clearAttachments(); setPendingAutoClose(false); onClose(); } // eslint-disable-next-line react-hooks/exhaustive-deps -- only trigger on pendingAutoClose flag }, [pendingAutoClose]); const QUOTE_COLLAPSE_THRESHOLD = 120; const isQuoteLong = (quote?.text.length ?? 0) > QUOTE_COLLAPSE_THRESHOLD; 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 attachmentsBlocked = attachments.length > 0 && !supportsAttachments; const trimmedText = textDraft.value.trim(); const serialized = serializeChipsWithText(trimmedText, chipDraft.chips); const finalText = quote ? buildReplyBlock(quote.from, quote.text, serialized) : serialized; const remaining = MAX_TEXT_LENGTH - finalText.length; const canSend = member.trim().length > 0 && finalText.length > 0 && finalText.length <= MAX_TEXT_LENGTH && !sending && !attachmentsBlocked; const handleChipRemove = (chipId: string): void => { const chip = chipDraft.chips.find((c) => c.id === chipId); if (chip) { textDraft.setValue(removeChipTokenFromText(textDraft.value, chip)); } chipDraft.setChips(chipDraft.chips.filter((c) => c.id !== chipId)); }; const handleSubmit = (): void => { if (!canSend) return; onSend(member.trim(), finalText, trimmedText, attachments.length > 0 ? attachments : undefined); textDraft.clearDraft(); chipDraft.clearChipDraft(); clearAttachments(); }; const handleOpenChange = (nextOpen: boolean): void => { if (!nextOpen) { onClose(); } }; 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] ); return ( Send Message Send a direct message to a team member.
setMember(v ?? '')} placeholder="Select member..." size="sm" />
{isLeadRecipient ? ( <> {!isTeamAlive ? 'Team must be online to attach images' : !canAddMore ? 'Maximum attachments reached' : 'Attach images (paste or drag & drop)'} ) : null}
{quote ? (
{/* Decorative quotation mark */} Remove quote
Replying to
{isQuoteLong ? ( ) : null}
) : null} chipDraft.setChips([...chipDraft.chips, chip])} onModEnter={handleSubmit} minRows={4} maxRows={12} maxLength={MAX_TEXT_LENGTH} disabled={sending} cornerAction={ } footerRight={
{sendError ? ( {sendError} ) : null} {remaining < 200 ? ( {remaining} chars left ) : null} {textDraft.isSaved ? ( Draft saved ) : null}
} />
); };