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;