Spaces:
Sleeping
Sleeping
import React, { useState } from 'react'; | |
import { motion } from 'framer-motion'; | |
import ReactMarkdown from 'react-markdown'; | |
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; | |
import { oneDark, oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism'; | |
import remarkGfm from 'remark-gfm'; | |
import { ClipboardIcon, CheckIcon } from '@heroicons/react/24/outline'; | |
const MessageBubble = ({ message, darkMode, isLast = false }) => { | |
const [copied, setCopied] = useState(false); | |
const copyToClipboard = async (text) => { | |
try { | |
await navigator.clipboard.writeText(text); | |
setCopied(true); | |
setTimeout(() => setCopied(false), 2000); | |
} catch (err) { | |
console.error('Failed to copy text: ', err); | |
} | |
}; | |
const formatTimestamp = (timestamp) => { | |
const date = new Date(timestamp); | |
return date.toLocaleTimeString('en-US', { | |
hour: '2-digit', | |
minute: '2-digit', | |
hour12: false | |
}); | |
}; | |
return ( | |
<motion.div | |
initial={{ opacity: 0, y: 20 }} | |
animate={{ opacity: 1, y: 0 }} | |
transition={{ duration: 0.3 }} | |
className={`flex ${message.role === 'user' ? 'justify-end' : 'justify-start'} mb-4 md:mb-6 px-2 md:px-4`} | |
> | |
<div className={`max-w-[85%] md:max-w-[80%] lg:max-w-[70%] ${ | |
message.role === 'user' ? 'order-2' : 'order-1' | |
}`}> | |
{/* Avatar and Timestamp - Mobile optimized */} | |
<div className={`flex items-center mb-2 ${ | |
message.role === 'user' ? 'justify-end' : 'justify-start' | |
}`}> | |
{message.role === 'assistant' && ( | |
<div className={`w-6 h-6 md:w-8 md:h-8 rounded-full flex items-center justify-center mr-2 md:mr-3 flex-shrink-0 ${ | |
darkMode | |
? 'bg-gradient-to-br from-primary-600 to-purple-600' | |
: 'bg-gradient-to-br from-primary-500 to-purple-500' | |
}`}> | |
<svg className="w-3 h-3 md:w-4 md:h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} | |
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.746 0 3.332.477 4.5 1.253v13C19.832 18.477 18.246 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" /> | |
</svg> | |
</div> | |
)} | |
<span className={`text-xs md:text-sm font-medium ${ | |
darkMode ? 'text-gray-400' : 'text-gray-500' | |
} ${message.role === 'user' ? 'order-2' : 'order-1'}`}> | |
{message.role === 'user' ? 'You' : 'CA Assistant'} | |
</span> | |
<span className={`text-xs ${ | |
darkMode ? 'text-gray-500' : 'text-gray-400' | |
} ml-2 ${message.role === 'user' ? 'order-1 mr-2 ml-0' : 'order-2'}`}> | |
{formatTimestamp(message.timestamp)} | |
</span> | |
{message.role === 'user' && ( | |
<div className={`w-6 h-6 md:w-8 md:h-8 rounded-full flex items-center justify-center ml-2 md:ml-3 flex-shrink-0 ${ | |
darkMode | |
? 'bg-gradient-to-br from-blue-600 to-blue-700' | |
: 'bg-gradient-to-br from-blue-500 to-blue-600' | |
}`}> | |
<svg className="w-3 h-3 md:w-4 md:h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} | |
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /> | |
</svg> | |
</div> | |
)} | |
</div> | |
{/* Message Content - Mobile optimized */} | |
<div className={`relative group ${ | |
message.role === 'user' ? 'text-right' : 'text-left' | |
}`}> | |
<div className={`inline-block p-3 md:p-4 rounded-2xl md:rounded-2xl relative shadow-md hover:shadow-lg transition-all duration-200 ${ | |
message.role === 'user' | |
? darkMode | |
? 'bg-gradient-to-br from-blue-600 to-blue-700 text-white' | |
: 'bg-gradient-to-br from-blue-500 to-blue-600 text-white' | |
: darkMode | |
? 'bg-gradient-to-br from-gray-800 to-gray-850 border border-gray-700/50 text-gray-100' | |
: 'bg-gradient-to-br from-white to-gray-50 border border-gray-200/50 text-gray-900' | |
} max-w-full`}> | |
{/* Message Text - Mobile optimized */} | |
<div className={`prose prose-sm md:prose-base max-w-none ${ | |
message.role === 'user' | |
? 'prose-invert' | |
: darkMode | |
? 'prose-invert prose-gray' | |
: 'prose-gray' | |
} ${message.role === 'user' ? 'text-left' : ''}`}> | |
{message.role === 'user' ? ( | |
<p className="mb-0 text-sm md:text-base leading-relaxed break-words">{message.content}</p> | |
) : ( | |
<ReactMarkdown | |
remarkPlugins={[remarkGfm]} | |
components={{ | |
// Mobile-optimized code blocks | |
code({ node, inline, className, children, ...props }) { | |
const match = /language-(\w+)/.exec(className || ''); | |
return !inline && match ? ( | |
<div className="relative my-3 md:my-4 rounded-lg md:rounded-xl overflow-hidden"> | |
<div className={`flex items-center justify-between px-3 md:px-4 py-2 md:py-3 text-xs md:text-sm ${ | |
darkMode ? 'bg-gray-900 text-gray-300' : 'bg-gray-800 text-gray-200' | |
}`}> | |
<span className="font-medium">{match[1]}</span> | |
<button | |
onClick={() => copyToClipboard(String(children).replace(/\n$/, ''))} | |
className={`flex items-center space-x-1 md:space-x-2 px-2 md:px-3 py-1 md:py-1.5 rounded-md transition-colors touch-manipulation ${ | |
darkMode | |
? 'hover:bg-gray-800 active:bg-gray-700' | |
: 'hover:bg-gray-700 active:bg-gray-600' | |
}`} | |
title="Copy code" | |
> | |
{copied ? ( | |
<CheckIcon className="w-3 h-3 md:w-4 md:h-4" /> | |
) : ( | |
<ClipboardIcon className="w-3 h-3 md:w-4 md:h-4" /> | |
)} | |
<span className="text-xs hidden md:inline"> | |
{copied ? 'Copied!' : 'Copy'} | |
</span> | |
</button> | |
</div> | |
<SyntaxHighlighter | |
style={darkMode ? oneDark : oneLight} | |
language={match[1]} | |
PreTag="div" | |
className="!m-0 text-xs md:text-sm" | |
customStyle={{ | |
fontSize: '12px', | |
lineHeight: '1.4', | |
padding: '12px 16px', | |
}} | |
{...props} | |
> | |
{String(children).replace(/\n$/, '')} | |
</SyntaxHighlighter> | |
</div> | |
) : ( | |
<code | |
className={`px-1.5 md:px-2 py-0.5 md:py-1 rounded text-xs md:text-sm font-mono ${ | |
darkMode | |
? 'bg-gray-700 text-gray-200' | |
: 'bg-gray-200 text-gray-800' | |
}`} | |
{...props} | |
> | |
{children} | |
</code> | |
); | |
}, | |
// Mobile-optimized paragraphs | |
p: ({ children }) => ( | |
<p className="mb-3 md:mb-4 last:mb-0 text-sm md:text-base leading-relaxed break-words"> | |
{children} | |
</p> | |
), | |
// Mobile-optimized lists | |
ul: ({ children }) => ( | |
<ul className="mb-3 md:mb-4 ml-4 md:ml-6 space-y-1 md:space-y-2 text-sm md:text-base"> | |
{children} | |
</ul> | |
), | |
ol: ({ children }) => ( | |
<ol className="mb-3 md:mb-4 ml-4 md:ml-6 space-y-1 md:space-y-2 text-sm md:text-base"> | |
{children} | |
</ol> | |
), | |
li: ({ children }) => ( | |
<li className="leading-relaxed break-words"> | |
{children} | |
</li> | |
), | |
// Mobile-optimized headings | |
h1: ({ children }) => ( | |
<h1 className="text-lg md:text-xl font-bold mb-2 md:mb-3 mt-4 md:mt-6 first:mt-0 break-words"> | |
{children} | |
</h1> | |
), | |
h2: ({ children }) => ( | |
<h2 className="text-base md:text-lg font-bold mb-2 md:mb-3 mt-3 md:mt-4 first:mt-0 break-words"> | |
{children} | |
</h2> | |
), | |
h3: ({ children }) => ( | |
<h3 className="text-sm md:text-base font-bold mb-1 md:mb-2 mt-2 md:mt-3 first:mt-0 break-words"> | |
{children} | |
</h3> | |
), | |
// Mobile-optimized blockquotes | |
blockquote: ({ children }) => ( | |
<blockquote className={`border-l-3 md:border-l-4 pl-3 md:pl-4 my-3 md:my-4 italic text-sm md:text-base ${ | |
darkMode ? 'border-gray-600 text-gray-300' : 'border-gray-400 text-gray-600' | |
}`}> | |
{children} | |
</blockquote> | |
), | |
// Mobile-optimized tables | |
table: ({ children }) => ( | |
<div className="overflow-x-auto my-3 md:my-4 -mx-1"> | |
<table className={`min-w-full text-xs md:text-sm border-collapse ${ | |
darkMode ? 'border-gray-600' : 'border-gray-300' | |
}`}> | |
{children} | |
</table> | |
</div> | |
), | |
th: ({ children }) => ( | |
<th className={`border px-2 md:px-3 py-1 md:py-2 font-medium text-left ${ | |
darkMode | |
? 'border-gray-600 bg-gray-700/50' | |
: 'border-gray-300 bg-gray-100' | |
}`}> | |
{children} | |
</th> | |
), | |
td: ({ children }) => ( | |
<td className={`border px-2 md:px-3 py-1 md:py-2 ${ | |
darkMode ? 'border-gray-600' : 'border-gray-300' | |
}`}> | |
{children} | |
</td> | |
), | |
}} | |
> | |
{message.content || '*Thinking...*'} | |
</ReactMarkdown> | |
)} | |
</div> | |
{/* Copy Button for Assistant Messages - Mobile optimized */} | |
{message.role === 'assistant' && message.content && ( | |
<button | |
onClick={() => copyToClipboard(message.content)} | |
className={`absolute top-2 md:top-3 right-2 md:right-3 opacity-0 group-hover:opacity-100 transition-all duration-200 p-1.5 md:p-2 rounded-lg touch-manipulation ${ | |
darkMode | |
? 'hover:bg-gray-700/70 active:bg-gray-600/70 text-gray-400 hover:text-gray-200' | |
: 'hover:bg-gray-200/70 active:bg-gray-300/70 text-gray-500 hover:text-gray-700' | |
} backdrop-blur-sm`} | |
title="Copy message" | |
> | |
{copied ? ( | |
<CheckIcon className="w-3 h-3 md:w-4 md:h-4" /> | |
) : ( | |
<ClipboardIcon className="w-3 h-3 md:w-4 md:h-4" /> | |
)} | |
</button> | |
)} | |
</div> | |
{/* Message tail/pointer - Mobile optimized */} | |
<div className={`absolute top-3 md:top-4 w-0 h-0 ${ | |
message.role === 'user' | |
? 'right-0 border-l-8 md:border-l-10 border-t-8 md:border-t-10 border-transparent' | |
: 'left-0 border-r-8 md:border-r-10 border-t-8 md:border-t-10 border-transparent' | |
} ${ | |
message.role === 'user' | |
? darkMode | |
? 'border-t-blue-600' | |
: 'border-t-blue-500' | |
: darkMode | |
? 'border-t-gray-800' | |
: 'border-t-white' | |
}`} /> | |
</div> | |
</div> | |
</motion.div> | |
); | |
}; | |
export default MessageBubble; |