feat: add KaTeX rendering to Markdown (#3868)

This commit is contained in:
Carlos Florêncio
2026-09-22 23:36:47 +01:00
committed by GitHub
parent 5b30ef4412
commit c4262c644d
38 changed files with 1821 additions and 29 deletions
+134
View File
@@ -465,6 +465,9 @@ importers:
input-otp:
specifier: ^1.4.2
version: 1.4.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
katex:
specifier: ^0.16.47
version: 0.16.47
lowlight:
specifier: ^3.3.0
version: 3.3.0
@@ -498,6 +501,9 @@ importers:
recharts:
specifier: 2.15.4
version: 2.15.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
rehype-katex:
specifier: ^7.0.1
version: 7.0.1
rehype-raw:
specifier: ^7.0.0
version: 7.0.0
@@ -513,6 +519,9 @@ importers:
remark-gfm:
specifier: ^4.0.1
version: 4.0.1
remark-math:
specifier: ^6.0.0
version: 6.0.0
shiki:
specifier: ^3.22.0
version: 3.22.0
@@ -553,6 +562,9 @@ importers:
'@types/diff':
specifier: ^8.0.0
version: 8.0.0
'@types/katex':
specifier: ^0.16.8
version: 0.16.8
'@types/node':
specifier: ^20
version: 20.19.28
@@ -3018,6 +3030,9 @@ packages:
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
'@types/katex@0.16.8':
resolution: {integrity: sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==}
'@types/linkify-it@3.0.5':
resolution: {integrity: sha512-yg6E+u0/+Zjva+buc3EIb+29XEg4wltq7cSmd4Uc2EE/1nUVmxyzpX6gUXD0V8jIrG0r7YeOGVIbYRkxeooCtw==}
@@ -3972,9 +3987,21 @@ packages:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
hast-util-from-dom@5.0.1:
resolution: {integrity: sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==}
hast-util-from-html-isomorphic@2.0.0:
resolution: {integrity: sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw==}
hast-util-from-html@2.0.3:
resolution: {integrity: sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==}
hast-util-from-parse5@8.0.3:
resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==}
hast-util-is-element@3.0.0:
resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==}
hast-util-parse-selector@4.0.0:
resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==}
@@ -3993,6 +4020,9 @@ packages:
hast-util-to-parse5@8.0.1:
resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==}
hast-util-to-text@4.0.2:
resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==}
hast-util-whitespace@3.0.0:
resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
@@ -4448,6 +4478,9 @@ packages:
mdast-util-gfm@3.1.0:
resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==}
mdast-util-math@3.0.0:
resolution: {integrity: sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==}
mdast-util-mdx-expression@2.0.1:
resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==}
@@ -4510,6 +4543,9 @@ packages:
micromark-extension-gfm@3.0.0:
resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==}
micromark-extension-math@3.1.0:
resolution: {integrity: sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==}
micromark-factory-destination@2.0.1:
resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==}
@@ -4940,6 +4976,9 @@ packages:
resolution: {integrity: sha512-sZuz1dYW/ZsfG17WSAG7eS85r5a0dDsvg+7BiiYR5o6lKCAtUrEwdmRmaGF6rwVj3LcmAeYkOWKEPlbPzN3Y3A==}
engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
rehype-katex@7.0.1:
resolution: {integrity: sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==}
rehype-raw@7.0.0:
resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==}
@@ -4955,6 +4994,9 @@ packages:
remark-gfm@4.0.1:
resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}
remark-math@6.0.0:
resolution: {integrity: sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==}
remark-parse@11.0.0:
resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==}
@@ -5244,12 +5286,18 @@ packages:
unified@11.0.5:
resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
unist-util-find-after@5.0.0:
resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==}
unist-util-is@6.0.1:
resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==}
unist-util-position@5.0.0:
resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==}
unist-util-remove-position@5.0.0:
resolution: {integrity: sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==}
unist-util-stringify-position@4.0.0:
resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==}
@@ -7980,6 +8028,8 @@ snapshots:
'@types/json-schema@7.0.15': {}
'@types/katex@0.16.8': {}
'@types/linkify-it@3.0.5': {}
'@types/linkify-it@5.0.0': {}
@@ -8997,6 +9047,28 @@ snapshots:
has-flag@4.0.0: {}
hast-util-from-dom@5.0.1:
dependencies:
'@types/hast': 3.0.4
hastscript: 9.0.1
web-namespaces: 2.0.1
hast-util-from-html-isomorphic@2.0.0:
dependencies:
'@types/hast': 3.0.4
hast-util-from-dom: 5.0.1
hast-util-from-html: 2.0.3
unist-util-remove-position: 5.0.0
hast-util-from-html@2.0.3:
dependencies:
'@types/hast': 3.0.4
devlop: 1.1.0
hast-util-from-parse5: 8.0.3
parse5: 7.3.0
vfile: 6.0.3
vfile-message: 4.0.3
hast-util-from-parse5@8.0.3:
dependencies:
'@types/hast': 3.0.4
@@ -9008,6 +9080,10 @@ snapshots:
vfile-location: 5.0.3
web-namespaces: 2.0.1
hast-util-is-element@3.0.0:
dependencies:
'@types/hast': 3.0.4
hast-util-parse-selector@4.0.0:
dependencies:
'@types/hast': 3.0.4
@@ -9078,6 +9154,13 @@ snapshots:
web-namespaces: 2.0.1
zwitch: 2.0.4
hast-util-to-text@4.0.2:
dependencies:
'@types/hast': 3.0.4
'@types/unist': 3.0.3
hast-util-is-element: 3.0.0
unist-util-find-after: 5.0.0
hast-util-whitespace@3.0.0:
dependencies:
'@types/hast': 3.0.4
@@ -9497,6 +9580,18 @@ snapshots:
transitivePeerDependencies:
- supports-color
mdast-util-math@3.0.0:
dependencies:
'@types/hast': 3.0.4
'@types/mdast': 4.0.4
devlop: 1.1.0
longest-streak: 3.1.0
mdast-util-from-markdown: 2.0.2
mdast-util-to-markdown: 2.1.2
unist-util-remove-position: 5.0.0
transitivePeerDependencies:
- supports-color
mdast-util-mdx-expression@2.0.1:
dependencies:
'@types/estree-jsx': 1.0.5
@@ -9681,6 +9776,16 @@ snapshots:
micromark-util-combine-extensions: 2.0.1
micromark-util-types: 2.0.2
micromark-extension-math@3.1.0:
dependencies:
'@types/katex': 0.16.8
devlop: 1.1.0
katex: 0.16.47
micromark-factory-space: 2.0.1
micromark-util-character: 2.1.1
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
micromark-factory-destination@2.0.1:
dependencies:
micromark-util-character: 2.1.1
@@ -10317,6 +10422,16 @@ snapshots:
'@eslint-community/regexpp': 4.12.2
refa: 0.12.1
rehype-katex@7.0.1:
dependencies:
'@types/hast': 3.0.4
'@types/katex': 0.16.8
hast-util-from-html-isomorphic: 2.0.0
hast-util-to-text: 4.0.2
katex: 0.16.47
unist-util-visit-parents: 6.0.2
vfile: 6.0.3
rehype-raw@7.0.0:
dependencies:
'@types/hast': 3.0.4
@@ -10351,6 +10466,15 @@ snapshots:
transitivePeerDependencies:
- supports-color
remark-math@6.0.0:
dependencies:
'@types/mdast': 4.0.4
mdast-util-math: 3.0.0
micromark-extension-math: 3.1.0
unified: 11.0.5
transitivePeerDependencies:
- supports-color
remark-parse@11.0.0:
dependencies:
'@types/mdast': 4.0.4
@@ -10635,6 +10759,11 @@ snapshots:
trough: 2.2.0
vfile: 6.0.3
unist-util-find-after@5.0.0:
dependencies:
'@types/unist': 3.0.3
unist-util-is: 6.0.1
unist-util-is@6.0.1:
dependencies:
'@types/unist': 3.0.3
@@ -10643,6 +10772,11 @@ snapshots:
dependencies:
'@types/unist': 3.0.3
unist-util-remove-position@5.0.0:
dependencies:
'@types/unist': 3.0.3
unist-util-visit: 5.0.0
unist-util-stringify-position@4.0.0:
dependencies:
'@types/unist': 3.0.3
+14
View File
@@ -1120,6 +1120,20 @@ html[data-rendering-engine="webkit"] [data-webkit-safe-motion="true"][data-state
margin-top: 0.5em;
}
.katex-display {
max-width: 100%;
margin: 1em 0;
overflow-x: auto;
overflow-y: hidden;
padding: 0.125rem 0.25rem;
text-align: left;
}
.katex-display > .katex {
display: inline-block;
min-width: max-content;
}
.tiptap-plan-wrapper .ProseMirror::after {
content: "";
display: block;
@@ -26,7 +26,12 @@ import type {
AzureDevOpsBoardWorkItem,
AzureDevOpsWorkItem,
} from "@/lib/types/azure-devops";
import { markdownComponents, remarkPlugins } from "@/components/shared/markdown-components";
import {
markdownComponents,
rehypePlugins,
remarkPlugins,
} from "@/components/shared/markdown-components";
import { markdownMathSanitizeSchema } from "@/lib/markdown/math-sanitize-schema";
import { useTranslation } from "react-i18next";
import { azureActionDisplayCopy } from "./azure-devops-workspace-defaults";
import { controlSizingClassName } from "@kandev/ui/control-sizing";
@@ -78,7 +83,7 @@ function Description({ value }: { value: string }) {
>
<ReactMarkdown
remarkPlugins={remarkPlugins}
rehypePlugins={[rehypeRaw, rehypeSanitize]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, markdownMathSanitizeSchema], ...rehypePlugins]}
components={markdownComponents}
>
{value}
@@ -7,6 +7,7 @@ import { Button } from "@kandev/ui/button";
import {
markdownComponents,
normalizeMarkdown,
rehypePlugins,
remarkPlugins,
} from "@/components/shared/markdown-components";
import { findingLocation } from "@/lib/review/format";
@@ -183,7 +184,11 @@ export function ReviewFindingCard(props: ReviewFindingCardProps) {
className="prose prose-sm dark:prose-invert mt-1 max-w-none text-xs leading-relaxed [overflow-wrap:anywhere] [&_p]:my-1"
data-testid="review-finding-body"
>
<ReactMarkdown remarkPlugins={remarkPlugins} components={markdownComponents}>
<ReactMarkdown
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
components={markdownComponents}
>
{normalizeMarkdown(finding.body)}
</ReactMarkdown>
</div>
@@ -15,6 +15,7 @@ import { useToast } from "@/components/toast-provider";
import {
markdownComponents,
normalizeMarkdown,
rehypePlugins,
remarkPlugins,
} from "@/components/shared/markdown-components";
import type { TaskWalkthrough, WalkthroughStep } from "@/lib/types/http";
@@ -49,7 +50,11 @@ function StepBody({ step, onOpenFile }: { step: WalkthroughStep; onOpenFile: ()
<span className="shrink-0 text-muted-foreground">:{lineLabel}</span>
</button>
<div className={WALKTHROUGH_STEP_BODY_CLASS} data-testid="walkthrough-step-body">
<ReactMarkdown remarkPlugins={remarkPlugins} components={markdownComponents}>
<ReactMarkdown
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
components={markdownComponents}
>
{normalizeMarkdown(step.text)}
</ReactMarkdown>
</div>
+9 -4
View File
@@ -5,7 +5,12 @@ import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
import { IconMessagePlus, IconChevronDown, IconChevronRight } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { markdownComponents, remarkPlugins } from "@/components/shared/markdown-components";
import {
markdownComponents,
rehypePlugins,
remarkPlugins,
} from "@/components/shared/markdown-components";
import { withMarkdownMathSanitizeSchema } from "@/lib/markdown/math-sanitize-schema";
import { useTranslation } from "react-i18next";
import { t } from "@/lib/i18n";
@@ -165,17 +170,17 @@ export function AddToContextButton({
}
/** Sanitization schema: default safe tags + details/summary for collapsible sections. */
const sanitizeSchema = {
const sanitizeSchema = withMarkdownMathSanitizeSchema({
...defaultSchema,
tagNames: [...(defaultSchema.tagNames ?? []), "details", "summary"],
};
});
export function PRMarkdownBody({ body }: { body: string }) {
return (
<div className="markdown-body max-w-none text-sm">
<ReactMarkdown
remarkPlugins={remarkPlugins}
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema]]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema], ...rehypePlugins]}
components={markdownComponents}
>
{body}
@@ -12,7 +12,11 @@ import { ScrollArea } from "@kandev/ui/scroll-area";
import { Badge } from "@kandev/ui/badge";
import { Separator } from "@kandev/ui/separator";
import { IconExternalLink } from "@tabler/icons-react";
import { remarkPlugins, markdownComponents } from "@/components/shared/markdown-components";
import {
markdownComponents,
rehypePlugins,
remarkPlugins,
} from "@/components/shared/markdown-components";
import { getReleaseUrl } from "@/lib/release-notes";
import type { ChangelogEntry } from "@/lib/changelog";
import type { TFunction } from "i18next";
@@ -66,7 +70,11 @@ export function ReleaseNotesDialog({
</div>
)}
<div className="markdown-body text-sm">
<ReactMarkdown remarkPlugins={remarkPlugins} components={markdownComponents}>
<ReactMarkdown
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
components={markdownComponents}
>
{entry.notes}
</ReactMarkdown>
</div>
@@ -16,7 +16,11 @@ import {
PaginationEllipsis,
} from "@kandev/ui/pagination";
import { IconExternalLink } from "@tabler/icons-react";
import { remarkPlugins, markdownComponents } from "@/components/shared/markdown-components";
import {
markdownComponents,
rehypePlugins,
remarkPlugins,
} from "@/components/shared/markdown-components";
import { getChangelog, type ChangelogEntry } from "@/lib/changelog";
import { getReleaseUrl } from "@/lib/release-notes";
@@ -59,7 +63,11 @@ function ChangelogEntryCard({ entry }: { entry: ChangelogEntry }) {
</CardHeader>
<CardContent>
<div className="markdown-body text-sm">
<ReactMarkdown remarkPlugins={remarkPlugins} components={markdownComponents}>
<ReactMarkdown
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
components={markdownComponents}
>
{entry.notes}
</ReactMarkdown>
</div>
@@ -8,6 +8,7 @@ vi.mock("@/components/shared/markdown-components", () => ({
MarkdownFileLinkContext: createContext({}),
MarkdownTaskContext: createContext(null),
markdownComponents: {},
rehypePlugins: [],
remarkPlugins: [
() => (tree: { children: { data?: object }[] }) => {
if (customSpanFixture.enabled) tree.children[0].data = { hName: "span" };
@@ -51,9 +51,11 @@ import {
MarkdownTaskContext,
markdownComponents,
normalizeMarkdown,
rehypePlugins,
remarkPlugins,
type MarkdownFileLinkContextValue,
} from "./markdown-components";
import { MemoizedMarkdown } from "./memoized-markdown";
function renderMarkdown(source: string): string {
return renderToStaticMarkup(
@@ -63,6 +65,14 @@ function renderMarkdown(source: string): string {
);
}
function renderMathMarkdown(source: string): string {
return renderToStaticMarkup(
<ReactMarkdown remarkPlugins={remarkPlugins} rehypePlugins={rehypePlugins}>
{source}
</ReactMarkdown>,
);
}
function renderTaskMarkdown(source: string, taskId: string): string {
return renderToStaticMarkup(
<MarkdownTaskContext.Provider value={taskId}>
@@ -92,6 +102,86 @@ function resetMarkdownComponentTestState() {
};
}
const KATEX_CLASS = 'class="katex"';
const KATEX_DISPLAY_CLASS = 'class="katex-display"';
describe("markdown math", () => {
afterEach(resetMarkdownComponentTestState);
// @covers AC-UI-MARKDOWN-MATH-001.1
it("renders inline math through the shared remark plugin list", () => {
const html = renderMathMarkdown("Energy: $E = mc^2$");
expect(html).toContain(KATEX_CLASS);
});
// @covers AC-UI-MARKDOWN-MATH-001.2
it("renders a single-line double-dollar expression as display math", () => {
const html = renderMathMarkdown("$$\\frac{a}{b}$$");
expect(html).toContain(KATEX_DISPLAY_CLASS);
expect(html).toContain('class="frac-line"');
});
// @covers AC-UI-MARKDOWN-MATH-002.1, AC-UI-MARKDOWN-MATH-002.2
it("keeps currency text literal when a dollar pair contains edge whitespace", () => {
const html = renderMathMarkdown("Cost: $100 and $200");
expect(html).toContain("Cost: $100 and $200");
expect(html).not.toContain(KATEX_CLASS);
});
// @covers AC-UI-MARKDOWN-MATH-001.2
it("renders multiline display math", () => {
const html = renderMathMarkdown("$$\n\\frac{a}{b}\n$$");
expect(html).toContain(KATEX_DISPLAY_CLASS);
expect(html).toContain('class="frac-line"');
});
// @covers AC-UI-MARKDOWN-MATH-002.1, AC-UI-MARKDOWN-MATH-002.2
it("keeps escaped and edge-whitespace dollar pairs literal", () => {
const html = renderMathMarkdown(String.raw`\$100 and \$200
$ 100$ and $100 $`);
expect(html).toContain("$100 and $200");
expect(html).toContain("$ 100$ and $100 $");
expect(html).not.toContain(KATEX_CLASS);
});
it("keeps source delimiters literal when both math edges contain whitespace", () => {
const html = renderMathMarkdown("Literal: $ E = mc^2 $");
expect(html).toContain("$ E = mc^2 $");
expect(html).not.toContain(KATEX_CLASS);
});
// @covers AC-UI-MARKDOWN-MATH-002.2
it("leaves dollar signs in inline and fenced code literal", () => {
const html = renderMathMarkdown("`$E = mc^2$`\n\n```text\n$E = mc^2$\n```");
expect(html).toContain("$E = mc^2$");
expect(html).not.toContain(KATEX_CLASS);
});
// @covers AC-UI-MARKDOWN-MATH-001.6
it("keeps surrounding prose when TeX is invalid", () => {
const html = renderMathMarkdown(String.raw`Before $\notARealCommand$ after`);
expect(html).toContain("Before");
expect(html).toContain("after");
expect(html).toContain("notARealCommand");
});
// @covers AC-UI-MARKDOWN-MATH-001.3
it("renders math in the memoized chat Markdown renderer", () => {
const { container } = render(<MemoizedMarkdown content="Energy: $E = mc^2$" />);
expect(container.querySelector(".katex")).not.toBeNull();
});
});
describe("markdownComponents", () => {
afterEach(resetMarkdownComponentTestState);
@@ -1,9 +1,12 @@
"use client";
import { createContext, isValidElement, useContext, type MouseEvent, type ReactNode } from "react";
import type { Options as MarkdownOptions } from "react-markdown";
import remarkGfm from "remark-gfm";
import remarkBreaks from "remark-breaks";
import remarkGemoji from "remark-gemoji";
import remarkMath from "remark-math";
import rehypeKatex from "rehype-katex";
import { InlineCode } from "@/components/task/chat/messages/inline-code";
import { CodeBlock } from "@/components/task/chat/messages/code-block";
import { MermaidBlock } from "@/components/shared/mermaid-block";
@@ -12,14 +15,25 @@ import { isMermaidContent } from "@/components/editors/tiptap/tiptap-mermaid-ext
import { usePanelActions } from "@/hooks/use-panel-actions";
import { useAppStore } from "@/components/state-provider";
import { getSessionWorkspacePath } from "@/lib/session-workspace-path";
import { remarkMathCompat } from "@/lib/markdown/remark-math-compat";
import {
resolveMarkdownFileTarget,
type MarkdownFileRootAlias,
} from "@/lib/markdown/file-link-target";
/** Shared remark plugins used by all markdown renderers */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const remarkPlugins: any[] = [remarkGfm, remarkBreaks, remarkGemoji];
type MarkdownPluginList = NonNullable<MarkdownOptions["remarkPlugins"]>;
export const remarkPlugins: MarkdownPluginList = [
remarkGfm,
remarkBreaks,
remarkGemoji,
remarkMath,
remarkMathCompat,
];
/** Shared rehype plugins used by non-raw Markdown renderers. */
export const rehypePlugins: NonNullable<MarkdownOptions["rehypePlugins"]> = [rehypeKatex];
// `normalizeMarkdown` (pure string transform) and its cached variant live in
// the React-free markdown cache module. Re-exported here so existing importers
@@ -0,0 +1,54 @@
import { cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { MarkdownFileLinkContext } from "./markdown-components";
import { MarkdownPreviewRenderer } from "@/components/task/markdown-preview-content";
import { MarkdownComment } from "@/components/task/simple/markdown-comment";
import { MemoizedMarkdown } from "./memoized-markdown";
afterEach(cleanup);
// @covers AC-UI-MARKDOWN-MATH-001.3, AC-UI-MARKDOWN-MATH-002.4
describe("sanitized Markdown math renderers", () => {
it("renders formulas in file previews while stripping executable HTML", () => {
render(
<MarkdownFileLinkContext.Provider value={{ onOpenFile: vi.fn() }}>
<MarkdownPreviewRenderer
content={[
"Energy: $E = mc^2$",
"",
"$$",
"\\frac{a}{b}",
"$$",
"",
"$\\href{javascript:alert(1)}{click}$",
"",
'[unsafe](javascript:alert("xss"))',
"",
'<script>alert("xss")</script>',
].join("\n")}
/>
</MarkdownFileLinkContext.Provider>,
);
expect(document.querySelector(".katex")).not.toBeNull();
expect(document.querySelector(".katex-display")).not.toBeNull();
expect(document.querySelector('a[href^="javascript:"]')).toBeNull();
expect(document.querySelector("script")).toBeNull();
});
it("renders formulas in sanitized comments", () => {
render(<MarkdownComment content="Energy: $E = mc^2$" />);
expect(document.querySelector(".katex")).not.toBeNull();
});
// @covers AC-UI-MARKDOWN-MATH-001.5, AC-UI-MARKDOWN-MATH-001.6
it("does not add chat motion spans inside generated KaTeX markup", () => {
const view = render(<MemoizedMarkdown content="Ready" animateText />);
view.rerender(<MemoizedMarkdown content="Ready $E = mc^2$" animateText />);
expect(view.container.querySelector(".katex")).not.toBeNull();
expect(view.container.querySelector(".katex [data-chat-text-motion]")).toBeNull();
});
});
@@ -16,6 +16,7 @@ vi.mock("@/components/shared/markdown-components", () => ({
MarkdownFileLinkContext: createContext({}),
MarkdownTaskContext: createContext(null),
markdownComponents: {},
rehypePlugins: [],
remarkPlugins: [],
}));
@@ -6,6 +6,7 @@ import {
MarkdownFileLinkContext,
MarkdownTaskContext,
markdownComponents,
rehypePlugins,
remarkPlugins,
type MarkdownFileLinkContextValue,
} from "@/components/shared/markdown-components";
@@ -78,7 +79,7 @@ export const MemoizedMarkdown = memo(function MemoizedMarkdown({
<MarkdownFileLinkContext.Provider value={fileLinkContext}>
<ReactMarkdown
remarkPlugins={remarkPlugins}
rehypePlugins={motionPlugins}
rehypePlugins={[...rehypePlugins, ...motionPlugins]}
components={resolvedComponents}
>
{normalized}
@@ -19,6 +19,7 @@ vi.mock("@/components/shared/markdown-components", () => ({
MarkdownFileLinkContext: createContext({}),
MarkdownTaskContext: createContext(null),
markdownComponents: {},
rehypePlugins: [],
remarkPlugins: [],
}));
@@ -2,9 +2,9 @@ import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { GetTaskPlanRenderer, UpdateTaskPlanRenderer } from "./document-renderers";
function plan(content: string | undefined) {
function plan(content: string | undefined, status: "complete" | "running" = "complete") {
return renderToStaticMarkup(
<GetTaskPlanRenderer args={{ task_id: "t1" }} result={{ content }} status="complete" />,
<GetTaskPlanRenderer args={{ task_id: "t1" }} result={{ content }} status={status} />,
);
}
@@ -36,3 +36,10 @@ describe("document summary", () => {
expect(updatedPlan(undefined)).toContain("empty");
});
});
// @covers AC-UI-MARKDOWN-MATH-001.3
describe("document Markdown math", () => {
it("renders formulas in task plan content", () => {
expect(plan("Energy: $E = mc^2$", "running")).toContain('class="katex"');
});
});
@@ -12,7 +12,11 @@ import {
IconTrash,
} from "@tabler/icons-react";
import { Badge } from "@kandev/ui/badge";
import { markdownComponents, remarkPlugins } from "@/components/shared/markdown-components";
import {
markdownComponents,
rehypePlugins,
remarkPlugins,
} from "@/components/shared/markdown-components";
import { EmptyListNote, IdChip, KandevBody, KandevRow, KeyValueRow, SummaryDot } from "./shared";
import { pickArray, pickNumber, pickString } from "./parse";
import type { KandevRenderer } from "./types";
@@ -26,7 +30,11 @@ function MarkdownBody({ content }: { content: string | undefined }) {
if (!content) return null;
return (
<div className="prose prose-sm dark:prose-invert max-w-none break-words">
<ReactMarkdown remarkPlugins={remarkPlugins} components={markdownComponents}>
<ReactMarkdown
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
components={markdownComponents}
>
{content}
</ReactMarkdown>
</div>
@@ -29,7 +29,7 @@ import {
workflowMessageInfoFromMetadata,
type WorkflowStepMessageInfo,
} from "@/components/task/chat/messages/workflow-step-message-badge";
import { remarkPlugins } from "@/components/shared/markdown-components";
import { rehypePlugins, remarkPlugins } from "@/components/shared/markdown-components";
import type { QueuedMessage } from "@/lib/state/slices/session/types";
import type { EntityReference } from "@/lib/types/entity-reference";
import {
@@ -259,7 +259,11 @@ function DisplayView({
expanded ? "max-h-[40rem]" : "max-h-[2.75rem]",
)}
renderContent={(preview) => (
<ReactMarkdown remarkPlugins={remarkPlugins} components={referenceMarkdownComponents}>
<ReactMarkdown
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
components={referenceMarkdownComponents}
>
{preview}
</ReactMarkdown>
)}
@@ -0,0 +1,110 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { TooltipProvider } from "@kandev/ui/tooltip";
import {
resolveMarkdownDomSelection,
SOURCE_END_ATTR,
SOURCE_START_ATTR,
} from "@/lib/markdown/source-line-ranges";
const showCommentsForRange = vi.fn();
vi.mock("@/hooks/domains/comments/use-markdown-preview-comments", () => ({
useMarkdownPreviewComments: () => ({
comments: [
{ id: "single-line", startLine: 3, endLine: 3 },
{ id: "multiline", startLine: 7, endLine: 9 },
],
commentView: null,
currentSelection: null,
textSelection: null,
dismissOverlays: vi.fn(),
showCommentsForRange,
closeCommentView: vi.fn(),
closeComposer: vi.fn(),
openComposer: vi.fn(),
removeComment: vi.fn(),
submitAndRunComment: vi.fn(),
submitComment: vi.fn(),
updateComment: vi.fn(),
}),
}));
vi.mock("@/components/editors/external-vcs-file-link", () => ({
ExternalVcsFileLink: () => null,
useExternalVcsFileStatus: () => ({ status: "modified" }),
}));
import { MarkdownPreviewContent } from "./markdown-preview-content";
afterEach(() => {
cleanup();
showCommentsForRange.mockClear();
});
const content = [
"Before formula",
"",
"$$\\frac{a}{b}$$",
"",
"Between formulas",
"",
"$$",
"\\frac{c}{d}",
"$$",
"After formula",
].join("\n");
describe("MarkdownPreviewContent math source ranges", () => {
it("keeps display formulas attached to source comments and selections", () => {
render(
<TooltipProvider>
<MarkdownPreviewContent
path="formula.md"
content={content}
sessionId="session-1"
taskId="task-1"
repositoryId="repo-1"
repositoryName="frontend"
enableComments
showExternalVcsLink={false}
onTogglePreview={vi.fn()}
/>
</TooltipProvider>,
);
const preview = screen.getByTestId("markdown-preview");
const displays = [...preview.querySelectorAll<HTMLElement>(".katex-display")];
expect(displays).toHaveLength(2);
const singleLineSource = displays[0].closest<HTMLElement>(`[${SOURCE_START_ATTR}]`);
const multilineSource = displays[1].closest<HTMLElement>(`[${SOURCE_START_ATTR}]`);
expect(singleLineSource?.getAttribute(SOURCE_START_ATTR)).toBe("3");
expect(singleLineSource?.getAttribute(SOURCE_END_ATTR)).toBe("3");
expect(multilineSource?.getAttribute(SOURCE_START_ATTR)).toBe("7");
expect(multilineSource?.getAttribute(SOURCE_END_ATTR)).toBe("9");
const root = preview.querySelector<HTMLElement>(".markdown-body");
expect(root).not.toBeNull();
expect(root?.querySelectorAll('[data-testid="markdown-preview-comment-badge"]')).toHaveLength(
2,
);
const range = document.createRange();
range.selectNodeContents(multilineSource!);
const selection = window.getSelection()!;
selection.removeAllRanges();
selection.addRange(range);
expect(resolveMarkdownDomSelection(root!, content, selection)).toMatchObject({
startLine: 7,
endLine: 9,
});
selection.removeAllRanges();
fireEvent.click(multilineSource!, { clientX: 10, clientY: 20 });
expect(showCommentsForRange).toHaveBeenCalledWith(
{ startLine: 7, endLine: 9 },
{ x: 10, y: 20 },
);
});
});
@@ -18,12 +18,13 @@ import {
import { createPortal } from "react-dom";
import ReactMarkdown, { type ExtraProps, type Components } from "react-markdown";
import rehypeRaw from "rehype-raw";
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
import rehypeSanitize from "rehype-sanitize";
import { Button } from "@kandev/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { IconCode, IconMessagePlus } from "@tabler/icons-react";
import {
MarkdownTaskContext,
rehypePlugins,
remarkPlugins,
markdownComponents,
} from "@/components/shared/markdown-components";
@@ -40,6 +41,8 @@ import {
type SourceLineRange,
} from "@/lib/markdown/source-line-ranges";
import { commentsBeginInRange, commentsOverlapRange } from "@/lib/markdown/preview-comments";
import { markdownMathSanitizeSchema } from "@/lib/markdown/math-sanitize-schema";
import { MATH_SOURCE_CLASS, rehypeMathSource } from "@/lib/markdown/rehype-math-source";
import type { DiffComment } from "@/lib/state/slices/comments";
import {
ExternalVcsFileLink,
@@ -284,8 +287,31 @@ function sourceComponent(tag: keyof HTMLElementTagNameMap) {
};
}
function MarkdownPreviewDiv({
node,
elementRef,
className,
children,
...rest
}: MarkdownSourceBlockProps) {
if (className?.split(/\s+/u).includes(MATH_SOURCE_CLASS)) {
return (
<SourceBlock tag="div" node={node} className={className} elementRef={elementRef} {...rest}>
{children}
</SourceBlock>
);
}
return (
<div {...rest} className={className}>
{children}
</div>
);
}
const markdownPreviewComponents: Components = {
...markdownComponents,
div: (props) => <MarkdownPreviewDiv {...(props as MarkdownSourceBlockProps)} />,
p: sourceComponent("p"),
h1: sourceComponent("h1"),
h2: sourceComponent("h2"),
@@ -372,7 +398,12 @@ export function MarkdownPreviewRenderer({
<MarkdownTaskContext.Provider value={taskId ?? null}>
<ReactMarkdown
remarkPlugins={remarkPlugins}
rehypePlugins={[rehypeRaw, [rehypeSanitize, defaultSchema]]}
rehypePlugins={[
rehypeRaw,
[rehypeSanitize, markdownMathSanitizeSchema],
rehypeMathSource,
...rehypePlugins,
]}
components={markdownPreviewComponents}
>
{content}
@@ -3,7 +3,12 @@
import { memo } from "react";
import ReactMarkdown from "react-markdown";
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
import { remarkPlugins, markdownComponents } from "@/components/shared/markdown-components";
import {
markdownComponents,
rehypePlugins,
remarkPlugins,
} from "@/components/shared/markdown-components";
import { withMarkdownMathSanitizeSchema } from "@/lib/markdown/math-sanitize-schema";
import { remarkTaskLinks } from "./task-identifier-link";
type MarkdownCommentProps = {
@@ -15,17 +20,17 @@ const localRemarkPlugins = [...remarkPlugins, remarkTaskLinks];
// Extend the default sanitize schema to allow relative hrefs (for task links like /office/tasks/KAN-42).
// The default schema only allows http/https/irc/mailto protocols, stripping relative paths.
const { href: _hrefProto, ...otherProtocols } = defaultSchema.protocols ?? {};
const sanitizeSchema = {
const sanitizeSchema = withMarkdownMathSanitizeSchema({
...defaultSchema,
protocols: otherProtocols,
};
});
export const MarkdownComment = memo(function MarkdownComment({ content }: MarkdownCommentProps) {
return (
<div className="prose prose-sm max-w-none text-sm markdown-body">
<ReactMarkdown
remarkPlugins={localRemarkPlugins}
rehypePlugins={[[rehypeSanitize, sanitizeSchema]]}
rehypePlugins={[[rehypeSanitize, sanitizeSchema], ...rehypePlugins]}
components={markdownComponents}
>
{content}
@@ -0,0 +1,175 @@
import fs from "node:fs";
import path from "node:path";
import { test, expect, type SeedData } from "../../fixtures/test-base";
import type { ApiClient } from "../../helpers/api-client";
import { waitForAgentMessage } from "../../helpers/session";
import { SessionPage } from "../../pages/session-page";
import type { Page } from "@playwright/test";
function messageScript(content: string): string {
const escaped = content.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("\n", "\\n");
return `e2e:message("${escaped}")`;
}
async function openScriptedTask(
testPage: Page,
apiClient: ApiClient,
seedData: SeedData,
title: string,
content: string,
): Promise<SessionPage> {
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
title,
seedData.agentProfileId,
{
description: messageScript(content),
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
if (!task.session_id) throw new Error("createTaskWithAgent did not return a session_id");
await waitForAgentMessage(apiClient, task.session_id, content.split("\n", 1)[0]);
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
return session;
}
async function openMarkdownPreview(
testPage: Page,
session: SessionPage,
fileName: string,
): Promise<void> {
await session.clickTab("Files");
await expect(session.files).toBeVisible({ timeout: 5_000 });
await expect(session.files.getByText(fileName)).toBeVisible({ timeout: 10_000 });
await session.files.getByText(fileName).click();
await expect(testPage.locator(`.dv-default-tab:has-text('${fileName}')`)).toBeVisible({
timeout: 10_000,
});
const previewToggle = testPage.getByTestId("markdown-preview-toggle").first();
await expect(previewToggle).toBeVisible({ timeout: 10_000 });
await previewToggle.click();
await expect(testPage.getByTestId("markdown-preview")).toBeVisible({ timeout: 10_000 });
}
test.describe("Markdown math", () => {
test.describe.configure({ retries: 1, timeout: 120_000 });
for (const theme of ["light", "dark"] as const) {
test(`renders chat formulas, currency, and invalid TeX safely in ${theme} theme`, async ({
testPage,
apiClient,
seedData,
}) => {
await testPage.addInitScript((value) => localStorage.setItem("theme", value), theme);
const session = await openScriptedTask(
testPage,
apiClient,
seedData,
`Markdown Math Chat ${theme}`,
[
"Energy: $E = mc^2$",
"",
"$$\\frac{a}{b}$$",
"",
"$$",
"\\frac{a + b}{c + d}",
"$$",
"",
"Cost: $100 and $200",
"",
"Before $\\notARealCommand$ after",
].join("\n"),
);
await expect(testPage.locator("html")).toHaveClass(new RegExp(`(^|\\s)${theme}(\\s|$)`));
const chat = session.activeChat();
await expect(chat.locator(".katex").first()).toBeVisible();
await expect(chat.locator(".katex-display")).toHaveCount(2);
await expect(chat.locator("math").first()).toBeVisible();
await expect(chat.getByText("Cost: $100 and $200", { exact: true })).toBeVisible();
const invalidFormula = chat.locator(".markdown-body").filter({ hasText: "Before" }).last();
await expect(invalidFormula).toContainText("Before");
await expect(invalidFormula).toContainText("after");
await expect(chat.locator("script")).toHaveCount(0);
const mathLayout = await chat
.locator(".katex-display")
.first()
.evaluate((element) => {
const katex = element.querySelector<HTMLElement>(".katex");
const base = element.querySelector<HTMLElement>(".base");
return {
baseDisplay: base ? getComputedStyle(base).display : null,
basePosition: base ? getComputedStyle(base).position : null,
baseWhiteSpace: base ? getComputedStyle(base).whiteSpace : null,
displayOverflow: getComputedStyle(element).overflowX,
katexFont: katex ? getComputedStyle(katex).fontFamily : null,
};
});
expect(mathLayout).toMatchObject({
baseDisplay: "inline-block",
basePosition: "relative",
baseWhiteSpace: "nowrap",
displayOverflow: "auto",
});
expect(mathLayout.katexFont).toContain("KaTeX_Main");
const katexFontLoaded = await testPage.evaluate(async () => {
await document.fonts.ready;
return Array.from(document.fonts).some(
(font) => font.family === "KaTeX_Main" && font.status === "loaded",
);
});
expect(katexFontLoaded).toBe(true);
});
}
test("renders formulas in a sanitized Markdown file preview", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
const fileName = "math-preview.md";
const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo");
fs.writeFileSync(
path.join(repoDir, fileName),
[
"# Formula preview",
"",
"Energy: $E = mc^2$",
"",
"$$",
"\\frac{a}{b}",
"$$",
"",
'[unsafe](javascript:alert("xss"))',
"",
"<script>window.markdownMathXss = true</script>",
].join("\n"),
);
const session = await openScriptedTask(
testPage,
apiClient,
seedData,
"Markdown Math Preview",
"Preview formula file",
);
await openMarkdownPreview(testPage, session, fileName);
const preview = testPage.getByTestId("markdown-preview");
await expect(preview.locator(".katex").first()).toBeVisible();
await expect(preview.locator(".katex-display")).toBeVisible();
await expect(preview.locator('a[href^="javascript:"]')).toHaveCount(0);
await expect(preview.locator("script")).toHaveCount(0);
expect(await testPage.evaluate(() => "markdownMathXss" in window)).toBe(false);
});
});
@@ -0,0 +1,157 @@
import fs from "node:fs";
import path from "node:path";
import { test, expect, type SeedData } from "../../fixtures/test-base";
import type { ApiClient } from "../../helpers/api-client";
import { waitForAgentMessage } from "../../helpers/session";
import { SessionPage } from "../../pages/session-page";
import type { Page } from "@playwright/test";
function messageScript(content: string): string {
const escaped = content.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("\n", "\\n");
return `e2e:message("${escaped}")`;
}
async function openMarkdownPreview(
testPage: Page,
apiClient: ApiClient,
seedData: SeedData,
fileName: string,
): Promise<void> {
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Mobile Markdown Math Preview",
seedData.agentProfileId,
{
description: messageScript("Preview formula file"),
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await testPage.getByRole("button", { name: "Files", exact: true }).tap();
await expect(session.files).toBeVisible({ timeout: 5_000 });
const fileNode = testPage.locator(`[data-testid="file-tree-node"][data-path="${fileName}"]`);
await expect(fileNode).toBeVisible({ timeout: 10_000 });
await fileNode.tap();
const viewer = testPage.getByTestId("mobile-file-viewer-panel");
await expect(viewer).toBeVisible({ timeout: 10_000 });
const previewToggle = viewer.getByTestId("markdown-preview-toggle");
await expect(previewToggle).toBeVisible({ timeout: 10_000 });
await previewToggle.tap();
await expect(viewer.getByTestId("markdown-preview")).toBeVisible({ timeout: 10_000 });
}
test.describe("mobile: Markdown math", () => {
test("keeps wide display formulas in a local scroll region", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(90_000);
const wideFormula = [
"\\frac{a_1 + b_1 + c_1 + d_1}{e_1 + f_1 + g_1}",
" + \\frac{a_2 + b_2 + c_2 + d_2}{e_2 + f_2 + g_2}",
" + \\frac{a_3 + b_3 + c_3 + d_3}{e_3 + f_3 + g_3}",
" + \\frac{a_4 + b_4 + c_4 + d_4}{e_4 + f_4 + g_4}",
" + \\frac{a_5 + b_5 + c_5 + d_5}{e_5 + f_5 + g_5}",
].join("");
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Mobile Markdown Math",
seedData.agentProfileId,
{
description: messageScript(["Inline: $E = mc^2$", "", "$$", wideFormula, "$$"].join("\n")),
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
if (!task.session_id) throw new Error("createTaskWithAgent did not return a session_id");
await waitForAgentMessage(apiClient, task.session_id, "Inline:");
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
const chat = session.activeChat();
const display = chat.locator(".katex-display");
await expect(display).toBeVisible();
await expect(chat.locator(".katex").first()).toBeVisible();
const scrollMetrics = await display.evaluate((element) => {
const maxScrollLeft = element.scrollWidth - element.clientWidth;
element.scrollLeft = element.scrollWidth;
return {
clientWidth: element.clientWidth,
scrollWidth: element.scrollWidth,
maxScrollLeft,
scrollLeft: element.scrollLeft,
};
});
expect(scrollMetrics.scrollWidth).toBeGreaterThan(scrollMetrics.clientWidth + 1);
expect(scrollMetrics.maxScrollLeft).toBeGreaterThan(0);
expect(scrollMetrics.scrollLeft).toBeGreaterThan(0);
expect(await chat.evaluate((element) => element.scrollWidth <= element.clientWidth + 1)).toBe(
true,
);
expect(
await testPage.evaluate(
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1,
),
).toBe(true);
});
test("keeps wide display formulas in a file preview scroll region", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
test.setTimeout(90_000);
const fileName = "mobile-math-preview.md";
const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo");
fs.writeFileSync(
path.join(repoDir, fileName),
[
"# Formula preview",
"",
"$$",
"\\frac{a_1 + b_1 + c_1 + d_1 + e_1 + f_1}{g_1 + h_1 + i_1 + j_1}",
" + \\frac{a_2 + b_2 + c_2 + d_2 + e_2 + f_2}{g_2 + h_2 + i_2 + j_2}",
" + \\frac{a_3 + b_3 + c_3 + d_3 + e_3 + f_3}{g_3 + h_3 + i_3 + j_3}",
"$$",
].join("\n"),
);
await openMarkdownPreview(testPage, apiClient, seedData, fileName);
const preview = testPage.getByTestId("markdown-preview");
const display = preview.locator(".katex-display");
await expect(display).toBeVisible();
const scrollMetrics = await display.evaluate((element) => {
const maxScrollLeft = element.scrollWidth - element.clientWidth;
element.scrollLeft = element.scrollWidth;
return {
clientWidth: element.clientWidth,
scrollWidth: element.scrollWidth,
maxScrollLeft,
scrollLeft: element.scrollLeft,
};
});
expect(scrollMetrics.scrollWidth).toBeGreaterThan(scrollMetrics.clientWidth + 1);
expect(scrollMetrics.maxScrollLeft).toBeGreaterThan(0);
expect(scrollMetrics.scrollLeft).toBeGreaterThan(0);
expect(
await testPage.evaluate(
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1,
),
).toBe(true);
});
});
@@ -0,0 +1,33 @@
import rehypeSanitize from "rehype-sanitize";
import { describe, expect, it } from "vitest";
import { markdownMathSanitizeSchema } from "./math-sanitize-schema";
type TestElement = {
properties?: {
className?: unknown;
};
};
// @covers AC-UI-MARKDOWN-MATH-002.4
describe("markdownMathSanitizeSchema", () => {
it("preserves both math marker classes while removing unrelated classes", () => {
const tree = {
type: "root",
children: [
{
type: "element",
tagName: "code",
properties: {
className: ["language-math", "math-inline", "math-display", "unsafe-class"],
},
children: [],
},
],
};
const sanitized = rehypeSanitize(markdownMathSanitizeSchema)(tree as never) as typeof tree;
const code = sanitized.children[0] as TestElement;
expect(code.properties?.className).toEqual(["language-math", "math-inline", "math-display"]);
});
});
@@ -0,0 +1,35 @@
import { defaultSchema, type Options as SanitizeSchema } from "rehype-sanitize";
type PropertyDefinition = NonNullable<NonNullable<SanitizeSchema["attributes"]>[string]>[number];
type ClassNameDefinition = [string, ...(string | number | boolean | RegExp | null | undefined)[]];
const mathClassNames = ["math-inline", "math-display"] as const;
function isClassNameDefinition(definition: PropertyDefinition): definition is ClassNameDefinition {
return Array.isArray(definition) && definition[0] === "className";
}
/** Extend an existing schema with only the marker classes consumed by rehype-katex. */
export function withMarkdownMathSanitizeSchema(schema: SanitizeSchema): SanitizeSchema {
const codeAttributes = schema.attributes?.code ?? [];
const existingClassNameDefinition = codeAttributes.find(isClassNameDefinition);
const existingClassNameValues = existingClassNameDefinition?.slice(1) ?? [];
const codeAttributesWithoutClassName = codeAttributes.filter(
(definition) => !isClassNameDefinition(definition),
);
const mergedClassNameDefinition = [
"className",
...existingClassNameValues,
...mathClassNames,
] as PropertyDefinition;
return {
...schema,
attributes: {
...schema.attributes,
code: [mergedClassNameDefinition, ...codeAttributesWithoutClassName],
},
};
}
export const markdownMathSanitizeSchema = withMarkdownMathSanitizeSchema(defaultSchema);
@@ -0,0 +1,88 @@
import { describe, expect, it } from "vitest";
import { MATH_SOURCE_CLASS, rehypeMathSource } from "./rehype-math-source";
type TestNode = {
type: string;
tagName?: string;
properties?: Record<string, unknown>;
children?: TestNode[];
position?: unknown;
};
function applySourceWrapper(tree: TestNode): TestNode {
rehypeMathSource()(tree as never);
return tree;
}
function displayPre(position?: TestNode["position"], children?: TestNode[]): TestNode {
return {
type: "element",
tagName: "pre",
...(position ? { position } : {}),
children: children ?? [
{
type: "element",
tagName: "code",
properties: { className: ["language-math"] },
children: [],
},
],
};
}
describe("rehypeMathSource", () => {
it("wraps a display math pre and preserves its source position", () => {
const position = {
start: { line: 3, column: 1, offset: 20 },
end: { line: 5, column: 3, offset: 48 },
};
const pre = displayPre(position);
const tree: TestNode = { type: "root", children: [pre] };
applySourceWrapper(tree);
const wrapper = tree.children?.[0];
expect(wrapper).toMatchObject({
type: "element",
tagName: "div",
properties: { className: [MATH_SOURCE_CLASS] },
position,
});
expect(wrapper?.children).toEqual([pre]);
});
it.each([
["an empty children array", displayPre(undefined, [])],
[
"a pre with multiple children",
displayPre(undefined, [
{
type: "element",
tagName: "code",
properties: { className: ["math-display"] },
children: [],
},
{ type: "text", children: [] },
]),
],
])("leaves %s unchanged", (_description, pre) => {
const tree: TestNode = { type: "root", children: [pre] };
applySourceWrapper(tree);
expect(tree.children).toEqual([pre]);
});
it("wraps display math without inventing a source position", () => {
const pre = displayPre();
const tree: TestNode = { type: "root", children: [pre] };
applySourceWrapper(tree);
expect(tree.children?.[0]).toMatchObject({
tagName: "div",
position: undefined,
children: [pre],
});
});
});
@@ -0,0 +1,64 @@
type RehypeNode = {
type: string;
tagName?: string;
properties?: Record<string, unknown>;
children?: RehypeNode[];
position?: RehypePosition;
};
type RehypePosition = {
start: { line: number; column: number; offset?: number };
end: { line: number; column: number; offset?: number };
indent?: number[];
};
const MATH_SOURCE_CLASS = "markdown-math-source";
function hasClass(node: RehypeNode | undefined, className: string): boolean {
const value = node?.properties?.className;
if (Array.isArray(value)) return value.includes(className);
if (typeof value === "string") return value.split(/\s+/u).includes(className);
return false;
}
function isDisplayMathPre(node: RehypeNode): boolean {
const code = node.children?.[0];
return (
node.tagName === "pre" &&
node.children?.length === 1 &&
code?.tagName === "code" &&
(hasClass(code, "language-math") || hasClass(code, "math-display"))
);
}
function wrapDisplayMath(node: RehypeNode, source: RehypeNode): RehypeNode {
return {
type: "element",
tagName: "div",
properties: { className: [MATH_SOURCE_CLASS] },
children: [node],
position: source.position,
};
}
function visit(node: RehypeNode): void {
if (!node.children) return;
for (let index = 0; index < node.children.length; index += 1) {
const child = node.children[index];
if (isDisplayMathPre(child)) {
node.children[index] = wrapDisplayMath(child, child);
continue;
}
visit(child);
}
}
/** Preserve display-math source positions before rehype-katex replaces `<pre>`. */
export function rehypeMathSource() {
return (tree: RehypeNode) => {
visit(tree);
};
}
export { MATH_SOURCE_CLASS };
@@ -0,0 +1,115 @@
import { describe, expect, it } from "vitest";
import { remarkMathCompat } from "./remark-math-compat";
function applyCompat(tree: Record<string, unknown>, source: string) {
const transform = remarkMathCompat();
transform(tree as never, { toString: () => source });
return tree;
}
describe("remarkMathCompat", () => {
// @covers AC-UI-MARKDOWN-MATH-002.1, AC-UI-MARKDOWN-MATH-002.5
it("restores the exact source slice for currency-like math", () => {
const source = "Cost: $100 and $200";
const tree = {
type: "root",
children: [
{
type: "paragraph",
children: [
{
type: "text",
value: "Cost: ",
position: { start: { offset: 0 }, end: { offset: 6 } },
},
{
type: "inlineMath",
value: "100 and ",
position: { start: { offset: 6 }, end: { offset: 16 } },
},
{
type: "text",
value: "200",
position: { start: { offset: 16 }, end: { offset: 19 } },
},
],
},
],
};
applyCompat(tree, source);
expect((tree.children as Array<{ children: unknown[] }>)[0].children[1]).toEqual({
type: "text",
value: "$100 and $",
position: { start: { offset: 6 }, end: { offset: 16 } },
});
});
// @covers AC-UI-MARKDOWN-MATH-001.2
it("promotes a paragraph-only single-line double-dollar node to display math", () => {
const source = "$$\\frac{a}{b}$$";
const node = {
type: "inlineMath",
value: "\\frac{a}{b}",
position: { start: { offset: 0 }, end: { offset: source.length } },
};
const tree = {
type: "root",
children: [{ type: "paragraph", children: [node] }],
};
applyCompat(tree, source);
const display = (tree.children as Array<Record<string, unknown>>)[0];
expect(display).toMatchObject({
type: "math",
position: node.position,
data: {
hName: "pre",
hChildren: [
{
type: "element",
tagName: "code",
properties: { className: ["language-math", "math-display"] },
},
],
},
});
});
// @covers AC-UI-MARKDOWN-MATH-002.2
it("does not change an embedded double-dollar expression", () => {
const source = "Before $$x$$ after";
const node = {
type: "inlineMath",
value: "x",
position: { start: { offset: 7 }, end: { offset: 12 } },
};
const tree = {
type: "root",
children: [
{
type: "paragraph",
children: [
{
type: "text",
value: "Before ",
position: { start: { offset: 0 }, end: { offset: 7 } },
},
node,
{
type: "text",
value: " after",
position: { start: { offset: 12 }, end: { offset: 18 } },
},
],
},
],
};
applyCompat(tree, source);
expect((tree.children as Array<{ children: unknown[] }>)[0].children[1]).toBe(node);
});
});
@@ -0,0 +1,95 @@
type MarkdownNode = {
type: string;
value?: string;
children?: MarkdownNode[];
data?: Record<string, unknown>;
position?: {
start?: { offset?: number };
end?: { offset?: number };
};
};
type MarkdownFile = {
toString(): string;
};
function sourceSlice(node: MarkdownNode, source: string): string | null {
const start = node.position?.start?.offset;
const end = node.position?.end?.offset;
if (start === undefined || end === undefined) return null;
return source.slice(start, end);
}
function textNode(node: MarkdownNode, value: string): MarkdownNode {
return { type: "text", value, position: node.position };
}
function displayMathNode(node: MarkdownNode): MarkdownNode {
return {
type: "math",
value: node.value,
position: node.position,
data: {
hName: "pre",
hChildren: [
{
type: "element",
tagName: "code",
properties: { className: ["language-math", "math-display"] },
children: [{ type: "text", value: node.value ?? "" }],
},
],
},
};
}
function hasWhitespaceAtMathBoundary(raw: string): boolean {
const inner = raw.slice(1, -1);
return /^\s|\s$/u.test(inner);
}
function isSingleLineDisplay(node: MarkdownNode, raw: string, parent: MarkdownNode): boolean {
return (
node.type === "inlineMath" &&
parent.type === "paragraph" &&
parent.children?.length === 1 &&
raw.startsWith("$$") &&
raw.endsWith("$$") &&
!raw.includes("\n")
);
}
function transformChildren(node: MarkdownNode, source: string): void {
if (!node.children) return;
const transformed: MarkdownNode[] = [];
for (const child of node.children) {
if (child.type === "paragraph" && child.children?.length === 1) {
const onlyChild = child.children[0];
const raw = sourceSlice(onlyChild, source);
if (raw && isSingleLineDisplay(onlyChild, raw, child)) {
transformed.push(displayMathNode(onlyChild));
continue;
}
}
if (child.type === "inlineMath") {
const raw = sourceSlice(child, source);
if (raw && !raw.startsWith("$$") && hasWhitespaceAtMathBoundary(raw)) {
transformed.push(textNode(child, raw));
continue;
}
}
transformChildren(child, source);
transformed.push(child);
}
node.children = transformed;
}
/** Restore parsed dollar pairs whose edge whitespace makes them literal text. */
export function remarkMathCompat() {
return (tree: MarkdownNode, file: MarkdownFile) => {
transformChildren(tree, file.toString());
};
}
+4
View File
@@ -118,6 +118,7 @@
"i18next": "^26.3.6",
"immer": "^10.1.1",
"input-otp": "^1.4.2",
"katex": "^0.16.47",
"lowlight": "^3.3.0",
"mermaid": "^11.12.2",
"monaco-editor": "^0.55.1",
@@ -129,11 +130,13 @@
"react-markdown": "^10.1.0",
"react-resizable-panels": "^4.4.1",
"recharts": "2.15.4",
"rehype-katex": "^7.0.1",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-breaks": "^4.0.0",
"remark-gemoji": "^8.0.0",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"shiki": "^3.22.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
@@ -149,6 +152,7 @@
"@tailwindcss/postcss": "^4",
"@testing-library/react": "^16.3.2",
"@types/diff": "^8.0.0",
"@types/katex": "^0.16.8",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
+23 -1
View File
@@ -472,5 +472,27 @@
"allowUserNamespacesTitle": "ユーザー名前空間サポート",
"allowUserNamespacesDescription": "コンテナ内で権限なしユーザーの名前空間作成と mount 操作を許可。bwrap みたいなサンドボックスツールが使える。",
"allowUserNamespacesNote": "この設定が効くのは新規作成コンテナだけ。変更を反映するには、既存タスク環境をリセットする必要あり。",
"allowUserNamespacesWarning": "有効にすると、Docker のデフォルト AppArmor 制限をオプトイン済みコンテナに対して無効化。user-namespace サンドボックスが必要な信頼済み agent runtime だけで有効にすること。"
"allowUserNamespacesWarning": "有効にすると、Docker のデフォルト AppArmor 制限をオプトイン済みコンテナに対して無効化。user-namespace サンドボックスが必要な信頼済み agent runtime だけで有効にすること。",
"sshReachabilityAgeLastProbe": "最後のプローブ: {{age}}",
"sshReachabilityAgeLastSuccess": "最後の成功: {{age}}",
"sshReachabilityConsecutiveFailures_one": "{{count}} 回連続して失敗",
"sshReachabilityConsecutiveFailures_other": "{{count}} 回連続して失敗",
"sshReachabilityDescription": "このホストの最新の SSH プローブ結果。バックグラウンドで自動的に確認されます。",
"sshReachabilityHost": "プローブしたホスト: {{host}}",
"sshReachabilityLoadFailed": "到達性ステータスを読み込めませんでした。不明です。",
"sshReachabilityNeverProbed": "まだプローブされていません。",
"sshReachabilityNoSuccess": "成功したプローブの記録はありません。",
"sshReachabilityProbeNow": "今すぐプローブ",
"sshReachabilityProbingOff": "定期プローブはオフです。現在の状態ではなく、最後に記録された結果を表示します。",
"sshReachabilityReasonAuth": "認証に失敗しました",
"sshReachabilityReasonConfig": "接続設定が無効です",
"sshReachabilityReasonHostKey": "ホストキーが固定フィンガープリントと一致しません",
"sshReachabilityReasonNetwork": "ネットワークエラーが発生しました",
"sshReachabilityReasonTimeout": "接続試行がタイムアウトしました",
"sshReachabilityReasonUnknown": "不明なエラーが発生しました",
"sshReachabilityStale": "古い情報: 過去 3 回のチェック間隔内にプローブされていません",
"sshReachabilityStateReachable": "到達可能",
"sshReachabilityStateUnknown": "不明",
"sshReachabilityStateUnreachable": "到達不能",
"sshReachabilityTitle": "到達性"
}
+11 -1
View File
@@ -2384,5 +2384,15 @@
"checkoutOptions.credentialsUnsupported": "高度なチェックアウトオプションには、Kandev 管理の Git 認証情報を使って。",
"toolPayloadRemovedAt": "ツール詳細は {{date}} に削除済み。",
"toolPayloadRemoved": "ツール詳細は削除済み。",
"openTaskSwitcherFor": "タスク切替を開く: {{title}}"
"openTaskSwitcherFor": "タスク切替を開く: {{title}}",
"launchWarningTitle": "SSH ホストに到達できない可能性があります",
"launchWarningHost": "プローブしたホスト: {{host}}",
"launchWarningLastSuccess": "最後の成功: {{age}}",
"launchWarningNeverSucceeded": "このホストで成功したプローブはまだ記録されていません。",
"launchWarningReasonConfig": "最後の試行は接続設定が無効だったため失敗しました。",
"launchWarningReasonTimeout": "最後の試行はタイムアウトしました。",
"launchWarningReasonHostKey": "最後の試行はホストキーが固定フィンガープリントと一致しなかったため失敗しました。",
"launchWarningReasonAuth": "最後の試行は認証に失敗しました。",
"launchWarningReasonNetwork": "最後の試行はネットワークエラーで失敗しました。",
"launchWarningReasonUnknown": "最後の試行は不明な理由で失敗しました。"
}
+1
View File
@@ -1,5 +1,6 @@
import { StrictMode, useEffect } from "react";
import { createRoot } from "react-dom/client";
import "katex/dist/katex.min.css";
import "@/app/globals.css";
import { setOnUnauthorized } from "@/lib/api/client";
import { scheduleFrontendErrorReport } from "@/lib/api/domains/frontend-error-log-api";
+122
View File
@@ -0,0 +1,122 @@
---
created: 2026-09-22
status: implemented
requirements:
- REQ-UI-MARKDOWN-MATH-001
- REQ-UI-MARKDOWN-MATH-002
system_design:
- ../../specs/ui/system-design/markdown-math.md
legacy_specs: []
---
# Implementation Plan: Markdown Math
## Overview
One sequential work order delivers shared math rendering and its regression evidence.
The shared parser, renderer wiring, and dependency lockfile belong in the same implementation pass.
The implementation is complete after the explicit implementation request.
## Scope
### In scope
- [Formula and compatibility requirements](../../specs/ui/requirements/markdown-math.md).
- Shared KaTeX dependencies, stylesheet, parser, sanitizer schemas, and renderer integration.
- Desktop and phone tests, plus a short public syntax reference.
### Out of scope
- Formula editing, standalone TeX compilation, backend changes, and new preferences.
- Specialized pipelines identified in the paired design.
## Technical approach
Use the [system design](../../specs/ui/system-design/markdown-math.md) as the renderer inventory and pipeline authority.
Start with failing tests for the requested examples before integrating dependencies and plugins.
Use parsed-node compatibility only where installed plugin behavior fails the required syntax cases.
Retain source offsets, sanitizer ordering, and existing motion behavior.
Apply math rendering to every consumer of the shared remark list in the same work order.
## ASCII UI preview
### UI-01: Formula reading, desktop and phone
Entry: an existing chat message, document body, comment, or file preview.
State: a complete formula inside surrounding prose.
```text
+------------------------------------+
| Energy: E = mc^2 |
| |
| a |
| --- |
| b |
| |
| Cost: $100 and $200 |
+------------------------------------+
```
The sketch uses ASCII approximations for typeset math. Formula spacing is illustrative.
Required structure: inline formulas stay in prose, display formulas use a separate region, and currency stays literal.
On phones, wide display formulas scroll horizontally within that region.
The existing chat or preview owns vertical scrolling. Navigation and controls do not change.
An incomplete expression remains readable until it closes. Invalid TeX shows a nonfatal fallback.
This view maps to AC-UI-MARKDOWN-MATH-001.1 through 001.6 and AC-UI-MARKDOWN-MATH-002.1.
## Tests
| Criteria | Planned evidence |
| --- | --- |
| 001.1, 001.2, 002.1, 002.2 | `markdown-components.test.tsx`: inline, standalone display, multiline display, currency, escapes, and code |
| 001.3, 002.4 | New `markdown-math-renderers.test.tsx`: four primary renderer integrations and sanitizer safety |
| 001.6, 002.5 | `chat-markdown-motion.test.tsx`: appended formulas and prose with animation enabled and disabled |
| 002.3, 002.5 | Existing markdown and preview suites, plus focused formula/source-position regressions |
All abbreviated criteria use the prefix `AC-UI-MARKDOWN-MATH-`.
New compatibility logic gets unit coverage in `lib/markdown/remark-math-compat.test.ts` if required.
Tests must use real plugins for rendering assertions. Existing mocked memoization tests remain distinct.
## E2E tests
Add `e2e/tests/chat/markdown-math.spec.ts` for the `chromium` project.
Add `e2e/tests/chat/mobile-markdown-math.spec.ts` for the `mobile-chrome` project.
Use the mock-agent message fixture and existing file-preview helper patterns.
- Render inline and display formulas in chat and file previews, including the supplied nested SPC fraction.
- Verify light and dark themes, loaded KaTeX fonts, and visible fractions.
- Verify currency and malformed input beside valid formulas.
- On phones, verify local formula scrolling, reachable formula edges, and no document overflow.
- Preserve existing Markdown preview HTML-safety and comment-selection tests.
These flows cover 001.1 through 001.6, 002.1, 002.4, and 002.5.
Component tests cover comments and tool document bodies without adding artificial browser routes.
## Work orders
- [x] [Task 01: Render math across shared Markdown](task-01-render-math.md) (`done`, wave 1, no dependencies)
## Verification results
Design validation passed on 2026-09-22:
- Catalog validation: 299 decisions and 1110 specifications.
- Specification linter tests: 36 passed.
- Full specification lint: passed.
- Whitespace validation: passed.
Implementation validation passed on 2026-09-22:
- TDD RED was recorded before plugin wiring; the final focused suite passed 10 files and 63 tests, including preview source/comment mapping, raw delimiter whitespace, and sanitizer marker regressions.
- Typecheck, lint, and the production Vite build passed, with KaTeX font assets bundled by Vite.
- Chromium browser coverage passed 11 tests, including light and dark chat themes, KaTeX font loading, sanitized file preview, and existing Markdown preview flows.
- Mobile Chromium coverage passed 2 tests for local display-formula overflow in chat and the native file preview viewer.
- Specification catalog validation, full specification lint, public docs tests, public docs validation, and whitespace checks passed.
## Risks
- Default math parsing can misread currency or classify single-line double-dollar formulas as inline math.
- Sanitization can remove display markers, and custom code components can intercept unconverted math nodes.
- Typography styles and centered wide formulas can clip content on phones.
- A missed shared-plugin consumer can parse formulas without rendering them.
- Preview source positions and chat motion offsets can drift after careless string preprocessing.
@@ -0,0 +1,155 @@
---
id: "01-render-math"
title: "Render math across shared Markdown"
status: done
wave: 1
depends_on: []
plan: "plan.md"
requirements:
- REQ-UI-MARKDOWN-MATH-001
- REQ-UI-MARKDOWN-MATH-002
acceptance_criteria:
- AC-UI-MARKDOWN-MATH-001.1
- AC-UI-MARKDOWN-MATH-001.2
- AC-UI-MARKDOWN-MATH-001.3
- AC-UI-MARKDOWN-MATH-001.4
- AC-UI-MARKDOWN-MATH-001.5
- AC-UI-MARKDOWN-MATH-001.6
- AC-UI-MARKDOWN-MATH-002.1
- AC-UI-MARKDOWN-MATH-002.2
- AC-UI-MARKDOWN-MATH-002.3
- AC-UI-MARKDOWN-MATH-002.4
- AC-UI-MARKDOWN-MATH-002.5
system_design:
- ../../specs/ui/system-design/markdown-math.md
---
# Task 01: Render Math Across Shared Markdown
## Summary
Integrate KaTeX throughout the shared Markdown pipeline.
Preserve ordinary text, sanitizer safety, source positions, and phone readability.
## In scope
- Install the requested packages through pnpm and update the workspace lockfile.
- Add stylesheet assets, shared plugins, and narrowly scoped syntax compatibility where required.
- Wire every shared-plugin consumer listed in the system design.
- Preserve preview sanitization and comment task-link behavior.
- Add meaningful unit, component, desktop, and phone coverage through TDD.
- Add the public syntax reference and record validation results.
## Out of scope
- Editor extensions, specialized Markdown pipelines, backend changes, and new settings.
- Unrelated renderer refactoring or broader sanitizer changes.
## Acceptance
- All mapped formula and compatibility criteria pass with the real rendering pipelines.
- Desktop and phone render readable formulas without page overflow or executable untrusted content.
- Required checks pass, documentation matches the implementation, and durable statuses reflect the results.
## ASCII UI preview
### UI-01: Formula reading, desktop and phone
```text
Energy: E = mc^2
a
---
b
Cost: $100 and $200
```
See the [full preview](plan.md#ascii-ui-preview).
Inline math stays in prose. Display math occupies its own region.
Phone overflow stays local to that region. The existing view owns vertical scrolling.
Spacing is illustrative. Applies to AC-UI-MARKDOWN-MATH-001.1 through 001.6 and 002.1.
## Verification
Run from the repository root unless the command changes directories.
If dependencies are absent, install them before running pnpm checks.
```bash
cd apps && pnpm install --frozen-lockfile
```
Install feature dependencies during the implementation phase:
```bash
cd apps/web && pnpm add remark-math rehype-katex katex
cd apps/web && pnpm add -D @types/katex
```
Run each command from a fresh repository-root shell:
```bash
cd apps/web && pnpm vitest run components/shared/markdown-components.test.tsx
cd apps/web && pnpm vitest run components/shared/markdown-math-renderers.test.tsx components/shared/memoized-markdown.test.tsx components/shared/chat-markdown-motion.test.tsx components/task/markdown-preview-content.test.ts components/task/markdown-preview-content.external-link.test.tsx
cd apps/web && pnpm run typecheck
cd apps/web && pnpm run lint
cd apps/web && pnpm e2e:run --project chromium e2e/tests/chat/markdown-math.spec.ts e2e/tests/chat/markdown-preview.spec.ts
cd apps/web && pnpm e2e:run --project mobile-chrome e2e/tests/chat/mobile-markdown-math.spec.ts
python3 scripts/list-docs.py validate
python3 scripts/lint-spec-files.py --all
node --test scripts/validate-public-docs.test.mjs
node scripts/validate-public-docs.mjs
git diff --check
```
If compatibility code is required, also run its focused tests:
```bash
cd apps/web && pnpm vitest run lib/markdown/remark-math-compat.test.ts
```
Record the failing test before implementation, then the passing result.
The E2E runner rebuilds production assets. Run suites sequentially with the configured worker budget.
Inspect desktop and phone screenshots in both themes. Record any unavailable runtime dependency as a blocker.
Do not claim browser validation from component snapshots alone.
## Files likely touched
- `apps/web/package.json`, `apps/pnpm-lock.yaml`, `apps/web/app/globals.css`.
- `apps/web/components/shared/markdown-components.tsx` and its test file.
- The eleven consumers listed in the system design, including the four primary renderers.
- Proposed `apps/web/lib/markdown/remark-math-compat.ts` and its test file, if parser gaps require them.
- Proposed `apps/web/lib/markdown/math-sanitize-schema.ts`, if needed to share exact marker allowances.
- `apps/web/lib/markdown/chat-text-motion-plugin.ts`, only if motion integration tests require a subtree exclusion.
- `apps/web/components/shared/{markdown-math-renderers,memoized-markdown,chat-markdown-motion}.test.tsx`.
- `apps/web/components/task/markdown-preview-content.test.ts` and `.external-link.test.tsx`.
- `apps/web/e2e/tests/chat/{markdown-math,mobile-markdown-math}.spec.ts`.
- `docs/public/sessions-and-review.md` and this design package's statuses/results.
## Dependencies
None. Implementation is authorized by the current user request.
## Risks
See the [plan risks](plan.md#risks).
Preserve existing schema attributes when extending the class allowlist.
Do not weaken the currency assertion to accept KaTeX output.
## Parallelism
`sequential`
## Inputs
- [Requirements](../../specs/ui/requirements/markdown-math.md).
- [System design](../../specs/ui/system-design/markdown-math.md).
- `apps/web/AGENTS.md` and `apps/web/components/task/chat/AGENTS.md`.
- Existing `markdown-components.test.tsx`, `mobile-markdown-wrap.spec.ts`, and `markdown-preview.spec.ts` patterns.
- Repository `/tdd`, `/e2e`, `/mobile-parity`, and `/docs-maintainer` skills.
## Results
Implemented shared KaTeX Markdown rendering across the shared plugin consumers. Added parsed-node compatibility for currency literals and single-line display formulas, preserved sanitizer ordering and chat motion boundaries, bundled KaTeX fonts through the Vite entry, added local phone overflow styling, and documented the supported syntax.
TDD RED was recorded before plugin wiring. The final focused suite passed 10 files and 63 tests, including preview source/comment mapping, raw delimiter whitespace, and sanitizer marker regressions. Typecheck, lint, production build, Chromium coverage (11 tests across light and dark themes), mobile Chromium coverage (2 tests for chat and the native file preview viewer), specification validation, public docs validation, and whitespace checks all passed on 2026-09-22.
+24
View File
@@ -255,6 +255,30 @@ the desktop/tablet bottom bar or phone Status drawer. Configure the plugin under
Provider Usage**. Kandev hides the context ring rather than presenting
impossible data when reported use exceeds the reported window.
## Render math in Markdown
Shared Markdown reading surfaces render common LaTeX formulas with KaTeX. Use
single dollar signs for inline math and double dollar signs for a display
formula:
```markdown
Energy: $E = mc^2$
$$
\frac{a}{b}
$$
```
A standalone display can also use one line, such as `$$a^2 + b^2 = c^2$$`.
Escape a dollar sign when you need literal currency text, such as `\$100`.
Text such as `$100 and $200` remains plain text.
Formulas use the existing chat or file-preview reading area. Wide display
formulas scroll inside their own region on a phone, so the page keeps its
normal width. This rendering applies to agent chat, Markdown file previews,
task documents and plans, comments, pull-request and work-item descriptions,
release notes, changelog entries, walkthroughs, findings, and queued messages.
## Control chat animations
Open **Settings > Preferences > Appearance** and change **Chat animations**.
@@ -0,0 +1,52 @@
---
status: active
system: ui
created: 2026-09-22
owners:
- kandev
---
# Markdown Math Requirements
## Overview
Readers need formatted formulas in agent responses, document previews, and comments.
UI owns this reusable presentation contract across shared Markdown surfaces.
Task storage and editor behavior remain with their existing owners.
## Requirements
### REQ-UI-MARKDOWN-MATH-001: Readable formulas
**Intent:** Readers can understand mathematical expressions without reading TeX source.
#### Acceptance criteria
- **AC-UI-MARKDOWN-MATH-001.1:** Inline `$E = mc^2$` shall show formatted math within the surrounding sentence.
- **AC-UI-MARKDOWN-MATH-001.2:** Standalone `$$\frac{a}{b}$$` shall show a display formula. Delimiters on separate lines shall also work.
- **AC-UI-MARKDOWN-MATH-001.3:** Chat messages, Markdown previews, document bodies, comments, and other shared Markdown surfaces shall show the same formula semantics.
- **AC-UI-MARKDOWN-MATH-001.4:** Formulas shall remain legible in light and dark themes, with an accessible mathematical representation.
- **AC-UI-MARKDOWN-MATH-001.5:** On phones, wide display formulas shall scroll within their content region without widening the page or hiding formula content.
- **AC-UI-MARKDOWN-MATH-001.6:** Incoming chat formulas shall render with animations enabled or disabled. Incomplete or invalid formulas shall not prevent surrounding content from rendering.
### REQ-UI-MARKDOWN-MATH-002: Markdown compatibility
**Intent:** Formula support preserves ordinary text and the existing content safety boundary.
#### Acceptance criteria
- **AC-UI-MARKDOWN-MATH-002.1:** `$100 and $200` and escaped dollar signs shall remain literal text without math containers.
- **AC-UI-MARKDOWN-MATH-002.2:** Single-dollar math requires non-whitespace characters immediately inside both delimiters. Dollar signs inside inline code and ordinary fenced code shall remain literal.
- **AC-UI-MARKDOWN-MATH-002.3:** Existing headings, tables, task links, file links, emoji, line breaks, and Mermaid diagrams shall retain their behavior.
- **AC-UI-MARKDOWN-MATH-002.4:** Formula rendering shall not enable executable HTML, unsafe URLs, or trusted TeX commands from untrusted content.
- **AC-UI-MARKDOWN-MATH-002.5:** Existing preview source-line comments and surrounding chat text animation shall retain their positions and behavior.
## Out of scope
- A formula editor, TeX document compilation, and custom macro configuration.
- New behavior in TipTap editing, exported share snapshots, clarification prompts, or authentication setup hints.
- Backend changes, persisted source rewriting, and new settings.
## Implementation plans
- [Markdown math](../../../plans/markdown-math/plan.md)
@@ -0,0 +1,129 @@
---
status: current
system: ui
requirements:
- REQ-UI-MARKDOWN-MATH-001
- REQ-UI-MARKDOWN-MATH-002
---
# Markdown Math System Design
## Purpose and boundaries
The shared Markdown pipeline owns formula presentation. This change adds no backend API or persistence contract.
The [comment Markdown contract](../requirements/comment-markdown.md) continues to own existing formatting and link behavior.
The [chat motion design](chat-motion.md) continues to own animation eligibility and source offsets.
## Requirement mapping
| Requirement | Design sections |
| --- | --- |
| REQ-UI-MARKDOWN-MATH-001 | Shared pipeline, Renderer integration, Presentation and mobile |
| REQ-UI-MARKDOWN-MATH-002 | Syntax compatibility, Safety and failure behavior, Verification |
## Shared pipeline
Add `remark-math`, `rehype-katex`, and `katex` as web runtime dependencies.
Add `@types/katex` as a development dependency. Update `apps/pnpm-lock.yaml` through pnpm.
Import `katex/dist/katex.min.css` from the Vite entry at `apps/web/src/main.tsx`
before the application stylesheet. This lets Vite bundle the referenced font
assets. Fonts remain bundled assets, without a CDN dependency.
`components/shared/markdown-components.tsx` exports both plugin lists.
The remark list retains GFM, breaks, and emoji, then adds math and any required syntax compatibility transform.
The rehype list contains KaTeX with trust disabled and the standard HTML/MathML output.
Use `PluggableList` where available instead of adding another untyped array.
## Syntax compatibility
The requested examples define the contract. Default plugin behavior alone is insufficient evidence for currency or single-line display support.
First, reproduce those cases with the installed library versions.
A small proposed `lib/markdown/remark-math-compat.ts` transform addresses confirmed parser gaps after `remarkMath`.
It operates on parsed math nodes and their original source positions, without a whole-document regular-expression rewrite.
For single-dollar inline nodes, inspect the original delimiter-adjacent characters.
If either inner character is whitespace, restore the exact source slice as a text node.
This preserves `$100 and $200`, whose apparent closing dollar has preceding whitespace.
Keep ordinary `$E = mc^2$`, `$2x$`, and `$100$` valid.
Escaped delimiters and code nodes remain outside this transform.
Ambiguous unescaped dollar pairs beyond this rule retain standard math parsing. Literal dollars can use backslash escapes.
A paragraph containing only a double-dollar expression becomes a display math node if the parser produced inline math.
Use the source slice to distinguish double-dollar delimiters from single-dollar syntax.
Preserve source positions and create the standard math HAST markers through the library's node shape.
Multiline display math remains native parser behavior.
An expression embedded within a larger sentence retains native inline behavior.
## Renderer integration
All paths in this section are relative to `apps/web/`.
| Consumer | Rehype order |
| --- | --- |
| `components/shared/memoized-markdown.tsx` | Shared KaTeX plugins, then motion plugins |
| `components/task/markdown-preview-content.tsx` | Raw HTML, sanitizer with math markers, shared KaTeX plugins |
| `components/task/simple/markdown-comment.tsx` | Existing comment sanitizer with math markers, shared KaTeX plugins |
| `components/task/chat/messages/kandev/document-renderers.tsx` | Shared KaTeX plugins |
`MemoizedMarkdown` memoizes the combined plugin array and retains the normalized source for motion offsets.
KaTeX-generated nodes have no source mapping suitable for text animation.
Regression tests must prove that no motion spans enter generated HTML or MathML.
If necessary, explicitly skip KaTeX subtrees in `lib/markdown/chat-text-motion-plugin.ts`.
The same shared-plugin wiring also applies to these existing consumers:
- `components/github/pr-shared.tsx`
- `components/azure-devops/azure-devops-work-item-detail.tsx`
- `components/release-notes/release-notes-dialog.tsx`
- `components/diff/walkthrough-step-card.tsx`
- `components/diff/review-finding-card.tsx`
- `components/task/chat/queued-ghost-message.tsx`
- `components/settings/changelog-list.tsx`
Specialized clarification, share-snapshot, and authentication-hint pipelines stay outside this change.
Their plugin lists do not consume the shared math parser.
## Safety and failure behavior
Raw HTML remains enabled only on the existing preview surface.
`rehypeRaw` remains immediately followed by `rehypeSanitize`.
Extend each existing schema's code class allowlist with exact `math-inline` and `math-display` values.
Retain the existing language-class rule and all other attributes and protocol rules.
A small shared schema helper or constant can prevent the two schemas from drifting.
Do not permit arbitrary styles, HTML attributes, or MathML from raw input.
KaTeX runs after sanitization and supplies its own generated markup.
This follows the upstream [sanitizer math example](https://github.com/rehypejs/rehype-sanitize#example-math).
The [KaTeX plugin documentation](https://github.com/remarkjs/remark-math/tree/main/packages/rehype-katex) describes math markers and rendering behavior.
Trust remains false. Do not share mutable macro definitions across messages.
Invalid TeX uses the plugin's nonfatal fallback. Surrounding Markdown remains usable.
No global exception handler, new telemetry, or new localized UI copy is required.
## Presentation and mobile
Inline math participates in existing prose. Display math occupies a separate reading region.
The shared stylesheet supplies bounded horizontal scrolling for wide display formulas.
Ensure the left edge remains reachable when KaTeX centers an oversized expression.
Scope overflow styles to math containers rather than every generated span.
Preserve MathML accessibility and the visual HTML tree's existing accessibility attributes.
The phone entry point remains Task Chat or the current file preview.
The nearest exemplar is `e2e/tests/chat/mobile-markdown-wrap.spec.ts`, which proves local overflow containment.
The existing full-height chat or viewer remains the vertical scroll owner.
Formula overflow adds only a local horizontal reading region, with no new toolbar, drawer, or navigation.
Existing safe areas and composer placement remain unchanged.
The desktop and phone compositions are identical at the formula level.
## Verification
Component tests use the real shared plugins and component overrides.
They cover both sanitized pipelines, motion composition, currency, code, links, and invalid TeX.
Preview tests retain source-line comment coverage and existing raw-HTML safety assertions.
Browser tests prove bundled fonts, theme rendering, and phone overflow containment.
The [work order](../../../plans/markdown-math/task-01-render-math.md) owns exact commands and evidence.
## Documentation
During implementation, add a short Markdown math section to `docs/public/sessions-and-review.md`.
Describe inline math, display math, literal dollar escaping, and the supported reading surfaces.
This is reference content. Do not claim TipTap or share exports support formulas.