Merge remote-tracking branch 'origin/master' into worktree/composer-caret-binding

This commit is contained in:
creatixchu
2026-08-04 15:40:27 +08:00
114 changed files with 5341 additions and 479 deletions

View File

@@ -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: 02347f2964942b89ec1f0a6ec483f4c2b2f9e68c
2026-07-23-client-plugin-loading-model.zh.md: ea927d35860fbbba567c47cea0ee3a45133ce0f4
2026-07-23-client-plugin-loading-model.md: 2dc0c68e5f20bd790c2362f92c16dece171babf5
2026-07-23-client-plugin-loading-model.zh.md: ce7850e37b9ae2735565a35ce3de28f4f290ed04

View File

@@ -14,7 +14,9 @@ The browser client runs the same cordis plugin mechanism, so it needs the same s
Conventional frontend engineering digests all dependencies at build time: one bundle, externals resolved by the bundler, nothing left to manage at runtime. Runtime module management on top of that is the unusual requirement here. The client therefore splits into two layers: the upper layer is cordis plugin loading through the same vendored Loader, and the lower layer is module-granular dependency management — `dsh-client-modules`.
The lower layer supplies four capabilities: externals (the platform list), remote arrival (bundle fetch plus lazy factory registration), versioning (content-hash revs), and hot update (invalidate/prefetch).
The lower layer supplies four capabilities: externals (the platform list), remote arrival (same-origin external classic scripts plus lazy factory registration), versioning (content-hash revs), and hot update (invalidate/prefetch).
Plugin bundles are built independently outside Vite's module graph. Feeding response text into an inline script leaves the browser with a dynamic source execution: no standard source-map chain connects the network resource, generated bundle, and TypeScript/TSX source, so performance profiles and stacks stop at generated `client.js`; the module system must also buffer the complete source and split one arrival responsibility across fetch and execute transport seams.
On top of that, client and host plugins register and load consistently: a package declares `dshClient` once, the host scans the declaration into the boot graph, and the same Loader semantics govern entries on both sides.
@@ -46,10 +48,18 @@ Four edge rules govern imports across the two kinds. None of them depends on any
The browser mirrors the host's division of labor. `dsh-client-modules` (`ClientModuleSystem`) takes the module-system seat that Node's internal ESM loader holds host-side; the same vendored `@cordisjs/plugin-loader` keeps the governance seat on both sides. The line between them in one sentence: **the module system owns module identity and bytes — how code arrives, registers, and becomes an export surface; the Loader owns plugin lifecycle — when a plugin mounts, what it waits for, and how it is torn down.**
`ClientModuleSystem` is a lazy CJS table. Executing a bundle only **registers** its factory — the bundle calls `window.__ModuleLoader__.load({ id, factory })` and nothing else happens. Every module body side effect, CSS injection included, lives inside the factory closure and runs at materialization: the first `require`/import of that id, memoized after that. A factory that requires a registered-but-unmaterialized sibling materializes it recursively, so no sort order exists anywhere. When asked to import an id, the table resolves through a fixed branch order: seed word → memoized record → static registration (shell-own modules, e.g. app-shell) → registered factory → graph-row fetch + execute → loud throw. That final throw is the runtime mirror of the build-time purity gate. The system also keeps per-module bookkeeping — owned `<style data-plugin>` tag ids, observed require edges — and exposes the two verbs HMR needs: `prefetch(id)` (fetch + execute, registration only; concurrent calls share one in-flight task) and `invalidate(id)` (drop factory, record, and consumed text so the next arrival refetches).
`ClientModuleSystem` is a lazy CJS table. Executing a bundle only **registers** its factory — the bundle calls `window.__ModuleLoader__.load({ id, factory })` and nothing else happens. Every module body side effect, CSS injection included, lives inside the factory closure and runs at materialization: the first `require`/import of that id, memoized after that. A factory that requires a registered-but-unmaterialized sibling materializes it recursively, so no sort order exists anywhere. When asked to import an id, the table resolves through a fixed branch order: seed word → memoized record → static registration (shell-own modules, e.g. app-shell) → registered factory → graph-row external classic-script load → loud throw. That final throw is the runtime mirror of the build-time purity gate. The system also keeps per-module bookkeeping — owned `<style data-plugin>` tag ids, observed require edges — and exposes the two verbs HMR needs: `prefetch(id)` (load the script and register its factory; concurrent calls share one in-flight task) and `invalidate(id)` (drop the factory and record so the next arrival reloads it).
The vendored Loader consumes the module system through its `internal` seam — the only call site is `tree.import` — and owns everything entry-shaped: entry creation, fiber activation through cordis service waiting (PENDING until injected services exist, cascading when a service is provided), update/refresh, teardown. The governance code is byte-identical to the host side, per vendor policy. Browserization is compile-time mapping in the shell's vite config: a `node:module` stub alias plus `process.*` defines make `ModuleLoader.fromInternal()` return undefined — exactly the empty slot the shell fills. The module system mounts as `ctx.modules`.
### External-script arrival and source maps
Each graph row's `url` goes to a same-origin external classic `<script src>` with `async` set. The browser owns the network request and script execution; the node is removed as soon as `load` or `error` settles so HMR cannot accumulate dead nodes. Successful settlement also requires the graph row's factory id to exist in the module table, or arrival fails; registration still 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 shape `/packages/<group>/<package>/src/...`. Other workspace sources inlined into a bundle likewise resolve to their `packages/` owner, while dependency paths remain unchanged; `sourcesContent` carries the source, so the host only serves the map at `/plugins/<id>/client.js.map` and exposes no source route. The Vite shell also emits source maps, letting both shell code and out-of-graph plugins map stacks and performance profiles back to TypeScript/TSX.
`rev` remains the script URL's query parameter and content-consistency anchor, and the bundle and map are both served with `no-cache`. 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 handoff 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
What happens between `dsh web` starting and the UI appearing? Three stages: the host composes and serves a graph, the shell prefetches, then cordis orchestrates.
@@ -58,11 +68,11 @@ What happens between `dsh web` starting and the UI appearing? Three stages: the
1. The composing app (`apps/cli`) ships the roster as ordinary rows in its `cordis.yml` config tree — client plugin packages are entry rows like every host plugin, and `--dev` appends the `client-hmr` row in code (`AppCLIEntry`) before the host activation audit so the same check covers it. A roster row that fails to import is caught by `assertEntriesLoaded`; a row whose fiber rejects is reported with its original stack by `assertEntriesActivated` ([host boot decision](2026-07-24-web-config-tree-boot-and-transport-layering.md)).
2. The `dsh-client-modules` node half (the package is dual-face: its browser half is the module table) scans loader entries' package.json `dshClient` declarations and composes `window.__DSH_BOOT__`: `{ rev, entries: [{ id, url, rev, inject?, immediately? }] }`. The `inject` edges and the `immediately` mark come from manifests, never hand-copied. It refuses declared plugins without built `./client` bundles and groups their package/path rows under one required source-build instruction; malformed declaration fields also fail activation, and the host audit reports either error from the FAILED fiber.
3. Scanning is incremental per package — there is no full-rescan code path. Each cordis `internal/plugin` emission marks the fiber's entry name dirty (entry-less fibers drop O(1)); a microtask flush reconciles each dirty name against live loader entries, with package metadata (including the negative "not a client package" verdict) cached per name forever and bundle re-hashing reachable only through `rebuilt(id)`. The activation pass seeds the same dirty set from current entries and flushes synchronously, so first scan and steady state share one implementation. Each bundle's content hash is its `rev` (cache busting + HMR diff anchor), the row set hashes into `graph.rev`, and every row is fetch-served: `/plugins/<id>/client.js?rev=…`. The graph types are single-sourced in the modules package's `./impl` export — the webserver knows nothing about the graph (it is a plain route-registration plugin; modules registers the bundle route and taps the index render itself).
3. Scanning is incremental per package — there is no full-rescan code path. Each cordis `internal/plugin` emission marks the fiber's entry name dirty (entry-less fibers drop O(1)); a microtask flush reconciles each dirty name against live loader entries, with package metadata (including the negative "not a client package" verdict) cached per name forever and bundle re-hashing reachable only through `rebuilt(id)`. The activation pass seeds the same dirty set from current entries and flushes synchronously, so first scan and steady state share one implementation. Each bundle's content hash is its `rev` (cache busting + HMR diff anchor), the row set hashes into `graph.rev`, and every row is served as a script resource at `/plugins/<id>/client.js?rev=…`, with its source map at the same path plus `.map`. The graph types are single-sourced in the modules package's `./client` export — the webserver knows nothing about the graph (it is a plain route-registration plugin; modules registers the bundle route and taps the index render itself).
Why is the roster yml rows and not a scan? Because which plugins compose into a deployment is a composition decision, not a package property — a dshClient package existing in the repo does not mean this deployment mounts it, so discovery-by-scan cannot make that call; the node half scans only what the tree actually mounted.
**Phase one — the module face.** The shell builds the module system over the graph, then prefetches every `immediately` row in parallel. Prefetch is fetch + execute, which registers factories only. A single row's prefetch failure is swallowed here: phase two's import retries the fetch and owns the loud failure, so one bad row cannot mask the others. `immediately` is a prefetch mark — not a barrier, not an identity. The package declares it, the registry carries it into the row. The infrastructure plugins (connection, runtime, ui-theme, i18n, plus hmr) declare it; UI plugins simply arrive on demand.
**Phase one — the module face.** The shell builds the module system over the graph, then prefetches every `immediately` row in parallel. Prefetch loads the external script and registers its factory only. A single row's prefetch failure is swallowed here: phase two's import retries the load and owns the loud failure, so one bad row cannot mask the others. `immediately` is a prefetch mark — not a barrier, not an identity. The package declares it, the registry carries it into the row. The infrastructure plugins (connection, runtime, ui-theme, i18n, plus hmr) declare it; UI plugins simply arrive on demand.
**Phase two — the plugin face.**
@@ -81,7 +91,7 @@ How does a rebuilt bundle become a reload signal? The hmr node half observes it
On the browser side, the driver reloads one plugin per frame, serialized:
1. `invalidate` — drop the stale factory and record. A live factory would make the next step a no-op.
2. `prefetch`fetch + execute + register the fresh factory, while the old fiber still serves.
2. `prefetch`load the external script and register the fresh factory, while the old fiber still serves.
3. `registry.delete` — before touching the fiber. A bare fiber dispose trips the vendored Loader's self-dispose branch, which would disable the entry permanently.
4. Drain the old fiber's disposers.
5. Remove owned `<style data-plugin>` tags.
@@ -112,9 +122,9 @@ The support boundary, stated honestly. Reload is coarse by design: fresh fiber,
## Consequences
One governance implementation runs on both sides of the wire; the browser-specific surface is one module system plus one reload plugin. Plugin packages have one shape, so the purity gate covers them all. Dependency edges and the boot tier live with their owners — the manifests — while the composing app holds only the roster and the `--dev` switch. The drift classes stay structurally closed: share-list hand-sync, load-order coupling, cross-plugin imports, roster/tier double bookkeeping.
One governance implementation runs on both sides of the wire; the browser-specific surface is one module system plus one reload plugin. Plugin packages have one shape, so the purity gate covers them all. Dependency edges and the boot tier live with their owners — the manifests — while the composing app holds only the roster and the `--dev` switch. The drift classes stay structurally closed: share-list hand-sync, load-order coupling, cross-plugin imports, roster/tier double bookkeeping. Browser-native script loading preserves the standard mapping among plugin network resources, generated bundles, and TypeScript/TSX sources, while the module system keeps only one replaceable `loadBundle` seam.
Costs accepted: the vendored Loader carries idle machinery in the browser (EntryTree persistence is a no-op, groups/isolation unused); every plugin edit in dev pays a bundle rebuild plus fiber remount; graph `inject` rows are informational — activation truth is service-level — so a mismatch surfaces at the settled sweep, not at graph validation; and the three not-yet-promoted libraries keep their static-import export surface until their DI conversions land.
Costs accepted: the vendored Loader carries idle machinery in the browser (EntryTree persistence is a no-op, groups/isolation unused); every plugin edit in dev pays a bundle rebuild plus fiber remount; graph `inject` rows are informational — activation truth is service-level — so a mismatch surfaces at the settled sweep, not at graph validation; the three not-yet-promoted libraries keep their static-import export surface until their DI conversions land; every bundle gains a source-map artifact; and external-script failures provide only coarse URL diagnostics instead of the HTTP status available to an explicit fetch.
Roster endgame (landed 2026-07-25 with the config-tree boot move): the roster lives in `apps/cli/config/web.cordis.yml`, `mountWebPlugins` and the `CLIENT_PACKAGES` constant are gone, and recomposing a deployment means swapping the yml/overlay. The graph composer moved from a webserver-side registry into the `dsh-client-modules` node half (the package upgraded to dual-face per this note's promotion rule — its consumer now reaches it through cordis DI), and the transport split landed alongside: the webserver became a plain route-registration plugin, `/api/*` binding moved to the connection node half over the upgraded `api-gateway` plugin (`dsh-host-apiproxy` providing `ctx.apiProxy`), and the dev bundle watch + SSE channel moved to the hmr node half.
@@ -129,4 +139,5 @@ Roster endgame (landed 2026-07-25 with the config-tree boot move): the roster li
| Import maps | Ruled out earlier; the DI require table is the terminal mechanism |
| Full ctx-ification now (react and libraries via services, no module table) | The module-axis extreme; parked — the upgrade law walks there one package at a time instead |
| Eager instantiation with a frozen table | Requires arrival-time ordering; lazy CJS registration makes recursive `require` self-ordering and matches the naive-puller phase split |
| Fetch response text, then inject an inline `<script>` | Makes the module system buffer the complete source and maintain separate fetch/execute seams; dynamic source execution also breaks the browser-native association among the network resource, source map, and profile |
| Builder-push rebuild channel (`POST /plugins/rebuilt` from the orchestrator's `onSuccess`) | Couples reload to one blessed builder process and a second wire protocol; the webserver already holds every bundle path, and stat polling covers the torn-write race (re-hash on every stat change) that once justified pushing |

View File

@@ -14,7 +14,9 @@ host 侧cordis 插件装载站在 Node 的模块机制之上——require cac
常规前端工程在构建期消化全部依赖:单一 bundleexternal 由打包器解决运行时无物可管。在此之上再做运行时模块管理正是这里的特殊需求。client 因此拆成两层:上层是经同一份 vendored Loader 的 cordis 插件装载,下层是模块粒度的依赖管理——`dsh-client-modules`
下层供给四项能力external平台清单、远程到达bundle 拉取加惰性工厂登记)、版本化(内容哈希 rev、热更新invalidate/prefetch
下层供给四项能力external平台清单、远程到达同源外部 classic script 加惰性工厂登记)、版本化(内容哈希 rev、热更新invalidate/prefetch
插件 bundle 独立构建在 Vite 模块图之外。若把响应文本塞进内联 script浏览器只能看到一次动态源码执行网络资源、生成 bundle、TypeScript/TSX 源码之间没有标准 sourcemap 链,性能 profile 与 stack 只能落到生成后的 `client.js`;模块系统还要持有整份源码文本,并把同一项到达职责拆成 fetch 与 execute 两道传输 seam。
在此之上client 与 host 插件以一致的方式注册与装载:包声明一次 `dshClient`host 把声明扫描进 boot 图,同一套 Loader 语义在两侧治理 entry。
@@ -46,10 +48,18 @@ manifest 拥有包的装载契约:它的 `inject` 依赖边,加可选的 `im
浏览器复刻 host 侧的分工。`dsh-client-modules``ClientModuleSystem`)坐上 host 侧由 Node 内部 ESM loader 占据的模块系统席位;同一份 vendored `@cordisjs/plugin-loader` 在两侧都坐治理席。二者的分界线一句话说尽:**模块系统拥有模块身份与字节——代码怎么到达、怎么登记、怎么变成导出面Loader 拥有插件生命周期——插件何时挂载、等待什么、如何拆除。**
`ClientModuleSystem` 是一张 lazy CJS 表。执行 bundle 只**登记**其工厂——bundle 调用 `window.__ModuleLoader__.load({ id, factory })`,此外什么都不发生。模块体的一切副作用(包括 CSS 注入)都住在工厂闭包里,在物化时运行:物化即该 id 的首次 `require`/import此后记忆化。工厂若 require 一个已登记未物化的同伴,就递归物化它,因此任何地方都不存在排序。被要求 import 一个 id 时,表按固定分支顺序解析:种子词条 → 记忆化的记录 → 静态登记(壳自有模块,如 app-shell→ 已登记的工厂 → 图行 fetch + 执行 → 大声抛错。最后这一抛是构建期纯度门禁在运行期的镜像。系统还保管逐模块的簿记——名下 `<style data-plugin>` 标签 id、观测到的 require 边——并暴露 HMR热模块替换需要的两个动词`prefetch(id)`fetch + 执行、只登记;并发调用共享同一在途任务)与 `invalidate(id)`(丢弃工厂记录与已消费文本,下次到达即重新拉取)。
`ClientModuleSystem` 是一张 lazy CJS 表。执行 bundle 只**登记**其工厂——bundle 调用 `window.__ModuleLoader__.load({ id, factory })`,此外什么都不发生。模块体的一切副作用(包括 CSS 注入)都住在工厂闭包里,在物化时运行:物化即该 id 的首次 `require`/import此后记忆化。工厂若 require 一个已登记未物化的同伴,就递归物化它,因此任何地方都不存在排序。被要求 import 一个 id 时,表按固定分支顺序解析:种子词条 → 记忆化的记录 → 静态登记(壳自有模块,如 app-shell→ 已登记的工厂 → 图行外部 classic script 加载 → 大声抛错。最后这一抛是构建期纯度门禁在运行期的镜像。系统还保管逐模块的簿记——名下 `<style data-plugin>` 标签 id、观测到的 require 边——并暴露 HMR热模块替换需要的两个动词`prefetch(id)`加载脚本、只登记工厂;并发调用共享同一在途任务)与 `invalidate(id)`(丢弃工厂记录,下次到达即重新加载)。
vendored Loader 经其 `internal` seam 消费模块系统——唯一调用点是 `tree.import`——并拥有一切 entry 形状的事务entry 创建、fiber 经 cordis 服务等待的激活(注入的服务未就位即保持 PENDING服务 provide 时级联激活、update/refresh、拆除。治理代码按 vendor 政策与 host 侧逐字节相同。浏览器化是壳 vite 配置里的编译期映射:一个 `node:module` stub 别名加若干 `process.*` define使 `ModuleLoader.fromInternal()` 返回 undefined——这正是留给壳来填的空槽。模块系统挂载为 `ctx.modules`
### 外部脚本到达与源码映射
每个图行的 `url` 交给一个带 `async` 的同源外部 classic `<script src>`。浏览器拥有网络请求与脚本执行;`load``error` 结算后节点立即移除,避免 HMR 累积失效节点。成功结算还要求图行对应的工厂 id 已出现在模块表中,否则到达失败;登记仍不运行工厂,副作用边界继续落在首次物化。
共享 tsdown 预设为每个插件产出 `client.js.map`,并把第一方源码路径重写成浏览器可识别的仓库形状 `/packages/<group>/<package>/src/...`。内联进 bundle 的其他 workspace 源码同样回到其 `packages/` 归属,依赖包路径保持原样;`sourcesContent` 承载源码,因此 host 只需在 `/plugins/<id>/client.js.map` 供给 map无需开放源码路由。Vite 壳也产出 sourcemap使壳代码与图外插件都能从 stack 和性能 profile 回到 TypeScript/TSX。
`rev` 继续作为脚本 URL 的查询参数和内容一致性锚点bundle 与 map 都以 `no-cache` 供给。外部脚本的 `error` 事件不给响应状态与正文,因此失败诊断只报告 URL同源 host 供给与构建期写入的 handoff id 是身份边界,`load` 后的工厂存在性检查负责拒绝未登记预期 id 的产物。
### 装载流程,端到端
`dsh web` 启动到 UI 出现之间发生了什么三个阶段host 组合并供给一张图,壳预取,然后 cordis 编排。
@@ -58,11 +68,11 @@ vendored Loader 经其 `internal` seam 消费模块系统——唯一调用点
1. 负责组合的 app`apps/cli`)把名册作为普通行放进它的 `cordis.yml` 配置树——client 插件包与每个 host 插件一样是 entry 行,`--dev` 由代码(`AppCLIEntry`)在 host 激活检查之前追加 `client-hmr` 行,使同一项检查覆盖它。名册行 import 失败由 `assertEntriesLoaded` 捕获fiber reject 的行则由 `assertEntriesActivated` 报告原始 stack[host boot 决策](2026-07-24-web-config-tree-boot-and-transport-layering.md))。
2. `dsh-client-modules` 的 node 半(该包是双面的:浏览器半就是模块表)扫描 loader entry 的 package.json `dshClient` 声明,组合出 `window.__DSH_BOOT__``{ rev, entries: [{ id, url, rev, inject?, immediately? }] }``inject` 边与 `immediately` 标记都来自 manifest永不人肉抄写。它会拒绝没有已构建 `./client` bundle 的已声明插件,并把它们的 package/path 行归到一条源码构建要求下畸形声明字段同样会让激活失败host 检查会从 FAILED fiber 报告这两类错误。
3. 扫描是单包增量——不存在全量重扫代码路径。每次 cordis `internal/plugin` 发射把该 fiber 的 entry 名标脏(无 entry 的 fiber O(1) 丢弃);微任务 flush 把每个脏名对账 live loader entries包元数据含「非 client 包」的否定结论按名永久缓存bundle 重哈希只经 `rebuilt(id)` 可达。激活趟从当前 entries 灌同一脏集合并同步 flush初扫与稳态共享一条实现。每个 bundle 的内容哈希是其 `rev`(缓存失效 + HMR diff 锚点),行集合哈希进 `graph.rev`,每一行都经 fetch 供给:`/plugins/<id>/client.js?rev=…`。图类型单源在 modules 包的 `./client` 出口——webserver 对图一无所知它是朴素路由注册插件bundle 路由和 index 渲染 tap 都由 modules 自己注册)。
3. 扫描是单包增量——不存在全量重扫代码路径。每次 cordis `internal/plugin` 发射把该 fiber 的 entry 名标脏(无 entry 的 fiber O(1) 丢弃);微任务 flush 把每个脏名对账 live loader entries包元数据含「非 client 包」的否定结论按名永久缓存bundle 重哈希只经 `rebuilt(id)` 可达。激活趟从当前 entries 灌同一脏集合并同步 flush初扫与稳态共享一条实现。每个 bundle 的内容哈希是其 `rev`(缓存失效 + HMR diff 锚点),行集合哈希进 `graph.rev`,每一行都作为脚本资源供给:`/plugins/<id>/client.js?rev=…`,对应 sourcemap 位于同一路径加 `.map`。图类型单源在 modules 包的 `./client` 出口——webserver 对图一无所知它是朴素路由注册插件bundle 路由和 index 渲染 tap 都由 modules 自己注册)。
为什么名册是 yml 行而不是扫描?因为哪些插件组合进一次部署是组合决策,不是包属性——一个 dshClient 包存在于仓库里不代表这次部署要挂载它扫描发现无从替人做这个决定node 半只扫描配置树实际挂载了的东西。
**第一层——模块面。**壳在图之上建起模块系统,然后并行预取每个 `immediately` 行。预取即 fetch + 执行,只登记工厂。单行预取失败在这里被吞下:第二层 import 时会重试 fetch 并拥有那次大声失败,因此一个坏行藏不住其他行。`immediately` 是预取标记——不是屏障不是身份。包声明它注册表把它带进图行。基础设施插件connection、runtime、ui-theme、i18n外加 hmr声明它UI 插件则径直按需到达。
**第一层——模块面。**壳在图之上建起模块系统,然后并行预取每个 `immediately` 行。预取即加载外部脚本,只登记工厂。单行预取失败在这里被吞下:第二层 import 时会重试加载并拥有那次大声失败,因此一个坏行藏不住其他行。`immediately` 是预取标记——不是屏障不是身份。包声明它注册表把它带进图行。基础设施插件connection、runtime、ui-theme、i18n外加 hmr声明它UI 插件则径直按需到达。
**第二层——插件面。**
@@ -81,7 +91,7 @@ vendored Loader 经其 `internal` seam 消费模块系统——唯一调用点
浏览器侧,驱动插件每帧重载一个插件,串行执行:
1. `invalidate`——丢弃陈旧的工厂与记录。工厂还活着会让下一步变成 no-op。
2. `prefetch`——fetch + 执行 + 登记新工厂,旧 fiber 此刻仍在服役。
2. `prefetch`——加载外部脚本并登记新工厂,旧 fiber 此刻仍在服役。
3. `registry.delete`——先于任何 fiber 操作。裸做 fiber dispose 会触发 vendored Loader 的自 dispose 分支,把 entry 永久停用。
4. 排空旧 fiber 的各 disposer。
5. 移除名下的 `<style data-plugin>` 标签。
@@ -112,9 +122,9 @@ vendored Loader 经其 `internal` seam 消费模块系统——唯一调用点
## Consequences
wire 两侧跑着同一份治理实现浏览器特有的表面只是一套模块系统加一个重载插件。插件包只有一种形态纯度门禁因此覆盖全部插件。依赖边与启动档位都与其所有者——manifest——同住负责组合的 app 只握名册与 `--dev` 开关。各漂移缺陷类被结构性关死:共享清单人肉同步、装载顺序耦合、跨插件 import、名册/档位双重记账。
wire 两侧跑着同一份治理实现浏览器特有的表面只是一套模块系统加一个重载插件。插件包只有一种形态纯度门禁因此覆盖全部插件。依赖边与启动档位都与其所有者——manifest——同住负责组合的 app 只握名册与 `--dev` 开关。各漂移缺陷类被结构性关死:共享清单人肉同步、装载顺序耦合、跨插件 import、名册/档位双重记账。浏览器原生脚本装载使插件网络资源、生成 bundle 与 TypeScript/TSX 源码保持标准映射,模块系统也只保留一道可替换的 `loadBundle` seam。
接受的代价vendored Loader 在浏览器里背着闲置机件EntryTree 持久化是 no-op分组/隔离未用);开发期每次修改插件都要付一次 bundle 重建加 fiber 重挂;图中 `inject` 行仅是信息性说明——激活的真相在服务层——因此不匹配会在 settled 扫描时浮出,而不是在图校验时被拦下;三个尚未升格的库在各自的 DI 转换落地之前保持静态 import 的导出面。
接受的代价vendored Loader 在浏览器里背着闲置机件EntryTree 持久化是 no-op分组/隔离未用);开发期每次修改插件都要付一次 bundle 重建加 fiber 重挂;图中 `inject` 行仅是信息性说明——激活的真相在服务层——因此不匹配会在 settled 扫描时浮出,而不是在图校验时被拦下;三个尚未升格的库在各自的 DI 转换落地之前保持静态 import 的导出面;每个 bundle 多出一份 sourcemap 产物,外部脚本失败也只能给出粗粒度的 URL 诊断,不能像显式 fetch 那样报告 HTTP 状态
名册的终局2026-07-25 随配置树 boot 迁移落地):名册住 `apps/cli/config/base.cordis.yml``apps/cli/config/web.cordis.yml``mountWebPlugins``CLIENT_PACKAGES` 常量已消失,重组一次部署等于换 yml/overlay。图的组合器从 webserver 侧的注册表迁进 `dsh-client-modules` 的 node 半(该包按本 note 的升级法则升格为双面——其消费方现经 cordis DI 到达传输拆分同轮落地webserver 变为朴素路由注册插件,`/api/*` 绑定迁到 connection 的 node 半、走升格后的 `api-gateway` 插件(`dsh-host-apiproxy` 提供 `ctx.apiProxy`dev 的 bundle 监视与 SSE 通道迁到 hmr 的 node 半。
@@ -129,4 +139,5 @@ wire 两侧跑着同一份治理实现;浏览器特有的表面只是一套模
| import map | 早已排除DI require 表是终局机制 |
| 现在就彻底 ctx 化react 与库全走服务,不设模块表) | 模块轴上的极端形态;搁置——升级法则改为一次一包走向它 |
| 冻结表 + 到达即实例化 | 要求按到达时刻排序lazy CJS 登记让递归 `require` 自行定序,且与朴素拉取器的分层相合 |
| fetch 响应文本后注入内联 `<script>` | 模块系统必须缓冲整份源码并维护 fetch/execute 两道 seam动态源码执行也切断浏览器网络资源、sourcemap 与 profile 的原生关联 |
| 构建器推送重建通道(编排器在 `onSuccess` 里 POST `/plugins/rebuilt` | 把重载耦合到一个钦定的构建器进程和第二套 wire 协议webserver 本就握有每个 bundle 路径stat 轮询(每次 stat 变化即重哈希)已兜住当年为推送辩护的撕裂写竞态 |

View File

@@ -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/bug-fix/2026-07-29-sticky-composer-conversation-scroll.md
2026-07-29-sticky-composer-conversation-scroll.md: 7ceae95dafffdb756ef49bb5612cd4e711eb59ca
2026-07-29-sticky-composer-conversation-scroll.zh.md: d925d82f94635b5fe67b0be119c041d003def393
2026-07-29-sticky-composer-conversation-scroll.md: 69d46894a53b0113f3e4f0fe871bbf3f9697969b
2026-07-29-sticky-composer-conversation-scroll.zh.md: c0d5a0640468207282316ecd2fa1f209708df7b5

View File

@@ -14,6 +14,8 @@ While a session exists, `ConversationRoot` always supplies a `wrapActiveBody` ow
Session stats live on `'conversation.composer.dock'` (above `'conversation.input.dock'`). The InputBar textarea, when inside the host, chains `wheel` with `{ passive: false }`: while the capped textarea can still scroll in that direction it keeps the native gesture; only at its own edge does it `preventDefault` and apply `deltaY` to the host.
Chat history prepend follows reader intent through stable rendered node/call identities rather than whole-scrollport height deltas. `ChatView` records the first visible `data-chat-anchor-key` and its top relative to the scrollport when paging starts, reselects the currently visible stable anchor after every reader scroll while the request is in flight, and compensates by that row's post-prepend rectangle delta. Reaching the bottom or appending the reader's own message cancels the paging anchor, so a late page cannot pull the view away from the newest content. Bottom follow is stored state rather than raw scroll geometry. A passive wheel listener takes its pre-input baseline from the last main-thread-delivered or programmatically written `scrollTop`, because Chromium may advance compositor geometry before delivering the event; the current non-negative floor excludes a concurrent layout clamp from reader movement. A scroll without matching wheel movement re-pins while following and only refreshes the semantic position while reading. ChatView's single `ResizeObserver` follows streaming, tool disclosure, and draft resize only while bottom ownership remains pinned, without a second per-chunk scroll write.
## Alternatives considered
**Sticky header and sticky composer inside one column scrollport.** Rejected for the header: it must occupy the top as fixed layout chrome, not participate in the scrollport's sticky layer.
@@ -24,6 +26,8 @@ Session stats live on `'conversation.composer.dock'` (above `'conversation.input
**Keep StatsLine inside ChatView below the message column.** Rejected: outside the sticky composer it would scroll away while the input stayed pinned.
**Model every browser scroll input source.** Rejected for this narrow fix: the reproduced desktop path uses wheel/trackpad input. Pointer/touch scrolling, native-scrollbar dragging, keyboard scrolling, focus navigation, and nested overflow ownership remain outside the provenance model instead of adding a general input state machine.
## Consequences
Wheel over the footer scrolls the transcript; the visible layout is a fixed header, scrolling transcript, and sticky bottom composer. Stats appear on every active view tab. Nested view scrollers under the host are suppressed so sticky Turn headers in Trajectory stick to the column host. Hero → active keeps the same textarea DOM node (assembled slash-flow snapshot) and the InputHub draft.
Wheel over the footer scrolls the transcript; the visible layout is a fixed header, scrolling transcript, and sticky bottom composer. Stats appear on every active view tab. Nested view scrollers under the host are suppressed so sticky Turn headers in Trajectory stick to the column host. Concurrent history, streaming, tool expansion, and composer reflow preserve wheel/trackpad scroll decisions, including Chromium's compositor-first delivery and stream-finalization clamp/regrow. Other browser scroll inputs do not change follow ownership under this narrow provenance rule. Hero → active keeps the same textarea DOM node (assembled slash-flow snapshot) and the InputHub draft.

View File

@@ -14,6 +14,8 @@ Status: implemented
会话统计挂在 `'conversation.composer.dock'`(位于 `'conversation.input.dock'` 之上。InputBar 的 textarea 在宿主内以 `{ passive: false }` 链式处理 `wheel`:在限高 textarea 仍能沿该方向滚动时保留原生手势;仅在自身边缘才 `preventDefault` 并将 `deltaY` 施加到宿主。
Chat 历史前插通过稳定的已渲染 nodecall 身份跟随读者意图,而不是使用整个滚动容器的高度差。分页开始时,`ChatView` 记录第一个可见的 `data-chat-anchor-key` 及其相对滚动容器的顶部位置请求在途期间每次读者滚动都会重新选择当前可见的稳定锚点页面到达后则按该行矩形的前后差值补偿。到达底部或追加读者自己的消息会取消分页锚点因此迟到的页面不能把视图从最新内容拉走。贴底跟随采用存储状态而不是原始滚动几何状态。passive wheel 监听器以最近一次由主线程交付或由程序写入的 `scrollTop` 作为输入前基线,因为 Chromium 可能先推进合成器几何状态,之后才交付事件;当前使用的非负下限不会将并发的布局钳制计入读者移动。没有对应滚轮/触控板输入位移的滚动,在跟随状态下会重新贴底,在阅读状态下则只刷新语义位置。`ChatView` 的单个 `ResizeObserver` 只会在贴底所有权仍保持时跟随流式输出、工具展开与草稿尺寸变化,且每个 chunk 不会触发第二次滚动写入。
## Alternatives considered
**标题栏与编辑器都在同一列滚动容器内 sticky。** 标题栏否决:它必须作为固定布局 chrome 占据顶部,而不是参与滚动容器的 sticky 层。
@@ -24,6 +26,8 @@ Status: implemented
**把 StatsLine 留在 ChatView 消息列下方。** 否决:落在 sticky 编辑器之外会随内容滚走,而输入区仍钉在底部。
**为每一种浏览器滚动输入来源建模。** 此次窄范围修复不采用:已复现的桌面端路径使用滚轮/触控板输入。指针/触控滚动、拖动原生滚动条、键盘滚动、焦点导航与嵌套 overflow 所有权仍不纳入输入来源模型,也不为此新增通用输入状态机。
## Consequences
在页脚上滚轮会滚动 transcript可见布局是固定标题栏、可滚动 transcript 与 sticky 底部编辑器。统计出现在每一个活跃视图标签上。宿主下的嵌套视图 scroller 被抑制,因而 Trajectory 的 sticky Turn 标题贴在列宿主上。hero → active 保持同一 textarea DOM 节点assembled slash-flow 快照)以及 InputHub 草稿。
在页脚上滚轮会滚动 transcript可见布局是固定标题栏、可滚动 transcript 与 sticky 底部编辑器。统计出现在每一个活跃视图标签上。宿主下的嵌套视图 scroller 被抑制,因而 Trajectory 的 sticky Turn 标题贴在列宿主上。并发历史加载、流式输出、工具展开与编辑器重排会保留滚轮/触控板的滚动决定,包括 Chromium 先推进合成器几何状态再交付事件,以及流收尾阶段滚动位置受钳制后滚动容器重新增长的情况。在这条窄范围的输入来源规则下,其他浏览器滚动输入不会改变贴底跟随所有权。hero → active 保持同一 textarea DOM 节点assembled slash-flow 快照)以及 InputHub 草稿。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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/bug-fix/2026-08-03-cli-signal-shutdown-escalation.md
2026-08-03-cli-signal-shutdown-escalation.md: 2746b5784baad0f3b14258280cd56a621db07c15
2026-08-03-cli-signal-shutdown-escalation.zh.md: 0bda83327d4cc8fe2edb61f8145a89138610901e

View File

@@ -0,0 +1,52 @@
# Agent Note: Bounded, escalating signal shutdown for Web and headless
Status: implemented
English | [中文](2026-08-03-cli-signal-shutdown-escalation.zh.md)
## Problem
The default telemetry mount added SIGINT/SIGTERM handlers to `dsh web` and `dsh -p` so process exit could drain the Cordis tree instead of dropping queued telemetry. Each handler used a one-way boolean latch and exited only after `ctx.fiber.dispose()` settled. Headless normal completion also awaited that disposal without a bound.
A user then reproduced `dsh -p` hanging immediately after the observation URL and ignoring repeated `Ctrl+C`; `DSH_TELEMETRY_DISABLED=1` removed the hang, while a standalone Node handler in the same Linux sandbox received SIGINT. This isolated the pending disposer to telemetry rather than terminal signal forwarding. OTel's `BatchLogRecordProcessor.shutdown()` awaits `exporter.forceFlush()` before the `exportTimeoutMillis`-bounded completion promise, and the OTLP exporter's `forceFlush()` waits directly on its in-flight HTTP Promise. A proxy/sandbox connection that never obtains a socket can therefore leave provider shutdown pending despite both configured SDK timeouts.
The latch then turned that telemetry defect into an unkillable CLI: normal completion was already awaiting the single-shot root disposal; the first SIGINT joined the same pending disposal and set the signal latch; later SIGINTs returned at the latch, so the process had no remaining escape. A signal received before normal completion had the same unbounded wait. Web used the same latch shape.
Telemetry's own timeouts cannot prove that the whole plugin tree settles. Any current or future disposer can wedge, and the process boundary must preserve both a graceful first attempt and a user-controlled way out.
## Decision
The fix has two ownership layers. The OTel backend adds `shutdownTimeoutMillis` (default and shipped value: three seconds) around the SDK provider's complete shutdown Promise. Crossing it rejects into the telemetry coordinator's existing contained-failure path, allowing the Cordis tree to finish disposal; pending records may be lost because OTel exposes no cancellation for the transport Promise.
Web and headless share `createProcessShutdown`, one process-level controller around root disposal:
- Normal shutdown calls coalesce onto one disposal and retain the first requested exit code; they never escalate one another.
- The first signal starts the same graceful disposal and a referenced five-second exit backstop. Disposal success or failure exits once; neither can cancel the process exit.
- A signal received while shutdown is pending forces immediate exit with that signal path's code. This includes the first `Ctrl+C` after headless normal completion has already entered disposal, and a second signal after a signal initiated the drain.
- The five-second bound is a process-safety invariant, not a deployment tunable. It is long enough for the telemetry deployment's ordinary drain ceiling while still bounding any wedged disposer at the launcher boundary.
Headless preserves exit 0 for a completed turn, exit 1 for another turn-end reason or API business error, 130 for SIGINT, and 143 for SIGTERM. Web preserves its existing SIGTERM exit 0 and SIGINT exit 130 behavior.
This supersedes the [telemetry deployment Note's](../feature/2026-07-31-web-telemetry-default-mount.md) assumption that SDK exporter/processor timeouts bound complete provider shutdown, and its earlier decision to defer a process-level backstop. The backend owns its export loss/latency policy and closes the known SDK `forceFlush()` gap; the launcher owns the outer guarantee that no plugin can trap the process indefinitely.
## Alternatives considered
**Bound only the telemetry backend's `shutdown()`.** Insufficient because it protects the known OTel wait but cannot protect the launcher from another plugin's disposer.
**Restore Node's default immediate signal exit.** Rejected because a healthy first signal should still flush telemetry and release other resources. Immediate exit is the explicit escalation path, not the default.
**Add only the five-second timeout.** Rejected because a user pressing `Ctrl+C` again is asking to stop waiting now. Swallowing that intent for the rest of the grace period recreates the reported behavior at a shorter duration.
## Consequences
A healthy exit still disposes the complete Cordis tree. The known telemetry wait releases after at most three seconds; any other wedged exit lasts at most five seconds without further input, and a repeated signal ends it immediately. Forced or deadline-bounded exit can interrupt telemetry export or remaining cleanup, which is intentional only after the graceful contract has failed or the user has explicitly escalated.
The controller is launcher infrastructure rather than a Cordis plugin: it makes no claim that disposal completed, and it does not weaken the lifecycle rule that ordinary disposers must reach quiescence.
## Testing
`apps/cli/tests/process-shutdown.spec.ts` pins resolved and rejected disposal, the five-second backstop, normal-call coalescing, a signal interrupting normal disposal, and second-signal escalation.
`apps/cli/tests/headless-shutdown.e2e.ts` boots the real shipped Web/headless Loader tree in a PTY with a test-only plugin whose disposer announces entry and never settles. The test sends SIGINT after the observation URL, waits for proof that disposal started, sends SIGINT again, and requires exit 130. The source/artifact launch resolver keeps the same regression on both execution planes. This PTY case covers the user-visible process state; no model-output snapshot changes.
`packages/telemetry/session-telemetry-otel/tests/otel.spec.ts` holds a real OTLP request open after timer export begins and pins that Cordis disposal returns at `shutdownTimeoutMillis`, despite the SDK's `forceFlush()` remaining pending. The collector is then released so the still-observed provider Promise settles cleanly.

View File

@@ -0,0 +1,52 @@
# Agent NoteWeb 与 headless 的有界信号关闭和重复信号强制退出
状态:已实现
[English](2026-08-03-cli-signal-shutdown-escalation.md) | 中文
## 问题
默认挂载遥测后,`dsh web``dsh -p` 新增了 SIGINT/SIGTERM 处理器,使进程退出时可以排空 Cordis 插件树而不是丢弃排队中的遥测数据。每个处理器都使用单向布尔闩锁latch并且只有在 `ctx.fiber.dispose()` 结算后才退出。headless 正常完成时同样会无界等待整棵树执行 dispose资源释放
随后有用户复现,`dsh -p` 在打印观察 URL 后立即卡死,重复按 `Ctrl+C` 也没有反应;设置 `DSH_TELEMETRY_DISABLED=1` 后不再卡死,而同一 Linux 沙箱中的独立 Node 信号处理器能够收到 SIGINT。这将待结算的 disposer 定位到遥测而非终端信号转发。OTel 的 `BatchLogRecordProcessor.shutdown()` 会先等待 `exporter.forceFlush()`,再进入受 `exportTimeoutMillis` 限制的完成 promiseOTLP 导出器的 `forceFlush()` 则直接等待正在进行的 HTTP Promise。因此代理沙箱连接始终无法取得 socket 时,即使已经配置两项 SDK 超时,也会让提供方关闭一直待结算。
闩锁随后把这个遥测缺陷变成无法终止的 CLI命令行界面正常完成流程已经在等待单次根级 dispose第一次 SIGINT 会加入同一个待结算的 dispose并设置信号闩锁后续 SIGINT 在闩锁处直接返回因此进程再无退出途径。正常完成之前收到信号时同样会陷入无界等待。Web 使用的闩锁结构与此相同。
遥测自身的超时无法证明整棵插件树都能结算。任何当前或未来的 disposer 都可能卡死;进程边界既要保留第一次优雅关闭的机会,也必须给用户留下强制退出的途径。
## 决策
修复分为两层归属。OTel 后端围绕 SDK 提供方的完整关闭 Promise 增加 `shutdownTimeoutMillis`(默认值和交付值均为 3 秒)。超过该截止时间时会 reject并进入遥测协调器现有的失败隔离路径使 Cordis 插件树能够完成 dispose由于 OTel 未公开取消传输 Promise 的能力,待处理记录可能丢失。
Web 与 headless 共用 `createProcessShutdown`,它是围绕根级 dispose 建立的进程级控制器:
- 多次正常关闭调用会汇合到同一次 dispose并保留首次请求的退出码这些调用不会相互触发强制退出。
- 第一个信号会启动同一次优雅 dispose并设置一个带引用的 5 秒退出兜底。dispose 无论成功或失败都会触发且仅触发一次退出;任何一种结果都无法取消进程退出。
- 关闭待结算期间收到信号时,会立即按该信号路径的退出码强制退出。这既包括 headless 正常完成已经进入 dispose 后收到的第一次 `Ctrl+C`,也包括由信号启动排空后收到的第二个信号。
- 5 秒上限是进程安全不变式,而不是部署调节项。它足以覆盖遥测部署的常规排空时限,同时仍在启动器边界为任何卡死的 disposer 设置等待上限。
headless 对完成的轮次仍以 0 退出,对其他轮次结束原因或 API 业务错误仍以 1 退出,对 SIGINT 以 130 退出,对 SIGTERM 以 143 退出。Web 保留现有行为SIGTERM 以 0 退出SIGINT 以 130 退出。
这项决策取代了[遥测部署 Agent Note](../feature/2026-07-31-web-telemetry-default-mount.md) 中 SDK 导出器/处理器超时能够限制提供方完整关闭流程的假设,也取代了其中暂缓进程级退出兜底的决定。后端负责导出数据丢失与延迟策略,并封住已知的 SDK `forceFlush()` 缺口;启动器负责最外层保证,确保任何插件都无法无限期困住进程。
## 考虑过的替代方案
**只限制遥测后端的 `shutdown()`。** 仍不充分:它能保护已知的 OTel 等待,但无法保护启动器免受其他插件 disposer 的影响。
**恢复 Node 默认的信号即时退出。** 不予采纳:收到第一个信号时,健康流程仍应刷新遥测数据并释放其他资源。即时退出是显式的强制退出路径,而非默认行为。
**只增加 5 秒超时。** 不予采纳:用户再次按下 `Ctrl+C`,就是要求立即停止等待。若在剩余宽限期内继续吞掉这一意图,只是缩短了报告中故障的持续时间,并未解决问题。
## 后果
健康的退出流程仍会对整棵 Cordis 插件树执行 dispose。已知的遥测等待最多会在 3 秒后解除;其他退出流程卡死时,如无进一步输入,最多等待 5 秒,再次收到信号则立即结束进程。强制退出或受截止时间限制的退出可能中断遥测导出或尚未完成的清理工作;只有优雅关闭契约已经失败,或用户明确要求强制退出时,才会有意接受这一结果。
该控制器属于启动器基础设施,而不是 Cordis 插件:它不会声称 dispose 已经完成,也不会削弱普通 disposer 必须达到完全停稳状态的生命周期规则。
## 测试
`apps/cli/tests/process-shutdown.spec.ts` 固定了 dispose 成功与失败、5 秒退出兜底、正常调用汇合、信号中断正常 dispose以及第二次信号强制退出的行为。
`apps/cli/tests/headless-shutdown.e2e.ts` 在 PTY 中启动真实交付的 Web/headless Loader 插件树,并挂载一个仅用于测试的插件;该插件的 disposer 会声明已经进入清理流程,但永不结算。测试在观察地址出现后发送 SIGINT等待 dispose 已启动的证据,再次发送 SIGINT并要求进程以 130 退出。源码/产物启动解析器使两个执行平面都覆盖同一项回归。该 PTY 用例覆盖用户可见的进程状态;模型输出快照没有变化。
`packages/telemetry/session-telemetry-otel/tests/otel.spec.ts` 在定时器导出开始后保持一条真实 OTLP 请求打开,并固定以下行为:即使 SDK 的 `forceFlush()` 仍待结算Cordis dispose 也会在 `shutdownTimeoutMillis` 到期时返回。随后测试释放 collector使仍受观察的提供方 Promise 干净结算。

View File

@@ -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/feature/2026-07-28-web-terminal-card.md
2026-07-28-web-terminal-card.md: 14896b1d88e5cfd2e4c58830c7a1bca1e54ed823
2026-07-28-web-terminal-card.zh.md: 16c9004f8f80b720b25b76ba5c04f308b0fccbaf
2026-07-28-web-terminal-card.md: 0e5f3e2157ebfc4e71aead26c15b6ee91958a5d5
2026-07-28-web-terminal-card.zh.md: 1285d3fbb46ebd32ff163feac632cd487e8a04f1

View File

@@ -6,7 +6,7 @@ English | [中文](2026-07-28-web-terminal-card.zh.md)
## Problem
The bash tool declares `card: 'terminal'` for both its call and its result ([render-intent union](../architecture/2026-07-02-tool-render-intent-union.md)): the call view carries the command, an optional model-authored description, and the working directory; the result view carries the output, exit code, and terminating signal. That view already reaches the browser — host, connection, and runtime deliver it onto `ConversationSnapshot` as `callView`/`resultView` — and the TUI already renders it as a `$`-prompt card with an exit line and a head/tail height cap.
The bash tool declares `card: 'terminal'` for both its call and its result ([render-intent union](../architecture/2026-07-02-tool-render-intent-union.md)): the call view carries the command, an optional model-authored description, and the working directory; the result view carries the output, exit code, and terminating signal. That view already reaches the browser — host, connection, and runtime deliver it onto `ConversationSnapshot` as `callView`/`resultView` — and the former TUI rendered it as a `$`-prompt card with an exit line and a head/tail height cap.
The Web client ignored it. `packages/client/ui-conversation/src/client/contract/tool-call-model.ts` derived every row from raw tool args, and `skeleton/DetailsPanel.tsx` flattened every tool's content blocks into one `<pre>` with `white-space: pre-wrap; word-break: break-word`. Two defects followed from soft-wrapping and from having no height bound: multi-column output (`ls`, a table, box drawing) folded into a paragraph and lost the column alignment that is the whole point of that output, and a long single-column listing stretched the details panel to the length of the listing.
@@ -19,7 +19,7 @@ The component's contract:
- **Prompt lines, one per command line.** Each line of the command gets its own row: label, then that line verbatim. A `command` carrying two shell commands on two lines therefore reads as the two commands it is, instead of collapsing into one ellipsized row. The label is the cwd's last path segment, or `~` when the cwd equals the `home` prop — a browser has no `$HOME`, so the caller supplies the absolute home directory and the collapse simply does not apply without it. A view with no cwd renders a plain `$`. A trailing newline is a terminator, not an empty final command. Only the FIRST row carries the label: the view knows one working directory — where the call started — and a later line may run somewhere else entirely, since a `cd` in the command is enough to move it. Repeating the label down the rows would state a directory per line that nothing here knows, which is the same reason the run-state dot appears once. Later rows keep a bare `$` so they still read as prompts.
- **One run-state dot for the call, on the first row.** `StateDot` in three of its four states: the chase while running, red for the exit status that also renders the pill, green for a clean settle — the same indicator a tool row's leading icon uses, so a row and its own card cannot disagree about one command. The dot exists because the first question a reader has about a shell command is whether it is still running, and without it that had to be inferred from the absence of output — which a settled command producing no output also looks like. It sits out of flow in a gutter the card reserves as its OWN left padding, so it neither indents its command nor depends on the command's text metrics to line up. The reservation is padding rather than margin because every render site rewrites `margin` wholesale to set its own indent, which silently cancelled a margin-based gutter and let a container clip the dot. Exactly one dot, whatever the line count: the exit status the view carries is the whole call's, and bash reports no per-command status, so a dot per line would assert of a line that succeeded inside a failing call that the line itself failed. The single visually hidden text label carries the same scope, since `StateDot` is `aria-hidden` and one label per row would read to assistive technology as several distinct outcomes.
- **No soft wrapping.** Output lines are `white-space: pre` inside a horizontally scrolling box. Column alignment survives; a long line scrolls instead of folding.
- **Height cap with an expand control.** Output longer than `DEFAULT_TERMINAL_MAX_LINES` (16) lines shows `ceil(max/2)` head lines plus the remaining tail lines, with a button in between that reports the hidden count and expands. The count is of parsed lines after the trailing output terminator is dropped, so an N-line output ending in a newline is N lines. The split arithmetic is the same as the TUI transcript's collapsed tool card (`packages/ui/tui/src/components/transcript.ts`), so one command's head and tail slices agree between the two front ends.
- **Height cap with an expand control.** Output longer than `DEFAULT_TERMINAL_MAX_LINES` (16) lines shows `ceil(max/2)` head lines plus the remaining tail lines, with a button in between that reports the hidden count and expands. The count is of parsed lines after the trailing output terminator is dropped, so an N-line output ending in a newline is N lines. The split arithmetic preserves the former TUI transcript's collapsed-card behavior, so the established head/tail selection stays stable.
- **ANSI color.** `anser` splits the SGR runs; `ui-primitives/src/ansi.ts` resolves each run into an inline style rendered as React spans. A foreground-only run maps the basic 16 colors onto `--dsw-*` theme tokens so authored color stays legible under both themes; a run that paints its own background keeps anser's literal rgb for both so its intended contrast survives, as do 256-palette, truecolor, and the two basic colors this design system has no token for. Sequences that carry no color (OSC strings, non-CSI escapes, inert C0 controls) are stripped before parsing so they never reach the DOM as literal characters. Cursor movements resolve before that strip, into a per-line column buffer rather than by string surgery, because carriage return and backspace only MOVE the cursor — neither erases anything, so what a reader sees is whatever each column last had written to it. `100%` then a carriage return and `OK` shows `OK0%`, since the redraw is shorter than the frame beneath it; a trailing `abc` plus a backspace still shows `abc`, since nothing overwrote the `c`; `abc` plus two backspaces and `XY` shows `aXY`. Each of these was checked against a real terminal, because the earlier truncate-and-delete approximations looked right and were not. SGR state is stamped per column as a terminal stores it per cell, so a partial overwrite keeps each surviving character's own color: red `bad`, three backspaces, then `ok` shows `okd` with the `d` still red. A CSI sequence occupies no column and changes only the state later writes are stamped with, which is also why a carriage return does not reset color, and why SGR state threads from one line to the next rather than closing at each newline. Erase-in-line is part of the same replay, because `\r\x1b[K` is the single idiom every spinner and progress bar writes — modelling the `\r` alone left the previous frame's tail standing, which is text the terminal never showed. Only `m` accumulates into a cell's style; a cursor or erase sequence must not, or the state string grows per redraw and emits boundaries anser has to discard. SGR is held per cell as a NORMALIZED record (foreground, background, attribute set), not as the sequence history: accumulating raw sequences made every state boundary re-emit the whole chain, so output that switches color without a full reset emitted O(n^2) characters — 3200 such cells produced 25 MB and a `RangeError` well under bash's own output cap. The record also lets the attribute closers every chalk-based tool writes (`39`, `49`, `22`, `24`, …) actually close their attribute, and each boundary emits one canonical sequence for the state it opens. A run also has to CLOSE: the replay converges to the state the scan ended in, not the last written cell's, because a reset after the final write changes no cell yet ends the run — without that a line finishing in `\x1b[0m` leaked its color onto every later line. The cursor advances by terminal columns, so a tab reaches the next 8-column stop, a wide character takes two (its spacer blanking rather than closing the gap once the lead cell is overwritten), and a combining mark takes none. Width follows emoji PRESENTATION rather than the U+2600-U+27BF block: `\u2713`, the check every progress line writes, is one column, so treating the block as wide misaligned exactly the output this card exists for. Writing over either half of a wide pair blanks the other, since a terminal cannot leave one cell of a two-cell glyph standing: `a\tb` then a redraw of `XY` shows `XY b`, since a two-character redraw cannot reach column 8.
- **Exit status and copy.** A non-zero exit code or a signal renders a status pill, matching the exit-status distinction the bash tool's own renderer draws; a clean exit renders none, and settled empty output renders a dimmed placeholder — judged on the parsed lines the card renders, not on the raw text, since output that is only escapes or control bytes survives a `trim()` yet parses to nothing visible and would otherwise draw blank rows plus a copy control for invisible bytes. The copy control copies the raw output text, not the rendered tree, so the prompt line and the pill stay out of the clipboard.

View File

@@ -6,7 +6,7 @@ Status: implemented
## Problem
bash 工具的调用与结果都声明 `card: 'terminal'`[渲染意图联合类型](../architecture/2026-07-02-tool-render-intent-union.md)调用视图携带命令、一段可选的模型撰写描述以及工作目录结果视图携带输出、退出码与终止信号。该视图早已抵达浏览器——host、connection 与 runtime 把它投递到 `ConversationSnapshot``callView`/`resultView` 上——TUI 也早已把它渲染为带 `$` 提示符的卡片,附退出行与首尾高度上限。
bash 工具的调用与结果都声明 `card: 'terminal'`[渲染意图联合类型](../architecture/2026-07-02-tool-render-intent-union.md)调用视图携带命令、一段可选的模型撰写描述以及工作目录结果视图携带输出、退出码与终止信号。该视图早已抵达浏览器——host、connection 与 runtime 把它投递到 `ConversationSnapshot``callView`/`resultView` 上——TUI 把它渲染为带 `$` 提示符的卡片,附退出行与首尾高度上限。
Web client 却对它视而不见。`packages/client/ui-conversation/src/client/contract/tool-call-model.ts` 仅从原始工具参数推导每一行,`skeleton/DetailsPanel.tsx` 则把所有工具的内容块压平进一个 `<pre>`,样式为 `white-space: pre-wrap; word-break: break-word`。软换行加上没有高度约束,带来两个缺陷:多列输出(`ls`、表格、制表符绘图)被折成一段文字,丢掉了这类输出赖以存在的列对齐;而单列的长列表会把详情面板拉长到与列表等长。
@@ -19,7 +19,7 @@ Web client 却对它视而不见。`packages/client/ui-conversation/src/client/c
- **提示符行,每条命令行一行。** 命令的每一行各占一行:标签,其后原样跟随该行。因此一个在两行上承载两条 shell 命令的 `command` 就读作它本身的两条命令,而不是被压成一行并省略号截断。标签取 cwd 的最后一段路径,当 cwd 等于 `home` prop 时取 `~`——浏览器没有 `$HOME`,因此由调用方提供绝对家目录,不提供时该折叠不生效。视图不带 cwd 时渲染一个纯 `$`。末尾换行是终止符,不是一条空的末命令。只有**第一行**携带该标签:视图只知道一个工作目录——调用开始处的那个——而后面的行完全可能在别处运行,命令里一个 `cd` 就足以改变它。把标签在各行重复,等于陈述一个此处无人知晓的逐行目录,这与运行状态点只出现一次是同一个理由。其余行保留一个裸 `$`,因此它们仍读作提示符。
- **整次调用一枚运行状态点,位于第一行。** 它是 `StateDot` 四种状态中的三种:运行期间为追逐动画,与渲染状态徽章相同的退出状态为红色,干净落定为绿色——与工具行行首图标使用同一个指示器,因此一行与其自身的卡片不可能对同一条命令产生分歧。该状态点存在的理由是:读者对一条 shell 命令的第一个问题就是它是否仍在运行;没有它时,这一点只能从「没有输出」推断,而一条落定后无输出的命令看起来也一样。它以脱离文档流的方式落在卡片以**自身左内边距**预留的落区里,因此既不会缩进其命令,也不依赖命令自身的文本度量来与之对齐。该预留用 padding 而非 margin是因为每个渲染点都会整条重写 `margin` 来设定自己的缩进——那会静默取消基于 margin 的落区,并让容器把状态点裁掉。无论有多少行,都只有一枚:视图携带的退出状态属于整次调用,而 bash 不报告逐条命令的状态,因此每行一枚状态点就等于在断言——一条在失败调用中其实成功了的命令行自身失败了。那一处视觉隐藏的文本标签具有相同的作用域,因为 `StateDot``aria-hidden`,而每行一个标签会被辅助技术读成好几个各自独立的结果。
- **不软换行。** 输出行使用 `white-space: pre`,置于横向滚动的容器内。列对齐得以保留;长行滚动,而非折行。
- **高度上限与展开控件。** 输出超过 `DEFAULT_TERMINAL_MAX_LINES`16行时显示 `ceil(max/2)` 行首部加余下的尾部行数,中间是一个按钮,报告被隐藏的行数并可展开。计数针对的是剥除输出末尾终止符之后解析出的行,因此以换行结尾的 N 行输出就是 N 行。切分算法 TUI transcript 折叠态工具卡片(`packages/ui/tui/src/components/transcript.ts`)完全一致,因此同一条命令的首尾切片在两个前端之间吻合
- **高度上限与展开控件。** 输出超过 `DEFAULT_TERMINAL_MAX_LINES`16行时显示 `ceil(max/2)` 行首部加余下的尾部行数,中间是一个按钮,报告被隐藏的行数并可展开。计数针对的是剥除输出末尾终止符之后解析出的行,因此以换行结尾的 N 行输出就是 N 行。切分算法保留原 TUI transcript 折叠卡片的行为,因此既有的首尾选择保持稳定
- **ANSI 颜色。** `anser` 切分 SGR 分段;`ui-primitives/src/ansi.ts` 把每段解析为内联样式,渲染成 React span。只设前景色的分段把基本 16 色映射到 `--dsw-*` 主题 token使作者指定的颜色在两种主题下都可读自行绘制背景的分段则前后景都保留 anser 给出的字面 rgb以保住它意图中的对比度256 色板、truecolor 以及本设计系统没有对应 token 的两种基本色同样如此。不承载颜色的转义序列OSC 串、非 CSI 转义、无显示意义的 C0 控制符)在解析前被剥除,因此绝不会以字面字符抵达 DOM。光标移动在该剥除之前先行结算且落在逐行的列缓冲里而不是靠字符串手术因为回车与退格**只移动**光标——两者都不擦除任何东西,所以读者看到的就是每一列最后被写入的内容。`100%` 后接回车再接 `OK` 显示为 `OK0%`,因为这次重绘比它下面的帧更短;末尾 `abc` 加一个退格仍显示 `abc`,因为没有任何东西覆盖过那个 `c``abc` 加两个退格再接 `XY` 显示 `aXY`。这些用例都对照真实终端核实过因为先前「截断加删除」的近似看起来是对的实际并不对。SGR 状态按列打戳,与终端按单元格存储颜色的方式一致,因此部分覆盖会保留每个存活字符自身的颜色:红色 `bad`、三个退格、再写 `ok`,显示为 `okd` 且那个 `d` 仍是红的。CSI 序列不占列,只改变后续写入被打上的状态——这也正是回车不会重置颜色的原因,以及 SGR 状态会从一行延续到下一行、而不是在每个换行处关闭的原因。行内擦除属于同一次重放,因为 `\r\x1b[K` 是每个 spinner 与进度条都会写的同一个惯用法——只建模 `\r` 会让上一帧的尾巴留在原处,那是终端从未显示过的文本。只有 `m` 会累加进单元格样式;光标或擦除序列不能累加,否则状态串会随每次重绘线性增长,并发出 anser 只能丢弃的边界。SGR 按单元格以**归一化记录**保存(前景、背景、属性集合),而不是序列历史:累积原始序列会让每个状态边界重新发射整条链,因此不做完整 reset 的换色输出会发射 O(n^2) 个字符——3200 个这样的单元格产生 25 MB 并最终 `RangeError`,远低于 bash 自身的输出上限。该记录也让所有 chalk 系工具写出的属性闭合码(`39``49``22``24` 等)真正闭合其属性,且每个边界只为它开启的状态发射一条规范序列。一个分段也必须**收束**:重放收敛到扫描结束时的状态,而不是最后一个被写入单元格的状态——因为最后一次写入之后的 reset 不改变任何单元格,却结束了该分段;没有这一步,以 `\x1b[0m` 结尾的行会把颜色泄漏到其后所有行。光标按终端列推进,因此制表符前进到下一个 8 列制表位、宽字符占两列(其续列在首列被覆盖后变为空白而非合拢),组合标记不占列。宽度依据 emoji **presentation** 而非 U+2600U+27BF 整个区块:`\u2713`——每条进度行都会写的对勾——只占一列,把该区块整体当作双宽恰好会错位这张卡片赖以存在的那类输出。写入宽字符对的任一半都会把另一半清成空白,因为终端无法让一个双格字形只留下一格:`a\tb` 之后用 `XY` 重绘显示为 `XY b`,因为两个字符的重绘到不了第 8 列。
- **退出状态与复制。** 非零退出码或信号渲染一枚状态徽章,与 bash 工具自身渲染器所作的退出状态区分一致;干净退出不渲染徽章,落定后的空输出渲染一处变暗的占位文字——该判定读的是卡片实际渲染的解析行,而非原始文本,因为只含转义或控制字节的输出能通过 `trim()` 却解析不出任何可见内容,否则就会画出一片空行外加一个把不可见字节写进剪贴板的复制控件。复制控件复制的是原始输出文本而非渲染后的树,因此提示符行与徽章不会进入剪贴板。

View File

@@ -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/feature/2026-07-30-search-render-card.md
2026-07-30-search-render-card.md: 36f772d7198ef30d6c243549cbaa9f16c780268b
2026-07-30-search-render-card.zh.md: 7d7ba352f19f3fb83cb6b7d049980776dc2c277e
2026-07-30-search-render-card.md: 29544cb703f3ab048f4e7702935887ecf05179bf
2026-07-30-search-render-card.zh.md: e0ae21924e82152ba629ab628476113ad9afe3d8

View File

@@ -18,7 +18,7 @@ The discriminant is `shape`, not `kind`, deliberately: the same presentation mod
One view with two shapes rather than two cards, because both tools are the same visual object — a search result — and a web consumer switches on one `card` value, then on `shape` for the row layout. The discriminated `shape` keeps each variant's fields non-optional (a matches view always has `files`, a paths view always has `paths`) instead of a single interface where every shape-specific field is optional.
The view carries **no** result text. An earlier revision attached the model-facing `result.content` to the view; that was a no-op for every consumer (the TUI already falls back to `result.content`, and web fallbacks read the raw `tool/result` content), and it serialized the whole search text a second time into the persisted view. The view is the structured shape only; a UI without a search card falls back to the raw `tool/result` content.
The view carries **no** result text. An earlier revision attached the model-facing `result.content` to the view; that was a no-op because consumer fallbacks already read the raw `tool/result` content, and it serialized the whole search text a second time into the persisted view. The view is the structured shape only; a UI without a search card falls back to the raw result content.
The card tag is result-time only. A search call stays a `GenericCallView` (`kind: 'search'`): the pending state has no matches or paths to show, so there is nothing a `SearchCallView` would carry that the generic title does not. This is the asymmetry with the terminal card, whose call view carries the command, cwd, and description that exist before execution; a search's structured content exists only after `execute`.
@@ -30,7 +30,7 @@ The card tag is result-time only. A search call stays a `GenericCallView` (`kind
The `SearchMeta` member shapes are object-literal `type` aliases, not the `SearchFileMatches`/`SearchLineMatch` interfaces the view exposes, because only a type alias is assignable to the `JsonValue` index signature `presentationMeta` returns; the two are structurally identical, so the projected value still reads back as a `SearchResultView`.
The TUI (`packages/ui/tui/src/components/transcript.ts`) needs no dedicated arm: its result-view switch handles `terminal` and `diff` explicitly, and a `search` view falls through to the same dim generic body, reading the model-facing text from `this.result?.content`. Because the search view carries no `content` of its own and grep/glob returned a generic card before this PR, the TUI output stays byte-identical to the pre-search-card fallback. The web frontend that renders the structured `files`/`paths` shape is a separate later PR; this PR is the backend contract and its two producers.
A consumer without a dedicated `search` arm falls back to the same generic body and reads the model-facing text from the raw result. Because the search view carries no `content` of its own and grep/glob returned a generic card before this PR, that fallback stays byte-identical to the pre-search-card path. The frontend that renders the structured `files`/`paths` shape is independent of this backend contract and its two producers.
## Alternatives considered
@@ -48,7 +48,7 @@ The TUI (`packages/ui/tui/src/components/transcript.ts`) needs no dedicated arm:
`grep` and `glob` now compute `presentationMeta` on every non-nested successful call, a bounded projection over the already-retained matches or paths — the same retention outcome the render consumes, so there is no second retention pass and no doubled search text on the wire. The serialized meta is bounded by `searchMetaMaxBytes`, so a broad search no longer persists an unbounded structured copy into the session log.
A UI without a search card renders the raw `tool/result` content, so no consumer regresses, and the TUI stays byte-identical. The web consumer that renders the structured shape reads `truncated`/`total` and the per-file groups; because the view carries only the retained, byte-bounded page, a UI wanting the complete result follows the spill locator in the model-facing text, exactly as the model does.
A UI without a search card renders the raw `tool/result` content, so no consumer regresses. A consumer that renders the structured shape reads `truncated`/`total` and the per-file groups; because the view carries only the retained, byte-bounded page, a UI wanting the complete result follows the spill locator in the model-facing text, exactly as the model does.
## Testing

View File

@@ -18,7 +18,7 @@ Status: implemented
用一个带两种形状的视图而非两张卡片,因为两个工具是同一个视觉对象 —— 一个搜索结果 —— web 消费方先在一个 `card` 值上分支,再在 `shape` 上分支决定行布局。判别式 `shape` 让每个变体的字段保持非可选matches 视图总有 `files`paths 视图总有 `paths`),而不是一个所有形状相关字段都可选的单一接口。
该视图**不**携带结果文本。早期版本曾把面向模型的 `result.content` 附到视图上;那对每个消费方都是 no-opTUI 本就回退到 `result.content`web 回退读原始 `tool/result` 内容,却把整段搜索文本又序列化进持久化视图一遍。视图只承载结构化形状;无 search 卡片的 UI 回退到原始 `tool/result` 内容。
该视图**不**携带结果文本。早期版本曾把面向模型的 `result.content` 附到视图上;但消费方的回退路径本就读取原始 `tool/result` 内容,因此这不会产生效果,却把整段搜索文本又序列化进持久化视图一遍。视图只承载结构化形状;无 search 卡片的 UI 回退到原始结果内容。
卡片标签只在结果时存在。搜索调用保持为 `GenericCallView``kind: 'search'`pending 状态没有匹配或路径可展示,所以 `SearchCallView` 能携带的东西不会比 generic 标题更多。这是与 terminal 卡片的不对称之处 —— terminal 的调用视图携带执行前就存在的命令、cwd、description搜索的结构化内容只在 `execute` 之后才存在。
@@ -30,7 +30,7 @@ Status: implemented
`SearchMeta` 的成员形状是对象字面量 `type` 别名,而非视图暴露的 `SearchFileMatches`/`SearchLineMatch` 接口,因为只有 type 别名可赋给 `presentationMeta` 返回的 `JsonValue` 索引签名;两者结构等价,所以投影值仍读回为 `SearchResultView`
TUI`packages/ui/tui/src/components/transcript.ts`)不需要专门分支:它的结果视图 switch 显式处理 `terminal``diff``search` 视图落入同一个变暗的 generic body`this.result?.content` 读取面向模型的文本。因为搜索视图不带自己的 `content`,而本 PR 之前 grep/glob 返回的是 generic 卡片,所以 TUI 输出与无 search 卡片的回退逐字节一致。渲染结构化 `files`/`paths` 形状的 web 前端是另一个后续 PR本 PR 是后端契约及其两个生产者。
没有专用 `search` 分支的消费方会回退到同一个 generic body并从原始结果中读取面向模型的文本。因为搜索视图不带自己的 `content`,而本 PR 之前 grep/glob 返回的是 generic 卡片,所以该回退与引入 search 卡片之前的路径逐字节一致。渲染结构化 `files`/`paths` 形状的前端独立于这个后端契约及其两个生产者。
## 考虑过的备选
@@ -48,7 +48,7 @@ TUI`packages/ui/tui/src/components/transcript.ts`)不需要专门分支:
`grep``glob` 现在在每次非嵌套的成功调用上计算 `presentationMeta`,这是对已保留匹配或路径的一次有界投影 —— 与 render 消费的是同一份保留产出,所以没有第二次保留计算,线上也没有翻倍的搜索文本。序列化 meta 受 `searchMetaMaxBytes` 约束,所以宽泛搜索不再把无界的结构化副本持久化进会话日志。
无 search 卡片的 UI 渲染原始 `tool/result` 内容,所以没有消费方退化TUI 也逐字节一致。渲染结构化形状的 web 消费方读 `truncated`/`total` 与按文件分组;因为视图只携带保留的、字节有界的页,想要完整结果的 UI 跟随面向模型文本里的 spill 定位符,与模型的做法完全一致。
无 search 卡片的 UI 渲染原始 `tool/result` 内容,所以没有消费方退化。渲染结构化形状的消费方读 `truncated`/`total` 与按文件分组;因为视图只携带保留的、字节有界的页,想要完整结果的 UI 跟随面向模型文本里的 spill 定位符,与模型的做法完全一致。
## 测试

View File

@@ -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/feature/2026-07-30-web-read-card.md
2026-07-30-web-read-card.md: 1fb3d61a113d26f6daf023fc791f3638055b5be0
2026-07-30-web-read-card.zh.md: 946bcca95bcc9bb50beb1ef22e77e4a21728b538
2026-07-30-web-read-card.md: 26d1634b6be86666980e65f842de51c868c26efd
2026-07-30-web-read-card.zh.md: 749177e93e8e3b2e34aed46d1fe99226395a6686

View File

@@ -16,7 +16,7 @@ Add a fourth `card` tag, `read`, to the [render-intent union](../architecture/20
The read tool projects the structured window through `output.presentationMeta`, the same persisted channel write/edit use for their applied-diff hunks ([canonical tool output contract](../architecture/2026-07-20-canonical-tool-output-contract.md)). `presentationMeta` runs once for a top-level surface call, returns `{ path, offset, lines, totalLines, lang? }` as JSON the session validates and stores on the result's `meta`, and `presentResult` narrows that meta back into the `ReadResultView` on both live and replay paths. `offset` (the 1-based first line the window requested) rides along because a byte cap below the first selected line yields an empty `lines` array with a positive `totalLines`; without the persisted `offset` a replayed card of such a window could not report where it starts or where a continuation resumes, and the last-line and re-parse fallbacks are both lossy. Without this channel the line array and total would be unreachable: the raw output object is not on the wire, and re-parsing the `N: text` text is lossy and fragile against the truncation footer.
`presentResult` returns `undefined` — the generic fallback — whenever the meta is absent or malformed (`readMetaFromMeta` narrows it defensively, so a replay of an older logged result never throws), whenever the result is an error, and whenever the single text block is not the read envelope. A pre-card logged result — a valid read envelope with no persisted `meta`, recorded before this card existed — takes that same `undefined` path deliberately: the client falls back to the raw `result.content`, so it shows the enveloped `<path>/<type>/<content>` text rather than the envelope-stripped generic card the old presenter returned. This is the accepted degradation under the [pre-release stance](../../../../AGENTS.md#pre-release-stance-foundation-over-blast-radius): reject the old on-disk format rather than add an envelope-stripping compatibility branch, since this PR re-records every published fixture and the session format promises no backward compatibility. On the success path `presentResult` carries `content` (the envelope-stripped text) alongside the structured fields, so a UI without the read capability, including the current TUI, renders the file text through the generic/default card arm exactly as before. The TUI's `renderBody` switch (`packages/ui/tui/src/components/transcript.ts`) is not `assertNever`-exhaustive: `terminal` and `diff` have arms and everything else falls through to the generic arm, which reads `view.content`. That default arm alone was not enough: `render()` sets `genericContent` — and with it the dim-Markdown `dimBody` treatment — on a separate gate that was `card === 'generic'` only, so a `read` card would have kept the text but lost its dim styling. The gate now admits `card: 'read'` too, taking `content` down the same dim-Markdown path, so a read renders in the TUI exactly as it did before the read card existed. Beyond that one gate the TUI needs no read-specific code.
`presentResult` returns `undefined` — the generic fallback — whenever the meta is absent or malformed (`readMetaFromMeta` narrows it defensively, so a replay of an older logged result never throws), whenever the result is an error, and whenever the single text block is not the read envelope. A pre-card logged result — a valid read envelope with no persisted `meta`, recorded before this card existed — takes that same `undefined` path deliberately: the client falls back to the raw `result.content`, so it shows the enveloped `<path>/<type>/<content>` text rather than the envelope-stripped generic card the old presenter returned. This is the accepted degradation under the [pre-release stance](../../../../AGENTS.md#pre-release-stance-foundation-over-blast-radius): reject the old on-disk format rather than add an envelope-stripping compatibility branch, since this PR re-records every published fixture and the session format promises no backward compatibility. On the success path `presentResult` carries `content` (the envelope-stripped text) alongside the structured fields, so a UI without the read capability renders the file text through its generic/default card arm. The former TUI established the need for this fallback: its non-exhaustive result switch read `view.content`, while a separate dim-Markdown gate also had to admit `card: 'read'`. That frontend has since been removed, but the content fallback remains part of the view contract for any consumer without a structured read card.
### Language hint derivation
@@ -34,13 +34,13 @@ The read tool projects the structured window through `output.presentationMeta`,
## Consequences
`ToolResultView` has a fourth member. Every consumer that switches on `card` keeps compiling: the TUI and the current Web client route an unknown card to their generic path, and the read card carries `content` so that path shows the file text. The Web frontend that renders the line-numbered, syntax-highlighted view from `lines`/`lang`/`totalLines` is a separate follow-up PR; this PR is the backend that makes the data reachable. Until that lands, a read renders exactly as it did before (the generic text card) everywhere.
`ToolResultView` has a fourth member. A consumer may render the structured `lines`/`lang`/`totalLines` shape or route an unsupported card to its generic path; the read card carries `content` so the latter still shows the file text. This producer change is the backend that makes the structured data reachable without requiring every consumer to implement the richer view at once.
The read tool now computes `presentationMeta` for every top-level read, a small per-call projection (a `lines.map` and one `langFromPath` call) on data already in hand. The meta is persisted with the session log, so a read result is slightly larger on disk — the line array it already rendered as text, now also structured.
## Testing
`packages/fs/tool-fs/tests/read-render.spec.ts` unit-tests `langFromPath` (known extensions case-insensitively, extension read after the last segment and last dot, and the `undefined` cases: dotfile, extensionless, trailing dot, unknown) and `readMetaFromMeta` (a well-formed narrow with and without `lang`, and every rejection: non-object, array, missing or wrong-typed `path`/`totalLines`/`lines`, a malformed line entry, a non-string `lang`, and — because the function narrows the opaque persisted `meta` boundary — the semantically invalid paths a well-typed replayed JSON can still carry: an `offset` that is not a 1-based integer, a first line `number` below `offset`, a line `number` that is not a 1-based integer (`0`, `1.5`, `NaN`, `Infinity`), a `totalLines` that is not a non-negative integer (`-1`, `1.5`, `NaN`), and lines whose numbers duplicate, decrease, or exceed `totalLines`; it also narrows an empty window at a positive `offset` (a byte cap below the first selected line). `packages/fs/tool-fs/tests/tools.spec.ts` pins the tool wiring: `execute` attaches the structured window (with and without a `lang` hint) as `meta`, `presentResult` narrows it into a `card: 'read'` view carrying the envelope-stripped `content`, and the decline paths (error result, non-single-text content, malformed envelope with valid meta, and valid envelope with absent or malformed meta) all fall back to `undefined`. Both changed source files hold per-file 100% coverage. This PR carries the snapshot evidence for the persisted meta and the extended union, not for a new rendered view: the re-recorded ACP session fixtures (`fs-read`, `fs-read-window`, `fs-edit`, `fs-policy-reject`, `fs-write-overwrite`, `parallel-tool-calls`, `workspace-context`, `workspace-edit`) pin the persisted read `meta` (with `{{cwd}}`-tokenized paths), and `cordis-inspect-jsdoc` pins the four-member `ToolResultView` union. The keyless snapshot and assembled-application transcript for the rendered read card belong to the follow-up Web PR that consumes the view, since this PR adds no new product-user-visible rendering — the TUI routes the read card through its existing generic dim-Markdown fallback (`transcript.ts` treats `card: 'read'` like `card: 'generic'`), so its output is unchanged. The `apps/cli` `parallel-file-reads` terminal golden (`apps/cli/tests/snapshots/parallel-file-reads/terminal.expected.txt`) pins exactly that: a real replay executes the read tool, renders it through the new `card: 'read'` gate, and the golden's dim-Markdown rows are byte-for-byte what a generic read produced before this card existed.
`packages/fs/tool-fs/tests/read-render.spec.ts` unit-tests `langFromPath` (known extensions case-insensitively, extension read after the last segment and last dot, and the `undefined` cases: dotfile, extensionless, trailing dot, unknown) and `readMetaFromMeta` (a well-formed narrow with and without `lang`, and every rejection: non-object, array, missing or wrong-typed `path`/`totalLines`/`lines`, a malformed line entry, a non-string `lang`, and — because the function narrows the opaque persisted `meta` boundary — the semantically invalid paths a well-typed replayed JSON can still carry: an `offset` that is not a 1-based integer, a first line `number` below `offset`, a line `number` that is not a 1-based integer (`0`, `1.5`, `NaN`, `Infinity`), a `totalLines` that is not a non-negative integer (`-1`, `1.5`, `NaN`), and lines whose numbers duplicate, decrease, or exceed `totalLines`; it also narrows an empty window at a positive `offset` (a byte cap below the first selected line). `packages/fs/tool-fs/tests/tools.spec.ts` pins the tool wiring: `execute` attaches the structured window (with and without a `lang` hint) as `meta`, `presentResult` narrows it into a `card: 'read'` view carrying the envelope-stripped `content`, and the decline paths (error result, non-single-text content, malformed envelope with valid meta, and valid envelope with absent or malformed meta) all fall back to `undefined`. Both changed source files hold per-file 100% coverage. This PR carries the snapshot evidence for the persisted meta and the extended union, not for a new rendered view: the re-recorded ACP session fixtures (`fs-read`, `fs-read-window`, `fs-edit`, `fs-policy-reject`, `fs-write-overwrite`, `parallel-tool-calls`, `workspace-context`, `workspace-edit`) pin the persisted read `meta` (with `{{cwd}}`-tokenized paths), and `cordis-inspect-jsdoc` pins the four-member `ToolResultView` union. The then-current terminal snapshot also pinned that a consumer's generic dim-Markdown fallback stayed byte-identical; the structured card's own assembled-application transcript belonged to its consuming frontend change.
## Related

View File

@@ -16,7 +16,7 @@ Status: implemented
read 工具通过 `output.presentationMeta` 投影结构化窗口,这与 write/edit 用来投影其应用 diff hunk 的持久化通道相同([规范化工具输出契约](../architecture/2026-07-20-canonical-tool-output-contract.md))。`presentationMeta` 对一次顶层 surface 调用运行一次,返回 `{ path, offset, lines, totalLines, lang? }` 作为会话校验并存储在结果 `meta` 上的 JSON`presentResult` 在 live 和回放路径上都把该 meta 收窄回 `ReadResultView``offset`(窗口请求的 1-based 起始行)一并携带,是因为当字节上限低于首个选中行时,窗口会返回空的 `lines` 数组而 `totalLines` 为正;没有持久化的 `offset`,这类窗口的回放 card 就无法报告它从哪行开始、或续读应从哪行继续,而末行推断与文本重解析两种兜底都有损。没有这个通道,行数组和总数就无法触及:原始输出对象不在线上,而重新解析 `N: text` 文本既有损又对截断脚注脆弱。
`presentResult` 在以下情况返回 `undefined`——即 generic 回退meta 缺失或畸形(`readMetaFromMeta` 防御性收窄它,因此回放旧的已记录结果永不抛错)、结果是错误、以及单个文本块不是 read 信封。本 card 出现之前记录的结果——信封合法但无持久化 `meta`——有意走同一条 `undefined` 路径:客户端回退到原始 `result.content`,因此显示带 `<path>/<type>/<content>` 信封的原文,而非旧展示器返回的剥信封 generic card。这是 [pre-release 立场](../../../../AGENTS.md#pre-release-stance-foundation-over-blast-radius)下接受的降级:拒绝旧的磁盘格式,而非加一个剥信封的兼容分支——本 PR 已重录全部已发布 fixtures且 session 格式不承诺向后兼容。在成功路径上,`presentResult` 在结构化字段之外携带 `content`(剥信封后的文本),因此不具备 read 能力的 UI(包括当前的 TUI通过 generic/default card 分支渲染文件文本与之前完全一致。TUI 的 `renderBody` switch`packages/ui/tui/src/components/transcript.ts`)不是 `assertNever` 穷尽的:`terminal``diff` 有分支,其余都落入 generic 分支,该分支读取 `view.content`。仅有该默认分支还不够:`render()` 在一个独立门控上设置 `genericContent`(连同 dim-Markdown 的 `dimBody` 处理),该门控原先只判 `card === 'generic'`,因此 `read` card 虽保留文本却会丢失 dim 样式。现在该门控也接纳 `card: 'read'`,让 `content` 走同一条 dim-Markdown 路径,因此 read 在 TUI 中的渲染与 read card 出现之前完全一致。除这一处门控外TUI 无需 read 专属代码
`presentResult` 在以下情况返回 `undefined`——即 generic 回退meta 缺失或畸形(`readMetaFromMeta` 防御性收窄它,因此回放旧的已记录结果永不抛错)、结果是错误、以及单个文本块不是 read 信封。本 card 出现之前记录的结果——信封合法但无持久化 `meta`——有意走同一条 `undefined` 路径:客户端回退到原始 `result.content`,因此显示带 `<path>/<type>/<content>` 信封的原文,而非旧展示器返回的剥信封 generic card。这是 [pre-release 立场](../../../../AGENTS.md#pre-release-stance-foundation-over-blast-radius)下接受的降级:拒绝旧的磁盘格式,而非加一个剥信封的兼容分支——本 PR 已重录全部已发布 fixtures且 session 格式不承诺向后兼容。在成功路径上,`presentResult` 在结构化字段之外携带 `content`(剥信封后的文本),因此不具备 read 能力的 UI 会通过自己的 generic/default card 分支渲染文件文本。原 TUI 证明了这条回退的必要性:它的非穷尽结果 switch 读取 `view.content`,而另一道 dim-Markdown 门控也必须接纳 `card: 'read'`。该前端随后被移除,但对任何没有结构化 read 卡片的消费方而言content 回退仍是视图契约的一部分
### 语言提示推导
@@ -34,13 +34,13 @@ read 工具通过 `output.presentationMeta` 投影结构化窗口,这与 write
## Consequences
`ToolResultView` 多了第四个成员。每个在 `card` 上 switch 的消费者都继续编译TUI 和当前 Web 客户端把未知 card 路由到 generic 路径,而 read card 携带 `content` 使该路径显示文件文本。从 `lines`/`lang`/`totalLines` 渲染带行号、语法高亮视图的 Web 前端是单独的后续 PR本 PR 是让数据可触及的后端。在它落地前read 在各处的渲染与之前完全一致generic 文本 card
`ToolResultView` 多了第四个成员。消费方可以渲染结构化的 `lines`/`lang`/`totalLines` 形状,也可以将不支持的 card 路由到 generic 路径read card 携带 `content`,所以后者仍会显示文件文本。本次生产者变更是让结构化数据可触及的后端,无需每个消费方同时实现更丰富的视图
read 工具现在为每次顶层 read 计算 `presentationMeta`,这是对已在手数据的一次小投影(一次 `lines.map` 和一次 `langFromPath` 调用。meta 随会话日志持久化,因此 read 结果在磁盘上略大——它已渲染为文本的行数组,现在也以结构化形式存在。
## Testing
`packages/fs/tool-fs/tests/read-render.spec.ts` 单测 `langFromPath`(已知扩展名的大小写不敏感、扩展名在最后一段与最后一个点之后读取、以及 `undefined` 各情况dotfile、无扩展名、结尾的点、未知`readMetaFromMeta`(含与不含 `lang` 的良构收窄,以及每种拒绝:非对象、数组、缺失或类型错误的 `path`/`totalLines`/`lines`、畸形行项、非字符串 `lang`,以及——因为该函数收窄持久化的 opaque `meta` 边界——良构类型的回放 JSON 仍可能携带的语义无效路径:不是 1-based 整数的 `offset`、小于 `offset` 的首行 `number`、不是 1-based 整数的行 `number``0``1.5``NaN``Infinity`)、不是非负整数的 `totalLines``-1``1.5``NaN`)、以及行号重复、递减或超过 `totalLines` 的情况;并且收窄正 `offset` 处的空窗口(字节上限低于首个选中行))。`packages/fs/tool-fs/tests/tools.spec.ts` 固定工具接线:`execute` 把结构化窗口(含与不含 `lang` 提示)作为 `meta` 附上、`presentResult` 把它收窄为携带剥信封 `content``card: 'read'` 视图、以及各拒绝路径错误结果、非单文本内容、meta 有效但信封畸形、信封有效但 meta 缺失或畸形)都回退到 `undefined`。两个改动的源文件保持逐文件 100% 覆盖率。本 PR 携带的是持久化 meta 与扩展后联合类型的快照证据,而非新渲染视图的证据:重录的 ACP session fixtures`fs-read``fs-read-window``fs-edit``fs-policy-reject``fs-write-overwrite``parallel-tool-calls``workspace-context``workspace-edit`)钉住持久化的读取 `meta`(含 `{{cwd}}` 令牌化路径),`cordis-inspect-jsdoc` 钉住四成员的 `ToolResultView` 联合类型。已渲染读取 card 的 keyless 快照与组装应用 transcript 属于消费该视图的后续 Web PR因为本 PR 不新增任何面向产品用户可见的渲染——TUI 通过其现有的通用 dim-Markdown 回退路由读取 card`transcript.ts``card: 'read'` 当作 `card: 'generic'` 处理),因此其输出保持不变。`apps/cli``parallel-file-reads` 终端 golden`apps/cli/tests/snapshots/parallel-file-reads/terminal.expected.txt`)正钉住这一点:一次真实回放执行 read 工具、经新的 `card: 'read'` 门渲染golden 的 dim-Markdown 行与本 card 出现前 generic read 所产出的逐字节一致
`packages/fs/tool-fs/tests/read-render.spec.ts` 单测 `langFromPath`(已知扩展名的大小写不敏感、扩展名在最后一段与最后一个点之后读取、以及 `undefined` 各情况dotfile、无扩展名、结尾的点、未知`readMetaFromMeta`(含与不含 `lang` 的良构收窄,以及每种拒绝:非对象、数组、缺失或类型错误的 `path`/`totalLines`/`lines`、畸形行项、非字符串 `lang`,以及——因为该函数收窄持久化的 opaque `meta` 边界——良构类型的回放 JSON 仍可能携带的语义无效路径:不是 1-based 整数的 `offset`、小于 `offset` 的首行 `number`、不是 1-based 整数的行 `number``0``1.5``NaN``Infinity`)、不是非负整数的 `totalLines``-1``1.5``NaN`)、以及行号重复、递减或超过 `totalLines` 的情况;并且收窄正 `offset` 处的空窗口(字节上限低于首个选中行))。`packages/fs/tool-fs/tests/tools.spec.ts` 固定工具接线:`execute` 把结构化窗口(含与不含 `lang` 提示)作为 `meta` 附上、`presentResult` 把它收窄为携带剥信封 `content``card: 'read'` 视图、以及各拒绝路径错误结果、非单文本内容、meta 有效但信封畸形、信封有效但 meta 缺失或畸形)都回退到 `undefined`。两个改动的源文件保持逐文件 100% 覆盖率。本 PR 携带的是持久化 meta 与扩展后联合类型的快照证据,而非新渲染视图的证据:重录的 ACP session fixtures`fs-read``fs-read-window``fs-edit``fs-policy-reject``fs-write-overwrite``parallel-tool-calls``workspace-context``workspace-edit`)钉住持久化的读取 `meta`(含 `{{cwd}}` 令牌化路径),`cordis-inspect-jsdoc` 钉住四成员的 `ToolResultView` 联合类型。当时的终端快照还钉住了消费方的 generic dim-Markdown 回退保持逐字节一致;结构化卡片自身的组装应用 transcript 则属于消费它的前端变更
## Related

View File

@@ -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/feature/2026-07-30-web-result-card.md
2026-07-30-web-result-card.md: deec27832aba2d5d868889f7306cbaef4f0b90b4
2026-07-30-web-result-card.zh.md: 037e029332fbb665d90860d7e11c2fd117e6eb45
2026-07-30-web-result-card.md: 838b13a7f3240c753e5cd1af6909389055c6352d
2026-07-30-web-result-card.zh.md: 6e5170fcad82d709b050fb05e8efcfe955f20391

View File

@@ -16,13 +16,13 @@ One tag with a `kind` discriminant, not two tags. Both calls are web retrieval a
`presentationMeta` carries what render text cannot. The structured result object a tool returns from `execute` does NOT reach a client over the wire — only the model-facing `render` text and, when declared, the `output.presentationMeta` JSON projected onto the `tool/result` event's `meta` do. For `web_search` the meta is the ONLY faithful route to `{url, title?, snippet?, publishedAt?}`: the render collapses those fields into one lossy free-text line, so a consumer cannot reparse them. For `web_fetch` the meta is a smaller but real gain: `url`/`statusCode` are recoverable from the deterministic `Fetched <url> (HTTP <n>)` header line, but `truncated` is the effective truncation — provider cap, pre-conversion source cut, or the deployment's `fetchMaxOutputChars` output cap — which a client cannot recompute because it does not know that cap. The fetch card and the model-facing text derive `truncated` from one shared `renderFetchOutput(result, maxOutputChars)` helper, so the card never disagrees with the footer the model saw. This mirrors the write/edit diff template (`packages/fs/tool-fs/src/diff.ts`): a `*MetaFromValue` projector feeds `output.presentationMeta`, and a `*MetaFromResult` narrower reads `result.meta` back with a defensive fallback to the generic card. `web_fetch`'s body is already markdown in the result content, so it is not duplicated into meta.
Neither result view carries a `content` copy. A UI that does not render the structured `web` card falls back to the raw `tool/result` content. The TUI does exactly this: it renders no structured web body, and its transcript renderer routes a `web` view's fallback content through the same dim Markdown path as a generic card's content (`packages/ui/tui/src/components/transcript.ts`, where both `render` and `renderBody` narrow the `generic` arm to `view.content` and give a `web` view the same `this.result?.content` fallback). Copying the result content into the view would duplicate up to `fetchMaxOutputChars` characters on the same delivered frame for no gain (the same rejection the meta section applies to the fetch body), so the views omit it and the fallback path renders the identical text. Each view sets its result-state `title` from the call args (`args.query` / `args.url`) so a window-truncated replay that dropped the call head still has a title, the way write/edit reset title at result time.
Neither result view carries a `content` copy. A UI that does not render the structured `web` card falls back to the raw `tool/result` content, the same input a generic card consumes. Copying that content into the view would duplicate up to `fetchMaxOutputChars` characters on the same delivered frame for no gain (the same rejection the meta section applies to the fetch body), so the views omit it and the fallback path renders the identical text. Each view sets its result-state `title` from the call args (`args.query` / `args.url`) so a window-truncated replay that dropped the call head still has a title, the way write/edit reset title at result time.
`presentResult` returns `undefined` (the generic card) on an error result and on absent or malformed `meta`, because presentation runs on replay of arbitrary logged results (possibly from an older schema) and must never throw. The narrowers validate every field defensively; an empty source list is valid meta, not malformed.
## Consequences
The web frontend consumer is a separate later PR: this PR adds the contract arm and makes the two tools emit it, with no client-side rendering. The one observable change is that the `web_search`/`web_fetch` `tool/result` events now persist a `data.meta` payload (the `web-fetch` keyless snapshot is refreshed accordingly); the model-facing render text and the TUI presentation are unchanged (the TUI falls back to the same result content). The assembled-application transcript snapshot that exercises a `web` card belongs to the consumer PR that renders it, delivered there. Any existing `ToolResultView` consumer that switches exhaustively must add a `web` arm; the TUI does not switch exhaustively and needs none. `apiproxy`'s session schema already accepts any `card` string (`packages/host/apiproxy/src/api/sessions.schema.ts`), so the new view crosses the wire without a schema change.
The frontend consumer was a separate later PR: this producer change adds the contract arm and makes the two tools emit it, with no client-side rendering. Its one observable change is that the `web_search`/`web_fetch` `tool/result` events persist a `data.meta` payload (the `web-fetch` keyless snapshot was refreshed accordingly); model-facing render text and generic fallback content stay unchanged. The assembled-application transcript snapshot that exercises a `web` card belongs to the consumer change that renders it. Any `ToolResultView` consumer that switches exhaustively must add a `web` arm; a non-exhaustive consumer may use the raw-result fallback. `apiproxy`'s session schema already accepts any `card` string (`packages/host/apiproxy/src/api/sessions.schema.ts`), so the new view crosses the wire without a schema change.
A future web tool that wants this card declares `presentResult` returning a `card: 'web'` view with its own `kind`; adding a third `kind` is a union edit plus the frontend's branch, not a new card tag.

View File

@@ -16,13 +16,13 @@ Status: implemented
`presentationMeta` 携带 render 文本无法携带的东西。工具从 `execute` 返回的结构化结果对象**不会**经由 wire 抵达客户端——只有面向模型的 `render` 文本,以及(声明时)投影到 `tool/result` 事件 `meta` 上的 `output.presentationMeta` JSON 会。对 `web_search`meta 是得到 `{url, title?, snippet?, publishedAt?}` 的**唯一**忠实途径render 把这些字段压进一行有损的自由文本,消费者无法重新解析。对 `web_fetch`meta 是更小但真实的收益:`url`/`statusCode` 可从确定格式的 `Fetched <url> (HTTP <n>)` header 行还原,但 `truncated` 是有效截断——provider cap、转换前源截断或部署的 `fetchMaxOutputChars` 输出上限——客户端无法重算,因为它不知道那个上限。抓取卡片与面向模型的文本都从同一个 `renderFetchOutput(result, maxOutputChars)` helper 派生 `truncated`,因此卡片绝不会与模型看到的脚注分叉。这照搬 write/edit 的 diff 模板(`packages/fs/tool-fs/src/diff.ts`):一个 `*MetaFromValue` 投影器喂给 `output.presentationMeta`,一个 `*MetaFromResult` 收窄器读回 `result.meta`,并在失败时防御性回退到 generic 卡片。`web_fetch` 的正文已是结果内容中的 markdown因此不重复写入 meta。
两个结果视图都不携带 `content` 副本。不渲染结构化 `web` 卡片的 UI 回退到原始 `tool/result` 内容。TUI 正是如此:它不渲染结构化的 web 正文,其 transcript 渲染器把 `web` 视图的回退内容与 generic 卡片的内容路由进同一条 dim Markdown 路径(`packages/ui/tui/src/components/transcript.ts``render``renderBody` 都把 `generic` 分支收窄为 `view.content`,并给 `web` 视图相同的 `this.result?.content` 回退)。把结果内容复制进视图会在同一投递帧上重复最多 `fetchMaxOutputChars` 个字符却毫无收益(与 meta 一节对抓取正文的否决同理),因此视图省略它,回退路径渲染完全相同的文本。每个视图从调用参数设置其结果期 `title``args.query``args.url`),因此丢掉了调用头的窗口截断重放仍有标题,与 write/edit 在结果期重设 title 的做法一致。
两个结果视图都不携带 `content` 副本。不渲染结构化 `web` 卡片的 UI 回退到原始 `tool/result` 内容,这也是 generic 卡片消费的输入。把结果内容复制进视图会在同一投递帧上重复最多 `fetchMaxOutputChars` 个字符却毫无收益(与 meta 一节对抓取正文的否决同理),因此视图省略它,回退路径渲染完全相同的文本。每个视图从调用参数设置其结果期 `title``args.query``args.url`),因此丢掉了调用头的窗口截断重放仍有标题,与 write/edit 在结果期重设 title 的做法一致。
`presentResult` 在错误结果、以及 `meta` 缺失或畸形时返回 `undefined`(即 generic 卡片),因为 presentation 会在对任意已记录结果(可能来自旧 schema的重放中运行绝不能抛错。收窄器防御性地校验每个字段空来源列表是有效 meta而非畸形。
## Consequences
web 前端消费者是一个独立的后续 PR本 PR 新增契约分支并让两个工具发出它,不含客户端渲染。唯一可观察的变化是 `web_search`/`web_fetch``tool/result` 事件现在持久化一个 `data.meta` 载荷(`web-fetch` keyless 快照随之刷新);面向模型的 render 文本与 TUI 呈现不变TUI 回退到相同的结果内容)。渲染 `web` 卡片的组装应用 transcript 快照属于渲染它的消费者 PR在那里交付。任何做穷尽 switch 的现有 `ToolResultView` 消费都必须新增一个 `web` 分支;TUI 并不穷尽 switch无需新增`apiproxy` 的会话 schema 已接受任意 `card` 字符串(`packages/host/apiproxy/src/api/sessions.schema.ts`),因此新视图无需 schema 变更即可跨 wire。
前端消费方由后续独立 PR 交付:本次生产者变更新增契约分支并让两个工具发出它,不含客户端渲染。唯一可观察的变化是 `web_search`/`web_fetch``tool/result` 事件持久化一个 `data.meta` 载荷(`web-fetch` keyless 快照当时随之刷新);面向模型的 render 文本与 generic 回退内容保持不变。渲染 `web` 卡片的组装应用 transcript 快照属于渲染它的消费方变更。任何做穷尽 switch 的 `ToolResultView` 消费都必须新增一个 `web` 分支;非穷尽消费方可以使用原始结果回退`apiproxy` 的会话 schema 已接受任意 `card` 字符串(`packages/host/apiproxy/src/api/sessions.schema.ts`),因此新视图无需 schema 变更即可跨 wire。
未来想用此卡片的 web 工具,声明一个返回带自有 `kind``card: 'web'` 视图的 `presentResult`;新增第三个 `kind` 是一次联合类型编辑加前端的分岔,而非一个新的 card 标签。

View File

@@ -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/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.md
2026-07-30-web-tool-row-unified-expand-and-inspect.md: ba2f4ead8023772fad578ca0b647241ecc332905
2026-07-30-web-tool-row-unified-expand-and-inspect.zh.md: ac4835c7429a3ff7d3042f73d26d267911533132
2026-07-30-web-tool-row-unified-expand-and-inspect.md: 98f1595564f0bd0d22f1ca4318b4c7fe15c6900d
2026-07-30-web-tool-row-unified-expand-and-inspect.zh.md: 8f00349a975f777cc4d556ade3a9abe9676b8848

View File

@@ -10,14 +10,14 @@ The chat view's tool rows had drifted into per-surface interaction dialects: Too
## Decision
**Every expandable tool row shares one interaction — the whole row toggles (click / Enter / Space) with an icon→chevron hover preview — and one expanded body: an IN/OUT gutter-labeled card with per-section scroll caps; a hover-revealed Inspect pill jumps to the call's trajectory record through a one-shot store handoff; the chat view preserves its scroll offset across view switches through an in-memory per-session map.**
**Every expandable tool row shares one interaction — the whole row toggles (click / Enter / Space) with an icon→chevron hover preview — and one expanded body: an IN/OUT gutter-labeled card with per-section scroll caps; a hover-revealed Inspect pill jumps to the call's trajectory record through a one-shot store handoff; the chat view preserves its semantic reading position across view switches through an in-memory per-session map.**
- `toolRowModel` now derives result material alongside args: `output` (the `resultText` flatten, moved from DetailsPanel into the contract), and `errorSummary` (the failure's first line, shown as the collapsed summary in the error color). A row with body, output, or terminal material is expandable; the row itself is the toggle (`role="button"`, `aria-expanded`), and file-path summaries stay independent links via `stopPropagation`.
- The expanded card (figma 1249:35657) is a column of IN/OUT sections: each section is its own scrollport (max-height 150px) with a sticky gutter label, and the l2 divider spans the full card width. Think prose and the run_code CodeBlock keep their non-card bodies; context injection reuses the row with a label-less `plainBody` card.
- `terminalFailed` reads a settled terminal card's exit status so BashRow and GenericToolCard surface a failing command as the row's red state dot — the only failure signal the collapsed row has, since the call itself settles `isError:false`.
- TerminalBlock's banner joins the same reading model: it shares the card surface (no banner token), an l2 hairline separates it from the body, the command column caps at 150px and scrolls with sticky copy/status controls top-aligned to the first prompt row.
- Inspect: `ToolRowOwnerProps.inspect` (absent for rows without a call identity) renders a pill in real flow under the expanded body's bottom-left, revealed by hovering anywhere on the tool call. Clicking writes `{ callId }` to the chat store's one-shot `inspect` field and switches to the trajectory view; TrajectoryTable finds the record, opens its summary, and acknowledges by clearing the field.
- Scroll preservation: the chat view saves its offset on every scroll (null when pinned to bottom) into an apply-scope `Map<SessionId, number>` exposed as `chatScroll` on the injected props; the open-jump branch restores it on remount. Deliberately not persisted — a fresh page load keeps the open-jump-to-bottom default.
- Scroll preservation: on every non-bottom scroll, the chat view saves `{ anchorKey, anchorTop, scrollTop }` into an apply-scope per-session map exposed as `chatScroll`; a remount first uses `scrollTop` to reach the approximate window, then corrects by the stable node/call anchor's rectangle delta so width reflow keeps the same reading row in place. Every pinned path, including Back to bottom, clears the entry synchronously before a tab or session switch. The map remains deliberately unpersisted — a fresh page load keeps the open-jump-to-bottom default.
## Alternatives considered

View File

@@ -10,14 +10,14 @@
## 决定
**所有可展开工具行共享同一交互——整行即开关(点击 / Enter / 空格),图标 hover 时渐变为 chevron 预览——以及同一展开体:带 IN/OUT 侧栏标签的卡片各分区独立滚动上限hover 显示的 Inspect 胶囊通过 store 的一次性交接跳到该调用的 trajectory 记录;聊天视图用内存态的按会话 Map 在视图切换间保留滚动位置。**
**所有可展开工具行共享同一交互——整行即开关(点击 / Enter / 空格),图标 hover 时渐变为 chevron 预览——以及同一展开体:带 IN/OUT 侧栏标签的卡片各分区独立滚动上限hover 显示的 Inspect 胶囊通过 store 的一次性交接跳到该调用的 trajectory 记录;聊天视图用内存态的按会话 Map 在视图切换间保留语义阅读位置。**
- `toolRowModel` 在 args 之外同时派生结果材料:`output``resultText` 拍平逻辑从 DetailsPanel 移入 contract`errorSummary`(失败首行,以错误色作为折叠摘要)。有 body、output 或 terminal 材料的行即可展开;行本身是开关(`role="button"``aria-expanded`),文件路径摘要通过 `stopPropagation` 保持独立链接。
- 展开卡片figma 1249:35657是 IN/OUT 分区列每个分区是独立滚动区max-height 150px侧栏标签 sticky 固定l2 分割线横贯整卡宽度。Think 的推理文本和 run_code 的 CodeBlock 保持非卡片体;上下文注入复用此行并以无标签的 `plainBody` 卡片展开。
- `terminalFailed` 读取已结算 terminal 卡片的退出状态,让 BashRow 和 GenericToolCard 把失败命令显示为行的红色状态点——这是折叠行唯一的失败信号,因为调用本身结算为 `isError:false`
- TerminalBlock 的横幅并入同一阅读模型:与卡片共用同一表面(不再用 banner token与正文之间是 l2 细线,命令列上限 150px 内部滚动,复制/状态控件 sticky 且顶对齐第一行提示符。
- Inspect`ToolRowOwnerProps.inspect`无调用身份的行不提供在展开体左下角以真实布局位置渲染胶囊hover 整个 tool call 任意位置显示。点击将 `{ callId }` 写入 chat store 的一次性 `inspect` 字段并切换到 trajectory 视图TrajectoryTable 找到记录、打开其摘要,并通过清空字段确认。
- 滚动保留:聊天视图在每次滚动时保存偏移(贴底时为 null到 apply 作用域的 `Map<SessionId, number>`,经注入 props 的 `chatScroll` 暴露;重挂载时 open-jump 分支恢复它。刻意不持久化——新页面加载保持打开即贴底的默认行为。
- 滚动保留:每次非贴底滚动时,聊天视图把 `{ anchorKey, anchorTop, scrollTop }` 保存到 apply 作用域的按会话 Map并经注入 props 的 `chatScroll` 暴露;重挂载时先用 `scrollTop` 到达近似窗口,再按稳定 nodecall 锚点的矩形差值校正,因此宽度重排后仍把同一阅读行保持在原位。包括「回到底部」在内的每条贴底路径都会在切换 tab 或会话前同步清除该项。Map 仍刻意不持久化——新页面加载保持打开即贴底的默认行为。
## 曾考虑的替代方案

View File

@@ -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/feature/2026-07-31-web-telemetry-default-mount.md
2026-07-31-web-telemetry-default-mount.md: e9ec7d0cda37db44e753c9aee572763b7e24ada6
2026-07-31-web-telemetry-default-mount.zh.md: 68b411d0668772ce81d7f323c2d286714a223ca4
2026-07-31-web-telemetry-default-mount.md: c1525a44196991d5059969ea70af34501b199323
2026-07-31-web-telemetry-default-mount.zh.md: f203ea1943387beda445bd81ac78fa2cc0471d45

View File

@@ -10,15 +10,15 @@ The telemetry seam and OTel backend ([revival Note](2026-07-23-session-telemetry
## Decision
The shared `dsh` core (`apps/cli/config/base.cordis.yml`) mounts the `telemetry-otel` row by default with a baked-in production endpoint, so every surface — TUI, web, and headless — reports; this is the **internal-testing deployment stance** — reporting is on when an endpoint exists, and users opt out through the environment. Each surface's exit path drains the queue: web/headless dispose on SIGINT/SIGTERM (headless gained those handlers in this change), and the TUI's normal exit runs `disposeRootAndExit` (root dispose, 5s bounded — above the ~1s drain ceiling configured here) while its `/resume` handoff disposes the root before `execve`.
The shared `dsh` base (`apps/cli/config/base.cordis.yml`) mounts the `telemetry-otel` row by default with a baked-in production endpoint, so Web and headless report; the raw-config command also mounts it before applying its required deployment overlay. This is the **internal-testing deployment stance** — reporting is on when an endpoint exists, and users opt out through the environment. Web and headless use the [bounded, escalating process-shutdown controller](../bug-fix/2026-08-03-cli-signal-shutdown-escalation.md) on SIGINT/SIGTERM, giving the backend's three-second shutdown deadline time to drain before the five-second launcher bound.
| Ruling | Value | Rationale |
|---|---|---|
| Mount surface | base.cordis.yml (TUI + web + headless) | One deployment stance for every surface; per-surface divergence would need a reason, and none exists |
| Mount surface | base.cordis.yml (raw config + Web + headless) | One deployment stance for every tree that loads the shared base; the raw overlay decides whether that deployment creates sessions |
| Endpoint | `DSH_TELEMETRY_OTLP_URL`, default `https://harness-telemetry.deepseeksvc.com/v1/logs` | Internal collector; the env override serves local/dev runs |
| Opt-out switch | any non-empty `DSH_TELEMETRY_DISABLED` (including `0`/`false`) disables | A privacy switch prefers off-by-mistake over on-by-mistake; a row can only be disabled at AppCLIEntry's patch layer (config has no disable semantic, and the switch must precede the load-time `exporter.url` validation) |
| Cadence | `processor.scheduledDelayMillis: 10000` (10s/batch) | Streaming while the session runs, never exit-time-only; a crash loses at most the last unexported interval |
| Exit-drain bound | `exporter.timeoutMillis: 1000` + `maxExportBatchSize: 2048` (== maxQueueSize) + `exportTimeoutMillis: 1500` | Dispose must release within ~1s against an unreachable collector: timeoutMillis doubles as the per-attempt socket timeout and the retry deadline (1s effectively disables the SDK's 5-try backoff), and aligning batch size with the queue cap makes the drain a single batch; SDK defaults can stall 40s+ |
| Exit-drain bound | `exporter.timeoutMillis: 1000` + `maxExportBatchSize: 2048` (== maxQueueSize) + `exportTimeoutMillis: 1500` + `shutdownTimeoutMillis: 3000` | Ordinary unreachable-collector failure releases in ~1s: timeoutMillis is the per-attempt socket timeout and retry deadline, while one queue-sized batch avoids sequential drain multiplication. The DSH-owned 3s outer bound covers the SDK's preceding unbounded `forceFlush()` wait when the transport Promise never obtains a socket. |
| Compression | `compression: gzip` | Event bodies carry full content; cross-datacenter bandwidth |
| CI isolation | top-level `env: DSH_TELEMETRY_DISABLED: '1'` in all 8 GitHub workflows | Every CI channel that boots the web composition (e2e/snapshot/built smokes) must not stream test sessions to the production endpoint |
@@ -30,7 +30,7 @@ The keyless integration test `apps/cli/tests/telemetry-web.e2e.ts` pins the depl
**A config field instead of an env patch for the switch.** Infeasible: cordis rows have no config-level disable semantic, and `exporter.url` validation fails loud at plugin construction, so the switch must take effect before the Loader — AppCLIEntry's patch layer is the only seat.
**A `Promise.race` timeout backstop around exit.** Deferred: the parameter set already bounds the worst-case drain to ~1.5-3s (typically <100ms), measured SIGINT-to-exit 110ms-1.1s; the unbounded drip-feed-response risk stays under observation, and on real evidence the race lands inside the backend's `shutdown()` (never the coordinator — that would decide loss semantics for every backend).
**A `Promise.race` timeout backstop around exit.** Originally deferred because the SDK parameters appeared to bound the backend's drain to ~1.5-3s (typically <100ms), with measured SIGINT-to-exit of 110ms-1.1s. A Linux sandbox reproduction later proved that `BatchLogRecordProcessor.shutdown()` can wait forever in `exporter.forceFlush()` before reaching its `exportTimeoutMillis`-bounded completion Promise. The [CLI shutdown fix](../bug-fix/2026-08-03-cli-signal-shutdown-escalation.md) therefore adds both a three-second backend bound for that specific gap and a five-second process-level bound plus repeated-signal escape for the whole plugin tree.
## Consequences

View File

@@ -10,15 +10,15 @@ Status: implemented
## Decision
`dsh` 共享核心`apps/cli/config/base.cordis.yml`)默认挂载 `telemetry-otel` 行,内置生产 endpoint因此所有 surface——TUI、web、headless——都上报这是**内部测试期的部署立场**——有 endpoint 就报,用户可环境变量退出。各 surface 的退出路径都会排空队列web/headless 在 SIGINT/SIGTERM 上 disposeheadless 的信号处理是本次补上的TUI 的正常退出走 `disposeRootAndExit`(根 dispose5s 兜底——高于此处配置的 ~1s drain 上界),其 `/resume` 移交也在 `execve` 前 dispose 根
`dsh` 共享 base`apps/cli/config/base.cordis.yml`)默认挂载 `telemetry-otel` 行,内置生产 endpoint因此 Web 与 headless 都会上报;原始配置命令也会先挂载该行,再应用其必需的部署 overlay。这是**内部测试期的部署立场**——有 endpoint 就报,用户可通过环境变量退出。Web 与 headless 在 SIGINT/SIGTERM 时使用[有界、可升级的进程关闭控制器](../bug-fix/2026-08-03-cli-signal-shutdown-escalation.md),在启动器 5 秒上限到期前,先给后端 3 秒关闭截止时间完成排空
| 决策项 | 取值 | 理由 |
|---|---|---|
| 挂载面 | base.cordis.ymlTUI + web + headless | 所有 surface 一个部署立场;按 surface 分化需要理由,而当前没有 |
| 挂载面 | base.cordis.yml原始配置 + Web + headless | 所有加载共享 base 的配置树采用同一个部署立场;原始配置 overlay 决定该部署是否创建会话 |
| endpoint | `DSH_TELEMETRY_OTLP_URL`,缺省 `https://harness-telemetry.deepseeksvc.com/v1/logs` | 内部 collectorenv 覆盖供本地/联调 |
| 退出开关 | `DSH_TELEMETRY_DISABLED` 非空(含 `0`/`false`)即关 | 隐私向开关取「宁关勿误开」;行级 disable 只能在 AppCLIEntry 的 patch 层做config 无 disable 语义,且必须先于 `exporter.url` 的加载期校验生效) |
| 上报节奏 | `processor.scheduledDelayMillis: 10000`10s/批) | 流式回流,非退出才报;崩溃至多丢最后一个未导出间隔 |
| 退出 drain 上界 | `exporter.timeoutMillis: 1000` + `maxExportBatchSize: 2048== maxQueueSize` + `exportTimeoutMillis: 1500` | collector 不可达时 dispose 必须 ~1s 内放行timeoutMillis 同时是单次 socket 超时与重试 deadline1s 等效关掉 SDK 5 次 backoff批大小对齐队列上限使 drain 恒为单批;默认参数下最坏可卡 40s+ |
| 退出 drain 上界 | `exporter.timeoutMillis: 1000` + `maxExportBatchSize: 2048== maxQueueSize` + `exportTimeoutMillis: 1500` + `shutdownTimeoutMillis: 3000` | collector 不可达的常规故障会在约 1s 内放行timeoutMillis 是单次 socket 超时与重试 deadline,使用与队列等大的单批可避免依次排空导致耗时倍增。由 DSH 管理的 3s 外层上限覆盖 SDK 先执行的无界 `forceFlush()` 等待,即传输 Promise 始终无法取得 socket 的情况。 |
| 压缩 | `compression: gzip` | 事件 body 含全文,跨机房带宽 |
| CI 隔离 | 全部 8 个 GitHub workflow 顶层 `env: DSH_TELEMETRY_DISABLED: '1'` | CI 启动 web 组合的所有通道e2e/snapshot/built smoke不得向生产 endpoint 泄测试会话 |
@@ -30,7 +30,7 @@ Status: implemented
**开关做成 config 字段而非 env patch。** 不可行cordis 行没有 config 层的 disable 语义,且 `exporter.url` 校验在插件构造期 fail-loud开关必须在 Loader 之前生效——AppCLIEntry patch 层是唯一落点。
**退出时 `Promise.race` 兜底超时。** 暂缓:参数组合已把最坏 drain 压到 ~1.5-3s典型 <100ms实测 SIGINT→退出 110ms-1.1sdrip-feed 慢滴响应的无界等待风险留观,出现实证再在 backend `shutdown()` 内加 race不放 coordinator——那会替所有 backend 决定丢失语义)
**退出时 `Promise.race` 兜底超时。** 最初暂缓,是因为 SDK 参数看似已经将后端排空耗时限制在约 1.5-3s通常 <100ms实测 SIGINT 到退出耗时 110ms-1.1s。后来在 Linux 沙箱中复现并证明,`BatchLogRecordProcessor.shutdown()` 可能在 `exporter.forceFlush()` 中永久等待,无法进入受 `exportTimeoutMillis` 限制的完成 Promise。因此[CLI 关闭修复](../bug-fix/2026-08-03-cli-signal-shutdown-escalation.md) 既为这一特定缺口增加 3 秒后端上限,也为整棵插件树增加 5 秒进程级上限和重复信号退出途径
## Consequences

View File

@@ -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/testing/2026-07-24-web-gui-browser-e2e-lane.md
2026-07-24-web-gui-browser-e2e-lane.md: f05fc7268cfb613d0af8240bbb65cb154252a620
2026-07-24-web-gui-browser-e2e-lane.zh.md: 3fd3805053a570a32e63601d7b039db41365309c
2026-07-24-web-gui-browser-e2e-lane.md: f8519a9622d2f7216226a695db95dbebdbf24ea1
2026-07-24-web-gui-browser-e2e-lane.zh.md: 294f3e840e0242d9a0d9c53ac510d44d3b0d100f

View File

@@ -24,7 +24,7 @@ Keyless model displacement is the disabled adapter row plus `installLlmReplay` f
### Determinism rules
The barrier stack for replay-mode browser assertions is, in order: (1) host-side `await agent.whenIdle()` under a timeout, keyed off the in-process `turn/end` — the idle flip follows the persistence flush, so one await covers turn completion and durability; (2) browser settled poll (streaming detached, final text visible). Record-mode log harvest runs after `whenIdle()` and before scaffold disposal while the live session remains available. An in-process `turn/end` listener alone is a wrong barrier (it fires before the SSE frame reaches the browser and before the fsync); file polling is banned (slow on NFS, superseded by `whenIdle`); `networkidle` is banned outright (never resolves while an SSE stream is open).
The barrier stack for replay-mode browser assertions is, in order: (1) host-side `await agent.whenIdle()` under a timeout, keyed off the in-process `turn/end` — the idle flip follows the persistence flush, so one await covers turn completion and durability; (2) browser settled poll (streaming detached, final text visible). Record-mode log harvest runs after `whenIdle()` and before scaffold disposal while the live session remains available. An in-process `turn/end` listener alone is a wrong barrier (it fires before the SSE frame reaches the browser and before the fsync); polling persistence files as a turn-completion or durability barrier is banned (slow on NFS, superseded by `whenIdle`), while a tool-controlled temp readiness marker may be polled only as an interaction gate before that completion barrier; `networkidle` is banned outright (never resolves while an SSE stream is open). Navigation assertions arm both initial `session.list` and `workspace.list` responses before page load, then wait for the seeded DOM projection; the mounted shell alone is not readiness because late bootstrap can replace controlled state.
No single-shot transient-DOM assertions: every hop from replay yield to React commit can coalesce chunks, so sampling `[data-streaming]` is a race by construction. Streaming incrementality is asserted from the persisted `assistant/chunk` events (model-visible ⟺ logged makes the log the authoritative proof). `dsh-llm-replay`'s opt-in `paceMs` (default absent = burst) is a realism knob so the browser observes genuinely incremental SSE; correctness never leans on it, and abort during a pace wait cancels promptly.
@@ -42,12 +42,14 @@ The typecheck plane split is structural: the host scaffold, its support module,
### Coverage contract
The lane covers three behavior families. Live-turn scenarios pin ordinary tool execution, cancellation, non-retryable failure, transient retry, resident questions, and mid-turn steering; synchronization uses durable events, `whenIdle()`, or an explicit replay marker rather than delays. Cold-history scenarios seed through the real persistence API and cover history rendering, sidebar search, trajectory and waterfall views, and tool details without model calls. Browser-lifecycle scenarios cover first-send workspace materialization, reload recovery, layout reset, theme and locale preferences, and workspace create/rename/view operations. Each family asserts the browser surface and the authoritative host state; a stray model call or under-consumed fixture fails teardown.
The lane covers three behavior families. Live-turn scenarios pin ordinary tool execution, cancellation, non-retryable failure, transient retry, resident questions, and mid-turn steering; synchronization uses durable events, `whenIdle()`, or an explicit replay marker rather than delays. Cold-history scenarios seed through the real persistence API and cover history rendering, sidebar search, trajectory and waterfall views, and tool details without model calls. Browser-lifecycle scenarios cover first-send workspace materialization, reload recovery, layout reset, theme and locale preferences, and workspace create/rename/view operations. Each family asserts the browser surface and the authoritative host state; a stray model call or under-consumed fixture fails teardown. The required lane additionally carries an 88-turn synthetic Chat scroll contract mixing wrapped Markdown, fenced code, and paired bash calls/results. Real wheel, composer, tool, tab, session, and viewport interactions assert a named settled row's top relative to the transcript scrollport and distance from the true bottom across concurrent history prepend plus paced streaming, pinned/away streaming, tool-disclosure offscreen cycles, expanded-history view/session remount, width reflow, immediate pinned remount, composer resize, and textarea wheel chaining; it deliberately pins neither DOM cardinality nor absolute `scrollTop`, so the same contract can qualify a virtualized implementation. A separate interaction contract over the same fixture pins heterogeneous-row order, independent adjacent tool disclosure, exact user-message clipboard content, a turn-bounded message fork, source/child isolation, and a real follow-up turn in the child; wheel input only navigates to semantic targets and carries no geometry expectation. A short live-history contract starts from a blank workspace and drives consecutive composer turns, including real bash call/result rounds and a paced long final response, pinning one session identity, exact per-turn event ownership, browser echo uniqueness, and composer recovery without timing thresholds.
### CI stance
The lane is a required compare-only gate for Linux pull requests under the [browser snapshot CI decision](2026-07-30-web-browser-snapshot-ci-gate.md). The `node 24 / snapshots and artifacts` consumer job owns the [single Linux build](../process/2026-07-30-independent-ci-consumer-build.md), installs the lockfile-selected Chromium, restores its OS-and-lockfile-keyed cache, and runs the lane with `DSH_SNAPSHOT=replay`. This is an intentional plane split: the host and specs use the [tsx source-launch contract](../architecture/2026-07-29-dsh-source-launch-tsx-esm.md), while the browser consumes `apps/web/dist` and package `lib/client.js` artifacts, so the gate depends on `built-package-invariants` for those client artifacts. The hosted and self-hosted default-branch Linux serial jobs run the same gate; the hosted job produces the browser cache consumed by pull requests, while the persistent self-hosted pool needs no hosted cache. CI never records or refreshes goldens. Scenarios remain POSIX-oriented and stay outside the Windows and macOS matrices.
High-cardinality performance diagnostics use the separate opt-in `apps/web/tests/**/*.perf.ts` inventory selected only by `vitest.web.perf.config.ts`. The isolated `complex-history.perf.ts` cases reuse the real scaffold: the workspace case seeds 1,000 compact sessions plus one 500-turn history containing 500 tool calls, exhausts and remounts that history in Chat, and reports Chromium main-thread, DOM, listener, heap, paging, search, and Trajectory measurements. Two continuation cases seed the same long history but compare the default 24-turn Chat window with all 500 turns expanded before each continues eight identical turns through the real composer, agent loop, SSE wire, tools, and persistence; two turns execute a real `bash` call and assert its durable result, while the final turn fills an 8,232-character mixed-language prompt and replays 120 paced text deltas. A separate soak case starts from a blank session, drives 100 consecutive real composer turns with a `bash` call and result every tenth turn, forces GC every ten turns, and reports ten-turn latency windows plus retained browser state. It then submits a 101st text-only turn with a trusted browser click and measures browser-clock send-to-transcript-DOM and send-to-post-paint latency, excluding the composer's draft mirrors, separately from full-turn completion. Per-turn diagnostics cover composer fill, click-to-user-echo, click-to-first-chunk, completion, browser mutations, persisted chunks, and tool events; the synthetic replay model has enough context capacity to keep fixture cardinality stable instead of consuming scripted calls through compaction. Structural assertions pin the intended load, stream, and tool shapes, but timing remains threshold-free because machine speed is not a correctness contract. The required `vitest.web.config.ts` inventory remains limited to `*.e2e.ts` and `*.snapshot.ts`, so neither `test:web:built` nor its CI gate collects performance cases.
## Prior art
Surveyed AI-chat/agent web UIs and mocking layers (LibreChat, vercel/ai-chatbot + AI SDK, lobe-chat, open-webui, OpenHands, Chainlit, continue, cline, langfuse, gradio/streamlit; Playwright HAR/route, MSW, Polly/nock, WireMock, aimock). The dominant proven architecture for apps that own their backend is an in-process fake/replay model behind the real backend seam with everything downstream real (LibreChat's `LIBRECHAT_TEST_RUN_HOOK` fake model; ai-chatbot's `MockLanguageModelV3` + `simulateReadableStream`; continue's scripted mock provider classes) — which is what `dsh-llm-replay` already is. Browser-level SSE interception cannot exercise incremental rendering (`route.fulfill` delivers the whole body at once; playwright#33564) and leaves the server SSE stack untested, so projects use it only for edge cases. Chunk pacing as a fixture parameter recurs everywhere (LibreChat 10ms default with slow profiles; ai-chatbot 500ms); real models in CI rot (open-webui's suite grew 120-second timeouts, was disabled, then deleted); sessions are seeded at the persistence layer with controlled timestamps (LibreChat inserts backdated Mongo documents; langfuse seeds its DB). No surveyed project replays a recorded agent-event log through the real backend for UI tests — the closest are provider-level recorded fixtures (aimock) and frontend-level socket history emission (OpenHands MSW) — so the session-log-as-fixture design goes one step beyond prior art along the axis this repo's model-visible ⟺ logged invariant makes natural.
@@ -72,11 +74,13 @@ Surveyed AI-chat/agent web UIs and mocking layers (LibreChat, vercel/ai-chatbot
**Real-model browser tests as the keyless lane.** Rejected: nondeterministic by construction; the surveyed cautionary case (open-webui) grew unbounded timeouts and was deleted. The with-key W5 smoke stays as the live-model complement.
**Running the high-cardinality performance case in the required browser gate.** Rejected: its fixture setup and full-history render add tens of seconds, while wall-clock and memory values vary with the host and cannot supply a stable correctness threshold. The required lane keeps deterministic behavior assertions; contributors run the diagnostic case when investigating or changing large-list and long-history rendering.
**A client `data-dsh-busy` settled signal.** Deferred: the host-side `whenIdle` barrier plus stable DOM polls cover the current scenarios. Reconsider after the first settled-poll flake or when a required state is not observable in the DOM.
## Testing
`pnpm run test:web` builds and runs the lane keylessly; `test:web:built` runs it against existing build artifacts. `DSH_SNAPSHOT=record pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/<spec>` records a prompting scenario against the live model, and `DSH_SNAPSHOT=refresh pnpm run test:web` rewrites aria goldens keylessly. CI explicitly selects replay mode. The live-interactions AUTH scenario pins a non-retryable terminal failure as an inline Chat status carrying the display-safe message and code, verifies that provider-echoed credential fragments stay absent from both Chat and Trajectory, and covers composer recovery plus the `turn/end` error. The scaffold hermeticity scenario populates distinct entries in all three ambient skill roots and requires none to enter the assembled catalog. `dsh-llm-replay` unit coverage pins pacing, cancellation, consumption diagnostics, sidecar validation, indexed replacement, and the single append position.
`pnpm run test:web` builds and runs the lane keylessly; `test:web:built` runs it against existing build artifacts. `pnpm run test:web:perf` builds and runs the manual performance inventory; `test:web:perf:built` reuses existing artifacts. `DSH_SNAPSHOT=record pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/<spec>` records a prompting scenario against the live model, and `DSH_SNAPSHOT=refresh pnpm run test:web` rewrites aria goldens keylessly. CI explicitly selects replay mode. The live-interactions AUTH scenario pins a non-retryable terminal failure as an inline Chat status carrying the display-safe message and code, verifies that provider-echoed credential fragments stay absent from both Chat and Trajectory, and covers composer recovery plus the `turn/end` error. The scaffold hermeticity scenario populates distinct entries in all three ambient skill roots and requires none to enter the assembled catalog. `dsh-llm-replay` unit coverage pins pacing, cancellation, consumption diagnostics, sidecar validation, indexed replacement, and the single append position.
## Deferred
@@ -84,7 +88,8 @@ Surveyed AI-chat/agent web UIs and mocking layers (LibreChat, vercel/ai-chatbot
- **Follow-up-prompt-after-resume scenario**: the history/live stitch path over the real wire; add as its own scenario when that code changes or regresses.
- **Composer steering gesture**: the input locks while running (stop-or-wait), so the steering scenario steers over the wire from the page; `TODO(web-steer-composer)` upgrades the drive step to a real composer gesture when the product grows one.
- **Drag session reorder**: `workspace.insertSessionBefore` has no browser scenario; it needs two sessions materialized in one workspace plus synthesized HTML5 drag events. Add it when that surface changes or regresses. The inert session Rename/Fork/Delete and workspace Delete menu rows get scenarios when they gain behavior.
- **Long-history Chat-to-Trajectory Inspect**: the independent inspection source exhausts history after the view opens, while the selected record is addressed by a derived table index that can move as older pages prepend. Short-history Inspect remains covered; the long-history interaction contract excludes this handoff until selection has a stable semantic identity.
## Consequences
The web surface gains its record-once/replay-forever tier: the real chromium → SSE → apiproxy → loop → tools → persistence chain runs keylessly in ~10-30s, deterministic across repeat runs, with fixtures owned and re-recordable by the lane itself. Costs accepted: every intentional conversation-UI change ends with a keyless `DSH_SNAPSHOT=refresh` (golden churn is reviewed diff, anchors keep semantic green); the aria format is Playwright-owned — the one committed snapshot format the repo does not control — so playwright version bumps must be deliberate bump-and-refresh commits (the dependency floats `^1.49.0` in `apps/web/package.json`; pin exactly if churn bites); replay's first-call-order binding constrains scenarios to one prompting session each, with the consumption assertion as the tripwire; `compact-basic` shares the session's replay cursor and stays inert only under the published 128k catalog window; and the required consumer job pays for Chromium provisioning and one browser run so the PR that changes the assembled UI owns its expected-output diff.
The web surface gains its record-once/replay-forever tier: the real chromium → SSE → apiproxy → loop → tools → persistence chain runs keylessly in ~10-30s, deterministic across repeat runs, with fixtures owned and re-recordable by the lane itself. Costs accepted: every intentional conversation-UI change ends with a keyless `DSH_SNAPSHOT=refresh` (golden churn is reviewed diff, anchors keep semantic green); the aria format is Playwright-owned — the one committed snapshot format the repo does not control — so playwright version bumps must be deliberate bump-and-refresh commits (the dependency floats `^1.49.0` in `apps/web/package.json`; pin exactly if churn bites); replay's first-call-order binding constrains scenarios to one prompting session each, with the consumption assertion as the tripwire; `compact-basic` shares the session's replay cursor and stays inert only under the published 128k catalog window; and the required consumer job pays for Chromium provisioning and one browser run so the PR that changes the assembled UI owns its expected-output diff. The opt-in performance lane preserves a repeatable diagnostic workload without adding host-sensitive duration or memory expectations to CI; performance regressions remain a manually interpreted signal until the repository owns a calibrated benchmark environment.

View File

@@ -24,7 +24,7 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu
### 确定性规则
回放模式下浏览器断言的屏障栈按序1host 侧 `await agent.whenIdle()` 加超时,以进程内 `turn/end` 为锚——空闲翻转发生在持久化落盘之后一次等待同时覆盖轮次完成与持久性2浏览器安定轮询流式输出节点已卸载、最终文本可见。录制模式下日志采收在 `whenIdle()` 之后、scaffold 释放之前进行,此时运行中的会话仍然可用。单独监听进程内 `turn/end` 是错误屏障(它先于 SSE 帧到达浏览器、先于 fsync 触发);文件轮询被禁止NFS 上慢,且被 `whenIdle` 取代);`networkidle` 被彻底禁止SSE 流保持打开时它永不解析)。
回放模式下浏览器断言的屏障栈按序1host 侧 `await agent.whenIdle()` 加超时,以进程内 `turn/end` 为锚——空闲翻转发生在持久化落盘之后一次等待同时覆盖轮次完成与持久性2浏览器安定轮询流式输出节点已卸载、最终文本可见。录制模式下日志采收在 `whenIdle()` 之后、scaffold 释放之前进行,此时运行中的会话仍然可用。单独监听进程内 `turn/end` 是错误屏障(它先于 SSE 帧到达浏览器、先于 fsync 触发);禁止轮询持久化文件来充当轮次完成或持久性屏障NFS 上慢,且被 `whenIdle` 取代),但工具控制的临时就绪标记可以仅作为该完成屏障之前的交互门控进行轮询`networkidle` 被彻底禁止SSE 流保持打开时它永不解析)。导航断言会在页面加载前同时监听 `session.list``workspace.list` 的初始响应,随后等待播种数据投影到 DOM仅凭 shell 已挂载不能判定就绪,因为较晚完成的 bootstrap 可能替换受控状态。
不做单次瞬态 DOM 断言:从回放产出到 React 提交的每一跳都可能合并分片,采样 `[data-streaming]` 天然就是竞态。流式输出的增量性由持久化的 `assistant/chunk` 事件断言(模型可见 ⟺ 已记录,使日志成为权威证据)。`dsh-llm-replay` 的可选 `paceMs`(默认缺省 = 突发)只是让浏览器观察到真正增量 SSE 的真实感旋钮;正确性绝不依赖它,且节奏等待期间中止会即时取消。
@@ -42,12 +42,14 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu
### 覆盖契约
该车道覆盖三类行为。实时轮次场景钉住普通工具执行、取消、不可重试失败、瞬态重试、常驻提问与轮次中途 steering同步依赖持久事件、`whenIdle()` 或显式回放标记,而不使用延时。冷历史场景通过真实持久化 API 播种在不调用模型的情况下覆盖历史渲染、侧栏搜索、Trajectory 与 Waterfall 视图及工具详情。浏览器生命周期场景覆盖首次发送时物化工作区、重新加载恢复、布局重置、主题与语言偏好,以及工作区的创建、重命名和视图操作。每类场景都断言浏览器表面和权威的 host 状态;离群的模型调用或未耗尽的 fixture 会使拆卸失败。
该车道覆盖三类行为。实时轮次场景钉住普通工具执行、取消、不可重试失败、瞬态重试、常驻提问与轮次中途 steering同步依赖持久事件、`whenIdle()` 或显式回放标记,而不使用延时。冷历史场景通过真实持久化 API 播种在不调用模型的情况下覆盖历史渲染、侧栏搜索、Trajectory 与 Waterfall 视图及工具详情。浏览器生命周期场景覆盖首次发送时物化工作区、重新加载恢复、布局重置、主题与语言偏好,以及工作区的创建、重命名和视图操作。每类场景都断言浏览器表面和权威的 host 状态;离群的模型调用或未耗尽的 fixture 会使拆卸失败。必需车道还包含一份合成的 88 轮 Chat 滚动契约,其中混合了换行 Markdown、围栏代码以及成对的 bash 调用/结果。真实 wheel、输入框、工具、tab、会话与 viewport 交互会在并发历史前插加带节奏流式输出、贴底/离底流式输出、工具 disclosure 离屏循环、扩展历史后的视图/会话重新挂载、宽度重排、贴底后立即重新挂载、输入框尺寸变化以及 textarea wheel 链场景中,断言一个具名已结算行相对 transcript scrollport 的顶部位置和到真实底部的距离;它刻意不钉 DOM 基数或绝对 `scrollTop`,因此同一契约可以验收虚拟化实现。另一份基于同一 fixture 的交互契约钉住异构行顺序、相邻工具 disclosure 的独立状态、用户消息剪贴板内容的精确值、以轮次为边界的消息 fork、源会话/子会话隔离以及子会话中的一次真实追问轮次wheel 输入只用于导航到语义目标,不承载几何预期。一份简短的实时历史契约从空白工作区开始,连续驱动输入框轮次,其中包括真实的 bash 调用/结果轮次和一段带节奏的长篇最终响应;它钉住单一会话身份、每轮事件的精确归属、浏览器回显唯一性与输入框恢复,不设置时间阈值。
### CI 立场
根据[浏览器快照 CI 决策](2026-07-30-web-browser-snapshot-ci-gate.md),该车道是 Linux 拉取请求必需的只比较门禁。`node 24 / snapshots and artifacts` 消费方任务在[消费方独立构建](../process/2026-07-30-independent-ci-consumer-build.md)中负责唯一一次 Linux 构建,安装锁文件选定的 Chromium恢复以操作系统和锁文件为键的缓存并用 `DSH_SNAPSHOT=replay` 运行该车道。这是有意的平面切分host 与 spec 使用 [tsx 源码启动契约](../architecture/2026-07-29-dsh-source-launch-tsx-esm.md),浏览器则消费 `apps/web/dist` 和包的 `lib/client.js` 产物,因此门禁依赖 `built-package-invariants` 提供这些客户端产物。托管和自托管的默认分支 Linux 串行任务运行同一门禁;托管任务生成供 PR 消费的浏览器缓存持久化自托管池则不需要托管侧缓存。CI 从不录制或刷新预期输出。场景仍面向 POSIX并继续置于 Windows 和 macOS 矩阵之外。
高基数性能诊断使用单独按需启用的 `apps/web/tests/**/*.perf.ts` 清单,并且只由 `vitest.web.perf.config.ts` 选中。`complex-history.perf.ts` 的隔离用例复用真实 scaffold工作区用例播种 1,000 个紧凑会话以及一份包含 500 次工具调用的 500 轮次历史,在 Chat 中穷尽并重新挂载该历史,并报告 Chromium 主线程、DOM、监听器、堆内存、分页、搜索和 Trajectory 测量结果。两个续聊用例播种同一份长历史,但比较默认的 24 轮次 Chat 窗口与展开全部 500 轮次的状态然后各自通过真实输入框、agent loop、SSE wire、工具和持久化继续进行 8 个相同轮次;其中两轮执行真实 `bash` 调用并断言其持久化结果,最后一轮则填入一条包含 8,232 个字符的混合语言提示词,并回放 120 个带节奏的文本增量。一个单独的 soak 用例从空白会话开始,通过真实输入框连续驱动 100 轮,每第 10 轮执行一次 `bash` 调用并产生结果,每 10 轮强制执行一次 GC并报告每 10 轮的延迟窗口及保留的浏览器状态。随后它通过受信任的浏览器点击提交第 101 个纯文本轮次,并使用浏览器时钟分别测量发送到 transcript DOM 和发送到绘制后的延迟,排除输入框的草稿镜像,并与完整轮次完成时间分开。逐轮诊断涵盖输入框填入、点击到用户消息回显、点击到首个分片、完成、浏览器变更、持久化分片和工具事件;合成回放模型拥有足够的上下文容量,可使 fixture 基数保持稳定而不会因压缩compaction消耗脚本化调用。结构性断言钉住预期的负载、流和工具形状但时间仍不设阈值因为机器速度不属于正确性契约。必需的 `vitest.web.config.ts` 清单仍仅限 `*.e2e.ts``*.snapshot.ts`,因此 `test:web:built` 及其 CI 门禁都不会收集性能用例。
## 业界先例
调研了 AI 聊天/agent web UI 与 mock 层LibreChat、vercel/ai-chatbot + AI SDK、lobe-chat、open-webui、OpenHands、Chainlit、continue、cline、langfuse、gradio/streamlitPlaywright HAR/route、MSW、Polly/nock、WireMock、aimock。自有后端的应用的主流成熟架构是真实后端 seam 后放一个进程内伪造/回放模型下游全部真实LibreChat 的 `LIBRECHAT_TEST_RUN_HOOK` 伪模型ai-chatbot 的 `MockLanguageModelV3` + `simulateReadableStream`continue 的脚本化 mock 提供方类)——这正是 `dsh-llm-replay` 已然所是。浏览器层 SSE 拦截无法检验增量渲染(`route.fulfill` 一次性交付整个响应体playwright#33564),且服务端 SSE 栈完全失测,因此各项目只把它用于边缘用例。分片节奏作为 fixture 参数反复出现LibreChat 默认 10ms 附慢速档ai-chatbot 500msCI 里的真实模型会腐烂open-webui 的套件长出 120 秒超时先被禁用后被删除会话在持久化层以受控时间戳播种LibreChat 直插回拨时间的 Mongo 文档langfuse 播种其数据库)。没有任何被调研项目为 UI 测试把录制的 agent 事件日志经真实后端回放——最接近的是提供方层录制 fixtureaimock与前端层 socket 历史发射OpenHands MSW——因此会话日志即 fixture 的设计沿着本仓库「模型可见 ⟺ 已记录」不变式所指的方向比业界先例多走了一步。
@@ -72,11 +74,13 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu
**以真实模型浏览器测试充当无密钥车道。** 已否决按构造即不确定被调研的前车之鉴open-webui长出无界超时后被删除。带密钥的 W5 冒烟仍是真实模型侧的补充。
**在必需的浏览器门禁中运行高基数性能用例。** 已否决:其 fixture 设置和完整历史渲染会增加数十秒耗时,而壁钟时间和内存值随 host 不同而变化,无法提供稳定的正确性阈值。必需车道保留确定性行为断言;贡献者在调查或更改大列表和长历史渲染时运行该诊断用例。
**客户端 `data-dsh-busy` 安定信号。** 暂缓host 侧 `whenIdle` 屏障配合稳定 DOM 轮询,足以覆盖当前场景。第一次安定轮询抖动,或必要状态在 DOM 中不可观察时,再重新考虑。
## Testing
`pnpm run test:web` 构建并无密钥运行该车道;`test:web:built` 基于现有构建产物运行。`DSH_SNAPSHOT=record pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/<spec>` 对真实模型录制一个发起提示的场景,`DSH_SNAPSHOT=refresh pnpm run test:web` 则无密钥重写 aria 预期输出。CI 显式选择回放模式。live-interactions AUTH 场景会把不可重试的终态失败钉为 Chat 内联状态,其中携带适合展示的消息与错误码,并验证提供方回显的凭据片段不会出现在 Chat 或 Trajectory 中;该场景同时覆盖输入框恢复与 `turn/end` 错误。scaffold 环境隔离场景会在全部 3 个环境 skill 根目录中分别填入不同条目,并要求这些条目都不得进入组装后的目录。`dsh-llm-replay` 单元覆盖率钉住节奏控制、取消、消费诊断、sidecar 校验、按索引替换与唯一的追加位置。
`pnpm run test:web` 构建并无密钥运行该车道;`test:web:built` 基于现有构建产物运行。`pnpm run test:web:perf` 构建并运行手动性能清单;`test:web:perf:built` 复用现有产物。`DSH_SNAPSHOT=record pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/<spec>` 对真实模型录制一个发起提示的场景,`DSH_SNAPSHOT=refresh pnpm run test:web` 则无密钥重写 aria 预期输出。CI 显式选择回放模式。live-interactions AUTH 场景会把不可重试的终态失败钉为 Chat 内联状态,其中携带适合展示的消息与错误码,并验证提供方回显的凭据片段不会出现在 Chat 或 Trajectory 中;该场景同时覆盖输入框恢复与 `turn/end` 错误。scaffold 环境隔离场景会在全部 3 个环境 skill 根目录中分别填入不同条目,并要求这些条目都不得进入组装后的目录。`dsh-llm-replay` 单元覆盖率钉住节奏控制、取消、消费诊断、sidecar 校验、按索引替换与唯一的追加位置。
## 暂缓
@@ -84,7 +88,8 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu
- **恢复后追问场景**:真实 wire 上的历史/实时缝合路径;当该代码变更或回归时作为独立场景补充。
- **输入框 steering 手势**:输入在运行期间锁定(只能停止或等待),因此 steering 场景从页面走 wire 做 steer`TODO(web-steer-composer)` 待产品长出真实的输入框手势后,把驱动步骤升级为该手势。
- **拖拽会话重排**`workspace.insertSessionBefore` 尚无浏览器场景;它需要在同一个工作区里物化两个会话,并合成 HTML5 拖拽事件。当该表面变更或回归时再补充。无行为的会话 Rename/Fork/Delete 和工作区 Delete 菜单行待获得行为后再补充场景。
- **长历史 Chat 到 Trajectory 的 Inspect**:独立的检查数据源会在视图打开后穷尽历史,而所选记录由一个派生的表格索引定位;随着较早页面前插,该索引可能移动。短历史 Inspect 仍有覆盖;在选中项具有稳定的语义身份之前,长历史交互契约不包含这项交接。
## 后果
Web 表面获得了录制一次/永久回放的层级:真实 chromium → SSE → apiproxy → 循环 → 工具 → 持久化的链路以约 10-30 秒无密钥运行重复运行结果确定fixture 由车道自身持有并可重录。接受的成本:每次有意的会话 UI 变更都以一次无密钥 `DSH_SNAPSHOT=refresh` 收尾(预期输出变动是受评审的 diff锚断言保住语义绿色aria 格式归 Playwright 所有——仓库唯一不受自己控制的提交快照格式——因此 playwright 版本升级必须是刻意的升级加刷新提交(依赖在 `apps/web/package.json` 中浮动为 `^1.49.0`;若变动伤人则改为精确锁定);回放的首次调用顺序绑定把每个场景限制为至多一个发起提示的会话,消费断言是绊线;`compact-basic` 与会话共享回放游标,仅在发布的 128k 目录窗口下保持闲置;必需的消费方任务承担 Chromium 供给与一次浏览器运行的成本,使改动组装后 UI 的 PRPull Request持有相应的预期输出 diff。
Web 表面获得了录制一次/永久回放的层级:真实 chromium → SSE → apiproxy → 循环 → 工具 → 持久化的链路以约 10-30 秒无密钥运行重复运行结果确定fixture 由车道自身持有并可重录。接受的成本:每次有意的会话 UI 变更都以一次无密钥 `DSH_SNAPSHOT=refresh` 收尾(预期输出变动是受评审的 diff锚断言保住语义绿色aria 格式归 Playwright 所有——仓库唯一不受自己控制的提交快照格式——因此 playwright 版本升级必须是刻意的升级加刷新提交(依赖在 `apps/web/package.json` 中浮动为 `^1.49.0`;若变动伤人则改为精确锁定);回放的首次调用顺序绑定把每个场景限制为至多一个发起提示的会话,消费断言是绊线;`compact-basic` 与会话共享回放游标,仅在发布的 128k 目录窗口下保持闲置;必需的消费方任务承担 Chromium 供给与一次浏览器运行的成本,使改动组装后 UI 的 PRPull Request持有相应的预期输出 diff。按需启用的性能车道保留了可重复的诊断工作负载,又不会向 CI 添加受 host 差异影响的时长或内存预期;在仓库拥有经校准的基准测试环境之前,性能回归仍是需要人工解读的信号。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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/testing/2026-08-03-opt-in-reasoning-chunk-browser-stress.md
2026-08-03-opt-in-reasoning-chunk-browser-stress.md: 70c200c7ade6ef995c68b68ddc21e4c85edf0da8
2026-08-03-opt-in-reasoning-chunk-browser-stress.zh.md: aa1d29bffd3dcba54925b43eab13ef4d9a4f7cff

View File

@@ -0,0 +1,43 @@
# Agent Note: Frame-coalesced reasoning-chunk publication and browser stress validation
Status: implemented
English | [中文](2026-08-03-opt-in-reasoning-chunk-browser-stress.zh.md)
## Problem
Long reasoning streams continuously produce large numbers of `assistant/chunk` events. Each raw event must be ordered, logged, and folded into `PartialAccumulator` to preserve replay fidelity and the completeness of the final content; React, however, needs only the current accumulated result, not every intermediate state within one browser frame.
Each `yield` in an async stream can create a new microtask boundary, so `Notifier.markDirty()` backed only by microtask batching degrades into rebuilding a `ConversationSnapshot`, notifying `useSyncExternalStore`, and running a React render for every chunk. Even with the live Think row collapsed, 100,000 reasoning chunks can overwhelm the main thread with reconciliation, commit, and layout work. The performance boundary must sit between session ingestion and React publication; it cannot hide the problem by slowing the producer or discarding raw events.
## Decision
`Session.acceptLiveEvent()` appends every raw event immediately and synchronously updates the transcript, `PartialAccumulator`, and other session-derived state. Visible `block-start`, `text-delta`, `reasoning-delta`, `tool-call-delta`, and `block-end` chunks publish through `Notifier.markFrameDirty()`: the first change schedules one `requestAnimationFrame`, later chunks only continue updating the accumulator, and the frame callback rebuilds one accumulated snapshot from the latest state and notifies subscribers once. `usage`, `finish`, and unknown invisible chunks remain in the event window but trigger no redundant React notifications. Session and history checks share the same visible-chunk classification.
`Notifier` tracks pending publication work with a scheduling kind and generation marker. Ordinary structural events continue to publish in a microtask through `markDirty()`; if a finalized message, tool event, or error arrives while a frame publication is pending, the microtask supersedes it and the old frame callback is invalidated by its generation mismatch. `notifyNow()` likewise invalidates the old schedule to preserve synchronous echo for controlled inputs. Environments without `requestAnimationFrame` fall back to microtask batching. A finalization event may skip one intermediate partial that has not yet appeared, while the published final content and raw event sequence remain complete.
Keeping the live Think row horizontally pinned to the end of the accumulated text is purely visual alignment and does not require synchronous layout reads on every React commit. An in-component scheduler coalesces consecutive requests into one update every three frames, reads `scrollWidth` and `clientWidth` from the latest DOM, and updates `scrollLeft` directly to the latest position; the fixed visual cadence keeps summary changes readable without allowing browser smooth-scroll animations to accumulate. This throttling applies only to Think's horizontal summary and does not delay Chat body scrolling, history-prepend anchoring, or user-triggered `scrollIntoView`.
`pnpm run test:web:stress` remains keyless, opt-in browser performance evidence. The deterministic `?fixture` session emits 100,000 `reasoning-delta` events at a cadence independent of painting, and a terminal marker proves that the events cross production session reduction and reach the live Think row; a 50-millisecond heartbeat and a pre-scheduled DOM event measure main-thread stalls and interaction latency, respectively, with a 250-millisecond budget for identifying clear regressions. `DSH_WEB_STRESS_HEADFUL=1` lets developers profile the same scenario in a visible browser with the Performance panel. The stress lane is evidence for manual performance diagnosis and fix acceptance, not a default CI gate or a substitute for deterministic scheduling unit tests.
Focused tests pin `Notifier`'s per-frame coalescing, structural-event preemption, invalidated callbacks, and no-rAF fallback, and prove at the `Session` layer that a frame publishes the latest accumulated text only once and that finalization is not followed by a duplicate notification from a stale frame callback. Small fixture unit tests continue to pin input validation, external arrival pacing, concurrency rejection, exact event count, and terminal-marker delivery without bringing the 100,000-chunk workload into the default test suites.
## Alternatives considered
**React transitions, deferred values, or component throttling applied to snapshots.** Rejected: the session source would still notify `useSyncExternalStore` for every chunk, the React render has already occurred before a component decides to defer display, and multiple components consuming the same snapshot would each need to implement the strategy. Visual tail-following throttling for the Think summary occurs after snapshot publication and only reduces the frequency of synchronous layout; it does not implement the data-publication policy.
**Dropping, sampling, or concatenating raw chunks at the ingestion or logging layer.** Rejected: raw `assistant/chunk` events are replayable session facts; changing them would reduce diagnostic and UI fidelity and mix display-frequency policy into the authoritative data layer.
**Microtask batching alone.** Rejected: consecutive asynchronous `yield` operations can drain the microtask queue between adjacent chunks, making microtask batching approximate one notification per chunk.
**Pacing the test producer by animation frames.** Rejected: the producer would slow whenever rendering slowed, giving the page implicit backpressure absent from a real network stream and masking main-thread starvation.
**A live model or recorded HTTP byte stream.** Rejected: live models are nondeterministic, and an HTTP/SSE recording would not improve the target assertion. The in-memory fixture preserves individual asynchronous session events, production client reduction, and the React rendering path while controlling the workload and arrival cadence.
## Consequences
The publication rate of streaming `ConversationSnapshot` objects is bounded by the browser's paint rate, so React handles at most one accumulated partial containing all received text per frame; structural events can still publish sooner. Ingestion, ordering, logging, string concatenation, and accumulator updates still run for every raw chunk, so this decision reduces snapshot rebuilding and React work without pretending to solve raw-stream parsing cost.
Horizontal layout reads and writes for the collapsed Think summary run at most once every three frames, and each update moves the summary directly to the latest position; React still commits accumulated snapshots normally, and the summary returns to the first line at finalization. This local visual policy does not change the immediacy of body scrolling or user interactions.
The browser stress lane continues to provide a responsiveness signal from the real assembled application and an entry point for visible profiling, but hardware and scheduling differences make it suitable only as explicit performance evidence. Deterministic focused tests guard publication counts, accumulated content, and preemption order, while the default test lanes remain fast.

View File

@@ -0,0 +1,43 @@
# Agent Note: 推理分片的逐帧累计发布与浏览器压力验证
Status: implemented
[English](2026-08-03-opt-in-reasoning-chunk-browser-stress.md) | 中文
## 问题
长推理流会连续产生大量 `assistant/chunk`。这些原始事件必须逐个完成排序、日志记录和 `PartialAccumulator` 折叠,以保持重放保真度和最终内容完整;但 React 只需要看到当前累计结果,不需要观察同一浏览器帧内的每个中间态。
异步流的每次 `yield` 都可能形成新的微任务边界,因此仅靠微任务合批的 `Notifier.markDirty()` 会退化为每个分片重建一次 `ConversationSnapshot`、通知一次 `useSyncExternalStore` 并运行一次 React render。即使实时 Think 行保持折叠100,000 个推理分片仍会让协调、提交和布局工作压住主线程。性能边界必须位于会话接收与 React 发布之间,不能通过减慢生产方或丢弃原始事件来掩盖问题。
## 决策
`Session.acceptLiveEvent()` 立即追加每个原始事件,并同步更新 transcript、`PartialAccumulator` 及其他会话派生状态。可见的 `block-start``text-delta``reasoning-delta``tool-call-delta``block-end` 分片通过 `Notifier.markFrameDirty()` 发布:第一项变化调度一次 `requestAnimationFrame`,后续分片只继续更新累积器;帧回调从最新状态重建一个累计快照并通知订阅者一次。`usage``finish` 及未知的不可见分片保留在事件窗口中,但不触发无效的 React 通知。会话与历史检查共用同一可见分片分类。
`Notifier` 用调度种类和代际标记管理待发布工作。普通结构事件继续通过 `markDirty()` 在微任务发布;如果定稿消息、工具事件或错误到达时仍有待执行的帧发布,微任务会取代它,旧帧回调因代际不匹配而失效。`notifyNow()` 同样使旧调度失效,以保留受控输入的同步回响。没有 `requestAnimationFrame` 的环境退回微任务合批。定稿事件可以跳过一次尚未显示的中间 partial但发布的定稿内容和原始事件序列保持完整。
实时 Think 行对累计文本的横向跟尾属于纯视觉对齐,不需要在每次 React 提交中同步读取布局。组件内调度器将连续请求合并为每三帧一次,从最新 DOM 读取 `scrollWidth``clientWidth` 并将 `scrollLeft` 直接更新到最新位置;固定的视觉节奏让摘要变化可读,又不会积压浏览器平滑滚动动画。该节流只作用于 Think 的横向摘要,不延迟 Chat 正文滚动、历史 prepend 锚定或用户触发的 `scrollIntoView`
`pnpm run test:web:stress` 保留为无密钥、需显式启用的浏览器性能证据。确定性的 `?fixture` 会话以独立于绘制的节奏发出 100,000 个 `reasoning-delta`,结尾标记证明事件经过生产会话归并并到达实时 Think 行50 毫秒心跳和预先调度的 DOM 事件分别测量主线程停顿与交互延迟250 毫秒预算用于识别明显回归。`DSH_WEB_STRESS_HEADFUL=1` 允许开发者在可见浏览器中使用 Performance 面板分析同一场景。该压力车道是手动性能诊断与修复验收证据,不是默认 CI 门禁,也不替代确定性的调度单元测试。
聚焦测试固定 `Notifier` 的逐帧合并、结构事件抢占、失效回调和无 rAF 回退,并在 `Session` 层证明一帧只发布一次最新累计文本且定稿不会被旧帧回调重复通知。fixture 的小型单元测试继续固定输入校验、外部到达节奏、并发拒绝、精确事件数和结尾标记交付,无需把 100,000 分片工作负载带入默认测试套件。
## 曾考虑的替代方案
**在 React 内对快照使用 transition、deferred value 或组件节流。** 不予采纳:会话源仍会逐分片通知 `useSyncExternalStore`React render 在组件决定延后展示之前已经发生且多个消费同一快照的组件需要重复实现策略。Think 摘要的视觉跟尾节流位于快照发布之后,只减少同步布局频率,不承担数据发布策略。
**在接收或日志层丢弃、抽样或拼接原始分片。** 不予采纳:原始 `assistant/chunk` 是可重放的会话事实,改变它会损失诊断与 UI 保真度,并把展示频率策略混入数据权威层。
**只使用微任务合批。** 不予采纳:连续异步 `yield` 会在相邻分片间排空微任务队列,使一个微任务调度近似退化为一次分片一次通知。
**按动画帧控制测试生产方节奏。** 不予采纳:生产方会在渲染变慢时同步减速,使页面获得真实网络流不存在的隐式背压,并掩盖主线程饥饿。
**真实模型或录制的 HTTP 字节流。** 不予采纳实时模型不具确定性HTTP/SSEServer-Sent Events录制也不会改进目标断言。内存 fixture 保留逐个异步会话事件、生产客户端归并和 React 渲染路径,同时控制工作负载与到达节奏。
## 后果
流式 `ConversationSnapshot` 的发布频率受浏览器绘制频率约束React 每帧至多处理一个包含全部已接收文本的累计 partial结构事件仍可更快发布。接收、排序、日志记录、字符串拼接和累积器更新仍按原始分片执行因此该决策降低的是快照重建与 React 工作,不把原始流解析成本伪装成已解决。
折叠 Think 摘要的横向布局读写最多每三帧执行一次并直接追上该时刻的最新位置React 仍按累计快照正常提交,定稿时摘要恢复到首行。该局部视觉策略不会改变正文滚动和用户交互的即时性。
浏览器压力车道继续提供真实组装应用上的响应性信号和可见 profiling 入口,但硬件与调度差异使其只适合作为显式性能证据。确定性的 focused tests 负责守住发布次数、累计内容与抢占顺序,默认测试车道保持快速。

View File

@@ -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 apps/cli/README.md
README.md: 360ab26ec3dfecf2841a012fda8947d6a84fdfec
README.zh.md: 3ffa5d7726a798b784c67fdb8c4154fddbdea7a4
README.md: 6fdca68eed11dffe46bf2fbde9a7899359690dca
README.zh.md: d8d7122729df1dd8aaed8207ddfb0a0470778b01

View File

@@ -49,6 +49,8 @@ The production Web runner needs built package and frontend artifacts (`pnpm run
`dsh -p "task"` uses the same base and Web composition with the startup personal config, starts its Web host on an OS-assigned port, runs one fresh persisted session, prints the final answer, and exits. It accepts neither `--config` nor raw config-dump flags.
Web and headless process shutdown gives the plugin tree up to five seconds to dispose. The first `SIGINT`/`SIGTERM` starts that graceful drain; a second signal forces immediate exit. If headless normal completion is already stuck in disposal, the first `Ctrl+C` is the escalation and exits immediately instead of being swallowed.
Both modes treat the invoking directory as the default workspace root, load applicable `AGENTS.md` or `CLAUDE.md` instructions with a 65,536-byte render budget, and use an in-memory SQLite session content index. Web watches valid personal config edits; headless reads the file once at startup. The [app-boot personal-config contract](../../packages/ui/app-boot/README.md#personal-config) owns layer precedence, credential storage, live-update failure behavior, and `$DSH_HOME` resolution.
New sessions default to the `workspace-write` permission preset. Bash and filesystem mutations are restricted to the session workspace and platform temporary roots; reads, network access, and process visibility are not confined. `DSH_PERMISSION_MODE` changes the process fallback. Stored General-settings permissions affect later Web sessions, not an already-open one.

View File

@@ -49,6 +49,8 @@ dsh web --dump-config
`dsh -p "task"` 使用相同的 base 与 Web 组合及启动时个人配置,在由操作系统分配的端口上启动 Web 宿主,运行一个全新的持久会话,打印最终答案后退出。它不接受 `--config` 或原始配置输出标志。
Web 与 headless 的进程关闭流程最多给插件树 5 秒执行 dispose。第一次 `SIGINT`/`SIGTERM` 会启动这次优雅排空;第二次信号会立即强制退出。如果 headless 的正常完成流程已经卡在 dispose 中,第一次 `Ctrl+C` 就会触发强制退出:进程立即结束,该信号不再被吞掉。
两种模式都以调用目录作为默认 workspace 根目录,加载适用的 `AGENTS.md``CLAUDE.md` 指令,渲染预算为 65,536 字节,并使用内存 SQLite 会话内容索引。Web 会持续应用有效的个人配置编辑headless 只在启动时读取该文件一次。层次优先级、凭据存储、实时更新失败行为与 `$DSH_HOME` 解析均由 [app-boot 个人配置契约](../../packages/ui/app-boot/README.md#personal-config) 统一定义。
新会话默认使用 `workspace-write` 权限 preset。Bash 和文件系统写操作受限于会话 workspace 与平台临时根目录;读取、网络访问与进程可见性不受限制。`DSH_PERMISSION_MODE` 会改变进程回退值。已存储的常规设置权限会影响之后的 Web 会话,不会更改已打开的会话。

View File

@@ -111,17 +111,19 @@
# process out (the launchers patch the row disabled; config cannot disable
# a row). Exports carry the harness home's anonymous user id ($DSH_HOME/.userid,
# random UUID; delete the file to reset the identity) as the Resource's
# user.id. The exporter/processor values bound the shutdown drain to ~1s
# against an unreachable collector: exporter.timeoutMillis is both the
# per-attempt socket timeout and the retry deadline (1s effectively
# disables the SDK's 5-try backoff), maxExportBatchSize == maxQueueSize
# (both explicit) makes the drain a single batch, and exportTimeoutMillis
# is the processor's own cap on that one export cycle — the second bound
# when the exporter's clock alone does not fire. Every CLI exit path drains it
# by disposing the root on SIGINT/SIGTERM.
# user.id. The exporter/processor values normally bound the shutdown drain
# to ~1s against an unreachable collector: exporter.timeoutMillis is both
# the per-attempt socket timeout and the retry deadline (1s effectively
# disables the SDK's 5-try backoff), while maxExportBatchSize == maxQueueSize
# (both explicit) makes the drain a single batch. The SDK awaits
# exporter.forceFlush() outside exportTimeoutMillis, so the backend's 3s
# shutdownTimeoutMillis is the load-bearing outer bound when a transport
# promise never settles. Every CLI exit path drains it by disposing the root
# on SIGINT/SIGTERM.
- id: telemetry-otel
name: '@deepseek-ai/dsh-session-telemetry-otel'
config:
shutdownTimeoutMillis: 3000
exporter:
url: !!js process.env.DSH_TELEMETRY_OTLP_URL ?? 'https://harness-telemetry.deepseeksvc.com/v1/logs'
compression: gzip

View File

@@ -14,6 +14,7 @@ import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import type { SessionId } from '@deepseek-ai/dsh-session'
import { AppCLIEntry } from './app-cli-entry.ts'
import { createProcessShutdown } from './process-shutdown.ts'
/** Outcome of one headless turn: aggregated final text plus the turn-end reason kind. */
interface TurnOutcome {
@@ -21,12 +22,12 @@ interface TurnOutcome {
reason: string
}
/** Unwrap an RpcResponse or fail loud: business errors print and exit 1 (dispose first). */
async function unwrap<T>(response: RpcResponse<T>, dispose: () => Promise<void>): Promise<T> {
/** Unwrap an RpcResponse or fail loud: business errors print and exit 1 (shutdown first). */
async function unwrap<T>(response: RpcResponse<T>, shutdown: () => Promise<void>): Promise<T> {
if (response.result.ok) return response.result.value
const { code, message } = response.result.error
process.stderr.write(`dsh: ${code}: ${message}\n`)
await dispose()
await shutdown()
process.exit(1)
}
@@ -82,23 +83,16 @@ export async function runHeadless(task: string): Promise<void> {
port: 0,
})
const { ctx, port } = await entry.run()
const dispose = async (): Promise<void> => { await ctx.fiber.dispose() }
// Signal exits must still dispose the tree: the composition mounts
// exit-drained plugins (telemetry's queued tail and shutdown marker would
// otherwise be lost), and Node's default signal exit skips disposal.
let signalled = false
const disposeAndExit = (code: number): void => {
if (signalled) return
signalled = true
void dispose().finally(() => { process.exit(code) })
}
process.on('SIGTERM', () => { disposeAndExit(143) })
process.on('SIGINT', () => { disposeAndExit(130) })
// Normal completion and signals share one bounded drain. A signal received
// during that drain escalates immediately instead of becoming a no-op.
const shutdown = createProcessShutdown(async () => { await ctx.fiber.dispose() })
process.on('SIGTERM', () => { shutdown.interrupt(143) })
process.on('SIGINT', () => { shutdown.interrupt(130) })
// The headless session is web-observable while it runs (same composition).
process.stderr.write(`dsh: observing at http://127.0.0.1:${String(port)}\n`)
const api = new InProcessApiClient(toFetchHandler(ctx.apiProxy))
const created = await unwrap(await api.sessions.create({}), dispose)
const created = await unwrap(await api.sessions.create({}), () => shutdown.shutdown(1))
// Open the stream before prompting so no frame is lost — kept in this order
// even though in-process delivery has no race, so the code survives a move
@@ -111,11 +105,10 @@ export async function runHeadless(task: string): Promise<void> {
sessionId: created.sessionId,
mode: 'queue',
content: [{ type: 'text', text: task }],
}), dispose)
}), () => shutdown.shutdown(1))
const outcome = await done
process.stdout.write(outcome.text + '\n')
abort.abort()
await dispose()
process.exit(outcome.reason === 'completed' ? 0 : 1)
await shutdown.shutdown(outcome.reason === 'completed' ? 0 : 1)
}

View File

@@ -0,0 +1,58 @@
/** Bounded, escalating process shutdown for the long-lived CLI surfaces. */
/** Maximum grace allowed for the application tree to dispose before process exit. */
export const PROCESS_SHUTDOWN_TIMEOUT_MS = 5_000
/** Process-exit controller shared by normal completion and Unix signal handlers. */
export interface ProcessShutdown {
/** Start or join graceful disposal before exiting with `code`. */
shutdown(code: number): Promise<void>
/** Start graceful disposal, or force exit when a shutdown is already running. */
interrupt(code: number): void
}
/**
* Create one process-exit controller around an application disposer.
* @param dispose - Whole-application teardown that resolves at quiescence.
* @param exit - Process exit boundary, replaceable by tests.
* @param timeoutMs - Grace before forced exit, replaceable by tests.
* @returns A controller whose normal calls coalesce and whose repeated signal call escalates.
*/
export function createProcessShutdown(
dispose: () => Promise<void>,
exit: (code: number) => void = (code) => { process.exit(code) },
timeoutMs = PROCESS_SHUTDOWN_TIMEOUT_MS,
): ProcessShutdown {
let pending: Promise<void> | undefined
let timeout: ReturnType<typeof setTimeout> | undefined
let exited = false
const exitOnce = (code: number): void => {
if (exited) return
exited = true
/* v8 ignore else -- shutdown() arms the timer before any asynchronous exit path can run. */
if (timeout !== undefined) clearTimeout(timeout)
exit(code)
}
const shutdown = (code: number): Promise<void> => {
if (pending !== undefined) return pending
timeout = setTimeout(() => { exitOnce(code) }, timeoutMs)
pending = Promise.resolve().then(dispose).then(
() => { exitOnce(code) },
() => { exitOnce(code) },
)
return pending
}
return {
shutdown,
interrupt(code) {
if (pending !== undefined) {
exitOnce(code)
return
}
void shutdown(code)
},
}
}

View File

@@ -13,6 +13,7 @@ import type {} from '@deepseek-ai/dsh-host-webserver'
import type {} from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-tool-bash'
import { AppCLIEntry } from './app-cli-entry.ts'
import { createProcessShutdown } from './process-shutdown.ts'
// The shipped base plus the Web application's overlay.
const BASE_CONFIG = fileURLToPath(new URL('../config/base.cordis.yml', import.meta.url))
@@ -118,17 +119,12 @@ export async function runWeb(
const { ctx, port: boundPort } = await entry.run()
const resolvedLocalWebUrl = localWebUrl(ctx)
let exiting = false
const shutdown = (code: number): void => {
if (exiting) return
exiting = true
void Promise.resolve(ctx.fiber.dispose()).finally(() => { process.exit(code) })
}
const shutdown = createProcessShutdown(async () => { await ctx.fiber.dispose() })
// Install shutdown handling before publishing readiness: supervisors may
// send a signal as soon as they observe the URL line.
process.on('SIGTERM', () => { shutdown(0) })
process.on('SIGINT', () => { shutdown(130) })
process.on('SIGTERM', () => { shutdown.interrupt(0) })
process.on('SIGINT', () => { shutdown.interrupt(130) })
// The entry's boot-time snapshot, not a fresh sample: the printed LAN URL
// must name an address the /api trust fence was configured with.

View File

@@ -0,0 +1,18 @@
/** Test-only Cordis plugin whose disposer announces entry and never settles. */
import { existsSync } from 'node:fs'
/**
* Register a disposer that keeps process shutdown pending until it is forced.
* @param {import('cordis').Context} ctx - loader-mounted test plugin context.
*/
export function apply(ctx) {
const keepAlive = setInterval(() => {}, 60_000)
ctx.effect(() => async () => {
clearInterval(keepAlive)
const armFile = process.env.DSH_TEST_SHUTDOWN_ARM_FILE
if (armFile === undefined || !existsSync(armFile)) return
process.stderr.write('dsh-test: never-dispose started\n')
await new Promise(() => {})
})
}

View File

@@ -0,0 +1,121 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { execa } from 'execa'
import { describe, expect, it } from 'vitest'
import { LOADER_SMOKE_TEST_TIMEOUT_MS, resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke'
const dshBinScript = fileURLToPath(new URL('../src/bin.ts', import.meta.url))
const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
const neverDisposePlugin = pathToFileURL(
fileURLToPath(new URL('./fixtures/never-dispose.mjs', import.meta.url)),
).href
const POSIX_HEADLESS_PTY_DRIVER = String.raw`
import errno, json, os, pty, select, signal, sys, time
node, launch_args_json, launch_env_json, cwd, timeout_seconds = sys.argv[1:]
env = os.environ.copy()
env.update(json.loads(launch_env_json))
pid, fd = pty.fork()
if pid == 0:
os.chdir(cwd)
os.execvpe(node, [node, *json.loads(launch_args_json)], env)
markers = [b"dsh: observing at ", b"dsh-test: never-dispose started"]
output = bytearray()
marker_index = 0
deadline = time.monotonic() + float(timeout_seconds)
status = None
while time.monotonic() < deadline:
ready, _, _ = select.select([fd], [], [], 0.05)
if ready:
try:
chunk = os.read(fd, 65536)
except OSError as error:
if error.errno != errno.EIO:
raise
chunk = b""
if chunk:
output.extend(chunk)
while marker_index < len(markers) and markers[marker_index] in output:
if marker_index == 0:
open(os.path.join(cwd, "shutdown-armed"), "w").close()
os.write(fd, b"\x03")
marker_index += 1
waited, candidate = os.waitpid(pid, os.WNOHANG)
if waited == pid:
status = candidate
break
if status is None:
os.kill(pid, signal.SIGKILL)
_, status = os.waitpid(pid, 0)
sys.stdout.buffer.write(output)
if marker_index != len(markers):
sys.stderr.write(f"completed {marker_index}/{len(markers)} PTY actions before timeout\n")
sys.exit(124)
actual_exit = os.waitstatus_to_exitcode(status)
if actual_exit != 130:
sys.stderr.write(f"expected exit 130, got {actual_exit}\n")
sys.exit(125)
`
async function runHeadlessPtySmoke(): Promise<string> {
const cwd = await mkdtemp(join(tmpdir(), 'dsh-headless-shutdown-'))
try {
const home = join(cwd, '.dsh')
await mkdir(home, { recursive: true })
await writeFile(join(home, 'config.yaml'), [
'- insert:',
' - id: never-dispose',
` name: '${neverDisposePlugin}'`,
'',
].join('\n'))
const launch = resolveExampleLaunch({
srcBin: dshBinScript,
configArgs: ['-p', 'never complete'],
tsconfigPath,
env: {
DSH_HOME: home,
DSH_AGENTS_HOME: join(cwd, '.agents'),
DEEPSEEK_API_KEY: 'keyless-shutdown-no-call',
DSH_TELEMETRY_DISABLED: '1',
DSH_TEST_SHUTDOWN_ARM_FILE: join(cwd, 'shutdown-armed'),
},
})
const timeoutMs = 15_000
const result = await execa('python3', [
'-c',
POSIX_HEADLESS_PTY_DRIVER,
launch.command,
JSON.stringify(launch.args),
JSON.stringify(launch.env),
cwd,
String(timeoutMs / 1_000),
], {
stdin: 'ignore',
timeout: timeoutMs + 5_000,
killSignal: 'SIGKILL',
reject: false,
stripFinalNewline: false,
})
if (result.timedOut) {
throw new Error(`dsh headless PTY driver did not exit. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
}
if (result.failed) {
throw new Error(`dsh headless PTY driver exited ${String(result.exitCode)}. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
}
return result.stdout
} finally {
await rm(cwd, { recursive: true, force: true })
}
}
describe.skipIf(process.platform === 'win32')('headless process shutdown (real Loader tree in a PTY)', () => {
it('lets a second Ctrl+C force exit while the first signal is draining', async () => {
const output = await runHeadlessPtySmoke()
expect(output).toContain('dsh: observing at ')
expect(output).toContain('dsh-test: never-dispose started')
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
})

View File

@@ -0,0 +1,131 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
createProcessShutdown,
PROCESS_SHUTDOWN_TIMEOUT_MS,
} from '../src/process-shutdown.ts'
function deferred(): { promise: Promise<void>; resolve: () => void; reject: (error: Error) => void } {
let resolve!: () => void
let reject!: (error: Error) => void
const promise = new Promise<void>((accept, fail) => {
resolve = accept
reject = fail
})
return { promise, resolve, reject }
}
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
})
describe('process shutdown', () => {
it('exits once after graceful disposal resolves or rejects', async () => {
const resolvedExit = vi.fn()
const resolved = createProcessShutdown(() => Promise.resolve(), resolvedExit)
await resolved.shutdown(0)
expect(resolvedExit).toHaveBeenCalledOnce()
expect(resolvedExit).toHaveBeenCalledWith(0)
const rejectedExit = vi.fn()
const rejected = createProcessShutdown(() => Promise.reject(new Error('dispose failed')), rejectedExit)
await rejected.shutdown(1)
expect(rejectedExit).toHaveBeenCalledOnce()
expect(rejectedExit).toHaveBeenCalledWith(1)
})
it('uses process.exit as the default process boundary', async () => {
const exit = vi.spyOn(process, 'exit').mockImplementation(_code => undefined as never)
const shutdown = createProcessShutdown(() => Promise.resolve())
await shutdown.shutdown(7)
expect(exit).toHaveBeenCalledOnce()
expect(exit).toHaveBeenCalledWith(7)
})
it('forces exit when graceful disposal reaches its bound', async () => {
vi.useFakeTimers()
const disposal = deferred()
const exit = vi.fn()
const shutdown = createProcessShutdown(() => disposal.promise, exit)
const pending = shutdown.shutdown(0)
await vi.advanceTimersByTimeAsync(PROCESS_SHUTDOWN_TIMEOUT_MS - 1)
expect(exit).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
expect(exit).toHaveBeenCalledOnce()
expect(exit).toHaveBeenCalledWith(0)
disposal.resolve()
await pending
expect(exit).toHaveBeenCalledOnce()
})
it('honors a caller-supplied grace period', async () => {
vi.useFakeTimers()
const disposal = deferred()
const exit = vi.fn()
const shutdown = createProcessShutdown(() => disposal.promise, exit, 25)
const pending = shutdown.shutdown(0)
await vi.advanceTimersByTimeAsync(24)
expect(exit).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
expect(exit).toHaveBeenCalledOnce()
disposal.resolve()
await pending
})
it('lets Ctrl+C force a normal shutdown already stuck in disposal', async () => {
const disposal = deferred()
const exit = vi.fn()
const shutdown = createProcessShutdown(() => disposal.promise, exit)
const pending = shutdown.shutdown(0)
shutdown.interrupt(130)
expect(exit).toHaveBeenCalledOnce()
expect(exit).toHaveBeenCalledWith(130)
disposal.resolve()
await pending
expect(exit).toHaveBeenCalledOnce()
})
it('drains on the first signal and forces on the second signal', async () => {
const disposal = deferred()
const dispose = vi.fn(() => disposal.promise)
const exit = vi.fn()
const shutdown = createProcessShutdown(dispose, exit)
shutdown.interrupt(143)
await Promise.resolve()
expect(dispose).toHaveBeenCalledOnce()
expect(exit).not.toHaveBeenCalled()
shutdown.interrupt(130)
expect(exit).toHaveBeenCalledOnce()
expect(exit).toHaveBeenCalledWith(130)
disposal.resolve()
await shutdown.shutdown(0)
expect(exit).toHaveBeenCalledOnce()
})
it('coalesces normal shutdown calls without treating them as escalation', async () => {
const disposal = deferred()
const exit = vi.fn()
const shutdown = createProcessShutdown(() => disposal.promise, exit)
const first = shutdown.shutdown(0)
const second = shutdown.shutdown(1)
expect(second).toBe(first)
expect(exit).not.toHaveBeenCalled()
disposal.resolve()
await first
expect(exit).toHaveBeenCalledOnce()
expect(exit).toHaveBeenCalledWith(0)
})
})

View File

@@ -0,0 +1,153 @@
/**
* Opt-in browser stress reproduction for reasoning-stream renderer stalls.
* The fixture emits 100,000 individual chunks through the normal async
* carrier; the test measures event-loop and scheduled-interaction delay while
* the assembled React surface keeps a collapsed Think row live.
*/
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { expect, it, onTestFailed } from 'vitest'
import { launchWebScaffold, watchConsole, type WebScaffold } from '../tests/scaffold.ts'
import { newEnglishPage, saveFailureShot } from '../tests/support.ts'
const CHUNK_COUNT = 100_000
const CHUNKS_PER_INTERVAL = 128
const CHUNK_INTERVAL_MS = 16
const MAIN_THREAD_DELAY_BUDGET_MS = 250
interface ReasoningChunkStormState {
sessionId: string
chunkCount: number
chunksPerInterval: number
intervalMs: number
emitted: number
marker: string
emitting: boolean
}
interface StressProbe {
intervalId: number
intervalMs: number
lastTickAt: number
maxDelayMs: number
samples: number
interactionDueAt: number
interactionHandledAt: number | null
}
interface StressWindow extends Window {
__fxTiming?: {
startReasoningChunkStorm(id: string, chunkCount: number, chunksPerInterval: number, intervalMs: number): string
reasoningChunkStormState(): ReasoningChunkStormState | null
}
__reasoningStressProbe?: StressProbe
}
it('keeps the browser responsive while rendering 100,000 reasoning chunks', async () => {
let scaffold: WebScaffold | undefined
let browser: Browser | undefined
let page: Page | undefined
try {
scaffold = await launchWebScaffold()
browser = await chromium.launch({ headless: process.env.DSH_WEB_STRESS_HEADFUL !== '1' })
page = await newEnglishPage(browser)
const activePage = page
await activePage.addInitScript(() => {
localStorage.setItem('dsh.sessions.current', JSON.stringify({ sessionId: 'fx-alpha' }))
})
const tripwire = watchConsole(activePage)
onTestFailed(() => saveFailureShot(activePage, 'web-stress-reasoning-chunks'))
await activePage.goto(`${scaffold.baseUrl}?fixture`, { waitUntil: 'load' })
await activePage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
// Fixture settings deliberately reject writes, so its welcome notice
// cannot acknowledge. Hide only that test overlay; the assembled chat
// tree beneath it remains mounted and exercises the production renderer.
await activePage.addStyleTag({ content: '[class*="onboardingOverlay"] { display: none !important; }' })
await activePage.locator('[data-sample="bash"]').first().waitFor({ timeout: 30_000 })
await activePage.evaluate(() => {
const intervalMs = 50
const now = performance.now()
const probe: StressProbe = {
intervalId: 0,
intervalMs,
lastTickAt: now,
maxDelayMs: 0,
samples: 0,
interactionDueAt: now + 1_000,
interactionHandledAt: null,
}
probe.intervalId = window.setInterval(() => {
const tickAt = performance.now()
probe.maxDelayMs = Math.max(probe.maxDelayMs, tickAt - probe.lastTickAt - intervalMs)
probe.lastTickAt = tickAt
probe.samples++
}, intervalMs)
document.body.addEventListener('reasoning-stress-interaction', () => {
probe.interactionHandledAt = performance.now()
}, { once: true })
window.setTimeout(() => {
document.body.dispatchEvent(new CustomEvent('reasoning-stress-interaction'))
}, 1_000)
;(window as StressWindow).__reasoningStressProbe = probe
})
const marker = await activePage.evaluate(({ chunkCount, chunksPerInterval, intervalMs }) => {
const hooks = (window as StressWindow).__fxTiming
if (hooks === undefined) throw new Error('reasoning stress fixture hooks unavailable')
return hooks.startReasoningChunkStorm('fx-alpha', chunkCount, chunksPerInterval, intervalMs)
}, {
chunkCount: CHUNK_COUNT,
chunksPerInterval: CHUNKS_PER_INTERVAL,
intervalMs: CHUNK_INTERVAL_MS,
})
const liveThink = activePage.locator('[data-variant="think"][data-state="running"]').last()
await liveThink.waitFor({ timeout: 60_000 })
await expect.poll(async () => await activePage.evaluate(() => {
const hooks = (window as StressWindow).__fxTiming
return hooks?.reasoningChunkStormState()?.emitted ?? 0
}), { timeout: 540_000, interval: 100 }).toBe(CHUNK_COUNT)
await expect.poll(() => liveThink.textContent(), { timeout: 60_000, interval: 100 }).toContain(marker)
const report = await activePage.evaluate(() => {
const win = window as StressWindow
const probe = win.__reasoningStressProbe
const state = win.__fxTiming?.reasoningChunkStormState()
if (probe === undefined || state === undefined || state === null) {
throw new Error('reasoning stress metrics unavailable')
}
window.clearInterval(probe.intervalId)
const interactionDelayMs = probe.interactionHandledAt === null
? null
: probe.interactionHandledAt - probe.interactionDueAt
return {
chunkCount: state.chunkCount,
chunksPerInterval: state.chunksPerInterval,
intervalMs: state.intervalMs,
emitted: state.emitted,
maxMainThreadDelayMs: Math.max(0, probe.maxDelayMs),
interactionDelayMs,
heartbeatSamples: probe.samples,
}
})
process.stdout.write(`reasoning-chunk stress report: ${JSON.stringify(report)}\n`)
expect(report).toMatchObject({
chunkCount: CHUNK_COUNT,
chunksPerInterval: CHUNKS_PER_INTERVAL,
intervalMs: CHUNK_INTERVAL_MS,
emitted: CHUNK_COUNT,
})
expect(report.heartbeatSamples).toBeGreaterThan(0)
const interactionDelayMs = report.interactionDelayMs
if (interactionDelayMs === null) throw new Error(`scheduled interaction was not handled: ${JSON.stringify(report)}`)
expect(report.maxMainThreadDelayMs, JSON.stringify(report)).toBeLessThan(MAIN_THREAD_DELAY_BUDGET_MS)
expect(interactionDelayMs, JSON.stringify(report)).toBeLessThan(MAIN_THREAD_DELAY_BUDGET_MS)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
} finally {
await browser?.close()
await scaffold?.close()
}
}, 600_000)

View File

@@ -1,7 +1,7 @@
// @vitest-environment jsdom
// The built-bundle boot smoke: the ONE assembled-jsdom test that loads the
// real `packages/client/*/lib/client.js` artifacts through AppWebEntry's
// ModuleLoader path (fetchBundle/executeBundle) and proves the boot graph
// ModuleLoader path (loadBundle) and proves the boot graph
// assembles — staged activation across the immediately tier and the inject
// layers, per-plugin CSS injection, and a rendered journey reaching chat
// content from the keyless FixtureApiClient transport.
@@ -91,11 +91,11 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
act(() => {
const entry = new AppWebEntry(root, {
fetchBundle: (url) => {
loadBundle: async (url) => {
const code = bundles.get(url)
return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code)
if (code === undefined) throw new Error(`missing built bundle ${url}`)
;(0, eval)(code)
},
executeBundle: (code) => { (0, eval)(code) },
})
void entry.run()
unmount = () => { entry.dispose() }

View File

@@ -0,0 +1,328 @@
// Web e2e contract for a conversation grown through the real composer rather
// than pre-seeded history. Twelve deterministic replay turns exercise repeated
// send/settle/render cycles, including two real bash executions and one long,
// multi-chunk final turn. Assertions stay semantic: no host timing, heap, or
// mounted-row cardinality is treated as a correctness contract.
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { CallId, type StreamChunk } from '@deepseek-ai/dsh-llm'
import type { ReplayEntry, ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import {
launchWebScaffold,
watchConsole,
webSnapshotMode,
type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
const MODE = webSnapshotMode()
const TURN_COUNT = 12
const TOOL_TURNS = [4, 9] as const
const STREAM_PACE_MS = 10
interface TurnSpec {
readonly index: number
readonly prompt: string
readonly userMarker: string
readonly firstMarker: string
readonly doneMarker: string
readonly deltas: readonly string[]
readonly callId?: ReturnType<typeof CallId>
readonly toolResultMarker?: string
}
function suffix(index: number): string {
return String(index).padStart(3, '0')
}
function longFinalPrompt(userMarker: string): string {
return [
`${userMarker} Reconcile this accumulated conversation without losing earlier turn ownership.`,
...Array.from(
{ length: 36 },
(_, index) => `Context ${String(index + 1).padStart(2, '0')}: preserve token-${String(index)} and verify ${'payload '.repeat(12).trimEnd()}.`,
),
'Return one continuous response and finish with the requested completion marker.',
].join('\n')
}
function turnSpec(index: number): TurnSpec {
const id = suffix(index)
const userMarker = `CONTINUOUS_CHAT_USER_${id}`
const firstMarker = `CONTINUOUS_CHAT_FIRST_${id}`
const doneMarker = `CONTINUOUS_CHAT_DONE_${id}`
const deltaCount = index === TURN_COUNT ? 36 : 8
const deltas = Array.from({ length: deltaCount }, (_, chunkIndex) => {
if (chunkIndex === 0) return `${firstMarker} `
if (chunkIndex === deltaCount - 1) return `${doneMarker}.`
return `turn-${id}-chunk-${String(chunkIndex).padStart(2, '0')} keeps semantic ownership stable. `
})
if (!TOOL_TURNS.includes(index as (typeof TOOL_TURNS)[number])) {
return {
index,
prompt: index === TURN_COUNT
? longFinalPrompt(userMarker)
: `${userMarker} Continue this same conversation through turn ${String(index)}.`,
userMarker,
firstMarker,
doneMarker,
deltas,
}
}
return {
index,
prompt: `${userMarker} Run the requested deterministic tool for turn ${String(index)}, then continue.`,
userMarker,
firstMarker,
doneMarker,
deltas,
callId: CallId(`continuous-chat-tool-${id}`),
toolResultMarker: `CONTINUOUS_CHAT_TOOL_RESULT_${id}`,
}
}
function textStream(spec: TurnSpec): StreamChunk[] {
const response = spec.deltas.join('')
return [
{ type: 'block-start', index: 0, blockType: 'text' },
...spec.deltas.map(text => ({ type: 'text-delta' as const, index: 0, text })),
{ type: 'block-end', index: 0, block: { type: 'text', text: response } },
{
type: 'usage',
usage: {
inputTokens: Math.ceil(spec.prompt.length / 4),
outputTokens: Math.ceil(response.length / 4),
},
},
{ type: 'finish', reason: { kind: 'stop' } },
]
}
function toolStream(spec: TurnSpec): StreamChunk[] {
if (spec.callId === undefined || spec.toolResultMarker === undefined) {
throw new Error(`turn ${String(spec.index)} has no tool identity`)
}
const args = JSON.stringify({
command: `printf '${spec.toolResultMarker}\\n'`,
description: spec.toolResultMarker,
})
return [
{ type: 'block-start', index: 0, blockType: 'tool-call' },
{
type: 'tool-call-delta',
index: 0,
id: spec.callId,
name: 'bash',
argumentsDelta: args,
},
{
type: 'block-end',
index: 0,
block: { type: 'tool-call', id: spec.callId, name: 'bash', arguments: args },
},
{ type: 'usage', usage: { inputTokens: 256, outputTokens: 24 } },
{ type: 'finish', reason: { kind: 'tool-calls' } },
]
}
function replayScript(specs: readonly TurnSpec[]): ReplayOverrideDoc {
return specs.flatMap((spec): ReplayEntry[] => {
const final: ReplayEntry = { kind: 'chunks', chunks: textStream(spec) }
return spec.callId === undefined
? [final]
: [{ kind: 'chunks', chunks: toolStream(spec) }, final]
})
}
function userText(event: Extract<SessionEvent, { type: 'user/message' }>): string {
return event.data.content
.filter(block => block.type === 'text')
.map(block => block.text)
.join('')
}
function assistantText(event: Extract<SessionEvent, { type: 'assistant/message' }>): string {
return event.data.message.content
.filter(block => block.type === 'text')
.map(block => block.text)
.join('')
}
function toolResultText(event: Extract<SessionEvent, { type: 'tool/result' }>): string {
return event.data.message.content[0].content
.filter(block => block.type === 'text')
.map(block => block.text)
.join('')
}
describe('web e2e: continuous conversation grown through the composer', () => {
let browser: Browser
let page: Page
let replayDir: string
let scaffold: WebScaffold
let tripwire: ReturnType<typeof watchConsole>
const consoleWarnings: string[] = []
const sessionEvents: SessionEvent[] = []
const specs = Array.from({ length: TURN_COUNT }, (_, offset) => turnSpec(offset + 1))
beforeAll(async () => {
replayDir = await mkdtemp(join(tmpdir(), 'dsh-continuous-chat-replay-'))
const replayOverride = join(replayDir, 'replay.override.json')
await writeFile(replayOverride, JSON.stringify(replayScript(specs)))
scaffold = await launchWebScaffold({
replayFixture: join(replayDir, 'override-only.jsonl'),
replayOverride,
replayContextWindow: 10_000_000,
paceMs: STREAM_PACE_MS,
})
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => {
sessionEvents.push(event)
})
browser = await chromium.launch()
page = await newEnglishPage(browser, 900)
tripwire = watchConsole(page)
page.on('console', (message) => {
if (message.type() === 'warning') consoleWarnings.push(message.text())
})
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await connectFreshWorkspace(page, scaffold.workspaceCwd, 'continuous-chat-e2e')
}, 120_000)
afterAll(async () => {
const failures: unknown[] = []
await browser?.close().catch((error: unknown) => failures.push(error))
await scaffold?.close().catch((error: unknown) => failures.push(error))
if (replayDir !== undefined) {
await rm(replayDir, { recursive: true, force: true })
.catch((error: unknown) => failures.push(error))
}
if (failures.length === 1) throw failures[0]
if (failures.length > 1) throw new AggregateError(failures, 'continuous Chat e2e cleanup failed')
})
it.skipIf(MODE === 'record')('keeps twelve generated turns and tool rows bound to one live session', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-chat-continuous-conversation'))
const composer = page.locator('textarea:enabled').last()
await composer.waitFor({ timeout: 15_000 })
let sessionId: SessionId | undefined
for (const spec of specs) {
const eventStart = sessionEvents.length
expect(await composer.inputValue()).toBe('')
expect(await composer.isEnabled()).toBe(true)
await composer.fill(spec.prompt)
expect(await composer.inputValue()).toBe(spec.prompt)
const settled = scaffold.whenTurnSettled(60_000)
await page.getByRole('button', { name: 'Send message', exact: true }).click()
await page.getByText(spec.userMarker, { exact: false }).last().waitFor({ timeout: 15_000 })
const echoedUser = sessionEvents.slice(eventStart).find(
(event): event is SessionEvent<'user/message'> => (
event.type === 'user/message'
&& event.data.source.kind === 'user'
&& userText(event).includes(spec.userMarker)
),
)
if (echoedUser === undefined) throw new Error(`turn ${String(spec.index)} has no user echo event`)
const userRow = page.locator(`[data-chat-anchor-key="node:${String(echoedUser.seq)}"]`)
await expect.poll(() => userRow.count(), { timeout: 10_000 }).toBe(1)
expect(await userRow.getAttribute('data-chat-flow-kind')).toBe('user')
expect(await userRow.textContent()).toContain(spec.userMarker)
await page.getByText(spec.firstMarker, { exact: false }).last().waitFor({ timeout: 15_000 })
const settledSessionId = await settled
if (sessionId === undefined) {
sessionId = settledSessionId
} else {
expect(settledSessionId).toBe(sessionId)
}
await expect.poll(() => page.locator('[data-streaming="true"]').count(), { timeout: 15_000 }).toBe(0)
await page.getByText(spec.doneMarker, { exact: false }).last().waitFor({ timeout: 15_000 })
await expect.poll(() => composer.inputValue(), { timeout: 10_000 }).toBe('')
await expect.poll(() => composer.isEnabled(), { timeout: 10_000 }).toBe(true)
const turnEvents = sessionEvents.slice(eventStart)
const turnStarts = turnEvents.filter((event): event is SessionEvent<'turn/start'> => (
event.type === 'turn/start'
))
const users = turnEvents.filter((event): event is SessionEvent<'user/message'> => (
event.type === 'user/message' && event.data.source.kind === 'user'
))
const assistants = turnEvents.filter((event): event is SessionEvent<'assistant/message'> => (
event.type === 'assistant/message'
))
const finalAssistants = assistants.filter(event => assistantText(event).includes(spec.doneMarker))
const turnEnds = turnEvents.filter((event): event is SessionEvent<'turn/end'> => (
event.type === 'turn/end'
))
const chunks = turnEvents.filter(event => event.type === 'assistant/chunk')
expect(turnStarts).toHaveLength(1)
expect(turnStarts[0]?.data.turn).toBe(spec.index)
expect(users).toHaveLength(1)
expect(users[0]?.seq).toBe(echoedUser.seq)
expect(userText(users[0]!)).toBe(spec.prompt)
expect(finalAssistants).toHaveLength(1)
expect(assistants).toHaveLength(spec.callId === undefined ? 1 : 2)
expect(turnEnds).toHaveLength(1)
expect(turnEnds[0]?.data).toEqual({ turn: spec.index, reason: { kind: 'completed' } })
expect(chunks).toHaveLength(spec.deltas.length + (spec.callId === undefined ? 4 : 9))
const assistantRow = page.locator(`[data-chat-anchor-key="node:${String(finalAssistants[0]!.seq)}"]`)
await expect.poll(() => assistantRow.count(), { timeout: 10_000 }).toBe(1)
expect(await assistantRow.getAttribute('data-chat-flow-kind')).toBe('assistant')
expect(await assistantRow.textContent()).toContain(spec.doneMarker)
const calls = turnEvents.filter((event): event is SessionEvent<'tool/call'> => event.type === 'tool/call')
const results = turnEvents.filter((event): event is SessionEvent<'tool/result'> => event.type === 'tool/result')
if (spec.callId === undefined || spec.toolResultMarker === undefined) {
expect(calls).toHaveLength(0)
expect(results).toHaveLength(0)
continue
}
expect(calls).toHaveLength(1)
expect(results).toHaveLength(1)
expect(calls[0]?.data).toMatchObject({
turn: spec.index,
callId: spec.callId,
name: 'bash',
})
expect(results[0]?.data.turn).toBe(spec.index)
expect(results[0]?.data.message.source.callId).toBe(spec.callId)
expect(results[0]?.data.message.content[0].isError).toBe(false)
expect(toolResultText(results[0]!)).toBe(`${spec.toolResultMarker}\n`)
const toolRow = page.locator(`[data-chat-call-id="${spec.callId}"]`)
await expect.poll(() => toolRow.count(), { timeout: 10_000 }).toBe(1)
expect(await toolRow.textContent()).toContain(spec.toolResultMarker)
const disclosure = toolRow.locator('[data-sample="bash"]')
expect(await disclosure.getAttribute('aria-expanded')).toBe('false')
await disclosure.click()
await expect.poll(() => disclosure.getAttribute('aria-expanded'), { timeout: 10_000 }).toBe('true')
// The collapsed summary deliberately repeats the result marker; the
// last exact match is the expanded terminal output owned by this call.
await toolRow.getByText(spec.toolResultMarker, { exact: true }).last().waitFor({ timeout: 10_000 })
await disclosure.click()
await expect.poll(() => disclosure.getAttribute('aria-expanded'), { timeout: 10_000 }).toBe('false')
}
if (sessionId === undefined) throw new Error('continuous conversation completed no turn')
expect(scaffold.ctx.agents.get(sessionId)?.session.events.filter(event => (
event.type === 'turn/end' && event.data.reason.kind === 'completed'
))).toHaveLength(TURN_COUNT)
expect(specs.at(-1)?.prompt.length).toBeGreaterThan(4_000)
expect(sessionEvents.filter(event => (
event.type === 'assistant/chunk' && event.data.turn === TURN_COUNT
)).length).toBeGreaterThan(30)
expect(consoleWarnings).toEqual([])
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
}, 180_000)
})

View File

@@ -0,0 +1,271 @@
// Long-history Chat behavior contract for a future virtualized renderer. Wheel
// input only navigates to the semantic target; assertions pin content identity
// and interaction routing rather than scroll geometry or mounted row counts.
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
import type { ReplayEntry, ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay'
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import { createChatScrollFixture } from './chat-scroll-fixture.ts'
import {
launchWebScaffold,
seedSession,
watchConsole,
webSnapshotMode,
type WebScaffold,
} from './scaffold.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
const MODE = webSnapshotMode()
const SESSION_ID = 'chat-long-interactions-e2e'
const FIXTURE_TURNS = 88
const TOOL_TURN = FIXTURE_TURNS
const BRANCH_TURN = 80
const TARGET_CALL_1 = 'chat-scroll-088-1'
const TARGET_CALL_2 = 'chat-scroll-088-2'
const CONTINUE_PROMPT = 'CHAT_INTERACTION_CONTINUE Continue from this exact branch point.'
const CONTINUE_FIRST = 'CHAT_INTERACTION_CONTINUE_FIRST'
const CONTINUE_DONE = 'CHAT_INTERACTION_CONTINUE_DONE'
const FIXTURE = createChatScrollFixture({
markerPrefix: 'INTERACTION',
title: 'CHAT_INTERACTION long semantic identity session',
turns: FIXTURE_TURNS,
})
function continuationChunks(): StreamChunk[] {
const response = `${CONTINUE_FIRST} The fork retained the intended prefix. ${CONTINUE_DONE}.`
return [
{ type: 'block-start', index: 0, blockType: 'text' },
{ type: 'text-delta', index: 0, text: `${CONTINUE_FIRST} ` },
{ type: 'text-delta', index: 0, text: `The fork retained the intended prefix. ${CONTINUE_DONE}.` },
{ type: 'block-end', index: 0, block: { type: 'text', text: response } },
{ type: 'usage', usage: { inputTokens: 512, outputTokens: 32 } },
{ type: 'finish', reason: { kind: 'stop' } },
]
}
function replayEntry(chunks: StreamChunk[]): ReplayEntry {
return { kind: 'chunks', chunks }
}
function carries(event: SessionEvent, marker: string): boolean {
return JSON.stringify(event).includes(marker)
}
function textContent(content: readonly unknown[]): string {
return content.flatMap((block) => {
if (typeof block !== 'object' || block === null) return []
const candidate = block as { type?: unknown; text?: unknown }
return candidate.type === 'text' && typeof candidate.text === 'string'
? [candidate.text]
: []
}).join('')
}
async function nextPaint(page: Page): Promise<void> {
await page.evaluate(async () => {
await document.fonts.ready
await new Promise<void>(resolve => requestAnimationFrame(() => {
requestAnimationFrame(() => { resolve() })
}))
})
}
async function openSeed(page: Page): Promise<void> {
await page.getByText(/^\d+ sessions?$/, { exact: true }).waitFor({ timeout: 30_000 })
const search = page.getByRole('textbox', { name: 'Search name, keywords...', exact: true })
await search.fill(FIXTURE.markers.user(1))
const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
await results.first().waitFor({ timeout: 60_000 })
const resultCount = await results.count()
if (resultCount !== 1) throw new Error(`expected one seeded search result, received ${String(resultCount)}`)
await results.click()
await results.click()
await page.getByText(FIXTURE.markers.assistant(FIXTURE.turns), { exact: false })
.last().waitFor({ timeout: 30_000 })
await nextPaint(page)
}
async function wheelUntilMounted(page: Page, selector: string, deltaY: number): Promise<void> {
const scrollport = page.locator('[data-conversation-scroll]')
const box = await scrollport.boundingBox()
if (box === null) throw new Error('conversation scrollport has no layout box')
await page.mouse.move(box.x + box.width / 2, box.y + Math.min(140, box.height / 3))
for (let attempt = 0; attempt < 20; attempt += 1) {
if (await page.locator(selector).count() > 0) return
await page.mouse.wheel(0, deltaY)
await nextPaint(page)
}
throw new Error(`semantic Chat target did not mount: ${selector}`)
}
function requiredEvent<T extends SessionEvent['type']>(
events: readonly SessionEvent[],
type: T,
marker: string,
): Extract<SessionEvent, { type: T }> {
const event = events.find((candidate): candidate is Extract<SessionEvent, { type: T }> => (
candidate.type === type && carries(candidate, marker)
))
if (event === undefined) throw new Error(`${type} carrying ${marker} is absent`)
return event
}
describe('web e2e: long Chat interaction contract', () => {
let browser: Browser
let page: Page
let replayDir: string
let scaffold: WebScaffold
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
replayDir = await mkdtemp(join(tmpdir(), 'dsh-chat-interaction-replay-'))
const replayOverride = join(replayDir, 'replay.override.json')
const replay: ReplayOverrideDoc = [replayEntry(continuationChunks())]
await writeFile(replayOverride, JSON.stringify(replay))
scaffold = await launchWebScaffold({
replayFixture: join(replayDir, 'override-only.jsonl'),
replayOverride,
replayContextWindow: 10_000_000,
paceMs: 18,
})
await seedSession(scaffold, FIXTURE.log, SESSION_ID)
browser = await chromium.launch()
page = await newEnglishPage(browser, 900)
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await openSeed(page)
}, 120_000)
afterAll(async () => {
const failures: unknown[] = []
await browser?.close().catch((error: unknown) => failures.push(error))
await scaffold?.close().catch((error: unknown) => failures.push(error))
if (replayDir !== undefined) {
await rm(replayDir, { recursive: true, force: true })
.catch((error: unknown) => failures.push(error))
}
if (failures.length === 1) throw failures[0]
if (failures.length > 1) throw new AggregateError(failures, 'long Chat interaction cleanup failed')
})
it.skipIf(MODE === 'record')('keeps heterogeneous rows and their actions bound to exact semantic identities', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-chat-long-interactions'))
const source = scaffold.ctx.agents.get(SessionId(SESSION_ID))
if (source === undefined) throw new Error('seeded long-history agent is not attached')
const toolUserMarker = FIXTURE.markers.user(TOOL_TURN)
const toolAssistantMarker = FIXTURE.markers.assistant(TOOL_TURN)
const toolMarker1 = FIXTURE.markers.tool(TOOL_TURN, 1)
const toolMarker2 = FIXTURE.markers.tool(TOOL_TURN, 2)
const toolUserEvent = requiredEvent(source.session.events, 'user/message', toolUserMarker)
const toolAssistantEvent = requiredEvent(source.session.events, 'assistant/message', toolAssistantMarker)
const branchUserMarker = FIXTURE.markers.user(BRANCH_TURN)
const branchAssistantMarker = FIXTURE.markers.assistant(BRANCH_TURN)
const branchUserEvent = requiredEvent(source.session.events, 'user/message', branchUserMarker)
const branchAssistantEvent = requiredEvent(source.session.events, 'assistant/message', branchAssistantMarker)
const boundary = source.session.events.find((event): event is SessionEvent<'turn/end'> => (
event.type === 'turn/end' && event.data.turn === BRANCH_TURN
))
if (boundary === undefined) throw new Error(`turn ${String(BRANCH_TURN)} has no completed boundary`)
const expectedUserText = textContent(branchUserEvent.data.content)
await wheelUntilMounted(page, `[data-chat-call-id="${TARGET_CALL_2}"]`, -1_100)
const toolUserRow = page.locator(`[data-chat-anchor-key="node:${String(toolUserEvent.seq)}"]`)
const toolAssistantRow = page.locator(`[data-chat-anchor-key="node:${String(toolAssistantEvent.seq)}"]`)
const call1 = page.locator(`[data-chat-call-id="${TARGET_CALL_1}"]`)
const call2 = page.locator(`[data-chat-call-id="${TARGET_CALL_2}"]`)
await expect.poll(() => toolUserRow.count(), { timeout: 10_000 }).toBe(1)
await expect.poll(() => toolAssistantRow.count(), { timeout: 10_000 }).toBe(1)
expect(await call1.count()).toBe(1)
expect(await call2.count()).toBe(1)
expect(await toolUserRow.getAttribute('data-chat-flow-kind')).toBe('user')
expect(await toolAssistantRow.getAttribute('data-chat-flow-kind')).toBe('assistant')
expect(await toolUserRow.textContent()).toContain(toolUserMarker)
expect(await toolAssistantRow.textContent()).toContain(toolAssistantMarker)
expect(await call1.textContent()).toContain(toolMarker1)
expect(await call2.textContent()).toContain(toolMarker2)
const expectedOrder = [
`node:${String(toolUserEvent.seq)}`,
`call:${TARGET_CALL_1}`,
`call:${TARGET_CALL_2}`,
`node:${String(toolAssistantEvent.seq)}`,
]
const actualOrder = await page.locator('[data-chat-anchor-key]').evaluateAll((rows, keys) => (
rows.map(row => (row as HTMLElement).dataset.chatAnchorKey)
.filter((key): key is string => key !== undefined && keys.includes(key))
), expectedOrder)
expect(actualOrder).toEqual(expectedOrder)
const groupKeys = await Promise.all([call1, call2].map(row => row.evaluate(element => (
element.closest<HTMLElement>('[data-chat-flow-kind="tool-group"]')?.dataset.chatFlowKey ?? null
))))
expect(groupKeys[0]).not.toBeNull()
expect(groupKeys[1]).toBe(groupKeys[0])
const summary1 = call1.locator('[data-sample="bash"]')
const summary2 = call2.locator('[data-sample="bash"]')
expect(await summary1.getAttribute('aria-expanded')).toBe('false')
expect(await summary2.getAttribute('aria-expanded')).toBe('false')
await summary2.focus()
await summary2.press('Enter')
await expect.poll(() => summary2.getAttribute('aria-expanded'), { timeout: 10_000 }).toBe('true')
expect(await summary1.getAttribute('aria-expanded')).toBe('false')
await call2.getByText(`${toolMarker2} output line 12`, { exact: true }).waitFor({ timeout: 10_000 })
await wheelUntilMounted(page, `[data-chat-anchor-key="node:${String(branchUserEvent.seq)}"]`, -1_100)
const userRow = page.locator(`[data-chat-anchor-key="node:${String(branchUserEvent.seq)}"]`)
const assistantRow = page.locator(`[data-chat-anchor-key="node:${String(branchAssistantEvent.seq)}"]`)
expect(await userRow.textContent()).toContain(branchUserMarker)
expect(await assistantRow.textContent()).toContain(branchAssistantMarker)
await page.context().grantPermissions(['clipboard-read', 'clipboard-write'])
await userRow.hover()
await userRow.getByRole('button', { name: 'Copy', exact: true }).click()
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText()), { timeout: 5_000 })
.toBe(expectedUserText)
await assistantRow.hover()
await assistantRow.getByRole('button', { name: 'Branch into a new conversation', exact: true }).click()
await expect.poll(
() => scaffold.ctx.agents.list().find(agent => agent.session.header.parentSession === SessionId(SESSION_ID)),
{ timeout: 15_000 },
).toBeDefined()
const child = scaffold.ctx.agents.list()
.find(agent => agent.session.header.parentSession === SessionId(SESSION_ID))
if (child === undefined) throw new Error('message branch did not create a child session')
expect(child.session.header.seedLength).toBe(boundary.seq + 1)
expect(child.session.events.some(event => carries(event, branchAssistantMarker))).toBe(true)
expect(child.session.events.some(event => carries(event, FIXTURE.markers.user(BRANCH_TURN + 1)))).toBe(false)
expect(child.session.events.some(event => carries(event, FIXTURE.markers.user(FIXTURE.turns)))).toBe(false)
const currentCrumb = page.getByRole('navigation', { name: 'Session hierarchy' })
.getByRole('button').last()
await expect.poll(() => currentCrumb.textContent(), { timeout: 15_000 })
.toBe(`${FIXTURE.title} (1)`)
await page.getByText(branchAssistantMarker, { exact: false }).last().waitFor({ timeout: 15_000 })
const settled = scaffold.whenTurnSettled(60_000)
const composer = page.locator('textarea:enabled').last()
await composer.fill(CONTINUE_PROMPT)
await page.getByRole('button', { name: 'Send message', exact: true }).click()
await expect.poll(() => page.getByText(CONTINUE_PROMPT, { exact: true }).count(), { timeout: 15_000 }).toBe(1)
expect(await settled).toBe(child.session.id)
await page.getByText(CONTINUE_DONE, { exact: false }).last().waitFor({ timeout: 15_000 })
await expect.poll(() => page.locator('[data-streaming="true"]').count(), { timeout: 15_000 }).toBe(0)
expect(await composer.inputValue()).toBe('')
expect(await composer.isEnabled()).toBe(true)
expect(source.session.events.some(event => carries(event, CONTINUE_PROMPT))).toBe(false)
expect(child.session.events.filter(event => carries(event, CONTINUE_PROMPT))).toHaveLength(1)
const lastTurnEnd = child.session.events.findLast((event): event is SessionEvent<'turn/end'> => (
event.type === 'turn/end'
))
expect(lastTurnEnd?.data.reason).toEqual({ kind: 'completed' })
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
}, 180_000)
})

View File

@@ -0,0 +1,683 @@
// Browser geometry contracts for a long Chat transcript. These scenarios are
// deliberately virtualizer-neutral: they assert semantic-row position,
// bottom ownership, interaction state, and the real outer scroll host rather
// than DOM cardinality or implementation-specific spacer markup.
import { access, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
import { CallId } from '@deepseek-ai/dsh-llm'
import type { ReplayEntry, ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import { createChatScrollFixture, type ChatScrollFixture } from './chat-scroll-fixture.ts'
import {
launchWebScaffold,
seedSession,
watchConsole,
webSnapshotMode,
type WebScaffold,
} from './scaffold.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
const MODE = webSnapshotMode()
const HISTORY_SESSION_ID = 'chat-scroll-history-e2e'
const TOOL_SESSION_ID = 'chat-scroll-tool-e2e'
const RESTORE_SESSION_A_ID = 'chat-scroll-restore-a-e2e'
const RESTORE_SESSION_B_ID = 'chat-scroll-restore-b-e2e'
const REPLAY_CONTEXT_WINDOW = 10_000_000
const STREAM_PACE_MS = 24
const GEOMETRY_TOLERANCE = 2
const LIVE_TEXT_PROMPT = 'CHAT_SCROLL_LIVE_USER Continue this long conversation while I inspect older history.'
const LIVE_TEXT_FIRST = 'CHAT_SCROLL_LIVE_FIRST'
const LIVE_TEXT_DONE = 'CHAT_SCROLL_LIVE_DONE'
const LIVE_TOOL_PROMPT = 'CHAT_SCROLL_TOOL_USER Run the requested diagnostic and then summarize it.'
const LIVE_TOOL_CALL_ID = CallId('chat-scroll-live-tool-call')
const LIVE_TOOL_RESULT = 'CHAT_SCROLL_LIVE_TOOL_RESULT'
const LIVE_TOOL_FIRST = 'CHAT_SCROLL_TOOL_STREAM_FIRST'
const LIVE_TOOL_DONE = 'CHAT_SCROLL_TOOL_STREAM_DONE'
const TOOL_READY_FILE = '.chat-scroll-tool-ready'
const TOOL_RELEASE_FILE = '.chat-scroll-tool-release'
const HISTORY_FIXTURE = createChatScrollFixture({
markerPrefix: 'HISTORY',
title: 'CHAT_SCROLL_HISTORY long paging session',
})
const TOOL_FIXTURE = createChatScrollFixture({
markerPrefix: 'TOOL',
title: 'CHAT_SCROLL_TOOL live tool session',
})
const RESTORE_FIXTURE_A = createChatScrollFixture({
markerPrefix: 'RESTORE_A',
title: 'CHAT_SCROLL_RESTORE_A long session',
})
const RESTORE_FIXTURE_B = createChatScrollFixture({
markerPrefix: 'RESTORE_B',
title: 'CHAT_SCROLL_RESTORE_B comparison session',
turns: 32,
})
interface ScrollGeometry {
readonly distanceFromBottom: number
readonly scrollTop: number
}
interface FlowAnchor {
readonly key: string
readonly top: number
}
interface ScrollWorld {
readonly events: SessionEvent[]
readonly page: Page
readonly replayDir?: string
readonly scaffold: WebScaffold
readonly tripwire: ReturnType<typeof watchConsole>
}
interface ScrollWorldOptions {
readonly failureShot: string
readonly replay?: ReplayOverrideDoc
readonly seeds: readonly { fixture: ChatScrollFixture; id: string }[]
}
function textStream(first: string, done: string, deltaCount: number): StreamChunk[] {
const deltas = Array.from({ length: deltaCount }, (_, index) => {
if (index === 0) return `${first} `
if (index === deltaCount - 1) return `${done}.`
return `stream-chunk-${String(index).padStart(3, '0')} ${'incremental response '.repeat(3)}`
})
const response = deltas.join('')
return [
{ type: 'block-start', index: 0, blockType: 'text' },
...deltas.map(text => ({ type: 'text-delta' as const, index: 0, text })),
{ type: 'block-end', index: 0, block: { type: 'text', text: response } },
{
type: 'usage',
usage: { inputTokens: 512, outputTokens: Math.ceil(response.length / 4) },
},
{ type: 'finish', reason: { kind: 'stop' } },
]
}
function toolStream(): StreamChunk[] {
const command = [
`: > ${TOOL_READY_FILE}`,
`while [ ! -f ${TOOL_RELEASE_FILE} ]; do sleep 0.02; done`,
'line=1',
`while [ "$line" -le 64 ]; do printf '${LIVE_TOOL_RESULT} line %02d\\n' "$line"; line=$((line + 1)); done`,
].join('; ')
const args = JSON.stringify({ command, description: LIVE_TOOL_RESULT })
return [
{ type: 'block-start', index: 0, blockType: 'tool-call' },
{
type: 'tool-call-delta',
index: 0,
id: LIVE_TOOL_CALL_ID,
name: 'bash',
argumentsDelta: args,
},
{
type: 'block-end',
index: 0,
block: { type: 'tool-call', id: LIVE_TOOL_CALL_ID, name: 'bash', arguments: args },
},
{ type: 'usage', usage: { inputTokens: 256, outputTokens: 48 } },
{ type: 'finish', reason: { kind: 'tool-calls' } },
]
}
function replayEntry(chunks: StreamChunk[]): ReplayEntry {
return { kind: 'chunks', chunks }
}
async function launchScrollWorld(options: ScrollWorldOptions): Promise<ScrollWorld> {
let replayDir: string | undefined
let scaffold: WebScaffold | undefined
let page: Page | undefined
try {
if (options.replay !== undefined) {
replayDir = await mkdtemp(join(tmpdir(), 'dsh-chat-scroll-replay-'))
const replayOverride = join(replayDir, 'replay.override.json')
await writeFile(replayOverride, JSON.stringify(options.replay))
scaffold = await launchWebScaffold({
replayFixture: join(replayDir, 'override-only.jsonl'),
replayOverride,
paceMs: STREAM_PACE_MS,
replayContextWindow: REPLAY_CONTEXT_WINDOW,
})
} else {
scaffold = await launchWebScaffold({})
}
for (const seed of options.seeds) await seedSession(scaffold, seed.fixture.log, seed.id)
const events: SessionEvent[] = []
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { events.push(event) })
page = await newEnglishPage(browser, 900)
const tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
// Session-list bootstrap can replace the controlled search state. Wait
// for the seeded baseline before openSeed starts the lazy content query.
await page.getByText(/^\d+ sessions?$/, { exact: true }).waitFor({ timeout: 30_000 })
return {
events,
page,
scaffold,
tripwire,
...(replayDir === undefined ? {} : { replayDir }),
}
} catch (error) {
const failures: unknown[] = [error]
if (page !== undefined) await page.context().close().catch((cleanupError: unknown) => failures.push(cleanupError))
if (scaffold !== undefined) await scaffold.close().catch((cleanupError: unknown) => failures.push(cleanupError))
if (replayDir !== undefined) {
await rm(replayDir, { recursive: true, force: true }).catch((cleanupError: unknown) => failures.push(cleanupError))
}
if (failures.length === 1) throw error
throw new AggregateError(failures, 'chat-scroll browser world setup failed and cleanup was incomplete')
}
}
async function closeScrollWorld(world: ScrollWorld): Promise<void> {
const failures: unknown[] = []
// newEnglishPage/browser.newPage owns an isolated context. Close the whole
// context so its SSE connection and cache cannot leak into the next world
// in this file's shared Chromium process.
await world.page.context().close().catch((error: unknown) => failures.push(error))
await world.scaffold.close().catch((error: unknown) => failures.push(error))
if (world.replayDir !== undefined) {
await rm(world.replayDir, { recursive: true, force: true }).catch((error: unknown) => failures.push(error))
}
if (failures.length === 1) throw failures[0]
if (failures.length > 1) throw new AggregateError(failures, 'chat-scroll browser world cleanup failed')
}
async function withScrollWorld(
options: ScrollWorldOptions,
run: (world: ScrollWorld) => Promise<void>,
): Promise<void> {
const world = await launchScrollWorld(options)
let runFailure: unknown
try {
await run(world)
} catch (error) {
runFailure = error
try {
await saveFailureShot(world.page, options.failureShot)
} catch {
// Best-effort evidence must never prevent cleanup of the owned world.
}
}
let cleanupFailure: unknown
try {
await closeScrollWorld(world)
} catch (error) {
cleanupFailure = error
}
if (runFailure !== undefined && cleanupFailure !== undefined) {
throw new AggregateError([runFailure, cleanupFailure], 'chat-scroll scenario and cleanup both failed')
}
if (runFailure !== undefined) throw runFailure
if (cleanupFailure !== undefined) throw cleanupFailure
}
async function nextPaint(page: Page): Promise<void> {
await page.evaluate(async () => {
await document.fonts.ready
await new Promise<void>(resolve => requestAnimationFrame(() => {
requestAnimationFrame(() => { resolve() })
}))
})
}
function scrollGeometry(page: Page): Promise<ScrollGeometry> {
return page.locator('[data-conversation-scroll]').evaluate(host => ({
distanceFromBottom: host.scrollHeight - host.clientHeight - host.scrollTop,
scrollTop: host.scrollTop,
}))
}
async function conversationTurns(page: Page): Promise<number> {
const stats = page.getByText(/\d+ turns · \d+ steps/, { exact: true }).last()
await stats.waitFor({ timeout: 15_000 })
const value = await stats.textContent()
const match = value?.match(/^(\d+) turns · \d+ steps$/)
if (match?.[1] === undefined) throw new Error(`unexpected conversation stats ${JSON.stringify(value)}`)
return Number(match[1])
}
async function openSeed(page: Page, fixture: ChatScrollFixture, tailMarker?: string): Promise<void> {
const search = page.getByRole('textbox', { name: 'Search name, keywords...', exact: true })
// Cold summaries initially show the temporary workspace basename, so the
// persisted first-message marker is the stable user-facing identity. The
// query itself triggers lazy content-index reconciliation; no transient
// empty-state paint is used as a barrier.
await search.fill(fixture.markers.user(1))
const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
await expect.poll(() => results.count(), { timeout: 60_000 }).toBe(1)
await results.click()
await page.getByRole('tab', { name: 'Chat', exact: true }).waitFor({ timeout: 30_000 })
if (tailMarker !== undefined) {
await page.getByText(tailMarker, { exact: false }).last().waitFor({ timeout: 30_000 })
}
await nextPaint(page)
}
async function wheelTranscript(page: Page, deltaY: number): Promise<void> {
const box = await page.locator('[data-conversation-scroll]').boundingBox()
if (box === null) throw new Error('conversation scrollport has no layout box')
await page.mouse.move(box.x + box.width / 2, box.y + Math.min(140, box.height / 3))
await page.mouse.wheel(0, deltaY)
await nextPaint(page)
}
async function wheelToHistoryStart(page: Page): Promise<void> {
for (let attempt = 0; attempt < 12; attempt += 1) {
if ((await scrollGeometry(page)).scrollTop <= 1) break
await wheelTranscript(page, -2_400)
}
await expect.poll(async () => (await scrollGeometry(page)).scrollTop, { timeout: 10_000 })
.toBeLessThanOrEqual(1)
}
async function wheelUntilMounted(page: Page, selector: string, deltaY: number): Promise<void> {
for (let attempt = 0; attempt < 16; attempt += 1) {
if (await page.locator(selector).count() > 0) return
await wheelTranscript(page, deltaY)
}
throw new Error(`selector did not mount during transcript wheel: ${selector}`)
}
async function wheelUntilVisible(page: Page, selector: string, deltaY: number): Promise<void> {
const target = page.locator(selector)
for (let attempt = 0; attempt < 32; attempt += 1) {
if (await target.count() > 0 && await target.evaluate((row) => {
const host = row.closest<HTMLElement>('[data-conversation-scroll]')
if (host === null) return false
const viewport = host.getBoundingClientRect()
const composer = host.querySelector<HTMLElement>('[data-composer-seat]')
const visibleBottom = composer?.getBoundingClientRect().top ?? viewport.bottom
const rect = row.getBoundingClientRect()
return rect.bottom > viewport.top && rect.top < visibleBottom
})) return
await wheelTranscript(page, deltaY)
}
throw new Error(`selector did not become visible during transcript wheel: ${selector}`)
}
function visibleFlowAnchor(page: Page): Promise<FlowAnchor> {
return page.locator('[data-conversation-scroll]').evaluate((host) => {
const rows = [...host.querySelectorAll<HTMLElement>('[data-chat-anchor-key]')]
const viewport = host.getBoundingClientRect()
const composer = host.querySelector<HTMLElement>('[data-composer-seat]')
const visibleBottom = composer?.getBoundingClientRect().top ?? viewport.bottom
const visible = rows.filter((candidate) => {
const rect = candidate.getBoundingClientRect()
return rect.bottom > viewport.top && rect.top < visibleBottom
})
const row = visible[0]
if (row?.dataset.chatAnchorKey === undefined) {
throw new Error(`no visible settled Chat row: ${JSON.stringify({
composerTop: visibleBottom,
host: { bottom: viewport.bottom, top: viewport.top },
rows: rows.slice(0, 4).map(candidate => ({
callId: candidate.dataset.chatCallId,
key: candidate.dataset.chatAnchorKey,
rect: {
bottom: candidate.getBoundingClientRect().bottom,
top: candidate.getBoundingClientRect().top,
},
})),
totalRows: rows.length,
})}`)
}
return {
key: row.dataset.chatAnchorKey,
top: row.getBoundingClientRect().top - viewport.top,
}
})
}
function flowTop(page: Page, key: string): Promise<number> {
return page.locator('[data-chat-anchor-key]').evaluateAll((rows, anchorKey) => {
const row = rows.find(candidate => (candidate as HTMLElement).dataset.chatAnchorKey === anchorKey)
if (!(row instanceof HTMLElement)) throw new Error(`stable Chat anchor ${anchorKey} is not mounted`)
const host = row.closest('[data-conversation-scroll]')
if (!(host instanceof HTMLElement)) throw new Error('flow row has no conversation scrollport')
return row.getBoundingClientRect().top - host.getBoundingClientRect().top
}, key)
}
async function expectSameFlowTop(page: Page, anchor: FlowAnchor): Promise<void> {
await expect.poll(async () => Math.abs((await flowTop(page, anchor.key)) - anchor.top), {
timeout: 10_000,
message: `flow row ${anchor.key} moved relative to the transcript viewport`,
}).toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
}
async function expectBottom(page: Page): Promise<void> {
await expect.poll(async () => Math.abs((await scrollGeometry(page)).distanceFromBottom), {
timeout: 10_000,
}).toBeLessThanOrEqual(1)
}
async function expectMarkerAboveComposer(page: Page, marker: string): Promise<void> {
const geometry = await page.getByText(marker, { exact: false }).last().evaluate((node) => {
const row = node.closest('[data-chat-flow-key], [data-streaming]')
const composer = node.closest('[data-conversation-scroll]')?.querySelector('[data-composer-seat]')
if (!(row instanceof HTMLElement) || !(composer instanceof HTMLElement)) {
throw new Error('latest marker or composer geometry is unavailable')
}
return {
composerTop: composer.getBoundingClientRect().top,
rowBottom: row.getBoundingClientRect().bottom,
}
})
expect(geometry.rowBottom).toBeLessThanOrEqual(geometry.composerTop + GEOMETRY_TOLERANCE)
}
async function loadEarlierWithAnchor(page: Page): Promise<void> {
await wheelToHistoryStart(page)
const older = page.getByRole('button', { name: 'Load earlier', exact: true })
await older.waitFor({ timeout: 10_000 })
const anchor = await visibleFlowAnchor(page)
const before = await conversationTurns(page)
await older.click()
await expect.poll(() => conversationTurns(page), { timeout: 30_000 }).toBeGreaterThan(before)
await nextPaint(page)
await expectSameFlowTop(page, anchor)
}
async function fileExists(path: string): Promise<boolean> {
try {
await access(path)
return true
} catch {
return false
}
}
function eventCarries(event: SessionEvent, marker: string): boolean {
return JSON.stringify(event).includes(marker)
}
function assertClean(world: ScrollWorld): void {
expect(world.tripwire.pageErrors).toEqual([])
expect(world.tripwire.warnings).toEqual([])
}
let browser: Browser
describe('web e2e: long Chat scroll contract', () => {
beforeAll(async () => {
browser = await chromium.launch()
})
afterAll(async () => {
await browser?.close()
})
it.skipIf(MODE === 'record')('preserves the reader anchor when history and streaming arrive concurrently', async () => {
await withScrollWorld({
failureShot: 'web-e2e-chat-scroll-history-stream',
replay: [replayEntry(textStream(LIVE_TEXT_FIRST, LIVE_TEXT_DONE, 120))],
seeds: [{ fixture: HISTORY_FIXTURE, id: HISTORY_SESSION_ID }],
}, async (world) => {
await openSeed(
world.page,
HISTORY_FIXTURE,
HISTORY_FIXTURE.markers.assistant(HISTORY_FIXTURE.turns),
)
await expectBottom(world.page)
let releaseHistory = (): void => {}
let held = false
let releaseGate: (() => void) | undefined
const gate = new Promise<void>((resolve) => { releaseGate = resolve })
releaseHistory = () => { releaseGate?.() }
await world.page.route('**/api/session.history', async (route) => {
const request = route.request().postDataJSON() as {
method?: string
payload?: { beforeSeq?: number }
}
if (!held && request.method === 'session.history' && request.payload?.beforeSeq !== undefined) {
held = true
await gate
}
await route.continue()
})
const settled = world.scaffold.whenTurnSettled(60_000)
try {
const composer = world.page.locator('textarea:enabled').last()
await composer.fill(LIVE_TEXT_PROMPT)
await world.page.getByRole('button', { name: 'Send message', exact: true }).click()
await world.page.getByText(LIVE_TEXT_FIRST, { exact: false }).last().waitFor({ timeout: 15_000 })
await wheelToHistoryStart(world.page)
const beforeTurns = await conversationTurns(world.page)
await world.page.getByRole('button', { name: 'Load earlier', exact: true }).click()
await expect.poll(() => held, { timeout: 10_000 }).toBe(true)
await wheelTranscript(world.page, 420)
const readerAnchor = await visibleFlowAnchor(world.page)
const chunksAfterAnchor = world.events.filter(event => event.type === 'assistant/chunk').length
await expect.poll(
() => world.events.filter(event => event.type === 'assistant/chunk').length,
{ timeout: 10_000 },
).toBeGreaterThan(chunksAfterAnchor + 5)
releaseHistory()
await expect.poll(() => conversationTurns(world.page), { timeout: 30_000 }).toBeGreaterThan(beforeTurns)
await nextPaint(world.page)
await expectSameFlowTop(world.page, readerAnchor)
} finally {
releaseHistory()
}
await settled
await expect.poll(() => world.page.locator('[data-streaming="true"]').count(), { timeout: 15_000 }).toBe(0)
await world.page.getByText(LIVE_TEXT_DONE, { exact: false }).last().waitFor({ timeout: 15_000 })
await world.page.unroute('**/api/session.history')
let additionalPages = 0
while (additionalPages < 8) {
await wheelToHistoryStart(world.page)
if (await world.page.getByRole('button', { name: 'Load earlier', exact: true }).count() === 0) break
await loadEarlierWithAnchor(world.page)
additionalPages += 1
}
expect(additionalPages).toBeGreaterThan(0)
expect(await conversationTurns(world.page)).toBe(HISTORY_FIXTURE.turns + 1)
expect(await world.page.getByRole('button', { name: 'Load earlier', exact: true }).count()).toBe(0)
assertClean(world)
})
}, 180_000)
it.skipIf(MODE === 'record')('keeps streaming ownership and tool disclosure state across a long scroll-away cycle', async () => {
await withScrollWorld({
failureShot: 'web-e2e-chat-scroll-live-tool',
replay: [
replayEntry(toolStream()),
replayEntry(textStream(LIVE_TOOL_FIRST, LIVE_TOOL_DONE, 84)),
],
seeds: [{ fixture: TOOL_FIXTURE, id: TOOL_SESSION_ID }],
}, async (world) => {
const readyPath = join(world.scaffold.workspaceCwd, TOOL_READY_FILE)
const releasePath = join(world.scaffold.workspaceCwd, TOOL_RELEASE_FILE)
await openSeed(world.page, TOOL_FIXTURE, TOOL_FIXTURE.markers.assistant(TOOL_FIXTURE.turns))
const settled = world.scaffold.whenTurnSettled(60_000)
let released = false
try {
const composer = world.page.locator('textarea:enabled').last()
await composer.fill(LIVE_TOOL_PROMPT)
await world.page.getByRole('button', { name: 'Send message', exact: true }).click()
await expect.poll(() => fileExists(readyPath), { timeout: 15_000 }).toBe(true)
const liveRow = world.page.locator(`[data-chat-call-id="${LIVE_TOOL_CALL_ID}"] [data-sample="bash"]`)
await liveRow.waitFor({ timeout: 15_000 })
expect(await liveRow.getAttribute('data-state')).toBe('running')
await expectBottom(world.page)
await wheelTranscript(world.page, -1_200)
await world.page.getByRole('button', { name: 'Back to bottom', exact: true }).waitFor({ timeout: 10_000 })
const awayAnchor = await visibleFlowAnchor(world.page)
const chunksBeforeRelease = world.events.filter(event => event.type === 'assistant/chunk').length
await writeFile(releasePath, 'release\n')
released = true
await expect.poll(
() => world.events.some(event => event.type === 'tool/result'),
{ timeout: 15_000 },
).toBe(true)
await expect.poll(
() => world.events.some(event => eventCarries(event, LIVE_TOOL_FIRST)),
{ timeout: 15_000 },
).toBe(true)
await expect.poll(
() => world.events.filter(event => event.type === 'assistant/chunk').length,
{ timeout: 15_000 },
).toBeGreaterThan(chunksBeforeRelease + 5)
await expectSameFlowTop(world.page, awayAnchor)
const chunksAtRepin = world.events.filter(event => event.type === 'assistant/chunk').length
await world.page.getByRole('button', { name: 'Back to bottom', exact: true }).click()
await expectBottom(world.page)
await expect.poll(
() => world.events.filter(event => event.type === 'assistant/chunk').length,
{ timeout: 15_000 },
).toBeGreaterThan(chunksAtRepin + 5)
await expectBottom(world.page)
} finally {
if (!released) await writeFile(releasePath, 'release\n').catch(() => {})
}
await settled
await expect.poll(() => world.page.locator('[data-streaming="true"]').count(), { timeout: 15_000 }).toBe(0)
await world.page.getByText(LIVE_TOOL_DONE, { exact: false }).last().waitFor({ timeout: 15_000 })
await expectBottom(world.page)
await expectMarkerAboveComposer(world.page, LIVE_TOOL_DONE)
const liveRowSelector = `[data-chat-call-id="${LIVE_TOOL_CALL_ID}"] [data-sample="bash"]`
const liveRow = world.page.locator(liveRowSelector)
await wheelUntilVisible(world.page, liveRowSelector, -300)
const toolAnchor = await liveRow.evaluate((row) => {
const flow = row.closest<HTMLElement>('[data-chat-anchor-key]')
const host = row.closest<HTMLElement>('[data-conversation-scroll]')
if (flow?.dataset.chatAnchorKey === undefined || host === null) {
throw new Error('live tool row has no settled flow identity')
}
return {
key: flow.dataset.chatAnchorKey,
top: flow.getBoundingClientRect().top - host.getBoundingClientRect().top,
}
})
await liveRow.click()
await expect.poll(() => liveRow.getAttribute('aria-expanded'), { timeout: 10_000 }).toBe('true')
await expectSameFlowTop(world.page, toolAnchor)
await wheelToHistoryStart(world.page)
await world.page.getByRole('button', { name: 'Back to bottom', exact: true }).click()
await expectBottom(world.page)
await wheelUntilMounted(world.page, liveRowSelector, -1_100)
const restoredRow = world.page.locator(liveRowSelector)
await restoredRow.waitFor({ timeout: 10_000 })
expect(await restoredRow.getAttribute('aria-expanded')).toBe('true')
expect(await world.page.getByText(LIVE_TOOL_RESULT, { exact: false }).count()).toBeGreaterThan(0)
assertClean(world)
})
}, 180_000)
it.skipIf(MODE === 'record')('restores tab/session position and keeps composer resizing on the correct scroll owner', async () => {
await withScrollWorld({
failureShot: 'web-e2e-chat-scroll-restore-composer',
seeds: [
{ fixture: RESTORE_FIXTURE_A, id: RESTORE_SESSION_A_ID },
{ fixture: RESTORE_FIXTURE_B, id: RESTORE_SESSION_B_ID },
],
}, async (world) => {
await openSeed(
world.page,
RESTORE_FIXTURE_A,
RESTORE_FIXTURE_A.markers.assistant(RESTORE_FIXTURE_A.turns),
)
await loadEarlierWithAnchor(world.page)
await loadEarlierWithAnchor(world.page)
await wheelToHistoryStart(world.page)
await wheelTranscript(world.page, 1_300)
const sessionAnchor = await visibleFlowAnchor(world.page)
await world.page.getByRole('tab', { name: 'Trajectory', exact: true }).click()
await world.page.getByLabel('Trajectory timeline').waitFor({ timeout: 30_000 })
await world.page.setViewportSize({ width: 700, height: 900 })
await world.page.getByRole('tab', { name: 'Chat', exact: true }).click()
await nextPaint(world.page)
await expectSameFlowTop(world.page, sessionAnchor)
await openSeed(
world.page,
RESTORE_FIXTURE_B,
RESTORE_FIXTURE_B.markers.assistant(RESTORE_FIXTURE_B.turns),
)
await openSeed(
world.page,
RESTORE_FIXTURE_A,
)
await expectSameFlowTop(world.page, sessionAnchor)
const backToBottom = world.page.getByRole('button', { name: 'Back to bottom', exact: true })
await backToBottom.evaluate((button) => {
if (!(button instanceof HTMLElement)) throw new Error('Back-to-bottom control is not an HTML element')
button.click()
const trajectory = [...document.querySelectorAll<HTMLElement>('[role="tab"]')]
.find(tab => tab.textContent?.trim() === 'Trajectory')
if (!(trajectory instanceof HTMLElement)) {
throw new Error('Trajectory tab is unavailable during pinned remount')
}
trajectory.click()
})
await world.page.getByLabel('Trajectory timeline').waitFor({ timeout: 30_000 })
await world.page.getByRole('tab', { name: 'Chat', exact: true }).click()
await expectBottom(world.page)
await openSeed(
world.page,
RESTORE_FIXTURE_B,
RESTORE_FIXTURE_B.markers.assistant(RESTORE_FIXTURE_B.turns),
)
await openSeed(
world.page,
RESTORE_FIXTURE_A,
RESTORE_FIXTURE_A.markers.assistant(RESTORE_FIXTURE_A.turns),
)
await expectBottom(world.page)
const composer = world.page.locator('textarea:enabled').last()
const longDraft = Array.from(
{ length: 18 },
(_, index) => `composer resize line ${String(index + 1).padStart(2, '0')}`,
).join('\n')
await composer.fill(longDraft)
await nextPaint(world.page)
await expectBottom(world.page)
await expectMarkerAboveComposer(
world.page,
RESTORE_FIXTURE_A.markers.assistant(RESTORE_FIXTURE_A.turns),
)
await composer.fill('short draft')
await nextPaint(world.page)
await wheelTranscript(world.page, -900)
const resizeAnchor = await visibleFlowAnchor(world.page)
await composer.fill(longDraft)
await nextPaint(world.page)
await expectSameFlowTop(world.page, resizeAnchor)
await composer.fill('short draft')
await nextPaint(world.page)
await expectSameFlowTop(world.page, resizeAnchor)
const beforeChain = await scrollGeometry(world.page)
await composer.hover()
await world.page.mouse.wheel(0, -320)
await expect.poll(async () => (await scrollGeometry(world.page)).scrollTop, { timeout: 10_000 })
.toBeLessThan(beforeChain.scrollTop)
assertClean(world)
})
}, 180_000)
})

View File

@@ -0,0 +1,235 @@
// Synthetic long-chat history for browser behavior contracts. The fixture is
// generated through Session so pagination exercises the same event shapes as
// persisted conversations, while unique markers let tests identify semantic
// rows without depending on CSS-module names or the eventual virtualizer DOM.
import {
CallId,
createAssistantMessage,
createToolResultMessage,
createUserMessage,
} from '@deepseek-ai/dsh-llm'
import {
SESSION_FORMAT_VERSION,
Session,
SessionId,
} from '@deepseek-ai/dsh-session'
// Carries the session/title event declaration into this fixture builder.
import type {} from '@deepseek-ai/dsh-session-title'
/** Options for one deterministic long-chat fixture. */
export interface ChatScrollFixtureOptions {
/** Marker namespace, used when two sessions share one browser world. */
readonly markerPrefix: string
/** Searchable title projected into the sidebar. */
readonly title: string
/** Number of closed turns to generate. */
readonly turns?: number
}
/** Semantic marker helpers returned with a generated fixture. */
interface ChatScrollMarkers {
/** Marker painted in the human message for a turn. */
user(turn: number): string
/** Marker painted in the final assistant message for a turn. */
assistant(turn: number): string
/** Marker painted in one seeded bash call and result. */
tool(turn: number, index: number): string
}
/** Generated JSONL plus the stable facts browser scenarios assert. */
export interface ChatScrollFixture {
readonly log: string
readonly markers: ChatScrollMarkers
readonly title: string
readonly turns: number
}
const DEFAULT_TURNS = 88
const TOOL_INTERVAL = 8
const CODE_INTERVAL = 11
function text(value: string): { type: 'text'; text: string }[] {
return [{ type: 'text', text: value }]
}
function suffix(turn: number): string {
return String(turn).padStart(3, '0')
}
function markerHelpers(prefix: string): ChatScrollMarkers {
return {
user: turn => `CHAT_SCROLL_${prefix}_USER_${suffix(turn)}`,
assistant: turn => `CHAT_SCROLL_${prefix}_ASSISTANT_${suffix(turn)}`,
tool: (turn, index) => `CHAT_SCROLL_${prefix}_TOOL_${suffix(turn)}_${String(index)}`,
}
}
function appendRequestHeader(session: Session, turn: number, step: number): void {
session.append('request/header', {
header: {
config: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
system: `Synthetic chat-scroll request for turn ${String(turn)}, step ${String(step)}.`,
},
reason: turn === 1 && step === 1 ? 'initial' : 'change',
})
}
function appendAssistant(session: Session, turn: number, step: number, body: string): void {
session.append('assistant/message', {
turn,
step,
message: createAssistantMessage({
content: text(body),
source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
}),
usage: {
inputTokens: 2_000 + turn * 7,
outputTokens: 180 + step * 20,
},
}, { surfaceOp: 'append' })
}
function codeBlock(turn: number): string {
if (turn % CODE_INTERVAL !== 0) return ''
const lines = Array.from(
{ length: 30 },
(_, index) => `const scroll_case_${suffix(turn)}_${String(index).padStart(2, '0')} = ${String(turn + index)}`,
)
return `\n\n\`\`\`ts\n${lines.join('\n')}\n\`\`\``
}
function appendToolStep(
session: Session,
markers: ChatScrollMarkers,
turn: number,
): void {
const calls = [1, 2].map((index) => {
const marker = markers.tool(turn, index)
const callId = CallId(`chat-scroll-${suffix(turn)}-${String(index)}`)
const args = JSON.stringify({
command: `printf '${marker}\\n'`,
description: marker,
})
return { args, callId, marker }
})
session.append('assistant/message', {
turn,
step: 1,
message: createAssistantMessage({
content: [
{ type: 'reasoning', text: `Inspecting two scroll fixtures for turn ${String(turn)}.` },
...calls.map(call => ({
type: 'tool-call' as const,
id: call.callId,
name: 'bash',
arguments: call.args,
})),
],
source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
}),
usage: { inputTokens: 2_000 + turn * 7, outputTokens: 240, reasoningTokens: 30 },
}, { surfaceOp: 'append' })
for (const call of calls) {
const source = session.append('tool/call', {
turn,
step: 1,
callId: call.callId,
name: 'bash',
arguments: call.args,
})
session.append('tool/result', {
turn,
step: 1,
message: createToolResultMessage({
callId: call.callId,
content: text(Array.from(
{ length: 12 },
(_, line) => `${call.marker} output line ${String(line + 1).padStart(2, '0')}`,
).join('\n')),
isError: false,
}),
}, { surfaceOp: 'append', sourceEventSeqs: [source.seq] })
}
}
function fixtureLog(session: Session): string {
return [
JSON.stringify({
type: 'session',
version: SESSION_FORMAT_VERSION,
id: '{{sessionId}}',
createdAt: Date.now() - 60_000,
cwd: '{{cwd}}',
delegationDepth: 0,
}),
...session.events.map(event => JSON.stringify(event)),
'',
].join('\n')
}
/**
* Build a multi-page conversation with prose, fenced code, and paired bash
* calls/results. Every turn is closed, so cold resume cannot repair or mutate
* the seed before the browser observes it.
* @param options - Fixture identity and optional turn count.
* @returns Canonical JSONL and semantic marker helpers.
*/
export function createChatScrollFixture(options: ChatScrollFixtureOptions): ChatScrollFixture {
const turns = options.turns ?? DEFAULT_TURNS
const markers = markerHelpers(options.markerPrefix)
const session = new Session(SessionId(`chat-scroll-${options.markerPrefix.toLowerCase()}-template`))
for (let turn = 1; turn <= turns; turn += 1) {
session.append('turn/start', {
turn,
trigger: { kind: 'message', source: { kind: 'user' } },
})
const user = session.append('user/message', createUserMessage({
content: text(
`${markers.user(turn)} Review the long-running conversation state for turn ${String(turn)}. `
+ 'Keep the visible message stable while history, tools, and new output change around it.',
),
source: { kind: 'user' },
}), { surfaceOp: 'append' })
if (turn === 1) {
session.append('session/title', {
title: options.title,
messageSeqs: [user.seq],
source: { kind: 'fallback' },
})
}
session.append('step/start', { turn, step: 1 })
appendRequestHeader(session, turn, 1)
if (turn % TOOL_INTERVAL === 0) {
appendToolStep(session, markers, turn)
session.append('step/end', { turn, step: 1 })
session.append('step/start', { turn, step: 2 })
appendRequestHeader(session, turn, 2)
appendAssistant(
session,
turn,
2,
`${markers.assistant(turn)} Both tool results are accounted for. `
+ `This settled response keeps turn ${String(turn)} identifiable after paging.${codeBlock(turn)}`,
)
session.append('step/end', { turn, step: 2 })
} else {
appendAssistant(
session,
turn,
1,
`${markers.assistant(turn)} The conversation remains readable after several paragraphs.\n\n`
+ `Turn ${String(turn)} deliberately carries enough prose to wrap at narrower viewport widths. `
+ 'The semantic marker stays near the start so geometry probes can find the same rendered row.\n\n'
+ `The closing paragraph makes this a realistic assistant response rather than a one-line list item.${codeBlock(turn)}`,
)
session.append('step/end', { turn, step: 1 })
}
session.append('turn/end', { turn, reason: { kind: 'completed' } })
}
return { log: fixtureLog(session), markers, title: options.title, turns }
}

File diff suppressed because it is too large Load Diff

View File

@@ -33,6 +33,7 @@ const RELOADED_EXPECTED = join(SNAPSHOT_DIR, 'reloaded.expected.md')
const MODE = webSnapshotMode()
const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.'
const REPLAY_PACE_MS = 100
describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () => {
let scaffold: WebScaffold
@@ -42,7 +43,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
const sessionEvents: SessionEvent[] = []
beforeAll(async () => {
scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 })
scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: REPLAY_PACE_MS })
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
browser = await chromium.launch()
page = await newEnglishPage(browser)

View File

@@ -9,9 +9,9 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
import type { Browser, Page, Response } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, onTestFailed } from 'vitest'
import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import {
@@ -34,12 +34,46 @@ const SEED_ID = 'navigation-panes-web-e2e'
const PROMPT_TURN1 = 'NavScenario: first run bash to print exactly NAVIGATION_OK, then read nav-a.md and nav-b.md using two read calls in ONE assistant message, then reply with the single word FIRST_DONE and stop.'
const PROMPT_TURN2 = 'Reply in markdown with: a level-2 heading "Navigation Summary", a bulleted list of exactly two items, and a fenced code block containing echo WATERFALL. Then stop.'
async function baselineResponse(
page: Page,
method: 'session.list' | 'workspace.list',
): Promise<Response> {
return page.waitForResponse(response => (
response.request().method() === 'POST'
&& new URL(response.url()).pathname === `/api/${method}`
), { timeout: 30_000 })
}
async function assertBaselineSucceeded(response: Response, method: string): Promise<void> {
expect(response.ok(), `${method} baseline HTTP response`).toBe(true)
const body = await response.json() as { result?: { ok?: unknown } }
expect(body.result?.ok, `${method} baseline RPC result`).toBe(true)
}
async function ensureSeedOpen(page: Page): Promise<void> {
const chat = page.getByRole('tab', { name: 'Chat', exact: true })
const search = page.getByPlaceholder('Search name, keywords', { exact: false })
if (await chat.count() === 0) {
await search.fill('WATERFALL')
const result = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
await expect.poll(() => result.count(), { timeout: 15_000 }).toBe(1)
await result.click()
await chat.waitFor({ timeout: 15_000 })
}
await chat.click()
await page.getByText('FIRST_DONE', { exact: true }).waitFor({ timeout: 15_000 })
if (await search.inputValue() !== '') {
await search.fill('')
await expect.poll(() => search.inputValue(), { timeout: 5_000 }).toBe('')
}
}
describe('web e2e: navigation & panes over a rich seeded session', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
let slotErrors: string[]
let tripwire: ReturnType<typeof watchConsole> = { warnings: [], pageErrors: [] }
let slotErrors: string[] = []
beforeAll(async () => {
scaffold = await launchWebScaffold({})
@@ -57,6 +91,9 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
await seedSession(scaffold, raw, SEED_ID)
}
browser = await chromium.launch()
}, 120_000)
beforeEach(async () => {
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
slotErrors = []
@@ -65,7 +102,19 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
slotErrors.push(message.text())
}
})
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
// Initial navigation and list ownership settle only after both independent
// RPC baselines succeed; arm before navigation so neither response is missed.
const sessionBaseline = baselineResponse(page, 'session.list')
const workspaceBaseline = baselineResponse(page, 'workspace.list')
const [, sessionResponse, workspaceResponse] = await Promise.all([
page.goto(scaffold.baseUrl, { waitUntil: 'load' }),
sessionBaseline,
workspaceBaseline,
])
await Promise.all([
assertBaselineSucceeded(sessionResponse, 'session.list'),
assertBaselineSucceeded(workspaceResponse, 'workspace.list'),
])
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
// The frame mounts before the asynchronous session-list baseline lands.
// Search must target the settled seeded row, not the startup input that
@@ -73,9 +122,32 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
await page.getByText('1 session', { exact: true }).waitFor({ timeout: 30_000 })
}, 120_000)
afterEach(async () => {
const failures: unknown[] = []
try {
expect({
pageErrors: tripwire.pageErrors,
slotErrors,
warnings: tripwire.warnings,
}).toEqual({
pageErrors: [],
slotErrors: [],
warnings: [],
})
} catch (error) {
failures.push(error)
}
await page?.close().catch((error: unknown) => failures.push(error))
if (failures.length === 1) throw failures[0]
if (failures.length > 1) throw new AggregateError(failures, 'navigation case cleanup failed')
})
afterAll(async () => {
await browser?.close()
await scaffold?.close()
const failures: unknown[] = []
await browser?.close().catch((error: unknown) => failures.push(error))
await scaffold?.close().catch((error: unknown) => failures.push(error))
if (failures.length === 1) throw failures[0]
if (failures.length > 1) throw new AggregateError(failures, 'navigation e2e cleanup failed')
})
it.skipIf(MODE !== 'record')('records the two-turn seed live through the composer', async () => {
@@ -102,6 +174,9 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
it.skipIf(MODE === 'record')('finds an unopened seeded session by message content and opens it', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-search'))
// The API baselines can settle before React commits their projection. The
// seeded count is the final user-visible barrier before editing search.
await page.getByText('1 session', { exact: true }).waitFor({ timeout: 30_000 })
const search = page.getByPlaceholder('Search name, keywords', { exact: false })
// The cold row has not been opened, so only the persisted log can satisfy
// this query. First search lazily reconciles the SQLite content index.
@@ -136,6 +211,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
it.skipIf(MODE === 'record')('renders the trajectory ledger and opens its local record inspector', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-trajectory'))
await ensureSeedOpen(page)
await page.getByRole('tab', { name: 'Trajectory' }).click()
await page.waitForTimeout(100)
const overlayLayout = await page.getByRole('table').evaluate((table) => {
@@ -200,7 +276,10 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
it.skipIf(MODE === 'record')('focuses the ledger by dragging an overview interval', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-timeline'))
await ensureSeedOpen(page)
await page.getByRole('tab', { name: 'Trajectory' }).click()
const plot = page.getByLabel('Timeline overview; drag horizontally to focus events')
await plot.waitFor({ timeout: 15_000 })
const before = await page.locator('tr[data-kind]').count()
const box = await plot.boundingBox()
if (box === null) throw new Error('trajectory timeline plot has no layout box')
@@ -217,7 +296,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
it.skipIf(MODE === 'record')('bash and file-path rows leave the default details column closed', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-details'))
await page.getByRole('tab', { name: 'Chat' }).click()
await ensureSeedOpen(page)
const bashRow = page.locator('[data-sample="bash"]').first()
await bashRow.waitFor({ timeout: 15_000 })
const frame = page.locator('[style*="grid-template-columns"]').first()
@@ -239,9 +318,9 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
it.skipIf(MODE === 'record')('renders the bash row as a terminal card in the real browser', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-terminal'))
await page.getByRole('tab', { name: 'Chat' }).click()
await ensureSeedOpen(page)
// The card is expand-gated behind the whole-row toggle (the unified
// tool-row interaction): open it if a previous case left it collapsed.
// tool-row interaction): open it if this fresh view leaves it collapsed.
// Expanded, the recorded command's own output sits in the message flow,
// derived from the logged call/result presentations alone.
const bashRow = page.locator('[data-sample="bash"]').first()
@@ -322,10 +401,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
expect(await page.evaluate(() => navigator.clipboard.readText())).toContain('NAVIGATION_OK')
}, 60_000)
it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => {
expect(tripwire.pageErrors).toEqual([])
expect(slotErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
it.skipIf(MODE === 'record')('keeps the recorded fixture inventory exact', async () => {
await assertFixtureInventory(SNAPSHOT_DIR, [
'seed.jsonl', 'search-results.expected.md', 'trajectory.expected.md',
'terminal-card.expected.md',

View File

@@ -85,6 +85,14 @@ const REPLAY_PROVIDERS = [{
models: [{ id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', contextWindow: 128_000 }],
}]
function replayProviders(contextWindow: number | undefined): typeof REPLAY_PROVIDERS {
if (contextWindow === undefined) return REPLAY_PROVIDERS
return REPLAY_PROVIDERS.map(provider => ({
...provider,
models: provider.models.map(model => ({ ...model, contextWindow })),
}))
}
/** A booted web scaffold: real composition, mode-selected model backend, temp world. */
export interface WebScaffold {
/** The active snapshot mode this scaffold booted under. */
@@ -134,6 +142,8 @@ export interface LaunchOptions {
replayOverride?: string
/** Per-chunk replay pacing (ms) so the browser observes genuinely incremental SSE; replay/refresh only. */
paceMs?: number
/** Synthetic model capacity for UI scenarios whose seeded history must remain uncompacted. */
replayContextWindow?: number
/**
* Tool presentation mode patched onto the shipped `tools` row (`code`
* collapses the wire to run_code + the SDK prompt section). Omit for the
@@ -344,7 +354,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
if (mode !== 'record' && options.replayFixture !== undefined) {
replayHandle = installLlmReplay(ctx, {
file: options.replayFixture,
providers: REPLAY_PROVIDERS,
providers: replayProviders(options.replayContextWindow),
...(options.replayOverride === undefined ? {} : { overrideFile: options.replayOverride }),
...(options.replayChildFixtures === undefined ? {} : { childFiles: options.replayChildFixtures }),
...(options.paceMs === undefined ? {} : { paceMs: options.paceMs }),

View File

@@ -128,11 +128,11 @@ describe('assembled search card', () => {
win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
act(() => {
const entry = new AppWebEntry(root, {
fetchBundle: (url) => {
loadBundle: async (url) => {
const code = bundles.get(url)
return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code)
if (code === undefined) throw new Error(`missing built bundle ${url}`)
;(0, eval)(code)
},
executeBundle: (code) => { (0, eval)(code) },
})
void entry.run()
unmount = () => { entry.dispose() }

View File

@@ -106,6 +106,10 @@ interface ListMetrics {
overflows: boolean
/** Border-box width minus client width: the space the scrollbar takes out of the content area. */
band: number
/** Distance from the scrollbar's right edge to the sidebar edge. */
scrollbarEdgeOffset: number
/** Distance from the first row background's right edge to the sidebar edge. */
rowEdgeInset: number
/** Client-area right edge in viewport coordinates (`clientWidth` excludes the scrollbar band). */
clientRight: number
/** Border-box right edge in viewport coordinates. */
@@ -133,6 +137,8 @@ function measureList(page: Page): Promise<ListMetrics> {
if (list === null) throw new Error('sidebar session list not in the DOM')
const time = list.querySelector<HTMLElement>('[class*="time"]')
if (time === null) throw new Error('no row relative-time element in the sidebar list')
const row = list.querySelector<HTMLElement>('[role="treeitem"]')
if (row === null) throw new Error('no row in the sidebar list')
// Each indirection variable is resolved through its own throwaway probe
// appended to the list: `var()` substitution then happens where the list
// sits in the cascade, which is the claim, and `color` normalizes whatever
@@ -167,6 +173,9 @@ function measureList(page: Page): Promise<ListMetrics> {
const style = getComputedStyle(list)
const pseudoWidth = getComputedStyle(list, '::-webkit-scrollbar').width
const barWidth = pseudoWidth === 'auto' ? 15 : Number.parseFloat(pseudoWidth)
const listRect = list.getBoundingClientRect()
const sidebarEdge = list.parentElement?.getBoundingClientRect().right
if (sidebarEdge === undefined) throw new Error('sidebar session list has no layout parent')
return {
gutter: style.scrollbarGutter,
width: pseudoWidth,
@@ -177,9 +186,11 @@ function measureList(page: Page): Promise<ListMetrics> {
token: resolve('--dsh-scrollbar-thumb'),
hoverToken: resolve('--dsh-scrollbar-thumb-hover'),
overflows: list.scrollHeight > list.clientHeight,
band: list.getBoundingClientRect().width - list.clientWidth,
clientRight: list.getBoundingClientRect().left + list.clientWidth,
borderRight: list.getBoundingClientRect().right,
band: listRect.width - list.clientWidth,
scrollbarEdgeOffset: sidebarEdge - listRect.right,
rowEdgeInset: sidebarEdge - row.getBoundingClientRect().right,
clientRight: listRect.left + list.clientWidth,
borderRight: listRect.right,
timeRight: time.getBoundingClientRect().right,
// The bar is drawn in the rightmost `barWidth` of the border box, whether
// or not that space was reserved. Its width comes from the sheet where the
@@ -188,7 +199,28 @@ function measureList(page: Page): Promise<ListMetrics> {
// absent. Taking the UA width as the fallback is what keeps the assertion
// honest: assuming 0 there would report no occlusion precisely in the
// state that has it.
timeCoveredBy: Math.max(0, time.getBoundingClientRect().right - (list.getBoundingClientRect().right - barWidth)),
timeCoveredBy: Math.max(0, time.getBoundingClientRect().right - (listRect.right - barWidth)),
}
})
}
/**
* Measure only overflow and row inset, which remain observable when every
* session is hidden under a collapsed workspace group.
* @param page - the page under test.
* @returns the list overflow state and first row's trailing inset.
*/
function measureRowInset(page: Page): Promise<Pick<ListMetrics, 'overflows' | 'rowEdgeInset'>> {
return page.evaluate(() => {
const list = document.querySelector<HTMLElement>('[role="tree"][aria-label="Sessions"]')
if (list === null) throw new Error('sidebar session list not in the DOM')
const row = list.querySelector<HTMLElement>('[role="treeitem"]')
if (row === null) throw new Error('no row in the sidebar list')
const sidebarEdge = list.parentElement?.getBoundingClientRect().right
if (sidebarEdge === undefined) throw new Error('sidebar session list has no layout parent')
return {
overflows: list.scrollHeight > list.clientHeight,
rowEdgeInset: sidebarEdge - row.getBoundingClientRect().right,
}
})
}
@@ -222,6 +254,8 @@ function renderGeometry(light: ListMetrics, dark: ListMetrics): string {
`- --dsh-scrollbar-thumb-hover: ${metrics.hoverToken}`,
`- list overflows: ${String(metrics.overflows)}`,
`- reserved band: ${String(metrics.band)}px`,
`- scrollbar inset from the sidebar edge: ${String(metrics.scrollbarEdgeOffset)}px`,
`- row background inset from the sidebar edge: ${String(metrics.rowEdgeInset)}px`,
`- relative time covered by the bar: ${String(metrics.timeCoveredBy)}px`,
`- relative time ends inside the content area: ${String(metrics.timeRight <= metrics.clientRight)}`,
`- content area ends before the border box: ${String(metrics.clientRight < metrics.borderRight)}`,
@@ -299,6 +333,8 @@ describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thum
// drawn over it. Removing the declaration makes it exactly 0. The value
// itself is not pinned — it tracks `scrollbar-width` and the platform.
expect(metrics.band).toBeGreaterThan(0)
expect(metrics.scrollbarEdgeOffset).toBe(2)
expect(metrics.rowEdgeInset).toBe(12)
// The reported symptom, stated directly: no part of the row's relative time
// lies under the bar. Measures 7 on clean master — the `h` of `1h` is the
// covered part. Unlike the client-edge comparison below it does not go
@@ -317,6 +353,20 @@ describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thum
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('keeps the row background inset when overflow disappears', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-scrollbar-stable-inset'))
expect(await measureRowInset(page)).toEqual({ overflows: true, rowEdgeInset: 12 })
const bucket = page.getByText('Ungrouped', { exact: true }).locator('..').locator('..')
await bucket.click()
try {
await expect.poll(async () => (await measureRowInset(page)).overflows, { timeout: 10_000 }).toBe(false)
expect(await measureRowInset(page)).toEqual({ overflows: false, rowEdgeInset: 12 })
} finally {
await expandSeededSessions(page)
}
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('renders the themed thumb through the WebKit path in both palettes', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-scrollbar-theme'))
const light = await measureList(page)

View File

@@ -12,6 +12,8 @@
- --dsh-scrollbar-thumb-hover: rgb(212, 212, 212)
- list overflows: true
- reserved band: 8px
- scrollbar inset from the sidebar edge: 2px
- row background inset from the sidebar edge: 12px
- relative time covered by the bar: 0px
- relative time ends inside the content area: true
- content area ends before the border box: true
@@ -28,6 +30,8 @@
- --dsh-scrollbar-thumb-hover: rgb(84, 85, 87)
- list overflows: true
- reserved band: 8px
- scrollbar inset from the sidebar edge: 2px
- row background inset from the sidebar edge: 12px
- relative time covered by the bar: 0px
- relative time ends inside the content area: true
- content area ends before the border box: true

View File

@@ -56,7 +56,12 @@
"tests/goal-bar.e2e.ts",
"tests/startup-auto-selection.e2e.ts",
"tests/subagent-conversation.e2e.ts",
"tests/bash-abort-row.e2e.ts"
"tests/bash-abort-row.e2e.ts",
"tests/chat-scroll-fixture.ts",
"tests/chat-scroll-contract.e2e.ts",
"tests/chat-long-interactions.e2e.ts",
"tests/chat-continuous-conversation.e2e.ts",
"tests/complex-history.perf.ts"
],
"references": [
{

View File

@@ -20,6 +20,9 @@ function rejectStandaloneServe(): Plugin {
export default defineConfig({
plugins: [rejectStandaloneServe(), react()],
build: {
sourcemap: true,
},
resolve: {
// Workspace packages resolve to SOURCE: package.json exports point at lib
// for Node/type consumers, but the browser bundle must compile src directly

View File

@@ -1219,10 +1219,9 @@ Requires: `sessions`
```ts config-catalog
/**
* Plugin configuration: two verbatim SDK option shapes plus nothing else.
* `exporter.url` is the one field this package validates itself — required,
* no default, must parse as an `http(s)` URL — because a missing endpoint
* must fail at plugin load, not at first export.
* Plugin configuration: two verbatim SDK option shapes plus one DSH-owned
* shutdown bound. The package validates its endpoint and shutdown deadline
* because both must fail at plugin load rather than at first export or exit.
*/
export interface Config {
/**
@@ -1240,6 +1239,8 @@ export interface Config {
* which this plugin fills); the SDK owns and documents these knobs.
*/
processor?: Omit<BatchLogRecordProcessorOptions, 'exporter'>
/** Maximum time spent awaiting the SDK provider's complete shutdown path. */
shutdownTimeoutMillis?: number
}
```

View File

@@ -587,6 +587,7 @@
"apps/web": {
"entry": [
"tests/**/*.e2e.ts",
"tests/**/*.perf.ts",
"tests/**/*.snapshot.ts",
"tests/support.ts",
"src/node-module-stub.ts"

View File

@@ -33,6 +33,9 @@
"test:web": "npm run build && npm run test:web:built",
"test:web:refresh": "npm run build && DSH_SNAPSHOT=refresh vitest run --config vitest.web.config.ts",
"test:web:built": "vitest run --config vitest.web.config.ts",
"test:web:perf": "npm run build && npm run test:web:perf:built",
"test:web:perf:built": "DSH_SNAPSHOT=replay vitest run --config vitest.web.perf.config.ts",
"test:web:stress": "npm run build && vitest run --config vitest.web-stress.config.ts",
"test:gui": "vitest run packages/client packages/host",
"check:all": "tsx scripts/run-gates.ts check-all",
"check:ci": "tsx scripts/run-gates.ts ci-primary",

View File

@@ -51,7 +51,7 @@ Non-negotiables across the layers:
- **Business data lives in the object layer, never a store.** Entry-declared stores carry shared viewing/interaction state (selection, drafts, panel widths); sessions, frames, and connections stay in the object layer.
- **rpcId is strictly bidirectional**: the initiator mints, the responder echoes; business signatures see only `RpcRequest<P>`, minting stays in the carrier layer ([layering and RPC protocol note](../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)).
- **Notifier dual-channel discipline**: `notifyNow` only as the direct echo of a user gesture; frame-driven updates always go through `markDirty` (microtask-batched). See `runtime/src/client/sessions/notifier.ts`.
- **Notifier publication discipline**: `notifyNow` is only the direct echo of a user gesture; structural updates use microtask-batched `markDirty`, while visible streaming chunks use cumulative `markFrameDirty`. See `runtime/src/client/sessions/notifier.ts`.
- **The web layer is pure presentation.** Nothing that is "how to draw" (tool-card views, queue states) enters the session log; the host computes such data per frame or pushes it live, and replay recomputes it — falling back to the generic form when it can't. A new *model-visible* input still requires a session event (repo-wide rule).
## Directory regime (plugin packages)

View File

@@ -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/README.md
README.md: b111d67fa49e06227e324a33bd53417ad28c3a5b
README.zh.md: b498008eb82f6ab357718f2af761f38e51140ef8
README.md: 31d884c04a8b0233713b77b82b3d9cc7052003ca
README.zh.md: 9c95db529306519136d6d68758889350e5dd65e4

View File

@@ -11,7 +11,7 @@ The browser side of the dsh web GUI: shell kernel, module system, wire consumer,
| `web-react/` | Shell-side React glue: `createSlotRenderer` + `SessionProvider` render seats | (renderer install) |
| `connection/` | Wire consumer both ends: browser `ctx.connection` (shared api client + stream loop) and the node half mounting the `/api` route with its browser-trust fence | `ctx.connection` |
| `runtime/` | Client cordis boot and React-free object services: slots, Sessions, Workspaces, per-session bindings | `ctx.slots` `ctx.sessions` `ctx.workspaces` |
| `hmr/` | Dev-only hot reload for fetch-arrival client plugins (`--dev` graphs) | (dev entry) |
| `hmr/` | Dev-only hot reload for script-loaded client plugins (`--dev` graphs) | (dev entry) |
| `locale/` | Browser locale preference (`zh`/`en`) plus the ns×locale dictionary registry | `ctx.locale` |
| `ui-slots/` | Slot registry pure core: SlotMap merging, single `register` API, the four-share props family | (types + core) |
| `ui-theme/` | Theme preference over the `--dsw-*` token stylesheets (`light`/`dark`/`system`) | `ctx.theme` |

View File

@@ -11,7 +11,7 @@ dsh web GUI 的浏览器侧shell 内核、模块系统、协议消费层、
| `web-react/` | shell 侧 React 胶水:`createSlotRenderer` + `SessionProvider` 渲染座位 | (渲染器安装) |
| `connection/` | 协议两端的消费者:浏览器侧 `ctx.connection`(共享 api 客户端 + 流循环node 半侧挂载带浏览器信任栅栏的 `/api` 路由 | `ctx.connection` |
| `runtime/` | 客户端 cordis 启动与无 React 对象服务slots、Session、Workspace、逐会话绑定 | `ctx.slots` `ctx.sessions` `ctx.workspaces` |
| `hmr/` | 仅开发用的 fetch 到达型客户端插件热重载(`--dev` 图) | (开发条目) |
| `hmr/` | 仅开发用的外部脚本加载型客户端插件热重载(`--dev` 图) | (开发条目) |
| `locale/` | 浏览器语言偏好(`zh``en`)与 ns×locale 词典注册表 | `ctx.locale` |
| `ui-slots/` | slot 注册表纯核心SlotMap 合并、单一 `register` API、四份额 props 族 | (类型 + 核心) |
| `ui-theme/` | 基于 `--dsw-*` token 样式表的主题偏好(`light``dark``system` | `ctx.theme` |

View File

@@ -1179,6 +1179,16 @@ interface StreamConn<F> {
push(envelope: RpcRequest<F>): void
}
interface ReasoningChunkStormState {
sessionId: string
chunkCount: number
chunksPerInterval: number
intervalMs: number
emitted: number
marker: string
emitting: boolean
}
/** Deterministic fixture branches used by keyless Web assembly tests. */
export interface FixtureOptions {
/** Start with no real Workspace or Session. */
@@ -1461,6 +1471,8 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
const streamBreakers = new Set<() => void>()
/** Retry scenarios opened by timing hooks and completed in a later browser assertion phase. */
const retryScenarios = new Map<SessionId, { turn: number; stepStarted: boolean }>()
/** The single opt-in browser stress producer; normal fixture journeys never start it. */
let activeReasoningChunkStorm: ReasoningChunkStormState | null = null
// Timing-acceptance hooks (browser test backdoor): the in-memory fixture is ideally timed, which
// is exactly what masked the open-window and reconnect-gap bugs (audit S1/S3). These let
@@ -1484,6 +1496,86 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
const messageSeqs = log.filter(event => event.type === 'user/message').map(event => event.seq)
append(sid(id), { type: 'session/title', data: { title, messageSeqs, source: { kind: 'provider', provider: 'fixture' } } })
},
/** Start an externally paced reasoning stream for the opt-in browser stress lane. */
startReasoningChunkStorm(
id: string,
chunkCount: number,
chunksPerInterval: number,
intervalMs: number,
): string {
if (!Number.isSafeInteger(chunkCount) || chunkCount < 1) {
throw new Error('fixture: reasoning chunk count must be a positive safe integer')
}
if (!Number.isSafeInteger(chunksPerInterval) || chunksPerInterval < 1) {
throw new Error('fixture: reasoning chunks per interval must be a positive safe integer')
}
if (!Number.isSafeInteger(intervalMs) || intervalMs < 1) {
throw new Error('fixture: reasoning interval must be a positive safe integer')
}
if (activeReasoningChunkStorm?.emitting === true) {
throw new Error('fixture: reasoning chunk storm already running')
}
const sessionId = sid(id)
const log = logOf(sessionId)
let turn = nextTurn.get(sessionId) ?? 0
for (const event of log) {
const candidate = (event as unknown as { data?: { turn?: unknown } }).data?.turn
if (typeof candidate === 'number') turn = Math.max(turn, candidate + 1)
}
nextTurn.set(sessionId, turn + 1)
const marker = `REASONING_STRESS_COMPLETE:${String(turn)}:${String(chunkCount)}`
const state: ReasoningChunkStormState = {
sessionId: id,
chunkCount,
chunksPerInterval,
intervalMs,
emitted: 0,
marker,
emitting: true,
}
activeReasoningChunkStorm = state
setRunning(sessionId, true)
append(sessionId, { type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
append(sessionId, {
type: 'user/message', surfaceOp: 'append',
data: userMessage(text(`Reasoning chunk stress: ${String(chunkCount)} chunks.`)),
})
append(sessionId, { type: 'step/start', data: { turn, step: 0 } })
append(sessionId, {
type: 'assistant/chunk',
data: { turn, step: 0, chunk: { type: 'block-start', index: 0, blockType: 'reasoning' } },
})
const startedAt = Date.now()
const pump = (): void => {
const elapsedIntervals = Math.floor((Date.now() - startedAt) / intervalMs) + 1
const due = Math.max(state.emitted + chunksPerInterval, elapsedIntervals * chunksPerInterval)
const end = Math.min(due, chunkCount)
for (let index = state.emitted; index < end; index++) {
const chunkText = index === chunkCount - 1
? `\n${marker}`
: index % 64 === 63 ? '推理\n' : '推理'
append(sessionId, {
type: 'assistant/chunk',
data: { turn, step: 0, chunk: { type: 'reasoning-delta', index: 0, text: chunkText } },
})
}
state.emitted = end
if (end < chunkCount) {
setTimeout(pump, intervalMs)
} else {
state.emitting = false
}
}
setTimeout(pump, 0)
return marker
},
/** Return a copy so browser probes cannot mutate the active producer. */
reasoningChunkStormState(): ReasoningChunkStormState | null {
return activeReasoningChunkStorm === null ? null : { ...activeReasoningChunkStorm }
},
/** Open one failed model step whose partial remains visible until llm/retry arrives. */
beginModelRetry(id: string): void {
const sessionId = sid(id)

View File

@@ -19,6 +19,16 @@ interface TimingHooks {
failNextHistory(): void
appendUser(id: string, msg: string): void
appendTitle(id: string, title: string): void
startReasoningChunkStorm(id: string, chunkCount: number, chunksPerInterval: number, intervalMs: number): string
reasoningChunkStormState(): {
sessionId: string
chunkCount: number
chunksPerInterval: number
intervalMs: number
emitted: number
marker: string
emitting: boolean
} | null
beginModelRetry(id: string): void
scheduleModelRetry(id: string, retry?: number, delayMs?: number): void
cancelModelRetryDuringBackoff(id: string, delayMs?: number): void
@@ -873,6 +883,50 @@ describe('createFixtureApi', () => {
expect(abort.signal.aborted).toBe(false)
expect(habort.signal.aborted).toBe(false)
})
it('paces the opt-in reasoning stress hook from an external interval', async () => {
vi.useFakeTimers()
vi.setSystemTime(0)
const api = createFixtureApi()
const hooks = timing()
expect(hooks.reasoningChunkStormState()).toBeNull()
expect(() => hooks.startReasoningChunkStorm('fx-alpha', 0, 1, 16)).toThrow(/chunk count/)
expect(() => hooks.startReasoningChunkStorm('fx-alpha', 1, 0, 16)).toThrow(/chunks per interval/)
expect(() => hooks.startReasoningChunkStorm('fx-alpha', 1, 1, 0)).toThrow(/reasoning interval/)
const abort = new AbortController()
try {
const streamed = collect(api.events.mux(req({}), abort.signal), abort, frames => frames.some(frame => (
frame.type === 'session/event'
&& frame.event.type === 'assistant/chunk'
&& frame.event.data.chunk.type === 'reasoning-delta'
&& frame.event.data.chunk.text.includes('REASONING_STRESS_COMPLETE')
)))
const marker = hooks.startReasoningChunkStorm('fx-alpha', 3, 2, 16)
expect(() => hooks.startReasoningChunkStorm('fx-alpha', 1, 1, 16)).toThrow(/already running/)
expect(hooks.reasoningChunkStormState()).toMatchObject({ emitted: 0, emitting: true, marker })
await vi.advanceTimersByTimeAsync(0)
expect(hooks.reasoningChunkStormState()).toMatchObject({ emitted: 2, emitting: true })
await vi.advanceTimersByTimeAsync(16)
expect(hooks.reasoningChunkStormState()).toEqual({
sessionId: 'fx-alpha', chunkCount: 3, chunksPerInterval: 2, intervalMs: 16,
emitted: 3, marker, emitting: false,
})
const frames = await streamed
const deltas = frames.flatMap(frame => (
frame.type === 'session/event'
&& frame.event.type === 'assistant/chunk'
&& frame.event.data.chunk.type === 'reasoning-delta'
? [frame.event.data.chunk.text]
: []
))
expect(deltas).toEqual(['推理', '推理', `\n${marker}`])
} finally {
abort.abort()
vi.useRealTimers()
}
})
})
describe('FixtureApiClient (protocol-level fake carrier)', () => {

View File

@@ -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/hmr/README.md
README.md: 2b2f63c25cbf3a46babef78a4dfb52f859156887
README.zh.md: 58fbad900d9ab86a9d28979f691f24de29e9b6f4
README.md: f91a6c6f685c88a1ea19312985ad3e933222192a
README.zh.md: 1d20a22d211c13d62089fb5618f40636ab7ae60a

View File

@@ -2,9 +2,9 @@
English | [中文](README.zh.md)
Hot reload for fetch-arrival client plugins. A static-arrival entry composed only into `--dev` graphs (`dsh web --dev`); production graphs omit the row, so the shell-bundled code stays inert.
Hot reload for script-loaded client plugins. A static-arrival entry composed only into `--dev` graphs (`dsh web --dev`); production graphs omit the row, so the shell-bundled code stays inert.
The browser half subscribes to the system SSE channel (`GET /plugins/events`) and reloads one plugin per `rebuilt` frame, serialized through a queue (the bundle handoff slot is single). The sequence per frame — `prefetch` (fetch the new bundle before touching anything), `invalidate`, `registry.delete` (before the fiber: a bare fiber dispose trips the vendored Loader's self-dispose branch, which would mark the entry disabled), drain the old fiber, delete `entry.fiber`, remove owned `<style data-plugin>` tags, `entry.refresh()` re-imports and remounts, `fiber.await()` rethrows startup failures loud. Dependents reload through cordis itself: a fiber's activation epoch strings its service providers' uids, so replacing a provider's fiber cascades every dependent with zero client-side graph analysis. The node half detects rebuilds with one interval that stat-polls each graph bundle from a synchronous baseline, immediately re-hashes after adding a row, retains missing rows as dirty, and broadcasts only real rev changes; any tsdown watch process producing the bundle therefore triggers HMR with no builder→host channel.
The browser half subscribes to the system SSE channel (`GET /plugins/events`) and reloads one plugin per `rebuilt` frame through a serialized queue. The sequence per frame — `invalidate`, `prefetch` (load and register the new bundle while the old fiber still serves), `registry.delete` (before the fiber: a bare fiber dispose trips the vendored Loader's self-dispose branch, which would mark the entry disabled), drain the old fiber, delete `entry.fiber`, remove owned `<style data-plugin>` tags, `entry.refresh()` re-imports and remounts, `fiber.await()` rethrows startup failures loud. Dependents reload through cordis itself: a fiber's activation epoch strings its service providers' uids, so replacing a provider's fiber cascades every dependent with zero client-side graph analysis. The node half detects rebuilds with one interval that stat-polls each graph bundle from a synchronous baseline, immediately re-hashes after adding a row, retains missing rows as dirty, and broadcasts only real rev changes; any tsdown watch process producing the bundle therefore triggers HMR with no builder→host channel.
## Model Experience

View File

@@ -2,9 +2,9 @@
[English](README.md) | 中文
为通过 fetch 加载的客户端插件提供热重载。该静态加载配置项只组合进 `--dev` 图(`dsh web --dev`);生产图省略该项,因此打包进 shell 的代码保持不活动。
为通过外部脚本加载的客户端插件提供热重载。该静态加载配置项只组合进 `--dev` 图(`dsh web --dev`);生产图省略该项,因此打包进 shell 的代码保持不活动。
浏览器侧订阅系统 SSEServer-Sent Events通道`GET /plugins/events`),每个 `rebuilt` 帧重载一个插件,并通过队列串行执行(组合包交接 slot 只能容纳一个)。每帧的顺序是:`prefetch`(在触碰任何内容前抓取新组合包)、`invalidate``registry.delete`(在 fiber dispose资源释放之前执行仅 dispose fiber 会触发 vendored Loader 的 self-dispose 分支,把配置项标为禁用)、排空旧 fiber、删除 `entry.fiber`、移除自身拥有的 `<style data-plugin>` 标签、通过 `entry.refresh()` 重新导入并挂载、通过 `fiber.await()` 直接重新抛出启动失败。依赖方由 Cordis 自身重载fiber 的激活 epoch 会串联其服务提供方的 uid因此替换提供方 fiber 会级联所有依赖方无需客户端图分析。node 侧使用一个 interval 检测重建:从同步基线开始 stat-poll 每个图组合包;新增一行后立即重新计算 hash缺失行保持 dirty只广播真实 rev 变更。因此,任何生成组合包的 tsdown watch 进程都能触发 HMR热模块替换无需 builder→host 通道。
浏览器侧订阅系统 SSEServer-Sent Events通道`GET /plugins/events`),每个 `rebuilt` 帧重载一个插件,并通过队列串行执行。每帧的顺序是:`invalidate``prefetch`(旧 fiber 仍在服务时加载并注册新组合包)`registry.delete`(在 fiber dispose资源释放之前执行仅 dispose fiber 会触发 vendored Loader 的 self-dispose 分支,把配置项标为禁用)、排空旧 fiber、删除 `entry.fiber`、移除自身拥有的 `<style data-plugin>` 标签、通过 `entry.refresh()` 重新导入并挂载、通过 `fiber.await()` 直接重新抛出启动失败。依赖方由 Cordis 自身重载fiber 的激活 epoch 会串联其服务提供方的 uid因此替换提供方 fiber 会级联所有依赖方无需客户端图分析。node 侧使用一个 interval 检测重建:从同步基线开始 stat-poll 每个图组合包;新增一行后立即重新计算 hash缺失行保持 dirty只广播真实 rev 变更。因此,任何生成组合包的 tsdown watch 进程都能触发 HMR热模块替换无需 builder→host 通道。
## 模型体验

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-client-hmr",
"description": "Dev-only hot-reload driver for fetch-arrival client entries: SSE rebuilt frames → prefetch/invalidate → fiber swap through the vendored Loader entry",
"description": "Dev-only hot-reload driver for script-loaded client entries: SSE rebuilt frames → invalidate/prefetch → fiber swap through the vendored Loader entry",
"version": "0.0.1",
"private": true,
"type": "module",

View File

@@ -2,7 +2,7 @@
* client-hmr, browser half: hot-reload driver for client plugin entries.
*
* Listens on the host's system SSE channel (`GET /plugins/events`); on a
* `rebuilt` frame it re-fetches the entry's bundle and swaps the cordis
* `rebuilt` frame it reloads the entry's bundle and swaps the cordis
* fiber in place. Every graph entry is a plugin bundle under the web2 model
* — `immediately` rows differ only in stage-one prefetch (a boot
* optimization), so all rostered plugin packages share these reload semantics;
@@ -14,7 +14,7 @@
* cascades into its UI dependents with no HMR-side bookkeeping.
*
* Reload order (lazy CJS table): invalidate (drop the stale factory and
* materialized record) → prefetch (fetch + execute + register the fresh
* materialized record) → prefetch (load and register the fresh
* factory) → registry-first teardown → drain old fiber unload → remove
* owned `<style data-plugin>` tags → `entry.refresh()` materializes the new
* factory. Invalidate MUST precede prefetch: a live factory makes prefetch
@@ -110,7 +110,7 @@ export function apply(ctx: Context): void {
}
// Invalidate first (drop stale factory + record — a live factory makes
// prefetch a no-op and re-registration a loud duplicate), then run the
// async half while the old fiber still serves: fetch + execute registers
// async half while the old fiber still serves: script loading registers
// the fresh factory with zero side effects (lazy CJS — module bodies run
// at materialization, not execution).
modLoader.invalidate(id)

View File

@@ -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: 99565b349d782c58752ac3e73ce7c0be527f78a8
README.zh.md: a8ed0a4949ccefce53933b4f2fb8f51f5291684f
README.md: 7d661c806955d0fac021dd6620994aab83c0f773
README.zh.md: a1da42a552dbe8770fcb78bf458c01a0057ce8dd

View File

@@ -6,9 +6,9 @@ Client module system: the browser peer of Node's internal ESM loader, built as a
Lazy CJS model (web2): executing a plugin bundle only REGISTERS its factory (`window.__ModuleLoader__.load({id, factory})`); every module body side effect — CSS injection included — lives in the factory closure and runs at materialization (`factory(require)` → export surface, memoized in `loadCache`), not at script execution. A factory that requires another registered-but-unmaterialized module materializes it recursively, so load order needs no external sequencing; require cycles throw (factory-form CJS cannot deliver partial exports). `<id>/client` and the bare id name the same surface (a plugin bundle IS its package's client half).
Resolution branch order (`import(specifier)`): platform seed word → shell instance; memoized record → surface; shell-own static registry (`registerStatic`, app-shell) → module; registered factory → materialize; graph row (`window.__DSH_BOOT__`) → fetch + execute + materialize; anything else throws — the runtime mirror of the build-time bundle purity gate. The synchronous `require` handed to factories walks the same order minus the fetch branch and records observed edges into the module record. `prefetch` is the stage-one arrival hook (fetch + execute, registration only; concurrent calls share one in-flight task); `invalidate` drops the factory and the materialized record so the next prefetch/import refetches (the HMR hook).
Resolution branch order (`import(specifier)`): platform seed word → shell instance; memoized record → surface; shell-own static registry (`registerStatic`, app-shell) → module; registered factory → materialize; graph row (`window.__DSH_BOOT__`) → load its external classic script + materialize; anything else throws — the runtime mirror of the build-time bundle purity gate. The synchronous `require` handed to factories walks the same order minus the asynchronous load branch and records observed edges into the module record. `prefetch` is the stage-one arrival hook (script load and factory registration only; concurrent calls share one in-flight task); `invalidate` drops the factory and materialized record so the next prefetch/import reloads the script (the HMR hook).
The Node half scans enabled Loader entries for web `dshClient` packages, resolves each `exports["./client"]`, hashes the built bundle into the boot graph, and serves it under `/plugins`. Source launch maps host imports to TypeScript source but still consumes this built client export; missing files share one build instruction followed by a package/path list, while unrelated filesystem errors remain separate failures.
The Node half scans enabled Loader entries for web `dshClient` packages, resolves each `exports["./client"]`, hashes the built bundle into the boot graph, and serves it with its source map under `/plugins`. Source launch maps host imports to TypeScript source but still consumes this built client export; missing files share one build instruction followed by a package/path list, while unrelated filesystem errors remain separate failures.
## Model Experience

View File

@@ -6,9 +6,9 @@
惰性 CJS 模型web2执行插件组合包只会注册其 factory`window.__ModuleLoader__.load({id, factory})`);每个模块主体的副作用(包括 CSS 注入)都位于 factory 闭包中,在物化时运行(`factory(require)` → 导出表层,并在 `loadCache` 中记忆化),不会在脚本执行时运行。如果 factory 依赖另一个已注册但尚未物化的模块系统会递归物化它因此加载顺序无需外部编排require 循环会抛出异常factory 形式的 CJS 无法提供部分导出)。`<id>/client` 与裸 id 指向同一表层(一个插件组合包就是其包的客户端侧)。
解析分支顺序(`import(specifier)`):平台种子词 → 外壳实例;记忆化记录 → 表层;外壳自身的静态注册表(`registerStatic`app-shell→ 模块;已注册 factory → 物化;模块图记录(`window.__DSH_BOOT__`)→ 抓取 + 执行 + 物化;其他情况一律抛出异常。这是构建时组合包纯度门禁的运行时镜像。交给 factory 的同步 `require` 采用相同顺序,但不含抓取分支,并把观察到的边记录到模块记录中。`prefetch` 是第一阶段加载钩子(抓取 + 执行,只注册;并发调用共享一个进行中的任务);`invalidate` 会丢弃 factory 与物化记录,使下一次 prefetch/import 重新抓取;它是 HMR热模块替换钩子。
解析分支顺序(`import(specifier)`):平台种子词 → 外壳实例;记忆化记录 → 表层;外壳自身的静态注册表(`registerStatic`app-shell→ 模块;已注册 factory → 物化;模块图记录(`window.__DSH_BOOT__`)→ 加载外部 classic script + 物化;其他情况一律抛出异常。这是构建时组合包纯度门禁的运行时镜像。交给 factory 的同步 `require` 采用相同顺序,但不含异步加载分支,并把观察到的边记录到模块记录中。`prefetch` 是第一阶段到达钩子(只加载脚本并注册 factory;并发调用共享一个进行中的任务);`invalidate` 会丢弃 factory 与物化记录,使下一次 prefetch/import 重新加载脚本;它是 HMR热模块替换钩子。
Node 侧会扫描已启用的 Loader 配置项以发现 web `dshClient` 包,解析每个 `exports["./client"]`,把构建后的组合包哈希写入启动图,并通过 `/plugins` 提供该文件。源码启动会把宿主侧导入映射到 TypeScript 源码,但仍消费客户端导出的构建产物;缺失文件共享一条构建要求,随后以 package/path list 列出各项,而无关的文件系统错误仍是独立故障。
Node 侧会扫描已启用的 Loader 配置项以发现 web `dshClient` 包,解析每个 `exports["./client"]`,把构建后的组合包哈希写入启动图,并通过 `/plugins` 提供该文件及其 sourcemap。源码启动会把宿主侧导入映射到 TypeScript 源码,但仍消费客户端导出的构建产物;缺失文件共享一条构建要求,随后以 package/path list 列出各项,而无关的文件系统错误仍是独立故障。
## 模型体验

View File

@@ -17,11 +17,11 @@
*
* Resolution branch order (import): seed word → shell instance; memoized
* record → surface; static registry (shell-own modules, e.g. app-shell) →
* module; registered factory → materialize; graph row → fetch + execute +
* materialize; anything else → throw (loud — the runtime mirror of the
* module; registered factory → materialize; graph row → load + materialize;
* anything else → throw (loud — the runtime mirror of the
* build-time bundle purity gate). The synchronous `require` handed to
* factories walks the same order minus the fetch branch: fetching is async,
* so only already-executed bundles can be required — and cross-plugin value
* factories walks the same order minus the load branch: loading is async,
* so only already-registered bundles can be required — and cross-plugin value
* imports are a build error anyway.
*
* This file is the browser-safe contract face (zero node imports): the
@@ -56,7 +56,7 @@ export interface WebBootEntry {
rev: string
/** Package-name dependency edges, informational (preflight display / HMR diffing). */
inject?: string[]
/** Stage-one prefetch mark: fetch + execute (factory registration) during module-face boot. */
/** Stage-one prefetch mark: load the script for factory registration during module-face boot. */
immediately?: boolean
}
@@ -210,18 +210,17 @@ export interface ClientModuleLoader {
*/
registerStatic(id: string, module: unknown): void
/**
* Stage-one arrival: fetch the entry's bundle and execute it, registering
* its factory (no materialization — module side effects wait for import).
* Stage-one arrival: load the entry's script to register its factory (no
* materialization — module side effects wait for import).
* No-op for static-registered ids and ids whose factory is already
* registered; concurrent calls share one in-flight task. To force a fresh
* fetch (HMR), {@link invalidate} first.
* load (HMR), {@link invalidate} first.
* @param id - graph entry name.
*/
prefetch(id: string): Promise<void>
/**
* Full reset of one module: drop its registered factory, its materialized
* record, and any consumed bundle text, so the next prefetch/import
* refetches and re-executes (the HMR invalidation hook).
* Full reset of one module: drop its registered factory and materialized
* record so the next prefetch/import reloads it (the HMR invalidation hook).
* @param id - entry name to invalidate.
*/
invalidate(id: string): void
@@ -233,11 +232,6 @@ export interface ClientModuleSystemOptions {
modules: BootModuleRow[]
/** Module-table seed: platform-singleton specifier → shell instance. */
staticModules: Record<string, unknown>
/** Bundle fetch seam (parallelizable half). Defaults to same-origin fetch().text(). */
fetchBundle?: (url: string) => Promise<string>
/**
* Bundle execution seam (synchronously performs the load() registration).
* Defaults to a <script> element carrying the code.
*/
executeBundle?: (code: string, url: string) => void
/** Bundle-load seam. Defaults to a same-origin classic `<script src>` element. */
loadBundle?: (url: string) => Promise<void>
}

View File

@@ -2,38 +2,28 @@
* ClientModuleSystem — the implementation behind the {@link ClientModuleLoader}
* seam. The conceptual contract (lazy CJS model, resolution branch order) is
* documented on the public interfaces in `./manifest.ts`; this file owns the
* state tables and the fetch/execute/materialize machinery.
* state tables and the load/materialize machinery.
*/
import type {
BootModuleRow, ClientModuleLoader, ClientModuleRecord,
ClientModuleSystemOptions, ClientPluginHandoff, DshWindow,
} from './manifest.ts'
/** A registered-but-unmaterialized bundle: the factory plus its source URL (diagnostics). */
interface RegisteredFactory {
factory: ClientPluginHandoff['factory']
url: string
}
/** Default bundle fetch seam: same-origin fetch().text(). */
const defaultFetchBundle = async (url: string): Promise<string> => {
const res = await fetch(url)
if (!res.ok) throw new Error(`client-modules: bundle fetch ${url} answered ${String(res.status)}`)
return res.text()
}
/** Default bundle execution seam: a <script> element carrying the code. */
const defaultExecuteBundle = (code: string, url: string): void => {
/** Default bundle-load seam: same-origin external classic script. */
const defaultLoadBundle = (url: string): Promise<void> => new Promise((resolve, reject) => {
const el = document.createElement('script')
// Inline execution (not src) so the fetch half stays parallelizable; the
// sourceURL comment keeps devtools stack frames attributed to the bundle.
el.textContent = `${code}\n//# sourceURL=${url}`
document.head.appendChild(el)
// Execution is synchronous for inline scripts: the factory is registered by
// now, so the node (and its source text) has no further job. Removing it
// keeps repeated HMR rebuilds from accumulating dead script nodes.
el.remove()
}
el.async = true
el.src = url
el.addEventListener('load', () => {
el.remove()
resolve()
}, { once: true })
el.addEventListener('error', () => {
el.remove()
reject(new Error(`client-modules: bundle script ${url} failed to load`))
}, { once: true })
document.head.append(el)
})
/**
* A plugin bundle IS its package's client half: `<id>/client` (the exports
@@ -72,31 +62,21 @@ export class ClientModuleSystem implements ClientModuleLoader {
private readonly seed: Map<string, unknown>
private readonly statics = new Map<string, unknown>()
private readonly factories = new Map<string, RegisteredFactory>()
/** In-flight prefetch (fetch + execute) per id; concurrent callers share it. */
private readonly factories = new Map<string, ClientPluginHandoff['factory']>()
/** In-flight prefetch (script load) per id; concurrent callers share it. */
private readonly pendingArrival = new Map<string, Promise<void>>()
/** Materialization re-entrancy guard: factory-form CJS cannot deliver partial exports, so a cycle is fatal. */
private readonly materializing = new Set<string>()
private readonly graphRows = new Map<string, BootModuleRow>()
// Execution URL of the bundle currently being executed (bound into the
// factory registration so diagnostics can name the source).
private executingUrl = ''
// Graph id of the row currently being executed ('' outside arrive):
// the load sink cross-checks the handoff id against it so a mis-stamped
// bundle cannot register under another entry's identity.
private executingId = ''
private readonly fetchBundle: (url: string) => Promise<string>
private readonly executeBundle: (code: string, url: string) => void
private readonly loadBundle: (url: string) => Promise<void>
/**
* Build the module system over the parsed boot rows.
* @param options - module rows, module-table staticModules, fetch/execute seams.
* @param options - module rows, module-table staticModules, and bundle-load seam.
*/
constructor(options: ClientModuleSystemOptions) {
this.seed = new Map(Object.entries(options.staticModules))
this.fetchBundle = options.fetchBundle ?? defaultFetchBundle
this.executeBundle = options.executeBundle ?? defaultExecuteBundle
this.loadBundle = options.loadBundle ?? defaultLoadBundle
for (const row of options.modules) {
if (this.graphRows.has(row.id)) throw new Error(`client-modules: duplicate graph entry "${row.id}"`)
@@ -110,37 +90,22 @@ export class ClientModuleSystem implements ClientModuleLoader {
// Registration is keyed by the handoff id; a duplicate means a bundle
// executed twice without an invalidate — always a bug, always loud.
if (this.factories.has(handoff.id)) throw new Error(`client-modules: duplicate factory registration for "${handoff.id}" (bundle executed twice without invalidate?)`)
// A fetched row's bundle must register the id its row names — a
// mis-stamped bundle registering under another entry's identity
// would let that entry silently materialize foreign exports.
if (this.executingId !== '' && handoff.id !== this.executingId) {
throw new Error(`client-modules: bundle ${this.executingUrl} registered "${handoff.id}" while arriving for "${this.executingId}" (mis-stamped bundle id)`)
}
this.factories.set(handoff.id, { factory: handoff.factory, url: this.executingUrl })
this.factories.set(handoff.id, handoff.factory)
},
}
}
/** Fetch + execute one graph row so its factory is registered (idempotent per in-flight arrival). */
/** Load one graph row so its factory is registered (idempotent per in-flight arrival). */
private arrive(row: BootModuleRow): Promise<void> {
const { id, url } = row
const pending = this.pendingArrival.get(id)
if (pending !== undefined) return pending
if (this.factories.has(id)) return Promise.resolve()
const task = (async (): Promise<void> => {
const code = await this.fetchBundle(url)
this.executingUrl = url
this.executingId = id
try {
this.executeBundle(code, url)
} finally {
this.executingUrl = ''
this.executingId = ''
}
const task = this.loadBundle(url).then(() => {
if (!this.factories.has(id)) {
throw new Error(`client-modules: bundle ${url} executed without registering "${id}" via __ModuleLoader__.load`)
throw new Error(`client-modules: bundle ${url} loaded without registering "${id}" via __ModuleLoader__.load`)
}
})().finally(() => { this.pendingArrival.delete(id) })
}).finally(() => { this.pendingArrival.delete(id) })
this.pendingArrival.set(id, task)
return task
}
@@ -158,7 +123,7 @@ export class ClientModuleSystem implements ClientModuleLoader {
this.materializing.add(id)
try {
const edges = new Set<string>()
const surface = registered.factory(this.makeRequire(edges))
const surface = registered(this.makeRequire(edges))
const record: ClientModuleRecord = { id, surface, styles: claimStyles(id), edges }
this.loadCache.set(id, record)
return record

View File

@@ -2,8 +2,8 @@
* Node half of the client module system (dshClient dual-face package): scans
* the host Loader's entries for `dshClient` packages, composes the
* `window.__DSH_BOOT__` entry graph (wire single source: {@link WebBootEntry}
* in `./client/manifest.ts`), serves `/plugins/<id>/client.js`, taps the
* index render to inject the boot manifest, and provides the
* in `./client/manifest.ts`), serves `/plugins/<id>/client.js` and its source
* map, taps the index render to inject the boot manifest, and provides the
* `clientModuleHost` service (the HMR node half's registration/notification
* face).
*
@@ -424,9 +424,15 @@ export class ClientModuleHostService extends Service {
const pathname = decodeURIComponent(new URL(req.url ?? '/', 'http://x').pathname)
// The id may contain a scope slash. Anything else under /plugins (including
// /plugins/events when the HMR row is absent) is an unknown resource.
const path = pathname.startsWith('/plugins/') && pathname.endsWith('/client.js')
? this.clientPath(pathname.slice('/plugins/'.length, -'/client.js'.length))
const prefix = '/plugins/'
const mapSuffix = '/client.js.map'
const bundleSuffix = '/client.js'
const isSourceMap = pathname.startsWith(prefix) && pathname.endsWith(mapSuffix)
const suffix = isSourceMap ? mapSuffix : bundleSuffix
const clientPath = pathname.startsWith(prefix) && pathname.endsWith(suffix)
? this.clientPath(pathname.slice(prefix.length, -suffix.length))
: undefined
const path = clientPath === undefined ? undefined : `${clientPath}${isSourceMap ? '.map' : ''}`
if (path === undefined) {
res.writeHead(404)
res.end()
@@ -434,7 +440,10 @@ export class ClientModuleHostService extends Service {
}
try {
const body = await readFile(path)
res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-cache' })
res.writeHead(200, {
'content-type': isSourceMap ? 'application/json; charset=utf-8' : 'text/javascript; charset=utf-8',
'cache-control': 'no-cache',
})
res.end(body)
} catch {
// Registered but unreadable (bundle not built yet): loud 404 beats a silent SPA-fallback HTML page.

View File

@@ -4,7 +4,7 @@
* registers the factory), materialization on first import/require with
* memoization and recursive self-sequencing, the resolution branch order,
* shared in-flight arrival, invalidate-refetch (HMR), style claiming, the
* default transport seams, and the loud failure modes (duplicate
* default transport seam, and the loud failure modes (duplicate
* registration, cycles, table misses, double boot).
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -20,7 +20,6 @@ type Factory = ClientPluginHandoff['factory']
afterEach(() => {
vi.unstubAllGlobals()
delete win.__ModuleLoader__
delete (document as unknown as Record<string, unknown>).__realmBridge
for (const el of document.querySelectorAll('style, script')) el.remove()
})
@@ -33,9 +32,9 @@ interface Bench {
}
/**
* Loader over scripted bundles: fetch resolves to the row url (optionally
* gated on a release callback); execute registers the scripted factory
* through the window sink (`null` scripts a bundle that never calls load).
* Loader over scripted bundles: load records the row URL, optionally waits on
* a release callback, then registers the scripted factory through the window
* sink (`null` scripts a bundle that never calls load).
*/
function bench(
entries: BootModuleRow[],
@@ -47,15 +46,12 @@ function bench(
const loader = new ClientModuleSystem({
modules: entries,
staticModules: opts.seed ?? {},
fetchBundle: (url) => {
loadBundle: async (url) => {
fetched.push(url)
if (opts.gated?.includes(url) === true) {
return new Promise((resolve) => { gates.set(url, () => { resolve(url) }) })
await new Promise<void>((resolve) => { gates.set(url, resolve) })
}
return Promise.resolve(url)
},
executeBundle: (code) => {
const id = /\/plugins\/(.+)\/client\.js/.exec(code)?.[1]
const id = /\/plugins\/(.+)\/client\.js/.exec(url)?.[1]
const factory = id === undefined ? undefined : bundles[id]
if (factory == null || id === undefined) return
win.__ModuleLoader__?.load({ id, factory })
@@ -65,7 +61,7 @@ function bench(
}
describe('lazy CJS arrival', () => {
it('prefetch fetches and executes but does not run the factory', async () => {
it('prefetch loads and registers but does not run the factory', async () => {
const ran: string[] = []
const b = bench([row('a')], { a: () => { ran.push('a'); return {} } })
await b.loader.prefetch('a')
@@ -85,7 +81,7 @@ describe('lazy CJS arrival', () => {
expect(b.loader.loadCache.get('a')?.id).toBe('a')
})
it('import without prefetch fetches, executes, and materializes in one call', async () => {
it('import without prefetch loads, registers, and materializes in one call', async () => {
const b = bench([row('a')], { a: () => ({ marker: 'direct' }) })
const surface = await b.loader.import('a', '', {})
expect((surface as { marker: string }).marker).toBe('direct')
@@ -228,7 +224,7 @@ describe('failure modes', () => {
})
describe('HMR reset', () => {
it('invalidate drops the factory and record so the module refetches and re-registers', async () => {
it('invalidate drops the factory and record so the module reloads and re-registers', async () => {
let generation = 0
const b = bench([row('a')], { a: () => ({ generation: ++generation }) })
const first = await b.loader.import('a', '', {})
@@ -275,27 +271,35 @@ describe('style claiming', () => {
})
})
describe('default transport seams', () => {
it('fetches same-origin and executes through an inline script tag', async () => {
// In a browser the loader's globalThis IS the page window; vitest's jsdom
// evaluates <script> in a separate realm that shares only the document,
// so the fixture bundle restores the sink from a document bridge before
// using the normal calling convention.
const code = 'window.__ModuleLoader__ = document.__realmBridge;\n'
+ 'window.__ModuleLoader__.load({ id: "dee", factory: function () { return { marker: "via-script" } } })'
vi.stubGlobal('fetch', async () => ({ ok: true, text: async () => code }))
describe('default transport seam', () => {
it('loads through an external classic script and removes the settled node', async () => {
const append = vi.spyOn(document.head, 'append').mockImplementation((...nodes) => {
const script = nodes[0]
if (!(script instanceof HTMLScriptElement)) throw new Error('expected script node')
expect(script.async).toBe(true)
expect(script.getAttribute('src')).toBe('/plugins/dee/client.js?rev=0')
queueMicrotask(() => {
win.__ModuleLoader__?.load({ id: 'dee', factory: () => ({ marker: 'via-script' }) })
script.dispatchEvent(new Event('load'))
})
})
const loader: ClientModuleLoader = new ClientModuleSystem({ modules: [row('dee')], staticModules: {} })
;(document as unknown as Record<string, unknown>).__realmBridge = win.__ModuleLoader__
const surface = await loader.import('dee', '', {})
expect((surface as { marker: string }).marker).toBe('via-script')
// The script node is removed right after its synchronous execution —
// repeated HMR rebuilds must not accumulate dead script nodes.
expect(append).toHaveBeenCalledOnce()
expect([...document.querySelectorAll('script')]).toEqual([])
})
it('a non-ok bundle response is loud with the status', async () => {
vi.stubGlobal('fetch', async () => ({ ok: false, status: 404 }))
it('a script load failure is loud and removes the node', async () => {
vi.spyOn(document.head, 'append').mockImplementation((...nodes) => {
const script = nodes[0]
if (!(script instanceof HTMLScriptElement)) throw new Error('expected script node')
queueMicrotask(() => { script.dispatchEvent(new Event('error')) })
})
const loader = new ClientModuleSystem({ modules: [row('dee')], staticModules: {} })
await expect(loader.prefetch('dee')).rejects.toThrow('answered 404')
await expect(loader.prefetch('dee')).rejects.toThrow(
'bundle script /plugins/dee/client.js?rev=0 failed to load',
)
expect([...document.querySelectorAll('script')]).toEqual([])
})
})

View File

@@ -1,12 +1,13 @@
/** Node-half composition diagnostics for package metadata and built client bundles. */
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
import type { IncomingMessage, ServerResponse } from 'node:http'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { dirname, join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { Context } from 'cordis'
import { afterEach, describe, expect, it } from 'vitest'
import type { HttpServerService } from '@deepseek-ai/dsh-host-webserver'
import type { HttpServerService, WebRoute } from '@deepseek-ai/dsh-host-webserver'
import { ClientModuleHostService } from '../src/index.ts'
let root: string | undefined
@@ -33,8 +34,8 @@ function writePackage(packageName: string): string {
return clientPath
}
/** Construct the node-half service over the enabled fixture entries. */
function construct(packageNames: string[]): ClientModuleHostService {
/** Construct the node-half service and capture its plugin-bundle route. */
function constructWithRoute(packageNames: string[]): { service: ClientModuleHostService; route: WebRoute } {
const ctx = new Context()
ctx.baseUrl = pathToFileURL(root!).href + '/'
ctx.provide('loader', {
@@ -44,13 +45,24 @@ function construct(packageNames: string[]): ClientModuleHostService {
}
},
})
let route: WebRoute | undefined
const httpServer: Pick<HttpServerService, 'port' | 'register' | 'tapIndex'> = {
port: 0,
register: () => () => {},
register: (candidate) => {
if (candidate.path === '/plugins') route = candidate
return () => {}
},
tapIndex: () => () => {},
}
ctx.provide('httpServer', httpServer as HttpServerService)
return new ClientModuleHostService(ctx)
const service = new ClientModuleHostService(ctx)
if (route === undefined) throw new Error('client bundle route was not registered')
return { service, route }
}
/** Construct the node-half service over the enabled fixture entries. */
function construct(packageNames: string[]): ClientModuleHostService {
return constructWithRoute(packageNames).service
}
describe('client bundle activation', () => {
@@ -84,4 +96,40 @@ describe('client bundle activation', () => {
expect(String(thrown)).toContain('EISDIR')
expect(String(thrown)).not.toContain('pnpm run build')
})
it('serves the source map beside a registered client bundle', async () => {
const packageName = '@fixture/source-map'
const clientPath = writePackage(packageName)
mkdirSync(dirname(clientPath), { recursive: true })
writeFileSync(clientPath, 'module.exports = {}\n')
const map = '{"version":3,"sources":["src/client/index.tsx"]}\n'
writeFileSync(`${clientPath}.map`, map)
const { route } = constructWithRoute([packageName])
let status = 0
let headers: Record<string, string> | undefined
let body = ''
const response = {
writeHead(nextStatus: number, nextHeaders?: Record<string, string>) {
status = nextStatus
headers = nextHeaders
return response
},
end(chunk?: Uint8Array) {
body = chunk === undefined ? '' : Buffer.from(chunk).toString('utf8')
return response
},
} as unknown as ServerResponse
await route.handler({
method: 'GET',
url: `/plugins/${packageName}/client.js.map`,
} as IncomingMessage, response)
expect(status).toBe(200)
expect(headers).toEqual({
'content-type': 'application/json; charset=utf-8',
'cache-control': 'no-cache',
})
expect(body).toBe(map)
})
})

View File

@@ -8,7 +8,7 @@ import type {
} from '../contract/session-history.ts'
import { createHistoryInspection } from '../sessions/history.ts'
import { Notifier } from '../sessions/notifier.ts'
import { PartialAccumulator } from '../sessions/partial.ts'
import { isVisibleAssistantChunk, PartialAccumulator } from '../sessions/partial.ts'
const HISTORY_PAGE_MESSAGES = 50
@@ -431,11 +431,3 @@ export class SessionHistorySource implements SessionHistoryFace {
return this.inspectionCache.value
}
}
function isVisibleAssistantChunk(type: string): boolean {
return type === 'block-start'
|| type === 'text-delta'
|| type === 'reasoning-delta'
|| type === 'tool-call-delta'
|| type === 'block-end'
}

View File

@@ -1,5 +1,6 @@
// Notifier: subscription + microtask-batched notification primitive shared by Session and
// SessionManager. Semantics: N markDirty calls collapse into one microtask flush;
// Notifier: subscription + batched notification primitive shared by Session and
// SessionManager. Semantics: N markDirty calls collapse into one microtask flush, while
// N markFrameDirty calls collapse into one animation-frame flush;
// the flush rebuilds the snapshot cache BEFORE notifying (useSyncExternalStore requires a stable
// getSnapshot reference). With no listeners the rebuild is skipped and only the dirty bit is set
// (keeps frame storms cheap); the next getSnapshot rebuilds lazily.
@@ -9,12 +10,13 @@
// swallow the notification — push subscribers (object-layer watchers) would
// otherwise starve whenever any reader pulls first.
/** Subscription + microtask-batched notification primitive (shared by Session and SessionManager). */
/** Subscription + batched notification primitive (shared by Session and SessionManager). */
export class Notifier {
private listeners = new Set<() => void>()
private dirty = false
private notifyPending = false
private scheduled = false
private scheduled: 'none' | 'microtask' | 'frame' = 'none'
private scheduleGeneration = 0
/** @param rebuild - snapshot rebuild function injected by the owner (writes the owner's snapshotCache). */
constructor(private readonly rebuild: () => void) {}
@@ -35,19 +37,16 @@ export class Notifier {
markDirty(): void {
this.dirty = true
this.notifyPending = true
if (this.scheduled) return
this.scheduled = true
queueMicrotask(() => {
this.scheduled = false
if (!this.notifyPending) return
if (this.listeners.size === 0) return // lazy: no subscribers; dirty (if still set) rebuilds on next getSnapshot
this.notifyPending = false
if (this.dirty) {
this.dirty = false
this.rebuild()
}
for (const listener of this.listeners) listener()
})
if (this.scheduled === 'microtask') return
this.schedule('microtask')
}
/** Stream-change entry: mark dirty and publish the cumulative state at most once per frame. */
markFrameDirty(): void {
this.dirty = true
this.notifyPending = true
if (this.scheduled !== 'none') return
this.schedule(typeof globalThis.requestAnimationFrame === 'function' ? 'frame' : 'microtask')
}
/**
@@ -57,11 +56,8 @@ export class Notifier {
notifyNow(): void {
this.dirty = true
this.notifyPending = true
if (this.listeners.size === 0) return // lazy: same as markDirty, next getSnapshot rebuilds
this.notifyPending = false
this.dirty = false
this.rebuild()
for (const listener of this.listeners) listener()
this.invalidateSchedule()
this.flush()
}
/**
@@ -73,4 +69,35 @@ export class Notifier {
this.dirty = false
this.rebuild()
}
private schedule(kind: 'microtask' | 'frame'): void {
const generation = ++this.scheduleGeneration
this.scheduled = kind
const publish = () => {
if (generation !== this.scheduleGeneration) return
this.scheduled = 'none'
this.flush()
}
if (kind === 'frame') {
globalThis.requestAnimationFrame(publish)
} else {
queueMicrotask(publish)
}
}
private invalidateSchedule(): void {
this.scheduleGeneration++
this.scheduled = 'none'
}
private flush(): void {
if (!this.notifyPending) return
if (this.listeners.size === 0) return // lazy: dirty (if still set) rebuilds on next getSnapshot
this.notifyPending = false
if (this.dirty) {
this.dirty = false
this.rebuild()
}
for (const listener of this.listeners) listener()
}
}

View File

@@ -6,6 +6,19 @@ import type { StreamChunk } from '@deepseek-ai/dsh-llm/types'
import type { AssistantBlock, PartialAssistant } from './conversation.ts'
import { toAssistantBlock } from './conversation.ts'
/**
* Whether a stream chunk changes the partial assistant projection shown by the UI.
* @param type - Stream chunk discriminant.
* @returns Whether publishing the accumulated partial can change the visible snapshot.
*/
export function isVisibleAssistantChunk(type: string): boolean {
return type === 'block-start'
|| type === 'text-delta'
|| type === 'reasoning-delta'
|| type === 'tool-call-delta'
|| type === 'block-end'
}
/** assistant/chunk accumulator: folds StreamChunks into AssistantBlock[] with block-level immutability. */
export class PartialAccumulator {
// Sparse on purpose: block-start may arrive out of order, leaving holes until compaction.

View File

@@ -21,7 +21,7 @@ import { PendingWait } from './pending.ts'
import { TranscriptAdapter } from './transcript-adapter.ts'
import { displayFailureMessage } from './failure-display.ts'
import { Notifier } from './notifier.ts'
import { PartialAccumulator } from './partial.ts'
import { isVisibleAssistantChunk, PartialAccumulator } from './partial.ts'
import { ProjectionValueStore } from './projection-store.ts'
import type { ProjectionsBaseline } from './projection-store.ts'
@@ -687,6 +687,10 @@ export class Session implements SessionFace {
return
}
this.appendLive(event, view)
if (event.type === 'assistant/chunk') {
if (isVisibleAssistantChunk(event.data.chunk.type)) this.notifier.markFrameDirty()
return
}
this.notifier.markDirty()
}

View File

@@ -1,13 +1,17 @@
/**
* Notifier: microtask batching, rebuild-before-notify ordering, no-listener
* laziness, synchronous notifyNow, and unsubscribe.
* Notifier: microtask/frame batching, rebuild-before-notify ordering,
* no-listener laziness, synchronous notifyNow, and unsubscribe.
*/
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Notifier } from '../src/client/sessions/notifier.ts'
const microtask = (): Promise<void> => new Promise((resolve) => { queueMicrotask(resolve) })
afterEach(() => {
vi.unstubAllGlobals()
})
describe('Notifier', () => {
it('collapses N markDirty calls into one flush, rebuilding before notifying', async () => {
const order: string[] = []
@@ -60,6 +64,57 @@ describe('Notifier', () => {
expect(rebuilds).toBe(1)
})
it('collapses frame-dirty changes into one cumulative frame publication', () => {
const frames: FrameRequestCallback[] = []
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
frames.push(callback)
return frames.length
})
const order: string[] = []
const notifier = new Notifier(() => order.push('rebuild'))
notifier.subscribe(() => order.push('notify'))
notifier.markFrameDirty()
notifier.markFrameDirty()
notifier.markFrameDirty()
expect(order).toEqual([])
expect(frames).toHaveLength(1)
frames.shift()!(0)
expect(order).toEqual(['rebuild', 'notify'])
})
it('lets a structural microtask publication supersede a pending frame', async () => {
const frames: FrameRequestCallback[] = []
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
frames.push(callback)
return frames.length
})
let notifications = 0
const notifier = new Notifier(() => undefined)
notifier.subscribe(() => { notifications++ })
notifier.markFrameDirty()
notifier.markDirty()
await microtask()
expect(notifications).toBe(1)
frames.shift()!(0)
expect(notifications).toBe(1)
})
it('falls back to microtask batching when animation frames are unavailable', async () => {
let notifications = 0
const notifier = new Notifier(() => undefined)
notifier.subscribe(() => { notifications++ })
notifier.markFrameDirty()
notifier.markFrameDirty()
expect(notifications).toBe(0)
await microtask()
expect(notifications).toBe(1)
})
it('unsubscribed listeners stop receiving notifications', async () => {
let calls = 0
const notifier = new Notifier(() => undefined)

View File

@@ -6,7 +6,7 @@
* enough.
*/
import { describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
@@ -20,6 +20,10 @@ const at = (seq: number, e: Record<string, unknown>): SessionEvent =>
const SID = 'fk-s1' as SessionId
const PARENT = 'fk-parent' as SessionId
afterEach(() => {
vi.unstubAllGlobals()
})
function makeSession(api = new FakeApiClient()): { api: FakeApiClient; session: Session } {
return { api, session: new Session(SID, api) }
}
@@ -163,6 +167,40 @@ describe('live event path', () => {
expect((last as { interrupted?: true }).interrupted).toBeUndefined()
})
it('publishes cumulative chunks once per frame and lets finalization supersede the pending frame', async () => {
const frames: FrameRequestCallback[] = []
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
frames.push(callback)
return frames.length
})
const { session } = await opened()
const published: Array<string | null> = []
session.subscribe(() => {
const block = session.getSnapshot().partial?.blocks[0]
published.push(block?.kind === 'text' ? block.text : null)
})
const feed = (event: SessionEvent) => {
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event })
}
feed(ev.chunkStart(6, 1))
feed(ev.chunkText(7, 1, '累'))
feed(ev.chunkText(8, 1, '计'))
expect(published).toEqual([])
expect(frames).toHaveLength(1)
frames.shift()!(0)
expect(published).toEqual(['累计'])
feed(ev.chunkText(9, 1, '完成'))
feed(ev.assistant(10, 1, '累计完成'))
await Promise.resolve()
expect(published).toEqual(['累计', null])
frames.shift()!(0)
expect(published).toEqual(['累计', null])
})
it('retracts the failed step partial on retry and keeps a replayable notice before the recovered response', async () => {
const { session } = await opened()
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }

View File

@@ -9,7 +9,8 @@
* The virtual loader registers each real stylesheet as a watch dependency.
*/
import { readFile } from 'node:fs/promises'
import { basename, dirname, resolve as resolvePath } from 'node:path'
import { basename, dirname, relative, resolve as resolvePath, sep } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { UserConfig } from 'tsdown'
import { transform } from 'lightningcss'
import { PLATFORM_MODULES } from './web/src/platform.ts'
@@ -45,6 +46,16 @@ const RUNTIME_STORE_EXEMPTION = '@deepseek-ai/dsh-client-runtime/client'
/** Externals resolved from the loader module table: the platform seed entries plus the documented runtime exemption. */
export const CLIENT_EXTERNALS: readonly string[] = [...PLATFORM_MODULES, RUNTIME_STORE_EXEMPTION]
const REPOSITORY_ROOT = fileURLToPath(new URL('../..', import.meta.url))
/** Rebase a physical lib-relative source onto the browser's repository-shaped URL tree. */
function browserSourcePath(source: string, sourcemapPath: string): string {
if (!source.startsWith('.')) return source
const physicalSource = resolvePath(dirname(sourcemapPath), source)
const repositoryPath = relative(REPOSITORY_ROOT, physicalSource).split(sep).join('/')
return repositoryPath.startsWith('packages/') ? `../../../${repositoryPath}` : source
}
/**
* Build the tsdown config for one UI plugin package: the node-half lib build
* plus the browser client bundle. A package-level tsdown.config.ts REPLACES
@@ -78,6 +89,9 @@ export function clientBundle(id: string, libEntry: readonly string[]): UserConfi
platform: 'browser',
// Types ship from lib/types (tsc); dts here would wrap the banner/footer into .d.cts and break parsing.
dts: false,
// Plugin code is fetched outside Vite's module graph, so its own bundle
// must carry the TS/TSX mapping consumed by browser profiling tools.
sourcemap: true,
clean: false,
external: [...CLIENT_EXTERNALS],
// Browser bundles inline node-idiom deps (zustand/immer read
@@ -156,6 +170,11 @@ export function clientBundle(id: string, libEntry: readonly string[]): UserConfi
}],
outputOptions: {
entryFileNames: 'client.js',
// The map is served from /plugins/<scoped-package>/client.js.map. The
// browser resolves its local sources back into the repository-shaped
// /packages/<group>/<package>/src tree; sourcesContent keeps them usable
// without exposing that tree as an HTTP route.
sourcemapPathTransform: browserSourcePath,
banner: `window.__ModuleLoader__.load({ id: ${JSON.stringify(id)}, factory: (require) => {`,
footer: `return module.exports; } });`,
intro: 'var module = { exports: {} }; var exports = module.exports;',

View File

@@ -7,7 +7,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
import type {} from '@deepseek-ai/dsh-client-locale/client'
import type { ViewTab } from './contract/views.ts'
import type {
ApprovalWait, ChatViewInjected, ComposerBarInjected, ComposerChainProps, ConversationInjected,
ApprovalWait, ChatScrollPosition, ChatViewInjected, ComposerBarInjected, ComposerChainProps, ConversationInjected,
ConversationSessionInjected, DetailsInjected,
} from './contract/slots.ts'
import type { InputNotice } from './input/contract.ts'
@@ -113,10 +113,10 @@ export function apply(ctx: Context): void {
return () => { row.dispose() }
}, 'ui-conversation: Enter behavior settings row')
// Chat scroll offsets by session, surviving view switches (the chat view
// unmounts under the tab ring). Deliberately not persisted: a fresh page
// load should keep the open-jump-to-bottom default.
const chatScrollTops = new Map<SessionId, number>()
// Chat semantic reader positions by session, surviving view switches and
// width reflow when the tab ring remounts the view. Deliberately not
// persisted: a fresh page load keeps the open-jump-to-bottom default.
const chatScrollPositions = new Map<SessionId, ChatScrollPosition>()
const viewTabs = (): ViewTab[] => {
const tabs: ViewTab[] = []
@@ -316,11 +316,11 @@ export function apply(ctx: Context): void {
actions.setView('trajectory')
},
chatScroll: {
save: (top) => {
if (top === null) chatScrollTops.delete(sessionId)
else chatScrollTops.set(sessionId, top)
save: (position) => {
if (position === null) chatScrollPositions.delete(sessionId)
else chatScrollPositions.set(sessionId, position)
},
read: () => chatScrollTops.get(sessionId) ?? null,
read: () => chatScrollPositions.get(sessionId) ?? null,
},
forkAt: (seq) => {
sessions.fork({ sessionId, atSeq: seq, increaseTitle: true })

View File

@@ -42,6 +42,12 @@
gap: 16px;
}
/* Settled-flow identity boundary. It is neutral today and becomes the natural
measurement/mount unit for a virtualizer without changing the column gap. */
.flowItem {
min-width: 0;
}
.toolGroup {
display: flex;
flex-direction: column;

View File

@@ -44,6 +44,59 @@ function scrollerOf(from: HTMLElement): HTMLElement {
return (from.closest('[data-conversation-scroll]')) ?? from
}
interface PagingAnchor {
/** Stable node/call identity, independent of boundary-spanning group keys. */
key: string
/** Row top relative to the scrollport after the latest user scroll. */
top: number
}
/** Find an already-rendered settled row without interpolating a selector. */
function anchorElement(list: HTMLElement, key: string): HTMLElement | null {
for (const row of list.querySelectorAll<HTMLElement>('[data-chat-anchor-key]')) {
if (row.dataset.chatAnchorKey === key) return row
}
return null
}
/** Row position in scrollport coordinates (viewport-independent). */
function flowTop(row: HTMLElement, scrollport: HTMLElement): number {
return row.getBoundingClientRect().top - scrollport.getBoundingClientRect().top
}
/** Select a visible stable node/call identity, falling back only when layout
* has not exposed a visible box yet. */
function pagingAnchor(list: HTMLElement, scrollport: HTMLElement): HTMLElement | null {
const viewport = scrollport.getBoundingClientRect()
const composer = scrollport.querySelector<HTMLElement>('[data-composer-seat]')
const visibleBottom = composer?.getBoundingClientRect().top ?? viewport.bottom
// Scroll events are hot: hit-test a few points through the stretched flow
// rows before considering the full mounted set. The fallback keeps jsdom
// and pre-layout states deterministic; a virtualizer naturally bounds it.
if (typeof document.elementsFromPoint === 'function' && visibleBottom > viewport.top) {
const content = list.getBoundingClientRect()
const left = Math.max(viewport.left, content.left)
const right = Math.min(viewport.right, content.right)
const x = left + Math.max(0, right - left) / 2
const height = visibleBottom - viewport.top
const points = [1, Math.min(32, height / 3), height / 2, Math.max(1, height - 1)]
for (const offset of points) {
for (const element of document.elementsFromPoint(x, viewport.top + offset)) {
const row = element instanceof HTMLElement
? element.closest<HTMLElement>('[data-chat-anchor-key]')
: null
if (row !== null && list.contains(row)) return row
}
}
}
const rows = [...list.querySelectorAll<HTMLElement>('[data-chat-anchor-key]')]
const visibleRows = rows.filter((row) => {
const rect = row.getBoundingClientRect()
return rect.bottom > viewport.top && rect.top < visibleBottom
})
return visibleRows[0] ?? rows[0] ?? null
}
type OpenFile = (path: string) => void
type InspectCall = (callId: string) => void
@@ -51,6 +104,8 @@ type InspectCall = (callId: string) => void
/** The declared toolview hole's render share (stable framework binding, passed through memoized rows). */
type RenderToolRow = ChatViewSlotProps['renderSlot']
type ChatScrollPosition = NonNullable<ReturnType<ChatViewSlotProps['chatScroll']['read']>>
/** ui-slots' UseSession is deliberately wide (dependency direction); the
* chat view narrows once to the runtime snapshot the binding actually feeds. */
type UseConversation = SnapshotSelectorHook<ConversationSnapshot>
@@ -66,6 +121,18 @@ function activeRetrySeq(nodes: readonly ConversationNode[], running: boolean): n
return null
}
/** Capture a reflow-resistant reader position from the current rendered window. */
function scrollPosition(list: HTMLElement, scrollport: HTMLElement): ChatScrollPosition | null {
const row = pagingAnchor(list, scrollport)
const anchorKey = row?.dataset.chatAnchorKey
if (row === null || anchorKey === undefined) return null
return {
anchorKey,
anchorTop: flowTop(row, scrollport),
scrollTop: scrollport.scrollTop,
}
}
/** One `run_code` sub-dispatch row: the identical keyed-slot dispatch as a
* top-level call (same registrations, same fallback), nested by the parent.
* A started-but-unsettled sub-call arrives as the RunningToolCall shape and
@@ -86,7 +153,12 @@ const SubCallRow = memo(function SubCallRow({ renderSlot, node, openFile, select
inspect: () => { inspectCall(node.callId) },
}), [node, toolName, openFile, cwd, inspectCall])
return (
<div className={css.callRow} data-selected={selected || undefined}>
<div
className={css.callRow}
data-chat-anchor-key={`call:${node.callId}`}
data-chat-call-id={node.callId}
data-selected={selected || undefined}
>
{renderSlot('conversation.chat.toolview', owner, {
entryKey: toolName,
fallback: <GenericToolCard {...owner} t={t} />,
@@ -124,7 +196,12 @@ const CallRow = memo(function CallRow({
inspect: () => { inspectCall(callId) },
}), [callId, toolName, block, openFile, cwd, inspectCall])
return (
<div className={css.callRow} data-selected={selected || undefined}>
<div
className={css.callRow}
data-chat-anchor-key={`call:${callId}`}
data-chat-call-id={callId}
data-selected={selected || undefined}
>
{renderSlot('conversation.chat.toolview', owner, {
entryKey: toolName,
fallback: <GenericToolCard {...owner} t={t} />,
@@ -213,17 +290,13 @@ function TurnStatus() {
)
}
/** The streaming partial, isolated so chunk batches re-render only this tail.
* onGrow lets the scroll owner follow content the parent never re-renders for. */
function StreamingTail({ useSession, onGrow, t }: {
/** The streaming partial, isolated so chunk batches re-render only this tail;
* the column ResizeObserver owns bottom-follow when its box grows. */
function StreamingTail({ useSession, t }: {
useSession: UseConversation
onGrow: () => void
t: ChatViewSlotProps['t']
}) {
const partial = useSession(s => s.partial)
useLayoutEffect(() => {
onGrow()
})
if (partial === null) return null
return <AssistantMarkdown blocks={partial.blocks} streaming t={t} />
}
@@ -261,10 +334,17 @@ export function ChatView({
const branchSeqs = useMemo(() => messageBranchSeqs(nodes, turnEnds), [nodes, turnEnds])
const listRef = useRef<HTMLDivElement | null>(null)
const columnRef = useRef<HTMLDivElement | null>(null)
const atBottomRef = useRef(true)
const [atBottom, setAtBottom] = useState(true)
/** Paging anchor: height/position at click, compensated after the prepend lands. */
const anchorRef = useRef<{ h: number; t: number } | null>(null)
/** Last position delivered or written on the main thread. */
const observedTopRef = useRef(0)
/** Pre-input position for the current wheel gesture. */
const wheelStartRef = useRef<number | null>(null)
const wheelEpochRef = useRef(0)
/** Paging anchor: semantic row/position at click, updated by reader scrolls
* while the request is pending and restored after the prepend lands. */
const anchorRef = useRef<PagingAnchor | null>(null)
const firstSeqRef = useRef<number | null>(null)
const openedRef = useRef(false)
const lastKeyRef = useRef<string | null>(null)
@@ -281,9 +361,14 @@ export function ChatView({
const followSig = `${openState}:${firstSeq}:${lastKey}:${nodes.length}:${running ? 1 : 0}:${runningCalls.length}:${lastSteeringId ?? ''}`
const toBottom = (el: HTMLElement): void => {
wheelStartRef.current = null
wheelEpochRef.current += 1
anchorRef.current = null
el.scrollTop = el.scrollHeight
observedTopRef.current = el.scrollTop
atBottomRef.current = true
setAtBottom(true)
chatScroll.save(null)
}
useLayoutEffect(() => {
@@ -300,10 +385,16 @@ export function ChatView({
if (saved === null) {
toBottom(el)
} else {
el.scrollTop = saved
el.scrollTop = saved.scrollTop
const row = anchorElement(local, saved.anchorKey)
if (row !== null) el.scrollTop += flowTop(row, el) - saved.anchorTop
observedTopRef.current = el.scrollTop
const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1
atBottomRef.current = isAtBottom
setAtBottom(isAtBottom)
const normalized = isAtBottom ? null : scrollPosition(local, el)
if (isAtBottom) chatScroll.save(null)
else if (normalized !== null) chatScroll.save(normalized)
}
firstSeqRef.current = firstSeq
lastKeyRef.current = lastKey
@@ -311,10 +402,15 @@ export function ChatView({
followSigRef.current = followSig
return
}
// Prepend (head seq decreased): compensate by the height delta.
// Prepend (head seq decreased): preserve the same settled row at the
// position established by the reader's latest scroll. This excludes
// unrelated tail/composer growth while the request was in flight.
if (anchorRef.current !== null && firstSeq !== null && firstSeqRef.current !== null && firstSeq < firstSeqRef.current) {
el.scrollTop = anchorRef.current.t + (el.scrollHeight - anchorRef.current.h)
const anchor = anchorRef.current
anchorRef.current = null
const row = anchorElement(local, anchor.key)
if (row !== null) el.scrollTop += flowTop(row, el) - anchor.top
observedTopRef.current = el.scrollTop
firstSeqRef.current = firstSeq
/* v8 ignore next -- ?? arm: a prepend adds nodes, so the flow list here is never empty. */
lastKeyRef.current = lastKey
@@ -343,26 +439,66 @@ export function ChatView({
/* v8 ignore next -- ref-null guard: the handler only fires while mounted. */
if (local === null) return
const el = scrollerOf(local)
const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1
// Only wheel input may make raw scroll geometry change follow ownership.
// Browser clamping and delayed programmatic scroll events otherwise have
// the same event shape and must preserve the current ownership state.
const floor = Math.max(0, el.scrollHeight - el.clientHeight)
const wheelStart = wheelStartRef.current
const movedByWheel = wheelStart !== null
&& Math.abs(el.scrollTop - Math.min(wheelStart, floor)) > 0.5
const isAtBottom = movedByWheel
? floor - el.scrollTop <= FOLLOW_THRESHOLD + 1
: atBottomRef.current
if (!movedByWheel && isAtBottom) {
toBottom(el)
return
}
atBottomRef.current = isAtBottom
setAtBottom(isAtBottom)
const position = isAtBottom ? null : scrollPosition(local, el)
if (isAtBottom) {
anchorRef.current = null
} else if (anchorRef.current !== null && position !== null) {
anchorRef.current = { key: position.anchorKey, top: position.anchorTop }
}
// Continuous save (unmount happens after ref detach, so saving there is
// too late); pinned-to-bottom clears so a remount keeps following.
chatScroll.save(isAtBottom ? null : el.scrollTop)
if (isAtBottom) chatScroll.save(null)
else if (position !== null) chatScroll.save(position)
observedTopRef.current = el.scrollTop
}
// Bind scroll to the resolved scrollport (host or local) once per mount.
// Bind scroll and the wheel provenance needed to distinguish reader input
// from layout-driven scrolls on the resolved scrollport once per mount.
useEffect(() => {
const local = listRef.current
/* v8 ignore next -- ref-null guard: effect runs after the list node commits. */
if (local === null) return
const el = scrollerOf(local)
const onScroll = (): void => { onScrollRef.current() }
const onWheel = (event: WheelEvent): void => {
if (event.ctrlKey || event.deltaY === 0) return
const startTop = observedTopRef.current
const floor = Math.max(0, el.scrollHeight - el.clientHeight)
const canMove = event.deltaY < 0 ? startTop > 1 : startTop < floor - 1
if (!canMove) return
wheelStartRef.current = startTop
const epoch = ++wheelEpochRef.current
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (wheelEpochRef.current === epoch) wheelStartRef.current = null
})
})
}
el.addEventListener('scroll', onScroll, { passive: true })
return () => { el.removeEventListener('scroll', onScroll) }
el.addEventListener('wheel', onWheel, { capture: true, passive: true })
return () => {
wheelStartRef.current = null
el.removeEventListener('scroll', onScroll)
el.removeEventListener('wheel', onWheel, true)
}
}, [])
// Follow streaming growth the parent never re-renders for (stable ref).
// The ref starts null and is assigned every render, so the placeholder
// initializer a function initial value would need never exists.
const followRef = useRef<(() => void) | null>(null)
@@ -371,16 +507,43 @@ export function ChatView({
if (local !== null && atBottomRef.current) {
const el = scrollerOf(local)
el.scrollTop = el.scrollHeight
observedTopRef.current = el.scrollTop
chatScroll.save(null)
}
}
const onGrow = useRef(() => followRef.current?.()).current
// Streaming, tool disclosures, and other flow changes resize the column;
// the sticky composer resizes outside it. This observer owns ChatView's
// dynamic-height follow decisions and writes only while the reader is pinned.
useEffect(() => {
const column = columnRef.current
const local = listRef.current
if (column === null || local === null || typeof ResizeObserver === 'undefined') return
const scrollport = scrollerOf(local)
const composer = scrollport.querySelector<HTMLElement>('[data-composer-seat]')
const observer = new ResizeObserver(() => { followRef.current?.() })
observer.observe(column)
if (composer !== null) observer.observe(composer)
return () => { observer.disconnect() }
}, [])
// A failed/empty page leaves the head unchanged. Once the request leaves
// its busy state there is no future prepend for the saved anchor to own.
useEffect(() => {
if (!loadingOlder) anchorRef.current = null
}, [loadingOlder])
const loadOlderAnchored = (): void => {
const local = listRef.current
/* v8 ignore next -- ref-null guard: the paging button renders inside the list tree. */
if (local !== null) {
const el = scrollerOf(local)
anchorRef.current = { h: el.scrollHeight, t: el.scrollTop }
const row = pagingAnchor(local, el)
if (row !== null && row.dataset.chatAnchorKey !== undefined) {
anchorRef.current = {
key: row.dataset.chatAnchorKey,
top: flowTop(row, el),
}
}
}
loadOlder()
}
@@ -392,7 +555,6 @@ export function ChatView({
|| codeDispatches.get(r.callId)?.some(sub => sub.callId === selectedCallId) === true)
return (
<ToolGroup
key={item.key}
renderSlot={renderSlot}
results={item.results}
openFile={openFile}
@@ -408,7 +570,6 @@ export function ChatView({
if (node.kind === 'assistant') {
return (
<AssistantMarkdown
key={item.key}
blocks={node.blocks}
streaming={false}
interrupted={node.interrupted}
@@ -421,13 +582,12 @@ export function ChatView({
)
}
if (node.kind === 'command') {
return <CommandRow key={item.key} renderSlot={renderSlot} node={node} t={t} />
return <CommandRow renderSlot={renderSlot} node={node} t={t} />
}
/* v8 ignore next -- tool-result never reaches here: deriveChatFlow folds them into groups. */
if (node.kind === 'tool-result') return null
return (
<MessageItem
key={item.key}
node={node}
retryActive={node.kind === 'model-retry' && node.seq === activeRetry}
onFork={forkAt}
@@ -440,7 +600,7 @@ export function ChatView({
return (
<div className={css.root}>
<div ref={listRef} className={css.scroll}>
<div className={css.column}>
<div ref={columnRef} className={css.column} data-chat-flow="">
{openState === 'loading' && <div className={css.hint}>{t('chat.loadingHistory')}</div>}
{openState === 'error' && openError !== null && (
<div className={css.openError}>
@@ -454,8 +614,18 @@ export function ChatView({
</button>
</div>
)}
{items.map(renderItem)}
<StreamingTail useSession={useSession} onGrow={onGrow} t={t} />
{items.map(item => (
<div
key={item.key}
className={css.flowItem}
data-chat-anchor-key={item.kind === 'node' ? `node:${String(item.node.seq)}` : undefined}
data-chat-flow-key={item.key}
data-chat-flow-kind={item.kind === 'node' ? item.node.kind : 'tool-group'}
>
{renderItem(item)}
</div>
))}
<StreamingTail useSession={useSession} t={t} />
{runningCalls.length > 0 && (
<div className={css.toolGroup}>
{runningCalls.map(call => (

View File

@@ -20,7 +20,7 @@
// independent); an error row's collapsed summary is the failure's first line in
// the error color.
import { useLayoutEffect, useRef, useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
import { useEffect, useRef, useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
import clsx from 'clsx'
import {
CodeBlock, DiffBlock, ReadBlock, SearchBlock, StateDot, TerminalBlock, WebBlock,
@@ -33,6 +33,7 @@ import { CHAT_SEARCH_MAX_LINES, type SearchCardModel } from '../contract/search-
import { terminalBlockLabels, type TerminalCardModel } from '../contract/terminal-card-model.ts'
import type { ToolRowState, ToolRowVariant } from '../contract/tool-call-model.ts'
import { DisclosureRow } from './DisclosureRow.tsx'
import { useThrottledVisualUpdate } from './use-throttled-visual-update.ts'
import css from './ToolRow.module.css'
export interface ToolRowProps {
@@ -176,13 +177,17 @@ export function ToolRow({
const fileLink = filePath !== undefined && onOpenFile !== undefined && failureLine === null
const isThink = variant === 'think'
const followSummaryEnd = isThink && state === 'running' && !open
useLayoutEffect(() => {
const scheduleSummaryScroll = useThrottledVisualUpdate(() => {
const summaryElement = summaryRef.current
if (summaryElement === null) return
summaryElement.scrollLeft = followSummaryEnd
? summaryElement.scrollWidth - summaryElement.clientWidth
: 0
}, [followSummaryEnd, summaryText])
})
useEffect(() => {
if (!isThink) return
scheduleSummaryScroll()
}, [followSummaryEnd, isThink, scheduleSummaryScroll, summaryText])
const toggleExpand = () => {
setExpanded(v => !v)
}

View File

@@ -0,0 +1,42 @@
/** Frame-throttled scheduling for non-essential visual alignment. */
import { useCallback, useLayoutEffect, useRef } from 'react'
const DEFAULT_INTERVAL_FRAMES = 3
/**
* Return a stable scheduler that coalesces visual updates over a frame interval.
* Repeated calls retain the latest callback, and unmount cancels pending work.
* @param update - DOM alignment to run after the throttle interval.
* @param intervalFrames - Frames to wait before applying the latest alignment.
* @returns a stable function that schedules the latest update.
*/
export function useThrottledVisualUpdate(
update: () => void,
intervalFrames = DEFAULT_INTERVAL_FRAMES,
): () => void {
const updateRef = useRef(update)
updateRef.current = update
const pendingFrameRef = useRef<number | null>(null)
useLayoutEffect(() => () => {
if (pendingFrameRef.current === null) return
cancelAnimationFrame(pendingFrameRef.current)
pendingFrameRef.current = null
}, [])
return useCallback(() => {
if (pendingFrameRef.current !== null) return
let remainingFrames = intervalFrames
const advance = (): void => {
remainingFrames -= 1
if (remainingFrames > 0) {
pendingFrameRef.current = requestAnimationFrame(advance)
return
}
pendingFrameRef.current = null
updateRef.current()
}
pendingFrameRef.current = requestAnimationFrame(advance)
}, [intervalFrames])
}

View File

@@ -435,6 +435,16 @@ export class PendingApproval {
export type ApprovalComposerProps =
PropsRuntime<'conversation.composer'> & { matched: ApprovalWait } & PropsLocale<'conversation'>
/** In-memory reader position resilient to transcript width reflow. */
export interface ChatScrollPosition {
/** Stable rendered node/call identity nearest the visible reading edge. */
readonly anchorKey: string
/** Anchor top relative to the transcript scrollport when saved. */
readonly anchorTop: number
/** Approximate offset used before the semantic anchor is measured. */
readonly scrollTop: number
}
/**
* Injected share of the chat view entry: the two callbacks whose targets live
* outside the view (layout orchestration; the session object layer).
@@ -456,10 +466,10 @@ export interface ChatViewInjected {
* fresh page load starts empty and keeps the open-jump-to-bottom default.
*/
chatScroll: {
/** Record the scroll offset; null clears it (pinned to bottom). */
save: (top: number | null) => void
/** Last recorded offset, or null when pinned or never recorded. */
read: () => number | null
/** Record a semantic reader position; null clears it when pinned. */
save: (position: ChatScrollPosition | null) => void
/** Last reader position, or null when pinned or never recorded. */
read: () => ChatScrollPosition | null
}
/** Fork through the completed turn ending at the eligible message `seq`, then open the child. */
forkAt: (seq: number) => void

View File

@@ -1,8 +1,7 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render } from '@testing-library/react'
afterEach(cleanup)
import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
@@ -12,6 +11,36 @@ import { ToolRow } from '../src/client/chat/ToolRow.tsx'
import { GenericToolCard, type GenericToolCardProps } from '../src/client/chat/GenericToolCard.tsx'
import { zh } from '../src/client/locales.ts'
let nextAnimationFrameId = 1
let animationFrames = new Map<number, FrameRequestCallback>()
function flushAnimationFrames(count: number): void {
for (let index = 0; index < count; index += 1) {
const callbacks = [...animationFrames.values()]
animationFrames.clear()
for (const callback of callbacks) callback(index)
}
}
beforeEach(() => {
nextAnimationFrameId = 1
animationFrames = new Map()
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
const id = nextAnimationFrameId
nextAnimationFrameId += 1
animationFrames.set(id, callback)
return id
})
vi.stubGlobal('cancelAnimationFrame', (id: number) => {
animationFrames.delete(id)
})
})
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
})
// Mirrors the real lookup chain (conversation namespace, then common).
const t: GenericToolCardProps['t'] = makeTranslate(zh, commonZh)
@@ -341,6 +370,10 @@ describe('ThinkRow', () => {
streaming
/>,
)
expect(summary.scrollLeft).toBe(0)
flushAnimationFrames(2)
expect(summary.scrollLeft).toBe(0)
flushAnimationFrames(1)
expect(summary.scrollLeft).toBe(200)
expect(summary.getAttribute('data-follow-end')).toBe('true')
@@ -351,6 +384,7 @@ describe('ThinkRow', () => {
streaming={false}
/>,
)
flushAnimationFrames(3)
expect(view.getByText('Inspect the session')).toBeTruthy()
expect(summary.scrollLeft).toBe(0)
expect(summary.hasAttribute('data-follow-end')).toBe(false)

View File

@@ -22,7 +22,10 @@ import { ChatView } from '../src/client/chat/ChatView.tsx'
import { zh } from '../src/client/locales.ts'
import { assistantActionsSeqs, deriveChatFlow, flowKeys, messageBranchSeqs } from '../src/client/chat/chat-flow.ts'
afterEach(cleanup)
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
})
// Keyless create() persists under the bare declared key; clear between cases
// so one harness's selection cannot rehydrate into the next.
beforeEach(() => {
@@ -112,10 +115,10 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
const loadOlder = vi.fn()
const inspectCall = vi.fn<(callId: string) => void>()
// In-memory scroll memory matching the apply.ts per-session map contract.
let savedScrollTop: number | null = null
const chatScroll = {
save: (top: number | null) => { savedScrollTop = top },
read: () => savedScrollTop,
let savedScroll: ReturnType<ChatViewSlotProps['chatScroll']['read']> = null
const chatScroll: ChatViewSlotProps['chatScroll'] = {
save: (position) => { savedScroll = position },
read: () => savedScroll,
}
const forkAt = vi.fn()
// Selection rides the REAL chat store (same construction path as
@@ -154,6 +157,32 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
return { set, ChatView, props, openDetails, openFile, loadOlder, inspectCall, chatScroll, forkAt, setSelection }
}
/** Simulate reader input before the browser delivers the host scroll event. */
function readerScroll(element: HTMLElement, top: number): void {
fireEvent.wheel(element, { deltaY: top < element.scrollTop ? -120 : 120 })
element.scrollTop = top
fireEvent.scroll(element)
}
function installScrollMetrics(element: HTMLElement, initialHeight: number, clientHeight: number) {
let scrollHeight = initialHeight
let scrollTop = 0
Object.defineProperty(element, 'scrollHeight', { configurable: true, get: () => scrollHeight })
Object.defineProperty(element, 'clientHeight', { configurable: true, get: () => clientHeight })
Object.defineProperty(element, 'scrollTop', {
configurable: true,
get: () => scrollTop,
set: (value: number) => { scrollTop = Math.max(0, Math.min(value, scrollHeight - clientHeight)) },
})
return {
setHeight: (value: number) => { scrollHeight = value },
setLayout: (height: number, top: number) => {
scrollHeight = height
scrollTop = Math.max(0, Math.min(top, scrollHeight - clientHeight))
},
}
}
describe('chat-flow derivation', () => {
it('groups consecutive tool results and keeps stable keys', () => {
const nodes: ConversationNode[] = [
@@ -247,20 +276,36 @@ describe('ChatView', () => {
expect(view.getByText('w1')).toBeTruthy()
})
it('prepend keeps the viewport anchored when the reader is NOT at the bottom (no lastKey force)', () => {
// Covers the prepend early-return arm where lastItem exists but the key
// path is not taken (anchor branch wins before the appended-user check).
const h = makeHarness({ nodes: [user(9, 'late')], hasMore: true })
it('prepend keeps the reader\'s latest pending-request scroll position anchored', () => {
const h = makeHarness({ nodes: [user(9, 'first visible'), user(10, 'next visible')], hasMore: true })
const view = render(<h.ChatView {...h.props} />)
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
const first = view.container.querySelector('[data-chat-flow-key="n9"]') as HTMLDivElement
const next = view.container.querySelector('[data-chat-flow-key="n10"]') as HTMLDivElement
let firstTop = 100
let nextTop = 300
vi.spyOn(scroller, 'getBoundingClientRect').mockImplementation(
() => ({ top: 0, bottom: 200 } as DOMRect),
)
vi.spyOn(first, 'getBoundingClientRect').mockImplementation(
() => ({ top: firstTop, bottom: firstTop + 40 } as DOMRect),
)
vi.spyOn(next, 'getBoundingClientRect').mockImplementation(
() => ({ top: nextTop, bottom: nextTop + 40 } as DOMRect),
)
Object.defineProperty(scroller, 'scrollHeight', { value: 800, writable: true })
Object.defineProperty(scroller, 'clientHeight', { value: 200, writable: true })
scroller.scrollTop = 50
fireEvent.scroll(scroller)
readerScroll(scroller, 50)
fireEvent.click(view.getByText('加载更早'))
// The reader moves after the request starts; this, not the click-time
// row, is the intent the arriving page must preserve.
firstTop = -200
nextTop = 60
readerScroll(scroller, 90)
Object.defineProperty(scroller, 'scrollHeight', { value: 1300, writable: true })
act(() => { h.set({ nodes: [assistant(2, 'older'), user(9, 'late')] }) })
expect(scroller.scrollTop).toBe(550) // 50 + (1300 - 800)
nextTop = 560
act(() => { h.set({ nodes: [assistant(2, 'older'), user(9, 'first visible'), user(10, 'next visible')] }) })
expect(scroller.scrollTop).toBe(590) // latest 90 + the anchored row's 500px prepend shift
})
it('renders the fixture main line: bubble, narration, grouped tool rows', () => {
@@ -272,6 +317,18 @@ describe('ChatView', () => {
expect(view.getByText('running tools')).toBeTruthy()
expect(view.getAllByText('Bash')).toHaveLength(2)
expect(view.getByText('run a')).toBeTruthy()
expect([...view.container.querySelectorAll('[data-chat-flow-key]')].map(row => ({
key: row.getAttribute('data-chat-flow-key'),
kind: row.getAttribute('data-chat-flow-kind'),
}))).toEqual([
{ key: 'n1', kind: 'user' },
{ key: 'n2', kind: 'assistant' },
{ key: 'g3', kind: 'tool-group' },
])
expect([...view.container.querySelectorAll('[data-chat-call-id]')].map(row => row.getAttribute('data-chat-call-id')))
.toEqual(['a', 'b'])
expect([...view.container.querySelectorAll('[data-chat-anchor-key]')].map(row => row.getAttribute('data-chat-anchor-key')))
.toEqual(['node:1', 'node:2', 'call:a', 'call:b'])
})
it('renders Host-pending steering at the flow tail and hands off to the durable node', () => {
@@ -622,31 +679,106 @@ describe('ChatView', () => {
expect(calls).toEqual([{ key: 'conversation.chat.toolview', entryKey: 'bash' }])
})
it('prepend compensates scrollTop by the height delta; a trailing user node force-scrolls', () => {
it('prepend preserves a semantic row; a trailing user node force-scrolls', () => {
const h = makeHarness({ nodes: [user(5, 'later'), assistant(6, 'a')], hasMore: true })
const view = render(<h.ChatView {...h.props} />)
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
// jsdom has no layout: fake the metrics the anchor math reads.
Object.defineProperty(scroller, 'scrollHeight', { value: 1000, writable: true })
Object.defineProperty(scroller, 'clientHeight', { value: 400, writable: true })
const anchored = view.container.querySelector('[data-chat-flow-key="n5"]') as HTMLDivElement
let anchoredTop = 100
vi.spyOn(anchored, 'getBoundingClientRect').mockImplementation(
() => ({ top: anchoredTop, bottom: anchoredTop + 40 } as DOMRect),
)
readerScroll(scroller, 80)
// Arm the paging anchor, then deliver an older page (head seq decreases).
fireEvent.click(view.getByText('加载更早'))
Object.defineProperty(scroller, 'scrollHeight', { value: 1600, writable: true })
anchoredTop = 700
act(() => { h.set({ nodes: [user(1, 'old'), assistant(2, 'b'), user(5, 'later'), assistant(6, 'a')] }) })
expect(scroller.scrollTop).toBe(600) // 0 + (1600 - 1000)
expect(scroller.scrollTop).toBe(680) // reader offset 80 + the anchored row's 600px shift
// A new trailing user bubble (own words) force-scrolls to the bottom.
act(() => { h.set({ nodes: [user(1, 'old'), assistant(2, 'b'), user(5, 'later'), assistant(6, 'a'), user(9, 'mine')] }) })
expect(scroller.scrollTop).toBe(1600)
})
it('uses stable call identity when a prepend changes the tool-group key amid unrelated growth', () => {
const h = makeHarness({ nodes: [toolResult(5, 'late')], hasMore: true })
const view = render(<h.ChatView {...h.props} />)
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
let prepended = false
const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
if (this.dataset.chatAnchorKey === 'call:late') {
const top = prepended ? 400 : 100
return { top, bottom: top + 40 } as DOMRect
}
return { top: 0, bottom: 200 } as DOMRect
})
try {
Object.defineProperty(scroller, 'scrollHeight', { value: 700, writable: true })
Object.defineProperty(scroller, 'clientHeight', { value: 200, writable: true })
readerScroll(scroller, 80)
fireEvent.click(view.getByText('加载更早'))
// Total height grows by 500, but only 300 belongs before the call row.
Object.defineProperty(scroller, 'scrollHeight', { value: 1_200, writable: true })
prepended = true
act(() => { h.set({ nodes: [toolResult(4, 'early'), toolResult(5, 'late')] }) })
expect(scroller.scrollTop).toBe(380)
} finally {
rect.mockRestore()
}
})
it('uses the latest retry identity when prepending an earlier retry changes the flow key', () => {
const h = makeHarness({ nodes: [retry(5)], hasMore: true })
const view = render(<h.ChatView {...h.props} />)
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
let prepended = false
const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
if (this.dataset.chatAnchorKey === 'node:5') {
const top = prepended ? 400 : 100
return { top, bottom: top + 40 } as DOMRect
}
return { top: 0, bottom: 200 } as DOMRect
})
try {
Object.defineProperty(scroller, 'scrollHeight', { value: 700, writable: true })
Object.defineProperty(scroller, 'clientHeight', { value: 200, writable: true })
readerScroll(scroller, 80)
fireEvent.click(view.getByText('加载更早'))
Object.defineProperty(scroller, 'scrollHeight', { value: 1_200, writable: true })
prepended = true
act(() => { h.set({ nodes: [retry(4), retry(5)] }) })
expect(scroller.scrollTop).toBe(380)
expect(view.container.querySelector('[data-chat-flow-key="n4"][data-chat-anchor-key="node:5"]')).not.toBeNull()
} finally {
rect.mockRestore()
}
})
it('back-to-bottom cancels an in-flight paging anchor', () => {
const h = makeHarness({ nodes: [user(9, 'late')], hasMore: true })
const view = render(<h.ChatView {...h.props} />)
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
Object.defineProperty(scroller, 'scrollHeight', { value: 800, writable: true })
Object.defineProperty(scroller, 'clientHeight', { value: 200, writable: true })
readerScroll(scroller, 50)
fireEvent.click(view.getByText('加载更早'))
fireEvent.click(view.getByLabelText('回到底部'))
Object.defineProperty(scroller, 'scrollHeight', { value: 1_300, writable: true })
act(() => { h.set({ nodes: [assistant(2, 'older'), user(9, 'late')] }) })
expect(scroller.scrollTop).toBe(1_300)
expect(h.chatScroll.read()).toBeNull()
})
it('scrolling away disables follow and shows the back-to-bottom button; clicking returns', () => {
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
const view = render(<h.ChatView {...h.props} />)
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
Object.defineProperty(scroller, 'scrollHeight', { value: 1000, writable: true })
Object.defineProperty(scroller, 'clientHeight', { value: 300, writable: true })
scroller.scrollTop = 100 // far from bottom
fireEvent.scroll(scroller)
readerScroll(scroller, 100) // far from bottom
const backButton = view.getByLabelText('回到底部')
expect(backButton).toBeTruthy()
// Streaming growth must NOT drag a scrolled-away reader down.
@@ -658,6 +790,71 @@ describe('ChatView', () => {
expect(view.queryByLabelText('回到底部')).toBeNull()
})
it('keeps following when a delayed clamp scroll arrives after layout regrows', () => {
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
const view = render(<h.ChatView {...h.props} />)
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
const metrics = installScrollMetrics(scroller, 1_000, 300)
scroller.scrollTop = 700
fireEvent.scroll(scroller)
// The wheel cannot move farther down. A stream-finalization shrink clamps
// the old position, then reflow grows the layout before scroll delivery.
fireEvent.wheel(scroller, { deltaY: 120 })
metrics.setLayout(1_040, 500)
fireEvent.scroll(scroller)
expect(scroller.scrollTop).toBe(740)
expect(view.queryByLabelText('回到底部')).toBeNull()
expect(h.chatScroll.read()).toBeNull()
metrics.setHeight(1_200)
act(() => { h.set({ running: true }) })
expect(scroller.scrollTop).toBe(900)
})
it('uses the last delivered top when compositor scrolling precedes passive wheel delivery', () => {
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
const view = render(<h.ChatView {...h.props} />)
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
installScrollMetrics(scroller, 1_000, 300)
scroller.scrollTop = 700
fireEvent.scroll(scroller)
scroller.scrollTop = 500
fireEvent.wheel(scroller, { deltaY: -200 })
fireEvent.scroll(scroller)
expect(view.getByLabelText('回到底部')).toBeTruthy()
})
it('one ResizeObserver owns pinned dynamic-height follow and ignores growth while away', () => {
let notify: (() => void) | undefined
const observe = vi.fn()
class ResizeObserverStub {
constructor(callback: ResizeObserverCallback) {
notify = () => { callback([], this as unknown as ResizeObserver) }
}
observe = observe
disconnect = vi.fn()
}
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
const view = render(<h.ChatView {...h.props} />)
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
Object.defineProperty(scroller, 'scrollHeight', { value: 1_000, writable: true })
Object.defineProperty(scroller, 'clientHeight', { value: 300, writable: true })
scroller.scrollTop = 700
fireEvent.scroll(scroller)
Object.defineProperty(scroller, 'scrollHeight', { value: 1_200, writable: true })
act(() => { notify?.() })
expect(scroller.scrollTop).toBe(1_200)
readerScroll(scroller, 200)
Object.defineProperty(scroller, 'scrollHeight', { value: 1_400, writable: true })
act(() => { notify?.() })
expect(scroller.scrollTop).toBe(200)
expect(observe).toHaveBeenCalledTimes(1)
})
it('entering the at-bottom threshold does not snap the remaining scroll distance', () => {
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
const view = render(<h.ChatView {...h.props} />)
@@ -666,8 +863,7 @@ describe('ChatView', () => {
Object.defineProperty(scroller, 'clientHeight', { value: 300, writable: true })
// Inside FOLLOW_THRESHOLD (24) but not flush with the floor — the chrome
// re-render from setAtBottom must not force scrollTop to scrollHeight.
scroller.scrollTop = 690 // distance-to-bottom = 10
fireEvent.scroll(scroller)
readerScroll(scroller, 690) // distance-to-bottom = 10
expect(view.queryByLabelText('回到底部')).toBeNull()
expect(scroller.scrollTop).toBe(690)
})
@@ -684,8 +880,7 @@ describe('ChatView', () => {
const view = render(<h.ChatView {...h.props} />, { container: host })
// Open jump uses the host, not the local .scroll node.
expect(host.scrollTop).toBe(2000)
host.scrollTop = 100
fireEvent.scroll(host)
readerScroll(host, 100)
expect(view.getByLabelText('回到底部')).toBeTruthy()
fireEvent.click(view.getByLabelText('回到底部'))
expect(host.scrollTop).toBe(2000)
@@ -694,29 +889,72 @@ describe('ChatView', () => {
}
})
it('a remount restores the saved scroll position instead of re-jumping to the bottom', () => {
it('a remount restores the saved semantic row after width reflow', () => {
const host = document.createElement('div')
host.setAttribute('data-conversation-scroll', '')
Object.defineProperty(host, 'scrollHeight', { value: 2000, writable: true, configurable: true })
Object.defineProperty(host, 'clientHeight', { value: 500, writable: true, configurable: true })
Object.defineProperty(host, 'scrollTop', { value: 0, writable: true, configurable: true })
document.body.appendChild(host)
let anchorTop = 80
vi.spyOn(host, 'getBoundingClientRect').mockImplementation(
() => ({ top: 0, bottom: 500 } as DOMRect),
)
const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
if (this.dataset.chatAnchorKey === 'node:1') {
return { top: anchorTop, bottom: anchorTop + 40 } as DOMRect
}
return { top: 0, bottom: 40 } as DOMRect
})
try {
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
// Fresh open (nothing saved): the bottom jump stands.
const view = render(<h.ChatView {...h.props} />, { container: host })
expect(host.scrollTop).toBe(2000)
// Reader scrolls up; the position is recorded continuously.
host.scrollTop = 100
fireEvent.scroll(host)
readerScroll(host, 100)
// View-tab switch away and back: the view unmounts, then remounts.
view.rerender(<div />)
anchorTop = 560
host.scrollTop = 0
view.rerender(<h.ChatView {...h.props} />)
expect(host.scrollTop).toBe(100)
expect(host.scrollTop).toBe(580) // approximate 100 + the row's 480px reflow shift
// The restored position is above the floor: follow stays disarmed.
expect(view.getByLabelText('回到底部')).toBeTruthy()
} finally {
rect.mockRestore()
host.remove()
}
})
it('normalizes a semantic restore clamped to the bottom before an immediate remount', () => {
const host = document.createElement('div')
host.setAttribute('data-conversation-scroll', '')
Object.defineProperty(host, 'scrollHeight', { value: 2_000, writable: true, configurable: true })
Object.defineProperty(host, 'clientHeight', { value: 500, writable: true, configurable: true })
let scrollTop = 0
Object.defineProperty(host, 'scrollTop', {
configurable: true,
get: () => scrollTop,
set: (value: number) => { scrollTop = Math.min(value, 1_500) },
})
document.body.appendChild(host)
const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
if (this.dataset.chatAnchorKey === 'node:1') return { top: 300, bottom: 340 } as DOMRect
return { top: 0, bottom: 500 } as DOMRect
})
try {
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
h.chatScroll.save({ anchorKey: 'node:1', anchorTop: 80, scrollTop: 1_400 })
const view = render(<h.ChatView {...h.props} />, { container: host })
expect(host.scrollTop).toBe(1_500)
expect(h.chatScroll.read()).toBeNull()
view.rerender(<div />)
host.scrollTop = 0
view.rerender(<h.ChatView {...h.props} />)
expect(host.scrollTop).toBe(1_500)
} finally {
rect.mockRestore()
host.remove()
}
})

View File

@@ -7,10 +7,11 @@
mid-slide. */
.root {
--dsh-sidebar-inline-padding: 12px;
display: flex;
flex-direction: column;
height: 100%;
padding: 6px 12px;
padding: 6px var(--dsh-sidebar-inline-padding);
box-sizing: border-box;
background: var(--dsw-specific-sidebar-fill);
color: var(--dsw-alias-label-primary);
@@ -189,16 +190,22 @@
max-width: 0;
}
/* Region seat: always mounted so the foot never moves; the browser inside
handles its own wide/rail content. */
/* Region seat: always mounted so the foot never moves. Its trailing margin
cancels the wide shell inset so the nested scrollbar can sit at the sidebar
edge; the browser restores that inset inside its own rows. */
.regionArea {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
margin-right: calc(-1 * var(--dsh-sidebar-inline-padding));
overflow: hidden;
}
.collapsed .regionArea {
margin-right: 0;
}
/* Foot seat: a pure layout socket pinned under the region; the ui-settings
trigger row inside owns its own geometry (49px wide row / 36px rail
circle) and hover chrome. */

View File

@@ -0,0 +1,38 @@
/** Sidebar shell inset contract shared with the nested workspace browser. */
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
const css = readFileSync(fileURLToPath(new URL('../src/client/SidebarRoot.module.css', import.meta.url)), 'utf8')
/**
* Declarations of one exact selector, keyed by property.
* @param selector - exact selector text.
* @returns the normalized declarations, or undefined when absent.
*/
function declarations(selector: string): Map<string, string> | undefined {
const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, ' ')
for (const [, selectorList = '', body = ''] of withoutComments.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
if (!selectorList.split(',').map(value => value.trim()).includes(selector)) continue
const found = new Map<string, string>()
for (const part of body.split(';')) {
const colon = part.indexOf(':')
if (colon === -1) continue
found.set(part.slice(0, colon).trim(), part.slice(colon + 1).trim().replace(/\s+/g, ' '))
}
return found
}
return undefined
}
describe('SidebarRoot.module.css inset', () => {
it('shares and cancels the wide shell trailing padding structurally', () => {
const root = declarations('.root')
expect(root?.get('--dsh-sidebar-inline-padding')).toBe('12px')
expect(root?.get('padding')).toBe('6px var(--dsh-sidebar-inline-padding)')
expect(declarations('.regionArea')?.get('margin-right')).toBe(
'calc(-1 * var(--dsh-sidebar-inline-padding))',
)
expect(declarations('.collapsed .regionArea')?.get('margin-right')).toBe('0')
})
})

View File

@@ -38,9 +38,8 @@ describe('tsdown client artifact', () => {
async function loadArtifact() {
let handoff: Handoff | undefined
;(window as Win).__ModuleLoader__ = { load: (h) => { handoff = h } }
// Same execution form the loader uses (inline script eval, window scope) —
// the implied-eval ban targets accidental string execution, not this
// deliberate bundle-execution fixture.
// The implied-eval ban targets accidental string execution, not this
// deliberate built-bundle fixture running in the window scope.
// oxlint-disable-next-line typescript/no-implied-eval, typescript/no-unsafe-call
new Function(code!)()
expect(handoff).toBeDefined()

View File

@@ -4,10 +4,19 @@
rail state renders only the two 36x36 icon controls. */
.root {
--dsh-session-list-edge-inset: var(--dsh-sidebar-inline-padding);
--dsh-session-list-scrollbar-width: 8px;
--dsh-session-list-scrollbar-offset: 2px;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
box-sizing: border-box;
padding-right: var(--dsh-session-list-edge-inset);
}
.root.rail {
padding-right: 0;
}
.iconButton {
@@ -167,9 +176,14 @@
min-height: 0;
display: flex;
flex-direction: column;
margin-right: calc(-1 * var(--dsh-session-list-edge-inset));
overflow: hidden;
}
.rail .listArea {
margin-right: 0;
}
/* Relative for the bottom fade overlay. */
.treeBody {
flex: 1;
@@ -184,7 +198,7 @@
.fade {
position: absolute;
left: 0;
right: 0;
right: var(--dsh-session-list-edge-inset);
bottom: 0;
height: 72px;
background: linear-gradient(to bottom, transparent, var(--dsw-specific-sidebar-fill));
@@ -200,29 +214,28 @@
from { opacity: 0; }
}
/* List: the only scrolling region. Block, not a flex column: as flex items
the 54/34 rows would shrink under content overflow; block children keep
their design heights and the 4px rhythm rides margins instead of gap. */
/* List: the only scrolling region. Block children keep their design heights
under content overflow. The 2px edge offset, stable 8px themed scrollbar,
and remaining padding equal the shell's right inset, with or without
overflow, so moving the bar does not move the rows. */
.list {
flex: 1;
min-height: 0;
overflow-y: auto;
margin-right: var(--dsh-session-list-scrollbar-offset);
padding-right: calc(
var(--dsh-session-list-edge-inset)
- var(--dsh-session-list-scrollbar-width)
- var(--dsh-session-list-scrollbar-offset)
);
padding-bottom: 12px;
/* Row trailing content (the relative time, and the hover action buttons
that replace it) sits flush against the row's 8px right padding, so an
overlaid scrollbar covers it. Reserving the gutter keeps the bar beside
the rows instead of on top of them; `stable` holds the reservation when
the list is short enough not to scroll, so expanding a group does not
shift every row left. */
scrollbar-gutter: stable;
}
.list > [role='treeitem'] + [role='treeitem'] {
margin-top: 4px;
}
.searchTree > [role='treeitem'] + [role='treeitem'] {
margin-top: 4px;
.flatList > * + *,
.searchTree > [role='treeitem'] + [role='treeitem'],
.groupSection > * + * {
margin-top: 2px;
}
.searchStatus,
@@ -237,22 +250,11 @@
color: var(--dsw-alias-label-secondary);
}
/* One workspace section: header row + expanded session run. Rows inside
keep the former flat-list 4px gap as sibling margins; the inter-group
breathing room (figma 133:7661 batch separator, 20px after an expanded
run) rides the NEXT section's top margin so the last group adds none. */
.groupSection > * + * {
margin-top: 4px;
}
/* One workspace section: header row + a compact expanded session run. */
.groupSection + .groupSection {
margin-top: 4px;
}
.groupSection:has([aria-expanded='true']) + .groupSection {
margin-top: 20px;
}
.empty {
padding: 16px 12px;
color: var(--dsw-alias-label-tertiary);

View File

@@ -238,7 +238,7 @@ function FlatList({ useSessions, open, forkSession, onSessionRename, onSessionAr
const now = Date.now()
return (
<div className={clsx(css.treeBody, css.wide)}>
<div className={css.list} role="tree" aria-label={t('section.sessions')}>
<div className={clsx(css.list, css.flatList)} role="tree" aria-label={t('section.sessions')}>
{rows.length === 0 && (
<div className={css.empty}>{t('empty.none')}</div>
)}

Some files were not shown because too many files have changed in this diff Show More