fix(export): shrink the exported transcript renderer to what a read-only transcript needs - #11038
fix(export): shrink the exported transcript renderer to what a read-only transcript needs#11038yiliang114 wants to merge 11 commits into
Conversation
…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
|
|
|
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 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" ( The one thing I'd want settled before merge is the closing keyword. Size: 115 changed lines across 9 files — all production; no test, generated, or schema files. No 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
Risk: no match against the high-risk-path patterns from the revert-history analysis. One awareness note: this adds a public package export ( Moving on to code review. 🔍 中文说明感谢贡献! 模板完整 ✓ —— 每个小节都填写了,并附有完整中文翻译。 问题: 是已观测并有实测数据的 bug,不是理论性加固。#11031 记录了空会话 方向: 对齐 —— 而且这正是本仓库在 SDK transcript projection 中已经写入设计的做法:独立的 opt-in 子路径入口、单独的尺寸预算并记录测量命令,以及明确的"不要依赖包根导入或顺带的 tree shaking"(见 合并前希望先定下来的一点是关闭关键字。 规模: 9 个文件共 115 行改动 —— 全部为生产代码;无测试、生成或 schema 文件。未触及 方案: 范围合理,与我独立想到的做法接近。第三次 vite 构建乍看像过度设计,但读过
风险: 未命中基于 revert 历史分析得出的高风险路径模式。一点提示:本 PR 新增了一个公开的包导出( 进入代码审查 🔍 — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
🖼️ web-shell visual previewRendered against a mock daemon (no real backend): the PR base vs this PR head Screenshots · before / afterFull-resolution recordings (.webm) are attached to the workflow run. — Qwen Code · web-shell visuals |
Code reviewRead only the title and the "Why it's needed" section first: I'd have landed in the same place — a narrow entry module inside No critical blockers. What I verified rather than assumed:
Three things worth a look, none of them blocking: 1. Nothing pins the two properties this PR actually rests on (Suggestion). The second property matters because the new docblock's "no daemon providers" is not evident from the module graph: 2. Does 3. CSS injection is now first-wins across two entries (note). Both Minor, take it or leave it: all four new resolver entries ( Files changed (9)
TestingThis 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
The build change is nonetheless exercised by CI rather than only by the author's machine: Not verified here: the author's own numbers (a 17,966,485-byte template, the build printing Final CI results for
One row per check name (latest run); skipped checks omitted; failures sort first. / 每个检查名一行(取最新一次运行),省略 skipped,失败项排在最前。 Sandboxed verification would settle this: 中文说明代码审查 先只看标题和"为什么需要"部分,我自己的方案也会落到同一处:在 web-shell 内加一个窄入口模块、让文档入口指向它、再加一个构建期断言防止体积静默回归。我唯一认真比对过的另一条路,是让文档 esbuild 直接消费 transcript 源码、从而不新增公开子路径 —— 这条路走不通:作用域化的组件样式表是由 vite lib 插件( 未发现阻断性问题。 以下是我实际核对过、而非想当然的部分:
有三点值得看一下,都不构成阻断: 1. 本 PR 真正依赖的两个性质目前没有任何测试钉住(建议)。 第二点之所以重要,是因为新 docblock 里的"无 daemon providers"从模块图上并不显而易见: 2. 3. CSS 注入现在是两个入口"先到先得"(备注)。 次要、可采纳可不采纳:四处新增的解析入口( 测试 本次为无人值守 CI 运行,因此我没有构建、运行或测试任何 PR 派生代码 —— 下面的证据是通过 API 取得的、该 PR 自身在本次受审提交上的 CI 结果。截至本次审查,该提交上没有任何 check 结论为 本次提交上 不过这处构建改动确实由 CI 执行,而不只依赖作者本机: 本次未验证:作者自报的数据(模板 17,966,485 字节、构建输出 沙箱化验证可以把这件事定下来: — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
|
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. 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 ( What I'd still like, none of it blocking this merge:
Since CI is still running on this commit ( 中文说明Confidence: 4/5 —— 机制扎实、改动最小、有既有先例支撑,我没有发现阻断性问题;我的保留意见都属于"把它钉住,免得将来静默回归"这一类,外加一个不构建就无法回答的疑问。 退一步看:这个 PR 做的正是我会做的,而且只用了 115 行。我推敲最久的是第三次 vite 构建,因为粗看它最像过度设计 —— 而它经得起推敲。 关于用户今天实际得到什么,说实话:8%。导出文件从 19.5 MB 降到 18 MB,用户不会有感知。真正持久的价值是另外两点 —— 交互式 shell(App、daemon SDK client、编辑器与终端组件)不再被塞进每一个导出文件;并且这类回归现在会让构建失败,而不是静默落地。如果对 #11031 的期待是"导出文件变小",那本 PR 不是那个 PR —— 而描述里把这点写得很清楚,我很认可。 六个月后再看,我会感谢作者而不是骂他:新入口的 docblock 解释了它为什么存在、以及不要用什么做法替代;预算注释记录了基线与测量命令;eslint 那一行落在已有的"直接用 node 运行的构建脚本"覆盖块里,而不是新开一个例外。 有一件事应该由人有意决定,而不是交给合并顺序。同一作者在 #11035( 我仍然希望看到的(都不阻断本次合并):
由于该提交上的 CI 仍在运行(撰写时 — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
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.ts — no such file or directory; client/index.test.tsx — no such file or directory; src/ui/utils/export/formatters/html.test.ts — no such file or directory; src/ui/utils/export/export-transcript-document.test.ts — no such file or directory; src/export-html/build.mjs — no 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.ts — no such file or directory; client/index.test.tsx — no such file or directory; src/ui/utils/export/formatters/html.test.ts — no such file or directory; src/ui/utils/export/export-transcript-document.test.ts — no such file or directory; src/export-html/build.mjs — no such file or directory; and 2 more。
— qwen3.8-max via Qwen Code /review (v0.23.0)
| * Exposes the read-only transcript renderer without the interactive shell: | ||
| * no `App`, no daemon providers, no editor/terminal chrome. Bundlers that |
There was a problem hiding this comment.
[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 WebShellTranscript → MessageList → UserMessage, 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 运行时。引用链为 WebShellTranscript → MessageList → UserMessage,后者按值导入 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/getComposerTagValue 从 hooks/useComposerCore.ts 移到一个无依赖的叶子模块(例如 UserMessage.tsx 已经在导入的 utils/composerTag.ts),让 useComposerCore.ts 和 ChatEditor.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)
| // 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 |
There was a problem hiding this comment.
[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)
| const isComponentEntryFacade = | ||
| item.facadeModuleId?.endsWith('/client/index.tsx') || | ||
| item.facadeModuleId?.endsWith('/client/transcript.ts'); |
There was a problem hiding this comment.
[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)
| * 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. |
There was a problem hiding this comment.
[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 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 |
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
|
Follow-up pushed (
Two corrections to the description above, which I will rewrite once the numbers are re-measured:
The before/after table is therefore stale, and 中文说明已追加提交
描述中有两处需要更正,待重新测量后一并重写:
因此前后对比表已过期, |
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.
|
Merged current Resolution keeps both sides: main's delivery path (root 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
Follow-ups filed while tracing this: #11091 (mermaid still in the bundle; echarts figure needs re-checking) and #11092 (duplicate |
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.
Head branch was pushed to by a user without write access
|
Pushed 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. const loadECharts = options.loadECharts ?? (async () => await import('echarts'));and this repository never takes that default: So 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 Also filed #11096 for the release-side problem this branch sits next to: The failing
|
chiga0
left a comment
There was a problem hiding this comment.
Reviewed at head 1837026a9d.
What I checked
Shiki stub contract — codeHighlighter.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 refactor — UserMessage.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
left a comment
There was a problem hiding this comment.
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-shell→npm run buildemitsdist/transcript.jsanddist/types/transcript.d.ts;client/build-artifact.test.tspasses 15/15, including the three new transcript-entry cases.dist/index.jsis 7,021,715 bytes;dist/transcript.jsis 3,432,823, of which 2,291,874 is the inlined scoped stylesheet and 1,140,948 is JS.packages/web-templates→node src/export-html/build.mjson the6e6d98e5tree printedDocument export runtime is 8456076 byteswith 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 droppedecharts/zrenderentirely (now led by first-party 3,730,270, mermaid 2,880,314, lucide-react 1,574,512, cytoscape 1,108,285).- The dead-
echartsreasoning checks out independently: everyloadEcharts/loadEChartsreference underpackages/is either insideMarkdownChartRenderer.tsx(the optional prop and its adapter) or in that component's test file — no production call site supplies a loader, soadaptLegacyRuntimeLoaderyields 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 keepdata-qwen-web-shell="component", andgetWebShellStyleTextconcatenates 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.jsand 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.tsand the eslint node-script allowlist; the composer-tag getters moved to a dependency-freeutils/composerTag.tswith a re-export fromuseComposerCore.ts, so importers are unaffected.
What still blocks
- The package README still teaches the import this PR makes wrong.
packages/web-shell/README.mdaround line 186 — the read-only JSONL recipe — still showsimport { WebShellTranscript } from '@qwen-code/web-shell';, andgit grep 'web-shell/transcript'over that README returns nothing, whileclient/transcript.tsrequires transcript-only bundlers to use the subpath. Thread 4 measured+1,559,322 bytesfor 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 newMAX_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 elseWebShellTranscriptis introduced. - The new entry's docblock over-claims and nothing pins the gap. It advertises "no
App, no daemon providers, no editor/terminal chrome", butWebShellTranscript.tsx:33value-importstranscriptBlocksToLocalizedMessagesfromhooks/useMessages, which value-importsuseConnection/useTranscriptBlocks/useWorkspacefrom thedaemon-react-sdkbarrel (useMessages.ts:9-13). On the artifact I built at this head,dist/transcript.jsstill containsuseDaemonActions 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 inbuild-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).
|
Both blockers addressed at 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.
2. README recipe fixed. 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 On the 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 |
chiga0
left a comment
There was a problem hiding this comment.
Reviewed at head 1d94060f5b (Round 2 delta: 1837026a9d→1d94060f5b).
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 tightened — DOCUMENT_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 discriminability — build-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 clean — useMessages.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
left a comment
There was a problem hiding this comment.
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:
- Narrow
useArtifactWorkspaceTarget.ts,McpStatusMessage.tsxandTasksStatusMessage.tsxto the modules that define what they use instead of the barrel (same treatment asutils/composerTag.tsandadapters/localizedMessages.ts), keep the assertion, and the "no daemon providers" line inclient/transcript.tsbecomes true. - Or accept the residual: drop the two
not.toContainassertions, and rewrite the docblock to what this PR actually delivers — noApp, no editor/terminal chrome — while naming the daemon hook runtime as a known, measured part of the transcript entry. - 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.mdnow imports@qwen-code/web-shell/transcriptin 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.mdgained the same correction. That was the real blocker last time and it is done properly.- Per-entry CSS injection (
data-qwen-web-shell-entrywithindex/transcript, shareddata-qwen-web-shell="component"marker kept,getWebShellStyleTextconcatenating every match) is intact and still asserted. - Editor-graph guard still green: the transcript JS contains no
@codemirror/,"codemirror"orvaul. - 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.
|
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 What I got wrong. Extracting
— 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 #11100 is the home for the real fix. It also carries a question your finding surfaced that I could not answer: 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 ( |

What this PR does
Shrinks the transcript document renderer to what a read-only transcript actually needs.
The document entry imported
WebShellTranscriptfrom the@qwen-code/web-shellpackage root, whose facade also reachesApp, 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:@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;shiki/@shikijs/*to a stub in the document build —CodeBlockreturns a plain<pre>in document mode and the export CSP has no'wasm-unsafe-eval', so the Oniguruma engine could never start;utils/composerTag.ts, so a read-only surface no longer drags an editor along;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.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
mainbefore 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
Build and measure the renderer asset — since refactor!: retire @qwen-code/webui #9812 that is the number that matters, not the template:
The build prints
Document export top inputs (pre-minify bytes): …andDocument export runtime is N bytes. Expect Shiki, CodeMirror andweb-shell/dist/index.jsto be absent from the top-inputs line.Structural guard, red run: point
document-main.tsxback atimport { WebShellTranscript } from '@qwen-code/web-shell'and re-runnode packages/web-templates/src/export-html/build.mjs. It must fail naming the forbidden input and the reason, not merely exceed a byte cap.Full input breakdown, if you want it:
cd packages/web-templates EXPORT_HTML_METAFILE=/tmp/metafile.json node src/export-html/build.mjsRendering 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).Targeted tests:
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-templatesfullnpm run build.docs/verification/export-html-runtime-size/README.mdon 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.htmlraw ≈ the runtime. They have not been re-measured on the merged branch.Rendering parity of the same injected transcript, before/after — byte-identical screenshots (md5
413301f7ca3a0d889affca500a06208cfor 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):
Tested on
Environment (optional)
Node v24.19.0, npm workspaces; template build via
node src/export-html/build.mjs; rendering verified in headless Chromium (Playwright) overfile://. The merge commit6e6d98e54was made on a machine that cannot build or test this repository:node --checkand Prettier pass, everything else is CI's to confirm.Risk & Scope
DOCUMENT_RUNTIME_WARNING_BYTES = 18_500_000/MAX_DOCUMENT_RUNTIME_BYTES = 19_000_000were 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.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 now0.23.0islateston npm but predates refactor!: retire @qwen-code/webui #9812, sohttps://unpkg.com/@qwen-code/qwen-code@0.23.0/export-transcript-document.jsreturns 404 and exports built from currentmainfail closed — amain/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.CodeBlockalready answers for syntax highlighting.WebShellTranscript;@qwen-code/web-shell/transcriptis 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:@qwen-code/web-shell/transcript),只导出只读渲染器,并单独跑一次 rollup 构建,因此只携带 transcript 渲染可达的样式表;shiki/@shikijs/*解析为 stub ——CodeBlock在 document 模式下返回纯<pre>,且导出页 CSP 没有'wasm-unsafe-eval',Oniguruma 引擎本来就起不来;utils/composerTag.ts,只读表面不再拖着一个编辑器;FORBIDDEN_DOCUMENT_INPUTS结构性构建守卫(Shiki、web-shell 包根、CodeMirror 重新进入依赖图时带原因失败),以及基于 esbuild metafile 的体积预算和 top-inputs 报告。为什么需要
#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 可达。评审验证计划
如何验证
构建并测量渲染器资源——refactor!: retire @qwen-code/webui #9812 之后这才是关键数字,不是模板:
构建会打印
Document export top inputs (pre-minify bytes): …和Document export runtime is N bytes。top-inputs 里应该看不到 Shiki、CodeMirror 和web-shell/dist/index.js。结构性守卫红测试:把
document-main.tsx改回import { WebShellTranscript } from '@qwen-code/web-shell',重跑node packages/web-templates/src/export-html/build.mjs。它必须指名违规输入并给出原因地失败,而不只是超出字节上限。需要完整输入构成时:
cd packages/web-templates EXPORT_HTML_METAFILE=/tmp/metafile.json node src/export-html/build.mjs渲染一致性:打开生成的导出件,确认与
main渲染一致。注意未发布版本的渲染器资源不在 unpkg 上,需要在文档请求的那个精确 URL 上提供本地构建产物(见下方"发布顺序")。目标测试:
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完整npm run build。本分支上的
docs/verification/export-html-runtime-size/README.md是给可构建机器的完整交接说明,已按 #9812 之后的情况更新。前后证据
下列数字取自 #9812 合并之前,当时渲染器仍是内联的,所以
document.htmlraw ≈ 运行时体积。合并后的分支上尚未重新测量。同一注入内容的前后渲染一致性——截图逐字节相同(两者 md5 均为
413301f7ca3a0d889affca500a06208c)。这一点依然成立:合并改变的是渲染器的投递方式,不是它渲染什么。预算断言的红测试输出,来自合并前的分支(合并时改了文案,断言本身未变):
测试环境
环境(可选)
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 步重测并下调。在此之前起作用的是结构性守卫。package.json的版本上,所以哪个版本第一个发布这个资源,那些字节就永久冻结在 unpkg 的该版本上。本 PR 在第一个含 refactor!: retire @qwen-code/webui #9812 的版本发布之前合入,才能保证发出去的自始至终只有瘦身后的资源。 目前 npm 上的latest是0.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 校验失败。CodeBlock对语法高亮已经给出的答案是同一个问题。WebShellTranscript;@qwen-code/web-shell/transcript是增量能力。关联 Issue
Fixes #11031
相关:#10076(引入文档渲染器)、#9812(退役旧渲染器并将投递改为 unpkg)、#11091(渲染器里剩余的 mermaid/katex,需要产品决策)、#11092(重复的
react-markdown)。