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");
+}
