Skip to content

fix(web): render LaTeX in chat messages - #9838

Open
dkritarth wants to merge 1 commit into
pingdotgg:mainfrom
dkritarth:fix/web-latex-rendering
Open

fix(web): render LaTeX in chat messages#9838
dkritarth wants to merge 1 commit into
pingdotgg:mainfrom
dkritarth:fix/web-latex-rendering

Conversation

@dkritarth

@dkritarth dkritarth commented Sep 4, 2026

Copy link
Copy Markdown

What Changed

  • Render inline and display LaTeX in the shared web chat renderer with remark-math, rehype-katex, and KaTeX styles.
  • Support the delimiters current models commonly emit: $...$, $$...$$, \(...\), and \[...\].
  • Normalize backslash delimiters without changing source length, preserving task-list source offsets.
  • Leave inline code, fenced code blocks, indented code blocks, and explicitly escaped delimiters unchanged.
  • Keep wide display equations usable with horizontal overflow and document the web/desktop behavior.

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:

T3 Code displaying raw LaTeX before the fix

After — the same class of inline and display expressions render as formatted mathematics:

T3 Code rendering LaTeX equations after the fix

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

  • Web: supported.
  • Desktop: supported through the shared web renderer.
  • Rendered Markdown file previews that use ChatMarkdown: supported.
  • Mobile: unchanged; it continues to display the source text.
  • Providers: provider-independent because rendering occurs in the shared client.
  • Local and remote connections: identical client-side behavior; the after state was verified remotely.

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.
  • Targeted lint for the changed TypeScript files — passed with two pre-existing warnings in ChatMarkdown.tsx.
  • Targeted formatting check — passed.
  • pnpm --dir apps/web build — passed (4,883 modules transformed).
  • Manual macOS verification over an SSH tunnel to the Linux development server — passed.

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

  • This PR is small and focused.
  • I explained what changed and why.
  • I included before/after UI evidence.
  • No animation or interaction changed, so a video is not applicable.

Built with GPT Soul.

Model: GPT-5.6-Sol
Harness: Codex in T3 Code

Note

Render LaTeX math in chat messages with KaTeX

  • Adds remark-math and rehype-katex to the ChatMarkdown ReactMarkdown pipeline so inline and display math render as KaTeX HTML and accessible MathML
  • Adds normalizeLatexDelimiters in markdown-latex.ts to convert unescaped \(..\) and \[..\] delimiters to dollar delimiters before parsing, while leaving code spans, fenced code, indented code, and escaped delimiters untouched
  • Imports the KaTeX stylesheet globally in main.tsx and adds block spacing plus horizontal scroll for wide display equations in index.css
  • Risk: normalizeLatexDelimiters rewrites 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

Render inline and display math with KaTeX while preserving code spans, fenced code blocks, and Markdown source offsets.

Built with GPT Soul.
@github-actions github-actions Bot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:L 100-499 changed lines (additions + deletions). labels Sep 4, 2026
continue;
}

if (inlineCodeTicks === 0 && character === "\\" && !isEscaped(line, index)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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)/)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Comment on lines +12 to +15
const match = /^ {0,3}(`{3,}|~{3,})/.exec(line);
const run = match?.[1];
if (!run) return null;
return { marker: run[0] as "`" | "~", length: run.length };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Suggested change
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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.

Fix All in Cursor

❌ 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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 87d0562. Configure here.

rehypeRaw,
rehypePreserveImageSourceMeta,
[rehypeSanitize, CHAT_MARKDOWN_SANITIZE_SCHEMA],
rehypeKatex,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 87d0562. Configure here.

@macroscopeapp

macroscopeapp Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: 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:

  • 6 blocking correctness issues found at or above your repo's Minimum Blocking Severity

Adjust the Minimum Blocking Severity for this repo — including turning it Off — in Settings. You can add or adjust custom eligibility rules. Learn more.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L 100-499 changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant