/** * ChatConversationView Component * ADR-024: Telegram-like Conversation View * * Active chat conversation panel: * - Header with chat info and actions * - Messages list with bubbles * - Input area with attachments */ import { useState, useRef, useEffect, useCallback, useMemo, FormEvent, KeyboardEvent } from 'react'; import { ArrowLeft, MoreVertical, Phone, Video, Search, Paperclip, Smile, Send, Mic, Bot, User, Check, CheckCheck, Image as ImageIcon, FileText, File, X, Link2, Settings, Trash2, Edit3, Copy, Reply, Forward, Pin, Loader2, ArrowDown } from 'lucide-react'; import { cn } from '@/shared/utils/cn'; import { MarkdownPreview, type CheckboxClickInfo } from '@/shared/components/MarkdownPreview'; import { MentionInput, MentionUser } from './MentionInput'; import { useAuthStore } from '@/features/auth/store/authStore'; import { groupChatMessageItems, type ChatMessageItemTurn } from '../utils/groupChatMessageItems'; import { AgentTurnBubble } from './AgentTurnBubble'; import { ChatAttachmentRenderer } from './AIChatPanel/components/ChatMessages/ChatAttachmentRenderer'; import { HighlightedText } from './HighlightedText'; // ADR-116: Structured Invocation Token validation on submit import { validateAndWrapMentions, validateAndWrapCommands } from '../utils/invocationTokens'; import { apiClient } from '@/shared/utils/apiClient'; import { toggleCheckboxByIndex, normalizeCheckboxes, denormalizeCheckboxes, getCheckboxContext } from '@/shared/utils/markdownCheckbox'; import { CHAT_CONFIG } from '../constants/chatConfig'; export type ChatMessageItemContentType = 'text' | 'thinking' | 'tool_call' | 'tool_result' | 'tool_approval' | 'plan'; export interface ChatMessageItem { id: number | string; content: string; role: 'user' | 'assistant' | 'system' | 'tool'; sender?: { id: number; name: string; avatar?: string; type: 'user' | 'agent'; }; timestamp: Date; isRead?: boolean; attachments?: Array<{ id: string; name: string; type: string; url?: string; size?: number; }>; replyTo?: { id: number; content: string; sender: string; }; isEdited?: boolean; /** Agent step support — contentType for thinking/tool_call/tool_result grouping */ contentType?: ChatMessageItemContentType; /** Legacy tool results array (from single-message agent responses) */ toolResults?: Array<{ tool: string; args?: Record; result?: unknown }>; /** Number of iterations the agent performed */ iterations?: number; /** Agent display name (for multi-agent group chats) */ agentName?: string; /** Whether message is deleted */ is_deleted?: boolean; } export interface ChatInfo { id: number; title: string; type: 'agent' | 'direct' | 'group' | 'task'; avatar?: string; icon?: string; status?: 'online' | 'typing' | 'offline' | 'last_seen'; lastSeen?: Date; participantsCount?: number; agentModel?: string; } export interface ChatConversationViewProps { chat: ChatInfo | null; messages: ChatMessageItem[]; isLoading?: boolean; isTyping?: boolean; /** Name of the agent currently processing — displayed as "{name} думает..." */ typingAgentName?: string | null; onSendMessage: (content: string, attachments?: File[], mentions?: MentionUser[]) => Promise; onBack?: () => void; onOpenSettings?: () => void; onDeleteMessage?: (messageId: number | string) => void; onEditMessage?: (messageId: number | string, newContent: string) => void; mentionUsers?: MentionUser[]; /** ADR-069: Agents available for /command invocation */ mentionAgents?: MentionUser[]; className?: string; currentUserId?: number; // For human-to-human chats to determine own messages /** Whether there are older messages to load (pagination) */ hasMoreMessages?: boolean; /** Callback to load older messages */ onLoadMoreMessages?: () => Promise; /** Whether older messages are currently loading */ isLoadingMore?: boolean; } export function ChatConversationView({ chat, messages, isLoading = false, isTyping = false, typingAgentName, onSendMessage, onBack, onOpenSettings, onDeleteMessage, onEditMessage, mentionUsers = [], mentionAgents = [], className, currentUserId, hasMoreMessages = false, onLoadMoreMessages, isLoadingMore = false }: ChatConversationViewProps) { const [inputValue, setInputValue] = useState(''); const [attachments, setAttachments] = useState([]); const [mentionedUsers, setMentionedUsers] = useState([]); const [isSending, setIsSending] = useState(false); const isSendingRef = useRef(false); // ref-based guard for rapid clicks (sync, no batching delay) const [selectedMessageId, setSelectedMessageId] = useState(null); const [replyTo, setReplyTo] = useState(null); const authUser = useAuthStore((s) => s.user); const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); const loadMoreSentinelRef = useRef(null); const fileInputRef = useRef(null); const isLoadingMoreRef = useRef(false); const prevMessageCountRef = useRef(0); const prevHumanVisibleCountRef = useRef(0); const isNearBottomRef = useRef(true); const isFirstLoadRef = useRef(true); const [showScrollToBottom, setShowScrollToBottom] = useState(false); const [newMessageCount, setNewMessageCount] = useState(0); const [agentWorking, setAgentWorking] = useState(false); // Keep ref in sync with prop isLoadingMoreRef.current = isLoadingMore; // Reset scroll state when conversation changes useEffect(() => { isFirstLoadRef.current = true; isNearBottomRef.current = true; prevMessageCountRef.current = 0; prevHumanVisibleCountRef.current = 0; setNewMessageCount(0); setAgentWorking(false); setShowScrollToBottom(false); }, [chat?.id]); // Track scroll position — show/hide scroll-to-bottom arrow useEffect(() => { const container = messagesContainerRef.current; if (!container) return; const handleScroll = () => { const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight; const wasNearBottom = isNearBottomRef.current; isNearBottomRef.current = distanceFromBottom <= CHAT_CONFIG.AUTO_SCROLL_THRESHOLD; // Show arrow when user is scrolled up beyond threshold setShowScrollToBottom(distanceFromBottom > CHAT_CONFIG.SCROLL_BUTTON_THRESHOLD); // Reset new message counter when user scrolls back to bottom if (!wasNearBottom && isNearBottomRef.current) { setNewMessageCount(0); } }; container.addEventListener('scroll', handleScroll, { passive: true }); return () => container.removeEventListener('scroll', handleScroll); }, []); // Helper: count only human-visible messages (user messages + final agent text responses) // Ticket #74080: tool_approval is user-actionable and should be counted as visible const countHumanVisible = useCallback((msgs: typeof messages) => { return msgs.filter(m => { // User messages are always visible if (m.role === 'user') return true; // Agent final text responses are visible if (m.role === 'assistant' && (!m.contentType || m.contentType === 'text')) return true; // Tool approval messages require user action — count as visible if (m.contentType === 'tool_approval') return true; // System messages are visible if (m.role === 'system') return true; // tool_call, tool_result, thinking — NOT counted for badge return false; }).length; }, []); // Scroll to bottom on new messages (only if user is near bottom) useEffect(() => { if (messages.length === 0) return; const prevCount = prevMessageCountRef.current; prevMessageCountRef.current = messages.length; // First load — scroll instantly to bottom if (isFirstLoadRef.current) { isFirstLoadRef.current = false; prevHumanVisibleCountRef.current = countHumanVisible(messages); setTimeout(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'instant' as ScrollBehavior }); isNearBottomRef.current = true; }, 50); return; } const totalNewCount = messages.length - prevCount; if (totalNewCount <= 0) return; // Count only human-visible new messages for the badge const currentHumanVisible = countHumanVisible(messages); const newHumanVisible = currentHumanVisible - prevHumanVisibleCountRef.current; prevHumanVisibleCountRef.current = currentHumanVisible; // Detect if agent is actively working (new messages are tool_call/thinking, not final text) const hasAgentInternalMessages = totalNewCount > 0 && newHumanVisible === 0; setAgentWorking(hasAgentInternalMessages); // Bug fix: Double-check scroll position from DOM directly (not just ref). // The ref might be stale if scroll events haven't fired yet after DOM update. // This prevents auto-scroll from pulling user back to bottom when reading old messages. const container = messagesContainerRef.current; if (container) { const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight; if (distanceFromBottom > CHAT_CONFIG.AUTO_SCROLL_THRESHOLD) { isNearBottomRef.current = false; } } if (!isNearBottomRef.current) { // User is scrolled up — don't auto-scroll, only count human-visible messages if (newHumanVisible > 0) { setNewMessageCount(prev => prev + newHumanVisible); } return; } // User is near the bottom — smooth scroll only on human-visible new messages // Don't scroll for tool_call/tool_result/thinking to avoid jumping during agent processing if (newHumanVisible > 0) { setTimeout(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' as ScrollBehavior }); }, 50); } }, [messages, countHumanVisible]); // Infinite scroll: IntersectionObserver on sentinel for loading older messages useEffect(() => { const sentinel = loadMoreSentinelRef.current; const container = messagesContainerRef.current; if (!sentinel || !container || !onLoadMoreMessages) return; const observer = new IntersectionObserver( (entries) => { const entry = entries[0]; if (entry?.isIntersecting && !isLoadingMoreRef.current && hasMoreMessages) { // Save scroll position before prepending messages const prevScrollHeight = container.scrollHeight; const prevScrollTop = container.scrollTop; Promise.resolve(onLoadMoreMessages()).finally(() => { // Restore scroll position after older messages are prepended requestAnimationFrame(() => { requestAnimationFrame(() => { const newScrollHeight = container.scrollHeight; const addedHeight = newScrollHeight - prevScrollHeight; container.scrollTop = prevScrollTop + addedHeight; }); }); }); } }, { root: container, rootMargin: '600px 0px 0px 0px', // Pre-trigger 600px before reaching top threshold: 0, } ); observer.observe(sentinel); return () => observer.disconnect(); }, [hasMoreMessages, onLoadMoreMessages, messages.length > 0]); // Handle checkbox toggle in markdown message: // 1. Update the message content in DB (PATCH) // 2. Send a system message notifying about the change const handleCheckboxToggleInMessage = useCallback(async ( messageId: number | string, originalContent: string, checkboxIndex: number ) => { // Toggle checkbox in content const normalized = normalizeCheckboxes(originalContent); const toggled = toggleCheckboxByIndex(normalized, checkboxIndex); const newContent = denormalizeCheckboxes(toggled, originalContent); const context = getCheckboxContext(normalized, checkboxIndex); // Determine new state (after toggle) const lines = normalized.split('\n'); let currentIdx = 0; let wasChecked = false; for (const line of lines) { const match = line.match(/^\s*[-*+]\s+\[([ xX])\]/); if (match) { if (currentIdx === checkboxIndex) { wasChecked = match[1] !== ' '; break; } currentIdx++; } } const isNowChecked = !wasChecked; // 1. Update message content in DB try { await apiClient.patch(`/chat/messages/${messageId}/content`, { content: newContent }); } catch (e) { console.error('Failed to update message content for checkbox toggle:', e); return; // Don't send system message if PATCH failed } // 2. Send system message about the checkbox change const prefix = context.heading ? `[${context.heading}] ` : ''; const status = isNowChecked ? '✅' : '⬜'; const userName = authUser?.name || 'User'; const systemText = `${status} ${prefix}${isNowChecked ? 'Checked' : 'Unchecked'}: "${context.lineText}" — ${userName}`; try { await onSendMessage(systemText); } catch (e) { console.error('Failed to send checkbox system message:', e); } }, [authUser?.name, onSendMessage]); // Legacy: handle checkbox click info (append to input) — fallback const handleCheckboxClick = useCallback((info: CheckboxClickInfo) => { const prefix = info.heading ? `[${info.heading}] ` : ''; const status = info.checked ? '[x]' : '[ ]'; const userTag = info.user ? ` — ${info.user.name} (${info.user.id})` : ''; const text = `${prefix}${status} ${info.lineText}${userTag}`; setInputValue(prev => prev ? `${prev}\n${text}` : text); }, []); const handleSubmit = async (e?: FormEvent) => { e?.preventDefault(); // ADR-116: Validate and wrap bare @mentions and /commands into structured tokens before sending let trimmedInput = inputValue.trim(); trimmedInput = validateAndWrapMentions(trimmedInput, mentionUsers); trimmedInput = validateAndWrapCommands(trimmedInput, mentionAgents); if (!trimmedInput && attachments.length === 0) return; // Guard: prevent rapid double/triple-click sending (ref is synchronous, no React batching delay) if (isSendingRef.current) return; isSendingRef.current = true; const mentionsToSend = [...mentionedUsers]; setIsSending(true); try { await onSendMessage( trimmedInput, attachments.length > 0 ? attachments : undefined, mentionsToSend.length > 0 ? mentionsToSend : undefined ); setInputValue(''); setAttachments([]); setMentionedUsers([]); setReplyTo(null); } finally { setIsSending(false); isSendingRef.current = false; } }; const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSubmit(); } }; const handleFileSelect = (e: React.ChangeEvent) => { const files = Array.from(e.target.files || []); setAttachments(prev => [...prev, ...files]); e.target.value = ''; }; const removeAttachment = (index: number) => { setAttachments(prev => prev.filter((_, i) => i !== index)); }; const formatTime = (date: Date) => { return date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }); }; const formatDate = (date: Date) => { const today = new Date(); const yesterday = new Date(today); yesterday.setDate(yesterday.getDate() - 1); if (date.toDateString() === today.toDateString()) { return 'Сегодня'; } else if (date.toDateString() === yesterday.toDateString()) { return 'Вчера'; } return date.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long' }); }; const getFileIcon = (type: string) => { if (type.startsWith('image/')) return ; if (type.includes('pdf') || type.includes('document')) return ; return ; }; const formatFileSize = (bytes?: number) => { if (!bytes) return ''; if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; }; // Helper: render avatar based on sender info const renderSenderAvatar = (sender: ChatMessageItem['sender'] | undefined, fallbackIcon?: string) => { // 1. Sender has avatar image if (sender?.avatar) { return {sender.name}; } // 2. Agent sender — show agent icon/emoji or Bot icon if (sender?.type === 'agent') { return fallbackIcon ? {fallbackIcon} : ; } // 3. Human sender — show first letter of name or User icon if (sender?.name) { const initial = sender.name.charAt(0).toUpperCase(); return {initial}; } // 4. Fallback return fallbackIcon ? {fallbackIcon} : ; }; // Helper: avatar background color based on sender type const getAvatarBg = (sender: ChatMessageItem['sender'] | undefined) => { if (sender?.type === 'agent') return 'bg-gradient-to-br from-purple-500 to-purple-600 text-white'; return 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)]'; }; // --- ADR-092: Group messages into turns, then by date --- const turns = useMemo(() => { return groupChatMessageItems(messages, { chatType: chat?.type ?? 'agent', currentUserId, isAgentProcessing: isTyping, }); }, [messages, chat?.type, currentUserId, isTyping]); // Group turns by date for date separators const groupedTurns = useMemo(() => { const groups: Record = {}; for (const turn of turns) { const timestamp = turn.messages[0]?.timestamp; const dateKey = timestamp ? timestamp.toDateString() : new Date().toDateString(); if (!groups[dateKey]) groups[dateKey] = []; groups[dateKey].push(turn); } return groups; }, [turns]); // Render chat status const renderStatus = () => { if (!chat) return null; if (isTyping) { return {typingAgentName ? `${typingAgentName} думает...` : 'печатает...'}; } if (chat.type === 'agent') { return {chat.agentModel || 'AI Agent'}; } if (chat.type === 'group' && chat.participantsCount) { return {chat.participantsCount} участников; } if (chat.status === 'online') { return онлайн; } if (chat.lastSeen) { return был(а) {formatTime(chat.lastSeen)}; } return null; }; // Empty state when no chat selected if (!chat) { return (

Выберите чат

Выберите чат из списка слева или создайте новый

); } return (
{/* Header - Telegram style */}
{/* Back button (mobile) */} {onBack && ( )} {/* Avatar */}
{chat.avatar ? ( {chat.title} ) : (
{chat.icon ? ( {chat.icon} ) : chat.type === 'agent' ? ( ) : ( )}
)} {chat.status === 'online' && ( )}
{/* Info */}
{chat.title} {chat.type === 'agent' && ( AI )}
{renderStatus()}
{/* Actions */}
{/* Messages Area */}
{isLoading ? (
) : messages.length === 0 ? (

Начните диалог

) : ( <> {/* Pagination sentinel — loads older messages when scrolled near top */}
{hasMoreMessages && isLoadingMore ? (
Loading older messages...
) : hasMoreMessages ? (
↑ Scroll up for older messages
) : null}
{Object.entries(groupedTurns).map(([dateKey, dayTurns]) => (
{/* Date separator */}
{formatDate(new Date(dateKey))}
{/* Turns */} {dayTurns.map((turn, turnIdx) => { const isOwn = turn.isOwn; const prevTurn = dayTurns[turnIdx - 1]; const showAvatar = !isOwn && (turnIdx === 0 || prevTurn?.isOwn); // --- Agent turn with steps → render as unified AgentTurnBubble --- if (turn.turnType === 'agent' && turn.messages.length > 0) { const hasSteps = turn.messages.some(m => m.contentType === 'thinking' || m.contentType === 'tool_call' || m.contentType === 'tool_result' || (m.toolResults && m.toolResults.length > 0) ); if (hasSteps) { // Multi-message agent turn → grouped bubble with reasoning chains const lastMsg = turn.messages[turn.messages.length - 1]; return (
{/* Avatar */}
{showAvatar && (
{renderSenderAvatar(turn.sender, chat.icon)}
)}
{/* Agent turn bubble */}
{/* Agent sender name */} {showAvatar && turn.sender?.name && ( {turn.sender.name} )} {/* Timestamp */} {formatTime(lastMsg.timestamp)}
); } } // --- Simple messages: merge consecutive same-sender into ONE bubble --- { const lastMessage = turn.messages[turn.messages.length - 1]; const hasEdited = turn.messages.some(m => m.isEdited); const lastRead = turn.messages[turn.messages.length - 1]?.isRead; return (
{/* Avatar — one per merged bubble, aligned to bottom */} {!isOwn && (
{showAvatar && (
{renderSenderAvatar(turn.sender, turn.sender?.type === 'agent' ? chat.icon : undefined)}
)}
)} {/* Merged bubble — all messages inside one visual container */}
{/* Sender name above bubble for non-own messages */} {!isOwn && showAvatar && turn.sender?.name && ( {turn.sender.name} )}
{ e.preventDefault(); // Find which message was right-clicked by traversing to the closest [data-msg-id] const target = (e.target as HTMLElement).closest('[data-msg-id]'); const msgId = target?.dataset.msgId; if (msgId) { const numId = Number(msgId); setSelectedMessageId(selectedMessageId === numId ? null : numId); } else { // Fallback: toggle last message context menu setSelectedMessageId(selectedMessageId === lastMessage.id ? null : lastMessage.id); } }} > {/* Render each message content inside the merged bubble */}
{turn.messages.map((message, msgIdx) => (
{/* Reply indicator (only shown if message has a reply) */} {message.replyTo && (
{message.replyTo.sender}

{message.replyTo.content}

)} {/* Message content */}
{isOwn ? ( setInputValue(prev => prev ? `${prev} ${token} ` : `${token} `)} /> ) : ( { // Auto-send: toggle checkbox in message + send system notification handleCheckboxToggleInMessage(message.id, message.content, info.index); }} currentUser={authUser ? { name: authUser.name, id: Number(authUser.id) } : undefined} /> )}
{/* Attachments — rich preview with FilePreviewModal */} {message.attachments && message.attachments.length > 0 && ( ({ id: att.id, name: att.name, type: att.type, size: att.size ?? 0, url: att.url, }))} className="mt-1" /> )} {/* Per-message context menu */} {selectedMessageId === message.id && (
e.stopPropagation()} > {isOwn && onEditMessage && ( )} {onDeleteMessage && ( )}
)}
))}
{/* Single timestamp + status at bottom of merged bubble */}
{hasEdited && ( изменено )} {formatTime(lastMessage.timestamp)} {isOwn && ( lastRead ? ( ) : ( ) )}
{/* Close sender name wrapper */}
); } })}
))} )} {/* Typing indicator */} {isTyping && (
{chat.icon || }
{typingAgentName && ( {typingAgentName} )}
{typingAgentName ? `${typingAgentName} думает...` : 'AI думает...'}
)}
{/* Scroll-to-bottom arrow with new message count + agent working indicator */} {showScrollToBottom && (
{/* Agent working indicator — shown when agent is processing (tool calls/thinking) */} {agentWorking && newMessageCount === 0 && (
Agent working…
)}
)}
{/* Reply preview */} {replyTo && (
{replyTo.sender?.name || (replyTo.role === 'user' ? 'Вы' : chat.title)}
{replyTo.content}
)} {/* Attachments preview */} {attachments.length > 0 && (
{attachments.map((file, index) => (
{getFileIcon(file.type)} {file.name}
))}
)} {/* Input Area - Telegram style */}
{/* Attach button */} {/* Input */}
{ // ADR-024: Collect mentioned users for subagent invocation setMentionedUsers(prev => { if (prev.some(u => u.id === user.id && u.type === user.type)) return prev; return [...prev, user]; }); }} availableUsers={mentionUsers} availableAgents={mentionAgents} placeholder="Сообщение... (@ или / для вызова агента)" disabled={isSending} className="bg-[var(--bg-tertiary)] rounded-2xl px-4 py-2" />
{/* Send button */}
); }