Skip to content

fix(export): shrink the exported transcript renderer to what a read-only transcript needs - #11038

Merged
yiliang114 merged 13 commits into
mainfrom
issue-11031
Sep 6, 2026
Merged

fix(export): shrink the exported transcript renderer to what a read-only transcript needs#11038
yiliang114 merged 13 commits into
mainfrom
issue-11031

Conversation

@yiliang114

@yiliang114 yiliang114 commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

Shrinks the transcript document renderer to what a read-only transcript actually needs.

The document entry imported WebShellTranscript from the @qwen-code/web-shell package root, whose facade also reaches App, the daemon providers, the editor/terminal chrome and the full injected component stylesheet — so the renderer carried the entire interactive Web Shell runtime. This PR:

  • adds a transcript-only subpath entry (@qwen-code/web-shell/transcript) that exports only the read-only renderer, built in its own rollup run so it carries only the stylesheet reachable from transcript rendering;
  • resolves shiki / @shikijs/* to a stub in the document build — CodeBlock returns a plain <pre> in document mode and the export CSP has no 'wasm-unsafe-eval', so the Oniguruma engine could never start;
  • moves three composer-tag getters out of the CodeMirror-importing hook into utils/composerTag.ts, so a read-only surface no longer drags an editor along;
  • adds FORBIDDEN_DOCUMENT_INPUTS, a structural build guard that fails with a reason when Shiki, the web-shell package root or CodeMirror re-enter the graph, plus a byte budget and a top-inputs report driven by the esbuild metafile.

Rebased onto the new delivery model. This PR was written when the renderer was inlined into every exported file. #9812 landed first and changed that: the document now loads a version-pinned, SRI-protected export-transcript-document.js from unpkg. 6e6d98e54 merges main in (merge, not rebase, so review anchors survive) and keeps both sides of build.mjs — main's delivery path (root package.json version, unpkg URL, build-id placeholder, SHA-384 SRI over the final bytes, single outfile) plus this branch's guards. The inline-only <script escaping and the unused createRequire import are gone.

Why it's needed

#11031: an empty-session export carried 19,525,807 bytes of renderer (4,775,943 gzip) against 311,854 bytes for the legacy renderer — ~62.6x.

#9812 moved that cost, it did not remove it. The same bytes are now downloaded the first time anyone opens an exported file, from a CDN, on a path that must fail closed. Size therefore decides whether an export opens at all on a slow or restricted network rather than how big a file is on disk — and if the asset gets small enough, self-contained inlining becomes viable again and the external dependency can be reconsidered on its merits.

The measured composition (metafile, on main before this PR) is why the levers above were chosen: @shikijs/* ~9.7 MB of dead code in document mode, the web-shell package-root facade 7,018,687 bytes, CodeMirror ~1 MB reachable only through the composer hook.

Reviewer Test Plan

How to verify

  1. Build and measure the renderer asset — since refactor!: retire @qwen-code/webui #9812 that is the number that matters, not the template:

    npm run build --workspace=@qwen-code/web-shell --workspace=@qwen-code/web-templates
    wc -c packages/web-templates/src/export-html/dist/export-transcript-document.js
    gzip -9 -c packages/web-templates/src/export-html/dist/export-transcript-document.js | wc -c
    wc -c packages/web-templates/src/export-html/dist/document.html   # template only; now small

    The build prints Document export top inputs (pre-minify bytes): … and Document export runtime is N bytes. Expect Shiki, CodeMirror and web-shell/dist/index.js to be absent from the top-inputs line.

  2. Structural guard, red run: point document-main.tsx back at import { WebShellTranscript } from '@qwen-code/web-shell' and re-run node packages/web-templates/src/export-html/build.mjs. It must fail naming the forbidden input and the reason, not merely exceed a byte cap.

  3. Full input breakdown, if you want it:

    cd packages/web-templates
    EXPORT_HTML_METAFILE=/tmp/metafile.json node src/export-html/build.mjs
  4. Rendering parity: open a generated export and confirm the transcript renders identically to main. Note that the renderer asset is not on unpkg for an unpublished version, so serve the locally built asset at the exact URL the document requests (see the "Release ordering" note below).

  5. Targeted tests: packages/web-shell vitest run client/build-artifact.test.ts client/index.test.tsx + typecheck; packages/cli vitest run src/ui/utils/export/formatters/html.test.ts src/ui/utils/export/export-transcript-document.test.ts + typecheck; packages/web-templates full npm run build.

docs/verification/export-html-runtime-size/README.md on this branch is the full hand-off brief for a machine that can build; it has been updated for the post-#9812 world.

Evidence (Before & After)

These numbers predate the #9812 merge, when the renderer was still inlined, so document.html raw ≈ the runtime. They have not been re-measured on the merged branch.

bytes (raw) bytes (gzip) runtime
Before (package-root import) 19,525,807 4,775,943 19,523,259
After (transcript subpath, before the Shiki/CodeMirror follow-up) 17,966,485 4,512,650 17,963,937
Legacy renderer template 311,854

Rendering parity of the same injected transcript, before/after — byte-identical screenshots (md5 413301f7ca3a0d889affca500a06208c for both). This still holds: the merge changed how the renderer is delivered, not what it renders.

Red run of the budget assertion, from the pre-merge branch (the message text was reworded in the merge, the assertion is unchanged):

Document export runtime is 19523259 bytes
Error: Document export runtime is 19523259 bytes; expected <= 19000000. …

Tested on

OS Status
🍏 macOS ⚠️
🪟 Windows ⚠️
🐧 Linux

Environment (optional)

Node v24.19.0, npm workspaces; template build via node src/export-html/build.mjs; rendering verified in headless Chromium (Playwright) over file://. The merge commit 6e6d98e54 was made on a machine that cannot build or test this repository: node --check and Prettier pass, everything else is CI's to confirm.

Risk & Scope

  • Open item — the byte budget is stale. DOCUMENT_RUNTIME_WARNING_BYTES = 18_500_000 / MAX_DOCUMENT_RUNTIME_BYTES = 19_000_000 were set for the pre-Shiki-stub world. With Shiki and CodeMirror gone the real value should be far below them, which makes the cap a no-op as a ratchet. Re-measure with step 1 and lower both before merge. The structural guard is the load-bearing one meanwhile.
  • Release ordering. npm versions are immutable and the renderer URL is pinned to the version in the root package.json, so whichever version first publishes this asset freezes those bytes on unpkg forever. Landing this PR before the first release that ships refactor!: retire @qwen-code/webui #9812 means the small asset is the only one ever published. As of now 0.23.0 is latest on npm but predates refactor!: retire @qwen-code/webui #9812, so https://unpkg.com/@qwen-code/qwen-code@0.23.0/export-transcript-document.js returns 404 and exports built from current main fail closed — a main/source-build condition, not something published users hit. The same immutability means the asset bytes for a given version must never change after publication, or every export from that version fails its SRI check.
  • Main risk or tradeoff: the renderer still carries the transcript rendering stack itself (markdown, mermaid, katex, the transcript-reachable stylesheet), so it stays multi-MB. fix(export): mermaid (~6 MB) is still flattened into the exported transcript renderer #11091 tracks the remaining product decision — what a read-only surface should do with a Mermaid diagram, a chart or a math block — which is the same question CodeBlock already answers for syntax highlighting.
  • Not validated / out of scope: CSP changes, the delivery model itself, e2e Playwright suites, other packages' full test suites. The Shiki plain-text fallback inside exported documents is pre-existing and identical before and after.
  • Breaking changes / migration notes: none. The package root keeps exporting WebShellTranscript; @qwen-code/web-shell/transcript is additive.

Linked Issues

Fixes #11031

Related: #10076 (introduced the document renderer), #9812 (retired the legacy renderer and moved delivery to unpkg), #11091 (mermaid/katex in the renderer — needs a product decision), #11092 (duplicate react-markdown).

中文说明

这个 PR 做了什么

把 transcript 文档渲染器收缩到只读 transcript 真正需要的范围。

文档入口原本从 @qwen-code/web-shell包根导入 WebShellTranscript,而包根 facade 还会连带 App、daemon providers、编辑器/终端组件以及整份注入式组件样式表——渲染器因此携带了整个交互式 Web Shell 运行时。本 PR:

  • 新增 transcript-only 子路径入口(@qwen-code/web-shell/transcript),只导出只读渲染器,并单独跑一次 rollup 构建,因此只携带 transcript 渲染可达的样式表;
  • 在 document 构建里把 shiki / @shikijs/* 解析为 stub —— CodeBlock 在 document 模式下返回纯 <pre>,且导出页 CSP 没有 'wasm-unsafe-eval',Oniguruma 引擎本来就起不来;
  • 把三个 composer-tag getter 从引入 CodeMirror 的 hook 移到 utils/composerTag.ts,只读表面不再拖着一个编辑器;
  • 增加 FORBIDDEN_DOCUMENT_INPUTS 结构性构建守卫(Shiki、web-shell 包根、CodeMirror 重新进入依赖图时带原因失败),以及基于 esbuild metafile 的体积预算和 top-inputs 报告。

已对齐新的投递模型。 本 PR 成文时渲染器还是内联进每个导出文件的。#9812 先合入并改变了这一点:文档现在从 unpkg 加载版本固定、带 SRI 的 export-transcript-document.js6e6d98e54main merge 了进来(用 merge 不用 rebase,保住已有 review 锚点),build.mjs 两边都保留——main 的投递路径(根 package.json 版本、unpkg URL、build-id 占位符、对最终字节的 SHA-384 SRI、单 outfile)加上本分支的守卫。只对内联有意义的 <script 转义和没用到的 createRequire 已删除。

为什么需要

#11031:空会话导出携带 19,525,807 字节渲染器(gzip 4,775,943),而旧渲染器只有 311,854 字节——约 62.6 倍。

#9812 转移了这个成本,没有消除它。 同样的字节现在变成每个人首次打开导出件时的下载量,走 CDN,且这条路径必须 fail closed。因此体积决定的不再是"磁盘上文件多大",而是"在慢速或受限网络下这个导出件能不能打开";而如果资源足够小,自包含内联重新可行,外部依赖也就可以重新评估。

实测构成(metafile,本 PR 之前的 main)解释了为什么选这几个杠杆:@shikijs/* 约 9.7 MB 在 document 模式下是死代码,web-shell 包根 facade 7,018,687 字节,CodeMirror 约 1 MB 且只经由 composer hook 可达。

评审验证计划

如何验证

  1. 构建并测量渲染器资源——refactor!: retire @qwen-code/webui #9812 之后这才是关键数字,不是模板:

    npm run build --workspace=@qwen-code/web-shell --workspace=@qwen-code/web-templates
    wc -c packages/web-templates/src/export-html/dist/export-transcript-document.js
    gzip -9 -c packages/web-templates/src/export-html/dist/export-transcript-document.js | wc -c
    wc -c packages/web-templates/src/export-html/dist/document.html   # 只是模板,现在很小

    构建会打印 Document export top inputs (pre-minify bytes): …Document export runtime is N bytes。top-inputs 里应该看不到 Shiki、CodeMirror 和 web-shell/dist/index.js

  2. 结构性守卫红测试:把 document-main.tsx 改回 import { WebShellTranscript } from '@qwen-code/web-shell',重跑 node packages/web-templates/src/export-html/build.mjs。它必须指名违规输入并给出原因地失败,而不只是超出字节上限。

  3. 需要完整输入构成时:

    cd packages/web-templates
    EXPORT_HTML_METAFILE=/tmp/metafile.json node src/export-html/build.mjs
  4. 渲染一致性:打开生成的导出件,确认与 main 渲染一致。注意未发布版本的渲染器资源不在 unpkg 上,需要在文档请求的那个精确 URL 上提供本地构建产物(见下方"发布顺序")。

  5. 目标测试:packages/web-shellvitest run client/build-artifact.test.ts client/index.test.tsx + typecheck;packages/clivitest run src/ui/utils/export/formatters/html.test.ts src/ui/utils/export/export-transcript-document.test.ts + typecheck;packages/web-templates 完整 npm run build

本分支上的 docs/verification/export-html-runtime-size/README.md 是给可构建机器的完整交接说明,已按 #9812 之后的情况更新。

前后证据

下列数字取自 #9812 合并之前,当时渲染器仍是内联的,所以 document.html raw ≈ 运行时体积。合并后的分支上尚未重新测量。

字节(raw) 字节(gzip) 运行时
修复前(包根导入) 19,525,807 4,775,943 19,523,259
修复后(transcript 子路径,尚未含 Shiki/CodeMirror 后续提交) 17,966,485 4,512,650 17,963,937
旧版渲染器模板 311,854

同一注入内容的前后渲染一致性——截图逐字节相同(两者 md5 均为 413301f7ca3a0d889affca500a06208c)。这一点依然成立:合并改变的是渲染器的投递方式,不是它渲染什么。

预算断言的红测试输出,来自合并前的分支(合并时改了文案,断言本身未变):

Document export runtime is 19523259 bytes
Error: Document export runtime is 19523259 bytes; expected <= 19000000. …

测试环境

OS 状态
🍏 macOS ⚠️
🪟 Windows ⚠️
🐧 Linux

环境(可选)

Node v24.19.0,npm workspaces;模板构建用 node src/export-html/build.mjs;渲染一致性通过无头 Chromium(Playwright)以 file:// 验证。合并提交 6e6d98e54 是在一台无法构建/测试本仓库的机器上做的:node --check 与 Prettier 通过,其余交给 CI 确认。

风险与范围

  • 待办——体积预算已过时。 DOCUMENT_RUNTIME_WARNING_BYTES = 18_500_000 / MAX_DOCUMENT_RUNTIME_BYTES = 19_000_000 是按加 Shiki stub 之前的世界定的。去掉 Shiki 和 CodeMirror 之后实际值应该远低于它们,这个上限作为 ratchet 等于失效。合并前请按第 1 步重测并下调。在此之前起作用的是结构性守卫。
  • 发布顺序。 npm 版本不可变,而渲染器 URL 钉在根 package.json 的版本上,所以哪个版本第一个发布这个资源,那些字节就永久冻结在 unpkg 的该版本上本 PR 在第一个含 refactor!: retire @qwen-code/webui #9812 的版本发布之前合入,才能保证发出去的自始至终只有瘦身后的资源。 目前 npm 上的 latest0.23.0,但它早于 refactor!: retire @qwen-code/webui #9812,因此 https://unpkg.com/@qwen-code/qwen-code@0.23.0/export-transcript-document.js 返回 404,从当前 main 构建出的导出件会 fail closed——这是 main/源码构建的状态,已发布版本的用户不受影响。同样由于不可变性,某个版本发布后其资源字节不得再变,否则该版本产生的所有导出都会 SRI 校验失败。
  • 主要风险/取舍:渲染器仍携带 transcript 渲染栈本身(markdown、mermaid、katex、transcript 可达样式表),因此仍是多 MB 级。剩下的产品决策由 fix(export): mermaid (~6 MB) is still flattened into the exported transcript renderer #11091 跟踪——只读表面上的 Mermaid 图、图表、公式应该怎么呈现,这与 CodeBlock 对语法高亮已经给出的答案是同一个问题。
  • 未验证/超出范围:CSP 调整、投递模型本身、e2e Playwright 套件、其他包的完整测试套件。导出文档内 Shiki 退化为纯文本是既有行为,前后一致。
  • 破坏性变更/迁移说明:无。包根继续导出 WebShellTranscript@qwen-code/web-shell/transcript 是增量能力。

关联 Issue

Fixes #11031

相关:#10076(引入文档渲染器)、#9812(退役旧渲染器并将投递改为 unpkg)、#11091(渲染器里剩余的 mermaid/katex,需要产品决策)、#11092(重复的 react-markdown)。

…ted HTML

/export html builds document-main.tsx with esbuild bundle:true and inlines
the result into every exported file. The entry imported WebShellTranscript
from the @qwen-code/web-shell package root, whose facade also reaches App,
the daemon providers, the editor/terminal chrome and the full injected
component stylesheet, so each export carried 19,523,259 runtime bytes
(62x the legacy renderer) even for an empty session (#11031).

Add a transcript-only subpath entry (@qwen-code/web-shell/transcript,
mirroring the existing ./daemon-react-sdk subpath and the SDK's
./daemon/transcript pattern) that exports only the read-only transcript
renderer, and point document-main.tsx at it. The entry is built in its own
rollup run so it carries only the stylesheet reachable from transcript
rendering instead of the whole component sheet. Exports stay fully
self-contained: CSP, nonce handling and offline rendering are unchanged.

Also add a hard size budget on the inlined document runtime in the
template build, mirroring the MAX_*_BROWSER_BUNDLE_BYTES assertions in
packages/sdk-typescript/scripts/build.js. The limit sits below the
regression value so a repeat fails the build instead of landing silently.

Measured on the generated document template (empty session):
- before: 19,525,807 bytes raw / 4,775,943 gzip (runtime 19,523,259)
- after:  17,966,485 bytes raw / 4,512,650 gzip (runtime 17,963,937)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-issue-patrol/jmtn7fhrmj3
@github-actions github-actions Bot added the review/self-reported The linked issue was opened by the PR author (self-reported) label Sep 4, 2026
@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Qwen Triage finishedview run. See the stage comments in this thread for the result.

Qwen Triage 已完成 —— 查看运行。结果见本线程中的各阶段评论。

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Re-run at head 08b6fa55, which is a long way from the c4fb0e62 this gate last looked at. The short version: the thing that made me hold back last time is gone.

Template looks good ✓ — all required sections present, including the Chinese translation.

Problem: observed and measured, not theoretical. #11031 is open and carries the original number (a 19,525,807-byte renderer for an empty-session export against 311,854 for the legacy one). Worth being precise about why this still matters after #9812/#11035 moved the renderer out of the file and onto unpkg: the bytes did not disappear, they became a download on a fail-closed path, so size now decides whether an exported file opens at all on a slow or restricted network rather than how big a file sits on disk. That is a stronger framing than "the HTML is big", and the description makes it explicitly rather than quietly.

Direction: aligned. This is a shipping feature whose payload is 2.7x larger than it needs to be, and the fix is build-graph surgery rather than a new subsystem. Upstream CHANGELOG has no direct reference to export-renderer size, but the area is relevant — the closest signal is a fix for transcripts that had grown past 5 MB failing outright, i.e. transcript payload size is a real user-facing failure mode, not a tidiness concern.

Size: no core path is touched (nothing under packages/core/src/**, auth/, providers/, models/, config/, tools/, services/). Stage 0 engages only on the cross-package clause — packages/web-shell + packages/web-templates. Breakdown of the 908 changed lines: 482 production code and build config, 316 markdown docs, 110 tests. Not a refactor (it is fix(export):), no core module, maintainer-authored, and under 500 production lines on the code count — so no Tier 1 block and no size escalation. Flagging the breakdown rather than a threshold.

Approach: the scope is right, and it matches what I'd have proposed independently — narrow subpath entry for the read-only renderer, stub the heavy deps that document mode provably never executes, move the three pure getters out of the CodeMirror-importing hook, then pin all three with a structural guard so the win cannot silently regress. Cutting 80% of this would not work: the subpath entry alone leaves Shiki (~9.7 MB pre-minify) and echarts (~4.4 MB) in the graph, and those two stubs are where the actual win is.

One thing I'd genuinely question, non-blocking: 309 of the 846 added lines — over a third of the diff — are docs/verification/export-html-runtime-size/README.md, a hand-off brief written for a machine that could build. It has served its purpose and is now stale against the head it ships on: it names 9515e5b78d/a9ff4f485b as the commits under test, its §3 table still has ? rows that CI has since answered, and §6 asks the reader to fix two constants that are already fixed at 7,400,000/7,300,000. A verification plan that contradicts the tree it lives in is a small liability in docs/. Either refresh it with the measured numbers or drop it now that the measurement exists — I'd lean toward trimming it to the results. Tracked as one of the suggestions in #11142, so it is not lost either way.

Risk: no elevated risk signals — none of the 22 files match the revert-correlated paths. The two files on the live interactive path (client/shadowDom.ts, client/hooks/useComposerCore.ts) are the ones to look at, and both are covered in the code review below.

Moving on to code review. 🔍

中文说明

在 head 08b6fa55 上重跑 —— 距离本闸门上次审查的 c4fb0e62 已经走了很远。一句话:上次让我保留意见的那个点,现在已经不存在了。

模板完整 ✓ —— 所有必需章节都在,包含中文翻译。

问题: 已观测且有实测数据,不是理论性加固。#11031 处于 open 状态并带有原始数字(空会话导出携带 19,525,807 字节的渲染器,而旧版渲染器只有 311,854 字节)。需要说清楚为什么在 #9812/#11035 把渲染器从文件里挪到 unpkg 之后这件事依然重要:那些字节并没有消失,而是变成了一次位于 fail-closed 路径上的下载 —— 所以体积现在决定的是"导出的文件在慢速或受限网络下能不能打开",而不只是"磁盘上的文件有多大"。这个表述比"HTML 太大"更有力,而且描述里是明说的,不是含糊带过。

方向: 对齐。这是一个已发布功能,其载荷比实际需要大 2.7 倍,而修法是构建图层面的手术,不是新增子系统。上游 CHANGELOG 没有直接涉及导出渲染器体积,但这个领域是相关的 —— 最接近的信号是一个"transcript 超过 5 MB 后直接失败"的修复,也就是说 transcript 载荷体积是真实的用户可见故障来源,不是整洁性问题。

规模: 没有触及任何核心路径(packages/core/src/**auth/providers/models/config/tools/services/ 都没有)。Stage 0 只是因为跨包条款而介入 —— packages/web-shell + packages/web-templates。908 行改动的构成:482 行生产代码与构建配置316 行 markdown 文档110 行测试。不是 refactor(是 fix(export):)、没有核心模块、作者是维护者、按代码口径也在 500 行以下 —— 因此没有 Tier 1 硬阻断,也不触发体积升级。这里只报构成,不报阈值。

方案: 范围合理,也和我独立想到的做法一致 —— 为只读渲染器开一个窄子路径入口、把 document 模式可证明永不执行的重依赖打桩、把三个纯 getter 从引入 CodeMirror 的 hook 里移出来,然后用结构化守卫把这三点都钉住,使成果不会静默回归。砍掉其中 80% 是行不通的:只有子路径入口的话,Shiki(预压缩约 9.7 MB)和 echarts(约 4.4 MB)仍留在图里,而真正的收益正是来自这两个桩。

有一点我确实想质疑,但不阻断:846 行新增里有 309 行 —— 超过 diff 的三分之一 —— 是 docs/verification/export-html-runtime-size/README.md,一份写给"能构建的机器"的交接说明。它已经完成了使命,而且相对于它所附着的 head 已经过期:它把 9515e5b78d/a9ff4f485b 写成待测提交,§3 的表格里还留着 CI 早已填上的 ? 行,§6 要求读者去修两个其实已经定为 7,400,000/7,300,000 的常量。一份与自身所在代码树相矛盾的验证计划,放在 docs/ 里是个小负担。要么用实测数字刷新它,要么在测量已经存在之后直接删掉 —— 我倾向于把它精简成结果。这一条已作为 #11142 中的建议之一被记录,所以两种方式都不会丢。

风险: 无升级风险信号 —— 22 个文件都不匹配与 revert 相关的路径。位于交互式实时路径上的两个文件(client/shadowDom.tsclient/hooks/useComposerCore.ts)是需要重点看的,下面代码审查里都覆盖了。

进入代码审查 🔍

Qwen Code · qwen3.8-max-2026-09-02

Reviewed at 08b6fa557d81c9498d4f2b5a57eaa155a02a3bc1 · re-run with @qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

🖼️ web-shell visual preview

Rendered against a mock daemon (no real backend): the PR base vs this PR head 08b6fa5. Only screenshots that changed are shown (flows below, if any, are head-only) — refreshes on every push.

Screenshots · before / after

terminal-turn-error-copy-narrow-dark before/after

Full-resolution recordings (.webm) are attached to the workflow run.

Qwen Code · web-shell visuals

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Code review

I wrote my independent proposal before opening the diff, and it landed on the same four moves: a narrow subpath entry for the read-only renderer, stubs for the heavy deps document mode provably never executes, the three pure getters lifted out of the CodeMirror-importing hook, and a structural guard so the win cannot silently regress. So my judgement here is mostly about whether the premises hold, because the whole PR rests on three of them. I checked each against the source rather than the description, and all three hold:

  • Shiki is genuinely dead in document mode. In Markdown.tsx's CodeBlock, the highlight effect returns early with setHtml(null) when documentMode is true, so getCodeHighlighter() — and therefore createHighlighter — is never called; the render branch then falls through to <pre><code>{code}</code></pre> because its condition starts with !documentMode. Separately, codeHighlighter.ts value-imports only createHighlighter from shiki (BundledLanguage and Highlighter are type-only and erased), so the stub's single export is exactly the surface the graph needs. This is dead-code removal, not a behaviour change.
  • echarts is only reachable through a default nobody takes. MarkdownChartRenderer.tsx:357 always passes loadECharts: adaptLegacyRuntimeLoader(options.loadEcharts), and that adapter returns a function which throws Chart runtime is unavailable. when no prop was supplied — so the library's ?? (async () => await import('echarts')) fallback is unreachable here, while esbuild's single-outfile IIFE output flattens it in anyway. Nothing in packages/web-shell/client/ imports echarts directly; the only path is @datafe-open/markdown-chart-echarts. The stub's reasoning is correct and its docblock says the quiet part out loud: if exports should ever render charts, the fix is a real runtime (fix(export): mermaid (~6 MB) is still flattened into the exported transcript renderer #11091), not making the stub work.
  • utils/composerTag.ts really is editor-free. Its entire import list is four SVG assets plus two type-only imports. And the move is complete rather than cosmetic: the three getters' local call sites inside useComposerCore.ts still resolve through the new import, and the re-export keeps ChatEditor.tsx and useComposerCore.test.ts working untouched — so no caller was stranded and nothing was duplicated.

Two more things I checked because they are the kind of gap that survives a green suite:

  • The CSS-injection contract has exactly two read sites, and both are in this diff. Grepping the whole tree for data-qwen-web-shell="component" finds only the injector in vite.lib.config.ts and the reader in shadowDom.ts. The injector still sets the shared marker alongside the new per-entry key, so shadowDom.test.ts and any external host that only knows the old marker keep working — the change is additive, not a break. The first-match → concatenate-every-match switch in getWebShellStyleText is the correct companion: with per-entry keys, a host importing both entries now has two tags, and taking the first would drop the editor/dialog rules whenever the transcript entry happened to load first.
  • dist/types/transcript.d.ts is actually emitted. The new exports-map entry declares a types target, and tsconfig.lib.json includes client/**/*.ts, so client/transcript.ts is covered. A missing declaration file there would not fail this repo's build — it would silently break typed consumers of the subpath later.

The guard is not decoration — I traced whether it runs anywhere. Root prepare fires on npm ci in CI, which builds every workspace; packages/web-templates/build.mjs spawns src/export-html/build.mjs and rejects on a non-zero exit. So a throw from FORBIDDEN_DOCUMENT_INPUTS or the byte cap reds the required Test and Lint & Static jobs. A structural guard that only runs on a maintainer's laptop is worth nothing; this one fails closed on every PR.

The budget is not an invented convention either. packages/sdk-typescript/scripts/build.js already carries DAEMON_BROWSER_BUNDLE_WARNING_BYTES and MAX_TRANSCRIPT_BROWSER_BUNDLE_BYTES with the same warn-then-throw shape and near-identical message wording, so this follows an established pattern instead of adding a parallel one.

Both prior blocking findings are resolved at this head, and I checked them against the code rather than the thread state. The Critical about the inaccurate "loading both entries is a no-op" comment is gone — the replacement states the real failure mode (first-wins suppression) plus the cascade-order caveat that makes it non-trivial. And the red not.toContain('DaemonSessionProvider') assertion was resolved by taking the reviewer's option 3 honestly: the absent-symbol assertions are replaced with a size bound, and client/transcript.ts's docblock now names the daemon hook runtime as a known residual with #11100 as its home, instead of claiming a cleanliness the graph does not deliver. That is the right call — a guard that asserts something false is worse than no guard.

The last blocker is genuinely fixed and pinned. WebShellTranscript.tsx now imports globals.css before katex.min.css, and the new keeps KaTeX border overrides after Tailwind preflight case asserts katexIndex > preflightIndex across both emitted stylesheets. So the cascade invariant that broke is now a test, not a comment.

No Critical findings. Two non-blocking observations:

  1. The budget has 1.7% headroom, and it sits on everyone's CI path. Cap 7,400,000 against a measured 7,275,173 leaves ~125 KB. Because that build runs inside npm ci's prepare, the next dependency bump that grows the transcript entry will red CI for a PR that has nothing to do with exports. The ratchet is deliberate, documented, precedented, and its error message says exactly what to do — so this is a heads-up for fix(export): mermaid (~6 MB) is still flattened into the exported transcript renderer #11091, not a request to change it.
  2. The injected stylesheet is now the largest single component of the transcript entry. CI reports dist/transcript.js at 3,432.92 kB total, against a JS remainder of ~1,140,948 bytes (that remainder is the author's reviewer-measured figure at 1d94060f5, not a CI number), so roughly two-thirds of the entry is CSS. And per this PR's own test comment, Tailwind v4 compiles classes from every scanned source file rather than the entry's graph — so the separate rollup run scopes the CSS Modules but not the Tailwind utility layer. That is a bigger remaining lever than the ~236 KB daemon residual in fix(web-shell): the transcript entry still carries the daemon hook runtime #11100, and worth recording next to the mermaid/echarts numbers in fix(export): mermaid (~6 MB) is still flattened into the exported transcript renderer #11091.
Files changed (22 of 22 shown)
File What changed
packages/web-shell/client/transcript.ts New transcript-only entry; docblock names the daemon-hook residual honestly instead of over-claiming
packages/web-shell/vite.lib.config.ts Per-entry CSS injection key, and a third rollup run so the transcript entry gets its own stylesheet
packages/web-shell/client/shadowDom.ts Reader concatenates every injected tag instead of taking the first — the companion to the key change
packages/web-shell/client/utils/composerTag.ts The three display getters land here, in a module with no editor imports
packages/web-shell/client/hooks/useComposerCore.ts Getter definitions removed, imported back and re-exported so existing callers are untouched
packages/web-shell/client/components/messages/UserMessage.tsx Imports the getters from the editor-free module, which is what drops CodeMirror from the transcript graph
packages/web-shell/client/adapters/localizedMessages.ts New leaf module for the localized projection, so the transcript need not reach the daemon barrel
packages/web-shell/client/hooks/useMessages.ts Projection removed and re-exported from the leaf; behaviour unchanged for callers
packages/web-shell/client/components/WebShellTranscript.tsx Imports from the leaf, and the KaTeX/globals CSS order swap that fixes the cascade
packages/web-shell/client/build-artifact.test.ts Five new cases: editor-stack absence, a size bound, entry keys, and the KaTeX cascade invariant
packages/web-templates/src/export-html/build.mjs Forbidden-input guard, byte budget, metafile top-inputs report, and the stub-resolving esbuild plugin
packages/web-templates/src/export-html/src/document-shiki-stub.ts Shiki replacement, with the document-mode and CSP reasoning recorded
packages/web-templates/src/export-html/src/document-echarts-stub.ts echarts replacement for the never-taken dynamic-import default
packages/web-templates/src/export-html/src/document-main.tsx One import line: package root to the transcript subpath
packages/web-shell/package.json The ./transcript exports entry and the third vite pass in the build script
packages/web-shell/tsconfig.json Path mapping for the new subpath
packages/web-shell/vite.config.ts Same alias for the app build
packages/web-shell/vitest.config.ts Same alias so tests resolve the subpath
eslint.config.js Adds the export-html build scripts to the existing node-run override
packages/web-shell/README.md Read-only recipe now uses the subpath, with a note not to rely on tree shaking
docs/design/2026-07-14-web-shell-readonly-daemon-transcript.md One-line example correction to match the new import path
docs/verification/export-html-runtime-size/README.md 309-line hand-off brief; see the Stage 1 note — now stale against its own head

Testing

This is an unattended CI run (GITHUB_EVENT_NAME=issue_comment), so per the skill's rules I did not build or execute any PR-derived code. Everything below is the PR's own CI on the reviewed commit, read through the API — real check names, real conclusions, and the build's own stdout from the Test job log.

Every check that ran is green: 17 success, 69 skipped, 0 failure. The only two non-terminal entries are review-pr jobs, which are bot orchestration on pull_request_target, not this PR's CI.

Check Conclusion
Test (ubuntu-latest, Node 22.x) success
Lint & Static (ubuntu-latest, Node 22.x) success
Integration Tests (no-AK, No Sandbox) success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) success
Capture web-shell visuals (ubuntu-latest, Node 22.x) success
Desktop Shell (ubuntu-22.04) success
Desktop Shell (windows-2022) success
Classify PR success
review-pr (×2, bot orchestration) in_progress
69 further checks (platform/lane variants not selected for this diff) skipped

Suite totals from the same log: 1,014 test files / 28,679 tests passed (90 skipped) in the main run, and 643 files / 23,524 passed in the CLI run, with no failures anywhere. Integration Tests (no-AK) is green here, which matters because the earlier qwen-live-m4-acp-steering failure in this lane was the flake blamed on the #11033/#11034 shared-pool family.

The interesting evidence is not the pass/fail column, though — it is that this PR's own build prints its measurements, and they ran on this exact commit. From the Test job log at 08b6fa55:

Document export top inputs (pre-minify bytes): first-party 3730357, mermaid 2880314,
  lucide-react 1574512, @mermaid-js/parser 1366732, cytoscape 1108285,
  lodash-es 629557, katex 601155, react-dom 545403
Document export runtime is 7275173 bytes

dist/index.js               6,978.71 kB │ gzip: 1,944.64 kB
dist/transcript.js          3,432.92 kB │ gzip: 1,285.36 kB
✓ 299 modules transformed.  ✓ built in 2.93s     ← the third vite pass

Three things this settles independently of anyone's local machine:

  • The size claim is real and much larger than this gate last reported. 19,523,259 → 7,275,173 bytes is a 62.7% reduction (2.68x), not the 8% I wrote at c4fb0e62 — that pass predates the Shiki/echarts/CodeMirror work. The number also matches what the previous reviewer measured locally, so two independent builds agree.
  • The forbidden inputs really are gone. No shiki, @shikijs, codemirror, @codemirror, echarts, zrender or vaul appears in the top-inputs list, and the guard did not throw. What remains is mermaid and its graph (~5.35 MB across mermaid, @mermaid-js/parser and cytoscape) plus lucide-react and katex — all genuinely used by document mode, and all recorded in fix(export): mermaid (~6 MB) is still flattened into the exported transcript renderer #11091.
  • The third vite pass costs 2.93s, transforming 299 modules against 583 for the index+SDK run. The hand-off doc asked whoever could build to flag it if this exceeded ~90s; it does not, and the narrow module count is itself evidence the entry really is a subset.

One red-looking line in the log is pre-existing and not this PR's: Browser daemon SDK bundle exceeds the 221184-byte warning threshold. That is the warning branch of the existing budget check in packages/sdk-typescript/scripts/build.js, a file this diff does not touch — and it is a console.warn, not a failure, in the same job that concluded success.

Not verified, and why it matters: rendered-output parity of an actual exported file. I confirmed statically that Shiki, echarts and CodeMirror are unreachable in document mode, and CI proves the bytes are gone, but no green check opens a generated export and compares it against main. Capture web-shell visuals and web-shell E2E Smoke cover the interactive shell — useful, since this diff touches live-path files — but not the document renderer.

Sandboxed verification would settle this: @qwen-code /verify — that an exported transcript renders identically to main is not observable from the diff, and the open #11100 question sits on exactly that path: TasksStatusMessage.tsx:353 calls useActions() unconditionally while WebShellTranscript mounts no provider, so either the document projector filters those blocks (dead weight) or an export containing one throws at render and the reader gets the fail-closed page (a real user-visible bug). Only a genuine export answers which. A /verify run is already in flight on this head (run 34015301707); its report will post separately and is the right place to read that answer. The author has write access, so this is an ordinary run rather than a sponsored one.

Real-scenario tmux testing: N/A — this is an unattended CI run, so live behaviour comes from the lane above rather than from anything I drove locally.

中文说明

代码审查。 我在打开 diff 之前先写了自己的独立方案,结论落在同样的四步上:为只读渲染器开窄子路径入口、给 document 模式可证明永不执行的重依赖打桩、把三个纯 getter 从引入 CodeMirror 的 hook 中提出来、再加结构化守卫防止成果静默回归。所以我的判断主要集中在前提是否成立 —— 整个 PR 依赖三个前提,我逐个对照源码而不是描述来验证,三个都成立:

  • Shiki 在 document 模式下确实是死代码。 Markdown.tsxCodeBlock 中,高亮 effect 在 documentMode 为真时提前 setHtml(null) 返回,因此 getCodeHighlighter()(进而 createHighlighter)永远不会被调用;渲染分支的条件以 !documentMode 开头,所以总是落到 <pre><code>。另外 codeHighlighter.tsshiki 只值导入了 createHighlighterBundledLanguageHighlighter 是纯类型,会被擦除),桩的单一导出恰好就是所需接口。这是删除死代码,不是行为变更。
  • echarts 只能通过一个没人走的默认分支到达。 MarkdownChartRenderer.tsx:357 总是传入 loadECharts: adaptLegacyRuntimeLoader(...),而该适配器在未传 prop 时返回一个抛 Chart runtime is unavailable. 的函数 —— 所以库里的 ?? (async () => await import('echarts')) 兜底在本仓库不可达,而 esbuild 的单文件 IIFE 输出又无法分包,只能把它压平进来。packages/web-shell/client/ 里没有任何直接 echarts 导入,唯一路径是 @datafe-open/markdown-chart-echarts
  • utils/composerTag.ts 确实与编辑器无关。 它的全部导入是四个 SVG 资源加两个纯类型导入。而且这次搬迁是完整的:useComposerCore.ts 内部对这三个 getter 的调用点仍通过新导入解析,re-export 让 ChatEditor.tsxuseComposerCore.test.ts 无需改动 —— 没有调用方被落下,也没有重复实现。

另外两点是我专门查的,因为它们是"绿测试也能漏掉"的那类缺口:

  • CSS 注入契约只有两个读取点,且都在本 diff 内。 全树搜索 data-qwen-web-shell="component" 只找到 vite.lib.config.ts 的注入方与 shadowDom.ts 的读取方。注入方在新增按 entry 区分的同时仍保留共享标记,所以 shadowDom.test.ts 和只认旧标记的外部宿主照常工作 —— 这是增量改动而非破坏。getWebShellStyleText 从"取第一个"改为"拼接全部"是正确的配套:有了 per-entry key,同时导入两个入口的宿主会有两个 style 标签,取第一个就会在 transcript 先加载时丢掉编辑器/对话框规则。
  • dist/types/transcript.d.ts 确实会产出。 新的 exports 条目声明了 types 目标,而 tsconfig.lib.json 包含 client/**/*.ts,所以 client/transcript.ts 在覆盖范围内。这里若缺声明文件,本仓库构建不会失败,只会在之后静默破坏子路径的类型消费者。

守卫不是摆设 —— 我追了它到底会不会运行。prepare 在 CI 的 npm ci 时触发,会构建所有 workspace;packages/web-templates/build.mjs 会 spawn src/export-html/build.mjs 并在退出码非零时 reject。所以 FORBIDDEN_DOCUMENT_INPUTS 或字节上限抛错会把必需的 TestLint & Static 变红。只在维护者本机运行的结构化守卫毫无价值;这个守卫在每个 PR 上都是 fail-closed 的。

预算也不是凭空发明的约定。 packages/sdk-typescript/scripts/build.js 已经有 DAEMON_BROWSER_BUNDLE_WARNING_BYTESMAX_TRANSCRIPT_BROWSER_BUNDLE_BYTES,同样是先 warn 后 throw、报错措辞几乎一致,所以这是沿用既有模式而非另起一套。

两条此前的阻断性意见在当前 head 已解决,而且我是对照代码确认的,不是看线程状态。 关于"加载两个入口是 no-op"这句不准确注释的 Critical 已经消失 —— 替换后的注释写出了真实的失效方式(先到先得的抑制)以及让它不平庸的层叠顺序注意事项。而红掉的 not.toContain('DaemonSessionProvider') 断言,是诚实地采纳了审查者的方案三:用体积上界替换"符号缺失"断言,并让 client/transcript.ts 的 docblock 把 daemon hook 运行时点名为已知残留、以 #11100 为归属,而不是宣称图比实际更干净。这个取舍是对的 —— 断言假事的守卫比没有守卫更糟。

最后一个阻断项是真修好了并且被钉住了。 WebShellTranscript.tsx 现在先导入 globals.css 再导入 katex.min.css,新增的 keeps KaTeX border overrides after Tailwind preflight 用例在两个产物样式表上都断言 katexIndex > preflightIndex。也就是说曾经出问题的层叠不变量现在是一个测试,而不是一句注释。

无 Critical。两条非阻断观察:

  1. 预算只有 1.7% 余量,而且它位于所有人的 CI 路径上。 上限 7,400,000 对实测 7,275,173,剩约 125 KB。由于该构建在 npm ciprepare 中运行,下一次让 transcript 入口变大的依赖升级,会把一个与导出毫无关系的 PR 的 CI 弄红。这个棘轮是有意的、有记录的、有先例的,报错信息也明确说了该怎么做 —— 所以这是给 fix(export): mermaid (~6 MB) is still flattened into the exported transcript renderer #11091 的提醒,不是要求修改。
  2. 注入的样式表现在是 transcript 入口中最大的单一组成。 CI 报告 dist/transcript.js 总计 3,432.92 kB,而 JS 剩余部分约 1,140,948 字节(这个剩余值是作者在 1d94060f5 时由审查者本地测得的,不是 CI 数字),所以该入口约三分之二是 CSS。并且按本 PR 自己的测试注释,Tailwind v4 是按扫描到的所有源文件编译类名、而非按入口的依赖图 —— 所以独立 rollup run 限定的是 CSS Modules,而不是 Tailwind 工具类层。这是比 fix(web-shell): the transcript entry still carries the daemon hook runtime #11100 里那约 236 KB daemon 残留更大的剩余杠杆,值得和 mermaid/echarts 的数字一起记进 fix(export): mermaid (~6 MB) is still flattened into the exported transcript renderer #11091

测试。 这是无人值守的 CI 运行(GITHUB_EVENT_NAME=issue_comment),因此按 skill 规则我没有构建或执行任何 PR 派生代码。以下全部是本 PR 自己在被审提交上的 CI,通过 API 读取 —— 真实的检查名、真实的结论,以及 Test 作业日志里构建自身的 stdout。

所有实际运行的检查都是绿的:17 success、69 skipped、0 failure。唯一的两个未终结项是 review-pr 作业,属于 pull_request_target 上的机器人编排,不是本 PR 的 CI。主运行 1,014 个测试文件 / 28,679 个测试通过(90 跳过),CLI 运行 643 文件 / 23,524 通过,无任何失败。

真正有意思的证据不在通过/失败那一列,而在于本 PR 的构建自己会打印测量值,并且它们正是跑在这个提交上:document 运行时 7,275,173 字节,top-inputs 中没有 shiki、codemirror、echarts、zrender、vaul;dist/index.js 6,978.71 kB 对 dist/transcript.js 3,432.92 kB;第三次 vite pass 只花 2.93s(299 个模块,而 index+SDK 那次是 583 个)。这三点独立于任何人的本机确定下来:体积收益是 62.7%(2.68x),而不是我上次在 c4fb0e62 写的 8%(那次早于 Shiki/echarts/CodeMirror 工作);被禁输入确实消失了;第三次构建的代价可以忽略 —— 交接文档要求"若超过 ~90s 请说明",实际远未达到,而且模块数收窄本身就证明该入口确实是子集。

日志里有一行看起来像红的,但是既有问题、与本 PR 无关Browser daemon SDK bundle exceeds the 221184-byte warning threshold。那是 packages/sdk-typescript/scripts/build.js 中既有预算检查的 warning 分支,本 diff 没有碰这个文件 —— 而且它是 console.warn 而非失败,所在作业结论是 success

未验证,以及为什么重要: 真实导出文件的渲染一致性。我从静态上确认了 Shiki、echarts、CodeMirror 在 document 模式不可达,CI 也证明字节确实消失了,但没有任何绿色检查会打开一个生成的导出文件并与 main 对比。Capture web-shell visualsweb-shell E2E Smoke 覆盖的是交互式 shell —— 这有用,因为本 diff 动了实时路径文件 —— 但不覆盖 document 渲染器。

沙箱验证可以定这件事:@qwen-code /verify —— "导出的 transcript 与 main 渲染一致"无法从 diff 观察,而 #11100 那个未决问题正好落在这条路径上:TasksStatusMessage.tsx:353 无条件调用 useActions(),而 WebShellTranscript 不挂载任何 provider,所以要么 document projector 过滤掉了这些块(属于死重量),要么含此类块的导出会在渲染时抛错、读者拿到 fail-closed 页面(那就是真实的用户可见 bug)。只有一次真正的导出能回答是哪一种。该 head 上已有一个 /verify 运行在进行中(run 34015301707),其报告会单独发布,是读取该答案的合适位置。作者有写权限,所以这是常规运行而非受赞助运行。

真实场景 tmux 测试:N/A —— 这是无人值守的 CI 运行,实时行为信号来自上面那条通道,而不是我在本地驱动的任何东西。

Qwen Code · qwen3.8-max-2026-09-02

Reviewed at 08b6fa557d81c9498d4f2b5a57eaa155a02a3bc1 · re-run with @qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Confidence: 4/5 — every premise this PR rests on checks out against the source, CI measured the win on this exact commit, and both prior blockers are fixed the honest way; what keeps it off 5/5 is a comment in the diff that contradicts another comment in the same diff, plus one claim no green check can reach.

First, a correction to my own last pass. At c4fb0e62 I wrote that a user gets 8% out of this — 19.5 MB down to 18 MB — and said plainly that if the expectation attached to #11031 was "exports become small", this was not that PR. That judgement is now obsolete and I want to retract it rather than let it stand in the thread. CI on this head prints Document export runtime is 7275173 bytes against the 19,523,259 baseline: 62.7% smaller, 2.68x, with Shiki, CodeMirror, echarts, zrender and vaul all absent from the top-inputs list. The three levers I had not yet seen were where the entire win was. My reservation was based on a real measurement of an earlier state, but it was the wrong number to carry forward, and the description's own honesty about the pre-merge figures is what let me check it instead of trusting it.

My independent proposal — narrow subpath entry, stubs for what document mode provably never executes, lift the three getters out of the CodeMirror hook, then pin it structurally — is what this PR does. Where it exceeds my proposal is in the two places I would probably have under-built:

  • The guard is structural rather than a bare byte cap, and it fails with a reason. A cap only catches growth and only once it is large; naming the module graphs that must never reach an export catches the actual regression, which is somebody re-widening an import. And I traced that it genuinely runs: root prepare on npm ci → workspace build → packages/web-templates/build.mjs spawns the export build and rejects on a non-zero exit, so a throw reds the required Test and Lint & Static jobs. A guard that only fires on a maintainer's laptop is worth nothing. This one fails closed on every PR, and the budget shape mirrors packages/sdk-typescript/scripts/build.js rather than inventing a parallel convention.
  • The daemon residual is documented instead of hidden. When the reviewer proved the "no daemon providers" assertion was false, the easy move was to delete the test and keep the claim. What landed instead says the quiet part in the entry's docblock — the provider guards are in dist/transcript.js, decoupling them is fix(web-shell): the transcript entry still carries the daemon hook runtime #11100 — and bounds the payload, which is a property the entry actually delivers. That is the correct call and it is why I trust the rest of the docblocks.

Six months from now I would thank the author rather than curse them. The stubs each carry the reasoning that makes them safe to delete, including the instruction to remove the plugin rather than make the stub work; the budget comment records both measurements and their provenance (whose machine, which commit); the eslint line extends an existing override instead of carving out an exception. The third vite pass — the piece that looks like over-engineering on a skim, and the one I probed hardest last time — costs 2.93s for 299 modules against 583 for the index run, which also answers the hand-off doc's "flag it if this exceeds ~90s". The narrow module count is itself evidence the entry is a real subset.

The coordination question I raised last time is settled: #11035 has merged, so there is no longer a second open PR claiming the same P1, and this one is the only track left on #11031.

What I would still like, none of it blocking this merge:

  • adapters/localizedMessages.ts's docblock ends with a line that is false at head — "client/build-artifact.test.ts asserts that dist/transcript.js carries no daemon provider code." The suite asserts a size bound and says so explicitly, and client/transcript.ts, added by this same PR, states the opposite ("this is not free of the daemon React runtime"). Two new files in one diff contradicting each other is a one-sentence fix, and it matters more than a ordinary stale comment because it sends the next reader hunting for a guard that does not exist. Here or in the fix(web-shell): the transcript entry still carries the daemon hook runtime #11100 pass.
  • The 309-line verification hand-off in docs/verification/ has done its job and now contradicts the tree it ships in — it names two superseded commits as under test, leaves ? rows CI has since filled, and asks the reader to fix constants already fixed. Trim it to the results or drop it.
  • The budget's 1.7% headroom sits on everyone's CI path, since that build runs inside npm ci's prepare. Deliberate, documented, precedented, actionable error message — but the next mermaid or katex bump will meet it, so fix(export): mermaid (~6 MB) is still flattened into the exported transcript renderer #11091 should know.
  • CSS is now the largest single component of the transcript entry (~2.29 MB of its 3.43 MB), and Tailwind v4 scans every source file rather than the entry's graph, so the separate rollup run scopes the CSS Modules but not the utility layer. That is a bigger remaining lever than the ~236 KB daemon residual and belongs next to the mermaid numbers in fix(export): mermaid (~6 MB) is still flattened into the exported transcript renderer #11091.

What I am not claiming. Rendered-output parity of an actual exported file. I verified statically that Shiki is unreachable in document mode (the highlight effect returns early and the render branch starts with !documentMode), that echarts is only reachable through a default nobody takes, and that the module the getters moved into has no editor imports — and CI proves the bytes are gone. But no green check opens a generated export and compares it to main, and the #11100 hazard sits exactly there: TasksStatusMessage.tsx:353 calls useActions() unconditionally while the document entry mounts no provider, so either those blocks never reach an export (dead weight) or one that contains them throws at render (a user-visible bug). A /verify run is already in flight on this head and will post its own report; that, not this comment, is where that question gets answered. I am approving on the strength of what is measured and what I read in the source, and saying so rather than letting the approval imply more than it covers.

On whether I am being worn down: this is one PR through many rounds, not a volume problem, and I re-derived the premises from the source and the CI log rather than accepting the thread's account of them. The re-run earned its keep — it corrected my own stale 8% figure, produced the measured third-pass cost, established that the guard actually runs in CI, and caught the contradictory docblock. Approving.

中文说明

Confidence: 4/5 —— 本 PR 依赖的每个前提都对照源码验证成立,CI 在这个确切提交上实测了收益,两个此前的阻断项都以诚实的方式修好了;让它没到 5/5 的,是 diff 里一句与同一 diff 中另一句相互矛盾的注释,外加一个绿色检查无法触及的声明。

首先,修正我自己上一轮的判断。在 c4fb0e62 时我写"用户从中得到 8%"——19.5 MB 降到 18 MB——并且明说:如果对 #11031 的期待是"导出变小",那这不是那个 PR。这个判断现在已经过期,我要撤回它,而不是让它留在讨论串里。本 head 上的 CI 打印 Document export runtime is 7275173 bytes,对照 19,523,259 的基线是 缩小 62.7%、2.68x,且 top-inputs 中 shiki、CodeMirror、echarts、zrender、vaul 全部消失。我上次没看到的三个杠杆,正是全部收益所在。我的保留意见基于对一个更早状态的真实测量,但它是不该被继续带下去的错误数字;而描述对合并前数据的坦诚,正是让我能够去核对它、而不是直接相信它的原因。

我的独立方案——窄子路径入口、给 document 模式可证明永不执行的部分打桩、把三个 getter 从 CodeMirror hook 中提出来、再做结构化钉住——正是本 PR 所做的。它超出我方案的地方,恰好是我自己很可能会做不足的两处:

  • 守卫是结构化的,而不是单纯的字节上限,并且失败时会给出理由。上限只能捕捉增长、而且要等增长足够大;点名"绝不能到达导出产物的模块图"才能捕捉真正的回归——也就是有人把某个导入重新放宽。而且我追了它确实会运行:npm ci 触发根 prepare → 构建各 workspace → packages/web-templates/build.mjs spawn 导出构建并在退出码非零时 reject,所以一次 throw 会把必需的 TestLint & Static 变红。只在维护者本机触发的守卫毫无价值;这个守卫在每个 PR 上都是 fail-closed,并且预算形态沿用了 packages/sdk-typescript/scripts/build.js,而不是另立一套约定。
  • daemon 残留是被写出来的,而不是被藏起来的。当审查者证明"无 daemon providers"这个断言是假的,省事的做法是删掉测试、保留说法。实际落地的是在入口 docblock 里把话说明白——provider guards 确实dist/transcript.js 里,解耦它们是 fix(web-shell): the transcript entry still carries the daemon hook runtime #11100——并改为约束载荷体积,这是该入口真正能交付的属性。这个取舍是对的,也是我为什么信任其余 docblock 的原因。

六个月后我会感谢作者而不是骂他。两个桩都带着"为什么可以安全删除"的推理,包括"应当删掉插件而不是把桩做通"的指示;预算注释同时记录了两个测量值及其来源(谁的机器、哪个提交);eslint 那一行扩展了已有的覆盖块而不是新开例外。第三次 vite 构建——粗看最像过度设计、也是我上次推敲最久的部分——代价是 2.93s、299 个模块,而 index 那次是 583 个;这同时回答了交接文档里"若超过 ~90s 请说明"的疑问。模块数收窄本身就是该入口确为子集的证据。

我上次提的协调问题已经解决:#11035 已合并,所以不再有第二个 open PR 声称同一个 P1,本 PR 是 #11031 上唯一剩下的线。

我仍然希望看到的(都不阻断本次合并):

  • adapters/localizedMessages.ts 的 docblock 结尾有一句在 head 上为假的话——"client/build-artifact.test.ts asserts that dist/transcript.js carries no daemon provider code"。测试套件断言的是体积上界并且明确这么写了,而由同一 PR 新增的 client/transcript.ts 说的是相反的话("this is not free of the daemon React runtime")。一个 diff 里两个新文件互相矛盾,是一句话就能修的事,但它比普通的过期注释更值得修:它会让下一个读者去找一个并不存在的守卫。可以在这里修,也可以在 fix(web-shell): the transcript entry still carries the daemon hook runtime #11100 那一轮修。
  • docs/verification/ 里那份 309 行的验证交接说明已经完成使命,现在与它所附着的代码树相矛盾——它把两个已被取代的提交写成待测提交,留着 CI 早已填上的 ? 行,还要求读者去修其实已经定好的常量。建议精简成结果,或直接删掉。
  • 预算 1.7% 的余量位于所有人的 CI 路径上,因为该构建在 npm ciprepare 中运行。这是有意为之、有记录、有先例,报错信息也可操作——但下一次 mermaid 或 katex 升级就会撞上它,所以 fix(export): mermaid (~6 MB) is still flattened into the exported transcript renderer #11091 应该知道这件事。
  • CSS 现在是 transcript 入口中最大的单一组成(3.43 MB 中约 2.29 MB),而 Tailwind v4 按所有源文件扫描、不按入口依赖图,所以独立 rollup run 限定的是 CSS Modules 而非工具类层。这是比约 236 KB daemon 残留更大的剩余杠杆,应与 mermaid 的数字一起放进 fix(export): mermaid (~6 MB) is still flattened into the exported transcript renderer #11091

我没有声称的部分。 真实导出文件的渲染一致性。我从静态上验证了 Shiki 在 document 模式不可达(高亮 effect 提前返回,渲染分支以 !documentMode 开头)、echarts 只能通过一个没人走的默认分支到达、以及 getter 移入的模块没有编辑器导入——CI 也证明字节确实消失了。但没有任何绿色检查会打开一个生成的导出文件与 main 对比,而 #11100 的隐患正好在那里:TasksStatusMessage.tsx:353 无条件调用 useActions(),而 document 入口不挂载任何 provider,所以要么这些块永远到不了导出产物(属于死重量),要么含有它们的导出会在渲染时抛错(用户可见 bug)。该 head 上已有 /verify 运行在进行中并会发布自己的报告;回答那个问题的地方是那份报告,而不是本条评论。我的批准建立在已实测的数据和我在源码中读到的内容之上,并且明说了这一点,而不是让批准暗示它并未覆盖的范围。

关于我是否被磨软了:这是一个 PR 走了很多轮,不是数量问题;而且我是从源码和 CI 日志重新推导前提,而不是接受讨论串对它们的转述。这次重跑是有价值的——它修正了我自己过期的 8% 数字、给出了第三次构建的实测代价、确认了守卫确实会在 CI 中运行,并抓到了那句自相矛盾的 docblock。批准。

Qwen Code · qwen3.8-max-2026-09-02

Reviewed at 08b6fa557d81c9498d4f2b5a57eaa155a02a3bc1 · re-run with @qwen-code /triage

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partially reviewed — gaps disclosed. Suggestions are inline.

Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

Not explored to full depth (tool budget reached): "agent 1a": did not run node src/export-html/build.mjs myself to re-measure the runtime size; relied on the dist/document.html artifact built from HEAD during worktree ….

Test Plan (not a blocker): client/build-artifact.test.tsno such file or directory; client/index.test.tsxno such file or directory; src/ui/utils/export/formatters/html.test.tsno such file or directory; src/ui/utils/export/export-transcript-document.test.tsno such file or directory; src/export-html/build.mjsno such file or directory; and 2 more.

中文说明

仅完成部分审查,审查缺口已披露。 建议见行内评论。

未审查(原文为英文):build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

未探索到全部深度(达到工具调用预算):"agent 1a"did not run node src/export-html/build.mjs myself to re-measure the runtime size; relied on the dist/document.html artifact built from HEAD during worktree …

Test Plan(非阻断):client/build-artifact.test.tsno such file or directory; client/index.test.tsxno such file or directory; src/ui/utils/export/formatters/html.test.tsno such file or directory; src/ui/utils/export/export-transcript-document.test.tsno such file or directory; src/export-html/build.mjsno such file or directory; and 2 more。

— qwen3.8-max via Qwen Code /review (v0.23.0)

Comment thread packages/web-shell/client/transcript.ts Outdated
Comment thread packages/web-shell/vite.lib.config.ts Outdated
Comment thread packages/web-shell/vite.lib.config.ts Outdated
Comment thread packages/web-shell/client/transcript.ts Outdated
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Qwen Code review did not complete successfully. The review pipeline failed before a review could be posted. A transient error is retried automatically; if you are seeing this, retry with @qwen-code /review. See workflow logs.

The transcript-only entry stopped the interactive shell from reaching the
inlined document runtime, but two large graphs still did, and the size
budget could not see either of them.

- Shiki (~9.7 MB of pre-minify inputs, the single largest) is unreachable
  in an export: `CodeBlock` returns before touching the highlighter when
  `renderMode === 'document'` and always renders a plain `<pre>`. It is a
  static import of `codeHighlighter.ts`, so esbuild cannot drop it. The
  document build now resolves `shiki` / `@shikijs/*` to a stub. Its
  Oniguruma WASM engine could not have started under the export CSP
  (`script-src 'nonce-…'`, no `'wasm-unsafe-eval'`) in any case.
- CodeMirror (~1 MB) still reached the transcript entry: `UserMessage`
  imported three composer-tag string getters from `hooks/useComposerCore.ts`,
  which pulls the whole editor at top level. The getters move to
  `utils/composerTag.ts` (editor-free, and already imported by
  `UserMessage`); `useComposerCore` re-exports them for existing callers.

Replace the byte-cap-only ratchet with a structural one: the document build
now emits a metafile and fails when a forbidden module graph appears in the
inputs (Shiki, the web-shell package root, CodeMirror), naming the reason.
The byte cap stays as a coarse backstop and still needs re-measuring.

Also fix a latent collision introduced by building the transcript entry in
its own rollup run: both entries injected their stylesheet under the same
`data-qwen-web-shell="component"` key, so a host importing both would keep
whichever loaded first and silently lose the other's rules. Injection is now
keyed per entry, and shadow-root style adoption concatenates every match.

Refs #11031
The change to the inlined `/export html` runtime rests on measurements that
cannot be taken on the machine that wrote it. Rather than leave that as a
caveat in the PR description, commit the plan to the branch so any
build-capable machine gets it by cloning: exact commands, the reference
numbers to compare against and whose machine they came from, the premise the
Shiki removal rests on plus a way to falsify it independently, the two budget
constants that are knowingly stale, and what to report back.

Follows the docs/verification/abort-controller-refactor layout; results belong
next to it as results.md.

Refs #11031
@yiliang114

Copy link
Copy Markdown
Collaborator Author

Follow-up pushed (a9ff4f485b, aca14c6be4). Two module graphs were still reaching the inlined document runtime that the transcript-only entry did not cut, and the byte budget could not see either of them.

  • Shiki (~9.7 MB of pre-minify inputs, the largest single item) is dead code in an export. CodeBlock in packages/web-shell/client/components/messages/Markdown.tsx returns before touching the highlighter when renderMode === 'document', and its render branch always emits a plain <pre> in that mode — which is why the plain-text fallback noted in the description looks identical before and after. It is a static import of codeHighlighter.ts, so esbuild cannot drop it; the document build now resolves shiki / @shikijs/* to a stub. Its Oniguruma WASM engine could not have started under the export CSP (script-src 'nonce-…', no 'wasm-unsafe-eval') in any case.
  • CodeMirror (~1 MB) still shipped. WebShellTranscript → MessageList → MessageItem → UserMessage imported three composer-tag string getters from hooks/useComposerCore.ts, which pulls the whole editor at top level. The getters moved to utils/composerTag.ts (editor-free, and already imported by UserMessage); useComposerCore re-exports them.
  • The budget is now structural, not just numeric. The document build emits an esbuild metafile and fails when a forbidden graph appears in the inputs (Shiki, the web-shell package root, CodeMirror), naming the reason. It also prints a per-package input breakdown, and EXPORT_HTML_METAFILE=<path> dumps the metafile, so re-measuring never requires editing the build script.
  • Latent bug from the separate rollup run: both entries injected their stylesheet under the same data-qwen-web-shell="component" key, so a host importing both the package root and /transcript would keep whichever loaded first and silently lose the other's rules. Injection is now keyed per entry, and shadow-root style adoption concatenates every match.

Two corrections to the description above, which I will rewrite once the numbers are re-measured:

  • "editor/terminal chrome … no longer ships" was not true at 9515e5b78d; CodeMirror did still ship, and the follow-up commit is what removes it.
  • The daemon React SDK still ships even after this. It is reachable from the transcript at five points, including client/hooks/useMessages.ts, which WebShellTranscript imports directly (~236 KB). Deliberately left alone.

The before/after table is therefore stale, and DOCUMENT_RUNTIME_WARNING_BYTES / MAX_DOCUMENT_RUNTIME_BYTES are now far above the real size. I could not re-measure — that machine cannot run the build — so the verification plan is committed to the branch instead of left as a caveat: docs/verification/export-html-runtime-size/README.md. It has the exact commands, the reference numbers and whose machine they came from, an independent falsification step for the "Shiki is dead in document mode" premise, the formula for the two stale constants, and what to report back. Results belong next to it as results.md.

中文说明

已追加提交 a9ff4f485baca14c6be4。transcript-only 入口没有切断的两条依赖图仍然进了内联的 document 运行时,而字节预算看不见它们。

  • Shiki(约 9.7 MB 预压缩输入,单项最大)在导出里是死代码。 Markdown.tsxCodeBlockrenderMode === 'document' 时会在触碰 highlighter 之前直接返回,渲染分支也始终输出纯 <pre> —— 这正是描述里"修复前后纯文本退化一致"的原因。它是 codeHighlighter.ts 的顶层静态 import,esbuild 摇不掉;现在 document 构建把 shiki / @shikijs/* 解析到一个 stub。且导出 CSP(script-src 'nonce-…',无 'wasm-unsafe-eval')本来就会阻止它的 Oniguruma WASM 引擎启动。
  • CodeMirror(约 1 MB)仍在分发。 WebShellTranscript → MessageList → MessageItem → UserMessage 为了三个 composer-tag 字符串 getter 引入了 hooks/useComposerCore.ts,而该模块顶层引入了整个编辑器。这三个 getter 已移到 utils/composerTag.ts(无编辑器依赖,且 UserMessage 本就引用它),useComposerCore 继续 re-export。
  • 预算从纯数字改为结构化。 document 构建会输出 esbuild metafile,一旦禁止的依赖图(Shiki、web-shell 包根、CodeMirror)出现在 inputs 中即失败并说明原因;同时打印按包统计的输入体积,EXPORT_HTML_METAFILE=<path> 可导出 metafile,重新测量不再需要改构建脚本。
  • 独立 rollup run 带来的潜在缺陷: 两个入口用同一个 data-qwen-web-shell="component" 键注入样式表,同时引用包根与 /transcript 的宿主会保留先加载的那份,静默丢失另一份规则。现改为按入口分键,shadow root 取样式改为拼接所有匹配项。

描述中有两处需要更正,待重新测量后一并重写:

  • "editor/terminal chrome … no longer ships" 在 9515e5b78d 上并不成立,CodeMirror 当时仍在分发,是本次追加提交才去掉的。
  • daemon React SDK 至今仍在分发。 它从 transcript 有五个可达点,其中 client/hooks/useMessages.tsWebShellTranscript 直接引用的(约 236 KB),本次刻意未处理。

因此前后对比表已过期,DOCUMENT_RUNTIME_WARNING_BYTES / MAX_DOCUMENT_RUNTIME_BYTES 也远高于真实体积。我无法重新测量(那台机器跑不了构建),所以验证方案直接提交到了分支上而不是留作说明:docs/verification/export-html-runtime-size/README.md,内含完整命令、参照数字及其来源机器、对"document 模式下 Shiki 是死代码"这一前提的独立证伪步骤、两个过期常数的换算方式,以及需要回报的内容。结果请以 results.md 提交在同一目录。

The not.toContain('vaul') guard ran against the whole bundle, but
injectCssModules prepends the Tailwind-generated stylesheet as a
single-line __qwenWebShellCss constant, and Tailwind v4 compiles
classes from every scanned source file regardless of the entry's
module graph — so drawer.tsx's data-[vaul-drawer-direction=…] variant
selectors land in the transcript entry's CSS even though no transcript
JS imports vaul (verified: 64 'vaul' occurrences in the injected CSS
line, 0 in the JS remainder, no from"vaul" import specifier).

Strip the injected CSS line before the graph guards. If the injection
shape ever changes, the replace() is a no-op and the checks fail
loudly rather than passing falsely.
`#9812` moved the export renderer out of the generated HTML: the document now
loads a version-pinned, SRI-protected `export-transcript-document.js` from
unpkg instead of inlining it. That conflicts with this branch, which shrinks
the same bundle.

Resolution keeps both sides of `build.mjs`:

- main's delivery path — root `package.json` version, the unpkg URL, the
  build-id placeholder, the SHA-384 SRI over the final bytes, and the single
  `outfile` asset.
- this branch's guards — `metafile: true`, the Shiki strip plugin, the
  top-inputs report, `FORBIDDEN_DOCUMENT_INPUTS`, and the byte budget.

Dropped the inline-only `<script` escaping (nothing is inlined any more) and
the unused `createRequire` import.

The budget's rationale changed rather than disappeared: the same bytes are now
downloaded the first time anyone opens an exported file, on a path that must
fail closed, so the ceiling still applies. Comments and the budget failure
message say that instead of "inlined into every export".

Also corrects the verification brief, which told the next machine to measure
`document.html` — now a small template. It should measure the renderer asset.
@yiliang114

Copy link
Copy Markdown
Collaborator Author

Merged current main into this branch (6e6d98e54) rather than rebasing, so existing review anchors survive. packages/web-templates/src/export-html/build.mjs was the only conflict — #9812 moved the renderer out of the generated HTML and onto a version-pinned, SRI-protected unpkg URL, which touches the same lines this branch rewrites.

Resolution keeps both sides: main's delivery path (root package.json version, unpkg URL, build-id placeholder, SHA-384 SRI over the final bytes, single outfile) plus this branch's guards (metafile: true, the Shiki strip plugin, the top-inputs report, FORBIDDEN_DOCUMENT_INPUTS, the byte budget). Dropped the inline-only <script escaping and the now-unused createRequire import.

The budget's rationale changed rather than disappeared — the same bytes are now downloaded the first time anyone opens an exported file, on a path that must fail closed — so the comments and the failure message say that instead of "inlined into every export". The verification brief was also corrected: it told the next machine to measure document.html, which is now just a small template; the number that matters is dist/export-transcript-document.js.

node --check and Prettier pass. The build and tests were not run on the machine that made this merge; that is CI's to confirm, and the byte-budget constants still need the re-measurement described in docs/verification/export-html-runtime-size/README.md.

Follow-ups filed while tracing this: #11091 (mermaid still in the bundle; echarts figure needs re-checking) and #11092 (duplicate react-markdown). Neither blocks this PR.

@yiliang114
yiliang114 enabled auto-merge September 5, 2026 10:06
@yiliang114 yiliang114 changed the title fix(export): stop inlining the interactive Web Shell runtime in exported HTML fix(export): shrink the exported transcript renderer to what a read-only transcript needs Sep 5, 2026
CI measured the merged branch at 8,456,076 runtime bytes, and named the
inputs: `echarts 3841596` and `zrender 624992` are still the largest single
block in it.

They arrive the same way mermaid does. `@datafe-open/markdown-chart-echarts`
ends `createEChartsRenderer` with

    const loadECharts = options.loadECharts ?? (async () => await import('echarts'));

and this repository never takes that default: `MarkdownChartRenderer` always
passes a `loadECharts`, because `adaptLegacyRuntimeLoader` returns a function
that throws 'Chart runtime is unavailable.' when no `loadEcharts` prop was
supplied, and no call site in `packages/web-shell/client/` supplies one. So the
fallback is dead in this codebase — but a bundler cannot prove that, and the
export build is esbuild `format: 'iife'` with a single outfile, which cannot
code-split, so the dynamic import is flattened straight into the renderer.

Resolves `echarts` to a stub on exactly the reasoning the Shiki stub already
uses in this file — unreachable in document mode — and adds echarts/zrender to
FORBIDDEN_DOCUMENT_INPUTS so it cannot come back unnoticed. Chart blocks in an
exported transcript already could not render; they threw
'Chart runtime is unavailable.' before this change and do the same after.
Whether exports *should* render charts and diagrams is #11091, and this does
not prejudge it: giving the renderer a real runtime stays a deliberate act.

Also lowers the runtime budget from 19,000,000 to 8,700,000 using the CI
measurement above. It is still loose, because the echarts stub in this same
commit lowers the real number again — tighten it from the next green run
rather than from a guess. The structural guard is the load-bearing check.
auto-merge was automatically disabled September 5, 2026 10:53

Head branch was pushed to by a user without write access

@yiliang114

Copy link
Copy Markdown
Collaborator Author

Pushed 1837026a9, driven by the first real measurement of this branch. CI printed it on the merge commit (run 33959200199, "Lint & Static"):

Document export top inputs (pre-minify bytes): echarts 3841596, first-party 3728505,
  mermaid 2880314, lucide-react 1574512, @mermaid-js/parser 1366732, cytoscape 1108285,
  lodash-es 629557, zrender 624992
Document export runtime is 8456076 bytes

Two things follow.

1. The budget was a no-op. 8,456,076 against a cap of 19,000,000. Lowered to 8,700,000 / 8,500,000 warning, from that measured number rather than a guess. It is still loose because the change below lowers the real value again — worth tightening once from the next green run, after which the structural guard remains the load-bearing check.

2. echarts + zrender (4,466,588 pre-minify bytes) are dead code that ships. @datafe-open/markdown-chart-echarts/dist/index.js:1842 ends createEChartsRenderer with

const loadECharts = options.loadECharts ?? (async () => await import('echarts'));

and this repository never takes that default: MarkdownChartRenderer always passes a loadECharts, because adaptLegacyRuntimeLoader returns a function that throws 'Chart runtime is unavailable.' when no loadEcharts prop was supplied, and nothing in packages/web-shell/client/ supplies one. A bundler cannot prove that, and esbuild format: 'iife' with a single outfile cannot code-split, so the dynamic import is flattened straight in — the same mechanism as mermaid, one layer deeper.

So echarts is now resolved to a stub, on exactly the reasoning the Shiki stub in this file already uses, and echarts/zrender join FORBIDDEN_DOCUMENT_INPUTS. Behaviour is unchanged: chart blocks in an exported transcript threw 'Chart runtime is unavailable.' before this commit and do the same after. It does not prejudge #11091 — giving the renderer a real chart runtime stays a deliberate act.

If you would rather keep this PR to the entry-point change, say so and I will move the stub to its own PR; it is self-contained.

What is left after this: mermaid and its graph deps (5,984,888 bytes) plus katex, which is #11091 and needs the product decision, and lucide-react at 1,574,512 — worth a look at how much of that is icons a read-only transcript actually renders.

Also filed #11096 for the release-side problem this branch sits next to: 0.23.0 is latest on npm but predates #9812, so https://unpkg.com/@qwen-code/qwen-code@0.23.0/export-transcript-document.js currently 404s and exports built from main fail closed. It carries the ordering argument for landing this PR before the first release that ships #9812.

The failing Integration Tests (no-AK) on the previous run was qwen-live-m4-acp-steering.test.tsacp backend 'qwen-acp' did not initialize, 183 passed / 1 suite failed, unrelated to web-shell or web-templates. This push re-runs it.

node --check and Prettier pass on the changed files; nothing else was run on the machine that made this commit.

chiga0
chiga0 previously approved these changes Sep 5, 2026

@chiga0 chiga0 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at head 1837026a9d.

What I checked

Shiki stub contractcodeHighlighter.ts imports createHighlighter, type BundledLanguage, type Highlighter from shiki. Types are erased at compile time; the stub exports createHighlighter. The stripDocumentDeadModules resolver plugin redirects all shiki and @shikijs/* imports to the stub. FORBIDDEN_DOCUMENT_INPUTS then asserts from the metafile that no shiki input reached the bundle. CodeBlock in Markdown.tsx:487 returns early when documentMode === true before ever calling getCodeHighlighter, so the stub's rejection is dead code. Double-guarded. ✓

Echarts stub (new in 1837026a9d)@datafe-open/markdown-chart-echarts flattens a ?? import('echarts') into the IIFE because esbuild cannot code-split. The stub is correctly limited to export function init() + export default { init }, matching the echarts API surface used by the dynamic path, and the FORBIDDEN check asserts neither echarts nor zrender enters the bundle. ✓

CodeMirror cut via composerTag refactorUserMessage.tsx previously imported getComposerTagDisplay/Label/Value from useComposerCore.ts, which is the CodeMirror host. The PR moves those three functions to utils/composerTag.ts (editor-free) and UserMessage.tsx now imports from there. useComposerCore re-exports them for compat. The FORBIDDEN codemirror check and the new does not pull the editor stack test together pin this. ✓

CSS entry-key change — Old: one shared guard style[data-qwen-web-shell="component"] (first-loaded entry wins). New: per-entry guard style[data-qwen-web-shell-entry="{entry}"] with the shared marker retained. shadowDom.ts updated to querySelectorAll all matching tags and concatenate. Build artifact tests verify both the index and transcript entries carry their respective entry keys and the shared marker. ✓

Runtime size cap (updated in 1837026a9d) — From 18.5/19 MB to 8.5/8.7 MB, calibrated against the CI-measured 8,456,076 bytes (before the echarts stub, so the actual bound is now lower). The comment correctly defers final tightening to the next green CI run rather than a local guess. ✓

Package exports + build ordering./transcript entry in package.json exports points to dist/transcript.js / dist/types/transcript.d.ts. files: ["dist"] already covers both. emptyOutDir: false means the third vite run adds dist/transcript.js without wiping the first two outputs. tsconfig.lib.json covers client/**/*.ts so type emit covers the new entry. ✓

Cross-check — ci-bot's R1-1 through R1-3 (filed at head c4fb0e62) are all addressed at the current head. R1-4 is confirmed:

[Minor] packages/web-shell/README.md:186 — read-only recipe still uses the root import

The "只读 ChatRecord JSONL" example at README.md:186 shows:

import { WebShellTranscript } from '@qwen-code/web-shell';

After this PR, the correct import for hosts that only need the transcript renderer is @qwen-code/web-shell/transcript. A host following the current README bundles the full interactive shell (App, daemon providers, editor/terminal chrome) unnecessarily — exactly the problem the new subpath exists to prevent. The file was not touched in this diff; flagging for a follow-up update.


No blocking findings. Approval blockers: none.

Reviewed with AI assistance.

@qwen-code-dev-bot qwen-code-dev-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

REQUEST_CHANGES at head 1837026a — no Critical, and the export-runtime work is genuinely good. Two of ci-bot's Suggestions stay open and both bear on this PR's own contract, so I can't certify the entry point yet.

Verified at this head (built locally, not inferred)

  • packages/web-shellnpm run build emits dist/transcript.js and dist/types/transcript.d.ts; client/build-artifact.test.ts passes 15/15, including the three new transcript-entry cases. dist/index.js is 7,021,715 bytes; dist/transcript.js is 3,432,823, of which 2,291,874 is the inlined scoped stylesheet and 1,140,948 is JS.
  • packages/web-templatesnode src/export-html/build.mjs on the 6e6d98e5 tree printed Document export runtime is 8456076 bytes with 0 shiki and 0 CodeMirror inputs in the metafile; re-running with the new echarts stub landed the number at 7,275,173 bytes, and the top-inputs list dropped echarts/zrender entirely (now led by first-party 3,730,270, mermaid 2,880,314, lucide-react 1,574,512, cytoscape 1,108,285).
  • The dead-echarts reasoning checks out independently: every loadEcharts/loadECharts reference under packages/ is either inside MarkdownChartRenderer.tsx (the optional prop and its adapter) or in that component's test file — no production call site supplies a loader, so adaptLegacyRuntimeLoader yields the throwing path and the vendored ?? () => import('echarts') default is unreachable. The build with the stub exits 0, so nothing in the graph needs another echarts export.
  • Per-entry CSS (thread 2) is fixed: injection is keyed by data-qwen-web-shell-entry (index/transcript) while both tags keep data-qwen-web-shell="component", and getWebShellStyleText concatenates every match rather than taking the first, so the import-order failure is gone and shadow-root adoption still works.
  • Artifact guard (thread 3) is fixed: the test now reads dist/transcript.js and asserts the editor-free graph plus the per-entry injection preamble, correctly scoping the check to the JS remainder because Tailwind v4 emits classes for every scanned file.
  • Plumbing is consistent across exports, tsconfig.paths, vite.config.ts, vitest.config.ts and the eslint node-script allowlist; the composer-tag getters moved to a dependency-free utils/composerTag.ts with a re-export from useComposerCore.ts, so importers are unaffected.

What still blocks

  1. The package README still teaches the import this PR makes wrong. packages/web-shell/README.md around line 186 — the read-only JSONL recipe — still shows import { WebShellTranscript } from '@qwen-code/web-shell';, and git grep 'web-shell/transcript' over that README returns nothing, while client/transcript.ts requires transcript-only bundlers to use the subpath. Thread 4 measured +1,559,322 bytes for exactly that specifier swap; combined with the build I measured here (7,275,173 bytes) a root-import document lands around 8.8 MB, past the new MAX_DOCUMENT_RUNTIME_BYTES = 8_700_000 — so a host following this repository's README either ships the regression or fails the guard. Please fix that recipe and anywhere else WebShellTranscript is introduced.
  2. The new entry's docblock over-claims and nothing pins the gap. It advertises "no App, no daemon providers, no editor/terminal chrome", but WebShellTranscript.tsx:33 value-imports transcriptBlocksToLocalizedMessages from hooks/useMessages, which value-imports useConnection/useTranscriptBlocks/useWorkspace from the daemon-react-sdk barrel (useMessages.ts:9-13). On the artifact I built at this head, dist/transcript.js still contains useDaemonActions must be used within DaemonSessionProvider. Either give that helper the same leaf-module treatment you gave the composer tags (or import the three hooks from their defining modules instead of the barrel) and assert the absence in build-artifact.test.ts, or state the residual in the docblock so the next reader measures against a true claim.

Non-blocking

The budget comment now says the constants are loose and to tighten them from the next green CI run — with the stub landed, the number is knowable (7,275,173 locally here), so MAX_DOCUMENT_RUNTIME_BYTES = 8_700_000 is still ~1.4 MB of headroom and worth lowering in this same pass. Chart blocks continuing to render "unavailable" in exports is a real user-visible gap, but it predates this PR and #11091 is the right home for it.

CI facts at review time

Lint & Static and Capture web-shell visuals are green on this head. Test (ubuntu-latest) was still running when I looked. Integration Tests (no-AK, No Sandbox) failed with qwen-live exited with 1 before listening: [qwen-live] ERROR acp backend 'qwen-acp' did not initialize on runner ecs-qwen-hk5-32, with 183 other tests in that lane passing and the sibling qwen-live-m4-acp-multibackend suite green in the same run; this diff never reaches packages/qwen-live or the ACP handshake, so it reads as the shared-pool ACP initialization-budget family behind #11033/#11034 rather than something introduced here. mergeable is MERGEABLE.

…im true

Review found the entry docblock over-claiming. `WebShellTranscript.tsx`
value-imported `transcriptBlocksToLocalizedMessages` from `hooks/useMessages`,
and that module value-imports `useConnection` / `useTranscriptBlocks` /
`useWorkspace` from the `daemon-react-sdk` barrel — so `dist/transcript.js`
still carried the provider guards, including
`useDaemonActions must be used within DaemonSessionProvider`.

Gives that helper the same leaf-module treatment as the composer tags: the
pure projection (and the `Translator` type it needs) moves to
`adapters/localizedMessages.ts`, which imports nothing from the daemon SDK
barrel, and `useMessages.ts` re-exports both so existing callers and its own
test are unaffected. `build-artifact.test.ts` now asserts `dist/transcript.js`
contains neither `DaemonSessionProvider` nor `DaemonWorkspaceProvider`, so the
docblock's claim is pinned rather than asserted in prose.

Also fixes the read-only recipe in `packages/web-shell/README.md`, which still
taught the root import this PR makes wrong — a host following it would ship
the interactive shell, which the reviewer measured as landing a document at
~8.8 MB, past the new cap. Same for the example in the read-only transcript
design doc.

Tightens the runtime budget to 7,400,000 / 7,300,000 warning, from the
reviewer's local measurement of 7,275,173 bytes with the echarts stub in place
(CI's 8,456,076 predates it).
chiga0
chiga0 previously approved these changes Sep 5, 2026

@chiga0 chiga0 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at head 1d94060f5b (Round 2 delta: 1837026a9d1d94060f5b).

What changed since Round 1

Round 1 Minor finding (README import example) — fixed. packages/web-shell/README.md now shows import { WebShellTranscript } from '@qwen-code/web-shell/transcript' with an added warning note for transcript-only hosts; the design doc updated to match. ✓

dev-bot Blocker 2 / ci-bot Thread 1 (daemon provider leakage) — the docblock claimed "no daemon providers" but WebShellTranscript → hooks/useMessages → useConnection/useTranscriptBlocks/useWorkspace (daemon-react-sdk barrel) was live in dist/transcript.js. Fixed by extracting transcriptBlocksToLocalizedMessages into a new leaf module adapters/localizedMessages.ts (same pattern as utils/composerTag.ts). The new module imports only transcriptBlocksToDaemonMessages from the existing adapters/transcriptToMessages.ts and two type-only imports — no React hooks, no daemon barrel. WebShellTranscript.tsx now imports from the new adapter; hooks/useMessages.ts re-imports from there. ✓

Budget constants tightenedDOCUMENT_RUNTIME_WARNING_BYTES lowered from 8,500,000 → 7,300,000; MAX_DOCUMENT_RUNTIME_BYTES from 8,700,000 → 7,400,000; consistent with the dev-bot's locally measured 7,275,173 bytes after the echarts stub landed. ✓

What I checked in this delta

adapters/localizedMessages.ts import graph — the file is a peer of the existing adapters/transcriptToMessages.ts and adapters/types.ts; all three live outside the hooks tree and outside the daemon-react-sdk barrel. No daemon hook imports in the new module. ✓

Build artifact test discriminabilitybuild-artifact.test.ts new case 'does not pull the daemon provider stack into the transcript entry' asserts dist/transcript.js does not contain DaemonSessionProvider or DaemonWorkspaceProvider. Both strings appear as invariant error message literals inside the provider implementations — they survive minification verbatim — so reverting the import swap would reintroduce them and fail the test. Prior tests for @codemirror/ and vaul exclusion remain. ✓

Mechanically cleanuseMessages.ts retains its daemon-hook body; only the pure projection moved to the adapter. Callers of useMessages are unaffected. ✓

Findings

None. All Round 1 and dev-bot findings are resolved at this head.

APPROVED.

@qwen-code-dev-bot qwen-code-dev-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

REQUEST_CHANGES at head 1d94060f — the README problem from the last round is fixed properly, but the daemon fix is incomplete and, as shipped, its own new guard test is red.

[Blocking] The new transcript-entry guard fails against a clean build

On a fresh checkout of 1d94060f I ran npm ci in the package workspace, npm run build in packages/web-shell (which is the command the CI test lane depends on: test:ci = vitest run --config vitest.config.ts, and this suite reads ../dist with no skip guard), then the suite:

FAIL  build-artifact.test.ts > build artifact — transcript entry (#11031) > does not pull the daemon provider stack into the transcript entry
AssertionError: expected 'if(typeof document!=="undefined"&&!do…' not to contain 'DaemonSessionProvider'
Test Files  1 failed (1)
     Tests  1 failed | 15 passed (16)

Test (ubuntu-latest) was still queued when I looked, so this is the lane that will report it.

The bundle really does still carry the provider guards — I checked the artifact directly, and the string is the inlined hook guard, not a locale table (client/i18n.tsx contains no such text):

throw new Error("useDaemonActions must be used within DaemonSessionProvider")
throw new Error("useDaemonWorkspace must be used within DaemonWorkspaceProvider")

Extracting transcriptBlocksToLocalizedMessages into adapters/localizedMessages.ts was the right idea (that leaf is genuinely hook-free, and the re-export from useMessages.ts keeps existing callers working — typecheck passes clean), but it was one of several paths into the barrel. A static walk of the transcript entry's value-import graph at this head (90 modules, starting from client/transcript.ts) reaches @qwen-code/web-shell/daemon-react-sdk through three more:

transcript.ts → components/WebShellTranscript.tsx → components/MessageList.tsx
  → components/artifacts/TurnOutputs.tsx → components/artifacts/useArtifactWorkspaceTarget.ts
  → components/MessageItem.tsx → components/messages/SystemMessage.tsx
      → components/messages/McpStatusMessage.tsx
      → components/messages/TasksStatusMessage.tsx

So the transcript reaches the provider stack through components it genuinely renders — the task/MCP status rows and turn outputs — not through the helper that was moved. Consistent with that, dist/transcript.js came out 90 bytes larger than at 1837026a (3,432.92 kB vs 3,432.83 kB), so the extraction did not drain anything from the graph.

Please pick one of these — either is fine, I just can't approve a PR whose own assertion fails:

  1. Narrow useArtifactWorkspaceTarget.ts, McpStatusMessage.tsx and TasksStatusMessage.tsx to the modules that define what they use instead of the barrel (same treatment as utils/composerTag.ts and adapters/localizedMessages.ts), keep the assertion, and the "no daemon providers" line in client/transcript.ts becomes true.
  2. Or accept the residual: drop the two not.toContain assertions, and rewrite the docblock to what this PR actually delivers — no App, no editor/terminal chrome — while naming the daemon hook runtime as a known, measured part of the transcript entry.
  3. Or keep a guard that matches the deliverable: assert a bound on the transcript JS instead of an absent symbol, so the property is pinned without pretending the graph is cleaner than it is.

Fixed since last round, verified

  • packages/web-shell/README.md now imports @qwen-code/web-shell/transcript in the read-only recipe and carries a note explaining why the package root must not be used; docs/design/2026-07-14-web-shell-readonly-daemon-transcript.md gained the same correction. That was the real blocker last time and it is done properly.
  • Per-entry CSS injection (data-qwen-web-shell-entry with index/transcript, shared data-qwen-web-shell="component" marker kept, getWebShellStyleText concatenating every match) is intact and still asserted.
  • Editor-graph guard still green: the transcript JS contains no @codemirror/, "codemirror" or vaul.
  • The export build runs clean here: Document export runtime is 7275173 bytes, with zero Shiki, CodeMirror, echarts or zrender inputs.

One more thing on the same edit

MAX_DOCUMENT_RUNTIME_BYTES moved to 7,400,000 and the comment records my previous local measurement as its source. The current head still measures 7,275,173, so the cap leaves ~125 KB (1.7%) of slack — any dependency added to the transcript entry now fails the export build rather than warning. That is a deliberate ratchet and your comment says to re-measure, so it is not a blocker on its own; I would just set it from a number that includes the change you are about to make here.

CI facts

Capture web-shell visuals (16m19s), Desktop Shell lanes, Classify PR and the gating jobs are green on this head; Lint & Static, Test (ubuntu-latest), Integration Tests (no-AK) and review-pr were still queued, and nothing had gone red yet. mergeable is MERGEABLE.

The projection that used transcriptBlocksToDaemonMessages moved to
adapters/localizedMessages.ts in 1d94060, leaving the import behind.
Lint & Static caught it:

  14:10  error  'transcriptBlocksToDaemonMessages' is defined but never used
…t doesn't

The guard added in 1d94060 was red against a clean build. Moving
`transcriptBlocksToLocalizedMessages` out of `hooks/useMessages` was correct
but it was one of several paths into the daemon barrel, and not the load-
bearing one: review's static walk of the entry's value-import graph found
three more, through components the transcript genuinely renders —
`useArtifactWorkspaceTarget.ts` (`useWorkspace`, `useWorkspaceActions`),
`McpStatusMessage.tsx` (`useMcp`) and `TasksStatusMessage.tsx` (`useActions`).
Every one of those barrel aliases resolves to a strict hook, so the provider
guards ship no matter which module the import names. Consistent with that,
`dist/transcript.js` came out 90 bytes larger after the extraction, not
smaller.

Asserting `DaemonSessionProvider` is absent therefore asserted something this
entry does not deliver. Replaced with a bound on the transcript JS remainder —
1,140,948 measured bytes against 7,021,715 for `dist/index.js`, ceiling
1,300,000 — which pins the property the entry actually exists for.

The entry docblock now says the same thing: no `App`, no composer, no
editor/terminal chrome, and the daemon hook runtime named as a known residual
with #11100 as its home. That issue also carries the question this raised —
`TasksStatusMessage` calls `useActions()` unconditionally and
`WebShellTranscript` mounts no provider, so whether an export containing a
tasks-status block renders or throws depends on what the document projector
filters, and nobody has checked.
@yiliang114

Copy link
Copy Markdown
Collaborator Author

You were right, and thank you for building it — the assertion I added was red and I could not have found that out here.

Pushed f6c23c5135.

What I got wrong. Extracting transcriptBlocksToLocalizedMessages was one path into the barrel and not the load-bearing one. Your walk names the three that matter, and checking them makes the situation worse than "narrow the imports": every one of those barrel aliases resolves to a strict hook —

Module Call Resolves to
useArtifactWorkspaceTarget.ts:90-91 useWorkspace(), useWorkspaceActions() useDaemonWorkspace, useDaemonWorkspaceActions
McpStatusMessage.tsx:301 useMcp({ autoLoad: false }) useDaemonMcp
TasksStatusMessage.tsx:353 useActions() useDaemonActions

— so your option 1 does not actually work as stated: these are real hook calls, and the implementation (with its provider guard) follows regardless of which module the import names. Fixing it means changing what those components consume, not where they import from. That is a bigger change than this PR should carry, which your 90-bytes-larger observation already implied.

So I took option 3, with option 2's honesty. The assertion is now a bound on the transcript JS remainder — 1,140,948 measured against 7,021,715 for dist/index.js, ceiling 1,300,000 — which pins the property the entry exists for instead of pretending the graph is cleaner than it is. The docblock now reads "no App, no composer, no editor/terminal chrome" and names the daemon hook runtime as a known residual.

#11100 is the home for the real fix. It also carries a question your finding surfaced that I could not answer: TasksStatusMessage.tsx:353 calls useActions() unconditionally — the documentMode checks below it only gate effects — and WebShellTranscript mounts no provider. So either the document projector filters MCP-status and tasks-status blocks, in which case this is dead weight, or an export containing one of those blocks throws at render and the reader gets the fail-closed page, in which case it is a user-visible bug. The issue has the reproduction; it needs a machine that can run an export.

On the cap: agreed it is tight at 1.7%, and I left it at 7,400,000 deliberately — this push changes no bundle input, so 7,275,173 still stands as the measurement behind it. Worth re-measuring and re-setting when #11100 lands, since that will move the number.

Main was merged into the branch (c0b7909cb2) while I was working; my commit is rebased on top of it, no force-push.

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partially reviewed — gaps disclosed.

Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

Not explored to full depth (tool budget reached): "agent 8a": I did not measure the wall-clock cost of the third vite pass (the verification doc asks for it); I only confirmed it produces the expected artifact.; "agent 8a": I did not run the red-run mutation for finding 3 (removing an entry from rollupOptions.external and rebuilding) — the claim is derived from the emitted artifa…; "agent 8a": I could not settle whether the transcript stylesheet's rule *order* is a strict subsequence of the index stylesheet's; my } -based rule splitter is too naive f…; chunk 4: none — no check was cut short.; "agent 1b": I did not resolve whether extracting adapters/localizedMessages.ts is *inert* — components/artifacts/SubagentDetail.tsx:11 imports useMessagesFromBlocks f….

Not reviewed: reverse audit — stopped before round 4 by the review time budget.

Test Plan (not a blocker): src/export-html/build.mjsno such file or directory; client/build-artifact.test.tsno such file or directory; client/index.test.tsxno such file or directory; src/ui/utils/export/formatters/html.test.tsno such file or directory; src/ui/utils/export/export-transcript-document.test.tsno such file or directory.

中文说明

仅完成部分审查,审查缺口已披露。

未审查(原文为英文):build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

未探索到全部深度(达到工具调用预算):"agent 8a"I did not measure the wall-clock cost of the third vite pass (the verification doc asks for it); I only confirmed it produces the expected artifact."agent 8a"I did not run the red-run mutation for finding 3 (removing an entry from rollupOptions.external and rebuilding) — the claim is derived from the emitted artifa…"agent 8a"I could not settle whether the transcript stylesheet's rule *order* is a strict subsequence of the index stylesheet's; my } -based rule splitter is too naive f…;chunk 4:none — no check was cut short."agent 1b"I did not resolve whether extracting adapters/localizedMessages.ts is *inert* — components/artifacts/SubagentDetail.tsx:11 imports useMessagesFromBlocks f…

未审查:反向审计——评审时间预算不足,未能开始第 4 轮。

Test Plan(非阻断):src/export-html/build.mjsno such file or directory; client/build-artifact.test.tsno such file or directory; client/index.test.tsxno such file or directory; src/ui/utils/export/formatters/html.test.tsno such file or directory; src/ui/utils/export/export-transcript-document.test.tsno such file or directory

— qwen3.8-max via Qwen Code /review (v0.23.0)

Comment thread packages/web-shell/vite.lib.config.ts Outdated
Comment thread docs/verification/export-html-runtime-size/README.md
Comment thread docs/verification/export-html-runtime-size/README.md
Comment thread docs/verification/export-html-runtime-size/README.md
Comment thread packages/web-shell/client/adapters/localizedMessages.ts
Comment thread packages/web-templates/src/export-html/build.mjs
Comment thread packages/web-templates/src/export-html/build.mjs
Comment thread packages/web-templates/src/export-html/build.mjs
Comment thread packages/web-templates/src/export-html/src/document-shiki-stub.ts
Comment thread packages/web-templates/src/export-html/src/document-shiki-stub.ts
@yiliang114

Copy link
Copy Markdown
Collaborator Author

Resolved the remaining runtime blocker in 08b6fa557d81 after merging current main into the branch.

The transcript entry now imports the shared Tailwind stylesheet before KaTeX, so KaTeX's equal-specificity currentColor border override wins in both the interactive and transcript bundles. The build-artifact test reads both emitted stylesheets and pins that exact cascade invariant. I also removed the inaccurate claim that loading both entry stylesheets is otherwise a no-op.

Verified locally:

  • @qwen-code/web-shell build
  • client/build-artifact.test.ts: 17/17
  • @qwen-code/web-shell typecheck
  • ESLint and Prettier on the changed files
  • @qwen-code/web-templates build: renderer remains 7,275,173 bytes
  • emitted CSS positions: index preflight=2, katex=4520; transcript preflight=2, katex=939

The older CodeMirror, per-entry CSS-key, transcript artifact, and README-import threads are outdated and already addressed by the current head. The 15 new non-blocking documentation, diagnostic, and test-hardening suggestions are recorded individually in #11142 rather than expanding this PR after more than five review/fix rounds.

@yiliang114
yiliang114 requested a review from chiga0 September 6, 2026 02:16
Comment thread packages/web-shell/client/components/WebShellTranscript.tsx
@yiliang114

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

Sandboxed verification: ❌ not passed — findings reported (agent verdict) - workflow run

Ran the PR in an isolated, token-free container: A/B against the base build, mock-free harness assertions, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check.

Scripted assertions: 53 passed · 0 failed · 53 total

Flakiness gate: ✅ 1 changed test file(s) x 5 identical rounds, no divergence

中文 — 判定:❌ 不通过 · 报告了发现(agent 判定)

沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查

脚本断言:53 通过 · 0 失败 · 53 总计

抖动门:✅ 1 changed test file(s) x 5 identical rounds, no divergence

Verification report

PR #11038 deep verification — fix(export): shrink the exported transcript renderer

Verdict: findings — the central claim is proven and nothing is blocking. 53/53 scripted assertions passed, 0 failed. The findings below are one maintainability risk in the new byte ratchet, two places where the PR description no longer matches the code it ships, and one changed code path with no test. A maintainer can reasonably merge with all four deferred.

Verified head: 08b6fa557d81c9498d4f2b5a57eaa155a02a3bc1 (git rev-parse HEAD^2)
Base: 1b604721b053da4e104ff610e1a925fe8c9ca488 (HEAD^1)
Assertions: {"pass": 53, "fail": 0, "total": 53} — itemised in assertions-breakdown.json

中文摘要

结论:findings(有发现,但无阻塞项)。 核心主张已被证明成立,53 项脚本化断言全部通过,0 失败。

A/B 结论:导出渲染器资源 export-transcript-document.js 从 base 的 19,521,158 字节降到 head 的 7,272,168 字节-62.7%,2.68 倍),gzip 从 4,747,889 降到 2,392,777(-49.6%)。base 侧与 issue #11031 报告的 19,525,807 相差 -0.02%,互相印证。四个杠杆逐一拆解见 Decomposition 表:Shiki stub 贡献最大(-9,300,694),其次 echarts stub(-1,180,903)、transcript 子路径(约 -1,559,413)、composerTag 迁移(-208,017)。FORBIDDEN_DOCUMENT_INPUTS 四条规则全部通过生产 build.mjs 跑红验证(A/B/C/D 均 exit 1,均指名违规输入并给出原因)。渲染一致性在真实 Chromium 中逐字节相同(截图 md5 两臂一致)。三项变异(M1 CodeMirror / M2 KaTeX 顺序 / M3 每入口 CSS key)全部杀死各自对应的断言。

发现(findings):① 字节 ratchet 与 Tailwind v4 的目录级扫描耦合——距硬上限仅 1.72%(124,827 字节),在 web-shell/client/ 任意位置新增 546 个新工具类就会让 /export html 构建直接失败(已实测,每个新类 229 字节,线性);② PR 描述里"体积预算已过时(18.5M/19M)"这一待办项与代码不符,代码里已是 7.3M/7.4M;③ 描述中"CodeMirror 约 1 MB"是 metafile 的压缩前数字,落到实际产物只有 208,017 字节(占总节省的 1.7%);④ shadowDom.ts 的样式拼接改动无测试覆盖,且同时引入两个入口的宿主会多承载 2,289,195 字节重复 CSS。

未覆盖范围:见 Not covered 一节——逐 commit 归因(浅克隆)、Playwright e2e 套件、CSP 与投递模型本身、unpkg 真实 CDN 拉取、其他包完整测试套件。

Central claim and A/B

Central claim. The exported transcript renderer asset shrinks dramatically because the document entry imports a transcript-only subpath and stubs out Shiki, echarts and CodeMirror.

Measured, not estimated — both arms are real node src/export-html/build.mjs runs against real built dist/ output. See 01-ab-renderer-asset-base-vs-head.png.

arm source export-transcript-document.js raw gzip-9 (zlib) oracle
base HEAD^1 1b604721, package-root import 19,521,158 4,747,889 build exit 0
head PR 08b6fa55, transcript subpath + stubs 7,272,168 2,392,777 build exit 0
Δ −12,248,990 (−62.7%, 2.68×) −2,355,112 (−49.6%)

Corroboration: issue #11031 reported 19,525,807 bytes for the pre-#9812 inlined renderer. This independently-built base arm lands at 19,521,158 — within −0.02%. The head build reproduces the 7,275,173 runtime bytes recorded in build.mjs's own comment exactly, and is byte-stable across three separate builds (CI's, mine, and a third tree's — identical sha256).

Decomposition — what each lever actually bought

The PR bundles four levers; a two-cell A/B cannot say what each did, so I built every intermediate with identical esbuild options, varying one lever at a time (decompose.mjs).

cell levers present jsBytes Δ vs prev forbidden inputs hit
V0 none (≈ base) 19,521,624 shiki 305, root facade 1, echarts 594, codemirror 9
V1 (a) subpath + (d) composerTag 17,754,194 −1,767,430 shiki 305, echarts 594
V2 + (b) shiki stub 8,453,500 −9,300,694 echarts 594
V3 + (c) echarts stub = head 7,272,597 −1,180,903 none
V4 head with (d) reverted 7,480,614 +208,017 codemirror 8

Harness calibration (so these cells are comparable to the real builds): V0 vs the real base build Δ 466 bytes; V3 vs the real head build Δ 429 bytes — both attributable to the virtual-entry indirection and build-id string length.

Attribution: the Shiki stub is 76% of the saving. Lever (a) alone is ≈1,559,413 bytes (V1's −1,767,430 minus lever (d)'s 208,017). Lever (d) is small in bytes but load-bearing twice over: V4's 7,480,614 exceeds the 7,400,000 hard cap, and it trips the structural guard (red run B).

Every residual is accounted for. The root dist/index.js grew +164 bytes at head (7,021,638 → 7,021,802): that is the per-entry data-qwen-web-shell-entry attribute plus the querySelectorAll-and-join rewrite in shadowDom.ts. The document's own inline CSS is ~3 KB in both arms (document-styles.css, unchanged).

The stylesheet subset claim holds

vite.lib.config.ts says the separate rollup run means the transcript entry "only carries the CSS reachable from the read-only transcript renderer". I tested that rather than trusting it (probe-css-subset.mjs):

rules injected CSS bytes
dist/index.js 4,672 3,152,806
dist/transcript.js 2,332 2,289,195 (72.6%)

Transcript-only rules: 0 — it is a strict subset, so the claim is true and the 863,611-byte reduction is real. CodeMirror (.cm-editor) and terminal (.xterm) selectors are present in the root stylesheet and absent from the transcript one.

Corrections to the PR description

These are corrections to the text, not requests to change code.

  1. The "Open item — the byte budget is stale" no longer applies. The description says DOCUMENT_RUNTIME_WARNING_BYTES = 18_500_000 / MAX_DOCUMENT_RUNTIME_BYTES = 19_000_000 and asks a reviewer to "re-measure with step 1 and lower both before merge". At the verified head those constants are already 7,300,000 / 7,400,000, and the measured 7,275,173 sits under both. The open item is closed; the description is describing an earlier commit.
  2. "CodeMirror ~1 MB" is a pre-minify metafile figure, not a contribution to the shipped asset. The composition note is accurate as a metafile reading of the root build, but lever (d)'s effect on the delivered bytes is 208,017 (V4 − V3), i.e. 1.7% of the 12.25 MB total. Worth stating because it is the lever a reader would otherwise expect to be second-largest; in fact it is the smallest, and its value is structural (it is what makes the guard and the cap hold) rather than volumetric.
  3. The Evidence table is explicitly pre-refactor!: retire @qwen-code/webui #9812 and the description says so — confirmed. Its "After" row (17,966,485) matches my V1 cell (17,754,194) in shape: both are "subpath, before the Shiki/CodeMirror follow-ups". The table is honest about being unrefreshed; the correct current number is 7,272,168.
  4. A test comment names a selector that is not in the built CSS. build-artifact.test.ts justifies stripping the CSS line before the not.toContain checks by saying the stylesheet "carries e.g. drawer.tsx's data-[vaul-drawer-direction=…] selectors". The substring vaul is present in both stylesheets, but that attribute selector form is in neither (inIndex: false, inTranscript: false). The underlying point survives — Tailwind v4 does scan beyond the entry graph, which I confirmed independently in Finding 1 — but the cited example does not. Also note @codemirror/ and "codemirror" appear in neither stylesheet, so the replace() strip is defensive rather than load-bearing for those two assertions today; it is load-bearing for keeping the size bound honest.

Findings

1. Suggestion — the byte ratchet is coupled to Tailwind's directory-wide source scan, so unrelated UI work can fail the export build

globals.css carries no @source directive, so Tailwind v4 uses automatic source detection and scans every non-gitignored file under the project — not just the modules the transcript entry imports. The cap therefore measures all of packages/web-shell/client/, while the comment above it tells the next maintainer to re-measure "after any change to the document entry's dependencies". Those are different sets.

Measured with ratchet-probe.mjs (scratch component dropped into the mutant tree, nothing imports it, transcript entry rebuilt, file removed afterwards):

novel utility classes in an unreferenced file transcript CSS growth
3 736 B
30 7,360 B
150 34,350 B

Linear at 229 bytes per class; removing the file restored the stylesheet byte-identically (2,289,195), so the measurement is clean.

Against the measured runtime of 7,275,173:

  • headroom to DOCUMENT_RUNTIME_WARNING_BYTES (7,300,000): 24,827 B = 0.34%109 novel utilities
  • headroom to MAX_DOCUMENT_RUNTIME_BYTES (7,400,000): 124,827 B = 1.72%546 novel utilities, at which point node src/export-html/build.mjs throws and /export html stops building

Reproduce:

cd tmp/mutant-tree/packages/web-shell   # or any scratch copy at head
node <artifact-dir>/ratchet-probe.mjs

Not a correctness defect and not blocking: 546 novel arbitrary-value utilities is a lot for one PR, and arbitrary values are the worst case. But the growth is cumulative and unbounded, and the failure lands on whoever happens to cross the line in an unrelated change, as a build error in a different package. Two cheap options: add an explicit @source scoping the transcript build to what it actually renders, or split the cap so the graph-scoped part ratchets tightly while the scanned-utility part warns only. See 04-ratchet-fragility-tailwind-scan.png.

2. Suggestion — shadowDom.ts concatenation is untested and costs 2,289,195 duplicated bytes for a both-entries host

getWebShellStyleText changed from querySelector(...) to querySelectorAll(...) + join('\n'). No test in the PR exercises it: the new injects its stylesheet under its own entry key case asserts only the emitted markup (data-qwen-web-shell-entry="…", s.dataset.qwenWebShell="component"), never the reader. Deleting the join and reverting to first-match would leave all 17 tests green.

The change is correct — I verified the hazard it guards is real: with per-entry keys, a host importing both entries gets two tags and the concatenation preserves the editor/dialog rules. But note the cost, since the comment frames the overlap as harmless: because the transcript stylesheet is a strict subset of the root one, a both-entries host now carries 3,152,806 + 2,289,195 = 5,442,001 bytes of shadow-root style text where 3,152,806 would do — 2,289,195 bytes of exact duplication. Per the repo's own review rules a missing test for changed behaviour is a Suggestion, not a Critical; the fixture that would pin it is one that installs two style tags and asserts the returned text contains a rule unique to each.

3. Note — the KaTeX commit fixes a bug this PR introduced, not a pre-existing one

Worth recording because it changes how the final commit should be read. Rule-order probe across five bundles (18-katex-order-probe.log, 03-katex-cascade-order-per-entry.png):

bundle rules preflight idx katex idx order
base dist/index.js 4,750 2 4,520 OK
head dist/index.js 4,750 2 4,520 OK
head dist/transcript.js 2,367 2 939 OK
mutant dist/index.js (order reverted) 4,750 2 4,520 OK
mutant dist/transcript.js (order reverted) 2,367 233 1 BROKEN

Reverting the import order leaves the root entry correct — in that graph globals.css is reached early through other modules, so its internal position in WebShellTranscript.tsx does not decide anything. Only the transcript entry, where WebShellTranscript.tsx is the root import, is order-sensitive. So base never had this bug; the new entry created it and the final commit closes it. That is why base and head render byte-identically and only the mutant differs.

The fix is browser-observable. Walking all 147 .katex descendants in real Chromium (katex-probe.mjs): 576 computed declarations flip from oklch(0.985 0 0) (currentColor) to oklch(1 0 0 / 0.1) (var(--border)) under the broken order. Visually it surfaces as exactly 5 pixels at y=311 in the parity fixture — the \frac{1}{3} fraction bar, the only KaTeX element that actually draws a border. My coarse 9-element style probe reported zero deltas here; the pixel oracle caught what it missed, which is why the screenshot identity carries the parity verdict and the style probe does not.

4. Note — one pre-existing, load-correlated test flake, not caused by this PR

packages/cli export-transcript-document.test.ts > bounds repeated-separator checks in decoded URL authorities timed out at 15,000 ms on the first combined run. Attribution:

  • the PR touches 0 files under packages/cli (git diff --stat HEAD^1..HEAD -- packages/cli is empty)
  • the test file's blob is byte-identical base vs head (7847222dc5e2dbf3e5f022e191a19abdea893077 both sides), as is the source under test
  • it passes 3/3 when run in isolation, and the combined pair passed 75/75 on re-run

The mechanism is a nested budget: the test spawnSyncs a tsx child with a 20 s internal timeout inside a 15 s vitest timeout, so on a loaded shared runner the child's startup alone can outrun the outer limit. Pre-existing and environmental; recorded so it is not mistaken for a regression.

Rendering parity (secondary claim)

parity.mjs drives the same envelope through both arms in real Chromium, faithful to the delivery model: each arm's document.html requests its version-pinned unpkg URL under integrity="sha384-…", which I intercept and fulfil with that arm's own bytes — so SRI genuinely validates (asserted per arm, sriMatches: true), and the renderer fetch is counted (intercepted=1).

Fixture: 5 blocks from export-transcript-document-v1.schema.json covering inline and display KaTeX (including \frac), a ts fence (the Shiki-stub plain-<pre> path), a mermaid fence, a table, a blockquote, a link, a list, a tool block and a thought block. Positive controls assert the surfaces actually rendered before any parity claim is made — 3 .katex, 2 pre, 1 mermaid svg, 1 table, 1 blockquote in both arms.

24/24 assertions passed. See 05-rendered-export-head-1280x2000.png and 06-rendered-export-base-byte-identical-to-05.png.

oracle base head identical
full-page screenshot md5 76193622db093ba7755cce78583927cf (90,980 B) 76193622db093ba7755cce78583927cf (90,980 B) byte-identical
transcript DOM md5 dcab9039b3c8baad780e95a9cf762ca7 (25,432 ch) same identical
visible text md5 9bfd916e67e754eecc9c8a15fe73dc66 same identical
computed styles, 9 probe elements 0 deltas
renderComplete true true
page errors / failed requests / console errors 0 / 0 / 0 0 / 0 / 0

The one census delta is styleTagEntries: [] on base, ["transcript"] on head — precisely this PR's per-entry key, expected and benign.

This is the load-bearing result of the round. Dropping 2,340 CSS rules and 12.2 MB of JS changed nothing on screen: none of the dropped rules matches anything a transcript renders. The PR's own parity claim ("byte-identical screenshots") was made pre-#9812 and explicitly not re-measured; it now holds again on the merged branch, at the pixel level, against a much broader fixture than a screenshot comparison alone.

Guard and mutation matrix

All four FORBIDDEN_DOCUMENT_INPUTS rules driven red through the production build.mjs, not a copy (02-guard-red-runs-and-mutation-matrix.png):

run mutation exit forbidden inputs names the reason?
A package root restored (test-plan step 2) 1 10 yes — both the CodeMirror and root-facade why strings
B composerTag lever reverted (M1 tree) 1 8 yes
C shiki onResolve removed 1 305 yes
D echarts onResolve removed 1 594 yes

Test-plan step 2's exact requirement — "must fail naming the forbidden input and the reason, not merely exceed a byte cap" — is met: the throw fires before the size check, and prints input paths plus the why.

Mutation matrix on build-artifact.test.ts (17 tests), one lever reverted per row. Each mutant lands in the same file as the assertions it kills, and each fails the intended assertion with expected-versus-actual values, not an import or compile break:

mut mutation kills failing assertion
M1 UserMessage.tsxHEAD^1, CodeMirror reachable does not pull the editor stack into the transcript entry expected '…' not to contain '@codemirror/'
M2 CSS import order swapped in WebShellTranscript.tsx (one line) keeps KaTeX border overrides after Tailwind preflight expected 1 to be greater than 233
M3 per-entry CSS key → shared key in vite.lib.config.ts (two lines) injects its stylesheet under its own entry key expected '…' to contain 'data-qwen-web-shell-entry="index"'

3/3 killed. These are also the positive controls: the suite demonstrably goes red under my harness, so the unmutated 17/17 green means something. Unmutated control: 17/17 on head, 34/34 across both gated files.

Survivors, classified:

  • keeps the transcript entry a fraction of the interactive entry (js.length < 1_300_000) — survived M1, which grows the JS remainder to 1,150,400 chars, still 11.5% under the bound. Not vacuous by construction (M4-style mutations that make the entry re-export the root would kill it), but I did not run one, so I am reporting it as unexercised rather than claiming it is pinned. Coverage note, not a defect.
  • still carries what a transcript actually renders (toContain('react-markdown'), toContain('WebShellTranscript')) — a presence check on strings that survive minification; no mutation in this PR's scope would remove them without breaking the entry entirely. Redundant defence alongside the size bound.

Gates

gate command result
web-shell tests npx vitest run client/build-artifact.test.ts client/index.test.tsx 34/34 passed (2 files)
cli export tests npx vitest run src/ui/utils/export/formatters/html.test.ts src/ui/utils/export/export-transcript-document.test.ts 75/75 passed on re-run (first run: 74/75, see Finding 4)
web-shell typecheck npx tsc -p tsconfig.json --noEmit exit 0
typecheck gate proven live planted export const x: number = "s" in the mutant tree caughtclient/transcript.ts(36,14): error TS2322, exit 2; restored → exit 0

Determinism evidence, so the byte comparisons are trustworthy: rebuilding head's index.js and transcript.js produced sha256 identical to CI's output (ba167c8dfd7e7e56, c011421e03eaa069); a third tree reset to head reproduced c011421e03eaa069 and 7,275,173 bytes; the base document build produced 19,521,158 bytes on both of its runs. Toolchains matched exactly across arms (vite 5.4.21, node v22.23.2, esbuild 0.25.6).

Not covered

  • Per-commit attribution. The snapshot lists 13 commits; the shallow merge-ref checkout reaches exactly 1 (git rev-list HEAD^1..HEAD^208b6fa55, --is-shallow-repositorytrue). I verified the aggregate HEAD^1..HEAD diff only. Note the naive count returns a plausible 1 rather than erroring, so this is stated explicitly rather than inferred.
  • Playwright e2e and visual-regression suites (packages/web-shell/client/e2e/) — the description puts these out of scope; I did not run them. My parity harness is a purpose-built probe, not the repo's suite.
  • CSP behaviour and the unpkg delivery model itself. I asserted the SRI hash matches the served bytes and that script-src/connect-src did not block the render, but did not test nonce handling, CDN availability, or the fail-closed path when the asset 404s. The description's release-ordering concern (which version first freezes these bytes on unpkg) is a publishing question I cannot reach without network access, and I made no attempt.
  • Real CDN fetch. Both arms were served from intercepted local bytes at the exact pinned URL. No request reached unpkg.
  • Reproducing the shape, not the cause, of fix(export): stop embedding the Web Shell runtime in every HTML file #11031. My base arm reproduces the byte count the issue reported (within 0.02%) by building it, which is stronger than replaying a captured artifact — but I did not reproduce the user-facing symptom (an actual /export html invocation from a live session), since that needs a real session and model calls.
  • Other packages' test suites, npm run lint, prettier --check, and the repo-wide npm run preflight. I ran only the affected workspaces. I deliberately did not run bare node scripts/lint.js, which invokes prettier --write . and would have rewritten the working tree under the A/B.
  • A real /export html run end to end through the CLI. I built the renderer asset and the document template directly and drove them in a browser, which covers the changed surface but not the CLI's export command wiring (untouched by this PR).
  • Windows and macOS. Linux only; the description marks the other two as unverified too.
  • No previous-report.md was present, so this is a first round with no carried-forward findings.

Methodology

Environment: the CI verify container (node:22-bookworm), working tree at refs/pull/11038/merge, npm ci and npm run build already complete at head. Controls ran in scratch git worktrees under tmp/tmp/base-tree at HEAD^1 and tmp/mutant-tree at the merge commit — each with a mirrored node_modules (1,040 top-level + 453 scoped symlinks). Internal workspace links were asserted by realpath before any control was trusted, which caught a live trap: the first mutant-tree run silently resolved @qwen-code/web-shell back into the head tree and reported head's byte count as the mutant's. @qwen-code/sdk is shared with the head tree, justified by git ls-tree hash equality for packages/sdk-typescript (8fdc4434864d1b83 on both sides) and an empty diff for that path; the root package-lock.json is byte-identical base vs head, so reusing the installed tree is a clean control rather than a dependency confound. Base's nested packages/web-shell/node_modules (Tailwind v4.3.2, against a root v3.4.18) is shared for the same reason. Harnesses: decompose.mjs (per-lever esbuild cells using head's literal options, patterns and stub files), probe-css-subset.mjs (postcss rule-set comparison), parity.mjs and katex-probe.mjs (real Chromium via Playwright at /__w/_temp/pw-browsers, loopback origin, route-intercepted renderer URL so SRI runs for real), ratchet-probe.mjs (Tailwind scan ladder), tables.mjs (re-prints every table from the live artifacts and raw logs). Raw per-cell stdout/stderr, build logs and both metafiles are in logs/; the four captures in evidence/ were produced with scripts/verify-capture.mjs. Both scratch worktrees were removed at the end of the round.

Incident, disclosed. A shell-precedence error of mine (A || cd base-tree && git checkout HEAD^1 -- <path> — the cd never ran because A used -C) executed that checkout in the main worktree and briefly reverted head's packages/web-templates/src/export-html/build.mjs to base's blob 47712bad. It was caught by a git status audit before the round closed, and the tree was restored with git checkout HEAD --; head's build.mjs, index and working file are now all blob 8461cdae, git status --porcelain is empty, and git diff HEAD --stat reports nothing. No measurement was affected, proven three ways: (i) the asset every head-side number came from is timestamped 06:43:53, before the corruption at 06:56, and was produced by a build that printed head-only output (the top inputs line and the 7,275,173 runtime line, neither of which exists in base's build.mjs); (ii) re-running the restored build.mjs reproduced it byte-identically — sha256 c6a4cf784cdd07a24c1aaa123397d322e1e1429df36eda3666e8915333835b61, 7,272,168 bytes, cmp -s identical to the snapshot the A/B and parity harnesses consumed; (iii) the head-only stub files were never touched (mtimes 06:36, CI's). Nothing in this report rests on the corrupted interval.

Flakiness gate log

rounds=5 files=1 skipped=0
file packages/web-shell/client/build-artifact.test.ts: (cd packages/web-shell) npx --no-install vitest run ./client/build-artifact.test.ts


per-file results (P=pass F=fail I=infra-exit, one letter per run):
  packages/web-shell/client/build-artifact.test.ts: PPPPP

verdict: pass
summary: 1 changed test file(s) x 5 identical rounds, no divergence

--- per-invocation detail (full copy in the artifact) ---
round 1 · packages/web-shell/client/build-artifact.test.ts: P (exit 0)
round 2 · packages/web-shell/client/build-artifact.test.ts: P (exit 0)
round 3 · packages/web-shell/client/build-artifact.test.ts: P (exit 0)
round 4 · packages/web-shell/client/build-artifact.test.ts: P (exit 0)
round 5 · packages/web-shell/client/build-artifact.test.ts: P (exit 0)

Evidence images

01-ab-renderer-asset-base-vs-head

02-guard-red-runs-and-mutation-matrix

03-katex-cascade-order-per-entry

04-ratchet-fragility-tailwind-scan

05-rendered-export-head-1280x2000

06-rendered-export-base-byte-identical-to-05

Harness scripts and raw logs are in the workflow run artifacts (7-day retention).

Qwen Code · sandboxed verification

@qwen-code-dev-bot qwen-code-dev-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved at head 08b6fa55.

The blocker from my last round is fixed the honest way. Rather than keep an assertion that measured nothing, client/transcript.ts now states the residual outright — "this is not free of the daemon React runtime … their provider guards are in dist/transcript.js. Decoupling them is #11100" — and the artifact guard bounds the payload instead of pretending the graph is clean, with its own comment spelling out why absence would be a false claim. keeps the transcript entry a fraction of the interactive entry (build-artifact.test.ts) is a real, checkable property.

Verified by building this head locally rather than reading the diff:

$ cd packages/web-shell && npm run build && npx vitest run --config vitest.config.ts client/build-artifact.test.ts
 ✓ build-artifact.test.ts (17 tests) 350ms
   Test Files 1 passed (1) | Tests 17 passed (17)
$ cd packages/web-templates && node src/export-html/build.mjs
Document export runtime is 7275173 bytes

so the test that was red at 1d94060f is gone, and the remaining 16 still pass. The @qwen-code/web-shell/daemon-react-sdk barrel is no longer reached through hooks/useMessages either — adapters/localizedMessages.ts is genuinely hook-free, useMessages.ts re-exports for existing callers, and the leftover unused adapter import was dropped.

ci-bot's round-2 Critical (R2-1, the KaTeX cascade) is fixed and I measured it, not just the comment. The import pair in WebShellTranscript.tsx:1-2 is swapped so preflight precedes KaTeX in the transcript entry as it does in the package root, and the false "injecting both is a no-op beyond the duplicated bytes" note in vite.lib.config.ts is replaced with the correct statement that equal-specificity declarations still depend on order. In the two stylesheets I built, the preflight rule that carries border-color:var(--border) sits at ~1.2 KB into both sheets while .katex *{ … border-color:currentColor } is at 3,054,937 of 3,152,802 bytes in dist/index.js and 1,757,058 of 2,289,195 in dist/transcript.js — KaTeX last in both, so the exported document and the app now break the tie the same way. keeps KaTeX border overrides after Tailwind preflight pins this by parsing both bundles, which is the important part: it cannot pass while one entry drifts.

Also re-checked from the earlier round and still holding: the README read-only recipe imports @qwen-code/web-shell/transcript with the note explaining why the root must not be used; per-entry injection keys (index/transcript) with the shared data-qwen-web-shell="component" marker and the concatenate-every-match reader; editor-graph absence (@codemirror/, "codemirror", vaul); and the export build's forbidden-input rules with zero Shiki/CodeMirror/echarts/zrender inputs. All 20 threads are resolved and mergeable is MERGEABLE.

CI facts. On this head Lint & Static (15m16s), Test (ubuntu-latest) (18m11s), Integration Tests (no-AK), web-shell E2E Smoke, Capture web-shell visuals (17m27s) and both Desktop Shell lanes are green; only the review-pr lane was still queued. The page still shows CHANGES_REQUESTED from ci-bot's review on the earlier f6c23c51, which a @qwen-code /triage re-run will clear.

Two things I am noting rather than asking for:

  1. adapters/localizedMessages.ts's docblock still ends with "client/build-artifact.test.ts asserts that dist/transcript.js carries no daemon provider code" — that is no longer true of the suite (it asserts a size bound and says so), and the paragraph above it still reads as if the extraction drained the provider stack. One sentence to correct, here or in the #11100 pass; it is the kind of line that sends the next reader looking for a guard that isn't there.
  2. The hazard the PR now documents is worth running before anything else, and #11100 already asks for it: TasksStatusMessage.tsx:353 calls useActions() unconditionally while document-main.tsx mounts no daemon provider (I checked — the document entry contains no provider reference at all), so either those blocks never reach a document and the daemon runtime in dist/transcript.js is pure dead weight, or an exported transcript containing one throws during render. That check decides whether this is cleanup or a user-visible bug fix, so it should lead the #11100 work.

@yiliang114
yiliang114 added this pull request to the merge queue Sep 6, 2026
Merged via the queue into main with commit db8897d Sep 6, 2026
97 of 99 checks passed

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, looks ready to ship. ✅

Verified at this head rather than taken from the thread: the Shiki, echarts and CodeMirror premises all hold against the source; the forbidden-input guard and byte cap really do run in CI (root prepare on npm ci → workspace build → packages/web-templates/build.mjs spawns the export build and rejects on a non-zero exit, so a throw reds the required Test and Lint & Static jobs); and CI on this commit prints Document export runtime is 7275173 bytes with no shiki, @shikijs, codemirror, echarts, zrender or vaul among the top inputs — a 62.7% reduction (2.68x) against the 19,523,259 baseline. The third vite pass costs 2.93s for 299 modules.

Both prior blockers are fixed, and fixed the honest way: the false "no-op" comment is replaced with the real failure mode and the cascade-order caveat, and when the "no daemon providers" assertion proved false it was replaced with a size bound on the entry plus a docblock naming the daemon hook runtime as a known residual with #11100 as its home — rather than deleting the test and keeping the claim. The KaTeX cascade fix is pinned by a test that parses both emitted stylesheets, so it cannot pass while one entry drifts.

Non-blocking, all named in the triage notes above:

  • packages/web-shell/client/adapters/localizedMessages.ts still ends its docblock claiming the suite asserts that dist/transcript.js "carries no daemon provider code". That contradicts client/transcript.ts in this same diff and is not what the test measures — one sentence, here or in the #11100 pass.
  • docs/verification/export-html-runtime-size/README.md (309 lines, over a third of the diff) is now stale against its own head: superseded commits named as under test, ? rows CI has filled, and a §6 asking for constants that are already set. Trim to the results or drop it.
  • The budget leaves 1.7% headroom (~125 KB) on a build that runs inside every npm ci, so the next dependency bump that grows this entry reds CI for an unrelated PR. Deliberate and precedented — just worth #11091 knowing.
  • CSS is now ~2.29 MB of the transcript entry's 3.43 MB, and Tailwind v4 scans every source file rather than the entry's graph, so the separate rollup run scopes CSS Modules but not the utility layer. A bigger remaining lever than the daemon residual.

Not covered by this approval: rendered-output parity of an actual exported file, and the #11100 question of whether an export containing an MCP-status or tasks-status block throws at render (TasksStatusMessage.tsx:353 calls useActions() unconditionally while the document entry mounts no provider). No green check opens a generated export and compares it to main. A /verify run is in flight on this head and should answer both; its report, not this approval, is the place to read that answer.

Qwen Code · qwen3.8-max-2026-09-02

pull Bot pushed a commit to edisplay/qwen-code that referenced this pull request Sep 6, 2026
…wenLM#11167)

* fix(export): point documents at a published renderer, drop mermaid

Three export-side fixes that share one file and one budget.

**QwenLM#11096 — every export built from main fails closed.** Since QwenLM#9812 an exported
file loads its renderer from unpkg by version and refuses to render unless the
envelope identity matches the asset it loaded; both derive from the root
package.json version. npm `latest` is 0.23.0, published *before* QwenLM#9812 added the
asset, so that URL 404s and every export built from source renders the error
page. Publishing fixes the steady state. Until then `build.mjs` accepts
QWEN_EXPORT_RENDERER_IDENTITY and QWEN_EXPORT_RENDERER_INTEGRITY — set together,
validated, describing one already-published asset — and CI sets them to the
`preview` tag so its exports open. The asset built here keeps its own true
identity; only the generated document points elsewhere. Delete the CI env once a
release containing QwenLM#9812 is on npm.

**QwenLM#11091 — mermaid leaves the document bundle.** A ```mermaid fence in an export
now renders as a plain <pre> holding its own source, the same degradation
document mode already applies to syntax highlighting. `mermaid` resolves to a
stub in the document build and is added to FORBIDDEN_DOCUMENT_INPUTS. Since
QwenLM#9812 the renderer is a download on first open rather than bytes already on
disk, and mermaid plus its graph dependencies were the largest remaining input
at ~6 MB pre-minify — paid by every reader whether or not the transcript has a
diagram. IIFE output cannot code-split, so the existing lazy `import()` was
flattened in; exclusion is the lever, as it was for Shiki and ECharts. Math is
deliberately kept: rehype-katex is a tenth of the size, and inline math degrades
to raw delimiters inside prose rather than to a block that was already a block.

The render limits inside MermaidBlock now bind to every non-interactive mode
instead of going dead with document mode: they were never about the export
format, they are about rendering a transcript the viewer did not author and
cannot interrupt, which is equally true of readonly replay.

**QwenLM#11092 — one react-markdown major.** @datafe-open/markdown-chart-react pulled
a nested react-markdown@10 beside the hoisted 9.1.0. Its only use of the library
is `createElement(ReactMarkdown, { components }, source)`, and web-shell never
imports the component holding that call, so an override to ^9 collapses the two.

The two byte constants in build.mjs are deliberately left at their pre-mermaid
values: lowering them without a measurement risks failing every build. Neither
build nor typecheck was run here — the handoff in
docs/verification/export-renderer-delegation-mermaid/README.md asks for the
measurement and the ratchet first. `Markdown.mermaid.test.ts` was run (4 passed);
everything else is CI's to confirm.

QwenLM#11142 stays separate on purpose: three of its items rewrite the QwenLM#11038
verification document with measured numbers, and this change moves those numbers.

Fixes QwenLM#11096
Fixes QwenLM#11091
Fixes QwenLM#11092

* chore(vscode): regenerate NOTICES.txt after the react-markdown dedupe

The override collapsing react-markdown onto ^9 removes the nested 10.1.0 copy
from the tree, so the companion's dependency notices no longer list it. Matches
the 1 insertion / 28 deletions the Lint & Static gate reported.

Regenerated with npm ci --ignore-scripts followed by
npm run generate:notices --workspace=qwen-code-vscode-ide-companion; the
dependency total is unchanged at 655.

* fix(export): teach the browser gate the document it actually ships

Addresses B2, S1 and S3 from the review on QwenLM#11167.

**B2 — the gate broke two independent ways.**

(a) It asserted that an exported document renders a Mermaid diagram, which is
exactly the behaviour this PR removes. It now asserts the opposite and pins the
replacement: no mermaidInline node, and the fence's own source present as text.
KaTeX stays asserted, because math is deliberately kept.

(b) The gate has no network — it fulfils the renderer request itself with the
locally built asset — while under delegation the document pins the SRI of an
already-published one. Those bytes cannot match by construction, so Chromium
would block the script and every success case would time out. The gate now
re-pins the document to the bytes it actually serves, so the integrity check
still runs for real rather than being defeated in either direction. On a
non-delegated build the rewrite is a no-op, since the document already carries
that hash. It throws if the attribute ever stops being there, so the rewrite
cannot silently become a way of skipping SRI.

The fail-closed case is deliberately left alone: it wants a mismatch. The
incompatible-envelope case is re-pinned, because the renderer has to load for
the envelope check to be the thing that fails.

What this still does not cover, and cannot offline: that the delegated hash
matches the published asset. That belongs to the release smoke test QwenLM#11096 asks
for, and is now stated in both the gate and the verification brief.

**S1 — the byte budget had ~3.2 MB of slack.** This PR's own Lint & Static lane
printed  with mermaid, @mermaid-js/parser,
cytoscape and lodash-es all gone from the top inputs — 7,275,173 -> 4,083,810,
-44%. The constants left at 7.3/7.4 MB would have let the entire mermaid family
return unnoticed, which is the one thing that guard exists to prevent. Ratcheted
to 4,100,000 / 4,200,000, and the verification brief's measurement section is
rewritten around the numbers instead of asking for them.

**S3 — the mermaid guard was narrower than the precedent it cites.** The echarts
rule names the package and its heavy transitive graph; the mermaid rule named
only the package, leaving @mermaid-js/parser and cytoscape unasserted. With S1's
slack gone they would now trip the budget, but the structural guard should say
so directly: the pattern matches the echarts shape.

Not run here: the browser gate needs a built web-shell and web-templates, which
this machine cannot produce. The assertion changes are CI's to confirm.

* fix(export): stop delegating the renderer in CI

The web-shell E2E Smoke lane failed on the previous commit with
"expected 'error' to be 'true'" on both success cases, while the
incompatible-envelope case kept passing. That is the whole diagnosis: the
renderer loaded (the re-pinned SRI was fine) and then rejected the envelope.

Under delegation the envelope announces the delegated identity, while the asset
actually running in the page is this build's own and announces its own —
document-main.tsx compares the two and fails closed on exactly that mismatch.
Re-pinning the integrity, which the previous commit did, fixes the SRI half and
cannot fix this one.

The conclusion is that the delegation should never have been wired into CI.
The only lane that opens an exported document is the transcript browser gate,
and it fulfils the renderer request itself from this build's dist/ — it never
reaches the CDN, so delegating there buys nothing and breaks the one place that
would have caught it. The knob stays in build.mjs, documented, for a human who
needs a source build's exports to open before the release lands; that is where
it was always useful.

So: the env block comes out of ci.yml, and the gate's integrity re-pin comes out
with it, since a non-delegated build already carries the hash of the bytes the
gate serves. What stays from the previous commit is the part that is a real
product change — the gate no longer asserts that an exported document renders a
Mermaid diagram, and instead pins the plain-<pre> degradation.

Not run here: the gate needs a built web-shell and web-templates, which this
machine cannot produce.
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Released in v0.23.1.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

review/self-reported The linked issue was opened by the PR author (self-reported)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(export): stop embedding the Web Shell runtime in every HTML file

5 participants