fix(desktop): render LaTeX math in file preview

The right-rail file preview rendered markdown through Streamdown with no
plugins and no math preprocessing, so $...$ and \(...\) stayed as raw
source text. Wire the memoized KaTeX plugin (same one the chat transcript
uses) into the preview and preprocess prose with a math-only normalizer that
skips chat-only transforms (reasoning-block stripping, session-ref linking,
preview-target stripping, URL autolinking, citation stripping) so a file's
prose, code fences, and inline code spans are never mangled.

- preview-file.tsx: import + module-scope createMemoizedMathPlugin, pass
  plugins={{ math }} to Streamdown, preprocess text with
  normalizeFilePreviewMath before render
- markdown-preprocess.ts: add exported normalizeFilePreviewMath
- markdown-preprocess.file-preview.test.ts: 8 tests (currency escaping,
  delimiter normalization, fence/inline-code preservation, verbatim
  citations/URLs/reasoning blocks)
This commit is contained in:
Topher Ross
2026-08-18 10:55:20 -06:00
committed by Teknium
parent c820a5d383
commit 93a2fae4e6
3 changed files with 100 additions and 2 deletions
@@ -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<EmptyStateTone, { cube: string; primary: string }> = {
@@ -370,10 +377,18 @@ const MARKDOWN_COMPONENTS = {
}
function MarkdownPreview({ text }: { text: string }) {
const mathText = useMemo(() => normalizeFilePreviewMath(text), [text])
return (
<div className="preview-markdown mx-auto max-w-3xl px-4 py-3 text-sm text-foreground" data-selectable-text="true">
<Streamdown components={MARKDOWN_COMPONENTS} controls={false} mode="static" parseIncompleteMarkdown={false}>
{text}
<Streamdown
components={MARKDOWN_COMPONENTS}
controls={false}
mode="static"
parseIncompleteMarkdown={false}
plugins={{ math: previewMathPlugin }}
>
{mathText}
</Streamdown>
</div>
)
@@ -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 = '<reasoning>keep me</reasoning>'
expect(normalizeFilePreviewMath(input)).toBe(input)
})
})
@@ -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
* `<code class="language-math">` 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('')
}