Skip to content
Closed
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
88 changes: 88 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,94 @@ describe("ChatMarkdown streaming", () => {
});
});

describe("ChatMarkdown math", () => {
it("keeps known skill tokens available for inline skill chips", () => {
const html = renderToStaticMarkup(
<ChatMarkdown
cwd="/tmp/project"
skills={[
{ name: "browser", displayName: "Browser" },
{ name: "deploy", displayName: "Deploy" },
]}
text="Use $browser and $deploy"
/>,
);

expect(html.match(/data-markdown-copy="\$(?:browser|deploy)"/g)).toHaveLength(2);
expect(html).toContain("Browser");
expect(html).toContain("Deploy");
});

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
89 changes: 84 additions & 5 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 @@ -239,6 +242,53 @@ export function shouldUseMarkdownFileBrowserPrimaryAction(input: {
const EMPTY_MARKDOWN_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = [];
const EMPTY_REMARK_PLUGINS: NonNullable<ReactMarkdownOptions["remarkPlugins"]> = [];

type MarkdownTreeNode = {
type?: string;
value?: string;
children?: MarkdownTreeNode[];
properties?: Record<string, unknown>;
};

function restoreProtectedSkillTokens() {
return (tree: MarkdownTreeNode) => {
const visit = (node: MarkdownTreeNode): void => {
if (node.type === "text" && node.value) node.value = node.value.replaceAll("\uE000", "$");
node.children?.forEach(visit);
};
visit(tree);
};
}

function addMathMarkdownCopy() {
return (tree: MarkdownTreeNode) => {
const findSource = (node: MarkdownTreeNode): string | undefined => {
if (node.type === "element" && node.properties?.encoding === "application/x-tex") {
return node.children.find((child) => child.type === "text")?.value;
}
for (const child of node.children ?? []) {
const source = findSource(child);
if (source) return source;
}
return undefined;
};
const visit = (node: MarkdownTreeNode): void => {
if (node.type === "element") {
const classes = Array.isArray(node.properties?.className)
? node.properties.className
: typeof node.properties?.className === "string"
? node.properties.className.split(/\s+/)
: [];
if (classes.includes("katex")) {
const source = findSource(node);
if (source) node.properties!["data-markdown-copy"] = `\\(${source}\\)`;
}
}
node.children?.forEach(visit);
};
visit(tree);
};
}

const ARTIFACT_TEMPLATE_ICON_BY_KIND = {
document: FileTextIcon,
presentation: PresentationIcon,
Expand Down Expand Up @@ -396,6 +446,7 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = {

const CHAT_MARKDOWN_REMARK_PLUGINS = [
remarkGfm,
remarkMath,
remarkGithubAlerts,
remarkNormalizeListItemIndentation,
remarkCodexDirectives,
Expand All @@ -405,6 +456,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 +465,16 @@ const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [
remarkNormalizeLinksAndTagInlineCode,
] satisfies NonNullable<ReactMarkdownOptions["remarkPlugins"]>;

const CHAT_MARKDOWN_REHYPE_PLUGINS = [
const CHAT_MARKDOWN_REHYPE_PLUGINS = [rehypeKatex, addMathMarkdownCopy] 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.

addMathMarkdownCopy,
] 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 @@ -2903,11 +2961,30 @@ function ChatMarkdown({
} = useChatMarkdownState({ text, ...props });
const remarkPlugins = useMemo(
() => [
...(lineBreaks ? CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS : CHAT_MARKDOWN_REMARK_PLUGINS),
...(lineBreaks
? [
remarkGfm,
remarkMath,
restoreProtectedSkillTokens,
...CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS.slice(3),
]
: [
remarkGfm,
remarkMath,
restoreProtectedSkillTokens,
...CHAT_MARKDOWN_REMARK_PLUGINS.slice(3),
]),
...extraRemarkPlugins,
],
[extraRemarkPlugins, lineBreaks],
[extraRemarkPlugins, lineBreaks, props.skills],
);
const normalizedText = useMemo(() => {
const names = new Set((props.skills ?? []).map((skill) => skill.name));
return normalizeLatexDelimiters(text).replace(
/(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s|$)/g,
(match, prefix: string, name: string) => (names.has(name) ? `${prefix}\uE000${name}` : match),
);
}, [text, props.skills]);

// 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 +3000,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
14 changes: 14 additions & 0 deletions apps/web/src/markdown-clipboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,20 @@ describe("serializeRenderedMarkdownFragment", () => {
expect(serializeRenderedMarkdownFragment(asNode(container))).toBe("run `git status` first");
});

it("copies rendered math from its markdown-copy value", () => {
const container = new FakeElement("DIV").append(
new FakeElement("P").append(
new FakeText("Solve "),
new FakeElement("SPAN", [], { "data-markdown-copy": String.raw`\(x^2\)` }),
new FakeText(" now"),
),
);

expect(serializeRenderedMarkdownFragment(asNode(container))).toBe(
String.raw`Solve \(x^2\) now`,
);
});

it("copies the complete quote, source, and comment instead of the comment-only chip label", () => {
const citation = {
version: 1 as const,
Expand Down
45 changes: 45 additions & 0 deletions apps/web/src/markdown-latex.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
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\n\n$$\ny\n$$\n\n\n- [ ] task");
});

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\]`, () => "\n\n$$math$$\n\n"),
);
});

it("handles CR-only fences and container-prefixed code", () => {
const markdown = "```text\r\\(fenced\\)\r```\r\r> \\(quoted code\\)\r\rAfter \\(x\\)";
expect(normalizeLatexDelimiters(markdown)).toBe(
markdown.replace(String.raw`\(x\)`, () => "$$x$$"),
);
});

it("does not normalize raw HTML code elements or malformed fences", () => {
const markdown = "<pre>\\(raw\\)</pre>\n```text `\n\\(not fenced\\)\nAfter \\(x\\)";
expect(normalizeLatexDelimiters(markdown)).toBe(
"<pre>\\(raw\\)</pre>\n```text `\n$$not fenced$$\nAfter $$x$$",
);
});

it("does not let an unmatched inline code opener hide later math", () => {
expect(normalizeLatexDelimiters("` unmatched\nLater \\(x\\)")).toBe("` unmatched\nLater $$x$$");
});
});
Loading
Loading