From a82dd49049d02509afd3321d157b383fdbc66687 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sat, 11 Jul 2026 04:06:38 +0100 Subject: [PATCH] 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 * 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 * 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 --------- Co-authored-by: Cursor --- e2e/file-md-preview.spec.ts | 33 +++++ playwright.config.ts | 2 +- web/e2e-fixtures/file-md-preview-fixture.html | 18 +++ web/e2e-fixtures/file-md-preview-fixture.tsx | 88 +++++++++++++ web/src/components/MarkdownRenderer.test.tsx | 31 +++++ web/src/components/MarkdownRenderer.tsx | 74 +++++++++++ web/src/lib/file-markdown-preview.test.ts | 36 ++++++ web/src/lib/file-markdown-preview.ts | 69 ++++++++++ web/src/lib/locales/en.ts | 2 + web/src/lib/locales/zh-CN.ts | 2 + web/src/routes/sessions/file.test.tsx | 98 +++++++++++++++ web/src/routes/sessions/file.tsx | 119 +++++++++++++----- 12 files changed, 540 insertions(+), 32 deletions(-) create mode 100644 e2e/file-md-preview.spec.ts create mode 100644 web/e2e-fixtures/file-md-preview-fixture.html create mode 100644 web/e2e-fixtures/file-md-preview-fixture.tsx create mode 100644 web/src/components/MarkdownRenderer.test.tsx create mode 100644 web/src/lib/file-markdown-preview.test.ts create mode 100644 web/src/lib/file-markdown-preview.ts create mode 100644 web/src/routes/sessions/file.test.tsx 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} @@ -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')}
)