mirror of
https://github.com/leonvanzyl/autocoder.git
synced 2026-03-16 18:33:08 +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:
1474
ui/package-lock.json
generated
1474
ui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -32,6 +32,8 @@
|
|||||||
"lucide-react": "^0.475.0",
|
"lucide-react": "^0.475.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
"tailwind-merge": "^3.4.0"
|
"tailwind-merge": "^3.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
|
|
||||||
import { memo } from 'react'
|
import { memo } from 'react'
|
||||||
import { Bot, User, Info } from 'lucide-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 type { ChatMessage as ChatMessageType } from '../lib/types'
|
||||||
import { Card } from '@/components/ui/card'
|
import { Card } from '@/components/ui/card'
|
||||||
|
|
||||||
@@ -14,8 +16,16 @@ interface ChatMessageProps {
|
|||||||
message: ChatMessageType
|
message: ChatMessageType
|
||||||
}
|
}
|
||||||
|
|
||||||
// Module-level regex to avoid recreating on each render
|
// Stable references for memo — avoids re-renders
|
||||||
const BOLD_REGEX = /\*\*(.*?)\*\*/g
|
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) {
|
export const ChatMessage = memo(function ChatMessage({ message }: ChatMessageProps) {
|
||||||
const { role, content, attachments, timestamp, isStreaming } = message
|
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' : ''}`}>
|
<Card className={`${config.bgColor} px-4 py-3 border ${isStreaming ? 'animate-pulse' : ''}`}>
|
||||||
{/* Parse content for basic markdown-like formatting */}
|
|
||||||
{content && (
|
{content && (
|
||||||
<div className={`whitespace-pre-wrap text-sm leading-relaxed ${config.textColor}`}>
|
<div className={`text-sm leading-relaxed ${config.textColor} chat-prose${role === 'user' ? ' chat-prose-user' : ''}`}>
|
||||||
{content.split('\n').map((line, i) => {
|
<ReactMarkdown remarkPlugins={remarkPlugins} components={markdownComponents}>
|
||||||
// Bold text - use module-level regex, reset lastIndex for each line
|
{content}
|
||||||
BOLD_REGEX.lastIndex = 0
|
</ReactMarkdown>
|
||||||
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1271,6 +1271,186 @@
|
|||||||
margin: 2rem 0;
|
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
|
Scrollbar Styling
|
||||||
============================================================================ */
|
============================================================================ */
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ export default defineConfig({
|
|||||||
'@radix-ui/react-slot',
|
'@radix-ui/react-slot',
|
||||||
'@radix-ui/react-switch',
|
'@radix-ui/react-switch',
|
||||||
],
|
],
|
||||||
|
// Markdown rendering
|
||||||
|
'vendor-markdown': ['react-markdown', 'remark-gfm'],
|
||||||
// Icons and utilities
|
// Icons and utilities
|
||||||
'vendor-utils': [
|
'vendor-utils': [
|
||||||
'lucide-react',
|
'lucide-react',
|
||||||
|
|||||||
Reference in New Issue
Block a user