import { useCallback, useEffect, useLayoutEffect, 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 { OpenCodeDeliveryWarning } from '@renderer/components/team/messages/OpenCodeDeliveryWarning'; 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 { useAppTranslation } from '@features/localization/renderer'; 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 { canMemberShowAttachmentControl, getAttachmentInputAcceptForMember, getMemberAttachmentUnavailableReason, validateAttachmentFilesForMember, validateAttachmentPayloadsForMember, } from '@renderer/utils/attachmentRecipientCapabilities'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { isOpenCodeRuntimeDeliveryHardUxFailureFromDebugDetails } from '@renderer/utils/openCodeRuntimeDeliveryDiagnostics'; import { nameColorSet } from '@renderer/utils/projectColor'; import { getSuggestedSlashCommandsForProvider } from '@renderer/utils/providerSlashCommands'; import { buildSlashCommandSuggestions } from '@renderer/utils/skillCommandSuggestions'; import { extractTaskRefsFromText, stripEncodedTaskReferenceMetadata, } from '@renderer/utils/taskReferenceUtils'; import { MAX_TEXT_LENGTH } from '@shared/constants'; import { isLeadMember } from '@shared/utils/leadDetection'; import { parseStandaloneSlashCommand } from '@shared/utils/slashCommands'; import { inferTeamProviderIdFromModel, normalizeOptionalTeamProviderId, } from '@shared/utils/teamProvider'; import { AlertCircle, Check, ChevronDown, Mic, Paperclip, Search, Send } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; import type { ActionMode } from '@renderer/components/team/messages/ActionModeSelector'; import type { ComposerDraftContent } from '@renderer/hooks/useComposerDraft'; import type { MentionSuggestion } from '@renderer/types/mention'; import type { OpenCodeRuntimeDeliveryDebugDetails } from '@renderer/utils/openCodeRuntimeDeliveryDiagnostics'; import type { AttachmentPayload, ResolvedTeamMember, SendMessageResult, TaskRef, } from '@shared/types'; interface MessageComposerProps { teamName: string; members: ResolvedTeamMember[]; layout?: 'default' | 'compact'; widthMode?: 'full' | 'floating-adaptive'; isTeamAlive?: boolean; sending: boolean; sendError: string | null; sendWarning?: string | null; sendDebugDetails?: OpenCodeRuntimeDeliveryDebugDetails | null; lastResult?: SendMessageResult | null; cornerActionPrefix?: React.ReactNode; /** 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; } interface PendingSendState { teamName: string; snapshot: ComposerDraftContent; previousDebugDetails: OpenCodeRuntimeDeliveryDebugDetails | null | undefined; previousLastResult: SendMessageResult | null | undefined; observedSending: boolean; optimisticallyCleared: boolean; } let pendingSendIdCounter = 0; const FLOATING_COMPOSER_MIN_WIDTH = 350; const FLOATING_COMPOSER_MAX_WIDTH = 500; const FLOATING_COMPOSER_TEXT_BUFFER = 4; function createPendingSendId(): string { const randomId = globalThis.crypto?.randomUUID?.(); if (randomId) return randomId; pendingSendIdCounter += 1; return `${Date.now()}-${pendingSendIdCounter}`; } export const MessageComposer = ({ teamName, members, layout = 'default', widthMode = 'full', isTeamAlive, sending, sendError, sendWarning, sendDebugDetails, lastResult, cornerActionPrefix, textareaRef: externalTextareaRef, onSend, onCrossTeamSend, }: MessageComposerProps): React.JSX.Element => { const { t } = useAppTranslation('team'); 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 focusComposerTextarea = useCallback(() => { const focus = (): void => { internalTextareaRef.current?.focus(); }; focus(); queueMicrotask(focus); window.requestAnimationFrame(focus); }, []); 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 [isTextareaFocused, setIsTextareaFocused] = useState(false); 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(useShallow((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 hasCrossTeamOptions = sortedCrossTeamTargets.length > 0; const isCrossTeam = selectedTeam !== null; const selectedTarget = sortedCrossTeamTargets.find((t) => t.teamName === selectedTeam); const targetDisplayName = selectedTarget?.displayName ?? selectedTeam; const crossTeamHintText = isCrossTeam ? t('messageComposer.crossTeam.hint') : 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.selectedTeamName === teamName ? (s.selectedTeamData?.config.projectPath ?? null) : null ); const currentTeamColor = useStore((s) => { if (s.selectedTeamName !== teamName) { return nameColorSet(teamName).border; } 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 leadProviderId = useMemo(() => { const lead = members.find((member) => isLeadMember(member)); return ( normalizeOptionalTeamProviderId(lead?.providerId) ?? inferTeamProviderIdFromModel(lead?.model) ); }, [members]); const { suggestions: teamMentionSuggestions } = useTeamSuggestions(teamName); const { suggestions: taskSuggestions } = useTaskSuggestions(teamName); // Project skills as slash command suggestions const projectSkills = useStore( useShallow((s) => (projectPath ? (s.skillsProjectCatalogByProjectPath[projectPath] ?? []) : [])) ); const userSkills = useStore(useShallow((s) => s.skillsUserCatalog)); const fetchSkillsCatalog = useStore((s) => s.fetchSkillsCatalog); const isLaunchBlocking = isProvisioning && !isTeamAlive; // Fetch skills catalog for the team's project on mount / project change useEffect(() => { void fetchSkillsCatalog(projectPath ?? undefined); }, [fetchSkillsCatalog, projectPath]); const slashCommandSuggestions = useMemo( () => buildSlashCommandSuggestions( getSuggestedSlashCommandsForProvider(leadProviderId), projectSkills, userSkills, leadProviderId ), [leadProviderId, projectSkills, userSkills] ); const trimmed = stripEncodedTaskReferenceMetadata(draft.text).trim(); const standaloneSlashCommand = useMemo(() => parseStandaloneSlashCommand(trimmed), [trimmed]); const selectedMember = members.find((m) => m.name === recipient); const selectedResolvedColor = selectedMember ? colorMap.get(selectedMember.name) : undefined; const isLeadRecipient = selectedMember ? isLeadMember(selectedMember) : false; const selectedProviderId = normalizeOptionalTeamProviderId(selectedMember?.providerId) ?? inferTeamProviderIdFromModel(selectedMember?.model); const isOpenCodeRecipient = selectedProviderId === 'opencode'; const showAttachmentControl = canMemberShowAttachmentControl(selectedMember); const memberAttachmentUnavailableReason = showAttachmentControl ? getMemberAttachmentUnavailableReason(selectedMember) : null; const attachmentInputAccept = getAttachmentInputAcceptForMember(selectedMember); 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; focusComposerTextarea(); } }, [actionMode, focusComposerTextarea]); // 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; if (shouldAutoDelegate && actionMode === 'do') { setActionMode('delegate'); } 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 = !isCrossTeam && !!isTeamAlive && showAttachmentControl && memberAttachmentUnavailableReason == null; const canAttach = supportsAttachments && draft.canAddMore && !sending; const attachmentRestrictionReason = !supportsAttachments ? isCrossTeam ? t('messageComposer.attachments.restrictions.crossTeam') : !isTeamAlive ? t('messageComposer.attachments.restrictions.teamOffline') : !showAttachmentControl ? t('messageComposer.attachments.restrictions.unsupportedRecipient') : (memberAttachmentUnavailableReason ?? (isOpenCodeRecipient ? t('messageComposer.attachments.restrictions.openCodeOffline') : t('messageComposer.attachments.restrictions.teamOffline'))) : sending ? t('messageComposer.attachments.restrictions.sending') : !draft.canAddMore ? t('messageComposer.attachments.restrictions.maximumReached') : undefined; const attachmentPayloadRestrictionReason = validateAttachmentPayloadsForMember({ member: selectedMember, attachments: draft.attachments, }); const attachmentsBlocked = draft.attachments.length > 0 && (!supportsAttachments || attachmentPayloadRestrictionReason != null); const slashCommandRestrictionReason = standaloneSlashCommand ? draft.attachments.length > 0 ? t('messageComposer.slash.restrictions.attachments') : isCrossTeam ? t('messageComposer.slash.restrictions.crossTeam') : !isLeadRecipient ? t('messageComposer.slash.restrictions.notLead') : !isTeamAlive ? t('messageComposer.slash.restrictions.leadOffline') : null : null; const canSend = recipient.length > 0 && trimmed.length > 0 && trimmed.length <= MAX_TEXT_LENGTH && !sending && !isLaunchBlocking && !attachmentsBlocked && !slashCommandRestrictionReason && (!isCrossTeam || onCrossTeamSend !== undefined); const pendingSendRef = useRef(null); const handleCycleActionMode = useCallback(() => { if (sending) return; const modes: ActionMode[] = canDelegate ? ['do', 'ask', 'delegate'] : ['do', 'ask']; const idx = modes.indexOf(actionMode); setActionMode(modes[(idx + 1) % modes.length]); }, [actionMode, canDelegate, sending, setActionMode]); const handleSend = useCallback(() => { if (!canSend) return; dismissMentionsRef.current?.(); pendingSendRef.current = { teamName, snapshot: { text: draft.text, chips: draft.chips, attachments: draft.attachments, actionMode, pendingSendId: createPendingSendId(), }, previousDebugDetails: sendDebugDetails, previousLastResult: lastResult, observedSending: false, optimisticallyCleared: false, }; 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 ); } focusComposerTextarea(); }, [ actionMode, canSend, recipient, trimmed, onSend, onCrossTeamSend, isCrossTeam, selectedTeam, sendDebugDetails, draft.attachments, draft.chips, draft.text, lastResult, focusComposerTextarea, taskSuggestions, teamName, ]); // Clear once the send starts, not after the IPC finishes. For OpenCode teammates the message // can already be visible from inbox refresh while runtime delivery diagnostics are still pending. useLayoutEffect(() => { const pending = pendingSendRef.current; if (!pending) return; const isPendingCurrentTeam = pending.teamName === teamName; if (sending) { pending.observedSending = true; if (isPendingCurrentTeam && !pending.optimisticallyCleared) { pending.optimisticallyCleared = true; draft.hideDraftForPendingSend(pending.snapshot); } return; } const hasNewResult = lastResult?.messageId != null && lastResult.messageId !== pending.previousLastResult?.messageId; const hasNewDebugDetails = sendDebugDetails?.messageId != null && sendDebugDetails.messageId !== pending.previousDebugDetails?.messageId; const hasCompletionSignal = pending.observedSending || sendError !== null || hasNewResult || hasNewDebugDetails; if (!hasCompletionSignal) return; pendingSendRef.current = null; const failed = sendError !== null || isOpenCodeRuntimeDeliveryHardUxFailureFromDebugDetails(sendDebugDetails); if (failed) { if (!isPendingCurrentTeam) return; const currentDraftIsEmpty = draft.text.length === 0 && draft.chips.length === 0 && draft.attachments.length === 0; if (pending.optimisticallyCleared && currentDraftIsEmpty) { draft.restoreDraft(pending.snapshot); } else if (!currentDraftIsEmpty) { draft.finalizePendingSendClear(undefined, pending.snapshot); } return; } if (!isPendingCurrentTeam) { draft.finalizePendingSendClear(pending.teamName, pending.snapshot); return; } if (!pending.optimisticallyCleared) { draft.clearDraft(); return; } draft.finalizePendingSendClear(undefined, pending.snapshot); }, [teamName, sending, sendError, sendDebugDetails, lastResult, draft]); const showFileRestrictionError = useCallback(() => { setFileRestrictionError( attachmentRestrictionReason ?? attachmentPayloadRestrictionReason ?? t('messageComposer.attachments.restrictions.leadOnly') ); window.clearTimeout(fileRestrictionTimerRef.current); fileRestrictionTimerRef.current = window.setTimeout(() => { setFileRestrictionError(null); }, 4000); }, [attachmentPayloadRestrictionReason, attachmentRestrictionReason]); const validateSelectedAttachmentFiles = useCallback( (files: FileList | File[]): boolean => { const reason = validateAttachmentFilesForMember({ member: selectedMember, files, }); if (!reason) { return true; } setFileRestrictionError(reason); window.clearTimeout(fileRestrictionTimerRef.current); fileRestrictionTimerRef.current = window.setTimeout(() => { setFileRestrictionError(null); }, 4000); return false; }, [selectedMember] ); const { addFiles: draftAddFiles } = draft; const handleFileInputChange = useCallback( (e: React.ChangeEvent) => { const input = e.target; if (input.files?.length) { if (!canAttach) { showFileRestrictionError(); input.value = ''; return; } if (!validateSelectedAttachmentFiles(input.files)) { input.value = ''; return; } void draftAddFiles(input.files); } input.value = ''; }, [canAttach, draftAddFiles, showFileRestrictionError, validateSelectedAttachmentFiles] ); // 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 (!canAttach) { const files = e.dataTransfer?.files; if (files?.length) { showFileRestrictionError(); } return; } const files = e.dataTransfer?.files; if (files?.length && !validateSelectedAttachmentFiles(files)) { return; } draftHandleDrop(e); }, [canAttach, draftHandleDrop, showFileRestrictionError, validateSelectedAttachmentFiles] ); const { handlePaste: draftHandlePaste } = draft; const handlePasteWrapper = useCallback( (e: React.ClipboardEvent) => { if (!canAttach) { const hasFiles = Array.from(e.clipboardData.items).some((item) => item.kind === 'file'); if (hasFiles) { e.preventDefault(); showFileRestrictionError(); } return; } const pastedFiles = Array.from(e.clipboardData.items) .filter((item) => item.kind === 'file') .map((item) => item.getAsFile()) .filter((file): file is File => file != null); if (pastedFiles.length > 0 && !validateSelectedAttachmentFiles(pastedFiles)) { e.preventDefault(); return; } draftHandlePaste(e); }, [canAttach, draftHandlePaste, showFileRestrictionError, validateSelectedAttachmentFiles] ); const handleTextareaFocus = useCallback(() => setIsTextareaFocused(true), []); const handleTextareaBlur = useCallback(() => setIsTextareaFocused(false), []); const remaining = MAX_TEXT_LENGTH - trimmed.length; const hasAttachmentPreviewContent = draft.attachments.length > 0 || Boolean(draft.attachmentError ?? fileRestrictionError); const shouldDockRecipientSelector = !hasAttachmentPreviewContent; const isCompactLayout = layout === 'compact'; const isFloatingAdaptiveWidth = widthMode === 'floating-adaptive'; const [floatingComposerWidth, setFloatingComposerWidth] = useState(FLOATING_COMPOSER_MIN_WIDTH); useLayoutEffect(() => { if (!isFloatingAdaptiveWidth) return; if (draft.attachments.length > 0) { setFloatingComposerWidth(FLOATING_COMPOSER_MAX_WIDTH); return; } const textarea = internalTextareaRef.current; if (!textarea) return; const visibleText = stripEncodedTaskReferenceMetadata(draft.text); if (visibleText.length === 0) { setFloatingComposerWidth(FLOATING_COMPOSER_MIN_WIDTH); return; } const computedStyle = window.getComputedStyle(textarea); const canvas = document.createElement('canvas'); const context = canvas.getContext('2d'); if (!context) return; context.font = computedStyle.font || [ computedStyle.fontStyle, computedStyle.fontVariant, computedStyle.fontWeight, computedStyle.fontSize, computedStyle.fontFamily, ] .filter(Boolean) .join(' '); const longestLineWidth = visibleText .split(/\r\n|\r|\n/) .reduce((maxWidth, line) => Math.max(maxWidth, context.measureText(line).width), 0); const horizontalInset = (Number.parseFloat(computedStyle.paddingLeft) || 0) + (Number.parseFloat(computedStyle.paddingRight) || 0) + (Number.parseFloat(computedStyle.borderLeftWidth) || 0) + (Number.parseFloat(computedStyle.borderRightWidth) || 0) + FLOATING_COMPOSER_TEXT_BUFFER; const nextWidth = Math.min( FLOATING_COMPOSER_MAX_WIDTH, Math.max(FLOATING_COMPOSER_MIN_WIDTH, Math.ceil(longestLineWidth + horizontalInset)) ); setFloatingComposerWidth((currentWidth) => currentWidth === nextWidth ? currentWidth : nextWidth ); }, [draft.attachments.length, draft.text, isFloatingAdaptiveWidth]); const floatingAdaptiveStyle = isFloatingAdaptiveWidth ? { width: floatingComposerWidth, maxWidth: `min(${FLOATING_COMPOSER_MAX_WIDTH}px, calc(100vw - 2rem))`, } : undefined; const compactFooterNotice = slashCommandRestrictionReason ? ( {slashCommandRestrictionReason} ) : sendError ? ( {sendError} ) : sendWarning ? ( ) : lastResult?.deduplicated ? ( {t('messageComposer.status.reusedCrossTeamRequest')} ) : null; const shouldShowFooterCharCount = remaining < 200; const shouldShowSavedIndicator = isTextareaFocused && draft.isSaved; const nonCompactFooterRight = compactFooterNotice || shouldShowFooterCharCount || shouldShowSavedIndicator ? (
{compactFooterNotice} {shouldShowFooterCharCount || shouldShowSavedIndicator ? (
{shouldShowFooterCharCount ? ( {t('messageComposer.input.charsLeft', { count: remaining })} ) : null} {shouldShowSavedIndicator ? ( {t('tasks.createTask.saved')} ) : null}
) : null}
) : null; const composerFooterRight = isCompactLayout ? compactFooterNotice : nonCompactFooterRight; return (
{showAttachmentControl ? ( <> {canAttach ? t('messageComposer.attachments.attachFiles') : (attachmentRestrictionReason ?? t('messageComposer.attachments.unavailable'))} ) : null}
{!isTeamAlive && !isLaunchBlocking && ( {t('messageComposer.status.teamOffline')} )} {/* Combined team + member selector */}
{/* Current team option */} {hasCrossTeamOptions ? ( <>
{sortedCrossTeamTargets.map((target) => { const isSelected = selectedTeam === target.teamName; return ( ); })} ) : 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 (
{t('messageComposer.recipient.noResults')}
); } 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={
{cornerActionPrefix} {/* NOTE: ContextRing disabled — usage formula is inaccurate */} {t('messageComposer.actions.voiceToText')} {slashCommandRestrictionReason ? ( {slashCommandRestrictionReason} ) : isLaunchBlocking && !sending ? ( {t('messageComposer.actions.sendingUnavailableLaunching')} ) : null}
} footerRight={composerFooterRight} />
); };