From 2496374904f45beb8a69be43b13524800ed61b9c Mon Sep 17 00:00:00 2001
From: arc53-machine <232052973+arc53-machine@users.noreply.github.com>
Date: Mon, 28 Sep 2026 13:21:28 +0100
Subject: [PATCH] Parse chat math in the markdown parser, not with string
rewrites
Inline \( \) math rendered as a literal $x$ (the rewrite produced single
dollars, which remark-math is told to ignore), a one-line \[ \] shrank to
inline math, model-written $x$ never rendered, and the citation pass
linked [1] inside formulas.
- \( \) and \[ \] are micromark constructs: inline anywhere, display when
\[ starts a line and its \] ends one. A line such as "\[a\] and more"
stays inline instead of swallowing the rest of the answer, which is why
this does not alias micromark-extension-llm-math.
- $x$ follows Pandoc's rules (ported from LibreChat, MIT), so prices stay
text.
- A paragraph holding only $$ or \[ math, and anything KaTeX can only set in
display mode (\tag, align, ...), becomes display math.
- mhchem is loaded for \ce and \pu.
- Citations are linked on the syntax tree, so code, math, link text and
escaped brackets are never touched.
- While streaming, open emphasis, code and $$ / \[ math are closed (remend
plus a code-aware math pass) and a formula KaTeX cannot set yet shows in
the muted colour. Each top-level block renders on its own and is
memoised, so only the growing block is parsed and typeset again.
---
frontend/package-lock.json | 22 +-
frontend/package.json | 10 +-
.../MarkdownAnswer.codeSpans.test.ts | 194 ------
.../MarkdownAnswer.codeSpans.test.tsx | 209 ++++++
.../conversation/MarkdownAnswer.math.test.tsx | 524 +++++++++++++++
frontend/src/conversation/MarkdownAnswer.tsx | 604 +++++++++---------
.../conversation/markdown/answerMarkdown.ts | 241 +++++++
.../markdown/micromarkExtension.ts | 25 +
.../conversation/markdown/remarkCitations.ts | 100 +++
.../markdown/remarkDisplayMath.ts | 134 ++++
.../conversation/markdown/singleDollarMath.ts | 160 +++++
frontend/src/conversation/markdown/texMath.ts | 398 ++++++++++++
12 files changed, 2106 insertions(+), 515 deletions(-)
delete mode 100644 frontend/src/conversation/MarkdownAnswer.codeSpans.test.ts
create mode 100644 frontend/src/conversation/MarkdownAnswer.codeSpans.test.tsx
create mode 100644 frontend/src/conversation/MarkdownAnswer.math.test.tsx
create mode 100644 frontend/src/conversation/markdown/answerMarkdown.ts
create mode 100644 frontend/src/conversation/markdown/micromarkExtension.ts
create mode 100644 frontend/src/conversation/markdown/remarkCitations.ts
create mode 100644 frontend/src/conversation/markdown/remarkDisplayMath.ts
create mode 100644 frontend/src/conversation/markdown/singleDollarMath.ts
create mode 100644 frontend/src/conversation/markdown/texMath.ts
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index aee05213..682a6f9f 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -21,6 +21,8 @@
"lodash": "^4.18.1",
"lucide-react": "^1.45.0",
"mermaid": "^12.0.0",
+ "micromark-util-character": "^2.1.1",
+ "micromark-util-symbol": "^2.0.1",
"radix-ui": "^1.6.7",
"react": "^19.3.0",
"react-chartjs-2": "^5.3.1",
@@ -38,7 +40,11 @@
"rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
- "tailwind-merge": "^3.6.0"
+ "remark-parse": "^11.0.0",
+ "remend": "^1.3.1",
+ "tailwind-merge": "^3.6.0",
+ "unified": "^11.0.5",
+ "unist-util-visit": "^5.1.0"
},
"devDependencies": {
"@eslint/js": "^9.39.5",
@@ -46,6 +52,7 @@
"@tailwindcss/postcss": "^4.3.3",
"@types/d3-force": "^3.0.10",
"@types/lodash": "^4.17.25",
+ "@types/mdast": "^4.0.4",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@types/react-syntax-highlighter": "^15.5.13",
@@ -64,6 +71,7 @@
"happy-dom": "^20.14.5",
"husky": "^9.1.7",
"lint-staged": "^17.5.1",
+ "micromark-util-types": "^2.0.3",
"postcss": "^8.5.28",
"prettier": "^3.9.6",
"prettier-plugin-tailwindcss": "^0.8.1",
@@ -9910,9 +9918,9 @@
"license": "MIT"
},
"node_modules/micromark-util-types": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz",
- "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==",
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.3.tgz",
+ "integrity": "sha512-oxB2Ik03hI0gv+VNn9tnh1t1YEe9MDPptViAEgfdf3YQHsn0pzGTgCdlSCJXcwhqm8phaEuM7zeEu3QQzVBrPg==",
"funding": [
{
"type": "GitHub Sponsors",
@@ -11277,6 +11285,12 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/remend": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/remend/-/remend-1.3.1.tgz",
+ "integrity": "sha512-N3DiY5qbRPoa5vkxn1oDLMyXOVTeo6Hp+XOj6SIqJAYUgLS0Q587gILPMom/qm86AQ/ZrcOdwEIzCz8V3J0nxQ==",
+ "license": "Apache-2.0"
+ },
"node_modules/reselect": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.3.0.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 81753fa8..64a9af9b 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -38,6 +38,8 @@
"lodash": "^4.18.1",
"lucide-react": "^1.45.0",
"mermaid": "^12.0.0",
+ "micromark-util-character": "^2.1.1",
+ "micromark-util-symbol": "^2.0.1",
"radix-ui": "^1.6.7",
"react": "^19.3.0",
"react-chartjs-2": "^5.3.1",
@@ -55,7 +57,11 @@
"rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
- "tailwind-merge": "^3.6.0"
+ "remark-parse": "^11.0.0",
+ "remend": "^1.3.1",
+ "tailwind-merge": "^3.6.0",
+ "unified": "^11.0.5",
+ "unist-util-visit": "^5.1.0"
},
"devDependencies": {
"@eslint/js": "^9.39.5",
@@ -63,6 +69,7 @@
"@tailwindcss/postcss": "^4.3.3",
"@types/d3-force": "^3.0.10",
"@types/lodash": "^4.17.25",
+ "@types/mdast": "^4.0.4",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@types/react-syntax-highlighter": "^15.5.13",
@@ -81,6 +88,7 @@
"happy-dom": "^20.14.5",
"husky": "^9.1.7",
"lint-staged": "^17.5.1",
+ "micromark-util-types": "^2.0.3",
"postcss": "^8.5.28",
"prettier": "^3.9.6",
"prettier-plugin-tailwindcss": "^0.8.1",
diff --git a/frontend/src/conversation/MarkdownAnswer.codeSpans.test.ts b/frontend/src/conversation/MarkdownAnswer.codeSpans.test.ts
deleted file mode 100644
index befd3305..00000000
--- a/frontend/src/conversation/MarkdownAnswer.codeSpans.test.ts
+++ /dev/null
@@ -1,194 +0,0 @@
-/**
- * Code must survive the markdown string pre-passes.
- *
- * ``processMarkdownContent`` rewrote every ``[N]`` as ``[N](#cite-N)`` and
- * ``preprocessLaTeX`` every ``\[…\]`` as ``$$…$$``, neither with any awareness
- * of code, so an index subscript or a bash prompt escape came out corrupted in
- * the rendered answer — and in what the copy button copied.
- *
- * Neither rewrite can move into a remark plugin, where a ``code`` node would be
- * distinct from a ``text`` one: remark-math tokenises ``$``/``$$`` while
- * parsing, so the LaTeX pass has to happen on the raw string. Hence the shared
- * ``applyOutsideCode`` masking helper rather than an AST visitor.
- */
-import { describe, expect, it } from 'vitest';
-
-import {
- applyOutsideCode,
- preprocessLaTeX,
- processMarkdownContent,
-} from './MarkdownAnswer';
-
-/** The rendered text of a single-segment answer. */
-const text = (source: string) => processMarkdownContent(source)[0].content;
-
-describe('citation rewriting spares code', () => {
- it('leaves an index subscript alone inside a fence', () => {
- const source = '```python\nfor row in cur:\n print(row[0])\n```';
- expect(text(source)).toBe(source);
- });
-
- it('leaves an index subscript alone inside an inline span', () => {
- expect(text('Use `items[2]` here.')).toBe('Use `items[2]` here.');
- });
-
- it('leaves a mid-sentence triple-backtick span alone', () => {
- expect(text('inline ```code[0]``` end')).toBe('inline ```code[0]``` end');
- });
-
- it('leaves a triple-backtick span that starts a line alone', () => {
- // A backtick fence's info string cannot contain backticks, so this opens a
- // code span, not an unclosed fence that swallows the rest of the answer.
- expect(text('```code[0]``` and see [1]')).toBe(
- '```code[0]``` and see [1](#cite-1)',
- );
- });
-
- it('closes an inline span only on a run of its own length', () => {
- // The `` run does not close the ` span, so `b[1]` is still code.
- const source = 'Use `a`` b[1] more` end';
- expect(text(source)).toBe(source);
- });
-
- it('recognises a fence closed on a CRLF line', () => {
- expect(text('```js\r\nconst a = arr[0];\r\n```\r\nSee [1].')).toBe(
- '```js\r\nconst a = arr[0];\r\n```\r\nSee [1](#cite-1).',
- );
- });
-
- it('does not carry an inline span across a blank line', () => {
- // The blank line ends the paragraph, so the backticks never pair and
- // `bar[1]` is prose — which is how remark parses it too.
- expect(text('a `foo\n\nbar[1]` end')).toBe(
- 'a `foo\n\nbar[1](#cite-1)` end',
- );
- });
-
- it('leaves an inline span that wraps a line alone', () => {
- expect(text('use `arr[0]\nnext` here')).toBe('use `arr[0]\nnext` here');
- });
-
- it('leaves a tilde fence alone', () => {
- const source = '~~~python\nprint(row[0])\n~~~';
- expect(text(source)).toBe(source);
- });
-
- it('leaves a fence nested in a longer one alone', () => {
- // A ``` run does not close a ```` fence, so the inner block is content.
- const source = '````markdown\n```js\nconst a = arr[0];\n```\n````';
- expect(text(source)).toBe(source);
- });
-
- it('leaves a fence indented under a list item alone', () => {
- const source =
- '1. Step:\n\n ```js\n const a = arr[0];\n\n const b = arr[1];\n ```';
- expect(text(source)).toBe(source);
- });
-
- it('leaves an unterminated fence alone while the answer is still streaming', () => {
- const source = '```python\nprint(row[0]';
- expect(text(source)).toBe(source);
- });
-
- it('leaves an unterminated inline span alone while streaming', () => {
- expect(text('Use `items[2')).toBe('Use `items[2');
- });
-
- it('leaves mermaid node labels alone', () => {
- // The cite pass runs before the ```mermaid split, so diagram source was
- // corrupted too and the render failed.
- const source = '```mermaid\nflowchart TD\n A[0] --> B[1]\n```';
- expect(processMarkdownContent(source)[0].content).toBe(
- 'flowchart TD\n A[0] --> B[1]',
- );
- });
-
- it('still links a real citation in prose', () => {
- expect(text('See source [1] for details.')).toBe(
- 'See source [1](#cite-1) for details.',
- );
- });
-
- it('links prose citations on either side of a fence without touching it', () => {
- expect(
- text('Per [1], run:\n```js\nconst a = arr[0];\n```\nthen [2].'),
- ).toBe(
- 'Per [1](#cite-1), run:\n```js\nconst a = arr[0];\n```\nthen [2](#cite-2).',
- );
- });
-
- it('still links a citation on an indented list continuation line', () => {
- // Four-space indents are list content far more often than they are code,
- // so they are prose to the masker.
- expect(text('- one\n - nested [1]')).toBe(
- '- one\n - nested [1](#cite-1)',
- );
- });
-
- it('leaves an already-linked reference alone', () => {
- expect(text('see [1](#cite-1) ok')).toBe('see [1](#cite-1) ok');
- });
-});
-
-describe('LaTeX preprocessing spares code', () => {
- it('leaves bash prompt escapes alone', () => {
- // `\[`/`\]` are non-printing-sequence markers in PS1; the block rule turned
- // them into `$$` delimiters and broke the prompt.
- const source = '```bash\nPS1="\\[\\e[0m\\]$ "\n```';
- expect(preprocessLaTeX(source)).toBe(source);
- });
-
- it('leaves bracket character classes alone in an inline span', () => {
- expect(preprocessLaTeX('match `\\[0-9\\]` here')).toBe(
- 'match `\\[0-9\\]` here',
- );
- });
-
- it('still converts real inline math in prose', () => {
- expect(preprocessLaTeX('The value \\(x^2\\) is fine.')).toBe(
- 'The value $x^2$ is fine.',
- );
- });
-
- it('still converts real block math in prose', () => {
- expect(preprocessLaTeX('Given \\[a+b\\] we get')).toBe(
- 'Given $$a+b$$ we get',
- );
- });
-
- it('does not link a subscript inside the math it just produced', () => {
- // `$a[1](#cite-1)$` is not something KaTeX can render.
- expect(text('value \\(a[1]\\) end')).toBe('value $a[1]$ end');
- expect(text('given \\[a[1]+b\\] we get [2]')).toBe(
- 'given $$a[1]+b$$ we get [2](#cite-2)',
- );
- });
-});
-
-describe('applyOutsideCode', () => {
- const shout = (segment: string) => segment.toUpperCase();
-
- it('transforms prose either side of every code span', () => {
- expect(applyOutsideCode('a `b` c ```d``` e', shout)).toBe(
- 'A `b` C ```d``` E',
- );
- });
-
- it('handles several inline spans on one line', () => {
- expect(applyOutsideCode('x `a` y `b` z', shout)).toBe('X `a` Y `b` Z');
- });
-
- it('treats a double-backtick span containing a backtick as one span', () => {
- expect(applyOutsideCode('x ``a`b`` y', shout)).toBe('X ``a`b`` Y');
- });
-
- it('does not close a long fence on a shorter run', () => {
- expect(applyOutsideCode('````\na\n```\nb\n````\nc', shout)).toBe(
- '````\na\n```\nb\n````\nC',
- );
- });
-
- it('passes an empty string through', () => {
- expect(applyOutsideCode('', shout)).toBe('');
- });
-});
diff --git a/frontend/src/conversation/MarkdownAnswer.codeSpans.test.tsx b/frontend/src/conversation/MarkdownAnswer.codeSpans.test.tsx
new file mode 100644
index 00000000..835a029e
--- /dev/null
+++ b/frontend/src/conversation/MarkdownAnswer.codeSpans.test.tsx
@@ -0,0 +1,209 @@
+/**
+ * Code must survive citation linking and math parsing.
+ *
+ * Both used to be regex passes over the raw answer, and both corrupted the
+ * user's own code: `print(row[0])` rendered as `print(row[0](#cite-0))`, and
+ * `PS1="\[\e[0m\]"` as `PS1="$$\e[0m$$"`, in the answer and in what the copy
+ * button copied. Math is now parsed by the markdown parser and citations are
+ * linked on its syntax tree, where code is never plain text; these cases pin
+ * that down on the rendered answer.
+ */
+import { act } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+
+vi.mock('../hooks', () => ({ useDarkTheme: () => [false, vi.fn()] }));
+vi.mock('../components/MermaidRenderer', () => ({
+ default: ({ code }: { code: string }) => (
+
{code}
+ ),
+}));
+vi.mock('../components/CopyButton', () => ({ default: () => null }));
+
+import MarkdownAnswer from './MarkdownAnswer';
+
+Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
+
+let container: HTMLDivElement;
+let root: Root;
+
+beforeEach(() => {
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ root = createRoot(container);
+});
+
+afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+});
+
+function render(content: string, isStreaming = false) {
+ act(() =>
+ root.render(),
+ );
+}
+
+/** Text of every code block and inline code span, in order. */
+function code() {
+ return Array.from(container.querySelectorAll('code')).map(
+ (node) => node.textContent,
+ );
+}
+
+/** Citation pills, the only buttons these answers render. */
+function citations() {
+ return Array.from(container.querySelectorAll('button')).map(
+ (node) => node.textContent,
+ );
+}
+
+function math() {
+ return container.querySelectorAll('.katex, .katex-error').length;
+}
+
+describe('citation linking spares code', () => {
+ it('leaves an index subscript alone inside a fence', () => {
+ render('```python\nfor row in cur:\n print(row[0])\n```');
+ expect(code()).toEqual(['for row in cur:\n print(row[0])']);
+ expect(citations()).toEqual([]);
+ });
+
+ it('leaves an index subscript alone inside an inline span', () => {
+ render('Use `items[2]` here.');
+ expect(code()).toEqual(['items[2]']);
+ expect(citations()).toEqual([]);
+ });
+
+ it('leaves a mid-sentence triple-backtick span alone', () => {
+ render('inline ```code[0]``` end');
+ expect(code()).toEqual(['code[0]']);
+ expect(citations()).toEqual([]);
+ });
+
+ it('leaves a triple-backtick span that starts a line alone', () => {
+ // A backtick fence's info string cannot contain backticks, so this opens a
+ // code span, not an unclosed fence that swallows the rest of the answer.
+ render('```code[0]``` and see [1]');
+ expect(code()).toEqual(['code[0]']);
+ expect(citations()).toEqual(['1']);
+ });
+
+ it('closes an inline span only on a run of its own length', () => {
+ // The `` run does not close the ` span, so `b[1]` is still code.
+ render('Use `a`` b[1] more` end');
+ expect(code()).toEqual(['a`` b[1] more']);
+ expect(citations()).toEqual([]);
+ });
+
+ it('recognises a fence closed on a CRLF line', () => {
+ render('```js\r\nconst a = arr[0];\r\n```\r\nSee [1].');
+ expect(code()).toEqual(['const a = arr[0];']);
+ expect(citations()).toEqual(['1']);
+ });
+
+ it('does not carry an inline span across a blank line', () => {
+ // The blank line ends the paragraph, so the backticks never pair and
+ // `bar[1]` is prose.
+ render('a `foo\n\nbar[1]` end');
+ expect(code()).toEqual([]);
+ expect(citations()).toEqual(['1']);
+ });
+
+ it('leaves an inline span that wraps a line alone', () => {
+ render('use `arr[0]\nnext` here');
+ expect(code()).toEqual(['arr[0] next']);
+ expect(citations()).toEqual([]);
+ });
+
+ it('leaves a tilde fence alone', () => {
+ render('~~~python\nprint(row[0])\n~~~');
+ expect(code()).toEqual(['print(row[0])']);
+ expect(citations()).toEqual([]);
+ });
+
+ it('leaves a fence nested in a longer one alone', () => {
+ // A ``` run does not close a ```` fence, so the inner block is content.
+ render('````markdown\n```js\nconst a = arr[0];\n```\n````');
+ expect(code()).toEqual(['```js\nconst a = arr[0];\n```']);
+ expect(citations()).toEqual([]);
+ });
+
+ it('leaves a fence indented under a list item alone', () => {
+ render(
+ '1. Step:\n\n ```js\n const a = arr[0];\n\n const b = arr[1];\n ```',
+ );
+ expect(code()).toEqual(['const a = arr[0];\n\nconst b = arr[1];']);
+ expect(citations()).toEqual([]);
+ });
+
+ it('leaves an unterminated fence alone while the answer is still streaming', () => {
+ render('```python\nprint(row[0]', true);
+ expect(code()).toEqual(['print(row[0]']);
+ expect(citations()).toEqual([]);
+ });
+
+ it('leaves an unterminated inline span alone while streaming', () => {
+ render('Use `items[2', true);
+ expect(code()).toEqual(['items[2']);
+ expect(citations()).toEqual([]);
+ });
+
+ it('leaves mermaid node labels alone', () => {
+ render('```mermaid\nflowchart TD\n A[0] --> B[1]\n```');
+ expect(
+ container.querySelector('[data-testid="mermaid"]')?.textContent,
+ ).toBe('flowchart TD\n A[0] --> B[1]');
+ expect(citations()).toEqual([]);
+ });
+
+ it('still links a real citation in prose', () => {
+ render('See source [1] for details.');
+ expect(citations()).toEqual(['1']);
+ expect(container.textContent).toBe('See source 1 for details.');
+ });
+
+ it('links prose citations on either side of a fence without touching it', () => {
+ render('Per [1], run:\n```js\nconst a = arr[0];\n```\nthen [2].');
+ expect(code()).toEqual(['const a = arr[0];']);
+ expect(citations()).toEqual(['1', '2']);
+ });
+
+ it('still links a citation on an indented list continuation line', () => {
+ render('- one\n - nested [1]');
+ expect(citations()).toEqual(['1']);
+ });
+
+ it('leaves an already-linked reference alone', () => {
+ render('see [1](#cite-1) ok');
+ expect(citations()).toEqual(['1']);
+ });
+});
+
+describe('math parsing spares code', () => {
+ it('leaves bash prompt escapes alone', () => {
+ // `\[`/`\]` are non-printing-sequence markers in PS1; the old block rule
+ // turned them into `$$` delimiters and broke the prompt.
+ render('```bash\nPS1="\\[\\e[0m\\]$ "\n```');
+ expect(code()).toEqual(['PS1="\\[\\e[0m\\]$ "']);
+ expect(math()).toBe(0);
+ });
+
+ it('leaves bracket character classes alone in an inline span', () => {
+ render('match `\\[0-9\\]` here');
+ expect(code()).toEqual(['\\[0-9\\]']);
+ expect(math()).toBe(0);
+ });
+
+ it('leaves dollars alone in an inline span', () => {
+ render('Run `echo $HOME and $$` then `$x$`.');
+ expect(code()).toEqual(['echo $HOME and $$', '$x$']);
+ expect(math()).toBe(0);
+ });
+
+ it('does not link a subscript inside math', () => {
+ render('value \\(a[1]\\) end, given \\[a[1]+b\\] we get [2]');
+ expect(math()).toBe(2);
+ expect(container.querySelectorAll('.katex-error').length).toBe(0);
+ expect(citations()).toEqual(['2']);
+ });
+});
diff --git a/frontend/src/conversation/MarkdownAnswer.math.test.tsx b/frontend/src/conversation/MarkdownAnswer.math.test.tsx
new file mode 100644
index 00000000..c52cf656
--- /dev/null
+++ b/frontend/src/conversation/MarkdownAnswer.math.test.tsx
@@ -0,0 +1,524 @@
+/**
+ * Math must render as math, and only math.
+ *
+ * ``processMarkdownContent`` rewrote inline ``\(x\)`` to ``$x$`` while
+ * remark-math runs with ``singleDollarTextMath: false``, so every inline span
+ * rendered as the literal text ``$x$`` (students asked "what does $ mean").
+ * A one-line ``\[ … \]`` became ``$$ … $$`` mid-paragraph, which remark-math
+ * reads as *inline* math, so display equations shrank into the sentence. And a
+ * citation pass over the raw string turned ``$$a[1]$$`` into a link inside the
+ * formula. Math is now parsed by the markdown parser itself; these tests only
+ * look at what renders.
+ */
+import { act } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+
+// Records the markdown each ReactMarkdown instance renders.
+const markdownRenders = vi.hoisted(() => vi.fn());
+vi.mock('react-markdown', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ default: (props: Parameters[0]) => {
+ markdownRenders(props.children);
+ return actual.default(props);
+ },
+ };
+});
+vi.mock('../hooks', () => ({ useDarkTheme: () => [false, vi.fn()] }));
+vi.mock('../components/MermaidRenderer', () => ({
+ default: ({ code }: { code: string }) => (
+
{code}
+ ),
+}));
+vi.mock('../components/CopyButton', () => ({ default: () => null }));
+
+import MarkdownAnswer from './MarkdownAnswer';
+
+Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
+
+let container: HTMLDivElement;
+let root: Root;
+
+beforeEach(() => {
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ root = createRoot(container);
+});
+
+afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+});
+
+type RenderOptions = { isStreaming?: boolean; sourceCount?: number };
+
+function render(content: string, options: RenderOptions = {}) {
+ act(() => root.render());
+ const all = container.querySelectorAll('.katex').length;
+ const display = container.querySelectorAll('.katex-display').length;
+ const clone = container.cloneNode(true) as HTMLElement;
+ clone
+ .querySelectorAll('.katex, .katex-error')
+ .forEach((node) => node.remove());
+ return {
+ inline: all - display,
+ display,
+ errors: container.querySelectorAll('.katex-error').length,
+ text: clone.textContent ?? '',
+ };
+}
+
+/** TeX source of every rendered formula, in order. */
+function formulas() {
+ return Array.from(
+ container.querySelectorAll(
+ '.katex annotation[encoding="application/x-tex"]',
+ ),
+ ).map((node) => node.textContent);
+}
+
+/** Citation pills, the only buttons these answers render. */
+function citations() {
+ return Array.from(container.querySelectorAll('button')).map(
+ (node) => node.textContent,
+ );
+}
+
+describe('backslash delimiters', () => {
+ it('renders inline \\( \\) math as inline KaTeX, not a literal $', () => {
+ const out = render('The velocity is \\(v = u + at\\) at time t.');
+ expect(out.inline).toBe(1);
+ expect(out.display).toBe(0);
+ expect(out.text).not.toContain('$');
+ expect(out.text).toContain('at time t.');
+ });
+
+ it.each([
+ ['at the start of a line', '\\(v\\) is the final velocity.'],
+ ['in a list item', '- speed \\(v\\) in metres'],
+ ['in a table cell', '| q | f |\n|---|---|\n| v | \\(v=u+at\\) |'],
+ ['wrapping a line', 'where \\(a +\nb\\) holds'],
+ ['in a heading', '## Speed \\(v\\)'],
+ ['inside bold', '**the speed \\(v\\)**'],
+ ])('renders inline math %s', (_, source) => {
+ const out = render(source);
+ expect(out.inline).toBe(1);
+ expect(out.errors).toBe(0);
+ expect(out.text).not.toContain('$');
+ });
+
+ it('keeps a line break inside inline math as whitespace', () => {
+ // `\alpha` followed by a newline must not fuse into `\alphab`.
+ render('then \\(\\alpha\nb\\) holds');
+ expect(formulas()).toEqual(['\\alpha\nb']);
+ });
+
+ it('renders a dollar sign inside inline math without a KaTeX error', () => {
+ const out = render('cost \\(C = \\$5 n\\) here');
+ expect(out.inline).toBe(1);
+ expect(out.errors).toBe(0);
+ });
+
+ it('renders a one-line \\[ \\] on its own line as display math', () => {
+ const out = render(
+ 'Displacement:\n\\[ s = ut + \\tfrac12 at^2 \\]\nwhere u is speed.',
+ );
+ expect(out.display).toBe(1);
+ expect(out.inline).toBe(0);
+ expect(out.text).toContain('Displacement:');
+ expect(out.text).toContain('where u is speed.');
+ });
+
+ it('renders a one-line \\[ \\] inside a list item as display math', () => {
+ const out = render(
+ '1. Step one:\n\n \\[ v^2 = u^2 + 2as \\]\n\n2. Step two',
+ );
+ expect(out.display).toBe(1);
+ expect(container.querySelectorAll('ol > li').length).toBe(2);
+ });
+
+ it('renders multi-line \\[ \\] as display math', () => {
+ expect(render('Then:\n\n\\[\ns = ut\n\\]\n\nDone.').display).toBe(1);
+ expect(render('\\[\na+b=c\n\\]').display).toBe(1);
+ });
+
+ it('does not read math lines as markdown structure', () => {
+ // A lone `=` under a line is a setext heading and a `- ` line a list item,
+ // outside math.
+ const out = render('\\[\nx\n=\ny\n- z\n\\]');
+ expect(out.display).toBe(1);
+ expect(container.querySelector('h1, ul')).toBeNull();
+ expect(formulas()).toEqual(['x\n=\ny\n- z']);
+ });
+
+ it('renders mid-sentence \\[ \\] as math', () => {
+ const out = render('So \\[ s = ut \\] holds.');
+ expect(out.inline + out.display).toBe(1);
+ expect(out.text).toContain('holds.');
+ });
+
+ it('keeps the rest of the answer when a line opens with \\[ \\] and goes on', () => {
+ // A line-start `\[` is a display block only when its `\]` ends the line;
+ // otherwise it must not swallow everything after it.
+ const out = render('\\[a+b=c\\] and more text\n\nNext paragraph.');
+ expect(out.inline).toBe(1);
+ expect(out.display).toBe(0);
+ expect(out.text).toContain('and more text');
+ expect(container.querySelectorAll('p').length).toBe(2);
+ });
+
+ it.each([
+ ['\\[x=1\\tag{1}\\]', '\\tag'],
+ ['\\[\\begin{aligned}x&=1\\end{aligned}\\]', 'aligned'],
+ ['where \\(x = 1 \\tag{2}\\) holds', 'inline \\tag'],
+ ])('renders %s as display math', (source) => {
+ const out = render(source);
+ expect(out.display).toBe(1);
+ expect(out.errors).toBe(0);
+ });
+
+ it('renders display math inside a blockquote', () => {
+ const out = render('> \\[\n> x\n> =\n> y\n> \\]');
+ expect(out.display).toBe(1);
+ expect(container.querySelector('blockquote .katex-display')).not.toBeNull();
+ expect(formulas()).toEqual(['x\n=\ny']);
+ });
+
+ it('renders display math inside a list item', () => {
+ const out = render('- \\[\n x\n =\n y\n \\]');
+ expect(out.display).toBe(1);
+ expect(container.querySelector('li .katex-display')).not.toBeNull();
+ });
+
+ it('does not close a display block across the end of a blockquote', () => {
+ const out = render('> \\[\n> x\n> =\n\ny\n\\]\n\nNext section\n=');
+ expect(out.inline + out.display + out.errors).toBe(0);
+ });
+
+ it('does not render an unclosed \\( as math', () => {
+ const out = render('Using \\(v = u');
+ expect(out.inline + out.display + out.errors).toBe(0);
+ });
+
+ it('does not render an unclosed \\[ block as math', () => {
+ const out = render('Then\n\n\\[\nv = u\n\nand the rest.');
+ expect(out.inline + out.display + out.errors).toBe(0);
+ expect(out.text).toContain('and the rest.');
+ });
+
+ it('keeps an escaped task checkbox as text', () => {
+ const out = render('- \\[ \\] todo\n- done');
+ expect(out.inline + out.display + out.errors).toBe(0);
+ expect(out.text).toContain('todo');
+ expect(container.querySelectorAll('li').length).toBe(2);
+ });
+
+ it('keeps escaped index brackets as text', () => {
+ const out = render('arr\\[0\\] and arr\\[1\\]');
+ expect(out.inline + out.display).toBe(0);
+ expect(out.text).toContain('arr[0] and arr[1]');
+ });
+
+ it('keeps nested \\( \\) in one span', () => {
+ // KaTeX cannot typeset a nested `\(`, but the span must not end at the
+ // inner `\)` and leak the outer one into the text.
+ const out = render('\\(a + \\(b + c\\)\\) end');
+ expect(out.inline + out.errors).toBe(1);
+ expect(out.text.trim()).toBe('end');
+ });
+
+ it('does not mistake a \\[ \\] after inline code for its own line', () => {
+ const out = render('Use `f` \\[x\\]\nnext');
+ expect(out.inline + out.display).toBe(1);
+ expect(out.text).not.toContain('$');
+ });
+
+ it('leaves \\( \\) inside code untouched', () => {
+ const out = render('Type `\\(x\\)` literally.');
+ expect(out.inline).toBe(0);
+ expect(container.querySelector('code')?.textContent).toBe('\\(x\\)');
+ });
+
+ it('leaves bash prompt escapes in a fence untouched', () => {
+ const out = render('```bash\nPS1="\\[\\e[0m\\]$ "\n```');
+ expect(out.inline + out.display).toBe(0);
+ expect(container.textContent).toContain('PS1="\\[\\e[0m\\]$ "');
+ });
+});
+
+describe('dollar delimiters', () => {
+ it.each([
+ 'from $2bn to at least $4bn per operation starting Sept 9.',
+ 'Price is $50 and $100',
+ '$50 is $20 + $30',
+ 'Total: $29.50 plus tax',
+ 'Revenue: $5M to $10M, funding: $1.5B, price: $5K',
+ '$250k is 25% of $1M',
+ 'Bitcoin: $0.00001234, Gas: $3.999, Rate: $1.234567890',
+ 'The total is $1157.90 (existing) + $500 (new investment) = $1657.90.',
+ 'a $100-$200 range',
+ 'a $100–$200 range',
+ 'in the $10k-$20k band',
+ '- **Total Savings**: $500 + $200 + $150 = $850',
+ 'Cela coûte 100$ et 200$ en Europe',
+ 'A single $ sign should not be converted',
+ 'The price hit $79,455 on',
+ 'Currency $100 and\nthen $200 later',
+ 'The cost is $',
+ 'Use $variable in the code',
+ '| Date | Price | Note |\n|---|---|---|\n| Aug 21 | $77,300, peak $79,455 | White House Clarity Act push |',
+ 'It costs $5 and $10 in total.',
+ 'Plans range between $5-$10 per seat.',
+ 'Price: $19.99/month or $199/year.',
+ 'Revenue grew from $3.2B to $4.1B.',
+ 'Price is $5 (was $10).',
+ '| Plan | Price |\n|---|---|\n| Basic | $5 |\n| Pro | $10 |',
+ ])('leaves currency as text: %s', (source) => {
+ const out = render(source);
+ expect(out.inline + out.display + out.errors).toBe(0);
+ const amount = source.match(/\$\d[\d.,]*|\d+\$/);
+ if (amount) expect(out.text).toContain(amount[0]);
+ });
+
+ it.each([
+ ['Inline math: $x^2 + y^2 = z^2$', 1],
+ ['the answer is $3$.', 1],
+ ['an eigenvalue of $-1$ is expected', 1],
+ ['the $n$th term', 1],
+ ['The set is defined as $\\{x | x > 0\\}$.', 1],
+ ['Calculate $\\text{Total} = \\$500 + \\$200$', 1],
+ ['The equation $12 \\times 12 = 144$ is simple', 1],
+ ['Price $100 then equation $x + y = z$ then another price $50', 1],
+ ['Formula $x^2$ costs $25', 1],
+ ])('renders %s as math', (source, count) => {
+ const out = render(source);
+ expect(out.inline).toBe(count);
+ expect(out.errors).toBe(0);
+ });
+
+ it('keeps the price beside a formula', () => {
+ render('Cost is $5 per unit and $x$ units.');
+ expect(formulas()).toEqual(['x']);
+ expect(container.textContent).toContain('$5 per unit');
+ });
+
+ it('does not form emphasis out of math', () => {
+ const out = render('terms $a_1 + b_2$ and $c_{i}^{*}$ here');
+ expect(out.inline).toBe(2);
+ expect(container.querySelector('em, strong')).toBeNull();
+ });
+
+ it('renders chemistry with mhchem', () => {
+ const out = render('$\\ce{H2O}$ and $\\pu{123 J}$');
+ expect(out.inline).toBe(2);
+ expect(out.errors).toBe(0);
+ });
+
+ it.each([
+ [
+ 'a code span',
+ 'The error "invalid $lookup namespace" occurs when using `$lookup` operator',
+ ],
+ ['escaped dollars', 'Already escaped \\$50 and \\$100'],
+ ['a line break', 'This has $x\ny$ which spans lines'],
+ ['an unbalanced brace', 'weird $a}b$ y'],
+ [
+ 'shell PIDs in a fence',
+ '```bash\npstree -p $$\necho $$\n```\n\nSome text after.',
+ ],
+ ])('renders no math across %s', (_, source) => {
+ const out = render(source);
+ expect(out.inline + out.display + out.errors).toBe(0);
+ });
+
+ it('renders math only outside a fence', () => {
+ const out = render('```\n$100\n$variable\n```\n\nOutside $x^2$');
+ expect(out.inline).toBe(1);
+ expect(container.querySelector('code')?.textContent).toContain('$variable');
+ });
+
+ it('renders a $$ paragraph as display math', () => {
+ const out = render('text\n\n$$E=mc^2$$\n\nmore');
+ expect(out.display).toBe(1);
+ expect(out.text).toContain('more');
+ });
+
+ it('keeps mid-line $$ math inline', () => {
+ const out = render('so $$E=mc^2$$ holds');
+ expect(out.inline).toBe(1);
+ expect(out.display).toBe(0);
+ });
+
+ it('renders $$ with \\begin on the fence line', () => {
+ const out = render(
+ '$$\\begin{aligned}\na&=b\\\\\nc&=d\n\\end{aligned}$$\n\nAfter.',
+ );
+ expect(out.display).toBe(1);
+ expect(out.errors).toBe(0);
+ expect(out.text).toContain('After.');
+ });
+});
+
+describe('citations', () => {
+ it('links citations next to inline math and renders the math', () => {
+ const out = render('Newton: \\(F=ma\\) [1] and \\(p=mv\\)[2].');
+ expect(out.inline).toBe(2);
+ expect(citations()).toEqual(['1', '2']);
+ });
+
+ it('never links a bracket inside math', () => {
+ const out = render('$$a[1]$$ and [3]');
+ expect(formulas()).toEqual(['a[1]']);
+ expect(out.errors).toBe(0);
+ expect(citations()).toEqual(['3']);
+ });
+
+ it('never links a bracket inside single-dollar math', () => {
+ const out = render('value $a[1]$ end [2]');
+ expect(citations()).toEqual(['2']);
+ expect(out.errors).toBe(0);
+ });
+
+ it.each([
+ ['**[1]** and [2][3]', ['1', '2', '3']],
+ ['see `row[0]` and [4]', ['4']],
+ ['[link](https://x.com) [1]', ['1']],
+ ['- one\n - nested [1]', ['1']],
+ ['Per [1], run:\n```js\nconst a = arr[0];\n```\nthen [2].', ['1', '2']],
+ ])('links %s', (source, expected) => {
+ render(source);
+ expect(citations()).toEqual(expected);
+ });
+
+ it('leaves code and diagram labels alone', () => {
+ render('```python\nprint(row[0])\n```');
+ expect(container.textContent).toContain('print(row[0])');
+ expect(citations()).toEqual([]);
+ });
+
+ it('keeps a reference-style link a link', () => {
+ render('See [the docs][1].\n\n[1]: https://example.com');
+ expect(
+ container.querySelector('a[href="https://example.com"]'),
+ ).not.toBeNull();
+ expect(citations()).toEqual([]);
+ });
+
+ it('links only citations within the number of sources', () => {
+ render('Per [1], [2] and [0] and [7].', { sourceCount: 2 });
+ expect(citations()).toEqual(['1', '2']);
+ expect(container.textContent).toContain('[0] and [7]');
+ });
+
+ it('links no citation when the answer has no sources', () => {
+ render('Per [1].', { sourceCount: 0 });
+ expect(citations()).toEqual([]);
+ expect(container.textContent).toContain('[1]');
+ });
+});
+
+describe('mermaid', () => {
+ it('renders a closed mermaid fence as a diagram, source untouched', () => {
+ render(
+ 'Intro [1]\n\n```mermaid\nflowchart TD\n A[0] --> B[1]\n```\n\nOutro',
+ );
+ expect(
+ container.querySelector('[data-testid="mermaid"]')?.textContent,
+ ).toBe('flowchart TD\n A[0] --> B[1]');
+ expect(citations()).toEqual(['1']);
+ expect(container.textContent).toContain('Outro');
+ });
+
+ it('shows an unclosed mermaid fence as code while it streams', () => {
+ render('```mermaid\nflowchart TD\n A --> B', { isStreaming: true });
+ expect(container.querySelector('[data-testid="mermaid"]')).toBeNull();
+ });
+});
+
+describe('streaming', () => {
+ it('closes an open $$ block while streaming', () => {
+ const out = render('$$\nx = 1\ny = 2', { isStreaming: true });
+ expect(out.display).toBe(1);
+ });
+
+ it('closes an open inline $$ while streaming', () => {
+ const out = render('Text with $$formula', { isStreaming: true });
+ expect(out.inline).toBe(1);
+ expect(out.text).not.toContain('$');
+ });
+
+ it('closes an open \\[ block while streaming', () => {
+ const out = render('Then:\n\n\\[\nv = u', { isStreaming: true });
+ expect(out.display).toBe(1);
+ });
+
+ it('closes open bold before an open formula, not after it', () => {
+ // Appended to the end, `**` turned the closing fence into `$$**`.
+ const out = render('Energy is **the work it takes\n$$\nE = mc', {
+ isStreaming: true,
+ });
+ expect(out.display).toBe(1);
+ expect(out.errors).toBe(0);
+ expect(formulas()).toEqual(['E = mc']);
+ });
+
+ it('holds back an inline $$ with nothing after it yet', () => {
+ const out = render('Then **bold** and $$', { isStreaming: true });
+ expect(out.inline + out.display + out.errors).toBe(0);
+ expect(out.text).not.toContain('$');
+ });
+
+ it('does not render an unclosed \\( while streaming', () => {
+ const out = render('Using \\(v = u', { isStreaming: true });
+ expect(out.inline + out.display + out.errors).toBe(0);
+ });
+
+ it('leaves a trailing $ alone', () => {
+ const out = render('The cost is $', { isStreaming: true });
+ expect(out.inline + out.display).toBe(0);
+ expect(out.text).toContain('The cost is $');
+ });
+
+ it('closes open bold while streaming', () => {
+ render('Text **bold', { isStreaming: true });
+ expect(container.querySelector('strong')?.textContent).toBe('bold');
+ });
+
+ it('does not add $$ inside an open code fence', () => {
+ render('```bash\necho $$', { isStreaming: true });
+ expect(container.querySelector('code')?.textContent).toBe('echo $$');
+ });
+
+ it('does not heal once the answer is complete', () => {
+ render('Text **bold');
+ expect(container.querySelector('strong')).toBeNull();
+ });
+
+ it('shows a formula that cannot render yet in the muted colour', () => {
+ const out = render('$$\n\\frac{a}{', { isStreaming: true });
+ expect(out.errors).toBe(1);
+ const error = container.querySelector('.katex-error');
+ expect(error?.getAttribute('style')).toContain('var(--muted-foreground)');
+ });
+});
+
+describe('block rendering', () => {
+ it('re-renders only the block that changed', () => {
+ render('First \\(x^2\\) [1].\n\nSecond', { isStreaming: true });
+ markdownRenders.mockClear();
+ render('First \\(x^2\\) [1].\n\nSecond paragraph grows', {
+ isStreaming: true,
+ });
+ // The first paragraph, formula and all, is not parsed or typeset again.
+ expect(markdownRenders.mock.calls).toEqual([['Second paragraph grows']]);
+ expect(container.textContent).toContain('Second paragraph grows');
+ });
+
+ it('keeps footnotes working across blocks', () => {
+ render('Claim[^1].\n\nMore.\n\n[^1]: The note.');
+ expect(
+ container.querySelector('a[href="#user-content-fn-1"]'),
+ ).not.toBeNull();
+ });
+});
diff --git a/frontend/src/conversation/MarkdownAnswer.tsx b/frontend/src/conversation/MarkdownAnswer.tsx
index 8736645b..39d4bedb 100644
--- a/frontend/src/conversation/MarkdownAnswer.tsx
+++ b/frontend/src/conversation/MarkdownAnswer.tsx
@@ -1,16 +1,17 @@
import 'katex/dist/katex.min.css';
+// Registers `\ce` and `\pu` with the KaTeX that rehype-katex renders with.
+import 'katex/contrib/mhchem';
-import { Fragment, type ReactNode, useMemo } from 'react';
+import { Fragment, memo, type ReactNode, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
-import ReactMarkdown from 'react-markdown';
+import ReactMarkdown, { type Components } from 'react-markdown';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import {
oneLight,
vscDarkPlus,
} from 'react-syntax-highlighter/dist/cjs/styles/prism';
import rehypeKatex from 'rehype-katex';
-import remarkGfm from 'remark-gfm';
-import remarkMath from 'remark-math';
+import type { PluggableList } from 'unified';
import { markdownHeadings } from '@/lib/markdown';
@@ -19,6 +20,14 @@ import MermaidRenderer from '../components/MermaidRenderer';
import { Button } from '../components/ui/button';
import { useDarkTheme } from '../hooks';
import classes from './ConversationBubble.module.css';
+import {
+ answerSyntaxPlugins,
+ healStreamingMarkdown,
+ normalizeMathFences,
+ splitAnswerBlocks,
+} from './markdown/answerMarkdown';
+import { remarkCitations } from './markdown/remarkCitations';
+import { remarkDisplayMath } from './markdown/remarkDisplayMath';
import {
resolveSandboxLink,
type SandboxArtifact,
@@ -26,118 +35,62 @@ import {
} from './sandboxLinks';
import { cn } from '@/lib/utils';
-// One fenced block or inline code span. Backtick runs are length-matched, so a
-// ```` fence closes only on ```` and nested fences stay masked. The
-// unterminated alternatives keep an open span matched too: answers stream in,
-// so a fence is open for most of its life and needs protecting the whole time,
-// not just once the closing fence arrives. A backtick fence's info string may
-// not itself contain backticks, so a line that opens with an inline span stays
-// an inline span. Four-space indented blocks are deliberately not masked —
-// list continuation lines are indented the same way, and masking those would
-// drop real citations out of nested lists.
-const CODE_SPAN =
- /(? string,
- onMasked: (segment: string) => string = (segment) => segment,
-): string {
- const pattern = new RegExp(mask.source, mask.flags); // its own lastIndex
- let result = '';
- let index = 0;
- let match: RegExpExecArray | null;
-
- while ((match = pattern.exec(content)) !== null) {
- result += transform(content.slice(index, match.index)) + onMasked(match[0]);
- index = pattern.lastIndex;
- }
- return result + transform(content.slice(index));
-}
-
-// Runs `transform` over the prose in `content`, leaving code untouched.
-//
-// The rewrites below are plain regex over the whole document, and both used to
-// corrupt the user's own code: `print(row[0])` rendered as
-// `print(row[0](#cite-0))`, and `PS1="\[\e[0m\]"` as `PS1="$$\e[0m$$"`. Neither
-// can move into a remark plugin: remark-math tokenises `$`/`$$` while parsing,
-// so the LaTeX pass has to run on the raw string before remark ever sees it.
-export function applyOutsideCode(
- content: string,
- transform: (segment: string) => string,
-): string {
- return transformOutside(content, CODE_SPAN, transform);
-}
-
-// Rewrites one LaTeX span into the ``$$``/``$`` form remark-math parses.
-function toDollarMath(span: string): string {
- const equation = span.slice(2, -2);
- return span.startsWith('\\[') ? `$$${equation}$$` : `$${equation}$`;
-}
-
-// Replaces block-level ``\[ \]`` and inline ``\( \)`` LaTeX delimiters with the
-// ``$$``/``$`` forms remark-math understands.
-export function preprocessLaTeX(content: string): string {
- return applyOutsideCode(content, (prose) =>
- prose.replace(LATEX_SPAN, toDollarMath),
+// One top-level block of the answer. Memoised, so while an answer streams only
+// the block that is still growing is parsed and typeset again.
+const MarkdownBlock = memo(function MarkdownBlock({
+ content,
+ remarkPlugins,
+ components,
+}: {
+ content: string;
+ remarkPlugins: PluggableList;
+ components: Components;
+}) {
+ return (
+
+ {content}
+
);
-}
+});
-// Turns citation references ``[N]`` into ``[N](#cite-N)`` links so
-// ReactMarkdown renders them as tags we can style. The lookarounds skip
-// references that are already links.
-function linkCitations(prose: string): string {
- return prose.replace(
- /(? `[${num}](#cite-${num})`,
+// Artifact lists are rebuilt on every render of the bubble; keep one identity
+// per content so the memoised blocks are not re-rendered for nothing.
+function useStableArtifacts(list?: SandboxArtifact[]) {
+ const signature = JSON.stringify(
+ list?.map(({ id, label, toolName, ref }) => [id, label, toolName, ref]),
);
+ return useMemo(() => list, [signature]);
}
-type ContentSegment = { type: 'text' | 'mermaid'; content: string };
-
-export function processMarkdownContent(content: string): ContentSegment[] {
- // Citations are linked outside code — an index subscript like `row[0]` is not
- // a citation — and outside the math this same pass produces. Math the answer
- // already wrote as ``$…$`` stays unmasked, since ``$`` is also a currency
- // sign. This has to run before the ```mermaid split below, so diagram source
- // needs the same guard.
- const processedContent = applyOutsideCode(content, (prose) =>
- transformOutside(prose, LATEX_SPAN, linkCitations, toDollarMath),
- );
-
- const contentSegments: ContentSegment[] = [];
- let lastIndex = 0;
- const regex = /```mermaid\n([\s\S]*?)```/g;
- let match;
-
- while ((match = regex.exec(processedContent)) !== null) {
- const textBefore = processedContent.substring(lastIndex, match.index);
- if (textBefore) contentSegments.push({ type: 'text', content: textBefore });
- contentSegments.push({ type: 'mermaid', content: match[1].trim() });
- lastIndex = match.index + match[0].length;
- }
-
- const textAfter = processedContent.substring(lastIndex);
- if (textAfter) contentSegments.push({ type: 'text', content: textAfter });
-
- return contentSegments;
-}
+type AnswerGroup =
+ { type: 'markdown'; blocks: string[] } | { type: 'mermaid'; content: string };
export default function MarkdownAnswer({
content,
isStreaming,
- artifacts,
- turnArtifacts,
+ sourceCount,
+ artifacts: artifactsProp,
+ turnArtifacts: turnArtifactsProp,
onOpenArtifact,
}: {
content: string;
isStreaming?: boolean;
+ /**
+ * How many sources the answer cites from; ``[N]`` beyond it is left as
+ * text, with no source to jump to. Unset links every ``[N]``.
+ */
+ sourceCount?: number;
/**
* Every artifact in the conversation, in creation order (``A1`` is the
* first) — refs are conversation-scoped, so a link may name an earlier turn's
@@ -150,230 +103,249 @@ export default function MarkdownAnswer({
}) {
const { t } = useTranslation();
const [isDarkTheme] = useDarkTheme();
- // Re-runs on every streamed token otherwise.
- const contentSegments = useMemo(
- () => processMarkdownContent(content),
- [content],
+ const artifacts = useStableArtifacts(artifactsProp);
+ const turnArtifacts = useStableArtifacts(turnArtifactsProp);
+
+ const groups = useMemo(() => {
+ const normalized = normalizeMathFences(content);
+ const blocks = splitAnswerBlocks(
+ isStreaming ? healStreamingMarkdown(normalized) : normalized,
+ );
+ // Consecutive markdown blocks share one column; a diagram breaks it.
+ const grouped: AnswerGroup[] = [];
+ for (const block of blocks) {
+ const last = grouped[grouped.length - 1];
+ if (block.type === 'mermaid') grouped.push(block);
+ else if (last?.type === 'markdown') last.blocks.push(block.content);
+ else grouped.push({ type: 'markdown', blocks: [block.content] });
+ }
+ return grouped;
+ }, [content, isStreaming]);
+
+ const remarkPlugins = useMemo(
+ () => [
+ ...answerSyntaxPlugins,
+ remarkDisplayMath,
+ [remarkCitations, { sourceCount }],
+ ],
+ [sourceCount],
);
- // Shared by the `a` and `img` renderers: a generated file is already on the
- // turn as a download chip, so both point at the chip rather than at a URL no
- // browser can open.
- const renderArtifactChip = (
- artifact: SandboxArtifact,
- content: ReactNode,
- ) => {
- if (!onOpenArtifact) return <>{content}>;
- return (
-
- );
- };
+ if (sandboxLink.kind === 'artifact') {
+ return renderArtifactChip(sandboxLink.artifact, children);
+ }
+ if (href?.startsWith('#cite-')) {
+ const num = href.replace('#cite-', '');
+ const sourceIdx = parseInt(num, 10) - 1;
+ return (
+ {
+ const el = document.getElementById(`source-${sourceIdx}`);
+ if (el) {
+ el.scrollIntoView({
+ behavior: 'smooth',
+ block: 'center',
+ });
+ el.classList.add('ring-3', 'ring-primary');
+ setTimeout(
+ () => el.classList.remove('ring-3', 'ring-primary'),
+ 2000,
+ );
+ }
+ }}
+ className="mx-0.5 h-5 min-w-5"
+ title={t('conversation.jumpToSource', { num })}
+ >
+ {num}
+
+ );
+ }
+ return (
+
+ {children}
+
+ );
+ },
+ img({ src, alt }) {
+ // `` is how a model
+ // announces a plot it produced. react-markdown runs
+ // urlTransform over `src` too, so without this the invented
+ // scheme survives and renders a broken-image box beside the
+ // chip that opens the very same file.
+ const source = typeof src === 'string' ? src : undefined;
+ const sandboxLink = resolveSandboxLink(
+ source,
+ artifacts,
+ turnArtifacts,
+ );
+ if (sandboxLink.kind === 'artifact') {
+ const { artifact } = sandboxLink;
+ return renderArtifactChip(
+ artifact,
+ alt || artifact.label || t('conversation.openFile'),
+ );
+ }
+ if (sandboxLink.kind === 'plain') {
+ return <>{alt ?? ''}>;
+ }
+ return ;
+ },
+ code(props) {
+ const { children, className, node, ref, ...rest } = props;
+ const match = /language-(\w+)/.exec(className || '');
+ const language = match ? match[1] : '';
+
+ return match ? (
+