import { useMemo, useRef, useState, useEffect } from 'react'; import { Box } from '@mui/material'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import type { SxProps, Theme } from '@mui/material/styles'; interface MarkdownContentProps { content: string; sx?: SxProps; /** When true, shows a blinking cursor and throttles renders. */ isStreaming?: boolean; } /** Shared markdown styles — adapts to light/dark via CSS variables. */ const markdownSx: SxProps = { fontSize: '0.925rem', lineHeight: 1.7, color: 'var(--text)', wordBreak: 'break-word', '& p': { m: 0, mb: 1.5, '&:last-child': { mb: 0 } }, '& h1, & h2, & h3, & h4': { mt: 2.5, mb: 1, fontWeight: 600, lineHeight: 1.3 }, '& h1': { fontSize: '1.35rem' }, '& h2': { fontSize: '1.15rem' }, '& h3': { fontSize: '1.05rem' }, '& pre': { bgcolor: 'var(--code-bg)', p: 2, borderRadius: 2, overflow: 'auto', fontSize: '0.82rem', lineHeight: 1.6, border: '1px solid var(--tool-border)', my: 2, }, '& code': { bgcolor: 'var(--hover-bg)', px: 0.75, py: 0.25, borderRadius: 0.5, fontSize: '0.84rem', fontFamily: '"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, monospace', }, '& pre code': { bgcolor: 'transparent', p: 0 }, '& a': { color: 'var(--accent-yellow)', textDecoration: 'none', fontWeight: 500, '&:hover': { textDecoration: 'underline' }, }, '& ul, & ol': { pl: 3, my: 1 }, '& li': { mb: 0.5 }, '& li::marker': { color: 'var(--muted-text)' }, '& blockquote': { borderLeft: '3px solid var(--accent-yellow)', pl: 2, ml: 0, my: 1.5, color: 'var(--muted-text)', fontStyle: 'italic', }, '& table': { borderCollapse: 'collapse', width: '100%', my: 2, fontSize: '0.85rem', }, '& th': { borderBottom: '2px solid var(--border-hover)', textAlign: 'left', p: 1, fontWeight: 600, }, '& td': { borderBottom: '1px solid var(--tool-border)', p: 1, }, '& hr': { border: 'none', borderTop: '1px solid var(--border)', my: 2, }, '& img': { maxWidth: '100%', borderRadius: 2, }, }; /** * Throttled content for streaming: render the full markdown through * ReactMarkdown but only re-parse every ~80ms to avoid layout thrashing. * This is the Claude approach — always render as markdown, never split * into raw text. The parser handles incomplete tables gracefully. */ function useThrottledValue(value: string, isStreaming: boolean, intervalMs = 80): string { const [throttled, setThrottled] = useState(value); const lastUpdate = useRef(0); const pending = useRef | null>(null); const latestValue = useRef(value); latestValue.current = value; useEffect(() => { if (!isStreaming) { // Not streaming — always use latest value immediately setThrottled(value); return; } const now = Date.now(); const elapsed = now - lastUpdate.current; if (elapsed >= intervalMs) { // Enough time passed — update immediately setThrottled(value); lastUpdate.current = now; } else { // Schedule an update for the remaining time if (pending.current) clearTimeout(pending.current); pending.current = setTimeout(() => { setThrottled(latestValue.current); lastUpdate.current = Date.now(); pending.current = null; }, intervalMs - elapsed); } return () => { if (pending.current) clearTimeout(pending.current); }; }, [value, isStreaming, intervalMs]); // When streaming ends, flush immediately useEffect(() => { if (!isStreaming) { setThrottled(latestValue.current); } }, [isStreaming]); return throttled; } export default function MarkdownContent({ content, sx, isStreaming = false }: MarkdownContentProps) { // Throttle re-parses during streaming to ~12fps (every 80ms) const displayContent = useThrottledValue(content, isStreaming); const remarkPlugins = useMemo(() => [remarkGfm], []); return ( {displayContent} ); }