feat: render chat messages as markdown with code blocks

- Add react-markdown and remark-gfm for markdown rendering
- Create MarkdownContent component with custom code block renderer
  featuring language labels and copy-to-clipboard buttons
- Add full markdown prose styles (headings, lists, tables, links,
  blockquotes, inline code) matching the dark zinc theme
- Replace plain-text message rendering in ChatPane with MarkdownContent
- Handle fenced code blocks with and without language specifiers

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-21 19:04:24 +01:00
co-authored by Copilot
parent 9138aa8819
commit 879c6c145c
5 changed files with 436 additions and 4 deletions
+5 -3
View File
@@ -1,6 +1,8 @@
import { type KeyboardEvent, useEffect, useRef, useState } from 'react';
import { AlertCircle, ArrowUp, Bot, Loader2, User } from 'lucide-react';
import { MarkdownContent } from '@renderer/components/MarkdownContent';
import type { PatternDefinition } from '@shared/domain/pattern';
import type { ProjectRecord } from '@shared/domain/project';
import type { SessionRecord } from '@shared/domain/session';
@@ -112,10 +114,10 @@ export function ChatPane({ project, pattern, session, onSend }: ChatPaneProps) {
<div className="mb-1 text-[12px] font-medium text-zinc-400">
{message.authorName}
</div>
<div className="whitespace-pre-wrap text-[14px] leading-relaxed text-zinc-200">
{message.content}
<div className="text-[14px] leading-relaxed text-zinc-200">
<MarkdownContent content={message.content} />
{message.pending && message.content && (
<span className="ml-0.5 inline-block h-[18px] w-[2px] animate-pulse rounded-sm bg-zinc-400 align-text-bottom" />
<span className="mt-1 inline-block h-4 w-[2px] animate-pulse rounded-sm bg-zinc-400" />
)}
</div>
{message.pending && !message.content && <ThinkingDots />}
+127
View File
@@ -0,0 +1,127 @@
import { useState, type ReactNode } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Check, Clipboard } from 'lucide-react';
function extractText(node: unknown): string {
if (!node || typeof node !== 'object') return '';
const n = node as Record<string, unknown>;
if (n.type === 'text') return String(n.value ?? '');
if (Array.isArray(n.children)) {
return (n.children as unknown[]).map(extractText).join('');
}
return '';
}
function CodeBlock({ language, children }: { language: string; children: string }) {
const [copied, setCopied] = useState(false);
function handleCopy() {
void navigator.clipboard.writeText(children);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
return (
<div className="group relative my-3 overflow-hidden rounded-lg border border-zinc-800 bg-[#0d0d10]">
<div className="flex items-center justify-between border-b border-zinc-800/80 px-4 py-1.5">
<span className="select-none text-[11px] text-zinc-500">
{language || 'text'}
</span>
<button
className="flex items-center gap-1 text-[11px] text-zinc-500 transition hover:text-zinc-300"
onClick={handleCopy}
type="button"
>
{copied ? (
<>
<Check className="size-3" />
Copied!
</>
) : (
<>
<Clipboard className="size-3" />
Copy
</>
)}
</button>
</div>
<pre className="overflow-x-auto p-4">
<code className="font-mono text-[13px] leading-relaxed text-zinc-300">
{children}
</code>
</pre>
</div>
);
}
const markdownComponents: Record<string, (props: Record<string, unknown>) => ReactNode> = {
pre({ children, node }: Record<string, unknown>) {
const codeChild = (node as Record<string, unknown[]>)?.children?.[0] as
| Record<string, unknown>
| undefined;
if (codeChild && (codeChild as Record<string, string>).tagName === 'code') {
const classNames = ((codeChild.properties as Record<string, string[]>)?.className ??
[]) as string[];
const hasLanguage = classNames.some((c) => c.startsWith('language-'));
if (!hasLanguage) {
const text = extractText(codeChild).replace(/\n$/, '');
return <CodeBlock language="">{text}</CodeBlock>;
}
}
return <>{children as ReactNode}</>;
},
code({ className, children, ...rest }: Record<string, unknown>) {
const match = /language-(\w+)/.exec(String(className ?? ''));
if (match) {
return (
<CodeBlock language={match[1]}>
{String(children).replace(/\n$/, '')}
</CodeBlock>
);
}
return (
<code
className="rounded bg-zinc-800 px-1.5 py-0.5 font-mono text-[0.875em] text-zinc-200"
{...rest}
>
{children as ReactNode}
</code>
);
},
a({ href, children }: Record<string, unknown>) {
return (
<a
className="text-indigo-400 underline underline-offset-2 transition hover:text-indigo-300"
href={String(href)}
rel="noopener noreferrer"
target="_blank"
>
{children as ReactNode}
</a>
);
},
};
interface MarkdownContentProps {
content: string;
}
export function MarkdownContent({ content }: MarkdownContentProps) {
return (
<div className="markdown-content">
<ReactMarkdown
components={markdownComponents}
remarkPlugins={[remarkGfm]}
>
{content}
</ReactMarkdown>
</div>
);
}
+105
View File
@@ -102,3 +102,108 @@ textarea {
min-height: 44px;
max-height: 200px;
}
/* Markdown prose styles for chat messages */
.markdown-content {
line-height: 1.7;
word-break: break-word;
}
.markdown-content > *:first-child {
margin-top: 0;
}
.markdown-content > *:last-child {
margin-bottom: 0;
}
.markdown-content h1,
.markdown-content h2,
.markdown-content h3,
.markdown-content h4,
.markdown-content h5,
.markdown-content h6 {
font-weight: 600;
color: #e4e4e7;
margin-top: 1.25em;
margin-bottom: 0.5em;
line-height: 1.4;
}
.markdown-content h1 { font-size: 1.25rem; }
.markdown-content h2 { font-size: 1.125rem; }
.markdown-content h3 { font-size: 1rem; }
.markdown-content p {
margin-top: 0.625em;
margin-bottom: 0.625em;
}
.markdown-content ul,
.markdown-content ol {
margin-top: 0.5em;
margin-bottom: 0.5em;
padding-left: 1.5em;
}
.markdown-content ul { list-style-type: disc; }
.markdown-content ol { list-style-type: decimal; }
.markdown-content li {
margin-top: 0.25em;
margin-bottom: 0.25em;
}
.markdown-content li > p {
margin-top: 0.25em;
margin-bottom: 0.25em;
}
.markdown-content blockquote {
border-left: 3px solid #3f3f46;
padding-left: 1em;
color: #a1a1aa;
margin-top: 0.75em;
margin-bottom: 0.75em;
}
.markdown-content table {
width: 100%;
border-collapse: collapse;
margin-top: 0.75em;
margin-bottom: 0.75em;
font-size: 0.875rem;
}
.markdown-content th,
.markdown-content td {
border: 1px solid #27272a;
padding: 0.5em 0.75em;
text-align: left;
}
.markdown-content th {
background: #18181b;
font-weight: 600;
color: #d4d4d8;
}
.markdown-content hr {
border: none;
border-top: 1px solid #27272a;
margin: 1.5em 0;
}
.markdown-content strong {
font-weight: 600;
color: #e4e4e7;
}
.markdown-content em {
font-style: italic;
}
.markdown-content img {
max-width: 100%;
border-radius: 0.5rem;
}