From 6de3efd93e5aeb1e7782a649c2580902da052407 Mon Sep 17 00:00:00 2001
From: Wout Stiens <71498452+StiensWout@users.noreply.github.com>
Date: Tue, 8 Sep 2026 10:57:04 +0200
Subject: [PATCH 01/10] feat(chat): render math across web and mobile
---
.../modules/t3-markdown-text/package.json | 4 +-
.../src/NativeMarkdownSelectableText.tsx | 5 +
.../t3-markdown-text/src/NativeMathText.tsx | 172 +++++++++++++
.../src/SelectableMarkdownText.tsx | 9 +-
.../src/nativeMarkdownMath.ts | 51 ++++
.../src/nativeMarkdownText.ts | 16 +-
.../src/nativeMathDocument.ts | 34 +++
.../t3-markdown-text/src/nativeMathHtml.ts | 102 ++++++++
apps/mobile/package.json | 3 +
.../mobile/src/lib/nativeMarkdownMath.test.ts | 72 ++++++
.../mobile/src/lib/nativeMathDocument.test.ts | 94 ++++++++
.../mobile/src/lib/wideMarkdownBlocks.test.ts | 6 +
apps/mobile/src/lib/wideMarkdownBlocks.ts | 5 +-
apps/web/package.json | 4 +
apps/web/src/components/ChatMarkdown.tsx | 13 +
apps/web/src/components/MarkdownMath.tsx | 61 +++++
apps/web/src/components/MathTypeset.tsx | 12 +
apps/web/src/index.css | 51 ++++
apps/web/src/markdown-clipboard.ts | 12 +-
apps/web/src/mathClipboard.test.ts | 51 ++++
apps/web/src/mathRendering.test.ts | 36 +++
apps/web/src/mathRendering.ts | 29 +++
docs/user/composer.md | 8 +
packages/client-runtime/package.json | 5 +
.../client-runtime/src/markdownMath.test.ts | 62 +++++
packages/client-runtime/src/markdownMath.ts | 227 ++++++++++++++++++
26 files changed, 1133 insertions(+), 11 deletions(-)
create mode 100644 apps/mobile/modules/t3-markdown-text/src/NativeMathText.tsx
create mode 100644 apps/mobile/modules/t3-markdown-text/src/nativeMarkdownMath.ts
create mode 100644 apps/mobile/modules/t3-markdown-text/src/nativeMathDocument.ts
create mode 100644 apps/mobile/modules/t3-markdown-text/src/nativeMathHtml.ts
create mode 100644 apps/mobile/src/lib/nativeMarkdownMath.test.ts
create mode 100644 apps/mobile/src/lib/nativeMathDocument.test.ts
create mode 100644 apps/web/src/components/MarkdownMath.tsx
create mode 100644 apps/web/src/components/MathTypeset.tsx
create mode 100644 apps/web/src/mathClipboard.test.ts
create mode 100644 apps/web/src/mathRendering.test.ts
create mode 100644 apps/web/src/mathRendering.ts
create mode 100644 packages/client-runtime/src/markdownMath.test.ts
create mode 100644 packages/client-runtime/src/markdownMath.ts
diff --git a/apps/mobile/modules/t3-markdown-text/package.json b/apps/mobile/modules/t3-markdown-text/package.json
index 376befbeb152..85f34bcc3051 100644
--- a/apps/mobile/modules/t3-markdown-text/package.json
+++ b/apps/mobile/modules/t3-markdown-text/package.json
@@ -36,9 +36,11 @@
"expo-clipboard": "*",
"expo-haptics": "*",
"expo-symbols": "*",
+ "mathjax-full": "*",
"react": "*",
"react-native": "*",
- "react-native-nitro-markdown": "*"
+ "react-native-nitro-markdown": "*",
+ "react-native-webview": "*"
},
"codegenConfig": {
"name": "T3MarkdownTextSpec",
diff --git a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.tsx b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.tsx
index 50a381bae288..9b5ec79a2a93 100644
--- a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.tsx
+++ b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.tsx
@@ -1,5 +1,6 @@
import { createContext, useCallback, useContext, useMemo } from "react";
import { decodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard";
+import { NativeMathText } from "./NativeMathText";
import {
findNodeHandle,
Image,
@@ -326,6 +327,10 @@ export function NativeMarkdownSelectableText(props: {
props.textStyle.contextChipBorderColor,
].join(":");
+ if (props.runs.some((run) => run.mathSource !== undefined)) {
+ return ;
+ }
+
return (
| undefined;
+function loadRenderer() {
+ return (rendererPromise ??= import("./nativeMathHtml").catch((error) => {
+ rendererPromise = undefined;
+ throw error;
+ }));
+}
+
+/** One view per math-containing text chunk preserves inline layout and selection on both OSes. */
+export const NativeMathText = memo(function NativeMathText(props: {
+ readonly runs: ReadonlyArray;
+ readonly textStyle: NativeMarkdownTextStyle;
+ readonly onLinkPress?: (href: string) => void;
+ readonly fileContextMenu?: (href: string) => MarkdownFileContextMenu | undefined;
+ readonly onFileContextMenuAction?: (href: string, actionId: string) => void;
+}) {
+ const webView = useRef(null);
+ const ready = useRef(false);
+ const [height, setHeight] = useState(props.textStyle.lineHeight);
+ const [failed, setFailed] = useState(false);
+ const [renderer, setRenderer] = useState();
+ const { fontScale } = useWindowDimensions();
+ useEffect(() => {
+ let active = true;
+ void loadRenderer().then(
+ (module) => {
+ if (active) setRenderer(module);
+ },
+ () => {
+ if (active) setFailed(true);
+ },
+ );
+ return () => {
+ active = false;
+ };
+ }, []);
+ const fileContextMenu = props.fileContextMenu;
+ const update = useMemo(() => {
+ if (!renderer) return null;
+ const style = {
+ ...props.textStyle,
+ fontSize: props.textStyle.fontSize * fontScale,
+ lineHeight: props.textStyle.lineHeight * fontScale,
+ headingFontSizes: props.textStyle.headingFontSizes?.map((size) => size * fontScale),
+ };
+ return {
+ runs: props.runs.map((run) =>
+ renderer.nativeMathRunHtml(run, style, run.href ? fileContextMenu?.(run.href) : undefined),
+ ),
+ color: style.color,
+ fontSize: style.fontSize,
+ lineHeight: style.lineHeight,
+ };
+ }, [renderer, props.runs, props.textStyle, fileContextMenu, fontScale]);
+ const revision = useRef(0);
+ const latest = useRef(update);
+ useEffect(() => {
+ latest.current = update;
+ if (ready.current && update) {
+ revision.current += 1;
+ webView.current?.injectJavaScript(
+ `window.updateMath(${JSON.stringify({ ...update, revision: revision.current })});true;`,
+ );
+ }
+ }, [update]);
+
+ if (failed || !renderer)
+ return (
+
+ {props.runs.map((run) => run.text).join("")}
+
+ );
+ return (
+ request.url === "about:blank"}
+ scrollEnabled={false}
+ bounces={false}
+ showsVerticalScrollIndicator={false}
+ dataDetectorTypes="none"
+ allowFileAccess={false}
+ setSupportMultipleWindows={false}
+ style={{ height, backgroundColor: "transparent", flex: 0 }}
+ onError={() => setFailed(true)}
+ onContentProcessDidTerminate={() => setFailed(true)}
+ onRenderProcessGone={() => setFailed(true)}
+ onMessage={(event) => {
+ let message: unknown;
+ try {
+ message = JSON.parse(event.nativeEvent.data);
+ } catch {
+ return;
+ }
+ if (!message || typeof message !== "object" || !("type" in message)) return;
+ if (message.type === "ready") {
+ ready.current = true;
+ if (latest.current) {
+ revision.current += 1;
+ webView.current?.injectJavaScript(
+ `window.updateMath(${JSON.stringify({ ...latest.current, revision: revision.current })});true;`,
+ );
+ }
+ return;
+ }
+ if (!("revision" in message) || message.revision !== revision.current) return;
+ if (
+ message.type === "height" &&
+ "height" in message &&
+ typeof message.height === "number" &&
+ Number.isFinite(message.height) &&
+ message.height > 0
+ )
+ setHeight(message.height);
+ if (message.type === "copy" && "text" in message && typeof message.text === "string") {
+ const text = message.text;
+ const copyRevision = revision.current;
+ void setStringAsync(text).then(
+ () => {
+ if (revision.current === copyRevision)
+ webView.current?.injectJavaScript(
+ `window.mathCopyResult(${JSON.stringify(text)});true;`,
+ );
+ },
+ () => Alert.alert("Could not copy text"),
+ );
+ }
+ if (
+ message.type === "file-action" &&
+ "href" in message &&
+ typeof message.href === "string" &&
+ "action" in message &&
+ typeof message.action === "string"
+ ) {
+ const href = message.href;
+ const actionId = message.action;
+ if (
+ props.runs.some((run) => run.href === href) &&
+ props
+ .fileContextMenu?.(href)
+ ?.actions.some((action) => action.id === actionId && !action.disabled)
+ )
+ props.onFileContextMenuAction?.(href, actionId);
+ }
+ if (message.type === "link" && "href" in message && typeof message.href === "string") {
+ if (props.onLinkPress) props.onLinkPress(message.href);
+ else if (/^https?:\/\//i.test(message.href)) void Linking.openURL(message.href);
+ }
+ }}
+ />
+ );
+});
diff --git a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.tsx b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.tsx
index 8e59256b12b8..8053088c140e 100644
--- a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.tsx
+++ b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.tsx
@@ -1,3 +1,4 @@
+import { parseNativeMarkdownMath } from "./nativeMarkdownMath";
import { useMemo } from "react";
import { View } from "react-native";
import { parseMarkdownWithOptions } from "react-native-nitro-markdown/headless";
@@ -51,11 +52,9 @@ export function SelectableMarkdownText({
marginBottom = 0,
}: SelectableMarkdownTextProps) {
const chunks = useMemo(() => {
- const parsedDocument = parseMarkdownWithOptions(markdown, {
- gfm: true,
- html: true,
- math: false,
- });
+ const parsedDocument = parseNativeMarkdownMath(markdown, (source) =>
+ parseMarkdownWithOptions(source, { gfm: true, html: true, math: false }),
+ );
const document = preserveSoftBreaks
? nativeMarkdownWithPreservedSoftBreaks(parsedDocument)
: parsedDocument;
diff --git a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownMath.ts b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownMath.ts
new file mode 100644
index 000000000000..c6a6a0a36110
--- /dev/null
+++ b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownMath.ts
@@ -0,0 +1,51 @@
+import { markdownMathRanges } from "@t3tools/client-runtime/markdown-math";
+import type { MarkdownNode } from "react-native-nitro-markdown/headless";
+
+/** Shield math from the native parser, then restore typed nodes using the shared grammar. */
+export function parseNativeMarkdownMath(
+ source: string,
+ parse: (markdown: string) => MarkdownNode,
+): MarkdownNode {
+ const ranges = markdownMathRanges(source);
+ if (ranges.length === 0) return parse(source);
+ let prefix = ":t3-math-";
+ while (source.includes(prefix)) prefix += "-";
+ const mathByMarker = new Map(ranges.map((math, index) => [`${prefix}${index}:`, math]));
+ // Text markers cannot merge with neighboring Markdown code-span delimiters.
+ let cursor = 0;
+ let protectedSource = "";
+ for (const [marker, math] of mathByMarker) {
+ protectedSource += source.slice(cursor, math.start) + marker;
+ cursor = math.end;
+ }
+ protectedSource += source.slice(cursor);
+ const markerPattern = new RegExp(`${prefix}\\d+:`, "g");
+ function restore(node: MarkdownNode): MarkdownNode {
+ if (!node.children) return node;
+ return {
+ ...node,
+ children: node.children.flatMap((child) => {
+ if (child.type !== "text" || !child.content) return [restore(child)];
+ const restored: MarkdownNode[] = [];
+ let offset = 0;
+ for (const match of child.content.matchAll(markerPattern)) {
+ const math = mathByMarker.get(match[0]);
+ if (!math) continue;
+ if (match.index > offset)
+ restored.push({ ...child, content: child.content.slice(offset, match.index) });
+ restored.push({
+ type: math.math ? (math.math.display ? "math_block" : "math_inline") : "text",
+ content: math.source,
+ beg: math.start,
+ end: math.end,
+ });
+ offset = match.index + match[0].length;
+ }
+ if (offset < child.content.length)
+ restored.push({ ...child, content: child.content.slice(offset) });
+ return restored;
+ }),
+ };
+ }
+ return restore(parse(protectedSource));
+}
diff --git a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts
index ec8cf74fee2b..dc1556c6fe85 100644
--- a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts
+++ b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts
@@ -131,6 +131,7 @@ import {
export interface NativeMarkdownTextRun {
readonly text: string;
+ readonly mathSource?: string;
readonly bold?: boolean;
readonly italic?: boolean;
readonly strikethrough?: boolean;
@@ -259,6 +260,8 @@ function inlineHtmlText(value: string): string {
function sameRunStyle(left: NativeMarkdownTextRun, right: NativeMarkdownTextRun): boolean {
return (
+ left.mathSource === undefined &&
+ right.mathSource === undefined &&
left.bold === right.bold &&
left.italic === right.italic &&
left.strikethrough === right.strikethrough &&
@@ -344,7 +347,7 @@ function decorateSkillRuns(
const decorated: NativeMarkdownTextRun[] = [];
for (const run of runs) {
- if (run.code || run.href || run.fileIcon || run.role === "code-block") {
+ if (run.mathSource || run.code || run.href || run.fileIcon || run.role === "code-block") {
decorated.push(run);
continue;
}
@@ -436,8 +439,15 @@ function appendNode(
context: RunContext,
): NativeMarkdownTextRun[] {
switch (node.type) {
- case "text":
case "math_inline":
+ case "math_block":
+ runs.push({
+ text: nodeTextContent(node),
+ mathSource: nodeTextContent(node),
+ ...(context.href ? { href: context.href } : {}),
+ });
+ return runs;
+ case "text":
return appendRun(runs, textNodeContent(nodeTextContent(node)), context);
case "html_inline":
return appendRun(runs, inlineHtmlText(nodeTextContent(node)), context);
@@ -839,7 +849,7 @@ function appendDocumentBlock(
});
return appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth });
case "math_block":
- appendRun(runs, nodeTextContent(node), { ...EMPTY_CONTEXT, role: "body", depth });
+ appendNode(runs, node, { ...EMPTY_CONTEXT, role: "body", depth });
return appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth });
default:
appendInlineChildren(runs, node, { ...EMPTY_CONTEXT, role: "body", depth });
diff --git a/apps/mobile/modules/t3-markdown-text/src/nativeMathDocument.ts b/apps/mobile/modules/t3-markdown-text/src/nativeMathDocument.ts
new file mode 100644
index 000000000000..2799f20aaff9
--- /dev/null
+++ b/apps/mobile/modules/t3-markdown-text/src/nativeMathDocument.ts
@@ -0,0 +1,34 @@
+// This document is installed once. Updates reconcile runs so streaming does not
+// reload the WebView or recreate completed equations and open source disclosures.
+export const NATIVE_MATH_DOCUMENT = ``;
diff --git a/apps/mobile/modules/t3-markdown-text/src/nativeMathHtml.ts b/apps/mobile/modules/t3-markdown-text/src/nativeMathHtml.ts
new file mode 100644
index 000000000000..68e9988620f2
--- /dev/null
+++ b/apps/mobile/modules/t3-markdown-text/src/nativeMathHtml.ts
@@ -0,0 +1,102 @@
+import { markdownMath } from "@t3tools/client-runtime/markdown-math";
+import { mathjax } from "mathjax-full/js/mathjax.js";
+import { TeX } from "mathjax-full/js/input/tex.js";
+import { SVG } from "mathjax-full/js/output/svg.js";
+import { liteAdaptor } from "mathjax-full/js/adaptors/liteAdaptor.js";
+import { AssistiveMmlHandler } from "mathjax-full/js/a11y/assistive-mml.js";
+import { RegisterHTMLHandler } from "mathjax-full/js/handlers/html.js";
+import "mathjax-full/js/input/tex/ams/AmsConfiguration.js";
+import type { NativeMarkdownTextRun } from "./nativeMarkdownText";
+import type {
+ MarkdownFileContextMenu,
+ NativeMarkdownTextStyle,
+} from "./SelectableMarkdownText.types";
+
+const adaptor = liteAdaptor();
+AssistiveMmlHandler(RegisterHTMLHandler(adaptor));
+const tex = new TeX({ packages: ["base", "ams"], maxBuffer: 16_384, maxMacros: 1000 });
+const renderer = mathjax.document("", { InputJax: tex, OutputJax: new SVG({ fontCache: "none" }) });
+const cache = new Map();
+let cacheSize = 0;
+
+function escapeHtml(text: string): string {
+ return text.replace(
+ /[&<>"']/g,
+ (character) =>
+ ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[character]!,
+ );
+}
+
+/** Self-contained SVG needs no font downloads, browser typesetter or network access. */
+export function nativeMathSvg(source: string): string | null {
+ if (cache.has(source)) {
+ const cached = cache.get(source) ?? null;
+ cache.delete(source);
+ cache.set(source, cached);
+ return cached;
+ }
+ const math = markdownMath(source);
+ if (!math) return null;
+ let svg: string | null = null;
+ try {
+ renderer.reset();
+ tex.reset();
+ const node = renderer.convert(math.tex, { display: math.display });
+ const output = adaptor.outerHTML(node);
+ if (!output.includes('data-mml-node="merror"')) svg = output;
+ } catch {
+ // Leave unsupported or malformed math as its original source.
+ }
+ const size = source.length + (svg?.length ?? 0);
+ if (size <= 1_000_000) {
+ while (cache.size >= 128 || cacheSize + size > 1_000_000) {
+ const oldest = cache.entries().next().value;
+ if (!oldest) break;
+ cacheSize -= oldest[0].length + (oldest[1]?.length ?? 0);
+ cache.delete(oldest[0]);
+ }
+ cache.set(source, svg);
+ cacheSize += size;
+ }
+ return svg;
+}
+
+/** Render typed native text runs, never raw Markdown HTML, inside the math text view. */
+export function nativeMathRunHtml(
+ run: NativeMarkdownTextRun,
+ style: NativeMarkdownTextStyle,
+ menu?: MarkdownFileContextMenu,
+): string {
+ if (run.mathSource) {
+ const math = markdownMath(run.mathSource);
+ const source = escapeHtml(run.mathSource);
+ const svg = nativeMathSvg(run.mathSource);
+ const equation = `${svg ?? source}`;
+ return math?.display
+ ? `${equation}${source}`
+ : run.href
+ ? `${equation}`
+ : equation;
+ }
+ const heading = run.role === "heading";
+ const fontSize = heading
+ ? (style.headingFontSizes?.[(run.headingLevel ?? 1) - 1] ?? style.fontSize * 1.3)
+ : style.fontSize;
+ const color = run.href
+ ? style.linkColor
+ : run.code
+ ? style.inlineCodeColor
+ : heading || run.bold
+ ? style.strongColor
+ : style.color;
+ const css = `color:${color};font-size:${fontSize}px;font-weight:${heading || run.bold ? 700 : 400};font-style:${run.italic ? "italic" : "normal"};font-family:${run.code ? "monospace" : "inherit"};text-decoration:${run.strikethrough ? "line-through" : "none"}`;
+ const content = escapeHtml(run.text);
+ if (run.href) {
+ const href = escapeHtml(run.href);
+ const actions = menu
+ ? ``
+ : "";
+ return `${content}${actions}`;
+ }
+ return `${content}`;
+}
diff --git a/apps/mobile/package.json b/apps/mobile/package.json
index 0fd35faf7528..31284b0804b4 100644
--- a/apps/mobile/package.json
+++ b/apps/mobile/package.json
@@ -107,6 +107,7 @@
"expo-video": "~57.0.3",
"expo-web-browser": "~57.0.2",
"expo-widgets": "~57.0.15",
+ "mathjax-full": "^3.2.2",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-native": "0.86.3",
@@ -129,9 +130,11 @@
"devDependencies": {
"@effect/vitest": "catalog:",
"@pierre/trees": "1.0.0-beta.4",
+ "@types/jsdom": "^30.0.0",
"@types/react": "~19.2.0",
"@types/react-dom": "~19.2.3",
"babel-preset-expo": "~57.0.9",
+ "jsdom": "^30.0.1",
"tailwindcss": "^4.0.0",
"typescript": "catalog:"
},
diff --git a/apps/mobile/src/lib/nativeMarkdownMath.test.ts b/apps/mobile/src/lib/nativeMarkdownMath.test.ts
new file mode 100644
index 000000000000..1147373e5a31
--- /dev/null
+++ b/apps/mobile/src/lib/nativeMarkdownMath.test.ts
@@ -0,0 +1,72 @@
+import { describe, expect, it } from "vite-plus/test";
+import { parseNativeMarkdownMath } from "../../modules/t3-markdown-text/src/nativeMarkdownMath";
+import {
+ nativeMathSvg,
+ nativeMathRunHtml,
+} from "../../modules/t3-markdown-text/src/nativeMathHtml";
+import { nativeMarkdownDocumentRuns } from "../../modules/t3-markdown-text/src/nativeMarkdownText";
+
+describe("native math", () => {
+ it("uses shared math recognition and keeps code and currency literal", () => {
+ let input = "";
+ const source = String.raw`Use \(x_i\), keep ` + "`$code$` and pay $20 or $30.";
+ const tree = parseNativeMarkdownMath(source, (markdown) => {
+ input = markdown;
+ return { type: "paragraph", children: [{ type: "text", content: ":t3-math-0:" }] };
+ });
+ expect(input).toContain(":t3-math-0:");
+ expect(input).toContain("`$code$`");
+ expect(input).toContain("$20 or $30");
+ expect(nativeMarkdownDocumentRuns(tree)).toEqual([
+ { text: String.raw`\(x_i\)`, mathSource: String.raw`\(x_i\)` },
+ ]);
+ });
+
+ it.each([
+ String.raw`$x_i^2+\alpha$`,
+ String.raw`\(\frac{a}{b}\)`,
+ String.raw`$$\begin{pmatrix}1&2\\3&4\end{pmatrix}$$`,
+ String.raw`\[\begin{aligned}x&=1\\y&=2\end{aligned}\]`,
+ ])("produces a self-contained equation for %s", (source) => {
+ const svg = nativeMathSvg(source);
+ expect(svg).toContain("