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 { MemberBadge } from '@renderer/components/team/MemberBadge'; 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 { useComposerDraft } from '@renderer/hooks/useComposerDraft'; import { cn } from '@renderer/lib/utils'; import { useStore } from '@renderer/store'; import { serializeChipsWithText } from '@renderer/types/inlineChip'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { MAX_TEXT_LENGTH } from '@shared/constants'; import { AlertCircle, Check, ChevronDown, ImagePlus, Mic, Search, Send } from 'lucide-react'; import type { MentionSuggestion } from '@renderer/types/mention'; import type { AttachmentPayload, LeadContextUsage, 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; } /** Circular progress indicator for lead context usage. */ const _ContextRing = ({ ctx }: { ctx: LeadContextUsage }): React.JSX.Element => { const size = 26; const stroke = 2.5; const radius = (size - stroke) / 2; const circumference = 2 * Math.PI * radius; const pct = Math.min(ctx.percent, 100); const offset = circumference - (pct / 100) * circumference; const color = pct > 90 ? '#ef4444' : pct > 70 ? '#f59e0b' : '#3b82f6'; return (
{Math.round(pct)}
Context: {Math.round(pct)}% ({(ctx.currentTokens / 1000).toFixed(1)}k /{' '} {(ctx.contextWindow / 1000).toFixed(0)}k tokens)
); }; 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 [recipientSearch, setRecipientSearch] = useState(''); const recipientSearchRef = useRef(null); const [isDragOver, setIsDragOver] = useState(false); const dragCounterRef = useRef(0); const fileInputRef = useRef(null); const [imageRestrictionError, setImageRestrictionError] = useState(null); const imageRestrictionTimerRef = useRef(0); // 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) { queueMicrotask(() => setRecipient(next)); } }, [members, recipient]); const projectPath = useStore((s) => s.selectedTeamData?.config.projectPath ?? null); const isProvisioning = useStore((s) => Object.values(s.provisioningRuns).some( (run) => run.teamName === teamName && !['ready', 'disconnected', 'failed', 'cancelled'].includes(run.state) ) ); const draft = useComposerDraft(teamName); 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.text.trim(); const selectedMember = members.find((m) => m.name === recipient); const selectedResolvedColor = selectedMember ? colorMap.get(selectedMember.name) : undefined; const isLeadRecipient = selectedMember?.role === 'lead' || selectedMember?.name === 'team-lead'; // NOTE: lead context ring disabled — usage formula is inaccurate // const isLeadAgentRecipient = selectedMember?.agentType === 'team-lead'; // const leadContext = useStore((s) => // isLeadAgentRecipient ? s.leadContextByTeam[teamName] : undefined // ); const supportsAttachments = isLeadRecipient; const canAttach = supportsAttachments && draft.canAddMore; const attachmentsBlocked = draft.attachments.length > 0 && !supportsAttachments; const canSend = recipient.length > 0 && trimmed.length > 0 && trimmed.length <= MAX_TEXT_LENGTH && !sending && !attachmentsBlocked; // 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, draft.chips); // Summary should stay compact (no expanded chip markdown) onSend( recipient, serialized, trimmed, draft.attachments.length > 0 ? draft.attachments : undefined ); }, [canSend, recipient, trimmed, onSend, draft.attachments, draft.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(); } } }, [sending, sendError, draft]); const { addFiles: draftAddFiles } = draft; const handleFileInputChange = useCallback( (e: React.ChangeEvent) => { const input = e.target; if (input.files?.length) { void draftAddFiles(input.files); } input.value = ''; }, [draftAddFiles] ); const showImageRestrictionError = useCallback(() => { setImageRestrictionError('Images can only be sent to the team lead'); window.clearTimeout(imageRestrictionTimerRef.current); imageRestrictionTimerRef.current = window.setTimeout(() => { setImageRestrictionError(null); }, 4000); }, []); // Cleanup restriction error timer on unmount useEffect(() => { const ref = imageRestrictionTimerRef; 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 { handleDrop: draftHandleDrop } = draft; const handleDropWrapper = useCallback( (e: React.DragEvent) => { e.preventDefault(); dragCounterRef.current = 0; setIsDragOver(false); if (!isLeadRecipient) { const files = e.dataTransfer?.files; if (files?.length) { const hasImages = Array.from(files).some((f) => f.type.startsWith('image/')); if (hasImages) { showImageRestrictionError(); } } return; } if (canAttach) draftHandleDrop(e); }, [isLeadRecipient, canAttach, draftHandleDrop, showImageRestrictionError] ); const { handlePaste: draftHandlePaste } = draft; const handlePasteWrapper = useCallback( (e: React.ClipboardEvent) => { if (!isLeadRecipient) { const hasImages = Array.from(e.clipboardData.items).some((item) => item.type.startsWith('image/') ); if (hasImages) { e.preventDefault(); showImageRestrictionError(); } return; } if (canAttach) draftHandlePaste(e); }, [isLeadRecipient, canAttach, draftHandlePaste, showImageRestrictionError] ); const remaining = MAX_TEXT_LENGTH - trimmed.length; return (
{isLeadRecipient ? ( <> {!isTeamAlive ? 'Team must be online to attach images' : !draft.canAddMore ? 'Maximum attachments reached' : 'Attach images (paste or drag & drop)'}
) : ( )}
{isProvisioning ? ( Launching... inbox delivery only ) : !isTeamAlive ? ( Team offline ) : null} { e.preventDefault(); setRecipientSearch(''); setTimeout(() => recipientSearchRef.current?.focus(), 0); }} > {members.length > 5 && (
setRecipientSearch(e.target.value)} />
)}
{/* eslint-disable-next-line sonarjs/function-return-type -- IIFE rendering mixed elements/null */} {(() => { const query = recipientSearch.toLowerCase().trim(); const filtered = query ? members.filter((m) => m.name.toLowerCase().includes(query)) : members; if (filtered.length === 0) { return (
No results
); } const sorted = [...filtered].sort((a, b) => { const aIsLead = a.role === 'lead' || a.name === 'team-lead' ? 1 : 0; const bIsLead = b.role === 'lead' || b.name === 'team-lead' ? 1 : 0; return bIsLead - aIsLead; }); return sorted.map((m) => { const resolvedColor = colorMap.get(m.name); const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType); const isSelected = m.name === recipient; return ( ); }); })()}
{/* NOTE: ContextRing disabled — usage formula is inaccurate */} Voice to text
} footerRight={
{sendError ? ( {sendError} ) : null} {remaining < 200 ? ( {remaining} chars left ) : null} {draft.isSaved ? ( Draft saved ) : null}
} /> ); };