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 { useAttachments } from '@renderer/hooks/useAttachments'; import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; import { cn } from '@renderer/lib/utils'; import { useStore } from '@renderer/store'; import { serializeChipsWithText } from '@renderer/types/inlineChip'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { getModifierKeyName } from '@renderer/utils/keyboardUtils'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; 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; } const MAX_MESSAGE_LENGTH = 4000; /** 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); // 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) { setRecipient(next); } }, [members, recipient]); const projectPath = useStore((s) => s.selectedTeamData?.config.projectPath ?? null); const draft = useDraftPersistence({ key: `compose:${teamName}` }); const chipDraft = useChipDraftPersistence(`compose:${teamName}:chips`); const { attachments, error: attachmentError, canAddMore, addFiles, removeAttachment, clearAttachments, handlePaste, handleDrop, } = useAttachments({ persistenceKey: `compose:${teamName}:attachments` }); 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.value.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'; // TODO: 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 && !!isTeamAlive; const canAttach = supportsAttachments && canAddMore; const attachmentsBlocked = attachments.length > 0 && !supportsAttachments; const canSend = recipient.length > 0 && trimmed.length > 0 && trimmed.length <= MAX_MESSAGE_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, chipDraft.chips); // Summary should stay compact (no expanded chip markdown) onSend(recipient, serialized, trimmed, attachments.length > 0 ? attachments : undefined); }, [canSend, recipient, trimmed, onSend, attachments, chipDraft.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(); chipDraft.clearChipDraft(); clearAttachments(); } } // eslint-disable-next-line react-hooks/exhaustive-deps -- clearChipDraft is stable (useCallback with []) }, [sending, sendError, draft, clearAttachments, chipDraft.clearChipDraft]); const handleKeyDownCapture = useCallback( (e: React.KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); handleSend(); } }, [handleSend] ); const handleFileInputChange = useCallback( (e: React.ChangeEvent) => { const input = e.target; if (input.files?.length) { void addFiles(input.files); } input.value = ''; }, [addFiles] ); 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) => { dragCounterRef.current = 0; setIsDragOver(false); if (canAttach) handleDrop(e); }, [canAttach, handleDrop] ); const handlePasteWrapper = useCallback( (e: React.ClipboardEvent) => { if (canAttach) handlePaste(e); }, [canAttach, handlePaste] ); const remaining = MAX_MESSAGE_LENGTH - trimmed.length; return (
{ e.preventDefault(); setRecipientSearch(''); setTimeout(() => recipientSearchRef.current?.focus(), 0); }} > {members.length > 5 && (
setRecipientSearch(e.target.value)} />
)}
{(() => { const query = recipientSearch.toLowerCase().trim(); const filtered = query ? members.filter((m) => m.name.toLowerCase().includes(query)) : members; if (filtered.length === 0) { return (
No results
); } return filtered.map((m) => { const resolvedColor = colorMap.get(m.name); const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType); const isSelected = m.name === recipient; return ( ); }); })()}
{isLeadRecipient ? ( <> {!isTeamAlive ? 'Team must be online to attach images' : !canAddMore ? 'Maximum attachments reached' : 'Attach images (paste or drag & drop)'} ) : null} {!isTeamAlive ? ( Team offline ) : null}
{/* TODO: ContextRing disabled — usage formula is inaccurate */} Voice to text
} footerRight={
Mention "create a task" to add it to the board {sendError ? ( {sendError} ) : null} {remaining < 200 ? ( {remaining} chars left ) : null} {draft.isSaved ? ( Draft saved ) : null}
} /> ); };