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 { 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 { 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'; isTeamAlive?: boolean; sending: boolean; sendError: string | null; sendWarning?: string | null; sendDebugDetails?: OpenCodeRuntimeDeliveryDebugDetails | 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; } interface PendingSendState { teamName: string; snapshot: ComposerDraftContent; previousDebugDetails: OpenCodeRuntimeDeliveryDebugDetails | null | undefined; previousLastResult: SendMessageResult | null | undefined; observedSending: boolean; optimisticallyCleared: boolean; } let pendingSendIdCounter = 0; 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', isTeamAlive, sending, sendError, sendWarning, sendDebugDetails, 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 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 [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 ? '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.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 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 && (isLeadRecipient || isOpenCodeRecipient); const canAttach = supportsAttachments && draft.canAddMore && !sending; const attachmentRestrictionReason = !supportsAttachments ? isCrossTeam ? 'File attachments are not supported for cross-team messages' : !isTeamAlive ? 'Team must be online to attach files' : !isLeadRecipient && !isOpenCodeRecipient ? 'Files can be sent to the team lead or OpenCode teammates' : isOpenCodeRecipient ? 'Team must be online to attach files for OpenCode teammates' : 'Team must be online to attach files' : sending ? 'Wait for current message to finish sending before adding files' : !draft.canAddMore ? 'Maximum attachments reached' : undefined; const attachmentsBlocked = draft.attachments.length > 0 && !supportsAttachments; const slashCommandRestrictionReason = standaloneSlashCommand ? draft.attachments.length > 0 ? 'Slash commands require a live team lead and cannot be sent with attachments' : isCrossTeam ? 'Slash commands can only be run on the current team lead' : !isLeadRecipient ? 'Slash commands can only be sent to the team lead' : !isTeamAlive ? 'Slash commands require the team lead to be online' : 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 || sendDebugDetails?.delivered === false; 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 ?? 'Files can only be sent to the team lead' ); window.clearTimeout(fileRestrictionTimerRef.current); fileRestrictionTimerRef.current = window.setTimeout(() => { setFileRestrictionError(null); }, 4000); }, [attachmentRestrictionReason]); const { addFiles: draftAddFiles } = draft; const handleFileInputChange = useCallback( (e: React.ChangeEvent) => { const input = e.target; if (input.files?.length) { if (!canAttach) { showFileRestrictionError(); input.value = ''; return; } void draftAddFiles(input.files); } input.value = ''; }, [canAttach, draftAddFiles, showFileRestrictionError] ); // 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; } draftHandleDrop(e); }, [canAttach, draftHandleDrop, showFileRestrictionError] ); 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; } draftHandlePaste(e); }, [canAttach, draftHandlePaste, showFileRestrictionError] ); 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 compactFooterNotice = slashCommandRestrictionReason ? ( {slashCommandRestrictionReason} ) : sendError ? ( {sendError} ) : sendWarning ? ( ) : lastResult?.deduplicated ? ( Reused recent cross-team request ) : null; return (
{isLeadRecipient ? ( <> {canAttach ? 'Attach files (paste or drag & drop)' : (attachmentRestrictionReason ?? 'Attachments are unavailable')} ) : null}
{!isTeamAlive && !isLaunchBlocking && ( Team offline )} {/* 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 (
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 {slashCommandRestrictionReason ? ( {slashCommandRestrictionReason} ) : isLaunchBlocking && !sending ? ( Sending unavailable while team is launching ) : null}
} footerRight={ isCompactLayout ? ( compactFooterNotice ) : (
{compactFooterNotice}
{remaining < 200 ? ( {remaining} chars left ) : null} {draft.isSaved ? ( Saved ) : null}
) } />
); };