fix(client): tighten UI ownership boundaries

This commit is contained in:
imccyu
2026-08-18 01:34:25 +08:00
parent 07484b9474
commit e1777b7891
31 changed files with 141 additions and 106 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-30-web-config-plane.md
2026-07-30-web-config-plane.md: 46d773f2b4cd9c50ef1eefb2c32d78dbf8dbd100
2026-07-30-web-config-plane.zh.md: d035be8533393df011f3047749bfa48e11c558bb
2026-07-30-web-config-plane.md: 1326f50a792b6c6f791c9515ea03cc362d16fc7e
2026-07-30-web-config-plane.zh.md: bea4bbc237f44864a5fa9bf677267ca0055c13ed
@@ -4,7 +4,7 @@ Status: implemented
English | [中文](2026-07-30-web-config-plane.zh.md)
> Scope: the wire face and web UI deferred from the [request-level LLM configuration note](2026-07-29-request-level-llm-config-credentials.md) — the `settings.*`/`credentials.*`/`llm.*` RPC domains with pushed invalidations, layered+redacted `describe()`, the local settings-document handoff, the llm configurable-provider directory and topology event, the standalone `dsh-client-schema-form` model layer, and the Models settings page with its hand-written provider editor. The `deepseek` → `deepseek-official` provider-route rename rides along as the enabling breaking change.
> Scope: the wire face and web UI deferred from the [request-level LLM configuration note](2026-07-29-request-level-llm-config-credentials.md) — the `settings.*`/`credentials.*`/`llm.*` RPC domains with pushed invalidations, layered+redacted `describe()`, the local settings-document handoff, the llm configurable-provider directory and topology event, the `ctx.settingsSchema` model service owned by `dsh-client-ui-settings`, and the Models settings page with its hand-written provider editor. The `deepseek` → `deepseek-official` provider-route rename rides along as the enabling breaking change.
## Problem
@@ -20,7 +20,7 @@ The request-level configuration seam made LLM adapter configuration restart-free
**The llm seam declares configurability and announces topology.** `registerConfigurableProviders()` is an all-or-nothing, fiber-scoped directory of `{provider, displayName, settingsNs, settingsPath}` — the addressing a config page needs to open the right settings subtree for a route that may not exist yet; `listConfigurableProviders()` merges with live routes in the wire handler so undeclared live routes still report active. The zero-payload `'llm/adapters-updated'` event fires from all four registration/unregistration commit points with contained listener dispatch (INVARIANT rethrow), following the settings/commands precedent. `llm-deepseek`'s route renamed to `deepseek-official` because the pi-ai catalog legitimately owns `deepseek` as an aggregator entry; pre-release stance, no alias.
**A hand-written editor over a schema model layer.** `dsh-client-schema-form` rehydrates the wire's `toJSON()` envelope into live schemastery nodes for validation, path resolution, and immutable draft editing — but no generic rendering: the first cut shipped a full schema-driven form renderer, and the resulting page was an unstyled schema dump (every advanced field flattened onto the card, raw field names as labels, the `retryPolicy` unsupported-fallback in the main flow). The hand-written direction won over adding a hint/grouping system, and a further simplification removed the reference input entirely: the card's primary field is one **API key** input, a whole-section provider without a configured key opens as its setup card, and the collapsed 自定义设置 fold carries the curated per-family extras (`baseURL` for both families, `reasoningEffort` for deepseek / `reasoning` for pi-ai, plus direct DeepSeek model rows with `id`, `name`, and `contextWindow`). Existing model fields outside that visible set survive array edits; retry policy, timeouts, and other fields remain owned by `settings.yaml`. Validation still runs the rehydrated schema before writing, while adapter-specific checks reject catalog invariants that the serialized schema cannot express. The card's colors resolve through the `--dsw-alias-*` design tokens; it had named `--border`/`--surface`/`--text-*`, which nothing in this app defines, so it rendered their light-mode fallbacks and stayed light under the dark theme. The model catalog takes the row shape the pi-ai provider form introduces: one bordered entry per model, id and display name on the row, and the capacities behind the row's own disclosure, so the two editors read as one design rather than diverging. Every field keeps the indexed `aria-label` that names it. Both capacities are text fields reading a decimal `K`/`M` suffix (`1M` is 1000K, matching how capacities are quoted) and storing the plain count: a field holds the typed text while it has focus, because re-deriving it from the parsed count on every keystroke would rewrite `1000` to `1K` mid-word, and text that does not parse stays on screen so the save-time rejection names a row the user can still see. The shared class names carry only declared token spellings: `--dsw-alias-border-subtle`, `--dsw-alias-text-tertiary`, and `--dsw-alias-text-primary` are undeclared, so naming them resolves to the light-mode literals in their fallback slots. A styles test now rejects any `--dsw-*` name the token sheet does not declare, so the next editor to name one fails rather than shipping a light-only surface.
**A hand-written editor over a schema model layer.** `ctx.settingsSchema`, provided by `dsh-client-ui-settings`, rehydrates the wire's `toJSON()` envelope into live schemastery nodes for validation, path resolution, and immutable draft editing — but no generic rendering: the first cut shipped a full schema-driven form renderer, and the resulting page was an unstyled schema dump (every advanced field flattened onto the card, raw field names as labels, the `retryPolicy` unsupported-fallback in the main flow). The hand-written direction won over adding a hint/grouping system, and a further simplification removed the reference input entirely: the card's primary field is one **API key** input, a whole-section provider without a configured key opens as its setup card, and the collapsed 自定义设置 fold carries the curated per-family extras (`baseURL` for both families, `reasoningEffort` for deepseek / `reasoning` for pi-ai, plus direct DeepSeek model rows with `id`, `name`, and `contextWindow`). Existing model fields outside that visible set survive array edits; retry policy, timeouts, and other fields remain owned by `settings.yaml`. Validation still runs the rehydrated schema before writing, while adapter-specific checks reject catalog invariants that the serialized schema cannot express. The card's colors resolve through the `--dsw-alias-*` design tokens; it had named `--border`/`--surface`/`--text-*`, which nothing in this app defines, so it rendered their light-mode fallbacks and stayed light under the dark theme. The model catalog takes the row shape the pi-ai provider form introduces: one bordered entry per model, id and display name on the row, and the capacities behind the row's own disclosure, so the two editors read as one design rather than diverging. Every field keeps the indexed `aria-label` that names it. Both capacities are text fields reading a decimal `K`/`M` suffix (`1M` is 1000K, matching how capacities are quoted) and storing the plain count: a field holds the typed text while it has focus, because re-deriving it from the parsed count on every keystroke would rewrite `1000` to `1K` mid-word, and text that does not parse stays on screen so the save-time rejection names a row the user can still see. The shared class names carry only declared token spellings: `--dsw-alias-border-subtle`, `--dsw-alias-text-tertiary`, and `--dsw-alias-text-primary` are undeclared, so naming them resolves to the light-mode literals in their fallback slots. A styles test now rejects any `--dsw-*` name the token sheet does not declare, so the next editor to name one fails rather than shipping a light-only surface.
**The Models page is a three-domain join with service-shaped apply semantics.** Rows are configured providers; the add card's select is the dormant directory remainder. Route liveness still gates readiness and invalidates the join, but the page does not render it as provider status because configuration presence and runtime availability are distinct. The key path stays reference-shaped without ever showing a reference: a typed key stores **write-only** through `credentials.set` under the profile's `apiKeyEnv`, deriving `<ROUTE>_API_KEY` when none exists (the pi-ai profile records the derivation only when a key is entered), so `settings.yaml` never carries a key value; a blank pi-ai key materializes a reference-free profile and preserves provider-native authentication. Profile edits and removals land as minimal path-addressed `settings.mutate` operations against the redacted user section, which never names a secret the page did not receive. Removing a user-layer provider first opens a localized confirmation dialog whose row actions, title, description, and final action identify the same provider; confirmation removes an exact configured+writable derived credential before the profile, while custom, environment, and unidentified targets remain untouched. Both stages are idempotent and a partial failure stays in the dialog for retry. DeepSeek's model list is array-replace configuration: inherited effective rows remain visible until the first edit materializes the complete list in the user layer, and reset unsets the list override. The partial-commit and credential-ownership rationale lives in the [provider credential lifecycle note](../bug-fix/2026-08-06-provider-credential-lifecycle.md).
@@ -4,7 +4,7 @@ Status: implemented
[English](2026-07-30-web-config-plane.md) | 中文
> 范围:[请求级 LLM(大语言模型)配置 note](2026-07-29-request-level-llm-config-credentials.md) 中延后的 wire 面与 web UI——带推送式失效的 `settings.*`/`credentials.*`/`llm.*` RPC 领域、分层且脱敏的 `describe()`、本地设置文档交接、llm 可配置提供方目录与拓扑事件、独立的 `dsh-client-schema-form` 模型,以及带手写提供方编辑器的 Models 设置页。`deepseek` → `deepseek-official` 提供方路由重命名作为解锁前提的破坏性变更一并搭车合入。
> 范围:[请求级 LLM(大语言模型)配置 note](2026-07-29-request-level-llm-config-credentials.md) 中延后的 wire 面与 web UI——带推送式失效的 `settings.*`/`credentials.*`/`llm.*` RPC 领域、分层且脱敏的 `describe()`、本地设置文档交接、llm 可配置提供方目录与拓扑事件、 `dsh-client-ui-settings` 持有的 `ctx.settingsSchema` 模型服务,以及带手写提供方编辑器的 Models 设置页。`deepseek` → `deepseek-official` 提供方路由重命名作为解锁前提的破坏性变更一并搭车合入。
## 问题
@@ -20,7 +20,7 @@ Status: implemented
**llm seam 声明可配置性并公布拓扑。**`registerConfigurableProviders()` 是一个全有或全无、以 fiber 为作用域的目录,条目为 `{provider, displayName, settingsNs, settingsPath}`——这正是配置页要为一条可能尚不存在的路由打开正确设置子树时所需要的寻址;`listConfigurableProviders()` 在 wire 处理器里与存活路由合并,未声明的存活路由因此仍报告为激活。零负载的 `'llm/adapters-updated'` 事件从全部四个注册/注销提交点触发,listener 派发带异常隔离(INVARIANT 重抛),沿用 settings/commands 的先例。`llm-deepseek` 的路由重命名为 `deepseek-official`,因为 pi-ai catalog 名正言顺地拥有 `deepseek` 这个聚合器条目;依预发布立场,不设别名。
**架在 schema 模型层之上的手写编辑器。**`dsh-client-schema-form` 把 wire 的 `toJSON()` 信封还原(rehydrate)为活的 schemastery 节点,用于校验、路径解析与不可变草稿编辑——但不做通用渲染:第一版交付了完整的 schema 驱动表单渲染器,得到的却是一个未加样式、把 schema 原样倾倒出来的页面(每个进阶字段都平铺到卡片上、原始字段名直接充当标签、`retryPolicy` 的「不支持」回退落在主流程里)。手写方向胜过了再加一套提示/分组系统,进一步的简化又把引用输入框整个移除:卡片的主字段是一个 **API 密钥** 输入框,未配置密钥的整分节提供方会以其设置卡片的形式打开,收起的「自定义设置」折叠区承载按家族精选的额外字段(两个家族都有 `baseURL`deepseek 有 `reasoningEffort`pi-ai 有 `reasoning`,另有直接 DeepSeek 模型行的 `id``name``contextWindow`)。现有模型字段中不在可见集合内的部分会在数组编辑后保留;重试策略、超时及其他字段仍归 `settings.yaml` 所有。校验仍会在写入前运行还原出的 schema,适配器特有的检查则会拒绝序列化 schema 无法表达的目录不变量。卡片的颜色经 `--dsw-alias-*` 设计 token 解析;它此前引用的 `--border``--surface``--text-*` 在本应用中无人定义,于是渲染出的是它们的亮色模式回退值,在暗色主题下依旧保持亮色。模型目录采用 pi-ai 提供方表单引入的行形态:每个模型一个带边框的条目,ID 与显示名称落在行上,容量则收在该行自己的折叠区里,使两个编辑器呈现为同一套设计,而不是各自分岔。每个字段都保留那个为其命名的带序号 `aria-label`。两项容量都是文本输入框,读取十进制的 `K``M` 后缀(`1M` 即 1000K,与容量的通行标注方式一致)并存储纯数值:字段持有焦点期间保留键入的文本,因为若每次按键都从解析出的数值重新推导该文本,`1000` 会在尚未输完时就被改写成 `1K`;无法解析的文本也会留在屏幕上,因此保存时的拒绝点名的是用户仍能看见的那一行。共用的类名只承载已声明的 token 写法:`--dsw-alias-border-subtle``--dsw-alias-text-tertiary``--dsw-alias-text-primary` 均未声明,写出它们就会解析为各自回退槽位中的亮色模式字面值。现在有一个样式测试会拒绝 token 表未声明的任何 `--dsw-*` 名称,因此下一个写出这类名称的编辑者会当场失败,而不是交付一个只有亮色的界面。
**架在 schema 模型层之上的手写编辑器。**`dsh-client-ui-settings` 提供的 `ctx.settingsSchema` 把 wire 的 `toJSON()` 信封还原(rehydrate)为活的 schemastery 节点,用于校验、路径解析与不可变草稿编辑——但不做通用渲染:第一版交付了完整的 schema 驱动表单渲染器,得到的却是一个未加样式、把 schema 原样倾倒出来的页面(每个进阶字段都平铺到卡片上、原始字段名直接充当标签、`retryPolicy` 的「不支持」回退落在主流程里)。手写方向胜过了再加一套提示/分组系统,进一步的简化又把引用输入框整个移除:卡片的主字段是一个 **API 密钥** 输入框,未配置密钥的整分节提供方会以其设置卡片的形式打开,收起的「自定义设置」折叠区承载按家族精选的额外字段(两个家族都有 `baseURL`deepseek 有 `reasoningEffort`pi-ai 有 `reasoning`,另有直接 DeepSeek 模型行的 `id``name``contextWindow`)。现有模型字段中不在可见集合内的部分会在数组编辑后保留;重试策略、超时及其他字段仍归 `settings.yaml` 所有。校验仍会在写入前运行还原出的 schema,适配器特有的检查则会拒绝序列化 schema 无法表达的目录不变量。卡片的颜色经 `--dsw-alias-*` 设计 token 解析;它此前引用的 `--border``--surface``--text-*` 在本应用中无人定义,于是渲染出的是它们的亮色模式回退值,在暗色主题下依旧保持亮色。模型目录采用 pi-ai 提供方表单引入的行形态:每个模型一个带边框的条目,ID 与显示名称落在行上,容量则收在该行自己的折叠区里,使两个编辑器呈现为同一套设计,而不是各自分岔。每个字段都保留那个为其命名的带序号 `aria-label`。两项容量都是文本输入框,读取十进制的 `K``M` 后缀(`1M` 即 1000K,与容量的通行标注方式一致)并存储纯数值:字段持有焦点期间保留键入的文本,因为若每次按键都从解析出的数值重新推导该文本,`1000` 会在尚未输完时就被改写成 `1K`;无法解析的文本也会留在屏幕上,因此保存时的拒绝点名的是用户仍能看见的那一行。共用的类名只承载已声明的 token 写法:`--dsw-alias-border-subtle``--dsw-alias-text-tertiary``--dsw-alias-text-primary` 均未声明,写出它们就会解析为各自回退槽位中的亮色模式字面值。现在有一个样式测试会拒绝 token 表未声明的任何 `--dsw-*` 名称,因此下一个写出这类名称的编辑者会当场失败,而不是交付一个只有亮色的界面。
**Models 页是一次三领域联接,应用语义与服务同形。**每一行是一个已配置的提供方;「新增」卡片的选择框是可配置提供方目录中剩余的休眠条目。路由存活状态仍用于就绪判定,并会使该联接失效,但页面不将其渲染为提供方状态,因为配置存在与运行时可用性是两个不同概念。密钥通道保持引用形态,却从不展示任何引用:键入的密钥经 `credentials.set` **只写**存入 profile 的 `apiKeyEnv` 之下,引用不存在时便派生 `<ROUTE>_API_KEY`(仅在输入密钥时,pi-ai profile 才会记录该派生),因此 `settings.yaml` 从不携带密钥值;留空 pi-ai 密钥会具化一个不带引用的 profile,并保留提供方原生认证。profile 的编辑和删除会针对脱敏后的用户分节,以按路径寻址的最小 `settings.mutate` 操作落地,绝不会点名页面未收到的机密。删除用户层提供方时,会先打开本地化确认对话框,其行操作、标题、说明和最终操作都会点名同一个提供方;确认后会先清除与派生目标精确匹配且已配置、可写的凭据,再删除 profile,自定义目标、环境目标和无法识别的目标则保持不变。两个阶段都具备幂等性,部分失败会留在对话框中供重试。DeepSeek 的模型列表是数组替换配置:继承而来的生效模型行会一直显示,直到第一次编辑将完整列表具化到用户层;重置则会取消设置该列表覆盖。部分提交与凭据所有权的理由记录在[提供方凭据生命周期 note](../bug-fix/2026-08-06-provider-credential-lifecycle.md)中。
@@ -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-08-17-dynamic-client-render-and-attachment-ownership.md
2026-08-17-dynamic-client-render-and-attachment-ownership.md: 7298d8c520aeeb37a43c026cffc232d044b4914c
2026-08-17-dynamic-client-render-and-attachment-ownership.zh.md: 78ca31962a322d15f1a2b79bb64f5a26b36fe0b9
2026-08-17-dynamic-client-render-and-attachment-ownership.md: a7955156adf76e8655295d4db316ed35d1968585
2026-08-17-dynamic-client-render-and-attachment-ownership.zh.md: 815d4f92a6db56b1b3c5da16f264e27e952aae5f
@@ -14,17 +14,17 @@ The loading and failure page has the opposite requirement: it must remain usable
`@deepseek-ai/dsh-client-web` is a framework-free boot kernel. It draws its loading and failure page with DOM operations and local CSS fallbacks, constructs the client module system and Cordis Loader, creates the statically adopted modules bootstrap entry plus every host-graph entry, and waits until every fiber is ACTIVE. Loader state changes retain one spinner node and update only its CSS arc when an entry first becomes active. The arc grows from one fifth to four fifths of the ring, preserving a visible gap throughout rotation. After the roster settles, the kernel resolves `ctx.uiRenderer` and hands the existing container to `mount()`.
`@deepseek-ai/dsh-client-ui-renderer` is an `immediately` dynamic client plugin. It owns the React slot outlets, SessionProvider, and observable-to-uSES binding. After `slots`, `sessions`, and `layout` activate, it installs the slot renderer and provides `ctx.uiRenderer`. `mount()` hydrates the kernel-authored boot DOM, then replaces it with the assembled application in a layout effect before the browser can paint an intermediate frame. The hydrated spinner node retains its animation phase. The assembled tree projects the selected session title and performs the sole context-level `renderSlot('root')` call. The service, renderer installation, and React root all dispose with their owners.
`@deepseek-ai/dsh-client-ui-renderer` is an `immediately` dynamic client plugin. It owns the React slot outlets, SessionProvider, and observable-to-uSES binding. After its `slots` and `sessions` injections activate, it installs the slot renderer and provides `ctx.uiRenderer`. `mount()` hydrates the kernel-authored boot DOM, then replaces it with the assembled application in a layout effect before the browser can paint an intermediate frame. The hydrated spinner node retains its animation phase. The assembled tree projects the selected session title and performs the sole context-level `renderSlot('root')` call. The service, renderer installation, and React root all dispose with their owners.
`ui-conversation` declares `conversation.input.attachments` and `conversation.message.images` and supplies attachment data, callbacks, authorized image loading, and its locale seat. `ui-attachment` waits on those declarations through `ctx.slots.inject()` and registers the draft rail/drop target and historical image gallery/lightbox. The React implementations remain internal package values; cross-plugin composition uses slots. This package integration supersedes the direct-import ruling in the [attachment display note](../feature/2026-08-11-web-attachment-display-alignment.md) without changing that note's visual and interaction decisions.
ui-theme imports its five global stylesheets from its client entry. The shared client-bundle preset compiles ordinary CSS as well as CSS Modules and injects plugin-owned style tags at bundle materialization, so unloading or reloading ui-theme removes or replaces its global CSS with the same lifecycle as its service. The web kernel retains only mount defaults and a self-contained boot-page palette whose fonts and colors match the corresponding theme tokens.
ui-theme imports its five global stylesheets as `?inline` strings. Its client entry calls `installThemeStyles(ctx)`, which installs one style tag per sheet through `ctx.effect()`, so unloading or reloading ui-theme removes or replaces its global CSS with the same lifecycle as its service. The web kernel retains only mount defaults and a self-contained boot-page palette whose fonts and colors match the corresponding theme tokens.
React, React DOM, Cordis, ui-slots, and ui-primitives remain static platform modules with one browser identity. The dynamic ui-renderer bundle consumes those shared modules and owns the rendering effects.
## Verification
Component tests pin the persistent progress spinner, hydration without boot-DOM mutation, document title, application tree, attachment entries, and disposal. The assembled built-bundle boot exercises the real module table and dynamic entries, while the client-bundle CSS tests prove global styles compile into watched plugin-owned injectors. The browser replay lane covers the complete handoff from the framework-free page to the rendered application.
Component tests pin the persistent progress spinner, hydration without boot-DOM mutation, document title, application tree, attachment entries, and disposal. The assembled built-bundle boot exercises the real module table and dynamic entries, while the theme style tests prove its tags install and dispose with the plugin fiber. The browser replay lane covers the complete handoff from the framework-free page to the rendered application.
## Alternatives considered
@@ -14,17 +14,17 @@ Status: implemented
`@deepseek-ai/dsh-client-web` 是不依赖框架的启动内核。它通过 DOM 操作与本地 CSS 回退绘制加载和失败页面,构造客户端模块系统与 Cordis Loader,创建静态接纳的 modules 启动 entry 和宿主图中的每个 entry,并等待所有 fiber 进入 ACTIVE。Loader 状态变化会保留同一个 spinner 节点,只在 entry 首次进入 active 时更新其 CSS 圆弧。圆弧从圆环的五分之一增长至五分之四,在旋转期间始终保留可见缺口。名册稳定后,内核解析 `ctx.uiRenderer`,把现有容器交给 `mount()`
`@deepseek-ai/dsh-client-ui-renderer` 是带 `immediately` 标记的动态客户端插件。它持有 React slot outlet、SessionProvider 与 observable 到 uSES 的绑定。`slots``sessions``layout` 激活后,安装 slot 渲染器并提供 `ctx.uiRenderer``mount()` hydrate 内核生成的启动 DOM,再通过 layout effect 在浏览器绘制中间帧前将其替换为组装完成的应用。hydrate 后的 spinner 节点会保持动画相位。组装后的树投影当前会话标题,并执行唯一一次上下文级 `renderSlot('root')` 调用。服务、渲染器安装和 React 根都随各自持有方 dispose。
`@deepseek-ai/dsh-client-ui-renderer` 是带 `immediately` 标记的动态客户端插件。它持有 React slot outlet、SessionProvider 与 observable 到 uSES 的绑定。它注入的 `slots``sessions` 激活后,便安装 slot 渲染器并提供 `ctx.uiRenderer``mount()` hydrate 内核生成的启动 DOM,再通过 layout effect 在浏览器绘制中间帧前将其替换为组装完成的应用。hydrate 后的 spinner 节点会保持动画相位。组装后的树投影当前会话标题,并执行唯一一次上下文级 `renderSlot('root')` 调用。服务、渲染器安装和 React 根都随各自持有方 dispose。
`ui-conversation` 声明 `conversation.input.attachments``conversation.message.images`,并提供附件数据、回调、经会话授权的图片加载及其 locale seat。`ui-attachment` 通过 `ctx.slots.inject()` 等待这些声明,再注册草稿附件栏/拖放目标和历史图片画廊/灯箱。React 实现仍是包内值;跨插件组合通过 slot 完成。这项包集成决策取代[附件展示 Note](../feature/2026-08-11-web-attachment-display-alignment.md)中的直接导入规则,但不改变该 Note 的视觉与交互决策。
ui-theme 从客户端 entry 导入自己的五份全局样式表。共享客户端 bundle 预设会编译普通 CSS 与 CSS Modules,并在 bundle 物化时注入插件持有的 style 标签,因此卸载或重载 ui-theme 时,其全局 CSS 会随服务的同一生命周期删除或替换。Web 内核只保留挂载默认值,以及字体和颜色与对应主题 token 一致的自给自足启动页配色。
ui-theme 把自己的五份全局样式表作为 `?inline` 字符串导入。客户端 entry 调用 `installThemeStyles(ctx)`,经 `ctx.effect()` 为每份样式安装一个 style 标签,因此卸载或重载 ui-theme 时,其全局 CSS 会随服务的同一生命周期删除或替换。Web 内核只保留挂载默认值,以及字体和颜色与对应主题 token 一致的自给自足启动页配色。
React、React DOM、Cordis、ui-slots 与 ui-primitives 仍是保持单一浏览器身份的静态平台模块。动态 ui-renderer bundle 消费这些共享模块并持有渲染副作用。
## 验证
组件测试固定持久进度 spinner、hydrate 不改变启动 DOM、文档标题、应用树、附件 entry 与 dispose 行为。组装后的构建 bundle 启动测试会运行真实模块表与动态 entry,客户端 bundle CSS 测试则证明全局样式会编译成受监视、由插件持有的注入器。浏览器回放测试覆盖从不依赖框架的页面到渲染应用的完整交接。
组件测试固定持久进度 spinner、hydrate 不改变启动 DOM、文档标题、应用树、附件 entry 与 dispose 行为。组装后的构建 bundle 启动测试会运行真实模块表与动态 entry,theme 样式测试则证明这些标签会随插件 fiber 安装和释放。浏览器回放测试覆盖从不依赖框架的页面到渲染应用的完整交接。
## 备选方案
@@ -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-06-host-backed-web-preferences.md
2026-08-06-host-backed-web-preferences.md: cb3222e3edc9a5382b2c54eb9953b6b952b86b11
2026-08-06-host-backed-web-preferences.zh.md: 08208daa683b7cc67e3ad4efd9a79f15ef82c7a3
2026-08-06-host-backed-web-preferences.md: 5d90f2be7c8b4030e9bdc00eed2769491ec009e5
2026-08-06-host-backed-web-preferences.zh.md: c861c45bff299e06841165a2b36d0781e8f54d99
@@ -14,7 +14,7 @@ The first theme implementation moved only Appearance to Host settings but awaite
The owning Host halves register three schemas: optional `locale.preference` (`zh` or `en`, where absence delegates to the browser), `ui-theme.preference` (`light`, `dark`, or `system`, default `system`), and `ui-conversation.busyEnter` (`queue` or `steer`, default `queue`). The local settings provider stores explicit choices in `$DSH_HOME/settings.yaml`, which resolves to `~/.dsh/settings.yaml` under the default home. The API proxy explicitly exposes all three namespaces beside the other Web settings; registration alone never crosses that configuration boundary.
The client runtime provides one `bindSettingsScope` lifecycle per namespace the browser mirror of the Host-side settings owner seam. It installs `settings/changed` and `connection/reset` listeners before starting a background initial read, so no settings transport can block plugin activation and an invalidation cannot fall into a read-before-subscribe gap, and it publishes a snapshot store (status, section value, revision, writability, host/memory mode) the domain service subscribes to. The default decoder validates each incoming section against the namespace's own serialized wire schema, rehydrated through dsh-client-schema-form, so domains carry no hand-written wire guards. Domain services take the scope as an ordinary constructor collaborator, publish their provisional defaults immediately—browser-derived locale, system theme, and Queue—then adopt an accepted Host section without writing it back; a service constructed without a scope (standalone dictionary or policy fixtures) simply stays process-local.
`dsh-client-ui-settings` provides `ctx.settingsScope.bind(spec)`, which owns one lifecycle per namespace as the browser mirror of the Host-side settings owner seam. It installs `settings/document-updated` and `connection/reset` listeners before starting a background initial read, so no settings transport can block plugin activation and an invalidation cannot fall into a read-before-subscribe gap, and it publishes a snapshot store (status, section value, revision, writability, host/memory mode) the domain service subscribes to. The default decoder validates each incoming section against the namespace's own serialized wire schema, rehydrated through the colocated `ctx.settingsSchema` service, so domains carry no hand-written wire guards. Domain services take the scope as an ordinary constructor collaborator, publish their provisional defaults immediately—browser-derived locale, system theme, and Queue—then adopt an accepted Host section without writing it back; a service constructed without a scope (standalone dictionary or policy fixtures) simply stays process-local.
User changes update the live service synchronously and queue a `settings.mutate` path operation through `scope.set`. The scope serializes gestures, sends the latest known namespace revision as `expectedRevision`, records every successful revision, and lets only the latest write settlement republish live state. A rejected or failed latest write reloads Host state. Disposal rejects new work, skips queued operations, suppresses publication by the in-flight operation, and waits for that operation to settle before the plugin reaches quiescence.
@@ -14,7 +14,7 @@ Web 的 Appearance、Language 和繁忙态 Enter 偏好原本存在浏览器 `lo
各领域所属的 Host half 注册三份 schema:可选的 `locale.preference``zh``en`,缺失时交由浏览器决定)、`ui-theme.preference``light``dark``system`,默认为 `system`),以及 `ui-conversation.busyEnter``queue``steer`,默认为 `queue`)。本地 settings 提供方将显式选择存入 `$DSH_HOME/settings.yaml`,在使用默认 home 时,该路径解析为 `~/.dsh/settings.yaml`。API 代理会显式暴露这三个 namespace,与其他 Web settings 并列;仅注册它们,绝不会跨越该配置边界。
客户端运行时为每个 namespace 提供一份 `bindSettingsScope` 生命周期——即 Host 侧 settings owner seam 的浏览器镜像。它在开始后台初始读取之前安装 `settings/changed``connection/reset` 监听器,因此任何 settings 传输都不会阻塞插件激活,失效通知也不会掉入先读取、后订阅的空档;它还会发布一个供领域服务订阅的快照 store(状态、分节值、revision、可写性、host/内存模式)。默认解码器会对照该 namespace 自身的序列化 wire schema(经 dsh-client-schema-form 还原)校验每个传入分节,因此各领域无需携带手写的 wire 校验器。领域服务把 scope 当作普通的构造函数协作者接收,立即发布各自的暂定默认值:由浏览器派生的 locale、系统主题和 Queue;随后采纳已获接受的 Host 分节,但不将其写回;不带 scope 构造的服务——独立词典或政策 fixture(测试前置数据)——则仅停留在进程本地。
`dsh-client-ui-settings` 提供 `ctx.settingsScope.bind(spec)`,为每个 namespace 持有一份生命周期,作为 Host 侧 settings owner seam 的浏览器镜像。它在开始后台初始读取之前安装 `settings/document-updated``connection/reset` 监听器,因此任何 settings 传输都不会阻塞插件激活,失效通知也不会掉入先读取、后订阅的空档;它还会发布一个供领域服务订阅的快照 store(状态、分节值、revision、可写性、host/内存模式)。默认解码器会对照该 namespace 自身的序列化 wire schema(经同包的 `ctx.settingsSchema` 服务还原)校验每个传入分节,因此各领域无需携带手写的 wire 校验器。领域服务把 scope 当作普通的构造函数协作者接收,立即发布各自的暂定默认值:由浏览器派生的 locale、系统主题和 Queue;随后采纳已获接受的 Host 分节,但不将其写回;不带 scope 构造的服务——独立词典或政策 fixture(测试前置数据)——则仅停留在进程本地。
用户变更会同步更新实时服务,并经 `scope.set` 将一项 `settings.mutate` 路径操作排入队列。scope 会串行处理手势,以最新已知 namespace revision 作为 `expectedRevision` 发送,记录每次成功写入的 revision,并且只允许最新写入的结算结果重新发布实时状态。最新写入被拒或失败时,scope 会重新加载 Host 状态。插件释放会拒绝新工作、跳过已排队操作、抑制运行中操作发布状态,并等待该操作结算后才让插件达到完全停稳。
@@ -4,7 +4,8 @@
import { Context } from '@deepseek-ai/cordis'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
import { SettingsSchemaService, SettingsScopeBinder } from '@deepseek-ai/dsh-client-ui-settings/client'
import { SettingsSchemaService } from '@deepseek-ai/dsh-client-ui-settings/src/client/schema.ts'
import { SettingsScopeBinder } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-scope.ts'
import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
import {
apply, inject, SETTINGS_NS,
@@ -1,38 +1,46 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it } from 'vitest'
import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
import { apply as applyHost } from '../src/index.ts'
import { apply, inject } from '../src/client/index.ts'
import { ComposerAttachments } from '../src/client/ComposerAttachments.tsx'
import { MessageImages } from '../src/client/MessageImages.tsx'
async function bench() {
const ctx = new Context()
await ctx.plugin(SlotRegistry).await()
ctx.slots.register({
name: 'root',
children: {
'conversation.input.attachments': { kind: 'single', scope: 'session-maybe' },
'conversation.message.images': { kind: 'single', scope: 'session' },
},
} as never, () => null)
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
return { ctx, fiber }
}
describe('attachment plugin', () => {
it('keeps the host half empty', () => {
expect(() => { applyHost() }).not.toThrow()
})
it('registers the composer and historical-image slot entries', () => {
const registered: Array<{ spec: unknown; component: unknown }> = []
const register = vi.fn((spec: unknown, component: unknown) => {
registered.push({ spec, component })
return () => {}
})
const injectSlot = vi.fn((_name: string, setup: () => unknown) => setup())
apply({ slots: { inject: injectSlot, register } } as never)
it('registers both entries and removes them with the plugin fiber', async () => {
const { ctx, fiber } = await bench()
expect(inject).toEqual(['slots'])
expect(injectSlot.mock.calls.map(([name]) => name)).toEqual([
'conversation.input.attachments',
'conversation.message.images',
])
expect(registered).toEqual([
{
spec: { name: 'conversation.input.attachments', locale: 'conversation' },
component: ComposerAttachments,
},
{
spec: { name: 'conversation.message.images', locale: 'conversation' },
component: MessageImages,
},
])
expect(ctx.slots.entries('conversation.input.attachments')).toMatchObject([{
locale: 'conversation',
component: ComposerAttachments,
}])
expect(ctx.slots.entries('conversation.message.images')).toMatchObject([{
locale: 'conversation',
component: MessageImages,
}])
await fiber.dispose()
expect(ctx.slots.entries('conversation.input.attachments')).toHaveLength(0)
expect(ctx.slots.entries('conversation.message.images')).toHaveLength(0)
})
})
@@ -14,7 +14,7 @@ import { SlotRegistry, type SessionId } from '@deepseek-ai/dsh-client-runtime/cl
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
import type { CommandDecoration } from '@deepseek-ai/dsh-client-ui-commands/client'
import { SettingsSchemaService } from '@deepseek-ai/dsh-client-ui-settings/client'
import { SettingsSchemaService } from '@deepseek-ai/dsh-client-ui-settings/src/client/schema.ts'
import type { PermissionSelect } from '@deepseek-ai/dsh-permission-presets/client'
import {
PermissionRow, type PermissionRowInjected,
@@ -4,7 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
import type { SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client'
import { SettingsSchemaService } from '@deepseek-ai/dsh-client-ui-settings/client'
import { SettingsSchemaService } from '@deepseek-ai/dsh-client-ui-settings/src/client/schema.ts'
import { PermissionRow, type PermissionRowProps } from '../src/client/PermissionRow.tsx'
import { en } from '../src/client/locales.ts'
import { PermissionPresetSettingsController } from '../src/client/settings-store.ts'
@@ -1,7 +1,7 @@
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it, vi } from 'vitest'
import type { SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client'
import { SettingsSchemaService } from '@deepseek-ai/dsh-client-ui-settings/client'
import { SettingsSchemaService } from '@deepseek-ai/dsh-client-ui-settings/src/client/schema.ts'
import {
PermissionPresetSettingsController, permissionDefaultOf, refreshPermissionIfLoaded,
} from '../src/client/settings-store.ts'
@@ -11,8 +11,6 @@ import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import { createSlotRenderer } from './scoped-slots.tsx'
import { buildRenderApp } from './app.tsx'
export { bindSnapshotSelector } from './bind.ts'
/** Selector hook over a session's conversation snapshot. */
export type UseSession<Snap extends object = object> = SnapshotSelectorHook<Snap>
@@ -20,9 +18,7 @@ export type {
ChainRenderOpts, HostObservable, RenderOpts, SessionProvideInfo, SnapshotSelectorHook,
SlotRenderer, SlotRendererHost, StoreInstanceLike,
} from '@deepseek-ai/dsh-client-ui-slots'
export { SlotOwnershipError, StaleAuthorizationError } from '@deepseek-ai/dsh-client-ui-slots'
export { createSlotRenderer } from './scoped-slots.tsx'
export { SessionProvider, SlotAssemblyError, type SessionProviderProps } from './session-provider.tsx'
export type { SessionProviderProps } from './session-provider.tsx'
/** Mount operation exposed to the framework-free boot kernel. */
export interface UiRendererService {
@@ -2,7 +2,7 @@
import { StrictMode } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { act, render } from '@testing-library/react'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-ui-renderer/client'
import { bindSnapshotSelector } from '../src/client/bind.ts'
import type { HostObservable as ObservableSnapshot, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
// Keep equality local: this suite asserts the eq parameter contract without
@@ -9,8 +9,10 @@
*/
import { describe, expect, it, vi } from 'vitest'
import { act, render } from '@testing-library/react'
import { SlotCore, type PropsRenderSlots, type SlotRendererHost } from '@deepseek-ai/dsh-client-ui-slots'
import { createSlotRenderer, StaleAuthorizationError } from '@deepseek-ai/dsh-client-ui-renderer/client'
import {
SlotCore, StaleAuthorizationError, type PropsRenderSlots, type SlotRendererHost,
} from '@deepseek-ai/dsh-client-ui-slots'
import { createSlotRenderer } from '../src/client/scoped-slots.tsx'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
@@ -11,13 +11,16 @@
import { describe, expect, it, vi } from 'vitest'
import { act, fireEvent, render } from '@testing-library/react'
import { useEffect, useState, type ReactNode } from 'react'
import type { ActionsDecl, SlotEntryDef, SlotSpec, StoreHandle, StoredEntry } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionMaybeProvideInfo } from '@deepseek-ai/dsh-client-ui-slots'
import {
createSlotRenderer, SessionProvider, SlotOwnershipError, StaleAuthorizationError,
type RenderOpts, type SessionProvideInfo,
type SlotRendererHost, type StoreInstanceLike,
SlotOwnershipError, StaleAuthorizationError,
type ActionsDecl, type SlotEntryDef, type SlotSpec, type StoreHandle, type StoredEntry,
} from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionMaybeProvideInfo } from '@deepseek-ai/dsh-client-ui-slots'
import type {
RenderOpts, SessionProvideInfo, SlotRendererHost, StoreInstanceLike,
} from '@deepseek-ai/dsh-client-ui-renderer/client'
import { createSlotRenderer } from '../src/client/scoped-slots.tsx'
import { SessionProvider } from '../src/client/session-provider.tsx'
type AnyProps = Record<string, unknown>
type RenderSlotFn = (key: string, owner: object, opts?: RenderOpts) => ReactNode
@@ -10,10 +10,9 @@ import { useEffect, useRef } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { act, render } from '@testing-library/react'
import type { SessionMaybeProvideInfo, StoredEntry } from '@deepseek-ai/dsh-client-ui-slots'
import {
createSlotRenderer, SessionProvider,
type SessionProvideInfo, type SlotRendererHost,
} from '@deepseek-ai/dsh-client-ui-renderer/client'
import type { SessionProvideInfo, SlotRendererHost } from '@deepseek-ai/dsh-client-ui-renderer/client'
import { createSlotRenderer } from '../src/client/scoped-slots.tsx'
import { SessionProvider } from '../src/client/session-provider.tsx'
function observable<T>(initial: T) {
let value = initial
@@ -6,11 +6,11 @@
import { describe, expect, it } from 'vitest'
import { act, render } from '@testing-library/react'
import type { ReactNode } from 'react'
import type { SlotEntryDef, SlotSpec, StoredEntry } from '@deepseek-ai/dsh-client-ui-slots'
import {
createSlotRenderer, StaleAuthorizationError,
type RenderOpts, type SlotRendererHost,
} from '@deepseek-ai/dsh-client-ui-renderer/client'
StaleAuthorizationError, type SlotEntryDef, type SlotSpec, type StoredEntry,
} from '@deepseek-ai/dsh-client-ui-slots'
import type { RenderOpts, SlotRendererHost } from '@deepseek-ai/dsh-client-ui-renderer/client'
import { createSlotRenderer } from '../src/client/scoped-slots.tsx'
type RenderSlotFn = (key: string, owner: object, opts?: RenderOpts) => ReactNode
type DeclaredSpec = SlotSpec<SlotEntryDef>
@@ -11,7 +11,8 @@
import { describe, expect, it } from 'vitest'
import { act, render } from '@testing-library/react'
import type { SessionMaybeProvideInfo, StoredEntry } from '@deepseek-ai/dsh-client-ui-slots'
import { createSlotRenderer, type SlotRendererHost } from '@deepseek-ai/dsh-client-ui-renderer/client'
import type { SlotRendererHost } from '@deepseek-ai/dsh-client-ui-renderer/client'
import { createSlotRenderer } from '../src/client/scoped-slots.tsx'
function observable<T>(initial: T) {
let value = initial
@@ -5,7 +5,7 @@ import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
import { SettingsSchemaService } from '@deepseek-ai/dsh-client-ui-settings/client'
import { SettingsSchemaService } from '@deepseek-ai/dsh-client-ui-settings/src/client/schema.ts'
import { apply, inject, refreshIfLoaded } from '@deepseek-ai/dsh-client-ui-settings-models/client'
import { ModelsSection } from '../src/client/ModelsSection.tsx'
import { DeepSeekOnboardingDialog } from '../src/client/DeepSeekOnboardingDialog.tsx'
@@ -1,5 +1,5 @@
import { Context } from '@deepseek-ai/cordis'
import { SettingsSchemaService } from '@deepseek-ai/dsh-client-ui-settings/client'
import { SettingsSchemaService } from '@deepseek-ai/dsh-client-ui-settings/src/client/schema.ts'
import { createSettingsSchemaOperations } from '../src/client/schema-operations.ts'
/** Stateless schema operations used by settings-model component fixtures. */
@@ -6,7 +6,8 @@ import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
import { SettingsSchemaService, SettingsScopeBinder } from '@deepseek-ai/dsh-client-ui-settings/client'
import { SettingsSchemaService } from '@deepseek-ai/dsh-client-ui-settings/src/client/schema.ts'
import { SettingsScopeBinder } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-scope.ts'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-plugins/client'
import type {
ConfigurablePluginsTabFace, PluginsSettingsSectionInjected,
@@ -16,8 +16,8 @@ export type {
SettingsGeneralItemOwnerProps, SettingsHeaderOwnerProps, SettingsOnboardingOwnerProps,
SettingsPluginsTabOwnerProps, SettingsSectionOwnerProps, SettingsTriggerOwnerProps,
} from './contract/slots.ts'
export { SettingsScopeController, SettingsScopeBinder } from './settings-scope.ts'
export { SettingsSchemaService } from './schema.ts'
export type { SettingsScopeController, SettingsScopeBinder } from './settings-scope.ts'
export type { SettingsSchemaService } from './schema.ts'
export type { SchemaNode } from './schema.ts'
/**
@@ -55,7 +55,7 @@ export class SettingsScopeController<T> implements SettingsScope<T> {
constructor(
private readonly api: SettingsFace,
private readonly spec: SettingsScopeSpec<T>,
private readonly persistence: 'host' | 'memory' = 'host',
private readonly persistence: 'host' | 'memory',
private readonly schema: SettingsSchemaService,
) {
this.store = createSnapshotStore<SettingsScopeSnapshot<T>>({
@@ -5,7 +5,9 @@
*/
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it } from 'vitest'
import { apply, inject, SettingsSchemaService, SettingsScopeBinder } from '../src/client/index.ts'
import { apply, inject } from '../src/client/index.ts'
import { SettingsSchemaService } from '../src/client/schema.ts'
import { SettingsScopeBinder } from '../src/client/settings-scope.ts'
/** Boot the browser half over a bare root context; it injects nothing. */
function bench() {
@@ -6,7 +6,8 @@ import { describe, expect, it, vi } from 'vitest'
import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
import { SettingsSchemaService, SettingsScopeBinder } from '@deepseek-ai/dsh-client-ui-settings/client'
import { SettingsSchemaService } from '@deepseek-ai/dsh-client-ui-settings/src/client/schema.ts'
import { SettingsScopeBinder } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-scope.ts'
import { apply, inject, SETTINGS_NS } from '@deepseek-ai/dsh-client-ui-theme/client'
import type { AppearanceRowInjected, ThemeRuntime } from '@deepseek-ai/dsh-client-ui-theme/client'
import { THEME_SETTINGS_NAMESPACE, ThemeSettingsSchema } from '../src/theme-settings.ts'
@@ -25,7 +25,7 @@ import type { BoundFunctions } from '@testing-library/dom'
import {
ConversationEventRegistry, ConversationViewRegistry, SlotRegistry,
} from '@deepseek-ai/dsh-client-runtime/client'
import { createSlotRenderer } from '@deepseek-ai/dsh-client-ui-renderer/client'
import { createSlotRenderer } from '@deepseek-ai/dsh-client-ui-renderer/src/client/scoped-slots.tsx'
import type {
ChildrenDecl, ComposedProps, OwnerOf, SlotComponent, SlotMap, SlotRendererHost, StoreInstanceLike,
} from '@deepseek-ai/dsh-client-ui-slots'
@@ -34,7 +34,8 @@ import { TestSessions } from './sessions.ts'
import { TestWorkspaces } from './workspaces.ts'
import type { Stabilizer } from './fixtures.ts'
export { bindSnapshotSelector, createSlotRenderer } from '@deepseek-ai/dsh-client-ui-renderer/client'
export { bindSnapshotSelector } from '@deepseek-ai/dsh-client-ui-renderer/src/client/bind.ts'
export { createSlotRenderer } from '@deepseek-ai/dsh-client-ui-renderer/src/client/scoped-slots.tsx'
export type { UseSession } from '@deepseek-ai/dsh-client-ui-renderer/client'
export { domSnapshotSerializer, registerDomSnapshotSerializer } from './snapshot.ts'
export { FixtureSession, TestSessions } from './sessions.ts'
@@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest'
import { resolveClientImport } from './verify-client-domain-graph.ts'
describe('client domain import resolution', () => {
it('preserves imports that leave src/client from a top-level file', () => {
expect(resolveClientImport('styles.ts', '../styles/base.css?inline'))
.toBe('../styles/base.css?inline')
})
it('normalizes imports between domains inside src/client', () => {
expect(resolveClientImport('input/hub.ts', '../queue/store.ts'))
.toBe('queue/store.ts')
})
})
+33 -27
View File
@@ -15,7 +15,7 @@
*/
import { globSync, readdirSync, readFileSync, statSync } from 'node:fs'
import { join, resolve, sep } from 'node:path'
import { join, posix, resolve, sep } from 'node:path'
const root = resolve(import.meta.dirname, '..')
const CLIENT_DIR = join(root, 'packages/client')
@@ -41,6 +41,16 @@ function domainOf(rel: string): string {
return ix === -1 ? '' : rel.slice(0, ix)
}
/**
* Resolve one relative import to a client-directory-relative path.
* @param file - Importing file relative to `src/client`.
* @param specifier - Relative module specifier from that file.
* @returns Normalized path, preserving leading `..` segments outside `src/client`.
*/
export function resolveClientImport(file: string, specifier: string): string {
return posix.normalize(posix.join(posix.dirname(file), specifier))
}
function checkPackage(pkgName: string, clientDir: string): Violation[] {
const violations: Violation[] = []
const files = listSources(clientDir)
@@ -52,17 +62,8 @@ function checkPackage(pkgName: string, clientDir: string): Violation[] {
for (const match of source.matchAll(/from\s+['"](\.[^'"]+)['"]/g)) {
const spec = match[1]
if (spec === undefined) continue
// Resolve the relative specifier against the importing file's directory
// to a client-dir-relative path.
const fromDir = rel.includes('/') ? rel.slice(0, rel.lastIndexOf('/')) : ''
const parts = (fromDir ? fromDir.split('/') : [])
for (const seg of spec.split('/')) {
if (seg === '.') continue
if (seg === '..') parts.pop()
else parts.push(seg)
}
const target = parts.join('/')
if (target.startsWith('..')) continue // out of client dir (package root) — package-level rules govern
const target = resolveClientImport(rel, spec)
if (target === '..' || target.startsWith('../')) continue // package-level rules govern
const toDomain = domainOf(target)
if (toDomain === '' || CONTRACT_DIRS.has(toDomain)) continue // top-level shared file or contract layer
if (fromDomain === toDomain) continue // inside one domain
@@ -78,21 +79,26 @@ function checkPackage(pkgName: string, clientDir: string): Violation[] {
return violations
}
const violations: Violation[] = []
for (const pkg of readdirSync(CLIENT_DIR)) {
const clientDir = join(CLIENT_DIR, pkg, 'src/client')
try {
if (!statSync(clientDir).isDirectory()) continue
} catch {
// No client half in this package — nothing to layer-check.
continue
function main(): void {
const violations: Violation[] = []
for (const pkg of readdirSync(CLIENT_DIR)) {
const clientDir = join(CLIENT_DIR, pkg, 'src/client')
try {
if (!statSync(clientDir).isDirectory()) continue
} catch {
// No client half in this package — nothing to layer-check.
continue
}
violations.push(...checkPackage(pkg, clientDir))
}
violations.push(...checkPackage(pkg, clientDir))
if (violations.length > 0) {
console.error(`verify-client-domain-graph: ${violations.length} violation(s):`)
for (const v of violations) console.error(` ${v.file} -> ${v.imported}\n ${v.reason}`)
process.exitCode = 1
return
}
console.log('verify-client-domain-graph: client domain layering clean.')
}
if (violations.length > 0) {
console.error(`verify-client-domain-graph: ${violations.length} violation(s):`)
for (const v of violations) console.error(` ${v.file} -> ${v.imported}\n ${v.reason}`)
process.exit(1)
}
console.log('verify-client-domain-graph: client domain layering clean.')
if (import.meta.filename === resolve(process.argv[1] ?? '')) main()