feat: add rendered HTML file preview (#1029)

This commit is contained in:
paisley
2026-05-18 13:42:38 +08:00
committed by GitHub
parent 0ee8f5e202
commit dea38c0c44
13 changed files with 341 additions and 36 deletions

View File

@@ -2356,6 +2356,7 @@ const EXT_MIME_MAP: Record<string, string> = {
'.txt': 'text/plain',
'.md': 'text/markdown',
'.html': 'text/html',
'.htm': 'text/html',
'.css': 'text/css',
'.js': 'text/javascript',
'.ts': 'text/typescript',

View File

@@ -16,7 +16,7 @@
* `useArtifactPanel` zustand store so any part of the page (file cards,
* toolbar buttons, "View file changes →" links) can drive it.
*/
import { useLayoutEffect, useMemo } from 'react';
import { useLayoutEffect, useMemo, useRef } from 'react';
import { Eye, FileEdit, FolderOpen, FolderTree, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
@@ -62,7 +62,7 @@ export function ArtifactPanel({ files, agent, runStartedAt, refreshSignal }: Art
return (
<div data-testid="artifact-panel" className="flex h-full min-h-0 flex-col bg-background">
<div className="flex shrink-0 items-center justify-between gap-2 border-b border-black/5 px-3 py-2 dark:border-white/10">
<div className="relative z-30 flex shrink-0 items-center justify-between gap-2 border-b border-black/5 bg-background px-3 py-2 dark:border-white/10">
<div className="flex min-w-0 items-center gap-1">
{richFocusedFile ? (
<PanelTabButton
@@ -110,22 +110,27 @@ export function ArtifactPanel({ files, agent, runStartedAt, refreshSignal }: Art
</Button>
</div>
<div className="min-h-0 flex-1">
{visibleTab === 'changes' && (
<div className="relative z-0 min-h-0 flex-1 overflow-hidden">
<div className={cn('h-full min-h-0', visibleTab !== 'changes' && 'hidden')}>
<ChangesTab
files={files}
focusedFile={focusedFile}
onFocus={(f) => setFocusedFile(f)}
active={visibleTab === 'changes'}
/>
)}
{visibleTab === 'preview' && <PreviewTab focusedFile={focusedFile} />}
{WORKSPACE_BROWSER_ENABLED && visibleTab === 'browser' && (
<WorkspaceBrowserBody
agent={agent}
runStartedAt={runStartedAt}
refreshSignal={refreshSignal}
compact
/>
</div>
<div className={cn('h-full min-h-0', visibleTab !== 'preview' && 'hidden')}>
<PreviewTab focusedFile={focusedFile} />
</div>
{WORKSPACE_BROWSER_ENABLED && (
<div className={cn('h-full min-h-0', visibleTab !== 'browser' && 'hidden')}>
<WorkspaceBrowserBody
agent={agent}
runStartedAt={runStartedAt}
refreshSignal={refreshSignal}
compact
/>
</div>
)}
</div>
</div>
@@ -141,13 +146,27 @@ interface PanelTabButtonProps {
}
function PanelTabButton({ testId, icon, label, active, onClick }: PanelTabButtonProps) {
const pointerActivated = useRef(false);
return (
<button
data-testid={testId}
type="button"
onClick={onClick}
onPointerDown={(event) => {
if (event.button !== 0) return;
pointerActivated.current = true;
event.preventDefault();
onClick();
}}
onClick={() => {
if (pointerActivated.current) {
pointerActivated.current = false;
return;
}
onClick();
}}
className={cn(
'flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors',
'relative z-40 flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors',
active
? 'bg-foreground/10 text-foreground'
: 'text-muted-foreground hover:bg-black/5 hover:text-foreground dark:hover:bg-white/10',
@@ -178,6 +197,7 @@ interface ChangesTabProps {
files: GeneratedFile[];
focusedFile: FilePreviewTarget | null;
onFocus: (file: FilePreviewTarget) => void;
active: boolean;
}
/**
@@ -185,7 +205,7 @@ interface ChangesTabProps {
* the runs file cards or “View file changes →” (auto-first); switching workspace
* tabs does not bring back a sidebar list.
*/
function ChangesTab({ files, focusedFile, onFocus }: ChangesTabProps) {
function ChangesTab({ files, focusedFile, onFocus, active }: ChangesTabProps) {
const { t } = useTranslation('chat');
// De-dup files by path, keep the latest entry (highest lastSeenIndex).
@@ -203,10 +223,11 @@ function ChangesTab({ files, focusedFile, onFocus }: ChangesTabProps) {
// be present in `files`; keep that focus instead of jumping to an unrelated
// generated file.
useLayoutEffect(() => {
if (!active) return;
if (focusedFile) return;
if (uniqueFiles.length === 0) return;
onFocus(generatedFileToTarget(uniqueFiles[0]));
}, [focusedFile, uniqueFiles, onFocus]);
}, [active, focusedFile, uniqueFiles, onFocus]);
if (!focusedFile && uniqueFiles.length === 0) {
return (

View File

@@ -30,6 +30,7 @@ import { cn } from '@/lib/utils';
import { invokeIpc, readTextFile, statFile, writeTextFile } from '@/lib/api-client';
import type { FilePreviewTarget } from './types';
import {
isHtmlPreviewExt,
isPdfPreviewExt,
isSheetPreviewExt,
supportsInlineDiff,
@@ -43,6 +44,7 @@ import {
shouldOfferDirectOpenFallback,
} from './open-file-utils';
import MarkdownPreview from './MarkdownPreview';
import HtmlPreview from './HtmlPreview';
import ImageViewer from './ImageViewer';
const MonacoViewerLazy = lazy(() => import('./MonacoViewer'));
@@ -103,8 +105,11 @@ function tabsForFile(file: FilePreviewTarget, mode: FilePreviewBodyMode): Tab[]
if (!supportsInlineDocumentPreview(file.ext)) {
return [];
}
// Markdown / plain-text style documents: rendered preview only.
// Markdown / HTML / rich documents: rendered preview first.
tabs.push('preview');
if (isHtmlPreviewExt(file.ext)) {
tabs.push('source');
}
} else if (file.contentType === 'snapshot') {
tabs.push('preview');
} else if (file.contentType === 'video' || file.contentType === 'audio') {
@@ -577,7 +582,15 @@ export function FilePreviewBody({
<SheetViewerLazy filePath={file.filePath} fileName={file.fileName} />
</Suspense>
) : file.contentType === 'document' ? (
<MarkdownPreview source={draft ?? state.content} />
isHtmlPreviewExt(file.ext) ? (
<HtmlPreview
source={draft ?? state.content}
filePath={file.filePath}
fileName={file.fileName}
/>
) : (
<MarkdownPreview source={draft ?? state.content} />
)
) : (
<div className="p-4 text-sm text-muted-foreground">
{t('filePreview.errors.noPreview', 'No preview available for this file')}

View File

@@ -11,6 +11,7 @@ import { invokeIpc } from '@/lib/api-client';
import {
computeLineStats,
supportsInlineDiff,
supportsInlineDocumentPreview,
supportsRichDocumentPreview,
type GeneratedFile,
} from '@/lib/generated-files';
@@ -50,7 +51,7 @@ export function GeneratedFilesPanel({
<div className="flex flex-wrap gap-2">
{files.map((file) => {
const lineStats = computeLineStats(file);
const clickable = supportsInlineDiff(file);
const clickable = supportsInlineDiff(file) || supportsInlineDocumentPreview(file.ext);
const revealOnly = supportsRichDocumentPreview(file.ext);
if (revealOnly) {
return (

View File

@@ -0,0 +1,69 @@
/**
* Sandboxed rendered HTML preview.
*
* HTML emitted by an agent is untrusted, so it is rendered inside an
* iframe with a sandbox instead of being injected into the React tree.
* We allow scripts so simple interactive demo pages still work, but do
* not grant same-origin/top-navigation/popups; the iframe therefore gets
* an opaque origin and cannot reach back into the ClawX renderer.
*/
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';
export interface HtmlPreviewProps {
source: string;
filePath: string;
fileName?: string;
className?: string;
}
function dirnameOf(filePath: string): string {
const normalized = filePath.replace(/\\/g, '/');
const idx = normalized.lastIndexOf('/');
if (idx < 0) return '';
return normalized.slice(0, idx + 1);
}
function pathToFileUrl(filePath: string): string | null {
if (!filePath) return null;
const normalized = filePath.replace(/\\/g, '/');
const absolutePath = normalized.startsWith('/') ? normalized : `/${normalized}`;
const encoded = absolutePath
.split('/')
.map((segment, index) => (index === 0 ? '' : encodeURIComponent(segment)))
.join('/');
return `file://${encoded}`;
}
function injectBaseHref(source: string, filePath: string): string {
const baseUrl = pathToFileUrl(dirnameOf(filePath));
if (!baseUrl) return source;
const baseTag = `<base href="${baseUrl}">`;
if (/<base\s/i.test(source)) return source;
if (/<head[\s>]/i.test(source)) {
return source.replace(/<head([^>]*)>/i, `<head$1>\n${baseTag}`);
}
if (/<html[\s>]/i.test(source)) {
return source.replace(/<html([^>]*)>/i, `<html$1>\n<head>${baseTag}</head>`);
}
return `<!doctype html><html><head>${baseTag}</head><body>${source}</body></html>`;
}
export default function HtmlPreview({ source, filePath, fileName, className }: HtmlPreviewProps) {
const { t } = useTranslation('chat');
const srcDoc = useMemo(() => injectBaseHref(source, filePath), [source, filePath]);
return (
<div className={cn('h-full min-h-0 bg-white', className)}>
<iframe
data-testid="html-preview-frame"
title={fileName ?? t('filePreview.html.title', 'HTML preview')}
srcDoc={srcDoc}
sandbox="allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-presentation allow-downloads"
className="h-full w-full border-0 bg-white"
/>
</div>
);
}

View File

@@ -112,11 +112,11 @@ const SNAPSHOT_EXTS = new Set([
const VIDEO_EXTS = new Set(['.mp4', '.webm', '.mov', '.avi', '.mkv']);
const AUDIO_EXTS = new Set(['.mp3', '.wav', '.ogg', '.flac', '.m4a']);
const DOCUMENT_EXTS = new Set([
'.md', '.markdown', '.txt', '.rst', '.adoc',
'.md', '.markdown', '.txt', '.rst', '.adoc', '.html', '.htm',
'.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
]);
const TEXT_DOCUMENT_EXTS = new Set([
'.md', '.markdown', '.txt', '.rst', '.adoc',
'.md', '.markdown', '.txt', '.rst', '.adoc', '.html', '.htm',
]);
const PDF_PREVIEW_EXTS = new Set(['.pdf']);
const SHEET_PREVIEW_EXTS = new Set(['.xlsx', '.xls']);
@@ -198,6 +198,12 @@ export function supportsRichDocumentPreview(ext: string): boolean {
return PDF_PREVIEW_EXTS.has(lower) || SHEET_PREVIEW_EXTS.has(lower);
}
export function isHtmlPreviewExt(ext: string | null | undefined): boolean {
if (!ext) return false;
const lower = ext.toLowerCase();
return lower === '.html' || lower === '.htm';
}
export function isPdfPreviewExt(ext: string | null | undefined): boolean {
if (!ext) return false;
return PDF_PREVIEW_EXTS.has(ext.toLowerCase());

View File

@@ -86,6 +86,7 @@ function validationKindForAttachment(file: AttachedFileMeta): 'file' | 'dir' | n
function previewMimeFromPath(filePath: string): string | null {
const lower = filePath.toLowerCase();
if (lower.endsWith('.md') || lower.endsWith('.markdown')) return 'text/markdown';
if (lower.endsWith('.html') || lower.endsWith('.htm')) return 'text/html';
if (lower.endsWith('.pdf')) return 'application/pdf';
if (lower.endsWith('.xls')) return 'application/vnd.ms-excel';
if (lower.endsWith('.xlsx')) return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
@@ -118,10 +119,10 @@ function extractPreviewDocumentPaths(text: string): AttachedFileMeta[] {
});
};
// Deliberately narrow this render-layer fallback to user-facing artifacts:
// PDF / spreadsheet previews and OpenClaw skill directories. The store-level
// extractor still handles broad file categories; this keeps visible outputs
// clickable even before history enrichment runs.
const exts = 'pdf|xlsx?|PDF|XLSX?';
// HTML / Markdown / PDF / spreadsheet previews and OpenClaw skill directories.
// The store-level extractor still handles broad file categories; this keeps
// visible outputs clickable even before history enrichment runs.
const exts = 'html?|md|markdown|pdf|xlsx?|HTML?|MD|MARKDOWN|PDF|XLSX?';
const taggedRegex = new RegExp(`(?:^|[\\s(\\[{>])(?:MEDIA|media):((?:\\/|~\\/)[^\\s\\n"'()\\[\\],<>]*?\\.(?:${exts}))`, 'g');
const unixRegex = new RegExp('(?<![\\w./:])((?:\\/|~\\/)[^\\s\\n"\'`()\\[\\],<>]*?\\.(?:' + exts + '))', 'g');
const skillPathBoundary = '(?=$|\\s|[\\x5b\\x5d"\'`(),<>,。;;,.!?])';

View File

@@ -24,7 +24,7 @@ import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';
import { useStickToBottomInstant } from '@/hooks/use-stick-to-bottom-instant';
import { useMinLoading } from '@/hooks/use-min-loading';
import { extractGeneratedFiles, generatedFileHasDiffPayload, type GeneratedFile } from '@/lib/generated-files';
import { extractGeneratedFiles, generatedFileHasDiffPayload, isHtmlPreviewExt, type GeneratedFile } from '@/lib/generated-files';
import { GeneratedFilesPanel } from '@/components/file-preview/GeneratedFilesPanel';
import type { FilePreviewTarget } from '@/components/file-preview/types';
import { buildPreviewTarget } from '@/components/file-preview/build-preview-target';
@@ -779,7 +779,14 @@ export function Chat() {
{generatedFiles.length > 0 && (
<GeneratedFilesPanel
files={generatedFiles}
onOpen={(file) => openChanges(generatedFileToTarget(file))}
onOpen={(file) => {
const target = generatedFileToTarget(file);
if (isHtmlPreviewExt(file.ext)) {
openPreview(target);
return;
}
openChanges(target);
}}
/>
)}
</div>

View File

@@ -372,6 +372,8 @@ function mimeFromExtension(filePath: string): string {
'pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'txt': 'text/plain',
'csv': 'text/csv',
'html': 'text/html',
'htm': 'text/html',
'md': 'text/markdown',
'rtf': 'application/rtf',
'epub': 'application/epub+zip',
@@ -418,7 +420,7 @@ function trimPathTerminators(filePath: string): string {
function extractRawFilePaths(text: string): Array<{ filePath: string; mimeType: string }> {
const refs: Array<{ filePath: string; mimeType: string }> = [];
const seen = new Set<string>();
const exts = 'png|jpe?g|gif|webp|bmp|avif|svg|pdf|docx?|xlsx?|pptx?|txt|csv|md|rtf|epub|zip|tar|gz|rar|7z|mp3|wav|ogg|aac|flac|m4a|mp4|mov|avi|mkv|webm|m4v';
const exts = 'png|jpe?g|gif|webp|bmp|avif|svg|pdf|docx?|xlsx?|pptx?|html?|txt|csv|md|rtf|epub|zip|tar|gz|rar|7z|mp3|wav|ogg|aac|flac|m4a|mp4|mov|avi|mkv|webm|m4v';
// Tagged media references (MEDIA:/path, media:~/path, ...). The agent
// runtime uses this prefix as an explicit "this is an artifact" marker,
// so we want them recognised even though the leading colon would

View File

@@ -66,6 +66,20 @@ const attachedFileHistory = [
},
];
const htmlFileHistory = [
{
role: 'user',
id: 'user-html-1',
content: [{ type: 'text', text: '预览 HTML 页面' }],
timestamp: Date.now(),
},
{
role: 'assistant',
id: 'assistant-html-1',
content: [{ type: 'text', text: '已生成 /workspace/demo.html' }],
timestamp: Date.now(),
},
];
test.describe('ClawX chat file changes', () => {
test('shows line stats on generated file cards', async ({ launchElectronApp }) => {
const app = await launchElectronApp({ skipSetup: true });
@@ -157,6 +171,100 @@ test.describe('ClawX chat file changes', () => {
}
});
test('opens html files from chat as rendered previews', async ({ launchElectronApp }) => {
const app = await launchElectronApp({ skipSetup: true });
try {
await installIpcMocks(app, {
gatewayStatus: { state: 'running', port: 18789, pid: 12345 },
gatewayRpc: {
[stableStringify(['sessions.list', {}])]: {
success: true,
result: {
sessions: [{ key: SESSION_KEY, displayName: 'main' }],
},
},
[stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 200 }])]: {
success: true,
result: { messages: htmlFileHistory },
},
[stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 1000 }])]: {
success: true,
result: { messages: htmlFileHistory },
},
},
hostApi: {
[stableStringify(['/api/gateway/status', 'GET'])]: {
ok: true,
data: {
status: 200,
ok: true,
json: { state: 'running', port: 18789, pid: 12345 },
},
},
[stableStringify(['/api/agents', 'GET'])]: {
ok: true,
data: {
status: 200,
ok: true,
json: {
success: true,
agents: [{ id: 'main', name: 'main', workspace: '/workspace' }],
},
},
},
},
});
await app.evaluate(async () => {
const { ipcMain } = process.mainModule!.require('electron') as typeof import('electron');
ipcMain.removeHandler('file:stat');
ipcMain.handle('file:stat', async (_event: unknown, inputPath: string) => ({
ok: inputPath === '/workspace/demo.html',
size: 154,
isFile: inputPath === '/workspace/demo.html',
isDir: false,
readOnly: true,
}));
ipcMain.removeHandler('file:readText');
ipcMain.handle('file:readText', async (_event: unknown, inputPath: string) => {
if (inputPath !== '/workspace/demo.html') return { ok: false, error: 'notFound' };
return {
ok: true,
content: '<!doctype html><html><body><h1 id="title">HTML Rendered Preview</h1><script>document.body.dataset.htmlPreview = "ok";</script></body></html>',
size: 154,
mimeType: 'text/html',
readOnly: true,
};
});
});
const page = await getStableWindow(app);
try {
await page.reload();
} catch (error) {
if (!String(error).includes('ERR_FILE_NOT_FOUND')) {
throw error;
}
}
await expect(page.getByTestId('main-layout')).toBeVisible();
const htmlFileCard = page.locator('[title="Open file"]').filter({ hasText: 'demo.html' }).first();
await expect(htmlFileCard).toBeVisible({ timeout: 30_000 });
await htmlFileCard.click();
const sidePanel = page.getByTestId('artifact-panel');
const frame = sidePanel.getByTestId('html-preview-frame');
await expect(frame).toBeVisible({ timeout: 30_000 });
await expect(sidePanel.getByText('<!doctype html>')).toHaveCount(0);
const htmlFrame = frame.contentFrame();
await expect(htmlFrame.locator('#title')).toHaveText('HTML Rendered Preview');
await expect(htmlFrame.locator('body')).toHaveAttribute('data-html-preview', 'ok');
} finally {
await closeElectronApp(app);
}
});
test('keeps an attached file selected after switching through workspace', async ({ launchElectronApp }) => {
const app = await launchElectronApp({ skipSetup: true });

View File

@@ -69,8 +69,9 @@ describe('ArtifactPanel', () => {
/>,
);
expect(screen.getByTestId('file-preview-body')).toHaveTextContent('SKILL.md');
expect(screen.getByTestId('file-preview-body')).toHaveTextContent('~/.openclaw/skills/open-baidu/SKILL.md');
const previewBodies = screen.getAllByTestId('file-preview-body');
expect(previewBodies[0]).toHaveTextContent('SKILL.md');
expect(previewBodies[0]).toHaveTextContent('~/.openclaw/skills/open-baidu/SKILL.md');
expect(screen.queryByText('test_example.py')).not.toBeInTheDocument();
});
@@ -95,13 +96,48 @@ describe('ArtifactPanel', () => {
/>,
);
expect(screen.getByTestId('file-preview-body')).toHaveTextContent('SKILL.md');
expect(screen.getAllByTestId('file-preview-body')[1]).toHaveTextContent('SKILL.md');
fireEvent.click(screen.getByRole('button', { name: 'Workspace' }));
expect(screen.getByTestId('workspace-browser')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Preview' }));
expect(screen.getByTestId('file-preview-body')).toHaveTextContent('SKILL.md');
expect(screen.getAllByTestId('file-preview-body')[1]).toHaveTextContent('SKILL.md');
expect(screen.queryByText('No file selected')).not.toBeInTheDocument();
});
it('keeps panel tab buttons above iframe previews so changes stays clickable', () => {
useArtifactPanel.setState({
open: true,
tab: 'preview',
focusedFile: {
filePath: '/tmp/demo.html',
fileName: 'demo.html',
ext: '.html',
mimeType: 'text/html',
contentType: 'document',
},
widthPct: ARTIFACT_PANEL_DEFAULT_WIDTH,
});
render(
<ArtifactPanel
files={[makeGeneratedFile({
filePath: '/tmp/demo.html',
fileName: 'demo.html',
ext: '.html',
mimeType: 'text/html',
contentType: 'document',
})]}
agent={null}
/>,
);
const changesButton = screen.getByTestId('artifact-panel-tab-changes');
expect(changesButton.className).toContain('z-40');
expect(changesButton.parentElement?.parentElement?.className).toContain('z-30');
fireEvent.pointerDown(changesButton, { button: 0 });
expect(screen.getAllByTestId('file-preview-body')[0]).toHaveTextContent('diff');
});
});

View File

@@ -16,12 +16,15 @@ const invokeIpc = vi.fn(async (channel: string) => {
if (channel === 'shell:openPath') return '';
return {};
});
const readTextFile = vi.fn();
const statFile = vi.fn();
const writeTextFile = vi.fn();
vi.mock('@/lib/api-client', () => ({
invokeIpc: (...args: unknown[]) => invokeIpc(...args),
readTextFile: vi.fn(),
statFile: vi.fn(),
writeTextFile: vi.fn(),
readTextFile: (...args: unknown[]) => readTextFile(...args),
statFile: (...args: unknown[]) => statFile(...args),
writeTextFile: (...args: unknown[]) => writeTextFile(...args),
}));
function makePreviewTarget(overrides: Partial<FilePreviewTarget> = {}): FilePreviewTarget {
@@ -37,6 +40,37 @@ function makePreviewTarget(overrides: Partial<FilePreviewTarget> = {}): FilePrev
}
describe('FilePreviewBody', () => {
it('renders html files as sandboxed HTML preview instead of raw source by default', async () => {
readTextFile.mockResolvedValueOnce({
ok: true,
content: '<!doctype html><html><body><h1>Rendered HTML</h1><script>document.body.dataset.scriptRan = "yes";</script></body></html>',
size: 121,
readOnly: true,
});
render(
<FilePreviewBody
file={makePreviewTarget({
filePath: '/tmp/demo.html',
fileName: 'demo.html',
ext: '.html',
mimeType: 'text/html',
contentType: 'document',
size: 121,
})}
mode="preview"
/>,
);
const frame = await screen.findByTestId('html-preview-frame');
expect(frame).toBeVisible();
expect(frame).toHaveAttribute(
'sandbox',
'allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-presentation allow-downloads',
);
expect(screen.queryByText('<!doctype html>')).not.toBeInTheDocument();
});
it('uses known attachment size to show direct-open fallback for large PDFs', async () => {
render(
<FilePreviewBody

View File

@@ -56,6 +56,12 @@ describe('generated-files utilities', () => {
expect(stats).toBeNull();
});
it('routes html documents to rendered inline preview and text diff support', () => {
expect(supportsInlineDocumentPreview('.html')).toBe(true);
expect(supportsInlineDocumentPreview('.htm')).toBe(true);
expect(supportsInlineDiff({ ext: '.html', contentType: 'document' })).toBe(true);
});
it('routes pdf/spreadsheet to rich-doc preview but never to text diff', () => {
expect(supportsInlineDocumentPreview('.md')).toBe(true);
// PDFs and spreadsheets now render through dedicated viewers, so they