S01Nour
feat: Add MessageList component for chat display with specialized UI and update package dependencies.
ef945f2
| import React, { useEffect, useRef } from 'react'; | |
| import ReactMarkdown from 'react-markdown'; | |
| import type { ChatMessage } from '../../types/chat.types.ts'; | |
| import { Loader2, AlertCircle, RotateCcw } from 'lucide-react'; | |
| import AudioPlayer from './AudioPlayer.tsx'; | |
| import TranscriptToggle from './TranscriptToggle.tsx'; | |
| type MessageListProps = { | |
| messages: ChatMessage[]; | |
| isLoading?: boolean; | |
| error?: string | null; | |
| onRetry?: () => void; | |
| }; | |
| /** | |
| * Helper to determine if a message should use the specialized detection stance UI | |
| */ | |
| const isSpecializedStanceUI = (message: ChatMessage): boolean => { | |
| const toolLower = message.tool?.toLowerCase() || ''; | |
| if (!toolLower.includes('detect') || !toolLower.includes('stance')) return false; | |
| if (message.role === 'user') { | |
| return message.content.includes('**Topic:**'); | |
| } | |
| return message.role === 'assistant'; | |
| }; | |
| const MessageList = ({ messages, isLoading = false, error = null, onRetry }: MessageListProps) => { | |
| const messagesEndRef = useRef<HTMLDivElement>(null); | |
| const scrollToBottom = () => { | |
| messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); | |
| }; | |
| useEffect(() => { | |
| scrollToBottom(); | |
| }, [messages, isLoading]); | |
| const formatTime = (date: Date) => { | |
| return date.toLocaleTimeString('en-US', { | |
| hour: '2-digit', | |
| minute: '2-digit', | |
| }); | |
| }; | |
| const getToolClasses = (message: ChatMessage) => { | |
| if (message.role !== 'assistant' || !message.tool) return ''; | |
| const toolLower = message.tool.toLowerCase(); | |
| // Match tool names flexibly (case-insensitive, handles variations) | |
| if (toolLower.includes('detect') && toolLower.includes('stance')) { | |
| return 'border-l-4 border-blue-400 pl-3 bg-blue-50/30 dark:bg-blue-900/10'; | |
| } | |
| if (toolLower.includes('generate') && toolLower.includes('argument')) { | |
| return 'border-l-4 border-purple-400 pl-3 bg-purple-50/30 dark:bg-purple-900/10'; | |
| } | |
| if (toolLower.includes('extract') && toolLower.includes('topic')) { | |
| return 'border-l-4 border-emerald-400 pl-3 bg-emerald-50/30 dark:bg-emerald-900/10'; | |
| } | |
| // Default styling for other tools | |
| return 'border-l-4 border-teal-400 pl-3 bg-teal-50/30 dark:bg-teal-900/10'; | |
| }; | |
| const getToolLabel = (tool: string | null | undefined): string => { | |
| if (!tool) return ''; | |
| const toolLower = tool.toLowerCase(); | |
| if (toolLower.includes('detect') && toolLower.includes('stance')) { | |
| return 'Detect Stance'; | |
| } | |
| if (toolLower.includes('generate') && toolLower.includes('argument')) { | |
| return 'Generate Argument'; | |
| } | |
| if (toolLower.includes('extract') && toolLower.includes('topic')) { | |
| return 'Extract Topic'; | |
| } | |
| // Return formatted tool name (capitalize first letter of each word) | |
| return tool.split(/[\s_-]+/).map(word => | |
| word.charAt(0).toUpperCase() + word.slice(1).toLowerCase() | |
| ).join(' '); | |
| }; | |
| return ( | |
| <div className="flex-1 overflow-y-auto px-4 py-6 space-y-4"> | |
| {messages.length === 0 && !isLoading && ( | |
| <div className="flex flex-col items-center justify-center h-full text-center"> | |
| <div className="mb-4"> | |
| <div className="w-16 h-16 bg-gradient-to-br from-teal-400 to-blue-500 rounded-full flex items-center justify-center"> | |
| <svg className="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" /> | |
| </svg> | |
| </div> | |
| </div> | |
| <h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2"> | |
| Start a conversation | |
| </h3> | |
| <p className="text-sm text-gray-500 dark:text-gray-400 max-w-md"> | |
| Ask me anything! I'm powered by Meta's Llama 4 Scout model and can help with a wide range of topics. | |
| </p> | |
| </div> | |
| )} | |
| {messages.map((message, index) => { | |
| const showSpecializedStance = isSpecializedStanceUI(message); | |
| return ( | |
| <div | |
| key={message.id} | |
| className={`flex ${message.role === 'user' ? 'justify-end' : 'justify-start'}`} | |
| > | |
| <div | |
| className={`max-w-3xl px-4 py-3 rounded-2xl ${message.role === 'user' | |
| ? 'bg-teal-500 text-white ml-12' | |
| : 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 mr-12' | |
| }${getToolClasses(message) ? ' ' + getToolClasses(message) : ''}`} | |
| > | |
| {message.audioUrl ? ( | |
| <div> | |
| <AudioPlayer | |
| src={message.audioUrl} | |
| autoPlay={message.role === 'assistant' && index === messages.length - 1} | |
| /> | |
| <TranscriptToggle content={message.content} /> | |
| </div> | |
| ) : ( | |
| <div className="space-y-3"> | |
| {message.tool && message.role === 'assistant' && ( | |
| <div className="mb-1 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"> | |
| {getToolLabel(message.tool)} | |
| </div> | |
| )} | |
| {showSpecializedStance ? ( | |
| <div className="space-y-3"> | |
| {message.role === 'user' ? ( | |
| <div className="space-y-2"> | |
| {message.content.split('\n').map((line, idx) => { | |
| if (line.startsWith('**Topic:**')) { | |
| const topicText = line.replace('**Topic:**', '').trim(); | |
| return ( | |
| <div key={idx}> | |
| <span className="text-xs font-semibold opacity-90">Topic: </span> | |
| <span className="text-sm">{topicText}</span> | |
| </div> | |
| ); | |
| } else if (line.startsWith('**Argument:**')) { | |
| const argumentText = line.replace('**Argument:**', '').trim(); | |
| return ( | |
| <div key={idx}> | |
| <span className="text-xs font-semibold opacity-90">Argument: </span> | |
| <span className="text-sm">{argumentText}</span> | |
| </div> | |
| ); | |
| } | |
| return null; | |
| })} | |
| </div> | |
| ) : ( | |
| <div className="space-y-3"> | |
| {message.content.split('\n').map((line, idx) => { | |
| if (line.startsWith('**Stance:**')) { | |
| const stanceText = line.replace('**Stance:**', '').trim(); | |
| const isPro = stanceText.includes('PRO') || stanceText.toLowerCase().includes('positive'); | |
| return ( | |
| <div key={idx} className="flex items-center gap-2"> | |
| <span className="text-xs font-semibold text-gray-600 dark:text-gray-400">Stance:</span> | |
| <span className={`px-2.5 py-1 rounded-full text-xs font-semibold ${isPro | |
| ? 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-200' | |
| : 'bg-rose-100 text-rose-800 dark:bg-rose-900/40 dark:text-rose-200' | |
| }`}> | |
| {stanceText} | |
| </span> | |
| </div> | |
| ); | |
| } else if (line.startsWith('**Confidence:**')) { | |
| const confidenceText = line.replace('**Confidence:**', '').trim(); | |
| const confidenceValue = parseFloat(confidenceText.replace('%', '')) || 0; | |
| const getConfidenceColor = (value: number) => { | |
| if (value >= 80) return 'bg-emerald-500'; | |
| if (value >= 60) return 'bg-yellow-500'; | |
| return 'bg-orange-500'; | |
| }; | |
| return ( | |
| <div key={idx} className="space-y-2"> | |
| <div className="flex items-center justify-between gap-2"> | |
| <span className="text-xs font-semibold text-gray-600 dark:text-gray-400">Confidence:</span> | |
| <span className="text-sm font-medium text-gray-700 dark:text-gray-300">{confidenceText}</span> | |
| </div> | |
| <div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-1.5 overflow-hidden"> | |
| <div | |
| className={`h-full transition-all duration-500 ${getConfidenceColor(confidenceValue)}`} | |
| style={{ width: `${Math.min(confidenceValue, 100)}%` }} | |
| /> | |
| </div> | |
| </div> | |
| ); | |
| } else if (line.startsWith('**Explanation:**')) { | |
| const explanationParts = message.content.split('**Explanation:**\n'); | |
| const explanationText = explanationParts.length > 1 ? explanationParts[1] : ''; | |
| return ( | |
| <div key={idx} className="pt-2 border-t border-gray-200 dark:border-gray-700"> | |
| <div className="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-1">Explanation:</div> | |
| <div className="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap leading-relaxed"> | |
| {explanationText} | |
| </div> | |
| </div> | |
| ); | |
| } | |
| return null; | |
| })} | |
| </div> | |
| )} | |
| </div> | |
| ) : ( | |
| <div className="text-sm leading-relaxed break-words"> | |
| <ReactMarkdown | |
| components={{ | |
| p: ({ node, ...props }) => ( | |
| <p {...props} className="whitespace-pre-wrap mb-2 last:mb-0" /> | |
| ), | |
| ul: ({ node, ...props }) => ( | |
| <ul {...props} className="list-disc ml-5 space-y-1 mb-2 last:mb-0" /> | |
| ), | |
| ol: ({ node, ...props }) => ( | |
| <ol {...props} className="list-decimal ml-5 space-y-1 mb-2 last:mb-0" /> | |
| ), | |
| li: ({ node, ...props }) => ( | |
| <li {...props} className="ml-1" /> | |
| ), | |
| }} | |
| > | |
| {typeof message.content === 'string' ? message.content : ''} | |
| </ReactMarkdown> | |
| </div> | |
| )} | |
| {/* Process pipeline for extract topic tool */} | |
| {message.role === 'assistant' && | |
| message.tool?.toLowerCase().includes('extract') && | |
| message.tool?.toLowerCase().includes('topic') && | |
| message.process && ( | |
| <div className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700"> | |
| <div className="flex items-center gap-2 flex-wrap"> | |
| <span className="text-[10px] uppercase tracking-wide text-gray-400 dark:text-gray-500 font-medium"> | |
| Process: | |
| </span> | |
| {message.process.split('→').map((step, sIdx, steps) => ( | |
| <React.Fragment key={sIdx}> | |
| <span className="px-2 py-0.5 text-[10px] bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 rounded font-medium"> | |
| {step.trim()} | |
| </span> | |
| {sIdx < steps.length - 1 && ( | |
| <span className="text-gray-400 dark:text-gray-600 text-xs">→</span> | |
| )} | |
| </React.Fragment> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| {/* Stance info for generate argument tool */} | |
| {message.role === 'assistant' && | |
| message.tool?.toLowerCase().includes('generate') && | |
| message.tool?.toLowerCase().includes('argument') && | |
| message.stance && ( | |
| <div className="mt-2 flex gap-2"> | |
| <span className={`px-2.5 py-0.5 text-[10px] rounded-full font-semibold uppercase tracking-wider ${message.stance === 'positive' | |
| ? 'bg-emerald-500 text-white' | |
| : 'bg-rose-500 text-white' | |
| }`}> | |
| {message.stance} stance | |
| </span> | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| <div | |
| className={`text-[10px] mt-2 opacity-60 ${message.role === 'user' ? 'text-right opacity-80' : 'text-left' | |
| }`} | |
| > | |
| {formatTime(message.timestamp)} | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| })} | |
| {isLoading && ( | |
| <div className="flex justify-start"> | |
| <div className="bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 mr-12 max-w-3xl px-4 py-3 rounded-2xl"> | |
| <div className="flex items-center space-x-2"> | |
| <Loader2 className="w-4 h-4 animate-spin" /> | |
| <span className="text-sm">Thinking...</span> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {error && ( | |
| <div className="flex justify-start"> | |
| <div className="bg-red-50 dark:bg-red-900/20 text-red-900 dark:text-red-100 mr-12 max-w-3xl px-4 py-3 rounded-2xl border border-red-200 dark:border-red-800"> | |
| <div className="flex items-start space-x-2"> | |
| <AlertCircle className="w-5 h-5 text-red-500 dark:text-red-400 flex-shrink-0 mt-0.5" /> | |
| <div className="flex-1"> | |
| <p className="text-sm font-medium">Error</p> | |
| <p className="text-sm opacity-90">{error}</p> | |
| {onRetry && ( | |
| <button | |
| onClick={onRetry} | |
| className="mt-2 flex items-center space-x-1 text-sm text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 transition-colors" | |
| > | |
| <RotateCcw className="w-3 h-3" /> | |
| <span>Retry</span> | |
| </button> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| <div ref={messagesEndRef} /> | |
| </div> | |
| ); | |
| }; | |
| export default MessageList; | |