From 0d4aee48f05d1e981e38efe624d6cc6de7f4341f Mon Sep 17 00:00:00 2001 From: TheAnimatrix Date: Thu, 8 Oct 2026 06:32:35 +0530 Subject: [PATCH 1/5] feat(web): add opt-in rendering for TeX equations --- .../settings/DesktopClientSettings.test.ts | 1 + apps/web/package.json | 3 + apps/web/src/components/ChatMarkdown.test.tsx | 280 +++++++++++++++- apps/web/src/components/ChatMarkdown.tsx | 19 +- apps/web/src/components/chat/KatexMath.tsx | 45 +++ apps/web/src/components/chat/MarkdownMath.tsx | 43 +++ .../components/settings/SettingsPanels.tsx | 36 +++ .../src/components/settings/settingsSearch.ts | 6 + apps/web/src/markdown-clipboard.ts | 44 ++- apps/web/src/markdown-math.test.ts | 86 +++++ apps/web/src/markdown-math.ts | 305 ++++++++++++++++++ docs/user/appearance.md | 8 + packages/contracts/src/settings.ts | 2 + pnpm-lock.yaml | 9 + 14 files changed, 881 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/components/chat/KatexMath.tsx create mode 100644 apps/web/src/components/chat/MarkdownMath.tsx create mode 100644 apps/web/src/markdown-math.test.ts create mode 100644 apps/web/src/markdown-math.ts diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 75ff38f32260..b028a25433da 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -72,6 +72,7 @@ const clientSettings: ClientSettings = { pullRequestMergeMethodOverrides: {}, timestampFormat: "24-hour", wordWrap: true, + mathRenderingEnabled: false, }; const decodeClientSettingsJson = Schema.decodeEffect(Schema.fromJsonString(ClientSettingsSchema)); diff --git a/apps/web/package.json b/apps/web/package.json index 915466100bcf..7e34082e2cd0 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -53,6 +53,7 @@ "jose": "catalog:", "jsonc-parser": "3.3.1", "jszip": "3.10.1", + "katex": "^0.16.47", "lucide": "^0.564.0", "lucide-react": "^0.564.0", "mermaid": "^11.17.2", @@ -86,6 +87,8 @@ "babel-plugin-react-compiler": "1.0.0", "compression": "^1.8.1", "jsdom": "^30.0.1", + "mdast-util-from-markdown": "^2.0.3", + "micromark-util-types": "^2.0.2", "react-test-renderer": "19.2.6", "tailwindcss": "^4.0.0", "unified": "^11.0.5", diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index b1ceb33062c8..77df009c2b1e 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -2,24 +2,33 @@ import { EnvironmentId, type AuthEnvironmentScope } from "@t3tools/contracts"; import { act, type ComponentProps, type ReactNode } from "react"; +import { createRoot } from "react-dom/client"; import { renderToStaticMarkup } from "react-dom/server"; import { create, type ReactTestRenderer } from "react-test-renderer"; import { describe, expect, it, vi } from "vite-plus/test"; import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting"; +import { + chatMarkdownClipboardPayload, + serializeTableElementToCsv, + serializeTableElementToMarkdown, +} from "../markdown-clipboard"; import { GitHubIcon } from "./Icons"; import { Button } from "./ui/button"; import { setMarkdownTaskChecked } from "./files/filePreviewMode"; vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null })); vi.mock("../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) })); +const settingsOverrides = vi.hoisted(() => ({}) as Record); vi.mock("../hooks/useSettings", async (importOriginal) => { const actual = await importOriginal(); - const settings = actual.getClientSettings(); + const defaults = actual.getClientSettings(); return { ...actual, - useClientSettings: (select?: (value: typeof settings) => unknown) => - select ? select(settings) : settings, + useClientSettings: (select?: (value: typeof defaults) => unknown) => { + const settings = { ...defaults, ...settingsOverrides }; + return select ? select(settings) : settings; + }, }; }); vi.mock("./ui/tooltip", async () => { @@ -1034,3 +1043,268 @@ describe("ChatMarkdown Windows file links", () => { expect(html).not.toContain("chat-markdown-file-link"); }); }); + +describe("ChatMarkdown math", () => { + // A real assistant reply: `\(…\)` inline, including table cells, and `\[` / `\]` on their own lines. + const reply = [ + "**Your revised \\(G\\) value is reproducible.** Starting from the printed mean free path:", + "", + "\\[", + "r_1=3.88781\\times10^{-5}\\frac{5772}{6000}", + "=3.74007322\\times10^{-5}\\ \\mathrm m.", + "\\]", + "", + "| Quantity | Calculated | Accepted |", + "|---|---:|---:|", + "| \\(G\\) | \\(6.74731\\times10^{-11}\\) | \\(6.67430\\times10^{-11}\\) |", + "", + "Your equations imply", + "", + "\\[", + "\\sigma\\propto\\frac{1}{r_1T^4},\\qquad r_1\\propto T", + "\\quad\\Rightarrow\\quad \\sigma\\propto T^{-5}.", + "\\]", + ].join("\n"); + + async function renderMath( + text: string, + props: Partial> = {}, + ) { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + // KaTeX loads lazily; having the module already resolved lets its Suspense boundaries settle. + await import("./chat/KatexMath"); + const container = document.createElement("div"); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + const tex = (selector: string) => + [...container.querySelectorAll(selector)].map( + (node) => node.querySelector('annotation[encoding="application/x-tex"]')?.textContent, + ); + return { container, tex, unmount: () => act(async () => root.unmount()) }; + } + + it("renders \\(…\\) and \\[…\\] as math when math rendering is enabled", async () => { + settingsOverrides.mathRenderingEnabled = true; + const view = await renderMath(reply); + try { + expect(view.tex(".katex-display")).toEqual([ + "r_1=3.88781\\times10^{-5}\\frac{5772}{6000}\n=3.74007322\\times10^{-5}\\ \\mathrm m.", + "\\sigma\\propto\\frac{1}{r_1T^4},\\qquad r_1\\propto T\n\\quad\\Rightarrow\\quad \\sigma\\propto T^{-5}.", + ]); + expect(view.tex("td .katex")).toEqual([ + "G", + "6.74731\\times10^{-11}", + "6.67430\\times10^{-11}", + ]); + expect(view.container.querySelector("strong .katex")).not.toBeNull(); + } finally { + await view.unmount(); + delete settingsOverrides.mathRenderingEnabled; + } + }); + + it.each([true, false])("renders math with parseRawHtml=%s", async (parseRawHtml) => { + settingsOverrides.mathRenderingEnabled = true; + const view = await renderMath("Inline \\(a_1\\) here.\n\n\\[\nb^2\n\\]", { + parseRawHtml, + lineBreaks: !parseRawHtml, + }); + try { + expect(view.tex(".katex")).toEqual(["a_1", "b^2"]); + } finally { + await view.unmount(); + delete settingsOverrides.mathRenderingEnabled; + } + }); + + it("keeps code, escapes, currency, and unfinished formulas as written", async () => { + settingsOverrides.mathRenderingEnabled = true; + const view = await renderMath( + [ + "Costs $5 and $10, and `\\(x\\)` stays code, as does \\\\(y\\\\).", + "", + "```tex", + "\\[", + "z", + "\\]", + "```", + "", + "Still streaming \\(w and a [link](https://x.test/a_\\(b\\))", + ].join("\n"), + ); + try { + expect(view.container.querySelector(".katex")).toBeNull(); + expect(view.container.querySelector("code")?.textContent).toBe("\\(x\\)"); + expect(view.container.querySelector("pre code")?.textContent).toBe("\\[\nz\n\\]\n"); + expect(view.container.textContent).toContain("Costs $5 and $10"); + expect(view.container.querySelector("a")?.getAttribute("href")).toBe("https://x.test/a_(b)"); + } finally { + await view.unmount(); + delete settingsOverrides.mathRenderingEnabled; + } + }); + + it("shows the source of TeX that KaTeX rejects and copies formulas as TeX", async () => { + settingsOverrides.mathRenderingEnabled = true; + const view = await renderMath("Good \\(x^2\\), bad \\(\\frac{1\\)."); + try { + expect(view.tex(".katex")).toEqual(["x^2"]); + const copies = [...view.container.querySelectorAll("[data-markdown-copy]")].map((node) => + node.getAttribute("data-markdown-copy"), + ); + expect(copies).toEqual(["\\(x^2\\)", "\\(\\frac{1\\)"]); + expect(view.container.querySelector("code")?.textContent).toBe("\\frac{1"); + } finally { + await view.unmount(); + delete settingsOverrides.mathRenderingEnabled; + } + }); + + describe("clipboard", () => { + const displayTex = "r_1=3.88781\\times10^{-5}\\frac{5772}{6000}\n=3.74\\ \\mathrm m."; + const source = `Intro \\(a_1\\) text.\n\n\\[\n${displayTex}\n\\]\n\nOutro.`; + + async function copyFrom(select: (container: HTMLElement, range: Range) => void) { + settingsOverrides.mathRenderingEnabled = true; + const view = await renderMath(source); + const range = document.createRange(); + select(view.container, range); + const selection = { rangeCount: 1, getRangeAt: () => range } as unknown as Selection; + const payload = chatMarkdownClipboardPayload(selection); + await view.unmount(); + delete settingsOverrides.mathRenderingEnabled; + return payload; + } + + it.each([ + [ + "the whole formula", + (c: HTMLElement, r: Range) => + r.selectNode(c.querySelector(".katex-display")!.parentElement!), + ], + [ + "the formula's contents", + (c: HTMLElement, r: Range) => + r.selectNodeContents(c.querySelector(".katex-display")!.parentElement!), + ], + [ + "its visible .katex-html", + (c: HTMLElement, r: Range) => + r.selectNodeContents(c.querySelector(".katex-display .katex-html")!), + ], + [ + "one glyph run inside it", + (c: HTMLElement, r: Range) => + r.selectNodeContents(c.querySelector(".katex-display .katex-html .base")!), + ], + [ + "an inline formula's .katex-html", + (c: HTMLElement, r: Range) => r.selectNodeContents(c.querySelector("p .katex-html")!), + ], + ])("copies the TeX when %s is selected", async (name, select) => { + const payload = await copyFrom(select); + expect(payload?.text).toBe( + name === "an inline formula's .katex-html" ? "\\(a_1\\)" : `\\[\n${displayTex}\n\\]`, + ); + // The rich flavor is the same source, never KaTeX's hidden MathML or its glyph soup. + expect(payload?.html).not.toMatch(/katex|annotation|"); + }); + + it("keeps one coherent copy when a selection spans prose and formulas", async () => { + const payload = await copyFrom((container, range) => { + range.setStartBefore(container.querySelector("p")!); + range.setEndAfter(container.lastElementChild!); + }); + expect(payload?.text).toBe(`Intro \\(a_1\\) text.\n\n\\[\n${displayTex}\n\\]\n\nOutro.`); + expect(payload?.html).not.toMatch(/katex|annotation| { + settingsOverrides.mathRenderingEnabled = true; + const view = await renderMath( + "| Quantity | Value |\n|---|---|\n| \\(G\\) | \\(6.7\\times10^{-11}\\) |\n| **bold**, plain | so \\(\\sigma\\) here |", + ); + try { + const table = view.container.querySelector("table")!; + expect(serializeTableElementToCsv(table)).toBe( + 'Quantity,Value\n\\(G\\),\\(6.7\\times10^{-11}\\)\n"bold, plain",so \\(\\sigma\\) here', + ); + expect(serializeTableElementToMarkdown(table)).toContain( + "| \\(G\\) | \\(6.7\\times10^{-11}\\) |", + ); + } finally { + await view.unmount(); + delete settingsOverrides.mathRenderingEnabled; + } + }); + + it("keeps code that only looks like math as code while math rendering is on", async () => { + settingsOverrides.mathRenderingEnabled = true; + const view = await renderMath( + [ + "```math-inline", + "a_1", + "```", + "", + "```math-display", + "b^2", + "```", + "", + 'c^3 and d^4', + "", + '
e^5
', + "", + "Real \\(f\\).", + ].join("\n"), + ); + try { + expect(view.tex(".katex")).toEqual(["f"]); + const code = view.container.textContent ?? ""; + for (const source of ["a_1", "b^2", "c^3", "d^4", "e^5"]) expect(code).toContain(source); + } finally { + await view.unmount(); + delete settingsOverrides.mathRenderingEnabled; + } + }); + + it("points task checkboxes at their own source after math", async () => { + settingsOverrides.mathRenderingEnabled = true; + const text = "Do \\(a\\) first:\n\n\\[\nb\n\\]\n\n- [ ] one \\(c\\)\n- [x] two"; + const view = await renderMath(text); + try { + const markers = [...view.container.querySelectorAll("li")].map((item) => { + const offset = Number(item.getAttribute("data-task-marker-offset")); + return text.slice(offset, offset + 3); + }); + expect(markers).toEqual(["[ ]", "[x]"]); + } finally { + await view.unmount(); + delete settingsOverrides.mathRenderingEnabled; + } + }); + + it("does not typeset math-classed raw HTML while math rendering is off", async () => { + const view = await renderMath('x^2'); + try { + expect(view.container.querySelector(".katex")).toBeNull(); + expect(view.container.querySelector("code")?.textContent).toBe("x^2"); + } finally { + await view.unmount(); + } + }); + + it("leaves the source as ordinary Markdown while math rendering is off", async () => { + const view = await renderMath(reply); + try { + expect(view.container.querySelector(".katex")).toBeNull(); + expect(view.container.textContent).toContain("(G)"); + } finally { + await view.unmount(); + } + }); +}); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index e595efdc5a07..ad06a1173913 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -90,11 +90,13 @@ import { parseAssistantCitationHref } from "@t3tools/shared/assistantCitations"; import { parseComposerContextHref } from "@t3tools/shared/composerContextReferences"; import { parseThreadLinkHref, THREAD_LINK_PROTOCOL } from "@t3tools/shared/threadLinks"; import { AssistantCitationChip } from "./chat/AssistantCitationChip"; +import { MarkdownMath, isTexMath, useMathRendering } from "./chat/MarkdownMath"; import { MarkdownThreadLink } from "./chat/MarkdownThreadLink"; import remarkGfm from "remark-gfm"; import type { Processor } from "unified"; import { isWindowsAbsolutePath } from "@t3tools/shared/path"; import { remarkGithubAlerts } from "../markdown-github-alerts"; +import { remarkTexMath } from "../markdown-math"; import { artifactTemplateFromHastProperties, CODEX_ARTIFACT_TEMPLATE_HAST_PROPERTIES, @@ -3404,9 +3406,13 @@ const CHAT_MARKDOWN_COMPONENTS = { ); }, code: function MarkdownCode({ node, children, className, ...props }) { - const { cwd, imageBaseDir, inlineCodeFileLinkMetaByText, fileLinkChip } = use( + const { cwd, imageBaseDir, inlineCodeFileLinkMetaByText, fileLinkChip, text } = use( ChatMarkdownRendererContext, ); + const mathRendering = useMathRendering(); + if (mathRendering && isTexMath(node, text, false)) { + return ; + } if (node?.properties?.dataInlineCode != null) { const codeText = nodeToPlainText(children); const fileLinkMeta = @@ -3565,10 +3571,14 @@ const CHAT_MARKDOWN_COMPONENTS = { const { resolvedTheme, diffThemeName, expandMedia, isStreaming, onRunShellCommand, text } = use( ChatMarkdownRendererContext, ); + const mathRendering = useMathRendering(); const codeBlock = extractCodeBlock(children); if (!codeBlock) { return
{children}
; } + if (mathRendering && isTexMath(node, text, true)) { + return ; + } const language = extractFenceLanguage(codeBlock.className); const fenceTitle = extractFenceTitle(extractPreCodeMeta(node)); @@ -3643,17 +3653,22 @@ function ChatMarkdown({ localMediaPreview, setLocalMediaPreview, } = useChatMarkdownState({ text, ...props }); + // Off by default; when on, only text that could hold `\(` or `\[` pays for the math syntax. + const parseMath = useMathRendering() && /\\[([]/.test(text); + // The incremental parser reuses a cached prefix and does not know about extra syntax. const incrementalParsing = props.isStreaming === true && extraRemarkPlugins.length === 0 && + !parseMath && /(?:^|\n) {0,3}(?:`{3}|~{3})/.test(text); const remarkPlugins = useMemo( () => [ ...(lineBreaks ? CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS : CHAT_MARKDOWN_REMARK_PLUGINS), ...extraRemarkPlugins, + ...(parseMath ? [remarkTexMath] : []), ...(incrementalParsing ? [createIncrementalMarkdownPlugin()] : []), ], - [extraRemarkPlugins, incrementalParsing, lineBreaks], + [extraRemarkPlugins, incrementalParsing, lineBreaks, parseMath], ); // react-markdown converts unparsed HTML nodes to text when skipHtml is false. diff --git a/apps/web/src/components/chat/KatexMath.tsx b/apps/web/src/components/chat/KatexMath.tsx new file mode 100644 index 000000000000..594aa3b534e8 --- /dev/null +++ b/apps/web/src/components/chat/KatexMath.tsx @@ -0,0 +1,45 @@ +import katex from "katex"; +import "katex/dist/katex.min.css"; +import type { ReactNode } from "react"; + +import { texMathCopyText } from "../../markdown-math"; + +/** + * The only module that imports KaTeX and its stylesheet, so both load together + * the first time a formula is shown. Mermaid already ships the same KaTeX build, + * and the bundler shares it between the two lazy chunks. Input is model output, + * so `trust` stays off; a formula KaTeX rejects shows its source instead. + */ +export default function KatexMath({ + tex, + display, + fallback, +}: { + tex: string; + display: boolean; + fallback: ReactNode; +}) { + let html: string; + try { + html = katex.renderToString(tex, { + displayMode: display, + throwOnError: true, + trust: false, + strict: "ignore", + maxSize: 20, + maxExpand: 1000, + }); + } catch { + return fallback; + } + const copy = texMathCopyText(tex, display); + return display ? ( +
+ ) : ( + + ); +} diff --git a/apps/web/src/components/chat/MarkdownMath.tsx b/apps/web/src/components/chat/MarkdownMath.tsx new file mode 100644 index 000000000000..c01aad818882 --- /dev/null +++ b/apps/web/src/components/chat/MarkdownMath.tsx @@ -0,0 +1,43 @@ +import { lazy, Suspense } from "react"; + +import { useClientSettings } from "../../hooks/useSettings"; +import { texMathCopyText } from "../../markdown-math"; + +const KatexMath = lazy(() => import("./KatexMath")); + +/** Whether the opt-in math rendering setting is on. */ +export function useMathRendering(): boolean { + return useClientSettings((settings) => settings.mathRenderingEnabled); +} + +/** + * Whether a code element came from the math syntax rather than from a fence or + * the author's raw HTML, which can carry the same `language-math-*` class. The + * source at the element's position is what tells them apart: the math syntax + * always begins with its `\(` or `\[` opener, which neither of those can. + */ +export function isTexMath( + node: { position?: { start: { offset?: number | undefined } } | undefined } | undefined, + source: string, + display: boolean, +): boolean { + const offset = node?.position?.start.offset; + return typeof offset === "number" && source.startsWith(display ? "\\[" : "\\(", offset); +} + +/** Shows the TeX source until KaTeX has loaded, and again if it cannot typeset it. */ +export function MarkdownMath({ tex, display }: { tex: string; display: boolean }) { + const copy = texMathCopyText(tex, display); + const source = display ? ( +
+      {tex}
+    
+ ) : ( + {tex} + ); + return ( + + + + ); +} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index bee57d21b253..f59c87bed5a2 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -591,6 +591,9 @@ export function useSettingsRestore(onRestored?: () => void) { ? ["Snooze limited threads"] : []), ...(settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? ["Word wrap"] : []), + ...(settings.mathRenderingEnabled !== DEFAULT_UNIFIED_SETTINGS.mathRenderingEnabled + ? ["Render math"] + : []), ...(settings.persistComposerContextStrip !== DEFAULT_UNIFIED_SETTINGS.persistComposerContextStrip ? ["Composer context"] @@ -719,6 +722,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.notificationMode, settings.inAppNotificationsEnabled, settings.wordWrap, + settings.mathRenderingEnabled, followSystem, theme, themeHalves, @@ -795,6 +799,7 @@ export function useSettingsRestore(onRestored?: () => void) { notificationMode: DEFAULT_UNIFIED_SETTINGS.notificationMode, inAppNotificationsEnabled: DEFAULT_UNIFIED_SETTINGS.inAppNotificationsEnabled, wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap, + mathRenderingEnabled: DEFAULT_UNIFIED_SETTINGS.mathRenderingEnabled, persistComposerContextStrip: DEFAULT_UNIFIED_SETTINGS.persistComposerContextStrip, diffFilesCollapsed: DEFAULT_UNIFIED_SETTINGS.diffFilesCollapsed, diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace, @@ -1736,6 +1741,36 @@ function WordWrapRow() { ); } +function MathRenderingRow() { + const settings = useScopedSettings(); + const updateSettings = useUpdateScopedSettings(); + return ( + + updateSettings({ + mathRenderingEnabled: DEFAULT_UNIFIED_SETTINGS.mathRenderingEnabled, + }) + } + /> + ) : null + } + control={ + updateSettings({ mathRenderingEnabled: Boolean(checked) })} + aria-label="Render math in chat" + /> + } + /> + ); +} + function FontSettingsGroup() { return ( <> @@ -1834,6 +1869,7 @@ function TypographySection() { > {advanced ? : } + ); } diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 853886d81fcd..f1c4a99ecaa5 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -279,6 +279,12 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/appearance", searchTerms: ["long lines code blocks tables diffs file previews"], }, + { + id: "render-math", + title: "Render math", + to: "/settings/appearance", + searchTerms: ["latex tex katex equations formulas"], + }, { id: "composer-context", title: "Composer context", diff --git a/apps/web/src/markdown-clipboard.ts b/apps/web/src/markdown-clipboard.ts index 39b941801638..942edc1b1c62 100644 --- a/apps/web/src/markdown-clipboard.ts +++ b/apps/web/src/markdown-clipboard.ts @@ -346,6 +346,17 @@ export function serializeTableElementToMarkdown(table: Element): string { return serializeTable(table).trim(); } +/** A cell's text, with each formula as its TeX instead of KaTeX's glyph and MathML text. */ +function csvCellText(cell: Element): string { + if (!cell.querySelector(".katex")) return cell.textContent ?? ""; + const copy = cell.cloneNode(true) as Element; + for (const wrapper of copy.querySelectorAll("[data-markdown-copy]")) { + if (mathWrapperOf(wrapper)) + wrapper.replaceWith(wrapper.getAttribute("data-markdown-copy")?.trim() ?? ""); + } + return copy.textContent ?? ""; +} + function csvCell(value: string): string { const normalized = value.replace(/\s+/g, " ").trim(); return /[",\n]/.test(normalized) ? `"${normalized.replaceAll('"', '""')}"` : normalized; @@ -359,12 +370,35 @@ export function serializeTableElementToCsv(table: Element): string { (cell) => cell.tagName === "TH" || cell.tagName === "TD", ); if (cells.length === 0) continue; - lines.push(cells.map((cell) => csvCell(cell.textContent ?? "")).join(",")); + lines.push(cells.map((cell) => csvCell(csvCellText(cell))).join(",")); } return lines.join("\n"); } +/** + * A rendered formula (`data-markdown-copy` around KaTeX's `.katex`) pastes as + * its TeX source. KaTeX's visible half is `aria-hidden` and its MathML half + * carries the TeX a second time, so neither survives into a paste target + * without the stylesheet. + */ +function mathWrapperOf(element: Element | null): Element | null { + const wrapper = element?.closest("[data-markdown-copy]") ?? null; + return wrapper?.querySelector(".katex") ? wrapper : null; +} + +function mathSourceElement(wrapper: Element): Element { + const code = document.createElement("code"); + code.textContent = wrapper.getAttribute("data-markdown-copy")?.trim() ?? ""; + if (!wrapper.querySelector(".katex-display")) return code; + const pre = document.createElement("pre"); + pre.append(code); + return pre; +} + function sanitizedHtmlFrom(container: Element): string { + for (const wrapper of container.querySelectorAll("[data-markdown-copy]")) { + if (mathWrapperOf(wrapper)) wrapper.replaceWith(mathSourceElement(wrapper)); + } for (const node of container.querySelectorAll(SANITIZED_HTML_SELECTOR)) { if ( node.classList.contains("chat-markdown-file-link") || @@ -393,6 +427,14 @@ export function chatMarkdownClipboardPayload( const ancestor = range.commonAncestorContainer; const ancestorElement = ancestor.nodeType === Node.ELEMENT_NODE ? (ancestor as Element) : ancestor.parentElement; + // A range inside one formula clones only KaTeX's glyph markup, which has lost + // the wrapper that knows the TeX. + const math = mathWrapperOf(ancestorElement); + if (math) { + texts.push(math.getAttribute("data-markdown-copy")?.trim() ?? ""); + htmls.push(`${mathSourceElement(math).outerHTML}`); + continue; + } if (ancestorElement?.closest("pre")) { const text = range.toString(); if (text) { diff --git a/apps/web/src/markdown-math.test.ts b/apps/web/src/markdown-math.test.ts new file mode 100644 index 000000000000..aeeda0c43aac --- /dev/null +++ b/apps/web/src/markdown-math.test.ts @@ -0,0 +1,86 @@ +import { fromMarkdown } from "mdast-util-from-markdown"; +import { describe, expect, it } from "vite-plus/test"; + +import { texMathFromMarkdown, texMathSyntax } from "./markdown-math"; + +type Node = { type: string; value?: string; children?: Node[] }; + +/** Math nodes in document order, as `type:value`. */ +function mathIn(source: string): string[] { + const found: string[] = []; + const visit = (node: Node) => { + if (node.type === "inlineMath" || node.type === "math") + found.push(`${node.type}:${node.value}`); + node.children?.forEach(visit); + }; + visit( + fromMarkdown(source, { + extensions: [texMathSyntax], + mdastExtensions: [texMathFromMarkdown], + }) as Node, + ); + return found; +} + +describe("TeX math syntax", () => { + it("reads \\(…\\) inline, including TeX backslashes and underscores", () => { + expect(mathIn("so \\(\\sigma\\propto r_1T^{-5}\\) holds")).toEqual([ + "inlineMath:\\sigma\\propto r_1T^{-5}", + ]); + // `\\` is a TeX line break, so the `)` after it does not close the formula. + expect(mathIn("\\(a\\\\ b\\) c")).toEqual(["inlineMath:a\\\\ b"]); + expect(mathIn("\\(a\\\\) c")).toEqual([]); + expect(mathIn("| \\(G\\) | \\(x\\) |\n|---|---|\n| \\(a_1\\) | b |")).toEqual([ + "inlineMath:G", + "inlineMath:x", + "inlineMath:a_1", + ]); + }); + + it("reads \\[ and \\] lines as display math, keeping lines that look like Markdown", () => { + expect(mathIn("\\[\na=b\n\\quad\\Rightarrow\\quad c\n\\]")).toEqual([ + "math:a=b\n\\quad\\Rightarrow\\quad c", + ]); + expect(mathIn("text\n\n\\[\n- a\n# b\n\n> c\n\\]\n\nafter")).toEqual(["math:- a\n# b\n\n> c"]); + expect(mathIn("> \\[\n> x\n> \\]")).toEqual(["math:x"]); + expect(mathIn("- item\n\n \\[\n x^2\n \\]")).toEqual(["math:x^2"]); + }); + + it.each([ + ["an unclosed inline opener", "costs \\(x and more"], + ["an empty formula", "\\(\\) text"], + ["an escaped backslash", "\\\\(x\\\\)"], + ["a code span", "`\\(x\\)` and \\(a `\\)` b"], + ["a fenced block", "```\n\\[\nx\n\\]\n```"], + ["an indented block", " \\[\n x\n \\]"], + ["a one-line display formula", "\\[ x \\]"], + ["a citation", "see \\[1\\] and \\[2\\]"], + ["an unclosed display block", "\\[\nx = 1\n"], + ["a display block cut by a lazy line", "> \\[\n> x\nlazy\n> \\]"], + ["a link target", "[a](https://x.test/\\(b\\))"], + ])("leaves %s alone", (_, source) => { + expect(mathIn(source)).toEqual([]); + }); + + it("closes an inline formula at its own closer when openers repeat", () => { + expect(mathIn("\\(a \\(b\\) c")).toEqual(["inlineMath:b"]); + }); + + it("scans a message of unmatched openers in linear time", () => { + const source = "\\( x ".repeat(20_000); + const start = performance.now(); + expect(mathIn(source)).toEqual([]); + expect(performance.now() - start).toBeLessThan(2000); + }); + + it("ends an unfinished display block at the next opener", () => { + expect(mathIn("\\[\na\n\\[\nb\n\\]")).toEqual(["math:b"]); + }); + + it("scans a message of unmatched display openers in linear time", () => { + const source = "\\[\nx\n".repeat(3200); + const start = performance.now(); + expect(mathIn(source)).toEqual([]); + expect(performance.now() - start).toBeLessThan(1000); + }); +}); diff --git a/apps/web/src/markdown-math.ts b/apps/web/src/markdown-math.ts new file mode 100644 index 000000000000..a3177705c13d --- /dev/null +++ b/apps/web/src/markdown-math.ts @@ -0,0 +1,305 @@ +import type { Extension as FromMarkdownExtension } from "mdast-util-from-markdown"; +import type { + Code, + Construct, + Extension as MicromarkExtension, + State, + TokenizeContext, + Tokenizer, +} from "micromark-util-types"; +import type { Processor } from "unified"; + +declare module "micromark-util-types" { + interface TokenTypeMap { + texMathText: "texMathText"; + texMathTextSequence: "texMathTextSequence"; + texMathTextData: "texMathTextData"; + texMathFlow: "texMathFlow"; + texMathFlowFence: "texMathFlowFence"; + texMathFlowValue: "texMathFlowValue"; + } +} + +// The two TeX delimiters models write: `\(…\)` inline, and `\[` / `\]` each on +// their own line for display. CommonMark reads `\(` and `\[` as escapes, so the +// backslash is gone before any later pass could see it; these constructs run +// first. Everything else in the chat parser (code spans, fences, links, `$`) is +// left alone, and `\[x\]` on one line stays prose, which keeps `\[1\]` citations. +const BACKSLASH = 92; +const LEFT_PAREN = 40; +const RIGHT_PAREN = 41; +const LEFT_BRACKET = 91; +const RIGHT_BRACKET = 93; +const GRAVE_ACCENT = 96; + +// micromark encodes tabs, virtual spaces, and line endings as negative codes. +const isLineEnding = (code: Code) => code !== null && code < -2; +const isSpace = (code: Code) => code === -2 || code === -1 || code === 32; + +/** + * `\(…\)` in running text. A formula never contains a backtick, which would open + * a code span, or a second `\(`, so a stray opener gives up at the next one and + * a message full of them is still scanned once. + */ +const texMathText: Construct = { + name: "texMathText", + tokenize(effects, ok, nok) { + let empty = true; + const closing: Construct = { + partial: true, + tokenize(effects, ok, nok) { + return function start(code) { + effects.enter("texMathTextSequence"); + effects.consume(code); + return function end(code) { + if (code !== RIGHT_PAREN || empty) return nok(code); + effects.consume(code); + effects.exit("texMathTextSequence"); + return ok; + }; + }; + }, + }; + + return function start(code) { + effects.enter("texMathText"); + effects.enter("texMathTextSequence"); + effects.consume(code); + return function open(code) { + if (code !== LEFT_PAREN) return nok(code); + effects.consume(code); + effects.exit("texMathTextSequence"); + return between; + }; + }; + + function between(code: Code): State | undefined { + if (code === null || code === GRAVE_ACCENT) return nok(code); + if (isLineEnding(code)) { + effects.enter("lineEnding"); + effects.consume(code); + effects.exit("lineEnding"); + return between; + } + if (code === BACKSLASH) { + return effects.attempt(closing, done, escape)(code); + } + effects.enter("texMathTextData"); + return data(code); + } + + function data(code: Code): State | undefined { + if (code === null || code === GRAVE_ACCENT || code === BACKSLASH || isLineEnding(code)) { + effects.exit("texMathTextData"); + return between(code); + } + empty = false; + effects.consume(code); + return data; + } + + // A backslash pair is one unit, so `\\)` is a line break then `)`, not a closer. + function escape(code: Code): State | undefined { + effects.enter("texMathTextData"); + empty = false; + effects.consume(code); + return function afterBackslash(code) { + if (code === LEFT_PAREN) return nok(code); + if (code === null || code === GRAVE_ACCENT || isLineEnding(code)) { + effects.exit("texMathTextData"); + return between(code); + } + effects.consume(code); + return data; + }; + } + + function done(code: Code): State | undefined { + effects.exit("texMathText"); + return ok(code); + } + }, +}; + +/** + * A line holding only `\[`, up to a line holding only `\]`. Like a code fence, + * it must close; an unfinished formula (a reply still streaming) stays prose. + */ +const tokenizeTexMathFlow: Tokenizer = function (effects, ok, nok) { + const nonLazyLine: Construct = { + partial: true, + tokenize(this: TokenizeContext, effects, ok, nok) { + return (code) => { + effects.enter("lineEnding"); + effects.consume(code); + effects.exit("lineEnding"); + return (next) => (this.parser.lazy[this.now().line] ? nok(next) : ok(next)); + }; + }, + }; + // A line holding only `\]` closes the formula. A line holding only `\[` can + // never be inside one, so giving up there, as the inline construct does at a + // second `\(`, keeps a run of unclosed openers linear. + const fenceLine = (bracket: number): Construct => ({ + partial: true, + tokenize(effects, ok, nok) { + return function start(code) { + effects.enter("texMathFlowFence"); + return indent(code); + }; + + function indent(code: Code): State | undefined { + if (isSpace(code)) { + effects.consume(code); + return indent; + } + if (code !== BACKSLASH) return nok(code); + effects.consume(code); + return function second(code) { + if (code !== bracket) return nok(code); + effects.consume(code); + return fenceEnd; + }; + } + + function fenceEnd(code: Code): State | undefined { + if (isSpace(code)) { + effects.consume(code); + return fenceEnd; + } + if (code !== null && !isLineEnding(code)) return nok(code); + effects.exit("texMathFlowFence"); + return ok(code); + } + }, + }); + const closingFence = fenceLine(RIGHT_BRACKET); + const openingFence = fenceLine(LEFT_BRACKET); + + return function start(code) { + effects.enter("texMathFlow"); + effects.enter("texMathFlowFence"); + effects.consume(code); + return function open(code) { + if (code !== LEFT_BRACKET) return nok(code); + effects.consume(code); + return openEnd; + }; + }; + + function openEnd(code: Code): State | undefined { + if (isSpace(code)) { + effects.consume(code); + return openEnd; + } + if (!isLineEnding(code)) return nok(code); + effects.exit("texMathFlowFence"); + return effects.attempt(nonLazyLine, lineStart, nok)(code); + } + + function lineStart(code: Code): State | undefined { + return effects.attempt(closingFence, close, notOpening)(code); + } + + function notOpening(code: Code): State | undefined { + return effects.attempt(openingFence, nok, content)(code); + } + + function content(code: Code): State | undefined { + if (code === null) return nok(code); + if (isLineEnding(code)) return effects.attempt(nonLazyLine, lineStart, nok)(code); + effects.enter("texMathFlowValue"); + return value(code); + } + + function value(code: Code): State | undefined { + if (code === null || isLineEnding(code)) { + effects.exit("texMathFlowValue"); + return content(code); + } + effects.consume(code); + return value; + } + + function close(code: Code): State | undefined { + effects.exit("texMathFlow"); + return ok(code); + } +}; + +export const texMathSyntax: MicromarkExtension = { + text: { [BACKSLASH]: texMathText }, + flow: { [BACKSLASH]: { name: "texMathFlow", tokenize: tokenizeTexMathFlow, concrete: true } }, +}; + +// The mdast nodes carry the hast shape react-markdown renders, the same one +// `remark-math` produces: `` inline and +// `
` for display. `language-*` is
+// already allowed by the sanitizer, so no schema change is needed.
+export const texMathFromMarkdown: FromMarkdownExtension = {
+  enter: {
+    texMathText(token) {
+      this.enter({ type: "inlineMath", value: "", data: { hName: "code" } } as never, token);
+      this.buffer();
+    },
+    texMathFlow(token) {
+      this.enter({ type: "math", value: "", data: { hName: "pre" } } as never, token);
+      this.buffer();
+    },
+  },
+  exit: {
+    texMathTextData(token) {
+      this.config.enter.data!.call(this, token);
+      this.config.exit.data!.call(this, token);
+    },
+    texMathFlowValue(token) {
+      this.config.enter.data!.call(this, token);
+      this.config.exit.data!.call(this, token);
+    },
+    texMathText(token) {
+      const value = this.resume().replace(/\r\n?/g, "\n");
+      const node = this.stack.at(-1) as unknown as {
+        value: string;
+        data: Record;
+      };
+      node.value = value;
+      node.data.hProperties = { className: ["language-math-inline"] };
+      node.data.hChildren = [{ type: "text", value }];
+      this.exit(token);
+    },
+    texMathFlow(token) {
+      const value = this.resume()
+        .replace(/\r\n?/g, "\n")
+        .replace(/^\n|\n$/g, "");
+      const node = this.stack.at(-1) as unknown as {
+        value: string;
+        data: Record;
+      };
+      node.value = value;
+      node.data.hChildren = [
+        {
+          type: "element",
+          tagName: "code",
+          properties: { className: ["language-math-display"] },
+          children: [{ type: "text", value }],
+        },
+      ];
+      this.exit(token);
+    },
+  },
+};
+
+/** unified plugin: registers both extensions on the remark-parse processor. */
+export const remarkTexMath = function (this: Processor) {
+  const data = this.data() as {
+    micromarkExtensions?: MicromarkExtension[];
+    fromMarkdownExtensions?: FromMarkdownExtension[];
+  };
+  (data.micromarkExtensions ??= []).push(texMathSyntax);
+  (data.fromMarkdownExtensions ??= []).push(texMathFromMarkdown);
+};
+
+/** The TeX source of a formula as Markdown, which is what copying it should give. */
+export function texMathCopyText(tex: string, display: boolean): string {
+  return display ? `\\[\n${tex}\n\\]\n\n` : `\\(${tex}\\)`;
+}
diff --git a/docs/user/appearance.md b/docs/user/appearance.md
index ec02a28ed787..acc7f5cf33d8 100644
--- a/docs/user/appearance.md
+++ b/docs/user/appearance.md
@@ -29,6 +29,14 @@ The controls retreat as the composer docks after you send the first message.
 Turn on **Composer context** to keep those controls visible after the thread starts. This preference
 applies to the web and desktop clients.
 
+## Math
+
+Turn on **Render math** to typeset TeX in chat messages and Markdown previews: `\(…\)` for inline
+formulas, and `\[` and `\]` each on their own line around a display formula. Anything else, including
+`$…$` and `\[…\]` on a single line, stays as written. A formula that is unfinished or invalid shows
+its TeX, and copying a formula gives you its TeX. This preference applies to the web and desktop
+clients.
+
 ## Motion
 
 The main sidebar, right panel, and terminal drawer open and close immediately by default. Move the
diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts
index 942fbb915ffe..26e458568447 100644
--- a/packages/contracts/src/settings.ts
+++ b/packages/contracts/src/settings.ts
@@ -504,6 +504,7 @@ export const ClientSettingsSchema = Schema.Struct({
   snapShotFlash: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
   snapShotAnimations: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
   wordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
+  mathRenderingEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
 });
 export type ClientSettings = typeof ClientSettingsSchema.Type;
 
@@ -1904,5 +1905,6 @@ export const ClientSettingsPatch = Schema.Struct({
   snapShotFlash: Schema.optionalKey(Schema.Boolean),
   snapShotAnimations: Schema.optionalKey(Schema.Boolean),
   wordWrap: Schema.optionalKey(Schema.Boolean),
+  mathRenderingEnabled: Schema.optionalKey(Schema.Boolean),
 });
 export type ClientSettingsPatch = typeof ClientSettingsPatch.Type;
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 8a54dc0d1fa2..60584535d5f2 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -750,6 +750,9 @@ importers:
       jszip:
         specifier: 3.10.1
         version: 3.10.1
+      katex:
+        specifier: ^0.16.47
+        version: 0.16.47
       lucide:
         specifier: ^0.564.0
         version: 0.564.0
@@ -844,6 +847,12 @@ importers:
       jsdom:
         specifier: ^30.0.1
         version: 30.1.0(@noble/hashes@1.8.0)
+      mdast-util-from-markdown:
+        specifier: ^2.0.3
+        version: 2.0.3
+      micromark-util-types:
+        specifier: ^2.0.2
+        version: 2.0.2
       react-test-renderer:
         specifier: 19.2.6
         version: 19.2.6(react@19.2.6)

From 58538268ddf577ee4c101c7bef54660a6bdab77e Mon Sep 17 00:00:00 2001
From: TheAnimatrix 
Date: Thu, 8 Oct 2026 06:46:24 +0530
Subject: [PATCH 2/5] fix(web): preserve file links containing rendered math
 when copying

---
 apps/web/src/components/ChatMarkdown.test.tsx | 45 +++++++++++++++++++
 apps/web/src/markdown-clipboard.ts            | 11 ++---
 2 files changed, 51 insertions(+), 5 deletions(-)

diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx
index 77df009c2b1e..d8cf122b9bc5 100644
--- a/apps/web/src/components/ChatMarkdown.test.tsx
+++ b/apps/web/src/components/ChatMarkdown.test.tsx
@@ -1224,6 +1224,51 @@ describe("ChatMarkdown math", () => {
     });
   });
 
+  it("copies a file link with math in its label as the link, its label text as text, and the formula as TeX", async () => {
+    settingsOverrides.mathRenderingEnabled = true;
+    const view = await renderMath("See [Report \\(x\\) notes](/tmp/project/src/main.ts).");
+    try {
+      const link = view.container.querySelector('[data-markdown-copy^="[Report"]')!;
+      const copy = (select: (range: Range) => void) => {
+        const range = document.createRange();
+        select(range);
+        const selection = { rangeCount: 1, getRangeAt: () => range } as unknown as Selection;
+        return chatMarkdownClipboardPayload(selection);
+      };
+
+      const label = copy((range) => range.selectNodeContents(link.firstChild!));
+      expect(label?.text).toBe("Report");
+      expect(label?.html).not.toContain("");
+
+      const whole = copy((range) => range.selectNode(link));
+      expect(whole?.text).toBe("[Report \\(x\\) notes](/tmp/project/src/main.ts)");
+      expect(whole?.html).toContain("Report \\(x\\) notes");
+      expect(whole?.html).not.toMatch(/katex|annotation| range.selectNodeContents(link.querySelector(".katex-html")!));
+      expect(formula?.text).toBe("\\(x\\)");
+      expect(formula?.html).toBe('\\(x\\)');
+    } finally {
+      await view.unmount();
+      delete settingsOverrides.mathRenderingEnabled;
+    }
+  });
+
+  it("copies a table cell's file link with math in its label as its label with the TeX", async () => {
+    settingsOverrides.mathRenderingEnabled = true;
+    const view = await renderMath(
+      "| File |\n|---|\n| [Report \\(x\\) notes](/tmp/project/src/main.ts) |",
+    );
+    try {
+      expect(serializeTableElementToCsv(view.container.querySelector("table")!)).toBe(
+        "File\nReport \\(x\\) notes main.ts",
+      );
+    } finally {
+      await view.unmount();
+      delete settingsOverrides.mathRenderingEnabled;
+    }
+  });
+
   it("copies table math as TeX in CSV and Markdown while ordinary cells stay as before", async () => {
     settingsOverrides.mathRenderingEnabled = true;
     const view = await renderMath(
diff --git a/apps/web/src/markdown-clipboard.ts b/apps/web/src/markdown-clipboard.ts
index 942edc1b1c62..acdabcdf7f11 100644
--- a/apps/web/src/markdown-clipboard.ts
+++ b/apps/web/src/markdown-clipboard.ts
@@ -376,14 +376,15 @@ export function serializeTableElementToCsv(table: Element): string {
 }
 
 /**
- * A rendered formula (`data-markdown-copy` around KaTeX's `.katex`) pastes as
- * its TeX source. KaTeX's visible half is `aria-hidden` and its MathML half
- * carries the TeX a second time, so neither survives into a paste target
- * without the stylesheet.
+ * A rendered formula (`data-markdown-copy` directly around KaTeX's `.katex` or
+ * `.katex-display`) pastes as its TeX source. KaTeX's visible half is
+ * `aria-hidden` and its MathML half carries the TeX a second time, so neither
+ * survives into a paste target without the stylesheet. A wrapper that merely
+ * holds a formula, like a file link with math in its label, is not one.
  */
 function mathWrapperOf(element: Element | null): Element | null {
   const wrapper = element?.closest("[data-markdown-copy]") ?? null;
-  return wrapper?.querySelector(".katex") ? wrapper : null;
+  return wrapper?.querySelector(":scope > .katex, :scope > .katex-display") ? wrapper : null;
 }
 
 function mathSourceElement(wrapper: Element): Element {

From 83c1993ee3d28fe9aacd81d74b9c2fefb3c521f4 Mon Sep 17 00:00:00 2001
From: TheAnimatrix 
Date: Thu, 8 Oct 2026 07:35:47 +0530
Subject: [PATCH 3/5] fix(web): keep math source stable when loading, copying,
 and citing

---
 apps/web/src/components/ChatMarkdown.test.tsx | 211 ++++++++++++++++++
 apps/web/src/components/ChatMarkdown.tsx      |   4 +-
 apps/web/src/components/chat/KatexMath.tsx    |   8 +-
 .../src/components/chat/MarkdownMath.test.tsx | 166 ++++++++++++++
 apps/web/src/components/chat/MarkdownMath.tsx |  38 +++-
 .../components/settings/SettingsPanels.tsx    |   4 +-
 .../src/lib/assistantTextSelection.test.ts    |  15 +-
 apps/web/src/lib/assistantTextSelection.ts    |  60 +++--
 apps/web/src/markdown-clipboard.ts            |  36 +--
 apps/web/src/markdown-math.ts                 |  21 +-
 docs/user/appearance.md                       |   3 +-
 11 files changed, 503 insertions(+), 63 deletions(-)
 create mode 100644 apps/web/src/components/chat/MarkdownMath.test.tsx

diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx
index d8cf122b9bc5..1fbb1214f5dc 100644
--- a/apps/web/src/components/ChatMarkdown.test.tsx
+++ b/apps/web/src/components/ChatMarkdown.test.tsx
@@ -7,6 +7,10 @@ import { renderToStaticMarkup } from "react-dom/server";
 import { create, type ReactTestRenderer } from "react-test-renderer";
 import { describe, expect, it, vi } from "vite-plus/test";
 
+import {
+  captureAssistantTextSelection,
+  resolveAssistantCitationRange,
+} from "../lib/assistantTextSelection";
 import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting";
 import {
   chatMarkdownClipboardPayload,
@@ -1044,6 +1048,8 @@ describe("ChatMarkdown Windows file links", () => {
   });
 });
 
+type CitedParts = { prose: Text[]; formulas: Element[]; html: Element[] };
+
 describe("ChatMarkdown math", () => {
   // A real assistant reply: `\(…\)` inline, including table cells, and `\[` / `\]` on their own lines.
   const reply = [
@@ -1224,6 +1230,151 @@ describe("ChatMarkdown math", () => {
     });
   });
 
+  describe("assistant citations", () => {
+    const sentence = "Your revised \\(G\\) value is \\(6.7\\times10^{-11}\\) today.";
+
+    // Browsers put selection endpoints in KaTeX's glyph text, so tests do too.
+    const textNodes = (node: Node) => {
+      const walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT);
+      const found: Text[] = [];
+      while (walker.nextNode()) found.push(walker.currentNode as Text);
+      return found;
+    };
+
+    async function citable(text: string) {
+      settingsOverrides.mathRenderingEnabled = true;
+      const view = await renderMath(text);
+      view.container.dataset.assistantCitationSource = "assistant";
+      const capture = (select: (range: Range) => void) => {
+        const range = document.createRange();
+        select(range);
+        const selection = {
+          isCollapsed: range.collapsed,
+          rangeCount: 1,
+          getRangeAt: () => range,
+        } as unknown as Selection;
+        return captureAssistantTextSelection(view.container, selection);
+      };
+      return {
+        ...view,
+        capture,
+        unmount: async () => {
+          await view.unmount();
+          delete settingsOverrides.mathRenderingEnabled;
+        },
+      };
+    }
+
+    it.each([
+      {
+        name: "the whole paragraph",
+        quote: sentence,
+        select: (range: Range, { prose }: CitedParts) =>
+          range.selectNodeContents(prose[0]!.parentElement!),
+        visible: (range: Range, { prose }: CitedParts) => {
+          range.setStart(prose[0]!, 0);
+          range.setEnd(prose.at(-1)!, prose.at(-1)!.length);
+        },
+      },
+      {
+        name: "a range starting inside a formula",
+        quote: "\\(G\\) value is \\(6.7\\times10^{-11}\\) today.",
+        select: (range: Range, { prose, html }: CitedParts) => {
+          range.setStart(textNodes(html[0]!)[0]!, 0);
+          range.setEnd(prose.at(-1)!, prose.at(-1)!.length);
+        },
+        visible: (range: Range, { prose, formulas }: CitedParts) => {
+          range.setStartBefore(formulas[0]!);
+          range.setEnd(prose.at(-1)!, prose.at(-1)!.length);
+        },
+      },
+      {
+        name: "a range ending inside a formula",
+        quote: "revised \\(G\\) value is \\(6.7\\times10^{-11}\\)",
+        select: (range: Range, { prose, html }: CitedParts) => {
+          range.setStart(prose[0]!, 5);
+          range.setEnd(textNodes(html[1]!)[1]!, 1);
+        },
+        visible: (range: Range, { prose, formulas }: CitedParts) => {
+          range.setStart(prose[0]!, 5);
+          range.setEndAfter(formulas[1]!);
+        },
+      },
+      {
+        name: "a range within one formula",
+        quote: "\\(6.7\\times10^{-11}\\)",
+        select: (range: Range, { html }: CitedParts) => {
+          const glyphs = textNodes(html[1]!);
+          range.setStart(glyphs[1]!, 1);
+          range.setEnd(glyphs.at(-1)!, 1);
+        },
+        visible: (range: Range, { formulas }: CitedParts) => {
+          range.setStartBefore(formulas[1]!);
+          range.setEndAfter(formulas[1]!);
+        },
+      },
+    ])("quotes $name as its source text and finds it again", async ({ quote, select, visible }) => {
+      const view = await citable(sentence);
+      try {
+        const paragraph = view.container.querySelector("p")!;
+        const parts: CitedParts = {
+          prose: textNodes(paragraph).filter((node) => !node.parentElement!.closest(".katex")),
+          formulas: [...paragraph.querySelectorAll("[data-markdown-copy]")],
+          html: [...paragraph.querySelectorAll(".katex-html")],
+        };
+        const captured = view.capture((range) => select(range, parts));
+        expect(captured?.selector.text).toBe(quote);
+
+        // The saved quote resolves to what is on screen: whole formulas, never KaTeX's glyph nodes.
+        const found = resolveAssistantCitationRange(view.container, captured!.selector);
+        const expected = document.createRange();
+        visible(expected, parts);
+        expect(found?.compareBoundaryPoints(Range.START_TO_START, expected)).toBe(0);
+        expect(found?.compareBoundaryPoints(Range.END_TO_END, expected)).toBe(0);
+      } finally {
+        await view.unmount();
+      }
+    });
+
+    it("quotes prose, a display formula, and a file link with math in its label once each", async () => {
+      const view = await citable(
+        [
+          "Intro \\(a_1\\) text.",
+          "",
+          "\\[",
+          "r=1",
+          "\\]",
+          "",
+          "See [Report \\(x\\) notes](/tmp/project/src/main.ts) and `code`.",
+          "",
+          "Outro.",
+        ].join("\n"),
+      );
+      try {
+        const whole = view.capture((range) => range.selectNodeContents(view.container));
+        // The file chip is a control, so its label stays out, and the link itself is not a formula.
+        expect(whole?.selector.text.replace(/\s+/g, " ")).toBe(
+          "Intro \\(a_1\\) text. \\[ r=1 \\] See Report \\(x\\) notes and code. Outro.",
+        );
+
+        const link = view.container.querySelector('[data-markdown-copy^="[Report"]')!;
+        const [first, last] = [
+          (t: Text) => t.data === "Report ",
+          (t: Text) => t.data === " notes",
+        ].map((match) => textNodes(link).find(match)!);
+        const inLink = view.capture((range) => {
+          range.setStart(first!, 0);
+          range.setEnd(last!, last!.length);
+        });
+        expect(inLink?.selector.text).toBe("Report \\(x\\) notes");
+        const found = resolveAssistantCitationRange(view.container, inLink!.selector);
+        expect([found?.startContainer, found?.endContainer]).toEqual([first, last]);
+      } finally {
+        await view.unmount();
+      }
+    });
+  });
+
   it("copies a file link with math in its label as the link, its label text as text, and the formula as TeX", async () => {
     settingsOverrides.mathRenderingEnabled = true;
     const view = await renderMath("See [Report \\(x\\) notes](/tmp/project/src/main.ts).");
@@ -1288,6 +1439,29 @@ describe("ChatMarkdown math", () => {
     }
   });
 
+  it("reads an escaped pipe in a table cell's formula the way a table reads one in a code span", async () => {
+    settingsOverrides.mathRenderingEnabled = true;
+    const table = [
+      "| Case | Formula |",
+      "| --- | --- |",
+      "| bars | \\(\\|x\\|\\) |",
+      "| norm | \\(\\Vert x\\Vert\\) |",
+      "| row break | \\(a\\\\\\|b\\) |",
+    ].join("\n");
+    const view = await renderMath(`${table}\n\nOutside a table, \\(\\|x\\|\\) is a norm.`);
+    try {
+      // `\|` is the table's escape for a pipe, so the formula holds `|x|`; the third cell's
+      // three backslashes are one `\\` and that escape.
+      expect(view.tex("td .katex")).toEqual(["|x|", "\\Vert x\\Vert", "a\\\\|b"]);
+      expect(view.tex("p .katex")).toEqual(["\\|x\\|"]);
+      // Copying the table escapes the pipes again, so pasting it reads back the same formulas.
+      expect(serializeTableElementToMarkdown(view.container.querySelector("table")!)).toBe(table);
+    } finally {
+      await view.unmount();
+      delete settingsOverrides.mathRenderingEnabled;
+    }
+  });
+
   it("keeps code that only looks like math as code while math rendering is on", async () => {
     settingsOverrides.mathRenderingEnabled = true;
     const view = await renderMath(
@@ -1317,6 +1491,43 @@ describe("ChatMarkdown math", () => {
     }
   });
 
+  // Offsets inside a raw HTML block in a blockquote leave out its `> ` prefixes, so an
+  // authored element can sit where they point at a real `\\(` or `\\[`.
+  it.each([
+    ["inline", "> 
\n> \\(hello\n>
"], + ["display", ">
\n> \\[
hello
\n>
"], + ])("keeps authored %s code as code when its offset lands on a math opener", async (_, source) => { + settingsOverrides.mathRenderingEnabled = true; + const view = await renderMath(`${source}\n\nReal \\(f\\).`); + try { + expect(view.tex(".katex")).toEqual(["f"]); + expect(view.container.querySelector("blockquote")?.textContent).toContain("hello"); + expect(view.container.querySelector("blockquote .katex")).toBeNull(); + } finally { + await view.unmount(); + delete settingsOverrides.mathRenderingEnabled; + } + }); + + it("does not let authored HTML pass itself off as a formula", async () => { + settingsOverrides.mathRenderingEnabled = true; + const view = await renderMath( + 'Real shown.', + { parseRawHtml: true, lineBreaks: false }, + ); + try { + const paragraph = view.container.querySelector("p")!; + expect(paragraph.querySelector("span")?.textContent).toBe("shown"); + const range = document.createRange(); + range.selectNodeContents(paragraph); + const selection = { rangeCount: 1, getRangeAt: () => range } as unknown as Selection; + expect(chatMarkdownClipboardPayload(selection)?.text).toBe("Real shown."); + } finally { + await view.unmount(); + delete settingsOverrides.mathRenderingEnabled; + } + }); + it("points task checkboxes at their own source after math", async () => { settingsOverrides.mathRenderingEnabled = true; const text = "Do \\(a\\) first:\n\n\\[\nb\n\\]\n\n- [ ] one \\(c\\)\n- [x] two"; diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index ad06a1173913..add5e4d21903 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -3410,7 +3410,7 @@ const CHAT_MARKDOWN_COMPONENTS = { ChatMarkdownRendererContext, ); const mathRendering = useMathRendering(); - if (mathRendering && isTexMath(node, text, false)) { + if (mathRendering && isTexMath(node, className, text, false)) { return ; } if (node?.properties?.dataInlineCode != null) { @@ -3576,7 +3576,7 @@ const CHAT_MARKDOWN_COMPONENTS = { if (!codeBlock) { return
{children}
; } - if (mathRendering && isTexMath(node, text, true)) { + if (mathRendering && isTexMath(node, codeBlock.className, text, true)) { return ; } diff --git a/apps/web/src/components/chat/KatexMath.tsx b/apps/web/src/components/chat/KatexMath.tsx index 594aa3b534e8..6ed864179430 100644 --- a/apps/web/src/components/chat/KatexMath.tsx +++ b/apps/web/src/components/chat/KatexMath.tsx @@ -2,7 +2,7 @@ import katex from "katex"; import "katex/dist/katex.min.css"; import type { ReactNode } from "react"; -import { texMathCopyText } from "../../markdown-math"; +import { texMathAttributes } from "../../markdown-math"; /** * The only module that imports KaTeX and its stylesheet, so both load together @@ -32,14 +32,14 @@ export default function KatexMath({ } catch { return fallback; } - const copy = texMathCopyText(tex, display); + const attributes = texMathAttributes(tex, display); return display ? (
) : ( - + ); } diff --git a/apps/web/src/components/chat/MarkdownMath.test.tsx b/apps/web/src/components/chat/MarkdownMath.test.tsx new file mode 100644 index 000000000000..5f4fe8b6cd14 --- /dev/null +++ b/apps/web/src/components/chat/MarkdownMath.test.tsx @@ -0,0 +1,166 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { + captureAssistantTextSelection, + resolveAssistantCitationRange, +} from "../../lib/assistantTextSelection"; +import { chatMarkdownClipboardPayload, serializeTableElementToCsv } from "../../markdown-clipboard"; + +// Each test gets its own lazy KaTeX chunk, since React keeps a lazy import's outcome for good. +async function loadMarkdownMath(chunk: () => Promise) { + vi.resetModules(); + vi.doMock("./KatexMath", chunk); + return (await import("./MarkdownMath")).MarkdownMath; +} + +describe("MarkdownMath", () => { + afterEach(() => { + vi.doUnmock("./KatexMath"); + vi.unstubAllGlobals(); + }); + + it("shows the TeX and leaves its neighbors alone when the KaTeX chunk fails to load", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const MarkdownMath = await loadMarkdownMath(async () => { + // A stale deploy or a dropped connection. + throw new Error("Failed to fetch dynamically imported module"); + }); + const caught = vi.spyOn(console, "error").mockImplementation(() => {}); + const container = document.createElement("div"); + const root = createRoot(container); + try { + await act(async () => { + root.render( + <> +

Before the formula.

+

+ Inline in prose. +

+ +
+              const kept = true;
+            
+ , + ); + }); + expect(container.querySelector("p")?.textContent).toBe("Before the formula."); + expect(container.querySelector("pre:not([data-markdown-copy])")?.textContent).toBe( + "const kept = true;", + ); + expect( + [...container.querySelectorAll("[data-markdown-copy]")].map((node) => [ + node.tagName, + node.textContent, + node.getAttribute("data-markdown-copy"), + ]), + ).toEqual([ + ["CODE", "E=mc^2", "\\(E=mc^2\\)"], + ["PRE", "a^2+b^2\n=c^2", "\\[\na^2+b^2\n=c^2\n\\]\n\n"], + ]); + } finally { + await act(async () => root.unmount()); + caught.mockRestore(); + } + }); + + it("quotes, copies, and exports a formula the same while KaTeX loads as once it is typeset", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const katex = await vi.importActual("./KatexMath"); + let release!: () => void; + const gate = new Promise((resolve) => (release = resolve)); + const MarkdownMath = await loadMarkdownMath(async () => { + await gate; + return katex; + }); + const container = document.createElement("div"); + container.dataset.assistantCitationSource = "assistant"; + const root = createRoot(container); + try { + act(() => { + root.render( + <> +

+ Energy here. +

+ + + + + + + +
+ Cell +
+ , + ); + }); + const paragraph = container.querySelector("p")!; + const [before, after] = [paragraph.firstChild as Text, paragraph.lastChild as Text]; + const formulaOf = () => paragraph.children[0]!; + const select = (pick: (range: Range) => void) => { + const range = document.createRange(); + pick(range); + return { + isCollapsed: range.collapsed, + rangeCount: 1, + getRangeAt: () => range, + } as unknown as Selection; + }; + const snapshot = () => ({ + quote: captureAssistantTextSelection( + container, + select((range) => range.selectNodeContents(paragraph)), + )?.selector, + formulaQuote: captureAssistantTextSelection( + container, + select((range) => range.selectNodeContents(formulaOf())), + )?.selector, + copy: chatMarkdownClipboardPayload(select((range) => range.selectNodeContents(container))), + // A drag that covers part of the formula's text, whichever form it is in. + partialCopy: chatMarkdownClipboardPayload( + select((range) => { + const glyph = document.createTreeWalker(formulaOf(), NodeFilter.SHOW_TEXT).nextNode()!; + range.setStart(glyph, 0); + range.setEnd(glyph, 1); + }), + ), + csv: serializeTableElementToCsv(container.querySelector("table")!), + }); + + // Still loading: the TeX source stands in, and no formula is typeset yet. + expect(container.querySelector(".katex")).toBeNull(); + const pending = snapshot(); + expect(pending.quote?.text).toBe("Energy \\(E=mc^2\\) here."); + expect(pending.formulaQuote?.text).toBe("\\(E=mc^2\\)"); + expect(pending.partialCopy).toEqual({ + text: "\\(E=mc^2\\)", + html: '\\(E=mc^2\\)', + }); + expect(pending.csv).toBe("Cell \\(x_1\\)"); + expect(pending.copy?.text).toBe( + "Energy \\(E=mc^2\\) here.\n\n\\[\na^2+b^2\n=c^2\n\\]\n\n| Cell \\(x_1\\) |\n| --- |", + ); + + await act(async () => release()); + expect(container.querySelectorAll(".katex")).toHaveLength(3); + + // The quote saved while loading is the one the typeset formula gives, and finds it. + expect(snapshot()).toEqual(pending); + const found = resolveAssistantCitationRange(container, pending.quote!); + expect([found?.startContainer, found?.startOffset]).toEqual([before, 0]); + expect([found?.endContainer, found?.endOffset]).toEqual([after, after.length]); + const formula = resolveAssistantCitationRange(container, pending.formulaQuote!); + const whole = document.createRange(); + whole.selectNode(formulaOf()); + expect(formula?.compareBoundaryPoints(Range.START_TO_START, whole)).toBe(0); + expect(formula?.compareBoundaryPoints(Range.END_TO_END, whole)).toBe(0); + } finally { + await act(async () => root.unmount()); + } + }); +}); diff --git a/apps/web/src/components/chat/MarkdownMath.tsx b/apps/web/src/components/chat/MarkdownMath.tsx index c01aad818882..8c8702ffd189 100644 --- a/apps/web/src/components/chat/MarkdownMath.tsx +++ b/apps/web/src/components/chat/MarkdownMath.tsx @@ -1,7 +1,8 @@ import { lazy, Suspense } from "react"; import { useClientSettings } from "../../hooks/useSettings"; -import { texMathCopyText } from "../../markdown-math"; +import { texMathAttributes } from "../../markdown-math"; +import { RenderErrorBoundary } from "../RenderErrorBoundary"; const KatexMath = lazy(() => import("./KatexMath")); @@ -12,32 +13,45 @@ export function useMathRendering(): boolean { /** * Whether a code element came from the math syntax rather than from a fence or - * the author's raw HTML, which can carry the same `language-math-*` class. The - * source at the element's position is what tells them apart: the math syntax - * always begins with its `\(` or `\[` opener, which neither of those can. + * the author's raw HTML. Raw HTML can carry the same `language-math-*` class, so + * the source at the element's position has to begin with the `\(` or `\[` + * opener too. That alone is not enough: offsets inside a raw HTML block in a + * blockquote leave out its `> ` prefixes, so an authored element can land on one. */ export function isTexMath( node: { position?: { start: { offset?: number | undefined } } | undefined } | undefined, + className: string | undefined, source: string, display: boolean, ): boolean { const offset = node?.position?.start.offset; - return typeof offset === "number" && source.startsWith(display ? "\\[" : "\\(", offset); + return ( + className?.split(/\s+/).includes(display ? "language-math-display" : "language-math-inline") === + true && + typeof offset === "number" && + source.startsWith(display ? "\\[" : "\\(", offset) + ); } -/** Shows the TeX source until KaTeX has loaded, and again if it cannot typeset it. */ +/** + * Shows the TeX source until KaTeX has loaded, and again if it cannot typeset + * it or the chunk fails to load. A failed load stays failed until the page is + * reloaded, as for any other lazy chunk. + */ export function MarkdownMath({ tex, display }: { tex: string; display: boolean }) { - const copy = texMathCopyText(tex, display); + const attributes = texMathAttributes(tex, display); const source = display ? ( -
+    
       {tex}
     
) : ( - {tex} + {tex} ); return ( - - - + + + + + ); } diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index f59c87bed5a2..2bf70c609da5 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1747,7 +1747,9 @@ function MathRenderingRow() { return ( { if (!part.startsWith("[")) return part === this.tagName.toLowerCase(); - const [attribute, value] = part.slice(1, -1).split("="); - return ( - Object.hasOwn(this.attributes, attribute!) && - (value === undefined || this.attributes[attribute!] === value) - ); + return part + .slice(1, -1) + .split("][") + .every((condition) => { + const [attribute, value] = condition.split("="); + return ( + Object.hasOwn(this.attributes, attribute!) && + (value === undefined || this.attributes[attribute!] === value) + ); + }); }); } closest(selector: string): SelectionNode | null { diff --git a/apps/web/src/lib/assistantTextSelection.ts b/apps/web/src/lib/assistantTextSelection.ts index f118555f3eac..2468939b421b 100644 --- a/apps/web/src/lib/assistantTextSelection.ts +++ b/apps/web/src/lib/assistantTextSelection.ts @@ -1,5 +1,7 @@ import { ASSISTANT_CITATION_CONTEXT_LENGTH, type AssistantCitation } from "@t3tools/contracts"; +import { mathWrapperOf } from "../markdown-clipboard"; + export type AssistantTextSelector = { readonly text: string; readonly start: number; @@ -128,14 +130,18 @@ export function findAssistantCitationText( return match ?? (quoteCount === 1 ? onlyQuote : null); } -type TextChunk = { node: Text; start: number; end: number }; +/** A text node, or a whole rendered formula standing for its TeX. */ +type TextChunk = { node: Text | Element; start: number; end: number }; + +const isText = (node: Node): node is Text => node.nodeType === 3; /** * Uses DOM text order, with a line break between HTML blocks and at
. * Inline markup, including code and links, contributes its displayed text. - * Controls and subtrees marked hidden/aria-hidden do not contribute. No layout - * reads, CSS-generated content, or soft-wrap line breaks enter the stream, so - * reflow cannot move it. + * Controls and subtrees marked hidden/aria-hidden do not contribute. A rendered + * formula contributes its TeX source once, as one unit, instead of KaTeX's + * glyphs and MathML. No layout reads, CSS-generated content, or soft-wrap line + * breaks enter the stream, so reflow cannot move it. */ function readAssistantText(root: HTMLElement) { const parts: string[] = []; @@ -143,18 +149,21 @@ function readAssistantText(root: HTMLElement) { let length = 0; let separator = false; + const append = (node: Text | Element, text: string) => { + if (separator && length > 0) { + parts.push("\n"); + length += 1; + } + separator = false; + chunks.push({ node, start: length, end: length + text.length }); + parts.push(text); + length += text.length; + }; + const visit = (node: Node) => { if (node.nodeType === 3) { const text = node as Text; - if (text.length === 0) return; - if (separator && length > 0) { - parts.push("\n"); - length += 1; - } - separator = false; - chunks.push({ node: text, start: length, end: length + text.length }); - parts.push(text.data); - length += text.length; + if (text.length > 0) append(text, text.data); return; } if (node.nodeType !== 1) return; @@ -162,7 +171,12 @@ function readAssistantText(root: HTMLElement) { if (element.matches(EXCLUDED_SELECTOR)) return; const block = element.matches(BLOCK_SELECTOR); if (block || element.tagName === "BR") separator = true; - for (const child of element.childNodes) visit(child); + const formula = element.matches("[data-markdown-math]") ? mathWrapperOf(element) : null; + if (formula) { + append(formula, formula.getAttribute("data-markdown-copy")?.trim() ?? ""); + } else { + for (const child of element.childNodes) visit(child); + } if (block) separator = true; }; @@ -223,8 +237,13 @@ export function captureAssistantTextSelection( // Paragraph selection can end at the next block's offset 0 or a parent // boundary. Validate the text actually selected, not that empty endpoint. - range.setStart(first, first === range.startContainer ? range.startOffset : 0); - range.setEnd(last, last === range.endContainer ? range.endOffset : last.length); + // An endpoint inside a formula's glyphs takes in the whole formula. + const startFormula = mathWrapperOf(first.parentElement); + const endFormula = mathWrapperOf(last.parentElement); + if (startFormula) range.setStartBefore(startFormula); + else range.setStart(first, first === range.startContainer ? range.startOffset : 0); + if (endFormula) range.setEndAfter(endFormula); + else range.setEnd(last, last === range.endContainer ? range.endOffset : last.length); if (!isUsableRange(source, range)) return null; const stream = readAssistantText(source); @@ -233,7 +252,7 @@ export function captureAssistantTextSelection( for (const chunk of stream.chunks) { if (!range.intersectsNode(chunk.node)) continue; const start = range.startContainer === chunk.node ? range.startOffset : 0; - const end = range.endContainer === chunk.node ? range.endOffset : chunk.node.length; + const end = range.endContainer === chunk.node ? range.endOffset : chunk.end - chunk.start; if (start === end) continue; rawStart ??= chunk.start + start; rawEnd = chunk.start + end; @@ -276,7 +295,10 @@ export function resolveAssistantCitationRange( if (first === undefined || last === undefined) return null; const range = root.ownerDocument.createRange(); - range.setStart(first.node, Math.max(0, start - first.start)); - range.setEnd(last.node, Math.min(last.node.length, end - last.start)); + // A match that touches a formula covers the formula as rendered. + if (isText(first.node)) range.setStart(first.node, Math.max(0, start - first.start)); + else range.setStartBefore(first.node); + if (isText(last.node)) range.setEnd(last.node, Math.min(last.node.length, end - last.start)); + else range.setEndAfter(last.node); return isUsableRange(root, range) ? range : null; } diff --git a/apps/web/src/markdown-clipboard.ts b/apps/web/src/markdown-clipboard.ts index acdabcdf7f11..444b3f720a37 100644 --- a/apps/web/src/markdown-clipboard.ts +++ b/apps/web/src/markdown-clipboard.ts @@ -348,11 +348,10 @@ export function serializeTableElementToMarkdown(table: Element): string { /** A cell's text, with each formula as its TeX instead of KaTeX's glyph and MathML text. */ function csvCellText(cell: Element): string { - if (!cell.querySelector(".katex")) return cell.textContent ?? ""; + if (!cell.querySelector(MATH_SELECTOR)) return cell.textContent ?? ""; const copy = cell.cloneNode(true) as Element; - for (const wrapper of copy.querySelectorAll("[data-markdown-copy]")) { - if (mathWrapperOf(wrapper)) - wrapper.replaceWith(wrapper.getAttribute("data-markdown-copy")?.trim() ?? ""); + for (const wrapper of copy.querySelectorAll(MATH_SELECTOR)) { + wrapper.replaceWith(wrapper.getAttribute("data-markdown-copy")?.trim() ?? ""); } return copy.textContent ?? ""; } @@ -376,29 +375,36 @@ export function serializeTableElementToCsv(table: Element): string { } /** - * A rendered formula (`data-markdown-copy` directly around KaTeX's `.katex` or - * `.katex-display`) pastes as its TeX source. KaTeX's visible half is - * `aria-hidden` and its MathML half carries the TeX a second time, so neither - * survives into a paste target without the stylesheet. A wrapper that merely - * holds a formula, like a file link with math in its label, is not one. + * MarkdownMath marks every formula it renders, typeset or showing its TeX source + * while KaTeX loads or cannot typeset it, with `data-markdown-math` beside the + * TeX in `data-markdown-copy`. Authored HTML cannot carry either attribute, as + * the sanitizer drops them. */ -function mathWrapperOf(element: Element | null): Element | null { - const wrapper = element?.closest("[data-markdown-copy]") ?? null; - return wrapper?.querySelector(":scope > .katex, :scope > .katex-display") ? wrapper : null; +const MATH_SELECTOR = "[data-markdown-math][data-markdown-copy]"; + +/** + * A formula pastes as its TeX source. KaTeX's visible half is `aria-hidden` and + * its MathML half carries the TeX a second time, so neither survives into a + * paste target without the stylesheet. A wrapper that merely holds a formula, + * like a file link with math in its label, is not one. Quoting a selection for + * the composer reads formulas the same way. + */ +export function mathWrapperOf(element: Element | null): Element | null { + return element?.closest(MATH_SELECTOR) ?? null; } function mathSourceElement(wrapper: Element): Element { const code = document.createElement("code"); code.textContent = wrapper.getAttribute("data-markdown-copy")?.trim() ?? ""; - if (!wrapper.querySelector(".katex-display")) return code; + if (wrapper.getAttribute("data-markdown-math") !== "display") return code; const pre = document.createElement("pre"); pre.append(code); return pre; } function sanitizedHtmlFrom(container: Element): string { - for (const wrapper of container.querySelectorAll("[data-markdown-copy]")) { - if (mathWrapperOf(wrapper)) wrapper.replaceWith(mathSourceElement(wrapper)); + for (const wrapper of container.querySelectorAll(MATH_SELECTOR)) { + wrapper.replaceWith(mathSourceElement(wrapper)); } for (const node of container.querySelectorAll(SANITIZED_HTML_SELECTOR)) { if ( diff --git a/apps/web/src/markdown-math.ts b/apps/web/src/markdown-math.ts index a3177705c13d..7d1137e5bc98 100644 --- a/apps/web/src/markdown-math.ts +++ b/apps/web/src/markdown-math.ts @@ -257,7 +257,12 @@ export const texMathFromMarkdown: FromMarkdownExtension = { this.config.exit.data!.call(this, token); }, texMathText(token) { - const value = this.resume().replace(/\r\n?/g, "\n"); + let value = this.resume().replace(/\r\n?/g, "\n"); + // A table reads `\|` in a cell as a pipe that does not split it, which is how + // `mdast-util-gfm-table` treats a code span, so a formula there does the same. + if (this.data.inTable) { + value = value.replace(/\\([\\|])/g, (whole, escaped) => (escaped === "|" ? "|" : whole)); + } const node = this.stack.at(-1) as unknown as { value: string; data: Record; @@ -299,7 +304,15 @@ export const remarkTexMath = function (this: Processor) { (data.fromMarkdownExtensions ??= []).push(texMathFromMarkdown); }; -/** The TeX source of a formula as Markdown, which is what copying it should give. */ -export function texMathCopyText(tex: string, display: boolean): string { - return display ? `\\[\n${tex}\n\\]\n\n` : `\\(${tex}\\)`; +/** + * What identifies a rendered formula to copying and citing (see `mathWrapperOf`): + * a marker for its kind, and its TeX source as Markdown, which is what copying it + * should give. Set on the typeset formula and on the source shown in its place, + * so a formula reads the same before KaTeX loads as after. + */ +export function texMathAttributes(tex: string, display: boolean) { + return { + "data-markdown-math": display ? "display" : "inline", + "data-markdown-copy": display ? `\\[\n${tex}\n\\]\n\n` : `\\(${tex}\\)`, + }; } diff --git a/docs/user/appearance.md b/docs/user/appearance.md index acc7f5cf33d8..3f6273fdaca5 100644 --- a/docs/user/appearance.md +++ b/docs/user/appearance.md @@ -33,7 +33,8 @@ applies to the web and desktop clients. Turn on **Render math** to typeset TeX in chat messages and Markdown previews: `\(…\)` for inline formulas, and `\[` and `\]` each on their own line around a display formula. Anything else, including -`$…$` and `\[…\]` on a single line, stays as written. A formula that is unfinished or invalid shows +`$…$` and `\[…\]` on a single line, stays as written. In a table cell a pipe is written `\|`, so +`\(\|x\|\)` shows |x|; use `\Vert` for a double bar. A formula that is unfinished or invalid shows its TeX, and copying a formula gives you its TeX. This preference applies to the web and desktop clients. From 992edb949f3fef615d90564b1510289f0a679d0d Mon Sep 17 00:00:00 2001 From: TheAnimatrix Date: Fri, 9 Oct 2026 04:18:52 +0530 Subject: [PATCH 4/5] feat(web): accept $$ display fences and simplify math wiring - Accept `$$` alone on a line as a display fence, alongside `\[` / `\]`. - Read the math setting once through the renderer context. - Identify formula wrappers by `data-markdown-math` alone. - Drop KaTeX options that restate its defaults; use the catalog for micromark-util-types. - Trim comments and docs. --- apps/web/package.json | 2 +- apps/web/src/components/ChatMarkdown.test.tsx | 39 +++- apps/web/src/components/ChatMarkdown.tsx | 29 ++- apps/web/src/components/chat/KatexMath.tsx | 11 +- .../src/components/chat/MarkdownMath.test.tsx | 8 +- apps/web/src/components/chat/MarkdownMath.tsx | 23 +- .../components/settings/SettingsPanels.tsx | 2 +- .../src/lib/assistantTextSelection.test.ts | 15 +- apps/web/src/lib/assistantTextSelection.ts | 10 +- apps/web/src/markdown-clipboard.ts | 33 +-- apps/web/src/markdown-math.test.ts | 21 ++ apps/web/src/markdown-math.ts | 218 +++++++++--------- docs/user/appearance.md | 11 +- pnpm-lock.yaml | 2 +- 14 files changed, 219 insertions(+), 205 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index a6012d7a937a..9b4ff18d78f2 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -87,7 +87,7 @@ "compression": "^1.8.1", "jsdom": "^30.0.1", "mdast-util-from-markdown": "^2.0.3", - "micromark-util-types": "^2.0.2", + "micromark-util-types": "catalog:", "react-test-renderer": "19.2.6", "tailwindcss": "^4.0.0", "unified": "catalog:", diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index 0bb3824fcf2e..1cb038180a45 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -1060,7 +1060,7 @@ describe("ChatMarkdown Windows file links", () => { type CitedParts = { prose: Text[]; formulas: Element[]; html: Element[] }; describe("ChatMarkdown math", () => { - // A real assistant reply: `\(…\)` inline, including table cells, and `\[` / `\]` on their own lines. + // A real assistant reply. const reply = [ "**Your revised \\(G\\) value is reproducible.** Starting from the printed mean free path:", "", @@ -1086,7 +1086,7 @@ describe("ChatMarkdown math", () => { props: Partial> = {}, ) { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - // KaTeX loads lazily; having the module already resolved lets its Suspense boundaries settle. + // Resolving the lazy KaTeX module first lets its Suspense boundaries settle. await import("./chat/KatexMath"); const container = document.createElement("div"); const root = createRoot(container); @@ -1134,6 +1134,26 @@ describe("ChatMarkdown math", () => { } }); + it("renders a $$ block as math when enabled and keeps it as written when disabled", async () => { + const source = "Before.\n\n$$\nb^2\n$$\n\nAfter."; + settingsOverrides.mathRenderingEnabled = true; + const enabled = await renderMath(source); + try { + expect(enabled.tex(".katex-display")).toEqual(["b^2"]); + expect(enabled.container.textContent).not.toContain("$$"); + } finally { + await enabled.unmount(); + delete settingsOverrides.mathRenderingEnabled; + } + const disabled = await renderMath(source); + try { + expect(disabled.container.querySelector(".katex")).toBeNull(); + expect(disabled.container.textContent).toContain("$$\nb^2\n$$"); + } finally { + await disabled.unmount(); + } + }); + it("keeps code, escapes, currency, and unfinished formulas as written", async () => { settingsOverrides.mathRenderingEnabled = true; const view = await renderMath( @@ -1223,7 +1243,7 @@ describe("ChatMarkdown math", () => { expect(payload?.text).toBe( name === "an inline formula's .katex-html" ? "\\(a_1\\)" : `\\[\n${displayTex}\n\\]`, ); - // The rich flavor is the same source, never KaTeX's hidden MathML or its glyph soup. + // The HTML flavor carries the TeX, not KaTeX's markup. expect(payload?.html).not.toMatch(/katex|annotation|"); }); @@ -1334,7 +1354,7 @@ describe("ChatMarkdown math", () => { const captured = view.capture((range) => select(range, parts)); expect(captured?.selector.text).toBe(quote); - // The saved quote resolves to what is on screen: whole formulas, never KaTeX's glyph nodes. + // The quote resolves to whole formulas, never KaTeX's glyph nodes. const found = resolveAssistantCitationRange(view.container, captured!.selector); const expected = document.createRange(); visible(expected, parts); @@ -1361,7 +1381,7 @@ describe("ChatMarkdown math", () => { ); try { const whole = view.capture((range) => range.selectNodeContents(view.container)); - // The file chip is a control, so its label stays out, and the link itself is not a formula. + // The file chip is a control, so its label stays out. expect(whole?.selector.text.replace(/\s+/g, " ")).toBe( "Intro \\(a_1\\) text. \\[ r=1 \\] See Report \\(x\\) notes and code. Outro.", ); @@ -1459,11 +1479,10 @@ describe("ChatMarkdown math", () => { ].join("\n"); const view = await renderMath(`${table}\n\nOutside a table, \\(\\|x\\|\\) is a norm.`); try { - // `\|` is the table's escape for a pipe, so the formula holds `|x|`; the third cell's - // three backslashes are one `\\` and that escape. + // `\|` is the table's escape for a pipe; `\\\|` in the third cell is `\\` then that escape. expect(view.tex("td .katex")).toEqual(["|x|", "\\Vert x\\Vert", "a\\\\|b"]); expect(view.tex("p .katex")).toEqual(["\\|x\\|"]); - // Copying the table escapes the pipes again, so pasting it reads back the same formulas. + // Copying the table escapes the pipes again. expect(serializeTableElementToMarkdown(view.container.querySelector("table")!)).toBe(table); } finally { await view.unmount(); @@ -1500,8 +1519,8 @@ describe("ChatMarkdown math", () => { } }); - // Offsets inside a raw HTML block in a blockquote leave out its `> ` prefixes, so an - // authored element can sit where they point at a real `\\(` or `\\[`. + // Offsets in a blockquoted raw HTML block skip its `> ` prefixes, so authored + // code can land on a real opener. it.each([ ["inline", ">
\n> \\(hello\n>
"], ["display", ">
\n> \\[
hello
\n>
"], diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index abeede59d81f..1c31e542e46d 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -105,7 +105,7 @@ import { parseAssistantCitationHref } from "@t3tools/shared/assistantCitations"; import { parseComposerContextHref } from "@t3tools/shared/composerContextReferences"; import { parseThreadLinkHref } from "@t3tools/shared/threadLinks"; import { AssistantCitationChip } from "./chat/AssistantCitationChip"; -import { MarkdownMath, isTexMath, useMathRendering } from "./chat/MarkdownMath"; +import { MarkdownMath, isTexMath } from "./chat/MarkdownMath"; import { MarkdownThreadLink } from "./chat/MarkdownThreadLink"; import { isWindowsAbsolutePath } from "@t3tools/shared/path"; import { remarkTexMath } from "../markdown-math"; @@ -2424,6 +2424,7 @@ function useChatMarkdownState({ // synchronously whether to intercept its `_blank`, and a subscription is what // makes a persisted "app" apply once settings hydrate after launch. const linkTargetPreference = useClientSettings((settings) => settings.browserLinkTarget); + const mathRendering = useClientSettings((settings) => settings.mathRenderingEnabled); const resolveThreadPullRequest = useCallback( (href: string): (ThreadPullRequestKey & { readonly url: string }) | null => { if ( @@ -2658,6 +2659,7 @@ function useChatMarkdownState({ isStreaming, linkTargetPreference, markdownFileLinkMetaByHref, + mathRendering, onTaskListChange, onUseArtifactTemplate, onRunShellCommand, @@ -2691,6 +2693,7 @@ function useChatMarkdownState({ isStreaming, linkTargetPreference, markdownFileLinkMetaByHref, + mathRendering, onTaskListChange, onUseArtifactTemplate, onRunShellCommand, @@ -3096,10 +3099,8 @@ const CHAT_MARKDOWN_COMPONENTS = { ); }, code: function MarkdownCode({ node, children, className, ...props }) { - const { cwd, imageBaseDir, inlineCodeFileLinkMetaByText, fileLinkChip, text } = use( - ChatMarkdownRendererContext, - ); - const mathRendering = useMathRendering(); + const { cwd, imageBaseDir, inlineCodeFileLinkMetaByText, fileLinkChip, mathRendering, text } = + use(ChatMarkdownRendererContext); if (mathRendering && isTexMath(node, className, text, false)) { return ; } @@ -3258,10 +3259,15 @@ const CHAT_MARKDOWN_COMPONENTS = { return {children}; }, pre: function MarkdownPre({ node, children, ...props }) { - const { resolvedTheme, diffThemeName, expandMedia, isStreaming, onRunShellCommand, text } = use( - ChatMarkdownRendererContext, - ); - const mathRendering = useMathRendering(); + const { + resolvedTheme, + diffThemeName, + expandMedia, + isStreaming, + mathRendering, + onRunShellCommand, + text, + } = use(ChatMarkdownRendererContext); const codeBlock = extractCodeBlock(children); if (!codeBlock) { return
{children}
; @@ -3343,9 +3349,8 @@ function ChatMarkdown({ localMediaPreview, setLocalMediaPreview, } = useChatMarkdownState({ text, ...props }); - // Off by default; when on, only text that could hold `\(` or `\[` pays for the math syntax. - const parseMath = useMathRendering() && /\\[([]/.test(text); - // The incremental parser reuses a cached prefix and does not know about extra syntax. + // Only text that can contain a math opener pays for the extra syntax. + const parseMath = componentState.mathRendering && /\\[([]|\$\$/.test(text); const incrementalParsing = props.isStreaming === true && extraRemarkPlugins.length === 0 && diff --git a/apps/web/src/components/chat/KatexMath.tsx b/apps/web/src/components/chat/KatexMath.tsx index 6ed864179430..bcadb7982d1d 100644 --- a/apps/web/src/components/chat/KatexMath.tsx +++ b/apps/web/src/components/chat/KatexMath.tsx @@ -4,12 +4,7 @@ import type { ReactNode } from "react"; import { texMathAttributes } from "../../markdown-math"; -/** - * The only module that imports KaTeX and its stylesheet, so both load together - * the first time a formula is shown. Mermaid already ships the same KaTeX build, - * and the bundler shares it between the two lazy chunks. Input is model output, - * so `trust` stays off; a formula KaTeX rejects shows its source instead. - */ +// Loaded lazily with its stylesheet on first use. Input is model output, so trust stays off. export default function KatexMath({ tex, display, @@ -23,11 +18,9 @@ export default function KatexMath({ try { html = katex.renderToString(tex, { displayMode: display, - throwOnError: true, trust: false, strict: "ignore", maxSize: 20, - maxExpand: 1000, }); } catch { return fallback; @@ -35,7 +28,7 @@ export default function KatexMath({ const attributes = texMathAttributes(tex, display); return display ? (
diff --git a/apps/web/src/components/chat/MarkdownMath.test.tsx b/apps/web/src/components/chat/MarkdownMath.test.tsx index 5f4fe8b6cd14..f938a5a6a6d5 100644 --- a/apps/web/src/components/chat/MarkdownMath.test.tsx +++ b/apps/web/src/components/chat/MarkdownMath.test.tsx @@ -10,7 +10,7 @@ import { } from "../../lib/assistantTextSelection"; import { chatMarkdownClipboardPayload, serializeTableElementToCsv } from "../../markdown-clipboard"; -// Each test gets its own lazy KaTeX chunk, since React keeps a lazy import's outcome for good. +// React caches a lazy import's outcome, so each test needs its own KaTeX chunk. async function loadMarkdownMath(chunk: () => Promise) { vi.resetModules(); vi.doMock("./KatexMath", chunk); @@ -121,7 +121,7 @@ describe("MarkdownMath", () => { select((range) => range.selectNodeContents(formulaOf())), )?.selector, copy: chatMarkdownClipboardPayload(select((range) => range.selectNodeContents(container))), - // A drag that covers part of the formula's text, whichever form it is in. + // A drag over part of the formula's text. partialCopy: chatMarkdownClipboardPayload( select((range) => { const glyph = document.createTreeWalker(formulaOf(), NodeFilter.SHOW_TEXT).nextNode()!; @@ -132,7 +132,7 @@ describe("MarkdownMath", () => { csv: serializeTableElementToCsv(container.querySelector("table")!), }); - // Still loading: the TeX source stands in, and no formula is typeset yet. + // While loading, the TeX source stands in. expect(container.querySelector(".katex")).toBeNull(); const pending = snapshot(); expect(pending.quote?.text).toBe("Energy \\(E=mc^2\\) here."); @@ -149,7 +149,7 @@ describe("MarkdownMath", () => { await act(async () => release()); expect(container.querySelectorAll(".katex")).toHaveLength(3); - // The quote saved while loading is the one the typeset formula gives, and finds it. + // The quote saved while loading matches the typeset formula. expect(snapshot()).toEqual(pending); const found = resolveAssistantCitationRange(container, pending.quote!); expect([found?.startContainer, found?.startOffset]).toEqual([before, 0]); diff --git a/apps/web/src/components/chat/MarkdownMath.tsx b/apps/web/src/components/chat/MarkdownMath.tsx index 8c8702ffd189..dae245d600dc 100644 --- a/apps/web/src/components/chat/MarkdownMath.tsx +++ b/apps/web/src/components/chat/MarkdownMath.tsx @@ -1,22 +1,13 @@ import { lazy, Suspense } from "react"; -import { useClientSettings } from "../../hooks/useSettings"; import { texMathAttributes } from "../../markdown-math"; import { RenderErrorBoundary } from "../RenderErrorBoundary"; const KatexMath = lazy(() => import("./KatexMath")); -/** Whether the opt-in math rendering setting is on. */ -export function useMathRendering(): boolean { - return useClientSettings((settings) => settings.mathRenderingEnabled); -} - /** - * Whether a code element came from the math syntax rather than from a fence or - * the author's raw HTML. Raw HTML can carry the same `language-math-*` class, so - * the source at the element's position has to begin with the `\(` or `\[` - * opener too. That alone is not enough: offsets inside a raw HTML block in a - * blockquote leave out its `> ` prefixes, so an authored element can land on one. + * Math classes can also come from fences or authored HTML, so the source at the + * node's offset must start with a math opener. */ export function isTexMath( node: { position?: { start: { offset?: number | undefined } } | undefined } | undefined, @@ -29,15 +20,13 @@ export function isTexMath( className?.split(/\s+/).includes(display ? "language-math-display" : "language-math-inline") === true && typeof offset === "number" && - source.startsWith(display ? "\\[" : "\\(", offset) + (display + ? source.startsWith("\\[", offset) || source.startsWith("$$", offset) + : source.startsWith("\\(", offset)) ); } -/** - * Shows the TeX source until KaTeX has loaded, and again if it cannot typeset - * it or the chunk fails to load. A failed load stays failed until the page is - * reloaded, as for any other lazy chunk. - */ +/** Shows the TeX source while KaTeX loads and when it cannot typeset it or load. */ export function MarkdownMath({ tex, display }: { tex: string; display: boolean }) { const attributes = texMathAttributes(tex, display); const source = display ? ( diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 2bf70c609da5..1c173342b215 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1748,7 +1748,7 @@ function MathRenderingRow() { { if (!part.startsWith("[")) return part === this.tagName.toLowerCase(); - return part - .slice(1, -1) - .split("][") - .every((condition) => { - const [attribute, value] = condition.split("="); - return ( - Object.hasOwn(this.attributes, attribute!) && - (value === undefined || this.attributes[attribute!] === value) - ); - }); + const [attribute, value] = part.slice(1, -1).split("="); + return ( + Object.hasOwn(this.attributes, attribute!) && + (value === undefined || this.attributes[attribute!] === value) + ); }); } closest(selector: string): SelectionNode | null { diff --git a/apps/web/src/lib/assistantTextSelection.ts b/apps/web/src/lib/assistantTextSelection.ts index 2468939b421b..f9f6f63b6724 100644 --- a/apps/web/src/lib/assistantTextSelection.ts +++ b/apps/web/src/lib/assistantTextSelection.ts @@ -139,9 +139,8 @@ const isText = (node: Node): node is Text => node.nodeType === 3; * Uses DOM text order, with a line break between HTML blocks and at
. * Inline markup, including code and links, contributes its displayed text. * Controls and subtrees marked hidden/aria-hidden do not contribute. A rendered - * formula contributes its TeX source once, as one unit, instead of KaTeX's - * glyphs and MathML. No layout reads, CSS-generated content, or soft-wrap line - * breaks enter the stream, so reflow cannot move it. + * formula contributes its TeX as one unit. No layout reads, CSS-generated + * content, or soft-wrap line breaks enter the stream, so reflow cannot move it. */ function readAssistantText(root: HTMLElement) { const parts: string[] = []; @@ -171,9 +170,8 @@ function readAssistantText(root: HTMLElement) { if (element.matches(EXCLUDED_SELECTOR)) return; const block = element.matches(BLOCK_SELECTOR); if (block || element.tagName === "BR") separator = true; - const formula = element.matches("[data-markdown-math]") ? mathWrapperOf(element) : null; - if (formula) { - append(formula, formula.getAttribute("data-markdown-copy")?.trim() ?? ""); + if (element.matches("[data-markdown-math]")) { + append(element, element.getAttribute("data-markdown-copy")?.trim() ?? ""); } else { for (const child of element.childNodes) visit(child); } diff --git a/apps/web/src/markdown-clipboard.ts b/apps/web/src/markdown-clipboard.ts index 444b3f720a37..248e171145b9 100644 --- a/apps/web/src/markdown-clipboard.ts +++ b/apps/web/src/markdown-clipboard.ts @@ -346,7 +346,16 @@ export function serializeTableElementToMarkdown(table: Element): string { return serializeTable(table).trim(); } -/** A cell's text, with each formula as its TeX instead of KaTeX's glyph and MathML text. */ +// The wrapper MarkdownMath puts on every formula it renders. The sanitizer drops +// the attribute from authored HTML. +const MATH_SELECTOR = "[data-markdown-math]"; + +/** The formula wrapper at or above `element`, if any. */ +export function mathWrapperOf(element: Element | null): Element | null { + return element?.closest(MATH_SELECTOR) ?? null; +} + +/** A cell's text, with each formula as its TeX. */ function csvCellText(cell: Element): string { if (!cell.querySelector(MATH_SELECTOR)) return cell.textContent ?? ""; const copy = cell.cloneNode(true) as Element; @@ -374,25 +383,6 @@ export function serializeTableElementToCsv(table: Element): string { return lines.join("\n"); } -/** - * MarkdownMath marks every formula it renders, typeset or showing its TeX source - * while KaTeX loads or cannot typeset it, with `data-markdown-math` beside the - * TeX in `data-markdown-copy`. Authored HTML cannot carry either attribute, as - * the sanitizer drops them. - */ -const MATH_SELECTOR = "[data-markdown-math][data-markdown-copy]"; - -/** - * A formula pastes as its TeX source. KaTeX's visible half is `aria-hidden` and - * its MathML half carries the TeX a second time, so neither survives into a - * paste target without the stylesheet. A wrapper that merely holds a formula, - * like a file link with math in its label, is not one. Quoting a selection for - * the composer reads formulas the same way. - */ -export function mathWrapperOf(element: Element | null): Element | null { - return element?.closest(MATH_SELECTOR) ?? null; -} - function mathSourceElement(wrapper: Element): Element { const code = document.createElement("code"); code.textContent = wrapper.getAttribute("data-markdown-copy")?.trim() ?? ""; @@ -434,8 +424,7 @@ export function chatMarkdownClipboardPayload( const ancestor = range.commonAncestorContainer; const ancestorElement = ancestor.nodeType === Node.ELEMENT_NODE ? (ancestor as Element) : ancestor.parentElement; - // A range inside one formula clones only KaTeX's glyph markup, which has lost - // the wrapper that knows the TeX. + // A range inside a formula would clone only KaTeX's glyphs, not the wrapper holding the TeX. const math = mathWrapperOf(ancestorElement); if (math) { texts.push(math.getAttribute("data-markdown-copy")?.trim() ?? ""); diff --git a/apps/web/src/markdown-math.test.ts b/apps/web/src/markdown-math.test.ts index aeeda0c43aac..de6fead7f667 100644 --- a/apps/web/src/markdown-math.test.ts +++ b/apps/web/src/markdown-math.test.ts @@ -46,6 +46,13 @@ describe("TeX math syntax", () => { expect(mathIn("- item\n\n \\[\n x^2\n \\]")).toEqual(["math:x^2"]); }); + it("reads $$ lines as display math, keeping lines that look like Markdown or TeX", () => { + expect(mathIn("$$\nx\n$$")).toEqual(["math:x"]); + expect(mathIn("text\n\n$$\n- a\n\\[\n\n> c\n$$\n\nafter")).toEqual(["math:- a\n\\[\n\n> c"]); + expect(mathIn("> $$\n> x\n> $$")).toEqual(["math:x"]); + expect(mathIn("- item\n\n $$\n x^2\n $$")).toEqual(["math:x^2"]); + }); + it.each([ ["an unclosed inline opener", "costs \\(x and more"], ["an empty formula", "\\(\\) text"], @@ -54,8 +61,15 @@ describe("TeX math syntax", () => { ["a fenced block", "```\n\\[\nx\n\\]\n```"], ["an indented block", " \\[\n x\n \\]"], ["a one-line display formula", "\\[ x \\]"], + ["a one-line $$ formula", "$$ x $$"], + ["inline $$ in text", "so $$x$$ holds"], + ["currency", "Costs $5 and $10"], + ["a single $", "$\nx\n$"], ["a citation", "see \\[1\\] and \\[2\\]"], ["an unclosed display block", "\\[\nx = 1\n"], + ["an unclosed $$ block", "$$\nx = 1\n"], + ["a $$ block closed by \\]", "$$\na\n\\]"], + ["a \\[ block closed by $$", "\\[\na\n$$"], ["a display block cut by a lazy line", "> \\[\n> x\nlazy\n> \\]"], ["a link target", "[a](https://x.test/\\(b\\))"], ])("leaves %s alone", (_, source) => { @@ -83,4 +97,11 @@ describe("TeX math syntax", () => { expect(mathIn(source)).toEqual([]); expect(performance.now() - start).toBeLessThan(1000); }); + + it("does not rescan display openers inside an unclosed $$ block", () => { + const source = `$$\n${"\\[\nx\n".repeat(3200)}`; + const start = performance.now(); + expect(mathIn(source)).toEqual([]); + expect(performance.now() - start).toBeLessThan(1000); + }); }); diff --git a/apps/web/src/markdown-math.ts b/apps/web/src/markdown-math.ts index 7d1137e5bc98..1569090483d7 100644 --- a/apps/web/src/markdown-math.ts +++ b/apps/web/src/markdown-math.ts @@ -20,11 +20,10 @@ declare module "micromark-util-types" { } } -// The two TeX delimiters models write: `\(…\)` inline, and `\[` / `\]` each on -// their own line for display. CommonMark reads `\(` and `\[` as escapes, so the -// backslash is gone before any later pass could see it; these constructs run -// first. Everything else in the chat parser (code spans, fences, links, `$`) is -// left alone, and `\[x\]` on one line stays prose, which keeps `\[1\]` citations. +// \(…\) inline, and \[ / \] or $$ on their own lines for display. These run before +// CommonMark's escape construct, which would otherwise consume \( and \[. A +// one-line \[x\] stays prose so \[1\] citations do. +const DOLLAR = 36; const BACKSLASH = 92; const LEFT_PAREN = 40; const RIGHT_PAREN = 41; @@ -37,9 +36,8 @@ const isLineEnding = (code: Code) => code !== null && code < -2; const isSpace = (code: Code) => code === -2 || code === -1 || code === 32; /** - * `\(…\)` in running text. A formula never contains a backtick, which would open - * a code span, or a second `\(`, so a stray opener gives up at the next one and - * a message full of them is still scanned once. + * `\(…\)` in text. Gives up at a backtick (code spans outrank math) or a second + * `\(`, so unmatched openers are scanned once. */ const texMathText: Construct = { name: "texMathText", @@ -121,121 +119,131 @@ const texMathText: Construct = { }, }; +type Marker = readonly [first: number, second: number]; + /** - * A line holding only `\[`, up to a line holding only `\]`. Like a code fence, - * it must close; an unfinished formula (a reply still streaming) stays prose. + * A line holding only the `open` marker, up to one holding only the `close` + * marker. Like a code fence, it must close; an unfinished formula (a reply still + * streaming) stays prose. */ -const tokenizeTexMathFlow: Tokenizer = function (effects, ok, nok) { - const nonLazyLine: Construct = { - partial: true, - tokenize(this: TokenizeContext, effects, ok, nok) { - return (code) => { - effects.enter("lineEnding"); - effects.consume(code); - effects.exit("lineEnding"); - return (next) => (this.parser.lazy[this.now().line] ? nok(next) : ok(next)); - }; - }, - }; - // A line holding only `\]` closes the formula. A line holding only `\[` can - // never be inside one, so giving up there, as the inline construct does at a - // second `\(`, keeps a run of unclosed openers linear. - const fenceLine = (bracket: number): Construct => ({ - partial: true, - tokenize(effects, ok, nok) { - return function start(code) { - effects.enter("texMathFlowFence"); - return indent(code); - }; - - function indent(code: Code): State | undefined { - if (isSpace(code)) { - effects.consume(code); - return indent; - } - if (code !== BACKSLASH) return nok(code); - effects.consume(code); - return function second(code) { - if (code !== bracket) return nok(code); +const tokenizeTexMathFlow = (open: Marker, close: Marker): Tokenizer => + function (effects, ok, nok) { + const nonLazyLine: Construct = { + partial: true, + tokenize(this: TokenizeContext, effects, ok, nok) { + return (code) => { + effects.enter("lineEnding"); effects.consume(code); - return fenceEnd; + effects.exit("lineEnding"); + return (next) => (this.parser.lazy[this.now().line] ? nok(next) : ok(next)); + }; + }, + }; + const fenceLine = (marker: Marker): Construct => ({ + partial: true, + tokenize(effects, ok, nok) { + return function start(code) { + effects.enter("texMathFlowFence"); + return indent(code); }; - } - function fenceEnd(code: Code): State | undefined { - if (isSpace(code)) { + function indent(code: Code): State | undefined { + if (isSpace(code)) { + effects.consume(code); + return indent; + } + if (code !== marker[0]) return nok(code); effects.consume(code); - return fenceEnd; + return function second(code) { + if (code !== marker[1]) return nok(code); + effects.consume(code); + return fenceEnd; + }; } - if (code !== null && !isLineEnding(code)) return nok(code); - effects.exit("texMathFlowFence"); - return ok(code); - } - }, - }); - const closingFence = fenceLine(RIGHT_BRACKET); - const openingFence = fenceLine(LEFT_BRACKET); - return function start(code) { - effects.enter("texMathFlow"); - effects.enter("texMathFlowFence"); - effects.consume(code); - return function open(code) { - if (code !== LEFT_BRACKET) return nok(code); + function fenceEnd(code: Code): State | undefined { + if (isSpace(code)) { + effects.consume(code); + return fenceEnd; + } + if (code !== null && !isLineEnding(code)) return nok(code); + effects.exit("texMathFlowFence"); + return ok(code); + } + }, + }); + const closingFence = fenceLine(close); + const openingFence = fenceLine(open); + + return function start(code) { + effects.enter("texMathFlow"); + effects.enter("texMathFlowFence"); effects.consume(code); - return openEnd; + return function second(code) { + if (code !== open[1]) return nok(code); + effects.consume(code); + return openEnd; + }; }; - }; - function openEnd(code: Code): State | undefined { - if (isSpace(code)) { - effects.consume(code); - return openEnd; + function openEnd(code: Code): State | undefined { + if (isSpace(code)) { + effects.consume(code); + return openEnd; + } + if (!isLineEnding(code)) return nok(code); + effects.exit("texMathFlowFence"); + return effects.attempt(nonLazyLine, lineStart, nok)(code); } - if (!isLineEnding(code)) return nok(code); - effects.exit("texMathFlowFence"); - return effects.attempt(nonLazyLine, lineStart, nok)(code); - } - function lineStart(code: Code): State | undefined { - return effects.attempt(closingFence, close, notOpening)(code); - } + function lineStart(code: Code): State | undefined { + return effects.attempt(closingFence, finish, notOpening)(code); + } - function notOpening(code: Code): State | undefined { - return effects.attempt(openingFence, nok, content)(code); - } + // Another opener cannot be inside a formula. Giving up at it keeps a run of + // unclosed openers linear. + function notOpening(code: Code): State | undefined { + return effects.attempt(openingFence, nok, content)(code); + } - function content(code: Code): State | undefined { - if (code === null) return nok(code); - if (isLineEnding(code)) return effects.attempt(nonLazyLine, lineStart, nok)(code); - effects.enter("texMathFlowValue"); - return value(code); - } + function content(code: Code): State | undefined { + if (code === null) return nok(code); + if (isLineEnding(code)) return effects.attempt(nonLazyLine, lineStart, nok)(code); + effects.enter("texMathFlowValue"); + return value(code); + } - function value(code: Code): State | undefined { - if (code === null || isLineEnding(code)) { - effects.exit("texMathFlowValue"); - return content(code); + function value(code: Code): State | undefined { + if (code === null || isLineEnding(code)) { + effects.exit("texMathFlowValue"); + return content(code); + } + effects.consume(code); + return value; } - effects.consume(code); - return value; - } - function close(code: Code): State | undefined { - effects.exit("texMathFlow"); - return ok(code); - } -}; + function finish(code: Code): State | undefined { + effects.exit("texMathFlow"); + return ok(code); + } + }; + +const texMathFlow = (open: Marker, close: Marker): Construct => ({ + name: "texMathFlow", + tokenize: tokenizeTexMathFlow(open, close), + concrete: true, +}); export const texMathSyntax: MicromarkExtension = { text: { [BACKSLASH]: texMathText }, - flow: { [BACKSLASH]: { name: "texMathFlow", tokenize: tokenizeTexMathFlow, concrete: true } }, + flow: { + [BACKSLASH]: texMathFlow([BACKSLASH, LEFT_BRACKET], [BACKSLASH, RIGHT_BRACKET]), + [DOLLAR]: texMathFlow([DOLLAR, DOLLAR], [DOLLAR, DOLLAR]), + }, }; -// The mdast nodes carry the hast shape react-markdown renders, the same one -// `remark-math` produces: `` inline and -// `
` for display. `language-*` is
-// already allowed by the sanitizer, so no schema change is needed.
+// Inline math renders as ``, display math as
+// `
`. The sanitizer already allows `language-*`.
 export const texMathFromMarkdown: FromMarkdownExtension = {
   enter: {
     texMathText(token) {
@@ -258,8 +266,7 @@ export const texMathFromMarkdown: FromMarkdownExtension = {
     },
     texMathText(token) {
       let value = this.resume().replace(/\r\n?/g, "\n");
-      // A table reads `\|` in a cell as a pipe that does not split it, which is how
-      // `mdast-util-gfm-table` treats a code span, so a formula there does the same.
+      // Like a code span, a formula in a table cell reads `\|` as a pipe.
       if (this.data.inTable) {
         value = value.replace(/\\([\\|])/g, (whole, escaped) => (escaped === "|" ? "|" : whole));
       }
@@ -305,10 +312,9 @@ export const remarkTexMath = function (this: Processor) {
 };
 
 /**
- * What identifies a rendered formula to copying and citing (see `mathWrapperOf`):
- * a marker for its kind, and its TeX source as Markdown, which is what copying it
- * should give. Set on the typeset formula and on the source shown in its place,
- * so a formula reads the same before KaTeX loads as after.
+ * Marks a formula for copying and citing (see `mathWrapperOf`), with its
+ * canonical TeX as the copy text. Set on the typeset formula and on the source
+ * shown in its place.
  */
 export function texMathAttributes(tex: string, display: boolean) {
   return {
diff --git a/docs/user/appearance.md b/docs/user/appearance.md
index 3f6273fdaca5..3c18095240b9 100644
--- a/docs/user/appearance.md
+++ b/docs/user/appearance.md
@@ -31,12 +31,11 @@ applies to the web and desktop clients.
 
 ## Math
 
-Turn on **Render math** to typeset TeX in chat messages and Markdown previews: `\(…\)` for inline
-formulas, and `\[` and `\]` each on their own line around a display formula. Anything else, including
-`$…$` and `\[…\]` on a single line, stays as written. In a table cell a pipe is written `\|`, so
-`\(\|x\|\)` shows |x|; use `\Vert` for a double bar. A formula that is unfinished or invalid shows
-its TeX, and copying a formula gives you its TeX. This preference applies to the web and desktop
-clients.
+Turn on **Render math** to typeset TeX in chat messages and Markdown previews: `\(…\)` inline, and
+`\[` and `\]` or `$$` on their own lines for display. `$…$` and one-line `\[…\]` or `$$…$$` stay as
+written. In a table cell `\|` is a pipe, so use `\Vert` for a double bar. A formula that is invalid
+or unfinished shows its TeX, and copying a formula gives you its TeX. This preference applies to
+the web and desktop clients.
 
 ## Motion
 
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a6960bfbcab8..3fc29d063c0f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -903,7 +903,7 @@ importers:
         specifier: ^2.0.3
         version: 2.0.3
       micromark-util-types:
-        specifier: ^2.0.2
+        specifier: 'catalog:'
         version: 2.0.2
       react-test-renderer:
         specifier: 19.2.6

From 9bda800ca20b23ec8f721a9e0f1b9c9dd957fd4c Mon Sep 17 00:00:00 2001
From: TheAnimatrix 
Date: Fri, 9 Oct 2026 04:49:38 +0530
Subject: [PATCH 5/5] fix(web): run an unclosed display block to the end of its
 container

An unclosed `\[` or `$$` block failed after consuming later lines, which
flattened the lists and quotes that followed. Like a fenced code block,
it now ends at its closing line or the end of its container, and an
unclosed block is shown as source instead of typeset.
---
 apps/web/src/components/ChatMarkdown.test.tsx | 22 +++++
 apps/web/src/markdown-clipboard.ts            |  1 -
 apps/web/src/markdown-math.test.ts            | 83 ++++++++++++----
 apps/web/src/markdown-math.ts                 | 98 ++++++++++++-------
 4 files changed, 148 insertions(+), 56 deletions(-)

diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx
index 1cb038180a45..b0f508a70e17 100644
--- a/apps/web/src/components/ChatMarkdown.test.tsx
+++ b/apps/web/src/components/ChatMarkdown.test.tsx
@@ -1181,6 +1181,28 @@ describe("ChatMarkdown math", () => {
     }
   });
 
+  it("shows an unfinished display formula as source until its closing line arrives", async () => {
+    settingsOverrides.mathRenderingEnabled = true;
+    const unfinished = "Intro\n\n$$\nx = 1";
+    const streaming = await renderMath(unfinished);
+    try {
+      expect(streaming.container.querySelector(".katex")).toBeNull();
+      expect(streaming.container.querySelector("[data-markdown-math]")).toBeNull();
+      const block = streaming.container.querySelector("pre");
+      expect(block?.textContent).toContain("$$");
+      expect(block?.textContent).toContain("x = 1");
+    } finally {
+      await streaming.unmount();
+    }
+    const finished = await renderMath(`${unfinished}\n$$`);
+    try {
+      expect(finished.tex(".katex-display")).toEqual(["x = 1"]);
+    } finally {
+      await finished.unmount();
+      delete settingsOverrides.mathRenderingEnabled;
+    }
+  });
+
   it("shows the source of TeX that KaTeX rejects and copies formulas as TeX", async () => {
     settingsOverrides.mathRenderingEnabled = true;
     const view = await renderMath("Good \\(x^2\\), bad \\(\\frac{1\\).");
diff --git a/apps/web/src/markdown-clipboard.ts b/apps/web/src/markdown-clipboard.ts
index 248e171145b9..ec02880f0d59 100644
--- a/apps/web/src/markdown-clipboard.ts
+++ b/apps/web/src/markdown-clipboard.ts
@@ -350,7 +350,6 @@ export function serializeTableElementToMarkdown(table: Element): string {
 // the attribute from authored HTML.
 const MATH_SELECTOR = "[data-markdown-math]";
 
-/** The formula wrapper at or above `element`, if any. */
 export function mathWrapperOf(element: Element | null): Element | null {
   return element?.closest(MATH_SELECTOR) ?? null;
 }
diff --git a/apps/web/src/markdown-math.test.ts b/apps/web/src/markdown-math.test.ts
index de6fead7f667..65d9bf8836ac 100644
--- a/apps/web/src/markdown-math.test.ts
+++ b/apps/web/src/markdown-math.test.ts
@@ -5,23 +5,32 @@ import { texMathFromMarkdown, texMathSyntax } from "./markdown-math";
 
 type Node = { type: string; value?: string; children?: Node[] };
 
-/** Math nodes in document order, as `type:value`. */
-function mathIn(source: string): string[] {
-  const found: string[] = [];
+function parse(source: string): Node {
+  return fromMarkdown(source, {
+    extensions: [texMathSyntax],
+    mdastExtensions: [texMathFromMarkdown],
+  }) as Node;
+}
+
+function nodesIn(source: string, ...types: string[]): Node[] {
+  const found: Node[] = [];
   const visit = (node: Node) => {
-    if (node.type === "inlineMath" || node.type === "math")
-      found.push(`${node.type}:${node.value}`);
+    if (types.includes(node.type)) found.push(node);
     node.children?.forEach(visit);
   };
-  visit(
-    fromMarkdown(source, {
-      extensions: [texMathSyntax],
-      mdastExtensions: [texMathFromMarkdown],
-    }) as Node,
-  );
+  visit(parse(source));
   return found;
 }
 
+/** Math nodes in document order, as `type:value`. */
+const mathIn = (source: string) =>
+  nodesIn(source, "inlineMath", "math").map((node) => `${node.type}:${node.value}`);
+
+/** Values of the code nodes in document order. */
+const codeIn = (source: string) => nodesIn(source, "code").map((node) => node.value);
+
+const typesOf = (node: Node) => node.children?.map((child) => child.type);
+
 describe("TeX math syntax", () => {
   it("reads \\(…\\) inline, including TeX backslashes and underscores", () => {
     expect(mathIn("so \\(\\sigma\\propto r_1T^{-5}\\) holds")).toEqual([
@@ -66,11 +75,6 @@ describe("TeX math syntax", () => {
     ["currency", "Costs $5 and $10"],
     ["a single $", "$\nx\n$"],
     ["a citation", "see \\[1\\] and \\[2\\]"],
-    ["an unclosed display block", "\\[\nx = 1\n"],
-    ["an unclosed $$ block", "$$\nx = 1\n"],
-    ["a $$ block closed by \\]", "$$\na\n\\]"],
-    ["a \\[ block closed by $$", "\\[\na\n$$"],
-    ["a display block cut by a lazy line", "> \\[\n> x\nlazy\n> \\]"],
     ["a link target", "[a](https://x.test/\\(b\\))"],
   ])("leaves %s alone", (_, source) => {
     expect(mathIn(source)).toEqual([]);
@@ -87,8 +91,51 @@ describe("TeX math syntax", () => {
     expect(performance.now() - start).toBeLessThan(2000);
   });
 
-  it("ends an unfinished display block at the next opener", () => {
-    expect(mathIn("\\[\na\n\\[\nb\n\\]")).toEqual(["math:b"]);
+  it("keeps an opener line inside a display block as formula content", () => {
+    expect(mathIn("\\[\na\n\\[\nb\n\\]")).toEqual(["math:a\n\\[\nb"]);
+  });
+
+  it.each([
+    ["an unclosed \\[ block", "\\[\nx = 1\n", "\\[\nx = 1"],
+    ["an unclosed $$ block", "$$\nx = 1\n", "$$\nx = 1"],
+    ["a $$ block closed by \\]", "$$\na\n\\]", "$$\na\n\\]"],
+    ["a \\[ block closed by $$", "\\[\na\n$$", "\\[\na\n$$"],
+    ["a display block cut by a lazy line", "> \\[\n> x\nlazy\n> \\]", "\\[\nx"],
+    ["a display opener at the end", "text\n\n$$", "$$"],
+  ])("shows %s as source in a code node", (_, source, code) => {
+    expect(mathIn(source)).toEqual([]);
+    expect(codeIn(source)).toEqual([code]);
+  });
+
+  it.each([
+    ["\\[", "\\]"],
+    ["$$", "$$"],
+  ])("keeps the structure after a %s block", (open, close) => {
+    const closed = parse(`Intro\n${open}\nx\n${close}\n\n- a\n- b\n\n> quote`);
+    expect(typesOf(closed)).toEqual(["paragraph", "math", "list", "blockquote"]);
+  });
+
+  it.each(["\\[", "$$"])("runs an unclosed %s block to the end of the document", (open) => {
+    const source = `Intro\n${open}\n\n- a\n- b\n\n> quote`;
+    expect(typesOf(parse(source))).toEqual(["paragraph", "code"]);
+    expect(codeIn(source)).toEqual([`${open}\n\n- a\n- b\n\n> quote`]);
+    expect(codeIn(`Intro\n${open}\nx`)).toEqual([`${open}\nx`]);
+  });
+
+  it("ends an unclosed block with its list item", () => {
+    const [list, quote] = parse("- item\n  $$\n  x\n- next\n\n> quote").children!;
+    expect(typesOf(list!)).toEqual(["listItem", "listItem"]);
+    expect(typesOf(list!.children![0]!)).toEqual(["paragraph", "code"]);
+    expect(list!.children![0]!.children![1]!.value).toBe("$$\nx");
+    expect(typesOf(list!.children![1]!)).toEqual(["paragraph"]);
+    expect(quote?.type).toBe("blockquote");
+  });
+
+  it("ends an unclosed block with its blockquote", () => {
+    const tree = parse("> $$\n> x\n\nafter");
+    expect(typesOf(tree)).toEqual(["blockquote", "paragraph"]);
+    expect(typesOf(tree.children![0]!)).toEqual(["code"]);
+    expect(codeIn("> $$\n> x\n\nafter")).toEqual(["$$\nx"]);
   });
 
   it("scans a message of unmatched display openers in linear time", () => {
diff --git a/apps/web/src/markdown-math.ts b/apps/web/src/markdown-math.ts
index 1569090483d7..965f58b55bfe 100644
--- a/apps/web/src/markdown-math.ts
+++ b/apps/web/src/markdown-math.ts
@@ -16,10 +16,17 @@ declare module "micromark-util-types" {
     texMathTextData: "texMathTextData";
     texMathFlow: "texMathFlow";
     texMathFlowFence: "texMathFlowFence";
+    texMathFlowClosingFence: "texMathFlowClosingFence";
     texMathFlowValue: "texMathFlowValue";
   }
 }
 
+declare module "mdast-util-from-markdown" {
+  interface CompileData {
+    texMathFlowClosed?: boolean | undefined;
+  }
+}
+
 // \(…\) inline, and \[ / \] or $$ on their own lines for display. These run before
 // CommonMark's escape construct, which would otherwise consume \( and \[. A
 // one-line \[x\] stays prose so \[1\] citations do.
@@ -122,16 +129,18 @@ const texMathText: Construct = {
 type Marker = readonly [first: number, second: number];
 
 /**
- * A line holding only the `open` marker, up to one holding only the `close`
- * marker. Like a code fence, it must close; an unfinished formula (a reply still
- * streaming) stays prose.
+ * A line holding only the `open` marker, then lines up to one holding only the
+ * `close` marker. Like a fenced code block, it runs to its closing line or the
+ * end of its container; an unclosed block is shown as source.
  */
 const tokenizeTexMathFlow = (open: Marker, close: Marker): Tokenizer =>
   function (effects, ok, nok) {
+    const interrupting = this.interrupt;
     const nonLazyLine: Construct = {
       partial: true,
       tokenize(this: TokenizeContext, effects, ok, nok) {
         return (code) => {
+          if (code === null) return nok(code);
           effects.enter("lineEnding");
           effects.consume(code);
           effects.exit("lineEnding");
@@ -139,11 +148,11 @@ const tokenizeTexMathFlow = (open: Marker, close: Marker): Tokenizer =>
         };
       },
     };
-    const fenceLine = (marker: Marker): Construct => ({
+    const closingFence: Construct = {
       partial: true,
       tokenize(effects, ok, nok) {
         return function start(code) {
-          effects.enter("texMathFlowFence");
+          effects.enter("texMathFlowClosingFence");
           return indent(code);
         };
 
@@ -152,10 +161,10 @@ const tokenizeTexMathFlow = (open: Marker, close: Marker): Tokenizer =>
             effects.consume(code);
             return indent;
           }
-          if (code !== marker[0]) return nok(code);
+          if (code !== close[0]) return nok(code);
           effects.consume(code);
           return function second(code) {
-            if (code !== marker[1]) return nok(code);
+            if (code !== close[1]) return nok(code);
             effects.consume(code);
             return fenceEnd;
           };
@@ -167,13 +176,11 @@ const tokenizeTexMathFlow = (open: Marker, close: Marker): Tokenizer =>
             return fenceEnd;
           }
           if (code !== null && !isLineEnding(code)) return nok(code);
-          effects.exit("texMathFlowFence");
+          effects.exit("texMathFlowClosingFence");
           return ok(code);
         }
       },
-    });
-    const closingFence = fenceLine(close);
-    const openingFence = fenceLine(open);
+    };
 
     return function start(code) {
       effects.enter("texMathFlow");
@@ -186,29 +193,28 @@ const tokenizeTexMathFlow = (open: Marker, close: Marker): Tokenizer =>
       };
     };
 
+    // Only the opening line can fail. Past it the block ends in `ok`, closed or
+    // not. Checking whether the line interrupts a paragraph stops there too.
     function openEnd(code: Code): State | undefined {
       if (isSpace(code)) {
         effects.consume(code);
         return openEnd;
       }
-      if (!isLineEnding(code)) return nok(code);
+      if (code !== null && !isLineEnding(code)) return nok(code);
       effects.exit("texMathFlowFence");
-      return effects.attempt(nonLazyLine, lineStart, nok)(code);
+      return interrupting ? ok(code) : lineEnd(code);
     }
 
-    function lineStart(code: Code): State | undefined {
-      return effects.attempt(closingFence, finish, notOpening)(code);
+    function lineEnd(code: Code): State | undefined {
+      return effects.attempt(nonLazyLine, lineStart, finish)(code);
     }
 
-    // Another opener cannot be inside a formula. Giving up at it keeps a run of
-    // unclosed openers linear.
-    function notOpening(code: Code): State | undefined {
-      return effects.attempt(openingFence, nok, content)(code);
+    function lineStart(code: Code): State | undefined {
+      return effects.attempt(closingFence, finish, content)(code);
     }
 
     function content(code: Code): State | undefined {
-      if (code === null) return nok(code);
-      if (isLineEnding(code)) return effects.attempt(nonLazyLine, lineStart, nok)(code);
+      if (code === null || isLineEnding(code)) return lineEnd(code);
       effects.enter("texMathFlowValue");
       return value(code);
     }
@@ -216,7 +222,7 @@ const tokenizeTexMathFlow = (open: Marker, close: Marker): Tokenizer =>
     function value(code: Code): State | undefined {
       if (code === null || isLineEnding(code)) {
         effects.exit("texMathFlowValue");
-        return content(code);
+        return lineEnd(code);
       }
       effects.consume(code);
       return value;
@@ -250,8 +256,7 @@ export const texMathFromMarkdown: FromMarkdownExtension = {
       this.enter({ type: "inlineMath", value: "", data: { hName: "code" } } as never, token);
       this.buffer();
     },
-    texMathFlow(token) {
-      this.enter({ type: "math", value: "", data: { hName: "pre" } } as never, token);
+    texMathFlow() {
       this.buffer();
     },
   },
@@ -279,29 +284,48 @@ export const texMathFromMarkdown: FromMarkdownExtension = {
       node.data.hChildren = [{ type: "text", value }];
       this.exit(token);
     },
+    texMathFlowClosingFence() {
+      this.data.texMathFlowClosed = true;
+    },
     texMathFlow(token) {
+      const closed = this.data.texMathFlowClosed;
+      this.data.texMathFlowClosed = undefined;
       const value = this.resume()
         .replace(/\r\n?/g, "\n")
         .replace(/^\n|\n$/g, "");
-      const node = this.stack.at(-1) as unknown as {
-        value: string;
-        data: Record;
-      };
-      node.value = value;
-      node.data.hChildren = [
+      if (!closed) {
+        // Not a formula: a plain code node that keeps its opener line.
+        const opener = this.sliceSerialize(token).slice(0, 2);
+        this.enter(
+          { type: "code", lang: null, meta: null, value: value ? `${opener}\n${value}` : opener },
+          token,
+        );
+        this.exit(token);
+        return;
+      }
+      this.enter(
         {
-          type: "element",
-          tagName: "code",
-          properties: { className: ["language-math-display"] },
-          children: [{ type: "text", value }],
-        },
-      ];
+          type: "math",
+          value,
+          data: {
+            hName: "pre",
+            hChildren: [
+              {
+                type: "element",
+                tagName: "code",
+                properties: { className: ["language-math-display"] },
+                children: [{ type: "text", value }],
+              },
+            ],
+          },
+        } as never,
+        token,
+      );
       this.exit(token);
     },
   },
 };
 
-/** unified plugin: registers both extensions on the remark-parse processor. */
 export const remarkTexMath = function (this: Processor) {
   const data = this.data() as {
     micromarkExtensions?: MicromarkExtension[];