/** * Chat Message Component * * Displays a single message in the spec creation chat. * Supports user, assistant, and system messages with clean styling. */ import { memo } from 'react' import { Bot, User, Info } from 'lucide-react' import type { ChatMessage as ChatMessageType } from '../lib/types' import { Card } from '@/components/ui/card' interface ChatMessageProps { message: ChatMessageType } // Module-level regex to avoid recreating on each render const BOLD_REGEX = /\*\*(.*?)\*\*/g export const ChatMessage = memo(function ChatMessage({ message }: ChatMessageProps) { const { role, content, attachments, timestamp, isStreaming } = message // Format timestamp const timeString = timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', }) // Role-specific styling const roleConfig = { user: { icon: User, bgColor: 'bg-primary', textColor: 'text-primary-foreground', align: 'justify-end', bubbleAlign: 'items-end', iconBg: 'bg-primary', iconColor: 'text-primary-foreground', }, assistant: { icon: Bot, bgColor: 'bg-muted', textColor: 'text-foreground', align: 'justify-start', bubbleAlign: 'items-start', iconBg: 'bg-secondary', iconColor: 'text-secondary-foreground', }, system: { icon: Info, bgColor: 'bg-green-100 dark:bg-green-900/30', textColor: 'text-green-900 dark:text-green-100', align: 'justify-center', bubbleAlign: 'items-center', iconBg: 'bg-green-500', iconColor: 'text-white', }, } const config = roleConfig[role] const Icon = config.icon // System messages are styled differently if (role === 'system') { return (
{content}
) } return (
{/* Message bubble */}
{role === 'assistant' && (
)} {/* Parse content for basic markdown-like formatting */} {content && (
{content.split('\n').map((line, i) => { // Bold text - use module-level regex, reset lastIndex for each line BOLD_REGEX.lastIndex = 0 const parts = [] let lastIndex = 0 let match while ((match = BOLD_REGEX.exec(line)) !== null) { if (match.index > lastIndex) { parts.push(line.slice(lastIndex, match.index)) } parts.push( {match[1]} ) lastIndex = match.index + match[0].length } if (lastIndex < line.length) { parts.push(line.slice(lastIndex)) } return ( {parts.length > 0 ? parts : line} {i < content.split('\n').length - 1 && '\n'} ) })}
)} {/* Display image attachments */} {attachments && attachments.length > 0 && (
{attachments.map((attachment) => (
{attachment.filename} window.open(attachment.previewUrl, '_blank')} title={`${attachment.filename} (click to enlarge)`} /> {attachment.filename}
))}
)} {/* Streaming indicator */} {isStreaming && ( )}
{role === 'user' && (
)}
{/* Timestamp */} {timeString}
) })