diff --git a/apps/app/src/components/ui/markdown-preview.tsx b/apps/app/src/components/ui/markdown-preview.tsx index 5b3e27243..a4b7e224f 100644 --- a/apps/app/src/components/ui/markdown-preview.tsx +++ b/apps/app/src/components/ui/markdown-preview.tsx @@ -37,6 +37,7 @@ import rehypeSanitize from "rehype-sanitize"; import remarkBreaks from "remark-breaks"; import remarkGfm from "remark-gfm"; import remarkMath from "remark-math"; +import { normalizeMathFences } from "./markdown-math-fences.js"; import { ImageLightbox } from "./image-lightbox.js"; import { markdownMayContainMath, @@ -1718,10 +1719,10 @@ function MarkdownPreviewComponent({ : markdownContent, [markdownContent, promptMentions], ); - const { frontmatter, body } = useMemo( - () => splitMarkdownFrontmatter(promptMarkdownContent), - [promptMarkdownContent], - ); + const { frontmatter, body } = useMemo(() => { + const split = splitMarkdownFrontmatter(promptMarkdownContent); + return { ...split, body: normalizeMathFences(split.body) }; + }, [promptMarkdownContent]); // The remark transform fills this shared mount table on every parse. Keep it // stable while assistant text streams so the custom React component type // also stays stable and an already-complete directive does not remount when diff --git a/apps/app/src/components/ui/markdown-math-fences.ts b/apps/app/src/components/ui/markdown-math-fences.ts new file mode 100644 index 000000000..bc37c48e4 --- /dev/null +++ b/apps/app/src/components/ui/markdown-math-fences.ts @@ -0,0 +1,97 @@ +// Prototype fix for get-bb/bb#1778 (report artifact, not shipped). +// +// `micromark-extension-math` only recognises a display-math *closing* fence +// when `$$` sits alone on its own line. Models frequently emit +// +// $$T_{a} <- opening fence with TeX on the same line ("meta") +// \approx 73$$ <- closing fence glued to the last content line +// +// which opens a flow block that never closes, so the remainder of the message +// is swallowed into one math node (and the first line is dropped as `meta`). +// Normalise such spans to the canonical shape before parsing: +// +// $$ +// T_{a} +// \approx 73 +// $$ +// +// Anything inside a fenced code block is left untouched. Inline `$$x$$` on a +// single line never matches the opener pattern (the remainder contains `$`), +// so it keeps being inline math. + +const CODE_FENCE = /^ {0,3}(`{3,}|~{3,})/; +// `$$` at line start (≤3 indent) followed by text without any `$` — exactly +// the shape micromark treats as an opening fence with meta. +const OPEN_WITH_META = /^( {0,3})\$\$[ \t]*([^$\n]+?)[ \t]*$/; +const OPEN_BARE = /^ {0,3}\$\$[ \t]*$/; +const CLOSE_BARE = /^ {0,3}\$\$[ \t]*$/; +// A content line that ends with `$$` but does not start with it. +const CLOSE_TRAILING = /^(.*?[^$\s])[ \t]*\$\$[ \t]*$/; + +export function normalizeMathFences(markdown: string): string { + if (!markdown.includes("$$")) return markdown; + const lines = markdown.split("\n"); + const out: string[] = []; + let inCode: string | null = null; + let i = 0; + while (i < lines.length) { + const line = lines[i] ?? ""; + const fence = CODE_FENCE.exec(line); + if (inCode !== null) { + out.push(line); + if (fence && fence[1].startsWith(inCode[0]) && fence[1].length >= inCode.length) { + inCode = null; + } + i++; + continue; + } + if (fence) { + inCode = fence[1]; + out.push(line); + i++; + continue; + } + const withMeta = OPEN_WITH_META.exec(line); + const bare = OPEN_BARE.test(line); + if (!withMeta && !bare) { + out.push(line); + i++; + continue; + } + // Find the closer: first later line that is a bare `$$` or ends in `$$`. + let close = -1; + let trailing = false; + for (let j = i + 1; j < lines.length; j++) { + const candidate = lines[j] ?? ""; + if (CODE_FENCE.test(candidate)) break; + if (CLOSE_BARE.test(candidate)) { + close = j; + break; + } + if (CLOSE_TRAILING.test(candidate)) { + close = j; + trailing = true; + break; + } + } + if (close === -1) { + // Truly unclosed: leave as is (micromark swallows to EOF either way). + out.push(line); + i++; + continue; + } + const indent = withMeta ? withMeta[1] : ""; + out.push(`${indent}$$`); + if (withMeta) out.push(`${indent}${withMeta[2]}`); + for (let j = i + 1; j < close; j++) out.push(lines[j] ?? ""); + if (trailing) { + const match = CLOSE_TRAILING.exec(lines[close] ?? ""); + out.push(match ? match[1] : (lines[close] ?? "")); + out.push(`${indent}$$`); + } else { + out.push(lines[close] ?? ""); + } + i = close + 1; + } + return out.join("\n"); +}