fix(export): shrink the exported transcript renderer to what a read-only transcript needs - #11038
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
|
Re-run at head Template looks good ✓ — all required sections present, including the Chinese translation. Problem: observed and measured, not theoretical. #11031 is open and carries the original number (a 19,525,807-byte renderer for an empty-session export against 311,854 for the legacy one). Worth being precise about why this still matters after #9812/#11035 moved the renderer out of the file and onto unpkg: the bytes did not disappear, they became a download on a fail-closed path, so size now decides whether an exported file opens at all on a slow or restricted network rather than how big a file sits on disk. That is a stronger framing than "the HTML is big", and the description makes it explicitly rather than quietly. Direction: aligned. This is a shipping feature whose payload is 2.7x larger than it needs to be, and the fix is build-graph surgery rather than a new subsystem. Upstream CHANGELOG has no direct reference to export-renderer size, but the area is relevant — the closest signal is a fix for transcripts that had grown past 5 MB failing outright, i.e. transcript payload size is a real user-facing failure mode, not a tidiness concern. Size: no core path is touched (nothing under Approach: the scope is right, and it matches what I'd have proposed independently — narrow subpath entry for the read-only renderer, stub the heavy deps that document mode provably never executes, move the three pure getters out of the CodeMirror-importing hook, then pin all three with a structural guard so the win cannot silently regress. Cutting 80% of this would not work: the subpath entry alone leaves Shiki (~9.7 MB pre-minify) and echarts (~4.4 MB) in the graph, and those two stubs are where the actual win is. One thing I'd genuinely question, non-blocking: 309 of the 846 added lines — over a third of the diff — are Risk: no elevated risk signals — none of the 22 files match the revert-correlated paths. The two files on the live interactive path ( Moving on to code review. 🔍 中文说明在 head 模板完整 ✓ —— 所有必需章节都在,包含中文翻译。 问题: 已观测且有实测数据,不是理论性加固。#11031 处于 open 状态并带有原始数字(空会话导出携带 19,525,807 字节的渲染器,而旧版渲染器只有 311,854 字节)。需要说清楚为什么在 #9812/#11035 把渲染器从文件里挪到 unpkg 之后这件事依然重要:那些字节并没有消失,而是变成了一次位于 fail-closed 路径上的下载 —— 所以体积现在决定的是"导出的文件在慢速或受限网络下能不能打开",而不只是"磁盘上的文件有多大"。这个表述比"HTML 太大"更有力,而且描述里是明说的,不是含糊带过。 方向: 对齐。这是一个已发布功能,其载荷比实际需要大 2.7 倍,而修法是构建图层面的手术,不是新增子系统。上游 CHANGELOG 没有直接涉及导出渲染器体积,但这个领域是相关的 —— 最接近的信号是一个"transcript 超过 5 MB 后直接失败"的修复,也就是说 transcript 载荷体积是真实的用户可见故障来源,不是整洁性问题。 规模: 没有触及任何核心路径( 方案: 范围合理,也和我独立想到的做法一致 —— 为只读渲染器开一个窄子路径入口、把 document 模式可证明永不执行的重依赖打桩、把三个纯 getter 从引入 CodeMirror 的 hook 里移出来,然后用结构化守卫把这三点都钉住,使成果不会静默回归。砍掉其中 80% 是行不通的:只有子路径入口的话,Shiki(预压缩约 9.7 MB)和 echarts(约 4.4 MB)仍留在图里,而真正的收益正是来自这两个桩。 有一点我确实想质疑,但不阻断:846 行新增里有 309 行 —— 超过 diff 的三分之一 —— 是 风险: 无升级风险信号 —— 22 个文件都不匹配与 revert 相关的路径。位于交互式实时路径上的两个文件( 进入代码审查 🔍 — 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 reviewI wrote my independent proposal before opening the diff, and it landed on the same four moves: a narrow subpath entry for the read-only renderer, stubs for the heavy deps document mode provably never executes, the three pure getters lifted out of the CodeMirror-importing hook, and a structural guard so the win cannot silently regress. So my judgement here is mostly about whether the premises hold, because the whole PR rests on three of them. I checked each against the source rather than the description, and all three hold:
Two more things I checked because they are the kind of gap that survives a green suite:
The guard is not decoration — I traced whether it runs anywhere. Root The budget is not an invented convention either. Both prior blocking findings are resolved at this head, and I checked them against the code rather than the thread state. The Critical about the inaccurate "loading both entries is a no-op" comment is gone — the replacement states the real failure mode (first-wins suppression) plus the cascade-order caveat that makes it non-trivial. And the red The last blocker is genuinely fixed and pinned. No Critical findings. Two non-blocking observations:
Files changed (22 of 22 shown)
TestingThis is an unattended CI run ( Every check that ran is green: 17 success, 69 skipped, 0 failure. The only two non-terminal entries are
Suite totals from the same log: 1,014 test files / 28,679 tests passed (90 skipped) in the main run, and 643 files / 23,524 passed in the CLI run, with no failures anywhere. The interesting evidence is not the pass/fail column, though — it is that this PR's own build prints its measurements, and they ran on this exact commit. From the Three things this settles independently of anyone's local machine:
One red-looking line in the log is pre-existing and not this PR's: Not verified, and why it matters: rendered-output parity of an actual exported file. I confirmed statically that Shiki, echarts and CodeMirror are unreachable in document mode, and CI proves the bytes are gone, but no green check opens a generated export and compares it against Sandboxed verification would settle this: Real-scenario tmux testing: N/A — this is an unattended CI run, so live behaviour comes from the lane above rather than from anything I drove locally. 中文说明代码审查。 我在打开 diff 之前先写了自己的独立方案,结论落在同样的四步上:为只读渲染器开窄子路径入口、给 document 模式可证明永不执行的重依赖打桩、把三个纯 getter 从引入 CodeMirror 的 hook 中提出来、再加结构化守卫防止成果静默回归。所以我的判断主要集中在前提是否成立 —— 整个 PR 依赖三个前提,我逐个对照源码而不是描述来验证,三个都成立:
另外两点是我专门查的,因为它们是"绿测试也能漏掉"的那类缺口:
守卫不是摆设 —— 我追了它到底会不会运行。 根 预算也不是凭空发明的约定。 两条此前的阻断性意见在当前 head 已解决,而且我是对照代码确认的,不是看线程状态。 关于"加载两个入口是 no-op"这句不准确注释的 Critical 已经消失 —— 替换后的注释写出了真实的失效方式(先到先得的抑制)以及让它不平庸的层叠顺序注意事项。而红掉的 最后一个阻断项是真修好了并且被钉住了。 无 Critical。两条非阻断观察:
测试。 这是无人值守的 CI 运行( 所有实际运行的检查都是绿的:17 success、69 skipped、0 failure。唯一的两个未终结项是 真正有意思的证据不在通过/失败那一列,而在于本 PR 的构建自己会打印测量值,并且它们正是跑在这个提交上:document 运行时 7,275,173 字节,top-inputs 中没有 shiki、codemirror、echarts、zrender、vaul; 日志里有一行看起来像红的,但是既有问题、与本 PR 无关: 未验证,以及为什么重要: 真实导出文件的渲染一致性。我从静态上确认了 Shiki、echarts、CodeMirror 在 document 模式不可达,CI 也证明字节确实消失了,但没有任何绿色检查会打开一个生成的导出文件并与 沙箱验证可以定这件事: 真实场景 tmux 测试:N/A —— 这是无人值守的 CI 运行,实时行为信号来自上面那条通道,而不是我在本地驱动的任何东西。 — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
|
Confidence: 4/5 — every premise this PR rests on checks out against the source, CI measured the win on this exact commit, and both prior blockers are fixed the honest way; what keeps it off 5/5 is a comment in the diff that contradicts another comment in the same diff, plus one claim no green check can reach. First, a correction to my own last pass. At My independent proposal — narrow subpath entry, stubs for what document mode provably never executes, lift the three getters out of the CodeMirror hook, then pin it structurally — is what this PR does. Where it exceeds my proposal is in the two places I would probably have under-built:
Six months from now I would thank the author rather than curse them. The stubs each carry the reasoning that makes them safe to delete, including the instruction to remove the plugin rather than make the stub work; the budget comment records both measurements and their provenance (whose machine, which commit); the eslint line extends an existing override instead of carving out an exception. The third vite pass — the piece that looks like over-engineering on a skim, and the one I probed hardest last time — costs 2.93s for 299 modules against 583 for the index run, which also answers the hand-off doc's "flag it if this exceeds ~90s". The narrow module count is itself evidence the entry is a real subset. The coordination question I raised last time is settled: #11035 has merged, so there is no longer a second open PR claiming the same P1, and this one is the only track left on #11031. What I would still like, none of it blocking this merge:
What I am not claiming. Rendered-output parity of an actual exported file. I verified statically that Shiki is unreachable in document mode (the highlight effect returns early and the render branch starts with On whether I am being worn down: this is one PR through many rounds, not a volume problem, and I re-derived the premises from the source and the CI log rather than accepting the thread's account of them. The re-run earned its keep — it corrected my own stale 8% figure, produced the measured third-pass cost, established that the guard actually runs in CI, and caught the contradictory docblock. Approving. 中文说明Confidence: 4/5 —— 本 PR 依赖的每个前提都对照源码验证成立,CI 在这个确切提交上实测了收益,两个此前的阻断项都以诚实的方式修好了;让它没到 5/5 的,是 diff 里一句与同一 diff 中另一句相互矛盾的注释,外加一个绿色检查无法触及的声明。 首先,修正我自己上一轮的判断。在 我的独立方案——窄子路径入口、给 document 模式可证明永不执行的部分打桩、把三个 getter 从 CodeMirror hook 中提出来、再做结构化钉住——正是本 PR 所做的。它超出我方案的地方,恰好是我自己很可能会做不足的两处:
六个月后我会感谢作者而不是骂他。两个桩都带着"为什么可以安全删除"的推理,包括"应当删掉插件而不是把桩做通"的指示;预算注释同时记录了两个测量值及其来源(谁的机器、哪个提交);eslint 那一行扩展了已有的覆盖块而不是新开例外。第三次 vite 构建——粗看最像过度设计、也是我上次推敲最久的部分——代价是 2.93s、299 个模块,而 index 那次是 583 个;这同时回答了交接文档里"若超过 ~90s 请说明"的疑问。模块数收窄本身就是该入口确为子集的证据。 我上次提的协调问题已经解决:#11035 已合并,所以不再有第二个 open PR 声称同一个 P1,本 PR 是 #11031 上唯一剩下的线。 我仍然希望看到的(都不阻断本次合并):
我没有声称的部分。 真实导出文件的渲染一致性。我从静态上验证了 Shiki 在 document 模式不可达(高亮 effect 提前返回,渲染分支以 关于我是否被磨软了:这是一个 PR 走了很多轮,不是数量问题;而且我是从源码和 CI 日志重新推导前提,而不是接受讨论串对它们的转述。这次重跑是有价值的——它修正了我自己过期的 8% 数字、给出了第三次构建的实测代价、确认了守卫确实会在 CI 中运行,并抓到了那句自相矛盾的 docblock。批准。 — 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)
|
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).
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 ( |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Partially reviewed — gaps disclosed.
Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.
Not explored to full depth (tool budget reached): "agent 8a": I did not measure the wall-clock cost of the third vite pass (the verification doc asks for it); I only confirmed it produces the expected artifact.; "agent 8a": I did not run the red-run mutation for finding 3 (removing an entry from rollupOptions.external and rebuilding) — the claim is derived from the emitted artifa…; "agent 8a": I could not settle whether the transcript stylesheet's rule *order* is a strict subsequence of the index stylesheet's; my } -based rule splitter is too naive f…; chunk 4: none — no check was cut short.; "agent 1b": I did not resolve whether extracting adapters/localizedMessages.ts is *inert* — components/artifacts/SubagentDetail.tsx:11 imports useMessagesFromBlocks f….
Not reviewed: reverse audit — stopped before round 4 by the review time budget.
Test Plan (not a blocker): src/export-html/build.mjs — no such file or directory; 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.
中文说明
仅完成部分审查,审查缺口已披露。
未审查(原文为英文):build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.
未探索到全部深度(达到工具调用预算):"agent 8a":I did not measure the wall-clock cost of the third vite pass (the verification doc asks for it); I only confirmed it produces the expected artifact.;"agent 8a":I did not run the red-run mutation for finding 3 (removing an entry from rollupOptions.external and rebuilding) — the claim is derived from the emitted artifa…;"agent 8a":I could not settle whether the transcript stylesheet's rule *order* is a strict subsequence of the index stylesheet's; my } -based rule splitter is too naive f…;chunk 4:none — no check was cut short.;"agent 1b":I did not resolve whether extracting adapters/localizedMessages.ts is *inert* — components/artifacts/SubagentDetail.tsx:11 imports useMessagesFromBlocks f…。
未审查:反向审计——评审时间预算不足,未能开始第 4 轮。
Test Plan(非阻断):src/export-html/build.mjs — no such file or directory; 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。
— qwen3.8-max via Qwen Code /review (v0.23.0)
|
Resolved the remaining runtime blocker in The transcript entry now imports the shared Tailwind stylesheet before KaTeX, so KaTeX's equal-specificity Verified locally:
The older CodeMirror, per-entry CSS-key, transcript artifact, and README-import threads are outdated and already addressed by the current head. The 15 new non-blocking documentation, diagnostic, and test-hardening suggestions are recorded individually in #11142 rather than expanding this PR after more than five review/fix rounds. |
|
@qwen-code /triage |
|
Sandboxed verification: ❌ not passed — findings reported (agent verdict) - workflow run Ran the PR in an isolated, token-free container: A/B against the base build, mock-free harness assertions, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check. Scripted assertions: 53 passed · 0 failed · 53 total Flakiness gate: ✅ 1 changed test file(s) x 5 identical rounds, no divergence 中文 — 判定:❌ 不通过 · 报告了发现(agent 判定)沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查。 脚本断言:53 通过 · 0 失败 · 53 总计 抖动门:✅ 1 changed test file(s) x 5 identical rounds, no divergence Verification reportPR #11038 deep verification —
|
| arm | source | export-transcript-document.js raw |
gzip-9 (zlib) | oracle |
|---|---|---|---|---|
| base | HEAD^1 1b604721, package-root import |
19,521,158 | 4,747,889 | build exit 0 |
| head | PR 08b6fa55, transcript subpath + stubs |
7,272,168 | 2,392,777 | build exit 0 |
| Δ | −12,248,990 (−62.7%, 2.68×) | −2,355,112 (−49.6%) |
Corroboration: issue #11031 reported 19,525,807 bytes for the pre-#9812 inlined renderer. This independently-built base arm lands at 19,521,158 — within −0.02%. The head build reproduces the 7,275,173 runtime bytes recorded in build.mjs's own comment exactly, and is byte-stable across three separate builds (CI's, mine, and a third tree's — identical sha256).
Decomposition — what each lever actually bought
The PR bundles four levers; a two-cell A/B cannot say what each did, so I built every intermediate with identical esbuild options, varying one lever at a time (decompose.mjs).
| cell | levers present | jsBytes | Δ vs prev | forbidden inputs hit |
|---|---|---|---|---|
| V0 | none (≈ base) | 19,521,624 | — | shiki 305, root facade 1, echarts 594, codemirror 9 |
| V1 | (a) subpath + (d) composerTag | 17,754,194 | −1,767,430 | shiki 305, echarts 594 |
| V2 | + (b) shiki stub | 8,453,500 | −9,300,694 | echarts 594 |
| V3 | + (c) echarts stub = head | 7,272,597 | −1,180,903 | none |
| V4 | head with (d) reverted | 7,480,614 | +208,017 | codemirror 8 |
Harness calibration (so these cells are comparable to the real builds): V0 vs the real base build Δ 466 bytes; V3 vs the real head build Δ 429 bytes — both attributable to the virtual-entry indirection and build-id string length.
Attribution: the Shiki stub is 76% of the saving. Lever (a) alone is ≈1,559,413 bytes (V1's −1,767,430 minus lever (d)'s 208,017). Lever (d) is small in bytes but load-bearing twice over: V4's 7,480,614 exceeds the 7,400,000 hard cap, and it trips the structural guard (red run B).
Every residual is accounted for. The root dist/index.js grew +164 bytes at head (7,021,638 → 7,021,802): that is the per-entry data-qwen-web-shell-entry attribute plus the querySelectorAll-and-join rewrite in shadowDom.ts. The document's own inline CSS is ~3 KB in both arms (document-styles.css, unchanged).
The stylesheet subset claim holds
vite.lib.config.ts says the separate rollup run means the transcript entry "only carries the CSS reachable from the read-only transcript renderer". I tested that rather than trusting it (probe-css-subset.mjs):
| rules | injected CSS bytes | |
|---|---|---|
dist/index.js |
4,672 | 3,152,806 |
dist/transcript.js |
2,332 | 2,289,195 (72.6%) |
Transcript-only rules: 0 — it is a strict subset, so the claim is true and the 863,611-byte reduction is real. CodeMirror (.cm-editor) and terminal (.xterm) selectors are present in the root stylesheet and absent from the transcript one.
Corrections to the PR description
These are corrections to the text, not requests to change code.
- The "Open item — the byte budget is stale" no longer applies. The description says
DOCUMENT_RUNTIME_WARNING_BYTES = 18_500_000/MAX_DOCUMENT_RUNTIME_BYTES = 19_000_000and asks a reviewer to "re-measure with step 1 and lower both before merge". At the verified head those constants are already 7,300,000 / 7,400,000, and the measured 7,275,173 sits under both. The open item is closed; the description is describing an earlier commit. - "CodeMirror ~1 MB" is a pre-minify metafile figure, not a contribution to the shipped asset. The composition note is accurate as a metafile reading of the root build, but lever (d)'s effect on the delivered bytes is 208,017 (V4 − V3), i.e. 1.7% of the 12.25 MB total. Worth stating because it is the lever a reader would otherwise expect to be second-largest; in fact it is the smallest, and its value is structural (it is what makes the guard and the cap hold) rather than volumetric.
- The Evidence table is explicitly pre-refactor!: retire @qwen-code/webui #9812 and the description says so — confirmed. Its "After" row (17,966,485) matches my V1 cell (17,754,194) in shape: both are "subpath, before the Shiki/CodeMirror follow-ups". The table is honest about being unrefreshed; the correct current number is 7,272,168.
- A test comment names a selector that is not in the built CSS.
build-artifact.test.tsjustifies stripping the CSS line before thenot.toContainchecks by saying the stylesheet "carries e.g. drawer.tsx'sdata-[vaul-drawer-direction=…]selectors". The substringvaulis present in both stylesheets, but that attribute selector form is in neither (inIndex: false, inTranscript: false). The underlying point survives — Tailwind v4 does scan beyond the entry graph, which I confirmed independently in Finding 1 — but the cited example does not. Also note@codemirror/and"codemirror"appear in neither stylesheet, so thereplace()strip is defensive rather than load-bearing for those two assertions today; it is load-bearing for keeping the size bound honest.
Findings
1. Suggestion — the byte ratchet is coupled to Tailwind's directory-wide source scan, so unrelated UI work can fail the export build
globals.css carries no @source directive, so Tailwind v4 uses automatic source detection and scans every non-gitignored file under the project — not just the modules the transcript entry imports. The cap therefore measures all of packages/web-shell/client/, while the comment above it tells the next maintainer to re-measure "after any change to the document entry's dependencies". Those are different sets.
Measured with ratchet-probe.mjs (scratch component dropped into the mutant tree, nothing imports it, transcript entry rebuilt, file removed afterwards):
| novel utility classes in an unreferenced file | transcript CSS growth |
|---|---|
| 3 | 736 B |
| 30 | 7,360 B |
| 150 | 34,350 B |
Linear at 229 bytes per class; removing the file restored the stylesheet byte-identically (2,289,195), so the measurement is clean.
Against the measured runtime of 7,275,173:
- headroom to
DOCUMENT_RUNTIME_WARNING_BYTES(7,300,000): 24,827 B = 0.34% → 109 novel utilities - headroom to
MAX_DOCUMENT_RUNTIME_BYTES(7,400,000): 124,827 B = 1.72% → 546 novel utilities, at which pointnode src/export-html/build.mjsthrows and/export htmlstops building
Reproduce:
cd tmp/mutant-tree/packages/web-shell # or any scratch copy at head
node <artifact-dir>/ratchet-probe.mjsNot a correctness defect and not blocking: 546 novel arbitrary-value utilities is a lot for one PR, and arbitrary values are the worst case. But the growth is cumulative and unbounded, and the failure lands on whoever happens to cross the line in an unrelated change, as a build error in a different package. Two cheap options: add an explicit @source scoping the transcript build to what it actually renders, or split the cap so the graph-scoped part ratchets tightly while the scanned-utility part warns only. See 04-ratchet-fragility-tailwind-scan.png.
2. Suggestion — shadowDom.ts concatenation is untested and costs 2,289,195 duplicated bytes for a both-entries host
getWebShellStyleText changed from querySelector(...) to querySelectorAll(...) + join('\n'). No test in the PR exercises it: the new injects its stylesheet under its own entry key case asserts only the emitted markup (data-qwen-web-shell-entry="…", s.dataset.qwenWebShell="component"), never the reader. Deleting the join and reverting to first-match would leave all 17 tests green.
The change is correct — I verified the hazard it guards is real: with per-entry keys, a host importing both entries gets two tags and the concatenation preserves the editor/dialog rules. But note the cost, since the comment frames the overlap as harmless: because the transcript stylesheet is a strict subset of the root one, a both-entries host now carries 3,152,806 + 2,289,195 = 5,442,001 bytes of shadow-root style text where 3,152,806 would do — 2,289,195 bytes of exact duplication. Per the repo's own review rules a missing test for changed behaviour is a Suggestion, not a Critical; the fixture that would pin it is one that installs two style tags and asserts the returned text contains a rule unique to each.
3. Note — the KaTeX commit fixes a bug this PR introduced, not a pre-existing one
Worth recording because it changes how the final commit should be read. Rule-order probe across five bundles (18-katex-order-probe.log, 03-katex-cascade-order-per-entry.png):
| bundle | rules | preflight idx | katex idx | order |
|---|---|---|---|---|
base dist/index.js |
4,750 | 2 | 4,520 | OK |
head dist/index.js |
4,750 | 2 | 4,520 | OK |
head dist/transcript.js |
2,367 | 2 | 939 | OK |
mutant dist/index.js (order reverted) |
4,750 | 2 | 4,520 | OK |
mutant dist/transcript.js (order reverted) |
2,367 | 233 | 1 | BROKEN |
Reverting the import order leaves the root entry correct — in that graph globals.css is reached early through other modules, so its internal position in WebShellTranscript.tsx does not decide anything. Only the transcript entry, where WebShellTranscript.tsx is the root import, is order-sensitive. So base never had this bug; the new entry created it and the final commit closes it. That is why base and head render byte-identically and only the mutant differs.
The fix is browser-observable. Walking all 147 .katex descendants in real Chromium (katex-probe.mjs): 576 computed declarations flip from oklch(0.985 0 0) (currentColor) to oklch(1 0 0 / 0.1) (var(--border)) under the broken order. Visually it surfaces as exactly 5 pixels at y=311 in the parity fixture — the \frac{1}{3} fraction bar, the only KaTeX element that actually draws a border. My coarse 9-element style probe reported zero deltas here; the pixel oracle caught what it missed, which is why the screenshot identity carries the parity verdict and the style probe does not.
4. Note — one pre-existing, load-correlated test flake, not caused by this PR
packages/cli export-transcript-document.test.ts > bounds repeated-separator checks in decoded URL authorities timed out at 15,000 ms on the first combined run. Attribution:
- the PR touches 0 files under
packages/cli(git diff --stat HEAD^1..HEAD -- packages/cliis empty) - the test file's blob is byte-identical base vs head (
7847222dc5e2dbf3e5f022e191a19abdea893077both sides), as is the source under test - it passes 3/3 when run in isolation, and the combined pair passed 75/75 on re-run
The mechanism is a nested budget: the test spawnSyncs a tsx child with a 20 s internal timeout inside a 15 s vitest timeout, so on a loaded shared runner the child's startup alone can outrun the outer limit. Pre-existing and environmental; recorded so it is not mistaken for a regression.
Rendering parity (secondary claim)
parity.mjs drives the same envelope through both arms in real Chromium, faithful to the delivery model: each arm's document.html requests its version-pinned unpkg URL under integrity="sha384-…", which I intercept and fulfil with that arm's own bytes — so SRI genuinely validates (asserted per arm, sriMatches: true), and the renderer fetch is counted (intercepted=1).
Fixture: 5 blocks from export-transcript-document-v1.schema.json covering inline and display KaTeX (including \frac), a ts fence (the Shiki-stub plain-<pre> path), a mermaid fence, a table, a blockquote, a link, a list, a tool block and a thought block. Positive controls assert the surfaces actually rendered before any parity claim is made — 3 .katex, 2 pre, 1 mermaid svg, 1 table, 1 blockquote in both arms.
24/24 assertions passed. See 05-rendered-export-head-1280x2000.png and 06-rendered-export-base-byte-identical-to-05.png.
| oracle | base | head | identical |
|---|---|---|---|
| full-page screenshot md5 | 76193622db093ba7755cce78583927cf (90,980 B) |
76193622db093ba7755cce78583927cf (90,980 B) |
byte-identical |
| transcript DOM md5 | dcab9039b3c8baad780e95a9cf762ca7 (25,432 ch) |
same | identical |
| visible text md5 | 9bfd916e67e754eecc9c8a15fe73dc66 |
same | identical |
| computed styles, 9 probe elements | — | — | 0 deltas |
renderComplete |
true |
true |
— |
| page errors / failed requests / console errors | 0 / 0 / 0 | 0 / 0 / 0 | — |
The one census delta is styleTagEntries: [] on base, ["transcript"] on head — precisely this PR's per-entry key, expected and benign.
This is the load-bearing result of the round. Dropping 2,340 CSS rules and 12.2 MB of JS changed nothing on screen: none of the dropped rules matches anything a transcript renders. The PR's own parity claim ("byte-identical screenshots") was made pre-#9812 and explicitly not re-measured; it now holds again on the merged branch, at the pixel level, against a much broader fixture than a screenshot comparison alone.
Guard and mutation matrix
All four FORBIDDEN_DOCUMENT_INPUTS rules driven red through the production build.mjs, not a copy (02-guard-red-runs-and-mutation-matrix.png):
| run | mutation | exit | forbidden inputs | names the reason? |
|---|---|---|---|---|
| A | package root restored (test-plan step 2) | 1 | 10 | yes — both the CodeMirror and root-facade why strings |
| B | composerTag lever reverted (M1 tree) | 1 | 8 | yes |
| C | shiki onResolve removed |
1 | 305 | yes |
| D | echarts onResolve removed |
1 | 594 | yes |
Test-plan step 2's exact requirement — "must fail naming the forbidden input and the reason, not merely exceed a byte cap" — is met: the throw fires before the size check, and prints input paths plus the why.
Mutation matrix on build-artifact.test.ts (17 tests), one lever reverted per row. Each mutant lands in the same file as the assertions it kills, and each fails the intended assertion with expected-versus-actual values, not an import or compile break:
| mut | mutation | kills | failing assertion |
|---|---|---|---|
| M1 | UserMessage.tsx → HEAD^1, CodeMirror reachable |
does not pull the editor stack into the transcript entry | expected '…' not to contain '@codemirror/' |
| M2 | CSS import order swapped in WebShellTranscript.tsx (one line) |
keeps KaTeX border overrides after Tailwind preflight | expected 1 to be greater than 233 |
| M3 | per-entry CSS key → shared key in vite.lib.config.ts (two lines) |
injects its stylesheet under its own entry key | expected '…' to contain 'data-qwen-web-shell-entry="index"' |
3/3 killed. These are also the positive controls: the suite demonstrably goes red under my harness, so the unmutated 17/17 green means something. Unmutated control: 17/17 on head, 34/34 across both gated files.
Survivors, classified:
keeps the transcript entry a fraction of the interactive entry(js.length < 1_300_000) — survived M1, which grows the JS remainder to 1,150,400 chars, still 11.5% under the bound. Not vacuous by construction (M4-style mutations that make the entry re-export the root would kill it), but I did not run one, so I am reporting it as unexercised rather than claiming it is pinned. Coverage note, not a defect.still carries what a transcript actually renders(toContain('react-markdown'),toContain('WebShellTranscript')) — a presence check on strings that survive minification; no mutation in this PR's scope would remove them without breaking the entry entirely. Redundant defence alongside the size bound.
Gates
| gate | command | result |
|---|---|---|
| web-shell tests | npx vitest run client/build-artifact.test.ts client/index.test.tsx |
34/34 passed (2 files) |
| cli export tests | npx vitest run src/ui/utils/export/formatters/html.test.ts src/ui/utils/export/export-transcript-document.test.ts |
75/75 passed on re-run (first run: 74/75, see Finding 4) |
| web-shell typecheck | npx tsc -p tsconfig.json --noEmit |
exit 0 |
| typecheck gate proven live | planted export const x: number = "s" in the mutant tree |
caught — client/transcript.ts(36,14): error TS2322, exit 2; restored → exit 0 |
Determinism evidence, so the byte comparisons are trustworthy: rebuilding head's index.js and transcript.js produced sha256 identical to CI's output (ba167c8dfd7e7e56, c011421e03eaa069); a third tree reset to head reproduced c011421e03eaa069 and 7,275,173 bytes; the base document build produced 19,521,158 bytes on both of its runs. Toolchains matched exactly across arms (vite 5.4.21, node v22.23.2, esbuild 0.25.6).
Not covered
- Per-commit attribution. The snapshot lists 13 commits; the shallow merge-ref checkout reaches exactly 1 (
git rev-list HEAD^1..HEAD^2→08b6fa55,--is-shallow-repository→true). I verified the aggregateHEAD^1..HEADdiff only. Note the naive count returns a plausible1rather than erroring, so this is stated explicitly rather than inferred. - Playwright e2e and visual-regression suites (
packages/web-shell/client/e2e/) — the description puts these out of scope; I did not run them. My parity harness is a purpose-built probe, not the repo's suite. - CSP behaviour and the unpkg delivery model itself. I asserted the SRI hash matches the served bytes and that
script-src/connect-srcdid not block the render, but did not test nonce handling, CDN availability, or the fail-closed path when the asset 404s. The description's release-ordering concern (which version first freezes these bytes on unpkg) is a publishing question I cannot reach without network access, and I made no attempt. - Real CDN fetch. Both arms were served from intercepted local bytes at the exact pinned URL. No request reached unpkg.
- Reproducing the shape, not the cause, of fix(export): stop embedding the Web Shell runtime in every HTML file #11031. My base arm reproduces the byte count the issue reported (within 0.02%) by building it, which is stronger than replaying a captured artifact — but I did not reproduce the user-facing symptom (an actual
/export htmlinvocation from a live session), since that needs a real session and model calls. - Other packages' test suites,
npm run lint,prettier --check, and the repo-widenpm run preflight. I ran only the affected workspaces. I deliberately did not run barenode scripts/lint.js, which invokesprettier --write .and would have rewritten the working tree under the A/B. - A real
/export htmlrun end to end through the CLI. I built the renderer asset and the document template directly and drove them in a browser, which covers the changed surface but not the CLI's export command wiring (untouched by this PR). - Windows and macOS. Linux only; the description marks the other two as unverified too.
- No previous-report.md was present, so this is a first round with no carried-forward findings.
Methodology
Environment: the CI verify container (node:22-bookworm), working tree at refs/pull/11038/merge, npm ci and npm run build already complete at head. Controls ran in scratch git worktrees under tmp/ — tmp/base-tree at HEAD^1 and tmp/mutant-tree at the merge commit — each with a mirrored node_modules (1,040 top-level + 453 scoped symlinks). Internal workspace links were asserted by realpath before any control was trusted, which caught a live trap: the first mutant-tree run silently resolved @qwen-code/web-shell back into the head tree and reported head's byte count as the mutant's. @qwen-code/sdk is shared with the head tree, justified by git ls-tree hash equality for packages/sdk-typescript (8fdc4434864d1b83 on both sides) and an empty diff for that path; the root package-lock.json is byte-identical base vs head, so reusing the installed tree is a clean control rather than a dependency confound. Base's nested packages/web-shell/node_modules (Tailwind v4.3.2, against a root v3.4.18) is shared for the same reason. Harnesses: decompose.mjs (per-lever esbuild cells using head's literal options, patterns and stub files), probe-css-subset.mjs (postcss rule-set comparison), parity.mjs and katex-probe.mjs (real Chromium via Playwright at /__w/_temp/pw-browsers, loopback origin, route-intercepted renderer URL so SRI runs for real), ratchet-probe.mjs (Tailwind scan ladder), tables.mjs (re-prints every table from the live artifacts and raw logs). Raw per-cell stdout/stderr, build logs and both metafiles are in logs/; the four captures in evidence/ were produced with scripts/verify-capture.mjs. Both scratch worktrees were removed at the end of the round.
Incident, disclosed. A shell-precedence error of mine (A || cd base-tree && git checkout HEAD^1 -- <path> — the cd never ran because A used -C) executed that checkout in the main worktree and briefly reverted head's packages/web-templates/src/export-html/build.mjs to base's blob 47712bad. It was caught by a git status audit before the round closed, and the tree was restored with git checkout HEAD --; head's build.mjs, index and working file are now all blob 8461cdae, git status --porcelain is empty, and git diff HEAD --stat reports nothing. No measurement was affected, proven three ways: (i) the asset every head-side number came from is timestamped 06:43:53, before the corruption at 06:56, and was produced by a build that printed head-only output (the top inputs line and the 7,275,173 runtime line, neither of which exists in base's build.mjs); (ii) re-running the restored build.mjs reproduced it byte-identically — sha256 c6a4cf784cdd07a24c1aaa123397d322e1e1429df36eda3666e8915333835b61, 7,272,168 bytes, cmp -s identical to the snapshot the A/B and parity harnesses consumed; (iii) the head-only stub files were never touched (mtimes 06:36, CI's). Nothing in this report rests on the corrupted interval.
Flakiness gate log
rounds=5 files=1 skipped=0
file packages/web-shell/client/build-artifact.test.ts: (cd packages/web-shell) npx --no-install vitest run ./client/build-artifact.test.ts
per-file results (P=pass F=fail I=infra-exit, one letter per run):
packages/web-shell/client/build-artifact.test.ts: PPPPP
verdict: pass
summary: 1 changed test file(s) x 5 identical rounds, no divergence
--- per-invocation detail (full copy in the artifact) ---
round 1 · packages/web-shell/client/build-artifact.test.ts: P (exit 0)
round 2 · packages/web-shell/client/build-artifact.test.ts: P (exit 0)
round 3 · packages/web-shell/client/build-artifact.test.ts: P (exit 0)
round 4 · packages/web-shell/client/build-artifact.test.ts: P (exit 0)
round 5 · packages/web-shell/client/build-artifact.test.ts: P (exit 0)
Evidence images
Harness scripts and raw logs are in the workflow run artifacts (7-day retention).
— Qwen Code · sandboxed verification
qwen-code-dev-bot
left a comment
There was a problem hiding this comment.
Approved at head 08b6fa55.
The blocker from my last round is fixed the honest way. Rather than keep an assertion that measured nothing, client/transcript.ts now states the residual outright — "this is not free of the daemon React runtime … their provider guards are in dist/transcript.js. Decoupling them is #11100" — and the artifact guard bounds the payload instead of pretending the graph is clean, with its own comment spelling out why absence would be a false claim. keeps the transcript entry a fraction of the interactive entry (build-artifact.test.ts) is a real, checkable property.
Verified by building this head locally rather than reading the diff:
$ cd packages/web-shell && npm run build && npx vitest run --config vitest.config.ts client/build-artifact.test.ts
✓ build-artifact.test.ts (17 tests) 350ms
Test Files 1 passed (1) | Tests 17 passed (17)
$ cd packages/web-templates && node src/export-html/build.mjs
Document export runtime is 7275173 bytes
so the test that was red at 1d94060f is gone, and the remaining 16 still pass. The @qwen-code/web-shell/daemon-react-sdk barrel is no longer reached through hooks/useMessages either — adapters/localizedMessages.ts is genuinely hook-free, useMessages.ts re-exports for existing callers, and the leftover unused adapter import was dropped.
ci-bot's round-2 Critical (R2-1, the KaTeX cascade) is fixed and I measured it, not just the comment. The import pair in WebShellTranscript.tsx:1-2 is swapped so preflight precedes KaTeX in the transcript entry as it does in the package root, and the false "injecting both is a no-op beyond the duplicated bytes" note in vite.lib.config.ts is replaced with the correct statement that equal-specificity declarations still depend on order. In the two stylesheets I built, the preflight rule that carries border-color:var(--border) sits at ~1.2 KB into both sheets while .katex *{ … border-color:currentColor } is at 3,054,937 of 3,152,802 bytes in dist/index.js and 1,757,058 of 2,289,195 in dist/transcript.js — KaTeX last in both, so the exported document and the app now break the tie the same way. keeps KaTeX border overrides after Tailwind preflight pins this by parsing both bundles, which is the important part: it cannot pass while one entry drifts.
Also re-checked from the earlier round and still holding: the README read-only recipe imports @qwen-code/web-shell/transcript with the note explaining why the root must not be used; per-entry injection keys (index/transcript) with the shared data-qwen-web-shell="component" marker and the concatenate-every-match reader; editor-graph absence (@codemirror/, "codemirror", vaul); and the export build's forbidden-input rules with zero Shiki/CodeMirror/echarts/zrender inputs. All 20 threads are resolved and mergeable is MERGEABLE.
CI facts. On this head Lint & Static (15m16s), Test (ubuntu-latest) (18m11s), Integration Tests (no-AK), web-shell E2E Smoke, Capture web-shell visuals (17m27s) and both Desktop Shell lanes are green; only the review-pr lane was still queued. The page still shows CHANGES_REQUESTED from ci-bot's review on the earlier f6c23c51, which a @qwen-code /triage re-run will clear.
Two things I am noting rather than asking for:
adapters/localizedMessages.ts's docblock still ends with "client/build-artifact.test.tsasserts thatdist/transcript.jscarries no daemon provider code" — that is no longer true of the suite (it asserts a size bound and says so), and the paragraph above it still reads as if the extraction drained the provider stack. One sentence to correct, here or in the #11100 pass; it is the kind of line that sends the next reader looking for a guard that isn't there.- The hazard the PR now documents is worth running before anything else, and #11100 already asks for it:
TasksStatusMessage.tsx:353callsuseActions()unconditionally whiledocument-main.tsxmounts no daemon provider (I checked — the document entry contains no provider reference at all), so either those blocks never reach a document and the daemon runtime indist/transcript.jsis pure dead weight, or an exported transcript containing one throws during render. That check decides whether this is cleanup or a user-visible bug fix, so it should lead the #11100 work.
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship. ✅
Verified at this head rather than taken from the thread: the Shiki, echarts and CodeMirror premises all hold against the source; the forbidden-input guard and byte cap really do run in CI (root prepare on npm ci → workspace build → packages/web-templates/build.mjs spawns the export build and rejects on a non-zero exit, so a throw reds the required Test and Lint & Static jobs); and CI on this commit prints Document export runtime is 7275173 bytes with no shiki, @shikijs, codemirror, echarts, zrender or vaul among the top inputs — a 62.7% reduction (2.68x) against the 19,523,259 baseline. The third vite pass costs 2.93s for 299 modules.
Both prior blockers are fixed, and fixed the honest way: the false "no-op" comment is replaced with the real failure mode and the cascade-order caveat, and when the "no daemon providers" assertion proved false it was replaced with a size bound on the entry plus a docblock naming the daemon hook runtime as a known residual with #11100 as its home — rather than deleting the test and keeping the claim. The KaTeX cascade fix is pinned by a test that parses both emitted stylesheets, so it cannot pass while one entry drifts.
Non-blocking, all named in the triage notes above:
packages/web-shell/client/adapters/localizedMessages.tsstill ends its docblock claiming the suite asserts thatdist/transcript.js"carries no daemon provider code". That contradictsclient/transcript.tsin this same diff and is not what the test measures — one sentence, here or in the #11100 pass.docs/verification/export-html-runtime-size/README.md(309 lines, over a third of the diff) is now stale against its own head: superseded commits named as under test,?rows CI has filled, and a §6 asking for constants that are already set. Trim to the results or drop it.- The budget leaves 1.7% headroom (~125 KB) on a build that runs inside every
npm ci, so the next dependency bump that grows this entry reds CI for an unrelated PR. Deliberate and precedented — just worth #11091 knowing. - CSS is now ~2.29 MB of the transcript entry's 3.43 MB, and Tailwind v4 scans every source file rather than the entry's graph, so the separate rollup run scopes CSS Modules but not the utility layer. A bigger remaining lever than the daemon residual.
Not covered by this approval: rendered-output parity of an actual exported file, and the #11100 question of whether an export containing an MCP-status or tasks-status block throws at render (TasksStatusMessage.tsx:353 calls useActions() unconditionally while the document entry mounts no provider). No green check opens a generated export and compares it to main. A /verify run is in flight on this head and should answer both; its report, not this approval, is the place to read that answer.
— Qwen Code · qwen3.8-max-2026-09-02
…wenLM#11167) * fix(export): point documents at a published renderer, drop mermaid Three export-side fixes that share one file and one budget. **QwenLM#11096 — every export built from main fails closed.** Since QwenLM#9812 an exported file loads its renderer from unpkg by version and refuses to render unless the envelope identity matches the asset it loaded; both derive from the root package.json version. npm `latest` is 0.23.0, published *before* QwenLM#9812 added the asset, so that URL 404s and every export built from source renders the error page. Publishing fixes the steady state. Until then `build.mjs` accepts QWEN_EXPORT_RENDERER_IDENTITY and QWEN_EXPORT_RENDERER_INTEGRITY — set together, validated, describing one already-published asset — and CI sets them to the `preview` tag so its exports open. The asset built here keeps its own true identity; only the generated document points elsewhere. Delete the CI env once a release containing QwenLM#9812 is on npm. **QwenLM#11091 — mermaid leaves the document bundle.** A ```mermaid fence in an export now renders as a plain <pre> holding its own source, the same degradation document mode already applies to syntax highlighting. `mermaid` resolves to a stub in the document build and is added to FORBIDDEN_DOCUMENT_INPUTS. Since QwenLM#9812 the renderer is a download on first open rather than bytes already on disk, and mermaid plus its graph dependencies were the largest remaining input at ~6 MB pre-minify — paid by every reader whether or not the transcript has a diagram. IIFE output cannot code-split, so the existing lazy `import()` was flattened in; exclusion is the lever, as it was for Shiki and ECharts. Math is deliberately kept: rehype-katex is a tenth of the size, and inline math degrades to raw delimiters inside prose rather than to a block that was already a block. The render limits inside MermaidBlock now bind to every non-interactive mode instead of going dead with document mode: they were never about the export format, they are about rendering a transcript the viewer did not author and cannot interrupt, which is equally true of readonly replay. **QwenLM#11092 — one react-markdown major.** @datafe-open/markdown-chart-react pulled a nested react-markdown@10 beside the hoisted 9.1.0. Its only use of the library is `createElement(ReactMarkdown, { components }, source)`, and web-shell never imports the component holding that call, so an override to ^9 collapses the two. The two byte constants in build.mjs are deliberately left at their pre-mermaid values: lowering them without a measurement risks failing every build. Neither build nor typecheck was run here — the handoff in docs/verification/export-renderer-delegation-mermaid/README.md asks for the measurement and the ratchet first. `Markdown.mermaid.test.ts` was run (4 passed); everything else is CI's to confirm. QwenLM#11142 stays separate on purpose: three of its items rewrite the QwenLM#11038 verification document with measured numbers, and this change moves those numbers. Fixes QwenLM#11096 Fixes QwenLM#11091 Fixes QwenLM#11092 * chore(vscode): regenerate NOTICES.txt after the react-markdown dedupe The override collapsing react-markdown onto ^9 removes the nested 10.1.0 copy from the tree, so the companion's dependency notices no longer list it. Matches the 1 insertion / 28 deletions the Lint & Static gate reported. Regenerated with npm ci --ignore-scripts followed by npm run generate:notices --workspace=qwen-code-vscode-ide-companion; the dependency total is unchanged at 655. * fix(export): teach the browser gate the document it actually ships Addresses B2, S1 and S3 from the review on QwenLM#11167. **B2 — the gate broke two independent ways.** (a) It asserted that an exported document renders a Mermaid diagram, which is exactly the behaviour this PR removes. It now asserts the opposite and pins the replacement: no mermaidInline node, and the fence's own source present as text. KaTeX stays asserted, because math is deliberately kept. (b) The gate has no network — it fulfils the renderer request itself with the locally built asset — while under delegation the document pins the SRI of an already-published one. Those bytes cannot match by construction, so Chromium would block the script and every success case would time out. The gate now re-pins the document to the bytes it actually serves, so the integrity check still runs for real rather than being defeated in either direction. On a non-delegated build the rewrite is a no-op, since the document already carries that hash. It throws if the attribute ever stops being there, so the rewrite cannot silently become a way of skipping SRI. The fail-closed case is deliberately left alone: it wants a mismatch. The incompatible-envelope case is re-pinned, because the renderer has to load for the envelope check to be the thing that fails. What this still does not cover, and cannot offline: that the delegated hash matches the published asset. That belongs to the release smoke test QwenLM#11096 asks for, and is now stated in both the gate and the verification brief. **S1 — the byte budget had ~3.2 MB of slack.** This PR's own Lint & Static lane printed with mermaid, @mermaid-js/parser, cytoscape and lodash-es all gone from the top inputs — 7,275,173 -> 4,083,810, -44%. The constants left at 7.3/7.4 MB would have let the entire mermaid family return unnoticed, which is the one thing that guard exists to prevent. Ratcheted to 4,100,000 / 4,200,000, and the verification brief's measurement section is rewritten around the numbers instead of asking for them. **S3 — the mermaid guard was narrower than the precedent it cites.** The echarts rule names the package and its heavy transitive graph; the mermaid rule named only the package, leaving @mermaid-js/parser and cytoscape unasserted. With S1's slack gone they would now trip the budget, but the structural guard should say so directly: the pattern matches the echarts shape. Not run here: the browser gate needs a built web-shell and web-templates, which this machine cannot produce. The assertion changes are CI's to confirm. * fix(export): stop delegating the renderer in CI The web-shell E2E Smoke lane failed on the previous commit with "expected 'error' to be 'true'" on both success cases, while the incompatible-envelope case kept passing. That is the whole diagnosis: the renderer loaded (the re-pinned SRI was fine) and then rejected the envelope. Under delegation the envelope announces the delegated identity, while the asset actually running in the page is this build's own and announces its own — document-main.tsx compares the two and fails closed on exactly that mismatch. Re-pinning the integrity, which the previous commit did, fixes the SRI half and cannot fix this one. The conclusion is that the delegation should never have been wired into CI. The only lane that opens an exported document is the transcript browser gate, and it fulfils the renderer request itself from this build's dist/ — it never reaches the CDN, so delegating there buys nothing and breaks the one place that would have caught it. The knob stays in build.mjs, documented, for a human who needs a source build's exports to open before the release lands; that is where it was always useful. So: the env block comes out of ci.yml, and the gate's integrity re-pin comes out with it, since a non-delegated build already carries the hash of the bytes the gate serves. What stays from the previous commit is the part that is a real product change — the gate no longer asserts that an exported document renders a Mermaid diagram, and instead pins the plain-<pre> degradation. Not run here: the gate needs a built web-shell and web-templates, which this machine cannot produce.
|
Released in v0.23.1. |







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)。