/** AIChatPanel v2 — Modular Composition (ADR-119) */ import React, { useState, useRef, useEffect, useMemo } from 'react'; import { logger } from '@/shared/utils/logger'; import { useAIChat } from '../../context/AIChatContext'; import { useAuthStore } from '@/features/auth/store/authStore'; import { useCurrentSpace } from '@/features/spaces/store/spacesStore'; import { cn } from '@/shared/utils/cn'; import type { ContextSettings } from './types'; import type { AIChatPanelProps } from '../AIChatPanel.types'; import type { MentionUser } from '../MentionInput'; import type { ChatMessage } from '../../types'; import { useChatState } from './hooks/useChatState'; import { useChatActions } from './hooks/useChatActions'; import { useDataQueries } from './hooks/useDataQueries'; import { useChatMutations } from './hooks/useChatMutations'; import { useResizeHandlers } from './hooks/useResizeHandlers'; import { useSyncEffects } from './hooks/useSyncEffects'; import { useScrollManagement } from './hooks/useScrollManagement'; import { useEventHandlers } from './hooks/useEventHandlers'; import { useVoiceInput } from '../../hooks/useVoiceInput'; import { useConversationMessages } from '../../hooks/useConversationMessages'; import { CHAT_CONFIG } from '../../constants/chatConfig'; import { usePanelContentWiring } from './hooks/usePanelContentWiring'; import { ChatHeaderFull } from './components/ChatHeader/ChatHeaderFull'; import { MessagesArea } from './components/MessagesArea'; import { InputArea } from './components/InputArea'; import { TerminalPanel } from '@/features/terminal'; import { AgentEditModal } from '../AgentEditModal'; import { FilePreviewModal, detectFileType } from '@/features/files/components/FilePreviewModal'; import { ChevronDown, ChevronUp, Maximize2 } from 'lucide-react'; // Re-export for barrel pattern compatibility export type { AIChatPanelProps }; // ─── Main Component ───────────────────────────────────────────────── export function AIChatPanel({ className }: AIChatPanelProps) { const ctx = useAIChat(); const { isOpen, closeChat, currentAgent, agents, messages, isLoading, isLoadingAgents, error, selectAgent, sendMessage, clearMessages, loadAgents, conversations, currentConversationId, loadConversations, selectConversation, createNewConversation, deleteConversation, isLoadingConversations, spaceId: contextSpaceId, pendingTaskChat, clearPendingTaskChat, rowFilter, clearRowFilter, isAgentProcessing, processingAgentName, processingStartedAt, setProcessingAgentName, dismissProcessing, resetProcessing, stopAgent, historyAgentFilter, setHistoryAgentFilter, renameConversation, } = ctx; const { state: chatState, actions: chatActions } = useChatState(); const { activePanel, chatMode, chatPartner, inputValue, attachments, previewFile, dragOver, mentionedUsers, boundRows, messageBoundRows, chatParticipants, tasksSource, filesSource, showFilePicker, attachTab, panelHeight, panelMode, isResizing, panelWidth, isResizingWidth, sidebarWidth, isResizingSidebar, isMobile, mobileKeyboardHeight, processingElapsed, markdownEnabled, agentMode, thinkingEnabled, settingsTab, localError, showTerminal, terminalFocusSessionId, contactsSearch, agentsSearch, historySearch, filesSearch, tasksSearch, userTypeFilter, showFavorites, favorites, showRowBinding, showBoundRowsBar, showMessageRowPicker, expandedTaskChats, showAllContacts, chatOperatorId, chatModelId, chatSystemPrompt, isSavingAgentSettings, sortOption, subAgents, editingAgentId, defaultAgentId, isSavingDefaultAgent, favoriteAgents, showFavoriteAgents, expandedAgentId, isVectorSearching, vectorSearchResults, agentChats, quickEmojis, isSavingEmojis, messageReactions, voiceMode, showScrollToBottom, newMessageCount, agentWorking, userConversationId, } = chatState; const { setActivePanel, setChatMode, setChatPartner, setInputValue, setAttachments, setPreviewFile, setDragOver, setMentionedUsers, setBoundRows, setMessageBoundRows, setChatParticipants, setTasksSource, setFilesSource, setShowFilePicker, setAttachTab, setPanelHeight, setPanelMode, setIsResizing, setPanelWidth, setIsResizingWidth, setSidebarWidth, setIsResizingSidebar, setProcessingElapsed, setMarkdownEnabled, setAgentMode, setThinkingEnabled, setSettingsTab, setLocalError, setShowTerminal, setTerminalFocusSessionId, setContactsSearch, setAgentsSearch, setHistorySearch, setFilesSearch, setTasksSearch, setUserTypeFilter, setShowFavorites, setFavorites, setShowRowBinding, setShowBoundRowsBar, setShowMessageRowPicker, setExpandedTaskChats, setShowAllContacts, setChatOperatorId, setChatModelId, setChatSystemPrompt, setIsSavingAgentSettings, setSortOption, setSubAgents, setEditingAgentId, setDefaultAgentId, setIsSavingDefaultAgent, setFavoriteAgents, setShowFavoriteAgents, setExpandedAgentId, setIsVectorSearching, setVectorSearchResults, setAgentChats, setQuickEmojis, setIsSavingEmojis, setMessageReactions, setVoiceMode, setShowScrollToBottom, setNewMessageCount, setAgentWorking, setUserConversationId, } = chatActions; const _chatActionHandlers = useChatActions(); const currentUser = useAuthStore((state) => state.user); const isAdminOrOwner = currentUser?.role === 'admin' || currentUser?.role === 'owner'; const currentSpace = useCurrentSpace(); const effectiveSpaceId = contextSpaceId ?? currentSpace?.id; const isWideMode = !isMobile && panelWidth >= 600; const conversationMode = chatPartner?.type === 'agent' ? 'solo' as const : chatPartner?.type === 'group' ? 'group' as const : chatPartner?.type === 'user' ? 'solo' as const : null; const [inboxSearch, setInboxSearch] = useState(''); const [inboxAgentFilter, setInboxAgentFilter] = useState(''); const [inboxDateFrom, setInboxDateFrom] = useState(''); const [inboxDateTo, setInboxDateTo] = useState(''); const [showInboxFilters, setShowInboxFilters] = useState(false); const [contextSettings, setContextSettings] = useState( currentAgent ? (currentAgent as unknown as Record).context_settings as ContextSettings | string | undefined : undefined ); const [isSavingContextSettings, setIsSavingContextSettings] = useState(false); const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); const loadMoreSentinelRef = useRef(null); const inputRef = useRef(null); const fileInputRef = useRef(null); const forceNewChatRef = useRef(false); const dataQueries = useDataQueries({ activePanel, effectiveSpaceId, showAllContacts, tasksSource, tasksSearch, filesSource, showFilePicker, chatOperatorId, currentAgentOperatorId: currentAgent?.operator_id, currentAgentProviderId: currentAgent?.provider_id, contextSpaceId, inboxSearch, inboxAgentFilter, inboxDateFrom, inboxDateTo, }); const { users, isLoadingUsers, totalUnreadCount, refetchUnread, inboxConversations, isLoadingInbox, refetchInbox, taskRows, filteredTaskRows, isLoadingTasks, taskStatusDict, tasksTableColumns, projectFiles, isLoadingFiles, operators, models, usersForMentions, aiAgentsData, allTablesDataMain, } = dataQueries; const { messages: userConversationMessages, conversation: userConversationData, hasNextPage: hasOlderMessages, fetchNextPage: fetchOlderMessages, isFetchingNextPage: isFetchingOlderMessages, refetch: refetchUserMessages, markAsRead, pollingError: userPollingError, pollingStopped: userPollingStopped, reconnect: userReconnect, fetchToolSteps: userFetchToolSteps, } = useConversationMessages(userConversationId, { pageSize: CHAT_CONFIG.MESSAGE_PAGE_SIZE, enabled: !!userConversationId && (chatPartner?.type === 'user' || chatPartner?.type === 'group'), adaptivePolling: true, chatActivityState: 'active', currentUserId: currentUser?.id ? Number(currentUser.id) : undefined, }); const { messages: aiConversationMessages, conversation: aiConversationData, fetchNextPage: fetchNextAIPage, hasNextPage: hasNextAIPage, isFetchingNextPage: isFetchingNextAIPage, pollingError: aiPollingError, pollingStopped: aiPollingStopped, reconnect: aiReconnect, isProcessing: aiBackendProcessing, processingAgentName: aiBackendProcessingAgentName, fetchToolSteps: aiFetchToolSteps, } = useConversationMessages(currentConversationId, { pageSize: CHAT_CONFIG.MESSAGE_PAGE_SIZE, enabled: !!currentConversationId && chatPartner?.type === 'agent', adaptivePolling: true, chatActivityState: isAgentProcessing ? 'agent_processing' : 'idle', currentUserId: currentUser?.id ? Number(currentUser.id) : undefined, }); const activePollingError = chatPartner?.type === 'agent' ? aiPollingError : userPollingError; const activePollingStopped = chatPartner?.type === 'agent' ? aiPollingStopped : userPollingStopped; const activeReconnect = chatPartner?.type === 'agent' ? aiReconnect : userReconnect; const mutations = useChatMutations({ currentAgent, chatOperatorId, chatModelId, chatSystemPrompt, setIsSavingAgentSettings, setContextSettings, setDefaultAgentId, setIsSavingDefaultAgent, setQuickEmojis, setIsSavingEmojis, setMessageReactions: setMessageReactions as any, loadAgents, refetchUserMessages, }); // ========== Resize ========== const resize = useResizeHandlers({ panelHeight, panelWidth, sidebarWidth, panelMode, setPanelHeight: setPanelHeight as any, setPanelMode: setPanelMode as any, setIsResizing, setPanelWidth: setPanelWidth as any, setIsResizingWidth, setSidebarWidth: setSidebarWidth as any, setIsResizingSidebar, activePanel, setActivePanel, }); // ========== Voice Input ========== const { isRecording, isProcessing: isTranscribing, error: voiceError, duration: recordingDuration, startRecording, stopRecording, cancelRecording, webSpeechAvailable, } = useVoiceInput({ mode: voiceMode, language: 'ru-RU', spaceId: currentSpace?.id, onResult: (text) => { setInputValue(prev => { const sep = prev.trim() ? ' ' : ''; return prev + sep + text; }); }, onError: (error) => { logger.error('[Voice Input] Error:', error); }, }); // ========== Display Messages ========== const displayMessages = useMemo(() => { if (chatPartner?.type === 'user' || chatPartner?.type === 'group') return (userConversationMessages || []) as ChatMessage[]; if (chatPartner?.type === 'agent') { if (aiConversationMessages && aiConversationMessages.length > 0) return aiConversationMessages as ChatMessage[]; if (messages && messages.length > 0) return messages; return []; } return messages; }, [chatPartner?.type, userConversationMessages, aiConversationMessages, messages]); // ========== Mention/Agent data ========== const availableMentionUsers: MentionUser[] = useMemo(() => { return (usersForMentions || []).map(user => ({ id: user.id, name: user.name, email: user.email, avatar: user.avatar_url, type: user.managed_by_agent_table_id ? 'bot' as const : 'human' as const })); }, [usersForMentions]); const availableSlashAgents: MentionUser[] = useMemo(() => { return (aiAgentsData?.data?.agents || []) .filter(agent => agent.status !== 'inactive' && agent.name) .map(agent => ({ id: agent.id, name: agent.name, icon: agent.icon, email: agent.description, type: 'agent' as const })); }, [aiAgentsData]); const resolvedConvTitle = useMemo(() => { const convId = currentConversationId || userConversationId; if (!convId) return null; const conv = conversations.find(c => c.id === convId); if (conv?.title) return conv.title; const inboxConv = inboxConversations?.find(c => c.id === convId); if (inboxConv?.title) return inboxConv.title; return null; }, [currentConversationId, userConversationId, conversations, inboxConversations]); const hasSlashCommand = useMemo(() => /(^|\s)\/[a-z][a-z0-9_-]*(\s|$)/i.test(inputValue), [inputValue]); // ========== Sync Effects (delegated to hook) ========== useSyncEffects({ currentAgent, setChatOperatorId, setChatModelId, setChatSystemPrompt, aiBackendProcessing, isAgentProcessing, dismissProcessing, aiBackendProcessingAgentName, setProcessingAgentName, userConversationId, userConversationData, markAsRead, refetchUnread, isOpen, agents, defaultAgentId, selectAgent, loadAgents, loadConversations, isMobile, chatPartner: chatPartner as any, setChatPartner: setChatPartner as any, setUserConversationId, pendingTaskChat, clearPendingTaskChat, setChatMode, setBoundRows: setBoundRows as any, setShowBoundRowsBar, setActivePanel: setActivePanel as any, aiConversationData, setSubAgents, subAgents, currentConversationId: currentConversationId ?? null, setChatParticipants: setChatParticipants as any, userParticipantsCount: chatParticipants.length, aiParticipantsCount: 0, processingStartedAt, setProcessingElapsed, }); // Extra effects not in useSyncEffects useEffect(() => { setContextSettings(currentAgent ? (currentAgent as unknown as Record).context_settings as ContextSettings | string | undefined : undefined); }, [currentAgent]); useEffect(() => { if (currentSpace?.settings && typeof currentSpace.settings === 'object') { const s = currentSpace.settings as Record; setDefaultAgentId(s.default_agent_id ? Number(s.default_agent_id) : null); setQuickEmojis(Array.isArray(s.quick_emojis) ? s.quick_emojis as string[] : ['👍','❤️','😂','🔥','💯','🙏','😍','😮']); } else { setDefaultAgentId(null); setQuickEmojis(['👍','❤️','😂','🔥','💯','🙏','😍','😮']); } }, [currentSpace?.id, currentSpace?.settings]); useEffect(() => { if (isOpen && activePanel === 'none') setTimeout(() => inputRef.current?.focus(), 300); }, [isOpen, activePanel]); // ========== Scroll Management (delegated to hook) ========== useScrollManagement({ displayMessages, aiConversationMessages, currentConversationId: currentConversationId ?? null, userConversationId, chatPartnerId: chatPartner?.id, chatPartnerType: chatPartner?.type, hasOlderMessages, fetchOlderMessages, hasNextAIPage, fetchNextAIPage, isFetchingOlderMessages: !!isFetchingOlderMessages, isFetchingNextAIPage: !!isFetchingNextAIPage, isAgentProcessing, messagesEndRef, messagesContainerRef, loadMoreSentinelRef, setShowScrollToBottom, setNewMessageCount: setNewMessageCount as any, setAgentWorking, fetchReactionsForMessages: mutations.fetchReactionsForMessages, }); // ========== Event Handlers (delegated to hook) ========== const events = useEventHandlers({ inputValue, setInputValue: setInputValue as any, attachments, setAttachments: setAttachments as any, mentionedUsers: mentionedUsers as any, setMentionedUsers: setMentionedUsers as any, messageBoundRows: messageBoundRows as any, setMessageBoundRows: setMessageBoundRows as any, setLocalError, setDragOver, chatPartner: chatPartner as any, currentAgent, agentMode, thinkingEnabled, subAgents, userConversationId, setUserConversationId, currentSpaceId: currentSpace?.id, effectiveSpaceId, availableMentionUsers, availableSlashAgents, sendMessage, selectAgent, sendUserMessageMutation: mutations.sendUserMessageMutation, setChatMode, setChatPartner: setChatPartner as any, setChatParticipants: setChatParticipants as any, setBoundRows: setBoundRows as any, setShowBoundRowsBar, setActivePanel: setActivePanel as any, setVectorSearchResults, }); // ========== Guard ========== if (!isOpen) return null; // ========== Panel content (delegated to wiring hook) ========== const { renderPanelContent } = usePanelContentWiring({ activePanel, setActivePanel, contactsSearch, setContactsSearch, showFavorites, setShowFavorites: setShowFavorites as any, userTypeFilter, setUserTypeFilter, showAllContacts, setShowAllContacts: setShowAllContacts as any, users, isLoadingUsers, chatParticipants: chatParticipants as any, chatPartner: chatPartner as any, favorites, setFavorites: setFavorites as any, setUserConversationId, setChatPartner: setChatPartner as any, setChatParticipants: setChatParticipants as any, setBoundRows: setBoundRows as any, setShowBoundRowsBar, handleAgentSelect: events.handleAgentSelect, selectConversation, createNewConversation, forceNewChatRef, setChatMode, clearMessages, agentsSearch, setAgentsSearch, agents, isLoadingAgents, currentAgent, showFavoriteAgents, setShowFavoriteAgents, favoriteAgents, setFavoriteAgents, isVectorSearching, vectorSearchResults, setVectorSearchResults, setIsVectorSearching, currentSpaceId: currentSpace?.id, createTablesMutation: mutations.createTablesMutation, currentSpace: currentSpace as any, inboxConversations: inboxConversations as any, isLoadingInbox, totalUnreadCount, refetchInbox, inboxSearch, setInboxSearch, inboxAgentFilter, setInboxAgentFilter, inboxDateFrom, setInboxDateFrom, inboxDateTo, setInboxDateTo, showInboxFilters, setShowInboxFilters, markAsReadMutation: mutations.markAsReadMutation, selectAgent, renameConversation, userConversationId, currentConversationId, allTablesDataMain: allTablesDataMain as any, tasksSource, filteredTaskRows, isLoadingTasks, taskRows, taskStatusDict, tasksTableColumns, tasksSearch, setTasksSearch, setTasksSource, expandedTaskChats, setExpandedTaskChats, conversations: conversations as any, effectiveSpaceId, settingsTab, setSettingsTab, chatOperatorId, setChatOperatorId, chatModelId, setChatModelId, chatSystemPrompt, setChatSystemPrompt, operators, models, isAdminOrOwner, isSavingAgentSettings, saveAgentSettings: mutations.saveAgentSettings, messages, contextSettings: contextSettings as any, setContextSettings, saveContextSettings: mutations.saveContextSettings as any, isSavingContextSettings, defaultAgentId, saveDefaultAgent: mutations.saveDefaultAgent, isSavingDefaultAgent, quickEmojis, setQuickEmojis, saveQuickEmojis: mutations.saveQuickEmojis, isSavingEmojis, voiceMode, setVoiceMode, voiceError: voiceError || null, webSpeechAvailable, filesSource, setFilesSource, }); // ========== JSX Render ========== return ( <> {isMobile && (
{ if (e.key === 'Enter' || e.key === ' ') closeChat(); }} /> )}
0 ? mobileKeyboardHeight : 0}px` } : { width: panelWidth }}> {/* Left resize handle */} {!isMobile && (
)}
{/* Chat area */}
{/* Panel + Messages Area */}
{/* Panel overlay */} {!isWideMode && activePanel !== 'none' && (
{renderPanelContent()}
{activePanel === 'contacts' && {users.length} контактов} {activePanel === 'ai-agents' && {agents.length} агентов} {activePanel === 'tasks' && {taskRows.length} задач} {activePanel === 'settings' && Настройки чата}
)} setInputValue(prev => prev ? `${prev} ${token} ` : `${token} `)} onOpenTerminal={(sessionId) => { setShowTerminal(true); if (sessionId) setTerminalFocusSessionId(sessionId); }} sendMessage={sendMessage} currentAgent={currentAgent} messagesEndRef={messagesEndRef} messagesContainerRef={messagesContainerRef} loadMoreSentinelRef={loadMoreSentinelRef} dragOver={dragOver} setDragOver={setDragOver} onDrop={events.handleDrop} isMobile={isMobile} setActivePanel={setActivePanel as any} hasOlderMessages={!!hasOlderMessages} isFetchingOlderMessages={!!isFetchingOlderMessages} hasNextAIPage={!!hasNextAIPage} isFetchingNextAIPage={!!isFetchingNextAIPage} showScrollToBottom={showScrollToBottom} setShowScrollToBottom={setShowScrollToBottom} newMessageCount={newMessageCount} setNewMessageCount={setNewMessageCount as any} agentWorking={agentWorking} setAgentWorking={setAgentWorking} activePollingError={activePollingError} activePollingStopped={activePollingStopped} activeReconnect={activeReconnect} error={error} localError={localError} fetchToolSteps={chatMode === 'ai' ? aiFetchToolSteps : userFetchToolSteps} />
setFilesSource(config)} projectFiles={projectFiles} isLoadingFiles={isLoadingFiles} filesSearch={filesSearch} setFilesSearch={setFilesSearch} effectiveSpaceId={effectiveSpaceId} tasksSource={tasksSource as any} chatPartner={chatPartner as any} hasSlashCommand={hasSlashCommand} thinkingEnabled={thinkingEnabled} setThinkingEnabled={setThinkingEnabled as any} agentMode={agentMode} setAgentMode={setAgentMode as any} markdownEnabled={markdownEnabled} setMarkdownEnabled={setMarkdownEnabled as any} showTerminal={showTerminal} setShowTerminal={setShowTerminal as any} isRecording={isRecording} isTranscribing={isTranscribing} voiceError={voiceError} recordingDuration={recordingDuration} startRecording={startRecording} stopRecording={stopRecording} cancelRecording={cancelRecording} voiceMode={voiceMode} isLoading={isLoading} isAgentProcessing={isAgentProcessing} stopAgent={stopAgent} handleSubmit={events.handleSubmit} handleFileSelect={events.handleFileSelect} fileInputRef={fileInputRef as any} availableMentionUsers={availableMentionUsers} availableSlashAgents={availableSlashAgents} panelMode={panelMode} /> {showTerminal && (
setShowTerminal(false)} />
)}
{/* Sidebar for wide mode */} {isWideMode && activePanel !== 'none' && ( <>
{renderPanelContent()}
)}
{editingAgentId && ( setEditingAgentId(null)} agent={agents.find(a => a.id === editingAgentId) || null} onSave={() => { loadAgents(); }} /> )} {previewFile && ( setPreviewFile(null)} fileUrl={previewFile.url} fileName={previewFile.name} fileType={detectFileType(previewFile.url)} /> )}
); }