Browse Source

feat(client): lazy-load PDF and terminal runtimes

imccyu 1 month ago
parent
commit
a5c224f9b1
43 changed files with 354 additions and 97 deletions
  1. 2 2
      .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml
  2. 3 3
      .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md
  3. 3 3
      .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md
  4. 2 2
      docs/subsystems/client-modules.i18n.yaml
  5. 2 2
      docs/subsystems/client-modules.md
  6. 2 2
      docs/subsystems/client-modules.zh.md
  7. 2 2
      packages/client/modules/README.i18n.yaml
  8. 2 2
      packages/client/modules/README.md
  9. 2 2
      packages/client/modules/README.zh.md
  10. 8 5
      packages/client/modules/src/client/manifest.ts
  11. 78 14
      packages/client/modules/src/client/system.ts
  12. 59 18
      packages/client/modules/src/index.ts
  13. 32 0
      packages/client/modules/tests/loader.client.spec.ts
  14. 31 0
      packages/client/modules/tests/node-half.client.spec.ts
  15. 11 4
      packages/client/tsdown.client.ts
  16. 2 2
      packages/client/ui-sidebar-documentpreview/README.i18n.yaml
  17. 1 1
      packages/client/ui-sidebar-documentpreview/README.md
  18. 1 1
      packages/client/ui-sidebar-documentpreview/README.zh.md
  19. 1 0
      packages/client/ui-sidebar-documentpreview/package.json
  20. 14 0
      packages/client/ui-sidebar-documentpreview/src/client/pdf/LazyPdfBody.tsx
  21. 14 0
      packages/client/ui-sidebar-documentpreview/src/client/pdf/PdfBody.module.css
  22. 3 2
      packages/client/ui-sidebar-documentpreview/src/client/pdf/index.ts
  23. 9 6
      packages/client/ui-sidebar-documentpreview/src/client/pdf/pdf.tsx
  24. 2 2
      packages/client/ui-sidebar-documentpreview/tests/apply.client.spec.ts
  25. 1 1
      packages/client/ui-sidebar-documentpreview/tests/pdf-body.client.spec.tsx
  26. 9 3
      packages/client/ui-sidebar-documentpreview/tests/pdf-license-bundle.client.spec.ts
  27. 3 3
      packages/client/ui-sidebar-documentpreview/tests/pdf-registration.client.spec.ts
  28. 3 2
      packages/client/ui-sidebar-documentpreview/tsdown.config.ts
  29. 2 2
      packages/client/ui-sidebar-terminal/README.i18n.yaml
  30. 1 1
      packages/client/ui-sidebar-terminal/README.md
  31. 1 1
      packages/client/ui-sidebar-terminal/README.zh.md
  32. 1 0
      packages/client/ui-sidebar-terminal/package.json
  33. 14 0
      packages/client/ui-sidebar-terminal/src/client/LazyTerminalBody.tsx
  34. 2 0
      packages/client/ui-sidebar-terminal/src/client/TerminalTitle.module.css
  35. 1 1
      packages/client/ui-sidebar-terminal/src/client/TerminalTitle.tsx
  36. 2 2
      packages/client/ui-sidebar-terminal/src/client/index.ts
  37. 0 2
      packages/client/ui-sidebar-terminal/src/client/terminal.module.css
  38. 1 1
      packages/client/ui-sidebar-terminal/src/client/terminal.tsx
  39. 2 2
      packages/client/ui-sidebar-terminal/tests/apply.client.spec.ts
  40. 18 0
      packages/client/ui-sidebar-terminal/tests/bundle-split.client.spec.ts
  41. 1 1
      packages/client/ui-sidebar-terminal/tests/terminal-body.client.spec.tsx
  42. 4 0
      scripts/client-build-environment.client.spec.ts
  43. 2 0
      scripts/client-build-environment.ts

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md
-2026-07-23-client-plugin-loading-model.md: e9dc734c05eb5a73a7c8ef531bd3042548e0f06e
-2026-07-23-client-plugin-loading-model.zh.md: f4df6c0a108bd2b99756764df089fd3c7f58839f
+2026-07-23-client-plugin-loading-model.md: 95194eeffeeb9ca1232bda2d5323133eaed8721f
+2026-07-23-client-plugin-loading-model.zh.md: 1f8ef5039cd897c2924b98bde70a2bca34fa4458

+ 3 - 3
.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md

@@ -40,11 +40,11 @@ The vendored Loader consumes the module system through its `internal` contract 
 
 ### Combo external-script arrival and source maps
 
-The Host snapshots every built plugin bundle and partitions each scheduling phase's ordered rows into one or more same-origin classic scripts. It greedily fills each group while the longer map-form request URL remains within 3 KiB, preserving graph order and allowing another request instead of emitting an oversized URL. Each script is addressed by its package resources, for example `/plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>`. The `bootstrap` and `application` values are scheduling phases in the graph, not URL components: HTML preloads every application URL before executing every parser-blocking bootstrap URL. The module system keys in-flight transport by combo URL, so concurrent row arrivals within one group execute one script. Successful settlement still requires each requested row's factory id to exist in the module table, and registration does not run the factory, so the side-effect boundary remains first materialization.
+The Host snapshots every built plugin entry and the sibling chunks recursively referenced by generated relative `require()` calls, then partitions each scheduling phase's ordered entry rows into one or more same-origin classic scripts. It greedily fills each group while the longer map-form request URL remains within 3 KiB, preserving graph order and allowing another request instead of emitting an oversized URL. Each startup script is addressed by its package entries, for example `/plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>`; sibling chunks use `/plugins/<package>/<chunk>?rev=<rev>` and share the package revision. The `bootstrap` and `application` values are scheduling phases in the graph, not URL components: HTML preloads every application URL before executing every parser-blocking bootstrap URL. The module system keys in-flight transport by URL, so concurrent entry or chunk arrivals execute one script. Successful settlement still requires each requested factory id to exist in the module table, and registration does not run the factory, so the side-effect boundary remains first materialization.
 
 The shared tsdown preset emits `client.js.map` for every plugin and rewrites first-party source paths into the browser-resolvable repository form `/packages/<group>/<package>/src/...`. The production Client pass consumes `lib/types`; the preset supplies each tsc map to Rolldown and fills `sourcesContent` from the original files, so the final map reaches TypeScript/TSX instead of stopping at emitted JavaScript. Other workspace sources inlined into a bundle likewise resolve to their `packages/` owner, while dependency paths remain unchanged. Combo generation strips each local debug directive, records its generated-line offset, resolves every authored source against the original per-plugin map URL, and emits an Indexed Source Map v3. An authored map supplies its section; otherwise an identity section embeds the generated bundle and uses the packer's `sourceURL` as its source name when present. The absolute map URL mirrors the script resource list by changing every `client.js` suffix to `client.js.map`, so `/plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>` points to `/plugins/??<package-a>/client.js.map,<package-b>/client.js.map&rev=<rev>`. One resource follows the same rule and still produces an indexed map with one section. The Vite shell also emits source maps, letting shell code and combo-loaded plugins map stacks and performance profiles back to TypeScript/TSX.
 
-The graph retains each row's revisioned one-resource combo URL for HMR and adds a revisioned descriptor for every startup combo request; several descriptors may carry the same scheduling phase. Initial row revisions are opaque process nonces rather than content hashes; they keep the snapshotted one-resource response immutable without hashing every plugin at startup. After the watcher observes one artifact change, `rebuilt(id)` hashes only that bundle and publishes the resulting revision. Startup combo revisions derive from the ordered row revisions. Script bodies are assembled on their first `GET`; source-map files are read and combined separately on their first map `GET`. `HEAD` materializes neither body. Versioned scripts and maps use immutable caching. The Host serves only exact generated URLs; stale revisions and unadvertised resource lists return 404 instead of aliasing different bytes. An external script's `error` event exposes neither response status nor body, so failure diagnostics name only the URL; the same-origin Host and build-stamped registration id form the identity boundary, while the post-`load` factory-presence check rejects an artifact that did not register the expected id.
+The graph retains each row's revisioned one-resource combo URL for HMR and adds a content-addressed descriptor for every startup combo request; several descriptors may carry the same scheduling phase. Initial row revisions are opaque process nonces rather than content hashes; they keep the snapshotted entry and sibling responses immutable without hashing every plugin at startup. After the watcher observes one artifact change, `rebuilt(id)` hashes that package's entry and referenced chunk bytes and publishes the resulting revision. Startup combo revisions cover the combined entry inputs and indexed map. Script bodies are assembled on their first `GET`; source-map files are read and combined separately on their first map `GET`. `HEAD` materializes neither body. Versioned scripts and maps use immutable caching. The Host serves only exact generated URLs; stale revisions and unadvertised resources return 404 instead of aliasing different bytes. An external script's `error` event exposes neither response status nor body, so failure diagnostics name only the URL; the same-origin Host and build-stamped registration id form the identity boundary, while the post-`load` factory-presence check rejects an artifact that did not register the expected id.
 
 ### The loading flow, end to end
 
@@ -78,7 +78,7 @@ The Host SSE adapter forwards existing graph-change notifications and sends the
 
 Hot reload is a composition decision: the web bundle mounts the `client-hmr` row (a normal plugin package) unconditionally; its node half brings the bundle watch and the SSE channel, and the chain stays idle until a rebuild watcher rewrites client bundles. A composition that must not expose it disables the row.
 
-How does a rebuilt bundle become a reload signal? The hmr node half observes it itself — no builder tells it. Before reading each startup snapshot, the module host captures the bundle stat baseline and exposes it through `ctx.clientModules.artifactBaseline(id)`. One HMR-owned interval compares every current graph row with that baseline. An unchanged row starts watching without a content read or hash; a write after baseline capture is already a stat delta and only that row enters `rebuilt(id)`. This avoids both an initial all-row re-hash and `fs.watchFile`, whose asynchronous first baseline can silently absorb a construction-time rebuild. Watch membership follows `onGraphChanged`; vanished rows drop out, and a bundle missing at poll time keeps its row dirty so reappearance forces a re-hash even with identical metadata. On a bundle mtime or size delta, or a dirty row, `rebuilt(id)` is the single re-hash entry point; it hashes only executable bundle bytes, so a map-only write does not remount unchanged executable code. When the `rev` actually changed, the node half broadcasts a `rebuilt` frame on `GET /plugins/events` — a system SSE channel that sends the full graph on connect and `rebuilt` frames on change, presentation-only wire that never enters the session log. Polling is deliberate because inotify does not fire on the weka network mount, the same reason the build-side watcher needs `--poll`; one bundle stat per row and interval is sufficient, the interval is a validated config field (default 500ms), and disposal clears the one timer. Rebuilding artifacts is any tsdown watch process's business — `scripts/dev-web.ts` remains the watch-build entry point, discovering its package list through `dsh.client` while scanning `packages/*/*/package.json` at startup — and builder and host share zero protocol. A torn bundle read self-heals: its stats keep changing while the write completes, so the next poll re-hashes and broadcasts the final rev.
+How does a rebuilt bundle become a reload signal? The hmr node half observes it itself — no builder tells it. Before reading each startup snapshot, the module host captures the entry stat baseline and exposes it through `ctx.clientModules.artifactBaseline(id)`. One HMR-owned interval compares every current graph row with that baseline. An unchanged row starts watching without a content read or hash; a write after baseline capture is already a stat delta and only that row enters `rebuilt(id)`. This avoids both an initial all-row re-hash and `fs.watchFile`, whose asynchronous first baseline can silently absorb a construction-time rebuild. Watch membership follows `onGraphChanged`; vanished rows drop out, and a missing entry keeps its row dirty so reappearance forces a re-hash even with identical metadata. On an entry mtime or size delta, or a dirty row, `rebuilt(id)` is the single re-hash entry point; it re-snapshots the entry and referenced chunks and hashes all executable bytes, while a map-only write does not remount unchanged code. When the `rev` actually changed, the node half broadcasts a `rebuilt` frame on `GET /plugins/events` — a system SSE channel that sends the full graph on connect and `rebuilt` frames on change, presentation-only wire that never enters the session log. Polling is deliberate because inotify does not fire on the weka network mount, the same reason the build-side watcher needs `--poll`; the interval is a validated config field (default 500ms), and disposal clears the one timer. Rebuilding artifacts is any tsdown watch process's business — `scripts/dev-web.ts` remains the watch-build entry point, discovering its package list through `dsh.client` while scanning `packages/*/*/package.json` at startup — and builder and host share zero protocol.
 
 On the browser side, the transport delegates code replacement to the same modules controller that owns graph reconciliation:
 

+ 3 - 3
.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md

@@ -40,11 +40,11 @@ vendored Loader 经其 `internal` 约定消费模块系统——唯一调用点
 
 ### Combo 外部脚本到达与源码映射
 
-Host 会快照每个已构建插件 bundle,并把每个调度阶段的有序 row 划入一个或多个同源 classic script。它在更长的 map 形式请求 URL 保持在 3 KiB 以内时贪心填充每组,既保留 graph 顺序,也以增加请求代替超长 URL。每个脚本都由其中的 package 资源寻址,例如 `/plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>`。`bootstrap` 与 `application` 是图中的调度阶段,不是 URL 组成部分:HTML 先预加载所有 application URL,再执行所有阻塞 parser 的 bootstrap URL。模块系统按 combo URL 复用进行中的传输,因此同组 row 的并发到达只执行一个脚本。成功结算仍要求模块表中已经存在被请求 row 的 factory id;登记不会运行 factory,所以副作用边界依然是首次物化。
+Host 会快照每个已构建插件 entry 及生成的相对 `require()` 递归引用的 sibling chunk,再把每个调度阶段的有序 entry row 划入一个或多个同源 classic script。它在更长的 map 形式请求 URL 保持在 3 KiB 以内时贪心填充每组,既保留 graph 顺序,也以增加请求代替超长 URL。启动脚本由其中的 package entry 寻址,例如 `/plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>`;sibling chunk 使用 `/plugins/<package>/<chunk>?rev=<rev>`,并与该包共用 revision。`bootstrap` 与 `application` 是图中的调度阶段,不是 URL 组成部分:HTML 先预加载所有 application URL,再执行所有阻塞 parser 的 bootstrap URL。模块系统按 URL 复用进行中的传输,因此并发 entry 或 chunk 到达只执行一个脚本。成功结算仍要求模块表中已经存在被请求的 factory id;登记不会运行 factory,所以副作用边界依然是首次物化。
 
 共享 tsdown 预设为每个插件产出 `client.js.map`,并把第一方源码路径重写成浏览器可识别的仓库形式 `/packages/<group>/<package>/src/...`。生产 Client 构建会消费 `lib/types`;预设把每份 tsc map 交给 Rolldown,并从原文件补齐 `sourcesContent`,使最终 map 回到 TypeScript/TSX,而不是停在编译后的 JavaScript。内联进 bundle 的其他 workspace 源码同样回到其 `packages/` 归属,依赖包路径保持原样。Combo 生成会移除每个局部调试指令、记录其生成行偏移、以原插件 map URL 解析每个自带 source,再产出 Indexed Source Map v3。插件有自带 map 时直接用于对应 section;没有时则生成 identity section,内嵌构建后 bundle,并在存在时把 packer 写入的 `sourceURL` 用作 source 名。绝对 map URL 会平行改写脚本资源列表中的每个 `client.js` 后缀,因此 `/plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>` 指向 `/plugins/??<package-a>/client.js.map,<package-b>/client.js.map&rev=<rev>`。单资源也采用相同规则,仍产出只有一个 section 的 indexed map。Vite 壳同样产出 sourcemap,使壳代码与经 combo 加载的插件都能从 stack 和性能 profile 回到 TypeScript/TSX。
 
-图为 HMR 保留每个 row 带 revision 的单资源 combo URL,并为每个启动 combo 请求增加带 revision 的描述;多条描述可以使用同一调度阶段。初始 row revision 是进程级不透明 nonce,而不是内容哈希;它无需在启动时哈希每个插件,也能保证已快照的单资源响应不可变。watcher 观察到某个产物变化后,`rebuilt(id)` 只哈希该 bundle,并发布所得 revision。启动 combo revision 从有序 row revision 派生。脚本 body 在首次 `GET` 时组合;source map 文件在首次 map `GET` 时单独读取并组合。`HEAD` 不会物化任一 body。版本化脚本与 map 使用 immutable 缓存。Host 只提供精确生成的 URL;陈旧 revision 与未发布资源列表返回 404,不会别名到其他字节。外部脚本的 `error` 事件不给响应状态与正文,因此失败诊断只报告 URL;同源 Host 与构建期写入的 registration id 是身份边界,`load` 后的 factory 存在性检查负责拒绝未登记预期 id 的产物。
+图为 HMR 保留每个 row 带 revision 的单资源 combo URL,并为每个启动 combo 请求增加按内容寻址的描述;多条描述可以使用同一调度阶段。初始 row revision 是进程级不透明 nonce,而不是内容哈希;它无需在启动时哈希每个插件,也能保证已快照的 entry 与 sibling 响应不可变。watcher 观察到某个产物变化后,`rebuilt(id)` 哈希该包的 entry 与引用的 chunk 字节,并发布所得 revision。启动 combo revision 覆盖合并后的 entry 输入与 indexed map。脚本 body 在首次 `GET` 时组合;source map 文件在首次 map `GET` 时单独读取并组合。`HEAD` 不会物化任一 body。版本化脚本与 map 使用 immutable 缓存。Host 只提供精确生成的 URL;陈旧 revision 与未发布资源返回 404,不会别名到其他字节。外部脚本的 `error` 事件不给响应状态与正文,因此失败诊断只报告 URL;同源 Host 与构建期写入的 registration id 是身份边界,`load` 后的 factory 存在性检查负责拒绝未登记预期 id 的产物。
 
 ### 装载流程,端到端
 
@@ -78,7 +78,7 @@ Host SSE 适配器转发现有图变化通知,并在连接时发送当前完
 
 热重载是一项组合决策:web 组合包无条件挂载 `client-hmr` 行(一个常规的插件包),其 node 半带来 bundle 监视与 SSE(Server-Sent Events)通道;没有重建 watcher 改写客户端 bundle 时链路保持空闲。不应暴露它的组合可以禁用该行。
 
-重建好的 bundle 怎么变成重载信号?hmr 的 node 半自己观察——没有构建器来通知它。模块 host 在读取每份启动快照前捕获 bundle 的 stat 基线,并通过 `ctx.clientModules.artifactBaseline(id)` 暴露它。HMR 自持的单个定时器把当前图的每个 row 与这份基线比较:未变化的 row 直接开始监视,不读取内容也不求哈希;基线捕获后的写入已经形成 stat 差异,只有该 row 会进入 `rebuilt(id)`。这同时消除了启动期的全量重哈希,并避开 `fs.watchFile` 以异步首次 stat 建立基线、可能静默吸收构造期重建的问题。监视集合的成员随 `onGraphChanged` 更新;消失的 row 撤下监视,轮询时缺失的 bundle 则让对应 row 保持标脏状态,文件重现时即使元数据相同也强制重哈希。Bundle 的 mtime 或 size 变化,或 row 处于标脏状态时,`rebuilt(id)` 是重哈希的唯一入口;它只哈希可执行 bundle 字节,所以仅写入 map 不会重新挂载未变化的可执行代码。`rev` 真正变化时,node 半才在 `GET /plugins/events` 上广播 `rebuilt` 帧——这是一条系统级 SSE 通道,连接即发全量图,变更时发 `rebuilt` 帧,仅供呈现的 wire,永不进会话日志。轮询是刻意选择:inotify 在 weka 网络挂载上不触发,构建侧监视器需要 `--poll` 也是同一原因;每个 row 每个间隔只需一次 bundle stat,轮询间隔是一个经校验的配置字段(默认 500ms),dispose(资源释放)会清掉那一个定时器。重建产物是任意一个 tsdown watch 进程的事——`scripts/dev-web.ts` 仍作为 watch 构建入口保留,其包清单在启动时扫描 `packages/*/*/package.json` 按 dsh.client 发现——构建器与 host 共享零协议。写一半的 bundle 被撕裂读取会自愈:写入完成期间 stat 持续变化,下一个轮询节拍会再次重哈希并广播最终的 rev。
+重建好的 bundle 怎么变成重载信号?hmr 的 node 半自己观察——没有构建器来通知它。模块 host 在读取每份启动快照前捕获 entry 的 stat 基线,并通过 `ctx.clientModules.artifactBaseline(id)` 暴露它。HMR 自持的单个定时器把当前图的每个 row 与这份基线比较:未变化的 row 直接开始监视,不读取内容也不求哈希;基线捕获后的写入已经形成 stat 差异,只有该 row 会进入 `rebuilt(id)`。这同时消除了启动期的全量重哈希,并避开 `fs.watchFile` 以异步首次 stat 建立基线、可能静默吸收构造期重建的问题。监视集合的成员随 `onGraphChanged` 更新;消失的 row 撤下监视,轮询时缺失的 entry 则让对应 row 保持标脏状态,文件重现时即使元数据相同也强制重哈希。Entry 的 mtime 或 size 变化,或 row 处于标脏状态时,`rebuilt(id)` 是重哈希的唯一入口;它重新快照 entry 与引用的 chunk,并哈希全部可执行字节,而仅写入 map 不会重新挂载未变化的代码。`rev` 真正变化时,node 半才在 `GET /plugins/events` 上广播 `rebuilt` 帧——这是一条系统级 SSE 通道,连接即发全量图,变更时发 `rebuilt` 帧,仅供呈现的 wire,永不进会话日志。轮询是刻意选择:inotify 在 weka 网络挂载上不触发,构建侧监视器需要 `--poll` 也是同一原因;轮询间隔是一个经校验的配置字段(默认 500ms),dispose(资源释放)会清掉那一个定时器。重建产物是任意一个 tsdown watch 进程的事——`scripts/dev-web.ts` 仍作为 watch 构建入口保留,其包清单在启动时扫描 `packages/*/*/package.json` 按 dsh.client 发现——构建器与 host 共享零协议。
 
 浏览器侧的传输把代码替换交给负责图对账的同一个 modules 控制器:
 

+ 2 - 2
docs/subsystems/client-modules.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/client-modules.md
-client-modules.md: aff74a1da1ce524689d1a746a7267eb1544e4fef
-client-modules.zh.md: 8100708edd88d35f123d658de42525f04a90ebd6
+client-modules.md: 7855cecd8e2802e9ad25f3ee05f01f286bc21c1b
+client-modules.zh.md: 19f3fb4dfb3b112700df879325525b4ff9e23850

+ 2 - 2
docs/subsystems/client-modules.md

@@ -82,7 +82,7 @@ Package metadata — including the negative "not a client package" verdict — i
 
 ## The bundle route and index injection
 
-`GET`/`HEAD /plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>` addresses one generated combo script; a one-resource request uses the same form and is the HMR path. The script is concatenated once on its first `GET` and ends with an absolute `sourceMappingURL` whose resource suffixes are `.js.map`. The map files are not read by startup, index rendering, script `GET`, or `HEAD`; the first map `GET` reads and validates them, composes one Indexed Source Map v3, and caches that body. An authored component map supplies its section; a component without one receives an identity section whose `sourcesContent` is the captured bundle and whose source name is its packaged `sourceURL` or plugin route. Every startup request URL is at most 3 KiB measured as UTF-8 bytes; partitioning uses the longer map form. All application URLs are preloaded, and all bootstrap URLs execute before the graph global and Vite entry. Materialized responses use long-lived immutable caching. Unknown or altered resource lists, missing revisions, and stale revisions answer 404 rather than serving different bytes or letting the SPA fallback return HTML as JavaScript; other methods are 405. The injection rows carry the current graph on every index render, so a reload always boots against the live composition.
+`GET`/`HEAD /plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>` addresses one generated combo script; a one-resource request uses the same form and is the HMR path. A package-local tsdown chunk uses `/plugins/<package>/<chunk>?rev=<rev>` and shares the entry revision. The Host snapshots chunks recursively from generated relative `require()` calls, and the browser loads each chunk only when its dynamic import executes. Each script is built once on its first `GET` and ends with an absolute `sourceMappingURL`. The map files are not read by startup, index rendering, script `GET`, or `HEAD`; the first map `GET` reads and validates them, composes one Indexed Source Map v3, and caches that body. An authored component map supplies its section; a component without one receives an identity section whose `sourcesContent` is the captured artifact and whose source name is its packaged `sourceURL` or plugin route. Every startup request URL is at most 3 KiB measured as UTF-8 bytes; partitioning uses the longer map form. All application URLs are preloaded, and all bootstrap URLs execute before the graph global and Vite entry. Materialized responses use long-lived immutable caching. Unknown or altered resource lists, missing revisions, and stale revisions answer 404 rather than serving different bytes or letting the SPA fallback return HTML as JavaScript; other methods are 405. The injection rows carry the current graph on every index render, so a reload always boots against the live composition.
 
 ## The service
 
@@ -98,7 +98,7 @@ interface ClientArtifactBaseline {
 }
 ```
 
-`ClientModuleRegistry` (`ctx.clientModules`, defined in [`packages/client/modules/src/index.ts`](../../packages/client/modules/src/index.ts)) exposes reads and the rebuild face; signatures are in the generated [service catalog](#ctxclientmodules--clientmoduleregistry). `graph()` returns the current composed graph (a stable object between changes), `clientPath(id)` returns the bundle's absolute path, and `artifactBaseline(id)` returns the bundle stat values captured before the current snapshot was read. `fetchBundle()` resolves the same lazy response used by the HTTP route. `rebuilt(id)` is the only entry point through which changed bundle content reaches the graph: it re-hashes the bundle bytes, and only a real revision change recomposes the graph and notifies. `onRebuilt` fires per changed bundle with the new revision; `onGraphChanged` fires after any flush that recomposed the graph (row added or removed, or a rebuilt revision change) and is pull-model — listeners re-read `graph()`. Both notification paths contain listener exceptions so one throwing subscriber cannot skip later subscribers or kill whatever triggered the flush.
+`ClientModuleRegistry` (`ctx.clientModules`, defined in [`packages/client/modules/src/index.ts`](../../packages/client/modules/src/index.ts)) exposes reads and the rebuild face; signatures are in the generated [service catalog](#ctxclientmodules--clientmoduleregistry). `graph()` returns the current composed graph (a stable object between changes), `clientPath(id)` returns the entry bundle's absolute path, and `artifactBaseline(id)` returns the entry stat values captured before the current snapshot was read. `fetchBundle()` resolves the same lazy response used by the HTTP route. `rebuilt(id)` is the only entry point through which changed executable artifacts reach the graph: it re-snapshots the entry and referenced chunks, then only a real revision change recomposes the graph and notifies. `onRebuilt` fires per changed package with the new revision; `onGraphChanged` fires after any flush that recomposed the graph (row added or removed, or a rebuilt revision change) and is pull-model — listeners re-read `graph()`. Both notification paths contain listener exceptions so one throwing subscriber cannot skip later subscribers or kill whatever triggered the flush.
 
 [`dsh-client-hmr`](../../packages/client/hmr/README.md) delivers live graph snapshots in the shipped Web composition. The Host forwards existing graph-change notifications immediately, and reconnect sends the current full graph. A graph describes desired browser entries without asserting that Host cleanup has completed. Its artifact poll separately reports rebuilt revisions. Source-map changes alone do not trigger a reload; a new combo-map URL appears only after a bundle revision changes, and each map body is fixed by its first `GET`. Client Modules validates snapshots and serializes reconciliation with those rebuilds; it owns the boot-created entry map, single-resource arrivals, asynchronous removal, unused-module/style cleanup and page-local retry status. Static platform modules and the bootstrap retain their page lifetime; Electron installation is a separate flow.
 

+ 2 - 2
docs/subsystems/client-modules.zh.md

@@ -82,7 +82,7 @@ interface WebBootGraph {
 
 ## bundle 路由与 index 注入
 
-`GET`/`HEAD /plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>` 寻址一份生成的 combo 脚本;单资源请求采用同一形式,也是 HMR 路径。脚本在首次 `GET` 时只拼接一次,并以绝对 `sourceMappingURL` 结尾,其中每个资源后缀改为 `.js.map`。启动、index 渲染、脚本 `GET` 和 `HEAD` 都不会读取 map 文件;首次 map `GET` 才会读取并校验这些文件、组合一份 Indexed Source Map v3,并缓存该 body。组件有自带 map 时直接用于对应 section;没有时则获得 identity section,其 `sourcesContent` 是捕获的 bundle,source 名取打包后的 `sourceURL` 或插件路由。每条启动请求 URL 按 UTF-8 字节计算都不超过 3 KiB;切分按更长的 map 形式计算。所有 application URL 都会预加载,所有 bootstrap URL 都会在图全局量与 Vite entry 之前执行。已物化响应使用长期 immutable 缓存。未知或被修改的资源列表、缺少 revision 及陈旧 revision 都返回 404,绝不提供其他字节,也不会让 SPA fallback 把 HTML 当作 JavaScript 返回;其他方法返回 405。注入行在每次 index 渲染时携带当前图,因此重新加载总是基于实时组合启动。
+`GET`/`HEAD /plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>` 寻址一份生成的 combo 脚本;单资源请求采用同一形式,也是 HMR 路径。包内 tsdown chunk 使用 `/plugins/<package>/<chunk>?rev=<rev>`,并与 entry 共用 revision。Host 从生成的相对 `require()` 递归快照 chunk,浏览器仅在对应 dynamic import 执行时加载该 chunk。每份脚本在首次 `GET` 时只构建一次,并以绝对 `sourceMappingURL` 结尾。启动、index 渲染、脚本 `GET` 和 `HEAD` 都不会读取 map 文件;首次 map `GET` 才会读取并校验这些文件、组合一份 Indexed Source Map v3,并缓存该 body。组件有自带 map 时直接用于对应 section;没有时则获得 identity section,其 `sourcesContent` 是捕获的产物,source 名取打包后的 `sourceURL` 或插件路由。每条启动请求 URL 按 UTF-8 字节计算都不超过 3 KiB;切分按更长的 map 形式计算。所有 application URL 都会预加载,所有 bootstrap URL 都会在图全局量与 Vite entry 之前执行。已物化响应使用长期 immutable 缓存。未知或被修改的资源列表、缺少 revision 及陈旧 revision 都返回 404,绝不提供其他字节,也不会让 SPA fallback 把 HTML 当作 JavaScript 返回;其他方法返回 405。注入行在每次 index 渲染时携带当前图,因此重新加载总是基于实时组合启动。
 
 ## 服务
 
@@ -98,7 +98,7 @@ interface ClientArtifactBaseline {
 }
 ```
 
-`ClientModuleRegistry`(`ctx.clientModules`,定义于 [`packages/client/modules/src/index.ts`](../../packages/client/modules/src/index.ts))暴露读取面与重建面;签名见生成的[服务目录](#ctxclientmodules--clientmoduleregistry)。`graph()` 返回当前组合出的图(两次变更之间是同一个稳定对象),`clientPath(id)` 返回 bundle 的绝对路径,`artifactBaseline(id)` 返回读取当前快照前捕获的 bundle stat 值。`fetchBundle()` 解析 HTTP 路由所使用的同一份惰性响应。`rebuilt(id)` 是变化后的 bundle 内容到达图的唯一入口:它重新哈希 bundle 字节,只有 revision 真正变化才会重新组合图并发出通知。`onRebuilt` 按发生变化的 bundle 逐个触发并携带新 revision;`onGraphChanged` 在任何一次重新组合了图的 flush 之后触发(行的增删,或 rebuilt 带来的 revision 变化),并采用拉取模型——监听器自行重读 `graph()`。两条通知路径都会兜住监听器异常,因此一个抛错的订阅者既不能让后续订阅者被跳过,也不能杀死触发这次 flush 的一方。
+`ClientModuleRegistry`(`ctx.clientModules`,定义于 [`packages/client/modules/src/index.ts`](../../packages/client/modules/src/index.ts))暴露读取面与重建面;签名见生成的[服务目录](#ctxclientmodules--clientmoduleregistry)。`graph()` 返回当前组合出的图(两次变更之间是同一个稳定对象),`clientPath(id)` 返回 entry bundle 的绝对路径,`artifactBaseline(id)` 返回读取当前快照前捕获的 entry stat 值。`fetchBundle()` 解析 HTTP 路由所使用的同一份惰性响应。`rebuilt(id)` 是变化后的可执行产物到达图的唯一入口:它重新快照 entry 及其引用的 chunks,只有 revision 真正变化才会重新组合图并发出通知。`onRebuilt` 按发生变化的包逐个触发并携带新 revision;`onGraphChanged` 在任何一次重新组合了图的 flush 之后触发(行的增删,或 rebuilt 带来的 revision 变化),并采用拉取模型——监听器自行重读 `graph()`。两条通知路径都会兜住监听器异常,因此一个抛错的订阅者既不能让后续订阅者被跳过,也不能杀死触发这次 flush 的一方。
 
 随包提供的 Web 组合通过 [`dsh-client-hmr`](../../packages/client/hmr/README.zh.md) 交付动态图快照。Host 立即转发现有图变化通知,重连会发送当前完整图。图描述浏览器的目标条目,不声明 Host 清理已经完成。产物轮询另外报告重建 revision。仅 source map 变化不会触发重载;新 combo-map URL 只会在 bundle revision 变化后出现,每份 map body 由其首次 `GET` 固定。Client Modules 校验快照,并将对账与重建串行协调;它持有启动创建的条目映射,负责单资源到达、异步移除、未使用模块与样式清理,以及页面本地重试状态。静态平台模块与 bootstrap 保持页面生命周期;Electron 安装属于独立流程。
 

+ 2 - 2
packages/client/modules/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/modules/README.md
-README.md: 1ea4976743eb0b396b02a765e51dec2a17f3c31b
-README.zh.md: 648af567b7d12f036ae50d5468cc88725bc052d5
+README.md: 0a20ec18ea82ccc4a6a09cb613f488eeabedec47
+README.zh.md: abce663a57ca5349410fbae54b12db827b9a8a6c

+ 2 - 2
packages/client/modules/README.md

@@ -65,13 +65,13 @@ The package has two sides: the Node half is the composition and serving side (`c
 
 ### Lazy-CJS model
 
-Executing a plugin bundle only registers its factory; every module-body side effect (CSS injection included) lives in the factory closure and runs at materialization (`factory(require)` → exports, memoized in `loadCache`). A factory that requires another registered-but-unmaterialized module materializes it recursively; require cycles throw because factory-form CJS cannot deliver partial exports. Resolution checks the platform seed table, memoized records, boot-graph rows, and registered factories in that order; anything else throws. The synchronous `require` uses the same order without asynchronous graph-row loading and records observed edges into the module record.
+Executing a plugin bundle only registers its factory; every module-body side effect (CSS injection included) lives in the factory closure and runs at materialization (`factory(require)` → exports, memoized in `loadCache`). A package may also register sibling chunks emitted by tsdown; the relative `require()` generated for `import()` loads and memoizes that chunk on demand. A factory that requires another registered-but-unmaterialized module materializes it recursively; require cycles throw because factory-form CJS cannot deliver partial exports. Resolution checks the platform seed table, memoized records, boot-graph rows, and registered factories in that order; anything else throws. Ordinary module-table `require` uses the same order without asynchronous graph-row loading and records observed edges into the module record.
 
 ### Incremental composition
 
 The Node half scans incrementally per package — no full-rescan path. Every `internal/plugin` emission marks the fiber's entry name dirty; a microtask flush reconciles each dirty name against the live loader entries, and the activation pass seeds the same dirty set and flushes synchronously, so first scan and steady state share one implementation. Package metadata is cached per Loader specifier and owning-tree base URL until restart, while the resolved manifest package name identifies the browser module. Distinct active Loader sources resolving to one package name are rejected; removing the conflict promotes the remaining source without requiring its fiber to restart. Bundle content changes reach the graph only through `rebuilt()` (the HMR hook).
 
-The Node half snapshots each client bundle before publication and creates combo descriptors without building response bodies. It groups resources into `/plugins/??...&rev=...` combo URLs, with one bootstrap combo for the modules row and one or more application combos for the other rows; each phase is partitioned before a URL exceeds 3 KiB. A script body is combined once on its first `GET` and ends with its map URL. The corresponding map files are read, validated, and combined separately on the first map `GET`; `HEAD` materializes neither body. Every combo map is Indexed Source Map v3 and uses an authored section when available or an identity section for the packaged bundle. Initial per-plugin revisions use process nonces, HMR hashes only a changed bundle, and combo revisions derive from the ordered row revisions. Advertised responses are immutable after first materialization, and an unknown combination or revision returns 404.
+The Node half snapshots each client entry and every recursively referenced sibling chunk before publication, then creates combo descriptors without building response bodies. It groups startup entries into `/plugins/??...&rev=...` combo URLs, with one bootstrap combo for the modules row and one or more application combos for the other rows; package-local chunks remain separate `/plugins/<package>/<chunk>?rev=...` resources and load only when their dynamic import executes. Each startup phase is partitioned before a URL exceeds 3 KiB. A script body is built once on its first `GET` and ends with its map URL. The corresponding map files are read, validated, and combined separately on the first map `GET`; `HEAD` materializes neither body. Every generated map is Indexed Source Map v3 and uses an authored section when available or an identity section for the packaged artifact. Initial per-plugin revisions use process nonces, HMR hashes the entry and its chunks, and combo revisions derive from the ordered row revisions. Advertised responses are immutable after first materialization, and an unknown resource or revision returns 404.
 
 ### Boot manifest injection
 

+ 2 - 2
packages/client/modules/README.zh.md

@@ -65,13 +65,13 @@ application combo 脚本在启动时仅注册一次插件 factory;模块主体
 
 ### 惰性 CJS 模型
 
-执行插件 bundle 只注册其 factory;每个模块主体副作用(包括 CSS 注入)都位于 factory 闭包中,在物化时运行(`factory(require)` → 导出,在 `loadCache` 中记忆化)。factory 依赖另一个已注册但未物化的模块时会递归物化它;require 循环会抛出异常,因为 factory 形式的 CJS 无法提供部分导出。解析会依次检查平台 seed 表、已记忆记录、启动图 row 与已注册 factory;其他情况一律抛错。交给 factory 的同步 `require` 使用相同顺序,但不含异步图 row 加载,并把观察到的边记录到模块记录中。
+执行插件 bundle 只注册其 factory;每个模块主体副作用(包括 CSS 注入)都位于 factory 闭包中,在物化时运行(`factory(require)` → 导出,在 `loadCache` 中记忆化)。一个包也可以注册 tsdown 产出的 sibling chunk;`import()` 生成的相对 `require()` 会按需加载并记忆该 chunk。factory 依赖另一个已注册但未物化的模块时会递归物化它;require 循环会抛出异常,因为 factory 形式的 CJS 无法提供部分导出。解析会依次检查平台 seed 表、已记忆记录、启动图 row 与已注册 factory;其他情况一律抛错。普通模块表 `require` 使用相同顺序,但不含异步 graph-row 加载,并把观察到的边记录到模块记录中。
 
 ### 增量组合
 
 Node 半侧逐包增量扫描——没有全量重扫路径。每次发出 `internal/plugin` 事件时,系统都会把该 fiber 的 entry 名标脏;微任务 flush 会把每个脏名与当前 loader 条目对账,激活 pass 会初始化同一个脏集合并同步 flush,因此首次扫描与稳态共用同一实现。包元数据按 Loader specifier 与所属 tree base URL 缓存至重启,解析出的 manifest(元数据清单)包名作为浏览器模块身份。若不同的 active Loader source 解析到同一包名,组合会失败;移除冲突来源后,剩余来源无需重启 fiber 即可接替。bundle 内容变更只能通过 `rebuilt()`(HMR 钩子)进入图。
 
-Node 半侧会在发布前快照每个客户端 bundle,并在不构建响应 body 的情况下创建 combo descriptor。它把资源分组到 `/plugins/??...&rev=...` combo URL:modules row 使用一个 bootstrap combo,其余 row 使用一个或多个 application combo;每个阶段都会在 URL 超过 3 KiB 之前分区。脚本 body 在首次 `GET` 时只组合一次,并以对应 map URL 结尾;map 文件则在首次 map `GET` 时单独读取、校验并组合,`HEAD` 不会物化任一 body。每个 combo map 都是 Indexed Source Map v3,并在可用时使用作者提供的 section,否则为已打包 bundle 生成 identity section。初始逐插件 revision 使用进程 nonce,HMR 只哈希变化的 bundle,combo revision 从有序 row revision 派生。已公告响应在首次物化后保持不可变;未知组合或 revision 返回 404。
+Node 半侧会在发布前快照每个客户端 entry 及其递归引用的全部 sibling chunk,并在不构建响应 body 的情况下创建 combo descriptor。它把启动 entry 分组到 `/plugins/??...&rev=...` combo URL:modules row 使用一个 bootstrap combo,其余 row 使用一个或多个 application combo;包内 chunk 保持为独立的 `/plugins/<package>/<chunk>?rev=...` 资源,仅在其 dynamic import 执行时加载。每个启动阶段都会在 URL 超过 3 KiB 之前分区。脚本 body 在首次 `GET` 时只构建一次,并以对应 map URL 结尾;map 文件则在首次 map `GET` 时单独读取、校验并组合,`HEAD` 不会物化任一 body。每份生成的 map 都是 Indexed Source Map v3,并在可用时使用作者提供的 section,否则为已打包产物生成 identity section。初始逐插件 revision 使用进程 nonce,HMR 哈希 entry 及其 chunks,combo revision 从有序 row revision 派生。已公告响应在首次物化后保持不可变;未知资源或 revision 返回 404。
 
 ### 启动 manifest 注入
 

+ 8 - 5
packages/client/modules/src/client/manifest.ts

@@ -306,10 +306,13 @@ export function parseBootManifest(wire: unknown): BootManifest {
 export interface ClientBundleRegistration {
   /** Plugin id (package name) — the registration key; must match the graph row being executed. */
   id: string
+  /** Package-local chunk filename; absent for the package's `client.js` entry. */
+  chunk?: string
   /**
    * Closure factory holding the whole bundle body: receives the synchronous
-   * require bound to the module table and returns the bundle's exports. Runs
-   * once, at materialization.
+   * require bound to the module table and returns the bundle's exports. A
+   * generated relative chunk require returns that chunk's loading promise.
+   * The factory runs once, at materialization.
    */
   factory: (require: (spec: string) => unknown) => Record<string, unknown>
 }
@@ -354,7 +357,7 @@ export interface DshWindow {
 
 /** Per-module bookkeeping in {@link ClientModuleLoader.loadCache} (flat module-graph boundary). */
 export interface ClientModuleRecord {
-  /** Module id (entry name / package name). */
+  /** Module id (entry package name or package-local chunk key). */
   id: string
   /** Materialized exports (`module.exports` from a factory or bootstrap registration). */
   exports: unknown
@@ -400,8 +403,8 @@ export interface ClientModuleLoader {
    */
   prefetch(id: string): Promise<void>
   /**
-   * Full reset of one non-bootstrap module: drop its registered factory and
-   * materialized record so the next prefetch/import loads its one-resource
+   * Full reset of one non-bootstrap package: drop its entry and chunk factories
+   * and materialized records so the next prefetch/import loads its one-resource
    * combo script rather than the initial multi-resource request. The bootstrap
    * module remains materialized.
    * @param id - entry name to invalidate.

+ 78 - 14
packages/client/modules/src/client/system.ts

@@ -36,6 +36,28 @@ function atRevision(url: string, rev: string): string {
   return url.replace(/([?&]rev=)[^&#]*/, `$1${encodeURIComponent(rev)}`)
 }
 
+const CLIENT_CHUNK = /^client\.[A-Za-z0-9][A-Za-z0-9._-]*\.js$/
+
+/** Internal module-table key for one package-local chunk. */
+function chunkId(ownerId: string, fileName: string): string {
+  return `${ownerId}/${fileName}`
+}
+
+/** Resolve a sibling chunk against the package's one-resource URL and current revision. */
+function chunkUrl(row: BootModuleRow, fileName: string, rev: string): string {
+  const url = atRevision(row.url, rev)
+  const marker = '/??'
+  const resourceStart = url.indexOf(marker)
+  const revisionStart = url.indexOf('&rev=', resourceStart + marker.length)
+  const resource = resourceStart < 0 || revisionStart < 0
+    ? undefined
+    : url.slice(resourceStart + marker.length, revisionStart)
+  if (resource !== `${row.id}/client.js`) {
+    throw new Error(`client-modules: cannot resolve chunk ${JSON.stringify(fileName)} from bundle URL ${url}`)
+  }
+  return `${url.slice(0, resourceStart)}/${row.id}/${fileName}?${url.slice(revisionStart + 1)}`
+}
+
 /**
  * Claim and inventory the <style> tags a factory injected during
  * materialization: preset-emitted tags arrive pre-tagged with data-plugin;
@@ -122,13 +144,18 @@ export class ClientModuleSystem implements ClientModuleLoader {
 
   /** Register one bundle factory, rejecting a script that executes twice without invalidation. */
   private register(registration: ClientBundleRegistration): void {
-    const id = stripClientSuffix(registration.id)
+    const ownerId = stripClientSuffix(registration.id)
+    if (registration.chunk !== undefined && !CLIENT_CHUNK.test(registration.chunk)) {
+      throw new Error(`client-modules: invalid package-local chunk ${JSON.stringify(registration.chunk)}`)
+    }
+    const id = registration.chunk === undefined ? ownerId : chunkId(ownerId, registration.chunk)
     if (this.bootstrapIds.has(id) || this.factories.has(id)) {
-      throw new Error(`client-modules: duplicate factory registration for "${registration.id}" (bundle executed twice without invalidate?)`)
+      const registrationName = registration.chunk === undefined ? registration.id : id
+      throw new Error(`client-modules: duplicate factory registration for "${registrationName}" (bundle executed twice without invalidate?)`)
     }
     this.factories.set(id, {
       factory: registration.factory,
-      rev: this.reloadTargets.get(id)?.rev ?? this.graphRows.get(id)?.rev,
+      rev: this.reloadTargets.get(ownerId)?.rev ?? this.graphRows.get(ownerId)?.rev,
     })
   }
 
@@ -183,7 +210,7 @@ export class ClientModuleSystem implements ClientModuleLoader {
   }
 
   /** Materialize a registered factory (synchronous; memoized in loadCache). */
-  private materialize(id: string): ClientModuleRecord {
+  private materialize(id: string, ownerId = id): ClientModuleRecord {
     const existing = this.loadCache.get(id)
     if (existing !== undefined) return existing
     const registered = this.factories.get(id)
@@ -195,12 +222,12 @@ export class ClientModuleSystem implements ClientModuleLoader {
     this.materializing.add(id)
     try {
       const edges = new Set<string>()
-      const exports = registered.factory(this.makeRequire(edges))
-      const record: ClientModuleRecord = { id, exports, styles: claimStyles(id), edges }
+      const exports = registered.factory(this.makeRequire(ownerId, edges))
+      const record: ClientModuleRecord = { id, exports, styles: claimStyles(ownerId), edges }
       this.loadCache.set(id, record)
       return record
     } catch (error) {
-      removeOwnedStyles(id)
+      removeOwnedStyles(ownerId)
       throw error
     } finally {
       this.materializing.delete(id)
@@ -208,14 +235,24 @@ export class ClientModuleSystem implements ClientModuleLoader {
   }
 
   /**
-   * The synchronous require answered to factories: seed → memoized record →
-   * registered factory. Fetching is async and therefore unreachable
-   * from here; an external dynamic package must have arrived before its
-   * consumer materializes.
+   * The require answered to factories: a generated relative chunk request
+   * returns its asynchronous load, while ordinary module-table requests stay
+   * synchronous: seed → memoized record → registered factory.
    */
-  private makeRequire(edges: Set<string>): (spec: string) => unknown {
+  private makeRequire(ownerId: string, edges: Set<string>): (spec: string) => unknown {
     return (spec: string): unknown => {
       edges.add(spec)
+      if (spec.startsWith('./')) {
+        const fileName = spec.slice(2)
+        if (!CLIENT_CHUNK.test(fileName)) {
+          throw new Error(`client-modules: invalid relative chunk request ${JSON.stringify(spec)}`)
+        }
+        const id = chunkId(ownerId, fileName)
+        const record = this.loadCache.get(id)
+        if (record !== undefined) return record.exports
+        if (this.factories.has(id)) return this.materialize(id, ownerId).exports
+        return this.importChunk(ownerId, fileName)
+      }
       if (this.seed.has(spec)) return this.seed.get(spec)
       const id = stripClientSuffix(spec)
       const record = this.loadCache.get(id)
@@ -228,6 +265,29 @@ export class ClientModuleSystem implements ClientModuleLoader {
     }
   }
 
+  /** Load, register, and materialize one package-local tsdown chunk. */
+  private async importChunk(ownerId: string, fileName: string): Promise<unknown> {
+    const id = chunkId(ownerId, fileName)
+    const existing = this.loadCache.get(id)
+    if (existing !== undefined) return existing.exports
+    if (!this.factories.has(id)) {
+      const row = this.graphRows.get(ownerId)
+      if (row === undefined) throw new Error(`client-modules: chunk owner "${ownerId}" is not a boot graph entry`)
+      const revision = this.factories.get(ownerId)?.rev ?? this.reloadTargets.get(ownerId)?.rev ?? row.rev
+      const url = chunkUrl(row, fileName, revision)
+      let transport = this.pendingArrival.get(url)
+      if (transport === undefined) {
+        transport = this.loadBundle(url).finally(() => { this.pendingArrival.delete(url) })
+        this.pendingArrival.set(url, transport)
+      }
+      await transport
+      if (!this.factories.has(id)) {
+        throw new Error(`client-modules: bundle ${url} loaded without registering "${id}" via __ModuleLoader__.load`)
+      }
+    }
+    return this.materialize(id, ownerId).exports
+  }
+
   async import(specifier: string): Promise<unknown> {
     if (this.seed.has(specifier)) return this.seed.get(specifier)
     const id = stripClientSuffix(specifier)
@@ -302,7 +362,11 @@ export class ClientModuleSystem implements ClientModuleLoader {
       const revision = rev ?? row.rev
       this.reloadTargets.set(normalized, { url: atRevision(row.url, revision), rev: revision })
     } else this.reloadTargets.delete(normalized)
-    this.factories.delete(normalized)
-    this.loadCache.delete(normalized)
+    for (const key of this.factories.keys()) {
+      if (key === normalized || key.startsWith(`${normalized}/client.`)) this.factories.delete(key)
+    }
+    for (const key of this.loadCache.keys()) {
+      if (key === normalized || key.startsWith(`${normalized}/client.`)) this.loadCache.delete(key)
+    }
   }
 }

+ 59 - 18
packages/client/modules/src/index.ts

@@ -58,7 +58,7 @@ interface WebBootRowFields {
 
 /** Filesystem baseline captured before a client artifact snapshot is read. */
 export interface ClientArtifactBaseline {
-  /** Absolute path of the client bundle. */
+  /** Absolute path of the client entry bundle watched for package rebuilds. */
   readonly path: string
   /** Bundle modification time in milliseconds. */
   readonly mtimeMs: number
@@ -137,6 +137,8 @@ interface WebPluginRecord {
   meta: PkgMeta
   /** Exact build artifact included in the startup batches. */
   bundle: Buffer
+  /** Package-local chunks referenced by the entry or another captured chunk. */
+  chunks: Map<string, Buffer>
   /** Pre-read filesystem baseline handed to the HMR watcher. */
   baseline: ClientArtifactBaseline
 }
@@ -146,6 +148,7 @@ interface ComboResource {
   id: string
   rev: string
   clientPath: string
+  fileName: string
   bundle: Buffer
 }
 
@@ -179,6 +182,8 @@ const COMBO_REVISION_PLACEHOLDER = '0'.repeat(HASH_REVISION_LENGTH)
 const SOURCE_MAP_TRAILER = /(?:\r?\n)?\/\/# sourceMappingURL=[^\r\n]*(?:\r?\n)?$/
 /** Debugger source name appended to page bundles in the WebWorker image. */
 const SOURCE_URL_TRAILER = /(?:\r?\n)?\/\/# sourceURL=([^\r\n]+)(?:\r?\n)?$/
+/** Relative CJS requests emitted by tsdown for package-local dynamic imports. */
+const CLIENT_CHUNK_REQUIRE = /\brequire\((["'])\.\/(client\.[A-Za-z0-9][A-Za-z0-9._-]*\.js)\1\)/g
 
 /** Resolve `exports["./client"]` to a relative path, accepting the string and one-level conditional forms. */
 function clientExportOf(pkgName: string, exportsField: unknown): string | undefined {
@@ -205,9 +210,10 @@ function framedHash(domain: string, parts: readonly Buffer[]): string {
   return hash.digest('hex').slice(0, HASH_REVISION_LENGTH)
 }
 
-/** Hash the executable artifact served after HMR observes one plugin change. */
-function artifactRevision(bundle: Buffer): string {
-  return framedHash('plugin-artifact', [bundle])
+/** Hash the entry and every package-local chunk served under one plugin revision. */
+function artifactRevision(bundle: Buffer, chunks: ReadonlyMap<string, Buffer>): string {
+  return framedHash('plugin-artifact', [bundle, ...[...chunks].sort(([left], [right]) => left.localeCompare(right))
+    .flatMap(([name, body]) => [Buffer.from(name), body])])
 }
 
 /** Address one ordered plugin-file list through the shared combo route. */
@@ -216,6 +222,11 @@ function comboUrl(ids: readonly string[], rev: string, sourceMap = false): strin
   return `/plugins/??${resources}&rev=${rev}`
 }
 
+/** Address one package-local chunk through the same revision as its entry. */
+function chunkUrl(id: string, fileName: string, rev: string, sourceMap = false): string {
+  return `/plugins/${id}/${fileName}${sourceMap ? '.map' : ''}?rev=${rev}`
+}
+
 /** Measure the longer map-form URL used to partition a startup resource list. */
 function projectedComboUrlBytes(records: readonly WebPluginRecord[]): number {
   return Buffer.byteLength(comboUrl(
@@ -265,7 +276,7 @@ function prepareSource(resource: ComboResource): PreparedSource {
   source = source.replace(SOURCE_URL_TRAILER, '').replace(SOURCE_MAP_TRAILER, '')
   if (!source.endsWith('\n')) source += '\n'
   const fallbackSource = sourceUrl === undefined
-    ? `/plugins/${resource.id}/client.js`
+    ? `/plugins/${resource.id}/${resource.fileName}`
     : /^(?:[A-Za-z][A-Za-z\d+.-]*:|\/)/.test(sourceUrl) ? sourceUrl : `/${sourceUrl}`
   return { source, fallbackSource }
 }
@@ -311,7 +322,7 @@ function newlineCount(value: string): number {
 function comboSectionMap(resource: ComboResource, original: Record<string, unknown>): Record<string, unknown> {
   const sourcePaths = original.sources as string[]
   const sourceRoot = typeof original.sourceRoot === 'string' ? original.sourceRoot : ''
-  const base = new URL(`/plugins/${resource.id}/client.js.map`, 'http://dsh.invalid')
+  const base = new URL(`/plugins/${resource.id}/${resource.fileName}.map`, 'http://dsh.invalid')
   const relocated = sourcePaths.map((source) => {
     const separator = sourceRoot !== '' && !sourceRoot.endsWith('/') && !source.startsWith('/') ? '/' : ''
     const resolved = new URL(`${sourceRoot}${separator}${source}`, base)
@@ -365,6 +376,7 @@ function buildComboScript(resources: readonly ComboResource[], sourceMapUrl: str
 function buildComboSourceMap(
   resources: readonly ComboResource[],
   sourceMapOf: (clientPath: string) => Record<string, unknown> | undefined,
+  fileName = 'client.js',
 ): Buffer {
   const sections: { offset: { line: number; column: 0 }; map: Record<string, unknown> }[] = []
   let line = 0
@@ -383,7 +395,7 @@ function buildComboSourceMap(
     sections.push({ offset: { line, column: 0 }, map: section })
     line += newlineCount(`${prepared.source};\n`)
   }
-  return Buffer.from(`${JSON.stringify({ version: 3, file: 'client.js', sections })}\n`)
+  return Buffer.from(`${JSON.stringify({ version: 3, file: fileName, sections })}\n`)
 }
 
 /** Describe one combo and defer its executable and debug payloads independently. */
@@ -396,6 +408,7 @@ function buildCombo(
     id: record.entry.id,
     rev: record.entry.rev,
     clientPath: record.meta.clientPath,
+    fileName: 'client.js',
     bundle: record.bundle,
   }))
   const rev = revision ?? comboRevision(resources)
@@ -655,21 +668,21 @@ export class ClientModuleRegistry extends Service {
   }
 
   /**
-   * Re-hash one bundle (the HMR watch's registration hook — the only entry
-   * point through which bundle content changes reach the graph).
+   * Re-snapshot one package's entry and chunks (the HMR watch's registration
+   * hook — the only entry point through which executable changes reach the graph).
    * @param id - entry id (package name).
    * @returns the new rev, or undefined for an unknown id.
    */
   rebuilt(id: string): string | undefined {
     const record = this.table.get(id)
     if (record === undefined) return undefined
-    const baseline = this.captureArtifactBaseline(record.meta.clientPath)
-    const bundle = readFileSync(record.meta.clientPath)
-    const rev = artifactRevision(bundle)
-    record.baseline = baseline
+    const snapshot = this.artifactSnapshot(id, record.meta.clientPath)
+    const rev = artifactRevision(snapshot.bundle, snapshot.chunks)
+    record.baseline = snapshot.baseline
     if (rev === record.entry.rev) return rev
     record.entry = graphRow(id, rev, record.meta)
-    record.bundle = bundle
+    record.bundle = snapshot.bundle
+    record.chunks = snapshot.chunks
     this.composed = this.compose()
     for (const notify of this.rebuildListeners) {
       // Containment: rebuilt() runs inside the HMR watch callback — a
@@ -745,6 +758,20 @@ export class ClientModuleRegistry extends Service {
         body: artifact.sourceMapBody,
         contentType: 'application/json; charset=utf-8',
       })
+      for (const [fileName, bundle] of record.chunks) {
+        const clientPath = join(dirname(record.meta.clientPath), fileName)
+        const resource = { id: record.entry.id, rev: record.entry.rev, clientPath, fileName, bundle }
+        const url = chunkUrl(record.entry.id, fileName, record.entry.rev)
+        const sourceMapUrl = chunkUrl(record.entry.id, fileName, record.entry.rev, true)
+        responses.set(url, this.responses.get(url) ?? {
+          body: lazyBody(() => buildComboScript([resource], sourceMapUrl)),
+          contentType: 'text/javascript; charset=utf-8',
+        })
+        responses.set(sourceMapUrl, this.responses.get(sourceMapUrl) ?? {
+          body: lazyBody(() => buildComboSourceMap([resource], this.readSourceMap, fileName)),
+          contentType: 'application/json; charset=utf-8',
+        })
+      }
     }
     this.previousBatchResponses = this.batchResponses
     this.batchResponses = batchResponses
@@ -896,20 +923,33 @@ export class ClientModuleRegistry extends Service {
   }
 
   /**
-   * Read the activation-time bundle snapshot.
+   * Read the activation-time entry and recursively referenced chunk snapshot.
    * @param pkgName - package that declares the client bundle.
    * @param clientPath - absolute path of the built client artifact.
    * @returns the immutable bytes plus the pre-read filesystem baseline.
    * @throws {MissingClientBundleError} when the read fails with `ENOENT`; other filesystem errors are rethrown unchanged.
    */
-  private initialBundleSnapshot(pkgName: string, clientPath: string): {
+  private artifactSnapshot(pkgName: string, clientPath: string): {
     bundle: Buffer
+    chunks: Map<string, Buffer>
     baseline: ClientArtifactBaseline
   } {
     try {
       const baseline = this.captureArtifactBaseline(clientPath)
       const bundle = readFileSync(clientPath)
-      return { bundle, baseline }
+      const chunks = new Map<string, Buffer>()
+      const pending = [bundle]
+      for (let index = 0; index < pending.length; index += 1) {
+        const source = pending[index]!.toString('utf8')
+        for (const match of source.matchAll(CLIENT_CHUNK_REQUIRE)) {
+          const fileName = match[2]!
+          if (chunks.has(fileName)) continue
+          const chunk = readFileSync(join(dirname(clientPath), fileName))
+          chunks.set(fileName, chunk)
+          pending.push(chunk)
+        }
+      }
+      return { bundle, chunks, baseline }
     } catch (error) {
       if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
       throw new MissingClientBundleError(pkgName, clientPath, error)
@@ -985,7 +1025,7 @@ export class ClientModuleRegistry extends Service {
     if (this.table.get(packageName)?.sourceKey === source.sourceKey) return false
     // The opaque initial rev rides the row until HMR observes a file change;
     // a fiber restart from the same source reuses the existing row.
-    const snapshot = this.initialBundleSnapshot(packageName, source.meta.clientPath)
+    const snapshot = this.artifactSnapshot(packageName, source.meta.clientPath)
     const rev = this.allocateInitialRevision()
     this.table.set(packageName, {
       entry: graphRow(packageName, rev, source.meta),
@@ -993,6 +1033,7 @@ export class ClientModuleRegistry extends Service {
       sourceKey: source.sourceKey,
       meta: source.meta,
       bundle: snapshot.bundle,
+      chunks: snapshot.chunks,
       baseline: snapshot.baseline,
     })
     return true

+ 32 - 0
packages/client/modules/tests/loader.client.spec.ts

@@ -12,6 +12,8 @@ const MODULES_ID = '@deepseek-ai/dsh-client-modules'
 
 const comboUrl = (ids: readonly string[], rev: string): string =>
   `/plugins/??${ids.map(id => `${id}/client.js`).join(',')}&rev=${rev}`
+const chunkUrl = (id: string, fileName: string, rev = '0'): string =>
+  `/plugins/${id}/${fileName}?rev=${rev}`
 const BOOTSTRAP_URL = comboUrl([MODULES_ID], 'bootstrap')
 const APPLICATION_URL = comboUrl(['a', 'b'], 'application')
 const win = globalThis as DshWindow
@@ -71,6 +73,7 @@ function bench(
     gated?: string[]
     pending?: ClientBundleRegistration[]
     defaultTransport?: boolean
+    chunks?: Record<string, Factory | null>
   } = {},
 ): Bench {
   const fetched: string[] = []
@@ -92,10 +95,18 @@ function bench(
     const singleId = combo?.split(',').length === 1 && combo.endsWith('/client.js')
       ? combo.slice(0, -'/client.js'.length)
       : undefined
+    const sibling = /^\/plugins\/(.+)\/(client\.[^/]+\.js)$/.exec(parsed.pathname)
     for (const id of batchIds ?? (singleId === undefined ? [] : [singleId])) {
       const factory = bundles[id]
       if (factory != null) win.__ModuleLoader__?.load({ id, factory })
     }
+    if (sibling !== null) {
+      const id = sibling[1]!
+      const chunk = sibling[2]!
+      const key = `${id}/${chunk}`
+      const factory = opts.chunks?.[key]
+      if (factory != null) win.__ModuleLoader__?.load({ id, chunk, factory })
+    }
   }
   const bootstrapEntries = entries.filter(entry => entry.initialUrl === BOOTSTRAP_URL).map(entry => entry.id)
   const applicationEntries = entries.filter(entry => entry.initialUrl === APPLICATION_URL).map(entry => entry.id)
@@ -223,6 +234,27 @@ describe('lazy CJS arrival', () => {
     await b.loader.prefetch('a')
     expect(b.fetched).toHaveLength(1)
   })
+
+  it('fetches a dynamic sibling chunk once on demand', async () => {
+    const b = bench([row('a')], {
+      a: req => ({
+        load: () => Promise.resolve().then(() => req('./client.pdf.js')),
+      }),
+    }, {
+      chunks: {
+        'a/client.pdf.js': () => ({ marker: 'pdf' }),
+      },
+    })
+    const entry = await b.loader.import('a', '', {}) as {
+      load: () => Promise<{ marker: string }>
+    }
+    expect(b.fetched).toEqual([APPLICATION_URL])
+
+    const [first, second] = await Promise.all([entry.load(), entry.load()])
+    expect(first).toBe(second)
+    expect(first).toEqual({ marker: 'pdf' })
+    expect(b.fetched).toEqual([APPLICATION_URL, chunkUrl('a', 'client.pdf.js')])
+  })
 })
 
 describe('require resolution', () => {

+ 31 - 0
packages/client/modules/tests/node-half.client.spec.ts

@@ -20,6 +20,7 @@ const UI_RENDERER_ID = '@deepseek-ai/dsh-client-ui-renderer'
 const comboUrl = (ids: readonly string[], rev: string): string =>
   `/plugins/??${ids.map(id => `${id}/client.js`).join(',')}&rev=${rev}`
 const mapUrl = (url: string): string => url.replace(/\/client\.js(?=,|&rev=)/g, '/client.js.map')
+const chunkUrl = (id: string, fileName: string, rev: string): string => `/plugins/${id}/${fileName}?rev=${rev}`
 const BOOTSTRAP_URL = comboUrl([MODULES_ID], 'boot')
 const APPLICATION_URL = comboUrl([UI_RENDERER_ID], 'app')
 
@@ -780,6 +781,36 @@ describe('client bundle activation', () => {
     })
   })
 
+  it('snapshots and serves package-local chunks under the entry revision', async () => {
+    const packageName = '@fixture/chunked'
+    const clientPath = writePackage(packageName)
+    const chunkPath = join(dirname(clientPath), 'client.pdf.js')
+    mkdirSync(dirname(clientPath), { recursive: true })
+    writeFileSync(clientPath, 'module.exports = require("./client.pdf.js")\n')
+    writeFileSync(chunkPath, 'module.exports = { version: 1 }\n//# sourceMappingURL=client.pdf.js.map')
+    writeFileSync(`${chunkPath}.map`, JSON.stringify({
+      version: 3,
+      names: [],
+      mappings: 'AAAA',
+      sources: ['../../../packages/client/demo/src/pdf.tsx'],
+    }))
+    const { service, route } = constructWithRoute([packageName])
+    const firstRev = service.graph().entries[0]!.rev
+    const firstUrl = chunkUrl(packageName, 'client.pdf.js', firstRev)
+
+    const script = await routeRequest(route, firstUrl)
+    expect(script.status).toBe(200)
+    expect(script.body.toString('utf8')).toContain('module.exports = { version: 1 }')
+    expect(script.body.toString('utf8')).toContain(`sourceMappingURL=${firstUrl.replace('.js?', '.js.map?')}`)
+    expect((await routeRequest(route, firstUrl.replace('.js?', '.js.map?'))).status).toBe(200)
+
+    writeFileSync(chunkPath, 'module.exports = { version: 2 }\n')
+    const secondRev = service.rebuilt(packageName)!
+    expect(secondRev).not.toBe(firstRev)
+    const second = await routeRequest(route, chunkUrl(packageName, 'client.pdf.js', secondRev))
+    expect(second.body.toString('utf8')).toContain('module.exports = { version: 2 }')
+  })
+
   it('applies sourceRoot before relocating absolute-looking section sources', async () => {
     const packageName = '@fixture/source-root'
     const clientPath = writePackage(packageName)

+ 11 - 4
packages/client/tsdown.client.ts

@@ -104,7 +104,7 @@ function browserSourcePath(source: string, sourcemapPath: string): string {
  * @param libEntry - node-half entries, spelled at the call site so the
  * package-invariants gate can see `lib/types/invariant.js` in each package's
  * own tsdown.config.ts (a preset-side glob hides it from the mechanical check).
- * @param options - phase placement, lib overrides, and companion Node configs.
+ * @param options - phase placement, lib overrides, companion Node configs, and optional per-file Client banner.
  * @returns ENV-selected tsdown config for the current build face.
  */
 export function clientBundle(
@@ -116,7 +116,7 @@ export function clientBundle(
   return ({ env }) => {
     const face = buildFace(env?.DSH_BUILD_FACE)
     const clientEntry = face === undefined ? 'src/client/index.ts' : 'lib/types/client/index.js'
-    const client = clientConfig(id, clientEntry)
+    const client = clientConfig(id, clientEntry, options.clientBanner)
     const node = [lib, ...(options.companions ?? [])]
     if (face === 'host') return options.hostPhase === true ? node : [SKIP_WORKSPACE_BUILD]
     if (face === 'client') {
@@ -205,6 +205,8 @@ interface ClientBundleOptions {
   readonly companions?: readonly UserConfig[]
   /** Overrides for the package's primary Node-side library config. */
   readonly lib?: UserConfig
+  /** Optional legal or attribution text selected by emitted client filename. */
+  readonly clientBanner?: (fileName: string) => string | undefined
 }
 
 type BuildFace = 'host' | 'client' | undefined
@@ -432,7 +434,7 @@ function matchesSpecifier(patterns: readonly RegExp[], specifier: string): boole
   return patterns.some(pattern => pattern.test(specifier))
 }
 
-function clientConfig(id: string, entry: string): UserConfig {
+function clientConfig(id: string, entry: string, clientBanner?: (fileName: string) => string | undefined): UserConfig {
   const isRequested = (specifier: string): boolean => clientExternals(id).has(specifier)
   const isolation = clientInputIsolation(id)
   return {
@@ -565,6 +567,7 @@ function clientConfig(id: string, entry: string): UserConfig {
     }],
     outputOptions: {
       entryFileNames: 'client.js',
+      chunkFileNames: 'client.[name].js',
       sourcemapExcludeSources: false,
       // The map is served from /plugins/<scoped-package>/client.js.map. The
       // browser resolves its local sources back into URLs that mirror the
@@ -574,7 +577,11 @@ function clientConfig(id: string, entry: string): UserConfig {
         isolation.sourcePath(source, mapPath)
         return browserSourcePath(source, mapPath)
       },
-      banner: `window.__ModuleLoader__.load({ id: ${JSON.stringify(id)}, factory: (require) => {`,
+      banner: (chunk) => {
+        const registration = `window.__ModuleLoader__.load({ id: ${JSON.stringify(id)}, ${chunk.isEntry ? '' : `chunk: ${JSON.stringify(chunk.fileName)}, `}factory: (require) => {`
+        const prefix = clientBanner?.(chunk.fileName)
+        return prefix === undefined ? registration : `${prefix}\n${registration}`
+      },
       footer: 'return module.exports; } });',
       intro: 'var module = { exports: {} }; var exports = module.exports;',
     },

+ 2 - 2
packages/client/ui-sidebar-documentpreview/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-sidebar-documentpreview/README.md
-README.md: 3da629129f36d18a20eef74be8f96a1455d532f3
-README.zh.md: 6f1eb7eef1f690ebdfdf38cfdd1f62b1314d7d66
+README.md: 5f93c30732e1786ad787d5397d7913c48bead723
+README.zh.md: 3f4e9ea7e38e06f2b513044926f0a7a654eea2ea

+ 1 - 1
packages/client/ui-sidebar-documentpreview/README.md

@@ -53,7 +53,7 @@ PNG, JPEG, GIF, WebP, BMP, ICO, and SVG render through Blob URLs in an `<img>` s
 
 Shared copy comes from `sidebarDocumentPreview`; each builtin renderer owns its localized labels.
 
-Initial reads, additional pages, and HTML/PDF/image preparation share an icon-only loading spinner that exposes its label to assistive technology and respects reduced-motion preferences; every wait before content exists centres the spinner in the pane, so opening a file shows one spinner in one position until the body appears. Loaded pages stay visible while another page loads. PDF pages fill the pane's width edge to edge as one vertical sequence and render lazily near the viewport; an unrendered page holds its place as a quiet 3:4 placeholder block. Code previews show source line numbers by default without including them in copied text; plain text uses the same font size and line height as code. Code sits on the pane's own background rather than the chat card's fill; its banner is adjacent to a full-height inner scrollport, so both scrollbars begin below the copy control.
+Initial reads, additional pages, and HTML/PDF/image preparation share an icon-only loading spinner that exposes its label to assistive technology and respects reduced-motion preferences; every wait before content exists centres the spinner in the pane, so opening a file shows one spinner in one position until the body appears. Loaded pages stay visible while another page loads. The PDF body loads its package-local `client.pdf.js` chunk only when a PDF preview mounts; PDF.js, its Worker source, and embedded support data stay out of the startup `client.js`. PDF pages fill the pane's width edge to edge as one vertical sequence and render lazily near the viewport; an unrendered page holds its place as a quiet 3:4 placeholder block. Code previews show source line numbers by default without including them in copied text; plain text uses the same font size and line height as code. Code sits on the pane's own background rather than the chat card's fill; its banner is adjacent to a full-height inner scrollport, so both scrollbars begin below the copy control.
 
 <a id="navigation"></a>
 ## Navigation

+ 1 - 1
packages/client/ui-sidebar-documentpreview/README.zh.md

@@ -53,7 +53,7 @@ PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 通过 Blob URL 在 `<img>` 静态
 
 共享文案来自 `sidebarDocumentPreview`;各内置渲染器拥有自己的本地化标签。
 
-首次读取、追加页及 HTML/PDF/图片准备共用仅图标的加载 spinner,其标签暴露给辅助技术,并遵循减少动态效果偏好;内容出现前的每个等待都把 spinner 居中在面板中,打开文件到正文出现始终是同一位置的一个 spinner。下一页加载期间保留已显示的内容。PDF 页面贴边占满面板宽度,组成一个纵向连续序列并在接近视口时惰性渲染;未渲染的页以安静的 3:4 占位块保持位置。代码预览默认显示源码行号,但复制文本不包含行号;纯文本与代码使用相同字号和行高。代码直接坐在分栏自身的背景上,而不是会话卡片的填充色;复制条与占满剩余高度的内部滚动区相邻,因此横纵滚动条都从复制控件下方开始。
+首次读取、追加页及 HTML/PDF/图片准备共用仅图标的加载 spinner,其标签暴露给辅助技术,并遵循减少动态效果偏好;内容出现前的每个等待都把 spinner 居中在面板中,打开文件到正文出现始终是同一位置的一个 spinner。下一页加载期间保留已显示的内容。PDF 正文仅在 PDF 预览挂载时加载包内 `client.pdf.js` chunk;PDF.js、Worker 源码和内嵌支持数据不会进入启动 `client.js`。PDF 页面贴边占满面板宽度,组成一个纵向连续序列并在接近视口时惰性渲染;未渲染的页以安静的 3:4 占位块保持位置。代码预览默认显示源码行号,但复制文本不包含行号;纯文本与代码使用相同字号和行高。代码直接坐在分栏自身的背景上,而不是会话卡片的填充色;复制条与占满剩余高度的内部滚动区相邻,因此横纵滚动条都从复制控件下方开始。
 
 <a id="navigation"></a>
 ## 导航

+ 1 - 0
packages/client/ui-sidebar-documentpreview/package.json

@@ -73,6 +73,7 @@
   "files": [
     "lib/index.js",
     "lib/client.js",
+    "lib/client.pdf.js",
     "lib/types/**/*.d.ts"
   ]
 }

+ 14 - 0
packages/client/ui-sidebar-documentpreview/src/client/pdf/LazyPdfBody.tsx

@@ -0,0 +1,14 @@
+/** Load the PDF renderer only after a PDF body is mounted. */
+import { lazy, Suspense, type ReactNode } from 'react'
+import type { PdfBodyProps } from './pdf.tsx'
+
+const LoadedPdfBody = lazy(async () => ({ default: (await import('./pdf.tsx')).PdfBody }))
+
+/**
+ * Suspend while the package-local PDF chunk arrives.
+ * @param props - PDF body props supplied by the document slot.
+ * @returns the deferred PDF renderer.
+ */
+export function LazyPdfBody(props: PdfBodyProps): ReactNode {
+  return <Suspense fallback={null}><LoadedPdfBody {...props} /></Suspense>
+}

+ 14 - 0
packages/client/ui-sidebar-documentpreview/src/client/pdf/PdfBody.module.css

@@ -54,3 +54,17 @@
   width: 100%;
   min-height: 100%;
 }
+
+.loadingIcon {
+  display: flex;
+  flex: none;
+  animation: turn 0.8s linear infinite;
+}
+
+@keyframes turn {
+  to { transform: rotate(360deg); }
+}
+
+@media (prefers-reduced-motion: reduce) {
+  .loadingIcon { animation: none; }
+}

+ 3 - 2
packages/client/ui-sidebar-documentpreview/src/client/pdf/index.ts

@@ -2,7 +2,8 @@
 import type { Context } from '@deepseek-ai/cordis'
 import type {} from '../index.ts'
 import type { DocumentPreviewDefinition } from '../document/registry.ts'
-import { PdfBody, type PdfBodyInjected } from './PdfBody.tsx'
+import type { PdfBodyInjected } from './pdf.tsx'
+import { LazyPdfBody } from './LazyPdfBody.tsx'
 import { createPdfStore } from './store.ts'
 import { en, zh } from './locales.ts'
 
@@ -43,5 +44,5 @@ export function apply(ctx: Context): void {
         signal.addEventListener('abort', forget, { once: true })
       },
     }),
-  }, PdfBody)))
+  }, LazyPdfBody)))
 }

+ 9 - 6
packages/client/ui-sidebar-documentpreview/src/client/pdf/PdfBody.tsx → packages/client/ui-sidebar-documentpreview/src/client/pdf/pdf.tsx

@@ -1,12 +1,10 @@
 /** PDF page presentation; binary content and tab information come from the document owner. */
 import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
-import clsx from 'clsx'
-import { Button } from '@deepseek-ai/dsh-client-ui-primitives'
+import { Button, IconLoadingOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { PropsLocale, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
 import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
 import type { DocumentPreviewProps } from '../document/contract.ts'
-import { LoadingIndicator } from '../LoadingIndicator.tsx'
-import { DEFAULT_PDF_VIEW, type PdfStore } from './store.ts'
+import type { PdfStore } from './store.ts'
 import { renderPdfPage, type PdfDocument } from './document.ts'
 import { openPdf } from './runtime.ts'
 import { PdfWorkerFailure } from './errors.ts'
@@ -30,6 +28,8 @@ type LoadState =
   | { readonly kind: 'loaded'; readonly data: Uint8Array<ArrayBuffer>; readonly document: PdfDocument }
   | { readonly kind: 'failed'; readonly data: Uint8Array<ArrayBuffer>; readonly error: unknown }
 
+const INITIAL_PDF_VIEW = { page: 1 }
+
 /**
  * Present a PDF with tab-local viewing preferences and component-owned rendering resources.
  * @param props - complete bytes and framework-owned tab/store/locale seats.
@@ -37,7 +37,7 @@ type LoadState =
  */
 export function PdfBody(props: PdfBodyProps): ReactNode {
   const { tab } = props.useTabInfo()
-  const view = props.useStore(state => state.byTab[tab.id] ?? DEFAULT_PDF_VIEW)
+  const view = props.useStore(state => state.byTab[tab.id] ?? INITIAL_PDF_VIEW)
   const data = props.content.kind === 'bytes' ? props.content.data : undefined
   const [load, setLoad] = useState<LoadState>()
   const [attempt, setAttempt] = useState(0)
@@ -67,7 +67,10 @@ export function PdfBody(props: PdfBodyProps): ReactNode {
   if (data === undefined) return <p className={css.status} role="alert">{t('unsupported')}</p>
   // The open wait centres like the owner's read spinner before it, so one
   // spinner position covers everything until the first page block appears.
-  if (load?.data !== data) return <LoadingIndicator className={clsx(css.status, css.opening)} label={t('loading')} />
+  if (load?.data !== data) return <span className={`${css.status} ${css.opening}`} role="status"
+    aria-label={t('loading')} data-document-loading>
+    <span className={css.loadingIcon} aria-hidden="true"><IconLoadingOutline16 /></span>
+  </span>
   if (load.kind === 'failed') {
     return <div className={css.status} role="alert">
       <span>{failureText(load.error, t)}</span>

+ 2 - 2
packages/client/ui-sidebar-documentpreview/tests/apply.client.spec.ts

@@ -23,7 +23,7 @@ import { HtmlBody } from '../src/client/html/HtmlBody.tsx'
 import { HTML_BODY_ID } from '../src/client/html/index.ts'
 import { ImageBody } from '../src/client/image/ImageBody.tsx'
 import { IMAGE_BODY_ID } from '../src/client/image/index.ts'
-import { PdfBody } from '../src/client/pdf/PdfBody.tsx'
+import { LazyPdfBody } from '../src/client/pdf/LazyPdfBody.tsx'
 import { PDF_BODY_ID } from '../src/client/pdf/index.ts'
 import { CodeBody } from '../src/client/code/CodeBody.tsx'
 import { en, zh } from '../src/client/locales.ts'
@@ -96,7 +96,7 @@ describe('ui-sidebar-documentpreview apply', () => {
       ['sidebar.right.tab.document', MARKDOWN_BODY_ID, 'documentMarkdown', MarkdownBody],
       ['sidebar.right.tab.document', HTML_BODY_ID, 'documentHtml', HtmlBody],
       ['sidebar.right.tab.document', IMAGE_BODY_ID, 'sidebarImage', ImageBody],
-      ['sidebar.right.tab.document', PDF_BODY_ID, 'sidebarPdf', PdfBody],
+      ['sidebar.right.tab.document', PDF_BODY_ID, 'sidebarPdf', LazyPdfBody],
       ['sidebar.right.tab.document', '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/code', 'sidebarCodePreview', CodeBody],
     ])
     expect(registered[0]?.store).toBeDefined()

+ 1 - 1
packages/client/ui-sidebar-documentpreview/tests/pdf-body.client.spec.tsx

@@ -12,7 +12,7 @@ import type { openPdf } from '../src/client/pdf/runtime.ts'
 const engine = vi.hoisted(() => ({ open: vi.fn<typeof openPdf>(), render: vi.fn<typeof renderPdfPage>() }))
 vi.mock('../src/client/pdf/runtime.ts', () => ({ openPdf: engine.open }))
 vi.mock('../src/client/pdf/document.ts', () => ({ renderPdfPage: engine.render }))
-import { PdfBody, type PdfBodyProps } from '../src/client/pdf/PdfBody.tsx'
+import { PdfBody, type PdfBodyProps } from '../src/client/pdf/pdf.tsx'
 import { createPdfStore, type PdfState } from '../src/client/pdf/store.ts'
 import { en } from '../src/client/pdf/locales.ts'
 import { PdfWorkerFailure } from '../src/client/pdf/errors.ts'

+ 9 - 3
packages/client/ui-sidebar-documentpreview/tests/pdf-license-bundle.client.spec.ts

@@ -7,6 +7,7 @@ import { describe, expect, it } from 'vitest'
 
 const packageRoot = resolve(import.meta.dirname, '..')
 const bundlePath = join(packageRoot, 'lib/client.js')
+const pdfChunkPath = join(packageRoot, 'lib/client.pdf.js')
 const require = createRequire(import.meta.url)
 const licenseNames = [
   'LICENSE',
@@ -41,22 +42,27 @@ function runPnpm(args: string[], cwd: string, timeout: number): string {
 }
 
 describe('published PDF.js licenses', () => {
-  it.skipIf(!existsSync(bundlePath))('keeps every bundled license in the packed client artifact', ({ task }) => {
+  it.skipIf(!existsSync(bundlePath) || !existsSync(pdfChunkPath))('keeps every bundled license in the packed PDF chunk', ({ task }) => {
     const output = mkdtempSync(join(tmpdir(), 'dsh-document-preview-pack-'))
     try {
       const packed = JSON.parse(runPnpm([
         'pack', '--json', '--pack-destination', output,
       ], packageRoot, task.timeout)) as { filename: string; files: { path: string }[] }
       expect(packed.files.map(file => file.path)).toContain('lib/client.js')
+      expect(packed.files.map(file => file.path)).toContain('lib/client.pdf.js')
       expect(packed.files.some(file => file.path.endsWith('pdfjs-NOTICES.txt'))).toBe(false)
 
       const client = run('tar', ['-xOf', resolve(packageRoot, packed.filename), 'package/lib/client.js'], packageRoot, task.timeout)
-      expect(client).toContain('//! Bundled PDF.js license notices')
+      const pdf = run('tar', ['-xOf', resolve(packageRoot, packed.filename), 'package/lib/client.pdf.js'], packageRoot, task.timeout)
+      expect([...client.matchAll(/require\("(\.\/client[^"/]*\.js)"\)/gu)].map(match => match[1])).toEqual(['./client.pdf.js'])
+      expect(client).not.toContain('//! Bundled PDF.js license notices')
+      expect(client).not.toContain('/pdfjs-dist/')
+      expect(pdf).toContain('//! Bundled PDF.js license notices')
       const pdfRoot = dirname(require.resolve('pdfjs-dist/package.json'))
       for (const name of licenseNames) {
         const source = readFileSync(join(pdfRoot, name), 'utf8').trimEnd()
         const commented = [`// ${name}`, '// ', ...source.split('\n').map(line => `// ${line}`)].join('\n')
-        expect(client, `${name} must be visible in package/lib/client.js`).toContain(commented)
+        expect(pdf, `${name} must be visible in package/lib/client.pdf.js`).toContain(commented)
       }
     } finally {
       rmSync(output, { recursive: true, force: true })

+ 3 - 3
packages/client/ui-sidebar-documentpreview/tests/pdf-registration.client.spec.ts

@@ -6,11 +6,11 @@ import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
 import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
 import { DocumentPreviewRegistry } from '../src/client/document/registry.ts'
 import { createPdfStore } from '../src/client/pdf/store.ts'
-import type { PdfBodyInjected } from '../src/client/pdf/PdfBody.tsx'
+import type { PdfBodyInjected } from '../src/client/pdf/pdf.tsx'
 
 vi.mock('../src/client/pdf/runtime.ts', () => ({ openPdf: vi.fn() }))
 import { apply, PDF_BODY_ID } from '../src/client/pdf/index.ts'
-import { PdfBody } from '../src/client/pdf/PdfBody.tsx'
+import { LazyPdfBody } from '../src/client/pdf/LazyPdfBody.tsx'
 import { en, zh } from '../src/client/pdf/locales.ts'
 
 describe('PDF registration', () => {
@@ -26,7 +26,7 @@ describe('PDF registration', () => {
       inject: (sessionId: SessionId, actions: ReturnType<ReturnType<typeof createPdfStore>['create']>['actions']) => PdfBodyInjected
     }> = []
     const register = vi.fn((options: typeof entries[number], component: unknown) => {
-      expect(component).toBe(PdfBody)
+      expect(component).toBe(LazyPdfBody)
       entries.push(options)
       return () => { entries.splice(entries.indexOf(options), 1) }
     })

+ 3 - 2
packages/client/ui-sidebar-documentpreview/tsdown.config.ts

@@ -4,7 +4,9 @@ import { dirname, join } from 'node:path'
 import type { UserConfig } from 'tsdown'
 import { clientBundle } from '../tsdown.client.ts'
 
-const bundle = clientBundle('@deepseek-ai/dsh-client-ui-sidebar-documentpreview', ['lib/types/index.js'])
+const bundle = clientBundle('@deepseek-ai/dsh-client-ui-sidebar-documentpreview', ['lib/types/index.js'], {
+  clientBanner: fileName => fileName.endsWith('client.pdf.js') ? pdfLicenseBanner() : undefined,
+})
 const require = createRequire(import.meta.url)
 const workerSpecifier = 'pdfjs-dist/build/pdf.worker.min.mjs?raw'
 const workerModule = '\0dsh-pdf-worker.mjs'
@@ -54,7 +56,6 @@ const pdfWorker: NonNullable<UserConfig['plugins']> = [{
 export default (options: Parameters<typeof bundle>[0]): UserConfig[] => bundle(options).map(config =>
   config.name?.endsWith('/client') === true ? {
     ...config,
-    banner: pdfLicenseBanner(),
     plugins: [config.plugins, pdfWorker],
     define: { ...config.define, __DSH_PDFJS_ASSETS__: pdfAssets() },
   } : config,

+ 2 - 2
packages/client/ui-sidebar-terminal/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-sidebar-terminal/README.md
-README.md: 04224d2ef8ff41df7c56bfb2ece130f03a790873
-README.zh.md: 80ea0373e22e888194a2d36ddebe330d1222183b
+README.md: f4850bbf9c4b9b3ce01577d2da8a51da84263a1d
+README.zh.md: 24dda6c55e29e91c5357619e86e3537b9b454a3d

+ 1 - 1
packages/client/ui-sidebar-terminal/README.md

@@ -41,7 +41,7 @@ The terminal background, default text, cursor, and selection follow the DSH them
 <details>
 <summary>Implementation internals — click to expand</summary>
 
-This plugin registers the `terminal` type and body/title seats with the right sidebar. The guide uses a compact dark terminal card, while tab titles retain the line glyph. The React-free terminal model belongs to `api-terminal-controller`; keyed framework hooks expose its state. `ui-primitives` Menu and Button provide the shell picker and startup controls, including keyboard navigation and the selected-item marker. xterm.js and FitAddon render the screen and measure the viewport. The body reserves an 8px gap below the tab strip within the pane height. Input, including Tab and control characters, travels unchanged to the PTY.
+This plugin registers the `terminal` type and body/title seats with the right sidebar. The guide uses a compact dark terminal card, while tab titles retain the line glyph. The React-free terminal model belongs to `api-terminal-controller`; keyed framework hooks expose its state. `ui-primitives` Menu and Button provide the shell picker and startup controls, including keyboard navigation and the selected-item marker. The body loads its package-local `client.terminal.js` chunk when a terminal view mounts, keeping xterm.js and FitAddon out of the startup `client.js`; they then render the screen and measure the viewport. The body reserves an 8px gap below the tab strip within the pane height. Input, including Tab and control characters, travels unchanged to the PTY.
 
 A Session header contribution queries Host terminals and opens only those without an existing tab association. The terminal controller saves each globally unique content identity's Host association independently and owns content recovery; the sidebar owns layout persistence. A recovered view cannot allocate a replacement process. The sidebar's close handler schedules cleanup through the [terminal controller](../../api/terminal-controller/README.md#understand-the-implementation) and returns synchronously. Browser component cleanup and the tab's abort signal only detach browser work.
 

+ 1 - 1
packages/client/ui-sidebar-terminal/README.zh.md

@@ -41,7 +41,7 @@ kind: "package-reference"
 <details>
 <summary>实现细节</summary>
 
-插件向右侧栏注册 `terminal` 类型及正文和标题 seat。开始页入口使用紧凑的黑底终端图标,页签标题保留线条图标。无 React 依赖的终端模型属于 `api-terminal-controller`,通过框架 keyed hooks 暴露状态。`ui-primitives` 的 Menu 与 Button 提供 shell 选择和启动控件,支持键盘导航与选中标记。xterm.js 与 FitAddon 负责屏幕渲染和视口测量。正文在面板高度内为页签条下方预留 8px 间距。输入原样传到 PTY,包括 Tab 和控制字符。
+插件向右侧栏注册 `terminal` 类型及正文和标题 seat。开始页入口使用紧凑的黑底终端图标,页签标题保留线条图标。无 React 依赖的终端模型属于 `api-terminal-controller`,通过框架 keyed hooks 暴露状态。`ui-primitives` 的 Menu 与 Button 提供 shell 选择和启动控件,支持键盘导航与选中标记。正文在 terminal 视图挂载时加载包内 `client.terminal.js` chunk,使 xterm.js 与 FitAddon 不进入启动 `client.js`;加载后由它们负责屏幕渲染和视口测量。正文在面板高度内为页签条下方预留 8px 间距。输入原样传到 PTY,包括 Tab 和控制字符。
 
 Session header contribution 查询 Host 终端,只打开没有现有标签关联的终端。终端 controller 独立保存每个全局唯一内容身份与 Host 的关联,并负责内容恢复;侧栏负责布局持久化。恢复视图不能分配替代进程。侧栏关闭 handler 通过[终端 controller](../../api/terminal-controller/README.zh.md#understand-the-implementation)安排清理并同步返回。浏览器组件清理和 tab 的 abort signal 只停止浏览器工作。
 

+ 1 - 0
packages/client/ui-sidebar-terminal/package.json

@@ -73,6 +73,7 @@
   "files": [
     "lib/index.js",
     "lib/client.js",
+    "lib/client.terminal.js",
     "lib/types/**/*.d.ts"
   ]
 }

+ 14 - 0
packages/client/ui-sidebar-terminal/src/client/LazyTerminalBody.tsx

@@ -0,0 +1,14 @@
+/** Load xterm only after a terminal body is mounted. */
+import { lazy, Suspense, type ReactNode } from 'react'
+import type { TerminalBodyProps } from './terminal.tsx'
+
+const LoadedTerminalBody = lazy(async () => ({ default: (await import('./terminal.tsx')).TerminalBody }))
+
+/**
+ * Suspend while the package-local terminal chunk arrives.
+ * @param props - Terminal body props supplied by the sidebar slot.
+ * @returns the deferred terminal renderer.
+ */
+export function LazyTerminalBody(props: TerminalBodyProps): ReactNode {
+  return <Suspense fallback={null}><LoadedTerminalBody {...props} /></Suspense>
+}

+ 2 - 0
packages/client/ui-sidebar-terminal/src/client/TerminalTitle.module.css

@@ -0,0 +1,2 @@
+.title { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.name { width: 120px; min-width: 48px; max-width: 100%; padding: 0 4px; color: inherit; font: inherit; background: var(--dsw-alias-bg-l1); border: 0.5px solid var(--dsw-alias-border-l3); border-radius: 4px; outline: none; }

+ 1 - 1
packages/client/ui-sidebar-terminal/src/client/TerminalTitle.tsx

@@ -4,7 +4,7 @@ import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-cli
 import { TerminalIcon } from './TerminalIcon.tsx'
 import type { TerminalInjected } from './face.ts'
 import type {} from './locales.ts'
-import css from './TerminalBody.module.css'
+import css from './TerminalTitle.module.css'
 
 /**
  * Render the terminal name, editable in place on double-click.

+ 2 - 2
packages/client/ui-sidebar-terminal/src/client/index.ts

@@ -11,7 +11,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-session/client'
 import type {} from '@deepseek-ai/dsh-client-ui-theme/client'
 import { TerminalGuideIcon } from './TerminalIcon.tsx'
 import { TerminalGuide, type TerminalGuideInjected } from './TerminalGuide.tsx'
-import { TerminalBody } from './TerminalBody.tsx'
+import { LazyTerminalBody } from './LazyTerminalBody.tsx'
 import { TerminalTitle } from './TerminalTitle.tsx'
 import { TerminalRecovery, type TerminalRecoveryInjected } from './TerminalRecovery.tsx'
 import { TerminalCleanup, type TerminalCleanupInjected } from './TerminalCleanup.tsx'
@@ -76,7 +76,7 @@ export function apply(ctx: Context): void {
   ctx.effect(() => ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register(
     { name: 'sidebar.right.pane.tab', key: id, locale: namespace,
       inject: (sessionId): TerminalBodyInjected => ({ ...inject(sessionId), hooks: { theme } }),
-    }, TerminalBody,
+    }, LazyTerminalBody,
   )), 'ui-sidebar-terminal.body')
   ctx.effect(() => ctx.slots.inject('sidebar.right.pane.tab.title', () => ctx.slots.register(
     { name: 'sidebar.right.pane.tab.title', key: id, locale: namespace, inject }, TerminalTitle,

+ 0 - 2
packages/client/ui-sidebar-terminal/src/client/TerminalBody.module.css → packages/client/ui-sidebar-terminal/src/client/terminal.module.css

@@ -1,6 +1,4 @@
 .root { display: flex; flex: 1; flex-direction: column; box-sizing: border-box; height: 100%; min-height: 0; padding-top: 8px; color: var(--dsw-alias-label-primary); font-size: 13px; }
-.title { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-.name { width: 120px; min-width: 48px; max-width: 100%; padding: 0 4px; color: inherit; font: inherit; background: var(--dsw-alias-bg-l1); border: 0.5px solid var(--dsw-alias-border-l3); border-radius: 4px; outline: none; }
 .screen { flex: 1; min-height: 0; min-width: 0; overflow: hidden; padding: 8px; color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-base); }
 .screen :global(.xterm) { height: 100%; }
 .screen :global(.xterm-viewport) { background: var(--dsw-alias-bg-base); }

+ 1 - 1
packages/client/ui-sidebar-terminal/src/client/TerminalBody.tsx → packages/client/ui-sidebar-terminal/src/client/terminal.tsx

@@ -10,7 +10,7 @@ import type { TerminalBodyInjected } from './face.ts'
 import type {} from './locales.ts'
 import { TerminalGuideIcon } from './TerminalIcon.tsx'
 import '@xterm/xterm/css/xterm.css'
-import css from './TerminalBody.module.css'
+import css from './terminal.module.css'
 import { TerminalTheme } from './terminal-theme.ts'
 import { observeTerminalCursor } from './terminal-cursor.ts'
 

+ 2 - 2
packages/client/ui-sidebar-terminal/tests/apply.client.spec.ts

@@ -13,7 +13,7 @@ import type { SidebarRightOpenTab } from '@deepseek-ai/dsh-client-ui-sidebar-rig
 import { apply, inject } from '../src/client/index.ts'
 import { apply as hostApply } from '../src/index.ts'
 import { TerminalGuide, type TerminalGuideInjected } from '../src/client/TerminalGuide.tsx'
-import { TerminalBody } from '../src/client/TerminalBody.tsx'
+import { LazyTerminalBody } from '../src/client/LazyTerminalBody.tsx'
 import { TerminalTitle } from '../src/client/TerminalTitle.tsx'
 import { TerminalRecovery, type TerminalRecoveryInjected } from '../src/client/TerminalRecovery.tsx'
 import { TerminalCleanup, type TerminalCleanupInjected } from '../src/client/TerminalCleanup.tsx'
@@ -89,7 +89,7 @@ it('registers terminal views, recovery and cleanup, then releases every contribu
     expect(h.dictionaries.get('sidebarTerminal')).toEqual({ en, zh })
     expect(h.entries.map(entry => [entry.name, entry.component, entry.locale])).toEqual([
       ['sidebar.right.tab.guide.entry', TerminalGuide, 'sidebarTerminal'],
-      ['sidebar.right.pane.tab', TerminalBody, 'sidebarTerminal'],
+      ['sidebar.right.pane.tab', LazyTerminalBody, 'sidebarTerminal'],
       ['sidebar.right.pane.tab.title', TerminalTitle, 'sidebarTerminal'],
       ['conversation.session.header.actions', TerminalRecovery, 'sidebarTerminal'],
       ['shell.overlay', TerminalCleanup, 'sidebarTerminal'],

+ 18 - 0
packages/client/ui-sidebar-terminal/tests/bundle-split.client.spec.ts

@@ -0,0 +1,18 @@
+import { existsSync, readFileSync } from 'node:fs'
+import { join, resolve } from 'node:path'
+import { describe, expect, it } from 'vitest'
+
+const packageRoot = resolve(import.meta.dirname, '..')
+const entryPath = join(packageRoot, 'lib/client.js')
+const terminalPath = join(packageRoot, 'lib/client.terminal.js')
+
+describe('terminal client artifacts', () => {
+  it.skipIf(!existsSync(entryPath) || !existsSync(terminalPath))('keeps xterm outside the startup bundle', () => {
+    const entry = readFileSync(entryPath, 'utf8')
+    const terminal = readFileSync(terminalPath, 'utf8')
+    expect([...entry.matchAll(/require\("(\.\/client[^"/]*\.js)"\)/gu)].map(match => match[1]))
+      .toEqual(['./client.terminal.js'])
+    expect(entry).not.toContain('/@xterm+xterm@')
+    expect(terminal).toContain('/@xterm+xterm@')
+  })
+})

+ 1 - 1
packages/client/ui-sidebar-terminal/tests/terminal-body.client.spec.tsx

@@ -8,7 +8,7 @@ import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client'
 import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
 import type { TerminalViewState } from '@deepseek-ai/dsh-api-terminal-controller/client'
 import type { WebTerminalId } from '@deepseek-ai/dsh-api-terminal-controller/types'
-import { TerminalBody, type TerminalBodyProps } from '../src/client/TerminalBody.tsx'
+import { TerminalBody, type TerminalBodyProps } from '../src/client/terminal.tsx'
 import { TerminalTitle } from '../src/client/TerminalTitle.tsx'
 import { en, zh } from '../src/client/locales.ts'
 

+ 4 - 0
scripts/client-build-environment.client.spec.ts

@@ -275,6 +275,10 @@ describe('client build environment', () => {
 
     write(join(official, 'apps/web/dist/index.html'), '<main>changed</main>')
     expect(() => { readClientBuildRecord(official) }).toThrow(/artifacts differ/)
+
+    const chunked = buildFixture(officialEnvironment)
+    write(join(chunked, 'packages/client/example/lib/client.pdf.js'), 'module.exports = {}\n')
+    expect(() => { readClientBuildRecord(chunked) }).toThrow(/artifacts differ/)
   })
 
   it('keeps public client values out of workflow-wide environments', () => {

+ 2 - 0
scripts/client-build-environment.ts

@@ -36,6 +36,8 @@ const CLIENT_ARTIFACT_PATTERNS = [
   'apps/web/dist/**/*',
   'packages/*/*/lib/client.js',
   'packages/*/*/lib/client.js.map',
+  'packages/*/*/lib/client.*.js',
+  'packages/*/*/lib/client.*.js.map',
 ] as const
 
 /** Public values embedded in one set of client artifacts. */