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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,10 @@
"jose": "catalog:",
"jsonc-parser": "3.3.1",
"jszip": "3.10.1",
"katex": "^0.16.47",
"lucide": "^0.564.0",
"lucide-react": "^0.564.0",
"mdast-util-math": "^3.0.0",
"mermaid": "^11.17.2",
"morphicons": "^1.7.1",
"react": "19.2.6",
Expand Down Expand Up @@ -90,6 +92,8 @@
"babel-plugin-react-compiler": "1.0.0",
"compression": "^1.8.2",
"jsdom": "^30.0.1",
"mdast-util-from-markdown": "^2.0.3",
"micromark-util-types": "^2.0.2",
"react-test-renderer": "19.2.6",
"tailwindcss": "^4.0.0",
"unified": "catalog:",
Expand Down
75 changes: 73 additions & 2 deletions apps/web/src/components/ChatMarkdown.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,16 @@ vi.mock("./chat/MermaidDiagram", () => ({
MermaidDiagram: () => <svg aria-label="Diagram" />,
}));
vi.mock("../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) }));
const settingsOverrides = vi.hoisted(() => ({ mathRenderingEnabled: false }));
vi.mock("../hooks/useSettings", async (importOriginal) => {
const actual = await importOriginal<typeof import("../hooks/useSettings")>();
const settings = actual.getClientSettings();
return {
...actual,
useClientSettings: (select?: (value: typeof settings) => unknown) =>
select ? select(settings) : settings,
useClientSettings: (select?: (value: typeof settings) => unknown) => {
const value = { ...settings, ...settingsOverrides };
return select ? select(value) : value;
},
};
});
vi.mock("./ui/tooltip", async () => {
Expand Down Expand Up @@ -545,6 +548,74 @@ describe("ChatMarkdown streaming", () => {
});
});

describe("ChatMarkdown math", () => {
it("typesets only when enabled, keeping prices and task offsets intact", async () => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
const text = "Area $\\pi r^2$ costs $5 and $10.\n\n\\[\nE = mc^2\n\\]\n\n- [ ] check \\(x\\)";
const renderers: ReactTestRenderer[] = [];
const render = async (parseRawHtml: boolean) => {
await act(async () => {
renderers.push(
create(<ChatMarkdown cwd="/tmp/project" text={text} parseRawHtml={parseRawHtml} />),
);
});
const renderer = renderers.at(-1)!;
return {
formulas: renderer.root
.findAll((node) => node.type === "span" && node.props["data-markdown-math"] === "")
.map((formula) => formula.props["data-markdown-copy"]),
markerOffset: Number(renderer.root.findByType("li").props["data-task-marker-offset"]),
};
};

try {
expect((await render(true)).formulas).toEqual([]);
// Raw HTML carrying the math classes stays code while the setting is off.
await act(async () => {
renderers.push(
create(
<ChatMarkdown
cwd="/tmp/project"
text={'<code class="language-math math-inline">x</code>'}
/>,
),
);
});
expect(
renderers.at(-1)!.root.findAll((node) => node.props["data-markdown-math"] === ""),
).toEqual([]);
settingsOverrides.mathRenderingEnabled = true;
// Both the sanitized raw-HTML pipeline and the literal one keep the math classes.
for (const parseRawHtml of [true, false]) {
const { formulas, markerOffset } = await render(parseRawHtml);
expect(formulas).toEqual(["$\\pi r^2$", "$$\nE = mc^2\n$$\n\n", "$x$"]);
expect(setMarkdownTaskChecked(text, markerOffset, true)).toBe(
text.replace("- [ ]", "- [x]"),
);
}
// List indentation recovery parses blocks again; they still typeset.
const recovered = await act(async () =>
create(
<ChatMarkdown
cwd="/tmp/project"
text={"- intro\n\n $$\n x^2\n $$"}
/>,
),
);
renderers.push(recovered);
expect(
recovered.root
.findAll((node) => node.type === "span" && node.props["data-markdown-math"] === "")
.map((formula) => formula.props["data-markdown-copy"]),
).toEqual(["$$\nx^2\n$$\n\n"]);
} finally {
settingsOverrides.mathRenderingEnabled = false;
await act(async () => renderers.forEach((renderer) => renderer.unmount()));
vi.unstubAllGlobals();
}
});
});

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

Expand Down
39 changes: 36 additions & 3 deletions apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,11 +107,13 @@ import { parseThreadLinkHref } from "@t3tools/shared/threadLinks";
import { AssistantCitationChip } from "./chat/AssistantCitationChip";
import { MarkdownThreadLink } from "./chat/MarkdownThreadLink";
import { isWindowsAbsolutePath } from "@t3tools/shared/path";
import { mathKind, mayContainMath, remarkChatMath } from "../markdown-math";
import {
artifactTemplateFromHastProperties,
renderCodexFileCitationsAsMarkdown,
} from "@t3tools/shared/codexMarkdownDirectives";
import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText";
import { MarkdownMath } from "./chat/MarkdownMath";
import {
resolveMarkdownMediaPreview,
type ExpandedImagePreview,
Expand Down Expand Up @@ -404,6 +406,22 @@ function orderedListGutterStyle(
return { "--list-gutter": `${markerWidth + 2}ch` };
}

const CHAT_MARKDOWN_MATH_REMARK_PLUGINS = [remarkChatMath, ...CHAT_MARKDOWN_REMARK_PLUGINS];

const CHAT_MARKDOWN_MATH_REMARK_PLUGINS_WITH_BREAKS = [
remarkChatMath,
...CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS,
];

function chatMarkdownRemarkPlugins(lineBreaks: boolean, math: boolean) {
if (math) {
return lineBreaks
? CHAT_MARKDOWN_MATH_REMARK_PLUGINS_WITH_BREAKS
: CHAT_MARKDOWN_MATH_REMARK_PLUGINS;
}
return lineBreaks ? CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS : CHAT_MARKDOWN_REMARK_PLUGINS;
}

const GITHUB_ALERT_PRESENTATIONS: Record<
string,
{ label: string; Icon: typeof InfoIcon; borderClassName: string; titleClassName: string }
Expand Down Expand Up @@ -3396,6 +3414,19 @@ const CHAT_MARKDOWN_COMPONENTS = {
},
} satisfies Components;

const MarkdownCodeOrMath: NonNullable<Components["code"]> = (props) => {
const math = mathKind(props.className);
if (!math) return CHAT_MARKDOWN_COMPONENTS.code(props);
return <MarkdownMath tex={nodeToPlainText(props.children)} display={math === "display"} />;
};

// Only messages parsed with math enabled can typeset, so raw HTML carrying the
// math classes stays an ordinary code element while the setting is off.
const CHAT_MARKDOWN_MATH_COMPONENTS = {
...CHAT_MARKDOWN_COMPONENTS,
code: MarkdownCodeOrMath,
} satisfies Components;

function ChatMarkdown({
text,
className,
Expand All @@ -3416,13 +3447,15 @@ function ChatMarkdown({
props.isStreaming === true &&
extraRemarkPlugins.length === 0 &&
/(?:^|\n) {0,3}(?:`{3}|~{3})/.test(text);
const math =
useClientSettings((settings) => settings.mathRenderingEnabled) && mayContainMath(text);
const remarkPlugins = useMemo(
() => [
...(lineBreaks ? CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS : CHAT_MARKDOWN_REMARK_PLUGINS),
...chatMarkdownRemarkPlugins(lineBreaks, math),
...extraRemarkPlugins,
...(incrementalParsing ? [createIncrementalMarkdownPlugin()] : []),
],
[extraRemarkPlugins, incrementalParsing, lineBreaks],
[extraRemarkPlugins, incrementalParsing, lineBreaks, math],
);

// react-markdown converts unparsed HTML nodes to text when skipHtml is false.
Expand All @@ -3448,7 +3481,7 @@ function ChatMarkdown({
: CHAT_MARKDOWN_LITERAL_HTML_REHYPE_PLUGINS
}
skipHtml={false}
components={CHAT_MARKDOWN_COMPONENTS}
components={math ? CHAT_MARKDOWN_MATH_COMPONENTS : CHAT_MARKDOWN_COMPONENTS}
urlTransform={markdownUrlTransform}
>
{text}
Expand Down
29 changes: 29 additions & 0 deletions apps/web/src/components/chat/MarkdownMath.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { lazy, memo, Suspense } from "react";

import { RenderErrorBoundary } from "../RenderErrorBoundary";

const MathTypeset = lazy(() => import("./MathTypeset"));

/**
* One formula from `remarkChatMath`. Shows its TeX until KaTeX loads, and
* keeps showing it when KaTeX cannot parse it, such as mid-stream. Copying
* any part of a formula copies its whole TeX source.
*/
export const MarkdownMath = memo(function MarkdownMath(props: { tex: string; display: boolean }) {
const tex = props.tex.trim();
const source = props.display ? `$$\n${tex}\n$$` : `$${tex}$`;
const fallback = <span className="chat-markdown-math-source">{source}</span>;
return (
<span
className={props.display ? "chat-markdown-math-display" : undefined}
data-markdown-math=""
data-markdown-copy={props.display ? `${source}\n\n` : source}
>
<RenderErrorBoundary resetKeys={[tex]} fallback={fallback}>
<Suspense fallback={fallback}>
<MathTypeset tex={tex} display={props.display} fallback={fallback} />
</Suspense>
</RenderErrorBoundary>
</span>
);
});
40 changes: 40 additions & 0 deletions apps/web/src/components/chat/MathTypeset.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import katex from "katex";
import "katex/dist/katex.min.css";
import type { ReactNode } from "react";

import { LRUCache } from "../../lib/lruCache";

// Virtualized threads remount messages while scrolling; cached markup keeps
// that from typesetting the same formulas again. "" records a parse error.
const typesetCache = new LRUCache<string>(1000, 4 * 1024 * 1024);

function typeset(tex: string, display: boolean): string {
const key = `${display ? "display" : "inline"}:${tex}`;
const cached = typesetCache.get(key);
if (cached !== null) return cached;
let html = "";
try {
html = katex.renderToString(tex, {
displayMode: display,
output: "htmlAndMathml",
throwOnError: true,
// No \href, \url, \htmlClass, or other commands that emit active HTML.
trust: false,
strict: "ignore",
maxSize: 20,
maxExpand: 1000,
});
} catch {
// Unfinished or unsupported TeX stays readable as its source.
}
typesetCache.set(key, html, (key.length + html.length) * 2);
return html;
}

/** KaTeX lives in this lazily loaded chunk, so it is only fetched once a formula renders. */
export default function MathTypeset(props: { tex: string; display: boolean; fallback: ReactNode }) {
const html = typeset(props.tex, props.display);
if (!html) return props.fallback;
// KaTeX escapes the TeX it renders, and `trust: false` keeps it from emitting links or attributes.
return <span dangerouslySetInnerHTML={{ __html: html }} />;
}
30 changes: 30 additions & 0 deletions apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -548,6 +548,9 @@ export function useSettingsRestore(onRestored?: () => void) {
? ["Diff colors"]
: []),
...(settings.chatWidth !== DEFAULT_UNIFIED_SETTINGS.chatWidth ? ["Chat width"] : []),
...(settings.mathRenderingEnabled !== DEFAULT_UNIFIED_SETTINGS.mathRenderingEnabled
? ["Render math"]
: []),
...(settings.panelAnimationDurationMs !== DEFAULT_UNIFIED_SETTINGS.panelAnimationDurationMs
? ["Panel animations"]
: []),
Expand Down Expand Up @@ -675,6 +678,7 @@ export function useSettingsRestore(onRestored?: () => void) {
settings.appearanceContrast,
settings.diffColorScheme,
settings.chatWidth,
settings.mathRenderingEnabled,
settings.enableAgentBrowserAccess,
settings.confirmQuit,
settings.confirmThreadArchive,
Expand Down Expand Up @@ -792,6 +796,7 @@ export function useSettingsRestore(onRestored?: () => void) {
appearanceContrast: DEFAULT_UNIFIED_SETTINGS.appearanceContrast,
diffColorScheme: DEFAULT_UNIFIED_SETTINGS.diffColorScheme,
chatWidth: DEFAULT_UNIFIED_SETTINGS.chatWidth,
mathRenderingEnabled: DEFAULT_UNIFIED_SETTINGS.mathRenderingEnabled,
timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat,
notificationMode: DEFAULT_UNIFIED_SETTINGS.notificationMode,
inAppNotificationsEnabled: DEFAULT_UNIFIED_SETTINGS.inAppNotificationsEnabled,
Expand Down Expand Up @@ -1457,6 +1462,31 @@ export function AppearanceSettingsPanel() {
</div>
}
/>
<SettingsRow
{...searchableSetting("math-rendering")}
description="Typeset LaTeX math, such as $x^2$ or \[x^2\], in messages and Markdown previews. Web and desktop only."
resetAction={
settings.mathRenderingEnabled !== DEFAULT_UNIFIED_SETTINGS.mathRenderingEnabled ? (
<SettingResetButton
label="math rendering"
onClick={() =>
updateSettings({
mathRenderingEnabled: DEFAULT_UNIFIED_SETTINGS.mathRenderingEnabled,
})
}
/>
) : null
}
control={
<Switch
checked={settings.mathRenderingEnabled}
onCheckedChange={(checked) =>
updateSettings({ mathRenderingEnabled: Boolean(checked) })
}
aria-label="Render math"
/>
}
/>
</SettingsSection>

<SettingsSection id="motion" title="Motion">
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/components/settings/settingsSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,12 @@ export const SETTINGS_SEARCH_ITEMS = [
to: "/settings/appearance",
searchTerms: ["wide full width column layout messages composer monitor"],
},
{
id: "math-rendering",
title: "Render math",
to: "/settings/appearance",
searchTerms: ["latex tex katex equations formulas markdown"],
},
{
id: "panel-animations",
title: "Panel animations",
Expand Down
16 changes: 16 additions & 0 deletions apps/web/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -1786,6 +1786,22 @@ code {
margin: 0.65rem 0;
}

/* Wide display equations scroll instead of clipping or widening the message. */
.chat-markdown .chat-markdown-math-display {
display: block;
margin: 0.65rem 0;
overflow-x: auto;
overflow-y: hidden;
}

.chat-markdown .chat-markdown-math-display .katex-display {
margin: 0;
}

.chat-markdown .chat-markdown-math-source {
white-space: pre-wrap;
}

:is(.chat-markdown, .composer-tiptap) h1,
:is(.chat-markdown, .composer-tiptap) h2,
:is(.chat-markdown, .composer-tiptap) h3,
Expand Down
28 changes: 28 additions & 0 deletions apps/web/src/markdown-clipboard.math.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// @vitest-environment jsdom

import { describe, expect, it } from "vite-plus/test";

import { chatMarkdownClipboardPayload } from "./markdown-clipboard";

function select(start: Node, startOffset: number, end: Node, endOffset: number) {
const range = document.createRange();
range.setStart(start, startOffset);
range.setEnd(end, endOffset);
const selection = window.getSelection()!;
selection.removeAllRanges();
selection.addRange(range);
return selection;
}

describe("copying rendered math", () => {
it("copies a formula's TeX whether the selection spans it or starts inside it", () => {
document.body.innerHTML = `<div class="chat-markdown"><p>Area <span data-markdown-math="" data-markdown-copy="$\\pi r^2$"><span class="katex"><span class="katex-mathml">πr2</span><span class="katex-html" aria-hidden="true">πr2</span></span></span> grows.</p></div>`;
const paragraph = document.querySelector("p")!;
const glyphs = document.querySelector(".katex-mathml")!.firstChild!;

expect(
chatMarkdownClipboardPayload(select(paragraph.firstChild!, 0, paragraph.lastChild!, 7))?.text,
).toBe("Area $\\pi r^2$ grows.");
expect(chatMarkdownClipboardPayload(select(glyphs, 0, glyphs, 1))?.text).toBe("$\\pi r^2$");
});
});
Loading
Loading