Skip to content

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

Open
yiliang114 wants to merge 11 commits into
mainfrom
issue-11031
Open

fix(export): shrink the exported transcript renderer to what a read-only transcript needs#11038
yiliang114 wants to merge 11 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

⚠️ Deferred approval withheld — 1 PR CI workflow run(s) on c4fb0e6 did not finish green; see the updated table in the Stage 2 comment. Re-run @qwen-code /triage after fixes. finalize run

⚠️ 延迟审批已搁置 —— c4fb0e6 有 1 个 PR CI workflow 未以绿色完成,详见 Stage 2 评论中已更新的表格。修复后可重新运行 @qwen-code /triage查看 finalize 运行

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓ — every heading filled in, with a full Chinese translation.

Problem: observed and measured, not theoretical. #11031 records an empty-session /export html at 19,526,066 bytes raw / 4,772,314 gzip against 311,990 for the previous lightweight export, with the payload identical from one export to the next. A real bug with numbers attached.

Direction: aligned — and it is the mechanism this repo already documented for the SDK's transcript projection: a separate opt-in subpath entry, its own size budget with the measurement command recorded, and an explicit "do not rely on importing the package root or incidental tree shaking" (docs/design/2026-07-14-chat-record-daemon-transcript-block-projection.md, Build requirements). packages/sdk-typescript/scripts/build.js already carries the same warn-then-throw shape, so this follows precedent rather than inventing one. Leaving the CDN direction alone is defensible too — it collides with the deliberately self-contained / offline / nonce-only design of the export document.

The one thing I'd want settled before merge is the closing keyword. Fixes #11031 auto-closes a P1 whose headline ask — React and the renderer loaded from immutable version-pinned CDN URLs so the runtime is not duplicated per export — is explicitly not what this does. What lands is ~8% of the bytes (19.52 MB → 17.97 MB); an export is still ~18 MB afterwards. Is the intent to close #11031 and track the CDN decision separately, or should this be a plain reference so the issue stays open?

Size: 115 changed lines across 9 files — all production; no test, generated, or schema files. No packages/core/src/** and none of the auth / providers / models / config / tools / services paths, and the author has write access, so the core-module gate does not apply. It does span two packages (web-shellweb-templates), but that is the change itself, not incidental spread.

Approach: the scope feels right and close to what I'd have done independently. The third vite run looks like over-engineering until you read injectCssModules(): it accumulates every CSS asset in the run's bundle and inlines it into the component entry chunk, so a transcript entry built alongside index would inherit the full editor/sidebar stylesheet no matter how narrow its own imports are. A separate rollup run is the only way to get transcript-only scoped CSS. Two smaller things:

  • The budget headroom is tight: 19,000,000 against a 17,963,937 baseline is ~5.8%, chosen so the pre-fix 19.52 MB value fails. The SDK precedent gives ~2.9x (66 KiB baseline, 192 KiB cap). This one catches the regression it aims at, but a routine shiki / mermaid / katex / echarts bump can plausibly add over a MB and turn npm run build red on an unrelated PR. The error does say "raise the budget deliberately", which softens it — just worth being clear that this is a tripwire on the old size, not a guard against slow creep (only the 18.5 MB warning covers that, and it fails nothing).
  • Nothing unrelated is riding along. The eslint.config.js line is needed by the new console.log / console.warn in the template build (packages/*/build.mjs does not reach packages/web-templates/src/export-html/build.mjs), and it lands in the existing "scripts we run directly with node" override.

Risk: no match against the high-risk-path patterns from the revert-history analysis. One awareness note: this adds a public package export (@qwen-code/web-shell/transcript), a small public-contract addition. Not escalating it — the author is a maintainer, and the root export surface is unchanged (WebShellTranscript still ships from the package root).

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓ —— 每个小节都填写了,并附有完整中文翻译。

问题: 是已观测并有实测数据的 bug,不是理论性加固。#11031 记录了空会话 /export html 为 19,526,066 字节(raw)/ 4,772,314(gzip),而此前轻量导出为 311,990 字节,且每次导出的负载完全相同。问题真实,数据齐备。

方向: 对齐 —— 而且这正是本仓库在 SDK transcript projection 中已经写入设计的做法:独立的 opt-in 子路径入口、单独的尺寸预算并记录测量命令,以及明确的"不要依赖包根导入或顺带的 tree shaking"(见 docs/design/2026-07-14-chat-record-daemon-transcript-block-projection.md 的 Build requirements)。packages/sdk-typescript/scripts/build.js 已经是同样的"先告警后抛错"结构,所以这是沿用既有先例而非新造一套。不动 CDN 方向也说得通 —— 它与导出文档刻意保持的自包含 / 离线 / 仅 nonce 的 CSP 设计冲突。

合并前希望先定下来的一点是关闭关键字。Fixes #11031 会自动关闭一个 P1,而该 issue 的核心诉求 —— React 与渲染器从不可变、版本锁定的 CDN URL 加载,从而不再在每次导出中重复内联运行时 —— 明确不是本 PR 做的事。本次落地的是约 8% 的体积(19.52 MB → 17.97 MB),导出文件之后仍约 18 MB。意图是关闭 #11031 并另行跟踪 CDN 决策,还是改成普通引用让 issue 保持打开?

规模: 9 个文件共 115 行改动 —— 全部为生产代码;无测试、生成或 schema 文件。未触及 packages/core/src/**,也未触及 auth / providers / models / config / tools / services 路径,且作者具有写权限,因此核心模块闸门不适用。改动确实跨两个包(web-shellweb-templates),但这就是改动本身,不是顺带扩散。

方案: 范围合理,与我独立想到的做法接近。第三次 vite 构建乍看像过度设计,但读过 injectCssModules() 就明白了:它会把该次构建 bundle 中所有 CSS 资产累积起来并内联进组件入口 chunk,因此与 index 一起构建的 transcript 入口,无论自身导入多窄,都会继承完整的编辑器/侧边栏样式表。独立的 rollup 运行是获得"仅 transcript 可达"作用域 CSS 的唯一办法。另外两点较小的问题:

  • 预算余量偏紧:基线 17,963,937 对应上限 19,000,000,约 5.8%,这是为了让修复前的 19.52 MB 值必然失败。SDK 的先例给的是约 2.9 倍(基线 66 KiB、上限 192 KiB)。当前这个能拦住它瞄准的那次回归,但 shiki / mermaid / katex / echarts 的一次常规升级很可能增加超过 1 MB,从而让某个无关 PR 的 npm run build 变红。报错信息确实写了"或有意提高预算",这缓解了一些 —— 只是要明确:这是对尺寸的红线,不是防止缓慢增长的护栏(后者只有 18.5 MB 的告警覆盖,而告警不会失败)。
  • 没有夹带无关改动。eslint.config.js 那一行是模板构建中新增 console.log / console.warn 所必需的(packages/*/build.mjs 匹配不到 packages/web-templates/src/export-html/build.mjs),并且落在已有的"直接用 node 运行的脚本"覆盖块中。

风险: 未命中基于 revert 历史分析得出的高风险路径模式。一点提示:本 PR 新增了一个公开的包导出(@qwen-code/web-shell/transcript),属于小范围的公共契约新增。不作升级处理 —— 作者是维护者,且包根导出面未变(WebShellTranscript 仍从包根导出)。

进入代码审查 🔍

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

Reviewed at c4fb0e62a578102ba18d6c96ff69f9c796b05f1a · 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 f6c23c5. 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-light 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

Read only the title and the "Why it's needed" section first: I'd have landed in the same place — a narrow entry module inside web-shell, the document entry pointed at it, and a build-time assertion so the size can't silently regress. The one materially different path I checked was whether the document esbuild could consume the transcript source directly and skip the new public subpath altogether. It can't: the scoped component stylesheet is produced by the vite lib plugin (scopeComponentCss + injectCssModules), which esbuild does not replicate, so a separate vite run is genuinely required rather than being ceremony. The PR's approach matches my proposal.

No critical blockers. What I verified rather than assumed:

  • tsconfig.lib.json includes client/**/*.ts, so tsc -p tsconfig.lib.json emits the dist/types/transcript.d.ts that the new exports condition points at, and files: ["dist/*.js", "dist/types"] already covers dist/transcript.js for publishing.
  • scripts/build.js builds packages/web-shell before packages/web-templates, and emptyOutDir: false means the third run can't wipe the first two — so the document esbuild resolves the subpath through the exports map to a freshly built bundle.
  • The root export surface is unchanged (client/index.tsx still exports WebShellTranscript), so nothing that imports the package root today is affected.
  • The precedent the new comment cites is accurate: packages/sdk-typescript/scripts/build.js really does carry DAEMON_BROWSER_BUNDLE_WARNING_BYTES / MAX_TRANSPORTS_BROWSER_BUNDLE_BYTES / MAX_TRANSCRIPT_BROWSER_BUNDLE_BYTES in the same warn-then-throw shape, with the measurement command recorded.

Three things worth a look, none of them blocking:

1. Nothing pins the two properties this PR actually rests on (Suggestion). build-artifact.test.ts reads its injected CSS from dist/index.js only (DIST_PATH), so the new isComponentEntryFacade branch in injectCssModules() is untested — if it stopped matching, every export would render with no component styling at all and the suite would stay green. The same file also has the cheap hook for the second gap: readPackageJavascript() already reads every dist/*.js and asserts that useDaemonSessionNotices must be used within DaemonSessionProvider is present somewhere in the package. Asserting that dist/transcript.js starts with the __qwenWebShellCss injection and does not contain that marker would pin both properties in about six lines.

The second property matters because the new docblock's "no daemon providers" is not evident from the module graph: WebShellTranscript.tsx pulls transcriptBlocksToLocalizedMessages from hooks/useMessages.ts, which value-imports useConnection, useTranscriptBlocks and useWorkspace from @qwen-code/web-shell/daemon-react-sdk, and MessageList reaches messages/ToolApproval.tsx (value-imports isAgentTool) and messages/McpStatusMessage.tsx (value-imports useMcp). Whether any daemon code lands in dist/transcript.js therefore comes down to rollup tree-shaking — the exact reliance the design doc this PR cites tells us not to depend on. It may well shake out clean; today nothing would notice if it didn't.

2. Does --mode transcript keep the lib build production-like? (question, not a defect) Vite keys .env loading and import.meta.env.MODE / PROD / DEV off mode, and a custom mode on vite build is the documented case where production behaviour can need an explicit NODE_ENV=production. There is a concrete branch in this graph: components/RootErrorFallback.tsx:94 renders error.message only under import.meta.env.DEV, and WebShellTranscript.tsx mounts RootErrorFallback as its error boundary (line 310) — so if the transcript run were treated as non-production, every exported HTML would silently grow a dev-only branch in its error path. I could not check this statically (this run doesn't build or execute PR code), and the evidence points the other way: a dev-mode bundle would more likely have grown than shrunk by 1.5 MB. Cheap to make unambiguous if you agree — cross-env NODE_ENV=production vite build --config vite.lib.config.ts --mode transcript, or switch the entry on an env var instead of mode.

3. CSS injection is now first-wins across two entries (note). Both dist/index.js and dist/transcript.js guard on style[data-qwen-web-shell="component"], so a page that loads both gets whichever injected first — and if the transcript entry wins, the interactive shell renders with the transcript-only stylesheet. Nothing in-repo loads both today (only document-main.tsx imports the subpath), so this is a line for the docblock rather than a change request.

Minor, take it or leave it: all four new resolver entries (vite.config.ts, vite.lib.config.ts, vitest.config.ts, tsconfig.json paths) currently have no consumer — client/transcript.ts re-exports from a relative path and the only external consumer resolves through the package exports map. They mirror the daemon-react-sdk entries, which internal modules do import by name, so keeping them for consistency is defensible.

Files changed (9)
File What changed
eslint.config.js Adds the export-html template build scripts to the existing "scripts we run directly with node" override, which is what permits the new console output; the existing build.mjs glob never matched this path.
packages/web-shell/client/transcript.ts New 28-line subpath entry re-exporting the transcript renderer and its props type, with a docblock pointing transcript-only bundlers here instead of the package root.
packages/web-shell/package.json Adds the transcript condition to the exports map in the same shape as the existing SDK subpath, and appends the third vite run to the build script.
packages/web-shell/tsconfig.json Paths entry so the subpath resolves during typecheck.
packages/web-shell/vite.config.ts Same alias for the app dev/build config.
packages/web-shell/vite.lib.config.ts The substantive one: config becomes a function of mode, the entry switches to transcript-only under the transcript mode, and the CSS-injection plugin now treats the transcript facade as a component entry so it carries its own scoped stylesheet.
packages/web-shell/vitest.config.ts Same alias for tests.
packages/web-templates/src/export-html/build.mjs Prints the document runtime size and enforces the budget: warn above 18.5 MB, throw above 19 MB.
packages/web-templates/src/export-html/src/document-main.tsx The import switches from the package root to the transcript subpath.

Testing

This is an unattended CI run, so I did not build, run, or test any PR-derived code — the evidence below is the PR's own CI, fetched through the API for the reviewed commit. No check has concluded failure on this commit as of this pass; the substantive jobs are still running, so nothing here is a green light yet. The Qwen Triage Finalize job rewrites the table below once CI settles.

Test (macos-latest, Node 22.x) and Test (windows-latest, Node 22.x) are skipped on this commit, so CI coverage for the build change is Linux-only — which matches the author's Tested-on table (Linux ✅, macOS ⚠️, Windows ⚠️).

The build change is nonetheless exercised by CI rather than only by the author's machine: npm ci runs prepare, which runs the full workspace build, and scripts/build.js builds web-shell (now three vite runs) before web-templates, where the new assertion lives. ci.yml sets no QWEN_SKIP_PREPARE, so a missing or broken transcript bundle, or a budget breach, turns CI red on every PR without needing a new test. That is real coverage for the mechanism — it is not coverage for the two unpinned properties in finding 1.

Not verified here: the author's own numbers (a 17,966,485-byte template, the build printing Document export runtime is 17963937 bytes, the red run at 19,523,259 bytes, and md5-identical before/after screenshots from headless Chromium). Those are the author's claim on Linux, not independently re-run by this review.

Final CI results for c4fb0e6 (auto-updated by the triage finalize job after CI completed):

Check Conclusion
Test (ubuntu-latest, Node 22.x) ❌ failure
web-shell E2E Smoke (ubuntu-latest, Node 22.x) 🚫 cancelled
Capture web-shell visuals (ubuntu-latest, Node 22.x) ✅ success
Classify PR ✅ success
Dependency CVE audit ✅ success
Desktop Shell (ubuntu-22.04) ✅ success
Desktop Shell (windows-2022) ✅ success
Integration Tests (no-AK, No Sandbox) ✅ success
Lint & Static (ubuntu-latest, Node 22.x) ✅ success
Secret scan (TruffleHog) ✅ success

One row per check name (latest run); skipped checks omitted; failures sort first. / 每个检查名一行(取最新一次运行),省略 skipped,失败项排在最前。

Sandboxed verification would settle this: @qwen-code /verify — that dist/transcript.js actually carries the scoped component stylesheet from the new facade branch, that it excludes the daemon/App runtime as the new docblock claims, and that the −8% figure reproduces against the base build. None of that is observable from the diff, and no test currently pins it. For the export as a user sees it, @qwen-code /tmux would drive a real /export html and capture the result.

中文说明

代码审查

先只看标题和"为什么需要"部分,我自己的方案也会落到同一处:在 web-shell 内加一个窄入口模块、让文档入口指向它、再加一个构建期断言防止体积静默回归。我唯一认真比对过的另一条路,是让文档 esbuild 直接消费 transcript 源码、从而不新增公开子路径 —— 这条路走不通:作用域化的组件样式表是由 vite lib 插件(scopeComponentCss + injectCssModules)产出的,esbuild 不会复现这套处理,所以第三次 vite 构建是必需的,不是形式主义。本 PR 的方案与我的独立提案一致。

未发现阻断性问题。 以下是我实际核对过、而非想当然的部分:

  • tsconfig.lib.json 的 include 覆盖 client/**/*.ts,因此 tsc -p tsconfig.lib.json 会产出新 exports 条件所指向的 dist/types/transcript.d.tsfiles: ["dist/*.js", "dist/types"] 也已经覆盖 dist/transcript.js 的发布。
  • scripts/build.js 先构建 packages/web-shell 再构建 packages/web-templates,且 emptyOutDir: false,所以第三次构建不会清掉前两次的产物 —— 文档 esbuild 能通过 exports 映射解析到刚构建出的 bundle。
  • 包根导出面未变(client/index.tsx 仍导出 WebShellTranscript),今天从包根导入的代码不受影响。
  • 新注释引用的先例是准确的:packages/sdk-typescript/scripts/build.js 确实有 DAEMON_BROWSER_BUNDLE_WARNING_BYTES / MAX_TRANSPORTS_BROWSER_BUNDLE_BYTES / MAX_TRANSCRIPT_BROWSER_BUNDLE_BYTES,同样是"先告警后抛错",同样记录了测量命令。

有三点值得看一下,都不构成阻断:

1. 本 PR 真正依赖的两个性质目前没有任何测试钉住(建议)。 build-artifact.test.ts 只从 dist/index.js 读取注入的 CSS(DIST_PATH),所以 injectCssModules() 中新增的 isComponentEntryFacade 分支是无测试覆盖的 —— 一旦它不再匹配,所有导出文件都会完全没有组件样式,而测试套件仍然是绿的。同一个文件里也有补第二个缺口的低成本入口:readPackageJavascript() 已经读取 dist/*.js 的全部文件,并断言 useDaemonSessionNotices must be used within DaemonSessionProvider 在包内存在。只要再断言 dist/transcript.js__qwenWebShellCss 注入开头、且包含上述 marker,约六行就能把两个性质都钉住。

第二点之所以重要,是因为新 docblock 里的"无 daemon providers"从模块图上并不显而易见:WebShellTranscript.tsx 会从 hooks/useMessages.ts 引入 transcriptBlocksToLocalizedMessages,而后者对 @qwen-code/web-shell/daemon-react-sdk值导入useConnectionuseTranscriptBlocksuseWorkspace);MessageList 又会到达 messages/ToolApproval.tsx(值导入 isAgentTool)与 messages/McpStatusMessage.tsx(值导入 useMcp)。因此 daemon 代码是否进入 dist/transcript.js,取决于 rollup 的 tree shaking —— 而这恰恰是本 PR 引用的设计文档明确要求不要依赖的东西。结果很可能是干净的;但今天如果它不干净,也没有任何东西会发现。

2. --mode transcript 是否仍保持 lib 构建的 production 语义?(疑问,不是缺陷) Vite 的 .env 加载以及 import.meta.env.MODE / PROD / DEV 都以 mode 为依据,而在 vite build 上使用自定义 mode,正是文档中那种可能需要显式 NODE_ENV=production 才能保证 production 行为的场景。这个模块图里有一个具体分支:components/RootErrorFallback.tsx:94 仅在 import.meta.env.DEV 下渲染 error.message,而 WebShellTranscript.tsxRootErrorFallback 作为其错误边界挂载(第 310 行)—— 所以如果 transcript 这次构建被当作非 production,每个导出的 HTML 都会在错误路径里静默多出一个 dev 分支。我无法静态确认(本次运行不构建、不执行 PR 代码),而现有证据倾向于另一边:dev 模式的 bundle 更可能变大,而不是缩小 1.5 MB。如果你认同,消除歧义的成本很低 —— cross-env NODE_ENV=production vite build --config vite.lib.config.ts --mode transcript,或者用环境变量而不是 mode 来切换入口。

3. CSS 注入现在是两个入口"先到先得"(备注)。 dist/index.jsdist/transcript.js 都以 style[data-qwen-web-shell="component"] 作为判重条件,所以同时加载两者的页面会用先注入的那一份 —— 如果 transcript 入口先注入,交互式 shell 就会以"仅 transcript"的样式表渲染。目前仓库内没有同时加载两者的地方(只有 document-main.tsx 导入该子路径),所以这更适合写进 docblock,而不是要求改动。

次要、可采纳可不采纳:四处新增的解析入口(vite.config.tsvite.lib.config.tsvitest.config.tstsconfig.json paths)目前都没有消费者 —— client/transcript.ts 用相对路径再导出,唯一的外部消费者走包 exports 映射。它们与 daemon-react-sdk 的对应条目保持一致(后者确实被内部模块按名导入),所以为一致性保留也说得通。

测试

本次为无人值守 CI 运行,因此我没有构建、运行或测试任何 PR 派生代码 —— 下面的证据是通过 API 取得的、该 PR 自身在本次受审提交上的 CI 结果。截至本次审查,该提交上没有任何 check 结论为 failure;但实质性任务仍在运行,所以这里还不是绿灯。CI 结束后 Qwen Triage Finalize 会就地重写上面的表格。

本次提交上 Test (macos-latest, Node 22.x)Test (windows-latest, Node 22.x)skipped,因此这次构建改动的 CI 覆盖仅限 Linux —— 与作者的测试环境表一致(Linux ✅、macOS ⚠️、Windows ⚠️)。

不过这处构建改动确实由 CI 执行,而不只依赖作者本机:npm ci 会触发 prepare,进而执行完整的 workspace 构建;scripts/build.js 先构建 web-shell(现在是三次 vite 构建)再构建 web-templates,而新断言就在后者里。ci.yml 没有设置 QWEN_SKIP_PREPARE,所以 transcript bundle 缺失/损坏或预算超限,都会让每个 PR 的 CI 变红,无需新增测试。这对构建机制本身是实实在在的覆盖 —— 但它不覆盖第 1 点里那两个未被钉住的性质。

本次未验证:作者自报的数据(模板 17,966,485 字节、构建输出 Document export runtime is 17963937 bytes、红测试的 19,523,259 字节,以及无头 Chromium 下前后 md5 相同的截图)。这些是作者在 Linux 上的声明,本审查未独立复现。

沙箱化验证可以把这件事定下来:@qwen-code /verify —— 用于确认 dist/transcript.js 确实携带新 facade 分支产出的作用域组件样式表、确实如新 docblock 所述排除了 daemon/App 运行时,以及 −8% 这个数字相对 base 构建能否复现。这些都无法从 diff 看出,目前也没有测试钉住。至于用户实际看到的导出效果,@qwen-code /tmux 可以真实驱动一次 /export html 并抓取终端输出。

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

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

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 4/5 — the mechanism is sound, minimal and precedent-backed, and I found nothing that blocks; my reservations are all of the "pin this so it can't silently regress" kind plus one question I can't answer without building the package.

Stepping back: this does what I'd have done, and it does it in 115 lines. The part I probed hardest is the third vite run, because it's the piece that looks like over-engineering on a skim — and it survives the probe. injectCssModules() accumulates every CSS asset in the run's bundle and inlines it into the component entry chunk, so a transcript entry built next to index would carry the full editor/sidebar stylesheet no matter how narrow its imports were. There is no smaller change that gets transcript-only scoped CSS, and consuming the source straight from esbuild isn't an alternative because the scoping happens in the vite plugin.

Being honest about what a user gets today: 8%. An export goes from 19.5 MB to 18 MB, which nobody will feel. The durable value is the other two things — the interactive shell (App, daemon SDK client, editor and terminal chrome) no longer ships inside every exported file, and a regression of this class now fails the build instead of landing silently. If the expectation attached to #11031 is "exports become small", this PR is not that PR, and the description says so plainly, which I appreciate.

Six months from now I'd thank the author rather than curse them: the new entry's docblock explains why it exists and what not to do instead, the budget comment records the baseline and the measurement command, and the eslint line lands in the override that already exists for node-run build scripts rather than carving out a new exception.

One thing a human should decide deliberately rather than let merge order decide. The same author has #11035 (fix(export): load transcript renderer from CDN) open against the same issue, also carrying Fixes #11031, based on codex/remove-webui rather than main, and touching the same packages/web-templates/src/export-html/build.mjs. Both tracks are the author's own and the description here already frames the CDN direction as a separate product decision, so this is coordination, not a gate concern — but whichever of the two merges first closes a P1 the other also claims, and the second one has to reconcile with the new inline-runtime budget (a CDN-loaded renderer changes what that number even measures). Worth picking the order on purpose.

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

  • The ~6-line pin in build-artifact.test.ts from the code review — that dist/transcript.js carries the injected scoped stylesheet and does not carry the daemon provider marker the same file already asserts exists elsewhere in the package. Right now the single most load-bearing line in this diff (the facade branch in injectCssModules()) fails silently if it breaks, and the docblock's "no daemon providers" rests on tree-shaking that the cited design doc tells us not to rely on.
  • A one-word answer on whether --mode transcript keeps import.meta.env.PROD true. Worst case is a dev-only branch in RootErrorFallback inside every export, which is minor — I just can't confirm it from a static read.
  • The first-wins CSS note in the transcript docblock, so the next person who loads both entries in one page isn't surprised.

Since CI is still running on this commit (Qwen Code CI in progress at the time of writing; Capture web-shell visuals has since landed green, which is a useful signal that the third vite run and the CSS-plugin change didn't disturb the shell's own rendering), approval is deferred until CI lands green on c4fb0e62a578102ba18d6c96ff69f9c796b05f1a. I am not approving unseen results, and I am not requesting changes over Suggestions.

中文说明

Confidence: 4/5 —— 机制扎实、改动最小、有既有先例支撑,我没有发现阻断性问题;我的保留意见都属于"把它钉住,免得将来静默回归"这一类,外加一个不构建就无法回答的疑问。

退一步看:这个 PR 做的正是我会做的,而且只用了 115 行。我推敲最久的是第三次 vite 构建,因为粗看它最像过度设计 —— 而它经得起推敲。injectCssModules() 会把该次构建 bundle 中的所有 CSS 资产累积起来并内联进组件入口 chunk,所以与 index 一起构建的 transcript 入口,无论导入多窄都会带上完整的编辑器/侧边栏样式表。没有更小的改动能拿到"仅 transcript 作用域"的 CSS;让 esbuild 直接消费源码也不是替代方案,因为作用域化就发生在这个 vite 插件里。

关于用户今天实际得到什么,说实话:8%。导出文件从 19.5 MB 降到 18 MB,用户不会有感知。真正持久的价值是另外两点 —— 交互式 shell(App、daemon SDK client、编辑器与终端组件)不再被塞进每一个导出文件;并且这类回归现在会让构建失败,而不是静默落地。如果对 #11031 的期待是"导出文件变小",那本 PR 不是那个 PR —— 而描述里把这点写得很清楚,我很认可。

六个月后再看,我会感谢作者而不是骂他:新入口的 docblock 解释了它为什么存在、以及不要用什么做法替代;预算注释记录了基线与测量命令;eslint 那一行落在已有的"直接用 node 运行的构建脚本"覆盖块里,而不是新开一个例外。

有一件事应该由人有意决定,而不是交给合并顺序。同一作者在 #11035fix(export): load transcript renderer from CDN)里针对同一个 issue 也开着 PR,同样写着 Fixes #11031,基线分支是 codex/remove-webui 而非 main,并且改动了同一个 packages/web-templates/src/export-html/build.mjs。两条线都是作者自己的,本 PR 的描述也已经把 CDN 方向定位为独立的产品决策,所以这是协调问题而不是闸门问题 —— 但两者中先合并的那个会关闭一个另一个同样声明要关的 P1,而后合并的那个必须与新的"内联运行时预算"对齐(渲染器改为 CDN 加载后,这个数字衡量什么都变了)。建议有意选定顺序。

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

  • 代码审查里提到的、build-artifact.test.ts 中约 6 行的钉子 —— 断言 dist/transcript.js 携带注入的作用域样式表,且携带同一个文件已断言"在包内某处存在"的 daemon provider marker。目前这个 diff 中最吃重的一行(injectCssModules() 里的 facade 分支)一旦失效是静默失败,而 docblock 里"无 daemon providers"依赖的正是被引用的设计文档明确要求不要依赖的 tree shaking。
  • --mode transcript 是否让 import.meta.env.PROD 保持为 true,给一个一句话的确认。最坏情况是每个导出文件的 RootErrorFallback 里多一个 dev 分支,影响很小 —— 我只是无法从静态阅读确认。
  • 在 transcript 的 docblock 里补一句"CSS 先到先得"的说明,免得下一个在同一页面加载两个入口的人被惊到。

由于该提交上的 CI 仍在运行(撰写时 Qwen Code CI 处于 in progress;Capture web-shell visuals 此后已经变绿 —— 这是个有用的信号,说明第三次 vite 构建与 CSS 插件改动没有影响 shell 自身的渲染),批准将推迟到 CI 在 c4fb0e62a578102ba18d6c96ff69f9c796b05f1a 上全绿之后。我不会为尚未产生的结果背书,也不会因为"建议级"意见而要求改动。

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

Reviewed at c4fb0e62a578102ba18d6c96ff69f9c796b05f1a · 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 on lines +10 to +11
* Exposes the read-only transcript renderer without the interactive shell:
* no `App`, no daemon providers, no editor/terminal chrome. Bundlers that

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.

[Suggestion] This entry documents itself as exposing the transcript renderer "without the interactive shell: no App, no daemon providers, no editor/terminal chrome", but the built dist/transcript.js still pulls in the CodeMirror editor stack and the daemon hook runtime. The chain is WebShellTranscriptMessageListUserMessage, which value-imports the three getComposerTag* display helpers from hooks/useComposerCore (UserMessage.tsx:35-38); that module's top-level CodeMirror imports are not tree-shakeable while the helpers are consumed. Separately, hooks/useMessages value-imports useConnection/useTranscriptBlocks/useWorkspace from the daemon-react-sdk barrel, which re-exports ./daemon/index.js wholesale. Every exported HTML therefore still ships editor + daemon runtime — the payload class this PR exists to remove — and the codemirror-family inputs alone (999,030 bytes) consume ≈96% of the 1,036,063-byte headroom between the 17,963,937-byte baseline and MAX_DOCUMENT_RUNTIME_BYTES.

Witness:

esbuild metafile of the document build (exact build.mjs config):
codemirror-family inputs: 999,030 bytes over 8 modules
  (@codemirror/view 488,543 / state 145,108 / language 101,913 / autocomplete 90,042 /
   commands 83,484 / search 48,624 / lint 36,579 / codemirror 4,737)
dist/transcript.js lines 25-29: imports "@codemirror/view", "@codemirror/state",
  bare "codemirror", "@codemirror/autocomplete", "@codemirror/commands"
dist/transcript.js contains 'useDaemonActions must be used within DaemonSessionProvider': true
dist/transcript.js contains 'cm-editor': true

Fix: move the pure getComposerTagDisplay/getComposerTagLabel/getComposerTagValue helpers out of hooks/useComposerCore.ts into a dependency-free leaf module (e.g. utils/composerTag.ts, which UserMessage.tsx already imports) and have useComposerCore.ts and ChatEditor.tsx import them from there; for the daemon residue, import the specific hooks from their deep modules instead of the barrel. Then rebuild and confirm dist/transcript.js has no @codemirror specifiers. Note the fix must not violate two existing facts: rollupOptions.external in vite.lib.config.ts lists codemirror and /^@codemirror\//, so reachability must be cut in web-shell source — tree shaking cannot drop these imports; and build-artifact.test.ts:44-50 asserts daemon code IS present in the package's dist JS overall, so any exclusion assertion must target dist/transcript.js alone.

Please pin the fix with a test: extend client/build-artifact.test.ts with a case reading dist/transcript.js and asserting it contains no @codemirror/ specifiers, and prove the test red by re-pointing UserMessage.tsx back at useComposerCore.

中文说明

[Suggestion](建议)该入口的文档声称只暴露 transcript 渲染器、"没有 App、没有 daemon providers、没有编辑器/终端相关组件",但构建出的 dist/transcript.js 仍然带入了 CodeMirror 编辑器栈和 daemon hook 运行时。引用链为 WebShellTranscriptMessageListUserMessage,后者按值导入 hooks/useComposerCore 中的三个 getComposerTag* 展示辅助函数(UserMessage.tsx:35-38);只要这些辅助函数被引用,该模块顶层的 CodeMirror 导入就无法被 tree-shake 掉。另外,hooks/useMessages 按值导入 daemon-react-sdk barrel 的 useConnection/useTranscriptBlocks/useWorkspace,而该 barrel 整体再导出了 ./daemon/index.js。因此每个导出 HTML 仍然携带编辑器 + daemon 运行时——正是本 PR 要移除的那类负载——仅 codemirror 一族输入(999,030 字节)就占用了 17,963,937 字节基线与 MAX_DOCUMENT_RUNTIME_BYTES 之间 1,036,063 字节余量的约 96%。

Witness:

esbuild metafile of the document build (exact build.mjs config):
codemirror-family inputs: 999,030 bytes over 8 modules
dist/transcript.js lines 25-29: imports "@codemirror/view", "@codemirror/state", bare "codemirror" ...
dist/transcript.js contains 'useDaemonActions must be used within DaemonSessionProvider': true
dist/transcript.js contains 'cm-editor': true

修复建议:把纯函数 getComposerTagDisplay/getComposerTagLabel/getComposerTagValuehooks/useComposerCore.ts 移到一个无依赖的叶子模块(例如 UserMessage.tsx 已经在导入的 utils/composerTag.ts),让 useComposerCore.tsChatEditor.tsx 改从那里导入;对于 daemon 残留,改为从深层模块直接导入具体 hook 而不是经过 barrel。然后重新构建并确认 dist/transcript.js 中没有任何 @codemirror 说明符。注意修复不能违反两个既有事实:vite.lib.config.ts 的 rollupOptions.external 列出了 codemirror/^@codemirror\//,所以必须在 web-shell 源码层面切断可达性——tree shaking 无法去掉这些导入;且 build-artifact.test.ts:44-50 断言包的整体 dist JS 中确实包含 daemon 代码,所以任何排除性断言必须只针对 dist/transcript.js

请为修复补一个测试作为验收标准:扩展 client/build-artifact.test.ts,读取 dist/transcript.js 并断言其中不含 @codemirror/ 说明符,再通过把 UserMessage.tsx 改回从 useComposerCore 导入来验证该测试会变红。

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

Comment thread packages/web-shell/vite.lib.config.ts Outdated
Comment on lines +112 to +114
// stylesheet. The transcript entry is consumed on its own by the
// `/export html` document build, so it cannot inherit the CSS from
// the root entry; runtime injection stays idempotent (the style tag

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.

[Suggestion] Both entry bundles now carry runtime CSS injection guarded by one document-wide marker (style[data-qwen-web-shell="component"]), so a document that loads both entries keeps only the first-evaluated entry's stylesheet. The comment asserts the transcript entry "is consumed on its own", but nothing enforces it: both entries are public exports, and the README still teaches the root import. If a host page bundles @qwen-code/web-shell/transcript alongside @qwen-code/web-shell and the transcript chunk evaluates first, its subset stylesheet (2,288,935 bytes, no editor rules) claims the marker tag; the root entry's preamble then sees the tag and skips injection, so the interactive shell renders without editor/sidebar/dialog styles — silently and import-order-dependently. getWebShellStyleText (client/shadowDom.ts:51) also clones the FIRST matching tag into shadow roots, so shadow-DOM embeds inherit the subset. Before this PR only one entry injected CSS, so the marker could not collide.

Witness:

jsdom probe with the real injection preambles lifted from dist/index.js and dist/transcript.js:
  transcript-first: style tags=1, css bytes=2,288,935, cm-editor hits=0  <- editor styles lost
  index-first     : style tags=1, css bytes=3,152,546, cm-editor hits=4
  fixed arm (distinct per-entry marker), transcript-first:
                    root css bytes=3,152,546, cm-editor hits=4  <- flips

Fix: key the injection slot per entry — e.g. keep data-qwen-web-shell="component" for the index facade and inject with a distinct value (such as data-qwen-web-shell="transcript-component") from the transcript facade, each deduping only against its own marker — or skip injection only when an identical-textContent tag already exists. The fix must keep client/shadowDom.ts:51 in sync: it queries 'style[data-qwen-web-shell="component"]' and uses only the first match's textContent (client/shadowDom.test.ts:59 sets the same dataset), so any marker change must update that lookup to keep shadow-DOM hosts receiving the full root stylesheet.

Please pin the fix with a test: a jsdom/build-level test that loads both dist entries with the transcript entry first and asserts the root (index-only) stylesheet is still present — it must go red while both entries share the first-wins marker.

中文说明

[Suggestion](建议)两个入口产物现在都带有由同一个文档级标记(style[data-qwen-web-shell="component"])守护的运行时 CSS 注入,因此同时加载两个入口的文档只会保留先求值入口的样式表。注释声称 transcript 入口"被单独消费",但没有任何机制保证这一点:两个入口都是公开导出,且 README 仍在教导入包根。如果宿主页面同时打包 @qwen-code/web-shell/transcript@qwen-code/web-shell,且 transcript chunk 先求值,其子集样式表(2,288,935 字节、不含编辑器规则)就会占据该标记;随后根入口的注入前导发现标记已存在而跳过注入,交互式 shell 就会失去编辑器/侧栏/对话框样式——无声无息,且依赖导入顺序。getWebShellStyleText(client/shadowDom.ts:51)也只把第一个匹配的标记克隆进 shadow root,因此 shadow-DOM 嵌入同样继承子集。本 PR 之前只有一个入口注入 CSS,该标记不可能冲突。

Witness:

jsdom probe with the real injection preambles lifted from dist/index.js and dist/transcript.js:
  transcript-first: style tags=1, css bytes=2,288,935, cm-editor hits=0  <- editor styles lost
  index-first     : style tags=1, css bytes=3,152,546, cm-editor hits=4
  fixed arm (distinct per-entry marker), transcript-first:
                    root css bytes=3,152,546, cm-editor hits=4  <- flips

修复建议:按入口区分注入槽位——例如根入口保留 data-qwen-web-shell="component",transcript 入口改用不同的值(如 data-qwen-web-shell="transcript-component")注入,各自只对自己的标记去重——或者仅当已存在文本内容完全相同的标记时才跳过注入。修复必须同步 client/shadowDom.ts:51:它查询 'style[data-qwen-web-shell="component"]' 且只使用第一个匹配的 textContent(client/shadowDom.test.ts:59 设置了同样的 dataset),任何标记变更都要更新该查找,确保 shadow-DOM 宿主仍能拿到完整的根样式表。

请为修复补一个测试作为验收标准:加一个 jsdom/构建级测试,先加载 transcript 入口再加载根入口,断言根入口独有的样式表仍然存在——当两个入口仍共享先到先得的标记时该测试必须变红。

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

Comment thread packages/web-shell/vite.lib.config.ts Outdated
Comment on lines +116 to +118
const isComponentEntryFacade =
item.facadeModuleId?.endsWith('/client/index.tsx') ||
item.facadeModuleId?.endsWith('/client/transcript.ts');

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.

[Suggestion] Nothing pins that dist/transcript.js carries the injected scoped stylesheet, or that it excludes the interactive runtime: build-artifact.test.ts reads only dist/index.js (DIST_PATH = resolve(DIST_DIR, 'index.js'), line 7). If a future edit drops the endsWith('/client/transcript.ts') clause — or renames the entry without updating this suffix — both builds still succeed, dist/transcript.js is emitted without the __qwenWebShellCss injection preamble, and every /export html document renders unstyled while the whole suite stays green; the bundle got smaller, so MAX_DOCUMENT_RUNTIME_BYTES also passes. Conversely, nothing asserts the transcript entry's CSS is the transcript-only subset, so a mode-separation regression that re-inherits the full stylesheet only trips the budget if the CSS delta alone exceeds ~1 MB.

Witness:

Scratch-tree mutation probe: dropped the endsWith('/client/transcript.ts') clause, rebuilt both modes:
  root build exit=0; transcript build exit=0   (build succeeds)
  dist/transcript.js: 1,190,400 bytes, __qwenWebShellCss count 0
                      (at HEAD: 3,482,334 bytes, count 2)
  dist/index.js: __qwenWebShellCss count 2 (unchanged)
  npx vitest run client/build-artifact.test.ts -> 12 tests passed (fully green)

Fix: extend packages/web-shell/client/build-artifact.test.ts (which exists for exactly this purpose) to also read dist/transcript.js and assert (a) the injected-CSS preamble const __qwenWebShellCss= is present and its rules are scoped under [data-web-shell-root]/[data-web-shell-portal-root] (the exact markers injectCssModules emits, vite.lib.config.ts:129-133), and ideally (b) no @codemirror specifiers — pinning the isolation property this PR exists for. Note the artifact only exists after the new vite build --config vite.lib.config.ts --mode transcript step added to the build script (package.json:28).

Please pin the fix with a test: removing the transcript clause from isComponentEntryFacade and rebuilding must make the new preamble-presence assertion fail while every pre-existing test stays green.

中文说明

[Suggestion](建议)目前没有任何测试保证 dist/transcript.js 携带注入的 scoped 样式表、或排除交互式运行时:build-artifact.test.ts 只读取 dist/index.js(第 7 行 DIST_PATH = resolve(DIST_DIR, 'index.js'))。如果未来某次修改删掉了 endsWith('/client/transcript.ts') 这一分支——或者重命名了入口却没更新这个后缀——两个构建仍会成功,dist/transcript.js 会不带 __qwenWebShellCss 注入前导被产出,每个 /export html 文档都会无样式渲染,而整个测试套件全绿;产物变小了,所以 MAX_DOCUMENT_RUNTIME_BYTES 也照样通过。反过来,也没有任何断言保证 transcript 入口的 CSS 是仅 transcript 可达的子集,因此一次让完整样式表重新混入的 mode 拆分回归,只有当 CSS 增量本身超过约 1 MB 时才会触发体积预算。

Witness:

Scratch-tree mutation probe: dropped the endsWith('/client/transcript.ts') clause, rebuilt both modes:
  root build exit=0; transcript build exit=0   (build succeeds)
  dist/transcript.js: 1,190,400 bytes, __qwenWebShellCss count 0
                      (at HEAD: 3,482,334 bytes, count 2)
  dist/index.js: __qwenWebShellCss count 2 (unchanged)
  npx vitest run client/build-artifact.test.ts -> 12 tests passed (fully green)

修复建议:扩展 packages/web-shell/client/build-artifact.test.ts(它正是为此而存在的)去读取 dist/transcript.js,断言 (a) 注入前导 const __qwenWebShellCss= 存在、且其规则都限定在 [data-web-shell-root]/[data-web-shell-portal-root] 作用域内(即 injectCssModules 实际产出的标记,vite.lib.config.ts:129-133),最好再断言 (b) 不含 @codemirror 说明符——把本 PR 存在的意义(隔离性)固化下来。注意该产物只在 build 脚本新增的 vite build --config vite.lib.config.ts --mode transcript 步骤(package.json:28)之后才存在。

请为修复补一个测试作为验收标准:删除 isComponentEntryFacade 中的 transcript 分支并重新构建后,新增的前导存在性断言必须失败,而所有既有测试保持绿色。

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

Comment thread packages/web-shell/client/transcript.ts Outdated
Comment on lines +12 to +15
* only render transcripts (for example the self-contained `/export html`
* document renderer in `@qwen-code/web-templates`) must import this
* subpath instead of the package root, so the interactive runtime is not
* inlined into their output.

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.

[Suggestion] This docblock requires transcript-only bundlers to "import this subpath instead of the package root", but the package's own README contradicts it: packages/web-shell/README.md:186 — the read-only "只读 ChatRecord JSONL" recipe — still shows import { WebShellTranscript } from '@qwen-code/web-shell'; and never mentions the new /transcript subpath. A host author following that README recipe imports the package root and bundles the full interactive runtime — the exact regression class this PR removes — while believing they are on the lightweight path.

Witness:

A/B document build (identical esbuild config, only the import specifier differs):
  subpath import (this PR) : total runtime 17,964,378 bytes
  root import (README recipe): total runtime 19,523,700 bytes
  README-recipe overhead: +1,559,322 bytes — and it would FAIL this PR's own
  MAX_DOCUMENT_RUNTIME_BYTES guard (19,000,000)
README.md: the /transcript subpath is never mentioned.

Fix: update the README read-only recipe (packages/web-shell/README.md:186) to import { WebShellTranscript } from '@qwen-code/web-shell/transcript'; and mention the subpath wherever WebShellTranscript usage is introduced.

中文说明

[Suggestion](建议)这段 docblock 要求仅需 transcript 的打包方"必须导入此子路径而不是包根",但包自身的 README 与之矛盾:packages/web-shell/README.md:186——"只读 ChatRecord JSONL"一节——仍然写着 import { WebShellTranscript } from '@qwen-code/web-shell';,且全文从未提及新的 /transcript 子路径。照着 README 该节操作的宿主作者会导入包根,从而把完整的交互式运行时打包进去——正是本 PR 要消除的那类回归——而他们还以为自己走的是轻量路径。

Witness:

A/B document build (identical esbuild config, only the import specifier differs):
  subpath import (this PR) : total runtime 17,964,378 bytes
  root import (README recipe): total runtime 19,523,700 bytes
  README-recipe overhead: +1,559,322 bytes — and it would FAIL this PR's own
  MAX_DOCUMENT_RUNTIME_BYTES guard (19,000,000)
README.md: the /transcript subpath is never mentioned.

修复建议:把 README "只读 ChatRecord JSONL" 一节(packages/web-shell/README.md:186)的示例改为 import { WebShellTranscript } from '@qwen-code/web-shell/transcript';,并在所有介绍 WebShellTranscript 用法的地方提及该子路径。

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

@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).
@yiliang114

Copy link
Copy Markdown
Collaborator Author

Both blockers addressed at 1d94060f5, plus the non-blocking budget point.

1. The entry's "no daemon providers" claim is now true, and pinned. You were right that the docblock over-claimed, and I took the fix rather than the disclaimer. transcriptBlocksToLocalizedMessages and the Translator type it needs move to client/adapters/localizedMessages.ts, a leaf module that imports only transcriptToMessages and adapters/types — no daemon-react-sdk barrel. WebShellTranscript.tsx imports from there; useMessages.ts re-exports both, so its own test and every other caller are untouched. This is the same treatment the composer tags got.

build-artifact.test.ts now asserts dist/transcript.js contains neither DaemonSessionProvider nor DaemonWorkspaceProvider, using the same JS-remainder technique as the editor-stack test. If another path into the barrel survives that I could not see statically, that test will say so on this run rather than the claim quietly staying false.

2. README recipe fixed. packages/web-shell/README.md §3 now imports from @qwen-code/web-shell/transcript, with a note that the package root pulls App, the daemon providers and the editor/terminal code and that tree shaking is not to be relied on. The example in docs/design/2026-07-14-web-shell-readonly-daemon-transcript.md got the same one-line change; the rest of that document is left as the historical record it is.

3. Budget tightened using your number. 7,400,000 cap / 7,300,000 warning, from the 7,275,173 you measured locally with the echarts stub in place. The comment records both measurements and their provenance — yours and CI's pre-stub 8,456,076 — so the next person can see which build each came from.

Thank you for building it: the 7,275,173 figure and the dist/transcript.js composition are things I could not produce myself. The machine I am working from cannot build or test this repository, so everything above is static reasoning plus node --check and Prettier; the new assertion and the budget are CI's to confirm.

On the Integration Tests (no-AK) failure — agreed, and your read matches mine: qwen-live-m4-acp-steering with acp backend 'qwen-acp' did not initialize, 183 other tests green in the same lane, and this diff never reaches packages/qwen-live. The #11033/#11034 shared-pool family looks like the right home for it.

Two items from this PR's surroundings, for the record: #11091 now carries the measured breakdown of what is left in the renderer (mermaid and its graph deps at 5,984,888 bytes, plus katex; lucide-react at 1,574,512 is worth a look), and #11096 covers the release-side problem — 0.23.0 is latest on npm but predates #9812, so the pinned unpkg URL currently 404s and exports built from main fail closed.

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.

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