From 195054d842d04b648b58adb4d7953fbb0ac692b6 Mon Sep 17 00:00:00 2001 From: Matthew Jansing Date: Mon, 5 Oct 2026 12:32:31 -0700 Subject: [PATCH 1/5] fix: render bracket equations and prevent duplicate math --- docs/images/transcript-math-after.png | Bin 0 -> 24386 bytes docs/images/transcript-math-before.png | Bin 0 -> 27325 bytes package.json | 3 + pnpm-lock.yaml | 9 + .../ai-elements/message-math.render.test.tsx | 109 +++++ .../ui/ai-elements/message-math.test.ts | 221 +++++++++ src/shared/ui/ai-elements/message-math.ts | 421 ++++++++++++++++++ src/shared/ui/ai-elements/message.tsx | 14 +- 8 files changed, 773 insertions(+), 4 deletions(-) create mode 100644 docs/images/transcript-math-after.png create mode 100644 docs/images/transcript-math-before.png create mode 100644 src/shared/ui/ai-elements/message-math.render.test.tsx create mode 100644 src/shared/ui/ai-elements/message-math.test.ts create mode 100644 src/shared/ui/ai-elements/message-math.ts diff --git a/docs/images/transcript-math-after.png b/docs/images/transcript-math-after.png new file mode 100644 index 0000000000000000000000000000000000000000..234d5c81b53df228ad7f9f15c164bfdb82716019 GIT binary patch literal 24386 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 JefsA8{{fv`lS2Ri literal 0 HcmV?d00001 diff --git a/docs/images/transcript-math-before.png b/docs/images/transcript-math-before.png new file mode 100644 index 0000000000000000000000000000000000000000..779095516c7881886d1f688a19391a5eb31ead0b GIT binary patch literal 27325 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 literal 0 HcmV?d00001 diff --git a/package.json b/package.json index 7e8204d0f..1da0ebeb6 100644 --- a/package.json +++ b/package.json @@ -120,6 +120,7 @@ "i18next": "^26.4.0", "i18next-resources-to-backend": "^1.2.3", "input-otp": "^1.5.0", + "katex": "^0.16.47", "lucide-react": "^1.34.0", "matter-js": "^0.20.0", "media-chrome": "^4.19.2", @@ -134,6 +135,7 @@ "recharts": "^3.10.1", "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", + "remark-parse": "^11.0.0", "shiki": "^3.23.0", "sonner": "^2.0.8", "streamdown": "^2.6.0", @@ -141,6 +143,7 @@ "three": "^0.185.1", "tokenlens": "^1.3.1", "tw-animate-css": "^1.4.0", + "unified": "^11.0.5", "use-stick-to-bottom": "^1.1.6", "vaul": "^1.1.2", "yaml": "2.9.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a9ff7a030..4cf88c6e4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -248,6 +248,9 @@ importers: input-otp: specifier: ^1.5.0 version: 1.5.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + katex: + specifier: ^0.16.47 + version: 0.16.47 lucide-react: specifier: ^1.34.0 version: 1.34.0(react@19.2.8) @@ -293,6 +296,9 @@ importers: shiki: specifier: ^3.23.0 version: 3.23.0 + remark-parse: + specifier: ^11.0.0 + version: 11.0.0 sonner: specifier: ^2.0.8 version: 2.0.8(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -311,6 +317,9 @@ importers: tw-animate-css: specifier: ^1.4.0 version: 1.4.0 + unified: + specifier: ^11.0.5 + version: 11.0.5 use-stick-to-bottom: specifier: ^1.1.6 version: 1.1.6(react@19.2.8) diff --git a/src/shared/ui/ai-elements/message-math.render.test.tsx b/src/shared/ui/ai-elements/message-math.render.test.tsx new file mode 100644 index 000000000..4915fbca3 --- /dev/null +++ b/src/shared/ui/ai-elements/message-math.render.test.tsx @@ -0,0 +1,109 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { MessageResponse } from "./message"; + +describe("MessageResponse math with the real renderer", () => { + it("renders fractions and multiple bracket equations as KaTeX", () => { + const { container } = render( + + {String.raw`Before \[\frac{x^2}{100} \le y\] then \[a+b=c\] after`} + , + ); + expect(container.querySelectorAll(".katex")).toHaveLength(2); + expect(container.querySelectorAll(".katex-display")).toHaveLength(2); + expect(container.querySelector(".katex-error")).toBeNull(); + expect(container.textContent).not.toContain(String.raw`\[`); + expect(container.textContent).toContain("Before"); + }); + + it.each([ + String.raw`\[x^2\]`, + "Before \\[a\n+ b\\] after", + "> \\[a\n> + b\\]", + "- Formula \\[a\n + b\\] afterwards", + "> - Formula \\[a\n> + b\\] afterwards", + "- Outer\n - Inner \\[a\n + b\\] afterwards", + ])("renders a display equation in its Markdown container: %s", (content) => { + const { container } = render( + {content}, + ); + expect(container.querySelectorAll(".katex-display")).toHaveLength(1); + expect(container.querySelector(".katex-error")).toBeNull(); + if (content.startsWith(">")) + expect( + container.querySelector("blockquote .katex-display"), + ).not.toBeNull(); + if (content.includes("- ")) + expect(container.querySelector("li .katex-display")).not.toBeNull(); + }); + + it("preserves raw HTML code and GFM autolink destinations", () => { + const { container } = render( + + {String.raw`$\rightarrow$ https://example.com/$\alpha$ and \[x^2\]`} + , + ); + expect(container.querySelector("code")?.textContent).toBe( + String.raw`$\rightarrow$`, + ); + expect(container.querySelector("a")?.getAttribute("href")).toBe( + "https://example.com/$%5Calpha$", + ); + expect(container.querySelectorAll(".katex-display")).toHaveLength(1); + }); + + it("updates a partially streamed formula once its closing delimiter arrives", () => { + const { container, rerender } = render( + {String.raw`\[x^2`}, + ); + expect(container.querySelector(".katex")).toBeNull(); + rerender({String.raw`\[x^2\]`}); + expect(container.querySelectorAll(".katex")).toHaveLength(1); + }); + + it("preserves unfinished inline code through the streaming renderer", () => { + const { container, rerender } = render( + {"`\\[literal\\]"}, + ); + expect(container.querySelector(".katex")).toBeNull(); + expect(container.querySelector("code")?.textContent).toBe( + String.raw`\[literal\]`, + ); + rerender( + {"`\\[literal\\]` then \\[x^2\\]"}, + ); + expect(container.querySelector("code")?.textContent).toBe( + String.raw`\[literal\]`, + ); + expect(container.querySelectorAll(".katex-display")).toHaveLength(1); + }); + + it("keeps currency, shell text and actual code literal while showing symbols", () => { + const { container } = render( + + {"Revenue $20 and $10; $PATH; $\\rightarrow$. Code `\\[x^2\\]`."} + , + ); + expect(container.querySelector(".katex")).toBeNull(); + expect(container.textContent).toContain("Revenue $20 and $10; $PATH; →."); + expect(container.querySelector("code")?.textContent).toBe( + String.raw`\[x^2\]`, + ); + }); + + it("keeps the voice cutoff aligned after symbol shortening", () => { + const content = String.raw`$\alpha$ heard $\rightarrow$ unheard`; + const { container } = render( + + {content} + , + ); + expect( + container.querySelector('[data-voice-unspoken="true"]')?.textContent, + ).toBe("unheard"); + expect(screen.getByText("Not spoken:")).toBeTruthy(); + }); +}); diff --git a/src/shared/ui/ai-elements/message-math.test.ts b/src/shared/ui/ai-elements/message-math.test.ts new file mode 100644 index 000000000..1e57bf519 --- /dev/null +++ b/src/shared/ui/ai-elements/message-math.test.ts @@ -0,0 +1,221 @@ +import { describe, expect, it } from "vitest"; +import { normalizeMessageMath, prepareMessageMath } from "./message-math"; + +const normalize = (source: string) => normalizeMessageMath(source).content; +const display = (body: string) => `\n\n$$\n${body}\n$$\n\n`; + +describe("message math normalization", () => { + it("renders bracket display equations with fractions and inequalities", () => { + const equation = String.raw`\frac{a^2}{b} \le \text{limit}_{n}`; + expect(normalize(`Before \\[${equation}\\] after`)).toBe( + `Before ${display(equation)} after`, + ); + expect(normalize(`\\[\n${equation}\n\\]`)).toBe(display(equation)); + }); + + it("handles separate equations and symbols without changing formula commands", () => { + expect( + normalize( + String.raw`$\alpha$ then \[x\] $\rightarrow$ \[\text{value}\] $\Omega$`, + ), + ).toBe(`α then ${display("x")} → ${display(String.raw`\text{value}`)} Ω`); + }); + + it.each([ + String.raw`\(a^2\)`, + "$a^2$", + "$\\unknownCommand$", + "Revenue $20; margin $10.", + "Run $PATH and $HOME; use snake_case.", + "[ordinary brackets]", + String.raw`\[unfinished`, + String.raw`\\[literal\\]`, + String.raw`\$\rightarrow$`, + String.raw`$$\rightarrow$$`, + "$$\n\\[literal\\]\n$$", + ])("leaves unsupported, escaped, ordinary and existing math unchanged: %s", (source) => { + expect(normalize(source)).toBe(source); + }); + + it.each([ + "`\\[literal\\]`", + "``\\[literal ` text\\]``", + "`first```\\[literal\\]`", + "```tex\n\\[literal\\]\n```", + "````tex\n```\n\\[literal\\]\n````", + "~~~tex\n$\\rightarrow$\n~~~", + "> ```tex\n> \\[literal\\]\n> ```", + "- example\n ~~~tex\n \\[literal\\]\n ~~~", + "```tex\n> ```\n\\[literal\\]\n```", + "```tex\r\n\\[literal\\]\r\n```", + "```tex\n ```\n\\[literal\\]\n```", + "
\\[literal\\]
", + "[link](https://example.com/\\[literal\\])", + ])("preserves parser-recognized code and markup: %s", (source) => { + expect(normalize(source)).toBe(source); + expect(normalize(`${source}\n\n\\[live\\]`)).toBe( + `${source}\n\n${display("live")}`, + ); + }); + + it("protects indented code and unclosed streaming fences", () => { + const indented = " \\[literal\\]\n"; + expect(normalize(indented)).toBe(indented); + const unclosed = "```tex\n\\[unfinished\n\n\\[live\\]"; + expect(normalize(unclosed)).toBe(unclosed); + }); + + it.each([ + String.raw`before \[live\] then ` + "`" + String.raw`\[literal\]`, + "`" + String.raw`$\alpha$`, + "``" + String.raw`\[literal\]`, + ])("preserves the remainder of an incomplete inline code span: %s", (source) => { + const expected = source.replace(String.raw`\[live\]`, () => + display("live"), + ); + expect(normalize(source)).toBe(expected); + }); + + it("does not let an escaped backtick hide live math", () => { + expect(normalize("\\` " + String.raw`\[live\]`)).toBe( + "\\` " + display("live"), + ); + }); + + it.each([ + "- item\n ~~~text\n text\n", + "```text `invalid`\n", + " ```\n literal\n\n", + ])("does not let invalid pseudo-fences hide following equations: %s", (prefix) => { + const rendered = normalize(`${prefix}\\[live\\]`); + expect(rendered).toContain("$$\n"); + expect(rendered).toMatch(/\n[ ]*live\n[ ]*\$\$/); + expect(rendered).not.toContain(String.raw`\[live\]`); + }); + + it("distinguishes even and odd backslash runs at both delimiters", () => { + expect(normalize(String.raw`\\\[x\\\]`)).toBe( + String.raw`\\` + display(String.raw`x\\`), + ); + expect(normalize(String.raw`\[x\\] remains incomplete`)).toBe( + String.raw`\[x\\] remains incomplete`, + ); + }); + + it("leaves a split streaming delimiter unchanged until complete", () => { + const chunks = ["\\", "\\[", "\\[x^2", "\\[x^2\\", "\\[x^2\\]"]; + expect(chunks.map(normalize)).toEqual([ + ...chunks.slice(0, -1), + display("x^2"), + ]); + }); + + it("is idempotent for normalized equations and symbols", () => { + const source = String.raw`$\leq$ \[\frac{a}{b}\] $\rightarrow$`; + expect(normalize(normalize(source))).toBe(normalize(source)); + }); + + it("maps the voice boundary after multiple shortened symbols", () => { + const source = String.raw`$\alpha$ heard $\rightarrow$ unheard`; + const cutoff = source.indexOf("unheard"); + const result = normalizeMessageMath(source, cutoff); + expect(result).toEqual({ + content: "α heard → unheard", + cutoff: "α heard → ".length, + }); + expect(normalizeMessageMath(source, 4).cutoff).toBe(0); + expect(normalizeMessageMath("Nothing changed", 4)).toEqual({ + content: "Nothing changed", + cutoff: 4, + }); + }); + + it.each([ + String.raw`$\rightarrow$ \[literal\]`, + String.raw`
\[literal\]
`, + String.raw`$\alpha$`, + String.raw`$\alpha$`, + String.raw`\[unfinished`, + String.raw`ordinary`, + String.raw`https://example.com/$\alpha$`, + String.raw`www.example.com/$\alpha$`, + String.raw``, + ])("preserves HTML code bodies, attributes, and GFM destinations: %s", (source) => { + expect(normalize(source)).toBe(source); + }); + + it("keeps inline HTML code unchanged while converting following prose", () => { + const source = String.raw`\[literal\] $\alpha$`; + expect(normalize(source + String.raw` then $\rightarrow$`)).toBe( + source + " then →", + ); + }); + + it.each([ + [String.raw`\[x\]`, display("x")], + ["\\[a\n\nb\\]", display("a\n\nb")], + [String.raw`> \[x\]`, "> \n>\n> $$\n> x\n> $$\n>\n> "], + ["> \\[a\n> + b\\]", "> \n>\n> $$\n> a\n> + b\n> $$\n>\n> "], + ["> \\[a\n>+ b\\]", "> \n>\n> $$\n> a\n> + b\n> $$\n>\n> "], + [String.raw`- \[x\]`, "- \n\n $$\n x\n $$\n\n "], + ["- \\[a\n + b\\]", "- \n\n $$\n a\n + b\n $$\n\n "], + [String.raw`> - \[x\]`, "> - \n>\n> $$\n> x\n> $$\n>\n> "], + ["- outer\n - \\[x\\]", "- outer\n - \n\n $$\n x\n $$\n\n "], + [String.raw`1. \[x\]`, "1. \n\n $$\n x\n $$\n\n "], + ])("emits display blocks with the source container: %s", (source, expected) => { + expect(normalize(source)).toBe(expected); + expect(normalize(expected)).toBe(expected); + }); + + it.each([ + String.raw`**before \[x\] after**`, + String.raw`*before \[x\] after*`, + String.raw`~~before \[x\] after~~`, + String.raw`# Heading \[x\] after`, + "| Value |\n| -- |\n| \\[x\\] |", + String.raw`before \[x\] after`, + "\\[root\n\n> quote\\]", + ])("preserves bracket source where display insertion would change markup: %s", (source) => { + expect(normalize(source)).toBe(source); + }); + + it("still converts known symbols within inline formatting and table cells", () => { + expect(normalize(String.raw`**$\alpha$**`)).toBe("**α**"); + expect(normalize("| Value |\n| -- |\n| $\\alpha$ |")).toBe( + "| Value |\n| -- |\n| α |", + ); + }); + + it("maps boundaries across inserted display lines and shortened symbols", () => { + const source = String.raw`$\alpha$ before \[x^2\] after $\rightarrow$ tail`; + const prepared = prepareMessageMath(source); + const formulaStart = source.indexOf(String.raw`\[`); + const formulaEnd = source.indexOf(String.raw`\]`) + 2; + const outputStart = prepared.content.indexOf(display("x^2")); + expect(prepared.remapCutoff(formulaStart)).toBe(outputStart); + expect(prepared.remapCutoff(formulaStart + 4)).toBe(outputStart); + expect(prepared.remapCutoff(formulaEnd)).toBe( + outputStart + display("x^2").length, + ); + expect(prepared.remapCutoff(source.indexOf("tail"))).toBe( + prepared.content.indexOf("tail"), + ); + expect(prepared.remapCutoff(source.length)).toBe(prepared.content.length); + expect(prepared.remapCutoff()).toBeUndefined(); + }); + it("normalizes equations through the source-size boundary", () => { + const source = "a".repeat(127_993) + String.raw`\[x^2\]`; + expect(source).toHaveLength(128_000); + expect(normalize(source)).toBe("a".repeat(127_993) + display("x^2")); + }); + + it.each([ + "a".repeat(128_000) + String.raw`\[x^2\]`, + "a".repeat(128_000) + "\n```tex\n" + String.raw`\[literal\]` + "\n```", + ])("preserves oversized source and voice offsets without parsing", (source) => { + const prepared = prepareMessageMath(source); + expect(prepared.content).toBe(source); + expect(prepared.remapCutoff(128_002)).toBe(128_002); + expect(prepared.remapCutoff()).toBeUndefined(); + }); +}); diff --git a/src/shared/ui/ai-elements/message-math.ts b/src/shared/ui/ai-elements/message-math.ts new file mode 100644 index 000000000..b97ff38aa --- /dev/null +++ b/src/shared/ui/ai-elements/message-math.ts @@ -0,0 +1,421 @@ +import { math } from "@streamdown/math"; +import remarkParse from "remark-parse"; +import remarkGfm from "remark-gfm"; +import { unified } from "unified"; + +// Use the renderer's math configuration and CommonMark's code boundaries. +const parser = unified() + .use(remarkParse) + .use(remarkGfm) + .use([math.remarkPlugin]); +// Bound the extra CommonMark pass on pathological model output. Larger +// messages retain native rendering and source offsets without partial edits. +const maxNormalizationLength = 128_000; +const protectedTypes = new Set([ + "code", + "inlineCode", + "html", + "math", + "inlineMath", + "link", + "linkReference", + "image", + "imageReference", + "definition", +]); + +const symbols: Readonly> = { + to: "→", + rightarrow: "→", + leftarrow: "←", + leftrightarrow: "↔", + Rightarrow: "⇒", + Leftarrow: "⇐", + Leftrightarrow: "⇔", + mapsto: "↦", + uparrow: "↑", + downarrow: "↓", + Uparrow: "⇑", + Downarrow: "⇓", + updownarrow: "↕", + Updownarrow: "⇕", + implies: "⇒", + iff: "⇔", + le: "≤", + leq: "≤", + ge: "≥", + geq: "≥", + ne: "≠", + neq: "≠", + approx: "≈", + sim: "∼", + simeq: "≃", + cong: "≅", + equiv: "≡", + propto: "∝", + pm: "±", + mp: "∓", + times: "×", + div: "÷", + cdot: "·", + infty: "∞", + degree: "°", + checkmark: "✓", + in: "∈", + notin: "∉", + ni: "∋", + subset: "⊂", + subseteq: "⊆", + supset: "⊃", + supseteq: "⊇", + cup: "∪", + cap: "∩", + emptyset: "∅", + forall: "∀", + exists: "∃", + neg: "¬", + land: "∧", + lor: "∨", + therefore: "∴", + because: "∵", + alpha: "α", + beta: "β", + gamma: "γ", + delta: "δ", + epsilon: "ε", + theta: "θ", + lambda: "λ", + mu: "μ", + pi: "π", + rho: "ρ", + sigma: "σ", + tau: "τ", + phi: "φ", + omega: "ω", + Delta: "Δ", + Gamma: "Γ", + Lambda: "Λ", + Pi: "Π", + Sigma: "Σ", + Phi: "Φ", + Omega: "Ω", +}; + +type SourceNode = { + type: string; + position?: { start: { offset?: number }; end: { offset?: number } }; + children?: SourceNode[]; +}; +type Edit = { start: number; end: number; replacement: string }; +type Range = { start: number; end: number }; +type Paragraph = Range & { prefix: string; container: number }; + +function paragraphAt(paragraphs: Paragraph[], offset: number) { + let low = 0; + let high = paragraphs.length - 1; + while (low <= high) { + const middle = (low + high) >>> 1; + const paragraph = paragraphs[middle]; + if (offset < paragraph.start) high = middle - 1; + else if (offset >= paragraph.end) low = middle + 1; + else return paragraph; + } + return undefined; +} + +function overlapsRange(ranges: Range[], start: number, end: number) { + let low = 0; + let high = ranges.length - 1; + while (low <= high) { + const middle = (low + high) >>> 1; + const range = ranges[middle]; + if (range.end <= start) low = middle + 1; + else if (range.start >= end) high = middle - 1; + else return true; + } + return false; +} + +function mergeRanges(ranges: Range[]): Range[] { + ranges.sort((a, b) => a.start - b.start); + const merged: Range[] = []; + for (const range of ranges) { + const previous = merged[merged.length - 1]; + if (previous && range.start <= previous.end) + previous.end = Math.max(previous.end, range.end); + else merged.push({ ...range }); + } + return merged; +} + +function isEscaped(source: string, index: number): boolean { + let backslashes = 0; + while (index > 0 && source[--index] === "\\") backslashes += 1; + return backslashes % 2 === 1; +} + +function displayReplacement(body: string, prefix: string): string { + const lines = body.split(/\r?\n/); + const continuation = new RegExp( + "^" + prefix.replace(/[ \t]+/g, (spaces) => `[ \\t]{0,${spaces.length}}`), + ); + // The first line starts after \\[. Subsequent lines still carry their + // Markdown quote/list continuation prefix, which is not part of TeX. + for (let index = 1; index < lines.length; index += 1) { + if (prefix) lines[index] = lines[index].replace(continuation, ""); + } + while (lines.length && !lines[0].trim()) lines.shift(); + while (lines.length && !lines[lines.length - 1].trim()) lines.pop(); + const blank = prefix.trimEnd(); + return `\n${blank}\n${prefix}$$\n${lines.map((line) => prefix + line).join("\n")}\n${prefix}$$\n${blank}\n${prefix}`; +} + +function segmentEdits( + source: string, + offset: number, + paragraphs: Paragraph[], + nonDisplayRanges: Range[], +): Edit[] { + const edits: Edit[] = []; + let opening: number | undefined; + for (const match of source.matchAll(/\\[[\]]/g)) { + const index = match.index; + if (isEscaped(source, index)) continue; + if (match[0] === "\\[") { + opening ??= index; + } else if (opening !== undefined) { + const start = offset + opening; + const end = offset + index + 2; + const paragraph = paragraphAt(paragraphs, start); + const closingParagraph = paragraphAt(paragraphs, end - 1); + const body = source.slice(opening + 2, index); + // Block displays cannot be inserted into inline formatting, table cells, + // or across unrelated list/quote containers without changing their markup. + // Preserve that source rather than manufacture an invalid block. + if ( + !paragraph || + !closingParagraph || + (/\r?\n[ \t]*\r?\n/.test(body) && + paragraph.container !== closingParagraph.container) || + overlapsRange(nonDisplayRanges, start, end) + ) { + opening = undefined; + continue; + } + edits.push({ + start, + end, + replacement: displayReplacement(body, paragraph.prefix), + }); + opening = undefined; + } + } + // Symbols inside bracket equations belong to KaTeX, not this prose pass. + const equations = [...edits]; + let equationIndex = 0; + for (const match of source.matchAll(/\$\\([A-Za-z]+)\$/g)) { + const start = offset + match.index; + while (equations[equationIndex] && equations[equationIndex].end <= start) + equationIndex += 1; + const equation = equations[equationIndex]; + const symbol = symbols[match[1]]; + if ( + !symbol || + isEscaped(source, match.index) || + (equation && start >= equation.start) + ) + continue; + edits.push({ start, end: start + match[0].length, replacement: symbol }); + } + return edits.sort((a, b) => a.start - b.start); +} + +export type PreparedMessageMath = { + content: string; + remapCutoff: (cutoff?: number) => number | undefined; +}; + +/** Prepare supported math once per source change, independent of voice updates. */ +export function prepareMessageMath(content: string): PreparedMessageMath { + if ( + content.length > maxNormalizationLength || + (!content.includes("\\[") && !/\$\\[A-Za-z]+\$/.test(content)) + ) { + return { content, remapCutoff: (cutoff) => cutoff }; + } + const protectedRanges: Range[] = []; + const paragraphs: Paragraph[] = []; + const htmlRanges: Range[] = []; + const nonDisplayRanges: Range[] = []; + const inlineFormatting = new Set(["emphasis", "strong", "delete"]); + let nextContainer = 0; + const visit = (node: SourceNode, container = 0) => { + if (node.type === "blockquote" || node.type === "listItem") + container = ++nextContainer; + const start = node.position?.start.offset; + const end = node.position?.end.offset; + if (node.type === "paragraph" && start !== undefined && end !== undefined) { + const lineStart = content.lastIndexOf("\n", start - 1) + 1; + // A paragraph's source position begins after its container markers. + // Keep quote markers and turn list markers into continuation indentation. + const prefix = content + .slice(lineStart, start) + .replace(/(?:[-+*]|\d+[.)])([ \t]+)/g, (marker) => + " ".repeat(marker.length), + ); + paragraphs.push({ start, end, prefix, container }); + } + if ( + inlineFormatting.has(node.type) && + start !== undefined && + end !== undefined + ) + nonDisplayRanges.push({ start, end }); + if (node.type === "html" && start !== undefined && end !== undefined) + htmlRanges.push({ start, end }); + if (protectedTypes.has(node.type)) { + if (start !== undefined && end !== undefined) + protectedRanges.push({ start, end }); + return; + } + for (const child of node.children ?? []) visit(child, container); + }; + visit(parser.parse(content)); + // Inline HTML tags are separate AST nodes from their bodies. Preserve code + // and pre contents through the matching close, or the end of a partial stream. + const rawCode: { tag: string; start: number }[] = []; + const rawHtml: { tag: string; start: number }[] = []; + const voidTags = new Set([ + "area", + "base", + "br", + "col", + "embed", + "hr", + "img", + "input", + "link", + "meta", + "param", + "source", + "track", + "wbr", + ]); + for (const range of htmlRanges) { + const html = content.slice(range.start, range.end); + for (const match of html.matchAll( + /<\s*(\/?)\s*([A-Za-z][\w-]*)\b[^>]*>/g, + )) { + const tag = match[2].toLowerCase(); + if (voidTags.has(tag) || /\/\s*>$/.test(match[0])) continue; + if (!match[1]) { + rawHtml.push({ tag, start: range.start + match.index }); + } else { + let opening = rawHtml.length - 1; + while (opening >= 0 && rawHtml[opening].tag !== tag) opening -= 1; + if (opening !== -1) { + nonDisplayRanges.push({ + start: rawHtml[opening].start, + end: range.start + match.index + match[0].length, + }); + rawHtml.splice(opening); + } + } + } + for (const match of html.matchAll(/<\s*(\/?)\s*(code|pre)\b[^>]*>/gi)) { + const tag = match[2].toLowerCase(); + if (!match[1]) { + rawCode.push({ tag, start: range.start + match.index }); + } else { + let opening = rawCode.length - 1; + while (opening >= 0 && rawCode[opening].tag !== tag) opening -= 1; + if (opening !== -1) { + protectedRanges.push({ + start: rawCode[opening].start, + end: range.start + match.index + match[0].length, + }); + rawCode.splice(opening); + } + } + } + } + for (const opening of rawCode) + protectedRanges.push({ start: opening.start, end: content.length }); + for (const opening of rawHtml) + nonDisplayRanges.push({ start: opening.start, end: content.length }); + const existingProtection = mergeRanges(protectedRanges); + // Streamdown can complete an unfinished inline code span while streaming. + // CommonMark has no code node yet, so conservatively preserve its remainder. + for (const paragraph of paragraphs) { + const source = content.slice(paragraph.start, paragraph.end); + for (const match of source.matchAll(/`+/g)) { + const start = paragraph.start + match.index; + const lineEnd = source.indexOf("\n", match.index); + if ( + match.index === 0 && + match[0].length >= 3 && + source + .slice(match[0].length, lineEnd === -1 ? undefined : lineEnd) + .includes("`") + ) + continue; + if ( + !isEscaped(content, start) && + !overlapsRange(existingProtection, start, start + match[0].length) + ) { + protectedRanges.push({ start, end: paragraph.end }); + break; + } + } + } + const mergedRanges = mergeRanges(protectedRanges); + const mergedNonDisplayRanges = mergeRanges(nonDisplayRanges); + const edits: Edit[] = []; + let position = 0; + for (const range of mergedRanges) { + edits.push( + ...segmentEdits( + content.slice(position, range.start), + position, + paragraphs, + mergedNonDisplayRanges, + ), + ); + position = range.end; + } + edits.push( + ...segmentEdits( + content.slice(position), + position, + paragraphs, + mergedNonDisplayRanges, + ), + ); + const parts: string[] = []; + position = 0; + for (const edit of edits) { + parts.push(content.slice(position, edit.start), edit.replacement); + position = edit.end; + } + parts.push(content.slice(position)); + return { + content: parts.join(""), + remapCutoff: (cutoff) => { + if (cutoff === undefined) return undefined; + let delta = 0; + for (const edit of edits) { + if (cutoff < edit.start) break; + // A partially spoken symbol or formula stays wholly unspoken. + if (cutoff < edit.end) return edit.start + delta; + delta += edit.replacement.length - (edit.end - edit.start); + } + return cutoff + delta; + }, + }; +} + +/** Compatibility wrapper for callers that already supply a voice boundary. */ +export function normalizeMessageMath(content: string, cutoff?: number) { + const prepared = prepareMessageMath(content); + return { content: prepared.content, cutoff: prepared.remapCutoff(cutoff) }; +} diff --git a/src/shared/ui/ai-elements/message.tsx b/src/shared/ui/ai-elements/message.tsx index f30a8f280..87cfbf5cb 100644 --- a/src/shared/ui/ai-elements/message.tsx +++ b/src/shared/ui/ai-elements/message.tsx @@ -13,9 +13,11 @@ import { LinkSafetyModal } from "@/shared/ui/ai-elements/link-safety-modal"; import { cn } from "@/shared/lib/cn"; import { useVirtualLayoutPendingForStreamdown } from "@/features/chat/transcript/measurement"; import { useStreamdownTableScrollbarSizing } from "@/shared/ui/ai-elements/streamdown-table-scrollbar"; +import { prepareMessageMath } from "@/shared/ui/ai-elements/message-math"; import { cjk } from "@streamdown/cjk"; import { code } from "@streamdown/code"; import { math } from "@streamdown/math"; +import "katex/dist/katex.min.css"; import { mermaid } from "@streamdown/mermaid"; import type { UIMessage } from "ai"; import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; @@ -974,6 +976,10 @@ export const MessageResponse = memo( }: MessageResponseProps) => { const { t } = useTranslation("common"); const [modalUrl, setModalUrl] = useState(null); + const normalized = useMemo( + () => prepareMessageMath(children ?? ""), + [children], + ); const streamdownComponents = useMemo( () => buildStreamdownComponents(imageRenderer, strikethroughLabel), [imageRenderer, strikethroughLabel], @@ -988,12 +994,12 @@ export const MessageResponse = memo( ...berdRehypePlugins, [ strikethroughFromPlugin, - strikethroughFrom, + normalized.remapCutoff(strikethroughFrom), strikethroughLabel, - children, + normalized.content, ], ], - [children, strikethroughFrom, strikethroughLabel], + [normalized, strikethroughFrom, strikethroughLabel], ); const streamdownRootRef = useRef(null); const streamdownLayoutPending = useVirtualLayoutPendingForStreamdown({ @@ -1075,7 +1081,7 @@ export const MessageResponse = memo( } {...props} > - {children} + {normalized.content} Date: Tue, 6 Oct 2026 09:17:15 -0700 Subject: [PATCH 2/5] fix: preserve list item boundaries in bracket equations Signed-off-by: Matthew Jansing --- .../ai-elements/message-math.render.test.tsx | 40 +++++++++++++++++++ .../ui/ai-elements/message-math.test.ts | 16 ++++++++ src/shared/ui/ai-elements/message-math.ts | 30 +++++++++++--- 3 files changed, 81 insertions(+), 5 deletions(-) diff --git a/src/shared/ui/ai-elements/message-math.render.test.tsx b/src/shared/ui/ai-elements/message-math.render.test.tsx index 4915fbca3..3f562d8ce 100644 --- a/src/shared/ui/ai-elements/message-math.render.test.tsx +++ b/src/shared/ui/ai-elements/message-math.render.test.tsx @@ -23,6 +23,8 @@ describe("MessageResponse math with the real renderer", () => { "- Formula \\[a\n + b\\] afterwards", "> - Formula \\[a\n> + b\\] afterwards", "- Outer\n - Inner \\[a\n + b\\] afterwards", + "1. Formula \\[a\n + b\\] afterwards\n2. Unrelated prose", + "- Formula \\[a\n + b\\] afterwards\n- Unrelated prose", ])("renders a display equation in its Markdown container: %s", (content) => { const { container } = render( {content}, @@ -37,6 +39,44 @@ describe("MessageResponse math with the real renderer", () => { expect(container.querySelector("li .katex-display")).not.toBeNull(); }); + it.each([ + "- First \\[x\n- Second y\\]", + "1. First \\[x\n2. Second y\\]", + "> - First \\[x\n> - Second y\\]", + "- First \\[x\n+ Second y\\]", + ])("preserves both list items when a delimiter pair spans them: %s", (content) => { + const { container } = render( + {content}, + ); + const items = container.querySelectorAll("li"); + expect(items).toHaveLength(2); + expect(items[0].textContent).toContain( + content.includes("Parent") ? "Parent" : "First", + ); + expect(items[1].textContent).toContain( + content.includes("Child") ? "Child" : "Second", + ); + expect(container.querySelector(".katex-display")).toBeNull(); + expect(container.querySelector(".katex-error")).toBeNull(); + }); + + it("preserves sibling list items as a cross-item delimiter pair streams in", () => { + const content = "- First \\[x\n- Second y"; + const { container, rerender } = render( + {content}, + ); + expect(container.querySelectorAll("li")).toHaveLength(2); + rerender({content + "\\]"}); + expect(container.querySelectorAll("li")).toHaveLength(2); + expect(container.querySelector(".katex-display")).toBeNull(); + expect(container.querySelectorAll("li")[1].textContent).toContain("Second"); + rerender( + {"- First \\[x\\]\n- Second y"}, + ); + expect(container.querySelectorAll("li")).toHaveLength(2); + expect(container.querySelectorAll(".katex-display")).toHaveLength(1); + }); + it("preserves raw HTML code and GFM autolink destinations", () => { const { container } = render( diff --git a/src/shared/ui/ai-elements/message-math.test.ts b/src/shared/ui/ai-elements/message-math.test.ts index 1e57bf519..3c5d06be5 100644 --- a/src/shared/ui/ai-elements/message-math.test.ts +++ b/src/shared/ui/ai-elements/message-math.test.ts @@ -186,6 +186,22 @@ describe("message math normalization", () => { ); }); + it.each([ + "- First \\[x\n- Second y\\]", + "1. First \\[x\n2. Second y\\]", + "> - First \\[x\n> - Second y\\]", + "- Parent\n - First \\[x\n - Second y\\]", + "- First \\[x\n+ Second y\\]", + "\\[root\n> quote\\]", + ])("preserves delimiters crossing Markdown containers: %s", (source) => { + const prepared = prepareMessageMath(source); + expect(prepared.content).toBe(source); + expect(prepared.remapCutoff(source.indexOf("\\]"))).toBe( + source.indexOf("\\]"), + ); + expect(prepared.remapCutoff(source.length)).toBe(source.length); + }); + it("maps boundaries across inserted display lines and shortened symbols", () => { const source = String.raw`$\alpha$ before \[x^2\] after $\rightarrow$ tail`; const prepared = prepareMessageMath(source); diff --git a/src/shared/ui/ai-elements/message-math.ts b/src/shared/ui/ai-elements/message-math.ts index b97ff38aa..869bce811 100644 --- a/src/shared/ui/ai-elements/message-math.ts +++ b/src/shared/ui/ai-elements/message-math.ts @@ -108,7 +108,11 @@ type SourceNode = { }; type Edit = { start: number; end: number; replacement: string }; type Range = { start: number; end: number }; -type Paragraph = Range & { prefix: string; container: number }; +type Paragraph = Range & { + prefix: string; + container: number; + containerEnd: number; +}; function paragraphAt(paragraphs: Paragraph[], offset: number) { let low = 0; @@ -191,10 +195,16 @@ function segmentEdits( const body = source.slice(opening + 2, index); // Block displays cannot be inserted into inline formatting, table cells, // or across unrelated list/quote containers without changing their markup. - // Preserve that source rather than manufacture an invalid block. + // A math continuation such as "+ b" can parse as a nested list. Stay + // within the opening item's/quote's source span even without blank lines, + // rather than requiring the closing paragraph to have the same AST parent. + // Preserve unrelated source rather than manufacture an invalid block. if ( !paragraph || !closingParagraph || + end > paragraph.containerEnd || + (paragraph.prefix.match(/>/g)?.length ?? 0) !== + (closingParagraph.prefix.match(/>/g)?.length ?? 0) || (/\r?\n[ \t]*\r?\n/.test(body) && paragraph.container !== closingParagraph.container) || overlapsRange(nonDisplayRanges, start, end) @@ -249,11 +259,20 @@ export function prepareMessageMath(content: string): PreparedMessageMath { const nonDisplayRanges: Range[] = []; const inlineFormatting = new Set(["emphasis", "strong", "delete"]); let nextContainer = 0; - const visit = (node: SourceNode, container = 0) => { + const visit = ( + node: SourceNode, + container = 0, + containerEnd = content.length, + ) => { if (node.type === "blockquote" || node.type === "listItem") container = ++nextContainer; const start = node.position?.start.offset; const end = node.position?.end.offset; + if ( + (node.type === "blockquote" || node.type === "listItem") && + end !== undefined + ) + containerEnd = end; if (node.type === "paragraph" && start !== undefined && end !== undefined) { const lineStart = content.lastIndexOf("\n", start - 1) + 1; // A paragraph's source position begins after its container markers. @@ -263,7 +282,7 @@ export function prepareMessageMath(content: string): PreparedMessageMath { .replace(/(?:[-+*]|\d+[.)])([ \t]+)/g, (marker) => " ".repeat(marker.length), ); - paragraphs.push({ start, end, prefix, container }); + paragraphs.push({ start, end, prefix, container, containerEnd }); } if ( inlineFormatting.has(node.type) && @@ -278,7 +297,8 @@ export function prepareMessageMath(content: string): PreparedMessageMath { protectedRanges.push({ start, end }); return; } - for (const child of node.children ?? []) visit(child, container); + for (const child of node.children ?? []) + visit(child, container, containerEnd); }; visit(parser.parse(content)); // Inline HTML tags are separate AST nodes from their bodies. Preserve code From c06e786413ae3c6960a3b4f8952f5a87e03d78cd Mon Sep 17 00:00:00 2001 From: Matthew Jansing Date: Tue, 6 Oct 2026 10:37:53 -0700 Subject: [PATCH 3/5] fix: preserve nested items in bracket equations Signed-off-by: Matthew Jansing --- .../ai-elements/message-math.render.test.tsx | 22 +++++++++ .../ui/ai-elements/message-math.test.ts | 27 +++++++++++ src/shared/ui/ai-elements/message-math.ts | 45 ++++++++++++++++--- 3 files changed, 88 insertions(+), 6 deletions(-) diff --git a/src/shared/ui/ai-elements/message-math.render.test.tsx b/src/shared/ui/ai-elements/message-math.render.test.tsx index 3f562d8ce..7ffea5ad8 100644 --- a/src/shared/ui/ai-elements/message-math.render.test.tsx +++ b/src/shared/ui/ai-elements/message-math.render.test.tsx @@ -21,6 +21,9 @@ describe("MessageResponse math with the real renderer", () => { "Before \\[a\n+ b\\] after", "> \\[a\n> + b\\]", "- Formula \\[a\n + b\\] afterwards", + "- Formula \\[a\n - b\\] afterwards", + "- Formula \\[a\n + \\beta\\] afterwards", + "- Formula \\[a\n + b^2\\] afterwards", "> - Formula \\[a\n> + b\\] afterwards", "- Outer\n - Inner \\[a\n + b\\] afterwards", "1. Formula \\[a\n + b\\] afterwards\n2. Unrelated prose", @@ -41,6 +44,12 @@ describe("MessageResponse math with the real renderer", () => { it.each([ "- First \\[x\n- Second y\\]", + "- Parent \\[x\n - Child y\\]", + "1. Parent \\[x\n 1. Child y\\]", + "> - Parent \\[x\n> - Child y\\]", + "- Parent \\[x\n + Child y\\]", + "- Parent \\[x\n * Child b\\]", + "- Parent \\[x\n - Child y\n z\\]", "1. First \\[x\n2. Second y\\]", "> - First \\[x\n> - Second y\\]", "- First \\[x\n+ Second y\\]", @@ -77,6 +86,19 @@ describe("MessageResponse math with the real renderer", () => { expect(container.querySelectorAll(".katex-display")).toHaveLength(1); }); + it("preserves parent and child as the closing delimiter streams in", () => { + const source = "- Parent \\[x\n - Child y"; + const { container, rerender } = render( + {source}, + ); + for (const suffix of ["", "\\", "\\]"]) { + rerender({source + suffix}); + expect(container.querySelectorAll("li")).toHaveLength(2); + expect(container.querySelector("li li")?.textContent).toContain("Child"); + expect(container.querySelector(".katex-display")).toBeNull(); + } + }); + it("preserves raw HTML code and GFM autolink destinations", () => { const { container } = render( diff --git a/src/shared/ui/ai-elements/message-math.test.ts b/src/shared/ui/ai-elements/message-math.test.ts index 3c5d06be5..016772543 100644 --- a/src/shared/ui/ai-elements/message-math.test.ts +++ b/src/shared/ui/ai-elements/message-math.test.ts @@ -202,6 +202,20 @@ describe("message math normalization", () => { expect(prepared.remapCutoff(source.length)).toBe(source.length); }); + it.each([ + "- Parent \\[x\n - Child y\\]", + "1. Parent \\[x\n 1. Child y\\]", + "> - Parent \\[x\n> - Child y\\]", + "- Parent \\[x\n + Child y\\]", + "- Parent \\[x\n * b\\]", + "- Parent \\[x\n - Child y\n z\\]", + ])("preserves equations crossing into a child item: %s", (source) => { + const prepared = prepareMessageMath(source); + expect(prepared.content).toBe(source); + for (let cutoff = 0; cutoff <= source.length; cutoff += 1) + expect(prepared.remapCutoff(cutoff)).toBe(cutoff); + }); + it("maps boundaries across inserted display lines and shortened symbols", () => { const source = String.raw`$\alpha$ before \[x^2\] after $\rightarrow$ tail`; const prepared = prepareMessageMath(source); @@ -219,6 +233,19 @@ describe("message math normalization", () => { expect(prepared.remapCutoff(source.length)).toBe(prepared.content.length); expect(prepared.remapCutoff()).toBeUndefined(); }); + + it.each([ + "+ b", + "- b", + "+ 2.5", + "+ \\beta", + "+ b^2", + "- x_{12}", + ])("keeps a simple algebra continuation: %s", (term) => { + const source = `- Formula \\[a\n ${term}\\] afterwards`; + expect(normalize(source)).toContain(` a\n ${term}\n $$`); + expect(normalize(source)).not.toContain("\\["); + }); it("normalizes equations through the source-size boundary", () => { const source = "a".repeat(127_993) + String.raw`\[x^2\]`; expect(source).toHaveLength(128_000); diff --git a/src/shared/ui/ai-elements/message-math.ts b/src/shared/ui/ai-elements/message-math.ts index 869bce811..78627f7b5 100644 --- a/src/shared/ui/ai-elements/message-math.ts +++ b/src/shared/ui/ai-elements/message-math.ts @@ -109,6 +109,7 @@ type SourceNode = { type Edit = { start: number; end: number; replacement: string }; type Range = { start: number; end: number }; type Paragraph = Range & { + index: number; prefix: string; container: number; containerEnd: number; @@ -174,6 +175,24 @@ function displayReplacement(body: string, prefix: string): string { return `\n${blank}\n${prefix}$$\n${lines.map((line) => prefix + line).join("\n")}\n${prefix}$$\n${blank}\n${prefix}`; } +function isOperatorContinuation(body: string, prefix: string): boolean { + // CommonMark can mistake a simple algebra continuation for a child bullet. + // Admit only an operator followed by one explicit math atom. Ambiguous prose + // and more complex cross-item source retain their original Markdown instead. + const continuation = new RegExp( + "^" + prefix.replace(/[ \t]+/g, (spaces) => `[ \\t]{0,${spaces.length}}`), + ); + return body + .split(/\r?\n/) + .slice(1) + .every((line) => { + const source = (prefix ? line.replace(continuation, "") : line).trim(); + return /^[+-][ \t]+(?:[A-Za-z]|\d+(?:\.\d+)?|\\[A-Za-z]+)(?:[_^](?:[A-Za-z0-9]|\{[A-Za-z0-9]+\}))?$/.test( + source, + ); + }); +} + function segmentEdits( source: string, offset: number, @@ -195,9 +214,9 @@ function segmentEdits( const body = source.slice(opening + 2, index); // Block displays cannot be inserted into inline formatting, table cells, // or across unrelated list/quote containers without changing their markup. - // A math continuation such as "+ b" can parse as a nested list. Stay - // within the opening item's/quote's source span even without blank lines, - // rather than requiring the closing paragraph to have the same AST parent. + // A math continuation such as "+ b" can parse as a nested list. Only + // admit that narrow algebra case across containers; a parent's source + // span also contains real child items and cannot alone protect them. // Preserve unrelated source rather than manufacture an invalid block. if ( !paragraph || @@ -205,8 +224,15 @@ function segmentEdits( end > paragraph.containerEnd || (paragraph.prefix.match(/>/g)?.length ?? 0) !== (closingParagraph.prefix.match(/>/g)?.length ?? 0) || - (/\r?\n[ \t]*\r?\n/.test(body) && - paragraph.container !== closingParagraph.container) || + (paragraphs + .slice(paragraph.index, closingParagraph.index + 1) + .some( + (candidate) => + candidate.start < end && + candidate.end > start && + candidate.container !== paragraph.container, + ) && + !isOperatorContinuation(body, paragraph.prefix)) || overlapsRange(nonDisplayRanges, start, end) ) { opening = undefined; @@ -282,7 +308,14 @@ export function prepareMessageMath(content: string): PreparedMessageMath { .replace(/(?:[-+*]|\d+[.)])([ \t]+)/g, (marker) => " ".repeat(marker.length), ); - paragraphs.push({ start, end, prefix, container, containerEnd }); + paragraphs.push({ + index: paragraphs.length, + start, + end, + prefix, + container, + containerEnd, + }); } if ( inlineFormatting.has(node.type) && From 0e5de06ecb6cb267cd0db1ab125e174eee00fae5 Mon Sep 17 00:00:00 2001 From: Matthew Jansing Date: Tue, 6 Oct 2026 11:42:49 -0700 Subject: [PATCH 4/5] fix: preserve markdown blocks and tabbed math lists Signed-off-by: Matthew Jansing --- .../ai-elements/message-math.render.test.tsx | 78 +++++++++++++++++++ .../ui/ai-elements/message-math.test.ts | 41 ++++++++++ src/shared/ui/ai-elements/message-math.ts | 36 +++++++-- 3 files changed, 148 insertions(+), 7 deletions(-) diff --git a/src/shared/ui/ai-elements/message-math.render.test.tsx b/src/shared/ui/ai-elements/message-math.render.test.tsx index 7ffea5ad8..ce61f3648 100644 --- a/src/shared/ui/ai-elements/message-math.render.test.tsx +++ b/src/shared/ui/ai-elements/message-math.render.test.tsx @@ -99,6 +99,84 @@ describe("MessageResponse math with the real renderer", () => { } }); + it.each([ + [ + "\\[unfinished\n\n## Next section\n\nHere is the closing marker \\]", + "h2", + ], + [ + "- \\[unfinished\n\n ## Next section\n\n Here is the closing marker \\]", + "li h2", + ], + [ + "> \\[unfinished\n>\n> ## Next section\n>\n> Here is the closing marker \\]", + "blockquote h2", + ], + ["\\[unfinished\n\nNext section\n============\n\nClosing \\]", "h1"], + ["\\[unfinished\n\n***\n\nClosing \\]", "hr"], + ["\\[unfinished\n\n| Value |\n| --- |\n| prose |\n\nClosing \\]", "table"], + ["\\[unfinished\n\n>\n\nClosing \\]", "blockquote"], + ["\\[unfinished\n\n-\n\nClosing \\]", "li"], + ])("preserves intervening blocks through the actual renderer: %s", (source, selector) => { + const { container, rerender } = render( + {source}, + ); + expect(container.querySelector(selector)).not.toBeNull(); + expect(container.querySelector(".katex-display")).toBeNull(); + rerender( + + {source + "\n\n\\[live\\]"} + , + ); + expect(container.querySelector(selector)).not.toBeNull(); + expect(container.querySelectorAll(".katex-display")).toHaveLength(1); + }); + + it("preserves a heading as the closing delimiter streams in", () => { + const source = "\\[unfinished\n\n## Next section\n\nClosing "; + const { container, rerender } = render( + {source}, + ); + for (const suffix of ["", "\\", "\\]"]) { + rerender({source + suffix}); + expect(container.querySelector("h2")?.textContent).toBe("Next section"); + expect(container.querySelector(".katex-display")).toBeNull(); + } + }); + + it.each([ + "-\tFormula \\[x\\] afterwards\n- Next item", + "1.\tFormula \\[x\\] afterwards\n2. Next item", + "12.\tFormula \\[x\\] afterwards\n13. Next item", + "123.\tFormula \\[x\\] afterwards\n124. Next item", + "> -\tFormula \\[x\\] afterwards\n> - Next item", + "- Outer\n -\tFormula \\[x\\] afterwards\n - Next item", + "-\tFormula \\[a\n\t+ b\\] afterwards\n- Next item", + ])("keeps tab-indented math and tail inside their list item: %s", (source) => { + const { container, rerender } = render( + {source}, + ); + const items = container.querySelectorAll("li"); + const formula = container.querySelector(".katex-display")?.closest("li"); + expect(items).toHaveLength(source.includes("Outer") ? 3 : 2); + expect(formula).toBeTruthy(); + expect(formula?.textContent).toContain("Formula"); + expect(formula?.textContent).toContain("afterwards"); + expect(items[items.length - 1].textContent?.trim()).toBe("Next item"); + expect(container.querySelector("pre")).toBeNull(); + expect(container.querySelector(".katex-error")).toBeNull(); + const cutoff = source.indexOf("afterwards"); + rerender( + + {source} + , + ); + expect( + container.querySelector('[data-voice-unspoken="true"]')?.textContent, + ).toContain("afterwards"); + expect(container.querySelector("li .katex-display")).not.toBeNull(); + }); + it("preserves raw HTML code and GFM autolink destinations", () => { const { container } = render( diff --git a/src/shared/ui/ai-elements/message-math.test.ts b/src/shared/ui/ai-elements/message-math.test.ts index 016772543..bcebccc2b 100644 --- a/src/shared/ui/ai-elements/message-math.test.ts +++ b/src/shared/ui/ai-elements/message-math.test.ts @@ -186,6 +186,47 @@ describe("message math normalization", () => { ); }); + it.each([ + "\\[unfinished\n\n## Next section\n\nHere is the closing marker \\]", + "- \\[unfinished\n\n ## Next section\n\n Here is the closing marker \\]", + "> \\[unfinished\n>\n> ## Next section\n>\n> Here is the closing marker \\]", + "\\[unfinished\n\nNext section\n============\n\nClosing \\]", + "\\[unfinished\n\n***\n\nClosing \\]", + "\\[unfinished\n\n| Value |\n| --- |\n| prose |\n\nClosing \\]", + "\\[unfinished\n\n>\n\nClosing \\]", + "\\[unfinished\n\n-\n\nClosing \\]", + ])("preserves intervening block structure: %s", (source) => { + const prepared = prepareMessageMath(source); + expect(prepared.content).toBe(source); + for (let cutoff = 0; cutoff <= source.length; cutoff += 1) + expect(prepared.remapCutoff(cutoff)).toBe(cutoff); + expect(normalize(`${source}\n\n\\[live\\]`)).toContain(display("live")); + }); + + it.each([ + ["-\t", " "], + ["1.\t", " "], + ["12.\t", " "], + ["123.\t", " "], + ["> -\t", "> "], + ])("uses tab-expanded source columns for a list prefix: %s", (marker, prefix) => { + const source = `${marker}Formula \\[x\\] afterwards`; + const prepared = prepareMessageMath(source); + expect(prepared.content).toContain( + `\n${prefix}$$\n${prefix}x\n${prefix}$$\n`, + ); + expect(prepared.content.endsWith(`${prefix} afterwards`)).toBe(true); + expect(prepared.remapCutoff(source.indexOf("afterwards"))).toBe( + prepared.content.indexOf("afterwards"), + ); + }); + + it("renders a multiline equation with tabbed list continuation", () => { + expect(normalize("-\tFormula \\[a\n\t+ b\\] afterwards")).toContain( + "\n $$\n a\n + b\n $$\n", + ); + }); + it.each([ "- First \\[x\n- Second y\\]", "1. First \\[x\n2. Second y\\]", diff --git a/src/shared/ui/ai-elements/message-math.ts b/src/shared/ui/ai-elements/message-math.ts index 78627f7b5..703042e03 100644 --- a/src/shared/ui/ai-elements/message-math.ts +++ b/src/shared/ui/ai-elements/message-math.ts @@ -159,6 +159,21 @@ function isEscaped(source: string, index: number): boolean { return backslashes % 2 === 1; } +function continuationPrefix(source: string): string { + // CommonMark indentation uses four-column tab stops, not character counts. + // Expand before replacing markers so nested list/quote columns stay aligned. + let column = 0; + let expanded = ""; + for (const character of source) { + const width = character === "\t" ? 4 - (column % 4) : 1; + expanded += character === "\t" ? " ".repeat(width) : character; + column += width; + } + return expanded.replace(/(?:[-+*]|\d+[.)]) +/g, (marker) => + " ".repeat(marker.length), + ); +} + function displayReplacement(body: string, prefix: string): string { const lines = body.split(/\r?\n/); const continuation = new RegExp( @@ -283,7 +298,16 @@ export function prepareMessageMath(content: string): PreparedMessageMath { const paragraphs: Paragraph[] = []; const htmlRanges: Range[] = []; const nonDisplayRanges: Range[] = []; - const inlineFormatting = new Set(["emphasis", "strong", "delete"]); + // Leaf blocks and empty containers are barriers even when the equation's + // opening and closing paragraphs share the same parent. + const nonDisplayTypes = new Set([ + "emphasis", + "strong", + "delete", + "heading", + "thematicBreak", + "table", + ]); let nextContainer = 0; const visit = ( node: SourceNode, @@ -303,11 +327,7 @@ export function prepareMessageMath(content: string): PreparedMessageMath { const lineStart = content.lastIndexOf("\n", start - 1) + 1; // A paragraph's source position begins after its container markers. // Keep quote markers and turn list markers into continuation indentation. - const prefix = content - .slice(lineStart, start) - .replace(/(?:[-+*]|\d+[.)])([ \t]+)/g, (marker) => - " ".repeat(marker.length), - ); + const prefix = continuationPrefix(content.slice(lineStart, start)); paragraphs.push({ index: paragraphs.length, start, @@ -318,7 +338,9 @@ export function prepareMessageMath(content: string): PreparedMessageMath { }); } if ( - inlineFormatting.has(node.type) && + (nonDisplayTypes.has(node.type) || + ((node.type === "blockquote" || node.type === "listItem") && + !node.children?.length)) && start !== undefined && end !== undefined ) From 929ae489c4f56e4bffe3a164d71c07d71d3f1094 Mon Sep 17 00:00:00 2001 From: Matthew Jansing Date: Tue, 6 Oct 2026 12:14:01 -0700 Subject: [PATCH 5/5] fix: retain equation-first list items Signed-off-by: Matthew Jansing --- .../ai-elements/message-math.render.test.tsx | 162 ++++++++++++++++++ .../ui/ai-elements/message-math.test.ts | 49 +++++- src/shared/ui/ai-elements/message-math.ts | 32 +++- 3 files changed, 232 insertions(+), 11 deletions(-) diff --git a/src/shared/ui/ai-elements/message-math.render.test.tsx b/src/shared/ui/ai-elements/message-math.render.test.tsx index ce61f3648..bf040a916 100644 --- a/src/shared/ui/ai-elements/message-math.render.test.tsx +++ b/src/shared/ui/ai-elements/message-math.render.test.tsx @@ -177,6 +177,168 @@ describe("MessageResponse math with the real renderer", () => { expect(container.querySelector("li .katex-display")).not.toBeNull(); }); + it.each([ + " - \\[x\\] after\n - Next", + " - \\[x\\] after\n - Next", + "- \\[x\\] after\n- Next", + "> > - \\[x\\] after\n> > - Next", + "-\n \\[x\\] after\n- Next", + "- \\[x\\] after\n- Next", + "+ \\[x\\] after\n+ Next", + "* \\[x\\] after\n* Next", + "1. \\[x\\] after\n2. Next", + "12. \\[x\\] after\n13. Next", + "123) \\[x\\] after\n124) Next", + "-\t\\[x\\] after\n- Next", + "1.\t\\[x\\] after\n2. Next", + "123.\t\\[x\\] after\n124. Next", + "> - \\[x\\] after\n> - Next", + "> 1. \\[x\\] after\n> 2. Next", + "- Outer\n - \\[x\\] after\n - Next", + "- Outer\n -\t\\[x\\] after\n - Next", + "> - Outer\n> - \\[x\\] after\n> - Next", + "- > \\[x\\] after\n- Next", + "- \\[a\n + b\\] after\n- Next", + "- \\[x\\] after\r\n- Next", + "- \\[x\\] after and \\[y\\] also\n- Next", + "- Intro\n\n \\[x\\] after\n- Next", + ])("keeps item-start equations, tail and following item in the same list: %s", (source) => { + const { container, rerender } = render( + {source}, + ); + const verify = () => { + const items = container.querySelectorAll("li"); + const next = items[items.length - 1]; + const owner = items[items.length - 2]; + expect(items).toHaveLength(source.includes("Outer") ? 3 : 2); + const displays = container.querySelectorAll(".katex-display"); + expect(displays).toHaveLength(source.includes("also") ? 2 : 1); + for (const display of displays) expect(display.closest("li")).toBe(owner); + expect(owner.textContent).toContain("after"); + expect(next.textContent?.trim()).toBe("Next"); + expect(owner.parentElement).toBe(next.parentElement); + expect(container.querySelector("pre")).toBeNull(); + expect(container.querySelector(".katex-error")).toBeNull(); + if (source.includes("[ ]") || source.includes("[x]")) + expect( + container.querySelectorAll('input[type="checkbox"]'), + ).toHaveLength(2); + }; + verify(); + rerender( + + {source} + , + ); + verify(); + expect( + container.querySelector('[data-voice-unspoken="true"]')?.textContent, + ).toContain("after"); + }); + + it.each([ + ["- ", "\n- Next"], + ["1. ", "\n2. Next"], + ["-\t", "\n- Next"], + ["> - ", "\n> - Next"], + ["- Outer\n - ", "\n - Next"], + ])("retains item-start equation ownership when the close streams in: %s", (prefix, following) => { + const { container, rerender } = render( + {prefix}, + ); + for (const equation of ["\\[x", "\\[x\\", "\\[x\\]"]) { + rerender( + + {prefix + equation + " after" + following} + , + ); + const items = container.querySelectorAll("li"); + const owner = items[items.length - 2]; + const next = items[items.length - 1]; + expect(items).toHaveLength(prefix.includes("Outer") ? 3 : 2); + expect(owner.textContent).toContain("after"); + expect(owner.parentElement).toBe(next.parentElement); + if (equation.endsWith("\\]")) + expect(next.textContent?.trim()).toBe("Next"); + else expect(next.textContent).toContain("Next"); + if (equation.endsWith("\\]")) + expect(container.querySelector(".katex-display")?.closest("li")).toBe( + owner, + ); + else expect(container.querySelector(".katex-display")).toBeNull(); + } + }); + + it.each([ + " ", + "x", + "X", + ])("preserves checkbox semantics and source for task math: %s", (state) => { + const source = `- [${state}] \\[x\\] after\n- [ ] Next`; + const { container } = render( + {source}, + ); + const items = container.querySelectorAll("li"); + expect(items).toHaveLength(2); + expect(items[0].textContent).toContain("[x] after"); + expect(items[1].textContent?.trim()).toBe("Next"); + const checkboxes = container.querySelectorAll( + 'input[type="checkbox"]', + ); + expect(checkboxes).toHaveLength(2); + expect(checkboxes[0].checked).toBe(state !== " "); + expect(checkboxes[1].checked).toBe(false); + expect(container.querySelector(".katex-display")).toBeNull(); + expect(container.querySelector("pre")).toBeNull(); + }); + + it("retains a later child under an equation-first parent", () => { + const { container } = render( + + {"- \\[x\\] after\n - Child\n- Next"} + , + ); + const rootList = container.querySelector("ul"); + const items = rootList?.children; + expect(items).toHaveLength(2); + expect(container.querySelector(".katex-display")?.closest("li")).toBe( + items?.[0], + ); + expect(items?.[0].querySelector("li")?.textContent?.trim()).toBe("Child"); + expect(items?.[1].textContent?.trim()).toBe("Next"); + }); + + it("renders an equation-only item at end of source", () => { + const { container } = render( + {"- \\[x\\]"}, + ); + const item = container.querySelector("li"); + expect(container.querySelectorAll("ul")).toHaveLength(1); + expect(container.querySelectorAll("li")).toHaveLength(1); + expect(container.querySelector(".katex-display")?.closest("li")).toBe(item); + expect(container.querySelector(".katex-error")).toBeNull(); + }); + + it("allows a later task-item paragraph and a plain child to render math", () => { + const source = + "- [ ] Task\n\n \\[x\\] after\n\n - \\[y\\] child\n- [ ] Next"; + const { container } = render( + {source}, + ); + const items = container.querySelectorAll("li"); + const math = container.querySelectorAll(".katex-display"); + expect(items).toHaveLength(3); + expect(math).toHaveLength(2); + expect(math[0].closest("li")).toBe(items[0]); + expect(math[1].closest("li")).toBe(items[1]); + expect(container.querySelectorAll('input[type="checkbox"]')).toHaveLength( + 2, + ); + }); + it("preserves raw HTML code and GFM autolink destinations", () => { const { container } = render( diff --git a/src/shared/ui/ai-elements/message-math.test.ts b/src/shared/ui/ai-elements/message-math.test.ts index bcebccc2b..489656f38 100644 --- a/src/shared/ui/ai-elements/message-math.test.ts +++ b/src/shared/ui/ai-elements/message-math.test.ts @@ -154,19 +154,52 @@ describe("message math normalization", () => { it.each([ [String.raw`\[x\]`, display("x")], ["\\[a\n\nb\\]", display("a\n\nb")], - [String.raw`> \[x\]`, "> \n>\n> $$\n> x\n> $$\n>\n> "], - ["> \\[a\n> + b\\]", "> \n>\n> $$\n> a\n> + b\n> $$\n>\n> "], - ["> \\[a\n>+ b\\]", "> \n>\n> $$\n> a\n> + b\n> $$\n>\n> "], - [String.raw`- \[x\]`, "- \n\n $$\n x\n $$\n\n "], - ["- \\[a\n + b\\]", "- \n\n $$\n a\n + b\n $$\n\n "], - [String.raw`> - \[x\]`, "> - \n>\n> $$\n> x\n> $$\n>\n> "], - ["- outer\n - \\[x\\]", "- outer\n - \n\n $$\n x\n $$\n\n "], - [String.raw`1. \[x\]`, "1. \n\n $$\n x\n $$\n\n "], + [String.raw`> \[x\]`, "> $$\n> x\n> $$\n>\n> "], + ["> \\[a\n> + b\\]", "> $$\n> a\n> + b\n> $$\n>\n> "], + ["> \\[a\n>+ b\\]", "> $$\n> a\n> + b\n> $$\n>\n> "], + [String.raw`- \[x\]`, "- $$\n x\n $$\n\n "], + ["- \\[a\n + b\\]", "- $$\n a\n + b\n $$\n\n "], + [String.raw`> - \[x\]`, "> - $$\n> x\n> $$\n>\n> "], + ["- outer\n - \\[x\\]", "- outer\n - $$\n x\n $$\n\n "], + [String.raw`1. \[x\]`, "1. $$\n x\n $$\n\n "], ])("emits display blocks with the source container: %s", (source, expected) => { expect(normalize(source)).toBe(expected); expect(normalize(expected)).toBe(expected); }); + it.each([ + "- ", + "1.\t", + "> - ", + "- Outer\n - ", + ])("maps voice positions for an item-start equation: %s", (prefix) => { + const source = prefix + "\\[x\\] after\n"; + const prepared = prepareMessageMath(source); + const start = source.indexOf("\\["); + const end = source.indexOf("\\]") + 2; + expect(prepared.content.slice(0, start)).toBe(source.slice(0, start)); + for (let cutoff = start; cutoff < end; cutoff += 1) + expect(prepared.remapCutoff(cutoff)).toBe(start); + expect(prepared.remapCutoff(end)).toBe(prepared.content.indexOf(" after")); + expect(prepared.remapCutoff(source.indexOf("after"))).toBe( + prepared.content.indexOf("after"), + ); + expect(prepared.remapCutoff(source.length)).toBe(prepared.content.length); + expect(normalize(prepared.content)).toBe(prepared.content); + }); + + it.each([ + " ", + "x", + "X", + ])("preserves source and all offsets in a task paragraph: %s", (state) => { + const source = `- [${state}] \\[x\\] after\n- [ ] Next`; + const prepared = prepareMessageMath(source); + expect(prepared.content).toBe(source); + for (let cutoff = 0; cutoff <= source.length; cutoff += 1) + expect(prepared.remapCutoff(cutoff)).toBe(cutoff); + }); + it.each([ String.raw`**before \[x\] after**`, String.raw`*before \[x\] after*`, diff --git a/src/shared/ui/ai-elements/message-math.ts b/src/shared/ui/ai-elements/message-math.ts index 703042e03..bcf5caad3 100644 --- a/src/shared/ui/ai-elements/message-math.ts +++ b/src/shared/ui/ai-elements/message-math.ts @@ -105,6 +105,7 @@ type SourceNode = { type: string; position?: { start: { offset?: number }; end: { offset?: number } }; children?: SourceNode[]; + checked?: boolean | null; }; type Edit = { start: number; end: number; replacement: string }; type Range = { start: number; end: number }; @@ -174,7 +175,11 @@ function continuationPrefix(source: string): string { ); } -function displayReplacement(body: string, prefix: string): string { +function displayReplacement( + body: string, + prefix: string, + atContainerParagraphStart: boolean, +): string { const lines = body.split(/\r?\n/); const continuation = new RegExp( "^" + prefix.replace(/[ \t]+/g, (spaces) => `[ \\t]{0,${spaces.length}}`), @@ -187,7 +192,10 @@ function displayReplacement(body: string, prefix: string): string { while (lines.length && !lines[0].trim()) lines.shift(); while (lines.length && !lines[lines.length - 1].trim()) lines.pop(); const blank = prefix.trimEnd(); - return `\n${blank}\n${prefix}$$\n${lines.map((line) => prefix + line).join("\n")}\n${prefix}$$\n${blank}\n${prefix}`; + // A list marker with no preceding prose cannot survive a blank line before + // its first block. Start the display on that retained marker/quote line. + const opening = atContainerParagraphStart ? "$$" : `\n${blank}\n${prefix}$$`; + return `${opening}\n${lines.map((line) => prefix + line).join("\n")}\n${prefix}$$\n${blank}\n${prefix}`; } function isOperatorContinuation(body: string, prefix: string): boolean { @@ -256,7 +264,11 @@ function segmentEdits( edits.push({ start, end, - replacement: displayReplacement(body, paragraph.prefix), + replacement: displayReplacement( + body, + paragraph.prefix, + start === paragraph.start && paragraph.prefix.length > 0, + ), }); opening = undefined; } @@ -345,6 +357,20 @@ export function prepareMessageMath(content: string): PreparedMessageMath { end !== undefined ) nonDisplayRanges.push({ start, end }); + // GFM task markers belong to their first paragraph. Replacing that + // paragraph with a display block loses checkbox semantics, so preserve + // bracket source there just as in other non-display Markdown contexts. + if (node.type === "listItem" && typeof node.checked === "boolean") { + const first = node.children?.[0]; + const taskStart = first?.position?.start.offset; + const taskEnd = first?.position?.end.offset; + if ( + first?.type === "paragraph" && + taskStart !== undefined && + taskEnd !== undefined + ) + nonDisplayRanges.push({ start: taskStart, end: taskEnd }); + } if (node.type === "html" && start !== undefined && end !== undefined) htmlRanges.push({ start, end }); if (protectedTypes.has(node.type)) {