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
5 changes: 5 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,16 +38,21 @@
"jose": "catalog:",
"jsonc-parser": "3.3.1",
"jszip": "3.10.1",
"katex": "^0.16.22",
"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",
"remark-parse": "^11.0.0",
"tailwind-merge": "^3.4.0",
"unified": "^11.0.5",
"zustand": "^5.0.11"
},
"devDependencies": {
Expand Down
69 changes: 48 additions & 21 deletions apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,12 @@ 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 remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import { remarkGithubAlerts } from "../markdown-github-alerts";
import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText";
import { CHAT_FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip";
Expand Down Expand Up @@ -88,6 +90,12 @@ import {
serializeTableElementToMarkdown,
} from "../markdown-clipboard";
import { remarkNormalizeListItemIndentation } from "../markdown-list-indentation";
import {
MARKDOWN_MATH_CODE_CLASS_NAMES,
normalizeLatexMathDelimiters,
rehypeStripKatexErrorTitle,
remarkPromoteBracketDisplayMath,
} from "../markdown-math";
import {
extractMarkdownLinkHrefs,
normalizeMarkdownLinkDestination,
Expand Down Expand Up @@ -272,7 +280,14 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = {
attributes: {
...defaultSchema.attributes,
"*": (defaultSchema.attributes?.["*"] ?? []).filter((attribute) => attribute !== "title"),
code: [...(defaultSchema.attributes?.code ?? []), "dataCodeMeta", "dataInlineCode"],
code: [
...(defaultSchema.attributes?.code ?? []).filter(
(attribute) => !Array.isArray(attribute) || attribute[0] !== "className",
),
["className", /^language-./, ...MARKDOWN_MATH_CODE_CLASS_NAMES],
"dataCodeMeta",
"dataInlineCode",
],
blockquote: [...(defaultSchema.attributes?.blockquote ?? []), "dataAlert"],
},
protocols: {
Expand All @@ -282,27 +297,22 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = {
},
} satisfies Parameters<typeof rehypeSanitize>[0];

const CHAT_MARKDOWN_REMARK_PLUGINS = [
remarkGfm,
remarkGithubAlerts,
remarkNormalizeListItemIndentation,
remarkPreserveCodeMeta,
remarkNormalizeLinksAndTagInlineCode,
] satisfies NonNullable<ReactMarkdownOptions["remarkPlugins"]>;

Comment thread
cursor[bot] marked this conversation as resolved.
const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [
remarkGfm,
remarkGithubAlerts,
remarkNormalizeListItemIndentation,
remarkBreaks,
remarkPreserveCodeMeta,
remarkNormalizeLinksAndTagInlineCode,
] satisfies NonNullable<ReactMarkdownOptions["remarkPlugins"]>;
const CHAT_MARKDOWN_KATEX_OPTIONS = {
output: "htmlAndMathml",
errorColor: "var(--destructive)",
} as const;

const CHAT_MARKDOWN_REHYPE_PLUGINS = [
rehypeRaw,
rehypeNormalizeWindowsImageSrc,
[rehypeSanitize, CHAT_MARKDOWN_SANITIZE_SCHEMA],
[rehypeKatex, CHAT_MARKDOWN_KATEX_OPTIONS],
rehypeStripKatexErrorTitle,
] satisfies NonNullable<ReactMarkdownOptions["rehypePlugins"]>;

const CHAT_MARKDOWN_REHYPE_PLUGINS_WITHOUT_RAW_HTML = [
[rehypeKatex, CHAT_MARKDOWN_KATEX_OPTIONS],
rehypeStripKatexErrorTitle,
] 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 @@ -1695,6 +1705,21 @@ function ChatMarkdown({
[environmentId, openInEditor],
);
const diffThemeName = resolveDiffThemeName(resolvedTheme);
const markdownSource = useMemo(() => normalizeLatexMathDelimiters(text), [text]);
const remarkPlugins = useMemo(
() =>
[
remarkGfm,
[remarkMath, { singleDollarTextMath: false }],
[remarkPromoteBracketDisplayMath, { source: text }],
remarkGithubAlerts,
remarkNormalizeListItemIndentation,
...(lineBreaks ? [remarkBreaks] : []),
remarkPreserveCodeMeta,
remarkNormalizeLinksAndTagInlineCode,
] satisfies NonNullable<ReactMarkdownOptions["remarkPlugins"]>,
[lineBreaks, text],
);
const markdownFileLinkMetaByHref = useMemo(() => {
const metaByHref = new Map<
string,
Expand Down Expand Up @@ -2245,15 +2270,17 @@ function ChatMarkdown({
onCopy={handleCopy}
>
<ReactMarkdown
remarkPlugins={
lineBreaks ? CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS : CHAT_MARKDOWN_REMARK_PLUGINS
remarkPlugins={remarkPlugins}
rehypePlugins={
parseRawHtml
? CHAT_MARKDOWN_REHYPE_PLUGINS
: CHAT_MARKDOWN_REHYPE_PLUGINS_WITHOUT_RAW_HTML
}
rehypePlugins={parseRawHtml ? CHAT_MARKDOWN_REHYPE_PLUGINS : undefined}
skipHtml={false}
components={markdownComponents}
urlTransform={markdownUrlTransform}
>
{text}
{markdownSource}

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.

With output: "htmlAndMathml", KaTeX emits a .katex-mathml subtree that is visually clipped but not aria-hidden, while .katex-html is aria-hidden. The onCopy handler on this container serializes the selection through markdown-clipboard.ts, which skips aria-hidden nodes — so copying a message with math keeps only the MathML branch and yields its glyph text plus the duplicated annotation TeX, with no $/$$ delimiters, breaking the markdown round-trip this serializer exists for.

Suggest teaching the serializer about math: on an element with the katex class, read annotation[encoding="application/x-tex"] and emit $…$ (or $$…$$ inside .katex-display) instead of serializing children.

Posted via Macroscope — UI Consistency

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in a0f1693e. The clipboard serializer now recognizes inline and display KaTeX, reads the application/x-tex annotation, and emits explicit LaTeX delimiters. I used \(…\) and \[...\] rather than single-dollar syntax because single-dollar math is now intentionally disabled. Added round-trip tests for both forms.

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.

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

</ReactMarkdown>
</div>
);
Expand Down
33 changes: 32 additions & 1 deletion apps/web/src/index.css
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
@import "tailwindcss";
@import "katex/dist/katex.min.css";

@custom-variant dark (&:is(.dark, .dark *));
@custom-variant light (&:not(.dark, .dark *));
Expand Down Expand Up @@ -2117,10 +2118,40 @@ code {
.chat-markdown ol,
.chat-markdown blockquote,
.chat-markdown pre,
.chat-markdown .chat-markdown-table-container {
.chat-markdown .chat-markdown-table-container,
.chat-markdown .katex-display {
margin: 0.65rem 0;
}

.chat-markdown .katex-display,
.chat-markdown :not(.katex-display) > .katex {
max-width: 100%;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: thin;
scrollbar-color: color-mix(in srgb, var(--contrast-border) 78%, transparent) transparent;
}

.chat-markdown :not(.katex-display) > .katex {
display: inline-block;
vertical-align: middle;
}

.chat-markdown .katex-display > .katex {
text-align: left;
}

.chat-markdown .katex-display::-webkit-scrollbar,
.chat-markdown :not(.katex-display) > .katex::-webkit-scrollbar {
height: 7px;
}

.chat-markdown .katex-display::-webkit-scrollbar-thumb,
.chat-markdown :not(.katex-display) > .katex::-webkit-scrollbar-thumb {
border-radius: 999px;
background: color-mix(in srgb, var(--contrast-border) 78%, transparent);
}

.chat-markdown h1,
.chat-markdown h2,
.chat-markdown h3,
Expand Down
74 changes: 71 additions & 3 deletions apps/web/src/markdown-clipboard.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";

import { serializeRenderedMarkdownFragment } from "./markdown-clipboard";
import {
prepareKatexHtmlForClipboard,
serializeRenderedMarkdownFragment,
} from "./markdown-clipboard";

const TEXT_NODE = 3;
const ELEMENT_NODE = 1;
Expand All @@ -22,6 +25,7 @@ class FakeElement {
constructor(
readonly tagName: string,
private readonly classNames: ReadonlyArray<string> = [],
private readonly attributes: Readonly<Record<string, string>> = {},
) {}

get localName(): string {
Expand All @@ -37,13 +41,30 @@ class FakeElement {
return this;
}

getAttribute(): string | null {
return null;
getAttribute(name: string): string | null {
return this.attributes[name] ?? null;
}

hasAttribute(): boolean {
return false;
}

querySelector(selector: string): FakeElement | null {
if (
selector === 'annotation[encoding="application/x-tex"]' &&
this.tagName === "ANNOTATION" &&
this.getAttribute("encoding") === "application/x-tex"
) {
return this;
}
for (const child of this.childNodes) {
if (child instanceof FakeElement) {
const match = child.querySelector(selector);
if (match) return match;
}
}
return null;
}
}

function asNode(element: FakeElement): Node {
Expand Down Expand Up @@ -92,4 +113,51 @@ describe("serializeRenderedMarkdownFragment", () => {

expect(serializeRenderedMarkdownFragment(asNode(container))).toBe("first line\nsecond line");
});

it("serializes inline KaTeX back to explicit LaTeX delimiters", () => {
const annotation = new FakeElement("ANNOTATION", [], {
encoding: "application/x-tex",
}).append(new FakeText("e^{i\\pi} + 1 = 0"));
const math = new FakeElement("SPAN", ["katex"]).append(annotation);
const container = new FakeElement("DIV").append(new FakeText("Euler: "), math);

expect(serializeRenderedMarkdownFragment(asNode(container))).toBe(
"Euler: \\(e^{i\\pi} + 1 = 0\\)",
);
});

it("serializes display KaTeX back to explicit LaTeX delimiters", () => {
const annotation = new FakeElement("ANNOTATION", [], {
encoding: "application/x-tex",
}).append(new FakeText("A_t = \\lambda_t A_t^{\\text{local}}"));
const math = new FakeElement("SPAN", ["katex"]).append(annotation);
const display = new FakeElement("SPAN", ["katex-display"]).append(math);
const container = new FakeElement("DIV").append(display);

expect(serializeRenderedMarkdownFragment(asNode(container))).toBe(
"\\[\nA_t = \\lambda_t A_t^{\\text{local}}\n\\]",
);
});
});

describe("prepareKatexHtmlForClipboard", () => {
it("keeps the visual KaTeX branch and removes duplicate MathML", () => {
const removeMathml = vi.fn();
const revealHtml = vi.fn();
const katex = {
querySelector: vi.fn((selector: string) => {
if (selector === ":scope > .katex-mathml") return { remove: removeMathml };
if (selector === ":scope > .katex-html") return { removeAttribute: revealHtml };
return null;
}),
};
const container = {
querySelectorAll: vi.fn(() => [katex]),
};

prepareKatexHtmlForClipboard(container as unknown as Element);

expect(removeMathml).toHaveBeenCalledOnce();
expect(revealHtml).toHaveBeenCalledWith("aria-hidden");
});
});
23 changes: 23 additions & 0 deletions apps/web/src/markdown-clipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,14 @@ function serializeAnchor(anchor: Element): string {
return `[${label}](${href})`;
}

function serializeKatex(element: Element, display: boolean): string {
Comment thread
cursor[bot] marked this conversation as resolved.
const tex = element
.querySelector('annotation[encoding="application/x-tex"]')
?.textContent?.trim();
if (!tex) return "";
return display ? `\\[\n${tex}\n\\]\n\n` : `\\(${tex}\\)`;
}

function serializeChildren(node: Node): string {
let out = "";
for (const child of node.childNodes) {
Expand All @@ -196,6 +204,12 @@ function serializeNode(node: Node): string {
}
if (node.nodeType !== Node.ELEMENT_NODE) return "";
const element = node as Element;
if (element.classList.contains("katex-display")) {
return serializeKatex(element, true);
}
if (element.classList.contains("katex")) {
return serializeKatex(element, false);
}
if (element.hasAttribute("data-markdown-details")) {
return serializeDetails(element);
}
Expand Down Expand Up @@ -293,7 +307,16 @@ export function serializeTableElementToCsv(table: Element): string {
return lines.join("\n");
}

/** Keeps KaTeX's visual branch usable when rich-paste targets prefer HTML. */
export function prepareKatexHtmlForClipboard(container: Element): void {
for (const katex of container.querySelectorAll(".katex")) {
katex.querySelector(":scope > .katex-mathml")?.remove();
katex.querySelector(":scope > .katex-html")?.removeAttribute("aria-hidden");
}
}

function sanitizedHtmlFrom(container: Element): string {
prepareKatexHtmlForClipboard(container);
for (const node of container.querySelectorAll(SANITIZED_HTML_SELECTOR)) {
if (
node.classList.contains("chat-markdown-file-link") ||
Expand Down
8 changes: 7 additions & 1 deletion apps/web/src/markdown-list-indentation.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { normalizeLatexMathDelimiters, remarkPromoteBracketDisplayMath } from "./markdown-math";

interface MarkdownPosition {
readonly start?: {
readonly line?: number;
Expand Down Expand Up @@ -93,7 +95,11 @@ function parseRecoveredMarkdown(value: string, parser: MarkdownParser): Recovere

function blocksFromIndentedCode(node: MarkdownAstNode, parser: MarkdownParser): RecoveredMarkdown {
const value = typeof node.value === "string" ? node.value.trim() : "";
const recovered = parseRecoveredMarkdown(value, parser);
const recovered = parseRecoveredMarkdown(normalizeLatexMathDelimiters(value), parser);
remarkPromoteBracketDisplayMath({ source: `${INLINE_PARSE_PREFIX}${value}` })({
type: "root",
children: recovered.blocks,
});
const first = recovered.blocks[0];
return {
...recovered,
Expand Down
Loading
Loading