mirror of
https://github.com/leonvanzyl/autocoder.git
synced 2026-03-17 19:03:09 +00:00
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:
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1271,6 +1271,186 @@
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Chat Prose Typography (for markdown in chat bubbles)
|
||||
============================================================================ */
|
||||
|
||||
.chat-prose {
|
||||
line-height: 1.6;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.chat-prose > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.chat-prose > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.chat-prose h1 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
margin-top: 1.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.chat-prose h2 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.chat-prose h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin-top: 0.75rem;
|
||||
margin-bottom: 0.375rem;
|
||||
}
|
||||
|
||||
.chat-prose h4,
|
||||
.chat-prose h5,
|
||||
.chat-prose h6 {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
margin-top: 0.75rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.chat-prose p {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.chat-prose ul,
|
||||
.chat-prose ol {
|
||||
margin-bottom: 0.5rem;
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
|
||||
.chat-prose ul {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.chat-prose ol {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
.chat-prose li {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.chat-prose li > ul,
|
||||
.chat-prose li > ol {
|
||||
margin-top: 0.25rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.chat-prose pre {
|
||||
background: var(--muted);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.75rem;
|
||||
overflow-x: auto;
|
||||
margin-bottom: 0.5rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.chat-prose code:not(pre code) {
|
||||
background: var(--muted);
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 0.25rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.chat-prose table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.chat-prose th {
|
||||
background: var(--muted);
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
padding: 0.375rem 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.chat-prose td {
|
||||
padding: 0.375rem 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.chat-prose blockquote {
|
||||
border-left: 3px solid var(--primary);
|
||||
padding-left: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
font-style: italic;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.chat-prose a {
|
||||
color: var(--primary);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.chat-prose a:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.chat-prose strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.chat-prose hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
|
||||
.chat-prose img {
|
||||
max-width: 100%;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
/* User message overrides - need contrast against primary-colored bubble */
|
||||
.chat-prose-user pre {
|
||||
background: rgb(255 255 255 / 0.15);
|
||||
border-color: rgb(255 255 255 / 0.2);
|
||||
}
|
||||
|
||||
.chat-prose-user code:not(pre code) {
|
||||
background: rgb(255 255 255 / 0.15);
|
||||
}
|
||||
|
||||
.chat-prose-user th {
|
||||
background: rgb(255 255 255 / 0.15);
|
||||
}
|
||||
|
||||
.chat-prose-user th,
|
||||
.chat-prose-user td {
|
||||
border-color: rgb(255 255 255 / 0.2);
|
||||
}
|
||||
|
||||
.chat-prose-user blockquote {
|
||||
border-left-color: rgb(255 255 255 / 0.5);
|
||||
}
|
||||
|
||||
.chat-prose-user a {
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.chat-prose-user hr {
|
||||
border-top-color: rgb(255 255 255 / 0.2);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Scrollbar Styling
|
||||
============================================================================ */
|
||||
|
||||
Reference in New Issue
Block a user