feat: add full markdown rendering to chat messages

Replace the custom BOLD_REGEX parser in ChatMessage.tsx with
react-markdown + remark-gfm for proper rendering of headers, tables,
lists, code blocks, blockquotes, links, and horizontal rules in all
chat UIs (AssistantChat, SpecCreationChat, ExpandProjectChat).

Changes:
- Add react-markdown and remark-gfm dependencies
- Add vendor-markdown chunk to Vite manual chunks for code splitting
- Add .chat-prose CSS class with styles for all markdown elements
- Add .chat-prose-user modifier for contrast on primary-colored bubbles
- Replace line-splitting + regex logic with ReactMarkdown component
- Links open in new tabs via custom component override
- System messages remain plain text (unchanged)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Auto
2026-02-06 14:37:39 +02:00
parent 7d08700f3a
commit 0d04a062a2
5 changed files with 1669 additions and 39 deletions

View File

@@ -7,6 +7,8 @@
import { memo } from 'react'
import { Bot, User, Info } from 'lucide-react'
import ReactMarkdown, { type Components } from 'react-markdown'
import remarkGfm from 'remark-gfm'
import type { ChatMessage as ChatMessageType } from '../lib/types'
import { Card } from '@/components/ui/card'
@@ -14,8 +16,16 @@ interface ChatMessageProps {
message: ChatMessageType
}
// Module-level regex to avoid recreating on each render
const BOLD_REGEX = /\*\*(.*?)\*\*/g
// Stable references for memo — avoids re-renders
const remarkPlugins = [remarkGfm]
const markdownComponents: Components = {
a: ({ children, href, ...props }) => (
<a href={href} target="_blank" rel="noopener noreferrer" {...props}>
{children}
</a>
),
}
export const ChatMessage = memo(function ChatMessage({ message }: ChatMessageProps) {
const { role, content, attachments, timestamp, isStreaming } = message
@@ -86,39 +96,11 @@ export const ChatMessage = memo(function ChatMessage({ message }: ChatMessagePro
)}
<Card className={`${config.bgColor} px-4 py-3 border ${isStreaming ? 'animate-pulse' : ''}`}>
{/* Parse content for basic markdown-like formatting */}
{content && (
<div className={`whitespace-pre-wrap text-sm leading-relaxed ${config.textColor}`}>
{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(
<strong key={`bold-${i}-${match.index}`} className="font-bold">
{match[1]}
</strong>
)
lastIndex = match.index + match[0].length
}
if (lastIndex < line.length) {
parts.push(line.slice(lastIndex))
}
return (
<span key={i}>
{parts.length > 0 ? parts : line}
{i < content.split('\n').length - 1 && '\n'}
</span>
)
})}
<div className={`text-sm leading-relaxed ${config.textColor} chat-prose${role === 'user' ? ' chat-prose-user' : ''}`}>
<ReactMarkdown remarkPlugins={remarkPlugins} components={markdownComponents}>
{content}
</ReactMarkdown>
</div>
)}