diff --git a/e2e/file-md-preview.spec.ts b/e2e/file-md-preview.spec.ts
new file mode 100644
index 00000000..7456ade0
--- /dev/null
+++ b/e2e/file-md-preview.spec.ts
@@ -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 })
+ })
+})
diff --git a/playwright.config.ts b/playwright.config.ts
index 779efd16..7ff2c288 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -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({
diff --git a/web/e2e-fixtures/file-md-preview-fixture.html b/web/e2e-fixtures/file-md-preview-fixture.html
new file mode 100644
index 00000000..cb3a1abe
--- /dev/null
+++ b/web/e2e-fixtures/file-md-preview-fixture.html
@@ -0,0 +1,18 @@
+
+
+
+
+
+ HAPI file markdown preview e2e fixture
+
+
+
+
+
+
+
diff --git a/web/e2e-fixtures/file-md-preview-fixture.tsx b/web/e2e-fixtures/file-md-preview-fixture.tsx
new file mode 100644
index 00000000..7e2f2754
--- /dev/null
+++ b/web/e2e-fixtures/file-md-preview-fixture.tsx
@@ -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(() => getInitialMarkdownPreviewMode())
+ const showSource = mode === 'source'
+
+ const setMarkdownPreviewMode = (next: MarkdownPreviewMode) => {
+ setMode(next)
+ persistMarkdownPreviewMode(next)
+ }
+
+ return (
+
+
+
+
+
+ {showSource ? (
+
+ {SAMPLE_MARKDOWN}
+
+ ) : (
+
+
+
+ )}
+
+ )
+}
+
+const rootEl = document.getElementById('root')
+if (rootEl) {
+ ReactDOM.createRoot(rootEl).render(
+
+
+
+
+
+ )
+}
diff --git a/web/src/components/MarkdownRenderer.test.tsx b/web/src/components/MarkdownRenderer.test.tsx
new file mode 100644
index 00000000..ed483499
--- /dev/null
+++ b/web/src/components/MarkdownRenderer.test.tsx
@@ -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(
+
+
+
+ )
+
+ expect(document.querySelector('.aui-md-codeblock')).toBeTruthy()
+ })
+
+ it('renders inline code without the fenced-code shell in standalone mode', () => {
+ render(
+
+
+
+ )
+
+ expect(document.querySelector('.aui-md-codeblock')).toBeFalsy()
+ expect(document.querySelector('.aui-md-code')).toBeTruthy()
+ })
+})
diff --git a/web/src/components/MarkdownRenderer.tsx b/web/src/components/MarkdownRenderer.tsx
index ecc71e73..e66122cd 100644
--- a/web/src/components/MarkdownRenderer.tsx
+++ b/web/src/components/MarkdownRenderer.tsx
@@ -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
+}
+
+function StandalonePre(props: ComponentPropsWithoutRef<'pre'>) {
+ const child = Children.toArray(props.children)[0]
+ if (!isValidElement>(child)) {
+ const Pre = defaultComponents.pre!
+ return
+ }
+
+ 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 =
+ MARKDOWN_COMPONENTS_BY_LANGUAGE[language as keyof typeof MARKDOWN_COMPONENTS_BY_LANGUAGE]?.SyntaxHighlighter
+ ?? SyntaxHighlighter
+ const CodeHeader = defaultComponents.CodeHeader as ComponentType
+ const Pre = defaultComponents.pre!
+ const Code = defaultComponents.code!
+
+ return (
+ <>
+
+
+ >
+ )
+}
+
+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
+
+ const components = useMemo(() => ({
+ ...(componentsRest as Components),
+ pre: StandalonePre,
+ code: StandaloneCode,
+ }), [componentsRest])
+
+ return (
+
+
+
+ {props.content}
+
+
+
+ )
}
function MarkdownContent(props: MarkdownRendererProps) {
@@ -42,5 +113,8 @@ function MarkdownContent(props: MarkdownRendererProps) {
}
export function MarkdownRenderer(props: MarkdownRendererProps) {
+ if (props.standalone) {
+ return
+ }
return
}
diff --git a/web/src/lib/file-markdown-preview.test.ts b/web/src/lib/file-markdown-preview.test.ts
new file mode 100644
index 00000000..68d57a6d
--- /dev/null
+++ b/web/src/lib/file-markdown-preview.test.ts
@@ -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)
+ })
+})
diff --git a/web/src/lib/file-markdown-preview.ts b/web/src/lib/file-markdown-preview.ts
new file mode 100644
index 00000000..a5d39920
--- /dev/null
+++ b/web/src/lib/file-markdown-preview.ts
@@ -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)
+}
diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts
index 3e1d9220..da94bdc7 100644
--- a/web/src/lib/locales/en.ts
+++ b/web/src/lib/locales/en.ts
@@ -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}',
diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts
index 7518bb77..05ad556f 100644
--- a/web/src/lib/locales/zh-CN.ts
+++ b/web/src/lib/locales/zh-CN.ts
@@ -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} 图片预览',
diff --git a/web/src/routes/sessions/file.test.tsx b/web/src/routes/sessions/file.test.tsx
new file mode 100644
index 00000000..a653e37d
--- /dev/null
+++ b/web/src/routes/sessions/file.test.tsx
@@ -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 }) => (
+ {props.content}
+ ),
+}))
+
+function renderWithProviders() {
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ },
+ })
+ return render(
+
+
+
+
+
+ )
+}
+
+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()
+ })
+ })
+})
diff --git a/web/src/routes/sessions/file.tsx b/web/src/routes/sessions/file.tsx
index ab2b89d9..b9df79e5 100644
--- a/web/src/routes/sessions/file.tsx
+++ b/web/src/routes/sessions/file.tsx
@@ -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 = {
@@ -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(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,23 +331,46 @@ export default function FilePage() {
- {diffContent ? (
+ {diffContent || (markdownFile && displayMode === 'file') ? (
-
-
+ {diffContent ? (
+ <>
+
+
+ >
+ ) : null}
+ {markdownFile && displayMode === 'file' ? (
+ <>
+ {diffContent ? : null}
+
+
+ >
+ ) : null}
) : null}
@@ -364,21 +405,37 @@ export default function FilePage() {
) : (
decodedContent ? (
-
- {canCopyContent ? (
-
- ) : null}
-
- {highlighted ?? decodedContent}
-
-
+ markdownFile && !showMarkdownSource ? (
+
+ {canCopyContent ? (
+
+ ) : null}
+
+
+ ) : (
+
+ {canCopyContent ? (
+
+ ) : null}
+
+ {highlighted ?? decodedContent}
+
+
+ )
) : (
{t('file.page.empty')}
)