From 87d05621530c81af141c40829f87f1e695068195 Mon Sep 17 00:00:00 2001 From: Kritarth-Dandapat Date: Fri, 4 Sep 2026 19:07:46 -0400 Subject: [PATCH 1/3] fix(web): render LaTeX in chat messages Render inline and display math with KaTeX while preserving code spans, fenced code blocks, and Markdown source offsets. Built with GPT Soul. --- apps/web/package.json | 3 + apps/web/src/components/ChatMarkdown.test.tsx | 71 ++++++++++++ apps/web/src/components/ChatMarkdown.tsx | 19 +++- apps/web/src/index.css | 7 ++ apps/web/src/main.tsx | 1 + apps/web/src/markdown-latex.test.ts | 28 +++++ apps/web/src/markdown-latex.ts | 78 +++++++++++++ docs/user/composer.md | 16 +++ pnpm-lock.yaml | 104 +++++++++++++++++- 9 files changed, 320 insertions(+), 7 deletions(-) create mode 100644 apps/web/src/markdown-latex.test.ts create mode 100644 apps/web/src/markdown-latex.ts diff --git a/apps/web/package.json b/apps/web/package.json index 4c9396cc725b..396d1cf81c49 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -38,15 +38,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" }, diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index 18a6c5115eeb..5ba0ad39ce70 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -205,6 +205,77 @@ describe("ChatMarkdown streaming", () => { }); }); +describe("ChatMarkdown math", () => { + it.each([true, false])( + "renders inline and display LaTeX with accessible MathML when parseRawHtml=%s", + (parseRawHtml) => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain("katex"); + expect(html).toContain("katex-display"); + expect(html).toContain(" { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain("katex"); + expect(html).toContain("katex-display"); + expect(html).toContain("GenCast represents "); + expect(html).toContain("x_1,\\ldots,x_M"); + expect(html).toContain("\\boxed{\\widehat{\\operatorname{CRPS}}"); + }, + ); + + it("does not interpret LaTeX delimiters inside code", () => { + const markdown = [ + "Render \\(x^2\\), but keep `\\(inlineExample\\)` unchanged.", + "", + "```text", + String.raw`\[displayExample\]`, + "```", + ].join("\n"); + const html = renderToStaticMarkup(); + + expect(html).toContain("katex"); + expect(html).toContain("\\(inlineExample\\)"); + expect(html).toContain("\\[displayExample\\]"); + }); + + it("keeps a lone dollar amount as text", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain("$20"); + expect(html).not.toContain("katex"); + }); +}); + describe("canUseMarkdownFileShellActions", () => { const environmentId = EnvironmentId.make("environment-1"); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index deaa68ebdaee..a55a4a4ef7d8 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -67,12 +67,14 @@ import React, { 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 { parseAssistantCitationHref } from "@t3tools/shared/assistantCitations"; import { AssistantCitationChip } from "./chat/AssistantCitationChip"; import remarkGfm from "remark-gfm"; +import remarkMath from "remark-math"; import { remarkGithubAlerts } from "../markdown-github-alerts"; import { artifactTemplateFromHastProperties, @@ -126,6 +128,7 @@ import { serializeTableElementToMarkdown, } from "../markdown-clipboard"; import { remarkNormalizeListItemIndentation } from "../markdown-list-indentation"; +import { normalizeLatexDelimiters } from "../markdown-latex"; import { extractMarkdownLinkHrefs, isWindowsDrivePathHref, @@ -396,6 +399,7 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = { const CHAT_MARKDOWN_REMARK_PLUGINS = [ remarkGfm, + remarkMath, remarkGithubAlerts, remarkNormalizeListItemIndentation, remarkCodexDirectives, @@ -405,6 +409,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS = [ const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ remarkGfm, + remarkMath, remarkGithubAlerts, remarkNormalizeListItemIndentation, remarkCodexDirectives, @@ -413,10 +418,15 @@ const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ remarkNormalizeLinksAndTagInlineCode, ] satisfies NonNullable; -const CHAT_MARKDOWN_REHYPE_PLUGINS = [ +const CHAT_MARKDOWN_REHYPE_PLUGINS = [rehypeKatex] satisfies NonNullable< + ReactMarkdownOptions["rehypePlugins"] +>; + +const CHAT_MARKDOWN_RAW_HTML_REHYPE_PLUGINS = [ rehypeRaw, rehypePreserveImageSourceMeta, [rehypeSanitize, CHAT_MARKDOWN_SANITIZE_SCHEMA], + rehypeKatex, ] satisfies NonNullable; /** GitHub's own five alert kinds, in its colors: the glyph names the urgency, the title says it. */ @@ -2908,6 +2918,7 @@ function ChatMarkdown({ ], [extraRemarkPlugins, lineBreaks], ); + const normalizedText = useMemo(() => normalizeLatexDelimiters(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 @@ -2923,12 +2934,14 @@ function ChatMarkdown({ - {text} + {normalizedText} {localMediaPreview ? ( diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 1369cd31f5b3..597123f56c1c 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1668,6 +1668,13 @@ code { color: var(--contrast-muted-foreground); } +.chat-markdown .katex-display { + margin: 0.65rem 0; + overflow-x: auto; + overflow-y: hidden; + padding-block: 0.15rem; +} + .chat-markdown section[data-footnotes] { margin-top: 1.25rem; border-top: 1px solid var(--contrast-border); diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 8cafbd5009b4..18887dc1a051 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -2,6 +2,7 @@ import React from "react"; import ReactDOM from "react-dom/client"; import { createHashHistory, createBrowserHistory } from "@tanstack/react-router"; +import "katex/dist/katex.min.css"; import "./index.css"; import { isElectron } from "./env"; diff --git a/apps/web/src/markdown-latex.test.ts b/apps/web/src/markdown-latex.test.ts new file mode 100644 index 000000000000..0192377964ec --- /dev/null +++ b/apps/web/src/markdown-latex.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vite-plus/test"; +import { normalizeLatexDelimiters } from "./markdown-latex"; + +describe("normalizeLatexDelimiters", () => { + it("normalizes parenthesis and bracket delimiters without shifting source offsets", () => { + const markdown = "Inline \\(x\\)\n\\[\ny\n\\]\n- [ ] task"; + const normalized = normalizeLatexDelimiters(markdown); + + expect(normalized).toBe("Inline $$x$$\n$$\ny\n$$\n- [ ] task"); + expect(normalized).toHaveLength(markdown.length); + }); + + it("leaves escaped delimiters and code unchanged", () => { + const markdown = [ + String.raw`Literal \\(x\\) and \[math\].`, + "Inline code: `\\(code\\)`.", + "", + "~~~text", + String.raw`\[fenced\]`, + "~~~", + String.raw` \(indented\)`, + ].join("\n"); + + expect(normalizeLatexDelimiters(markdown)).toBe( + markdown.replace(String.raw`\[math\]`, () => "$$math$$"), + ); + }); +}); diff --git a/apps/web/src/markdown-latex.ts b/apps/web/src/markdown-latex.ts new file mode 100644 index 000000000000..862eec078961 --- /dev/null +++ b/apps/web/src/markdown-latex.ts @@ -0,0 +1,78 @@ +const LATEX_DELIMITERS = ["\\(", "\\)", "\\[", "\\]"] as const; + +function isEscaped(value: string, index: number): boolean { + let slashCount = 0; + for (let cursor = index - 1; cursor >= 0 && value[cursor] === "\\"; cursor -= 1) { + slashCount += 1; + } + return slashCount % 2 === 1; +} + +function startingFence(line: string): { marker: "`" | "~"; length: number } | null { + const match = /^ {0,3}(`{3,}|~{3,})/.exec(line); + const run = match?.[1]; + if (!run) return null; + return { marker: run[0] as "`" | "~", length: run.length }; +} + +function closesFence(line: string, fence: { marker: "`" | "~"; length: number }): boolean { + const match = /^ {0,3}(`+|~+)[ \t]*$/.exec(line); + const run = match?.[1]; + return Boolean(run && run[0] === fence.marker && run.length >= fence.length); +} + +/** Converts TeX delimiters before Markdown consumes their leading backslashes. */ +export function normalizeLatexDelimiters(markdown: string): string { + if (!LATEX_DELIMITERS.some((delimiter) => markdown.includes(delimiter))) return markdown; + + let fence: { marker: "`" | "~"; length: number } | null = null; + let inlineCodeTicks = 0; + + return markdown + .split(/(\r?\n)/) + .map((line) => { + if (line === "\n" || line === "\r\n") return line; + + if (fence) { + if (closesFence(line, fence)) fence = null; + return line; + } + + if (inlineCodeTicks === 0) { + const openingFence = startingFence(line); + if (openingFence) { + fence = openingFence; + return line; + } + if (/^(?: {4}|\t)/.test(line)) return line; + } + + let normalized = ""; + for (let index = 0; index < line.length; index += 1) { + const character = line[index]; + + if (character === "`" && !isEscaped(line, index)) { + let runLength = 1; + while (line[index + runLength] === "`") runLength += 1; + if (inlineCodeTicks === 0) inlineCodeTicks = runLength; + else if (inlineCodeTicks === runLength) inlineCodeTicks = 0; + normalized += "`".repeat(runLength); + index += runLength - 1; + continue; + } + + if (inlineCodeTicks === 0 && character === "\\" && !isEscaped(line, index)) { + const delimiter = line[index + 1]; + if (delimiter === "(" || delimiter === ")" || delimiter === "[" || delimiter === "]") { + normalized += "$$"; + index += 1; + continue; + } + } + + normalized += character; + } + return normalized; + }) + .join(""); +} diff --git a/docs/user/composer.md b/docs/user/composer.md index 740c44b8129d..54b67fea4071 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -103,6 +103,22 @@ File links refer to the environment's machine, including when you connect remote Previews use the original file, even outside the workspace. Moving or deleting it can break the preview, so save a copy if you need to keep it. +## Math in messages + +Web and desktop render LaTeX in messages. Inline math accepts `$...$` or `\(...\)`. Display math +accepts `$$...$$` or `\[...\]`: + +```markdown +Euler's identity is $e^{i\pi} + 1 = 0$. + +$$ +\int_0^1 x^2\,dx = \frac{1}{3} +$$ +``` + +Escape a dollar sign as `\$` when two prices or other dollar amounts in the same paragraph could +look like inline math. Mobile currently displays the source text instead. + ## Files outside the workspace Follow an agent's file link to read a report or other file outside the workspace. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d2bd2b64ecb3..54aaf3445d4b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -626,6 +626,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 @@ -641,6 +644,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 @@ -653,6 +659,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 @@ -4822,6 +4831,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==} @@ -5972,6 +5984,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} @@ -7199,6 +7215,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==} @@ -7613,6 +7635,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==} @@ -7862,6 +7888,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==} @@ -8059,6 +8088,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==} @@ -9167,6 +9199,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==} @@ -9188,6 +9223,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==} @@ -14708,6 +14746,8 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/katex@0.16.8': {} + '@types/keyv@3.1.4': dependencies: '@types/node': 24.12.4 @@ -15925,6 +15965,8 @@ snapshots: commander@7.2.0: {} + commander@8.3.0: {} + commander@9.5.0: optional: true @@ -17330,6 +17372,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 @@ -17353,7 +17408,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: @@ -17431,7 +17485,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: @@ -17793,6 +17846,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 @@ -18079,6 +18136,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 @@ -18608,6 +18677,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 @@ -19876,6 +19955,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 @@ -19923,6 +20012,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 @@ -20766,7 +20864,6 @@ snapshots: dependencies: '@types/unist': 3.0.3 unist-util-is: 6.0.1 - optional: true unist-util-is@6.0.1: dependencies: @@ -20786,7 +20883,6 @@ snapshots: dependencies: '@types/unist': 3.0.3 unist-util-visit: 5.1.0 - optional: true unist-util-stringify-position@4.0.0: dependencies: From 49a8c6433a0cdef3cbff1233ed07236c24bdf8cf Mon Sep 17 00:00:00 2001 From: Kritarth-Dandapat Date: Sun, 6 Sep 2026 20:59:42 -0400 Subject: [PATCH 2/3] fix(web): handle LaTeX delimiter edge cases --- apps/web/src/markdown-latex.test.ts | 23 +++++++++++++++++--- apps/web/src/markdown-latex.ts | 33 +++++++++++++++++++++-------- 2 files changed, 44 insertions(+), 12 deletions(-) diff --git a/apps/web/src/markdown-latex.test.ts b/apps/web/src/markdown-latex.test.ts index 0192377964ec..895693ec685b 100644 --- a/apps/web/src/markdown-latex.test.ts +++ b/apps/web/src/markdown-latex.test.ts @@ -6,8 +6,7 @@ describe("normalizeLatexDelimiters", () => { const markdown = "Inline \\(x\\)\n\\[\ny\n\\]\n- [ ] task"; const normalized = normalizeLatexDelimiters(markdown); - expect(normalized).toBe("Inline $$x$$\n$$\ny\n$$\n- [ ] task"); - expect(normalized).toHaveLength(markdown.length); + expect(normalized).toBe("Inline $$x$$\n\n\n$$\ny\n$$\n\n\n- [ ] task"); }); it("leaves escaped delimiters and code unchanged", () => { @@ -22,7 +21,25 @@ describe("normalizeLatexDelimiters", () => { ].join("\n"); expect(normalizeLatexDelimiters(markdown)).toBe( - markdown.replace(String.raw`\[math\]`, () => "$$math$$"), + markdown.replace(String.raw`\[math\]`, () => "\n\n$$math$$\n\n"), ); }); + + it("handles CR-only fences and container-prefixed code", () => { + const markdown = "```text\r\\(fenced\\)\r```\r\r> \\(quoted code\\)\r\rAfter \\(x\\)"; + expect(normalizeLatexDelimiters(markdown)).toBe( + markdown.replace(String.raw`\(x\)`, () => "$$x$$"), + ); + }); + + it("does not normalize raw HTML code elements or malformed fences", () => { + const markdown = "
\\(raw\\)
\n```text `\n\\(not fenced\\)\nAfter \\(x\\)"; + expect(normalizeLatexDelimiters(markdown)).toBe( + "
\\(raw\\)
\n```text `\n$$not fenced$$\nAfter $$x$$", + ); + }); + + it("does not let an unmatched inline code opener hide later math", () => { + expect(normalizeLatexDelimiters("` unmatched\nLater \\(x\\)")).toBe("` unmatched\nLater $$x$$"); + }); }); diff --git a/apps/web/src/markdown-latex.ts b/apps/web/src/markdown-latex.ts index 862eec078961..65be6399e1f1 100644 --- a/apps/web/src/markdown-latex.ts +++ b/apps/web/src/markdown-latex.ts @@ -9,14 +9,14 @@ function isEscaped(value: string, index: number): boolean { } function startingFence(line: string): { marker: "`" | "~"; length: number } | null { - const match = /^ {0,3}(`{3,}|~{3,})/.exec(line); - const run = match?.[1]; - if (!run) return null; + const match = /^((?: {0,3}(?:> ?|(?:[*+-]|\d+[.)]) +)*)?)(`{3,}|~{3,})(.*)$/.exec(line); + const run = match?.[2]; + if (!run || (run[0] === "`" && match[3].includes("`"))) return null; return { marker: run[0] as "`" | "~", length: run.length }; } function closesFence(line: string, fence: { marker: "`" | "~"; length: number }): boolean { - const match = /^ {0,3}(`+|~+)[ \t]*$/.exec(line); + const match = /^(?: {0,3}(?:> ?|(?:[*+-]|\d+[.)]) +)*)?(`+|~+)[ \t]*$/.exec(line); const run = match?.[1]; return Boolean(run && run[0] === fence.marker && run.length >= fence.length); } @@ -27,9 +27,10 @@ export function normalizeLatexDelimiters(markdown: string): string { let fence: { marker: "`" | "~"; length: number } | null = null; let inlineCodeTicks = 0; + let rawCodeElement = false; return markdown - .split(/(\r?\n)/) + .split(/(\r\n|\n|\r)/) .map((line) => { if (line === "\n" || line === "\r\n") return line; @@ -38,13 +39,25 @@ export function normalizeLatexDelimiters(markdown: string): string { return line; } + if (rawCodeElement) { + if (/<\/(?:pre|code)>/i.test(line)) rawCodeElement = false; + return line; + } + if (/<(?:pre|code)(?:\s|>)/i.test(line)) { + if (!/<\/(?:pre|code)>/i.test(line)) rawCodeElement = true; + return line; + } + if (inlineCodeTicks === 0) { const openingFence = startingFence(line); if (openingFence) { fence = openingFence; return line; } - if (/^(?: {4}|\t)/.test(line)) return line; + const markdownContent = line + .replace(/^(?: {0,3}> ?)+/, "") + .replace(/^(?: {0,3}(?:[*+-]|\d+[.)]) +)+/, ""); + if (/^(?: {4}|\t)/.test(markdownContent)) return line; } let normalized = ""; @@ -54,8 +67,10 @@ export function normalizeLatexDelimiters(markdown: string): string { if (character === "`" && !isEscaped(line, index)) { let runLength = 1; while (line[index + runLength] === "`") runLength += 1; - if (inlineCodeTicks === 0) inlineCodeTicks = runLength; - else if (inlineCodeTicks === runLength) inlineCodeTicks = 0; + if (inlineCodeTicks === 0) { + const hasCloser = line.indexOf("`".repeat(runLength), index + runLength) !== -1; + if (hasCloser) inlineCodeTicks = runLength; + } else if (inlineCodeTicks === runLength) inlineCodeTicks = 0; normalized += "`".repeat(runLength); index += runLength - 1; continue; @@ -64,7 +79,7 @@ export function normalizeLatexDelimiters(markdown: string): string { if (inlineCodeTicks === 0 && character === "\\" && !isEscaped(line, index)) { const delimiter = line[index + 1]; if (delimiter === "(" || delimiter === ")" || delimiter === "[" || delimiter === "]") { - normalized += "$$"; + normalized += delimiter === "[" ? "\n\n$$" : delimiter === "]" ? "$$\n\n" : "$$"; index += 1; continue; } From aa5dc980a21d1925e7848ad8108d65dde87053f4 Mon Sep 17 00:00:00 2001 From: Kritarth-Dandapat Date: Sun, 6 Sep 2026 21:04:09 -0400 Subject: [PATCH 3/3] fix(web): preserve skills and copy rendered math --- apps/web/src/components/ChatMarkdown.test.tsx | 17 +++++ apps/web/src/components/ChatMarkdown.tsx | 74 ++++++++++++++++++- apps/web/src/markdown-clipboard.test.ts | 14 ++++ 3 files changed, 101 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index 5ba0ad39ce70..5446b3e084c3 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -206,6 +206,23 @@ describe("ChatMarkdown streaming", () => { }); describe("ChatMarkdown math", () => { + it("keeps known skill tokens available for inline skill chips", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html.match(/data-markdown-copy="\$(?:browser|deploy)"/g)).toHaveLength(2); + expect(html).toContain("Browser"); + expect(html).toContain("Deploy"); + }); + it.each([true, false])( "renders inline and display LaTeX with accessible MathML when parseRawHtml=%s", (parseRawHtml) => { diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index a55a4a4ef7d8..722bdc52e9bb 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -242,6 +242,53 @@ export function shouldUseMarkdownFileBrowserPrimaryAction(input: { const EMPTY_MARKDOWN_SKILLS: ReadonlyArray> = []; const EMPTY_REMARK_PLUGINS: NonNullable = []; +type MarkdownTreeNode = { + type?: string; + value?: string; + children?: MarkdownTreeNode[]; + properties?: Record; +}; + +function restoreProtectedSkillTokens() { + return (tree: MarkdownTreeNode) => { + const visit = (node: MarkdownTreeNode): void => { + if (node.type === "text" && node.value) node.value = node.value.replaceAll("\uE000", "$"); + node.children?.forEach(visit); + }; + visit(tree); + }; +} + +function addMathMarkdownCopy() { + return (tree: MarkdownTreeNode) => { + const findSource = (node: MarkdownTreeNode): string | undefined => { + if (node.type === "element" && node.properties?.encoding === "application/x-tex") { + return node.children.find((child) => child.type === "text")?.value; + } + for (const child of node.children ?? []) { + const source = findSource(child); + if (source) return source; + } + return undefined; + }; + const visit = (node: MarkdownTreeNode): void => { + if (node.type === "element") { + const classes = Array.isArray(node.properties?.className) + ? node.properties.className + : typeof node.properties?.className === "string" + ? node.properties.className.split(/\s+/) + : []; + if (classes.includes("katex")) { + const source = findSource(node); + if (source) node.properties!["data-markdown-copy"] = `\\(${source}\\)`; + } + } + node.children?.forEach(visit); + }; + visit(tree); + }; +} + const ARTIFACT_TEMPLATE_ICON_BY_KIND = { document: FileTextIcon, presentation: PresentationIcon, @@ -418,7 +465,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ remarkNormalizeLinksAndTagInlineCode, ] satisfies NonNullable; -const CHAT_MARKDOWN_REHYPE_PLUGINS = [rehypeKatex] satisfies NonNullable< +const CHAT_MARKDOWN_REHYPE_PLUGINS = [rehypeKatex, addMathMarkdownCopy] satisfies NonNullable< ReactMarkdownOptions["rehypePlugins"] >; @@ -427,6 +474,7 @@ const CHAT_MARKDOWN_RAW_HTML_REHYPE_PLUGINS = [ rehypePreserveImageSourceMeta, [rehypeSanitize, CHAT_MARKDOWN_SANITIZE_SCHEMA], rehypeKatex, + addMathMarkdownCopy, ] satisfies NonNullable; /** GitHub's own five alert kinds, in its colors: the glyph names the urgency, the title says it. */ @@ -2913,12 +2961,30 @@ function ChatMarkdown({ } = useChatMarkdownState({ text, ...props }); const remarkPlugins = useMemo( () => [ - ...(lineBreaks ? CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS : CHAT_MARKDOWN_REMARK_PLUGINS), + ...(lineBreaks + ? [ + remarkGfm, + remarkMath, + restoreProtectedSkillTokens, + ...CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS.slice(3), + ] + : [ + remarkGfm, + remarkMath, + restoreProtectedSkillTokens, + ...CHAT_MARKDOWN_REMARK_PLUGINS.slice(3), + ]), ...extraRemarkPlugins, ], - [extraRemarkPlugins, lineBreaks], + [extraRemarkPlugins, lineBreaks, props.skills], ); - const normalizedText = useMemo(() => normalizeLatexDelimiters(text), [text]); + const normalizedText = useMemo(() => { + const names = new Set((props.skills ?? []).map((skill) => skill.name)); + return normalizeLatexDelimiters(text).replace( + /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s|$)/g, + (match, prefix: string, name: string) => (names.has(name) ? `${prefix}\uE000${name}` : match), + ); + }, [text, props.skills]); // react-markdown converts unparsed HTML nodes to text when skipHtml is false. // Keep that behavior explicit because literal mode depends on escaping the diff --git a/apps/web/src/markdown-clipboard.test.ts b/apps/web/src/markdown-clipboard.test.ts index ec44fcc22292..0e8cf06a16ad 100644 --- a/apps/web/src/markdown-clipboard.test.ts +++ b/apps/web/src/markdown-clipboard.test.ts @@ -128,6 +128,20 @@ describe("serializeRenderedMarkdownFragment", () => { expect(serializeRenderedMarkdownFragment(asNode(container))).toBe("run `git status` first"); }); + it("copies rendered math from its markdown-copy value", () => { + const container = new FakeElement("DIV").append( + new FakeElement("P").append( + new FakeText("Solve "), + new FakeElement("SPAN", [], { "data-markdown-copy": String.raw`\(x^2\)` }), + new FakeText(" now"), + ), + ); + + expect(serializeRenderedMarkdownFragment(asNode(container))).toBe( + String.raw`Solve \(x^2\) now`, + ); + }); + it("copies the complete quote, source, and comment instead of the comment-only chip label", () => { const citation = { version: 1 as const,