feat: add rendered HTML file preview (#1029)
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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 run’s 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 (
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
69
src/components/file-preview/HtmlPreview.tsx
Normal file
69
src/components/file-preview/HtmlPreview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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"\'`(),<>,。;;,.!?])';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user