fix(web): render LaTeX in chat messages - #9838
Conversation
Render inline and display math with KaTeX while preserving code spans, fenced code blocks, and Markdown source offsets. Built with GPT Soul.
| continue; | ||
| } | ||
|
|
||
| if (inlineCodeTicks === 0 && character === "\\" && !isEscaped(line, index)) { |
There was a problem hiding this comment.
🟡 Medium src/markdown-latex.ts:64
normalizeLatexDelimiters changes literal TeX delimiters inside authored raw HTML code elements, so <pre>\(example\)</pre> becomes <pre>$$example$$</pre> and the displayed code no longer matches the message source. Because line 64 scans raw HTML before rehypeRaw parses it, exclude the contents of <pre>/<code> raw HTML elements from normalization (while preserving the existing Markdown code handling).
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/markdown-latex.ts around line 64:
`normalizeLatexDelimiters` changes literal TeX delimiters inside authored raw HTML code elements, so `<pre>\(example\)</pre>` becomes `<pre>$$example$$</pre>` and the displayed code no longer matches the message source. Because line 64 scans raw HTML before `rehypeRaw` parses it, exclude the contents of `<pre>`/`<code>` raw HTML elements from normalization (while preserving the existing Markdown code handling).
| if (character === "`" && !isEscaped(line, index)) { | ||
| let runLength = 1; | ||
| while (line[index + runLength] === "`") runLength += 1; | ||
| if (inlineCodeTicks === 0) inlineCodeTicks = runLength; |
There was a problem hiding this comment.
🟡 Medium src/markdown-latex.ts:57
An unmatched backtick sets inlineCodeTicks at line 57 and leaves it enabled for the rest of the message, so later delimiters such as \(x\) remain unchanged and are not rendered as math. CommonMark only creates a code span when an equal-length closing run exists; track unmatched backticks without suppressing subsequent lines, or reset the state when no matching closer exists.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/markdown-latex.ts around line 57:
An unmatched backtick sets `inlineCodeTicks` at line 57 and leaves it enabled for the rest of the message, so later delimiters such as `\(x\)` remain unchanged and are not rendered as math. CommonMark only creates a code span when an equal-length closing run exists; track unmatched backticks without suppressing subsequent lines, or reset the state when no matching closer exists.
| let inlineCodeTicks = 0; | ||
|
|
||
| return markdown | ||
| .split(/(\r?\n)/) |
There was a problem hiding this comment.
🟡 Medium src/markdown-latex.ts:32
CR-only input is treated as a single line, so fenced code is never recognized and \(example\) inside the fence is rewritten to $$example$$. Include lone \r as a line separator so fence tracking applies to Markdown messages using CR line endings.
- .split(/(\r?\n)/)
+ .split(/(\r\n|\n|\r)/)🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/markdown-latex.ts around line 32:
CR-only input is treated as a single line, so fenced code is never recognized and `\(example\)` inside the fence is rewritten to `$$example$$`. Include lone `\r` as a line separator so fence tracking applies to Markdown messages using CR line endings.
| const match = /^ {0,3}(`{3,}|~{3,})/.exec(line); | ||
| const run = match?.[1]; | ||
| if (!run) return null; | ||
| return { marker: run[0] as "`" | "~", length: run.length }; |
There was a problem hiding this comment.
🟡 Medium src/markdown-latex.ts:12
startingFence treats a backtick fence with backticks in its info string as valid, so a line such as `````text `` enters fence mode and leaves subsequent valid(...)` text unnormalized until a closing-looking fence appears. Validate the info string and reject backticks for backtick fences.
| const match = /^ {0,3}(`{3,}|~{3,})/.exec(line); | |
| const run = match?.[1]; | |
| if (!run) return null; | |
| return { marker: run[0] as "`" | "~", length: run.length }; | |
| const match = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line); | |
| const run = match?.[1]; | |
| if (!run || (run[0] === "`" && match[2].includes("`"))) return null; | |
| return { marker: run[0] as "`" | "~", length: run.length }; |
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/markdown-latex.ts around lines 12-15:
`startingFence` treats a backtick fence with backticks in its info string as valid, so a line such as [code fence]``text ` `` enters fence mode and leaves subsequent valid `\(...\)` text unnormalized until a closing-looking fence appears. Validate the info string and reject backticks for backtick fences.
| const delimiter = line[index + 1]; | ||
| if (delimiter === "(" || delimiter === ")" || delimiter === "[" || delimiter === "]") { | ||
| normalized += "$$"; | ||
| index += 1; |
There was a problem hiding this comment.
🟡 Medium src/markdown-latex.ts:68
A same-line \[...\] expression is returned as $$...$$ inside the surrounding paragraph, so remark-math parses it as math-inline and renders it inline instead of as a display equation. Add paragraph boundaries around the [ and ] delimiters so the converted expression is parsed as block math.
- normalized += "$$";
+ normalized += delimiter === "[" ? "\n\n$$" : delimiter === "]" ? "$$\n\n" : "$$";🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/markdown-latex.ts around line 68:
A same-line `\[...\]` expression is returned as `$$...$$` inside the surrounding paragraph, so `remark-math` parses it as `math-inline` and renders it inline instead of as a display equation. Add paragraph boundaries around the `[` and `]` delimiters so the converted expression is parsed as block math.
| continue; | ||
| } | ||
|
|
||
| if (inlineCodeTicks === 0 && character === "\\" && !isEscaped(line, index)) { |
There was a problem hiding this comment.
🟡 Medium src/markdown-latex.ts:64
The normalizer rewrites \(example\) inside block quotes and list-nested indented code to $$example$$, changing code that must remain literal. startingFence and the indented-code guard only inspect the physical line start, so container prefixes prevent fence and code-block detection; please account for Markdown container prefixes before normalizing delimiters.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/markdown-latex.ts around line 64:
The normalizer rewrites `\(example\)` inside block quotes and list-nested indented code to `$$example$$`, changing code that must remain literal. `startingFence` and the indented-code guard only inspect the physical line start, so container prefixes prevent `fence` and code-block detection; please account for Markdown container prefixes before normalizing delimiters.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Want higher recall? High effort reviews run extra passes and find more bugs. A team admin can switch effort levels in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 87d0562. Configure here.
|
|
||
| const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ | ||
| remarkGfm, | ||
| remarkMath, |
There was a problem hiding this comment.
Skill tokens collide with math dollars
Medium Severity
remark-math treats paired $...$ as inline math, which collides with chat $skill tokens. Two skills in one paragraph, such as $browser and $deploy, are consumed as one math span, so skill chips never render. Escaping the dollars avoids math but also prevents SkillInlineText from matching the tokens.
Reviewed by Cursor Bugbot for commit 87d0562. Configure here.
| rehypeRaw, | ||
| rehypePreserveImageSourceMeta, | ||
| [rehypeSanitize, CHAT_MARKDOWN_SANITIZE_SCHEMA], | ||
| rehypeKatex, |
There was a problem hiding this comment.
Copied math becomes garbled text
Medium Severity
KaTeX emits MathML plus an aria-hidden HTML span. Selection copy already skips aria-hidden nodes and then concatenates MathML character nodes with the TeX annotation, so highlight-and-copy of a rendered equation pastes duplicated, unreadable text instead of the LaTeX source or a single readable form.
Reviewed by Cursor Bugbot for commit 87d0562. Configure here.
ApprovabilityVerdict: Not approved Macroscope's review found this PR not approvable — This PR adds default-on LaTeX parsing and KaTeX rendering to the shared production Markdown renderer, changing how existing chat content is processed and copied. Unresolved findings identify regressions involving skill tokens, clipboard output, and several code/Markdown delimiter edge cases. 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. |


What Changed
remark-math,rehype-katex, and KaTeX styles.$...$,$$...$$,\(...\), and\[...\].Why
Assistant responses routinely emit LaTeX for probability, optimization, matrix operations, and scientific notation. T3 Code currently displays that source literally, making technical responses harder to read and forcing users to copy them into another renderer.
This is a focused implementation of the use case documented in Ideas discussion #9641. It runs KaTeX after the existing raw-HTML sanitizer, so enabling math does not broaden the accepted raw HTML surface.
UI Changes
Before — LaTeX commands and delimiters are displayed as ordinary text:
After — the same class of inline and display expressions render as formatted mathematics:
The after state was also verified from macOS against the Linux-hosted development server over an SSH tunnel, covering a long CRPS response with fractions, sums, subscripts,
\operatorname, hats, boxes, and display equations.Surfaces
ChatMarkdown: supported.Verification
vp test run --passWithNoTests --project unit src/markdown-latex.test.ts src/components/ChatMarkdown.test.tsx— 56 tests passed.pnpm --dir apps/web typecheck— passed.ChatMarkdown.tsx.pnpm --dir apps/web build— passed (4,883 modules transformed).Related Work
Those earlier pull requests were closed as part of an upstream product decision/backlog cleanup, not because LaTeX had begun rendering. This PR keeps that history visible while providing a current, rebased, independently verified implementation for maintainers to evaluate.
Checklist
Built with GPT Soul.
Model: GPT-5.6-Sol
Harness: Codex in T3 Code
Note
Render LaTeX math in chat messages with KaTeX
remark-mathandrehype-katexto theChatMarkdownReactMarkdown pipeline so inline and display math render as KaTeX HTML and accessible MathMLnormalizeLatexDelimitersin markdown-latex.ts to convert unescaped\(..\)and\[..\]delimiters to dollar delimiters before parsing, while leaving code spans, fenced code, indented code, and escaped delimiters untouchednormalizeLatexDelimitersrewrites all unescaped\(,\),\[, and\]sequences outside code regions; non-math content using those character pairs will be interpreted as math delimiters📊 Macroscope summarized 87d0562. 5 files reviewed, 7 issues evaluated, 0 issues filtered, 6 comments posted
🗂️ Filtered Issues