feat(web): markdown Source | Preview toggle in session file pane (#957)

* feat(web): markdown Source | Preview toggle in session file pane

Add Source | Preview toggle for .md/.mdx files in the session file route,
defaulting to preview with localStorage persistence. Reuse chat markdown
pipeline via MarkdownRenderer standalone mode (no assistant-ui thread).

Includes unit tests, Playwright smoke, and e2e fixture.

Closes tiann/hapi#954

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): cast standalone MarkdownRenderer components for react-markdown

Soup verify gate: defaultComponents merge type is wider than
react-markdown Components; standalone file-pane path needs explicit cast.

* fix(web): route file-pane markdown fences through SyntaxHighlighter

Standalone file preview now mirrors chat code-block rendering: fenced
blocks use SyntaxHighlighter and MARKDOWN_COMPONENTS_BY_LANGUAGE (mermaid
included) without requiring ThreadPrimitive context.

Addresses HAPI Bot Major on tiann/hapi#957.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): detect fenced vs inline code in standalone markdown preview

Move block detection to the pre override (react-markdown v10 does not pass
inline to custom code components). Add inline-code regression test.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
HeavyGee
2026-07-11 11:06:38 +08:00
committed by GitHub
co-authored by Cursor
parent 018bcb8eaa
commit a82dd49049
12 changed files with 540 additions and 32 deletions
+33
View File
@@ -0,0 +1,33 @@
/*
* Playwright smoke for issue #954 — markdown Source | Preview toggle in
* the session file pane. Drives the Vite fixture that mounts the same
* MarkdownRenderer + toggle affordance as production `file.tsx`.
*/
import { test, expect } from '@playwright/test'
import path from 'node:path'
const SCREENSHOT_PATH = path.resolve(
process.env.HOME ?? '',
'coding/hapi/localdocs/playwright-runs/954-file-md-preview.png'
)
test.describe('file markdown preview e2e', () => {
test('preview renders heading and table; source shows raw markdown', async ({ page }) => {
await page.goto('/e2e-fixtures/file-md-preview-fixture.html')
await expect(page.getByTestId('file-md-preview-fixture')).toBeVisible()
await expect(page.getByRole('heading', { name: 'Teams and channels' })).toBeVisible()
await expect(page.getByRole('cell', { name: 'general' })).toBeVisible()
await expect(page.locator('.aui-md-codeblock')).toBeVisible()
await page.getByTestId('markdown-mode-source').click()
await expect(page.getByTestId('markdown-source-view')).toContainText('# Teams and channels')
await expect(page.getByRole('heading', { name: 'Teams and channels' })).toHaveCount(0)
await page.getByTestId('markdown-mode-preview').click()
await expect(page.getByRole('heading', { name: 'Teams and channels' })).toBeVisible()
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true })
})
})
+1 -1
View File
@@ -1,6 +1,6 @@
import { defineConfig, devices } from '@playwright/test'
const PORT = 5179
const PORT = Number(process.env.PLAYWRIGHT_WEB_PORT ?? 5179)
const BASE_URL = `http://localhost:${PORT}`
export default defineConfig({
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>HAPI file markdown preview e2e fixture</title>
<style>
html { background: #fff }
html[data-theme="dark"] { background: #1c1c1e; color-scheme: dark }
body { margin: 0; padding: 16px; font-family: system-ui, sans-serif }
#root { max-width: 720px; margin: 0 auto }
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="./file-md-preview-fixture.tsx"></script>
</body>
</html>
@@ -0,0 +1,88 @@
/*
* Standalone Vite-served fixture for the file-pane markdown Source |
* Preview Playwright smoke. Mounts the same toggle + MarkdownRenderer
* path as `file.tsx` without the HAPI auth / git / socket stack.
*/
import React from 'react'
import ReactDOM from 'react-dom/client'
import '../src/index.css'
import { I18nProvider } from '../src/lib/i18n-context'
import { MarkdownRenderer } from '../src/components/MarkdownRenderer'
import {
getInitialMarkdownPreviewMode,
persistMarkdownPreviewMode,
type MarkdownPreviewMode,
} from '../src/lib/file-markdown-preview'
import { useTranslation } from '../src/lib/use-translation'
const SAMPLE_MARKDOWN = `# Teams and channels
| Channel | Purpose |
| --- | --- |
| general | Day-to-day coordination |
| incidents | Outage response |
\`\`\`ts
export const ok = true
\`\`\`
> Preview uses the same markdown pipeline as chat.
`
function FileMarkdownPreviewFixture() {
const { t } = useTranslation()
const [mode, setMode] = React.useState<MarkdownPreviewMode>(() => getInitialMarkdownPreviewMode())
const showSource = mode === 'source'
const setMarkdownPreviewMode = (next: MarkdownPreviewMode) => {
setMode(next)
persistMarkdownPreviewMode(next)
}
return (
<div data-testid="file-md-preview-fixture" className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<button
type="button"
data-testid="markdown-mode-source"
onClick={() => setMarkdownPreviewMode('source')}
className={`rounded px-3 py-1 text-xs font-semibold ${showSource ? 'bg-[var(--app-button)] text-[var(--app-button-text)] opacity-80' : 'bg-[var(--app-subtle-bg)] text-[var(--app-hint)]'}`}
>
{t('file.page.tab.source')}
</button>
<button
type="button"
data-testid="markdown-mode-preview"
onClick={() => setMarkdownPreviewMode('preview')}
className={`rounded px-3 py-1 text-xs font-semibold ${!showSource ? 'bg-[var(--app-button)] text-[var(--app-button-text)] opacity-80' : 'bg-[var(--app-subtle-bg)] text-[var(--app-hint)]'}`}
>
{t('file.page.tab.preview')}
</button>
</div>
{showSource ? (
<pre
data-testid="markdown-source-view"
className="overflow-auto rounded-md bg-[var(--app-code-bg)] p-3 text-xs font-mono"
>
<code>{SAMPLE_MARKDOWN}</code>
</pre>
) : (
<div data-testid="markdown-preview-view" className="markdown-content">
<MarkdownRenderer content={SAMPLE_MARKDOWN} standalone />
</div>
)}
</div>
)
}
const rootEl = document.getElementById('root')
if (rootEl) {
ReactDOM.createRoot(rootEl).render(
<React.StrictMode>
<I18nProvider>
<FileMarkdownPreviewFixture />
</I18nProvider>
</React.StrictMode>
)
}
@@ -0,0 +1,31 @@
import { afterEach, describe, expect, it } from 'vitest'
import { cleanup, render } from '@testing-library/react'
import { I18nProvider } from '@/lib/i18n-context'
import { MarkdownRenderer } from './MarkdownRenderer'
describe('MarkdownRenderer', () => {
afterEach(() => {
cleanup()
})
it('renders fenced code blocks with the shared syntax highlighter shell in standalone mode', () => {
render(
<I18nProvider>
<MarkdownRenderer standalone content={'```ts\nexport const ok = true\n```'} />
</I18nProvider>
)
expect(document.querySelector('.aui-md-codeblock')).toBeTruthy()
})
it('renders inline code without the fenced-code shell in standalone mode', () => {
render(
<I18nProvider>
<MarkdownRenderer standalone content={'Use `npm test` here.'} />
</I18nProvider>
)
expect(document.querySelector('.aui-md-codeblock')).toBeFalsy()
expect(document.querySelector('.aui-md-code')).toBeTruthy()
})
})
+74
View File
@@ -1,6 +1,8 @@
import type { MarkdownTextPrimitiveProps } from '@assistant-ui/react-markdown'
import { MarkdownTextPrimitive } from '@assistant-ui/react-markdown'
import { TextMessagePartProvider } from '@assistant-ui/react'
import { Children, isValidElement, useMemo, type ComponentPropsWithoutRef, type ComponentType } from 'react'
import ReactMarkdown, { type Components } from 'react-markdown'
import {
MARKDOWN_PLUGINS,
MARKDOWN_PLUGINS_WITH_BREAKS,
@@ -11,6 +13,8 @@ import {
denyOnlyTransform,
UriConfirmProvider,
} from '@/components/assistant-ui/markdown-text'
import { SyntaxHighlighter } from '@/components/assistant-ui/shiki-highlighter'
import type { CodeHeaderProps, SyntaxHighlighterProps } from '@assistant-ui/react-markdown'
import { cn } from '@/lib/utils'
interface MarkdownRendererProps {
@@ -18,6 +22,73 @@ interface MarkdownRendererProps {
components?: MarkdownTextPrimitiveProps['components']
className?: string
preserveSingleLineBreaks?: boolean
/** Render outside assistant-ui thread context (file pane, fixtures). */
standalone?: boolean
}
function StandaloneCode(props: ComponentPropsWithoutRef<'code'>) {
const Code = defaultComponents.code!
return <Code {...props} />
}
function StandalonePre(props: ComponentPropsWithoutRef<'pre'>) {
const child = Children.toArray(props.children)[0]
if (!isValidElement<ComponentPropsWithoutRef<'code'>>(child)) {
const Pre = defaultComponents.pre!
return <Pre {...props} />
}
const className = String(child.props.className ?? '')
const language = /language-(\w+)/.exec(className)?.[1] ?? 'unknown'
const code = String(child.props.children ?? '').replace(/\n$/, '')
const Highlighter: ComponentType<SyntaxHighlighterProps> =
MARKDOWN_COMPONENTS_BY_LANGUAGE[language as keyof typeof MARKDOWN_COMPONENTS_BY_LANGUAGE]?.SyntaxHighlighter
?? SyntaxHighlighter
const CodeHeader = defaultComponents.CodeHeader as ComponentType<CodeHeaderProps>
const Pre = defaultComponents.pre!
const Code = defaultComponents.code!
return (
<>
<CodeHeader language={language} code={code} />
<Highlighter language={language} code={code} components={{ Pre, Code }} />
</>
)
}
function StandaloneMarkdownContent(props: MarkdownRendererProps) {
const mergedComponents = props.components
? { ...defaultComponents, ...props.components }
: defaultComponents
const {
pre: _pre,
code: _code,
SyntaxHighlighter: _sh,
CodeHeader: _header,
...componentsRest
} = mergedComponents as typeof mergedComponents & Record<string, unknown>
const components = useMemo<Components>(() => ({
...(componentsRest as Components),
pre: StandalonePre,
code: StandaloneCode,
}), [componentsRest])
return (
<UriConfirmProvider>
<div className={cn(MARKDOWN_CLASSNAME, props.className)}>
<ReactMarkdown
remarkPlugins={props.preserveSingleLineBreaks ? MARKDOWN_PLUGINS_WITH_BREAKS : MARKDOWN_PLUGINS}
rehypePlugins={MARKDOWN_REHYPE_PLUGINS}
components={components}
urlTransform={denyOnlyTransform}
>
{props.content}
</ReactMarkdown>
</div>
</UriConfirmProvider>
)
}
function MarkdownContent(props: MarkdownRendererProps) {
@@ -42,5 +113,8 @@ function MarkdownContent(props: MarkdownRendererProps) {
}
export function MarkdownRenderer(props: MarkdownRendererProps) {
if (props.standalone) {
return <StandaloneMarkdownContent {...props} />
}
return <MarkdownContent {...props} />
}
+36
View File
@@ -0,0 +1,36 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
DEFAULT_MARKDOWN_PREVIEW_MODE,
MARKDOWN_PREVIEW_MODE_STORAGE_KEY,
getInitialMarkdownPreviewMode,
isMarkdownFile,
persistMarkdownPreviewMode,
} from './file-markdown-preview'
describe('file-markdown-preview helpers', () => {
beforeEach(() => {
window.localStorage.clear()
})
it('detects markdown file extensions', () => {
expect(isMarkdownFile('README.md')).toBe(true)
expect(isMarkdownFile('docs/guide/page.mdx')).toBe(true)
expect(isMarkdownFile('src/file.ts')).toBe(false)
expect(isMarkdownFile('noext')).toBe(false)
})
it('defaults to preview when storage is missing or invalid', () => {
expect(getInitialMarkdownPreviewMode()).toBe(DEFAULT_MARKDOWN_PREVIEW_MODE)
window.localStorage.setItem(MARKDOWN_PREVIEW_MODE_STORAGE_KEY, 'nope')
expect(getInitialMarkdownPreviewMode()).toBe(DEFAULT_MARKDOWN_PREVIEW_MODE)
})
it('reads and persists a valid preview mode', () => {
persistMarkdownPreviewMode('source')
expect(getInitialMarkdownPreviewMode()).toBe('source')
persistMarkdownPreviewMode('preview')
expect(window.localStorage.getItem(MARKDOWN_PREVIEW_MODE_STORAGE_KEY)).toBeNull()
expect(getInitialMarkdownPreviewMode()).toBe(DEFAULT_MARKDOWN_PREVIEW_MODE)
})
})
+69
View File
@@ -0,0 +1,69 @@
export type MarkdownPreviewMode = 'source' | 'preview'
export const MARKDOWN_PREVIEW_MODE_STORAGE_KEY = 'hapi.filePreview.markdownMode.v1'
export const DEFAULT_MARKDOWN_PREVIEW_MODE: MarkdownPreviewMode = 'preview'
function isBrowser(): boolean {
return typeof window !== 'undefined' && typeof document !== 'undefined'
}
function safeGetItem(key: string): string | null {
if (!isBrowser()) {
return null
}
try {
return localStorage.getItem(key)
} catch {
return null
}
}
function safeSetItem(key: string, value: string): void {
if (!isBrowser()) {
return
}
try {
localStorage.setItem(key, value)
} catch {
// Ignore storage errors
}
}
function safeRemoveItem(key: string): void {
if (!isBrowser()) {
return
}
try {
localStorage.removeItem(key)
} catch {
// Ignore storage errors
}
}
export function isMarkdownFile(path: string): boolean {
const parts = path.split('.')
if (parts.length <= 1) {
return false
}
const ext = parts[parts.length - 1]?.toLowerCase()
return ext === 'md' || ext === 'mdx'
}
function parseMarkdownPreviewMode(raw: string | null): MarkdownPreviewMode {
if (raw === 'source' || raw === 'preview') {
return raw
}
return DEFAULT_MARKDOWN_PREVIEW_MODE
}
export function getInitialMarkdownPreviewMode(): MarkdownPreviewMode {
return parseMarkdownPreviewMode(safeGetItem(MARKDOWN_PREVIEW_MODE_STORAGE_KEY))
}
export function persistMarkdownPreviewMode(mode: MarkdownPreviewMode): void {
if (mode === DEFAULT_MARKDOWN_PREVIEW_MODE) {
safeRemoveItem(MARKDOWN_PREVIEW_MODE_STORAGE_KEY)
return
}
safeSetItem(MARKDOWN_PREVIEW_MODE_STORAGE_KEY, mode)
}
+2
View File
@@ -336,6 +336,8 @@ export default {
'file.page.download': 'Download file',
'file.page.tab.diff': 'Diff',
'file.page.tab.file': 'File',
'file.page.tab.source': 'Source',
'file.page.tab.preview': 'Preview',
'file.page.missingPath': 'No file path provided.',
'file.page.binary': 'This looks like a binary file. It cannot be displayed.',
'file.page.imagePreviewAlt': 'Image preview for {name}',
+2
View File
@@ -340,6 +340,8 @@ export default {
'file.page.download': '下载文件',
'file.page.tab.diff': 'Diff',
'file.page.tab.file': '文件',
'file.page.tab.source': '源码',
'file.page.tab.preview': '预览',
'file.page.missingPath': '未提供文件路径。',
'file.page.binary': '该文件看起来是二进制文件,无法显示。',
'file.page.imagePreviewAlt': '{name} 图片预览',
+98
View File
@@ -0,0 +1,98 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { I18nProvider } from '@/lib/i18n-context'
import { encodeBase64 } from '@/lib/utils'
import FilePage from './file'
const goBackMock = vi.fn()
const sampleMarkdown = '# Heading\n\n| Col A | Col B |\n| --- | --- |\n| one | two |'
const filePath = 'docs/README.md'
const encodedPath = encodeBase64(filePath)
const encodedContent = encodeBase64(sampleMarkdown)
vi.mock('@tanstack/react-router', () => ({
useParams: () => ({ sessionId: 'session-1' }),
useSearch: () => ({
path: encodedPath,
staged: undefined,
}),
}))
vi.mock('@/lib/app-context', () => ({
useAppContext: () => ({
api: {
getGitDiffFile: vi.fn(async () => ({ success: true, stdout: '' })),
readSessionFile: vi.fn(async () => ({
success: true,
content: encodedContent,
})),
},
}),
}))
vi.mock('@/hooks/useAppGoBack', () => ({
useAppGoBack: () => goBackMock,
}))
vi.mock('@/hooks/useCopyToClipboard', () => ({
useCopyToClipboard: () => ({
copied: false,
copy: vi.fn(),
}),
}))
vi.mock('@/lib/shiki', () => ({
langAlias: { md: 'markdown' },
useShikiHighlighter: (content: string) => content,
}))
vi.mock('@/components/MarkdownRenderer', () => ({
MarkdownRenderer: (props: { content: string }) => (
<div data-testid="markdown-preview">{props.content}</div>
),
}))
function renderWithProviders() {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
})
return render(
<QueryClientProvider client={queryClient}>
<I18nProvider>
<FilePage />
</I18nProvider>
</QueryClientProvider>
)
}
describe('FilePage markdown preview', () => {
beforeEach(() => {
vi.clearAllMocks()
window.localStorage.clear()
})
it('renders markdown preview by default and toggles to source', async () => {
renderWithProviders()
await waitFor(() => {
expect(screen.getByTestId('markdown-preview')).toHaveTextContent('# Heading')
})
expect(screen.getByRole('button', { name: 'Preview' })).toHaveClass('opacity-80')
fireEvent.click(screen.getByRole('button', { name: 'Source' }))
await waitFor(() => {
expect(screen.getByRole('code')).toHaveTextContent('# Heading')
})
expect(screen.queryByTestId('markdown-preview')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Preview' }))
await waitFor(() => {
expect(screen.getByTestId('markdown-preview')).toBeInTheDocument()
})
})
})
+59 -2
View File
@@ -13,6 +13,13 @@ import { langAlias, useShikiHighlighter } from '@/lib/shiki'
import { useTranslation } from '@/lib/use-translation'
import { decodeBase64 } from '@/lib/utils'
import { ImagePreview } from '@/components/ImagePreview'
import { MarkdownRenderer } from '@/components/MarkdownRenderer'
import {
getInitialMarkdownPreviewMode,
isMarkdownFile,
persistMarkdownPreviewMode,
type MarkdownPreviewMode,
} from '@/lib/file-markdown-preview'
const MAX_COPYABLE_FILE_BYTES = 1_000_000
const IMAGE_MIME_BY_EXTENSION: Record<string, string> = {
@@ -193,6 +200,7 @@ export default function FilePage() {
const filePath = useMemo(() => decodePath(encodedPath), [encodedPath])
const fileName = filePath.split('/').pop() || filePath || t('file.page.fallbackName')
const imageMimeType = useMemo(() => resolveImageMimeType(filePath), [filePath])
const markdownFile = useMemo(() => isMarkdownFile(filePath), [filePath])
const diffQuery = useQuery({
queryKey: queryKeys.gitFileDiff(sessionId, filePath, staged),
@@ -234,7 +242,12 @@ export default function FilePage() {
: null
const language = useMemo(() => imageMimeType ? undefined : resolveLanguage(filePath), [filePath, imageMimeType])
const highlighted = useShikiHighlighter(imageMimeType ? '' : decodedContent, language)
const [markdownMode, setMarkdownMode] = useState<MarkdownPreviewMode>(getInitialMarkdownPreviewMode)
const showMarkdownSource = !markdownFile || markdownMode === 'source'
const highlighted = useShikiHighlighter(
imageMimeType || (markdownFile && !showMarkdownSource) ? '' : decodedContent,
language
)
const contentSizeBytes = useMemo(
() => (decodedContent ? getUtf8ByteLength(decodedContent) : 0),
[decodedContent]
@@ -248,6 +261,11 @@ export default function FilePage() {
const [displayMode, setDisplayMode] = useState<'diff' | 'file'>('diff')
const setMarkdownPreviewMode = (mode: MarkdownPreviewMode) => {
setMarkdownMode(mode)
persistMarkdownPreviewMode(mode)
}
useEffect(() => {
if (imageMimeType) {
setDisplayMode('file')
@@ -313,9 +331,11 @@ export default function FilePage() {
</div>
</div>
{diffContent ? (
{diffContent || (markdownFile && displayMode === 'file') ? (
<div className="bg-[var(--app-bg)]">
<div className="mx-auto w-full max-w-content px-3 py-2 flex items-center gap-2 border-b border-[var(--app-divider)]">
{diffContent ? (
<>
<button
type="button"
onClick={() => setDisplayMode('diff')}
@@ -330,6 +350,27 @@ export default function FilePage() {
>
{t('file.page.tab.file')}
</button>
</>
) : null}
{markdownFile && displayMode === 'file' ? (
<>
{diffContent ? <span className="mx-1 h-4 w-px bg-[var(--app-divider)]" aria-hidden="true" /> : null}
<button
type="button"
onClick={() => setMarkdownPreviewMode('source')}
className={`rounded px-3 py-1 text-xs font-semibold ${showMarkdownSource ? 'bg-[var(--app-button)] text-[var(--app-button-text)] opacity-80' : 'bg-[var(--app-subtle-bg)] text-[var(--app-hint)]'}`}
>
{t('file.page.tab.source')}
</button>
<button
type="button"
onClick={() => setMarkdownPreviewMode('preview')}
className={`rounded px-3 py-1 text-xs font-semibold ${!showMarkdownSource ? 'bg-[var(--app-button)] text-[var(--app-button-text)] opacity-80' : 'bg-[var(--app-subtle-bg)] text-[var(--app-hint)]'}`}
>
{t('file.page.tab.preview')}
</button>
</>
) : null}
</div>
</div>
) : null}
@@ -364,6 +405,21 @@ export default function FilePage() {
</div>
) : (
decodedContent ? (
markdownFile && !showMarkdownSource ? (
<div className="markdown-content relative">
{canCopyContent ? (
<button
type="button"
onClick={() => copyContent(decodedContent)}
className="absolute right-2 top-2 z-10 rounded p-1 text-[var(--app-hint)] hover:bg-[var(--app-subtle-bg)] hover:text-[var(--app-fg)] transition-colors"
title={t('file.page.copyContent')}
>
{contentCopied ? <CheckIcon className="h-3.5 w-3.5" /> : <CopyIcon className="h-3.5 w-3.5" />}
</button>
) : null}
<MarkdownRenderer content={decodedContent} standalone />
</div>
) : (
<div className="relative">
{canCopyContent ? (
<button
@@ -379,6 +435,7 @@ export default function FilePage() {
<code>{highlighted ?? decodedContent}</code>
</pre>
</div>
)
) : (
<div className="text-sm text-[var(--app-hint)]">{t('file.page.empty')}</div>
)