import { useEffect, useRef, useCallback, useState, useMemo } from 'react'; import { ChatMessageComponent } from './ChatMessage'; import { ChatInput } from './ChatInput'; import { TypingIndicator } from './TypingIndicator'; import type { ChatMessage, ConnectionStatus } from '../types'; import { Bot, ArrowDown, Loader2, ChevronsDownUp, ChevronsUpDown } from 'lucide-react'; import { useT } from '../hooks/useLocale'; import { getLocale, type TranslationKey } from '../lib/i18n'; import { useToolCollapse } from '../contexts/ToolCollapseContext'; interface Props { messages: ChatMessage[]; isGenerating: boolean; isLoadingHistory: boolean; status: ConnectionStatus; sessionKey?: string; onSend: (text: string, attachments?: Array<{ mimeType: string; fileName: string; content: string }>) => void; onAbort: () => void; } function isNoReply(msg: ChatMessage): boolean { const text = (msg.content || '').trim(); if (text === 'NO_REPLY') return true; const textBlocks = msg.blocks.filter(b => b.type === 'text'); if (textBlocks.length === 1 && (textBlocks[0] as { text: string }).text.trim() === 'NO_REPLY') return true; return false; } function hasVisibleContent(msg: ChatMessage): boolean { if (msg.role === 'user') return true; if (msg.role === 'assistant' && isNoReply(msg)) return false; if (msg.blocks.length === 0) return !!msg.content; return msg.blocks.some(b => (b.type === 'text' && b.text.trim()) || b.type === 'thinking' || b.type === 'tool_use' || b.type === 'tool_result' ); } function hasStreamedText(messages: ChatMessage[]): boolean { if (messages.length === 0) return false; const last = messages[messages.length - 1]; if (last.role !== 'assistant') return false; return last.blocks.some(b => b.type === 'text' && b.text.trim().length > 0) || (last.content?.trim().length > 0); } function formatDateSeparator(ts: number, t: (k: TranslationKey) => string): string { const date = new Date(ts); const now = new Date(); const locale = getLocale(); const bcp47 = locale === 'fr' ? 'fr-FR' : 'en-US'; if (date.toDateString() === now.toDateString()) return t('time.today'); const yesterday = new Date(now); yesterday.setDate(yesterday.getDate() - 1); if (date.toDateString() === yesterday.toDateString()) return t('time.yesterday'); return date.toLocaleDateString(bcp47, { weekday: 'long', day: 'numeric', month: 'long', year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined }); } function getDateKey(ts: number): string { const d = new Date(ts); return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`; } /** Threshold in pixels — if the user is within this distance of the bottom, auto-scroll */ const SCROLL_THRESHOLD = 150; export function Chat({ messages, isGenerating, isLoadingHistory, status, sessionKey, onSend, onAbort }: Props) { const t = useT(); const bottomRef = useRef(null); const scrollContainerRef = useRef(null); const isNearBottomRef = useRef(true); const userSentRef = useRef(false); const [showScrollBtn, setShowScrollBtn] = useState(false); const checkIfNearBottom = useCallback(() => { const el = scrollContainerRef.current; if (!el) return; const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; isNearBottomRef.current = distanceFromBottom <= SCROLL_THRESHOLD; setShowScrollBtn(distanceFromBottom > SCROLL_THRESHOLD * 2); }, []); const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => { bottomRef.current?.scrollIntoView({ behavior }); }, []); // Track scroll position to decide whether to auto-scroll useEffect(() => { const el = scrollContainerRef.current; if (!el) return; const handler = () => checkIfNearBottom(); el.addEventListener('scroll', handler, { passive: true }); return () => el.removeEventListener('scroll', handler); }, [checkIfNearBottom]); // Auto-scroll when messages change, but only if user is near bottom or just sent a message useEffect(() => { if (userSentRef.current) { // User just sent a message — always scroll to bottom userSentRef.current = false; scrollToBottom('smooth'); isNearBottomRef.current = true; return; } if (isNearBottomRef.current) { scrollToBottom('smooth'); } }, [messages, isGenerating, scrollToBottom]); // Wrap onSend to flag that user initiated a message const handleSend = useCallback((text: string, attachments?: Array<{ mimeType: string; fileName: string; content: string }>) => { userSentRef.current = true; onSend(text, attachments); }, [onSend]); const visibleMessages = useMemo(() => { const filtered = messages.filter(hasVisibleContent); return filtered.reduce>((acc, msg) => { const dk = getDateKey(msg.timestamp); const prevDk = acc.length > 0 ? getDateKey(acc[acc.length - 1].msg.timestamp) : ''; acc.push({ msg, showSep: dk !== prevDk }); return acc; }, []); }, [messages]); const showTyping = isGenerating && !hasStreamedText(messages); const { globalState, collapseAll, expandAll } = useToolCollapse(); const hasToolCalls = useMemo(() => messages.some(m => m.blocks.some(b => b.type === 'tool_use' || b.type === 'tool_result')), [messages]); return (
{messages.length === 0 && isLoadingHistory && (
{t('chat.loadingHistory')}
)} {messages.length === 0 && !isLoadingHistory && (
{t('chat.welcome')}
{t('chat.welcomeSub')}
)} {visibleMessages.map(({ msg, showSep }) => (
{showSep && (
{formatDateSeparator(msg.timestamp, t)}
)}
))} {showTyping && }
{/* Floating action buttons */}
{hasToolCalls && ( )} {showScrollBtn && ( )}
); }