diff --git a/apps/web/package.json b/apps/web/package.json
index 598feaec0ce9..9106dba094cf 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -37,15 +37,18 @@
"jose": "catalog:",
"jsonc-parser": "3.3.1",
"jszip": "3.10.1",
+ "katex": "^0.16.47",
"lexical": "^0.41.0",
"lucide-react": "^0.564.0",
"react": "19.2.6",
"react-dom": "19.2.6",
"react-markdown": "^10.1.0",
+ "rehype-katex": "^7.0.1",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
+ "remark-math": "^6.0.0",
"tailwind-merge": "^3.4.0",
"zustand": "^5.0.11"
},
@@ -58,14 +61,18 @@
"@types/babel__core": "^7.20.5",
"@types/compression": "^1.8.1",
"@types/culori": "^4.0.1",
+ "@types/hast": "^3.0.4",
+ "@types/mdast": "^4.0.4",
"@types/react": "~19.2.14",
"@types/react-dom": "~19.2.3",
"@vercel/config": "^0.3.0",
"@vitejs/plugin-react": "^6.0.0",
"babel-plugin-react-compiler": "1.0.0",
"compression": "^1.8.1",
+ "mdast-util-math": "^3.0.0",
"msw": "2.12.11",
"tailwindcss": "^4.0.0",
+ "unified": "^11.0.5",
"vite": "catalog:",
"vite-plus": "catalog:"
}
diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx
index 9499ee5a6915..8b175d35d991 100644
--- a/apps/web/src/components/ChatMarkdown.test.tsx
+++ b/apps/web/src/components/ChatMarkdown.test.tsx
@@ -1,6 +1,7 @@
+import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vite-plus/test";
-import { orderedListGutterStyle } from "./ChatMarkdown";
+import ChatMarkdown, { orderedListGutterStyle } from "./ChatMarkdown";
describe("orderedListGutterStyle", () => {
it("leaves the default gutter alone for single-digit lists", () => {
@@ -34,3 +35,528 @@ describe("orderedListGutterStyle", () => {
expect(orderedListGutterStyle(0, undefined)).toBeUndefined();
});
});
+
+describe("LaTeX / Math rendering in ChatMarkdown", () => {
+ it("renders inline math with KaTeX markup", () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain("katex");
+ expect(html).toContain("katex-html");
+ expect(html).toContain("E");
+ });
+
+ it("renders repeated inline LaTeX delimiters in prose", () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain("katex");
+ expect(html.match(/class="katex"/g)).toHaveLength(5);
+ expect(html).toContain("i=\\sqrt{-1}");
+ expect(html).toContain("i^2=-1");
+ });
+
+ it("renders block display math with KaTeX markup", () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain("katex-display");
+ expect(html).toContain("katex-html");
+ });
+
+ it("renders math when sanitized raw HTML parsing is enabled", () => {
+ const html = renderToStaticMarkup(
+ Formula: \(x^2\)`}
+ cwd={undefined}
+ parseRawHtml
+ />,
+ );
+ expect(html).toContain("katex-html");
+ });
+
+ it("renders LaTeX bracket notations \\[ ... \\] and \\( ... \\)", () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain("katex-display");
+ expect(html).toContain("katex-html");
+ expect(html).toContain("1+i");
+ expect(html).toContain("2-i");
+ });
+
+ it("does not mutate brackets inside code blocks", () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain("\\[ \\frac{a}{b} \\]");
+ });
+
+ it.each([
+ "~~~ts\nconst x = \\\\(notMath\\\\);\n~~~",
+ " const x = \\\\(notMath\\\\);",
+ "Use ``\\(notMath\\)`` here.",
+ ])("does not mutate math delimiters in another code form", (text) => {
+ const html = renderToStaticMarkup();
+ expect(html).toContain("notMath");
+ expect(html).not.toContain("katex");
+ });
+
+ it("leaves paired currency amounts intact without triggering math rendering", () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain("$5 today and $10 tomorrow.");
+ expect(html).not.toContain("katex");
+ });
+
+ it.each([
+ ["blockquotes", "> Quote: \\(E = mc^2\\)", "katex"],
+ ["display math in blockquotes", "> \\[\n> E = mc^2\n> \\]", "katex-display"],
+ ["list items", "- Item with \\(x + y\\)", "katex"],
+ ["display math in list items", "- Item:\n \\[\n x = 1\n \\]", "katex-display"],
+ ["emphasis", "**Bold \\(x^2\\)** and *italic \\(y^2\\)*", "katex"],
+ ["links", "[Formula \\(x^2\\)](https://example.com)", "katex"],
+ ["tables", "| Formula |\n| --- |\n| \\(x^2 + y^2\\) |", "katex"],
+ ["footnotes", "Formula[^1]\n\n[^1]: Here is \\(x^2\\)", "katex"],
+ ])("renders math nested inside %s", (_container, markdown, expectedClass) => {
+ const html = renderToStaticMarkup();
+ expect(html).toContain(expectedClass);
+ expect(html).toContain("katex-html");
+ });
+
+ it("renders multiline aligned equations and cases with row breaks in display math", () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain("katex-display");
+ expect(html).toContain("aligned");
+ expect(html).not.toContain("KaTeX parse error");
+ });
+
+ it("handles formulas with nested parentheses and brackets without delimiter truncation", () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain("katex-html");
+ expect(html).toContain("katex-display");
+ expect(html).not.toContain("KaTeX parse error");
+ });
+
+ it("renders formulas containing markdown-sensitive characters like underscores and asterisks", () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain("katex-html");
+ expect(html.match(/class="katex"/g)).toHaveLength(2);
+ expect(html).not.toContain("KaTeX parse error");
+ });
+
+ it("renders multiple display equations interspersed with text in a single block", () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ const displayMatches = html.match(/class="katex-display"/g);
+ expect(displayMatches).toHaveLength(2);
+ expect(html).toContain("First equation:");
+ expect(html).toContain("and second equation:");
+ expect(html).toContain("with conclusion.");
+ });
+
+ it("leaves unclosed delimiters as literal text without crashing or rendering invalid math", () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain("This has an unclosed delimiter ( x^2 + y^2 and continues as text.");
+ expect(html).not.toContain("katex-error");
+ });
+
+ it("does not treat plain bracketed text or array indexing as math", () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain("arr[i]");
+ expect(html).toContain("TODO: do not convert this plain bracketed text");
+ expect(html).not.toContain("katex");
+ });
+
+ it("handles empty math delimiters gracefully", () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).not.toContain("KaTeX parse error");
+ });
+
+ it("renders complex nested structures with both inline and display math in blockquotes and lists", () => {
+ const html = renderToStaticMarkup(
+ Quote starting with inline \(a=1\):
+> \[
+> \begin{pmatrix} 1 & 0 \\ 0 & 1 \end{pmatrix}
+> \]
+> and ending with \(b=2\).
+
+1. List item 1 with \(x_i\):
+ \[
+ y_i = x_i^2 + 1
+ \]
+2. List item 2 with \(\sum_{k=1}^n k\)`}
+ cwd={undefined}
+ />,
+ );
+ expect(html).toContain("katex-display");
+ expect(html).toContain("katex-html");
+ expect(html).not.toContain("KaTeX parse error");
+ });
+
+ it("renders complex calculus, limits, summations, and vectors without errors", () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain("katex-display");
+ expect(html.match(/class="katex"/g)?.length).toBeGreaterThanOrEqual(4);
+ expect(html).not.toContain("KaTeX parse error");
+ });
+
+ it("leaves standard backslashes in prose untouched without false math triggering", () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain("C:\\Users\\user\\Desktop\\file.txt");
+ expect(html).toContain("\\n, \\t, \\r");
+ expect(html).toContain("\\d+ and \\w+");
+ expect(html).not.toContain("katex");
+ });
+
+ it("correctly handles interleaved inline code and math on the same line", () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain("x = 2");
+ expect(html).toContain("data-inline-code");
+ expect(html).toContain("katex-html");
+ expect(html).toContain("f(x)");
+ });
+
+ it("renders math inside GFM task list items", () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain('type="checkbox"');
+ expect(html).toContain("katex-html");
+ expect(html).toContain("katex-display");
+ expect(html).not.toContain("KaTeX parse error");
+ });
+
+ it("strips blockquote continuation markers from display math without rendering stray > relation", () => {
+ const html = renderToStaticMarkup(
+ \\[
+> E = mc^2
+> \\]`}
+ cwd={undefined}
+ />,
+ );
+ expect(html).toContain("katex-display");
+ expect(html).toContain('E = mc^2');
+ expect(html).not.toContain(">");
+ expect(html).not.toContain("KaTeX parse error");
+ });
+
+ it("renders display math inside mixed-phrasing paragraphs alongside emphasis and links", () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain("Note:");
+ expect(html).toContain("katex-display");
+ expect(html).toContain("E = mc^2");
+ expect(html).toContain('href="https://example.com"');
+ expect(html).not.toContain("\\[");
+ expect(html).not.toContain("\\]");
+ });
+
+ it("preserves markdown escape resolutions in text adjacent to math", () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain("Costs $100 for");
+ expect(html).toContain("katex-html");
+ expect(html).toContain("with *not italic* and _not underline_.");
+ expect(html).not.toContain("\\$");
+ expect(html).not.toContain("\\*");
+ expect(html).not.toContain("\\_");
+ });
+
+ it("preserves valid > operators in display math outside blockquotes", () => {
+ const html = renderToStaticMarkup(
+ b
+\\]`}
+ cwd={undefined}
+ />,
+ );
+ expect(html).toContain("katex-display");
+ expect(html).toContain(">");
+ expect(html).toContain("a");
+ expect(html).toContain("b");
+ expect(html).not.toContain("KaTeX parse error");
+ });
+
+ it("preserves valid > operators in display math inside blockquotes after stripping quote prefix", () => {
+ const html = renderToStaticMarkup(
+ \\[
+> a > b
+> \\]`}
+ cwd={undefined}
+ />,
+ );
+ expect(html).toContain("katex-display");
+ expect(html).toContain(">");
+ expect(html).toContain("a");
+ expect(html).toContain("b");
+ expect(html).not.toContain("KaTeX parse error");
+ });
+
+ it("does not produce block display math elements inside inline containers like strong or links", () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain("Result:");
+ expect(html).toContain('href="https://example.com"');
+ expect(html).not.toContain("katex-display");
+ });
+
+ it("does not render escaped literal delimiters as math when adjacent to real math in a paragraph", () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain(String.raw`\(literal\)`);
+ expect(html).not.toContain('literal');
+ expect(html).toContain('x^2');
+ });
+
+ it("decodes HTML character references in text adjacent to math", () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain("AT&T");
+ expect(html).not.toContain("&");
+ expect(html).toContain("© 2026");
+ expect(html).not.toContain("©");
+ expect(html).toContain("& / & symbol.");
+ expect(html).not.toContain("&");
+ expect(html).not.toContain("&");
+ });
+
+ it("strips blockquote continuation markers from prose surrounding inline math", () => {
+ const html = renderToStaticMarkup(
+ Text \\(x\\) and more
+> next line`}
+ cwd={undefined}
+ />,
+ );
+ expect(html).toContain("");
+ expect(html).toContain("katex-html");
+ expect(html).toContain("and more\nnext line");
+ expect(html).not.toContain(">");
+ });
+
+ it("strips blockquote continuation markers from prose surrounding display math", () => {
+ const html = renderToStaticMarkup(
+ Text before
+> \\[ E = mc^2 \\]
+> text after
+> next line`}
+ cwd={undefined}
+ />,
+ );
+ expect(html).toContain("");
+ expect(html).toContain("katex-display");
+ expect(html).toContain("text after\nnext line");
+ expect(html).not.toContain(">");
+ });
+
+ it("renders complex multi-line blockquote with inline math, display math, prose, and character entities without corruption", () => {
+ const html = renderToStaticMarkup(
+ AT&T © 2026 with \\(x_1 + x_2\\) equation
+> continuation line before display math
+> \\[ \\frac{a}{b} = c \\]
+> and final prose with "quoted" text on line 4.`}
+ cwd={undefined}
+ />,
+ );
+ expect(html).toContain("");
+ expect(html).toContain("AT&T");
+ expect(html).toContain("© 2026");
+ expect(html).toContain("katex-html");
+ expect(html).toContain("katex-display");
+ expect(html).toContain(""quoted"");
+ expect(html).toContain("continuation line before display math");
+ expect(html).toContain("and final prose with");
+ expect(html).not.toContain(">");
+ expect(html).not.toContain("&");
+ expect(html).not.toContain("©");
+ expect(html).not.toContain(""");
+ });
+
+ it("handles multi-line inline backticks and tilde code fences without corrupting math or dollar signs", () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain("with \\(x\\) and $100");
+ expect(html).toContain("echo "$HOME and $100 and \\[x\\]"");
+ expect(html).toContain("katex-html");
+ expect(html).toContain('y^2');
+ });
+
+ it("correctly handles backslash parity (odd vs even backslashes) before math delimiters and dollar signs", () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ // Even backslash \\( remains literal \(literal\)
+ expect(html).toContain(String.raw`\(literal\)`);
+ expect(html).not.toContain('literal');
+ // Odd backslashes \\\( renders literal \ plus math for \(x^2\)
+ expect(html).toContain('x^2');
+ // \\$100 renders as \$100
+ expect(html).toContain("$100");
+ });
+
+ it("preserves shell variables and multiple ambient dollar signs without triggering false math", () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain("$HOME");
+ expect(html).toContain("$PATH");
+ expect(html).toContain("$1");
+ expect(html).toContain("$?");
+ expect(html).toContain("$50");
+ expect(html).toContain("$100");
+ expect(html).toContain('x^2');
+ });
+
+ it("handles empty inline and display delimiters gracefully without crashing or invalid display math", () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain("Empty inline");
+ expect(html).toContain("empty display");
+ expect(html).toContain('x^2');
+ expect(html).not.toContain("KaTeX parse error");
+ });
+});
diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx
index c3e1c5288da7..ca46a0159837 100644
--- a/apps/web/src/components/ChatMarkdown.tsx
+++ b/apps/web/src/components/ChatMarkdown.tsx
@@ -36,13 +36,14 @@ import React, {
useState,
type ReactNode,
} from "react";
+import ReactMarkdown, { defaultUrlTransform } from "react-markdown";
import type { Components, Options as ReactMarkdownOptions } from "react-markdown";
-import ReactMarkdown from "react-markdown";
-import { defaultUrlTransform } from "react-markdown";
+import rehypeKatex from "rehype-katex";
import rehypeRaw from "rehype-raw";
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
import remarkBreaks from "remark-breaks";
import remarkGfm from "remark-gfm";
+import remarkMath from "remark-math";
import { remarkGithubAlerts } from "../markdown-github-alerts";
import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText";
import { CHAT_FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip";
@@ -175,12 +176,147 @@ export function orderedListGutterStyle(
return { "--list-gutter": `${digits + 1}ch` };
}
+export function preprocessLatexDelimiters(text: string): string {
+ if (!text || (!text.includes("\\(") && !text.includes("\\[") && !text.includes("$"))) {
+ return text;
+ }
+
+ // Match fenced code blocks (``` or ~~~ with >= 3 chars), or inline code (`+)
+ const codeRegex =
+ /(?:^|\n)([ \t]*)(`{3,}|~{3,})[^\n]*\n[\s\S]*?(?:\n\1\2[ \t]*(?=\n|$)|$)|`+[^`]+`+/g;
+
+ let result = "";
+ let lastIndex = 0;
+ let match: RegExpExecArray | null;
+
+ while ((match = codeRegex.exec(text)) !== null) {
+ const textChunk = text.slice(lastIndex, match.index);
+ result += transformLatexDelimitersInText(textChunk);
+ result += match[0];
+ lastIndex = codeRegex.lastIndex;
+ }
+
+ result += transformLatexDelimitersInText(text.slice(lastIndex));
+ return result;
+}
+
+function countPrecedingBackslashes(str: string, index: number): number {
+ let count = 0;
+ for (let k = index - 1; k >= 0 && str.charCodeAt(k) === 92; k--) {
+ count++;
+ }
+ return count;
+}
+
+function findClosingDelimiter(str: string, startIdx: number, delimiter: string): number {
+ for (let j = startIdx; j < str.length; j++) {
+ if (str.startsWith(delimiter, j)) {
+ if (countPrecedingBackslashes(str, j) % 2 === 0) {
+ return j;
+ }
+ }
+ }
+ return -1;
+}
+
+function transformLatexDelimitersInText(raw: string): string {
+ if (!raw) return "";
+
+ let out = "";
+ let i = 0;
+
+ while (i < raw.length) {
+ // Check for display math $$...$$
+ if (raw.startsWith("$$", i)) {
+ const closeIdx = raw.indexOf("$$", i + 2);
+ if (closeIdx !== -1) {
+ out += raw.slice(i, closeIdx + 2);
+ i = closeIdx + 2;
+ continue;
+ }
+ }
+
+ const isEscaped = countPrecedingBackslashes(raw, i) % 2 === 1;
+
+ // Check for \[...\]
+ if (raw.startsWith("\\[", i) && !isEscaped) {
+ const closeIdx = findClosingDelimiter(raw, i + 2, "\\]");
+ if (closeIdx !== -1) {
+ const formula = raw.slice(i + 2, closeIdx);
+ if (formula.trim() === "") {
+ out += raw.slice(i, closeIdx + 2);
+ i = closeIdx + 2;
+ continue;
+ }
+
+ 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";
+ }
+ i = closeIdx + 2;
+ continue;
+ }
+ }
+
+ // Check for \(...\)
+ if (raw.startsWith("\\(", i) && !isEscaped) {
+ const closeIdx = findClosingDelimiter(raw, i + 2, "\\)");
+ if (closeIdx !== -1) {
+ let formula = raw.slice(i + 2, closeIdx);
+ if (formula.trim() === "") {
+ out += raw.slice(i, closeIdx + 2);
+ i = closeIdx + 2;
+ continue;
+ }
+
+ const lineStart = Math.max(0, raw.lastIndexOf("\n", i) + 1);
+ const linePrefix = raw.slice(lineStart, i);
+ const isBlockquote = /^[ \t]*>[ \t]?/.test(linePrefix);
+
+ if (isBlockquote) {
+ formula = formula.replace(/\n[ \t]*>[ \t]?/g, "\n");
+ }
+ out += "$" + formula + "$";
+ i = closeIdx + 2;
+ continue;
+ }
+ }
+
+ // Escape unescaped $ that is not math delimiter so remark-math does not parse currency/variables
+ if (raw[i] === "$" && !isEscaped) {
+ out += "\\$";
+ i++;
+ continue;
+ }
+
+ out += raw[i];
+ i++;
+ }
+
+ return out;
+}
+
const CHAT_MARKDOWN_SANITIZE_SCHEMA = {
...defaultSchema,
attributes: {
...defaultSchema.attributes,
"*": (defaultSchema.attributes?.["*"] ?? []).filter((attribute) => attribute !== "title"),
- code: [...(defaultSchema.attributes?.code ?? []), "dataCodeMeta", "dataInlineCode"],
+ code: [
+ ...(defaultSchema.attributes?.code ?? []),
+ "dataCodeMeta",
+ "dataInlineCode",
+ ["className", "language-math", "math-inline", "math-display"],
+ ],
blockquote: [...(defaultSchema.attributes?.blockquote ?? []), "dataAlert"],
},
protocols: {
@@ -191,6 +327,7 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = {
const CHAT_MARKDOWN_REMARK_PLUGINS = [
remarkGfm,
+ [remarkMath, { singleDollarTextMath: true }],
remarkGithubAlerts,
remarkNormalizeListItemIndentation,
remarkPreserveCodeMeta,
@@ -199,9 +336,10 @@ const CHAT_MARKDOWN_REMARK_PLUGINS = [
const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [
remarkGfm,
+ [remarkMath, { singleDollarTextMath: true }],
+ remarkBreaks,
remarkGithubAlerts,
remarkNormalizeListItemIndentation,
- remarkBreaks,
remarkPreserveCodeMeta,
remarkTagInlineCode,
] satisfies NonNullable;
@@ -209,8 +347,13 @@ const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [
const CHAT_MARKDOWN_REHYPE_PLUGINS = [
rehypeRaw,
[rehypeSanitize, CHAT_MARKDOWN_SANITIZE_SCHEMA],
+ rehypeKatex,
] satisfies NonNullable;
+const CHAT_MARKDOWN_REHYPE_PLUGINS_WITHOUT_RAW = [rehypeKatex] satisfies NonNullable<
+ ReactMarkdownOptions["rehypePlugins"]
+>;
+
/** GitHub's own five alert kinds, in its colors: the glyph names the urgency, the title says it. */
const GITHUB_ALERT_PRESENTATIONS: Record<
string,
@@ -1785,6 +1928,8 @@ function ChatMarkdown({
]);
/* eslint-enable react/no-unstable-nested-components */
+ const preprocessedText = useMemo(() => preprocessLatexDelimiters(text), [text]);
+
// react-markdown converts unparsed HTML nodes to text when skipHtml is false.
// Keep that behavior explicit because literal mode depends on escaping the
// complete source token instead of dropping it from the rendered message.
@@ -1800,12 +1945,14 @@ function ChatMarkdown({
remarkPlugins={
lineBreaks ? CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS : CHAT_MARKDOWN_REMARK_PLUGINS
}
- rehypePlugins={parseRawHtml ? CHAT_MARKDOWN_REHYPE_PLUGINS : undefined}
+ rehypePlugins={
+ parseRawHtml ? CHAT_MARKDOWN_REHYPE_PLUGINS : CHAT_MARKDOWN_REHYPE_PLUGINS_WITHOUT_RAW
+ }
skipHtml={false}
components={markdownComponents}
urlTransform={markdownUrlTransform}
>
- {text}
+ {preprocessedText}
);
diff --git a/apps/web/src/index.css b/apps/web/src/index.css
index 71f9443f7f62..17bd44a03890 100644
--- a/apps/web/src/index.css
+++ b/apps/web/src/index.css
@@ -1,4 +1,5 @@
@import "tailwindcss";
+@import "katex/dist/katex.min.css";
@custom-variant dark (&:is(.dark, .dark *));
@custom-variant light (&:not(.dark, .dark *));
@@ -1991,7 +1992,8 @@ code {
.chat-markdown ol,
.chat-markdown blockquote,
.chat-markdown pre,
-.chat-markdown .chat-markdown-table-container {
+.chat-markdown .chat-markdown-table-container,
+.chat-markdown .katex-display {
margin: 0.65rem 0;
}
@@ -2179,6 +2181,23 @@ code {
background: color-mix(in srgb, var(--border) 78%, transparent);
}
+.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;
+}
+
+.chat-markdown .katex-display::-webkit-scrollbar {
+ height: 7px;
+}
+
+.chat-markdown .katex-display::-webkit-scrollbar-thumb {
+ border-radius: 999px;
+ background: color-mix(in srgb, var(--border) 78%, transparent);
+}
+
.chat-markdown .chat-markdown-codeblock-header,
.chat-markdown .chat-markdown-chrome-action {
color: color-mix(in srgb, var(--foreground) 72%, transparent);
diff --git a/apps/web/src/markdown-clipboard.test.ts b/apps/web/src/markdown-clipboard.test.ts
index 7265e8b60430..4b979eb224c2 100644
--- a/apps/web/src/markdown-clipboard.test.ts
+++ b/apps/web/src/markdown-clipboard.test.ts
@@ -19,6 +19,8 @@ class FakeElement {
contains: (name: string) => this.classNames.includes(name),
};
+ private readonly attributes = new Map();
+
constructor(
readonly tagName: string,
private readonly classNames: ReadonlyArray = [],
@@ -37,12 +39,43 @@ class FakeElement {
return this;
}
- getAttribute(): string | null {
- return null;
+ setAttribute(name: string, value: string): this {
+ this.attributes.set(name, value);
+ return this;
}
- hasAttribute(): boolean {
- return false;
+ getAttribute(name: string): string | null {
+ return this.attributes.get(name) ?? null;
+ }
+
+ hasAttribute(name: string): boolean {
+ return this.attributes.has(name);
+ }
+
+ querySelector(selector: string): FakeElement | null {
+ for (const child of this.childNodes) {
+ if (child.nodeType === ELEMENT_NODE) {
+ const el = child as FakeElement;
+ if (selector === "annotation" && el.tagName === "ANNOTATION") return el;
+ if (
+ selector === 'annotation[encoding="application/x-tex"]' &&
+ el.tagName === "ANNOTATION" &&
+ el.getAttribute("encoding") === "application/x-tex"
+ ) {
+ return el;
+ }
+ if (
+ selector === 'math[display="block"]' &&
+ el.tagName === "MATH" &&
+ el.getAttribute("display") === "block"
+ ) {
+ return el;
+ }
+ const found = el.querySelector(selector);
+ if (found) return found;
+ }
+ }
+ return null;
}
}
@@ -92,4 +125,50 @@ describe("serializeRenderedMarkdownFragment", () => {
expect(serializeRenderedMarkdownFragment(asNode(container))).toBe("first line\nsecond line");
});
+
+ it("serializes inline KaTeX math back to LaTeX delimiters", () => {
+ const mathml = new FakeElement("SPAN", ["katex-mathml"]).append(
+ new FakeElement("MATH").append(
+ new FakeElement("SEMANTICS").append(
+ new FakeElement("MROW").append(new FakeText("E=mc2")),
+ new FakeElement("ANNOTATION")
+ .setAttribute("encoding", "application/x-tex")
+ .append(new FakeText("E = mc^2")),
+ ),
+ ),
+ );
+ const katexHtml = new FakeElement("SPAN", ["katex-html"])
+ .setAttribute("aria-hidden", "true")
+ .append(new FakeText("E = mc2"));
+ const katex = new FakeElement("SPAN", ["katex"]).append(mathml, katexHtml);
+ const paragraph = new FakeElement("P").append(
+ new FakeText("The formula is "),
+ katex,
+ new FakeText(" in physics."),
+ );
+ const container = new FakeElement("DIV").append(paragraph);
+
+ expect(serializeRenderedMarkdownFragment(asNode(container))).toBe(
+ "The formula is \\(E = mc^2\\) in physics.",
+ );
+ });
+
+ it("serializes display KaTeX math to block delimiters", () => {
+ const mathml = new FakeElement("SPAN", ["katex-mathml"]).append(
+ new FakeElement("MATH")
+ .setAttribute("display", "block")
+ .append(
+ new FakeElement("SEMANTICS").append(
+ new FakeElement("ANNOTATION")
+ .setAttribute("encoding", "application/x-tex")
+ .append(new FakeText("\\frac{a}{b} = c")),
+ ),
+ ),
+ );
+ const katex = new FakeElement("SPAN", ["katex"]).append(mathml);
+ const display = new FakeElement("DIV", ["katex-display"]).append(katex);
+ const container = new FakeElement("DIV").append(display);
+
+ expect(serializeRenderedMarkdownFragment(asNode(container))).toBe("$$\n\\frac{a}{b} = c\n$$");
+ });
});
diff --git a/apps/web/src/markdown-clipboard.ts b/apps/web/src/markdown-clipboard.ts
index 069d161a188c..750a2a114077 100644
--- a/apps/web/src/markdown-clipboard.ts
+++ b/apps/web/src/markdown-clipboard.ts
@@ -186,6 +186,19 @@ function serializeChildren(node: Node): string {
return out;
}
+function serializeKatex(element: Element): string {
+ const annotation =
+ element.querySelector('annotation[encoding="application/x-tex"]') ??
+ element.querySelector("annotation");
+ const tex = annotation?.textContent?.trim() ?? "";
+ if (!tex) return "";
+ const isDisplay =
+ element.classList.contains("katex-display") ||
+ Boolean(element.closest?.(".katex-display")) ||
+ element.querySelector('math[display="block"]') !== null;
+ return isDisplay ? `\n\n$$\n${tex}\n$$\n\n` : `\\(${tex}\\)`;
+}
+
function serializeNode(node: Node): string {
if (node.nodeType === Node.TEXT_NODE) {
const text = node.textContent ?? "";
@@ -201,6 +214,9 @@ function serializeNode(node: Node): string {
}
const markdownCopy = element.getAttribute("data-markdown-copy");
if (markdownCopy !== null) return markdownCopy;
+ if (element.classList.contains("katex-display") || element.classList.contains("katex")) {
+ return serializeKatex(element);
+ }
if (isSkippedElement(element)) return "";
const headingLevel = /^H([1-6])$/.exec(element.tagName)?.[1];
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 0f66f69b87f0..947c53ae3ee7 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -591,6 +591,9 @@ importers:
jszip:
specifier: 3.10.1
version: 3.10.1
+ katex:
+ specifier: ^0.16.47
+ version: 0.16.47
lexical:
specifier: ^0.41.0
version: 0.41.0
@@ -606,6 +609,9 @@ importers:
react-markdown:
specifier: ^10.1.0
version: 10.1.0(@types/react@19.2.16)(react@19.2.6)
+ rehype-katex:
+ specifier: ^7.0.1
+ version: 7.0.1
rehype-raw:
specifier: ^7.0.0
version: 7.0.0
@@ -618,6 +624,9 @@ importers:
remark-gfm:
specifier: ^4.0.1
version: 4.0.1
+ remark-math:
+ specifier: ^6.0.0
+ version: 6.0.0
tailwind-merge:
specifier: ^3.4.0
version: 3.6.0
@@ -649,6 +658,12 @@ importers:
'@types/culori':
specifier: ^4.0.1
version: 4.0.1
+ '@types/hast':
+ specifier: ^3.0.4
+ version: 3.0.4
+ '@types/mdast':
+ specifier: ^4.0.4
+ version: 4.0.4
'@types/react':
specifier: ~19.2.14
version: 19.2.16
@@ -667,12 +682,18 @@ importers:
compression:
specifier: ^1.8.1
version: 1.8.1
+ mdast-util-math:
+ specifier: ^3.0.0
+ version: 3.0.0
msw:
specifier: 2.12.11
version: 2.12.11(@types/node@24.12.4)(typescript@6.0.3)
tailwindcss:
specifier: ^4.0.0
version: 4.3.0
+ unified:
+ specifier: ^11.0.5
+ version: 11.0.5
vite:
specifier: npm:@voidzero-dev/vite-plus-core@0.2.2
version: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)'
@@ -4866,6 +4887,9 @@ packages:
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
+ '@types/katex@0.16.8':
+ resolution: {integrity: sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==}
+
'@types/keyv@3.1.4':
resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==}
@@ -5184,10 +5208,12 @@ packages:
'@xmldom/xmldom@0.8.13':
resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==}
engines: {node: '>=10.0.0'}
+ deprecated: this version has critical issues, please update to the latest version
'@xmldom/xmldom@0.9.10':
resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==}
engines: {node: '>=14.6'}
+ deprecated: this version has critical issues, please update to the latest version
'@yuuang/ffi-rs-android-arm64@1.3.2':
resolution: {integrity: sha512-eDYLT0kVBkp7e2BwdRDmt6N1rkeDPUHDefk3ZX0/nok+GLsqfy1WBoSL3Yg7HVXN1EyW8OBVc2uK8Zq8HbmaSA==}
@@ -5902,6 +5928,10 @@ packages:
resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==}
engines: {node: '>= 10'}
+ commander@8.3.0:
+ resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==}
+ engines: {node: '>= 12'}
+
commander@9.5.0:
resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==}
engines: {node: ^12.20.0 || >=14}
@@ -7146,6 +7176,12 @@ packages:
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
engines: {node: '>= 0.4'}
+ hast-util-from-dom@5.0.1:
+ resolution: {integrity: sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==}
+
+ hast-util-from-html-isomorphic@2.0.0:
+ resolution: {integrity: sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw==}
+
hast-util-from-html@2.0.3:
resolution: {integrity: sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==}
@@ -7552,6 +7588,10 @@ packages:
jszip@3.10.1:
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
+ katex@0.16.47:
+ resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==}
+ hasBin: true
+
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
@@ -7940,6 +7980,9 @@ packages:
mdast-util-gfm@3.1.0:
resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==}
+ mdast-util-math@3.0.0:
+ resolution: {integrity: sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==}
+
mdast-util-mdx-expression@2.0.1:
resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==}
@@ -8076,6 +8119,9 @@ packages:
micromark-extension-gfm@3.0.0:
resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==}
+ micromark-extension-math@3.1.0:
+ resolution: {integrity: sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==}
+
micromark-factory-destination@2.0.1:
resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==}
@@ -9194,6 +9240,9 @@ packages:
resolution: {integrity: sha512-dLsljMd9sqwRkby8zhO1gSg3PnJIBFid8f4CQj/sXx+7cKx+E7u0PKhZ+U4wmhx7EfmtvnA318oVaIkAB1lRJw==}
hasBin: true
+ rehype-katex@7.0.1:
+ resolution: {integrity: sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==}
+
rehype-parse@9.0.1:
resolution: {integrity: sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag==}
@@ -9215,6 +9264,9 @@ packages:
remark-gfm@4.0.1:
resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}
+ remark-math@6.0.0:
+ resolution: {integrity: sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==}
+
remark-parse@11.0.0:
resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==}
@@ -15051,6 +15103,8 @@ snapshots:
'@types/json-schema@7.0.15': {}
+ '@types/katex@0.16.8': {}
+
'@types/keyv@3.1.4':
dependencies:
'@types/node': 24.12.4
@@ -16241,6 +16295,8 @@ snapshots:
commander@7.2.0: {}
+ commander@8.3.0: {}
+
commander@9.5.0:
optional: true
@@ -17772,6 +17828,19 @@ snapshots:
dependencies:
function-bind: 1.1.2
+ hast-util-from-dom@5.0.1:
+ dependencies:
+ '@types/hast': 3.0.4
+ hastscript: 9.0.1
+ web-namespaces: 2.0.1
+
+ hast-util-from-html-isomorphic@2.0.0:
+ dependencies:
+ '@types/hast': 3.0.4
+ hast-util-from-dom: 5.0.1
+ hast-util-from-html: 2.0.3
+ unist-util-remove-position: 5.0.0
+
hast-util-from-html@2.0.3:
dependencies:
'@types/hast': 3.0.4
@@ -17795,7 +17864,6 @@ snapshots:
hast-util-is-element@3.0.0:
dependencies:
'@types/hast': 3.0.4
- optional: true
hast-util-parse-selector@4.0.0:
dependencies:
@@ -17873,7 +17941,6 @@ snapshots:
'@types/unist': 3.0.3
hast-util-is-element: 3.0.0
unist-util-find-after: 5.0.0
- optional: true
hast-util-whitespace@3.0.0:
dependencies:
@@ -18226,6 +18293,10 @@ snapshots:
readable-stream: 2.3.8
setimmediate: 1.0.5
+ katex@0.16.47:
+ dependencies:
+ commander: 8.3.0
+
keyv@4.5.4:
dependencies:
json-buffer: 3.0.1
@@ -18592,6 +18663,18 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ mdast-util-math@3.0.0:
+ dependencies:
+ '@types/hast': 3.0.4
+ '@types/mdast': 4.0.4
+ devlop: 1.1.0
+ longest-streak: 3.1.0
+ mdast-util-from-markdown: 2.0.3
+ mdast-util-to-markdown: 2.1.2
+ unist-util-remove-position: 5.0.0
+ transitivePeerDependencies:
+ - supports-color
+
mdast-util-mdx-expression@2.0.1:
dependencies:
'@types/estree-jsx': 1.0.5
@@ -18938,6 +19021,16 @@ snapshots:
micromark-util-combine-extensions: 2.0.1
micromark-util-types: 2.0.2
+ micromark-extension-math@3.1.0:
+ dependencies:
+ '@types/katex': 0.16.8
+ devlop: 1.1.0
+ katex: 0.16.47
+ micromark-factory-space: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
micromark-factory-destination@2.0.1:
dependencies:
micromark-util-character: 2.1.1
@@ -20297,6 +20390,16 @@ snapshots:
dependencies:
jsesc: 3.1.0
+ rehype-katex@7.0.1:
+ dependencies:
+ '@types/hast': 3.0.4
+ '@types/katex': 0.16.8
+ hast-util-from-html-isomorphic: 2.0.0
+ hast-util-to-text: 4.0.2
+ katex: 0.16.47
+ unist-util-visit-parents: 6.0.2
+ vfile: 6.0.3
+
rehype-parse@9.0.1:
dependencies:
'@types/hast': 3.0.4
@@ -20344,6 +20447,15 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ remark-math@6.0.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ mdast-util-math: 3.0.0
+ micromark-extension-math: 3.1.0
+ unified: 11.0.5
+ transitivePeerDependencies:
+ - supports-color
+
remark-parse@11.0.0:
dependencies:
'@types/mdast': 4.0.4
@@ -21195,7 +21307,6 @@ snapshots:
dependencies:
'@types/unist': 3.0.3
unist-util-is: 6.0.1
- optional: true
unist-util-is@6.0.1:
dependencies:
@@ -21215,7 +21326,6 @@ snapshots:
dependencies:
'@types/unist': 3.0.3
unist-util-visit: 5.1.0
- optional: true
unist-util-stringify-position@4.0.0:
dependencies: