import React, { useState, useEffect, useRef, useCallback, useLayoutEffect } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkMath from 'remark-math'
import remarkGfm from 'remark-gfm'
import rehypeKatex from 'rehype-katex'
import { Copy, Check, ChevronDown, ChevronUp, Bot, User, FileText, FileDown, FileCode, Download } from 'lucide-react'
import { jsPDF } from 'jspdf'
import html2canvas from 'html2canvas'
import { preprocessLaTeX, parseMessageContent } from '../utils/chatUtils'
import ErrorBoundary from './ErrorBoundary'
import calculusIcon from '../assets/calculus-icon.png'
const MessageFooter = ({ role, onCopy, onToggleExpand, isExpanded, isOverflow, copiedId, idx, onExportMD, onExportPDF, onExportLaTeX }) => {
const [showMenu, setShowMenu] = useState(false)
return (
{isOverflow && (
)}
{role === 'assistant' && (
{showMenu && (
setShowMenu(false)}>
)}
)}
)
}
const CollapsibleContent = ({ content, messageId, maxLines = 12, isStreaming = false, children }) => {
const [expanded, setExpanded] = useState(() => {
if (!messageId) return false
return localStorage.getItem(messageId) === 'true'
})
// Maintain expanded state after streaming finishes for the current session
const [wasStreaming, setWasStreaming] = useState(false)
useEffect(() => {
if (isStreaming && !wasStreaming) {
setWasStreaming(true)
setExpanded(true)
}
}, [isStreaming, wasStreaming])
const [isOverflow, setIsOverflow] = useState(false)
const contentRef = useRef(null)
useEffect(() => {
if (contentRef.current) {
const lineHeight = parseFloat(getComputedStyle(contentRef.current).lineHeight)
const maxHeight = lineHeight * maxLines
setIsOverflow(contentRef.current.scrollHeight > maxHeight + 10)
}
}, [content, maxLines])
const toggleExpand = () => {
const nextState = !expanded
setExpanded(nextState)
if (messageId) {
localStorage.setItem(messageId, String(nextState))
}
}
return (
}}
>
{preprocessLaTeX(parseMessageContent(content))}
{children({ isOverflow, isExpanded: expanded, onToggleExpand: toggleExpand })}
)
}
const MessageList = ({
messages,
isLoading,
conversationId,
onExampleClick,
onImageClick,
userAvatar,
userName = 'User'
}) => {
const messagesEndRef = useRef(null)
const containerRef = useRef(null)
const [showScrollBtn, setShowScrollBtn] = useState(false)
const [isRestored, setIsRestored] = useState(false)
const [isTransitioning, setIsTransitioning] = useState(false)
const scrollPositionsRef = useRef({}) // Persistent in-memory scroll storage
const prevConversationId = useRef(conversationId)
const prevMessagesLengthRef = useRef(0)
const prevStreamingRef = useRef(false)
const [copiedId, setCopiedId] = useState(null)
const pdfExportRef = useRef(null)
const [exportingIndex, setExportingIndex] = useState(null)
const scrollToBottom = (behavior = 'smooth') => {
if (containerRef.current) {
const container = containerRef.current
if (behavior === 'smooth') {
// Use requestAnimationFrame to ensure React has rendered the new content
requestAnimationFrame(() => {
container.scrollTo({
top: container.scrollHeight,
behavior: 'smooth'
})
})
} else {
container.scrollTop = container.scrollHeight
}
}
}
const handleScroll = useCallback(() => {
if (containerRef.current && conversationId && isRestored && !isTransitioning) {
const { scrollTop, scrollHeight, clientHeight } = containerRef.current
// Save to both Ref (instant access) and SessionStorage (persistence)
scrollPositionsRef.current[conversationId] = scrollTop
sessionStorage.setItem(`scroll_${conversationId}`, String(scrollTop))
setShowScrollBtn(scrollHeight - scrollTop - clientHeight > 100)
}
}, [conversationId, isRestored, isTransitioning])
// Detect Session Change
useLayoutEffect(() => {
if (prevConversationId.current !== conversationId) {
// Store current position before switching
if (containerRef.current && prevConversationId.current) {
scrollPositionsRef.current[prevConversationId.current] = containerRef.current.scrollTop
}
setIsRestored(false)
// Only set transitioning if we're moving from one real session to another
// This prevents "white screen" on new sessions while streaming starts
setIsTransitioning(!!prevConversationId.current && !!conversationId)
prevConversationId.current = conversationId
prevMessagesLengthRef.current = 0
}
}, [conversationId])
// Restore Scroll Position
useLayoutEffect(() => {
if (!containerRef.current || messages.length === 0 || !conversationId) {
if (messages.length === 0 && conversationId) {
setIsTransitioning(false) // No messages, nothing to restore
setIsRestored(true)
}
return
}
if (isRestored) return
const container = containerRef.current
// Priority: Ref -> SessionStorage -> Bottom
const savedScroll = scrollPositionsRef.current[conversationId] ??
sessionStorage.getItem(`scroll_${conversationId}`)
// Use 'auto' behavior for instant restoration (no jump)
if (savedScroll !== null) {
container.scrollTo({
top: parseInt(savedScroll, 10),
behavior: 'auto'
})
} else {
container.scrollTo({
top: container.scrollHeight,
behavior: 'auto'
})
}
// Force a layout reflow to ensure scroll is applied before showing
void container.offsetHeight
// If it's a session switch (isTransitioning), we use a small delay to hide the "jump"
if (isTransitioning) {
const timer = setTimeout(() => {
setIsRestored(true)
setIsTransitioning(false)
prevMessagesLengthRef.current = messages.length
}, 50)
return () => clearTimeout(timer)
} else {
// --- REFRESH CASE (INITIAL LOAD) ---
// Set isRestored to true to start animations
// Set prevMessagesLength to 0 to make ALL messages animate from the start
setIsRestored(true)
prevMessagesLengthRef.current = 0
}
}, [messages.length, conversationId, isRestored, isTransitioning])
const isInitialLoadRef = useRef(true)
// Handle New Messages
useEffect(() => {
if (!isRestored || isTransitioning || messages.length === 0) return
const isNewMessage = messages.length > prevMessagesLengthRef.current
// If it's the initial load (refresh), we want animations but NOT auto-scroll
if (isInitialLoadRef.current) {
isInitialLoadRef.current = false
prevMessagesLengthRef.current = messages.length
return
}
const lastMessage = messages[messages.length - 1]
const isUserMessage = lastMessage?.role === 'user'
if (isNewMessage) {
// Always scroll to bottom for user messages
// For bot messages, only scroll if already at bottom (sticky)
if (isUserMessage || !showScrollBtn) {
scrollToBottom('smooth')
}
}
prevMessagesLengthRef.current = messages.length
}, [messages.length, showScrollBtn, isRestored, isTransitioning])
// Auto-scroll during streaming to keep user's view locked on new content
const lastMessageContent = messages[messages.length - 1]?.content
const isLastMessageStreaming = messages[messages.length - 1]?.isStreaming
useEffect(() => {
if (!containerRef.current) return
if (isLastMessageStreaming) {
// "Sticky Scroll": Only auto-scroll if user is already near the bottom
// !showScrollBtn means distance to bottom < 100px
if (!showScrollBtn) {
containerRef.current.scrollTop = containerRef.current.scrollHeight
}
} else if (prevStreamingRef.current) {
// Just finished streaming - do one final sync to be sure
// but only if the user didn't scroll too far up
if (!showScrollBtn) {
scrollToBottom('smooth')
}
}
prevStreamingRef.current = isLastMessageStreaming
}, [lastMessageContent, isLastMessageStreaming, showScrollBtn])
const copyToClipboard = (text, idx) => {
navigator.clipboard.writeText(text)
setCopiedId(idx)
setTimeout(() => setCopiedId(null), 2000)
}
const exportToMarkdown = (content, idx) => {
const blob = new Blob([content], { type: 'text/markdown' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `chat-answer-${idx + 1}.md`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
const exportToLaTeX = (content, idx) => {
const texContent = `\\documentclass{article}\n\\usepackage[utf8]{inputenc}\n\\usepackage{amsmath}\n\n\\begin{document}\n${content}\n\\end{document}`
const blob = new Blob([texContent], { type: 'text/x-tex' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `chat-answer-${idx + 1}.tex`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
const exportToPDF = async (idx) => {
setExportingIndex(idx)
// Wait for DOM to update the hidden element
setTimeout(async () => {
if (!pdfExportRef.current) return
try {
const canvas = await html2canvas(pdfExportRef.current, {
scale: 3,
useCORS: true,
backgroundColor: '#ffffff',
logging: false,
})
const imgData = canvas.toDataURL('image/png')
const pdfWidth = 595.28
const pdfHeight = 841.89
const pdf = new jsPDF('p', 'pt', 'a4')
const margin = 0 // Container already has padding
const innerWidth = pdfWidth
const imgProps = pdf.getImageProperties(imgData)
const imgHeight = (imgProps.height * innerWidth) / imgProps.width
let heightLeft = imgHeight
let position = 0
pdf.addImage(imgData, 'PNG', 0, position, innerWidth, imgHeight)
heightLeft -= pdfHeight
while (heightLeft >= 0) {
pdf.addPage()
position = heightLeft - imgHeight
pdf.addImage(imgData, 'PNG', 0, position, innerWidth, imgHeight)
heightLeft -= pdfHeight
}
pdf.save(`chat-answer-${idx + 1}.pdf`)
setExportingIndex(null)
} catch (error) {
console.error('PDF Export failed:', error)
setExportingIndex(null)
}
}, 100)
}
if (messages.length === 0) {
return (

e.target.style.display = 'none'} />
Xin chào, tôi có thể giúp gì?
Tôi là Pochi, bạn đồng hành của bạn trong việc chinh phục môn toán giải tích.
Hãy bắt đầu bằng việc đặt câu hỏi cho tôi nhé!
)
}
return (
{messages.map((msg, idx) => (
= prevMessagesLengthRef.current - 1 ? 'animate-msg-slide-up' : ''}`}
style={{ animationDelay: isRestored ? `${Math.min((idx - (prevMessagesLengthRef.current - 1)) * 0.05, 0.5)}s` : '0s' }}
>
{/* Avatar removed */}
{msg.status &&
{msg.status}
}
{msg.images && msg.images.length > 0 && (
{msg.images.map((src, i) => (
onImageClick(msg.images, i)}>
))}
)}
{msg.content ? (
{({ isOverflow, isExpanded, onToggleExpand }) => (
copyToClipboard(msg.content, idx)}
onExportMD={() => exportToMarkdown(msg.content, idx)}
onExportPDF={() => exportToPDF(idx)}
onExportLaTeX={() => exportToLaTeX(msg.content, idx)}
copiedId={copiedId}
idx={idx}
/>
)}
) : msg.role === 'assistant' ? (
Đang suy nghĩ...
) : (
...
)}
))}
{/* Hidden Premium PDF Layout Component */}
{exportingIndex !== null && (
{/* Background Watermark */}
POCHI
POCHI
Assistant Export
{new Date().toLocaleDateString('vi-VN')}
{preprocessLaTeX(parseMessageContent(messages[exportingIndex].content))}
Tài liệu được tạo bởi Pochi
)}
{showScrollBtn && (
)}
)
}
export default MessageList