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 { ActionModeSelector } from '@renderer/components/team/messages/ActionModeSelector'; 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 { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions'; import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions'; 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 { extractTaskRefsFromText, stripEncodedTaskReferenceMetadata, } from '@renderer/utils/taskReferenceUtils'; import { MAX_TEXT_LENGTH } from '@shared/constants'; import { isLeadMember } from '@shared/utils/leadDetection'; import { AlertCircle, Paperclip, Send, X } from 'lucide-react'; import { MemberBadge } from '../MemberBadge'; import type { ActionMode } from '@renderer/components/team/messages/ActionModeSelector'; import type { InlineChip } from '@renderer/types/inlineChip'; import type { MentionSuggestion } from '@renderer/types/mention'; import type { AttachmentPayload, ResolvedTeamMember, SendMessageResult, TaskRef, } 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; sendWarning?: string | null; lastResult: SendMessageResult | null; onSend: ( member: string, text: string, summary?: string, attachments?: AttachmentPayload[], actionMode?: ActionMode, taskRefs?: TaskRef[] ) => void | Promise; onClose: () => void; } // Sticky action mode within the current session. // Each dialog open still re-derives the default from the current team shape. let stickyActionMode: ActionMode = 'delegate'; export const SendMessageDialog = ({ open, teamName, members, defaultRecipient, defaultText, defaultChip, quotedMessage, isTeamAlive, sending, sendError, sendWarning, 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 [fileRestrictionError, setFileRestrictionError] = useState(null); const fileRestrictionTimerRef = useRef(0); const [actionMode, setActionModeState] = useState(stickyActionMode); const actionModeRef = useRef(stickyActionMode); const setActionMode = useCallback((mode: ActionMode) => { actionModeRef.current = mode; stickyActionMode = mode; setActionModeState(mode); }, []); const { attachments, error: attachmentError, canAddMore, addFiles, removeAttachment, clearAttachments, clearError: clearAttachmentError, handlePaste, handleDrop, } = useAttachments({ persistenceKey: `sendMessage:${teamName}:attachments` }); const selectedMember = members.find((m) => m.name === member); const isLeadRecipient = selectedMember ? isLeadMember(selectedMember) : false; const hasTeammates = members.length > 1; const canDelegate = hasTeammates && isLeadRecipient; const shouldAutoDelegate = canDelegate; const supportsAttachments = isLeadRecipient && !!isTeamAlive; const canAttach = supportsAttachments && canAddMore; const attachmentRestrictionReason = !supportsAttachments ? !isLeadRecipient ? 'Files can only be sent to the team lead' : 'Team must be online to attach files' : undefined; // Auto-switch to delegate when lead recipient is selected, but don't // override user's explicit choice on dialog open. const prevShouldAutoDelegateRef = useRef(shouldAutoDelegate); useEffect(() => { if (!canDelegate && actionMode === 'delegate') { setActionMode('do'); return; } // Skip the initial mount — honour the sticky mode if (prevShouldAutoDelegateRef.current === shouldAutoDelegate) return; prevShouldAutoDelegateRef.current = shouldAutoDelegate; if (shouldAutoDelegate) { setActionMode('delegate'); } else { setActionModeState((prev) => (prev === 'delegate' ? 'do' : prev)); } }, [actionMode, canDelegate, setActionMode, shouldAutoDelegate]); const [pendingAutoClose, setPendingAutoClose] = useState(false); // Reset form on open transition (avoid setState in render) useEffect(() => { if (open && !prevOpenRef.current) { const leadName = members.find((m) => isLeadMember(m))?.name; const nextRecipient = defaultRecipient ?? leadName ?? ''; const nextRecipientMember = members.find((candidate) => candidate.name === nextRecipient); const nextCanDelegate = members.length > 1 && Boolean(nextRecipientMember && isLeadMember(nextRecipientMember)); setMember(nextRecipient); setActionMode(nextCanDelegate ? 'delegate' : 'do'); 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, members, setActionMode, 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 { suggestions: teamMentionSuggestions } = useTeamSuggestions(teamName); const { suggestions: taskSuggestions } = useTaskSuggestions(teamName); const attachmentsBlocked = attachments.length > 0 && !supportsAttachments; const trimmedText = stripEncodedTaskReferenceMetadata(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; const taskRefs = extractTaskRefsFromText(textDraft.value, taskSuggestions); void Promise.resolve( onSend( member.trim(), finalText, trimmedText, attachments.length > 0 ? attachments : undefined, actionMode, taskRefs ) ) .then(() => { textDraft.clearDraft(); chipDraft.clearChipDraft(); clearAttachments(); }) .catch(() => { // The store owns the visible send error; keep the draft intact for retry. }); }; 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 showFileRestrictionError = useCallback(() => { setFileRestrictionError( attachmentRestrictionReason ?? 'Files can only be sent to the team lead' ); window.clearTimeout(fileRestrictionTimerRef.current); fileRestrictionTimerRef.current = window.setTimeout(() => { setFileRestrictionError(null); }, 4000); }, [attachmentRestrictionReason]); // Cleanup restriction error timer on unmount useEffect(() => { const ref = fileRestrictionTimerRef; return () => window.clearTimeout(ref.current); }, []); 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) => { e.preventDefault(); dragCounterRef.current = 0; setIsDragOver(false); if (!supportsAttachments) { const files = e.dataTransfer?.files; if (files?.length) { showFileRestrictionError(); } return; } handleDrop(e); }, [supportsAttachments, handleDrop, showFileRestrictionError] ); const handlePasteWrapper = useCallback( (e: React.ClipboardEvent) => { if (!supportsAttachments) { const hasFiles = Array.from(e.clipboardData.items).some((item) => item.kind === 'file'); if (hasFiles) { e.preventDefault(); showFileRestrictionError(); } return; } handlePaste(e); }, [supportsAttachments, handlePaste, showFileRestrictionError] ); 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 files' : !canAddMore ? 'Maximum attachments reached' : 'Attach files (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} cornerActionLeft={ } cornerAction={ } footerRight={
{sendError ? ( {sendError} ) : sendWarning ? ( {sendWarning} ) : null} {remaining < 200 ? ( {remaining} chars left ) : null} {textDraft.isSaved ? ( Saved ) : null}
} />
); };