import React, { useEffect, useRef, useState, useMemo } from 'react'; import { Box, Typography, Paper, Avatar, IconButton } from '@mui/material'; import ChevronRightIcon from '@mui/icons-material/ChevronRight'; import type { DocumentMetadata } from '../../../electron/types'; import ReactMarkdown from 'react-markdown'; import rehypeHighlight from 'rehype-highlight'; import 'highlight.js/styles/github.css'; interface ChatMessage { id: string; text: string; isUser: boolean; timestamp: string; sources?: DocumentMetadata[]; avatar?: string; } interface MessageListProps { messages: ChatMessage[]; } export default function MessageList({ messages }: MessageListProps) { const messageListRef = useRef<HTMLDivElement>(null); useEffect(() => { if (messageListRef.current) { messageListRef.current.scrollTop = messageListRef.current.scrollHeight; } }, [messages]); return ( <Box ref={messageListRef} sx={{ flex: 1, overflow: 'auto', p: 2, display: 'flex', flexDirection: 'column', gap: 2, scrollBehavior: 'smooth', '&::-webkit-scrollbar': { width: '8px', background: 'transparent', }, '&::-webkit-scrollbar-thumb': { background: (theme) => theme.palette.divider, borderRadius: '4px', '&:hover': { background: (theme) => theme.palette.action.hover, }, }, '&::-webkit-scrollbar-track': { background: 'transparent', }, }}> {messages.map((message) => ( <Box key={message.id} sx={{ display: 'flex', flexDirection: message.isUser ? 'row-reverse' : 'row', alignItems: 'flex-start', gap: 2, maxWidth: message.isUser ? '80%' : '100%', alignSelf: message.isUser ? 'flex-end' : 'stretch', }} > <Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 0.5 }}> <Avatar src={message.isUser ? '/profiles/user-profile.webp' : '/profiles/ai-profile.webp'} alt={message.isUser ? 'User' : 'AI'} variant="square" sx={{ width: 40, height: 40, boxShadow: 1 }} /> <Typography variant="caption" sx={{ fontSize: '0.75rem', color: 'text.secondary', fontWeight: 500 }} > {message.isUser ? 'User' : 'Data Hound'} </Typography> </Box> <Box component={Paper} elevation={1} sx={{ p: 2.5, flex: 1, bgcolor: message.isUser ? 'primary.main' : 'background.paper', color: message.isUser ? 'primary.contrastText' : 'text.primary', transition: 'all 0.2s ease-in-out', boxShadow: 1, borderRadius: 2, '&:hover': { boxShadow: 2, }, }} > <Box sx={{ '& .markdown-body': { whiteSpace: 'pre-wrap', wordBreak: 'break-word', minHeight: '1.5em', lineHeight: 1.6, '& pre': { backgroundColor: (theme) => theme.palette.mode === 'dark' ? '#1e1e1e' : '#f6f8fa', padding: 2, borderRadius: 1, overflow: 'auto' }, '& code': { backgroundColor: (theme) => theme.palette.mode === 'dark' ? '#1e1e1e' : '#f6f8fa', padding: '0.2em 0.4em', borderRadius: 1, fontSize: '85%' }, '& h1, & h2, & h3, & h4, & h5, & h6': { marginTop: '24px', marginBottom: '16px', fontWeight: 600, lineHeight: 1.25 }, '& p': { marginTop: '0', marginBottom: '16px' }, '& a': { color: (theme) => theme.palette.primary.main, textDecoration: 'none', '&:hover': { textDecoration: 'underline' } }, '& img': { maxWidth: '100%', height: 'auto' }, '& blockquote': { padding: '0 1em', color: (theme) => theme.palette.text.secondary, borderLeft: (theme) => `0.25em solid ${theme.palette.divider}`, margin: '0 0 16px 0' }, '& ul, & ol': { paddingLeft: '2em', marginBottom: '16px' } } }}> {message.text.includes('<think>') ? ( message.text.split(/<think>|<\/think>/).map((segment, index) => { if (index % 2 === 1) { // This is a thinking section // Calculate thinking time - assume 1 character = 0.1s const thinkingTime = (segment.length * 0.1).toFixed(1); return ( <div key={index} className="thinking-section"> <div className="thinking-header" onClick={() => { const content = document.getElementById(`thinking-content-${message.id}-${index}`); const indicator = document.getElementById(`thinking-indicator-${message.id}-${index}`); if (content && indicator) { content.classList.toggle('open'); indicator.classList.toggle('open'); } }} > <div className="thinking-header-left"> <ChevronRightIcon id={`thinking-indicator-${message.id}-${index}`} className="indicator" /> <span>Reasoning</span> </div> <span className="timestamp">(thought for {thinkingTime}s)</span> </div> <div id={`thinking-content-${message.id}-${index}`} className="thinking-content" > {segment} </div> </div> ); } else if (segment.includes('Sorry, there was an error processing your request.')) { return ( <div key={index} className="error-message"> {segment} </div> ); } return ( <ReactMarkdown key={index} className="markdown-body" rehypePlugins={[rehypeHighlight]} > {segment} </ReactMarkdown> ); }) ) : ( <ReactMarkdown className="markdown-body" rehypePlugins={[rehypeHighlight]} > {message.text} </ReactMarkdown> )} </Box> {message.sources && message.sources.length > 0 && ( <Box sx={{ mt: 1, pt: 0, borderTop: '1px solid', borderColor: 'divider' }}> <Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5, fontWeight: 'medium' }} > Sources: </Typography> {message.sources.map((source, index) => ( <Typography key={index} variant="caption" component="div" color="text.secondary" sx={{ pl: 1, borderLeft: '2px solid', borderColor: 'divider' }} > {source.path} </Typography> ))} </Box> )} <Typography variant="caption" color={message.isUser ? '#ffffff' : 'text.secondary'} sx={{ display: 'block', mt: 0.5 }} > {new Date(message.timestamp).toLocaleTimeString()} </Typography> </Box> </Box> ))} </Box> ); }