underwaterfront / src /components /Chat /MarkdownContent.tsx
onewayto's picture
Upload 36 files
1a12d36 verified
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<Theme>;
/** When true, shows a blinking cursor and throttles renders. */
isStreaming?: boolean;
}
/** Shared markdown styles β€” adapts to light/dark via CSS variables. */
const markdownSx: SxProps<Theme> = {
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<ReturnType<typeof setTimeout> | 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 (
<Box sx={[markdownSx, ...(Array.isArray(sx) ? sx : sx ? [sx] : [])]}>
<ReactMarkdown remarkPlugins={remarkPlugins}>{displayContent}</ReactMarkdown>
</Box>
);
}