From e31b7221e726f6e93c6462a1e46dddd122e19816 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 4 Aug 2026 16:33:35 +0800 Subject: [PATCH 1/3] feat(web): open the local settings file --- .../2026-07-30-web-config-plane.i18n.yaml | 4 +- .../2026-07-30-web-config-plane.md | 9 +- .../2026-07-30-web-config-plane.zh.md | 9 +- apps/web/tests/settings-chrome.e2e.ts | 24 ++++ .../settings-chrome/dialog.expected.md | 1 + docs/cordis-catalog/services.md | 8 ++ packages/client/connection/README.i18n.yaml | 4 +- packages/client/connection/README.md | 2 +- packages/client/connection/README.zh.md | 2 +- .../client/connection/src/client/fixture.ts | 3 + packages/client/connection/src/index.ts | 1 + packages/client/connection/tests/fake-api.ts | 1 + .../client/connection/tests/node-half.spec.ts | 4 +- packages/client/runtime/tests/fake-api.ts | 1 + .../ui-settings-general/README.i18n.yaml | 4 +- packages/client/ui-settings-general/README.md | 4 +- .../client/ui-settings-general/README.zh.md | 4 +- .../client/SettingsDocumentAction.module.css | 16 +++ .../src/client/SettingsDocumentAction.tsx | 50 +++++++ .../ui-settings-general/src/client/index.ts | 32 ++++- .../ui-settings-general/src/client/locales.ts | 4 + .../src/client/settings-document-store.ts | 87 ++++++++++++ .../ui-settings-general/src/invariant.ts | 5 +- .../ui-settings-general/tests/apply.spec.ts | 29 +++- .../tests/components.spec.tsx | 82 ++++++++++- .../tests/settings-document-store.spec.ts | 132 ++++++++++++++++++ packages/client/ui-settings/README.i18n.yaml | 4 +- packages/client/ui-settings/README.md | 2 +- packages/client/ui-settings/README.zh.md | 2 +- .../src/client/SettingsRoot.module.css | 12 +- .../ui-settings/src/client/SettingsRoot.tsx | 1 + .../ui-settings/src/client/contract/slots.ts | 17 ++- .../client/ui-settings/src/client/index.ts | 1 + .../client/ui-settings/tests/apply.spec.ts | 7 +- .../ui-settings/tests/settings-root.spec.tsx | 8 ++ .../cordis/tool-cordis/src/api-catalog.ts | 4 + packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- packages/host/apiproxy/src/api-proxy.ts | 110 ++++++++++++--- packages/host/apiproxy/src/api/rpc-map.ts | 1 + .../host/apiproxy/src/api/settings.schema.ts | 9 ++ packages/host/apiproxy/src/api/settings.ts | 23 ++- packages/host/apiproxy/src/fetch/client.ts | 6 +- packages/host/apiproxy/src/fetch/handler.ts | 9 +- .../host/apiproxy/src/native-path-opener.ts | 46 ++++-- .../apiproxy/tests/api-proxy-config.spec.ts | 59 +++++++- .../apiproxy/tests/client-handler.spec.ts | 11 +- .../host/apiproxy/tests/fetch-carrier.spec.ts | 3 + .../apiproxy/tests/native-path-opener.spec.ts | 14 +- .../settings/settings-local/README.i18n.yaml | 4 +- packages/settings/settings-local/README.md | 1 + packages/settings/settings-local/README.zh.md | 1 + packages/settings/settings-local/src/index.ts | 30 +++- .../settings-local/tests/local.spec.ts | 28 +++- .../settings-local/tests/lock-race.spec.ts | 53 +++++++ packages/settings/settings/README.i18n.yaml | 4 +- packages/settings/settings/README.md | 4 +- packages/settings/settings/README.zh.md | 4 +- packages/settings/settings/src/index.ts | 21 +++ .../settings/settings/tests/settings.spec.ts | 8 ++ 61 files changed, 939 insertions(+), 98 deletions(-) create mode 100644 packages/client/ui-settings-general/src/client/SettingsDocumentAction.module.css create mode 100644 packages/client/ui-settings-general/src/client/SettingsDocumentAction.tsx create mode 100644 packages/client/ui-settings-general/src/client/settings-document-store.ts create mode 100644 packages/client/ui-settings-general/tests/settings-document-store.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml index fedfc7e489..c453ef0502 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-web-config-plane.md -2026-07-30-web-config-plane.md: 6d1a8c242c1888ee4fca9e21ebc814f7a345d633 -2026-07-30-web-config-plane.zh.md: c3255cacfdd1f06d12f7bb2631f95273536b7ef9 +2026-07-30-web-config-plane.md: 399684c782db86213ac7a0944e6e0bfe19145cea +2026-07-30-web-config-plane.zh.md: de4be026d7431e058e9ae0a6e48bb0a4aba769a7 diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md index 6d1a8c242c..399684c782 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md @@ -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 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 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. ## Problem @@ -12,10 +12,12 @@ PR1 made LLM adapter configuration restart-free at the seam, but the only writer ## Decision -**Wire domains on the compiled RPC map, rejections as codes, invalidations as frames.** `settings.describe/update/replace`, `credentials.describe/set/unset`, `llm.providers`, and `llm.models` (claiming the reserved `host.listModels` surface) join `RpcMethodMap`, so the seven compiler-locked wiring sites keep contract, schema, handler, and client in lockstep. Seam rejections fold into `settings-rejected {ns}` / `credential-rejected {ref}` business errors (HTTP stays a carrier), and three `HostFrame`s — `host/settings-changed {ns}`, `host/credentials-changed {ref}`, `host/models-changed` — follow the `host/commands-changed` shape so every client converges without polling. Writes join `pickDirectory`/`openPath` in the connection guard's privileged set: loopback + same-origin or 403, because a LAN-exposed dsh web must not accept config mutation from another origin. +**Wire domains on the compiled RPC map, rejections as codes, invalidations as frames.** `settings.describe/openDocument/update/replace/mutate`, `credentials.describe/set/unset`, `llm.providers`, and `llm.models` (claiming the reserved `host.listModels` surface) join `RpcMethodMap`, so the seven compiler-locked wiring sites keep contract, schema, handler, and client in lockstep. Seam rejections fold into `settings-rejected {ns}` / `credential-rejected {ref}` business errors (HTTP stays a carrier), and three `HostFrame`s — `host/settings-changed {ns}`, `host/credentials-changed {ref}`, `host/models-changed` — follow the `host/commands-changed` shape so every client converges without polling. Settings reads, native actions, and writes join `pickDirectory`/`openPath` in the connection guard's privileged set: loopback + same-origin or 403, because a LAN-exposed dsh web must not accept configuration access from another origin. **`describe()` grows layers and structural secret redaction.** `SettingsDescriptor` carries `base`/`user` beside the effective value, so the form marks "overridden" by presence in the user layer, not value inequality (an override *equal* to the base is still an override). `describe({ redactSecrets: true })` — mandatory at every wire face — strips `role('secret')` subtrees from all three layers via a pure structural walk of the schema (object/dict/array containers; a secret-role subtree is one opaque leaf) and enumerates the stripped slots as `{path, set}`, so a page can render write-only inputs without ever receiving a value. +**The Host identifies and opens the local settings document.** The settings seam exposes optional `documentPath` provider metadata and a `prepareDocument()` operation; `settings-local` returns its fully resolved custom or `$DSH_HOME/settings.yaml` filename and exclusively creates an absent empty document with owner-only permissions, while non-file providers retain the base `undefined`. The loopback-only `settings.describe` response carries the path as availability metadata beside the redacted namespace views. `ui-settings-general` registers a `settings.action` entry only on loopback pages, shows it only after the metadata confirms a file, and invokes pathless `settings.openDocument`; the Host resolves the provider path again before a text-document handoff (`open -t` on macOS so an arbitrary YAML file association cannot redirect the gesture, `xdg-open` on Linux, and `Invoke-Item` on Windows). Generic workspace paths retain the existing default-application handoff. The browser neither derives `$DSH_HOME` nor receives a filesystem-target parameter; remote pages make no privileged settings read for this action. + **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 user chose the hand-written direction over adding a hint/grouping system, and a second round 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, plus `reasoningEffort` for deepseek / `reasoning` for pi-ai), with every other field owned by `settings.yaml`. Validation still runs the rehydrated schema before writing, so a hand-coded field that drifts from its schema fails loud on save rather than silently. @@ -30,7 +32,8 @@ PR1 made LLM adapter configuration restart-free at the seam, but the only writer - **Storing the typed key as a literal `apiKey` setting** — the v1 "one API key input" requirement could have written the literal into the profile, but every UI removal path rebuilds the user section from the *redacted* layers, so any reset or row deletion would silently drop stored sibling keys; deriving a reference keeps the input single-field while keeping `settings.yaml` secret-free and every replace safe. - **A `models` bridge plugin owning provider configuration** — same rejection as PR1: per-plugin namespaces plus a four-field directory declaration give the UI everything it needs; the bridge's unified dict re-imports the adapter-mapping indirection. - **Page-side polling instead of pushed frames** — the mux already carries `host/commands-changed`; three more frames cost one shape each and make a second tab, an external `settings.yaml` edit, and a settings-born route converge at event speed. +- **Hard-coding `$DSH_HOME/settings.yaml` or returning `documentPath` through `host.openPath` in the browser** — rejected because `settings-local.path` may select another YAML/JSON document, non-file providers have no Host path, and a general path request makes the browser the authority for a local filesystem target. Provider preparation is the authoritative source, and the Host-owned operation feeds the existing opener. ## Consequences -The whole loop is pinned keyless in the browser lane (`apps/web/tests/models-settings.e2e.ts`): the add card offers the dormant pi-ai catalog, adding `minimax-cn` with a typed key writes the reference-only profile into `settings.yaml`, stores the value into the harness home's `.env` under the derived `MINIMAX_CN_API_KEY`, registers the route live on the topology frame, and the customized fold merges `reasoning` beside the reference — zero model calls, ARIA goldens for the add-card, configured, and delete-confirmation states, plus a scaffold `harnessHome` so tests never touch a real `~/.dsh` (the provider under test is one whose derived reference cannot collide with a developer's exported keys). The removal scenario proves cancellation leaves the profile intact, confirmation removes it, and the intentionally retained credential survives. The rename touched 239 files (fixtures, goldens, docs, python) in one commit with no compatibility alias. The renderer replacement cost one commit and no wire change: apply semantics, redaction, and the directory join were renderer-agnostic all along. Deferred: a per-row models preview (the picker already lists models), a page address for live routes that never declared configurability, and explicit removal of a provider's retained credential. +The whole loop is pinned keyless in the browser lane (`apps/web/tests/models-settings.e2e.ts`): the add card offers the dormant pi-ai catalog, adding `minimax-cn` with a typed key writes the reference-only profile into `settings.yaml`, stores the value into the harness home's `.env` under the derived `MINIMAX_CN_API_KEY`, registers the route live on the topology frame, and the customized fold merges `reasoning` beside the reference — zero model calls, ARIA goldens for the add-card, configured, and delete-confirmation states, plus a scaffold `harnessHome` so tests never touch a real `~/.dsh` (the provider under test is one whose derived reference cannot collide with a developer's exported keys). The settings-shell scenario intercepts the pathless native intent; seam, provider, wire, React, and native-opener tests separately pin provider absence, custom-path resolution, absent-file materialization, owner-only permissions, hidden remote/unavailable states, duplicate-click collapse, localized failure, macOS text-editor dispatch, and Linux/Windows desktop dispatch. The removal scenario proves cancellation leaves the profile intact, confirmation removes it, and the intentionally retained credential survives. The rename touched 239 files (fixtures, goldens, docs, python) in one commit with no compatibility alias. The renderer replacement cost one commit and no wire change: apply semantics, redaction, and the directory join were renderer-agnostic all along. Deferred: a per-row models preview (the picker already lists models), a page address for live routes that never declared configurability, and explicit removal of a provider's retained credential. diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md index c3255cacfd..de4be026d7 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.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-schema-form` 模型层,以及带手写提供方编辑器的 Models 设置页。`deepseek` → `deepseek-official` 提供方路由重命名作为解锁前提的破坏性变更一并搭车合入。 ## 问题 @@ -12,10 +12,12 @@ PR1 让 LLM(大语言模型)适配器配置在 seam 层面免重启,但唯 ## 决策 -**wire 领域挂上编译期 RPC 映射,拒绝落为错误码,失效落为帧。**`settings.describe/update/replace`、`credentials.describe/set/unset`、`llm.providers` 与 `llm.models`(认领预留的 `host.listModels` 面)一同加入 `RpcMethodMap`,七处由编译器锁定的接线位点因此让契约、schema、处理器与客户端保持步调一致。seam 侧的拒绝折叠为 `settings-rejected {ns}`/`credential-rejected {ref}` 业务错误(HTTP 仍只是载体),三个 `HostFrame`——`host/settings-changed {ns}`、`host/credentials-changed {ref}`、`host/models-changed`——沿用 `host/commands-changed` 的形状,因此每个客户端都无需轮询即可收敛。写入与 `pickDirectory`/`openPath` 一起进入连接守卫的特权集合:回环 + 同源,否则 403,因为暴露在局域网上的 dsh web 绝不能接受来自其他源的配置修改。 +**wire 领域挂上编译期 RPC 映射,拒绝落为错误码,失效落为帧。**`settings.describe/openDocument/update/replace/mutate`、`credentials.describe/set/unset`、`llm.providers` 与 `llm.models`(认领预留的 `host.listModels` 面)一同加入 `RpcMethodMap`,七处由编译器锁定的接线位点因此让契约、schema、处理器与客户端保持步调一致。seam 侧的拒绝折叠为 `settings-rejected {ns}`/`credential-rejected {ref}` 业务错误(HTTP 仍只是载体),三个 `HostFrame`——`host/settings-changed {ns}`、`host/credentials-changed {ref}`、`host/models-changed`——沿用 `host/commands-changed` 的形状,因此每个客户端都无需轮询即可收敛。settings 读取、原生操作与写入和 `pickDirectory`/`openPath` 一起进入连接守卫的特权集合:回环 + 同源,否则 403,因为暴露在局域网上的 dsh web 绝不能接受来自其他源的配置访问。 **`describe()` 增加分层与结构化 secret 脱敏。**`SettingsDescriptor` 在生效值之外携带 `base`/`user`,表单据此按「字段是否出现在用户层」来标记「已覆盖」,而非按值是否不等(与 base *相等*的覆盖仍然是覆盖)。`describe({ redactSecrets: true })`——在每个 wire 面都强制启用——经由对 schema 的纯结构遍历(object/dict/array 容器;secret 角色子树整体是一个不透明叶节点)从全部三层剥除 `role('secret')` 子树,并把剥除的槽位枚举为 `{path, set}`,页面因此不必收到任何值就能渲染只写输入框。 +**Host 识别并打开本地设置文档。** settings seam 暴露可选的 `documentPath` 提供方元数据和 `prepareDocument()` 操作;`settings-local` 返回已完全解析的自定义文件名或 `$DSH_HOME/settings.yaml` 文件名,并在文档缺失时以仅属主可访问的权限独占创建空文档,非文件提供方则保留基类的 `undefined`。仅限回环访问的 `settings.describe` 响应会在脱敏 namespace 视图旁把该路径作为可用性元数据携带。`ui-settings-general` 只在回环页面注册一条 `settings.action` 条目,只有元数据确认存在文件后才显示,并调用无路径参数的 `settings.openDocument`;Host 会在文本文档交接前再次解析提供方路径(macOS 上使用 `open -t`,使任意 YAML 文件关联无法重定向这次操作;Linux 上使用 `xdg-open`;Windows 上使用 `Invoke-Item`)。通用 Workspace 路径仍保留现有的默认应用交接。浏览器既不推导 `$DSH_HOME`,也不会收到文件系统目标参数;远程页面不会为这项操作发起特权 settings 读取。 + **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`),其余每个字段都归 `settings.yaml` 所有。校验仍会在写入前运行还原出的 schema,因此偏离其 schema 的手写字段会在保存时大声失败,而非静默失败。 @@ -30,7 +32,8 @@ PR1 让 LLM(大语言模型)适配器配置在 seam 层面免重启,但唯 - **把键入的密钥存成字面 `apiKey` 设置**——v1「单个 API 密钥输入框」的需求本可以把字面量直接写进 profile,但 UI 的每条删除路径都会从*脱敏后的*各层重建用户分节,任何重置或整行删除都会静默丢掉已存储的兄弟密钥;派生引用让输入保持单字段,同时让 `settings.yaml` 不含机密、每一次 replace 都安全。 - **由 `models` 桥接插件持有提供方配置**——与 PR1 相同的否决理由:按插件划分的 namespace 加上四字段的目录声明已经给了 UI 需要的一切;桥接层的统一字典会把适配器映射那层间接重新引进来。 - **页面侧轮询而非推送帧**——mux 已经承载 `host/commands-changed`;再加三个帧各自只多一个形状的成本,就让第二个标签页、外部的 `settings.yaml` 编辑和由设置催生的路由都以事件速度收敛。 +- **在浏览器中硬编码 `$DSH_HOME/settings.yaml`,或经 `host.openPath` 回传 `documentPath`**——否决,因为 `settings-local.path` 可能选择另一份 YAML/JSON 文档、非文件提供方没有 Host 路径,而且通用路径请求会让浏览器成为本地文件系统目标的权威。提供方的准备操作才是权威来源,由 Host 持有的操作会把结果交给现有打开器。 ## 后果 -整条闭环以无密钥方式固定在浏览器测试通道(`apps/web/tests/models-settings.e2e.ts`):「新增」卡片提供休眠的 pi-ai catalog,携键入的密钥添加 `minimax-cn` 会把只含引用的 profile 写入 `settings.yaml`、把密钥值存入 harness 家目录 `.env` 中派生的 `MINIMAX_CN_API_KEY` 之下、路由随拓扑帧注册为存活,「自定义设置」折叠区则把 `reasoning` 合并到引用旁边——全程零模型调用,「新增」卡片态、已配置态与删除确认态各有 ARIA golden,另有脚手架式的 `harnessHome`,测试绝不触碰真实的 `~/.dsh`(受测提供方是派生引用不可能与开发者已导出密钥相撞的那一个)。删除场景证明:取消后 profile 保持原样,确认后会将其删除,而刻意保留的凭据依然存在。这次重命名在一次提交中触及 239 个文件(fixture(测试前置数据)、golden、文档、python),未保留兼容别名。替换渲染器只花了一次提交,且没有任何 wire 变更:应用语义、脱敏与目录联接从一开始就与渲染器无关。延后事项:每行的模型预览(选择器已能列出模型)、为从未声明可配置性的存活路由提供页面地址,以及显式删除提供方所保留的凭据。 +整条闭环以无密钥方式固定在浏览器测试通道(`apps/web/tests/models-settings.e2e.ts`):「新增」卡片提供休眠的 pi-ai catalog,携键入的密钥添加 `minimax-cn` 会把只含引用的 profile 写入 `settings.yaml`、把密钥值存入 harness 家目录 `.env` 中派生的 `MINIMAX_CN_API_KEY` 之下、路由随拓扑帧注册为存活,「自定义设置」折叠区则把 `reasoning` 合并到引用旁边——全程零模型调用,「新增」卡片态、已配置态与删除确认态各有 ARIA golden,另有脚手架式的 `harnessHome`,测试绝不触碰真实的 `~/.dsh`(受测提供方是派生引用不可能与开发者已导出密钥相撞的那一个)。设置外壳场景会截获无路径参数的原生意图;seam、提供方、wire、React 与原生打开器测试分别固定了提供方缺失、自定义路径解析、缺失文件创建、仅属主权限、远程/不可用时隐藏、重复点击合并、本地化失败、macOS 文本编辑器分发,以及 Linux/Windows 桌面分发。删除场景证明:取消后 profile 保持原样,确认后会将其删除,而刻意保留的凭据依然存在。这次重命名在一次提交中触及 239 个文件(fixture(测试前置数据)、golden、文档、python),未保留兼容别名。替换渲染器只花了一次提交,且没有任何 wire 变更:应用语义、脱敏与目录联接从一开始就与渲染器无关。延后事项:每行的模型预览(选择器已能列出模型)、为从未声明可配置性的存活路由提供页面地址,以及显式删除提供方所保留的凭据。 diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index 19e1b33538..43500585d8 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -61,6 +61,30 @@ describe('web e2e: settings modal and General preferences', () => { await dialog.getByRole('button', { name: 'Workspace Write' }).waitFor({ timeout: 10_000 }) await expect.poll(() => dialog.getByText('语言', { exact: true }).count(), { timeout: 5_000 }).toBe(1) await expect.poll(() => dialog.getByText('外观', { exact: true }).count(), { timeout: 5_000 }).toBe(1) + const openDocument = dialog.getByRole('button', { name: '打开配置文件' }) + await openDocument.waitFor({ timeout: 10_000 }) + let openRequests = 0 + await page.route('**/api/settings.openDocument', async (route) => { + const envelope = route.request().postDataJSON() as { + rpcId: string + payload: Record + } + expect(envelope.payload).toEqual({}) + openRequests += 1 + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + type: 'server-response', + rpcId: envelope.rpcId, + result: { ok: true, value: { opened: true } }, + }), + }) + }) + await openDocument.click() + await expect.poll(() => openRequests, { timeout: 5_000 }).toBe(1) + await expect.poll(() => openDocument.isEnabled(), { timeout: 5_000 }).toBe(true) + await page.unroute('**/api/settings.openDocument') // Golden of the freshly opened dialog (default zh, General active). const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) await compareOrRefreshGolden(DIALOG_EXPECTED, snapshot, MODE) diff --git a/apps/web/tests/snapshots/settings-chrome/dialog.expected.md b/apps/web/tests/snapshots/settings-chrome/dialog.expected.md index cb693b62a8..f358ff26f5 100644 --- a/apps/web/tests/snapshots/settings-chrome/dialog.expected.md +++ b/apps/web/tests/snapshots/settings-chrome/dialog.expected.md @@ -7,6 +7,7 @@ - button "模型": - img - text: 模型 + - button "打开配置文件" - button "关闭": - img - text: 关闭 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index a713efa09c..40b6498a75 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1727,6 +1727,14 @@ Source: [`packages/session-title/session-title/src/index.ts:261`](../../packages Abstract settings service. Providers implement raw-document storage (`load`/`persist`) and push external changes through Settings.publish; the base class owns namespace registration, resolution, validation, change detection, and the `settings/updated` commit event. ```ts cordis-catalog +/** + * Prepare the provider's user-editable document for a native editor. File + * providers may materialize an absent document before returning its path; + * non-file providers return undefined. + * @returns the absolute local document path, or undefined for non-file storage. + */ +prepareDocument(): Promise + /** * Register a namespace schema and receive its owner scope. The registration * is an effect on the calling plugin's fiber: disposing that fiber removes diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index a636d8bc49..bb48dfea60 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/connection/README.md -README.md: f537fee3273e3b5d2411197cf1a1a6e0d34af5f9 -README.zh.md: a29d2c00e7df3f6290a03ffdad59b70b43702aca +README.md: 33ef3431a2a0a6c8c0f68e040da190741fb482a6 +README.zh.md: 2b6280dd957a809d61d12601cdca5af2eccbcdab diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index f537fee327..33ef3431a2 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -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 + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. Loopback hostname classification stays package-internal: the `/api` Host fence uses 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`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`, reads included, since describing returns the exposed configuration and probing an arbitrary reference reports where a credential comes from) 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 subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3. +Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. Loopback hostname classification stays package-internal: the `/api` Host fence uses 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) 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 subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3. ## /api browser-trust fence diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index a29d2c00e7..2b6280dd95 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`,读取也在内,因为 describe 会返回已暴露的配置,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。契约:api-contracts v3 §3。 +协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`,读取与原生操作也在内,因为 describe 会返回已暴露的配置、打开操作会作用于 Host 桌面,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。契约:api-contracts v3 §3。 ## /api 浏览器信任栅栏 diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index dff0069b87..4816ab70a5 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -2390,6 +2390,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { // editor; real schema-driven forms ride the HTTP transport. describe: request => ok(request, { writable: true, + documentPath: `${FIXTURE_HOME}/settings.yaml`, namespaces: [{ ns: 'llm-deepseek', schema: {}, @@ -2399,6 +2400,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { revision: 0, }], }), + openDocument: request => ok(request, { opened: true as const }), update: request => err(request, { code: 'settings-rejected', message: 'fixture: the minimal readiness settings descriptor is read-only', @@ -2549,6 +2551,7 @@ export class FixtureApiClient extends AbstractApiClient { case 'goal.complete': return this.api.goals.complete(request) case 'goal.clear': return this.api.goals.clear(request) case 'settings.describe': return this.api.settings.describe(request) + case 'settings.openDocument': return this.api.settings.openDocument(request, signal) case 'settings.update': return this.api.settings.update(request) case 'settings.replace': return this.api.settings.replace(request) case 'settings.mutate': return this.api.settings.mutate(request) diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index ed4af2d21f..5780518281 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -52,6 +52,7 @@ const PRIVILEGED_METHODS = new Set([ 'host.pickDirectory', 'host.openPath', 'settings.describe', + 'settings.openDocument', 'settings.update', 'settings.replace', 'settings.mutate', diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index 88708f0625..c70a8a7ad0 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -182,6 +182,7 @@ export class FakeApiClient implements IApiClient { readonly settings: IApiClient['settings'] = { describe: payload => this.record('settings.describe', payload, Promise.resolve(ok({ writable: true, namespaces: [] }))), + openDocument: payload => this.record('settings.openDocument', payload, Promise.resolve(ok({ opened: true as const }))), update: payload => this.record('settings.update', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))), replace: payload => this.record('settings.replace', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))), mutate: payload => this.record('settings.mutate', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))), diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.spec.ts index 08c65de2ba..de7d613f8e 100644 --- a/packages/client/connection/tests/node-half.spec.ts +++ b/packages/client/connection/tests/node-half.spec.ts @@ -93,7 +93,7 @@ describe('connection node half', () => { // passed), but each privileged method stays loopback-only and 403s. for (const method of [ 'host.pickDirectory', 'host.openPath', - 'settings.describe', 'settings.update', 'settings.replace', 'settings.mutate', + 'settings.describe', 'settings.openDocument', 'settings.update', 'settings.replace', 'settings.mutate', 'credentials.describe', 'credentials.set', 'credentials.unset', ]) { const denied = fakeResponse() @@ -177,7 +177,7 @@ describe('connection node half over a real HTTP server', () => { // Reads are as privileged as writes: describe returns the exposed // configuration, and credentials.describe probes arbitrary env-var names. for (const method of [ - 'settings.describe', 'settings.update', 'settings.replace', 'settings.mutate', + 'settings.describe', 'settings.openDocument', 'settings.update', 'settings.replace', 'settings.mutate', 'credentials.describe', 'credentials.set', 'credentials.unset', 'host.pickDirectory', 'host.openPath', ]) { diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index 3c0b86fec8..d2b5c13a5d 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -217,6 +217,7 @@ export class FakeApiClient implements IApiClient { readonly settings: IApiClient['settings'] = { describe: payload => this.record('settings.describe', payload, Promise.resolve(ok({ writable: true, namespaces: [] }))), + openDocument: payload => this.record('settings.openDocument', payload, Promise.resolve(ok({ opened: true as const }))), update: payload => this.record('settings.update', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))), replace: payload => this.record('settings.replace', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))), mutate: payload => this.record('settings.mutate', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))), diff --git a/packages/client/ui-settings-general/README.i18n.yaml b/packages/client/ui-settings-general/README.i18n.yaml index b14bd3ded3..a4b757d7fe 100644 --- a/packages/client/ui-settings-general/README.i18n.yaml +++ b/packages/client/ui-settings-general/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-settings-general/README.md -README.md: 0202d596f509feeba39a38254e8bab2fae27b649 -README.zh.md: adec73edda00d34e209772f0bcc54a994f593997 +README.md: d9aeaa37b3953f586821871118b009abd72b896d +README.zh.md: 96666e2d4d2087ca9579a1bcdd6a00ccfdaaa90d diff --git a/packages/client/ui-settings-general/README.md b/packages/client/ui-settings-general/README.md index 0202d596f5..d9aeaa37b3 100644 --- a/packages/client/ui-settings-general/README.md +++ b/packages/client/ui-settings-general/README.md @@ -2,7 +2,9 @@ English | [中文](README.zh.md) -Settings ownerless-copy and product-onboarding plugin: registers everything on the Settings surface that belongs to no single feature — the shell's trigger/header/close chrome content, the General section and its `settings.general.item` slot, the `settings` dictionaries, and the first ordered welcome step. Feature-owned rows (Permission, Language, Appearance), sections (Models), and conditional onboarding steps stay with their feature packages. +Settings ownerless-copy and product-onboarding plugin: registers everything on the Settings surface that belongs to no single feature — the shell's trigger/header/close chrome content, the local configuration-file action, the General section and its `settings.general.item` slot, the `settings` dictionaries, and the first ordered welcome step. Feature-owned rows (Permission, Language, Appearance), sections (Models), and conditional onboarding steps stay with their feature packages. + +A loopback browser loads the provider's optional `documentPath` through `settings.describe` and renders **Open configuration file** only when the Host confirms one local file. The action sends the pathless, loopback-only `settings.openDocument` request; the Host resolves the provider path again, materializes an absent document, and hands it to a native text editor (`open -t` on macOS, bypassing a browser file association; the desktop file association on Linux and Windows). Open failures keep the action available and render a localized error. Remote browsers never register the action and never issue the privileged settings read. `src/onboarding-copy.ts` is the single editable owner of the complete notice plus `WELCOME_NOTICE_VERSION`; both supported GUI locales intentionally render the same Chinese copy. The Host half registers `ui-onboarding` in the user-settings seam. A loopback browser compares `welcomeNoticeVersion` for exact equality and writes the current value only after Continue succeeds. The path mutation is idempotent across tabs and preserves sibling settings, while `host/settings-changed` makes an externally acknowledged notice advance without a reload. A non-loopback browser cannot access the privileged settings API: it still presents the notice, but Continue advances only the current browser process and a reload presents the notice again. A different version deliberately presents the notice again. The welcome page preserves every authored paragraph, gives the requested clause in the final paragraph the sole emphasis, initially focuses the title, and has no close, Escape, mask-click, or secondary path. None of its copy or acknowledgement enters a Session log or model request. The notice identifies `DSH_TELEMETRY_DISABLED=1` as the telemetry opt-out. diff --git a/packages/client/ui-settings-general/README.zh.md b/packages/client/ui-settings-general/README.zh.md index adec73edda..96666e2d4d 100644 --- a/packages/client/ui-settings-general/README.zh.md +++ b/packages/client/ui-settings-general/README.zh.md @@ -2,7 +2,9 @@ [English](README.md) | 中文 -设置界面无特定功能归属的文案与产品引导插件:在设置界面注册所有不属于单一功能的内容,包括外壳的触发器、标题栏与关闭控件内容,「通用」分区及其 `settings.general.item` slot、`settings` 字典,以及第一个有序欢迎步骤。归具体功能所有的行(「权限」、「语言」、「外观」)、分区(「模型」)和条件式首次使用引导步骤仍由各自的功能包提供。 +设置界面无特定功能归属的文案与产品引导插件:在设置界面注册所有不属于单一功能的内容,包括外壳的触发器、标题栏与关闭控件内容、本地配置文件操作,「通用」分区及其 `settings.general.item` slot、`settings` 字典,以及第一个有序欢迎步骤。归具体功能所有的行(「权限」、「语言」、「外观」)、分区(「模型」)和条件式首次使用引导步骤仍由各自的功能包提供。 + +回环浏览器通过 `settings.describe` 加载提供方可选的 `documentPath`,且只有在 Host 确认存在一个本地文件时才渲染**打开配置文件**。该操作发送无路径参数且仅限回环访问的 `settings.openDocument` 请求;Host 会再次解析提供方路径、在文档缺失时将其创建出来,并交给原生文本编辑器(macOS 上使用 `open -t`,绕过浏览器文件关联;Linux 和 Windows 上使用桌面文件关联)。打开失败时该操作仍可使用,并渲染本地化错误。远程浏览器从不注册该操作,也从不发起这项特权 settings 读取。 `src/onboarding-copy.ts` 是完整通知文案和 `WELCOME_NOTICE_VERSION` 的唯一可编辑来源;GUI 支持的两种 locale 都有意渲染同一份中文文案。宿主端在 user-settings seam 中注册 `ui-onboarding`。loopback 浏览器会比较 `welcomeNoticeVersion` 是否精确相等,仅在「继续」操作成功后写入当前值。该路径变更在不同标签页间幂等,并会保留同级设置;`host/settings-changed` 则让页面在通知被外部确认后,无需重新加载即可推进。非 loopback 浏览器不能访问受保护的 settings API:它仍会显示通知,但「继续」只推进当前浏览器进程,重新加载后会再次显示通知。版本不同时,系统也会有意重新显示通知。欢迎页保留原文的每个段落,仅强调最后一段中指定的句段,初始焦点落在标题上,并且没有关闭操作、Escape、点击遮罩或次要操作路径。其文案和确认状态均不会进入会话日志或模型请求。通知明确以 `DSH_TELEMETRY_DISABLED=1` 作为遥测关闭方式。 diff --git a/packages/client/ui-settings-general/src/client/SettingsDocumentAction.module.css b/packages/client/ui-settings-general/src/client/SettingsDocumentAction.module.css new file mode 100644 index 0000000000..c4bb82e12f --- /dev/null +++ b/packages/client/ui-settings-general/src/client/SettingsDocumentAction.module.css @@ -0,0 +1,16 @@ +.action { + display: flex; + min-width: 0; + align-items: center; + gap: 8px; +} + +.error { + max-width: 180px; + overflow: hidden; + color: var(--dsw-alias-state-error-primary); + font-size: 12px; + line-height: 18px; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/packages/client/ui-settings-general/src/client/SettingsDocumentAction.tsx b/packages/client/ui-settings-general/src/client/SettingsDocumentAction.tsx new file mode 100644 index 0000000000..6904ae7183 --- /dev/null +++ b/packages/client/ui-settings-general/src/client/SettingsDocumentAction.tsx @@ -0,0 +1,50 @@ +/** Optional settings-header action for opening a file-backed Host document. */ + +import { useEffect } from 'react' +import type { ReactNode } from 'react' +import { Button } from '@deepseek-ai/dsh-client-ui-primitives' +import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react' +import type { SettingsDocumentState, SettingsDocumentStore } from './settings-document-store.ts' +import css from './SettingsDocumentAction.module.css' + +/** Registrant-owned dependencies of {@link SettingsDocumentAction}. */ +export interface SettingsDocumentActionInjected { + /** Provider metadata and action state owner. */ + controller: SettingsDocumentStore + /** Bound selector hook for the controller snapshot. */ + useSnapshot: SnapshotSelectorHook +} + +/** Header-action owner share, localized copy, and the registrant's state face. */ +export type SettingsDocumentActionProps = + PropsRuntime<'settings.action'> & PropsLocale<'settings'> & SettingsDocumentActionInjected + +/** + * Render the open-document action only after Host metadata confirms a local file. + * @param props - header owner props, localized copy, and injected document state. + * @returns the action, or null while unavailable or unresolved. + */ +export function SettingsDocumentAction({ controller, useSnapshot, t }: SettingsDocumentActionProps): ReactNode { + const state = useSnapshot(snapshot => snapshot) + + useEffect(() => { + if (state.status === 'idle') void controller.load() + }, [controller, state.status]) + + if (state.status !== 'ready') return null + + return ( +
+ {state.error === null ? null : {t('openDocument.error')}} + +
+ ) +} diff --git a/packages/client/ui-settings-general/src/client/index.ts b/packages/client/ui-settings-general/src/client/index.ts index 87b05e86cb..bdcb2876e6 100644 --- a/packages/client/ui-settings-general/src/client/index.ts +++ b/packages/client/ui-settings-general/src/client/index.ts @@ -1,8 +1,8 @@ /** * Settings ownerless-copy plugin, browser half: registers everything on the * Settings surface that belongs to no single feature — the trigger/header - * chrome content, the General section, and the `settings` dictionaries. - * Feature-owned rows and sections stay with their features. + * chrome content, local-document action, General section, and `settings` + * dictionaries. Feature-owned rows and sections stay with their features. * Export discipline: packages/client/AGENTS.md. */ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' @@ -15,6 +15,9 @@ import type {} from '@deepseek-ai/dsh-client-ui-settings/client' import type {} from '@deepseek-ai/dsh-client-locale/client' import { CloseLabel, HeaderContent, TriggerContent } from './chrome.tsx' import { GeneralSection } from './GeneralSection.tsx' +import { SettingsDocumentAction } from './SettingsDocumentAction.tsx' +import type { SettingsDocumentActionInjected } from './SettingsDocumentAction.tsx' +import { SettingsDocumentStore } from './settings-document-store.ts' import type { WelcomeNoticeInjected } from './WelcomeNotice.tsx' import { WelcomeNotice } from './WelcomeNotice.tsx' import { refreshWelcomeIfLoaded, WelcomeNoticeStore } from './welcome-store.ts' @@ -27,6 +30,9 @@ export type { export type { GeneralSectionComponentProps, } from './GeneralSection.tsx' +export type { SettingsDocumentActionInjected, SettingsDocumentActionProps } from './SettingsDocumentAction.tsx' +export type { SettingsDocumentState } from './settings-document-store.ts' +export { SettingsDocumentStore } from './settings-document-store.ts' export type { WelcomeNoticeInjected, WelcomeNoticeProps } from './WelcomeNotice.tsx' export type { WelcomeNoticeState } from './welcome-store.ts' export type { SettingsKey } from './locales.ts' @@ -61,6 +67,15 @@ export function apply(ctx: ClientContext): void { // locale/change re-registration wiring. const t = ctx.locale.bind(NS) const connection = ctx.get('connection') as ConnectionHandle + const documentController = connection.isLoopback + ? new SettingsDocumentStore(connection.api) + : undefined + const documentInjected = documentController === undefined + ? undefined + : (() => { + const useSnapshot = bindSnapshotSelector(documentController.store) + return (): SettingsDocumentActionInjected => ({ controller: documentController, useSnapshot }) + })() const welcomeController = new WelcomeNoticeStore(connection.api, connection.isLoopback ? 'host' : 'memory') const useWelcomeSnapshot = bindSnapshotSelector(welcomeController.store) const welcomeInjected = (): WelcomeNoticeInjected => ({ @@ -84,6 +99,16 @@ export function apply(ctx: ClientContext): void { ctx.slots.register({ name: 'settings.trigger', locale: NS }, TriggerContent)) const header = deferRegistration(ctx.slots, 'settings.header', HeaderContent, () => ctx.slots.register({ name: 'settings.header', locale: NS }, HeaderContent)) + const action = documentInjected === undefined + ? undefined + : deferRegistration(ctx.slots, 'settings.action', SettingsDocumentAction, () => + ctx.slots.register({ + name: 'settings.action', + id: 'open-document', + order: 0, + locale: NS, + inject: documentInjected, + }, SettingsDocumentAction)) const close = deferRegistration(ctx.slots, 'settings.close', CloseLabel, () => ctx.slots.register({ name: 'settings.close', locale: NS }, CloseLabel)) const general = deferRegistration(ctx.slots, 'settings.section', GeneralSection, () => @@ -106,9 +131,10 @@ export function apply(ctx: ClientContext): void { return () => { trigger.dispose() header.dispose() + action?.dispose() close.dispose() general.dispose() welcome.dispose() } - }, 'ui-settings-general: chrome, section, and onboarding registrations') + }, 'ui-settings-general: chrome, action, section, and onboarding registrations') } diff --git a/packages/client/ui-settings-general/src/client/locales.ts b/packages/client/ui-settings-general/src/client/locales.ts index ef300e8e1f..41a44eae3f 100644 --- a/packages/client/ui-settings-general/src/client/locales.ts +++ b/packages/client/ui-settings-general/src/client/locales.ts @@ -6,6 +6,8 @@ export const zh = { 'trigger': '设置', 'title': '设置', 'close': '关闭', + 'openDocument': '打开配置文件', + 'openDocument.error': '无法打开配置文件', 'general.nav': '通用设置', 'welcome.title': WELCOME_NOTICE_COPY.zh.title, 'welcome.paragraph.0': WELCOME_NOTICE_COPY.zh.paragraphs[0], @@ -24,6 +26,8 @@ export const en = { 'trigger': 'Settings', 'title': 'Settings', 'close': 'Close', + 'openDocument': 'Open configuration file', + 'openDocument.error': 'Could not open configuration file', 'general.nav': 'General', 'welcome.title': WELCOME_NOTICE_COPY.en.title, 'welcome.paragraph.0': WELCOME_NOTICE_COPY.en.paragraphs[0], diff --git a/packages/client/ui-settings-general/src/client/settings-document-store.ts b/packages/client/ui-settings-general/src/client/settings-document-store.ts new file mode 100644 index 0000000000..ff55517771 --- /dev/null +++ b/packages/client/ui-settings-general/src/client/settings-document-store.ts @@ -0,0 +1,87 @@ +/** State owner for the optional local settings-document action. */ + +import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client' +import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' + +/** Browser state of the Host-owned settings document. */ +export interface SettingsDocumentState { + /** Metadata-loading phase; unavailable means the provider has no local file or the read failed. */ + status: 'idle' | 'loading' | 'ready' | 'unavailable' + /** Whether one native-open request is in flight. */ + opening: boolean + /** Last metadata/native-open diagnostic; UI exposes only localized copy. */ + error: string | null +} + +function messageOf(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +/** Loads local-document availability and invokes the pathless Host-owned open operation. */ +export class SettingsDocumentStore { + /** uSES-safe state source shared by the registered header action. */ + readonly store: SnapshotStore = createSnapshotStore({ + status: 'idle', opening: false, error: null, + }) + + private generation = 0 + + /** + * @param api - loopback settings wire face that reports and opens the provider document. + */ + constructor(private readonly api: Pick) {} + + /** + * Load the current provider's optional local document path. + * @returns after the latest metadata response updates the store. + */ + async load(): Promise { + const generation = ++this.generation + this.store.update((state) => { + state.status = 'loading' + state.error = null + }) + try { + const { result } = await this.api.settings.describe({}) + if (generation !== this.generation) return + if (!result.ok) { + this.store.update((state) => { + state.status = 'unavailable' + state.error = result.error.message + }) + return + } + this.store.update((state) => { + state.status = result.value.documentPath === undefined ? 'unavailable' : 'ready' + state.error = null + }) + } catch (error) { + if (generation !== this.generation) return + this.store.update((state) => { + state.status = 'unavailable' + state.error = messageOf(error) + }) + } + } + + /** + * Open the loaded document once; concurrent gestures collapse behind the in-flight action. + * @returns after the native-open request settles, or immediately when unavailable/already opening. + */ + async open(): Promise { + const current = this.store.getSnapshot() + if (current.status !== 'ready' || current.opening) return + this.store.update((state) => { + state.opening = true + state.error = null + }) + try { + const response = await this.api.settings.openDocument({}) + if (!response.result.ok) throw new Error(response.result.error.message) + } catch (error) { + this.store.update((state) => { state.error = messageOf(error) }) + } finally { + this.store.update((state) => { state.opening = false }) + } + } +} diff --git a/packages/client/ui-settings-general/src/invariant.ts b/packages/client/ui-settings-general/src/invariant.ts index d13ecc5cb8..c5917bd83b 100644 --- a/packages/client/ui-settings-general/src/invariant.ts +++ b/packages/client/ui-settings-general/src/invariant.ts @@ -16,8 +16,9 @@ export const inject = ['invariants'] /** * No runtime invariant: the settings seam validates and publishes the durable - * welcome section, while slot conflicts fail loud in the slot core; this - * package owns no additional event/data relationship between those systems. + * welcome section, while slot conflicts fail loud in the slot core. The local + * document action is browser state over typed RPC responses and is covered by + * store/component tests rather than a Cordis runtime relationship. */ const install: InvariantInstaller = () => {} diff --git a/packages/client/ui-settings-general/tests/apply.spec.ts b/packages/client/ui-settings-general/tests/apply.spec.ts index 73f6d8207e..0f79b0c4a0 100644 --- a/packages/client/ui-settings-general/tests/apply.spec.ts +++ b/packages/client/ui-settings-general/tests/apply.spec.ts @@ -1,4 +1,4 @@ -/** Ownerless-copy registrations: the four seats, the dictionaries, thunked labels, and HMR recovery. */ +/** Ownerless-copy registrations: the six seats, dictionaries, thunked labels, and HMR recovery. */ import { Context } from 'cordis' import { describe, expect, it, vi } from 'vitest' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' @@ -8,6 +8,8 @@ import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-general/client' import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx' import { GeneralSection } from '../src/client/GeneralSection.tsx' +import { SettingsDocumentAction } from '../src/client/SettingsDocumentAction.tsx' +import type { SettingsDocumentActionInjected } from '../src/client/SettingsDocumentAction.tsx' import { WelcomeNotice } from '../src/client/WelcomeNotice.tsx' import type { WelcomeNoticeInjected } from '../src/client/WelcomeNotice.tsx' import { WELCOME_NOTICE_SETTINGS_NAMESPACE } from '../src/onboarding-copy.ts' @@ -16,10 +18,11 @@ import { WELCOME_NOTICE_SETTINGS_NAMESPACE } from '../src/onboarding-copy.ts' // the shipped Chinese copy, so they state the browser they assume. usePinnedBrowserLanguages('zh-CN') -/** The five seats this plugin fills (slot name → expected component). */ +/** The seats this plugin fills for a loopback browser (slot name → expected component). */ const SEATS = [ ['settings.trigger', TriggerContent], ['settings.header', HeaderContent], + ['settings.action', SettingsDocumentAction], ['settings.close', CloseLabel], ['settings.section', GeneralSection], ['settings.onboarding', WelcomeNotice], @@ -36,6 +39,7 @@ async function bench(isLoopback = true) { ok: true as const, value: { writable: true, + documentPath: '/tmp/test-settings.yaml', namespaces: [{ ns: WELCOME_NOTICE_SETTINGS_NAMESPACE, schema: {}, @@ -47,11 +51,18 @@ async function bench(isLoopback = true) { }, }, })) - ctx.provide('connection', { api: { settings: { describe: settingsDescribe } }, isLoopback } as never) - return { ctx, slots: ctx.get('slots') as SlotsService, locale, settingsDescribe } + const settingsOpenDocument = vi.fn(() => Promise.resolve({ + rpcId: 'settings-open' as never, + result: { ok: true as const, value: { opened: true as const } }, + })) + ctx.provide('connection', { + api: { settings: { describe: settingsDescribe, openDocument: settingsOpenDocument } }, + isLoopback, + } as never) + return { ctx, slots: ctx.get('slots') as SlotsService, locale, settingsDescribe, settingsOpenDocument } } -/** Declare the shell's four child slots the way ui-settings' entry does. */ +/** Declare the shell's six child slots the way ui-settings' entry does. */ function declare(slots: SlotsService): () => void { return slots.register( { @@ -59,6 +70,7 @@ function declare(slots: SlotsService): () => void { children: { 'settings.trigger': { kind: 'single', scope: 'root' }, 'settings.header': { kind: 'single', scope: 'root' }, + 'settings.action': { kind: 'list', scope: 'root' }, 'settings.close': { kind: 'single', scope: 'root' }, 'settings.section': { kind: 'list', scope: 'root' }, 'settings.onboarding': { kind: 'list', scope: 'root' }, @@ -77,7 +89,7 @@ describe('ui-settings-general apply', () => { expect(inject).toEqual(['slots', 'locale', 'connection']) }) - it('fills all five seats for declarations before or after apply', async () => { + it('fills all six seats for declarations before or after apply', async () => { const before = await bench() declare(before.slots) await before.ctx.plugin({ inject: [...inject], apply }).await() @@ -92,6 +104,10 @@ describe('ui-settings-general apply', () => { expect(before.slots.entries('settings.general.item')).toEqual([]) const welcome = before.slots.entries('settings.onboarding')[0]! expect(welcome.options).toMatchObject({ id: 'welcome-notice', order: -100 }) + const action = before.slots.entries('settings.action')[0]! + const actionInjected = (action.inject as unknown as () => SettingsDocumentActionInjected)() + expect(actionInjected.controller.store.getSnapshot().status).toBe('idle') + expect(actionInjected.useSnapshot).toEqual(expect.any(Function)) // Copy rides the standard locale seat: every seat declares the namespace. for (const [name] of SEATS) { expect(before.slots.entries(name)[0]!.locale).toBe('settings') @@ -170,6 +186,7 @@ describe('ui-settings-general apply', () => { await expect(controller.acknowledge()).resolves.toBe(true) expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: true }) expect(b.settingsDescribe).not.toHaveBeenCalled() + expect(b.slots.entries('settings.action')).toEqual([]) }) it('re-registers after an HMR collapse of the declaring chain (stale disposers must not block)', async () => { diff --git a/packages/client/ui-settings-general/tests/components.spec.tsx b/packages/client/ui-settings-general/tests/components.spec.tsx index db6be78ccd..0b11a575f4 100644 --- a/packages/client/ui-settings-general/tests/components.spec.tsx +++ b/packages/client/ui-settings-general/tests/components.spec.tsx @@ -1,10 +1,13 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, render, screen } from '@testing-library/react' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import type { GeneralSectionComponentProps } from '../src/client/GeneralSection.tsx' import { GeneralSection } from '../src/client/GeneralSection.tsx' import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx' import type { TriggerContentProps } from '../src/client/chrome.tsx' +import { SettingsDocumentAction } from '../src/client/SettingsDocumentAction.tsx' +import { SettingsDocumentStore } from '../src/client/settings-document-store.ts' import { en } from '../src/client/locales.ts' afterEach(cleanup) @@ -54,3 +57,80 @@ describe('GeneralSection', () => { expect(screen.getByTestId('slot-settings.general.item')).toBeTruthy() }) }) + +describe('SettingsDocumentAction', () => { + it('appears only for a file-backed provider and requests its Host-owned document', async () => { + const openDocument = vi.fn(() => Promise.resolve({ + rpcId: 'document-open' as never, + result: { ok: true as const, value: { opened: true as const } }, + })) + const controller = new SettingsDocumentStore({ + settings: { + describe: vi.fn(() => Promise.resolve({ + rpcId: 'document-action' as never, + result: { + ok: true as const, + value: { writable: true, documentPath: '/tmp/custom.yaml', namespaces: [] }, + }, + })), + openDocument, + }, + } as never) + render() + const action = await screen.findByRole('button', { name: 'Open configuration file' }) + fireEvent.click(action) + await waitFor(() => { expect(openDocument).toHaveBeenCalledWith({}) }) + }) + + it('stays absent when the provider has no local document', async () => { + const controller = new SettingsDocumentStore({ + settings: { + describe: vi.fn(() => Promise.resolve({ + rpcId: 'document-action' as never, + result: { ok: true as const, value: { writable: true, namespaces: [] } }, + })), + openDocument: vi.fn(), + }, + } as never) + render() + await waitFor(() => { expect(controller.store.getSnapshot().status).toBe('unavailable') }) + expect(screen.queryByRole('button', { name: 'Open configuration file' })).toBeNull() + }) + + it('keeps the action available and reports a native-open failure', async () => { + const controller = new SettingsDocumentStore({ + settings: { + describe: vi.fn(() => Promise.resolve({ + rpcId: 'document-action' as never, + result: { + ok: true as const, + value: { writable: true, documentPath: '/tmp/settings.yaml', namespaces: [] }, + }, + })), + openDocument: vi.fn(() => Promise.resolve({ + rpcId: 'document-open-failed' as never, + result: { ok: false as const, error: { code: 'internal' as const, message: 'xdg-open missing', details: {} } }, + })), + }, + } as never) + render() + fireEvent.click(await screen.findByRole('button', { name: 'Open configuration file' })) + expect((await screen.findByRole('alert')).textContent).toBe('Could not open configuration file') + expect(screen.getByRole('button', { name: 'Open configuration file' })).toBeTruthy() + }) +}) diff --git a/packages/client/ui-settings-general/tests/settings-document-store.spec.ts b/packages/client/ui-settings-general/tests/settings-document-store.spec.ts new file mode 100644 index 0000000000..2104e3e1f1 --- /dev/null +++ b/packages/client/ui-settings-general/tests/settings-document-store.spec.ts @@ -0,0 +1,132 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RpcResponse } from '@deepseek-ai/dsh-client-connection/client' +import { SettingsDocumentStore } from '../src/client/settings-document-store.ts' + +function response(documentPath?: string): RpcResponse<{ + writable: boolean + documentPath?: string + namespaces: [] +}> { + return { + rpcId: 'settings-document' as never, + result: { + ok: true, + value: { writable: true, ...documentPath === undefined ? {} : { documentPath }, namespaces: [] }, + }, + } +} + +function opened(): RpcResponse<{ opened: true }> { + return { + rpcId: 'settings-open' as never, + result: { ok: true, value: { opened: true } }, + } +} + +function describeFailed(message: string): RpcResponse { + return { + rpcId: 'settings-document-failed' as never, + result: { ok: false, error: { code: 'internal', message, details: {} } }, + } +} + +describe('SettingsDocumentStore', () => { + it('loads provider metadata and asks the settings domain to open its document', async () => { + const describe = vi.fn(() => Promise.resolve(response('/home/test/settings.yaml'))) + const openDocument = vi.fn(() => Promise.resolve(opened())) + const controller = new SettingsDocumentStore({ settings: { describe, openDocument } } as never) + await controller.load() + expect(controller.store.getSnapshot()).toEqual({ + status: 'ready', opening: false, error: null, + }) + await controller.open() + expect(openDocument).toHaveBeenCalledWith({}) + }) + + it('marks absent or failed metadata unavailable without opening anything', async () => { + const openDocument = vi.fn(() => Promise.resolve(opened())) + const absent = new SettingsDocumentStore({ + settings: { describe: () => Promise.resolve(response()), openDocument }, + } as never) + await absent.load() + await absent.open() + expect(absent.store.getSnapshot().status).toBe('unavailable') + expect(openDocument).not.toHaveBeenCalled() + + const failed = new SettingsDocumentStore({ + settings: { describe: () => Promise.reject(new Error('offline')), openDocument }, + } as never) + await failed.load() + expect(failed.store.getSnapshot()).toMatchObject({ status: 'unavailable', error: 'offline' }) + + const rejected = new SettingsDocumentStore({ + settings: { describe: () => Promise.resolve(describeFailed('provider failed')), openDocument }, + } as never) + await rejected.load() + expect(rejected.store.getSnapshot()).toMatchObject({ + status: 'unavailable', error: 'provider failed', + }) + }) + + it('collapses concurrent open gestures and recovers after a failure', async () => { + let resolveOpen!: (response: RpcResponse<{ opened: true }>) => void + const openDocument = vi.fn(() => new Promise>((resolve) => { resolveOpen = resolve })) + const controller = new SettingsDocumentStore({ + settings: { describe: () => Promise.resolve(response('/tmp/settings.yaml')), openDocument }, + } as never) + await controller.load() + const first = controller.open() + const second = controller.open() + expect(openDocument).toHaveBeenCalledOnce() + resolveOpen({ + rpcId: 'settings-open-failed' as never, + result: { ok: false, error: { code: 'internal', message: 'no default editor', details: {} } }, + }) + await Promise.all([first, second]) + expect(controller.store.getSnapshot()).toMatchObject({ + status: 'ready', opening: false, error: 'no default editor', + }) + }) + + it('ignores stale metadata completions and reports non-Error native failures', async () => { + let resolveFirst!: (value: ReturnType) => void + const first = new Promise>((resolve) => { resolveFirst = resolve }) + const describe = vi.fn() + .mockReturnValueOnce(first) + .mockResolvedValueOnce(response('/tmp/current.yaml')) + let rejectOpen!: (reason?: unknown) => void + const controller = new SettingsDocumentStore({ + settings: { + describe, + openDocument: () => new Promise((_, reject) => { rejectOpen = reject }), + }, + } as never) + const stale = controller.load() + await controller.load() + resolveFirst(response()) + await stale + expect(controller.store.getSnapshot().status).toBe('ready') + const opening = controller.open() + rejectOpen('native unavailable') + await opening + expect(controller.store.getSnapshot()).toMatchObject({ + status: 'ready', opening: false, error: 'native unavailable', + }) + + let rejectFirst!: (error: Error) => void + const rejectedFirst = new Promise>((_, reject) => { rejectFirst = reject }) + const caught = new SettingsDocumentStore({ + settings: { + describe: vi.fn() + .mockReturnValueOnce(rejectedFirst) + .mockResolvedValueOnce(response('/tmp/current.yaml')), + openDocument: vi.fn(), + }, + } as never) + const staleRejection = caught.load() + await caught.load() + rejectFirst(new Error('stale offline')) + await staleRejection + expect(caught.store.getSnapshot()).toMatchObject({ status: 'ready', error: null }) + }) +}) diff --git a/packages/client/ui-settings/README.i18n.yaml b/packages/client/ui-settings/README.i18n.yaml index 668853e41f..6c4fee22aa 100644 --- a/packages/client/ui-settings/README.i18n.yaml +++ b/packages/client/ui-settings/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-settings/README.md -README.md: 14c78c83467313a6efa7033c31fb9c9b1cd94e0c -README.zh.md: 8831842d03db4572f1dca5547b84c0d8a3be8f2d +README.md: de78d599b7833179339ceeb680fbd665b056bd83 +README.zh.md: fdc6ade1413c7d02afba85ed3b69a2b78b401836 diff --git a/packages/client/ui-settings/README.md b/packages/client/ui-settings/README.md index 14c78c8346..de78d599b7 100644 --- a/packages/client/ui-settings/README.md +++ b/packages/client/ui-settings/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Settings shell plugin: a pure composition face. It occupies `sidebar.settings` with the trigger chrome and modal settings panel, and declares the slots registrants fill: `settings.trigger` / `settings.header` / `settings.close` (chrome content), `settings.section` (one page per feature), and `settings.onboarding` (ordered feature-owned pages in a full-viewport stage). The shell ships no copy of its own — all text arrives from registrants (ui-settings-general owns chrome, General, and the product notice; features own their sections, rows, and conditional onboarding pages). Nav labels may be locale-following thunks, so the nav projection resolves them through `resolveSlotLabel` and re-renders on the section ledger bump or the locale revision (an optional `ctx.get('locale')` read; no hard locale dependency). +Settings shell plugin: a pure composition face. It occupies `sidebar.settings` with the trigger chrome and modal settings panel, and declares the slots registrants fill: `settings.trigger` / `settings.header` / `settings.close` (chrome content), `settings.action` (ordered content-header actions), `settings.section` (one page per feature), and `settings.onboarding` (ordered feature-owned pages in a full-viewport stage). The shell ships no copy of its own — all text arrives from registrants (ui-settings-general owns chrome, General, and the product notice; features own their actions, sections, rows, and conditional onboarding pages). Nav labels may be locale-following thunks, so the nav projection resolves them through `resolveSlotLabel` and re-renders on the section ledger bump or the locale revision (an optional `ctx.get('locale')` read; no hard locale dependency). The shell projects the onboarding ledger into ascending order and mounts exactly one page at a time in a body-level stage while marking the underlying app root inert. The active registrant receives its id, `complete()`, and an `openSection(id)` callback; completing or skipping transfers ownership to the next entry. Registrants own durable completion, capability readiness, copy, and mutations, so independently registered flows cannot stack and the shell does not become a second configuration fact source. diff --git a/packages/client/ui-settings/README.zh.md b/packages/client/ui-settings/README.zh.md index 8831842d03..fdc6ade141 100644 --- a/packages/client/ui-settings/README.zh.md +++ b/packages/client/ui-settings/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -设置外壳插件:一个纯组合表层。它以触发控件和模态设置面板占用 `sidebar.settings`,并声明由注册方填充的 slot:`settings.trigger`/`settings.header`/`settings.close`(界面框架内容)、`settings.section`(每项功能一页)和 `settings.onboarding`(由各功能持有、显示在全视口展示层中的有序页面)。外壳不自带文案:所有文本都来自注册方(ui-settings-general 拥有界面框架、「通用」分区和产品声明;各功能拥有各自的分区、行和条件式首次使用引导页面)。导航 label 可以是跟随语言的 thunk,因此导航投影经 `resolveSlotLabel` 解析,并在分区账本更新或 locale revision 变化时重新渲染(`ctx.get('locale')` 可选读取,无硬 locale 依赖)。 +设置外壳插件:一个纯组合表层。它以触发控件和模态设置面板占用 `sidebar.settings`,并声明由注册方填充的 slot:`settings.trigger`/`settings.header`/`settings.close`(界面框架内容)、`settings.action`(内容标题栏中的有序操作)、`settings.section`(每项功能一页)和 `settings.onboarding`(由各功能持有、显示在全视口展示层中的有序页面)。外壳不自带文案:所有文本都来自注册方(ui-settings-general 拥有界面框架、「通用」分区和产品声明;各功能拥有各自的操作、分区、行和条件式首次使用引导页面)。导航 label 可以是跟随语言的 thunk,因此导航投影经 `resolveSlotLabel` 解析,并在分区账本更新或 locale revision 变化时重新渲染(`ctx.get('locale')` 可选读取,无硬 locale 依赖)。 外壳将首次使用引导记录按升序投影,在 body 层级的展示层中每次只挂载一个页面,同时将下层应用根节点标记为 `inert`。当前注册方会收到该条目的 id、`complete()` 和 `openSection(id)` 回调;完成或跳过当前页面后,所有权转交给下一项。持久化完成状态、能力就绪状态、文案和变更操作均由注册方持有,因此独立注册的流程无法堆叠,外壳也不会成为第二个配置事实来源。 diff --git a/packages/client/ui-settings/src/client/SettingsRoot.module.css b/packages/client/ui-settings/src/client/SettingsRoot.module.css index 817ab38d9a..72c188e019 100644 --- a/packages/client/ui-settings/src/client/SettingsRoot.module.css +++ b/packages/client/ui-settings/src/client/SettingsRoot.module.css @@ -167,12 +167,22 @@ flex: none; display: flex; align-items: flex-start; - justify-content: flex-end; + justify-content: space-between; + gap: 8px; height: 54px; padding: 20px 14px 8px 10px; box-sizing: border-box; } +.actions { + min-width: 0; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + margin-left: auto; +} + /* Close button (figma .Icon_container 501:29982): 28x28, r28, 14px glyph. */ .close { display: inline-flex; diff --git a/packages/client/ui-settings/src/client/SettingsRoot.tsx b/packages/client/ui-settings/src/client/SettingsRoot.tsx index 3eefbd4ef1..45055753ac 100644 --- a/packages/client/ui-settings/src/client/SettingsRoot.tsx +++ b/packages/client/ui-settings/src/client/SettingsRoot.tsx @@ -76,6 +76,7 @@ function SettingsPanel({ rows, renderSlot, activeId, onSelect, onClose }: PanelP
+
{renderSlot('settings.action', {})}