import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { api } from '@renderer/api'; import { AttachmentPreviewList } from '@renderer/components/team/attachments/AttachmentPreviewList'; import { DropZoneOverlay } from '@renderer/components/team/attachments/DropZoneOverlay'; import { MemberBadge } from '@renderer/components/team/MemberBadge'; import { ActionModeSelector } from '@renderer/components/team/messages/ActionModeSelector'; 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 { useComposerDraft } from '@renderer/hooks/useComposerDraft'; import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions'; import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions'; import { cn } from '@renderer/lib/utils'; import { useStore } from '@renderer/store'; import { isTeamProvisioningActive } from '@renderer/store/slices/teamSlice'; import { serializeChipsWithText } from '@renderer/types/inlineChip'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { nameColorSet } from '@renderer/utils/projectColor'; import { extractTaskRefsFromText, stripEncodedTaskReferenceMetadata, } from '@renderer/utils/taskReferenceUtils'; import { MAX_TEXT_LENGTH } from '@shared/constants'; import { isLeadMember } from '@shared/utils/leadDetection'; import { AlertCircle, Check, ChevronDown, Mic, Paperclip, Search, Send } from 'lucide-react'; import type { ActionMode } from '@renderer/components/team/messages/ActionModeSelector'; import type { MentionSuggestion } from '@renderer/types/mention'; import type { AttachmentPayload, ResolvedTeamMember, SendMessageResult, TaskRef, } from '@shared/types'; interface MessageComposerProps { teamName: string; members: ResolvedTeamMember[]; isTeamAlive?: boolean; sending: boolean; sendError: string | null; lastResult?: SendMessageResult | null; /** Ref to the underlying textarea element for external focus management. */ textareaRef?: React.Ref; onSend: ( recipient: string, text: string, summary?: string, attachments?: AttachmentPayload[], actionMode?: ActionMode, taskRefs?: TaskRef[] ) => void; onCrossTeamSend?: ( toTeam: string, text: string, summary?: string, actionMode?: ActionMode, taskRefs?: TaskRef[] ) => void; } export const MessageComposer = ({ teamName, members, isTeamAlive, sending, sendError, lastResult, textareaRef: externalTextareaRef, onSend, onCrossTeamSend, }: MessageComposerProps): React.JSX.Element => { const internalTextareaRef = useRef(null); const textareaRef = useMemo(() => { // Merge internal and external refs into a single callback ref return (node: HTMLTextAreaElement | null) => { (internalTextareaRef as React.MutableRefObject).current = node; if (typeof externalTextareaRef === 'function') { externalTextareaRef(node); } else if (externalTextareaRef) { (externalTextareaRef as React.MutableRefObject).current = node; } }; }, [externalTextareaRef]); const [recipient, setRecipient] = useState(() => { const lead = members.find((m) => isLeadMember(m)); 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 [fileRestrictionError, setFileRestrictionError] = useState(null); const fileRestrictionTimerRef = useRef(0); const dismissMentionsRef = useRef<(() => void) | null>(null); // Cross-team state const [selectedTeam, setSelectedTeam] = useState(null); const [teamSelectorOpen, setTeamSelectorOpen] = useState(false); const [aliveTeams, setAliveTeams] = useState>(new Set()); const allCrossTeamTargets = useStore((s) => s.crossTeamTargets); const fetchCrossTeamTargets = useStore((s) => s.fetchCrossTeamTargets); useEffect(() => { void fetchCrossTeamTargets(); }, [fetchCrossTeamTargets]); const refreshAliveTeams = useCallback(async () => { try { const list = await api.teams.aliveList(); setAliveTeams(new Set(list)); } catch { // best-effort } }, []); useEffect(() => { void refreshAliveTeams(); }, [refreshAliveTeams]); useEffect(() => { if (!teamSelectorOpen) return; void refreshAliveTeams(); }, [teamSelectorOpen, refreshAliveTeams]); // Always filter out current team on the UI side (store is global, shared across tabs) const crossTeamTargets = useMemo( () => allCrossTeamTargets.filter((t) => t.teamName !== teamName), [allCrossTeamTargets, teamName] ); const sortedCrossTeamTargets = useMemo( () => crossTeamTargets .map((target) => ({ ...target, isOnline: aliveTeams.has(target.teamName), })) .sort((a, b) => { if (a.isOnline && !b.isOnline) return -1; if (!a.isOnline && b.isOnline) return 1; return (a.displayName || a.teamName).localeCompare( b.displayName || b.teamName, undefined, { sensitivity: 'base', } ); }), [aliveTeams, crossTeamTargets] ); const isCrossTeam = selectedTeam !== null; const selectedTarget = sortedCrossTeamTargets.find((t) => t.teamName === selectedTeam); const targetDisplayName = selectedTarget?.displayName ?? selectedTeam; const crossTeamHintText = isCrossTeam ? 'Tip: Cross-team messages go to the target team lead. If you want the reply to come back to your team lead instead of you, say that explicitly in the message.' : undefined; // 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) => isLeadMember(m)); 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 currentTeamColor = useStore((s) => { const configColor = s.selectedTeamData?.config.color; if (configColor) return getTeamColorSet(configColor).border; const displayName = s.selectedTeamData?.config.name ?? teamName; return nameColorSet(displayName).border; }); const isProvisioning = useStore((s) => isTeamProvisioningActive(s, teamName)); 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 { suggestions: teamMentionSuggestions } = useTeamSuggestions(teamName); const { suggestions: taskSuggestions } = useTaskSuggestions(teamName); const trimmed = stripEncodedTaskReferenceMetadata(draft.text).trim(); const selectedMember = members.find((m) => m.name === recipient); const selectedResolvedColor = selectedMember ? colorMap.get(selectedMember.name) : undefined; const isLeadRecipient = selectedMember ? isLeadMember(selectedMember) : false; const hasTeammates = members.length > 1; const canDelegate = hasTeammates && (isCrossTeam || isLeadRecipient); const shouldAutoDelegate = isLeadRecipient && canDelegate; const { actionMode, setActionMode, isLoaded: draftLoaded } = draft; // Re-focus textarea after action mode changes (Do/Ask/Delegate button clicks) const prevActionModeRef = useRef(actionMode); useEffect(() => { if (prevActionModeRef.current !== actionMode) { prevActionModeRef.current = actionMode; internalTextareaRef.current?.focus(); } }, [actionMode]); // Auto-select delegate when lead recipient is chosen by the user. // Wait until draft is restored from IndexedDB (draftLoaded) before running, // so we don't overwrite the persisted actionMode during initialization. // After draft loads, only auto-switch on subsequent recipient changes. const isInitializedRef = useRef(false); const prevShouldAutoDelegateRef = useRef(shouldAutoDelegate); useEffect(() => { if (!draftLoaded) return; if (!canDelegate && actionMode === 'delegate') { setActionMode('do'); return; } // On first run after load, just record the baseline — don't overwrite if (!isInitializedRef.current) { isInitializedRef.current = true; prevShouldAutoDelegateRef.current = shouldAutoDelegate; return; } // Only react when delegate availability actually changes if (shouldAutoDelegate === prevShouldAutoDelegateRef.current) return; prevShouldAutoDelegateRef.current = shouldAutoDelegate; if (shouldAutoDelegate) { setActionMode('delegate'); } else if (actionMode === 'delegate') { setActionMode('do'); } }, [actionMode, canDelegate, draftLoaded, setActionMode, shouldAutoDelegate]); // 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 && !isCrossTeam && !!isTeamAlive; 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 && !isProvisioning && !attachmentsBlocked && (!isCrossTeam || onCrossTeamSend !== undefined); // Track whether we initiated a send — clear draft only on confirmed success const pendingSendRef = useRef(false); const handleCycleActionMode = useCallback(() => { const modes: ActionMode[] = canDelegate ? ['do', 'ask', 'delegate'] : ['do', 'ask']; const idx = modes.indexOf(actionMode); setActionMode(modes[(idx + 1) % modes.length]); }, [actionMode, canDelegate, setActionMode]); const handleSend = useCallback(() => { if (!canSend) return; dismissMentionsRef.current?.(); pendingSendRef.current = true; const taskRefs = extractTaskRefsFromText(draft.text, taskSuggestions); const serialized = serializeChipsWithText(trimmed, draft.chips); if (isCrossTeam && selectedTeam && onCrossTeamSend) { onCrossTeamSend(selectedTeam, serialized, trimmed, actionMode, taskRefs); } else { // Summary should stay compact (no expanded chip markdown) onSend( recipient, serialized, trimmed, draft.attachments.length > 0 ? draft.attachments : undefined, actionMode, taskRefs ); } }, [ actionMode, canSend, recipient, trimmed, onSend, onCrossTeamSend, isCrossTeam, selectedTeam, draft.attachments, draft.chips, taskSuggestions, ]); // 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 showFileRestrictionError = useCallback(() => { setFileRestrictionError('Files can only be sent to the team lead'); window.clearTimeout(fileRestrictionTimerRef.current); fileRestrictionTimerRef.current = window.setTimeout(() => { setFileRestrictionError(null); }, 4000); }, []); // 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 { handleDrop: draftHandleDrop } = draft; 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; } draftHandleDrop(e); }, [supportsAttachments, draftHandleDrop, showFileRestrictionError] ); const { handlePaste: draftHandlePaste } = draft; 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; } draftHandlePaste(e); }, [supportsAttachments, draftHandlePaste, showFileRestrictionError] ); const remaining = MAX_TEXT_LENGTH - trimmed.length; const hasAttachmentPreviewContent = draft.attachments.length > 0 || Boolean(draft.attachmentError ?? fileRestrictionError); return (
{isLeadRecipient ? ( <> {!isTeamAlive ? 'Team must be online to attach files' : !draft.canAddMore ? 'Maximum attachments reached' : 'Attach files (paste or drag & drop)'} ) : null}
{!isTeamAlive && !isProvisioning && ( Team offline )} {/* Combined team + member selector */} {sortedCrossTeamTargets.length > 0 ? (
{/* Current team option */} {/* Separator */}
{/* Other teams */} {sortedCrossTeamTargets.map((target) => { const isSelected = selectedTeam === target.teamName; return ( ); })}
{ 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 = isLeadMember(a) ? 1 : 0; const bIsLead = isLeadMember(b) ? 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 ( ); }); })()}
) : ( { 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 = isLeadMember(a) ? 1 : 0; const bIsLead = isLeadMember(b) ? 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 ( ); }); })()}
)}
{hasAttachmentPreviewContent ? ( ) : null}
} cornerAction={
{/* NOTE: ContextRing disabled — usage formula is inaccurate */} Voice to text {isProvisioning && !sending ? ( Sending unavailable while team is launching ) : null}
} footerRight={
{sendError ? ( {sendError} ) : lastResult?.deduplicated ? ( Reused recent cross-team request ) : null} {remaining < 200 ? ( {remaining} chars left ) : null} {draft.isSaved ? ( Saved ) : null}
} />
); };