Merge remote-tracking branch 'origin/master' into dshw/pr-deepseek-harness-deepseek-harness-2702

This commit is contained in:
_Kerman
2026-08-20 17:52:56 +08:00
115 changed files with 2653 additions and 477 deletions
@@ -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-24-web-config-tree-boot-and-transport-layering.md
2026-07-24-web-config-tree-boot-and-transport-layering.md: f397ca6a25cffa0ba709d0a8e84717a1c228b951
2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: e1cde78bad16d618913e52894eff1271dac74be1
2026-07-24-web-config-tree-boot-and-transport-layering.md: eb30ba84ef293a169931ef6519a9d6d2ea98af7f
2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 7a175b44fa8806b4b73eca538f32f16aa778fdee
@@ -18,7 +18,7 @@ English | [中文](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md)
**Config sources have one declaration place each.** Bundle yml values are engineering defaults, Settings sections are writable user preferences, CLI flags address their owning launcher rows, and env values enter through yml `!!js` expressions. Patches replace a row's config wholesale. The resolved frontend `distIndex` uses that patch channel as an assembly fact. The transport-independent provider/model default belongs to `ctx.agentDefaultModel`; the [direct headless entry point](2026-08-09-headless-direct-core-entry-point.md) and the Web gateway consume the same state.
**The transport splits five ways.** `dsh-host-apiproxy` is the gateway plugin (`api-gateway` row): it default-exports `ApiProxyService`, configures only `{nativeOpen?}`, consumes the base layer's entry-point-neutral `ctx.agentDefaultModel`, provides `ctx.apiProxy`, remains transport-agnostic, and registers no routes. `dsh-host-webserver` is a plain route-registration plugin: `WebServer` provides `ctx.webServer` (`register(route) → disposer` with duplicate-pattern throw, `tapIndex` transforms applied in registration order, `port`), listens on activation, answers per-request failures with 400 and logging, and knows no harness concepts. The connection node half owns the `/api` binding from `ctx.apiProxy` through `toFetchHandler`. The modules node half (`ClientModuleRegistry`, providing `ctx.clientModules`) owns incremental package scanning, the bundle route, the index tap, and `onRebuilt`/`onGraphChanged` notification. The hmr node half owns dev reload through `fs.watchFile` membership and the `/plugins/events` SSE route.
**The transport splits five ways.** `dsh-host-apiproxy` is the gateway plugin (`api-gateway` row): it default-exports `ApiProxyService`, configures only `{nativeOpen?}`, consumes the base layer's entry-point-neutral `ctx.agentDefaultModel`, provides `ctx.apiProxy`, remains transport-agnostic, and registers no routes. `dsh-host-webserver` is a plain route-registration plugin: `WebServer` provides `ctx.webServer` (`register(route) → disposer` with duplicate-pattern throw, `renderIndex` rendering — structured `webserver/index-inject` rows, then raw `tapIndex` transforms in registration order — and `port`), listens on activation, answers per-request failures with 400 and logging, and knows no harness concepts. The connection node half owns the `/api` binding from `ctx.apiProxy` through `toFetchHandler`. The modules node half (`ClientModuleRegistry`, providing `ctx.clientModules`) owns incremental package scanning, the bundle route, the boot injection rows, and `onRebuilt`/`onGraphChanged` notification. The hmr node half owns dev reload through `fs.watchFile` membership and the `/plugins/events` SSE route.
**Package export discipline.** The modules package exposes exactly `.` (node half) and `./client` (the complete browser half: `ClientModuleSystem`, `parseBootManifest`, the adoption plugin face) — no bespoke subpaths; wire types re-export through the root for host-side consumers. The adoption handshake: the kernel writes the constructed instance to `window.__DSH_MODULES__` before cordis exists; the `./client` apply reads the slot (missing = loud throw) and provides `ctx.modules`.
@@ -18,7 +18,7 @@ Status: implemented
**每个配置源有唯一声明位置。** 组合包 yml 值是工程默认,Settings 分节是可写的用户偏好,CLI(命令行界面)flags 面向其归属的启动器配置行,env 值则通过 yml `!!js` 表达式进入。patch 会整体替换一行的 config。解析后的前端 `distIndex` 通过同一条 patch 通道作为组装事实传递。与传输无关的提供方/模型默认值归 `ctx.agentDefaultModel` 所有;[直接 headless 入口](2026-08-09-headless-direct-core-entry-point.md)与 Web 网关消费同一份状态。
**传输五分。** `dsh-host-apiproxy` 是网关插件(`api-gateway` 行):默认导出 `ApiProxyService`,只配置 `{nativeOpen?}`,消费 base 层不偏向特定入口的 `ctx.agentDefaultModel`provide `ctx.apiProxy`,保持传输无关且不注册路由。`dsh-host-webserver` 是朴素的路由注册插件:`WebServer` provide `ctx.webServer``register(route) → disposer`、重复 pattern 即抛、`tapIndex` 按注册序应用`port`),激活即 listen,单请求失败时答 400 并记日志,且不认识任何 harness 概念。connection node 半拥有从 `ctx.apiProxy``toFetchHandler` 绑定到 `/api` 的逻辑。modules node 半(`ClientModuleRegistry`provide `ctx.clientModules`)拥有单包增量扫描、bundle 路由、index tap `onRebuilt`/`onGraphChanged` 通知。HMR(热模块替换) node 半通过 `fs.watchFile` membership 与 `/plugins/events` SSE 路由拥有开发期重载。
**传输五分。** `dsh-host-apiproxy` 是网关插件(`api-gateway` 行):默认导出 `ApiProxyService`,只配置 `{nativeOpen?}`,消费 base 层不偏向特定入口的 `ctx.agentDefaultModel`provide `ctx.apiProxy`,保持传输无关且不注册路由。`dsh-host-webserver` 是朴素的路由注册插件:`WebServer` provide `ctx.webServer``register(route) → disposer`、重复 pattern 即抛、`renderIndex` 渲染——先结构化 `webserver/index-inject` 行、后原始 `tapIndex` 按注册序应用——与 `port`),激活即 listen,单请求失败时答 400 并记日志,且不认识任何 harness 概念。connection node 半拥有从 `ctx.apiProxy``toFetchHandler` 绑定到 `/api` 的逻辑。modules node 半(`ClientModuleRegistry`provide `ctx.clientModules`)拥有单包增量扫描、bundle 路由、启动注入行`onRebuilt`/`onGraphChanged` 通知。HMR(热模块替换) node 半通过 `fs.watchFile` membership 与 `/plugins/events` SSE 路由拥有开发期重载。
**包出口纪律。** modules 包只暴露 `.`node 半)与 `./client`(完整浏览器半:`ClientModuleSystem``parseBootManifest`、收编插件面)——不设专用子路径;wire 类型经根出口 re-export 给 host 侧消费方。收编握手:内核在 cordis 之前把建好的实例写入 `window.__DSH_MODULES__``./client` 的 apply 读取该槽位(缺少时显式抛错)并 provide `ctx.modules`
@@ -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/architecture/2026-08-19-web-index-injection-table.md
2026-08-19-web-index-injection-table.md: 9ed02aa94cd318d107a32802d8723652e6b10ea2
2026-08-19-web-index-injection-table.zh.md: 8ad036766faa14071b20da12ef907ab012cae23f
@@ -0,0 +1,30 @@
# Agent Note: structured index injection table (webserver/index-inject)
Status: implemented
English | [中文](2026-08-19-web-index-injection-table.zh.md)
## Problem
The web shell's boot HTML needs three kinds of injection: client-modules' boot protocol (the `__ModuleLoader__` registration queue inline script, the parser-blocking preload `<script src>` tags, the `__DSH_BOOT__` graph global) and ui-theme's first-paint theme script. The old mechanism was `webServer.tapIndex(html => html)` string transforms: each registrant regex-located `<head>`/`<body>` and spliced HTML on its own. The static worker deployment (the page is a build artifact; the host tree runs in a Web Worker) has no serve-HTML step at all, so the worker side hand-copied the same data into its `/__boot__` payload (`graph` + `theme` via `ctx.get`), and the page side re-implemented what the taps did (a facade installer, a theme applier, a preload loop) — one boot semantics, three implementations.
## Decision
Make the injection surface an event over pure data: the webserver declares the `webserver/index-inject` event and the `IndexInjection` row union (`global`/`script`/`script-src`/`style`/`html`, `head|body` placement). A plugin that wants to inject subscribes and pushes rows; every collection (`collectIndexInjections()`) is a fresh emit, so subscribers read live state at emit time (module graph, theme preference — no re-registration staleness), and a subscription dies with its fiber.
One table, two renderers: the served form's `webServer.renderIndex(html)` renders rows into index.html deterministically (head rows after the opening head tag, body rows after the opening body tag; `<` JSON-escaped in global values, attribute-escaped `src`); the worker form's `/__boot__` payload is `{ injections }`, executed row by row by a small page-side interpreter (set global / create script element / load external through the tunnel's `loadBundle` / mount style and markup). Rows are pure JSON data — that is the both-ends-equivalent discipline.
`tapIndex`/`applyIndexTaps` survive as the raw-HTML escape hatch, applied after row rendering; every internal consumer moved to the event.
## Consequences
- client-modules and ui-theme no longer regex-edit HTML; the worker's `readBootPayload` service-poking (`clientModules`, `settings`, theme constants through `loader.load`) is deleted; the page-side `installModuleLoaderFacade`, `applyBootTheme`, and `PARSER_PRELOAD_IDS` re-implementations retire.
- Ordering: across subscribers, subscription order (same as the old tap order); within one subscriber, push order — modules itself guarantees queue → preloads → global.
- The served rendering of the manifest global changed from `window.__DSH_BOOT__ =` to `globalThis["__DSH_BOOT__"] =`; no committed snapshot expectation carries that text, so none needed re-recording.
- New model-visible or page-visible boot inputs extend the row union; no new tap consumers.
## Alternatives considered
- **Keep tap functions, add a worker-side renderer that re-runs them over a fake document** — rejected: taps are opaque `html => html` closures, so the worker cannot serialize or replay them without shipping a DOM emulation into the boot path.
- **A registration-style table (`registerInjection(row): dispose`)** — rejected for the two problems the event dissolves: rows staled against live state (theme preference, module graph) unless every producer re-registered on change, and every producer owned one more disposer. The per-emit pull reads fresh state with fiber-scoped cleanup for free.
- **Deleting `tapIndex` outright** — rejected: an escape hatch for raw HTML transforms costs nothing while the table is young, and external compositions may have transforms no row kind expresses yet.
@@ -0,0 +1,30 @@
# Agent Note: 结构化 index 注入表(webserver/index-inject 事件)
Status: implemented
[English](2026-08-19-web-index-injection-table.md) | 中文
## Problem
Web 壳的启动 HTML 需要三类注入:client-modules 的引导协议(`__ModuleLoader__` 注册队列内联脚本、parser 阻塞的 preload `<script src>``__DSH_BOOT__` 全局图)与 ui-theme 的首帧主题脚本。旧机制是 `webServer.tapIndex(html => html)` 字符串变换:每个注册方各自用正则找 `<head>`/`<body>` 改 HTML。静态 worker 部署(页面是构建产物、host 树在 Web Worker 里)没有「服 HTML」这一步,于是 worker 侧只能在 `/__boot__` 载荷里手工重抄同一批数据(graph + theme,经 `ctx.get` 硬掏),页面侧再用手写代码(facade 安装、theme 应用、preload 循环)把 tap 干的事重演一遍——同一份启动语义存在三份实现。
## Decision
注入面事件化、数据化:webserver 声明 `webserver/index-inject` 事件与纯数据行类型 `IndexInjection``global`/`script`/`script-src`/`style`/`html``head|body` 定位)。想注入的插件订阅事件、往表里 push 行;每次收集(`collectIndexInjections()`)都是一次全新 emit,订阅方现读现填(模块图、主题偏好天然新鲜,无重注册问题),订阅随 fiber 销毁自动摘除。
一张表两个渲染器:served 形态 `webServer.renderIndex(html)` 确定性把行渲染进 index.htmlhead 行插 head 首、body 行插 body 首,全局值 JSON `<` 转义、src 属性转义);worker 形态 `/__boot__` 载荷就是 `{ injections }`,页面侧小解释器逐行执行(设全局 / 建脚本元素 / 经 tunnel loadBundle 载外链 / 挂样式与 DOM)。行是纯 JSON 数据,这是双端等价的纪律。
`tapIndex`/`applyIndexTaps` 保留为原始 HTML 变换的逃生口,在行渲染之后执行;内部消费者全部迁走。
## Consequences
- client-modules 与 ui-theme 不再各自正则改 HTMLworker 侧 `readBootPayload``ctx.get` 手掏(clientModules、settings、theme 常量 loader.load)删除;页面侧 `installModuleLoaderFacade``applyBootTheme``PARSER_PRELOAD_IDS` 三份重抄退役。
- 顺序语义:跨订阅方按订阅注册顺序(与旧 tap 顺序一致),单订阅方内按 push 顺序;modules 自己保证 队列→preload→全局 三行有序。
- `__DSH_BOOT__` 的 served 渲染文本从 `window.__DSH_BOOT__ =` 变为 `globalThis["__DSH_BOOT__"] =`;已核实无已提交快照期望含此文本,无需重录。
- 新的模型可见/页面可见注入一律走行类型扩展,不再新增 tap 消费者。
## Alternatives considered
- **保留 tap 函数、worker 侧对假 document 重放**——否决:tap 是不透明的 `html => html` 闭包,worker 无法序列化或重放,除非把 DOM 仿真塞进启动链。
- **注册表式(`registerInjection(row): dispose`)**——否决于事件天然化解的两个问题:行数据会相对活状态(主题偏好、模块图)过期,除非每个生产者变更时重注册;且每个生产者多背一个 disposer。按次 emit 的拉取免费获得新鲜读取与 fiber 级清理。
- **直接删除 `tapIndex`**——否决:表还年轻,原始 HTML 变换的逃生口零成本,外部组合可能还有行类型暂不能表达的变换。
@@ -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-08-10-pre-plugin-theme-bootstrap.md
2026-08-10-pre-plugin-theme-bootstrap.md: 7f5c57316d7dd022b08918459282a6f6128eb93b
2026-08-10-pre-plugin-theme-bootstrap.zh.md: 7152bf8dc2bbfae2a671c4da899cbebda0bd4b39
2026-08-10-pre-plugin-theme-bootstrap.md: cf3b2d5b6c8e8b102df6a37bab0fc63d81d8736b
2026-08-10-pre-plugin-theme-bootstrap.zh.md: 2836f7b6d32528796bf64bf708229d5677866364
@@ -12,7 +12,7 @@ The web shell renders `Loading plugins…` before the browser-side plugin tree a
## Decision
ui-theme's host half transforms each index HTML document through `ctx.webServer.tapIndex()`, inserting a synchronous inline script immediately after the opening `<body>` tag. The transform registers under an optional `httpServer` injection, so compositions without that service still activate ui-theme and install no transform. When the HTML parser executes the script, the body exists, but the shell's module script and framework-free boot page have not yet run.
ui-theme's host half answers every `webserver/index-inject` collection with one body-placed script row (`bootThemeInjection`), which `renderIndex` renders as a synchronous inline script immediately after the opening `<body>` tag. The subscription is unconditional — a composition without a web server never emits the event, so ui-theme still activates and contributes nothing. When the HTML parser executes the script, the body exists, but the shell's module script and framework-free boot page have not yet run.
The host half registers the [`ui-theme.preference` settings section](2026-08-06-host-backed-web-preferences.md) when a settings provider exists. For each index response, it embeds that schema-validated built-in preference in the inline script; without a settings provider or active registration, it embeds the `system` default. The browser resolves `system` through `prefers-color-scheme`, falling back to light when `matchMedia` is unavailable. It writes only the two pieces of DOM state that ThemePresenter later owns: `document.documentElement.style.colorScheme` and `body[data-ds-dark-theme]`.
@@ -12,7 +12,7 @@ Web 壳在浏览器侧插件树激活前呈现 `Loading plugins…`。ui-theme
## 决策
ui-theme 的主机侧通过 `ctx.webServer.tapIndex()` 转换每份 index HTML,在 `<body>` 起始标签后紧接一段同步内联脚本。该转换通过可选的 `httpServer` 注入注册,因此不含该服务的组合仍会激活 ui-theme,但不会安装转换。HTML 解析器执行该脚本时,body 已存在,而壳的模块脚本与不依赖框架的启动页尚未运行。
ui-theme 的主机侧以一条 body 定位的 script 行(`bootThemeInjection`)回应每次 `webserver/index-inject` 收集,`renderIndex` 把它渲染为 `<body>` 起始标签后紧接一段同步内联脚本。订阅是无条件的——没有 web server 的组合根本不会 emit 该事件,ui-theme 照常激活且不贡献任何行。HTML 解析器执行该脚本时,body 已存在,而壳的模块脚本与不依赖框架的启动页尚未运行。
settings provider 存在时,主机侧会注册 [`ui-theme.preference` settings 分节](2026-08-06-host-backed-web-preferences.md)。它为每份 index 响应把经过 schema 校验的内建偏好嵌入内联脚本;不存在 settings provider 或有效注册时则嵌入默认值 `system`。浏览器通过 `prefers-color-scheme` 解析 `system`,不支持 `matchMedia` 时回退为浅色。脚本只写 ThemePresenter 后续拥有的两项 DOM 状态:`document.documentElement.style.colorScheme``body[data-ds-dark-theme]`
@@ -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/feature/2026-08-19-high-cache-hit-decimal-display.md
2026-08-19-high-cache-hit-decimal-display.md: 952d838fdf330175915e13ef767fd80d145506b2
2026-08-19-high-cache-hit-decimal-display.zh.md: 4a72becd608e756507f3d5a6fd051b4b2b437964
@@ -0,0 +1,50 @@
# Agent Note: High cache-hit decimal display
Status: implemented
English | [中文](2026-08-19-high-cache-hit-decimal-display.zh.md)
## Problem
The Web conversation stats line rounded every non-empty cache-hit ratio to an integer. Once the actual ratio passed 99%, the display hid further progress, and a ratio of at least 99.5% appeared as 100% even while uncached input or cache writes remained.
Users therefore could not distinguish a nearly complete cache hit from a true full hit.
## Decision
`StatsLine` continues to derive the ratio from the whole-session `tokenUsage` projection owned by `@deepseek-ai/dsh-token-meter`; the projection remains the only owner of the uncached-input, cache-read, cache-write, and output counts ([projection decision](../architecture/2026-07-29-projected-token-usage-and-request-context.md)). The presentation layer changes only the text inserted into the existing `stats.cacheHit` locale template.
| Actual ratio | Display |
|---|---|
| No billed input | Cache-hit group omitted |
| Integer rounding is below 100% | Rounded integer |
| Non-full ratio whose current rounding is 100% | Minimum decimal precision whose rounded result is below 100% |
| 100% | `100%` |
Every non-empty ratio starts at zero decimal places. A non-full ratio increases precision one place at a time only while rounding would produce 100%, so `99.1%` and `99.49%` remain `99%`, while `99.5%`, `99.95%`, and `99.995%` retain one, two, and three decimal places respectively. `StatsLine` uses exact small-factor comparisons over the safe-integer token counts, then scales the near-full gap only while the intermediate remains within that range. This avoids floating-point tie errors without imposing a precision cap or substitute label. A full hit does not carry a redundant decimal. The same derived string feeds the inline row and its overflow tooltip.
## Ownership and lifecycle
Token-meter continues to fold usage from the complete durable session log. `StatsLine` performs a synchronous display derivation whenever the standard projection value changes. It introduces no setting, stored percentage, event, wire field, client state, or recovery path.
Live updates, reload replay, and reconnect recovery all restore the same `tokenUsage` counts and run the same display function. A missing projection still omits every token group, and a zero input denominator still omits only the cache-hit group.
## Verification
The component spec pins the zero denominator, ordinary integer rounding, half-step rounding at several decimal precisions, each precision boundary through three decimal places, a near-full cumulative sample that needs fourteen decimal places, the true `100%` result, both locales, and equality between inline and tooltip values. The assembled `lifecycle-chrome` replay sidecar selects `9,950 / 10,000 = 99.5%` as a deterministic ratio that integer rounding would misreport as 100% while the base session fixture remains recordable; the live assertion and post-reload browser snapshot both display `99.5%` without another model call.
## Alternatives considered
**Keep integer rounding for every ratio.** Rejected because it hides all movement above 99% and still reports some non-full hits as 100%.
**Truncate the high band to one decimal.** Rejected because `99.95%`, `99.995%`, and still closer ratios all collapse to `99.9%` instead of retaining the minimum precision that distinguishes them from a full hit.
**Cap precision and use a substitute such as `<100%`.** Rejected because the exact cumulative counts can produce the required numeric result, and a cap would make display behavior depend on an arbitrary presentation limit.
**Show one decimal at every ratio.** Rejected because the additional low-band motion adds noise and changes the established display where integer precision is sufficient.
**Persist a display percentage in token-meter.** Rejected because the projection already carries the exact counts, while presentation precision belongs to the Web stats line. A second stored value would duplicate derivable state and expand replay and wire responsibilities.
## Consequences
High cache-hit sessions remain visually stable until integer rounding would falsely report a full hit, then expose only the decimal places needed to preserve that distinction. Extremely close non-full ratios can therefore produce long decimal strings; this is the accepted cost of having no arbitrary precision cap or nonnumeric fallback. Every delivery and recovery path stays on the existing durable projection lifecycle.
@@ -0,0 +1,50 @@
# Agent Note: 高缓存命中率的小数显示
Status: implemented
[English](2026-08-19-high-cache-hit-decimal-display.md) | 中文
## 问题
Web 会话统计行会把所有非空缓存命中率舍入为整数。真实比率超过 99% 后,显示会隐藏后续提升;比率达到 99.5% 时,即使仍有未缓存输入或缓存写入,也会显示为 100%。
用户因此无法区分接近完整的缓存命中与真实满命中。
## 决策
`StatsLine` 继续从 `@deepseek-ai/dsh-token-meter` 所拥有的完整会话 `tokenUsage` 投影派生比率;该投影仍是未缓存输入、缓存读取、缓存写入与输出计数的唯一所有方([投影决策](../architecture/2026-07-29-projected-token-usage-and-request-context.md))。展示层只改变插入现有 `stats.cacheHit` locale 模板的文本。
| 真实比率 | 显示结果 |
|---|---|
| 没有计费输入 | 省略缓存命中分组 |
| 整数舍入结果低于 100% | 舍入后的整数 |
| 当前舍入结果为 100% 的非满命中 | 舍入结果低于 100% 所需的最少小数位 |
| 100% | `100%` |
所有非空比率都从零位小数开始。非满命中只有在舍入结果会成为 100% 时才逐位增加精度,因此 `99.1%``99.49%` 仍显示为 `99%`,而 `99.5%``99.95%``99.995%` 分别保留一位、两位与三位小数。`StatsLine` 对安全整数 token 计数执行精确的小因子比较,并且只在中间值仍处于该范围内时缩放接近满命中的差值。该算法既避开浮点临界值误差,也不设置精度上限或替代文案。真实满命中不会携带多余的小数。同一份派生字符串同时用于行内统计与溢出 tooltip。
## 归属与生命周期
token-meter 继续从完整持久会话日志折叠用量。标准投影值变化时,`StatsLine` 同步派生显示文本。本决策不引入设置、持久百分比、事件、协议字段、客户端状态或恢复路径。
实时更新、刷新回放与重连恢复都会还原同一组 `tokenUsage` 计数,并运行同一个显示函数。投影缺失时仍会省略全部 token 分组;输入分母为零时仍只省略缓存命中分组。
## 验证
组件测试固定了零分母、普通整数舍入、多个小数精度上的半步舍入、直至三位小数的各个精度边界、需要十四位小数的近满累计样本、真实 `100%`、两种 locale,以及行内值与 tooltip 值的一致性。组装后的 `lifecycle-chrome` replay sidecar 将 `9,950 / 10,000 = 99.5%` 选作确定性测试输入;该比率按整数舍入会误报为 100%,同时基础会话 fixture 仍可重录。活跃页面断言与刷新后的浏览器快照都会显示 `99.5%`,且不会产生额外模型调用。
## 备选方案
**对所有比率继续使用整数舍入。** 不予采纳,因为它会隐藏 99% 以上的全部变化,并继续把部分非满命中显示为 100%。
**把高位区间向下截取到一位小数。** 不予采纳,因为 `99.95%``99.995%` 以及更接近满命中的比率都会坍缩为 `99.9%`,无法保留区分真实满命中所需的最少精度。
**限制精度并使用 `<100%` 等替代文案。** 不予采纳,因为精确累计计数能够产生所需的数值结果,而精度上限会让显示行为依赖任意的展示限制。
**所有比率都显示一位小数。** 不予采纳,因为低位区间的额外变化会增加无效抖动,并改变整数精度已经足够的既有显示。
**在 token-meter 中持久化显示百分比。** 不予采纳,因为投影已经携带精确计数,而展示精度属于 Web 统计行。第二个持久值会复制可派生状态,并扩大回放与协议职责。
## 后果
高缓存命中率会保持稳定的整数显示,直到整数舍入会错误地报告满命中;此时界面只展示维持区分所需的小数位。极接近满命中的非满比率可能因此产生较长的小数字符串,这是不设置任意精度上限或非数值回退所接受的代价。所有交付与恢复路径继续沿用既有持久投影生命周期。
@@ -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/feature/2026-08-19-web-markdown-wide-table-view.md
2026-08-19-web-markdown-wide-table-view.md: fc025f6755caa2ca160b02f0c03ae80fd04cc1c7
2026-08-19-web-markdown-wide-table-view.zh.md: 4d13a29f69fac31f9796dae064726d4d3a779ffd
@@ -0,0 +1,37 @@
# Agent Note: Web markdown tables fill the column by count, wide ones break out
Status: implemented
English | [中文](2026-08-19-web-markdown-wide-table-view.zh.md)
## Problem
`MarkdownText` rendered every GFM table at its natural width (`.tableScroll table { width: max-content; max-width: max-content }`, `packages/client/ui-primitives/src/markdown/MarkdownText.module.css`), so any table wider than the 748px message column could only be read through horizontal scrolling. A three-column table whose cells could comfortably wrap still forced a scroll, and a genuinely wide table could never use more than the message column even when the transcript around it had hundreds of spare pixels. Issue #1761 (with external feedback dsh-external/issues#520) asks for wrap-first adaptation and a wider view for tables that need it. The deepsuite chat product solved the same problem CSS-first; per review direction this change mirrors that solution instead of the interactive expand-dialog approach first drafted here.
## Decision
**Column count picks the sizing arm, statically, in the renderer.** `renderTable` reads the parsed column count (the `align` array, header-row fallback): tables under four columns — and any table inside a blockquote (`inBlockquote` render-context flag) — get the module's `tableFill` class, `table { width: 100%; max-width: none }`, filling the column with cells wrapping down to their existing `min-width: 100px` floor. Four-or-more-column tables keep the natural-width rules and instead carry the stable global hook class `md-table-wide` (the `md-code-block` precedent), so a hosting layout can widen them. This is deepsuite chat's discriminator (`.wrapper:not(:has(th:nth-child(4), td:nth-child(4)))` plus its blockquote exemption) computed in the renderer, which already knows the column count. No measurement, observers, or interaction state anywhere.
**The chat transcript widens hooked tables with container-query CSS.** ChatView's `.scroll` declares `container-type: inline-size`, and `AssistantMarkdown.module.css` gives `.body :global(.md-table-wide)` the breakout: `--dsh-table-spare` is the per-side spare width `max(0px, (100cqw - --dsh-chat-content-width) / 2)`, `--dsh-table-lead` adds the wrapper's own indent (`min(--dsh-chat-content-width, 100cqw) - 100%`), and width/negative-margin/lead-padding combine so the wrapper's scroll area spans the full transcript while the table content keeps starting at the message column's left edge. `100cqw` is the CSS stand-in for deepsuite chat's JS-measured `--dsl-virtual-list-width`; the `max(0px, …)` clamp replaces its below-SM JS gate, degrading continuously to the plain in-column scroll when the transcript is narrower than the message column. Scoping the rule under `AssistantMarkdown .body` keeps tool cards, compaction rows, and every other `MarkdownText` surface at plain in-column behavior.
**The parity fixtures change deliberately.** The table-containing markdown-dom fixtures pin the discriminator: `tableScroll tableFill` for narrow and blockquote tables, `tableScroll md-table-wide` for wide ones, and a new `table-wide-and-blockquote` corpus document pins both arms of the blockquote exemption.
## Alternatives considered
**Overflow-measured chrome: a ResizeObserver-gated expand entry opening the table in a `Modal` wide view.** Implemented first, then rejected on review direction in favor of deepsuite chat parity: the CSS solution needs no per-table observers, no dialog state that the streaming finalize swap would drop, no label plumbing through the cordis-free package, and gives the wide view permanently instead of behind an interaction.
**`:has()`-based column counting in CSS, as deepsuite chat does.** Rejected: their wrapper is generic while this renderer already walks the table node, so the count is available statically; a class is cheaper than a `:has()` selector re-evaluated on DOM changes and pins the decision in the DOM for fixtures.
**Breaking out to the viewport rather than the transcript.** Rejected: the conversation column pins `overflow-x: hidden` (the one-axis contract in `apps/web/tests/conversation-column-overflow.e2e.ts`), and anything wider than the transcript box would clip; the transcript width is exactly the space the layout actually has.
## Consequences
An ordinary wide table reads in place with wrapped cells; a many-column table keeps its readable natural width, spans the whole transcript where the layout has spare width, and scrolls for the remainder — with no interaction required and nothing to restore. A wide table's horizontal bar reveals on hover instead of staying painted: Chromium never repaints state-conditioned scrollbar styles (neither hover-conditioned `::-webkit-scrollbar*` rules nor a `:hover` `scrollbar-color` change reaches the painted bar — measured headed and headless), so the reveal toggles `overflow-x` itself (`hidden` at rest, `auto` on hover or focus), with resting `padding-bottom` matching the themed bar height so the appearing bar replaces it without moving content below. Resting `overflow-x: hidden` drops Chromium's implicit scroller focusability, so wide wrappers carry an explicit `tabindex="0"` (a `:focus-visible` ring marks them, and focus restores scrolling for arrow keys). Two knowledge edges: `container-type: inline-size` on ChatView's `.scroll` makes it the nearest query container for anything inside the transcript that later uses container units, and sub-four-column tables now always stretch to the full column width (deepsuite chat behavior) rather than shrink-wrapping short content.
## Testing
The markdown-dom parity fixtures pin the wrapper classes per arm, including the new `table-wide-and-blockquote` document; `markdown-render-units.client.spec.tsx` covers the hand-built rowless/align-less fallback. `apps/web/tests/markdown-wide-table.e2e.ts` seeds a closed turn with three tables (three-column fill, twelve-column wide, long-token/CJK long-cell) and, in real Chromium, pins the relations golden across viewport stops — fill and long-cell tables fill the column with no residual scroll at every stop and grow taller as the column narrows, the wide table always scrolls, breaks out past the message column exactly at the stops where the transcript is wider than it, keeps its content left-aligned with the fill table's under the breakout, and clamps to neutral at the narrow stop — plus arrow-key scrolling of the focused wrapper, a zoom arm, and a deviceScaleFactor-2 arm that must report the same relations.
## Related
- [Web markdown incremental AST renderer](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.md) — the renderer and DOM-parity contract this change extends.
@@ -0,0 +1,37 @@
# Agent NoteWeb markdown 表格按列数填充消息列,宽表突破列宽
Status: implemented
[English](2026-08-19-web-markdown-wide-table-view.md) | 中文
## 问题
`MarkdownText` 把每个 GFM 表格都按自然宽度渲染(`.tableScroll table { width: max-content; max-width: max-content }``packages/client/ui-primitives/src/markdown/MarkdownText.module.css`),于是任何比 748px 消息列更宽的表格都只能靠横向滚动阅读。单元格本可以舒适换行的三列表格也被迫滚动;而真正宽的表格即便转录区周围有几百像素的空余,也永远只能用消息列那么宽。Issue #1761(含外部反馈 dsh-external/issues#520)要求先换行适应,并为需要的表格提供更宽的视图。deepsuite chat 产品已用 CSS 优先的方式解决了同一问题;按评审方向,本变更对齐该方案,替换此前起草的交互式展开对话框方案。
## 决定
**列数在渲染器里静态决定尺寸分支。**`renderTable` 读取解析出的列数(`align` 数组,缺省回退表头行):不足四列的表格——以及 blockquote 内的任何表格(渲染上下文的 `inBlockquote` 标志)——获得模块的 `tableFill` 类,`table { width: 100%; max-width: none }`,填满消息列,单元格按既有 `min-width: 100px` 下限换行。四列及以上的表格保持自然宽度规则,改挂稳定的全局钩子类 `md-table-wide`(沿用 `md-code-block` 先例),供宿主布局加宽。这正是 deepsuite chat 的判别式(`.wrapper:not(:has(th:nth-child(4), td:nth-child(4)))` 及其 blockquote 豁免),只是移到已经掌握列数的渲染器里计算。全程没有测量、observer 或交互状态。
**聊天转录区用容器查询 CSS 加宽挂钩表格。**ChatView 的 `.scroll` 声明 `container-type: inline-size``AssistantMarkdown.module.css``.body :global(.md-table-wide)` 定义突破:`--dsh-table-spare` 是单侧空余宽度 `max(0px, (100cqw - --dsh-chat-content-width) / 2)``--dsh-table-lead` 再加上包裹层自身的缩进(`min(--dsh-chat-content-width, 100cqw) - 100%`),宽度/负 margin/前导 padding 组合起来,让包裹层的滚动区横跨整个转录区,而表格内容仍从消息列左缘起排。`100cqw` 是 deepsuite chat 用 JS 测量的 `--dsl-virtual-list-width` 的 CSS 等价物;`max(0px, …)` 钳制取代其 below-SM 的 JS 开关,转录区窄于消息列时连续退化为普通列内滚动。规则限定在 `AssistantMarkdown .body` 之下,工具卡片、压缩行等其他 `MarkdownText` 表面保持普通列内行为。
**一致性 fixture 的变化是有意的。**含表格的 markdown-dom fixture pin 住判别结果:窄表与 blockquote 表为 `tableScroll tableFill`,宽表为 `tableScroll md-table-wide`;新增的 `table-wide-and-blockquote` 语料文档同时 pin 住 blockquote 豁免的两个分支。
## 曾考虑的替代方案
**实测溢出的交互件:ResizeObserver 门控的展开入口,用 `Modal` 打开宽视图。**先行实现,后按评审方向否决、改为对齐 deepsuite chatCSS 方案不需要逐表 observer,没有会被流式定稿替换丢弃的对话框状态,不用穿过 cordis-free 包的文案管道,且宽视图是常驻的而非藏在交互后面。
**像 deepsuite chat 那样用 `:has()` 在 CSS 里数列。**否决:它们的包裹层是通用组件,而本渲染器本来就在遍历表格节点,列数是静态可得的;类名比随 DOM 变化反复求值的 `:has()` 选择器更便宜,还把决定固化进 DOM 供 fixture pin 住。
**突破到视口宽而不是转录区宽。**否决:会话列 pin 死了 `overflow-x: hidden``apps/web/tests/conversation-column-overflow.e2e.ts` 的单轴契约),超出转录区盒子的部分会被裁剪;转录区宽度正是布局实际拥有的空间。
## 后果
普通宽表原地换行阅读;多列表格保持可读的自然宽度,在布局有空余处横跨整个转录区,剩余部分滚动——无需任何交互,也没有状态要恢复。宽表的横向滚动条悬停才出现、不再常驻:Chromium 从不重绘状态条件化的滚动条样式(悬停条件化的 `::-webkit-scrollbar*` 规则和 `:hover` 下的 `scrollbar-color` 变化都到不了已绘制的滚动条——有头与无头模式均已实测),因此显隐切换的是 `overflow-x` 本身(静止 `hidden`,悬停或聚焦 `auto`),静止时的 `padding-bottom` 与主题滚动条高度一致,出现的滚动条恰好顶替它、下方内容不动。静止的 `overflow-x: hidden` 会失去 Chromium 对滚动容器的隐式可聚焦性,因此宽表包裹层带显式 `tabindex="0"``:focus-visible` 有焦点圈,聚焦后方向键可滚)。两个需要知道的点:ChatView `.scroll` 上的 `container-type: inline-size` 使它成为转录区内后续使用容器单位的最近查询容器;不足四列的表格现在总是拉伸到整列宽(deepsuite chat 行为),而不是按内容收缩。
## 测试
markdown-dom 一致性 fixture 按分支 pin 住包裹层类名,含新增的 `table-wide-and-blockquote` 文档;`markdown-render-units.client.spec.tsx` 覆盖手工树的无行无 align 兜底。`apps/web/tests/markdown-wide-table.e2e.ts` seed 一个含三个表格的已关闭轮次(三列填充表、十二列宽表、长 token/中文长单元格表),在真实 Chromium 中跨视口档 pin 关系 golden——填充表与长单元格表在每一档都填满消息列、无残余滚动、随列变窄而变高;宽表始终滚动、恰好在转录区宽于消息列的档位突破列宽、突破时内容与填充表左对齐、窄档钳制为中性——外加聚焦包裹层的方向键滚动、缩放分支、以及必须报告相同关系的 deviceScaleFactor-2 分支。
## 相关
- [Web markdown 增量 AST 渲染器](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.md) —— 本变更所扩展的渲染器与 DOM 一致性契约。
@@ -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/feature/2026-08-20-multiline-question-answer-field.md
2026-08-20-multiline-question-answer-field.md: 5e411d6728205ce1b72ef8355bbf2493f28873eb
2026-08-20-multiline-question-answer-field.zh.md: 6b98f017bbee2e8d89810c5b193b3707c85d1e95
@@ -0,0 +1,45 @@
# Agent Note: Multi-line answers in the question composer
Status: implemented
English | [中文](2026-08-20-multiline-question-answer-field.zh.md)
## Problem
`ask_user_question` offers a free-text answer beside the model's own options. On a question that carried options, that answer was a single-line `<input>`: a long sentence scrolled sideways inside one 24px line, Shift+Enter did nothing, and an answer with structure — two requirements, a short list, a paragraph — could not be typed at all. The optionless question already used a textarea, but a fixed 64140px box that neither followed the draft nor opened wider.
The chat composer next to it grows with the draft and takes Shift+Enter as a newline. A user who has just typed a multi-line prompt there meets a field that silently flattens the same answer.
## Decision
Both question shapes answer into one `AnswerField`: a `<textarea rows={1}>` sharing a single CSS-grid cell with a hidden mirror `<div>` that renders the draft plus a trailing newline.
The mirror sits in normal flow and so sizes the grid row; the textarea stretches to that row, and `rows={1}` keeps the control's own intrinsic height out of the row sizing, leaving the mirror the only input to the height. Soft wraps are invisible to a `'\n'` count, so the mirror is what makes a wrapped answer grow the box rather than scroll one line. The trailing newline covers the last line the textarea's caret can reach and the block container drops. Mirror and textarea must keep identical type, padding, and wrapping rules; a divergence sizes the box wrong for the text being typed.
Growth stops at the mirror's `max-height` of six lines, and past that the textarea scrolls itself. The mirror takes `box-sizing: content-box` against the card-wide `border-box` so that cap counts text lines rather than text plus padding: the optionless variant carries 16px of vertical padding, which under `border-box` spends two thirds of a line and delivers the last one as an 8px sliver, while the inline variant has no padding and would land on a different line count from the same declaration. It is the only scrollport in the stack: unlike the chat composer, this field paints its own glyphs, so there is no second layer whose scroll offset would have to match.
Enter continues the flow and submits the batch on the last question, Shift+Enter breaks the line, and the IME guard is unchanged — Enter during composition confirms the candidate without advancing. The `variant` prop names which of the two looks the field takes, so the field owns both and neither caller assembles one out of class names.
## Alternatives considered
**`field-sizing: content`.** Rejected for the same reason [the composer's Safari recovery](../bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.md) rejected it: Safari reproduces a stale intrinsic height after a deletion crosses a wrap threshold. The mirror is a plain block whose height Safari computes correctly, and it is already the technique this repository runs in the chat composer.
**Resize in JS on every keystroke** — set `height: auto`, read `scrollHeight`, write it back. Rejected: it pays two forced layouts per keystroke and reintroduces the stale-geometry class of defect the mirror avoids, in exchange for no capability the mirror lacks.
**Reuse the InputBar stack verbatim.** Rejected because that stack carries a decoration backdrop between mirror and textarea, which forces both layers into one outer scrollport so the caret and the glyphs cannot drift apart. This field has no backdrop, so letting the textarea own its own scroll removes the outer scrollport and that obligation with it.
**A separate expand-to-dialog entry for long answers.** Rejected as unnecessary: growing in place already satisfies the requirement, and a dialog would take the options the answer is an alternative to off screen at the moment the user is weighing them.
**Uncapped growth.** Rejected because the card tops out at `min(60vh, 520px)` and still owes that budget to the title, the option rows, and the footer actions; an unbounded field pushes the choices the answer belongs to out of view.
**A per-variant cap that absorbs each variant's padding.** Rejected because it couples the line count to a padding value: changing `.customBlock`'s inset would silently change how many lines the field grows to. `content-box` states the intent once, in the units the cap is written in.
## Testing
Component tests pin the round trip: both shapes render a textarea, the mirror follows the draft, Shift+Enter never advances the flow, and line breaks reach the answer batch verbatim. The assembled `question-composer` web e2e measures the live engine — a soft-wrapped draft grows the field without scrolling it, two Shift+Enter presses leave `"\n\n"` in a taller field with the question still open, and a draft past the cap scrolls instead of growing at exactly six text lines in both variants.
## Consequences
An answer can now carry the structure the question asks for, and the field the user sees behaves like the chat composer above it. The cost is a second element per field and the standing obligation to keep mirror and textarea metrics identical, which the JSDoc at `AnswerField` states and the e2e growth assertion detects.
The [single-select highlight item](https://github.com/deepseek-harness/deepseek-harness/issues/1687) of the same issue is untouched: focusing the custom field still leaves the previously chosen option visually selected until the first character lands.
@@ -0,0 +1,45 @@
# Agent Note: Multi-line answers in the question composer
Status: implemented
[English](2026-08-20-multiline-question-answer-field.md) | 中文
## Problem
`ask_user_question`在模型给出的选项旁提供一个自由文本答案。在带选项的问题上,该答案曾是单行 `<input>`:长句子在 24px 的一行内横向滚动,Shift+Enter 毫无作用,带结构的答案——两条要求、一个短列表、一段话——根本无法输入。无选项问题虽已使用 textarea,但那是固定 64–140px 的框,既不跟随草稿,也无法展开。
紧邻它的聊天输入框会随草稿增高,并把 Shift+Enter 当作换行。刚在那里敲完多行提示词的用户,转身遇到的却是一个会悄悄抹平同一个答案的输入框。
## Decision
两种问题形状都写入同一个 `AnswerField`:一个 `<textarea rows={1}>`,与一个渲染「草稿 + 结尾换行」的隐藏镜像 `<div>` 共享同一个 CSS grid 单元格。
镜像位于常规流中,因而决定 grid 行的高度;textarea 拉伸到该行,而 `rows={1}` 使控件自身的固有高度不参与行尺寸计算,于是镜像是高度的唯一输入。软换行对 `'\n'` 计数不可见,因此正是镜像让换行后的答案把输入框撑高,而不是在一行内滚动。结尾的换行补上 textarea 光标可达、而块级容器会丢弃的最后一行。镜像与 textarea 必须保持完全一致的字体、内边距与换行规则;一旦偏离,输入框的高度就与正在输入的文本不符。
增高在镜像 `max-height` 的六行处停止,此后由 textarea 自身滚动。镜像用 `box-sizing: content-box` 覆盖卡片范围内的 `border-box`,使该上限计量的是文本行而不是「文本加内边距」:无选项变体带 16px 纵向内边距,在 `border-box` 下这会吃掉三分之二行、并把最后一行变成 8px 残条,而 inline 变体没有内边距,同一条声明会落到不同的行数上。它是这个结构里唯一的滚动容器:与聊天输入框不同,本输入框自己绘制字形,因此不存在第二个需要对齐滚动偏移的图层。
Enter 继续流程并在最后一题提交整批,Shift+Enter 换行,IME 保护不变——组合输入期间按 Enter 只确认候选,不前进。`variant` 属性指明该输入框采用两种外观中的哪一种,因此外观归输入框自己所有,任何调用方都不必用类名拼装。
## Alternatives considered
**`field-sizing: content`。** 与[聊天输入框的 Safari 恢复方案](../bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.md)出于同一理由否决:当删除跨过换行阈值后,Safari 会复现陈旧的固有高度。镜像是一个 Safari 能正确计算高度的普通块,而且它已经是本仓库在聊天输入框中运行的技术。
**在 JS 中逐次按键重算高度**——置 `height: auto`、读 `scrollHeight`、再写回。否决:每次按键要付两次强制布局,并重新引入镜像所避开的那类陈旧几何缺陷,换来的却是镜像本就具备的能力。
**原样复用 InputBar 的图层结构。** 否决,因为该结构在镜像与 textarea 之间还有一层装饰背板,这迫使两个图层共用一个外层滚动容器,以免光标与字形彼此漂移。本输入框没有背板,因此让 textarea 自己滚动,既去掉了外层滚动容器,也一并去掉了那项对齐义务。
**为长答案单独提供展开为对话框的入口。** 作为多余方案否决:就地增高已满足需求,而对话框会在用户正权衡选项时,把答案所要替代的那些选项挤出屏幕。
**不设增高上限。** 否决,因为卡片上限为 `min(60vh, 520px)`,还要把这份额度分给标题、选项行与底部操作;不设上限的输入框会把答案所属的选项挤出视野。
**按变体各自吸收内边距的上限。** 否决,因为那会把行数与某个内边距值耦合:改动 `.customBlock` 的内边距会悄悄改变输入框能长到几行。`content-box` 只需声明一次意图,且与上限本身的单位一致。
## Testing
组件测试固定了整条往返:两种形状都渲染 textarea、镜像跟随草稿、Shift+Enter 绝不前进流程、换行原样进入答案批次。组装后的 `question-composer` web e2e 则在真实引擎上测量——软换行的草稿把输入框撑高而不滚动,两次 Shift+Enter 之后 `"\n\n"` 留在更高的输入框里且问题仍未关闭,超过上限的草稿在两种变体下都恰好于六行文本处改为滚动而不再增高。
## Consequences
答案现在能承载问题所要求的结构,用户看到的输入框与其上方的聊天输入框行为一致。代价是每个输入框多一个元素,以及保持镜像与 textarea 度量完全一致的长期义务——`AnswerField` 的 JSDoc 陈述了这项义务,e2e 的增高断言负责发现违反。
同一 Issue 中的[单选高亮项](https://github.com/deepseek-harness/deepseek-harness/issues/1687)未被触及:聚焦自定义输入框时,先前选中的选项在第一个字符落下之前仍保持视觉选中。
@@ -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/process/2026-08-10-event-directed-pr-review-status.md
2026-08-10-event-directed-pr-review-status.md: 9db9c64fc87c1701028ae825357c3cbd7fef44d1
2026-08-10-event-directed-pr-review-status.zh.md: 381a3f64a62930a584f48cfbc3571679bbcbcef7
2026-08-10-event-directed-pr-review-status.md: 3ed6038929d3c2c1e9cd82182978262ee363f5ab
2026-08-10-event-directed-pr-review-status.zh.md: 1fa8650057e53ab894c597b712720a3ee7a5c46a
@@ -12,7 +12,7 @@ A monotonic projection also cannot return an automation-owned Issue from `In rev
## Decision
The Issue lifecycle workflow treats review webhooks as commands. `pull_request.review_requested`, including a repeated request, targets `In review`. `pull_request_review.submitted` targets `In progress` only when `review.state` is `changes_requested`; the submitted event remains necessary because a reviewer can request changes without an earlier review-request event. Approved and commented submissions skip their lifecycle job before it creates a Project token, while dismissed reviews are not subscribed.
The Issue lifecycle workflow treats review webhooks as commands. `pull_request.review_requested`, including a repeated request, targets `In review`. `pull_request_review.submitted` targets `In progress` only when `review.state` is `changes_requested`; the submitted event remains necessary because a reviewer can request changes without an earlier review-request event. Approved and commented submissions run their lifecycle job but no-op (they never reach the Project token step), while dismissed reviews are not subscribed.
Ordinary subscribed pull-request events remain forward-only implementation signals: they can move `Inbox`, `Backlog`, or `Ready` to `In progress`, but they cannot move `In review` backward. Review-request commands can move any earlier active status to `In review`. Changes-requested commands can move earlier active statuses forward to `In progress` and can move `In review` back only when the latest status event for the target Project was written by the configured lifecycle actor. A human or unknown latest actor preserves the current status.
@@ -22,7 +22,7 @@ The handler resolves only exact same-repository `Fixes`, `Closes`, or `Resolves`
## Verification
[Issue-management tests](../../../../.github/issue-management/policy.test.mjs) pin the event-to-command mapping, the repeated-review-request transition after a changes-requested command, the changes-requested regression, terminal protection, and human override preservation. [Workflow tests](../../../../scripts/ci-workflow.spec.ts) pin the subscribed events, the changes-requested job condition, and the separate `ready_for_review` policy trigger.
[Issue-management tests](../../../../.github/issue-management/policy.test.mjs) pin the event-to-command mapping, the repeated-review-request transition after a changes-requested command, the changes-requested regression, terminal protection, and human override preservation. [Workflow tests](../../../../scripts/ci-workflow.spec.ts) pin the subscribed events, the job-level absence of `if` plus the step-level gate on the token/board steps (so approved/commented reviews pass without minting a token), and the separate `ready_for_review` policy trigger.
## Alternatives considered
@@ -12,7 +12,7 @@ Issue 所在 Project 中的状态记录了解决工作的下一步由谁负责
## 决策
Issue 生命周期工作流把评审 webhook 视为命令。`pull_request.review_requested`(包括重复请求)将目标状态指定为 `In review``pull_request_review.submitted` 将目标状态指定为 `In progress`,但仅在 `review.state``changes_requested` 时生效;submitted 事件仍不可省略,因为评审人即使没有先触发 review-request 事件,也可以直接提出修改要求。对于 approved 和 commented 提交,工作流会在生命周期作业创建 Project token 前跳过该作业;dismissed 评审则不在订阅范围内。
Issue 生命周期工作流把评审 webhook 视为命令。`pull_request.review_requested`(包括重复请求)将目标状态指定为 `In review``pull_request_review.submitted` 将目标状态指定为 `In progress`,但仅在 `review.state``changes_requested` 时生效;submitted 事件仍不可省略,因为评审人即使没有先触发 review-request 事件,也可以直接提出修改要求。对于 approved 和 commented 提交,生命周期作业会运行但空操作(不会走到创建 Project token 一步);dismissed 评审则不在订阅范围内。
工作流订阅的普通 PR 事件仍是只向前推进的实现信号:它们可以将 `Inbox``Backlog``Ready` 推进至 `In progress`,但不能让 `In review` 倒退。请求评审命令可将任意较早的活跃状态推进至 `In review`。请求修改命令可将较早的活跃状态推进至 `In progress`;它也可以让 `In review` 状态回退,但仅在目标 Project 的最新状态事件由配置的生命周期执行主体写入时进行。若最新状态事件的执行主体是人工用户或未知主体,则保留当前状态。
@@ -22,7 +22,7 @@ Issue 生命周期工作流把评审 webhook 视为命令。`pull_request.review
## 验证
[Issue 管理测试](../../../../.github/issue-management/policy.test.mjs)锁定事件到命令的映射、请求修改命令后重复请求评审所触发的状态转换、请求修改后的状态回退、终态保护,以及保留人工覆盖状态。[工作流测试](../../../../scripts/ci-workflow.spec.ts)锁定订阅事件、请求修改作业的条件,以及独立的 `ready_for_review` 策略触发器。
[Issue 管理测试](../../../../.github/issue-management/policy.test.mjs)锁定事件到命令的映射、请求修改命令后重复请求评审所触发的状态转换、请求修改后的状态回退、终态保护,以及保留人工覆盖状态。[工作流测试](../../../../scripts/ci-workflow.spec.ts)锁定订阅事件、job 级无 `if` 且 token/看板步骤带 step 级门控(使 approved/commented 评审以 pass 呈现且不铸 token),以及独立的 `ready_for_review` 策略触发器。
## 考虑过的替代方案
@@ -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/process/2026-08-10-npm-release-sequences.md
2026-08-10-npm-release-sequences.md: efeda91b6a85e1316c563cc04411878122096953
2026-08-10-npm-release-sequences.zh.md: d905ac4b58691890d2aad955b87713a278dcb4f8
2026-08-10-npm-release-sequences.md: 487f2dbe717375b2adb7daf18550799544d2e147
2026-08-10-npm-release-sequences.zh.md: 051c1c9fd0e8d5330477c5e3053c55f1b51d1802
@@ -22,8 +22,8 @@ Two hard blockers sat in the way. All 217 workspace manifests set `private: true
| Sequence | Members | Version baseline | Tag | Workflow |
|---|---|---|---|---|
| dsh | Publish set: non-experimental `packages/*/*` + `apps/*`; private experimental packages join only the shared version bump | one version for the publish set, private dsh packages, and workspace root, `0.0.x` | `dsh-v<version>` | `release.yml` |
| vendored framework | the nine `vendor/*` packages | each package on its own version line | `vendor-<package>-v<version>` (one per package) | `release-vendor.yml` |
| dsh | Publish set: non-experimental `packages/*/*` + `apps/*`; private experimental packages join only the shared version bump | one version for the publish set, private dsh packages, and workspace root, `0.0.x` | `dsh-v<version>` | `release.yml` (pack) / `release-publish.yml` (publish) |
| vendored framework | the nine `vendor/*` packages | each package on its own version line | `vendor-<package>-v<version>` (one per package) | `release-vendor.yml` (pack) / `release-vendor-publish.yml` (publish) |
| native | `native/landlock-run/packages/*` | its own `0.0.x` | `landlock-run-v<version>` | `landlock-run-release.yml` |
All three publish to the `@deepseek-ai` scope on npmjs.com, and access is per sequence rather than per scope: the vendored framework and the native packages are `public`, the dsh family is `restricted` ([rationale](2026-08-13-public-vendor-and-native-sequences.md)). No publish path passes `--access`, because one flag cannot serve sequences that disagree and would override the manifest that owns the level.
@@ -105,13 +105,13 @@ The entity in this domain is a **release family**: a set of packages sharing one
The dsh family applies the repository's publication payload policy, which rejects sources and declaration maps. The vendored family keeps upstream's payload, because those manifests export `./src/*` and dropping `src` would publish an export map pointing at absent files.
### Workflow shape: pack everything at once, then publish as one set
### Workflow shape: pack on PR/push, publish from a manual dispatch workflow
The `pack` job walks the whole release set once, packing each member into one directory, writes the upload order, and uploads that directory as one artifact; the `publish` job downloads that artifact and publishes each entry in order. The release set is one unit — half the packages can never reach the registry while the other half is still building.
The `pack` job walks the whole release set once, packing each member into one directory, writes the upload order, and uploads that directory as one artifact; it lives in `release.yml` / `release-vendor.yml`. The release set is one unit — half the packages can never reach the registry while the other half is still building.
`pack` carries no credentials and runs on every pull request and master push, so a pull request proves the release set still packs. `publish` is a manual dispatch, sits behind the `npm-publish` environment for human approval, and neither builds nor rebuilds — it uploads the bytes pack produced. Pack runs are grouped per ref so concurrent pull requests do not displace each other; the publish job carries the global group, because dist-tags are shared registry state.
`pack` carries no credentials and runs on every pull request and master push, so a pull request proves the release set still packs. Publication lives in a separate `release-publish.yml` / `release-vendor-publish.yml` workflow that is `workflow_dispatch`-only (so it never appears as a PR check): it repacks the current tree and then publishes each entry in order, behind the `npm-publish` environment for human approval. Pack runs are grouped per ref so concurrent pull requests do not displace each other; the `publish` job carries the global `Release-publish` group, because dist-tags are shared registry state.
A dsh verification installs the vendored family's pack output too. The harness packages declare the vendored framework as a peer, those packages live in another sequence, and the credential-free job cannot fetch them from a private registry — so `release.yml` packs the vendored family for verification while publishing only its own set.
A dsh verification installs the vendored family's pack output too. The harness packages declare the vendored framework as a peer, those packages live in another sequence, and the credential-free job cannot fetch them from a private registry — so the dsh `pack` job packs the vendored family for verification while publishing only the dsh set. The publish workflow (`release-publish.yml`) repacks the current tree and publishes only the dsh set.
The verification also packs the Landlock entry, which `dsh-sandbox-local` declares as a plain dependency, and omits optional dependencies. The platform packages behind those optional entries need a musl toolchain and one build per architecture, so a job on one runner cannot produce them; a consumer that cannot install them must still start, which is what optional means here. The verification therefore reads a directory by its contents rather than a pack order, because a directory can hold tarballs packed only to satisfy a cross-sequence dependency.
@@ -22,8 +22,8 @@ Status: implemented
| 序列 | 成员 | 版本基线 | tag | workflow |
|---|---|---|---|---|
| dsh | 发布集:非 experimental 的 `packages/*/*` + `apps/*`;私有实验性包仅加入共享版本 bump | 发布集、私有 dsh 包与 workspace 根共用一个 `0.0.x` | `dsh-v<版本>` | `release.yml` |
| vendored framework | `vendor/*` 九个包 | 每包各自一条版本线 | `vendor-<包名>-v<版本>`(每包一个) | `release-vendor.yml` |
| dsh | 发布集:非 experimental 的 `packages/*/*` + `apps/*`;私有实验性包仅加入共享版本 bump | 发布集、私有 dsh 包与 workspace 根共用一个 `0.0.x` | `dsh-v<版本>` | `release.yml`pack/ `release-publish.yml`(发布) |
| vendored framework | `vendor/*` 九个包 | 每包各自一条版本线 | `vendor-<包名>-v<版本>`(每包一个) | `release-vendor.yml`pack/ `release-vendor-publish.yml`(发布) |
| native | `native/landlock-run/packages/*` | 自己的 `0.0.x` | `landlock-run-v<版本>` | `landlock-run-release.yml` |
三组一律发到 npmjs.com 的 `@deepseek-ai` scope,且 access 按序列而非按 scope 区分:vendored 框架与 native 包是 `public`dsh 族是 `restricted`[理由](2026-08-13-public-vendor-and-native-sequences.md))。没有任何发布路径传 `--access`——一个选项无法服务级别互不相同的序列,且会覆盖真正拥有该级别的 manifest。
@@ -105,13 +105,13 @@ registry 的两个行为决定了「怎么尝试一次发布」。写入之间
dsh 族套用仓库的发布 payload 策略(拒绝源码与声明映射)。vendored 族保留上游 payload,因为那些 manifest 导出 `./src/*`,去掉 `src` 会发出一个导出映射指向不存在文件的包。
### workflow 形状:一次性 pack 全部,再统一 publish
### workflow 形状:PR/push 上 pack,从手动 dispatch 工作流发布
`pack` job 一趟遍历整个发布集,把每个成员打进同一个目录,写出上传顺序,整个目录作为一份 artifact 上传;`publish` job 下载那一份 artifact,按顺序逐个发布。发布集是一个整体——绝不会出现一半的包已经上了 registry、另一半还在构建。
`pack` job 一趟遍历整个发布集,把每个成员打进同一个目录,写出上传顺序,整个目录作为一份 artifact 上传;它位于 `release.yml` / `release-vendor.yml`。发布集是一个整体——绝不会出现一半的包已经上了 registry、另一半还在构建。
`pack` 无凭据,在每个 pull request 和每次 master push 上跑,所以一个 pull request 就能证明发布集仍能完整打出来。`publish` 是手动 dispatch,挂在 `npm-publish` environment 后面等人工审批,且既不构建也不重建——它上传的就是 pack 产出的字节。pack 的 run 按 ref 分组,并发的 pull request 不会互相顶掉;全局分组落在 publish job 上,因为 dist-tag 是共享的 registry 状态。
`pack` 无凭据,在每个 pull request 和每次 master push 上跑,所以一个 pull request 就能证明发布集仍能完整打出来。发布则位于独立的 `release-publish.yml` / `release-vendor-publish.yml` 工作流,仅 `workflow_dispatch`(因此不会作为 PR check 出现):它重新打包当前树,再按顺序逐个发布,挂在 `npm-publish` environment 后面等人工审批。pack 的 run 按 ref 分组,并发的 pull request 不会互相顶掉;全局 `Release-publish` 分组落在 `publish` job 上,因为 dist-tag 是共享的 registry 状态。
dsh 的验证会一并安装 vendored 族的 pack 产物。harness 的包把 vendored 框架声明成 peer,而那些包属于另一条序列,无凭据的 job 无法从私有 registry 取到——所以 `release.yml` 为验证而打包 vendored 族,发布的仍只有自己那一份
dsh 的验证会一并安装 vendored 族的 pack 产物。harness 的包把 vendored 框架声明成 peer,而那些包属于另一条序列,无凭据的 job 无法从私有 registry 取到——所以 dsh 的 `pack` job 为验证而打包 vendored 族,发布的仍只有 dsh 那一份。发布工作流(`release-publish.yml`)重新打包当前树,只发布 dsh 族
验证还会打一份 Landlock entry 的 tarball——`dsh-sandbox-local` 把它声明为普通 `dependencies`——同时略去可选依赖。那些可选项背后的平台包需要 musl 工具链且每个架构各构建一次,单台 runner 产不出来;而装不到它们的消费方也必须能起,这正是「可选」在这里的含义。因此验证按目录内容读取 tarball,而不是读发布顺序:一个目录可能只装着为满足跨序列依赖而打出来的包,任何发布顺序都不描述它。
+7 -1
View File
@@ -36,7 +36,11 @@ concurrency:
jobs:
lifecycle:
name: Issue lifecycle
if: ${{ github.event_name != 'pull_request_review' || (github.event.action == 'submitted' && github.event.review.state == 'changes_requested') }}
# Runs on every pull_request_review event so the check reports success rather
# than a gray "skipped" segment. The token-creating and board-mutating steps
# are gated at step level (a skipped step does not gray the job): only a
# changes_requested review drives the Project board; approved/commented
# reviews never mint a write-capable App token.
runs-on: ubuntu-latest
steps:
- name: Check out trusted policy
@@ -46,6 +50,7 @@ jobs:
persist-credentials: false
- name: Create project token
id: app-token
if: ${{ github.event_name != 'pull_request_review' || github.event.review.state == 'changes_requested' }}
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
with:
client-id: ${{ vars.DSH_ISSUE_APP_CLIENT_ID }}
@@ -53,6 +58,7 @@ jobs:
owner: deepseek-harness
repositories: deepseek-harness
- name: Handle repository event
if: ${{ github.event_name != 'pull_request_review' || github.event.review.state == 'changes_requested' }}
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: node .github/issue-management/policy.mjs lifecycle
+131
View File
@@ -0,0 +1,131 @@
# Publish the dsh release sequence to npm. This workflow is manual-only
# (workflow_dispatch) and intentionally does not listen to pull_request or push:
# publication must always be an explicit, reviewed act from a dsh-v* tag, and it
# must never appear as a PR check. It repacks the current tree before publishing
# so the bytes uploaded are exactly what this dispatch produced.
name: Release publish (dsh)
on:
workflow_dispatch:
permissions:
contents: read
env:
PRIMARY_NODE_VERSION: '24'
DSH_TELEMETRY_DISABLED: '1'
jobs:
pack:
name: Pack npm tarballs
runs-on: ubuntu-24.04
steps:
# Complete history: the release scripts read tags.
- uses: actions/checkout@v6
with:
fetch-depth: 0
persist-credentials: false
- uses: pnpm/action-setup@v4
with:
dest: ${{ runner.temp }}/setup-pnpm
- uses: actions/setup-node@v6
with:
node-version: ${{ env.PRIMARY_NODE_VERSION }}
- name: Configure pnpm store path
id: pnpm-store
run: |
store_root="$HOME/.local/share/pnpm/store"
echo "PNPM_CONFIG_STORE_DIR=$store_root" >> "$GITHUB_ENV"
store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent)
echo "path=$store_path" >> "$GITHUB_OUTPUT"
- uses: actions/cache/restore@v4
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-
- name: Install (immutable)
run: pnpm install --frozen-lockfile
- name: Verify release version
env:
RELEASE_PUBLISH: 'true'
run: pnpm run release:verify --family dsh
- name: Build
run: pnpm run build:official
- name: Pack release tarballs
run: pnpm run release:pack --family dsh --out dist/npm
# The harness packages declare the vendored framework as a peer, and this
# verification must not depend on the registry already carrying matching
# versions — one pull request may bump both families before either
# publishes — so it installs that family's pack output too. Only dist/npm
# is published.
- name: Pack the vendored framework for verification
run: pnpm run release:pack --family vendor --out dist/npm-vendor
# dsh-sandbox-local declares the Landlock entry as a runtime dependency, so
# the verification needs its tarball. Its platform packages stay out: they
# are optional, and building them needs a musl toolchain per architecture.
- name: Pack the Landlock entry for verification
run: |
pnpm --dir native/landlock-run run build:ts
pnpm --dir native/landlock-run/packages/entry pack --pack-destination "$PWD/dist/npm-landlock"
- name: Verify packed install
run: pnpm run release:verify-packed-install --family dsh --from dist/npm --from dist/npm-vendor --from dist/npm-landlock
- uses: actions/upload-artifact@v4
with:
name: dsh-npm-tarballs
path: dist/npm/*
if-no-files-found: error
retention-days: 7
publish:
name: Publish to npm
needs: pack
runs-on: ubuntu-24.04
# Required reviewers and the allowed tags live on the environment; this is
# the only job in the sequence that can write to the registry.
environment: npm-publish
concurrency:
group: Release-publish
cancel-in-progress: false
permissions:
contents: read
steps:
# Checkout and install carry the release scripts only. There is no build
# step: publication uploads the bytes the pack job produced.
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: pnpm/action-setup@v4
with:
dest: ${{ runner.temp }}/setup-pnpm
- uses: actions/setup-node@v6
with:
node-version: ${{ env.PRIMARY_NODE_VERSION }}
registry-url: https://registry.npmjs.org
- name: Install (immutable, no package scripts)
run: pnpm install --frozen-lockfile --ignore-scripts
- uses: actions/download-artifact@v4
with:
name: dsh-npm-tarballs
path: dist/npm
- name: Publish tarballs
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: pnpm run release:publish --family dsh --from dist/npm
@@ -0,0 +1,114 @@
# Publish the vendored framework sequence to npm. This workflow is manual-only
# (workflow_dispatch) and intentionally does not listen to pull_request or push:
# publication must always be an explicit, reviewed act from a vendor-* tag, and
# it must never appear as a PR check. It repacks the current tree before
# publishing so the bytes uploaded are exactly what this dispatch produced.
name: Release publish (vendor)
on:
workflow_dispatch:
permissions:
contents: read
env:
PRIMARY_NODE_VERSION: '24'
DSH_TELEMETRY_DISABLED: '1'
jobs:
pack:
name: Pack npm tarballs
runs-on: ubuntu-24.04
steps:
# Complete history: the release scripts read tags.
- uses: actions/checkout@v6
with:
fetch-depth: 0
persist-credentials: false
- uses: pnpm/action-setup@v4
with:
dest: ${{ runner.temp }}/setup-pnpm
- uses: actions/setup-node@v6
with:
node-version: ${{ env.PRIMARY_NODE_VERSION }}
- name: Configure pnpm store path
id: pnpm-store
run: |
store_root="$HOME/.local/share/pnpm/store"
echo "PNPM_CONFIG_STORE_DIR=$store_root" >> "$GITHUB_ENV"
store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent)
echo "path=$store_path" >> "$GITHUB_OUTPUT"
- uses: actions/cache/restore@v4
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-
- name: Install (immutable)
run: pnpm install --frozen-lockfile
- name: Verify release version
env:
RELEASE_PUBLISH: 'true'
run: pnpm run release:verify --family vendor
# The vendored packages publish their own sources and build outputs; the
# host build produces what their manifests select.
- name: Build
run: pnpm run build:lib:host
- name: Pack release tarballs
run: pnpm run release:pack --family vendor --out dist/npm-vendor
- name: Verify packed install
run: pnpm run release:verify-packed-install --family vendor --from dist/npm-vendor
- uses: actions/upload-artifact@v4
with:
name: vendor-npm-tarballs
path: dist/npm-vendor/*
if-no-files-found: error
retention-days: 7
publish:
name: Publish to npm
needs: pack
runs-on: ubuntu-24.04
environment: npm-publish
concurrency:
group: Release-publish
cancel-in-progress: false
permissions:
contents: read
steps:
# Checkout and install carry the release scripts only; no build step.
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: pnpm/action-setup@v4
with:
dest: ${{ runner.temp }}/setup-pnpm
- uses: actions/setup-node@v6
with:
node-version: ${{ env.PRIMARY_NODE_VERSION }}
registry-url: https://registry.npmjs.org
- name: Install (immutable, no package scripts)
run: pnpm install --frozen-lockfile --ignore-scripts
- uses: actions/download-artifact@v4
with:
name: vendor-npm-tarballs
path: dist/npm-vendor
- name: Publish tarballs
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: pnpm run release:publish --family vendor --from dist/npm-vendor
+7 -54
View File
@@ -1,10 +1,11 @@
# Pack and publish the vendored framework sequence: the nine rescoped Cordis
# packages under vendor/, each on its own version line. This sequence releases
# independently of dsh and of the native packages.
# Pack the vendored framework sequence: the nine rescoped Cordis packages under
# vendor/, each on its own version line. This sequence releases independently of
# dsh and of the native packages.
#
# Pack runs without credentials on every pull request and master push.
# Publication is a manual dispatch from a vendor-* tag; a vendor release can
# carry several versions, so each package has its own tag.
# Publication is a manual workflow_dispatch of release-vendor-publish.yml from a
# vendor-* tag; a vendor release can carry several versions, so each package has
# its own tag.
name: Release (vendor)
on:
@@ -12,19 +13,12 @@ on:
push:
branches: [master]
workflow_dispatch:
inputs:
publish:
description: Publish the packed tarballs to npm. Must run from a vendor-* tag.
required: true
type: boolean
default: false
permissions:
contents: read
concurrency:
# Pack runs per ref so concurrent pull requests never displace each
# other; the publish job below serializes the shared dist-tag state.
# Pack runs per ref so concurrent pull requests never displace each other.
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
@@ -70,8 +64,6 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Verify release version
env:
RELEASE_PUBLISH: ${{ inputs.publish }}
run: pnpm run release:verify --family vendor
# The vendored packages publish their own sources and build outputs; the
@@ -91,42 +83,3 @@ jobs:
path: dist/npm-vendor/*
if-no-files-found: error
retention-days: 7
publish:
name: Publish to npm
if: inputs.publish
needs: pack
runs-on: ubuntu-24.04
environment: npm-publish
concurrency:
group: Release-publish
cancel-in-progress: false
permissions:
contents: read
steps:
# Checkout and install carry the release scripts only; no build step.
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: pnpm/action-setup@v4
with:
dest: ${{ runner.temp }}/setup-pnpm
- uses: actions/setup-node@v6
with:
node-version: ${{ env.PRIMARY_NODE_VERSION }}
registry-url: https://registry.npmjs.org
- name: Install (immutable, no package scripts)
run: pnpm install --frozen-lockfile --ignore-scripts
- uses: actions/download-artifact@v4
with:
name: vendor-npm-tarballs
path: dist/npm-vendor
- name: Publish tarballs
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: pnpm run release:publish --family vendor --from dist/npm-vendor
+6 -57
View File
@@ -1,10 +1,10 @@
# Pack and publish the dsh release sequence: every package under packages/ plus
# the apps/ entries, all on one version. The vendored framework and the native
# packages are separate sequences with their own workflows and version lines.
# Pack the dsh release sequence: every package under packages/ plus the apps/
# entries, all on one version. The vendored framework and the native packages are
# separate sequences with their own workflows and version lines.
#
# Pack runs without credentials on every pull request and master push, so a
# pull request proves the whole publish set still packs. Publication is a
# manual dispatch from a dsh-v* tag and consumes exactly the packed bytes.
# pull request proves the whole publish set still packs. Publication is a manual
# workflow_dispatch of release-publish.yml from a dsh-v* tag.
name: Release (dsh)
on:
@@ -12,19 +12,12 @@ on:
push:
branches: [master]
workflow_dispatch:
inputs:
publish:
description: Publish the packed tarballs to npm. Must run from a dsh-v* tag.
required: true
type: boolean
default: false
permissions:
contents: read
concurrency:
# Pack runs per ref so concurrent pull requests never displace each
# other; the publish job below serializes the shared dist-tag state.
# Pack runs per ref so concurrent pull requests never displace each other.
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
@@ -70,8 +63,6 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Verify release version
env:
RELEASE_PUBLISH: ${{ inputs.publish }}
run: pnpm run release:verify --family dsh
- name: Build
@@ -105,45 +96,3 @@ jobs:
path: dist/npm/*
if-no-files-found: error
retention-days: 7
publish:
name: Publish to npm
if: inputs.publish
needs: pack
runs-on: ubuntu-24.04
# Required reviewers and the allowed tags live on the environment; this is
# the only step in the sequence that can write to the registry.
environment: npm-publish
concurrency:
group: Release-publish
cancel-in-progress: false
permissions:
contents: read
steps:
# Checkout and install carry the release scripts only. There is no build
# step: publication uploads the bytes the pack job produced.
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: pnpm/action-setup@v4
with:
dest: ${{ runner.temp }}/setup-pnpm
- uses: actions/setup-node@v6
with:
node-version: ${{ env.PRIMARY_NODE_VERSION }}
registry-url: https://registry.npmjs.org
- name: Install (immutable, no package scripts)
run: pnpm install --frozen-lockfile --ignore-scripts
- uses: actions/download-artifact@v4
with:
name: dsh-npm-tarballs
path: dist/npm
- name: Publish tarballs
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: pnpm run release:publish --family dsh --from dist/npm
+4 -5
View File
@@ -13,7 +13,7 @@ import { dirname, join, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { act, cleanup } from '@testing-library/react'
import { afterEach, beforeEach, vi } from 'vitest'
import { injectBootManifest, orderByModuleGraph } from '@deepseek-ai/dsh-client-modules'
import { bootInjections, orderByModuleGraph } from '@deepseek-ai/dsh-client-modules'
import type { ClientModuleLoaderTarget, WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
@@ -194,10 +194,9 @@ export function mountAssembledApp(search = '?fixture'): void {
root.id = 'root'
document.body.appendChild(root)
win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ bundlePath: _bundlePath, ...plugin }) => plugin) }
const html = injectBootManifest('<head></head>', win.__DSH_BOOT__)
const facadeSource = /<head><script>([\s\S]*?)<\/script>/.exec(html)?.[1]
if (facadeSource === undefined) throw new Error('missing injected ModuleLoader facade')
;(0, eval)(facadeSource)
const [facadeRow] = bootInjections(win.__DSH_BOOT__)
if (facadeRow?.kind !== 'script') throw new Error('missing injected ModuleLoader facade row')
;(0, eval)(facadeRow.text)
// Mirror the blocking Host-injected scripts before the Vite entry calls create().
for (const id of ['@deepseek-ai/dsh-client-modules', '@deepseek-ai/dsh-client-runtime']) {
const plugin = PLUGINS.find(candidate => candidate.id === id)
+6 -2
View File
@@ -25,6 +25,7 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/lifecycle-chrome', import.meta.url))
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
const REPLAY_OVERRIDE = join(SNAPSHOT_DIR, 'replay.override.json')
const HERO_EXPECTED = join(SNAPSHOT_DIR, 'hero.expected.md')
const COMMAND_MENU_EXPECTED = join(SNAPSHOT_DIR, 'command-menu.expected.md')
const FUZZY_COMMAND_MENU_EXPECTED = join(SNAPSHOT_DIR, 'command-menu-fuzzy.expected.md')
@@ -45,7 +46,9 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
const sessionEvents: SessionEvent[] = []
beforeAll(async () => {
scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: REPLAY_PACE_MS })
scaffold = await launchWebScaffold(MODE === 'record'
? {}
: { replayFixture: FIXTURE, replayOverride: REPLAY_OVERRIDE, paceMs: REPLAY_PACE_MS })
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
browser = await chromium.launch()
page = await newEnglishPage(browser)
@@ -206,6 +209,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
).toBeGreaterThanOrEqual(1)
await expect.poll(() => page.locator('[role="treeitem"][aria-selected="true"]').count(), { timeout: 10_000 }).toBe(1)
await expect.poll(() => page.getByText('LIGHTHOUSE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
await expect.poll(() => page.getByText('Cache hit 99.5%', { exact: true }).count(), { timeout: 15_000 }).toBe(1)
// Host: the session's durable header cwd is the folder the workspace
// flow created and adopted (<workspaceCwd>/workspace) — the proof the
// send went through workspace materialization rather than a bare
@@ -271,7 +275,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, [
'session.jsonl', 'command-menu.expected.md', 'command-menu-fuzzy.expected.md', 'hero.expected.md', 'plan-active.expected.md', 'reloaded.expected.md',
'session.jsonl', 'replay.override.json', 'command-menu.expected.md', 'command-menu-fuzzy.expected.md', 'hero.expected.md', 'plan-active.expected.md', 'reloaded.expected.md',
])
})
})
+482
View File
@@ -0,0 +1,482 @@
// Web e2e scenario: markdown tables in the message column, deepsuite-chat
// parity. Tables under four columns (and long-cell tables) fill the 748px
// message column and wrap; four-or-more-column tables keep their natural
// width, scroll horizontally inside their wrapper, and — through the
// renderer's `md-table-wide` hook plus AssistantMarkdown's container-query
// breakout — span the whole transcript width instead of clipping at the
// message column, with the table content still starting at the message
// column's left edge. When the transcript is narrower than the message
// column the breakout clamps to neutral and the plain in-column scroll
// remains.
//
// Only a real engine lays out CSS tables and resolves container-query
// units, so the fill/scroll/breakout relations, the lead-padding alignment,
// arrow-key scrolling, and the zoom/DPR arms are all measured in Chromium
// across viewport stops. The golden records relations and booleans, never
// pixels: absolute widths document the platform, not the behavior.
//
// Zero model calls: the transcript is a closed turn assembled through the
// Session API and seeded cold; a stray stream would fail loud with
// NO_ADAPTER.
import { fileURLToPath } from 'node:url'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
import {
SESSION_FORMAT_VERSION,
Session,
SessionId,
} from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-session-title'
import {
assertFixtureInventory,
compareOrRefreshGolden,
launchWebScaffold,
seedSession,
watchConsole,
webSnapshotMode,
type WebScaffold,
} from './scaffold.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/markdown-wide-table', import.meta.url))
const GEOMETRY_EXPECTED = fileURLToPath(
new URL('./snapshots/markdown-wide-table/geometry.expected.md', import.meta.url),
)
const MODE = webSnapshotMode()
const SEED_ID = 'markdown-wide-table-web-e2e'
/** Painted into the final paragraph; the open barrier waits for it. */
const TAIL_MARKER = 'MWT_TABLES_DONE'
/**
* First-header-cell markers identify each table without depending on CSS
* module hashes or DOM order.
*/
const FILL_MARKER = 'MWT_FILL_C1'
const WIDE_MARKER = 'MWT_WIDE_C01'
const LONG_CELL_MARKER = 'MWT_LONGCELL_F1'
const MARKERS = [FILL_MARKER, WIDE_MARKER, LONG_CELL_MARKER]
/** Golden-facing names, in {@link MARKERS} order. */
const TABLE_NAMES = ['fill', 'wide', 'long-cell']
/**
* Viewport sweep. The wide stops leave the transcript far wider than the
* 748px message column, so the breakout relation holds with a fat margin on
* every platform; the narrow stop drops the transcript below the message
* column, which must clamp the breakout to neutral. The sidebar is collapsed
* for the whole sweep (see beforeAll), so the transcript width follows the
* viewport identically on overlay- and classic-scrollbar platforms.
*/
const WIDTHS = [1680, 1100, 640]
/** A sentence long enough that three of them cannot sit unwrapped in the 748px column. */
const SENTENCE = 'This cell carries one full sentence so the unwrapped table is far wider than the message column.'
/** Unbroken path-like token (no scheme, so GFM does not autolink it and no anchor joins the tab order). */
const LONG_TOKEN = 'workspace/deepseek-harness/packages/client/ui-primitives/src/markdown/render.tsx/'.repeat(3)
const CJK_SENTENCE = '这个单元格包含一段较长的中文说明,用来验证长内容在窄列宽下按最小可读宽度换行而不是把列压缩到无法阅读。'
/** The assistant markdown: one 3-column fill, one 12-column wide, one long-cell table. */
function tablesMarkdown(): string {
const wideHeader = [WIDE_MARKER, ...Array.from({ length: 11 }, (_, i) => `C${String(i + 2).padStart(2, '0')}`)]
const wideRow = (row: number): string[] =>
Array.from({ length: 12 }, (_, i) => `v${String(row)}${String(i + 1).padStart(2, '0')}`)
return [
'Three markdown tables exercise the wide-table layout rules.',
'',
`| ${FILL_MARKER} | Current approach | Proposed approach |`,
'| --- | --- | --- |',
`| Rendering | ${SENTENCE} | ${SENTENCE} |`,
`| Memory | ${SENTENCE} | ${SENTENCE} |`,
'',
`| ${wideHeader.join(' | ')} |`,
`|${' --- |'.repeat(12)}`,
`| ${wideRow(1).join(' | ')} |`,
`| ${wideRow(2).join(' | ')} |`,
'',
`| ${LONG_CELL_MARKER} | Value |`,
'| --- | --- |',
`| path | ${LONG_TOKEN} |`,
`| 说明 | ${CJK_SENTENCE} |`,
'',
TAIL_MARKER,
].join('\n')
}
/** Build one closed, invariant-checked session fixture carrying the three tables. */
function wideTableFixture(): string {
const session = Session.create(SessionId('markdown-wide-table-source'))
const eventTimeOrigin = new Date().setHours(12, 0, 0, 0)
session.append('turn/start', { turn: 1 })
const user = session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'Show the wide-table layout scenarios.' }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
session.append('session/title', {
title: 'Markdown wide tables',
messageSeqs: [user.seq],
source: { kind: 'fallback' },
})
session.append('step/start', { turn: 1, step: 1 })
session.append('assistant/message', {
turn: 1,
step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: tablesMarkdown() }],
source: { kind: 'model', provider: 'fixture', model: 'fixture' },
}),
}, { surfaceOp: 'append' })
session.append('step/end', { turn: 1, step: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
const header = {
type: 'session',
version: SESSION_FORMAT_VERSION,
id: '{{sessionId}}',
createdAt: 0,
cwd: '{{cwd}}',
}
return [
JSON.stringify(header),
// Spaced event times, as the sibling markdown fixtures pin them.
...session.events.map(event => JSON.stringify({
...event,
time: eventTimeOrigin + event.seq * 1_000,
})),
'',
].join('\n')
}
/** One table's layout relations at the current viewport. */
interface TableReading {
/** Identifying first-header-cell marker. */
marker: string
/** `scrollWidth - clientWidth` of the wrapper: the residual horizontal scroll. */
overflow: number
/** Wrapper content width. */
clientWidth: number
/** Rendered wrapper height; wrapping shows up as growth when the column narrows. */
height: number
/** Renderer marked the table with the `md-table-wide` breakout hook. */
wideHook: boolean
/** Resolved lead padding (the breakout's alignment compensation). */
paddingLeft: number
/** The table's own left x, for the content-alignment relation. */
tableLeft: number
}
/** Read all three tables' relations in one pass. */
function readTables(page: Page): Promise<TableReading[]> {
return page.evaluate((markers) => {
const wrappers = [...document.querySelectorAll<HTMLElement>('[class*="tableScroll"]')]
return markers.map((marker) => {
const wrapper = wrappers.find(candidate => candidate.textContent?.includes(marker) ?? false)
if (wrapper === undefined) throw new Error(`table wrapper ${marker} not rendered`)
const table = wrapper.querySelector('table')
if (table === null) throw new Error(`table ${marker} not rendered`)
return {
marker,
overflow: wrapper.scrollWidth - wrapper.clientWidth,
clientWidth: wrapper.clientWidth,
height: wrapper.getBoundingClientRect().height,
wideHook: wrapper.classList.contains('md-table-wide'),
paddingLeft: Number.parseFloat(getComputedStyle(wrapper).paddingLeft),
tableLeft: table.getBoundingClientRect().left,
}
})
}, MARKERS)
}
/** A sweep stop: the three tables' readings at one viewport width. */
interface TableStop {
width: number
tables: TableReading[]
}
/**
* Close the details pane so the transcript spans the viewport. Open, it pins
* the transcript to exactly the message column and every breakout relation
* would go vacuous.
* @param target - the page whose pane to close.
*/
async function closeDetailsPane(target: Page): Promise<void> {
await target.getByRole('button', { name: 'Close details', exact: true }).waitFor({ timeout: 10_000 })
await target.evaluate(() => {
document.querySelector<HTMLElement>('button[aria-label="Close details"]')?.click()
})
// Closed details resolve to zero width but never unmount (ui-layout
// columns contract), so the settled signal is the frame's collapse marker,
// not the button's detachment.
await target.waitForSelector('[data-details-collapsed]', { timeout: 5_000 })
}
/**
* Render the golden body: relations only. `fills` is the wrap-first claim
* (the wrapper has no residual horizontal scroll), `scrolls` the many-column
* fallback, and `breaks out` whether the wide wrapper spans past the message
* column (compared against the fill table, which by construction is exactly
* the message column's width).
* @param stops - the measured stops, in sweep order.
* @param wrapTighter - per table name, whether the block grew taller at the
* narrowest stop than at the widest (the proof wrapping engaged).
* @returns the golden body, without a trailing newline.
*/
function renderGeometry(stops: TableStop[], wrapTighter: Map<string, boolean>): string {
return [
'# Markdown wide-table relations',
'',
'| viewport | table | fills the column | scrolls | breaks out past the column |',
'| --- | --- | --- | --- | --- |',
...stops.flatMap((stop) => {
const columnWidth = stop.tables[0]!.clientWidth
return stop.tables.map((table, index) =>
`| ${String(stop.width)}px | ${TABLE_NAMES[index]} | ${String(table.overflow <= 1)} `
+ `| ${String(table.overflow > 1)} | ${String(table.clientWidth > columnWidth + 8)} |`,
)
}),
'',
'Wrap-first engagement (taller at the narrowest stop than at the widest):',
'',
...[...wrapTighter.entries()].map(([name, tighter]) => `- ${name}: ${String(tighter)}`),
].join('\n')
}
describe('web e2e: markdown tables fill the column, wide ones break out and scroll', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({})
await seedSession(scaffold, wideTableFixture(), SEED_ID)
browser = await chromium.launch()
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
const groupRow = page.locator('[role="treeitem"]').first()
await groupRow.waitFor({ timeout: 15_000 })
await groupRow.click()
const sessionRow = page.locator('[role="treeitem"]').nth(1)
await sessionRow.waitFor({ timeout: 10_000 })
await sessionRow.click()
await page.getByText(TAIL_MARKER, { exact: true }).waitFor({ timeout: 15_000 })
// Collapse the sidebar and close the details pane for the whole sweep:
// classic-scrollbar platforms (Linux CI) lose ~15px of layout width,
// which shifts how much of a narrow viewport the panes leave the
// transcript and lands the narrow stop's readings far from the macOS
// ones — and the details pane alone pins the transcript to exactly the
// message column, which would make every breakout relation vacuous.
// With both out of the equation the transcript follows the viewport
// identically on every platform, which is what keeps one committed
// golden true for all lanes.
await page.getByRole('button', { name: 'Collapse sidebar', exact: true }).click()
// JS click: after the transcript scrolled to its tail, the pane's close
// button can sit under the sticky header where a pointer click is
// intercepted; the pane itself is scaffolding, not the behavior under
// test, so actionability adds nothing here.
await closeDetailsPane(page)
}, 180_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
/**
* Resize to a viewport and read the tables once layout settles (the frame
* eases its column tracks, so a read straight after a resize can catch a
* mid-transition width).
* @param width - viewport width to settle at.
* @returns the three tables' readings at that width.
*/
const settleAt = async (width: number): Promise<TableReading[]> => {
await page.setViewportSize({ width, height: 900 })
// The wide wrapper follows the transcript width (the fill wrapper caps
// at the message column and would report "settled" mid-transition).
let previousWidth = -1
await expect.poll(async () => {
const current = (await readTables(page))[1]!.clientWidth
const settled = current === previousWidth
previousWidth = current
return settled
}, { timeout: 10_000 }).toBe(true)
return readTables(page)
}
/** Sweep once; every assertion reads the same measurement. */
let swept: Promise<TableStop[]> | undefined
const sweep = (): Promise<TableStop[]> => {
swept ??= (async () => {
const stops: TableStop[] = []
for (const width of WIDTHS) stops.push({ width, tables: await settleAt(width) })
return stops
})()
return swept
}
it('fills narrow tables, scrolls wide ones, and breaks them out where the transcript is wider', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-wide-table'))
const stops = await sweep()
for (const stop of stops) {
const [fill, wide, longCell] = stop.tables
const columnWidth = fill!.clientWidth
// #520: an ordinary wide table fills the message column and wraps…
expect(fill!.overflow, `fill viewport ${String(stop.width)}`).toBeLessThanOrEqual(1)
// …a long unbroken token and long CJK prose wrap instead of forcing a scroll…
expect(longCell!.overflow, `long-cell viewport ${String(stop.width)}`).toBeLessThanOrEqual(1)
// …and a many-column table keeps its natural width behind the scroll fallback.
expect(wide!.overflow, `wide viewport ${String(stop.width)}`).toBeGreaterThan(1)
// The hook is column-count static, present at every stop.
expect(wide!.wideHook).toBe(true)
expect(fill!.wideHook).toBe(false)
expect(longCell!.wideHook).toBe(false)
if (stop.width > 748) {
// Breakout: the wide wrapper spans past the message column, and its
// lead padding keeps the table content starting at the message
// column's left edge (compared to the fill table's content).
expect(wide!.clientWidth, `wide breakout at ${String(stop.width)}`).toBeGreaterThan(columnWidth + 8)
expect(wide!.paddingLeft, `lead at ${String(stop.width)}`).toBeGreaterThan(0)
expect(Math.abs(wide!.tableLeft - fill!.tableLeft), `alignment at ${String(stop.width)}`).toBeLessThan(1.5)
} else {
// Below the message column there is no spare width: the breakout
// clamps to neutral and the wrapper stays the column's width.
expect(Math.abs(wide!.clientWidth - columnWidth), `neutral at ${String(stop.width)}`).toBeLessThan(1.5)
expect(wide!.paddingLeft, `no lead at ${String(stop.width)}`).toBeLessThan(1.5)
}
}
// Wrap-first engaged for real: the filling tables grow taller as the
// column narrows (the wide table only scrolls, so it is exempt).
const widest = stops[0]!
const narrowest = stops[stops.length - 1]!
expect(narrowest.tables[0]!.height).toBeGreaterThan(widest.tables[0]!.height)
expect(narrowest.tables[2]!.height).toBeGreaterThan(widest.tables[2]!.height)
expect(tripwire.pageErrors).toEqual([])
}, 120_000)
it('keeps the wide table keyboard-scrollable', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-wide-table-keyboard'))
await sweep()
await settleAt(1680)
const wide = page.locator('[class*="tableScroll"]', { hasText: WIDE_MARKER })
// Chromium makes scrollable containers keyboard-focusable by default;
// arrow keys then scroll the focused wrapper.
await wide.focus()
await page.keyboard.press('ArrowRight')
await page.keyboard.press('ArrowRight')
await expect.poll(() => wide.evaluate(element => element.scrollLeft), { timeout: 5_000 })
.toBeGreaterThan(0)
expect(tripwire.pageErrors).toEqual([])
}, 120_000)
it('reveals the wide table scrollbar on hover only', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-wide-table-scrollbar'))
await sweep()
await settleAt(1680)
const wide = page.locator('[class*="tableScroll"]', { hasText: WIDE_MARKER })
// Chromium never repaints state-conditioned scrollbar STYLES, so the
// hover reveal toggles overflow-x itself; the resting padding matches
// the bar height so the swap does not move anything below. Both are
// ordinary properties whose computed values follow :hover.
const overflowState = () => wide.evaluate(element => [
getComputedStyle(element).overflowX,
getComputedStyle(element).paddingBottom,
].join(' '))
// Park the pointer away and drop focus: the keyboard case above leaves
// the wrapper focused, and focus-visible also reveals the bar.
await page.mouse.move(4, 4)
await wide.evaluate((element) => { element.blur() })
await expect.poll(overflowState, { timeout: 5_000 }).toBe('hidden 8px')
// Resting hidden overflow keeps the scroll position reachable and intact.
expect(await wide.evaluate(element => element.scrollLeft)).toBeGreaterThanOrEqual(0)
await wide.hover()
await expect.poll(overflowState, { timeout: 5_000 }).toBe('auto 0px')
// Pointer leaves: the bar rests hidden again.
await page.mouse.move(4, 4)
await expect.poll(overflowState, { timeout: 5_000 }).toBe('hidden 8px')
expect(tripwire.pageErrors).toEqual([])
}, 120_000)
it('keeps the fill/scroll relations under page zoom', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-wide-table-zoom'))
await sweep()
await settleAt(1100)
try {
await page.evaluate(() => { document.documentElement.style.zoom = '1.25' })
await expect.poll(async () => {
const [fill, wide, longCell] = await readTables(page)
return fill!.overflow <= 1 && longCell!.overflow <= 1 && wide!.overflow > 1
}, { timeout: 10_000 }).toBe(true)
} finally {
await page.evaluate(() => { document.documentElement.style.zoom = '' })
}
expect(tripwire.pageErrors).toEqual([])
}, 120_000)
it('reports the same relations on a high-DPI page', async () => {
const hidpiPage = await browser.newPage({
viewport: { width: 1100, height: 900 },
deviceScaleFactor: 2,
locale: 'en-US',
})
const hidpiTripwire = watchConsole(hidpiPage)
try {
onTestFailed(() => saveFailureShot(hidpiPage, 'web-e2e-markdown-wide-table-hidpi'))
await hidpiPage.goto(scaffold.baseUrl, { waitUntil: 'load' })
await hidpiPage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
const groupRow = hidpiPage.locator('[role="treeitem"]').first()
await groupRow.waitFor({ timeout: 15_000 })
await groupRow.click()
const sessionRow = hidpiPage.locator('[role="treeitem"]').nth(1)
await sessionRow.waitFor({ timeout: 10_000 })
await sessionRow.click()
await hidpiPage.getByText(TAIL_MARKER, { exact: true }).waitFor({ timeout: 15_000 })
await hidpiPage.getByRole('button', { name: 'Collapse sidebar', exact: true }).click()
await closeDetailsPane(hidpiPage)
// The pane collapses ease over the layout transition: compare only a
// settled reading (two consecutive equal wide-wrapper widths).
let readings: TableReading[] = []
let previousWide = -1
await expect.poll(async () => {
readings = await readTables(hidpiPage)
const settled = readings[1]!.clientWidth === previousWide
previousWide = readings[1]!.clientWidth
return settled
}, { timeout: 10_000 }).toBe(true)
const baseline = (await sweep()).find(stop => stop.width === 1100)!
const relations = (tables: TableReading[]) => tables.map(table => ({
marker: table.marker,
fills: table.overflow <= 1,
breaksOut: table.clientWidth > tables[0]!.clientWidth + 8,
}))
expect(relations(readings)).toEqual(relations(baseline.tables))
expect(hidpiTripwire.pageErrors).toEqual([])
} finally {
await hidpiPage.close()
}
}, 120_000)
it('matches the committed geometry golden', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-wide-table-golden'))
const stops = await sweep()
const widest = stops[0]!
const narrowest = stops[stops.length - 1]!
const wrapTighter = new Map<string, boolean>([
['fill', narrowest.tables[0]!.height > widest.tables[0]!.height],
['long-cell', narrowest.tables[2]!.height > widest.tables[2]!.height],
])
await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(stops, wrapTighter), MODE)
expect(tripwire.pageErrors).toEqual([])
}, 120_000)
it('commits exactly the fixtures it reads', async () => {
// No model calls, so no replay log: the golden is the whole inventory.
await assertFixtureInventory(SNAPSHOT_DIR, ['geometry.expected.md'])
})
it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', () => {
expect(tripwire.warnings).toEqual([])
expect(tripwire.pageErrors).toEqual([])
})
})
+100 -2
View File
@@ -10,10 +10,11 @@
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
import type { Browser, Locator, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
@@ -30,6 +31,31 @@ const COMPOSED_EXPECTED = join(SNAPSHOT_DIR, 'composed.expected.md')
const ANSWERED_EXPECTED = join(SNAPSHOT_DIR, 'answered.expected.md')
const MODE = webSnapshotMode()
// The composer's own growth cap, in text lines (QuestionComposer.module.css
// .fieldMirror). Asserted as TEXT lines, not as a box height: the two variants
// carry different padding, and a cap measured in border-box pixels silently
// means a different line count in each — which is exactly how the optionless
// field came to stop two thirds of a line short.
const CAP_LINES = 6
/**
* Measure a saturated answer field: how many whole text lines it grew to, and
* whether it took over the scrolling once it stopped growing.
* @param field - the composer's custom-answer textarea.
* @returns whole text lines the content box holds, and whether the field scrolls.
*/
async function capMetrics(field: Locator): Promise<{ textLines: number; scrolls: boolean }> {
await field.fill('x\n'.repeat(40))
return field.evaluate((el: HTMLTextAreaElement) => {
const style = getComputedStyle(el)
const text = el.clientHeight - parseFloat(style.paddingTop) - parseFloat(style.paddingBottom)
return {
textLines: Math.round(text / parseFloat(style.lineHeight)),
scrolls: el.scrollHeight > el.clientHeight,
}
})
}
// The options carry long descriptions on purpose: the squeeze assertion below
// needs option copy that WRAPS, which is the only text layout that reproduces a
// collapsed row painting its copy outside its own box.
@@ -41,6 +67,7 @@ describe('web e2e: resident question composer round trip', () => {
let page: Page
let tripwire: ReturnType<typeof watchConsole>
const sessionEvents: SessionEvent[] = []
let answeredSession: SessionId | undefined
beforeAll(async () => {
scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 })
@@ -132,9 +159,38 @@ describe('web e2e: resident question composer round trip', () => {
await page.setViewportSize(original)
}
// Multi-line custom answer: the field is a textarea whose hidden mirror
// owns the box height, so a soft-wrapped or line-broken draft GROWS the
// field instead of scrolling one line, and Shift+Enter breaks the line
// rather than continuing the flow. Measured on the live composer because
// only a real engine soft-wraps; growth stops at the mirror's cap, past
// which the textarea is the one thing that scrolls. Replay only, same as
// the squeeze above: record mode must reach the recording write.
const custom = composer.getByRole('textbox')
if (MODE !== 'record') {
const oneLineHeight = await custom.evaluate(el => el.getBoundingClientRect().height)
await custom.fill('a'.repeat(120))
const wrapped = await custom.evaluate(el => ({
height: el.getBoundingClientRect().height,
scrolls: el.scrollHeight > el.clientHeight,
}))
expect(wrapped.height).toBeGreaterThan(oneLineHeight * 1.5)
expect(wrapped.scrolls).toBe(false)
await custom.fill('')
await custom.press('Shift+Enter')
await custom.press('Shift+Enter')
expect(await custom.inputValue()).toBe('\n\n')
expect(await composer.getByText('Which color do you prefer?').count()).toBeGreaterThan(0)
expect(await custom.evaluate(el => el.getBoundingClientRect().height))
.toBeGreaterThan(oneLineHeight * 2.5)
expect(await capMetrics(custom)).toEqual({ textLines: CAP_LINES, scrolls: true })
await custom.fill('')
}
const blue = composer.getByRole('checkbox', { name: 'Blue' })
await blue.click()
const custom = composer.getByRole('textbox')
await custom.fill('Include accessibility notes')
expect(await blue.getAttribute('aria-checked')).toBe('true')
expect(await custom.inputValue()).toBe('Include accessibility notes')
@@ -149,6 +205,7 @@ describe('web e2e: resident question composer round trip', () => {
await recordFixture(scaffold, sessionId, FIXTURE)
return
}
answeredSession = sessionId
// World state: the tool result carries the chosen answer, and DONE lands.
const results = sessionEvents.filter(e => e.type === 'tool/result')
const answerText = results.flatMap(event => event.data.message.content.flatMap(block =>
@@ -172,6 +229,47 @@ describe('web e2e: resident question composer round trip', () => {
expect(tripwire.warnings).toEqual([])
}, 200_000)
// The fixture's question carries options, so the round trip above only ever
// exercises the inline shape. The optionless shape is the one that carries
// padding, which is where a cap measured in box pixels drifts off the line
// count — so it is asked straight through the user-questions seam (the same
// service the tool calls; no model round is involved in a layout metric).
it.skipIf(MODE === 'record')('grows the optionless answer to the same cap', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-question-optionless'))
const sessionId = answeredSession
expect(sessionId).toBeDefined()
const agent = scaffold.ctx.agents.get(sessionId as SessionId)
expect(agent).toBeDefined()
const asked = scaffold.ctx.userQuestions.ask({
agent: agent as NonNullable<typeof agent>,
questions: [{ id: 'free', header: 'More', question: 'Anything else?' }],
})
const composer = page.locator('[data-question-key]')
await composer.waitFor({ timeout: 30_000 })
const field = composer.getByRole('textbox')
// The empty field reserves its two lines AND the textarea fills that frame:
// a reserved box the control does not fill leaves a strip that looks like
// the field but takes no click.
expect(await field.evaluate((el) => {
const frame = el.parentElement as HTMLElement
const style = getComputedStyle(frame)
const inner = frame.getBoundingClientRect().height
- parseFloat(style.borderTopWidth) - parseFloat(style.borderBottomWidth)
return {
reserved: Math.round(frame.getBoundingClientRect().height),
fills: Math.abs(el.getBoundingClientRect().height - inner) < 0.5,
}
})).toEqual({ reserved: 64, fills: true })
// The same cap the inline shape stops at — the assertion a border-box cap fails.
expect(await capMetrics(field)).toEqual({ textLines: CAP_LINES, scrolls: true })
// Settle the wait so teardown is not racing a pending question.
await composer.getByRole('button', { name: 'Skip this question' }).click()
expect(await asked).toEqual({ answers: [{ id: 'free', selected: [] }] })
await expect.poll(() => page.locator('[data-question-key]').count(), { timeout: 10_000 }).toBe(0)
}, 60_000)
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
await assertFixtureInventory(SNAPSHOT_DIR, [
'session.jsonl',
@@ -37,6 +37,6 @@
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "6% of context used"
- button "8% of context used"
- button "Send message" [disabled]
- text: 1 turns · 1 steps LLM {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 99% Input 7.8K tok · Output 21 tok
- text: 1 turns · 1 steps LLM {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 99.5% Input 10K tok · Output 21 tok
@@ -0,0 +1,153 @@
{
"patches": [
{
"at": 0,
"entry": {
"kind": "chunks",
"chunks": [
{
"type": "block-start",
"index": 0,
"blockType": "reasoning"
},
{
"type": "reasoning-delta",
"index": 0,
"text": "The"
},
{
"type": "reasoning-delta",
"index": 0,
"text": " user"
},
{
"type": "reasoning-delta",
"index": 0,
"text": " wants"
},
{
"type": "reasoning-delta",
"index": 0,
"text": " me"
},
{
"type": "reasoning-delta",
"index": 0,
"text": " to"
},
{
"type": "reasoning-delta",
"index": 0,
"text": " reply"
},
{
"type": "reasoning-delta",
"index": 0,
"text": " with"
},
{
"type": "reasoning-delta",
"index": 0,
"text": " a"
},
{
"type": "reasoning-delta",
"index": 0,
"text": " single"
},
{
"type": "reasoning-delta",
"index": 0,
"text": " word"
},
{
"type": "reasoning-delta",
"index": 0,
"text": "."
},
{
"type": "reasoning-delta",
"index": 0,
"text": " Let"
},
{
"type": "reasoning-delta",
"index": 0,
"text": " me"
},
{
"type": "reasoning-delta",
"index": 0,
"text": " comply"
},
{
"type": "reasoning-delta",
"index": 0,
"text": "."
},
{
"type": "block-start",
"index": 1,
"blockType": "text"
},
{
"type": "text-delta",
"index": 1,
"text": "L"
},
{
"type": "text-delta",
"index": 1,
"text": "IGH"
},
{
"type": "text-delta",
"index": 1,
"text": "TH"
},
{
"type": "text-delta",
"index": 1,
"text": "O"
},
{
"type": "text-delta",
"index": 1,
"text": "USE"
},
{
"type": "block-end",
"index": 0,
"block": {
"type": "reasoning",
"text": "The user wants me to reply with a single word. Let me comply."
}
},
{
"type": "block-end",
"index": 1,
"block": {
"type": "text",
"text": "LIGHTHOUSE"
}
},
{
"type": "usage",
"usage": {
"inputTokens": 50,
"outputTokens": 21,
"cacheReadTokens": 9950,
"reasoningTokens": 15
}
},
{
"type": "finish",
"reason": {
"kind": "stop"
}
}
]
}
}
]
}
@@ -0,0 +1,18 @@
# Markdown wide-table relations
| viewport | table | fills the column | scrolls | breaks out past the column |
| --- | --- | --- | --- | --- |
| 1680px | fill | true | false | false |
| 1680px | wide | false | true | true |
| 1680px | long-cell | true | false | false |
| 1100px | fill | true | false | false |
| 1100px | wide | false | true | true |
| 1100px | long-cell | true | false | false |
| 640px | fill | true | false | false |
| 640px | wide | false | true | false |
| 640px | long-cell | true | false | false |
Wrap-first engagement (taller at the narrowest stop than at the widest):
- fill: true
- long-cell: true
+1
View File
@@ -64,6 +64,7 @@
"tests/message-feedback-layout.e2e.ts",
"tests/markdown-images.e2e.ts",
"tests/reference-composer.e2e.ts",
"tests/markdown-wide-table.e2e.ts",
"tests/math-rendering.e2e.ts",
"tests/markdown-cjk-strong.e2e.ts",
"tests/markdown-inline-code-links.e2e.ts",
+2 -2
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 docs/config-catalog.md
config-catalog.md: f955cdb0ddfa48159bb3dd15daa217bfddaa6fdd
config-catalog.zh.md: e852f57639574316511be62f9aa1f8390f965cf0
config-catalog.md: 1d2b3de46c376be0001970ff3fdc484aa3bd1570
config-catalog.zh.md: 21f582dedc72621c165894e5cb5f5790e9dbdf0c
+1 -1
View File
@@ -863,7 +863,7 @@ export interface Config {
}
```
Source: [`packages/host/webserver/src/index.ts:45`](../packages/host/webserver/src/index.ts)
Source: [`packages/host/webserver/src/index.ts:59`](../packages/host/webserver/src/index.ts)
<a id="deepseek-aidsh-invariants"></a>
+1 -1
View File
@@ -865,7 +865,7 @@ export interface Config {
}
```
来源:[`packages/host/webserver/src/index.ts:45`](../packages/host/webserver/src/index.ts)
来源:[`packages/host/webserver/src/index.ts:59`](../packages/host/webserver/src/index.ts)
<a id="deepseek-aidsh-invariants"></a>
+2 -2
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 docs/event-producer-consumer.md
event-producer-consumer.md: d75d64b58b041c117bf2eaa1612c1b4683378a00
event-producer-consumer.zh.md: adfc11782e179077b17199fc4ea6e8f52b3c465b
event-producer-consumer.md: d3901b1ddc7d99d4960324ab7459388fa4612bbe
event-producer-consumer.zh.md: 346a293a226719f781a9587eb0adefcf3f6bed08
+1
View File
@@ -57,6 +57,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:175`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) |
| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:152`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs) |
| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:197`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) |
| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | - |
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
| `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |
+1
View File
@@ -59,6 +59,7 @@
| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:175`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) |
| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:152`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs) |
| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:197`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) |
| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | - |
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
| `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |
+2 -2
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 docs/subsystems/client-modules.md
client-modules.md: 52fa4ef73766e8e7e7601e604f78aded9940328f
client-modules.zh.md: e830349be77000759684a76b2834478a803e3e11
client-modules.md: d939cbbafa186b82d9110a6ccc41e9892b53eb95
client-modules.zh.md: 8f407de0bd44ea2cc95b6d29ca7e149648785c0f
+6 -6
View File
@@ -2,13 +2,13 @@
English | [中文](client-modules.zh.md)
The web plugin table: the Node half of the client module system in [dsh-client-modules](../../packages/client/modules), provided as `ctx.clientModules` (`ClientModuleRegistry`). It scans the host Loader's entries for packages declaring `dsh.client`, composes the `window.__DSH_BOOT__` entry graph, serves each bundle at `/plugins/<id>/client.js`, and taps the index render to inject the boot manifest — the four faces of one service. It is an optional capability of the web GUI stack, not part of the agent-loop spine, and it is a consumer of [dsh-host-webserver](../../packages/host/webserver): the carrier described in [web-server.md](web-server.md) supplies the prefix route and index tap this service registers. The same package's browser half (`ctx.modules`, the lazy-CJS module table that fetches and materializes these bundles) is kernel machinery documented in the [package README](../../packages/client/modules/README.md), not here.
The web plugin table: the Node half of the client module system in [dsh-client-modules](../../packages/client/modules), provided as `ctx.clientModules` (`ClientModuleRegistry`). It scans the host Loader's entries for packages declaring `dsh.client`, composes the `window.__DSH_BOOT__` entry graph, serves each bundle at `/plugins/<id>/client.js`, and answers every index-injection collection with the boot manifest rows — the four faces of one service. It is an optional capability of the web GUI stack, not part of the agent-loop spine, and it is a consumer of [dsh-host-webserver](../../packages/host/webserver): the carrier described in [web-server.md](web-server.md) supplies the prefix route and the `webserver/index-inject` event this service answers. The same package's browser half (`ctx.modules`, the lazy-CJS module table that fetches and materializes these bundles) is kernel machinery documented in the [package README](../../packages/client/modules/README.md), not here.
Source: [`packages/client/modules/src/client/manifest.ts`](../../packages/client/modules/src/client/manifest.ts)
## The wire
The graph is the wire single source between the Node and browser halves: the host composes `WebBootEntry` rows from scanned packages, injects the graph as the first script in `<head>` (`window.__DSH_BOOT__`, with `<` escaped so plugin-controlled strings cannot break out of the script element), and the shell parses it before booting anything. A page without a valid manifest cannot boot — the browser-side parser throws loud on a missing or malformed graph.
The graph is the wire single source between the Node and browser halves: the host composes `WebBootEntry` rows from scanned packages, publishes the graph as a `global` injection row rendered ahead of later script rows (`globalThis["__DSH_BOOT__"]`, with `<` escaped so plugin-controlled strings cannot break out of the script element), and the shell parses it before booting anything. A page without a valid manifest cannot boot — the browser-side parser throws loud on a missing or malformed graph.
```ts type-equiv
/**
@@ -60,9 +60,9 @@ Scanning is incremental per package; there is no full-rescan code path. Every co
Package metadata — including the negative "not a client package" verdict — is cached per name and never expires: plugin-set changes take effect on restart. A fiber restart reuses its row and rev untouched; bundle content changes reach the graph only through `rebuilt()`.
## The bundle route and index tap
## The bundle route and index injection
`GET`/`HEAD /plugins/<id>/client.js` serves the registered bundle from disk with `no-cache` (the rev query, not HTTP caching, anchors consistency); other methods are 405. An unknown id — or a registered row whose bundle is unreadable because it has not been built yet — answers a loud 404 rather than letting the carrier's SPA fallback ship HTML as JavaScript. The index tap injects the current graph on every index render, so a reload always boots against the live composition.
`GET`/`HEAD /plugins/<id>/client.js` serves the registered bundle from disk with `no-cache` (the rev query, not HTTP caching, anchors consistency); other methods are 405. An unknown id — or a registered row whose bundle is unreadable because it has not been built yet — answers a loud 404 rather than letting the carrier's SPA fallback ship HTML as JavaScript. The injection rows carry the current graph on every index render, so a reload always boots against the live composition.
## The service
@@ -82,7 +82,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp
### `ctx.clientModules``ClientModuleRegistry`
The web plugin table service: incremental `dsh.client` scan + wire composition + bundle route + index tap. Construction runs the activation scan synchronously — a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud throw (FAILED fiber; the boot activation audit reports it).
The web plugin table service: incremental `dsh.client` scan + wire composition + bundle route + index injection rows. Construction runs the activation scan synchronously — a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud throw (FAILED fiber; the boot activation audit reports it).
```ts cordis-catalog
/**
@@ -122,5 +122,5 @@ onRebuilt(listener: (id: string, rev: string) => void): () => void
onGraphChanged(listener: () => void): () => void
```
Source: [`packages/client/modules/src/index.ts:295`](../../packages/client/modules/src/index.ts)
Source: [`packages/client/modules/src/index.ts:282`](../../packages/client/modules/src/index.ts)
<!-- END GENERATED cordis-surface -->
+6 -6
View File
@@ -2,13 +2,13 @@
[English](client-modules.md) | 中文
Web 插件表:[dsh-client-modules](../../packages/client/modules) 中 client 模块系统的 Node 半,以 `ctx.clientModules``ClientModuleRegistry`)形式提供。它扫描宿主 Loader 的 entry,找出声明了 `dsh.client` 的包,组合出 `window.__DSH_BOOT__` entry 图,在 `/plugins/<id>/client.js` 提供各个 bundle,并经 index 转换(index tap)注入启动 manifest(元数据清单)——这是同一个服务的四个面。它是 Web GUI 栈的一项可选能力,不属于 agent loop(智能体循环)主干,并且是 [dsh-host-webserver](../../packages/host/webserver) 的消费方:[web-server.md](web-server.md) 所述的载体提供本服务注册的前缀路由与 index 转换。同一个包的浏览器半(`ctx.modules`,即拉取并物化这些 bundle 的 lazy CJS 模块表)属于内核机件,记录在[包 README](../../packages/client/modules/README.md)中,不在本页。
Web 插件表:[dsh-client-modules](../../packages/client/modules) 中 client 模块系统的 Node 半,以 `ctx.clientModules``ClientModuleRegistry`)形式提供。它扫描宿主 Loader 的 entry,找出声明了 `dsh.client` 的包,组合出 `window.__DSH_BOOT__` entry 图,在 `/plugins/<id>/client.js` 提供各个 bundle,并启动 manifest(元数据清单)行回应每次 index 注入收集——这是同一个服务的四个面。它是 Web GUI 栈的一项可选能力,不属于 agent loop(智能体循环)主干,并且是 [dsh-host-webserver](../../packages/host/webserver) 的消费方:[web-server.md](web-server.md) 所述的载体提供本服务注册的前缀路由与其回应的 `webserver/index-inject` 事件。同一个包的浏览器半(`ctx.modules`,即拉取并物化这些 bundle 的 lazy CJS 模块表)属于内核机件,记录在[包 README](../../packages/client/modules/README.md)中,不在本页。
源码:[`packages/client/modules/src/client/manifest.ts`](../../packages/client/modules/src/client/manifest.ts)
## wire
图是 Node 半与浏览器半之间协议层的唯一真源:宿主从扫描到的包组合出 `WebBootEntry` 行,把图作为 `<head>` 中的第一个脚本注入(`window.__DSH_BOOT__`,其中 `<` 已转义,插件可控的字符串因此无法逃出 script 元素),壳则在启动任何东西之前先解析它。没有有效 manifest 的页面无法启动——浏览器侧的解析器在图缺失或畸形时大声抛错。
图是 Node 半与浏览器半之间协议层的唯一真源:宿主从扫描到的包组合出 `WebBootEntry` 行,把图发布为一条 `global` 注入行、渲染在后续 script 行之前(`globalThis["__DSH_BOOT__"]`,其中 `<` 已转义,插件可控的字符串因此无法逃出 script 元素),壳则在启动任何东西之前先解析它。没有有效 manifest 的页面无法启动——浏览器侧的解析器在图缺失或畸形时大声抛错。
```ts type-equiv
/**
@@ -60,9 +60,9 @@ interface WebBootGraph {
包元数据——包括「非 client 包」这一否定结论——按名缓存且永不过期:插件集合的变更在重启后生效。fiber 重启原样复用其行与 rev;bundle 内容变更只经 `rebuilt()` 到达图。
## bundle 路由与 index 转换
## bundle 路由与 index 注入
`GET`/`HEAD /plugins/<id>/client.js``no-cache` 从磁盘提供已注册的 bundle(锚定一致性的是 rev 查询参数,而非 HTTP 缓存);其他方法返回 405。未知 id——或已注册、但 bundle 因尚未构建而不可读的行——回应一个大声的 404,而不是让载体的 SPA 回退把 HTML 当作 JavaScript 发出。index 转换在每次 index 渲染时注入当前图,因此刷新页面总是针对实时组合启动。
`GET`/`HEAD /plugins/<id>/client.js``no-cache` 从磁盘提供已注册的 bundle(锚定一致性的是 rev 查询参数,而非 HTTP 缓存);其他方法返回 405。未知 id——或已注册、但 bundle 因尚未构建而不可读的行——回应一个大声的 404,而不是让载体的 SPA 回退把 HTML 当作 JavaScript 发出。注入行在每次 index 渲染时携带当前图,因此刷新页面总是针对实时组合启动。
## 服务
@@ -82,7 +82,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp
### `ctx.clientModules``ClientModuleRegistry`
The web plugin table service: incremental `dsh.client` scan + wire composition + bundle route + index tap. Construction runs the activation scan synchronously — a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud throw (FAILED fiber; the boot activation audit reports it).
The web plugin table service: incremental `dsh.client` scan + wire composition + bundle route + index injection rows. Construction runs the activation scan synchronously — a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud throw (FAILED fiber; the boot activation audit reports it).
```ts cordis-catalog
/**
@@ -122,5 +122,5 @@ onRebuilt(listener: (id: string, rev: string) => void): () => void
onGraphChanged(listener: () => void): () => void
```
Source: [`packages/client/modules/src/index.ts:295`](../../packages/client/modules/src/index.ts)
Source: [`packages/client/modules/src/index.ts:282`](../../packages/client/modules/src/index.ts)
<!-- END GENERATED cordis-surface -->
+2 -2
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 docs/subsystems/web-server.md
web-server.md: 414863d88cdc34163882cdc3dfe8dc7c1471ac77
web-server.zh.md: d756192ce733a3fe79c9eaf44e5d556c4bac90a8
web-server.md: 6a33eb2bacb7dd9e85a13d7f2ef9cf870b5c44d9
web-server.zh.md: 5dcc4b6e002c4bdeb0d839e67a750f67f40eb0a6
+44 -4
View File
@@ -42,7 +42,7 @@ interface Config {
## The service
`WebServer` (`ctx.webServer`) listens immediately on activation; a listen failure (EADDRINUSE…) rejects initialization, and the boot process reports the failed fiber. `register(route)` adds one named route and returns its disposer; a duplicate `(kind, path)` throws because route patterns are a composition-level contract and a collision is a misconfiguration. `tapIndex(transform)` adds a pure html-to-html transform applied to every index response — `/` and each SPA fallback — in registration order; [dsh-client-modules](../../packages/client/modules) uses it to inject the boot manifest. `port` reads the listening port, including the port assigned by the OS when `config.port` is 0.
`WebServer` (`ctx.webServer`) listens immediately on activation; a listen failure (EADDRINUSE…) rejects initialization, and the boot process reports the failed fiber. `register(route)` adds one named route and returns its disposer; a duplicate `(kind, path)` throws because route patterns are a composition-level contract and a collision is a misconfiguration. `collectIndexInjections()` gathers structured `IndexInjection` rows over one `webserver/index-inject` emit, and `renderIndex(html)` renders them into every index response — `/` and each SPA fallback — before applying the raw `tapIndex(transform)` escape-hatch transforms in registration order; [dsh-client-modules](../../packages/client/modules) answers the event with the boot manifest rows. `port` reads the listening port, including the port assigned by the OS when `config.port` is 0.
A request whose handling throws (a malformed %-escape hitting `decodeURIComponent`, a client dropping mid-body) is logged as a warning and answered 400 — or the socket destroyed when headers are already out — never a process exit. Disposal pairs `close()` with `closeAllConnections()` because a handler may hold its response open (SSE) and such connections never end on their own; without the force-close, teardown would hang. The package never prints: the URL line belongs to the shell. Per-package operational detail, including the dev-mode bundle watch pipeline, stays in the [README](../../packages/host/webserver/README.md).
@@ -88,8 +88,9 @@ registerUpgrade(route: WebUpgradeRoute): () => void
registerFallback(handler: WebRoute['handler']): () => void
/**
* Register an index.html transform, applied by the fallback owner to every
* index response ({@link applyIndexTaps}) in registration order.
* Register a raw-HTML index transform, the escape hatch for markup no
* {@link IndexInjection} row expresses: {@link renderIndex} applies taps in
* registration order after rendering the structured rows.
* @param transform - pure html-to-html function.
* @returns the disposer removing the transform.
*/
@@ -102,7 +103,46 @@ tapIndex(transform: (html: string) => string): () => void
* @returns the transformed body.
*/
applyIndexTaps(html: string): string
/**
* Gather the structured injection table: one `webserver/index-inject` emit,
* every subscriber pushes its current rows. Fresh per call, so subscribers
* read live state (module graph, theme preference) at emit time.
* @returns rows in subscriber activation order.
*/
collectIndexInjections(): IndexInjection[]
/**
* Render one index.html body: the structured injection table first, then
* the raw `tapIndex` transforms over the result.
* @param html - the raw index.html body.
* @returns the transformed body.
*/
renderIndex(html: string): string
```
Source: [`packages/host/webserver/src/index.ts:59`](../../packages/host/webserver/src/index.ts)
Source: [`packages/host/webserver/src/index.ts:73`](../../packages/host/webserver/src/index.ts)
<a id="webserver-events"></a>
### `webserver/*` events
<a id="webserverindex-inject--emit"></a>
#### `webserver/index-inject` — emit
Collect the structured index injection table. Emitted on every index render and every worker boot-payload request; listeners push their current rows, so a row's data is read fresh at emit time.
```ts cordis-catalog
/**
* Collect the structured index injection table. Emitted on every index
* render and every worker boot-payload request; listeners push their
* current rows, so a row's data is read fresh at emit time.
* @param table - Mutable row table; listeners append in activation order.
* @mode emit
*/
'webserver/index-inject'(table: IndexInjection[]): void
```
Source: [`packages/host/webserver/src/index.ts:34`](../../packages/host/webserver/src/index.ts)
<!-- END GENERATED cordis-surface -->
+44 -4
View File
@@ -42,7 +42,7 @@ interface Config {
## 服务
`WebServer``ctx.webServer`)在激活时立即监听;监听失败(EADDRINUSE 等)会使初始化被拒绝,启动进程会报告失败的 fiber。`register(route)` 添加一条具名路由并返回其 disposer;重复的 `(kind, path)` 抛出异常,因为路由模式是组合层约定,冲突即配置错误。`tapIndex(transform)` 添加一个纯 HTML 到 HTML 转换函数,按注册顺序应用于每个 index 响应(`/` 和每次 SPA 回退)[dsh-client-modules](../../packages/client/modules) 用它注入启动 manifest(元数据清单)。`port` 读取监听端口,包括 `config.port` 为 0 时操作系统分配的端口。
`WebServer``ctx.webServer`)在激活时立即监听;监听失败(EADDRINUSE 等)会使初始化被拒绝,启动进程会报告失败的 fiber。`register(route)` 添加一条具名路由并返回其 disposer;重复的 `(kind, path)` 抛出异常,因为路由模式是组合层约定,冲突即配置错误。`collectIndexInjections()` 经一次 `webserver/index-inject` emit 收集结构化 `IndexInjection` 行,`renderIndex(html)` 把它们渲染进每个 index 响应(`/` 和每次 SPA 回退),随后再按注册顺序应用原始的 `tapIndex(transform)` 逃生口转换[dsh-client-modules](../../packages/client/modules) 启动 manifest(元数据清单)行回应该事件`port` 读取监听端口,包括 `config.port` 为 0 时操作系统分配的端口。
处理过程中抛出异常的请求(畸形的 % 转义撞上 `decodeURIComponent`、客户端在请求体中途断开)会记录为警告并应答 400(响应头已发出时则销毁 socket),绝不导致进程退出。dispose(资源释放)把 `close()``closeAllConnections()` 配对使用,因为处理器可能像 SSEServer-Sent Events)那样保持响应打开,而这类连接永远不会自行结束;没有强制关闭,拆卸就会挂起。该包从不打印输出:URL 行归 shell 所有。逐包运维细节(含开发模式的 bundle 监视流水线)留在 [README](../../packages/host/webserver/README.md) 中。
@@ -88,8 +88,9 @@ registerUpgrade(route: WebUpgradeRoute): () => void
registerFallback(handler: WebRoute['handler']): () => void
/**
* Register an index.html transform, applied by the fallback owner to every
* index response ({@link applyIndexTaps}) in registration order.
* Register a raw-HTML index transform, the escape hatch for markup no
* {@link IndexInjection} row expresses: {@link renderIndex} applies taps in
* registration order after rendering the structured rows.
* @param transform - pure html-to-html function.
* @returns the disposer removing the transform.
*/
@@ -102,7 +103,46 @@ tapIndex(transform: (html: string) => string): () => void
* @returns the transformed body.
*/
applyIndexTaps(html: string): string
/**
* Gather the structured injection table: one `webserver/index-inject` emit,
* every subscriber pushes its current rows. Fresh per call, so subscribers
* read live state (module graph, theme preference) at emit time.
* @returns rows in subscriber activation order.
*/
collectIndexInjections(): IndexInjection[]
/**
* Render one index.html body: the structured injection table first, then
* the raw `tapIndex` transforms over the result.
* @param html - the raw index.html body.
* @returns the transformed body.
*/
renderIndex(html: string): string
```
Source: [`packages/host/webserver/src/index.ts:59`](../../packages/host/webserver/src/index.ts)
Source: [`packages/host/webserver/src/index.ts:73`](../../packages/host/webserver/src/index.ts)
<a id="webserver-events"></a>
### `webserver/*` events
<a id="webserverindex-inject--emit"></a>
#### `webserver/index-inject` — emit
Collect the structured index injection table. Emitted on every index render and every worker boot-payload request; listeners push their current rows, so a row's data is read fresh at emit time.
```ts cordis-catalog
/**
* Collect the structured index injection table. Emitted on every index
* render and every worker boot-payload request; listeners push their
* current rows, so a row's data is read fresh at emit time.
* @param table - Mutable row table; listeners append in activation order.
* @mode emit
*/
'webserver/index-inject'(table: IndexInjection[]): void
```
Source: [`packages/host/webserver/src/index.ts:34`](../../packages/host/webserver/src/index.ts)
<!-- END GENERATED cordis-surface -->
+103 -58
View File
@@ -52,6 +52,14 @@ interface RemoteNamespaceHandle {
readonly dispose: TypertDisposer
}
/** One descriptor's mounted variants, for the group disposer to unwind. */
interface InstalledMethod {
readonly descriptor: InvocationDescriptor
readonly token: MountToken
direct: boolean
scoped: boolean
}
/** Typed Remote service augmented by generated direct namespaces. */
export type ClientRemote = TypertClientRemote
@@ -177,9 +185,17 @@ class ClientRemoteService extends Service implements TypertClientRemote {
): Promise<TypertDisposer> {
this.validateContribution(contribution)
const disposeRemote = callerCtx.typert.remotes.register(contribution)
const groups = new Map<string, InvocationDescriptor[]>()
for (const descriptor of contribution.descriptors) {
const group = groups.get(descriptor.namespace)
if (group === undefined) groups.set(descriptor.namespace, [descriptor])
else group.push(descriptor)
}
const installed: TypertDisposer[] = []
try {
for (const descriptor of contribution.descriptors) installed.push(await this.install(descriptor))
for (const [namespace, descriptors] of groups) {
installed.push(await this.installNamespace(namespace, descriptors))
}
} catch (error) {
for (const dispose of installed.reverse()) await dispose()
await disposeRemote()
@@ -235,66 +251,47 @@ class ClientRemoteService extends Service implements TypertClientRemote {
}
}
private async install(descriptor: InvocationDescriptor): Promise<TypertDisposer> {
const token: MountToken = { active: true, abort: new AbortController() }
const installed: TypertDisposer[] = []
try {
if (descriptor.invocation.kind === 'direct') {
installed.push(await this.installDirect(descriptor, token))
}
const projection = scopedProjection(descriptor)
if (projection !== undefined) installed.push(await this.installScoped(descriptor, projection, token))
} catch (error) {
token.active = false
token.abort.abort()
for (const dispose of installed.reverse()) await dispose()
throw error
}
return async () => {
/* v8 ignore next -- Cordis effect disposers are idempotent and invoke this cleanup at most once. */
if (!token.active) return
token.active = false
token.abort.abort()
for (const dispose of installed.reverse()) await dispose()
}
}
private async installDirect(descriptor: InvocationDescriptor, token: MountToken): Promise<TypertDisposer> {
const namespace = await this.namespace(descriptor.namespace)
try {
namespace.service.installDirect(descriptor, token)
} catch (error) {
await this.disposeNamespace(descriptor.namespace, namespace)
throw error
}
return async () => {
namespace.service.remove('direct', descriptor.method, token)
await this.disposeNamespace(descriptor.namespace, namespace)
}
}
private async installScoped(
descriptor: InvocationDescriptor,
projection: ScopedProjection,
token: MountToken,
/**
* Mount one namespace's descriptor group with no visibility gap: a fresh
* namespace installs its whole group synchronously inside its fiber's
* apply, so a plugin parked on the namespace service never observes it
* without the methods the same contribution carries; an existing namespace
* takes the group in one synchronous step.
* @param name - Remote namespace.
* @param descriptors - Every contribution descriptor naming that namespace.
* @returns disposer unmounting the group and the namespace once empty.
*/
private async installNamespace(
name: string,
descriptors: readonly InvocationDescriptor[],
): Promise<TypertDisposer> {
const namespace = await this.namespace(descriptor.namespace)
try {
namespace.service.installScoped(descriptor, projection, token)
} catch (error) {
await this.disposeNamespace(descriptor.namespace, namespace)
throw error
let namespace = this.namespaces.get(name)
let installed: InstalledMethod[]
if (namespace === undefined) {
({ namespace, installed } = await this.createNamespace(name, descriptors))
} else {
installed = installMethods(namespace.service, descriptors)
}
const handle = namespace
return async () => {
namespace.service.remove('scoped', descriptor.method, token)
await this.disposeNamespace(descriptor.namespace, namespace)
for (const method of [...installed].reverse()) {
/* v8 ignore next -- Cordis effect disposers are idempotent and invoke this cleanup at most once. */
if (!method.token.active) continue
method.token.active = false
method.token.abort.abort()
if (method.scoped) handle.service.remove('scoped', method.descriptor.method, method.token)
if (method.direct) handle.service.remove('direct', method.descriptor.method, method.token)
}
await this.disposeNamespace(name, handle)
}
}
private async namespace(name: string): Promise<RemoteNamespaceHandle> {
let namespace = this.namespaces.get(name)
if (namespace !== undefined) return namespace
private async createNamespace(
name: string,
descriptors: readonly InvocationDescriptor[],
): Promise<{ namespace: RemoteNamespaceHandle; installed: InstalledMethod[] }> {
let service: RemoteNamespaceService | undefined
let installed: InstalledMethod[] | undefined
const fiber = this.ownerCtx.plugin({
name: remoteServiceKey(name),
apply: (ctx: Context) => {
@@ -303,6 +300,9 @@ class ClientRemoteService extends Service implements TypertClientRemote {
name,
(direct, scoped, caller, args) => this.invokeMethod(direct, scoped, caller, args),
)
// Same synchronous window as the service registration: a dependent the
// new service unparks runs only after the methods exist.
installed = installMethods(service, descriptors)
},
})
try {
@@ -311,11 +311,13 @@ class ClientRemoteService extends Service implements TypertClientRemote {
await fiber.dispose()
throw error
}
/* v8 ignore next -- a settled namespace fiber synchronously constructs its Service. */
if (service === undefined) throw new Error(`client api: namespace ${JSON.stringify(name)} did not start`)
namespace = { service, dispose: fiber.dispose }
/* v8 ignore next 3 -- a settled namespace fiber synchronously constructs its Service and installs the group. */
if (service === undefined || installed === undefined) {
throw new Error(`client api: namespace ${JSON.stringify(name)} did not start`)
}
const namespace = { service, dispose: fiber.dispose }
this.namespaces.set(name, namespace)
return namespace
return { namespace, installed }
}
private async disposeNamespace(name: string, namespace: RemoteNamespaceHandle): Promise<void> {
@@ -504,6 +506,49 @@ class RemoteNamespaceService extends Service {
}
}
/**
* Install one descriptor group on a namespace service, unwinding the partial
* group when a descriptor is refused.
* @param service - Namespace service taking the methods.
* @param descriptors - Descriptor group of one contribution.
* @returns per-descriptor records for the group disposer.
*/
function installMethods(
service: RemoteNamespaceService,
descriptors: readonly InvocationDescriptor[],
): InstalledMethod[] {
const installed: InstalledMethod[] = []
try {
for (const descriptor of descriptors) {
const method: InstalledMethod = {
descriptor,
token: { active: true, abort: new AbortController() },
direct: false,
scoped: false,
}
installed.push(method)
if (descriptor.invocation.kind === 'direct') {
service.installDirect(descriptor, method.token)
method.direct = true
}
const projection = scopedProjection(descriptor)
if (projection !== undefined) {
service.installScoped(descriptor, projection, method.token)
method.scoped = true
}
}
} catch (error) {
for (const method of [...installed].reverse()) {
method.token.active = false
method.token.abort.abort()
if (method.scoped) service.remove('scoped', method.descriptor.method, method.token)
if (method.direct) service.remove('direct', method.descriptor.method, method.token)
}
throw error
}
return installed
}
const REMOTE_NAMESPACE_FIELDS = new Set(['ctx', 'empty', 'invokeRemote', 'methods', 'name', 'namespace'])
function remoteServiceKey(namespace: string): string {
@@ -458,6 +458,91 @@ describe('Client Typert API', () => {
await disposeContext()
})
it('unwinds an already-installed namespace when a later namespace fails to install', async () => {
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
const { scope: _scope, ...probe } = directDescriptor()
const vault: InvocationDescriptor = {
...probe,
id: '@fixture/vault#vault/seal',
service: 'vault',
namespace: 'vault',
method: 'seal',
}
const defineProperty = Object.defineProperty
const spy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => {
if (key === 'seal') throw new Error('fixture later-namespace failure')
return defineProperty(target, key, attributes)
})
try {
await expect(ctx.remote.$mount({ package: '@fixture/two-namespaces', descriptors: [probe, vault] }))
.rejects.toThrow('fixture later-namespace failure')
} finally {
spy.mockRestore()
}
expect((ctx.remote as unknown as Record<string, unknown>).probe).toBeUndefined()
expect((ctx.remote as unknown as Record<string, unknown>).vault).toBeUndefined()
await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) })
})
it('rolls back an earlier scoped projection when a later descriptor fails to install', async () => {
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
const { scope: _scope, ...direct } = directDescriptor()
const failing: InvocationDescriptor = {
...direct,
id: '@fixture/probe#probe/archive',
method: 'archive',
}
const defineProperty = Object.defineProperty
const spy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => {
if (key === 'archive') throw new Error('fixture trailing failure')
return defineProperty(target, key, attributes)
})
try {
await expect(ctx.remote.$mount({
package: '@fixture/scoped-then-failing',
descriptors: [contextDescriptor(), failing],
})).rejects.toThrow('fixture trailing failure')
} finally {
spy.mockRestore()
}
expect((ctx.remote as unknown as Record<string, unknown>).probe).toBeUndefined()
await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) })
})
it('keeps a namespace another contribution still populates when a group leaves', async () => {
const call = vi.fn<ConnectionHandle['rpc']['call']>()
.mockResolvedValue({ ok: true, value: { renamed: true } })
const ctx = await bench(call)
const { scope: _scope, ...direct } = directDescriptor()
const disposeDirect = await ctx.remote.$mount({ package: '@fixture/direct-owner', descriptors: [direct] })
const disposeScoped = await ctx.remote.$mount({ package: '@fixture/scoped-owner', descriptors: [contextDescriptor()] })
await disposeDirect()
// The namespace survives its first group: the second contribution still owns methods on it.
const surviving = ctx.get('remote.probe') as unknown as Record<string, unknown> | undefined
expect(surviving).toBeDefined()
expect(surviving?.create).toBeUndefined()
await disposeScoped()
expect(ctx.get('remote.probe')).toBeUndefined()
})
it('unparks a namespace dependent only after its contribution methods exist', async () => {
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
const { scope: _scope, ...direct } = directDescriptor()
let observed: string | undefined
// Parked before the mount: the unpark moment is the observation — the
// atomic-visibility guarantee says the service never appears methodless.
const parked = ctx.inject(['remote.probe'], (probeCtx) => {
observed = typeof (probeCtx.get('remote.probe') as { create?: unknown } | undefined)?.create
})
const dispose = await ctx.remote.$mount({ package: '@fixture/atomic-visibility', descriptors: [direct] })
await parked
expect(observed).toBe('function')
await dispose()
})
it('rejects weak parameter and Context codecs plus malformed scope projections', async () => {
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
const direct = directDescriptor()
@@ -78,7 +78,7 @@ function fakeHttpServer(host: '127.0.0.1' | '0.0.0.0' = '127.0.0.1'): { server:
fallback = handler
return () => { fallback = undefined }
},
applyIndexTaps: (html: string) => html,
renderIndex: (html: string) => html,
} as unknown as WebServer
return { server, seat: () => fallback }
}
+2 -2
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/connection/README.md
README.md: f0a707cc5f3a962c7852f7323c727d0a39a57b10
README.zh.md: b529eebf9af93e36b6b92c19964678b7f5a04ea1
README.md: a7562b9dac57930b1abc0b76b9079a6865a38b35
README.zh.md: c18740e98721a9aa7ec95037da45c739c58a06f7
+1 -1
View File
@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + observable generation-scoped `hostDescription` + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` abstraction, and the loop's sink/config types. Each successful readiness handshake publishes the exact `host.describe` value before `onConnected`; generation loss and explicit stop clear it, so native-capability consumers never retain a disconnected answer. The browser carrier uses HTTP POST for unary and respond operations and opens one downlink-only WebSocket each for `events.mux` and `events.host`; the in-process carrier satisfies the same two-stream abstraction. The Host half owns the single `/api` route and its Fetch bridge; a registered Typert interceptor claims its Remote endpoints before the API Proxy fallback. Loopback hostname classification stays package-internal: the `/api` Host fence and WebSocket upgrades use it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`openDocument`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`; reads and native actions included, since describing returns the exposed configuration, opening acts on the Host desktop, and probing an arbitrary reference reports where a credential comes from — and the agent-preset authoring plane, `agentPreset.read`/`copy`/`openDocument`/`remove`, since a composition names the plugins a session runs, so reading one is reconnaissance, and copy/remove/openDocument manage the roster and drive the host desktop (authoring is copy-only, so none of them accepts composition text or a path); `agentPreset.list` and `agentPreset.select` stay out — the roster carries only ids and trust, and choosing a preset grants nothing `session.create`'s own `agentPreset` did not, over a default that already carries bash) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform carriers and ConnectionController loop are package-internal; apply selects and drives them. The downlink boundary is documented in the [WebSocket downlink carrier Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md).
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + observable generation-scoped `hostDescription` + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` abstraction, and the loop's sink/config types. Each successful readiness handshake publishes the exact `host.describe` value before `onConnected`; generation loss and explicit stop clear it, so native-capability consumers never retain a disconnected answer. The browser carrier uses HTTP POST for unary and respond operations and opens one downlink-only WebSocket each for `events.mux` and `events.host`; the in-process carrier satisfies the same two-stream abstraction. The exported `ClientTransportHooks` names the page global `__DSH_TRANSPORT__` that replaces the browser carrier wholesale: the served web app leaves it unset and gets HTTP + WebSocket, while a shell owning a different physical transport (the worker preview's postMessage tunnel) provides `createApiClient` and `fetch` — plus `loadBundle` when it also owns bundle bytes — instead of forking the plugin. The Host half owns the single `/api` route and its Fetch bridge; a registered Typert interceptor claims its Remote endpoints before the API Proxy fallback. Loopback hostname classification stays package-internal: the `/api` Host fence and WebSocket upgrades use it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`openDocument`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`; reads and native actions included, since describing returns the exposed configuration, opening acts on the Host desktop, and probing an arbitrary reference reports where a credential comes from — and the agent-preset authoring plane, `agentPreset.read`/`copy`/`openDocument`/`remove`, since a composition names the plugins a session runs, so reading one is reconnaissance, and copy/remove/openDocument manage the roster and drive the host desktop (authoring is copy-only, so none of them accepts composition text or a path); `agentPreset.list` and `agentPreset.select` stay out — the roster carries only ids and trust, and choosing a preset grants nothing `session.create`'s own `agentPreset` did not, over a default that already carries bash) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform carriers and ConnectionController loop are package-internal; apply selects and drives them. The downlink boundary is documented in the [WebSocket downlink carrier Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md).
## /api browser-trust fence
+1 -1
View File
@@ -2,7 +2,7 @@
[English](README.md) | 中文
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 可观察且按 generation 生效的 `hostDescription` + 单消费方流循环启动器);导出表层携带协议约定类型、`AbstractApiClient` 抽象,以及循环的 sink/配置类型。每次就绪握手成功后,都会在 `onConnected` 之前发布完整的 `host.describe` 值;generation 失效或显式 stop 会清空它,因此原生能力消费者不会保留已经断线的判断。浏览器载体以 HTTP POST 发送 unaryrespond,并为 `events.mux``events.host` 各开一条只下行的 WebSocket;进程内载体满足同一双流抽象。Host half 持有唯一 `/api` route 及其 Fetch bridge;已注册的 Typert interceptor 会先认领自己的 Remote endpoint,未认领请求再回退 API Proxy。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 与 WebSocket upgrade 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory``host.openPath`,以及整个配置面——`settings.describe`/`openDocument`/`update`/`replace`/`mutate``credentials.describe`/`set`/`unset`;读取与原生操作也在内,因为 describe 会返回已暴露的配置、打开操作会作用于 Host 桌面,而探测任意引用会报出某条凭据来自何处——以及 agent(智能体) preset 的创作面 `agentPreset.read`/`copy`/`openDocument`/`remove`,因为组装指明了一个会话所运行的插件,读取它是侦察,而 copy/remove/openDocument 管理名单并驱动宿主桌面(创作只有复制一种写入,因此这些方法都不接收组装文本或路径);`agentPreset.list``agentPreset.select` 不在其中——名单只携带 id 与信任级别,而选择一个 preset 并不比 `session.create` 自带的 `agentPreset` 多给任何能力,何况默认 preset 本就带着 bash)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台载体与 ConnectionController 循环属于包内部;apply 负责选择并驱动它们。下行边界见 [WebSocket 下行载体 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md)。
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 可观察且按 generation 生效的 `hostDescription` + 单消费方流循环启动器);导出表层携带协议约定类型、`AbstractApiClient` 抽象,以及循环的 sink/配置类型。每次就绪握手成功后,都会在 `onConnected` 之前发布完整的 `host.describe` 值;generation 失效或显式 stop 会清空它,因此原生能力消费者不会保留已经断线的判断。浏览器载体以 HTTP POST 发送 unaryrespond,并为 `events.mux``events.host` 各开一条只下行的 WebSocket;进程内载体满足同一双流抽象。导出的 `ClientTransportHooks` 命名了整体替换浏览器载体的页面全局量 `__DSH_TRANSPORT__`served web app 不设置它、走 HTTP + WebSocket;拥有另一种物理传输的壳(worker 预览的 postMessage 隧道)则在此提供 `createApiClient``fetch`——当它同时持有 bundle 字节时再加 `loadBundle`——而不必 fork 本插件。Host half 持有唯一 `/api` route 及其 Fetch bridge;已注册的 Typert interceptor 会先认领自己的 Remote endpoint,未认领请求再回退 API Proxy。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 与 WebSocket upgrade 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory``host.openPath`,以及整个配置面——`settings.describe`/`openDocument`/`update`/`replace`/`mutate``credentials.describe`/`set`/`unset`;读取与原生操作也在内,因为 describe 会返回已暴露的配置、打开操作会作用于 Host 桌面,而探测任意引用会报出某条凭据来自何处——以及 agent(智能体) preset 的创作面 `agentPreset.read`/`copy`/`openDocument`/`remove`,因为组装指明了一个会话所运行的插件,读取它是侦察,而 copy/remove/openDocument 管理名单并驱动宿主桌面(创作只有复制一种写入,因此这些方法都不接收组装文本或路径);`agentPreset.list``agentPreset.select` 不在其中——名单只携带 id 与信任级别,而选择一个 preset 并不比 `session.create` 自带的 `agentPreset` 多给任何能力,何况默认 preset 本就带着 bash)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台载体与 ConnectionController 循环属于包内部;apply 负责选择并驱动它们。下行边界见 [WebSocket 下行载体 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md)。
## /api 浏览器信任栅栏
+29 -3
View File
@@ -8,7 +8,7 @@ import type { HostDescription, IApiClient } from './api.ts'
import { ConnectionController, type ConnectionConfig, type ConnectionSinks, type ConnectionState } from './connection.ts'
import { FixtureApiClient } from './fixture.ts'
import { WebApiClient } from './web-api-client.ts'
import { createWebConnectionRpc } from './rpc.ts'
import { createWebConnectionRpc, type RpcFetch } from './rpc.ts'
import { isLoopbackHostname } from '../loopback-hostname.ts'
import type { ClientConnectionRpc } from '../rpc.ts'
@@ -40,6 +40,7 @@ export {
// controller remains package-internal.
export type { ConnectionConfig, ConnectionSinks, ConnectionState }
export type { ClientConnectionRpc } from '../rpc.ts'
export type { RpcFetch } from './rpc.ts'
/** Observable Host description published by each completed connection handshake. */
export interface HostDescriptionSource {
@@ -52,6 +53,30 @@ export interface HostDescriptionSource {
/** Required services (none — this is the wire root). */
export const inject: string[] = []
/**
* Carrier override installed on the page global before plugin boot. The served
* web app leaves it unset and gets HTTP + WebSocket; a shell that owns a
* different physical transport (the worker preview's postMessage tunnel)
* provides both halves here instead of forking this plugin.
*/
export interface ClientTransportHooks {
/** Build the API carrier: unary calls plus the two downstream event streams. */
createApiClient(): IApiClient
/** Transport for generic unary RPC channels (the Typert gateway). */
fetch: RpcFetch
/**
* Bundle transport for the module system, present when the carrier also owns
* bundle bytes (the worker tunnel). Absent in the served web app, whose
* bundles load over HTTP.
*/
loadBundle?(url: string): Promise<void>
}
/** Page global carrying {@link ClientTransportHooks}; absent in the served web app. */
interface ClientTransportGlobal {
__DSH_TRANSPORT__?: ClientTransportHooks
}
/**
* The ctx.connection service API: the API client plus a one-shot
* controller starter (the runtime plugin supplies sinks when its object layer
@@ -85,8 +110,9 @@ export function apply(ctx: Context): void {
const pageLocation = typeof location === 'undefined' ? undefined : location
const fixture = pageLocation !== undefined && new URLSearchParams(pageLocation.search).has('fixture')
const fixtureClient = fixture ? new FixtureApiClient() : undefined
const api: IApiClient = fixtureClient ?? new WebApiClient()
const rpc = fixtureClient?.rpc ?? createWebConnectionRpc()
const transport = (globalThis as ClientTransportGlobal).__DSH_TRANSPORT__
const api: IApiClient = fixtureClient ?? transport?.createApiClient() ?? new WebApiClient()
const rpc = fixtureClient?.rpc ?? createWebConnectionRpc(transport?.fetch)
let started = false
let description: HostDescription | undefined
const descriptionListeners = new Set<() => void>()
+7 -2
View File
@@ -12,11 +12,16 @@ const INTERNAL_BASE = 'http://dsh.internal'
const CHANNEL_PATTERN = /^\/[A-Za-z0-9._~-]+$/
const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/
/** Transport this caller posts through; same signature as the global `fetch`. */
export type RpcFetch = (input: URL, init: RequestInit) => Promise<Response>
/**
* Create the browser-backed generic RPC caller.
* @param doFetch - transport override; defaults to the page's global fetch.
* @returns caller that owns request correlation and response-envelope validation.
*/
export function createWebConnectionRpc(): ClientConnectionRpc {
export function createWebConnectionRpc(doFetch?: RpcFetch): ClientConnectionRpc {
const send: RpcFetch = doFetch ?? ((input, init) => globalThis.fetch(input, init))
return {
async call(channel, endpoint, payload, signal) {
assertTarget(channel, endpoint)
@@ -27,7 +32,7 @@ export function createWebConnectionRpc(): ClientConnectionRpc {
method: endpoint,
payload,
}
const response = await globalThis.fetch(
const response = await send(
new URL(`${channel}/${endpoint}`, resolveBase()),
{
method: 'POST',
+23 -37
View File
@@ -3,10 +3,10 @@
* the host Loader's entries for packages declaring `dsh.client`, composes the
* `window.__DSH_BOOT__` entry graph (wire single source: {@link WebBootEntry}
* in `./client/manifest.ts`) in module-graph order, serves
* `/plugins/<id>/client.js` and its source map, taps the index render to
* inject the boot manifest plus the parser-blocking bootstrap preloads, and
* provides the `clientModuleHost` service (the HMR node half's
* registration/notification face).
* `/plugins/<id>/client.js` and its source map, contributes the boot manifest
* plus the parser-blocking bootstrap preloads to the webserver's index
* injection table, and provides the `clientModuleHost` service (the HMR node
* half's registration/notification face).
*
* Scanning is incremental per package there is no full-rescan code path.
* Every cordis `internal/plugin` emission (fiber construction/disposal) marks
@@ -30,7 +30,7 @@ import { dirname, join } from 'node:path'
import { Service } from '@deepseek-ai/cordis'
import type { Context } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/cordis-plugin-loader'
import type {} from '@deepseek-ai/dsh-host-webserver'
import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver'
import { optionalStringArray, stripClientSuffix } from './client/manifest.ts'
import type { WebBootEntry, WebBootGraph } from './client/manifest.ts'
@@ -228,31 +228,19 @@ const CLIENT_RUNTIME_ID = '@deepseek-ai/dsh-client-runtime'
/** Ordinary dynamic bundles the HTML parser executes before the Vite shell. */
const PARSER_PRELOAD_IDS = [CLIENT_MODULES_ID, CLIENT_RUNTIME_ID] as const
/** Escape a graph URL before placing it in a quoted HTML attribute. */
function escapeHtmlAttribute(value: string): string {
return value
.replaceAll('&', '&amp;')
.replaceAll('"', '&quot;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
}
/**
* Inject the boot protocol into index.html. The inline registration queue precedes
* blocking classic scripts for modules' and runtime's ordinary
* The boot protocol as index injection rows. The inline registration queue
* precedes blocking classic scripts for modules' and runtime's ordinary
* `lib/client.js` artifacts. Its `create()` method materializes the modules
* bundle, delegates construction to that bundle, and leaves the same facade
* in live-registration mode. The graph script follows before the shell reads
* it. `<` is escaped in JSON so a plugin-controlled string cannot break out
* of the script element.
* @param html - the index.html source.
* in live-registration mode. The graph global follows before the shell reads
* it.
* @param graph - the composed entry graph.
* @returns the html with the graph script injected.
* @returns head rows in execution order: queue script, preload scripts, graph global.
*/
export function injectBootManifest(html: string, graph: WebBootGraph): string {
const json = JSON.stringify(graph).replaceAll('<', '\\u003c')
export function bootInjections(graph: WebBootGraph): IndexInjection[] {
const bootstrapId = JSON.stringify(CLIENT_MODULES_ID)
const queue = `<script>(()=>{
const queue = `(()=>{
const pendingQueue=[]
window.__ModuleLoader__={
mode:"queue",
@@ -273,21 +261,20 @@ window.__ModuleLoader__={
return exports.createClientModuleSystem(this,{id:registration.id,exports},options)
}
}
})()</script>`
})()`
const preload = PARSER_PRELOAD_IDS.map(id => graph.entries.find(entry => entry.id === id))
.filter((entry): entry is WebBootEntry => entry !== undefined)
.map(entry => `<script src="${escapeHtmlAttribute(entry.url)}"></script>`)
.join('')
const script = `${queue}${preload}<script>window.__DSH_BOOT__ = ${json}</script>`
const head = html.indexOf('<head>')
if (head !== -1) return `${html.slice(0, head + 6)}${script}${html.slice(head + 6)}`
// Headless fixture pages may lack <head>; prepending keeps the read-before-shell ordering.
return `${script}${html}`
.map((entry): IndexInjection => ({ kind: 'script-src', placement: 'head', src: entry.url }))
return [
{ kind: 'script', placement: 'head', text: queue },
...preload,
{ kind: 'global', name: '__DSH_BOOT__', value: graph },
]
}
/**
* The web plugin table service: incremental `dsh.client` scan + wire composition
* + bundle route + index tap. Construction runs the activation scan
* + bundle route + index injection rows. Construction runs the activation scan
* synchronously a malformed declaration or missing bundle among the
* already-loaded entries aggregates into one loud throw (FAILED fiber; the
* boot activation audit reports it).
@@ -353,10 +340,9 @@ export class ClientModuleRegistry extends Service {
() => ctx.webServer.register({ kind: 'prefix', path: '/plugins', handler: this.serveBundle }),
'client-modules: bundle route',
)
ctx.effect(
() => ctx.webServer.tapIndex(html => injectBootManifest(html, this.composed)),
'client-modules: boot manifest injection',
)
ctx.on('webserver/index-inject', (table) => {
table.push(...bootInjections(this.composed))
})
}
/**
@@ -8,9 +8,9 @@ import { pathToFileURL } from 'node:url'
import { runInNewContext } from 'node:vm'
import { Context } from '@deepseek-ai/cordis'
import { afterEach, describe, expect, it } from 'vitest'
import type { WebServer, WebRoute } from '@deepseek-ai/dsh-host-webserver'
import { renderIndexInjections, type WebServer, type WebRoute } from '@deepseek-ai/dsh-host-webserver'
import * as modulesClient from '../src/client/index.ts'
import { ClientModuleRegistry, injectBootManifest, orderByModuleGraph } from '../src/index.ts'
import { ClientModuleRegistry, bootInjections, orderByModuleGraph } from '../src/index.ts'
import type { ClientModuleLoaderTarget, WebBootEntry, WebBootGraph } from '../src/client/index.ts'
const MODULES_ID = '@deepseek-ai/dsh-client-modules'
@@ -81,9 +81,12 @@ function construct(packageNames: string[]): ClientModuleRegistry {
return constructWithRoute(packageNames).service
}
/** Execute the exact first inline script emitted by the Host HTML transform. */
/** Execute the exact first inline script emitted by the Host boot rows. */
function injectedFacade(graph: WebBootGraph): { html: string; target: ClientModuleLoaderTarget } {
const html = injectBootManifest('<html><head></head><body><script type="module" src="/index.js"></script></body></html>', graph)
const html = renderIndexInjections(
'<html><head></head><body><script type="module" src="/index.js"></script></body></html>',
bootInjections(graph),
)
const source = /<head><script>([\s\S]*?)<\/script>/.exec(html)?.[1]
if (source === undefined) throw new Error('missing injected ModuleLoader facade script')
const window: { __ModuleLoader__?: ClientModuleLoaderTarget } = {}
@@ -107,7 +110,7 @@ describe('HTML bootstrap facade', () => {
const facadeAt = html.indexOf('window.__ModuleLoader__=')
const modulesAt = html.indexOf('<script src="/plugins/modules.js?rev=m"></script>')
const runtimeAt = html.indexOf('<script src="/plugins/runtime.js?rev=r"></script>')
const graphAt = html.indexOf('window.__DSH_BOOT__ = ')
const graphAt = html.indexOf('globalThis["__DSH_BOOT__"] = ')
const entryAt = html.indexOf('<script type="module" src="/index.js"></script>')
expect([facadeAt, modulesAt, runtimeAt, graphAt, entryAt]).toEqual([...new Set([
facadeAt, modulesAt, runtimeAt, graphAt, entryAt,
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
README.md: 71dafd1c4d683f2d6da8fefd88aec81d56149481
README.zh.md: e4d4ab990c74229bd2e077b575e7c87bb648ade9
README.md: a90d1d9dd8156fc1063dc7358374f8b1715deb1a
README.zh.md: acab41ad8c95c9e24b39fc875f3047f429f3add2
+1 -1
View File
@@ -40,7 +40,7 @@ Image intake accepts paste and whole-page drop: the bar binds document-level dra
The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop controls), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. The leading plus button is a Command launcher, not an attachment surface: it asks the session's `InputTriggerController` to open only the `/` trigger's `command` source over the current textarea selection, while ui-input-trigger's existing `MenuView` remains the sole floating menu and pick path. No file row, file input, upload protocol, or second menu component is introduced. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `conversation` locale namespace this package registers (the `placeholder.plan` / `hint.plan` keys) and shared verbatim with the claimed `/plan` command hint (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The composer-bar slot itself is `session-maybe`: with no current session the same bar keeps message actions inert (machine faces absent, `disabled` owner prop), while the whole dashed card opens the existing Workspace picker by pointer and the read-only textarea opens it through Enter or Space. Disabled controls release pointer events to the card, and the card contains `pointerdown` so the open picker's outside-close cannot race a reopen. The bar never swaps in a parallel tree, so the textarea DOM survives Workspace selection; strict-session control seats stay empty until a session exists.
The chat stats line takes its token accounting from the generic token-meter `tokenUsage` projection read through the standard-kit `useProjection`: billed input is uncached input plus cache reads and writes; cache hit divides cache reads by that total. The turn and step counts, the LLM and tool wall times, and the latency/throughput group all ride the whole-log `sessionStats` projection (host-folded from step boundaries, first-token chunks, tool pairs, and assembled messages), so paging and compaction cannot change any strip figure; an assembly without that unit falls back to the window fold over visible nodes, whose fields mirror the projection's. The strip averages each recorded step's TTFT and divides sampled output tokens by their summed decode spans into a latency/throughput group localized through the `conversation` locale namespace (`TTFT avg … · … tok/s` in English); a step missing a timing boundary or a usage sample drops out of those figures instead of skewing them, and durable count, token, and context groups remain visible when compaction leaves no assistant node in the loaded window. The turn-count, step-count, duration, cache, and token labels use the same namespace. Each settled turn additionally appends hover-revealed `TTFT {s}s · {tps} tok/s` labels to its assistant footer after the `Ran for` duration — the turn's first-step TTFT and its turn-aggregate decode throughput — gated on the turn's timing being in the loaded window (a contiguous log suffix, so an in-window turn carries every one of its steps) and omitting whichever figure is unrecorded. A deployment without token-meter drops the token groups; when the line overflows, it elides with an ellipsis and a delayed hover tooltip carries the full text only while actually clipped. Context occupancy renders as the composer's trailing ContextMeter: a 14px occupancy ring after the model seat, fed by `contextPressure` and rendered only once both a numerator and a route capacity are known, that click-opens a panel pairing the `percent used` header and `~used / capacity` figures with a color-segmented bar and `~`-prefixed heuristic composition rows (system prompt, tools, messages) from the `contextBreakdown` projection. The ring and header read `projectedTokens` — the provider sample carried forward over the surface's movement since — so a compaction registers immediately instead of after a further turn; the composition rows stay wholly heuristic and therefore still do not sum to the header ([rationale](../../llm/token-meter/README.md)). Occupancy is deliberately an approximation: numerator and capacity are independent last-wins projection fields, not one atomic request observation.
The chat stats line takes its token accounting from the generic token-meter `tokenUsage` projection read through the standard-kit `useProjection`: billed input is uncached input plus cache reads and writes; cache hit divides cache reads by that total. Every non-empty ratio starts with integer rounding. A non-full ratio adds decimal places only while the current precision would round to 100%, stopping at the minimum precision that remains below 100%; only a full cache hit displays 100%, and the precision has no fixed limit. The turn and step counts, the LLM and tool wall times, and the latency/throughput group all ride the whole-log `sessionStats` projection (host-folded from step boundaries, first-token chunks, tool pairs, and assembled messages), so paging and compaction cannot change any strip figure; an assembly without that unit falls back to the window fold over visible nodes, whose fields mirror the projection's. The strip averages each recorded step's TTFT and divides sampled output tokens by their summed decode spans into a latency/throughput group localized through the `conversation` locale namespace (`TTFT avg … · … tok/s` in English); a step missing a timing boundary or a usage sample drops out of those figures instead of skewing them, and durable count, token, and context groups remain visible when compaction leaves no assistant node in the loaded window. The turn-count, step-count, duration, cache, and token labels use the same namespace. Each settled turn additionally appends hover-revealed `TTFT {s}s · {tps} tok/s` labels to its assistant footer after the `Ran for` duration — the turn's first-step TTFT and its turn-aggregate decode throughput — gated on the turn's timing being in the loaded window (a contiguous log suffix, so an in-window turn carries every one of its steps) and omitting whichever figure is unrecorded. A deployment without token-meter drops the token groups; when the line overflows, it elides with an ellipsis and a delayed hover tooltip carries the full text only while actually clipped. Context occupancy renders as the composer's trailing ContextMeter: a 14px occupancy ring after the model seat, fed by `contextPressure` and rendered only once both a numerator and a route capacity are known, that click-opens a panel pairing the `percent used` header and `~used / capacity` figures with a color-segmented bar and `~`-prefixed heuristic composition rows (system prompt, tools, messages) from the `contextBreakdown` projection. The ring and header read `projectedTokens` — the provider sample carried forward over the surface's movement since — so a compaction registers immediately instead of after a further turn; the composition rows stay wholly heuristic and therefore still do not sum to the header ([rationale](../../llm/token-meter/README.md)). Occupancy is deliberately an approximation: numerator and capacity are independent last-wins projection fields, not one atomic request observation.
`src/client/` is organized by domain. `contract/` is the shared face for slot declarations, composed props, and cross-domain types; `skeleton/`, `chat/`, `input/`, `queue/`, and `settings/` keep their implementations internal, while `apply.ts` is their assembly point. The `/client` exports contain only loader entries, service classes, and contract types; components and store factories reach the page through slot registrations.
+1 -1
View File
@@ -40,7 +40,7 @@ Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。Qu
输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止控件之前)声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。前置加号按钮是 Command launcher,而非附件入口:它要求当前会话的 `InputTriggerController` 基于 textarea 当前 selection,只打开 `/` trigger 的 `command` source,同时 ui-input-trigger 既有的 `MenuView` 仍是唯一的浮层菜单与 pick 路径。不引入 File 行、file input、上传协议或第二套菜单组件。当 `plan` 投影的有效目标为 plan mode 时,InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `conversation` locale 命名空间(`placeholder.plan` / `hint.plan` 键)本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent(智能体)仍能收到回答;没有待处理交互时,活跃会话的 composer 归 Chat 所有。composer bar slot 本身为 `session-maybe`:没有当前会话时,同一个 bar 会让消息操作保持不可交互(machine face 均缺席、`disabled` owner prop),整张虚线卡片可经指针打开现有 Workspace picker,只读 textarea 也可通过 Enter 或 Space 打开。禁用控件会把指针事件交给卡片,卡片也会拦下 `pointerdown`,避免已打开 picker 的外点关闭与重新打开发生竞态。它不会换入一棵平行树,因此选择 Workspace 时 textarea DOM 不会被销毁;严格会话作用域的控件 seat 在会话存在之前保持为空。
聊天统计行的 token 账目来自经标准套件 `useProjection` 读取的通用 token-meter 投影 `tokenUsage`:计费输入为未缓存输入、缓存读取与缓存写入之和;缓存命中率以缓存读取除以该总量。轮次与步骤计数、LLM(大语言模型)与工具墙钟时间、以及延迟/吞吐分组都来自全日志的 `sessionStats` 投影(Host 端从步边界、首 token chunk、工具配对与已组装消息折算),因此分页与压缩都无法改变统计条的任何数字;未组合该单元的装配回退为对可见节点做窗口折算,其字段与投影一一对应。统计条把每个有完整记录的步骤的 TTFT(首 token 延迟)取平均,并用采样到的输出 token 数除以其解码时长之和,得到经 `conversation` locale 命名空间本地化的延迟/吞吐分组(中文为 `首 token 平均 … · … tok/s`);缺少某个 timing 边界或 usage 采样的步骤会直接退出这些数字,而不是让它们失真;压缩(compaction)使已加载窗口不再包含 assistant 节点时,持久计数、token 与上下文分组仍保持可见。轮次计数、步骤计数、耗时、缓存与 token 各项的标签也使用同一命名空间。每个已结算轮次还会在其 assistant footer 的 `用时` 之后追加 hover 才显示的 `首 token {s}秒 · {tps} tok/s` 标签——即该轮次首个步骤的 TTFT 与轮次聚合的解码吞吐——仅当该轮次的 timing 位于已加载窗口内才显示(窗口是日志的连续后缀,因此窗口内的轮次必然带着它的全部步骤),未记录的数字会各自省略。未组合 token-meter 的部署会整组省略 token 分组;统计行过长时以省略号截断,仅在内容真的被裁切时由延迟 hover tooltip 承载完整文本。上下文占用率渲染为 composer 尾部的 ContextMeter:模型座位之后的一枚 14px 占用圆环,由 `contextPressure` 供数,仅当分子与路由容量都已知时才渲染;点击弹出的面板把「已用百分比」标题与 `~已用 / 容量` 数字,与来自 `contextBreakdown` 投影、带 `~` 前缀的启发式组成明细行(系统提示词、工具、对话消息)及分色分段进度条并列。圆环与标题读取 `projectedTokens`——把提供方样本沿此后表层的增减推进到当下——因此压缩会立刻反映出来,而不必再等一整轮;组成明细行仍是纯启发式,因此加起来依然不等于标题数字([原理](../../llm/token-meter/README.md))。占用率是刻意为之的近似值:分子与容量是两个相互独立的「后写覆盖」投影字段,并非同一次请求的原子观测。
聊天统计行的 token 账目来自经标准套件 `useProjection` 读取的通用 token-meter 投影 `tokenUsage`:计费输入为未缓存输入、缓存读取与缓存写入之和;缓存命中率以缓存读取除以该总量。所有非空比率都先按整数舍入。非满命中只有在当前精度会舍入成 100% 时才增加小数位,并在首次得到低于 100% 的结果时停止;只有完整缓存命中才显示 100%,且精度没有固定上限。轮次与步骤计数、LLM(大语言模型)与工具墙钟时间、以及延迟/吞吐分组都来自全日志的 `sessionStats` 投影(Host 端从步边界、首 token chunk、工具配对与已组装消息折算),因此分页与压缩都无法改变统计条的任何数字;未组合该单元的装配回退为对可见节点做窗口折算,其字段与投影一一对应。统计条把每个有完整记录的步骤的 TTFT(首 token 延迟)取平均,并用采样到的输出 token 数除以其解码时长之和,得到经 `conversation` locale 命名空间本地化的延迟/吞吐分组(中文为 `首 token 平均 … · … tok/s`);缺少某个 timing 边界或 usage 采样的步骤会直接退出这些数字,而不是让它们失真;压缩(compaction)使已加载窗口不再包含 assistant 节点时,持久计数、token 与上下文分组仍保持可见。轮次计数、步骤计数、耗时、缓存与 token 各项的标签也使用同一命名空间。每个已结算轮次还会在其 assistant footer 的 `用时` 之后追加 hover 才显示的 `首 token {s}秒 · {tps} tok/s` 标签——即该轮次首个步骤的 TTFT 与轮次聚合的解码吞吐——仅当该轮次的 timing 位于已加载窗口内才显示(窗口是日志的连续后缀,因此窗口内的轮次必然带着它的全部步骤),未记录的数字会各自省略。未组合 token-meter 的部署会整组省略 token 分组;统计行过长时以省略号截断,仅在内容真的被裁切时由延迟 hover tooltip 承载完整文本。上下文占用率渲染为 composer 尾部的 ContextMeter:模型座位之后的一枚 14px 占用圆环,由 `contextPressure` 供数,仅当分子与路由容量都已知时才渲染;点击弹出的面板把「已用百分比」标题与 `~已用 / 容量` 数字,与来自 `contextBreakdown` 投影、带 `~` 前缀的启发式组成明细行(系统提示词、工具、对话消息)及分色分段进度条并列。圆环与标题读取 `projectedTokens`——把提供方样本沿此后表层的增减推进到当下——因此压缩会立刻反映出来,而不必再等一整轮;组成明细行仍是纯启发式,因此加起来依然不等于标题数字([原理](../../llm/token-meter/README.md))。占用率是刻意为之的近似值:分子与容量是两个相互独立的「后写覆盖」投影字段,并非同一次请求的原子观测。
`src/client/` 按领域组织。`contract/` 是 slot 声明、组合 props 与跨领域类型的共享表层;`skeleton/``chat/``input/``queue/``settings/` 保持内部实现,`apply.ts` 是它们的组装点。`/client` 导出表层只包含 loader entry、service class 和 contract 类型;组件与 store factory 经 slot 注册抵达页面。
@@ -17,6 +17,28 @@
gap: 16px;
}
/* Wide markdown tables (the renderer's 4-column md-table-wide hook) span
the whole transcript width instead of clipping at the 748px message
column, while the table content keeps starting at its normal x (the lead
padding compensates the negative margin) deepsuite chat TableWrapper
parity, with 100cqw (the ChatView scroll container) standing in for its
JS-measured --dsl-virtual-list-width. `--dsh-table-spare` clamps to zero
when the transcript is narrower than the message column, so narrow
viewports keep the plain in-column scroll with no sideways shift.
Percentages resolve against the wrapper's containing block, so a table
indented inside a list still reaches the same transcript edges. */
.body :global(.md-table-wide) {
--dsh-table-spare: max(0px, calc((100cqw - var(--dsh-chat-content-width)) / 2));
--dsh-table-lead: calc(var(--dsh-table-spare) + min(var(--dsh-chat-content-width), 100cqw) - 100%);
box-sizing: border-box;
width: calc(100% + var(--dsh-table-lead) + var(--dsh-table-spare));
/* The base .tableScroll caps at the column (max-width: 100%); the breakout
is exactly the case that must exceed it. */
max-width: none;
margin-left: calc(-1 * var(--dsh-table-lead));
padding-left: var(--dsh-table-lead);
}
/* Interrupted-turn terminal marker: quiet inline tag, no animation. */
.stopped {
align-self: flex-start;
@@ -18,6 +18,11 @@
/* Sides = composer clearance + 16px: on narrow viewports the transcript
stays exactly 32px narrower than the input card (the shared width rule). */
padding: 16px calc(var(--dsh-composer-side-clearance) + 16px);
/* Inline-size query container: wide markdown tables size their breakout
against this box's content width (100cqw in AssistantMarkdown's
md-table-wide rule) the CSS stand-in for deepsuite chat's JS-measured
--dsl-virtual-list-width. */
container-type: inline-size;
}
:global([data-conversation-scroll]) .root {
@@ -101,16 +101,64 @@ export function formatDuration(ms: number): string {
return `${Math.floor(whole / 60)}m${whole % 60}s`
}
/** Round a cache-read ratio to an integer percentage, with positive ties rounded up. */
function roundedIntegerPercent(cacheReadTokens: number, denominator: number): number {
const denominatorQuotient = Math.floor(denominator / 200)
const denominatorRemainder = denominator % 200
let lower = 0
let upper = 100
while (lower < upper) {
const candidate = Math.floor((lower + upper + 1) / 2)
const factor = candidate * 2 - 1
const threshold = factor * denominatorQuotient
+ Math.ceil(factor * denominatorRemainder / 200)
if (cacheReadTokens >= threshold) {
lower = candidate
} else {
upper = candidate - 1
}
}
return lower
}
/**
* Cache-hit share of prompt-side input over the whole durable log.
* Display-ready cache-hit share of prompt-side input over the whole durable log.
* @param usage - the session's token-usage projection value.
* @returns rounded integer percent, or null when no input was billed.
* @returns integer text when integer rounding stays below 100, otherwise the
* minimum decimal precision that still rounds below 100; a full hit returns
* 100, and no billed input returns null.
*/
export function cacheHitPercent(usage: TokenUsageProjection): number | null {
export function cacheHitPercent(usage: TokenUsageProjection): string | null {
const denominator = billedInputTokens(usage)
return denominator === 0
? null
: Math.round(usage.cacheReadTokens / denominator * 100)
if (denominator === 0) return null
const missedInputTokens = usage.uncachedInputTokens + usage.cacheWriteTokens
if (missedInputTokens === 0) return '100'
const integerPercent = roundedIntegerPercent(usage.cacheReadTokens, denominator)
if (integerPercent < 100) return String(integerPercent)
// At the first distinguishing precision, the rounded result is 100 minus
// one to five units in the final decimal place. Scale only while the next
// multiplication remains at or below the denominator, then derive that
// final digit through exact small-factor comparisons.
let decimalPlaces = 1
let scaledDoubleGap = missedInputTokens * 200
const denominatorTens = Math.floor(denominator / 10)
while (scaledDoubleGap <= denominatorTens) {
scaledDoubleGap *= 10
decimalPlaces += 1
}
const denominatorOnes = denominator % 10
let roundedLoss = 5
for (let loss = 1; loss < 5; loss += 1) {
const factor = loss * 2 + 1
const threshold = factor * denominatorTens + Math.floor(factor * denominatorOnes / 10)
if (scaledDoubleGap <= threshold) {
roundedLoss = loss
break
}
}
return `99.${'9'.repeat(decimalPlaces - 1)}${10 - roundedLoss}`
}
/**
@@ -191,6 +191,10 @@ describe('StatsLine', () => {
return { useSession: bindSnapshotSelector(source), useProjection: projections(values), t: tEn }
}
function tokenUsage(cacheReadTokens: number, uncachedInputTokens: number) {
return { uncachedInputTokens, outputTokens: 1, cacheReadTokens, cacheWriteTokens: 0 }
}
it('renders the grouped stats row and hides a brand-new empty session', () => {
const { source } = makeSource({ nodes: [assistant(1, 1)] })
const view = render(<StatsLine {...props(source)} />)
@@ -205,19 +209,43 @@ describe('StatsLine', () => {
expect(emptyView.container.textContent).toBe('')
})
it.each([
{ actual: '98.6%', tokenUsageValue: tokenUsage(986, 14), expected: 'Cache hit 99%' },
{ actual: '99.1%', tokenUsageValue: tokenUsage(991, 9), expected: 'Cache hit 99%' },
{ actual: '99.49%', tokenUsageValue: tokenUsage(9_949, 51), expected: 'Cache hit 99%' },
{ actual: '99.5%', tokenUsageValue: tokenUsage(995, 5), expected: 'Cache hit 99.5%' },
{ actual: '99.94%', tokenUsageValue: tokenUsage(9_994, 6), expected: 'Cache hit 99.9%' },
{ actual: '99.95%', tokenUsageValue: tokenUsage(9_995, 5), expected: 'Cache hit 99.95%' },
{ actual: '99.955%', tokenUsageValue: tokenUsage(19_991, 9), expected: 'Cache hit 99.96%' },
{ actual: '99.985%', tokenUsageValue: tokenUsage(19_997, 3), expected: 'Cache hit 99.99%' },
{ actual: '99.995%', tokenUsageValue: tokenUsage(19_999, 1), expected: 'Cache hit 99.995%' },
{ actual: '99.9975%', tokenUsageValue: tokenUsage(39_999, 1), expected: 'Cache hit 99.998%' },
{
actual: 'the closest non-full ratio available from safe integer cumulative counts',
tokenUsageValue: tokenUsage(Number.MAX_SAFE_INTEGER - 1, 1),
expected: 'Cache hit 99.99999999999999%',
},
{ actual: '100%', tokenUsageValue: tokenUsage(10_000, 0), expected: 'Cache hit 100%' },
])('formats an actual $actual cache-hit ratio as $expected', ({ tokenUsageValue, expected }) => {
const { source } = makeSource({ nodes: [assistant(1, 1)] })
const view = render(<StatsLine {...props(source, { tokenUsage: tokenUsageValue })} />)
expect(view.container.textContent).toContain(expected)
})
it('reveals the full line in a delayed hover tooltip only while the row is clipped', () => {
vi.useFakeTimers()
// jsdom lays nothing out; fake a row narrower than its content.
vi.spyOn(Element.prototype, 'scrollWidth', 'get').mockReturnValue(800)
vi.spyOn(Element.prototype, 'clientWidth', 'get').mockReturnValue(400)
const { source } = makeSource({ nodes: [assistant(1, 1)] })
const view = render(<StatsLine {...props(source)} />)
const view = render(<StatsLine {...props(source, { tokenUsage: tokenUsage(9_995, 5) })} />)
expect(view.container.textContent).toContain('Cache hit 99.95%')
fireEvent.mouseEnter(view.container.firstElementChild!)
act(() => { vi.advanceTimersByTime(499) })
expect(view.container.querySelector('[role="tooltip"]')).toBeNull()
act(() => { vi.advanceTimersByTime(1) })
expect(view.container.querySelector('[role="tooltip"]')?.textContent)
.toBe('1 turns · 1 steps | Cache hit 90% | Input 100 tok · Output 5 tok')
.toBe('1 turns · 1 steps | Cache hit 99.95% | Input 10K tok · Output 1 tok')
})
it('suppresses the tooltip while the row fits without truncation', () => {
@@ -245,9 +273,9 @@ describe('StatsLine', () => {
timing: { stepStartTime: 1_000, firstTokenTime: 1_800, completedTime: 4_800 },
}
const { source } = makeSource({ nodes: [timed] })
const view = render(<StatsLine {...props(source)} t={t} />)
const view = render(<StatsLine {...props(source, { tokenUsage: tokenUsage(9_995, 5) })} t={t} />)
expect(view.container.textContent)
.toBe('1 轮 · 1 步| LLM 3.8s| 首 token 平均 0.8s · 20 tok/s| 缓存命中 90%| 输入 100 tok · 输出 5 tok')
.toBe('1 轮 · 1 步| LLM 3.8s| 首 token 平均 0.8s · 20 tok/s| 缓存命中 99.95%| 输入 10K tok · 输出 1 tok')
})
it('renders without ResizeObserver support', () => {
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
README.md: a675b3cd0aa9e09e243b69065110e1d2b67ff1d9
README.zh.md: aa67993ec879635fc0677501665d2e23de996dcf
README.md: 7822a5d41e8125752b8bc28fea3db2232323fd81
README.zh.md: 5a78e683f3bb94f8b9d3a64b0d4ba1f3f3df9d85
+1 -1
View File
@@ -14,7 +14,7 @@ Pure React atoms (zero cordis): StateDot, DisclosureRow, ic_ds_* icons, Button/P
## Markdown rendering
`MarkdownText` renders GFM and `$…$`, `$$…$$`, `\(…\)`, and `\[…\]` TeX math from untrusted assistant output through React elements, with math typeset by KaTeX and trusted commands disabled; block-level same-line `$$…$$` is display math, including `\tag{}`. A narrow micromark extension lets asterisk strong emphasis ending in punctuation close before adjacent CJK text, where prose normally omits the whitespace CommonMark requires; single-asterisk emphasis, non-CJK adjacency, escapes, code, and math retain upstream parsing. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders absolute HTTP(S) images without a referrer; relative paths, absolute local paths, `file:` URLs, and unsupported schemes retain their alt text. Inline code whose complete value is an absolute HTTP(S) URL keeps its code styling and gains the same safe external anchor; commands, partial URLs, other schemes, and fenced code remain inert. An optional `fileMentions` resolver lets the owning view link inline code that names a real file: the token keeps code styling and gains a button wired to the resolved opener, with the resolver's accessible label and full-path `title`. The renderer never guesses at what looks like a path — an unresolved token stays inert, mentions apply to settled renders only (the streaming cache must not bake in handlers that could go stale), and a token inside an anchor stays inert because a button cannot nest there. While a reply streams, `MarkdownText` parses incrementally: all but the trailing two blocks freeze as cached React elements and only the source tail behind them re-parses per chunk, so per-chunk work tracks the tail instead of the whole reply ([mechanism and DOM-parity contract](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md)). `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, responsive images, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars).
`MarkdownText` renders GFM and `$…$`, `$$…$$`, `\(…\)`, and `\[…\]` TeX math from untrusted assistant output through React elements, with math typeset by KaTeX and trusted commands disabled; block-level same-line `$$…$$` is display math, including `\tag{}`. A narrow micromark extension lets asterisk strong emphasis ending in punctuation close before adjacent CJK text, where prose normally omits the whitespace CommonMark requires; single-asterisk emphasis, non-CJK adjacency, escapes, code, and math retain upstream parsing. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders absolute HTTP(S) images without a referrer; relative paths, absolute local paths, `file:` URLs, and unsupported schemes retain their alt text. Inline code whose complete value is an absolute HTTP(S) URL keeps its code styling and gains the same safe external anchor; commands, partial URLs, other schemes, and fenced code remain inert. An optional `fileMentions` resolver lets the owning view link inline code that names a real file: the token keeps code styling and gains a button wired to the resolved opener, with the resolver's accessible label and full-path `title`. The renderer never guesses at what looks like a path — an unresolved token stays inert, mentions apply to settled renders only (the streaming cache must not bake in handlers that could go stale), and a token inside an anchor stays inert because a button cannot nest there. While a reply streams, `MarkdownText` parses incrementally: all but the trailing two blocks freeze as cached React elements and only the source tail behind them re-parses per chunk, so per-chunk work tracks the tail instead of the whole reply ([mechanism and DOM-parity contract](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md)). Tables size by column count (deepsuite chat parity): under four columns — or inside a blockquote — a table fills its column and wraps cell text down to the cells' minimum readable width, while four-or-more-column tables keep their natural width, scroll horizontally inside their wrapper, and carry the stable `md-table-wide` class so a hosting layout can widen the wrapper past its column (the chat transcript's container-query breakout in `dsh-client-ui-conversation`); a wide table's horizontal bar reveals on hover or keyboard focus (the wrapper carries `tabindex="0"`) instead of staying painted ([decision record](../../../.agents/notes/implemented/feature/2026-08-19-web-markdown-wide-table-view.md)). `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, responsive images, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars).
## Terminal output
+1 -1
View File
@@ -14,7 +14,7 @@
## Markdown 渲染
`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM 与 `$…$``$$…$$``\(…\)``\[…\]` TeX 公式,公式由 KaTeX 排版并禁用受信任命令;块级同一行 `$$…$$` 是显示公式并支持 `\tag{}`。一个小范围的 micromark 扩展允许由星号标记、以标点结尾的粗体在紧邻的 CJK 文本前闭合,以适应 CJK 文本通常省略 CommonMark 所要求空格的写法;单星号强调、紧邻非 CJK 文本的情况、转义、代码与数学公式仍沿用上游解析行为。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并在不发送 referrer 的情况下渲染采用绝对 HTTP(S) URL 的图片;相对路径、绝对本地路径、`file:` URL 与不受支持的 scheme 会保留其 alt 文本。完整内容为绝对 HTTP(S) URL 的行内代码会保留代码样式,并获得同样安全的外部链接;命令、非完整 URL、其他 scheme 与围栏代码仍不会成为链接。可选的 `fileMentions` 解析器让持有该组件的视图为命名真实文件的行内代码添加可点击入口:token 保留代码样式,并获得一个连接到解析所得 opener 的按钮,按钮带有解析器提供的无障碍标签和以完整路径为值的 `title`。渲染器绝不猜测哪些内容像路径:未解析的 token 保持不可交互;文件提及仅应用于已定稿的渲染(流式缓存不得固化可能过期的 handler);锚点内的 token 也保持不可交互,因为按钮不能嵌套其中。回复流式输出期间,`MarkdownText` 增量解析:除末尾两个块外全部冻结为缓存的 React 元素,每个分片只重新解析其后的源文本尾部,因此每分片的工作量跟随尾部而非整个回复([机制与 DOM 一致性约定](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md))。`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、响应式图片、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。
`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM 与 `$…$``$$…$$``\(…\)``\[…\]` TeX 公式,公式由 KaTeX 排版并禁用受信任命令;块级同一行 `$$…$$` 是显示公式并支持 `\tag{}`。一个小范围的 micromark 扩展允许由星号标记、以标点结尾的粗体在紧邻的 CJK 文本前闭合,以适应 CJK 文本通常省略 CommonMark 所要求空格的写法;单星号强调、紧邻非 CJK 文本的情况、转义、代码与数学公式仍沿用上游解析行为。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并在不发送 referrer 的情况下渲染采用绝对 HTTP(S) URL 的图片;相对路径、绝对本地路径、`file:` URL 与不受支持的 scheme 会保留其 alt 文本。完整内容为绝对 HTTP(S) URL 的行内代码会保留代码样式,并获得同样安全的外部链接;命令、非完整 URL、其他 scheme 与围栏代码仍不会成为链接。可选的 `fileMentions` 解析器让持有该组件的视图为命名真实文件的行内代码添加可点击入口:token 保留代码样式,并获得一个连接到解析所得 opener 的按钮,按钮带有解析器提供的无障碍标签和以完整路径为值的 `title`。渲染器绝不猜测哪些内容像路径:未解析的 token 保持不可交互;文件提及仅应用于已定稿的渲染(流式缓存不得固化可能过期的 handler);锚点内的 token 也保持不可交互,因为按钮不能嵌套其中。回复流式输出期间,`MarkdownText` 增量解析:除末尾两个块外全部冻结为缓存的 React 元素,每个分片只重新解析其后的源文本尾部,因此每分片的工作量跟随尾部而非整个回复([机制与 DOM 一致性约定](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md))。表格按列数决定尺寸(对齐 deepsuite chat):不足四列——或位于 blockquote 内——的表格填满所在列,单元格文本换行收缩至最小可读列宽;四列及以上的表格保持自然宽度、在包裹层内横向滚动,并携带稳定的 `md-table-wide` 类,供宿主布局把包裹层加宽到所在列之外(`dsh-client-ui-conversation` 中聊天转录区的容器查询突破样式);宽表的横向滚动条在悬停或键盘聚焦(包裹层带 `tabindex="0"`)时才出现、不再常驻([决策记录](../../../.agents/notes/implemented/feature/2026-08-19-web-markdown-wide-table-view.md))。`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、响应式图片、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。
## 终端输出
@@ -177,12 +177,51 @@
overscroll-behavior-x: contain;
}
/* Wide tables reveal their horizontal bar on hover (or keyboard focus)
instead of keeping it painted. Chromium never repaints state-conditioned
scrollbar STYLES (neither hover-conditioned `::-webkit-scrollbar*` rules
nor a :hover `scrollbar-color` change reaches the painted bar), so the
toggle is `overflow-x` itself a layout change repaints reliably. The
resting padding matches the themed bar's height, so on an overflowing
table the appearing bar exactly replaces it and nothing below shifts.
Wheel and trackpad scrolling need the pointer over the table, which is
already the hover that re-enables `auto`. */
.tableScroll:global(.md-table-wide) {
overflow-x: hidden;
padding-bottom: var(--dsh-scrollbar-width, 8px);
}
.tableScroll:global(.md-table-wide):hover,
.tableScroll:global(.md-table-wide):focus-visible {
overflow-x: auto;
padding-bottom: 0;
}
/* Chromium keyboard-focuses scrollable containers by default; the ring uses
the sheet's link focus color. */
.tableScroll:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--dsw-alias-state-business-primary);
}
/* Many-column tables (the renderer's `md-table-wide` hook) keep their
natural width and scroll inside the wrapper; a hosting layout may widen
the wrapper through the hook (deepsuite chat TableWrapper parity). */
.tableScroll table {
border-collapse: collapse;
width: max-content;
max-width: max-content;
}
/* Tables under four columns, and any table inside a blockquote, fill the
column instead: cells wrap down to their minimum readable width, and the
wrapper's scroll only remains for the below-floor case (deepsuite chat
parity: `.wrapper:not(:has(th:nth-child(4), td:nth-child(4)))`). */
.tableFill table {
width: 100%;
max-width: none;
}
.tableScroll th {
text-align: start;
padding: 10px 16px;
@@ -18,6 +18,7 @@
import { Fragment, createElement } from 'react'
import type { Key, ReactNode } from 'react'
import clsx from 'clsx'
import type * as Md from 'mdast'
import type {} from 'mdast-util-math'
import { normalizeUri } from 'micromark-util-sanitize-uri'
@@ -123,6 +124,8 @@ export interface MarkdownRenderContext {
readonly streaming: boolean
/** Localized fence copy-button labels. */
readonly codeLabels: MarkdownCodeLabels | undefined
/** Inside a blockquote's children: tables there always fill the quote's width. */
readonly inBlockquote?: boolean
/** Inline-code file mentions; absent wherever no opener vocabulary exists. */
readonly fileMentions: MarkdownFileMentions | undefined
/** Inside an anchor's children: interactive mentions must not nest there. */
@@ -213,7 +216,10 @@ function renderNode(node: Md.RootContent, key: Key, context: MarkdownRenderConte
case 'blockquote':
return (
<blockquote key={key}>
{wrapBlockChildren(renderChildren(node.children, context).filter(child => child !== null), true)}
{wrapBlockChildren(
renderChildren(node.children, { ...context, inBlockquote: true }).filter(child => child !== null),
true,
)}
</blockquote>
)
case 'thematicBreak':
@@ -393,8 +399,23 @@ function renderListItem(
function renderTable(node: Md.Table, key: Key, context: MarkdownRenderContext): ReactNode {
const align = node.align ?? null
const [headRow, ...bodyRows] = node.children
const columns = align === null ? headRow?.children.length ?? 0 : align.length
// Four or more columns read as a comparison matrix: the block keeps the
// table at natural width and exposes the stable `md-table-wide` hook so a
// hosting layout (the chat transcript) can widen it past the message
// column. Narrower tables — and any table inside a blockquote — fill the
// column and wrap instead (deepsuite chat TableWrapper parity).
const wide = columns >= 4 && context.inBlockquote !== true
return (
<div key={key} className={css.tableScroll}>
// Wide tables rest with overflow-x hidden (the hover-revealed bar in
// MarkdownText.module.css), which drops Chromium's implicit scroller
// focusability — the explicit tabindex keeps them keyboard-reachable,
// and :focus-visible restores scrolling.
<div
key={key}
className={clsx(css.tableScroll, wide ? 'md-table-wide' : css.tableFill)}
tabIndex={wide ? 0 : undefined}
>
<table>
{headRow !== undefined && <thead>{renderTableRow(headRow, 'th', align, 0, context)}</thead>}
{bodyRows.length > 0 && (
@@ -22,7 +22,7 @@
#text "Unbalanced errors render the error arm: "
<span class="katex-error" style="color: rgb(204, 0, 0);" title="ParseError: KaTeX parse error: Unexpected end of input in a macro argument, expected '}' at end of input: \\frac{">
#text "\\frac{"
<div class="_tableScroll_404681">
<div class="_tableScroll_404681 _tableFill_404681">
<table>
<thead>
<tr>
@@ -3,7 +3,7 @@
#text "Trusted commands stay off: $\\href{javascript:alert(1)}{unsafe}$."
<p>
#text "Unbalanced errors render the error arm: $\\frac{$"
<div class="_tableScroll_404681">
<div class="_tableScroll_404681 _tableFill_404681">
<table>
<thead>
<tr>
@@ -1,5 +1,5 @@
<div class="_markdown_404681">
<div class="_tableScroll_404681">
<div class="_tableScroll_404681 _tableFill_404681">
<table>
<thead>
<tr>
@@ -1,5 +1,5 @@
<div class="_markdown_404681">
<div class="_tableScroll_404681">
<div class="_tableScroll_404681 _tableFill_404681">
<table>
<thead>
<tr>
@@ -0,0 +1,46 @@
<div class="_markdown_404681">
<div class="_tableScroll_404681 md-table-wide" tabindex="0">
<table>
<thead>
<tr>
<th>
#text "C1"
<th>
#text "C2"
<th>
#text "C3"
<th>
#text "C4"
<tbody>
<tr>
<td>
#text "a"
<td>
#text "b"
<td>
#text "c"
<td>
#text "d"
<blockquote>
<div class="_tableScroll_404681 _tableFill_404681">
<table>
<thead>
<tr>
<th>
#text "Q1"
<th>
#text "Q2"
<th>
#text "Q3"
<th>
#text "Q4"
<tbody>
<tr>
<td>
#text "a"
<td>
#text "b"
<td>
#text "c"
<td>
#text "d"
@@ -0,0 +1,46 @@
<div class="_markdown_404681">
<div class="_tableScroll_404681 md-table-wide" tabindex="0">
<table>
<thead>
<tr>
<th>
#text "C1"
<th>
#text "C2"
<th>
#text "C3"
<th>
#text "C4"
<tbody>
<tr>
<td>
#text "a"
<td>
#text "b"
<td>
#text "c"
<td>
#text "d"
<blockquote>
<div class="_tableScroll_404681 _tableFill_404681">
<table>
<thead>
<tr>
<th>
#text "Q1"
<th>
#text "Q2"
<th>
#text "Q3"
<th>
#text "Q4"
<tbody>
<tr>
<td>
#text "a"
<td>
#text "b"
<td>
#text "c"
<td>
#text "d"
@@ -1,5 +1,5 @@
<div class="_markdown_404681">
<div class="_tableScroll_404681">
<div class="_tableScroll_404681 md-table-wide" tabindex="0">
<table>
<thead>
<tr>
@@ -1,5 +1,5 @@
<div class="_markdown_404681">
<div class="_tableScroll_404681">
<div class="_tableScroll_404681 md-table-wide" tabindex="0">
<table>
<thead>
<tr>
@@ -163,6 +163,17 @@ const CORPUS: Record<string, string> = {
'after',
].join('\n'),
'table-header-only': '| a | b |\n| --- | --- |\n\nafter',
// Four columns take the md-table-wide hook at top level; the same table
// inside a blockquote falls back to the fill arm.
'table-wide-and-blockquote': [
'| C1 | C2 | C3 | C4 |',
'| --- | --- | --- | --- |',
'| a | b | c | d |',
'',
'> | Q1 | Q2 | Q3 | Q4 |',
'> | --- | --- | --- | --- |',
'> | a | b | c | d |',
].join('\n'),
'inline-code-with-newline': 'Spans `a\nb` across a line.',
'links-and-autolinks': [
'[https ok](https://example.com "with title") and [mailto ok](mailto:dev@example.com).',
@@ -101,6 +101,14 @@ describe('renderBlocks over hand-built trees', () => {
expect(container.querySelector('td')?.textContent).toBe('short')
})
it('renders a rowless align-less table as an empty fill wrapper', () => {
// Zero columns is below the wide threshold, so the fill arm applies.
const container = renderNodes([{ type: 'table', children: [] }])
const wrapper = container.querySelector('table')?.parentElement
expect(wrapper?.className).not.toContain('md-table-wide')
expect(container.querySelector('table')?.childElementCount).toBe(0)
})
it('pads rows against the alignment width with empty cells', () => {
const container = renderNodes([
{
+3 -3
View File
@@ -57,6 +57,7 @@
"devDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
@@ -65,10 +66,9 @@
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-host-webserver": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react": "~18.3.1",
"react": "^18.2.0",
"@deepseek-ai/dsh-settings": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^"
"@types/react": "~18.3.1",
"react": "^18.2.0"
},
"files": [
"lib/index.js",
+14 -20
View File
@@ -1,15 +1,16 @@
/**
* Host-rendered theme bootstrap for the browser's pre-plugin interval. Each
* index response embeds the current durable built-in preference; the browser
* resolves only `system`, then writes the same DOM fields ui-layout's
* ThemePresenter owns after the client plugin tree activates.
* Theme bootstrap row for the browser's pre-plugin interval. Each index
* render embeds the current durable built-in preference; the browser resolves
* only `system`, then writes the same DOM fields ui-layout's ThemePresenter
* owns after the client plugin tree activates.
*/
import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver'
import { DEFAULT_PREFERENCE, type ThemePreference } from './theme-settings.ts'
/** Build the inline script for one schema-validated built-in preference. */
/** Build the inline script body for one schema-validated built-in preference. */
function bootThemeScript(preference: ThemePreference): string {
return `<script>(() => {
return `(() => {
const preference = ${JSON.stringify(preference)}
const systemDark = preference === 'system'
&& typeof matchMedia !== 'undefined'
@@ -17,24 +18,17 @@ function bootThemeScript(preference: ThemePreference): string {
const dark = preference === 'dark' || systemDark
document.documentElement.style.colorScheme = dark ? 'dark' : 'light'
document.body.toggleAttribute('data-ds-dark-theme', dark)
})()</script>`
})()`
}
/**
* Insert the theme bootstrap immediately after the opening body tag, before
* the shell mount and module script. Body-less fragments receive it at the
* end, where the HTML parser has already synthesized a body.
* @param html - Raw application index HTML.
* The theme bootstrap as an injection row: an inline script immediately after
* the opening body tag, before the shell mount and module script.
* @param preference - Current Host-backed built-in preference.
* @returns HTML containing the theme bootstrap.
* @returns the body script row.
*/
export function injectBootTheme(
html: string,
export function bootThemeInjection(
preference: ThemePreference = DEFAULT_PREFERENCE,
): string {
const script = bootThemeScript(preference)
const body = /<body(?:\s[^>]*)?>/i.exec(html)
if (body === null) return `${html}${script}`
const at = body.index + body[0].length
return `${html.slice(0, at)}${script}${html.slice(at)}`
): IndexInjection {
return { kind: 'script', placement: 'body', text: bootThemeScript(preference) }
}
+7 -9
View File
@@ -3,7 +3,7 @@
import type { Context } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-host-webserver'
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
import { injectBootTheme } from './boot-theme.ts'
import { bootThemeInjection } from './boot-theme.ts'
import {
DEFAULT_PREFERENCE, THEME_SETTINGS_NAMESPACE, ThemeSettingsSchema,
type ThemePreference, type ThemeSettings,
@@ -26,18 +26,16 @@ function readPreference(ctx: Context): ThemePreference {
}
/**
* Register the durable theme section and initial-theme index transform when
* their optional Host services are composed.
* @param ctx - Host context that may acquire settings and HTTP services.
* Register the durable theme section when the optional settings service is
* composed, and answer every index injection collection with the current
* theme bootstrap row.
* @param ctx - Host context that may acquire the settings service.
*/
export function apply(ctx: Context): void {
ctx.inject(['settings'], (settingsCtx) => {
settingsCtx.settings.register(THEME_NAMESPACE, ThemeSettingsSchema)
})
ctx.inject(['webServer'], (httpCtx) => {
httpCtx.effect(
() => httpCtx.webServer.tapIndex(html => injectBootTheme(html, readPreference(ctx))),
'client-ui-theme: initial theme bootstrap',
)
ctx.on('webserver/index-inject', (table) => {
table.push(bootThemeInjection(readPreference(ctx)))
})
}
@@ -1,8 +1,8 @@
// @vitest-environment jsdom
/** Host index injection and the resulting pre-plugin browser theme. */
/** The theme bootstrap injection row and the resulting pre-plugin browser theme. */
import { runInNewContext } from 'node:vm'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { injectBootTheme } from '../src/boot-theme.ts'
import { bootThemeInjection } from '../src/boot-theme.ts'
import type { ThemePreference } from '../src/theme-settings.ts'
const DARK_ATTRIBUTE = 'data-ds-dark-theme'
@@ -11,15 +11,10 @@ function mockSystemDark(matches: boolean): void {
vi.stubGlobal('matchMedia', vi.fn(() => ({ matches }) as MediaQueryList))
}
function executeBootstrap(
preference?: ThemePreference,
html = '<html><body><div id="root"></div><script type="module"></script></body></html>',
): string {
const injected = injectBootTheme(html, preference)
const source = /<script>([\s\S]*?)<\/script>/.exec(injected)?.[1]
if (source === undefined) throw new Error('theme bootstrap script missing')
runInNewContext(source, { document, matchMedia: globalThis.matchMedia })
return injected
function executeBootstrap(preference?: ThemePreference): void {
const row = bootThemeInjection(preference)
if (row.kind !== 'script') throw new Error('theme bootstrap row is not a script')
runInNewContext(row.text, { document, matchMedia: globalThis.matchMedia })
}
afterEach(() => {
@@ -29,12 +24,12 @@ afterEach(() => {
document.body.removeAttribute(DARK_ATTRIBUTE)
})
describe('theme boot index transform', () => {
it('runs immediately inside the body before the shell mount', () => {
describe('theme bootstrap row', () => {
it('is a body script row, so it runs before the shell mount', () => {
mockSystemDark(false)
const html = executeBootstrap('dark', '<html><body class="app"><div id="root"></div></body></html>')
expect(html.indexOf('<script>')).toBeGreaterThan(html.indexOf('<body class="app">'))
expect(html.indexOf('<script>')).toBeLessThan(html.indexOf('<div id="root">'))
const row = bootThemeInjection('dark')
expect(row).toMatchObject({ kind: 'script', placement: 'body' })
executeBootstrap('dark')
expect(document.documentElement.style.colorScheme).toBe('dark')
expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(true)
})
@@ -63,9 +58,4 @@ describe('theme boot index transform', () => {
expect(document.documentElement.style.colorScheme).toBe('light')
expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(false)
})
it('appends the script to a body-less fragment', () => {
const html = injectBootTheme('<main>loading</main>', 'dark')
expect(html.startsWith('<main>loading</main><script>')).toBe(true)
})
})
@@ -1,6 +1,6 @@
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it } from 'vitest'
import type { WebServer } from '@deepseek-ai/dsh-host-webserver'
import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver'
import { SettingsProvider, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
import {
DEFAULT_PREFERENCE, THEME_SETTINGS_NAMESPACE, apply,
@@ -14,6 +14,19 @@ class MemorySettings extends SettingsProvider {
}
}
/** Collect the injection table the way an index render or boot payload does. */
function collect(ctx: Context): IndexInjection[] {
const table: IndexInjection[] = []
ctx.emit('webserver/index-inject', table)
return table
}
/** Narrow the theme row and return its script body. */
function scriptText(row: IndexInjection | undefined): string {
if (row?.kind !== 'script') throw new Error('expected a script row')
return row.text
}
describe('ui-theme host', () => {
it('registers, validates, and disposes the durable theme namespace with its fiber', async () => {
const ctx = new Context()
@@ -29,37 +42,33 @@ describe('ui-theme host', () => {
expect(ctx.settings.describe().map(row => row.ns)).not.toContain(ns)
})
it('renders the current durable preference and disposes the index transform', async () => {
it('answers each collection with the current durable preference until disposal', async () => {
const ctx = new Context()
await ctx.plugin(MemorySettings).await()
let transform: ((html: string) => string) | undefined
let disposed = false
ctx.provide('webServer', {
tapIndex: (next: (html: string) => string) => {
transform = next
return () => { disposed = true }
},
} as WebServer)
const fiber = ctx.plugin({ apply })
await fiber.await()
expect(transform?.('<body></body>')).toContain('const preference = "system"')
const rows = collect(ctx)
expect(rows).toHaveLength(1)
expect(rows[0]).toMatchObject({ kind: 'script', placement: 'body' })
expect(scriptText(rows[0])).toContain('const preference = "system"')
await ctx.settings.update(settingsNamespace(THEME_SETTINGS_NAMESPACE), { preference: 'dark' })
expect(transform?.('<body></body>')).toContain('const preference = "dark"')
expect(scriptText(collect(ctx)[0])).toContain('const preference = "dark"')
await fiber.dispose()
expect(disposed).toBe(true)
expect(transform?.('<body></body>')).toContain('const preference = "system"')
expect(collect(ctx)).toEqual([])
})
it('uses the system preference when only an HTTP server exists', async () => {
it('uses the system preference without a settings provider', async () => {
const ctx = new Context()
let transform: ((html: string) => string) | undefined
ctx.provide('webServer', {
tapIndex: (next: (html: string) => string) => {
transform = next
return () => undefined
},
} as WebServer)
await ctx.plugin({ apply }).await()
expect(transform?.('<body></body>')).toContain('const preference = "system"')
expect(scriptText(collect(ctx)[0])).toContain('const preference = "system"')
})
it('falls back to the schema default while the theme namespace holds no section', async () => {
// A settings provider whose namespace read comes back empty (registration
// still pending or a provider without schema defaults).
const ctx = new Context()
ctx.provide('settings', { register: () => () => {}, get: () => undefined } as never)
await ctx.plugin({ apply }).await()
expect(scriptText(collect(ctx)[0])).toContain('const preference = "system"')
})
})
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-user-questions/README.md
README.md: ba4b23150a7f3a2536db97df99410cb92238210e
README.zh.md: 96c81800ef85514c565defecc779da7d2e6523b7
README.md: 4c7c54ad3c9dd5a06f8bd0f5537044479e4ad7e2
README.zh.md: 47d654bb7f687819558e8ef228664a815a4b46d0
+1 -1
View File
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
Web question feature plugin: its browser half registers the `question` entry in the conversation-owned `conversation.composer` keyed slot. Its host half is empty on purpose — mounting `dsh-tool-ask-user` there put the tool in the registry's GLOBAL layer, which merges into every agent regardless of the preset that composed it, so a two-tool benchmark preset really presented three. Rendering a question is a host UI capability; having the tool is an agent capability, so the `tool-ask-user` row belongs to the presets that want it (and to the TUI composition, which has no presets).
The component renders one question at a time with progress navigation, single- and multi-select choices, recommendation badges derived from label suffixes, and custom answers. A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps its title, navigation, and submission actions fixed while long detail and choices share an internal scroll region. Single-select choices advance immediately, and Enter submits once every question is answered or skipped; Enter during IME composition confirms the input candidate without advancing. It submits one structured answer batch for the whole request: “Skip this question” retains other drafts and emits the existing blank `{ selected: [] }` shape for that item, while close rejects the whole wait as `ASK_CANCELLED`.
The component renders one question at a time with progress navigation, single- and multi-select choices, recommendation badges derived from label suffixes, and custom answers. A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps its title, navigation, and submission actions fixed while long detail and choices share an internal scroll region. Both question shapes answer into a textarea over a hidden height mirror, so a long answer soft-wraps and grows the field in place; growth stops at six lines of text — the same count in both variants — and the field scrolls from there, keeping the choices the answer belongs to in view. Single-select choices advance immediately, Enter continues the flow and submits once every question is answered or skipped, and Shift+Enter breaks a line instead; Enter during IME composition confirms the input candidate without advancing. It submits one structured answer batch for the whole request: “Skip this question” retains other drafts and emits the existing blank `{ selected: [] }` shape for that item, while close rejects the whole wait as `ASK_CANCELLED`.
A request whose single question declares a presentation intent renders as that intent's own surface instead. `plan-review` — set by `dsh-plan-mode` on the `exit_plan_mode` review — takes the waiting-approval card shape: a `Plan review` strip, the plan as the scrolling markdown body, the question text as the card's accessible name, and one decision row of `Chat about it` / `Refuse` / `Approve`. Approve and Refuse answer with the asker's own option labels (the intent names which label approves, so the verdict never rides option order) and keep the asker's descriptions as tooltips; `Chat about it` rejects the wait as `ASK_CANCELLED`, returning the composer so the user can say what they want instead. The card claims a request only when it can send every answer that request allows: one question, the intent declared, the plan present as `detail`, the named approve label offered, and a binary single choice (at most one option besides approve, not multi-select). Anything else — no intent, a batch of several questions, a missing plan, an approve label naming no option, a third option, a multi-select decision — stays on the generic flow, which can express it. An intent changes the layout, never which answers are reachable.
@@ -4,7 +4,7 @@
Web 提问功能插件:其浏览器侧把 `question` 条目注册到会话拥有的 `conversation.composer` 键控 slot 中。其主机侧刻意为空——在那里挂载 `dsh-tool-ask-user` 会把工具放进注册表的**全局层**,而全局层会并入每一个 agent(智能体),无论它由哪个 preset 组装,于是一个「两工具」的 benchmark preset 实际会呈现三个。渲染提问是宿主的 UI 能力,拥有该工具则是 agent 的能力,因此 `tool-ask-user` 行属于需要它的各个 preset(以及没有 preset 的 TUI 组装)。
组件每次渲染一个问题,提供进度导航、单选和多选选项、由标签后缀派生的推荐徽标,以及自定义答案。用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected``custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持标题、导航与提交动作固定,超长的详情与选项共享内部滚动区。选择单选选项后会立即前进;所有问题均已回答或跳过后Enter 会提交;IME 组合输入期间按 Enter 只会确认输入候选,不会前进。组件为整个请求提交一批结构化答案:「跳过此问题」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 形状;关闭则以 `ASK_CANCELLED` 拒绝整个等待。
组件每次渲染一个问题,提供进度导航、单选和多选选项、由标签后缀派生的推荐徽标,以及自定义答案。用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected``custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持标题、导航与提交动作固定,超长的详情与选项共享内部滚动区。两种问题形状的自定义答案都写入一个带隐藏高度镜像的 textarea,因此长答案会软换行并就地把输入框撑高;增高到六行文本为止——两种形状行数相同——此后由输入框自身滚动,使答案所属的选项仍留在视野内。选择单选选项后会立即前进;Enter 继续流程,所有问题均已回答或跳过后即提交,Shift+Enter 则改为换行;IME 组合输入期间按 Enter 只会确认输入候选,不会前进。组件为整个请求提交一批结构化答案:「跳过此问题」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 形状;关闭则以 `ASK_CANCELLED` 拒绝整个等待。
若某个请求的唯一问题声明了呈现意图,则改为渲染该意图自己的界面。`plan-review`——由 `dsh-plan-mode``exit_plan_mode` 审阅上设置——采用等待审批卡片的形状:一条 `Plan review` 条带、计划作为可滚动的 markdown 主体、问题文本作为卡片的无障碍名称,以及一行 `Chat about it` / `Refuse` / `Approve` 的决定操作。Approve 与 Refuse 用提问方自己的选项标签回答(意图指名哪个标签表示批准,因此裁决绝不依赖选项顺序),并把提问方的描述保留为 tooltip;`Chat about it``ASK_CANCELLED` 拒绝该等待,让编辑器归位,用户可以直接说出他想说的话。卡片只在能够发出该请求允许的每一个答案时才接管:只有一个问题、声明了意图、计划以 `detail` 存在、提供了被指名的批准标签,且是二元单选(除批准外最多一个选项,且非多选)。其他任何情形——没有意图、一批含多个问题、缺少计划、批准标签未命中任何选项、出现第三个选项、多选决定——都留在能够表达它的通用流程上。意图改变的只是布局,从不改变可达的答案。
@@ -321,54 +321,83 @@
border-color: var(--dsw-alias-border-l2);
}
.customInput {
flex: 1;
/* Auto-growing free-text answer (see AnswerField): mirror and textarea share
one grid cell, the mirror sizes it, and the textarea stretches to it. The
two layers MUST keep identical type, padding and wrapping a mismatch
makes the box the wrong height for the text the user is typing. */
.field {
display: grid;
min-width: 0;
padding: 0;
--dsh-answer-field-padding: 0;
}
.field > * {
grid-area: 1 / 1;
min-width: 0;
padding: var(--dsh-answer-field-padding);
font: inherit;
font-size: 14px;
line-height: 24px;
white-space: pre-wrap;
word-break: break-word;
overflow-wrap: anywhere;
}
/* The height ruler, never painted. Growth stops at 6 lines: the card body is
capped at 520px and still owes that budget to the title, the option rows and
the footer actions, so a long answer scrolls in the textarea instead of
pushing the choices it belongs to out of view.
content-box overrides the card-wide border-box so the cap counts TEXT lines
in both variants: under border-box the block variant's 16px of vertical
padding eats two thirds of a line and the sixth one arrives as an 8px sliver.
Width is unaffected a stretched grid item's used width comes from the
track, not from this box-sizing. */
.fieldMirror {
box-sizing: content-box;
max-height: 144px;
overflow: hidden;
visibility: hidden;
}
.fieldInput {
resize: none;
/* The stack's only scrollport: past the mirror's cap the field stops growing
and this scrolls. */
overflow-y: auto;
border: none;
outline: none;
background: transparent;
color: var(--dsw-alias-label-primary);
caret-color: var(--dsw-alias-state-business-primary);
font: inherit;
font-size: 14px;
line-height: 24px;
}
.customInput::placeholder {
.fieldInput::placeholder {
color: var(--dsw-alias-label-caption);
}
/* Optionless question: the free-form answer is the whole body. The 12px side
margins add to the .options 12px padding so both edges align with the
title's 24px inset; type matches the option rows, no resize handle. */
.customTextarea {
display: block;
/* Inline variant: the custom row's copy column, borrowing the row's chrome. */
.customInline {
flex: 1;
}
/* Optionless question: the free-form answer is the whole body, so it carries
its own frame. The 12px side margins add to the .options 12px padding so both
edges align with the title's 24px inset; two lines are reserved before the
answer starts growing. */
.customBlock {
min-height: 64px;
max-height: 140px;
flex-shrink: 0;
margin: 0 12px;
padding: 8px 12px;
resize: none;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 10px;
outline: none;
background: var(--dsw-alias-bg-module-platform);
color: var(--dsw-alias-label-primary);
caret-color: var(--dsw-alias-state-business-primary);
font: inherit;
font-size: 14px;
line-height: 24px;
--dsh-answer-field-padding: 8px 12px;
}
.customTextarea:focus {
.customBlock:focus-within {
border-color: var(--dsw-alias-state-business-primary);
}
.customTextarea::placeholder {
color: var(--dsw-alias-label-caption);
}
.footer {
display: flex;
align-items: center;
@@ -39,12 +39,67 @@ export function parseRecommendedLabel(label: string): { label: string; recommend
}
/** Return whether a text-field key event belongs to an active IME composition. */
function isComposing(event: KeyboardEvent<HTMLTextAreaElement | HTMLInputElement>): boolean {
function isComposing(event: KeyboardEvent<HTMLTextAreaElement>): boolean {
// keyCode 229 is the legacy IME-composition signal engines emit without isComposing.
// oxlint-disable-next-line typescript/no-deprecated
return event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229
}
/** The free-text answer field shared by both question shapes. */
interface AnswerFieldProps {
/** Which shape the field takes: the custom row's inline column, or the optionless question's own framed block. */
variant: 'inline' | 'block'
/** Current draft text. */
value: string
/** Empty-field prompt. */
placeholder: string
/** Whether a submission in flight has frozen the field. */
disabled: boolean
/** Whether this field takes focus on mount. */
autoFocus?: boolean
/** Called when the field takes focus. */
onFocus?: () => void
/** Called with each edit of the draft. */
onChange: (event: ChangeEvent<HTMLTextAreaElement>) => void
/** Called with each key press, before the browser's own handling. */
onKeyDown: (event: KeyboardEvent<HTMLTextAreaElement>) => void
}
/**
* Auto-growing free-text answer: a textarea, so a long answer soft-wraps and
* Shift+Enter breaks a line, over a hidden mirror that owns the height.
*
* The mirror renders the draft plus a trailing newline in normal flow and so
* sizes the grid row (counting rows by '\n' cannot see soft wraps); the
* textarea shares that one cell and stretches to it, and `rows={1}` keeps the
* control's own intrinsic height out of the row sizing so the mirror alone
* decides. Past the mirror's cap the textarea scrolls itself it is the only
* scrollport in the stack, there being no second glyph layer to keep aligned.
* Mirror and textarea MUST share font, line-height, padding and wrapping rules
* or the two heights diverge.
*
* @param props - field shape, draft text, and the field's event handlers.
* @returns The mirrored auto-growing field.
*/
function AnswerField(props: AnswerFieldProps) {
return (
<div className={clsx(css.field, props.variant === 'inline' ? css.customInline : css.customBlock)}>
<div aria-hidden className={css.fieldMirror}>{`${props.value}\n`}</div>
<textarea
autoFocus={props.autoFocus}
className={css.fieldInput}
value={props.value}
disabled={props.disabled}
rows={1}
placeholder={props.placeholder}
onFocus={props.onFocus}
onChange={props.onChange}
onKeyDown={props.onKeyDown}
/>
</div>
)
}
/**
* Composer takeover boundary; the carrier key keys local drafts, so a
* same-request replay (same key, new carrier object) preserves them.
@@ -164,11 +219,10 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<Questi
submitDrafts(drafts)
}
// Shared by the inline custom input and the optionless textarea: a
// multi-select draft retains checked labels, while a single-select custom
// answer replaces its selection. Enter continues the flow (Shift+Enter
// stays a newline in the textarea; on the single-line input it is inert).
const draftCustom = (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>): void => {
// Shared by the inline custom field and the optionless one: a multi-select
// draft retains checked labels, while a single-select custom answer replaces
// its selection. Enter continues the flow, Shift+Enter breaks a line.
const draftCustom = (event: ChangeEvent<HTMLTextAreaElement>): void => {
const value = event.target.value
updateDraft(current => ({
...current,
@@ -178,7 +232,7 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<Questi
}))
}
const continueFromCustom = (event: KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>): void => {
const continueFromCustom = (event: KeyboardEvent<HTMLTextAreaElement>): void => {
if (event.key !== 'Enter' || event.shiftKey || isComposing(event)) return
event.preventDefault()
continueFlow()
@@ -295,9 +349,8 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<Questi
<IconEditOutline16 size={12} />
</span>
)}
<input
type="text"
className={css.customInput}
<AnswerField
variant="inline"
value={draft.custom}
disabled={busy !== null}
placeholder={t('custom.placeholder')}
@@ -307,12 +360,11 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<Questi
</div>
)
: (
<textarea
<AnswerField
autoFocus={!focusedQuestions.current.has(index)}
className={css.customTextarea}
variant="block"
value={draft.custom}
disabled={busy !== null}
rows={2}
placeholder={t('custom.placeholder')}
onFocus={() => { focusedQuestions.current.add(index) }}
onChange={draftCustom}
@@ -204,6 +204,43 @@ describe('QuestionComposer', () => {
expect(respond).not.toHaveBeenCalled()
})
it('answers over multiple lines: both fields grow with the draft and keep Shift+Enter a newline', () => {
const { carrier, respond } = wait()
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
// Both question shapes answer into a textarea, so the engine soft-wraps a
// long answer and Shift+Enter breaks the line natively.
const inline = screen.getByPlaceholderText('输入你的答案')
expect(inline.tagName).toBe('TEXTAREA')
const multiline = '第一行\n第二行'
fireEvent.change(inline, { target: { value: multiline } })
// The hidden height ruler carries the draft plus the trailing newline the
// textarea's own last line needs, so the box is as tall as the answer.
expect(inline.previousElementSibling?.textContent).toBe(`${multiline}\n`)
// Shift+Enter belongs to the field, never to the flow.
fireEvent.keyDown(inline, { key: 'Enter', shiftKey: true })
expect(screen.getByText('1 / 3')).toBeTruthy()
fireEvent.keyDown(inline, { key: 'Enter' })
const optionless = screen.getByPlaceholderText('输入你的答案')
expect(optionless.tagName).toBe('TEXTAREA')
fireEvent.change(optionless, { target: { value: multiline } })
expect(optionless.previousElementSibling?.textContent).toBe(`${multiline}\n`)
fireEvent.keyDown(optionless, { key: 'Enter', shiftKey: true })
expect(screen.getByText('2 / 3')).toBeTruthy()
fireEvent.keyDown(optionless, { key: 'Enter' })
fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' }))
fireEvent.click(screen.getByRole('button', { name: '提交' }))
// Line breaks reach the model verbatim: nothing along the way flattens them.
expect(respond).toHaveBeenCalledWith(answeredEnvelope('question-1', [
{ id: 'profile', selected: [], custom: multiline },
{ id: 'detail', selected: [], custom: multiline },
{ id: 'signals', selected: ['系统设计'] },
]))
})
it('surfaces cancellation failures: rejected receipt text and raw transport reasons', async () => {
const respond = vi.fn()
.mockResolvedValueOnce({ accepted: false, reason: 'bad-response' })
+2 -2
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/web/README.md
README.md: c7b77d3eba8cf56017c816362e9ffc4fcb3be403
README.zh.md: 2a52bcdfe7395466866b80160f0646cf3b7fe111
README.md: b4304ddc3f7d8fc0f8bfc5cb8373196a97e0273f
README.zh.md: aebaccf819f02ed49afdf4f20027738c19c6cde0
+1 -1
View File
@@ -8,7 +8,7 @@ The boot page uses plain DOM and local CSS, so client-bundle and plugin-activati
`PLATFORM_MODULES` (src/platform.ts) is the single source of truth for shell-seeded shared modules. Together with `PRELOADED_CLIENT_EXTERNALS`, it defines the implicit external baseline for every dynamic bundle; `dsh.client.external` adds only exact non-baseline requests.
The optional override parameter `seams` forwards the module system's `loadBundle` transport override (`BootSeams`) for environments where external `<script>` execution cannot reach the page context; ordinary browser callers omit it.
The optional override parameter `seams` forwards the module system's `loadBundle` transport override (`BootSeams`) for environments where external `<script>` execution cannot reach the page context; ordinary browser callers omit it. A pre-injected page transport is the default ahead of it: when `globalThis.__DSH_TRANSPORT__` (the connection package's `ClientTransportHooks`) carries `loadBundle`, the module stage adopts it as the bundle transport and skips the immediate-tier HTTP prefetch — explicit `seams` still win.
## Model Experience
+1 -1
View File
@@ -8,7 +8,7 @@ Web 启动内核:`new AppWebEntry(el, seams?).run()` 分两个阶段挂载客
`PLATFORM_MODULES`src/platform.ts)是外壳播种共享模块的唯一事实来源。它与 `PRELOADED_CLIENT_EXTERNALS` 一起定义全部动态 bundle 的隐式 external 基座;`dsh.client.external` 只添加基座之外的精确请求。
可选的覆盖参数 `seams` 会为外部 `<script>` 执行无法到达页面上下文的环境转发模块系统的 `loadBundle` 传输覆盖(`BootSeams`);普通浏览器调用方省略此参数。
可选的覆盖参数 `seams` 会为外部 `<script>` 执行无法到达页面上下文的环境转发模块系统的 `loadBundle` 传输覆盖(`BootSeams`);普通浏览器调用方省略此参数。预注入的页面传输是位于其前的默认值:当 `globalThis.__DSH_TRANSPORT__`connection 包的 `ClientTransportHooks`)携带 `loadBundle` 时,模块阶段将其采纳为 bundle 传输并跳过 immediately 层级的 HTTP 预取——显式 `seams` 仍然优先。
## 模型体验
+16
View File
@@ -50,9 +50,18 @@ export class AppWebEntry {
if (moduleLoader === undefined) {
throw new Error('web boot: window.__ModuleLoader__ bootstrap facade is missing')
}
// A pre-injected transport (the worker preview page) owns bundle bytes;
// its loadBundle is the default and explicit seams still win. The global
// is `ClientTransportHooks`, owned by @deepseek-ai/dsh-client-connection;
// this structural slice reads one optional member without adding a
// package edge.
const transport = (globalThis as {
__DSH_TRANSPORT__?: { loadBundle?: ClientModuleCreateOptions['loadBundle'] }
}).__DSH_TRANSPORT__
this.modules = moduleLoader.create({
boot: win.__DSH_BOOT__,
staticModules: getStaticModules(),
...transport?.loadBundle === undefined ? {} : { loadBundle: transport.loadBundle },
...this.seams,
})
this.manifest = this.modules.manifest
@@ -86,6 +95,13 @@ export class AppWebEntry {
/** Prefetch stage-one bundles; their import path owns any eventual failure. */
private async prefetchImmediateTier(): Promise<void> {
// A transport carrying loadBundle owns the bundle bytes; HTTP prefetch
// against its static deployment answers nothing. A transport without
// loadBundle leaves bundles on HTTP, prefetch included.
const transport = (globalThis as {
__DSH_TRANSPORT__?: { loadBundle?: unknown }
}).__DSH_TRANSPORT__
if (transport?.loadBundle !== undefined) return
await Promise.all(this.manifest.plugins
.filter(row => row.immediately)
.map(row => this.modules.prefetch(row.id).catch((_prefetchError: unknown) => {
@@ -196,23 +196,50 @@ export function parseErrorMessage(half: 'code.host' | 'code.client', context: st
/**
* Parse one half's source without running it: the define-time precheck that
* keeps unparseable code out of the registry, so a model fixes it and defines
* again instead of discovering the failure at run time. Compiling through `vm`
* rather than `new Function` is what makes the two agree same wrapper, same
* compiler, and the same source-line-and-caret prelude in the failure.
* again instead of discovering the failure at run time. `new Function` is the
* gate hosts without a real `node:vm` (the browser worker) still refuse
* unparseable code and `vm.Script` is only the best-effort prettifier: on a
* Node host its failure carries the source-line-and-caret prelude the
* teaching text builds on, and where the vm is a stub the message stays bare.
* The two parsers' syntax faces differ at the margin (`new.target` parses in
* a function body but not at the vm wrapper's top level), an accepted cost of
* a vm-free gate; and under a page CSP without `'unsafe-eval'`, `new Function`
* throws `EvalError`, which propagates unwrapped.
* @param code - the model-written function body.
* @param half - which define argument carried it, for the error text.
* @throws when the body does not parse, with the offending line and a teaching hint.
*/
export function precheckCode(code: string, half: 'code.host' | 'code.client'): void {
const wrapped = `(async () => {\n${code}\n})()`
try {
// Compile-only: constructing the Script parses the source and runs nothing.
new Script(`(async () => {\n${code}\n})()`, { filename: `cordis-dyn-${half}.js` })
// Compile-only: constructing the function parses the source and runs nothing.
// oxlint-disable-next-line typescript/no-implied-eval -- parse gate over model-written code; nothing is invoked
new Function(wrapped)
} catch (error) {
if (!isSyntaxError(error)) throw error
throw new Error(parseErrorMessage(half, syntaxErrorContext(error)))
throw new Error(parseErrorMessage(half, prettyParseContext(wrapped, half, error)))
}
}
/**
* Best-effort vm recompile of a body `new Function` already refused, for the
* source-line-and-caret prelude only.
* @param wrapped - the wrapped source that failed to parse.
* @param half - which define argument carried it, for the vm filename.
* @param refusal - the gate's own `SyntaxError`, the fallback context source.
* @returns the vm prelude when a real vm produced one, else the bare refusal.
*/
function prettyParseContext(wrapped: string, half: 'code.host' | 'code.client', refusal: Error): string {
try {
new Script(wrapped, { filename: `cordis-dyn-${half}.js` })
} catch (vmError) {
if (isSyntaxError(vmError)) return syntaxErrorContext(vmError)
// A stubbed vm (the browser worker) refuses Script itself; the gate's
// error is the only context there is.
}
return String(refusal)
}
/**
* Evaluate a host half as the body of an async function inside the sandbox. `vmTimeoutMs` only
* bounds the SYNCHRONOUS portion; an async body escapes it acceptable under the module's
@@ -459,8 +459,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
{
key: 'clientModules',
summary: 'The web plugin table service: incremental `dsh.client` scan + wire composition + bundle route + index tap.',
description: 'The web plugin table service: incremental `dsh.client` scan + wire composition + bundle route + index tap. Construction runs the activation scan synchronously — a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud throw (FAILED fiber; the boot activation audit reports it).',
summary: 'The web plugin table service: incremental `dsh.client` scan + wire composition + bundle route + index injection rows.',
description: 'The web plugin table service: incremental `dsh.client` scan + wire composition + bundle route + index injection rows. Construction runs the activation scan synchronously — a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud throw (FAILED fiber; the boot activation audit reports it).',
methods: [
{
signature: 'graph(): WebBootGraph',
@@ -2207,7 +2207,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
{
signature: 'tapIndex(transform: (html: string) => string): () => void',
description: 'Register an index.html transform, applied by the fallback owner to every index response (applyIndexTaps) in registration order.',
description: 'Register a raw-HTML index transform, the escape hatch for markup no IndexInjection row expresses: renderIndex applies taps in registration order after rendering the structured rows.',
parameters: [{ name: 'transform', description: 'pure html-to-html function.' }],
returns: 'the disposer removing the transform.',
},
@@ -2217,6 +2217,18 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
parameters: [{ name: 'html', description: 'the raw index.html body.' }],
returns: 'the transformed body.',
},
{
signature: 'collectIndexInjections(): IndexInjection[]',
description: 'Gather the structured injection table: one `webserver/index-inject` emit, every subscriber pushes its current rows. Fresh per call, so subscribers read live state (module graph, theme preference) at emit time.',
parameters: [],
returns: 'rows in subscriber activation order.',
},
{
signature: 'renderIndex(html: string): string',
description: 'Render one index.html body: the structured injection table first, then the raw `tapIndex` transforms over the result.',
parameters: [{ name: 'html', description: 'the raw index.html body.' }],
returns: 'the transformed body.',
},
],
},
{
@@ -2685,6 +2697,14 @@ export const EVENT_API: readonly EventApiEntry[] = [
description: 'Observe the frozen, lossless-JSON final outcome. Listener failures are contained. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): keyed by `exec.agent`.',
parameters: [{ name: 'exec', description: 'the execution object that traversed the pipeline.' }, { name: 'result', description: 'a deep-frozen snapshot of the final returned result.' }],
},
{
name: 'webserver/index-inject',
mode: 'emit',
signature: '\'webserver/index-inject\'(table: IndexInjection[]): void',
summary: 'Collect the structured index injection table.',
description: 'Collect the structured index injection table. Emitted on every index render and every worker boot-payload request; listeners push their current rows, so a row\'s data is read fresh at emit time.',
parameters: [{ name: 'table', description: 'Mutable row table; listeners append in activation order.' }],
},
{
name: 'workflow/agent-end',
mode: 'emit',
@@ -3297,6 +3317,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'InboxTarget',
declaration: 'export type InboxTarget = \'next-turn\' | \'next-step\';',
},
{
name: 'IndexInjection',
declaration: 'export type IndexInjection = {\n kind: \'global\';\n name: string;\n value: unknown;\n} | {\n kind: \'script\';\n placement: IndexInjectionPlacement;\n text: string;\n} | {\n kind: \'script-src\';\n placement: IndexInjectionPlacement;\n src: string;\n} | {\n kind: \'style\';\n text: string;\n} | {\n kind: \'html\';\n placement: IndexInjectionPlacement;\n html: string;\n};',
},
{
name: 'IndexInjectionPlacement',
declaration: 'export type IndexInjectionPlacement = \'head\' | \'body\';',
},
{
name: 'InvariantFailure',
declaration: 'export type InvariantFailure = (message: string) => never;',
+2 -2
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/host/apiproxy/README.md
README.md: 27efe5a75eb6947d71f95c0a60e590c15a35887c
README.zh.md: 71f4e63a013170c6822a5c8dae28bc5f899b271a
README.md: a9fb07519c1d8b3b2e2d759bd9138dac60f2306f
README.zh.md: 9bde3583b0f5e6ce172bfe145f440150aabfa230
+1
View File
@@ -76,6 +76,7 @@ None; this package neither assembles nor sends a provider request.
- **Forwarded Remote events are parasitic on this legacy frame union**`host/remote-event` lives in `HostFrame` so the delivery path could reuse the existing host stream instead of opening a third downlink, which makes it read as if this package owned the Remote event contract. It does not: the allowlist is `dsh-api-remotes`' and the consumer verb is `ctx.remote.$on`. When the host stream moves off this package, the frame moves with it and the consumer contract is unaffected ([rationale](../../../.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md)).
- **Pending-interaction state is host-side** — the wire uses POST `/api/respond` plus `RpcReceipt`; the table in `src/api-proxy.ts` handles questions only and has no approval entries.
- **A pending question does not survive a host restart** — the registry holds the awaiting tool call's own `resolve`/`reject`, so it is host-process memory. `events.mux` replays every still-pending question on each reopen, which covers a browser reload or a reconnect; a host restart takes the awaiting turn with it, and the reopened Session offers no composer for that question. Restoring one across a host restart needs a durable pending-interaction record and is deferred.
- **Reserved seams stay out of `RpcMethodMap`**`prompt.mode: 'inject'`, `job.list`, and a describe `hostInstanceId` are documented reservations; model discovery uses `llm.models`. An unknown method fails loud at envelope parse rather than getting a not-implemented code.
- **No protocol version field** — client and host ship together; `host.describe` gains a version negotiation field only when an independently released client exists.
- **Search failures include provider diagnostics** — the gateway is a single-user local service. A carrier that exposes it to multiple users must replace internal search details with a public-safe diagnostic.

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