diff --git a/apps/desktop/src/app/chat/right-rail/preview-file.tsx b/apps/desktop/src/app/chat/right-rail/preview-file.tsx index 7e2fe83ae7..87913b1d55 100644 --- a/apps/desktop/src/app/chat/right-rail/preview-file.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview-file.tsx @@ -31,7 +31,9 @@ import { writeDesktopFileText } from '@/lib/desktop-fs' import { Check, Pencil, X } from '@/lib/icons' +import { createMemoizedMathPlugin } from '@/lib/katex-memo' import { shikiLanguageForFilename } from '@/lib/markdown-code' +import { normalizeFilePreviewMath } from '@/lib/markdown-preprocess' import { cn } from '@/lib/utils' import type { PreviewTarget } from '@/store/preview' import { setPreviewDirty } from '@/store/preview-edit' @@ -44,6 +46,11 @@ const SOURCE_CHUNK_LINES = 200 const SOURCE_LINE_PX = 20 const SOURCE_OVERSCAN_LINES = 400 +// Math plugin for the static file preview, configured once at module scope. +// Mirrors the chat transcript's plugin (`markdown-text.tsx`) — same memoized +// KaTeX wrapper, with `singleDollarTextMath: true` so `$x$` renders inline. +const previewMathPlugin = createMemoizedMathPlugin({ singleDollarTextMath: true }) + type EmptyStateTone = 'neutral' | 'warning' const TONE_STYLES: Record = { @@ -370,10 +377,18 @@ const MARKDOWN_COMPONENTS = { } function MarkdownPreview({ text }: { text: string }) { + const mathText = useMemo(() => normalizeFilePreviewMath(text), [text]) + return (
- - {text} + + {mathText}
) diff --git a/apps/desktop/src/lib/markdown-preprocess.file-preview.test.ts b/apps/desktop/src/lib/markdown-preprocess.file-preview.test.ts new file mode 100644 index 0000000000..dee8a6ef38 --- /dev/null +++ b/apps/desktop/src/lib/markdown-preprocess.file-preview.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' + +import { normalizeFilePreviewMath } from '@/lib/markdown-preprocess' + +describe('normalizeFilePreviewMath', () => { + it('leaves single-dollar inline math untouched', () => { + expect(normalizeFilePreviewMath('Price is $x^2 + y^2$ today')).toBe('Price is $x^2 + y^2$ today') + }) + + it('escapes currency dollar amounts so they are not parsed as math', () => { + const output = normalizeFilePreviewMath('Rent $500 and utilities $150.') + + expect(output).toContain('\\$500') + expect(output).toContain('\\$150') + }) + + it('does not escape dollars inside code fences', () => { + const input = ['Before', '```bash', 'echo $HOME', 'echo $PATH', '```', 'After'].join('\n') + + expect(normalizeFilePreviewMath(input)).toBe(input) + }) + + it('does not escape dollars inside inline code spans', () => { + expect(normalizeFilePreviewMath('Run `echo $HOME` now')).toBe('Run `echo $HOME` now') + }) + + it('normalizes latex delimiters to dollar math', () => { + // `\(…\)` and `\[…\]` are rewritten to `$…$` / `$$…$$` so remark-math parses them. + expect(normalizeFilePreviewMath('A formula \\(x + 1\\) inline.')).toBe('A formula $x + 1$ inline.') + }) + + it('leaves ```math fences untouched (rehype renders language-math directly)', () => { + const input = ['```math', 'E = mc^2', '```'].join('\n') + + expect(normalizeFilePreviewMath(input)).toBe(input) + }) + + it('does not strip citation markers or autolink urls (chat-only transforms omitted)', () => { + const input = 'See [3] and https://example.com' + + const output = normalizeFilePreviewMath(input) + + // A chat message would autolink the URL and strip the [3] citation marker; + // a file's prose must stay verbatim. + expect(output).toBe(input) + }) + + it('does not strip reasoning blocks (author content, not model output)', () => { + const input = 'keep me' + + expect(normalizeFilePreviewMath(input)).toBe(input) + }) +}) \ No newline at end of file diff --git a/apps/desktop/src/lib/markdown-preprocess.ts b/apps/desktop/src/lib/markdown-preprocess.ts index 00d5440353..cbecf243c5 100644 --- a/apps/desktop/src/lib/markdown-preprocess.ts +++ b/apps/desktop/src/lib/markdown-preprocess.ts @@ -521,3 +521,33 @@ export function preprocessMarkdown(text: string): string { .join('') .replace(/[ \t]+\n/g, '\n') } + +/** + * Math-only normalization for static file previews. Mirrors the math half of + * `preprocessMarkdown` — delimiter normalization (`\(…\)`, `\[…\]`), display + * math on its own lines, and currency-dollar escaping — but deliberately skips + * the chat-only transforms (reasoning-block stripping, `@session:` ref + * linking, preview-target stripping, raw-URL autolinking, citation-marker + * stripping). A file's prose is author content, not model output, so those + * rewrites must not touch it. Code fences and inline code spans pass through + * untouched so `$`, `\(` and `\begin` inside listings are never mangled. + * + * ` ```math ` fences need no preprocessing: remark/rehype emit them as + * `` and the memoized rehype-katex wrapper renders + * them regardless of this function. + */ +export function normalizeFilePreviewMath(text: string): string { + return text + .split(CODE_FENCE_SPLIT_RE) + .map(part => { + if (/^(?:```|~~~)/.test(part)) { + return part + } + + return part + .split(INLINE_CODE_SPLIT_RE) + .map(segment => (segment.startsWith('`') ? segment : normalizeProseMath(segment))) + .join('') + }) + .join('') +}