/** * TelegramChatLayout Component * ADR-024: Telegram-like Chat Architecture * * Main container that combines: * - ChatListView (left panel) * - ChatConversationView (right panel) * - Settings modal * - Row binding modal * * Responsive: mobile shows one panel at a time */ import { useState, useEffect, useMemo, useRef, useCallback } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { X, Settings, Link2, Users, Edit3, Trash2, Bot, Loader2 } from 'lucide-react'; import { cn } from '@/shared/utils/cn'; import { apiClient } from '@/shared/utils/apiClient'; import { useCurrentSpace } from '@/features/spaces/store/spacesStore'; import { useAuthStore } from '@/features/auth/store/authStore'; import { useAIChat } from '../context/AIChatContext'; import { useConversationMessages, type ChatActivityState } from '../hooks/useConversationMessages'; import { ChatListView, ChatPreview } from './ChatListView'; import { ChatConversationView, ChatMessageItem, ChatInfo } from './ChatConversationView'; import { ParticipantSelector, Participant } from './ParticipantSelector'; import { NewChatDialog, SelectedParticipant } from './NewChatDialog'; import { RowBinding, BoundRow } from './RowBinding'; import { MentionUser } from './MentionInput'; export interface TelegramChatLayoutProps { className?: string; } export function TelegramChatLayout({ className }: TelegramChatLayoutProps) { const { isOpen, closeChat, currentAgent, agents, messages, isLoading, isStreaming, selectAgent, sendMessage, clearMessages, loadAgents, conversations, currentConversationId, loadConversations, selectConversation, createNewConversation, deleteConversation, isLoadingConversations, agentMode, setAgentMode, hasMoreAIMessages, isFetchingOlderAIMessages, fetchOlderAIMessages, processingAgentName, isAgentProcessing, } = useAIChat(); const currentSpace = useCurrentSpace(); const authUser = useAuthStore(s => s.user); const queryClient = useQueryClient(); // ADR-103: Adaptive polling state — track last user activity to transition active -> idle // Starts at 1.5s when agent is processing, 3s when user recently sent a message, // slows to 8s after 30s of inactivity. const lastActivityRef = useRef(Date.now()); const [isRecentlyActive, setIsRecentlyActive] = useState(false); // Mark activity on conversation selection or message send const markActivity = useCallback(() => { lastActivityRef.current = Date.now(); setIsRecentlyActive(true); }, []); // Timer to transition from 'active' to 'idle' after 30s of inactivity useEffect(() => { if (!isRecentlyActive) return; const timer = setInterval(() => { const elapsed = Date.now() - lastActivityRef.current; if (elapsed >= 30_000) { setIsRecentlyActive(false); } }, 5_000); // Check every 5s return () => clearInterval(timer); }, [isRecentlyActive]); // Mark activity when conversation changes (user selected a chat) useEffect(() => { if (currentConversationId) { markActivity(); } }, [currentConversationId, markActivity]); // Compute adaptive polling state: // - agent_processing (1.5s): backend agent is actively working // - active (3s): user recently interacted (< 30s ago) // - idle (8s): no recent activity const chatActivityState: ChatActivityState = useMemo(() => { if (isAgentProcessing || isStreaming) return 'agent_processing'; if (isRecentlyActive) return 'active'; return 'idle'; }, [isAgentProcessing, isStreaming, isRecentlyActive]); // ADR-103: Use useConversationMessages for real-time polling (replaces stale AIChatContext.messages) const { messages: polledMessages, isLoading: isPolledLoading, isProcessing: isPolledProcessing, processingAgentName: polledProcessingAgentName, hasNextPage, fetchNextPage, isFetchingNextPage, } = useConversationMessages(currentConversationId, { adaptivePolling: true, chatActivityState, currentUserId: authUser?.id ? Number(authUser.id) : undefined, }); // Use polled messages when available, fallback to context messages (for optimistic updates) const effectiveMessages = polledMessages.length > 0 ? polledMessages : messages; // Default agent selection (mirrors AIChatPanel.tsx logic) const [defaultAgentId, setDefaultAgentId] = useState(null); const defaultAgentAppliedRef = useRef(false); // Step 1: Read default agent from space settings useEffect(() => { if (currentSpace?.settings && typeof currentSpace.settings === 'object') { const spaceSettings = currentSpace.settings as Record; if (spaceSettings.default_agent_id) { setDefaultAgentId(Number(spaceSettings.default_agent_id)); } else { setDefaultAgentId(null); } } else { setDefaultAgentId(null); } // Reset applied flag when space changes so default re-applies defaultAgentAppliedRef.current = false; }, [currentSpace?.id, currentSpace?.settings]); // Step 2: Auto-select default agent when chat opens // Note: loadAgents() may auto-select the FIRST agent before this effect runs, // so we can't rely on !currentAgent. Instead we use a ref to apply once per session. useEffect(() => { if (isOpen && defaultAgentId && agents.length > 0 && !defaultAgentAppliedRef.current) { const defaultAgent = agents.find(a => a.id === defaultAgentId); if (defaultAgent) { defaultAgentAppliedRef.current = true; // Only switch if no agent selected OR the wrong agent was auto-selected if (!currentAgent || currentAgent.id !== defaultAgentId) { selectAgent(defaultAgent); } } } }, [isOpen, defaultAgentId, agents, currentAgent, selectAgent]); // UI State const [mobileView, setMobileView] = useState<'list' | 'chat'>('list'); const [showSettingsModal, setShowSettingsModal] = useState(false); const [showBindingModal, setShowBindingModal] = useState(false); const [showNewChatModal, setShowNewChatModal] = useState(false); // Chat state const [chatTitle, setChatTitle] = useState(''); const [participants, setParticipants] = useState([]); const [boundRows, setBoundRows] = useState([]); // Pending sub-agents selected from NewChatDialog (cleared after first message sent) const [pendingSubAgentIds, setPendingSubAgentIds] = useState([]); // Convert conversations to ChatPreview format const chatPreviews: ChatPreview[] = useMemo(() => { return conversations.map(conv => ({ id: conv.id, title: conv.title || 'Новый чат', type: 'agent' as const, icon: agents.find(a => a.id === conv.agent_id)?.icon, lastMessage: conv.lastMessage ? { content: conv.lastMessage, sender: 'AI', time: conv.updatedAt ? new Date(conv.updatedAt) : new Date(), isRead: true, isOwn: false } : undefined, unreadCount: 0, isPinned: false, isMuted: false, participantsCount: 2 })); }, [conversations, agents]); // Current chat info // ADR-103: status reflects both SSE streaming AND backend polling processing state const isAgentTyping = isStreaming || isPolledProcessing || isAgentProcessing; const currentChatInfo: ChatInfo | null = useMemo(() => { if (!currentConversationId) return null; const conv = conversations.find(c => c.id === currentConversationId); if (!conv) return null; const agent = agents.find(a => a.id === conv.agent_id); return { id: conv.id, title: conv.title || agent?.name || 'Чат', type: 'agent', icon: agent?.icon, status: isAgentTyping ? 'typing' : 'online', agentModel: agent?.model }; }, [currentConversationId, conversations, agents, isAgentTyping]); // Convert messages to ChatMessageItem format (preserving contentType for agent step grouping) // ADR-103: Uses effectiveMessages (from useConversationMessages polling) instead of stale context messages const chatMessages: ChatMessageItem[] = useMemo(() => { return effectiveMessages.map((msg, index) => { // Determine sender info from backend data or fallback to currentAgent const isAgentMessage = msg.role === 'assistant' || msg.role === 'tool'; const isUserMessage = msg.role === 'user'; let sender: ChatMessageItem['sender'] | undefined; if (msg.sender_id && msg.sender_name) { // Backend provided sender info via users JOIN — use it for all messages sender = { id: msg.sender_id, name: msg.sender_name, avatar: msg.sender_avatar || undefined, type: (msg.sender_user_type === 'agent' || msg.senderType === 'agent') ? 'agent' : 'user', }; } else if (isAgentMessage && currentAgent) { // Fallback for agent messages without sender_id sender = { id: currentAgent.id, name: msg.agentName || currentAgent.name, avatar: undefined, type: 'agent' as const, }; } else if (isUserMessage && msg.sender_id) { // User message with sender_id but no name (shouldn't happen after backend fix) sender = { id: msg.sender_id, name: msg.sender_name || 'User', avatar: msg.sender_avatar || undefined, type: 'user' as const, }; } return { id: msg.id || `msg-${index}`, content: msg.content, role: msg.role as 'user' | 'assistant' | 'system' | 'tool', sender, timestamp: msg.timestamp ? new Date(msg.timestamp) : new Date(), isRead: true, attachments: msg.attachments?.map(att => ({ id: att.id, name: att.name, type: att.type, url: att.url, size: att.size })), // Preserve agent step metadata for turn grouping (ADR-092) contentType: msg.contentType, toolResults: msg.toolResults?.map(tr => ({ tool: tr.tool, args: tr.args, result: tr.result })), iterations: msg.iterations, agentName: msg.agentName || msg.sender_name || currentAgent?.name, is_deleted: msg.is_deleted, }; }); }, [effectiveMessages, currentAgent]); // Build mention users // ADR-069: @ shows humans + bots, / shows agents const mentionUsers: MentionUser[] = useMemo(() => { return agents.map(agent => ({ id: agent.id, name: agent.name, icon: agent.icon, type: 'agent' as const })); }, [agents]); const mentionAgents: MentionUser[] = useMemo(() => { return agents.map(agent => ({ id: agent.id, name: agent.name, icon: agent.icon, type: 'agent' as const })); }, [agents]); // Load conversations on mount useEffect(() => { if (isOpen) { loadConversations(); loadAgents(); } }, [isOpen, loadConversations, loadAgents]); // Update mobile view when selecting chat useEffect(() => { if (currentConversationId) { setMobileView('chat'); } }, [currentConversationId]); // Handlers const handleSelectChat = async (chatId: number) => { await selectConversation(chatId); }; const handleCreateChat = () => { setShowNewChatModal(true); }; // ADR-024: Unified New Chat — agents are just participants const handleStartChat = useCallback((selectedParticipants: SelectedParticipant[]) => { const agentParticipants = selectedParticipants.filter(p => p.type === 'agent'); // Map selected agent participants back to AIAgent objects (by name match) const selectedAgents = agentParticipants .map(p => agents.find(a => a.name.toLowerCase() === p.name.toLowerCase())) .filter((a): a is (typeof agents)[0] => a !== undefined); // Select first agent as primary conversation partner if (selectedAgents.length > 0) { selectAgent(selectedAgents[0]); } // Store all selected agent IDs for multi-agent support const subAgentIds = selectedAgents.map(a => a.id); setPendingSubAgentIds(subAgentIds); // Clear conversation state (conversation created on first message) createNewConversation(); setShowNewChatModal(false); }, [agents, selectAgent, createNewConversation]); const handleSendMessage = async (content: string, attachments?: File[], mentions?: MentionUser[]) => { // ADR-103: Mark activity so polling speeds up (active -> 3s, then agent_processing -> 1.5s) markActivity(); // ADR-024: Convert MentionUser[] to format expected by sendMessage const formattedMentions = mentions?.map(m => ({ id: m.id, name: m.name, type: m.type })); // Pass pending sub-agent IDs (from NewChatDialog selection) on first message const subAgentIds = pendingSubAgentIds.length > 0 ? pendingSubAgentIds : undefined; await sendMessage(content, attachments, undefined, formattedMentions, undefined, undefined, subAgentIds); // Clear pending sub-agents after first message (conversation is now created) if (pendingSubAgentIds.length > 0) { setPendingSubAgentIds([]); } }; const handleBack = () => { setMobileView('list'); }; const handleDeleteChat = (chatId: number) => { deleteConversation(chatId); }; if (!isOpen) return null; return (
{/* Left Panel: Chat List */}
{/* Header */}

Чаты

{/* Right Panel: Active Chat */}
setShowSettingsModal(true)} mentionUsers={mentionUsers} mentionAgents={mentionAgents} currentUserId={authUser?.id ? Number(authUser.id) : undefined} hasMoreMessages={hasNextPage || hasMoreAIMessages} onLoadMoreMessages={hasNextPage ? (async () => { await fetchNextPage(); }) : fetchOlderAIMessages} isLoadingMore={isFetchingNextPage || isFetchingOlderAIMessages} />
{/* New Chat Dialog — ADR-024: unified participant picker (agents + humans) */} setShowNewChatModal(false)} /> {/* Settings Modal */} {showSettingsModal && currentChatInfo && (

Настройки чата

{/* Chat Avatar & Title */}
{currentChatInfo.icon || }
setChatTitle(e.target.value)} className="text-center text-lg font-semibold bg-transparent border-b border-transparent hover:border-[var(--border-primary)] focus:border-[var(--color-primary-500)] focus:outline-none px-2 py-1 text-[var(--text-primary)]" placeholder="Название чата" /> {currentChatInfo.agentModel || currentChatInfo.type}
{/* Bindings Section */}
{boundRows.length === 0 ? (

Нет привязок. Привяжите чат к задаче, проекту или любой записи.

) : (
{boundRows.map((row, idx) => (
{row.row_title || `#${row.row_id}`}
{row.table_name}
))}
)}
{/* Participants Section */}
{ if (!participants.some(sp => sp.type === p.type && sp.id === p.id)) { setParticipants([...participants, p]); } }} onMultiSelect={setParticipants} multiSelect={true} placeholder="Добавить участника..." />
{/* Danger Zone */}
)} {/* Row Binding Modal */} {showBindingModal && (

Привязать к записи

{ setBoundRows(prev => [...prev, { table_id: tableId, row_id: rowId, table_name: meta?.tableName, row_title: meta?.rowTitle }]); }} onUnbind={(tableId, rowId) => { setBoundRows(prev => prev.filter( r => !(r.table_id === tableId && r.row_id === rowId) )); }} />
)}
); }