Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,18 @@
"jose": "catalog:",
"jsonc-parser": "3.3.1",
"jszip": "3.10.1",
"katex": "^0.16.47",
"lexical": "^0.41.0",
"lucide-react": "^0.564.0",
"react": "19.2.6",
"react-dom": "19.2.6",
"react-markdown": "^10.1.0",
"rehype-katex": "^7.0.1",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"tailwind-merge": "^3.4.0",
"zustand": "^5.0.11"
},
Expand Down
71 changes: 71 additions & 0 deletions apps/web/src/components/ChatMarkdown.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,77 @@ describe("ChatMarkdown streaming", () => {
});
});

describe("ChatMarkdown math", () => {
it.each([true, false])(
"renders inline and display LaTeX with accessible MathML when parseRawHtml=%s",
(parseRawHtml) => {
const html = renderToStaticMarkup(
<ChatMarkdown
cwd="/tmp/project"
parseRawHtml={parseRawHtml}
text={String.raw`Euler wrote $e^{i\pi} + 1 = 0$.

$$
\int_0^1 x^2\,dx = \frac{1}{3}
$$`}
/>,
);

expect(html).toContain("katex");
expect(html).toContain("katex-display");
expect(html).toContain("<math");
expect(html).toContain("e^{i\\pi} + 1 = 0");
},
);

it.each([true, false])(
"renders parenthesis and bracket LaTeX delimiters when parseRawHtml=%s",
(parseRawHtml) => {
const html = renderToStaticMarkup(
<ChatMarkdown
cwd="/tmp/project"
parseRawHtml={parseRawHtml}
text={String.raw`GenCast represents \(F\) using samples \(x_1,\ldots,x_M\).

\[
\boxed{\widehat{\operatorname{CRPS}} = \frac{1}{M}\sum_{i=1}^{M}|x_i-y|}
\]`}
/>,
);

expect(html).toContain("katex");
expect(html).toContain("katex-display");
expect(html).toContain("GenCast represents ");
expect(html).toContain("x_1,\\ldots,x_M");
expect(html).toContain("\\boxed{\\widehat{\\operatorname{CRPS}}");
},
);

it("does not interpret LaTeX delimiters inside code", () => {
const markdown = [
"Render \\(x^2\\), but keep `\\(inlineExample\\)` unchanged.",
"",
"```text",
String.raw`\[displayExample\]`,
"```",
].join("\n");
const html = renderToStaticMarkup(<ChatMarkdown cwd="/tmp/project" text={markdown} />);

expect(html).toContain("katex");
expect(html).toContain("\\(inlineExample\\)");
expect(html).toContain("\\[displayExample\\]");
});

it("keeps a lone dollar amount as text", () => {
const html = renderToStaticMarkup(
<ChatMarkdown cwd="/tmp/project" text="The build costs $20." />,
);

expect(html).toContain("$20");
expect(html).not.toContain("katex");
});
});

describe("canUseMarkdownFileShellActions", () => {
const environmentId = EnvironmentId.make("environment-1");

Expand Down
19 changes: 16 additions & 3 deletions apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,12 +67,14 @@ import React, {
import type { Components, Options as ReactMarkdownOptions } from "react-markdown";
import ReactMarkdown from "react-markdown";
import { defaultUrlTransform } from "react-markdown";
import rehypeKatex from "rehype-katex";
import rehypeRaw from "rehype-raw";
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
import remarkBreaks from "remark-breaks";
import { parseAssistantCitationHref } from "@t3tools/shared/assistantCitations";
import { AssistantCitationChip } from "./chat/AssistantCitationChip";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import { remarkGithubAlerts } from "../markdown-github-alerts";
import {
artifactTemplateFromHastProperties,
Expand Down Expand Up @@ -126,6 +128,7 @@ import {
serializeTableElementToMarkdown,
} from "../markdown-clipboard";
import { remarkNormalizeListItemIndentation } from "../markdown-list-indentation";
import { normalizeLatexDelimiters } from "../markdown-latex";
import {
extractMarkdownLinkHrefs,
isWindowsDrivePathHref,
Expand Down Expand Up @@ -396,6 +399,7 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = {

const CHAT_MARKDOWN_REMARK_PLUGINS = [
remarkGfm,
remarkMath,
remarkGithubAlerts,
remarkNormalizeListItemIndentation,
remarkCodexDirectives,
Expand All @@ -405,6 +409,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS = [

const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [
remarkGfm,
remarkMath,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skill tokens collide with math dollars

Medium Severity

remark-math treats paired $...$ as inline math, which collides with chat $skill tokens. Two skills in one paragraph, such as $browser and $deploy, are consumed as one math span, so skill chips never render. Escaping the dollars avoids math but also prevents SkillInlineText from matching the tokens.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 87d0562. Configure here.

remarkGithubAlerts,
remarkNormalizeListItemIndentation,
remarkCodexDirectives,
Expand All @@ -413,10 +418,15 @@ const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [
remarkNormalizeLinksAndTagInlineCode,
] satisfies NonNullable<ReactMarkdownOptions["remarkPlugins"]>;

const CHAT_MARKDOWN_REHYPE_PLUGINS = [
const CHAT_MARKDOWN_REHYPE_PLUGINS = [rehypeKatex] satisfies NonNullable<
ReactMarkdownOptions["rehypePlugins"]
>;

const CHAT_MARKDOWN_RAW_HTML_REHYPE_PLUGINS = [
rehypeRaw,
rehypePreserveImageSourceMeta,
[rehypeSanitize, CHAT_MARKDOWN_SANITIZE_SCHEMA],
rehypeKatex,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copied math becomes garbled text

Medium Severity

KaTeX emits MathML plus an aria-hidden HTML span. Selection copy already skips aria-hidden nodes and then concatenates MathML character nodes with the TeX annotation, so highlight-and-copy of a rendered equation pastes duplicated, unreadable text instead of the LaTeX source or a single readable form.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 87d0562. Configure here.

] satisfies NonNullable<ReactMarkdownOptions["rehypePlugins"]>;

/** GitHub's own five alert kinds, in its colors: the glyph names the urgency, the title says it. */
Expand Down Expand Up @@ -2908,6 +2918,7 @@ function ChatMarkdown({
],
[extraRemarkPlugins, lineBreaks],
);
const normalizedText = useMemo(() => normalizeLatexDelimiters(text), [text]);

// react-markdown converts unparsed HTML nodes to text when skipHtml is false.
// Keep that behavior explicit because literal mode depends on escaping the
Expand All @@ -2923,12 +2934,14 @@ function ChatMarkdown({
<ChatMarkdownRendererContext value={componentState}>
<ReactMarkdown
remarkPlugins={remarkPlugins}
rehypePlugins={parseRawHtml ? CHAT_MARKDOWN_REHYPE_PLUGINS : undefined}
rehypePlugins={
parseRawHtml ? CHAT_MARKDOWN_RAW_HTML_REHYPE_PLUGINS : CHAT_MARKDOWN_REHYPE_PLUGINS
}
skipHtml={false}
components={CHAT_MARKDOWN_COMPONENTS}
urlTransform={markdownUrlTransform}
>
{text}
{normalizedText}
</ReactMarkdown>
</ChatMarkdownRendererContext>
{localMediaPreview ? (
Expand Down
7 changes: 7 additions & 0 deletions apps/web/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -1668,6 +1668,13 @@ code {
color: var(--contrast-muted-foreground);
}

.chat-markdown .katex-display {
margin: 0.65rem 0;
overflow-x: auto;
overflow-y: hidden;
padding-block: 0.15rem;
}

.chat-markdown section[data-footnotes] {
margin-top: 1.25rem;
border-top: 1px solid var(--contrast-border);
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React from "react";
import ReactDOM from "react-dom/client";
import { createHashHistory, createBrowserHistory } from "@tanstack/react-router";

import "katex/dist/katex.min.css";
import "./index.css";

import { isElectron } from "./env";
Expand Down
28 changes: 28 additions & 0 deletions apps/web/src/markdown-latex.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vite-plus/test";
import { normalizeLatexDelimiters } from "./markdown-latex";

describe("normalizeLatexDelimiters", () => {
it("normalizes parenthesis and bracket delimiters without shifting source offsets", () => {
const markdown = "Inline \\(x\\)\n\\[\ny\n\\]\n- [ ] task";
const normalized = normalizeLatexDelimiters(markdown);

expect(normalized).toBe("Inline $$x$$\n$$\ny\n$$\n- [ ] task");
expect(normalized).toHaveLength(markdown.length);
});

it("leaves escaped delimiters and code unchanged", () => {
const markdown = [
String.raw`Literal \\(x\\) and \[math\].`,
"Inline code: `\\(code\\)`.",
"",
"~~~text",
String.raw`\[fenced\]`,
"~~~",
String.raw` \(indented\)`,
].join("\n");

expect(normalizeLatexDelimiters(markdown)).toBe(
markdown.replace(String.raw`\[math\]`, () => "$$math$$"),
);
});
});
78 changes: 78 additions & 0 deletions apps/web/src/markdown-latex.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
const LATEX_DELIMITERS = ["\\(", "\\)", "\\[", "\\]"] as const;

function isEscaped(value: string, index: number): boolean {
let slashCount = 0;
for (let cursor = index - 1; cursor >= 0 && value[cursor] === "\\"; cursor -= 1) {
slashCount += 1;
}
return slashCount % 2 === 1;
}

function startingFence(line: string): { marker: "`" | "~"; length: number } | null {
const match = /^ {0,3}(`{3,}|~{3,})/.exec(line);
const run = match?.[1];
if (!run) return null;
return { marker: run[0] as "`" | "~", length: run.length };
Comment on lines +12 to +15

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium src/markdown-latex.ts:12

startingFence treats a backtick fence with backticks in its info string as valid, so a line such as `````text `` enters fence mode and leaves subsequent valid(...)` text unnormalized until a closing-looking fence appears. Validate the info string and reject backticks for backtick fences.

Suggested change
const match = /^ {0,3}(`{3,}|~{3,})/.exec(line);
const run = match?.[1];
if (!run) return null;
return { marker: run[0] as "`" | "~", length: run.length };
const match = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line);
const run = match?.[1];
if (!run || (run[0] === "`" && match[2].includes("`"))) return null;
return { marker: run[0] as "`" | "~", length: run.length };
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/markdown-latex.ts around lines 12-15:

`startingFence` treats a backtick fence with backticks in its info string as valid, so a line such as [code fence]``text ` `` enters fence mode and leaves subsequent valid `\(...\)` text unnormalized until a closing-looking fence appears. Validate the info string and reject backticks for backtick fences.

}

function closesFence(line: string, fence: { marker: "`" | "~"; length: number }): boolean {
const match = /^ {0,3}(`+|~+)[ \t]*$/.exec(line);
const run = match?.[1];
return Boolean(run && run[0] === fence.marker && run.length >= fence.length);
}

/** Converts TeX delimiters before Markdown consumes their leading backslashes. */
export function normalizeLatexDelimiters(markdown: string): string {
if (!LATEX_DELIMITERS.some((delimiter) => markdown.includes(delimiter))) return markdown;

let fence: { marker: "`" | "~"; length: number } | null = null;
let inlineCodeTicks = 0;

return markdown
.split(/(\r?\n)/)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium src/markdown-latex.ts:32

CR-only input is treated as a single line, so fenced code is never recognized and \(example\) inside the fence is rewritten to $$example$$. Include lone \r as a line separator so fence tracking applies to Markdown messages using CR line endings.

-    .split(/(\r?\n)/)
+    .split(/(\r\n|\n|\r)/)
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/markdown-latex.ts around line 32:

CR-only input is treated as a single line, so fenced code is never recognized and `\(example\)` inside the fence is rewritten to `$$example$$`. Include lone `\r` as a line separator so fence tracking applies to Markdown messages using CR line endings.

.map((line) => {
if (line === "\n" || line === "\r\n") return line;

if (fence) {
if (closesFence(line, fence)) fence = null;
return line;
}

if (inlineCodeTicks === 0) {
const openingFence = startingFence(line);
if (openingFence) {
fence = openingFence;
return line;
}
if (/^(?: {4}|\t)/.test(line)) return line;
}

let normalized = "";
for (let index = 0; index < line.length; index += 1) {
const character = line[index];

if (character === "`" && !isEscaped(line, index)) {
let runLength = 1;
while (line[index + runLength] === "`") runLength += 1;
if (inlineCodeTicks === 0) inlineCodeTicks = runLength;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium src/markdown-latex.ts:57

An unmatched backtick sets inlineCodeTicks at line 57 and leaves it enabled for the rest of the message, so later delimiters such as \(x\) remain unchanged and are not rendered as math. CommonMark only creates a code span when an equal-length closing run exists; track unmatched backticks without suppressing subsequent lines, or reset the state when no matching closer exists.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/markdown-latex.ts around line 57:

An unmatched backtick sets `inlineCodeTicks` at line 57 and leaves it enabled for the rest of the message, so later delimiters such as `\(x\)` remain unchanged and are not rendered as math. CommonMark only creates a code span when an equal-length closing run exists; track unmatched backticks without suppressing subsequent lines, or reset the state when no matching closer exists.

else if (inlineCodeTicks === runLength) inlineCodeTicks = 0;
normalized += "`".repeat(runLength);
index += runLength - 1;
continue;
}

if (inlineCodeTicks === 0 && character === "\\" && !isEscaped(line, index)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium src/markdown-latex.ts:64

normalizeLatexDelimiters changes literal TeX delimiters inside authored raw HTML code elements, so <pre>\(example\)</pre> becomes <pre>$$example$$</pre> and the displayed code no longer matches the message source. Because line 64 scans raw HTML before rehypeRaw parses it, exclude the contents of <pre>/<code> raw HTML elements from normalization (while preserving the existing Markdown code handling).

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/markdown-latex.ts around line 64:

`normalizeLatexDelimiters` changes literal TeX delimiters inside authored raw HTML code elements, so `<pre>\(example\)</pre>` becomes `<pre>$$example$$</pre>` and the displayed code no longer matches the message source. Because line 64 scans raw HTML before `rehypeRaw` parses it, exclude the contents of `<pre>`/`<code>` raw HTML elements from normalization (while preserving the existing Markdown code handling).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium src/markdown-latex.ts:64

The normalizer rewrites \(example\) inside block quotes and list-nested indented code to $$example$$, changing code that must remain literal. startingFence and the indented-code guard only inspect the physical line start, so container prefixes prevent fence and code-block detection; please account for Markdown container prefixes before normalizing delimiters.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/markdown-latex.ts around line 64:

The normalizer rewrites `\(example\)` inside block quotes and list-nested indented code to `$$example$$`, changing code that must remain literal. `startingFence` and the indented-code guard only inspect the physical line start, so container prefixes prevent `fence` and code-block detection; please account for Markdown container prefixes before normalizing delimiters.

const delimiter = line[index + 1];
if (delimiter === "(" || delimiter === ")" || delimiter === "[" || delimiter === "]") {
normalized += "$$";
index += 1;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium src/markdown-latex.ts:68

A same-line \[...\] expression is returned as $$...$$ inside the surrounding paragraph, so remark-math parses it as math-inline and renders it inline instead of as a display equation. Add paragraph boundaries around the [ and ] delimiters so the converted expression is parsed as block math.

-            normalized += "$$";
+            normalized += delimiter === "[" ? "\n\n$$" : delimiter === "]" ? "$$\n\n" : "$$";
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/markdown-latex.ts around line 68:

A same-line `\[...\]` expression is returned as `$$...$$` inside the surrounding paragraph, so `remark-math` parses it as `math-inline` and renders it inline instead of as a display equation. Add paragraph boundaries around the `[` and `]` delimiters so the converted expression is parsed as block math.

continue;
}
}

normalized += character;
}
return normalized;
})
.join("");
}
16 changes: 16 additions & 0 deletions docs/user/composer.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,22 @@ File links refer to the environment's machine, including when you connect remote
Previews use the original file, even outside the workspace. Moving or deleting it
can break the preview, so save a copy if you need to keep it.

## Math in messages

Web and desktop render LaTeX in messages. Inline math accepts `$...$` or `\(...\)`. Display math
accepts `$$...$$` or `\[...\]`:

```markdown
Euler's identity is $e^{i\pi} + 1 = 0$.

$$
\int_0^1 x^2\,dx = \frac{1}{3}
$$
```

Escape a dollar sign as `\$` when two prices or other dollar amounts in the same paragraph could
look like inline math. Mobile currently displays the source text instead.

## Files outside the workspace

Follow an agent's file link to read a report or other file outside the workspace.
Expand Down
Loading
Loading