feat(web): load export, preview, and charts only when opened

The login shell was downloading the document engines with the workbench. Those modules now load on click or when a matching block appears, and a failed chunk can be retried without submitting an edit.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-19 09:51:04 +00:00
co-authored by Cursor
parent 1b17e01ce7
commit 17f7fed1db
11 changed files with 242 additions and 100 deletions
+1 -1
View File
@@ -18,7 +18,7 @@
| Cursor | audit-b2-authz-hardening | fix/audit-b2-authz-hardening | - | authz | done |
| Cursor | audit-b3-sse-events | fix/audit-b3-sse-events | - | chat+events | done |
| Cursor | audit-b4-frontend | fix/audit-b4-frontend | - | web | done |
| Cursor | audit-remainder-backlog | docs/audit-remediation-backlog | - | audit | done |
| Cursor-Auto | 2026-09-19-audit-remainder-closeout | fix/audit-remainder-closeout | - | audit-remainder | in_progress |
| Cursor-Auto | 2026-09-19-remove-control-center | fix/remove-control-center | - | controlcenter | done |
| Cursor-Auto | 2026-09-17-control-center-server | feature/control-center-server | - | controlcenter | cancelled |
| Kimi | 2026-09-18-user-manual | feature/user-manual | .worktrees/feature-user-manual | docs | done |
@@ -46,10 +46,10 @@ updated: 2026-09-19
| FE 缓存 | done(版本字段)。窗口缓存写入 `{schema,data}`,无 schema 的旧载荷丢弃。`window cache: 无 schema` |
| FE lucide | 关闭理由:`package-lock.json` 锁定 `lucide-react@1.42.0`,来源 `registry.npmjs.org`,带 integrity。`^1.16.0` 是合法的 1.x 范围,不是来源不明的包 |
| M7 | done(出站意图 + 同键重放)。`document_content_projections` 与账本同一事务写入;投影成功才销账。`TestDocRevision_PatchOrder_RetryProjectsWithoutSecondLedgerCommit`。账本库与正文库仍不是一个 SQL 事务,进程退出后靠未投影行重放,不靠客户端记忆 |
| FE App.tsx | 仍开放。4600 行拆 hook 是独立重构,本轮未拆,避免在没有行为变更需求时改写工作台 |
| FE App.tsx | 部分。登录壳不再静态引用导出引擎、文件预览、管理台、块编辑器和图表。入口包 2.83 MB → 1.00 MB(br 626 KB → 235 KB)。`loginShellImports.test.ts`。工作台主体仍在 `App.tsx`,未按 hook 拆文件 |
| H1-del | 关闭理由:`allowLegacyTestPrincipal` 仍被 `AllowInMemory && ContextDbHost==""` 的 dev/test 内存测试使用。删除会把这批测试变成未登录 401。保留告警,删除另开、先改测试夹具 |
仍开放:FE `App.tsx` 拆分、rdev S2/S3 与 P2–P4、首次加载 U1/U3–U7、dev 容器重建与治理库 PG。本分支已跑通 `cd web && npm test`(tsx 407 + vitest 32,全过)和 `npm run build`(主包 `index-*.js` 仍约 2.83 MB,U3 拆包未做)。H2 批注/审计的项目成员检查在 Postgres 上走 `RequireProjectAccess`;内存授权器在无库时把任意正用户 id 视为成员,这是 `TestAuthorizeDocRevision_ReviewerAndNonMemberCannotWrite` 的隔离前提,不能改成默认拒绝。H2 的 HTTP 负例因此要在 `TEST_DATABASE_URL` 上补,本机未设置该变量。M7 的投影意图已随账本提交(goose `00053`);容器 schema 仍是 52,要等占槽重建后才升到 53。
仍开放:rdev S2/S3 与 P2–P4、首次加载 U1/U4–U7、dev 容器重建与治理库 PG。U3 只完成登录壳拆包(入口包 1.00 MB / br 235 KB),消息公式引擎仍在入口图里。本分支已跑通 `cd web && npm test` 与 `npm run build`。H2 批注/审计的项目成员检查在 Postgres 上走 `RequireProjectAccess`;内存授权器在无库时把任意正用户 id 视为成员,这是 `TestAuthorizeDocRevision_ReviewerAndNonMemberCannotWrite` 的隔离前提,不能改成默认拒绝。H2 的 HTTP 负例因此要在 `TEST_DATABASE_URL` 上补,本机未设置该变量。M7 的投影意图已随账本提交(goose `00053`);容器 schema 仍是 52,要等占槽重建后才升到 53。
红线关闭(不是实现):
+1 -1
View File
@@ -96,7 +96,7 @@
## 2026-09-17 首次加载性能专项
首次加载与重启首访性能优化:**partial**(U2 静态交付已验收;不改变现有功能 done/发布门禁结论)。[审计](../audit/2026-09-17-initial-load-performance-audit.md) · [方案](../remediation/2026-09-17-initial-load-performance-plan.md) · [U2 报告](../test-reports/2026-09-17-initial-load-perf-u2-test-report.md)。U2:自托管字体 + br/gzip + assets 404;主 JS 传输 2.82 MB → ~624 KB。U1/U3–U7 未完成。mutex `web + static-delivery`(U2 done)/ `loading-performance-audit`。
首次加载与重启首访性能优化:**partial**(U2 静态交付已验收;U3 只做了登录壳拆包,未关闭)。[审计](../audit/2026-09-17-initial-load-performance-audit.md) · [方案](../remediation/2026-09-17-initial-load-performance-plan.md) · [U2 报告](../test-reports/2026-09-17-initial-load-perf-u2-test-report.md)。U2:自托管字体 + br/gzip。U3 进展(`fix/audit-remainder-closeout`):导出、预览、管理台、块编辑器、Mermaid/公式改为点击或出现时再加载。入口包 `index-*.js` 由 2.83 MB / br 626 KB 降到 1.00 MB / br 235 KB。消息气泡里的公式引擎仍在入口图里。U1、U4–U7 未做。mutex `web + static-delivery`(U2 done)/ `loading-performance-audit`。
## 2026-09-17 文档编制人兼任方案
+53 -33
View File
@@ -5,7 +5,6 @@ import { InputArea } from './components/chat/InputArea';
import { SlimTabBar } from './components/doc/SlimTabBar';
import { DeckDrawer } from './components/doc/DeckDrawer';
import { BlockRenderer } from './components/doc/BlockRenderer';
import { BlockEditorModal } from './components/doc/BlockEditorModal';
import { DocumentRevisionToolbar } from './components/doc/DocumentRevisionToolbar';
import { CommentSidePanel } from './components/doc/CommentSidePanel';
import { DocGovActionBar } from './components/doc/DocGovActionBar';
@@ -106,17 +105,16 @@ import { StaleDocumentNotice } from './components/workflow/StaleDocumentNotice';
import { StageAdvanceBanner } from './components/workflow/StageAdvanceBanner';
import { deriveReplyTitle } from './features/replyExport/title';
import { copyRichReplyToClipboard, copyMarkdownReply } from './features/replyExport/clipboard';
import { downloadMarkdownReply, downloadDocxReply } from './features/replyExport/download';
import { downloadMarkdownReply } from './features/replyExport/download';
import { ProposalConfirmCard } from './components/doc/ProposalConfirmCard';
import { UserProjectRightsModal } from './components/user/UserProjectRightsModal';
import { UserSettingsModal } from './components/user/UserSettingsModal';
import { AdminConsoleView } from './components/admin/console/AdminConsoleView';
import { OnDemand } from './components/lazy/OnDemand';
import { DocVisibilityModal } from './components/doc/DocVisibilityModal';
import { CreateProjectModal, type CreateProjectConfirmData } from './components/project/CreateProjectModal';
import { ProjectMembersModal } from './components/project/ProjectMembersModal';
import { ProjectSettingsModal } from './components/project/ProjectSettingsModal';
import { fetchProjectSettings } from './api/projectSettings';
import { FilePreviewPanel } from './components/doc/preview/FilePreviewPanel';
import { ShieldAlert } from 'lucide-react';
import { LoginView } from './components/auth/LoginView';
import { FirstLoginPasswordModal } from './components/auth/FirstLoginPasswordModal';
@@ -153,8 +151,6 @@ import {
hasPermission,
filterVisibleDeliverablesWithRBAC,
} from './engine/rbac';
import { exportDocumentToDocx } from './engine/docxRenderer';
import { exportDocumentToXlsx } from './engine/xlsxRenderer';
import {
findMatchingBlockForReview,
createRejectConfirmationCard,
@@ -225,7 +221,6 @@ import { HistoryCalendar } from './components/history/HistoryCalendar';
import { ChatSearchModal } from './components/history/ChatSearchModal';
import { DigestTimelineModal } from './components/history/DigestTimelineModal';
import { navigateToAnchor, parseAnchorHash, resolveTurnAnchorId } from './engine/anchorNavigator';
import { ChatExportModal } from './components/history/ChatExportModal';
import { useUnreadMarks } from './hooks/useUnreadMarks';
import { CategoryDock } from './components/chat/CategoryDock';
import { fetchChatCategories, fetchChatDates, mapTaskStatusToMessageStatus } from './api/chatWindow';
@@ -284,6 +279,15 @@ export {
MOCK_USERS,
} from '@demo-seeds';
const loadFilePreviewPanel = () =>
import('./components/doc/preview/FilePreviewPanel').then((m) => ({ default: m.FilePreviewPanel }));
const loadAdminConsoleView = () =>
import('./components/admin/console/AdminConsoleView').then((m) => ({ default: m.AdminConsoleView }));
const loadChatExportModal = () =>
import('./components/history/ChatExportModal').then((m) => ({ default: m.ChatExportModal }));
const loadBlockEditorModal = () =>
import('./components/doc/BlockEditorModal').then((m) => ({ default: m.BlockEditorModal }));
export default function App() {
// Wave 2:启动时幂等剔除 localStorage 中的 demo seed 残留(须先于 load*)
purgeDemoSeedsFromLocalStorage();
@@ -3653,15 +3657,16 @@ export default function App() {
// 导出 DOCX (Word 渲染器)
const handleExportDocx = async () => {
try {
const { exportDocumentToDocx } = await import('./engine/docxRenderer');
await exportDocumentToDocx(docAST);
} catch (err) {
console.error('导出 DOCX 失败:', err);
}
};
// 导出单条智能体回复 DOCX (轻量专属 Word 导出)
const handleExportReplyDocx = async (message: ChatMessage) => {
try {
const { downloadDocxReply } = await import('./features/replyExport/downloadDocx');
await downloadDocxReply(message.content || '', undefined, {
createdAt: message.createdAt || message.timestamp,
});
@@ -3670,10 +3675,9 @@ export default function App() {
}
};
// 导出 XLSX (Excel 渲染器)
const handleExportXlsx = async () => {
try {
const { exportDocumentToXlsx } = await import('./engine/xlsxRenderer');
await exportDocumentToXlsx(docAST);
} catch (err) {
console.error('导出 XLSX 失败:', err);
@@ -3996,15 +4000,19 @@ export default function App() {
// 全屏系统管理台:与工作台并列的顶层视图,不经过 AppShell
if (view === 'admin') {
return (
<AdminConsoleView
currentUser={currentUser}
env={systemEnv}
onExit={() => setView('workbench')}
onUsersChanged={async () => {
const users = await authClient.getAdminUsers();
setAllUsers(users);
<OnDemand
label="管理台"
load={loadAdminConsoleView}
componentProps={{
currentUser,
env: systemEnv,
onExit: () => setView('workbench'),
onUsersChanged: async () => {
const users = await authClient.getAdminUsers();
setAllUsers(users);
},
onRolesChanged: (newRoles: RoleDefinition[]) => setRoles(newRoles),
}}
onRolesChanged={(newRoles) => setRoles(newRoles)}
/>
);
}
@@ -4117,11 +4125,15 @@ export default function App() {
/>
)}
{isExportOpen && (
<ChatExportModal
isOpen={isExportOpen}
onClose={() => setIsExportOpen(false)}
sessionId={activeSessionId}
dayStack={chatWindow.displayDayStack}
<OnDemand
label="聊天导出"
load={loadChatExportModal}
componentProps={{
isOpen: isExportOpen,
onClose: () => setIsExportOpen(false),
sessionId: activeSessionId,
dayStack: chatWindow.displayDayStack,
}}
/>
)}
<div style={{ position: 'relative', flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
@@ -4356,11 +4368,15 @@ export default function App() {
);
if (isUploadedFile && activeDeliverable) {
return (
<FilePreviewPanel
deliverable={activeDeliverable}
onOpenDocVisibility={() => {
setTargetDocForVisibility(activeDeliverable);
setDocVisibilityModalOpen(true);
<OnDemand
label="文件预览"
load={loadFilePreviewPanel}
componentProps={{
deliverable: activeDeliverable,
onOpenDocVisibility: () => {
setTargetDocForVisibility(activeDeliverable);
setDocVisibilityModalOpen(true);
},
}}
/>
);
@@ -4466,11 +4482,15 @@ export default function App() {
{/* 块就地编辑弹窗 (支持富文本工具栏、多维表格与未保存拦截) */}
{editorSession && (
<BlockEditorModal
block={editorSession.block}
actorLabel={`${currentUser.name} (${currentUser.position || '成员'})`}
onSave={handleSaveBlockEdit}
onClose={closeBlockEditor}
<OnDemand
label="块编辑器"
load={loadBlockEditorModal}
componentProps={{
block: editorSession.block,
actorLabel: `${currentUser.name} (${currentUser.position || '成员'})`,
onSave: handleSaveBlockEdit,
onClose: closeBlockEditor,
}}
/>
)}
+2 -1
View File
@@ -2,7 +2,7 @@ import React, { useState, useRef, useEffect } from 'react';
import { Copy, Check, Bookmark, Download, ChevronDown, FileText, FileCode } from 'lucide-react';
import { ChatMessage } from '../../types/workbench';
import { copyRichReplyToClipboard, copyMarkdownReply } from '../../features/replyExport/clipboard';
import { downloadMarkdownReply, downloadDocxReply } from '../../features/replyExport/download';
import { downloadMarkdownReply } from '../../features/replyExport/download';
export interface ReplyActionBarProps {
message: ChatMessage;
@@ -334,6 +334,7 @@ export const ReplyActionBar: React.FC<ReplyActionBarProps> = ({
if (onExportDocx) {
await onExportDocx(message);
} else {
const { downloadDocxReply } = await import('../../features/replyExport/downloadDocx');
await downloadDocxReply(message.content || '', undefined, {
createdAt: message.createdAt || message.timestamp,
});
+20 -4
View File
@@ -7,14 +7,18 @@ import { HeadingBlockView } from './blocks/HeadingBlockView';
import { ParagraphBlockView } from './blocks/ParagraphBlockView';
import { TableBlockView } from './blocks/TableBlockView';
import { CalloutBlockView } from './blocks/CalloutBlockView';
import { MermaidBlockView } from './blocks/MermaidBlockView';
import { MathBlockView } from './blocks/MathBlockView';
import { ListBlockView } from './blocks/ListBlockView';
import { OnDemand } from '../lazy/OnDemand';
import { BlockContextMenu } from './BlockContextMenu';
import { canAddBlockComment } from '../../lib/commentPolicy';
import { documentBlockDomAttrs } from '../../lib/documentBlockDom';
import { Plus, ArrowUp, ArrowDown, Trash2, MessageSquare } from 'lucide-react';
const loadMermaidBlock = () =>
import('./blocks/MermaidBlockView').then((m) => ({ default: m.MermaidBlockView }));
const loadMathBlock = () =>
import('./blocks/MathBlockView').then((m) => ({ default: m.MathBlockView }));
export interface BlockRendererProps {
ast: DocumentAST;
currentRole: UserRole;
@@ -75,9 +79,21 @@ export const BlockRenderer: React.FC<BlockRendererProps> = ({
case 'callout':
return <CalloutBlockView block={block} />;
case 'mermaid':
return <MermaidBlockView block={block} />;
return (
<OnDemand
label="图表"
load={loadMermaidBlock}
componentProps={{ block }}
/>
);
case 'math':
return <MathBlockView block={block} />;
return (
<OnDemand
label="公式"
load={loadMathBlock}
componentProps={{ block }}
/>
);
case 'list':
return <ListBlockView block={block} />;
case 'divider':
@@ -15,12 +15,14 @@ import {
ShieldCheck,
} from 'lucide-react';
import { DeliverableItem } from '../../../types/workbench';
import { DocxViewer } from './renderers/DocxViewer';
import { PdfViewer } from './renderers/PdfViewer';
import { ExcelViewer } from './renderers/ExcelViewer';
import { ImageViewer } from './renderers/ImageViewer';
import { PdfViewer } from './renderers/PdfViewer';
import { PptViewer } from './renderers/PptViewer';
import { TextViewer } from './renderers/TextViewer';
import { OnDemand } from '../../lazy/OnDemand';
const loadDocxViewer = () => import('./renderers/DocxViewer').then((m) => ({ default: m.DocxViewer }));
const loadExcelViewer = () => import('./renderers/ExcelViewer').then((m) => ({ default: m.ExcelViewer }));
interface FilePreviewPanelProps {
deliverable: DeliverableItem;
@@ -273,11 +275,23 @@ export const FilePreviewPanel: React.FC<FilePreviewPanelProps> = ({
}
switch (fileType) {
case 'docx':
return <DocxViewer fileRaw={fileRaw} fileUrl={fileUrl} fileName={fileName} />;
return (
<OnDemand
label="Word 预览"
load={loadDocxViewer}
componentProps={{ fileRaw, fileUrl, fileName }}
/>
);
case 'pdf':
return <PdfViewer fileRaw={fileRaw} fileUrl={fileUrl} fileName={fileName} />;
case 'excel':
return <ExcelViewer fileRaw={fileRaw} fileUrl={fileUrl} fileName={fileName} />;
return (
<OnDemand
label="表格预览"
load={loadExcelViewer}
componentProps={{ fileRaw, fileUrl, fileName }}
/>
);
case 'image':
return <ImageViewer fileRaw={fileRaw} fileUrl={fileUrl} fileName={fileName} />;
case 'ppt':
+52
View File
@@ -0,0 +1,52 @@
import { Component, lazy, Suspense, useMemo, useState, type ComponentType, type ReactNode } from 'react';
type BoundaryProps = {
label: string;
onRetry: () => void;
children: ReactNode;
};
type BoundaryState = { error: Error | null };
/** Catches a failed dynamic import. Retry builds a new lazy() so a rejected chunk is not sticky. */
export class LazyBoundary extends Component<BoundaryProps, BoundaryState> {
state: BoundaryState = { error: null };
static getDerivedStateFromError(error: Error): BoundaryState {
return { error };
}
render() {
if (this.state.error) {
return (
<div role="alert" style={{ padding: 12, fontSize: 13 }}>
<p style={{ margin: '0 0 8px' }}>
{this.props.label}加载失败,可重试。这次失败不会提交文档或聊天动作。
</p>
<button type="button" onClick={this.props.onRetry}>
重试
</button>
</div>
);
}
return (
<Suspense fallback={<div style={{ padding: 12, fontSize: 13 }}>{this.props.label}加载中…</div>}>
{this.props.children}
</Suspense>
);
}
}
export function OnDemand<P extends object>(props: {
label: string;
load: () => Promise<{ default: ComponentType<P> }>;
componentProps: P;
}) {
const [attempt, setAttempt] = useState(0);
const Comp = useMemo(() => lazy(props.load), [attempt, props.load]);
return (
<LazyBoundary key={attempt} label={props.label} onRetry={() => setAttempt((n) => n + 1)}>
<Comp {...props.componentProps} />
</LazyBoundary>
);
}
-53
View File
@@ -1,7 +1,4 @@
import { Packer } from 'docx';
import { deriveReplyTitle, sanitizeFilename } from './title';
import { markdownToBlockAST } from '../../engine/markdownToBlock';
import { buildDocxModel } from '../../engine/docxRenderer';
export interface DownloadOptions {
includeSource?: boolean;
@@ -42,53 +39,3 @@ export function downloadMarkdownReply(
return { filename };
}
/**
* 触发 Word (.docx) 专用轻量排版导出并直接下载
*/
export async function downloadDocxReply(
markdown: string,
title?: string,
options: DownloadOptions = {}
): Promise<{ filename: string; blob: Blob }> {
const effectiveTitle = title || deriveReplyTitle(markdown, options.createdAt);
const dateStr = new Date().toISOString().substring(0, 10);
const baseName = sanitizeFilename(effectiveTitle);
const filename = options.filenameOverride || `${baseName}_${dateStr}.docx`;
let exportContent = (markdown || '').trim();
if (options.includeSource) {
const metaTime = options.createdAt || new Date().toLocaleString();
exportContent += `\n\n---\n*来源:水木东方项目助手回复 · 生成时间:${metaTime}*`;
}
const ast = markdownToBlockAST(exportContent, {
docId: `reply-docx-${Date.now()}`,
title: effectiveTitle,
taskKey: 'reply_export',
version: 'V1.0',
category: 'private',
lifecycleState: 'drafting',
isClaimed: true,
author: '智能体助手回复',
reviewer: '',
approver: '',
updatedAt: new Date().toISOString().replace('T', ' ').substring(0, 16),
});
const doc = await buildDocxModel(ast);
const blob = await Packer.toBlob(doc);
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
return { filename, blob };
}
@@ -0,0 +1,53 @@
import { Packer } from 'docx';
import { deriveReplyTitle, sanitizeFilename } from './title';
import { markdownToBlockAST } from '../../engine/markdownToBlock';
import { buildDocxModel } from '../../engine/docxRenderer';
import type { DownloadOptions } from './download';
/** Word 导出。只在点击时动态引入,避免 docx 进入登录壳。 */
export async function downloadDocxReply(
markdown: string,
title?: string,
options: DownloadOptions = {}
): Promise<{ filename: string; blob: Blob }> {
const effectiveTitle = title || deriveReplyTitle(markdown, options.createdAt);
const dateStr = new Date().toISOString().substring(0, 10);
const baseName = sanitizeFilename(effectiveTitle);
const filename = options.filenameOverride || `${baseName}_${dateStr}.docx`;
let exportContent = (markdown || '').trim();
if (options.includeSource) {
const metaTime = options.createdAt || new Date().toLocaleString();
exportContent += `\n\n---\n*来源:水木东方项目助手回复 · 生成时间:${metaTime}*`;
}
const ast = markdownToBlockAST(exportContent, {
docId: `reply-docx-${Date.now()}`,
title: effectiveTitle,
taskKey: 'reply_export',
version: 'V1.0',
category: 'private',
lifecycleState: 'drafting',
isClaimed: true,
author: '智能体助手回复',
reviewer: '',
approver: '',
updatedAt: new Date().toISOString().replace('T', ' ').substring(0, 16),
});
const doc = await buildDocxModel(ast);
const blob = await Packer.toBlob(doc);
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
return { filename, blob };
}
+39
View File
@@ -0,0 +1,39 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { test } from 'node:test';
function staticImports(rel: string): string[] {
const text = readFileSync(new URL(rel, import.meta.url), 'utf8');
return text
.split('\n')
.filter((line) => /^\s*import\s/.test(line) && !line.includes('import('));
}
test('login shell does not statically import export, preview, admin, or chart engines', () => {
const app = staticImports('./App.tsx').join('\n');
for (const banned of [
'docxRenderer',
'xlsxRenderer',
'downloadDocx',
'FilePreviewPanel',
'AdminConsoleView',
'ChatExportModal',
'BlockEditorModal',
"from 'docx'",
"from 'mermaid'",
"from 'xlsx'",
]) {
assert.equal(app.includes(banned), false, `App.tsx static import still mentions ${banned}`);
}
const appText = readFileSync(new URL('./App.tsx', import.meta.url), 'utf8');
assert.match(appText, /import\('\.\/engine\/docxRenderer'\)/);
assert.match(appText, /import\('\.\/engine\/xlsxRenderer'\)/);
assert.match(appText, /import\('\.\/features\/replyExport\/downloadDocx'\)/);
const blocks = staticImports('./components/doc/BlockRenderer.tsx').join('\n');
assert.equal(blocks.includes('MermaidBlockView'), false);
assert.equal(blocks.includes('MathBlockView'), false);
const markdownDownload = readFileSync(new URL('./features/replyExport/download.ts', import.meta.url), 'utf8');
assert.equal(markdownDownload.includes("from 'docx'"), false);
});