feat(web): add LaTeX math rendering support to ChatMarkdown - #7739
feat(web): add LaTeX math rendering support to ChatMarkdown#7739sugoidesune wants to merge 1 commit into
Conversation
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
| "dataInlineCode", | ||
| "className", | ||
| ], | ||
| span: [...(defaultSchema.attributes?.span ?? []), "className"], |
There was a problem hiding this comment.
🟠 High components/ChatMarkdown.tsx:205
Raw chat Markdown can preserve arbitrary className values on div and span, so <div class="fixed inset-0 z-50 bg-background"> can cover the application and spoof or block its UI. Because parseRawHtml enables these elements, replace unrestricted className access with an allowlist or pattern limited to the KaTeX-generated class names.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/ChatMarkdown.tsx around line 205:
Raw chat Markdown can preserve arbitrary `className` values on `div` and `span`, so `<div class="fixed inset-0 z-50 bg-background">` can cover the application and spoof or block its UI. Because `parseRawHtml` enables these elements, replace unrestricted `className` access with an allowlist or pattern limited to the KaTeX-generated class names.
There was a problem hiding this comment.
Reviewed the KaTeX/math rendering changes for chat-markdown rendering ownership and layout preservation. Four findings, all in the changed lines: the default single-dollar inline math delimiter changes how ordinary agent prose ($VAR, currency) renders, the raw-string delimiter normalization collides with markdown escapes and unterminated/alternate code fences, the sanitize allowlist now lets message content inject arbitrary classes into app-owned markdown DOM, and the imported KaTeX stylesheet is a different major than the KaTeX that renders the markup (plus no overflow containment for display math).
Posted via Macroscope — UI Consistency
| @import "tailwindcss"; | ||
| @import "katex/dist/katex.min.css"; |
There was a problem hiding this comment.
Two issues with this import:
- Stylesheet/renderer version mismatch.
apps/web/package.jsonpinskatex: ^0.18.4(this import resolves to 0.18.4's CSS), but the markup is produced byrehype-katex@7, which renders with its ownkatex@0.16.47perpnpm-lock.yaml. KaTeX requires the CSS to match the JS version that generated the HTML; two majors apart risks wrong metrics/fonts and ships a duplicate copy. Consider aligning the directkatexdependency with the versionrehype-katexresolves (or importing the CSS from that copy). - No overflow containment for display math. Every other wide generated-markdown block in this file is contained —
.chat-markdown preusesoverflow-x: auto, tables use the scroll-fade container — but.katex-displayhas none, so a long equation pushes past the fixed message column. Consider adding.chat-markdown .katex-display { overflow-x: auto; overflow-y: hidden; }next to the other chat-markdown rules.
Posted via Macroscope — UI Consistency
| const CHAT_MARKDOWN_REMARK_PLUGINS = [ | ||
| remarkGfm, | ||
| remarkMath, |
There was a problem hiding this comment.
remarkMath defaults to singleDollarTextMath: true, so any line containing two $ now parses as inline math. In a coding-agent chat that is common prose: export $HOME and $PATH first or it costs $5, not $10 will render as KaTeX italics instead of literal text, changing how existing messages display (bare $VAR outside backticks is frequent). Consider passing { singleDollarTextMath: false } to remarkMath in both plugin lists and requiring $$…$$ / \(…\) for inline math (the \(…\) normalization would then need to emit $$…$$). The new test only covers a single $ on the line, so it does not catch this case.
Posted via Macroscope — UI Consistency
| "className", | ||
| ], | ||
| span: [...(defaultSchema.attributes?.span ?? []), "className"], | ||
| div: [...(defaultSchema.attributes?.div ?? []), "className"], |
There was a problem hiding this comment.
Adding a bare "className" allows any class on code, span, and div, so with parseRawHtml message content can apply arbitrary Tailwind utilities inside .chat-markdown (e.g. <div class="fixed inset-0 z-50 bg-background">) and take over app-owned styling and layout; it also lets content-supplied language-* / chat-markdown-* classes reach extractFenceLanguage and the codeblock chrome. rehypeKatex runs after rehypeSanitize, so KaTeX's own output is never sanitized — only remark-math's wrapper classes need to survive. Consider restricting the additions to the value-list form (["className", "math", "math-inline", "math-display"]) instead of allowing every class.
Posted via Macroscope — UI Consistency
| export function normalizeMathDelimiters(text: string): string { | ||
| // Normalize LaTeX display \[ ... \] to $$ ... $$ and inline \( ... \) to $ ... $ | ||
| // without touching fenced code blocks (``` ... ``` or ` ... `) | ||
| const segments = text.split(/(```[\s\S]*?```|`[^`\n]*`)/g); | ||
| return segments | ||
| .map((segment, index) => { | ||
| // Odd indices are code fences or inline code | ||
| if (index % 2 === 1) return segment; | ||
| return segment | ||
| .replace(/\\\[([\s\S]*?)\\\]/g, (_, math) => `$$\n${math.trim()}\n$$`) | ||
| .replace(/\\\(([\s\S]*?)\\\)/g, (_, math) => `$${math.trim()}$`); | ||
| }) | ||
| .join(""); | ||
| } |
There was a problem hiding this comment.
Rewriting the raw source string has two behavior problems:
\[,\],\(,\)are also standard markdown escapes for literal brackets/parens, so existing content likesee \[1\]ormatch \(group\)now becomes math (or a red KaTeX parse error) instead of the literal characters it used to render.- The split only protects triple-backtick fences and single-backtick spans.
~~~fences, 4+ backtick fences, 4-space indented code, and — during streaming — a fence whose closing ``` has not arrived yet are all rewritten, so a code block visibly flips presentation mid-stream and settles back when the fence closes.
Consider doing this as a small remark plugin that visits only text nodes (then code, inlineCode, and html nodes are untouched by construction, regardless of fence style or streaming state) and requiring \[ to open a line before treating it as display math.
Posted via Macroscope — UI Consistency
ApprovabilityVerdict: Not approved Macroscope's review found this PR not approvable — This PR adds significant new LaTeX rendering capability with complex text preprocessing logic. Multiple unresolved review comments identify substantive issues: a High severity security concern allowing arbitrary className values to spoof UI, plus Medium severity issues with text/URL corruption. These findings warrant human review. Not approved because:
Adjust the Minimum Blocking Severity for this repo — including turning it Off — in Settings. You can add or adjust custom eligibility rules. Learn more. |
There was a problem hiding this comment.
Reviewed the LaTeX rendering path for markdown/UI consistency. The earlier concerns (single-$ math, source-string rewriting outside the AST, the broad className allowance, and the katex CSS/renderer version mismatch) all look addressed. Three remaining issues in the changed lines: two mangle the rendered equation for common LLM output shapes, and one leaves display math without the horizontal-overflow containment every other wide chat-markdown surface has.
Posted via Macroscope — UI Consistency
| const value = node.children | ||
| .map((child: Text) => originalNodeText(child, source) ?? child.value) | ||
| .join(""); | ||
| const standaloneDisplay = /^(?:\\\[|\[)\s*\n([\s\S]*?)\n\s*(?:\\\]|\])$/.exec(value); |
There was a problem hiding this comment.
Rebuilding the value from source offsets brings the container markers back with it. For > \[\n> E = mc^2\n> \] the paragraph's source span is \[\n> E = mc^2\n> \]: the standalone pattern cannot match (\s* can't cross > ), the \[...\] fallback wins, and the math value becomes > E = mc^2, so the equation renders with a stray > relation in front. The list-item case only works because its continuation prefix is whitespace, and the new blockquote test passes because it only asserts the katex-display class.
Smallest fix: strip the block-quote continuation prefix per line before matching, e.g. value.replace(/\n[ \t]*>[ \t]?/g, "\n") when the paragraph's ancestor is a blockquote, and assert the rendered math text (not just the class) in the blockquote test so it stays covered.
Separately, this pattern also accepts unescaped [ … ], so a plain bracketed block on its own lines (a pasted JSON array, for example) now renders as display math; requiring \[/\] here would keep non-math brackets rendering as prose.
Posted via Macroscope — UI Consistency
| // react-markdown doubles backslashes in the vfile source used by positions. | ||
| return source.slice(start, end).replace(/\\\\/g, "\\"); |
There was a problem hiding this comment.
This collapse rewrites every \\ in the sliced source, including LaTeX row separators, so \[ \begin{aligned} x &= 1 \\ y &= 2 \end{aligned} \] reaches KaTeX as ... x &= 1 \y &= 2 ... and renders as a red katex-error instead of the equation (matrices/aligned blocks are very common in model output).
The doubling being compensated for is not from react-markdown: the JSX fixtures pass delimiters as string attributes (text="... \\(E = mc^2\\)"), and JSX attribute literals do not process escape sequences, so those fixtures genuinely contain two backslashes. The array-based fixtures in the same test file ("> \\[\n...", i.e. real single backslashes) render fine without this replace. Suggest dropping it and changing the JSX fixtures to String.raw / {"..."} so they carry the same single backslashes real messages do.
| // react-markdown doubles backslashes in the vfile source used by positions. | |
| return source.slice(start, end).replace(/\\\\/g, "\\"); | |
| return source.slice(start, end); |
Posted via Macroscope — UI Consistency
There was a problem hiding this comment.
Two findings on the math rendering path. The KaTeX overflow point from the previous run (apps/web/src/index.css:2) is still open and is not repeated here.
Posted via Macroscope — UI Consistency
| const CHAT_MARKDOWN_REHYPE_PLUGINS = [ | ||
| rehypeRaw, | ||
| [rehypeSanitize, CHAT_MARKDOWN_SANITIZE_SCHEMA], | ||
| rehypeKatex, |
There was a problem hiding this comment.
KaTeX output breaks this surface's clipboard contract. .chat-markdown installs handleCopy → chatMarkdownClipboardPayload → serializeNode (apps/web/src/markdown-clipboard.ts), which unwraps unknown elements and skips aria-hidden="true" subtrees. For <span class="katex"> that drops the visual .katex-html and walks .katex-mathml, emitting both the MathML glyph text and the <annotation encoding="application/x-tex"> source, so copying The formula is \(E = mc^2\) yields The formula is E=mc2E = mc^2 instead of the formula.
Smallest fix that keeps ownership in the serializer: handle .katex explicitly there by emitting the annotation[encoding="application/x-tex"] text wrapped in \(…\) / $$…$$. Alternatively set data-markdown-copy on the rendered math (the serializer already honors that attribute, and it wins over the aria-hidden skip).
Posted via Macroscope — UI Consistency
| const value = node.children | ||
| .map((child: Text) => originalNodeText(child, source) ?? child.value) | ||
| .join(""); | ||
| const standaloneDisplay = /^(?:\\\\|\\)\[\s*\n([\s\S]*?)\n\s*(?:\\\\|\\)\]$/.exec(value); |
There was a problem hiding this comment.
The bare-[...] half of this pattern is fixed, but blockquote continuation markers still leak into the math value. For > \[\n> E = mc^2\n> \] the paragraph's source span is \[\n> E = mc^2\n> \]; \s* cannot cross > , so the standalone pattern fails, the \[...\] fallback below wins, and the math value becomes > E = mc^2 — KaTeX renders a stray > relation in front of the equation. The new blockquote test passes because it only asserts the katex-display class.
Smallest fix: strip the blockquote continuation prefix per line before matching (e.g. value.replace(/\n[ \t]*>[ \t]?/g, "\n") when the paragraph is inside a blockquote), and assert the rendered math text — not just the class — in the blockquote tests so it stays covered.
Posted via Macroscope — UI Consistency
There was a problem hiding this comment.
One rendering regression found in the new LaTeX transform: blockquote continuation markers (and character references) leak into the non-math text that surrounds inline/display math, because those text tokens are rebuilt from the raw source slice. Details inline.
The KaTeX CSS additions (.katex-display margin in the shared block-spacing rule, overflow container + scrollbar treatment mirroring .chat-markdown pre) are correctly scoped to the generated markdown surface and consistent with the existing .chat-markdown ownership, and the clipboard serializer now round-trips math, so no findings there.
Posted via Macroscope — UI Consistency
| const nodes: PhrasingContent[] = []; | ||
| for (const token of tokens) { | ||
| if (token.type === "text") { | ||
| const val = unescapeMarkdownText(token.raw); |
There was a problem hiding this comment.
Blockquote continuation markers leak into the prose around inline math. Because a text node containing \( is re-derived from the raw source, its > line prefixes come with it: for
> Text \(x\) and more
> next line
the text node spans Text \(x\) and more\n> next line, so the tokens are Text and and more\n> next line, and the rendered quote shows a literal > after the equation. cleanMathFormula strips markers only from the math value, and the new blockquote tests only assert the math side (katex-display, the annotation), so this slips through. The same applies to the text tokens in splitParagraphDisplay (line 420).
Smallest fix: thread inBlockquote into transformPhrasing (the paragraph/heading/tableCell cases already know it) and strip \n[ \t]*>[ \t]? from each text token's raw before unescapeMarkdownText — without trimming, so the spaces adjacent to the math survive.
Same raw-source path, worth handling together: character references are no longer decoded for these tokens, so Costs & \(x\) renders the literal & next to the equation.
Posted via Macroscope — UI Consistency
|
|
||
| function unescapeMarkdownText(raw: string): string { | ||
| const unescapedBackslashes = raw.replace(/\\([!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~])/g, "$1"); | ||
| return decodeCharacterReferences(unescapedBackslashes); |
There was a problem hiding this comment.
🟡 Medium components/ChatMarkdown.tsx:294
When display math triggers reconstruction, unescapeMarkdownText changes adjacent text: α remains literal instead of becoming α, and escaped \& becomes & instead of remaining &. The hand-written entity table omits valid named references, while decoding after removing backslash escapes incorrectly treats escaped references as Markdown entities; reuse the parser’s already-decoded text or a Markdown-compliant decoder that preserves escaped references.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/ChatMarkdown.tsx around line 294:
When display math triggers reconstruction, `unescapeMarkdownText` changes adjacent text: `α` remains literal instead of becoming `α`, and escaped `\&` becomes `&` instead of remaining `&`. The hand-written entity table omits valid named references, while decoding after removing backslash escapes incorrectly treats escaped references as Markdown entities; reuse the parser’s already-decoded text or a Markdown-compliant decoder that preserves escaped references.
Support inline LaTeX \(...\) and block display LaTeX \[...\] / 79210...79210 delimiters in ChatMarkdown without mutating code blocks or ambient prose. Includes clean markdown math preprocessing, KaTeX styling and overflow containment, and markdown clipboard copy serialization.
| ]); | ||
| /* eslint-enable react/no-unstable-nested-components */ | ||
|
|
||
| const preprocessedText = useMemo(() => preprocessLatexDelimiters(text), [text]); |
There was a problem hiding this comment.
🟡 Medium components/ChatMarkdown.tsx:1931
preprocessLatexDelimiters(text) rewrites $ inside Markdown link destinations, so [docs](https://example.test/search?q=$term) renders an anchor with a different URL (for example, an encoded backslash) and navigation breaks. Apply the delimiter preprocessing only to text content, or otherwise preserve link destinations while transforming math delimiters.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/ChatMarkdown.tsx around line 1931:
`preprocessLatexDelimiters(text)` rewrites `$` inside Markdown link destinations, so `[docs](https://example.test/search?q=$term)` renders an anchor with a different URL (for example, an encoded backslash) and navigation breaks. Apply the delimiter preprocessing only to text content, or otherwise preserve link destinations while transforming math delimiters.
There was a problem hiding this comment.
Reviewed the LaTeX rendering changes for UI/layout consistency in apps/web/src. Three findings, all in changed lines: mid-line \[…\] promotion breaks the inline markup and list structure it sits inside, the new .katex-display scroll container clips the left half of over-wide equations, and blanket $ escaping corrupts indented code blocks. Two of the new tests that look like they cover these cases are vacuous because of JSX/JS backslash escaping.
Posted via Macroscope — UI Consistency
| // Escape unescaped $ that is not math delimiter so remark-math does not parse currency/variables | ||
| if (raw[i] === "$" && !isEscaped) { | ||
| out += "\\$"; |
There was a problem hiding this comment.
codeRegex protects fences and inline spans but not 4-space indented code blocks, and every unescaped $ in the unprotected text becomes \$, so an indented code sample renders with visible backslashes: echo $HOME displays as echo \$HOME (and \(…\) in such a block becomes real math).
The it.each case that looks like it covers this is vacuous: " const x = \\\\(notMath\\\\);" is a JS string literal, so the component receives two backslashes and the delimiter is skipped as escaped regardless of the code context.
Consider having preprocessLatexDelimiters also skip indented code blocks (4-space indent at a block boundary, not list-item continuation) before escaping $.
Posted via Macroscope — UI Consistency
| const lineStart = Math.max(0, raw.lastIndexOf("\n", i) + 1); | ||
| const linePrefix = raw.slice(lineStart, i); | ||
| const isBlockquote = /^[ \t]*>[ \t]?/.test(linePrefix); | ||
|
|
||
| if (isBlockquote) { | ||
| const cleanedFormula = formula.replace(/\n[ \t]*>[ \t]?/g, "\n").trim(); | ||
| const blockLines = cleanedFormula | ||
| .split("\n") | ||
| .map((line) => `> ${line}`) | ||
| .join("\n"); | ||
| out += `\n>\n> $$\n${blockLines}\n> $$\n>\n`; | ||
| } else { | ||
| out += "\n\n$$\n" + formula.trim() + "\n$$\n\n"; |
There was a problem hiding this comment.
Mid-line \[…\] is unconditionally promoted to a block, which breaks whatever inline markup it sits inside.
**Result: \[x^2\]**is emitted as**Result: \n\n$$\nx^2\n$$\n\n**, so the strong never closes and the message shows a literal**.[Formula \[x^2\]](https://example.com)loses the link entirely and renders](https://example.com)as text; a table row is split the same way.- Inside a list item the block is emitted at column 0 (
- Item:\n \[\n x = 1\n \]→ blank line +$$at column 0), which terminates the list: the equation is no longer indented under the item and following items begin a new list.
The new test meant to cover the first case is vacuous — JSX attribute strings do not process escapes, so text="**Result: \\[x^2\\]**" reaches the component with two backslashes and is treated as escaped, never entering the math path (use String.raw in a template expression, as the other tests do).
Smallest fix: promote to a $$ block only when the \[ owns its line (whitespace or blockquote prefix before it, nothing but whitespace after the closing \]), reusing that line's leading indentation for the emitted block lines; otherwise keep the math inline.
Posted via Macroscope — UI Consistency
| .chat-markdown .katex-display { | ||
| max-width: 100%; | ||
| overflow-x: auto; | ||
| overflow-y: hidden; | ||
| scrollbar-width: thin; | ||
| scrollbar-color: color-mix(in srgb, var(--border) 78%, transparent) transparent; |
There was a problem hiding this comment.
KaTeX sets text-align: center on both .katex-display and .katex-display > .katex (with white-space: nowrap), and inline-start overflow is not part of the scrollable overflow region, so an equation wider than the container is centered and its left portion is permanently unreachable — the new scrollbar only exposes the right side. .chat-markdown pre avoids this because its content is start-aligned.
Smallest fix: start-align the scroll content, e.g. add text-align: left here plus .chat-markdown .katex-display > .katex { text-align: left; }, or center with display: flex; justify-content: safe center; on the container so narrow equations stay centered while wide ones scroll from their left edge.
Posted via Macroscope — UI Consistency
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 1841ccc. Configure here.
| } | ||
|
|
||
| result += transformLatexDelimitersInText(text.slice(lastIndex)); | ||
| return result; |
There was a problem hiding this comment.
Indented code still becomes math
Low Severity
preprocessLatexDelimiters only skips fenced (``` / ~~~) and inline backtick spans. Four-space indented code blocks are still run through transformLatexDelimitersInText, so literal `(...)` or `[...]` inside indented code can be rewritten into `$` / `$$` math before remark parses the fence.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 1841ccc. Configure here.


Summary
Adds LaTeX math rendering support to
ChatMarkdownin@t3tools/webusing KaTeX and remark-math.$...$and$$...$$) viaremark-mathand renders viarehype-katex.\[ ... \]and\( ... \)) into Markdown math blocks/spans while preserving code fences and inline backticks.rehype-sanitizeschema to preserve math attributes and classes without stripping KaTeX markup.index.css.Note
Medium Risk
Touches the chat markdown sanitize/rehype pipeline and renders KaTeX HTML from untrusted message text. Schema allowlists extra math class names; delimiter preprocessing is custom and easy to get wrong.
Overview
Chat messages can now show inline and display math.
ChatMarkdownpreprocesses LLM-style\(...\)/\[...\]into$/$$(skipping code), thenremark-math+rehype-katexrender KaTeX. Bare$is escaped so currency and shell vars are not treated as math.KaTeX still runs when raw HTML parsing is off. Sanitize allowlists math
codeclasses. Copy serializes KaTeX back to\(...\)or$$...$$. Display blocks get KaTeX CSS and horizontal scroll.Broad tests cover nested markdown, blockquotes, fences, escapes, and false positives.
Reviewed by Cursor Bugbot for commit 1841ccc. Bugbot is set up for automated code reviews on this repo. Configure here.
Note
Add LaTeX math rendering to
ChatMarkdownpreprocessLatexDelimitersin ChatMarkdown.tsx normalizes\(...\)and\[...\]delimiters to$...$and$$...$$, and escapes stray$outside math.remark-mathandrehype-katexplugins render math to KaTeX HTML, with styles and horizontal scrolling added to index.css.serializeKatexin markdown-clipboard.ts.$in non-math text outside code spans will be backslash-escaped bypreprocessLatexDelimiters, altering output for existing messages containing stray dollar signs.Macroscope summarized 1841ccc.