From c4262c644d47497a80765b8d4d38ed53b3700606 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Tue, 22 Sep 2026 23:36:47 +0100 Subject: [PATCH] feat: add KaTeX rendering to Markdown (#3868) --- apps/pnpm-lock.yaml | 134 ++++++++++++++ apps/web/app/globals.css | 14 ++ .../azure-devops-work-item-detail.tsx | 9 +- .../components/diff/review-finding-card.tsx | 7 +- .../components/diff/walkthrough-step-card.tsx | 7 +- apps/web/components/github/pr-shared.tsx | 13 +- .../release-notes/release-notes-dialog.tsx | 12 +- .../components/settings/changelog-list.tsx | 12 +- .../shared/chat-markdown-motion.test.tsx | 1 + .../shared/markdown-components.test.tsx | 90 +++++++++ .../components/shared/markdown-components.tsx | 18 +- .../shared/markdown-math-renderers.test.tsx | 54 ++++++ .../shared/memoized-markdown.test.tsx | 1 + .../components/shared/memoized-markdown.tsx | 3 +- .../task/chat/__evals__/render-cost.test.tsx | 1 + .../kandev/document-renderers.test.tsx | 11 +- .../messages/kandev/document-renderers.tsx | 12 +- .../task/chat/queued-ghost-message.tsx | 8 +- .../markdown-preview-content.math.test.tsx | 110 +++++++++++ .../task/markdown-preview-content.tsx | 35 +++- .../task/simple/markdown-comment.tsx | 13 +- apps/web/e2e/tests/chat/markdown-math.spec.ts | 175 ++++++++++++++++++ .../tests/chat/mobile-markdown-math.spec.ts | 157 ++++++++++++++++ .../lib/markdown/math-sanitize-schema.test.ts | 33 ++++ apps/web/lib/markdown/math-sanitize-schema.ts | 35 ++++ .../lib/markdown/rehype-math-source.test.ts | 88 +++++++++ apps/web/lib/markdown/rehype-math-source.ts | 64 +++++++ .../lib/markdown/remark-math-compat.test.ts | 115 ++++++++++++ apps/web/lib/markdown/remark-math-compat.ts | 95 ++++++++++ apps/web/package.json | 4 + apps/web/src/locales/ja/executors.json | 24 ++- apps/web/src/locales/ja/task.json | 12 +- apps/web/src/main.tsx | 1 + docs/plans/markdown-math/plan.md | 122 ++++++++++++ .../markdown-math/task-01-render-math.md | 155 ++++++++++++++++ docs/public/sessions-and-review.md | 24 +++ docs/specs/ui/requirements/markdown-math.md | 52 ++++++ docs/specs/ui/system-design/markdown-math.md | 129 +++++++++++++ 38 files changed, 1821 insertions(+), 29 deletions(-) create mode 100644 apps/web/components/shared/markdown-math-renderers.test.tsx create mode 100644 apps/web/components/task/markdown-preview-content.math.test.tsx create mode 100644 apps/web/e2e/tests/chat/markdown-math.spec.ts create mode 100644 apps/web/e2e/tests/chat/mobile-markdown-math.spec.ts create mode 100644 apps/web/lib/markdown/math-sanitize-schema.test.ts create mode 100644 apps/web/lib/markdown/math-sanitize-schema.ts create mode 100644 apps/web/lib/markdown/rehype-math-source.test.ts create mode 100644 apps/web/lib/markdown/rehype-math-source.ts create mode 100644 apps/web/lib/markdown/remark-math-compat.test.ts create mode 100644 apps/web/lib/markdown/remark-math-compat.ts create mode 100644 docs/plans/markdown-math/plan.md create mode 100644 docs/plans/markdown-math/task-01-render-math.md create mode 100644 docs/specs/ui/requirements/markdown-math.md create mode 100644 docs/specs/ui/system-design/markdown-math.md diff --git a/apps/pnpm-lock.yaml b/apps/pnpm-lock.yaml index b8cec56296..5d2f3fab90 100644 --- a/apps/pnpm-lock.yaml +++ b/apps/pnpm-lock.yaml @@ -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 diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index efab72071e..c8cf7a6b1a 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -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; diff --git a/apps/web/components/azure-devops/azure-devops-work-item-detail.tsx b/apps/web/components/azure-devops/azure-devops-work-item-detail.tsx index f54bb39de0..b1eae65890 100644 --- a/apps/web/components/azure-devops/azure-devops-work-item-detail.tsx +++ b/apps/web/components/azure-devops/azure-devops-work-item-detail.tsx @@ -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 }) { > {value} diff --git a/apps/web/components/diff/review-finding-card.tsx b/apps/web/components/diff/review-finding-card.tsx index b6c26fc633..1b0216e4fa 100644 --- a/apps/web/components/diff/review-finding-card.tsx +++ b/apps/web/components/diff/review-finding-card.tsx @@ -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" > - + {normalizeMarkdown(finding.body)} diff --git a/apps/web/components/diff/walkthrough-step-card.tsx b/apps/web/components/diff/walkthrough-step-card.tsx index 3428eaeab4..5bf0a05452 100644 --- a/apps/web/components/diff/walkthrough-step-card.tsx +++ b/apps/web/components/diff/walkthrough-step-card.tsx @@ -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: () :{lineLabel}
- + {normalizeMarkdown(step.text)}
diff --git a/apps/web/components/github/pr-shared.tsx b/apps/web/components/github/pr-shared.tsx index c93a017a2b..7da4134a1b 100644 --- a/apps/web/components/github/pr-shared.tsx +++ b/apps/web/components/github/pr-shared.tsx @@ -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 (
{body} diff --git a/apps/web/components/release-notes/release-notes-dialog.tsx b/apps/web/components/release-notes/release-notes-dialog.tsx index b670b427be..f30af7b52c 100644 --- a/apps/web/components/release-notes/release-notes-dialog.tsx +++ b/apps/web/components/release-notes/release-notes-dialog.tsx @@ -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({
)}
- + {entry.notes}
diff --git a/apps/web/components/settings/changelog-list.tsx b/apps/web/components/settings/changelog-list.tsx index 9be34f0a30..8cde4b96ee 100644 --- a/apps/web/components/settings/changelog-list.tsx +++ b/apps/web/components/settings/changelog-list.tsx @@ -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 }) {
- + {entry.notes}
diff --git a/apps/web/components/shared/chat-markdown-motion.test.tsx b/apps/web/components/shared/chat-markdown-motion.test.tsx index bb5e5f6fdc..630bfc793b 100644 --- a/apps/web/components/shared/chat-markdown-motion.test.tsx +++ b/apps/web/components/shared/chat-markdown-motion.test.tsx @@ -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" }; diff --git a/apps/web/components/shared/markdown-components.test.tsx b/apps/web/components/shared/markdown-components.test.tsx index a5299bca28..38d6ecdb6a 100644 --- a/apps/web/components/shared/markdown-components.test.tsx +++ b/apps/web/components/shared/markdown-components.test.tsx @@ -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( + + {source} + , + ); +} + function renderTaskMarkdown(source: string, taskId: string): string { return renderToStaticMarkup( @@ -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(); + + expect(container.querySelector(".katex")).not.toBeNull(); + }); +}); + describe("markdownComponents", () => { afterEach(resetMarkdownComponentTestState); diff --git a/apps/web/components/shared/markdown-components.tsx b/apps/web/components/shared/markdown-components.tsx index f4a0f09388..db511ac5e4 100644 --- a/apps/web/components/shared/markdown-components.tsx +++ b/apps/web/components/shared/markdown-components.tsx @@ -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; + +export const remarkPlugins: MarkdownPluginList = [ + remarkGfm, + remarkBreaks, + remarkGemoji, + remarkMath, + remarkMathCompat, +]; + +/** Shared rehype plugins used by non-raw Markdown renderers. */ +export const rehypePlugins: NonNullable = [rehypeKatex]; // `normalizeMarkdown` (pure string transform) and its cached variant live in // the React-free markdown cache module. Re-exported here so existing importers diff --git a/apps/web/components/shared/markdown-math-renderers.test.tsx b/apps/web/components/shared/markdown-math-renderers.test.tsx new file mode 100644 index 0000000000..1fb3b8b0ad --- /dev/null +++ b/apps/web/components/shared/markdown-math-renderers.test.tsx @@ -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( + + alert("xss")', + ].join("\n")} + /> + , + ); + + 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(); + + 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(); + + view.rerender(); + + expect(view.container.querySelector(".katex")).not.toBeNull(); + expect(view.container.querySelector(".katex [data-chat-text-motion]")).toBeNull(); + }); +}); diff --git a/apps/web/components/shared/memoized-markdown.test.tsx b/apps/web/components/shared/memoized-markdown.test.tsx index e039e5c7c0..3dde84c5fc 100644 --- a/apps/web/components/shared/memoized-markdown.test.tsx +++ b/apps/web/components/shared/memoized-markdown.test.tsx @@ -16,6 +16,7 @@ vi.mock("@/components/shared/markdown-components", () => ({ MarkdownFileLinkContext: createContext({}), MarkdownTaskContext: createContext(null), markdownComponents: {}, + rehypePlugins: [], remarkPlugins: [], })); diff --git a/apps/web/components/shared/memoized-markdown.tsx b/apps/web/components/shared/memoized-markdown.tsx index 4e27673bc1..c41664452c 100644 --- a/apps/web/components/shared/memoized-markdown.tsx +++ b/apps/web/components/shared/memoized-markdown.tsx @@ -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({ {normalized} diff --git a/apps/web/components/task/chat/__evals__/render-cost.test.tsx b/apps/web/components/task/chat/__evals__/render-cost.test.tsx index 64eb51fa52..53868e1f6f 100644 --- a/apps/web/components/task/chat/__evals__/render-cost.test.tsx +++ b/apps/web/components/task/chat/__evals__/render-cost.test.tsx @@ -19,6 +19,7 @@ vi.mock("@/components/shared/markdown-components", () => ({ MarkdownFileLinkContext: createContext({}), MarkdownTaskContext: createContext(null), markdownComponents: {}, + rehypePlugins: [], remarkPlugins: [], })); diff --git a/apps/web/components/task/chat/messages/kandev/document-renderers.test.tsx b/apps/web/components/task/chat/messages/kandev/document-renderers.test.tsx index ba16656655..02a7f59d4a 100644 --- a/apps/web/components/task/chat/messages/kandev/document-renderers.test.tsx +++ b/apps/web/components/task/chat/messages/kandev/document-renderers.test.tsx @@ -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( - , + , ); } @@ -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"'); + }); +}); diff --git a/apps/web/components/task/chat/messages/kandev/document-renderers.tsx b/apps/web/components/task/chat/messages/kandev/document-renderers.tsx index 1c2c307f02..6473774c5a 100644 --- a/apps/web/components/task/chat/messages/kandev/document-renderers.tsx +++ b/apps/web/components/task/chat/messages/kandev/document-renderers.tsx @@ -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 (
- + {content}
diff --git a/apps/web/components/task/chat/queued-ghost-message.tsx b/apps/web/components/task/chat/queued-ghost-message.tsx index 9cd4f6d8bf..c0f1d26ab3 100644 --- a/apps/web/components/task/chat/queued-ghost-message.tsx +++ b/apps/web/components/task/chat/queued-ghost-message.tsx @@ -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) => ( - + {preview} )} diff --git a/apps/web/components/task/markdown-preview-content.math.test.tsx b/apps/web/components/task/markdown-preview-content.math.test.tsx new file mode 100644 index 0000000000..fb9057532d --- /dev/null +++ b/apps/web/components/task/markdown-preview-content.math.test.tsx @@ -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( + + + , + ); + + const preview = screen.getByTestId("markdown-preview"); + const displays = [...preview.querySelectorAll(".katex-display")]; + expect(displays).toHaveLength(2); + + const singleLineSource = displays[0].closest(`[${SOURCE_START_ATTR}]`); + const multilineSource = displays[1].closest(`[${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(".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 }, + ); + }); +}); diff --git a/apps/web/components/task/markdown-preview-content.tsx b/apps/web/components/task/markdown-preview-content.tsx index 6ae9d48b97..d29e4af20b 100644 --- a/apps/web/components/task/markdown-preview-content.tsx +++ b/apps/web/components/task/markdown-preview-content.tsx @@ -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 ( + + {children} + + ); + } + + return ( +
+ {children} +
+ ); +} + const markdownPreviewComponents: Components = { ...markdownComponents, + div: (props) => , p: sourceComponent("p"), h1: sourceComponent("h1"), h2: sourceComponent("h2"), @@ -372,7 +398,12 @@ export function MarkdownPreviewRenderer({ {content} diff --git a/apps/web/components/task/simple/markdown-comment.tsx b/apps/web/components/task/simple/markdown-comment.tsx index 989bb242d3..5f6692a09f 100644 --- a/apps/web/components/task/simple/markdown-comment.tsx +++ b/apps/web/components/task/simple/markdown-comment.tsx @@ -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 (
{content} diff --git a/apps/web/e2e/tests/chat/markdown-math.spec.ts b/apps/web/e2e/tests/chat/markdown-math.spec.ts new file mode 100644 index 0000000000..5982db804e --- /dev/null +++ b/apps/web/e2e/tests/chat/markdown-math.spec.ts @@ -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 { + 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 { + 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(".katex"); + const base = element.querySelector(".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"))', + "", + "", + ].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); + }); +}); diff --git a/apps/web/e2e/tests/chat/mobile-markdown-math.spec.ts b/apps/web/e2e/tests/chat/mobile-markdown-math.spec.ts new file mode 100644 index 0000000000..27e31e5517 --- /dev/null +++ b/apps/web/e2e/tests/chat/mobile-markdown-math.spec.ts @@ -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 { + 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); + }); +}); diff --git a/apps/web/lib/markdown/math-sanitize-schema.test.ts b/apps/web/lib/markdown/math-sanitize-schema.test.ts new file mode 100644 index 0000000000..ad97ddbdfc --- /dev/null +++ b/apps/web/lib/markdown/math-sanitize-schema.test.ts @@ -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"]); + }); +}); diff --git a/apps/web/lib/markdown/math-sanitize-schema.ts b/apps/web/lib/markdown/math-sanitize-schema.ts new file mode 100644 index 0000000000..3d85589237 --- /dev/null +++ b/apps/web/lib/markdown/math-sanitize-schema.ts @@ -0,0 +1,35 @@ +import { defaultSchema, type Options as SanitizeSchema } from "rehype-sanitize"; + +type PropertyDefinition = NonNullable[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); diff --git a/apps/web/lib/markdown/rehype-math-source.test.ts b/apps/web/lib/markdown/rehype-math-source.test.ts new file mode 100644 index 0000000000..2d617cd125 --- /dev/null +++ b/apps/web/lib/markdown/rehype-math-source.test.ts @@ -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; + 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], + }); + }); +}); diff --git a/apps/web/lib/markdown/rehype-math-source.ts b/apps/web/lib/markdown/rehype-math-source.ts new file mode 100644 index 0000000000..fb920517c4 --- /dev/null +++ b/apps/web/lib/markdown/rehype-math-source.ts @@ -0,0 +1,64 @@ +type RehypeNode = { + type: string; + tagName?: string; + properties?: Record; + 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 `
`. */
+export function rehypeMathSource() {
+  return (tree: RehypeNode) => {
+    visit(tree);
+  };
+}
+
+export { MATH_SOURCE_CLASS };
diff --git a/apps/web/lib/markdown/remark-math-compat.test.ts b/apps/web/lib/markdown/remark-math-compat.test.ts
new file mode 100644
index 0000000000..0968a0584c
--- /dev/null
+++ b/apps/web/lib/markdown/remark-math-compat.test.ts
@@ -0,0 +1,115 @@
+import { describe, expect, it } from "vitest";
+import { remarkMathCompat } from "./remark-math-compat";
+
+function applyCompat(tree: Record, 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>)[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);
+  });
+});
diff --git a/apps/web/lib/markdown/remark-math-compat.ts b/apps/web/lib/markdown/remark-math-compat.ts
new file mode 100644
index 0000000000..2d31e5eb27
--- /dev/null
+++ b/apps/web/lib/markdown/remark-math-compat.ts
@@ -0,0 +1,95 @@
+type MarkdownNode = {
+  type: string;
+  value?: string;
+  children?: MarkdownNode[];
+  data?: Record;
+  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());
+  };
+}
diff --git a/apps/web/package.json b/apps/web/package.json
index 8ac7f62420..28945b8aea 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -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",
diff --git a/apps/web/src/locales/ja/executors.json b/apps/web/src/locales/ja/executors.json
index e7ebeb9b05..73a31025ce 100644
--- a/apps/web/src/locales/ja/executors.json
+++ b/apps/web/src/locales/ja/executors.json
@@ -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": "到達性"
 }
diff --git a/apps/web/src/locales/ja/task.json b/apps/web/src/locales/ja/task.json
index 299b2536bc..49be550b5c 100644
--- a/apps/web/src/locales/ja/task.json
+++ b/apps/web/src/locales/ja/task.json
@@ -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": "最後の試行は不明な理由で失敗しました。"
 }
diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx
index 9ab1767aef..ca83221f0d 100644
--- a/apps/web/src/main.tsx
+++ b/apps/web/src/main.tsx
@@ -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";
diff --git a/docs/plans/markdown-math/plan.md b/docs/plans/markdown-math/plan.md
new file mode 100644
index 0000000000..d020818e06
--- /dev/null
+++ b/docs/plans/markdown-math/plan.md
@@ -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.
diff --git a/docs/plans/markdown-math/task-01-render-math.md b/docs/plans/markdown-math/task-01-render-math.md
new file mode 100644
index 0000000000..c22f49c472
--- /dev/null
+++ b/docs/plans/markdown-math/task-01-render-math.md
@@ -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.
diff --git a/docs/public/sessions-and-review.md b/docs/public/sessions-and-review.md
index f419bf15d6..34346c002e 100644
--- a/docs/public/sessions-and-review.md
+++ b/docs/public/sessions-and-review.md
@@ -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**.
diff --git a/docs/specs/ui/requirements/markdown-math.md b/docs/specs/ui/requirements/markdown-math.md
new file mode 100644
index 0000000000..d405abeca3
--- /dev/null
+++ b/docs/specs/ui/requirements/markdown-math.md
@@ -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)
diff --git a/docs/specs/ui/system-design/markdown-math.md b/docs/specs/ui/system-design/markdown-math.md
new file mode 100644
index 0000000000..5b56a67697
--- /dev/null
+++ b/docs/specs/ui/system-design/markdown-math.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.