Quellcode durchsuchen

Merge pull request #2503 from deepseek-harness/agent/onboarding-modal-flow

feat(web): unify onboarding dialogs
Tianyi Cui vor 1 Monat
Ursprung
Commit
6e4e585e9a
54 geänderte Dateien mit 1383 neuen und 341 gelöschten Zeilen
  1. 2 2
      .agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.i18n.yaml
  2. 4 4
      .agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.md
  3. 4 4
      .agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.zh.md
  4. 2 2
      .agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.i18n.yaml
  5. 5 5
      .agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md
  6. 5 5
      .agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.zh.md
  7. 2 2
      .agents/notes/implemented/feature/2026-08-10-telemetry-default-off.i18n.yaml
  8. 1 1
      .agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md
  9. 1 1
      .agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md
  10. 6 0
      .agents/notes/implemented/feature/2026-08-13-shared-modal-product-onboarding.i18n.yaml
  11. 33 0
      .agents/notes/implemented/feature/2026-08-13-shared-modal-product-onboarding.md
  12. 33 0
      .agents/notes/implemented/feature/2026-08-13-shared-modal-product-onboarding.zh.md
  13. 2 2
      .agents/notes/implemented/simplification/2026-08-13-remove-first-run-beta-notice.i18n.yaml
  14. 2 2
      .agents/notes/implemented/simplification/2026-08-13-remove-first-run-beta-notice.md
  15. 2 2
      .agents/notes/implemented/simplification/2026-08-13-remove-first-run-beta-notice.zh.md
  16. 2 2
      apps/web/tests/README.i18n.yaml
  17. 2 1
      apps/web/tests/README.md
  18. 1 1
      apps/web/tests/README.zh.md
  19. 1 1
      apps/web/tests/goal-bar.e2e.ts
  20. 68 52
      apps/web/tests/onboarding-deepseek-config.e2e.ts
  21. 9 8
      apps/web/tests/onboarding-usable-provider.e2e.ts
  22. 60 0
      apps/web/tests/remote-welcome.e2e.ts
  23. 27 0
      apps/web/tests/scaffold.ts
  24. 5 2
      apps/web/tests/snapshots/onboarding-deepseek-config/missing.expected.md
  25. 5 0
      apps/web/tests/snapshots/onboarding-deepseek-config/welcome.expected.md
  26. 2 2
      packages/client/ui-settings-general/README.i18n.yaml
  27. 3 3
      packages/client/ui-settings-general/README.md
  28. 3 3
      packages/client/ui-settings-general/README.zh.md
  29. 5 7
      packages/client/ui-settings-general/src/client/SettingsRoot.tsx
  30. 1 1
      packages/client/ui-settings-general/src/index.ts
  31. 1 1
      packages/client/ui-settings-general/tests/settings-root.client.spec.tsx
  32. 2 2
      packages/client/ui-settings-models/README.i18n.yaml
  33. 1 1
      packages/client/ui-settings-models/README.md
  34. 1 1
      packages/client/ui-settings-models/README.zh.md
  35. 1 1
      packages/client/ui-settings-models/package.json
  36. 7 90
      packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.module.css
  37. 59 50
      packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx
  38. 3 1
      packages/client/ui-settings-models/src/client/EditorFooter.tsx
  39. 32 0
      packages/client/ui-settings-models/src/client/OnboardingModal.module.css
  40. 52 0
      packages/client/ui-settings-models/src/client/OnboardingModal.tsx
  41. 40 15
      packages/client/ui-settings-models/src/client/ProviderEditor.tsx
  42. 36 0
      packages/client/ui-settings-models/src/client/WelcomeNotice.module.css
  43. 77 0
      packages/client/ui-settings-models/src/client/WelcomeNotice.tsx
  44. 41 13
      packages/client/ui-settings-models/src/client/index.ts
  45. 16 2
      packages/client/ui-settings-models/src/client/locales.ts
  46. 126 0
      packages/client/ui-settings-models/src/client/welcome-store.ts
  47. 25 0
      packages/client/ui-settings-models/src/onboarding-copy.ts
  48. 42 7
      packages/client/ui-settings-models/tests/apply.client.spec.ts
  49. 59 0
      packages/client/ui-settings-models/tests/components.client.spec.tsx
  50. 130 32
      packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx
  51. 126 0
      packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx
  52. 199 0
      packages/client/ui-settings-models/tests/welcome-store.client.spec.ts
  53. 5 7
      packages/client/ui-settings/src/client/contract/slots.ts
  54. 4 3
      packages/extensions/cordis-client-runner/src/client/slot-catalog.ts

+ 2 - 2
.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.md
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.md
-2026-07-30-deepseek-onboarding-credential-setup.md: ebf5c66cffa8a7db90328dfcdd04e4134a78e3b1
-2026-07-30-deepseek-onboarding-credential-setup.zh.md: 3df63c59fd23acf32c16dac8f4ca373409ab2968
+2026-07-30-deepseek-onboarding-credential-setup.md: 823d10a723af70ec4ff51018b8b86198db0f5c29
+2026-07-30-deepseek-onboarding-credential-setup.zh.md: 7e8d79c23c4b1489bfd818c90558f36509635486

+ 4 - 4
.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.md

@@ -12,9 +12,9 @@ The [web configuration plane](../architecture/2026-07-30-web-config-plane.md) ma
 
 
 **One readiness projection owns both Models and onboarding facts.** `ui-settings-models` keeps a single store that joins `llm.providers({})`, redacted `settings.describe({})`, and batched `credentials.describe({refs})`. The onboarding projection selects the `deepseek-official` configurable-provider entry owned by the `llm-deepseek` namespace and empty settings path, reads the effective `apiKeyEnv`, and evaluates the matching credential descriptor. A live route with the same provider id but no matching configurable-provider declaration is adapter-absent for onboarding. A configured process-environment credential is ready and remains read-only.
 **One readiness projection owns both Models and onboarding facts.** `ui-settings-models` keeps a single store that joins `llm.providers({})`, redacted `settings.describe({})`, and batched `credentials.describe({refs})`. The onboarding projection selects the `deepseek-official` configurable-provider entry owned by the `llm-deepseek` namespace and empty settings path, reads the effective `apiKeyEnv`, and evaluates the matching credential descriptor. A live route with the same provider id but no matching configurable-provider declaration is adapter-absent for onboarding. A configured process-environment credential is ready and remains read-only.
 
 
-**The settings shell contributes ordering and navigation, not provider policy.** `ui-settings` declares a root-scoped `settings.onboarding` list slot and mounts one ordered step at a time while the current surface is the empty Hero. The active registrant receives `complete()` and a private `openSection(id)` callback; completion transfers ownership to the next entry. `ui-settings-models` registers the DeepSeek step and its Models section through `slots.inject()`, so each contribution follows its declaration lifetime without making plugin load order a contract, and independently contributed dialogs cannot stack. The product-wide welcome step that precedes it is owned separately by [the versioned welcome decision](2026-07-30-versioned-gui-welcome-onboarding.md).
+**The settings shell contributes ordering, not provider policy.** `ui-settings` declares a root-scoped `settings.onboarding` list slot and mounts one ordered step at a time while the current surface is the empty Hero. The active registrant receives `complete()` and a private `openSection(id)` callback; completion transfers ownership to the next entry. `ui-settings-models` registers the DeepSeek step, the preceding welcome notice, and its Models section through `slots.inject()`, so every contribution follows one client Cordis plugin's lifecycle and the dialogs cannot stack. Their common presentation is owned by the [shared-modal onboarding decision](2026-08-13-shared-modal-product-onboarding.md).
 
 
-**The prompt routes to the one credential editor.** A mounted, active adapter with a resolved, writable, unconfigured reference presents one action that opens Settings on Models. The existing DeepSeek setup card there exclusively owns the password input, `credentials.set({ref, value})`, write failures, and post-write refresh; the onboarding overlay never holds or submits a secret. An unavailable settings or credential capability keeps its deployment diagnostic and routes to the same page, while an absent adapter remains skipped because navigation cannot mount a Cordis plugin.
+**The prompt renders the existing credential editor inline.** A mounted, active adapter with a resolved, writable, unconfigured reference renders `ProviderEditor` in credential-only mode inside the shared onboarding modal. The same component owns the password input, validation, `credentials.set({ref, value})`, write failures, and post-write refresh; credential-only mode emits no provider-settings mutation. Configure later completes only this coordinator pass. An absent adapter remains skipped because the browser cannot mount a missing Cordis plugin.
 
 
 **Unavailable states do not capture the product.** An absent configurable-provider entry, inactive route, failed initial join, read-only deployment, or unresolved settings or credential capability completes the step without rendering because the onboarding action cannot repair that state. The Models page remains the deployment diagnostic and retry surface. Configure later completes a missing-credential step for the current mounted coordinator pass and writes no completion fact. Settings, credential, provider-topology, and connection invalidations all refresh the shared join, so an external credential update completes an open step without a reload.
 **Unavailable states do not capture the product.** An absent configurable-provider entry, inactive route, failed initial join, read-only deployment, or unresolved settings or credential capability completes the step without rendering because the onboarding action cannot repair that state. The Models page remains the deployment diagnostic and retry surface. Configure later completes a missing-credential step for the current mounted coordinator pass and writes no completion fact. Settings, credential, provider-topology, and connection invalidations all refresh the shared join, so an external credential update completes an open step without a reload.
 
 
@@ -22,7 +22,7 @@ The [web configuration plane](../architecture/2026-07-30-web-config-plane.md) ma
 
 
 **A separate onboarding store and readiness RPC sequence** — rejected because it would create a second client-side interpretation of provider identity, settings paths, secret sidecars, credential references, and invalidation ordering beside the Models page.
 **A separate onboarding store and readiness RPC sequence** — rejected because it would create a second client-side interpretation of provider identity, settings paths, secret sidecars, credential references, and invalidation ordering beside the Models page.
 
 
-**A second API-key editor inside onboarding** — rejected because the Models page already renders its DeepSeek setup card for exactly this state. Duplicating its secret draft, write errors, and configured-state convergence would add a second security-sensitive UI without another user capability.
+**A standalone API-key implementation inside onboarding** — rejected because it would duplicate the Models editor's secret draft, validation, errors, and configured-state convergence. The modal renders the existing `ProviderEditor` in a constrained mode instead.
 
 
 **Writing the API key into provider settings** — rejected because a literal secret would enter the settings mutation path and whole-section replacement cannot safely reconstruct redacted values. Credential storage is already the product seam and supplies immediate invalidation.
 **Writing the API key into provider settings** — rejected because a literal secret would enter the settings mutation path and whole-section replacement cannot safely reconstruct redacted values. Credential storage is already the product seam and supplies immediate invalidation.
 
 
@@ -30,4 +30,4 @@ The [web configuration plane](../architecture/2026-07-30-web-config-plane.md) ma
 
 
 ## Consequences
 ## Consequences
 
 
-The ordered flow leads from the product notice to the shipped adapter's existing editor without restarting: a keyless browser test boots the real Web composition under an isolated harness home, acknowledges the notice, follows the DeepSeek page to Models, stores a generated key through that page into the home's `.credentials.yaml`, verifies no key reaches DOM, ARIA, or browser console output, and confirms the running page reports configured. The full keyless Web replay lane also pins that a non-configurable replay route with the same provider id does not block unrelated journeys. Pure readiness and React tests pin managed-file and process-environment credentials, missing providers and capabilities, navigation, cancellation, external invalidation, and coordinator transfer. The flow deliberately inherits the configuration plane's documented base limitations rather than adding local secret storage, redaction, or settings replacement workarounds.
+The ordered flow leads from the product notice to an inline key form without restarting: a keyless browser test boots the real Web composition under an isolated harness home, acknowledges the notice, stores a generated key from the shared modal into the home's `.credentials.yaml`, verifies no key reaches DOM, ARIA, or browser console output, and confirms the ordinary Models page reports configured. The full keyless Web replay lane also pins that a non-configurable replay route with the same provider id does not block unrelated journeys. Pure readiness and React tests pin managed-file and process-environment credentials, missing providers and capabilities, cancellation, external invalidation, and coordinator transfer. The flow deliberately inherits the configuration plane's documented base limitations rather than adding local secret storage, redaction, or settings replacement workarounds.

+ 4 - 4
.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.zh.md

@@ -12,9 +12,9 @@ Status: implemented
 
 
 **Models 与首次使用引导共享同一个就绪状态投影。**`ui-settings-models` 维护一个 store,把 `llm.providers({})`、脱敏后的 `settings.describe({})` 和批量调用的 `credentials.describe({refs})` 联接为同一份状态。首次使用投影选取由 `llm-deepseek` namespace 与空 settings path 持有的 `deepseek-official` 可配置提供方条目,读取生效的 `apiKeyEnv`,并检查对应的凭据描述符。同 provider id 但没有匹配可配置提供方声明的存活路由,在首次使用引导中视为适配器缺失。通过进程环境提供的凭据若已配置,则判定为就绪并保持只读。
 **Models 与首次使用引导共享同一个就绪状态投影。**`ui-settings-models` 维护一个 store,把 `llm.providers({})`、脱敏后的 `settings.describe({})` 和批量调用的 `credentials.describe({refs})` 联接为同一份状态。首次使用投影选取由 `llm-deepseek` namespace 与空 settings path 持有的 `deepseek-official` 可配置提供方条目,读取生效的 `apiKeyEnv`,并检查对应的凭据描述符。同 provider id 但没有匹配可配置提供方声明的存活路由,在首次使用引导中视为适配器缺失。通过进程环境提供的凭据若已配置,则判定为就绪并保持只读。
 
 
-**设置外壳只贡献排序与导航,不持有提供方策略。** `ui-settings` 声明一个根作用域的 `settings.onboarding` list slot,并在当前界面为空白 Hero 时,每次只挂载一个有序步骤。当前注册方会收到 `complete()` 和私有 `openSection(id)` 回调;完成当前步骤后,所有权转交给下一项。`ui-settings-models` 通过 `slots.inject()` 注册 DeepSeek 步骤及其 Models 分区,使每项贡献都跟随自身的声明生命周期,不让插件加载顺序成为约定;独立贡献的对话框也无法堆叠。排在它之前的产品级欢迎步骤由[版本化欢迎决策](2026-07-30-versioned-gui-welcome-onboarding.md)单独持有。
+**设置外壳只贡献排序,不持有提供方策略。** `ui-settings` 声明一个根作用域的 `settings.onboarding` list slot,并在当前界面为空白 Hero 时,每次只挂载一个有序步骤。当前注册方会收到 `complete()` 和私有 `openSection(id)` 回调;完成当前步骤后,所有权转交给下一项。`ui-settings-models` 通过 `slots.inject()` 注册 DeepSeek 步骤、排在它之前的欢迎声明及 Models 分区,因此所有贡献都跟随同一个 client Cordis 插件的生命周期,两个弹窗也无法堆叠。它们的共用展示由[共用弹窗引导决策](2026-08-13-shared-modal-product-onboarding.md)持有。
 
 
-**首次使用页面只负责跳转到唯一的凭据编辑器。**适配器已挂载且处于活跃状态,其引用可解析、可写但尚未配置时,界面会显示一个操作按钮,用于打开「设置」的 Models 分区。该分区已有的 DeepSeek 设置卡片全权负责密码输入框、`credentials.set({ref, value})`、写入失败处理和写入后刷新;首次使用页面绝不持有或提交 secret。
+**首次使用弹窗行内渲染既有凭据编辑器。** 适配器已挂载且处于活跃状态,其引用可解析、可写但尚未配置时,`ProviderEditor` 会以仅凭据模式渲染在共用引导弹窗中。同一个组件全权负责密码输入框、校验、`credentials.set({ref, value})`、写入失败处理和写入后刷新;仅凭据模式不会发出提供方 settings 变更。「稍后配置」只完成协调器当前这一轮。适配器缺失时仍跳过,因为浏览器不能挂载缺失的 Cordis 插件。
 
 
 **不可用状态不会占住产品。** 可配置提供方条目缺失、路由不活跃、初始联接失败、部署只读或设置/凭据能力无法解析时,都会直接完成而不渲染该步骤,因为首次使用引导无法修复这些状态。Models 页仍是部署诊断与重试界面。「稍后配置」只会完成协调器当前这一次缺少凭据的步骤,不写入任何完成状态。设置、凭据、提供方拓扑和连接失效事件都会刷新共享联接,因此外部凭据更新无需重新加载页面即可完成已打开的步骤。
 **不可用状态不会占住产品。** 可配置提供方条目缺失、路由不活跃、初始联接失败、部署只读或设置/凭据能力无法解析时,都会直接完成而不渲染该步骤,因为首次使用引导无法修复这些状态。Models 页仍是部署诊断与重试界面。「稍后配置」只会完成协调器当前这一次缺少凭据的步骤,不写入任何完成状态。设置、凭据、提供方拓扑和连接失效事件都会刷新共享联接,因此外部凭据更新无需重新加载页面即可完成已打开的步骤。
 
 
@@ -22,7 +22,7 @@ Status: implemented
 
 
 **为首次使用引导单设 store 与就绪状态 RPC 调用序列**:不予采用,因为这会在 Models 页之外,再建立一套客户端解释,用于判定提供方身份、设置路径、secret 槽位的伴随信息、凭据引用及失效事件顺序。
 **为首次使用引导单设 store 与就绪状态 RPC 调用序列**:不予采用,因为这会在 Models 页之外,再建立一套客户端解释,用于判定提供方身份、设置路径、secret 槽位的伴随信息、凭据引用及失效事件顺序。
 
 
-**在首次使用引导中增设第二个 API key 编辑器**:不予采用,因为 Models 页已为这一状态渲染 DeepSeek 设置卡片。复制其中的 secret 草稿、写入错误处理和已配置状态收敛会增加第二个安全敏感的 UI,却不会带来新的用户能力。
+**在首次使用引导中单独实现 API key 表单**:不予采用,因为这会复制 Models 编辑器的 secret 草稿、校验、错误和已配置状态收敛。弹窗改为以受限模式渲染既有 `ProviderEditor`。
 
 
 **把 API key 写入提供方设置**:不予采用,因为字面量 secret 会进入设置变更路径,而整个分节替换无法安全重建脱敏值。凭据存储已经是产品 seam,并能立即发出失效事件。
 **把 API key 写入提供方设置**:不予采用,因为字面量 secret 会进入设置变更路径,而整个分节替换无法安全重建脱敏值。凭据存储已经是产品 seam,并能立即发出失效事件。
 
 
@@ -30,4 +30,4 @@ Status: implemented
 
 
 ## 后果
 ## 后果
 
 
-有序流程从产品声明页开始,无需重启即可引导用户前往随产品提供的适配器已有的编辑器:无密钥浏览器测试在隔离的 harness 家目录下启动真实 Web 组合,确认声明后依照 DeepSeek 页面前往 Models,通过该页面把生成的密钥存入该目录的 `.credentials.yaml`,验证密钥未进入 DOM、ARIA 或浏览器控制台输出,并确认运行中的页面报告已配置。完整的无密钥 Web 回放也固定了同 id 的不可配置回放路由不会阻塞无关流程。纯就绪状态测试与 React 测试固化了受管文件凭据与进程环境凭据、提供方与能力缺失、导航、取消、外部失效和协调器移交。该流程直接继承配置平面已记录的基础限制,不会另加局部的机密存储、脱敏或设置替换变通方案。
+有序流程从产品声明页开始,无需重启即可进入行内密钥表单:无密钥浏览器测试在隔离的 harness 家目录下启动真实 Web 组合,确认声明后从共用弹窗把生成的密钥存入该目录的 `.credentials.yaml`,验证密钥未进入 DOM、ARIA 或浏览器控制台输出,并确认普通 Models 页面报告已配置。完整的无密钥 Web 回放也固定了同 id 的不可配置回放路由不会阻塞无关流程。纯就绪状态测试与 React 测试固化了受管文件凭据与进程环境凭据、提供方与能力缺失、取消、外部失效和协调器移交。该流程直接继承配置平面已记录的基础限制,不会另加局部的机密存储、脱敏或设置替换变通方案。

+ 2 - 2
.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md
-2026-07-30-versioned-gui-welcome-onboarding.md: 7a8c5c5b9ba1ec62ad8dd0f5bdf67c516ba0ddec
-2026-07-30-versioned-gui-welcome-onboarding.zh.md: 646080b893bb6264a3a92f49400cb9207e29755b
+2026-07-30-versioned-gui-welcome-onboarding.md: 9c8684c0510c50c28e8ece53fd794844c771e496
+2026-07-30-versioned-gui-welcome-onboarding.zh.md: 52c35bf46e40afca1248bcbb94f38d7154f138a4

+ 5 - 5
.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md

@@ -10,13 +10,13 @@ The GUI's credential onboarding begins with a DeepSeek-specific readiness check,
 
 
 ## Decision
 ## Decision
 
 
-**The Settings shell coordinates ordered steps.** `settings.onboarding` remains a root-scoped list, but `ui-settings` projects its entry ids and order into one coordinator and mounts only the first incomplete step. The active registrant receives `complete()` and `openSection(id)`; no later step mounts until ownership transfers. `ui-settings-models` registers the conditional DeepSeek readiness and credential-routing step at order `0`, the only shipped occupant since the [first-run beta notice removal](../simplification/2026-08-13-remove-first-run-beta-notice.md).
+**The Settings shell coordinates ordered steps.** `settings.onboarding` remains a root-scoped list, but `ui-settings` projects its entry ids and order into one coordinator and mounts only the first incomplete step. The active registrant receives `complete()` and `openSection(id)`; no later step mounts until ownership transfers. `ui-settings-models` now registers the restored welcome notice at order `-100` and the conditional DeepSeek credential step at order `0`; their current shared presentation is owned by the [shared-modal onboarding decision](2026-08-13-shared-modal-product-onboarding.md).
 
 
-**The product welcome step is removed.** The versioned notice, its copy owner, and its acknowledgement store shipped from this decision until the [first-run beta notice removal](../simplification/2026-08-13-remove-first-run-beta-notice.md), which owns the removal rationale; `ui-settings-general` seats no onboarding step.
+**The product welcome step is versioned and feature-owned.** The notice was historically removed by the [first-run beta notice removal](../simplification/2026-08-13-remove-first-run-beta-notice.md) and is now restored in `ui-settings-models` with new testing-stage copy. `ui-settings-general` still seats no onboarding step; the plugin that owns both current steps also owns the copy, store, and shared modal.
 
 
-**The durable `ui-onboarding` section outlives the notice.** The Host half registers it in the user-settings seam under the active `$DSH_HOME/settings.yaml`; its `welcomeNoticeVersion` field keeps stored acknowledgements valid and has no reader. The connection plugin publishes whether the current page uses a loopback authority as `ctx.connection.isLoopback`; hostname classification remains internal to the connection package, and other client plugins consume the service state instead of importing its implementation. The API proxy exposes this one product namespace through a closed allowlist beside configurable-provider namespaces, without treating its changes as model-catalog invalidations.
+**The durable `ui-onboarding` section owns acknowledgement.** The Host half registers it in the user-settings seam under the active `$DSH_HOME/settings.yaml`; the current welcome store reads and writes `welcomeNoticeVersion` through the existing public settings API. The connection plugin publishes whether the current page uses a loopback authority as `ctx.connection.isLoopback`; hostname classification remains internal to the connection package, and other client plugins consume the service state instead of importing its implementation. The API proxy exposes this one product namespace through a closed allowlist beside configurable-provider namespaces, without treating its changes as model-catalog invalidations.
 
 
-**Onboarding temporarily owns the viewport as one continuous stage.** A solid product surface replaces the complete application view through a body-level portal and marks the underlying app root inert; the exact required mask remains mounted behind that surface with `position:absolute`, zero left/right/bottom offsets, `top:80px`, `rgba(0, 0, 0, 0.24)`, and `backdrop-filter: blur(2px)`. Onboarding steps render as successive pages in this stage instead of independent modals, reusing the Web UI's black `BrandWordmark`; the conditional credential setup is the only shipped page.
+**Visible onboarding uses one shared modal contract.** Both current steps render through the same body-portaled `OnboardingModal`, and the underlying app root stays inert only while a dialog is visible. The shell renders no wrapper while a step loads its private facts. Explicit actions transfer coordinator ownership; Escape and mask clicks do not acknowledge or skip a step.
 
 
 ## Alternatives considered
 ## Alternatives considered
 
 
@@ -30,4 +30,4 @@ The GUI's credential onboarding begins with a DeepSeek-specific readiness check,
 
 
 ## Consequences
 ## Consequences
 
 
-A fresh profile proceeds directly to provider-specific onboarding: the conditional DeepSeek step mounts when its credential is missing, and an already configured credential shows no onboarding page at all. Focused store and React tests pin coordinator ordering, conditional DeepSeek transfer, and HMR cleanup. The real Chromium scenario boots the shipped Web composition with an isolated harness home, verifies the exact mask geometry and computed styles while the credential step owns the viewport, continues into missing-credential setup, and checks the browser console.
+A fresh profile sees the current testing-stage notice, then the conditional DeepSeek key dialog when no provider is usable. Focused store and React tests pin exact-version acknowledgement, coordinator ordering, conditional transfer, shared modal behavior, and HMR cleanup. The real Chromium scenario boots the shipped Web composition with an isolated harness home, verifies both dialogs, writes the key through the existing credential boundary, and checks that no secret reaches the DOM, ARIA, or browser console.

+ 5 - 5
.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.zh.md

@@ -10,13 +10,13 @@ GUI 的凭据引导从 DeepSeek 专用的就绪状态检查开始,但内部测
 
 
 ## 决策
 ## 决策
 
 
-**设置外壳协调有序步骤。** `settings.onboarding` 仍是根作用域 list,但 `ui-settings` 会把其中各条目的 id 和顺序投影到一个协调器中,并且只挂载第一个未完成的步骤。当前注册方会收到 `complete()` 和 `openSection(id)`;所有权转移前,不会挂载后续步骤。`ui-settings-models` 注册顺序为 `0` 的 DeepSeek 条件式就绪状态与凭据跳转步骤,自[移除首次启动内测声明](../simplification/2026-08-13-remove-first-run-beta-notice.md)起,它是当前唯一的注册方。
+**设置外壳协调有序步骤。** `settings.onboarding` 仍是根作用域 list,但 `ui-settings` 会把其中各条目的 id 和顺序投影到一个协调器中,并且只挂载第一个未完成的步骤。当前注册方会收到 `complete()` 和 `openSection(id)`;所有权转移前,不会挂载后续步骤。`ui-settings-models` 现在以顺序 `-100` 注册恢复后的欢迎声明,以顺序 `0` 注册 DeepSeek 条件式凭据步骤;两者当前的共用展示由[共用弹窗引导决策](2026-08-13-shared-modal-product-onboarding.md)持有。
 
 
-**产品欢迎步骤已移除。** 版本化通知、其文案所有者文件和确认 store 自本决策起随产品发布,直至[移除首次启动内测声明](../simplification/2026-08-13-remove-first-run-beta-notice.md);移除理由由该 note 持有。`ui-settings-general` 不再注册任何引导步骤。
+**产品欢迎步骤按版本管理并归功能插件所有。** 该声明曾由[移除首次启动内测声明](../simplification/2026-08-13-remove-first-run-beta-notice.md)历史决策移除,现在以新的测试阶段文案恢复在 `ui-settings-models` 中。`ui-settings-general` 仍不注册任何引导步骤;持有当前两个步骤的插件也持有文案、store 和共用弹窗。
 
 
-**持久化的 `ui-onboarding` 分节在通知移除后继续存在。** 宿主端在 user-settings seam 中注册它,存入当前 `$DSH_HOME/settings.yaml`;其中的 `welcomeNoticeVersion` 字段让已存储的确认记录保持有效,没有读取方。connection 插件通过 `ctx.connection.isLoopback` 统一发布当前页面是否使用 loopback authority;hostname 判定留在 connection 包内,其他客户端插件只消费服务状态,而不导入其实现。API Proxy 在可配置提供方 namespace 之外,通过封闭的允许列表暴露这一个产品 namespace,同时不会把它的变更视为模型目录失效事件。
+**持久化的 `ui-onboarding` 分节持有确认状态。** 宿主端在 user-settings seam 中注册它,存入当前 `$DSH_HOME/settings.yaml`;当前欢迎 store 通过既有公开 settings API 读写其中的 `welcomeNoticeVersion`。connection 插件通过 `ctx.connection.isLoopback` 统一发布当前页面是否使用 loopback authority;hostname 判定留在 connection 包内,其他客户端插件只消费服务状态,而不导入其实现。API Proxy 在可配置提供方 namespace 之外,通过封闭的允许列表暴露这一个产品 namespace,同时不会把它的变更视为模型目录失效事件。
 
 
-**引导流程会暂时接管视口,形成一个连续阶段。** 纯色产品界面通过挂载到 `body` 的 portal 取代完整的应用视图,并将底层应用根节点标记为 inert;严格符合要求的遮罩仍挂载在该界面后方,并保留 `position:absolute`、left/right/bottom 偏移量为零、`top:80px`、`rgba(0, 0, 0, 0.24)` 和 `backdrop-filter: blur(2px)`。引导步骤在这一阶段中依次呈现,而不是各自作为独立的模态窗口,并复用 Web UI 的黑色 `BrandWordmark`;按条件显示的凭据设置页是当前唯一的页面。
+**可见引导使用同一个弹窗契约。** 当前两个步骤都通过 body portal 的同一个 `OnboardingModal` 渲染,且只在弹窗可见期间把下层应用根节点设为 inert。步骤加载私有事实时,外壳不渲染任何包装。明确操作会移交协调器所有权;Escape 和点击遮罩都不会确认或跳过步骤。
 
 
 ## 曾考虑的替代方案
 ## 曾考虑的替代方案
 
 
@@ -30,4 +30,4 @@ GUI 的凭据引导从 DeepSeek 专用的就绪状态检查开始,但内部测
 
 
 ## 后果
 ## 后果
 
 
-全新 profile 直接进入提供方专用引导:DeepSeek 步骤仅在其凭据缺失时挂载,凭据已配置时不会出现任何引导页面。针对性的 store 与 React 测试固化了协调器顺序、按条件移交 DeepSeek 步骤和 HMR(热模块替换)清理行为。真实 Chromium 场景会使用隔离的 harness 家目录启动随产品提供的 Web 组合,在凭据步骤占据视口时验证遮罩的精确几何尺寸和计算样式,继续进入凭据缺失设置流程,并检查浏览器控制台。
+全新 profile 会先看到当前测试阶段声明;当没有任何可用提供方时,再看到条件式 DeepSeek 密钥弹窗。定向 store 与 React 测试固定精确版本确认、协调器顺序、条件式移交、共用弹窗行为与 HMR 清理。真实 Chromium 场景会在隔离的 harness 家目录下启动已发布 Web 组合,验证两个弹窗,通过既有凭据边界写入密钥,并检查 secret 未进入 DOM、ARIA 或浏览器控制台。

+ 2 - 2
.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md
-2026-08-10-telemetry-default-off.md: b70fdbc64074936ae910cba305a59111d6f9b572
-2026-08-10-telemetry-default-off.zh.md: abb5cafa9f877659dca1d53c0be9df9414799cf9
+2026-08-10-telemetry-default-off.md: 3f56817c9c23ec55f2173b66fa915ab05646b2a7
+2026-08-10-telemetry-default-off.zh.md: aa7b640f292713aa4e598f5ea1194f806dd6ae0d

+ 1 - 1
.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md

@@ -14,7 +14,7 @@ Both feeds use `DSH_TELEMETRY_MODE` as their positive consent setting. Unset and
 
 
 The dsh-sdk launcher reads the same variable without parsing `cordis.yml` or booting Cordis. `FULL` permits reporting; `FEEDBACK_ONLY`, `DISABLED`, unset, and empty values deny it. Consent is frozen from the launching environment before the command runs, because `dsh-sdk start` loads a project `.env` and project code can mutate `process.env`: resolving afterwards would let a project grant reporting of its own configuration, which the [configuration source ownership decision](../architecture/2026-08-04-configuration-source-ownership.md) denies for the whole `DSH_*` namespace. An unsupported mode denies rather than throwing at that boundary, since telemetry may never change a command's result. This rule superseded the default-on launcher consent before the launcher and its proposal were deleted by the [SDK project toolchain removal](../simplification/2026-08-11-remove-sdk-project-toolchain.md).
 The dsh-sdk launcher reads the same variable without parsing `cordis.yml` or booting Cordis. `FULL` permits reporting; `FEEDBACK_ONLY`, `DISABLED`, unset, and empty values deny it. Consent is frozen from the launching environment before the command runs, because `dsh-sdk start` loads a project `.env` and project code can mutate `process.env`: resolving afterwards would let a project grant reporting of its own configuration, which the [configuration source ownership decision](../architecture/2026-08-04-configuration-source-ownership.md) denies for the whole `DSH_*` namespace. An unsupported mode denies rather than throwing at that boundary, since telemetry may never change a command's result. This rule superseded the default-on launcher consent before the launcher and its proposal were deleted by the [SDK project toolchain removal](../simplification/2026-08-11-remove-sdk-project-toolchain.md).
 
 
-The [CLI reference README](../../../../apps/cli/reference/README.md) documents the deployment stance: Session Log upload is off by default, `DSH_TELEMETRY_MODE=FEEDBACK_ONLY` and `DSH_TELEMETRY_MODE=FULL` are the two opt-in choices, and explicitly enabled exports can contain complete session content. The product itself presents no prompt about enabling telemetry since the [first-run beta notice removal](../simplification/2026-08-13-remove-first-run-beta-notice.md).
+The [CLI reference README](../../../../apps/cli/reference/README.md) documents the deployment stance: Session Log upload is off by default, `DSH_TELEMETRY_MODE=FEEDBACK_ONLY` and `DSH_TELEMETRY_MODE=FULL` are the two opt-in choices, and explicitly enabled exports can contain complete session content. The restored [testing-stage onboarding notice](2026-08-13-shared-modal-product-onboarding.md) contains no telemetry copy, so the product still presents no prompt about enabling upload.
 
 
 ## Alternatives considered
 ## Alternatives considered
 
 

+ 1 - 1
.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md

@@ -14,7 +14,7 @@ DeepSeek Harness 有两路出站遥测数据流。在内测阶段,共享基础
 
 
 dsh-sdk 启动器读取同一变量,不解析 `cordis.yml`,也不启动 Cordis。`FULL` 允许上报;`FEEDBACK_ONLY`、`DISABLED`、未设置和空值都会拒绝。授权在命令执行前从启动环境冻结:`dsh-sdk start` 会加载项目 `.env`,项目代码也能修改 `process.env`,若在执行后解析,项目便能自行授权上报其自身配置,而[配置来源所有权决策](../architecture/2026-08-04-configuration-source-ownership.md)对整个 `DSH_*` 命名空间禁止这种行为。在该边界上,不受支持的模式按拒绝处理而非抛出,因为遥测不得改变命令结果。此规则在启动器及其提案被[SDK 项目工具链移除决策](../simplification/2026-08-11-remove-sdk-project-toolchain.md)删除之前,仅取代了启动器默认允许上报的规则。
 dsh-sdk 启动器读取同一变量,不解析 `cordis.yml`,也不启动 Cordis。`FULL` 允许上报;`FEEDBACK_ONLY`、`DISABLED`、未设置和空值都会拒绝。授权在命令执行前从启动环境冻结:`dsh-sdk start` 会加载项目 `.env`,项目代码也能修改 `process.env`,若在执行后解析,项目便能自行授权上报其自身配置,而[配置来源所有权决策](../architecture/2026-08-04-configuration-source-ownership.md)对整个 `DSH_*` 命名空间禁止这种行为。在该边界上,不受支持的模式按拒绝处理而非抛出,因为遥测不得改变命令结果。此规则在启动器及其提案被[SDK 项目工具链移除决策](../simplification/2026-08-11-remove-sdk-project-toolchain.md)删除之前,仅取代了启动器默认允许上报的规则。
 
 
-[CLI reference README](../../../../apps/cli/reference/README.md) 记录了这一部署口径:会话日志上传默认关闭,`DSH_TELEMETRY_MODE=FEEDBACK_ONLY` 和 `DSH_TELEMETRY_MODE=FULL` 是两种显式启用选项,显式开启后的导出可能包含完整会话内容。自[移除首次启动内测声明](../simplification/2026-08-13-remove-first-run-beta-notice.md)起,产品本身不提供任何关于开启遥测的提示。
+[CLI reference README](../../../../apps/cli/reference/README.md) 记录了这一部署口径:会话日志上传默认关闭,`DSH_TELEMETRY_MODE=FEEDBACK_ONLY` 和 `DSH_TELEMETRY_MODE=FULL` 是两种显式启用选项,显式开启后的导出可能包含完整会话内容。恢复后的[测试阶段引导声明](2026-08-13-shared-modal-product-onboarding.md)不包含遥测文案,因此产品仍不提供任何关于开启上传的提示。
 
 
 ## 考虑过的替代方案
 ## 考虑过的替代方案
 
 

+ 6 - 0
.agents/notes/implemented/feature/2026-08-13-shared-modal-product-onboarding.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-13-shared-modal-product-onboarding.md
+2026-08-13-shared-modal-product-onboarding.md: 771b6675ea170c5001fca7c880f6c246e19fe7f1
+2026-08-13-shared-modal-product-onboarding.zh.md: ebbcb73344b531e90a6d27641f25fa9961d572ac

+ 33 - 0
.agents/notes/implemented/feature/2026-08-13-shared-modal-product-onboarding.md

@@ -0,0 +1,33 @@
+# Agent Note: Shared-modal product onboarding
+
+Status: implemented
+
+English | [中文](2026-08-13-shared-modal-product-onboarding.zh.md)
+
+## Problem
+
+First-run onboarding mixed two interaction models: a viewport takeover for product context and a credential prompt that redirected users into Settings before they could enter a key. That made a short, ordered flow feel like two unrelated surfaces and left onboarding UI ownership split across packages. The product still needs a versioned testing-stage notice before provider setup, but restoring it must not add a second independent overlay or change the Host settings and credential boundaries.
+
+## Decision
+
+**One existing client Cordis plugin owns both shipped steps.** `ui-settings-models` registers `welcome-notice` at order `-100` and `deepseek-official` at order `0` in `settings.onboarding`. The shell continues to mount only the first incomplete entry, so the dialogs cannot stack. No additional client package or plugin row is introduced.
+
+**Both steps share one modal component.** `OnboardingModal` wraps the existing ui-primitives `Modal`, supplies the common title and content geometry, and owns `#root` inert for exactly the visible lifetime. Escape and mask clicks do not silently complete mandatory onboarding; each step exposes only its explicit actions. A step still loading private facts returns `null`, so it paints and blocks nothing.
+
+**The welcome notice reuses the existing durable field.** Its exact copy and version live in `onboarding-copy.ts`. Loopback clients compare and write `ui-onboarding.welcomeNoticeVersion` through the existing settings API, and only Continue acknowledges the current version. Remote clients retain the existing process-local fallback because the settings namespace is loopback-only. No Host schema, API-proxy allowlist, or persistence implementation changes.
+
+**The credential dialog reuses the existing editor and write boundary.** The Models join still decides whether any provider is usable. When the official DeepSeek reference is writable and missing, `ProviderEditor` renders in credential-only mode inside the shared modal. It validates the key and calls the existing `credentials.set`; it does not mutate provider settings. Save and continue waits for the write and refreshed readiness, while Configure later completes only the current coordinator pass.
+
+## Alternatives considered
+
+**Separate client plugins for the notice and credential steps.** Rejected because the product asks for one client Cordis plugin and the two surfaces share copy, ordering, modal chrome, and invalidation ownership.
+
+**Move acknowledgement or credential logic into a new Host API.** Rejected because both backend contracts already express the required state and writes. A new endpoint would widen scope without changing user capability.
+
+**Keep the credential step as navigation into Models.** Rejected because the key is the only required first-run field, and the existing editor can expose that write safely without sending the user through a second dialog.
+
+**Keep the former full-viewport stage.** Rejected because the requested onboarding is a pair of dialogs over the current app, and the common ui-primitives modal already provides the appropriate portal, mask, and accessibility contract.
+
+## Consequences
+
+A fresh loopback profile sees the specified internal-testing notice, then an inline DeepSeek key dialog only when no provider is usable. Acknowledgement remains versioned in `settings.yaml`, secrets remain write-only in `.credentials.yaml`, and already-ready or unsupported deployments render no onboarding chrome while readiness loads. The Models package now owns product-onboarding presentation as well as provider configuration; its README and browser coverage make that broader responsibility explicit. This decision restores a concise testing-stage notice after the historical [full-viewport beta notice removal](../simplification/2026-08-13-remove-first-run-beta-notice.md) without restoring that notice's telemetry copy or takeover layout.

+ 33 - 0
.agents/notes/implemented/feature/2026-08-13-shared-modal-product-onboarding.zh.md

@@ -0,0 +1,33 @@
+# Agent Note: 共用弹窗的产品引导
+
+Status: implemented
+
+[English](2026-08-13-shared-modal-product-onboarding.md) | 中文
+
+## 问题
+
+首次使用引导混用了两种交互:产品背景说明占满整个视口,凭据提示则先把用户带进「设置」,之后才能输入密钥。一个很短的有序流程因此像两个互不相关的界面,引导 UI 的归属也分散在多个包中。产品仍需要在提供方配置之前显示版本化的测试阶段声明,但恢复它不能增加第二个独立浮层,也不能改变 Host 的设置与凭据边界。
+
+## 决策
+
+**由同一个既有 client Cordis 插件持有两个已发布步骤。** `ui-settings-models` 在 `settings.onboarding` 中以顺序 `-100` 注册 `welcome-notice`,以顺序 `0` 注册 `deepseek-official`。外壳仍然只挂载第一个未完成条目,因此两个弹窗不会堆叠。不新增 client 包或插件配置行。
+
+**两个步骤共用同一个弹窗组件。** `OnboardingModal` 包装既有 ui-primitives `Modal`,提供统一的标题和内容布局,并只在可见期间持有 `#root` 的 inert 状态。Escape 和遮罩点击不会静默完成强制引导;每个步骤只暴露自己的明确操作。步骤仍在加载私有事实时返回 `null`,因此不会绘制或阻塞界面。
+
+**欢迎声明复用既有持久化字段。** 完整文案与版本由 `onboarding-copy.ts` 持有。回环客户端通过既有 settings API 比较和写入 `ui-onboarding.welcomeNoticeVersion`,且只有点击「继续」才确认当前版本。远程客户端继续使用既有的进程内回退,因为该 settings namespace 仅限回环访问。不改变 Host schema、API Proxy 允许列表或持久化实现。
+
+**凭据弹窗复用既有编辑器与写入边界。** Models 联接仍负责判断是否已有任意可用提供方。当 DeepSeek 官方引用可写但缺失时,`ProviderEditor` 以仅凭据模式渲染在共用弹窗中。它校验密钥并调用既有 `credentials.set`,不会修改提供方设置。「保存并继续」会等待写入与就绪状态刷新;「稍后配置」只完成协调器当前这一轮。
+
+## 曾考虑的替代方案
+
+**让声明与凭据步骤分别成为 client 插件。** 不采用:产品要求只使用一个 client Cordis 插件,且两个界面共享文案、顺序、弹窗框架与失效刷新归属。
+
+**把确认或凭据逻辑移入新的 Host API。** 不采用:两个既有后端契约已经能表达所需状态与写入;新增 endpoint 只会扩大范围,不会增加用户能力。
+
+**继续从凭据步骤跳转到 Models。** 不采用:首次使用唯一必填的是密钥,既有编辑器可以安全暴露这项写入,无需再把用户送进第二个对话框。
+
+**保留此前占满视口的展示层。** 不采用:本次需要的是叠加在当前应用上的两个弹窗,既有 ui-primitives modal 已提供合适的 portal、遮罩与无障碍契约。
+
+## 后果
+
+新的回环 profile 会先看到指定的内测声明;仅当没有任何可用提供方时,之后才会出现行内 DeepSeek 密钥弹窗。确认仍按版本写入 `settings.yaml`,secret 仍以只写方式存入 `.credentials.yaml`,已就绪或无法修复的部署在加载判定期间不会渲染任何引导框架。Models 包现在同时持有产品引导展示与提供方配置;README 和浏览器覆盖明确记录了这项扩展后的职责。本决策在历史上的[全屏内测声明移除](../simplification/2026-08-13-remove-first-run-beta-notice.md)之后恢复简洁的测试阶段声明,但不会恢复那份声明中的遥测文案或接管式布局。

+ 2 - 2
.agents/notes/implemented/simplification/2026-08-13-remove-first-run-beta-notice.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-13-remove-first-run-beta-notice.md
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-13-remove-first-run-beta-notice.md
-2026-08-13-remove-first-run-beta-notice.md: 69f47be445ebfa9f10e6ba1217e4674a98c747f1
-2026-08-13-remove-first-run-beta-notice.zh.md: 51c91918baaf1ae3db28434d7d86093fbd5ffb4e
+2026-08-13-remove-first-run-beta-notice.md: 21396eb9cc3e0e766238115967e354abe1032bd2
+2026-08-13-remove-first-run-beta-notice.zh.md: 2818343804e88e7114f5a6298393780b0fdcaf24

+ 2 - 2
.agents/notes/implemented/simplification/2026-08-13-remove-first-run-beta-notice.md

@@ -10,7 +10,7 @@ Every GUI first launch opened with a full-viewport internal-test statement (内
 
 
 ## Decision
 ## Decision
 
 
-The first-run notice is removed from the assembled product rather than reworded. `ui-settings-general` seats no `settings.onboarding` step; the notice component, its durable acknowledgement store, its copy owner, and its locale keys are deleted. The `settings.onboarding` coordinator and its takeover stage stay ([ordered onboarding](../feature/2026-07-30-versioned-gui-welcome-onboarding.md)), and the conditional DeepSeek credential step is the only shipped occupant. The Host half still registers the `ui-onboarding` settings namespace: its `welcomeNoticeVersion` field keeps acknowledgements already stored in `$DSH_HOME/settings.yaml` valid, and nothing reads or writes it. Telemetry opt-in remains an explicit deployment environment choice documented in the [CLI reference README](../../../../apps/cli/reference/README.md); the product presents no prompt about enabling it.
+This decision removed the first-run notice from the assembled product rather than rewording it. `ui-settings-general` seated no `settings.onboarding` step; the notice component, acknowledgement store, copy owner, and locale keys were deleted, while the Host kept the `ui-onboarding` namespace so stored documents remained valid. The later [shared-modal product onboarding](../feature/2026-08-13-shared-modal-product-onboarding.md) restores a new concise testing-stage notice in `ui-settings-models`, reusing that field and backend contract without restoring the removed takeover layout or telemetry instructions. Telemetry opt-in remains an explicit deployment environment choice documented in the [CLI reference README](../../../../apps/cli/reference/README.md); the restored notice says nothing about enabling it.
 
 
 ## Alternatives considered
 ## Alternatives considered
 
 
@@ -22,4 +22,4 @@ The first-run notice is removed from the assembled product rather than reworded.
 
 
 ## Consequences
 ## Consequences
 
 
-A fresh profile boots into the credential step when the DeepSeek credential is missing and directly into the product otherwise; no full-viewport notice precedes either. The assembled onboarding scenario starts at the credential step, the remote-notice scenario is deleted with the feature, and the goal-bar fixture keeps the settings shell disabled because the fixture API client rejects settings traffic. Restoring a first-run notice requires a new onboarding registration and a new versioned field; the retained namespace does not resurrect the old acknowledgement semantics.
+This removal eliminated the full-viewport notice and its telemetry copy. The later restoration is intentionally a different presentation and copy revision: a shared modal precedes the inline credential dialog, the remote scenario again covers process-local acknowledgement, and the existing `welcomeNoticeVersion` field records the new copy version. The historical telemetry prompt remains absent.

+ 2 - 2
.agents/notes/implemented/simplification/2026-08-13-remove-first-run-beta-notice.zh.md

@@ -10,7 +10,7 @@ GUI 每次首启都会先显示占满视口的内测声明:内部测试的定
 
 
 ## 决策
 ## 决策
 
 
-首启声明从组装后的产品中整体移除,而不是改写。`ui-settings-general` 不再注册任何 `settings.onboarding` 步骤;声明组件、其持久化确认 store、文案所有者文件和 locale 键全部删除。`settings.onboarding` 协调器及其接管式展示阶段保留([有序引导](../feature/2026-07-30-versioned-gui-welcome-onboarding.md)),按条件显示的 DeepSeek 凭据步骤是当前唯一的注册方。宿主端仍注册 `ui-onboarding` 设置 namespace:其中的 `welcomeNoticeVersion` 字段让 `$DSH_HOME/settings.yaml` 中已写入的确认记录保持有效,没有任何代码读取或写入它。遥测的开启仍是显式的部署环境变量选择,记录在 [CLI reference README](../../../../apps/cli/reference/README.md) 中;产品界面不出现任何关于开启遥测的提示。
+本决策当时把首启声明从组装后的产品中整体移除,而不是改写。`ui-settings-general` 不再注册任何 `settings.onboarding` 步骤;声明组件、确认 store、文案所有者文件和 locale 键均被删除,Host 则保留 `ui-onboarding` namespace,使既有设置文档继续有效。后续的[共用弹窗产品引导](../feature/2026-08-13-shared-modal-product-onboarding.md)在 `ui-settings-models` 中恢复了一份新的简洁测试阶段声明,复用该字段与后端契约,但不会恢复已移除的接管式布局或遥测说明。遥测的开启仍是显式的部署环境变量选择,记录在 [CLI reference README](../../../../apps/cli/reference/README.md) 中;恢复后的声明不涉及如何开启遥测。
 
 
 ## 曾考虑的替代方案
 ## 曾考虑的替代方案
 
 
@@ -22,4 +22,4 @@ GUI 每次首启都会先显示占满视口的内测声明:内部测试的定
 
 
 ## 后果
 ## 后果
 
 
-全新 profile 在缺少 DeepSeek 凭据时直接进入凭据步骤,凭据已配置时直接进入产品,两种情况都没有占满视口的声明。组装级引导场景从凭据步骤开始,远程声明场景随功能一并删除,goal-bar fixture 继续禁用设置外壳,因为 fixture API 客户端会拒绝设置请求。将来若要恢复首启声明,需要新的引导注册和新的版本化字段;保留的 namespace 不会复活旧的确认语义。
+这次移除消除了占满视口的声明及其遥测文案。后续恢复有意采用不同的展示与文案版本:共用弹窗先于行内凭据弹窗出现,远程场景重新覆盖进程内确认,既有 `welcomeNoticeVersion` 字段记录新的文案版本。历史上的遥测提示仍未恢复。

+ 2 - 2
apps/web/tests/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write apps/web/tests/README.md
 #   pnpm run verify-translation-pairing --write apps/web/tests/README.md
-README.md: 4d7236ef69189845dbc2847289d497fc54c94ae9
-README.zh.md: 21c2f2ce7ccc8fe5314d766c06235453aa292031
+README.md: acb0c300bafe221f6a92f0168908bebf965377b9
+README.zh.md: fd3d950a106375bb038b205415407b4a31bd2b32

+ 2 - 1
apps/web/tests/README.md

@@ -30,7 +30,8 @@ artifact it produces itself.
 When a scenario needs a Client-owned constant or pure function, mirror it here
 When a scenario needs a Client-owned constant or pure function, mirror it here
 instead, next to the commented-out import that names the source module. A drift
 instead, next to the commented-out import that names the source module. A drift
 then surfaces as a missed selector or a stale mirrored value — a loud failure,
 then surfaces as a missed selector or a stale mirrored value — a loud failure,
-never a silent pass.
+never a silent pass. `scaffold.ts` follows this rule for the welcome-notice
+namespace, acknowledgement field, version, and asserted Chinese copy.
 
 
 Two kinds of Client import stand. `assembled-boot.ts` drives the shell itself, so
 Two kinds of Client import stand. `assembled-boot.ts` drives the shell itself, so
 it imports `AppWebEntry` from `@deepseek-ai/dsh-client-web` and the boot-manifest
 it imports `AppWebEntry` from `@deepseek-ai/dsh-client-web` and the boot-manifest

+ 1 - 1
apps/web/tests/README.zh.md

@@ -24,7 +24,7 @@ Client face,而该 face 必须等 Host tsdown 生成 `@deepseek-ai/dsh-goal/re
 
 
 当某个场景需要 Client 持有的常量或纯函数时,改为在此处镜像一份,并紧挨着一条注释掉的
 当某个场景需要 Client 持有的常量或纯函数时,改为在此处镜像一份,并紧挨着一条注释掉的
 import 点明源模块。这样漂移会表现为选择器未命中或镜像值过期——是响亮的失败,绝不会是静默
 import 点明源模块。这样漂移会表现为选择器未命中或镜像值过期——是响亮的失败,绝不会是静默
-通过。
+通过。`scaffold.ts` 按此规则镜像欢迎声明的 namespace、确认字段、版本和被断言的中文文案。
 
 
 有两类 Client import 是长期成立的。`assembled-boot.ts` 驱动 shell 本身,因此它从
 有两类 Client import 是长期成立的。`assembled-boot.ts` 驱动 shell 本身,因此它从
 `@deepseek-ai/dsh-client-web` import `AppWebEntry`、从
 `@deepseek-ai/dsh-client-web` import `AppWebEntry`、从

+ 1 - 1
apps/web/tests/goal-bar.e2e.ts

@@ -26,7 +26,7 @@ describe('web e2e: goal bar clear convergence', () => {
   let tripwire: ReturnType<typeof watchConsole>
   let tripwire: ReturnType<typeof watchConsole>
 
 
   beforeAll(async () => {
   beforeAll(async () => {
-    scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY })
+    scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY, welcomeNoticePending: true })
     browser = await chromium.launch()
     browser = await chromium.launch()
     page = await newEnglishPage(browser)
     page = await newEnglishPage(browser)
     tripwire = watchConsole(page)
     tripwire = watchConsole(page)

+ 68 - 52
apps/web/tests/onboarding-deepseek-config.e2e.ts

@@ -1,6 +1,7 @@
 // Keyless browser e2e: the shipped DeepSeek adapter stays mounted while its
 // Keyless browser e2e: the shipped DeepSeek adapter stays mounted while its
-// credential is absent, onboarding routes to the real Models editor, and its
-// write lands in an isolated harness home without a reload or model call.
+// credential is absent, both ordered steps share the shipped modal chrome,
+// and the inline key write lands in an isolated harness home without a reload
+// or model call.
 import { randomBytes } from 'node:crypto'
 import { randomBytes } from 'node:crypto'
 import { readFile } from 'node:fs/promises'
 import { readFile } from 'node:fs/promises'
 import { fileURLToPath } from 'node:url'
 import { fileURLToPath } from 'node:url'
@@ -8,13 +9,17 @@ import { join } from 'node:path'
 import type { Browser, Page } from 'playwright'
 import type { Browser, Page } from 'playwright'
 import { chromium } from 'playwright'
 import { chromium } from 'playwright'
 import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
 import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import { settingsNamespace } from '@deepseek-ai/dsh-settings'
 import {
 import {
   acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
   acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
   launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
   launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
+  WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_COPY, WELCOME_NOTICE_SETTINGS_NAMESPACE,
+  WELCOME_NOTICE_VERSION,
 } from './scaffold.ts'
 } from './scaffold.ts'
 import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts'
 import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts'
 
 
 const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/onboarding-deepseek-config', import.meta.url))
 const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/onboarding-deepseek-config', import.meta.url))
+const WELCOME_EXPECTED = join(SNAPSHOT_DIR, 'welcome.expected.md')
 const MISSING_EXPECTED = join(SNAPSHOT_DIR, 'missing.expected.md')
 const MISSING_EXPECTED = join(SNAPSHOT_DIR, 'missing.expected.md')
 const MODELS_EXPECTED = join(SNAPSHOT_DIR, 'models.expected.md')
 const MODELS_EXPECTED = join(SNAPSHOT_DIR, 'models.expected.md')
 const MODE = webSnapshotMode()
 const MODE = webSnapshotMode()
@@ -27,7 +32,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
   const browserConsole: string[] = []
   const browserConsole: string[] = []
 
 
   beforeAll(async () => {
   beforeAll(async () => {
-    scaffold = await launchWebScaffold({ deepSeekMissingCredential: true })
+    scaffold = await launchWebScaffold({ deepSeekMissingCredential: true, welcomeNoticePending: true })
     browser = await chromium.launch()
     browser = await chromium.launch()
     // The scenario asserts the shipped Chinese copy, so the browser asks for it.
     // The scenario asserts the shipped Chinese copy, so the browser asks for it.
     page = await browser.newPage({ viewport: { width: 1440, height: 960 }, locale: ZH_BROWSER_LOCALE })
     page = await browser.newPage({ viewport: { width: 1440, height: 960 }, locale: ZH_BROWSER_LOCALE })
@@ -44,53 +49,40 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
 
 
   it('stores a key write-only and observes configured state without restarting', async () => {
   it('stores a key write-only and observes configured state without restarting', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-deepseek-config'))
     onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-deepseek-config'))
-    const credentialStep = page.getByRole('region', { name: '添加一个 API Key 开始使用' })
-    await credentialStep.waitFor({ timeout: 15_000 })
+    const welcome = page.getByRole('dialog', { name: WELCOME_NOTICE_COPY.zh.title })
+    await welcome.waitFor({ timeout: 15_000 })
     expect(await page.locator('#root').evaluate(root => (root as HTMLElement).inert)).toBe(true)
     expect(await page.locator('#root').evaluate(root => (root as HTMLElement).inert)).toBe(true)
+    for (const paragraph of WELCOME_NOTICE_COPY.zh.body.split('\n\n')) {
+      expect(await welcome.getByText(paragraph, { exact: true }).count()).toBe(1)
+    }
+    expect(await welcome.getByRole('button').allTextContents()).toEqual([
+      WELCOME_NOTICE_COPY.zh.continueLabel,
+    ])
+    const welcomeAria = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
+    await compareOrRefreshGolden(WELCOME_EXPECTED, welcomeAria, MODE)
+
+    // Observation is not acknowledgement: the exact version is persisted
+    // only by the explicit action, so a reload still presents this dialog.
+    const firstReloadWarnings = tripwire.warnings.length
+    await page.reload({ waitUntil: 'load' })
+    acknowledgeReloadConnectionLoss(tripwire, firstReloadWarnings)
+    await welcome.waitFor({ timeout: 15_000 })
 
 
-    const mask = page.locator('[class*="onboardingMask"]')
-    expect(await mask.count()).toBe(1)
-    const maskStyles = await mask.evaluate((mask) => {
-      const style = getComputedStyle(mask)
-      const rect = mask.getBoundingClientRect()
-      return {
-        position: style.position,
-        left: style.left,
-        right: style.right,
-        top: style.top,
-        bottom: style.bottom,
-        background: style.backgroundColor,
-        backdropFilter: style.backdropFilter,
-        rect: { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom },
-      }
-    })
-    expect(maskStyles).toEqual({
-      position: 'absolute',
-      left: '0px',
-      right: '0px',
-      top: '80px',
-      bottom: '0px',
-      background: 'rgba(0, 0, 0, 0.24)',
-      backdropFilter: 'blur(2px)',
-      rect: { left: 0, top: 80, right: 1440, bottom: 960 },
-    })
+    await welcome.getByRole('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel }).click()
+    await welcome.waitFor({ state: 'detached', timeout: 15_000 })
 
 
-    expect(await credentialStep.getByRole('textbox').count()).toBe(0)
-    const initial = await captureStableAria(page, '[role="region"]', scaffold.workspaceCwd)
-    await compareOrRefreshGolden(MISSING_EXPECTED, initial, MODE)
-
-    await credentialStep.getByRole('button', { name: '前往配置' }).click()
-    await credentialStep.waitFor({ state: 'detached', timeout: 15_000 })
-    const settings = page.getByRole('dialog', { name: '设置' })
-    await settings.waitFor({ timeout: 10_000 })
-    expect(await page.locator('#root').evaluate(root => (root as HTMLElement).inert)).toBe(false)
-    const keyInput = settings.getByLabel('API 密钥', { exact: true })
+    const credentialStep = page.getByRole('dialog', { name: '添加一个 API Key 开始使用' })
+    await credentialStep.waitFor({ timeout: 15_000 })
+    const keyInput = credentialStep.getByLabel('API 密钥', { exact: true })
     await keyInput.waitFor({ timeout: 10_000 })
     await keyInput.waitFor({ timeout: 10_000 })
+    const initial = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
+    await compareOrRefreshGolden(MISSING_EXPECTED, initial, MODE)
 
 
     const secret = `dsh_onboarding_${randomBytes(12).toString('hex')}`
     const secret = `dsh_onboarding_${randomBytes(12).toString('hex')}`
     await keyInput.fill(secret)
     await keyInput.fill(secret)
-    await settings.getByRole('button', { name: '保存', exact: true }).click()
-    await keyInput.waitFor({ state: 'detached', timeout: 15_000 })
+    await credentialStep.getByRole('button', { name: '保存并继续' }).click()
+    await credentialStep.waitFor({ state: 'detached', timeout: 15_000 })
+    expect(await page.locator('#root').evaluate(root => (root as HTMLElement).inert)).toBe(false)
 
 
     const stored = await readFile(join(scaffold.harnessHome, '.credentials.yaml'), 'utf8')
     const stored = await readFile(join(scaffold.harnessHome, '.credentials.yaml'), 'utf8')
     expect(stored.includes(`DEEPSEEK_API_KEY: ${secret}`)).toBe(true)
     expect(stored.includes(`DEEPSEEK_API_KEY: ${secret}`)).toBe(true)
@@ -98,8 +90,15 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
     expect((await page.locator('body').ariaSnapshot()).includes(secret)).toBe(false)
     expect((await page.locator('body').ariaSnapshot()).includes(secret)).toBe(false)
     expect(browserConsole.some(line => line.includes(secret))).toBe(false)
     expect(browserConsole.some(line => line.includes(secret))).toBe(false)
 
 
-    // The same open Models surface reuses the refreshed join and exposes the
+    const acknowledgedSettings = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
+    expect(acknowledgedSettings).toContain(`${WELCOME_NOTICE_ACK_FIELD}: ${WELCOME_NOTICE_VERSION}`)
+
+    // The ordinary Models surface reuses the refreshed join and exposes the
     // configured write-only placeholder without a reload.
     // configured write-only placeholder without a reload.
+    await page.getByRole('button', { name: '设置', exact: true }).click()
+    const settings = page.getByRole('dialog', { name: '设置' })
+    await settings.waitFor({ timeout: 10_000 })
+    await settings.getByRole('button', { name: '模型' }).click()
     const deepSeekRow = settings.getByText('DeepSeek', { exact: true }).first()
     const deepSeekRow = settings.getByText('DeepSeek', { exact: true }).first()
     await deepSeekRow.waitFor({ timeout: 10_000 })
     await deepSeekRow.waitFor({ timeout: 10_000 })
     await deepSeekRow.locator('xpath=ancestor::li').getByRole('button', { name: '编辑' }).click()
     await deepSeekRow.locator('xpath=ancestor::li').getByRole('button', { name: '编辑' }).click()
@@ -114,7 +113,21 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
     await page.reload({ waitUntil: 'load' })
     await page.reload({ waitUntil: 'load' })
     acknowledgeReloadConnectionLoss(tripwire, secondReloadWarnings)
     acknowledgeReloadConnectionLoss(tripwire, secondReloadWarnings)
     await page.waitForSelector('[class*="frame"]', { timeout: 15_000 })
     await page.waitForSelector('[class*="frame"]', { timeout: 15_000 })
-    expect(await page.getByRole('region', { name: '添加一个 API Key 开始使用' }).count()).toBe(0)
+    expect(await page.getByRole('dialog', { name: WELCOME_NOTICE_COPY.zh.title }).count()).toBe(0)
+    expect(await page.getByRole('dialog', { name: '添加一个 API Key 开始使用' }).count()).toBe(0)
+
+    // An old acknowledgement means materially revised copy: welcome returns,
+    // while the already-configured provider step remains complete.
+    await scaffold.ctx.settings.mutate(settingsNamespace(WELCOME_NOTICE_SETTINGS_NAMESPACE), [{
+      op: 'set', path: [WELCOME_NOTICE_ACK_FIELD], value: 'previous-copy-version',
+    }])
+    const thirdReloadWarnings = tripwire.warnings.length
+    await page.reload({ waitUntil: 'load' })
+    acknowledgeReloadConnectionLoss(tripwire, thirdReloadWarnings)
+    await welcome.waitFor({ timeout: 15_000 })
+    await welcome.getByRole('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel }).click()
+    await welcome.waitFor({ state: 'detached', timeout: 15_000 })
+    expect(await page.getByRole('dialog', { name: '添加一个 API Key 开始使用' }).count()).toBe(0)
 
 
     expect((await page.content()).includes(secret)).toBe(false)
     expect((await page.content()).includes(secret)).toBe(false)
     expect((await page.locator('body').ariaSnapshot()).includes(secret)).toBe(false)
     expect((await page.locator('body').ariaSnapshot()).includes(secret)).toBe(false)
@@ -125,10 +138,9 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
 
 
   it('never paints the takeover chrome on a configured reload, even with the settings join held open', async () => {
   it('never paints the takeover chrome on a configured reload, even with the settings join held open', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-configured-reload'))
     onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-configured-reload'))
-    // Regression pin for the reload white flash: the credential step is
-    // satisfied (credential configured), yet it must LOAD its
-    // private join before it can decide not to show. The chrome lives inside
-    // the step (OnboardingSurface), so the deciding window paints and blocks
+    // Regression pin for the reload flash: both steps are satisfied, yet each
+    // must load private facts before deciding not to show. Dialog chrome lives
+    // inside each visible branch, so the deciding window paints and blocks
     // nothing. Holding settings.describe widens that window from loopback
     // nothing. Holding settings.describe widens that window from loopback
     // RTT scale to a deterministic hundreds of milliseconds, removing all
     // RTT scale to a deterministic hundreds of milliseconds, removing all
     // timing dependence from the sampler assertions below.
     // timing dependence from the sampler assertions below.
@@ -141,7 +153,10 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
       const sightings: string[] = []
       const sightings: string[] = []
       ;(window as unknown as { __takeoverSightings: string[] }).__takeoverSightings = sightings
       ;(window as unknown as { __takeoverSightings: string[] }).__takeoverSightings = sightings
       setInterval(() => {
       setInterval(() => {
-        if (document.querySelector('[class*="onboardingStage"], [class*="onboardingMask"]') !== null) {
+        if (document.querySelector(
+          '[role="dialog"][aria-label="内测声明"], '
+          + '[role="dialog"][aria-label="添加一个 API Key 开始使用"]',
+        ) !== null) {
           sightings.push('chrome')
           sightings.push('chrome')
         }
         }
         if (document.getElementById('root')?.inert === true) sightings.push('inert')
         if (document.getElementById('root')?.inert === true) sightings.push('inert')
@@ -171,7 +186,8 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
     acknowledgeReloadConnectionLoss(tripwire, warningsBefore)
     acknowledgeReloadConnectionLoss(tripwire, warningsBefore)
     expect(await page.evaluate(() =>
     expect(await page.evaluate(() =>
       (window as unknown as { __takeoverSightings: string[] }).__takeoverSightings)).toEqual([])
       (window as unknown as { __takeoverSightings: string[] }).__takeoverSightings)).toEqual([])
-    expect(await page.locator('[class*="onboardingStage"]').count()).toBe(0)
+    expect(await page.getByRole('dialog', { name: WELCOME_NOTICE_COPY.zh.title }).count()).toBe(0)
+    expect(await page.getByRole('dialog', { name: '添加一个 API Key 开始使用' }).count()).toBe(0)
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.pageErrors).toEqual([])
   }, 60_000)
   }, 60_000)
 
 
@@ -228,7 +244,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
   it('keeps the fixture inventory closed', async () => {
   it('keeps the fixture inventory closed', async () => {
     await assertFixtureInventory(
     await assertFixtureInventory(
       SNAPSHOT_DIR,
       SNAPSHOT_DIR,
-      ['missing.expected.md', 'models.expected.md'],
+      ['welcome.expected.md', 'missing.expected.md', 'models.expected.md'],
     )
     )
   })
   })
 })
 })

+ 9 - 8
apps/web/tests/onboarding-usable-provider.e2e.ts

@@ -44,14 +44,17 @@ describe.skipIf(MODE === 'record')('web e2e: another usable provider ends first-
 
 
   it('closes the setup card without discarding the add card beside it', async () => {
   it('closes the setup card without discarding the add card beside it', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-setup-card-cancel'))
     onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-setup-card-cancel'))
-    const credentialStep = page.getByRole('region', { name: CREDENTIAL_STEP })
+    const credentialStep = page.getByRole('dialog', { name: CREDENTIAL_STEP })
     await credentialStep.waitFor({ timeout: 15_000 })
     await credentialStep.waitFor({ timeout: 15_000 })
-    await credentialStep.getByRole('button', { name: '前往配置' }).click()
+    await credentialStep.getByRole('button', { name: '稍后配置' }).click()
     await credentialStep.waitFor({ state: 'detached', timeout: 15_000 })
     await credentialStep.waitFor({ state: 'detached', timeout: 15_000 })
 
 
+    await page.getByRole('button', { name: '设置', exact: true }).click()
     const settings = page.getByRole('dialog', { name: '设置' })
     const settings = page.getByRole('dialog', { name: '设置' })
     await settings.waitFor({ timeout: 10_000 })
     await settings.waitFor({ timeout: 10_000 })
-    // Nothing is reachable yet, so DeepSeek presents itself as its open card.
+    // The onboarding step no longer navigates into Settings on dismissal, so
+    // enter the Models section explicitly before exercising its normal cards.
+    await settings.getByRole('button', { name: '模型' }).click()
     const setupKey = settings.getByRole('textbox', { name: 'API 密钥', exact: true })
     const setupKey = settings.getByRole('textbox', { name: 'API 密钥', exact: true })
     await setupKey.waitFor({ timeout: 10_000 })
     await setupKey.waitFor({ timeout: 10_000 })
 
 
@@ -66,15 +69,14 @@ describe.skipIf(MODE === 'record')('web e2e: another usable provider ends first-
       { timeout: 10_000 },
       { timeout: 10_000 },
     ).toBe(2)
     ).toBe(2)
 
 
-    // Cancelling the setup card is the regression: it used to leave itself open
-    // and close the add card, discarding that draft.
+    // Cancelling the setup card must not close the independent add-provider
+    // draft beside it.
     await settings.getByRole('button', { name: '取消', exact: true }).first().click()
     await settings.getByRole('button', { name: '取消', exact: true }).first().click()
     expect(await settings.getByLabel('提供方').count()).toBe(1)
     expect(await settings.getByLabel('提供方').count()).toBe(1)
     await expect.poll(
     await expect.poll(
       async () => settings.getByRole('textbox', { name: 'API 密钥', exact: true }).count(),
       async () => settings.getByRole('textbox', { name: 'API 密钥', exact: true }).count(),
       { timeout: 10_000 },
       { timeout: 10_000 },
     ).toBe(1)
     ).toBe(1)
-    // DeepSeek is now an ordinary row: a missing-key dot and an Edit button.
     await settings.getByRole('button', { name: '编辑 DeepSeek (deepseek-official)' }).waitFor({ timeout: 10_000 })
     await settings.getByRole('button', { name: '编辑 DeepSeek (deepseek-official)' }).waitFor({ timeout: 10_000 })
     const dismissed = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
     const dismissed = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
     await compareOrRefreshGolden(DISMISSED_EXPECTED, dismissed, MODE)
     await compareOrRefreshGolden(DISMISSED_EXPECTED, dismissed, MODE)
@@ -104,10 +106,9 @@ describe.skipIf(MODE === 'record')('web e2e: another usable provider ends first-
     // The regression: the step read only the official route's credential, so a
     // The regression: the step read only the official route's credential, so a
     // fully configured user was taken over on every blank session.
     // fully configured user was taken over on every blank session.
     await expect.poll(
     await expect.poll(
-      async () => page.getByRole('region', { name: CREDENTIAL_STEP }).count(),
+      async () => page.getByRole('dialog', { name: CREDENTIAL_STEP }).count(),
       { timeout: 10_000 },
       { timeout: 10_000 },
     ).toBe(0)
     ).toBe(0)
-    expect(await page.locator('[class*="onboardingStage"]').count()).toBe(0)
     expect(await page.locator('#root').evaluate(root => (root as HTMLElement).inert)).toBe(false)
     expect(await page.locator('#root').evaluate(root => (root as HTMLElement).inert)).toBe(false)
 
 
     // The Models page agrees: DeepSeek stays a row rather than reopening its
     // The Models page agrees: DeepSeek stays a row rather than reopening its

+ 60 - 0
apps/web/tests/remote-welcome.e2e.ts

@@ -0,0 +1,60 @@
+// Trusted non-loopback Web access cannot call the loopback-only settings API;
+// the notice therefore advances for this browser process and returns on reload.
+import type { Browser, Page } from 'playwright'
+import { chromium } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it } from 'vitest'
+import {
+  acknowledgeReloadConnectionLoss, launchWebScaffold, watchConsole, webSnapshotMode,
+  WELCOME_NOTICE_COPY,
+  type WebScaffold,
+} from './scaffold.ts'
+import { ZH_BROWSER_LOCALE } from './support.ts'
+
+const MODE = webSnapshotMode()
+
+describe.skipIf(MODE === 'record')('web e2e: remote welcome notice', () => {
+  let scaffold: WebScaffold
+  let browser: Browser
+  let page: Page
+  let tripwire: ReturnType<typeof watchConsole>
+
+  beforeAll(async () => {
+    scaffold = await launchWebScaffold({
+      remoteAuthority: 'remote.localhost',
+      welcomeNoticePending: true,
+    })
+    browser = await chromium.launch()
+    page = await browser.newPage({
+      viewport: { width: 1440, height: 960 },
+      locale: ZH_BROWSER_LOCALE,
+    })
+    tripwire = watchConsole(page)
+    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.waitForSelector('#root', { timeout: 30_000 })
+  }, 120_000)
+
+  afterAll(async () => {
+    await browser?.close()
+    await scaffold?.close()
+  })
+
+  it('advances process-locally and presents the notice again after reload', async () => {
+    const welcome = page.getByRole('dialog', { name: WELCOME_NOTICE_COPY.zh.title })
+    await welcome.waitFor({ timeout: 15_000 })
+    expect(await page.locator('#root').evaluate(root => (root as HTMLElement).inert)).toBe(true)
+
+    await welcome.getByRole('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel }).click()
+    await welcome.waitFor({ state: 'detached', timeout: 15_000 })
+    await expect.poll(
+      () => page.locator('#root').evaluate(root => (root as HTMLElement).inert),
+      { timeout: 15_000 },
+    ).toBe(false)
+
+    const reloadWarnings = tripwire.warnings.length
+    await page.reload({ waitUntil: 'load' })
+    acknowledgeReloadConnectionLoss(tripwire, reloadWarnings)
+    await welcome.waitFor({ timeout: 15_000 })
+    expect(tripwire.warnings).toEqual([])
+    expect(tripwire.pageErrors).toEqual([])
+  }, 60_000)
+})

+ 27 - 0
apps/web/tests/scaffold.ts

@@ -41,6 +41,7 @@ import {
   loadOverlayPatches,
   loadOverlayPatches,
 } from '@deepseek-ai/dsh-app-boot'
 } from '@deepseek-ai/dsh-app-boot'
 import { dshHomePath } from '@deepseek-ai/dsh-home-paths'
 import { dshHomePath } from '@deepseek-ai/dsh-home-paths'
+import { settingsNamespace } from '@deepseek-ai/dsh-settings'
 import { LlmAdapter } from '@deepseek-ai/dsh-llm'
 import { LlmAdapter } from '@deepseek-ai/dsh-llm'
 import type {
 import type {
   LlmModelInfo, LlmProviderInfo, LlmResolvedModelInfo, StreamChunk,
   LlmModelInfo, LlmProviderInfo, LlmResolvedModelInfo, StreamChunk,
@@ -62,6 +63,25 @@ import type {} from '@deepseek-ai/dsh-agent'
 import { provideCmdline } from '@deepseek-ai/dsh-cmdline'
 import { provideCmdline } from '@deepseek-ai/dsh-cmdline'
 import { REPO_ROOT, requireDist } from './support.ts'
 import { REPO_ROOT, requireDist } from './support.ts'
 
 
+// Host-side web e2e cannot import a browser package: doing so would pull that
+// package's complete TS project into this graph. Mirrored from
+// packages/client/ui-settings-models/src/onboarding-copy.ts; drift makes the
+// default pre-acknowledgement stop suppressing the notice and fails loudly.
+// import {
+//   WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE,
+//   WELCOME_NOTICE_VERSION, WELCOME_NOTICE_COPY,
+// } from '@deepseek-ai/dsh-client-ui-settings-models'
+export const WELCOME_NOTICE_SETTINGS_NAMESPACE = 'ui-onboarding'
+export const WELCOME_NOTICE_ACK_FIELD = 'welcomeNoticeVersion'
+export const WELCOME_NOTICE_VERSION = '2026-08-13.1'
+export const WELCOME_NOTICE_COPY = {
+  zh: {
+    title: '内测声明',
+    body: 'DeepSeek Harness 目前的 0.1 版本仍处在面向 Harness 开发者进行测试的阶段,还有许多地方需要持续改进和打磨,希望听取广大开发者的反馈建议。预计 DeepSeek Harness 的核心插件以及基础 API 都会在接下来的一段时间内快速迭代、持续演化。\n\n我们期待与全球开发者一起,在开源、开放、可复用、可组合的基础设施之上,共同探索智能上限。欢迎全球 Harness 开发者加入 DSH 插件生态。',
+    continueLabel: '继续',
+  },
+} as const
+
 /** Snapshot mode for the lane, from $DSH_SNAPSHOT (same vocabulary as the other snapshot suites). */
 /** Snapshot mode for the lane, from $DSH_SNAPSHOT (same vocabulary as the other snapshot suites). */
 export type WebSnapshotMode = 'replay' | 'record' | 'refresh'
 export type WebSnapshotMode = 'replay' | 'record' | 'refresh'
 
 
@@ -213,6 +233,8 @@ export interface LaunchOptions {
    * keyless first-run configuration lane; the default disables the adapter.
    * keyless first-run configuration lane; the default disables the adapter.
    */
    */
   deepSeekMissingCredential?: boolean
   deepSeekMissingCredential?: boolean
+  /** Leave the current welcome notice pending; ordinary scenarios pre-acknowledge it before browser boot. */
+  welcomeNoticePending?: boolean
   /**
   /**
    * Patch the shipped DeepSeek search row to a deterministic endpoint and
    * Patch the shipped DeepSeek search row to a deterministic endpoint and
    * credential reference. Browser search scenarios keep the real provider and
    * credential reference. Browser search scenarios keep the real provider and
@@ -510,6 +532,11 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
     })
     })
     await ctx.loader.await()
     await ctx.loader.await()
     assertEntriesLoaded(ctx, 'web e2e scaffold')
     assertEntriesLoaded(ctx, 'web e2e scaffold')
+    if (options.welcomeNoticePending !== true) {
+      await ctx.settings.mutate(settingsNamespace(WELCOME_NOTICE_SETTINGS_NAMESPACE), [{
+        op: 'set', path: [WELCOME_NOTICE_ACK_FIELD], value: WELCOME_NOTICE_VERSION,
+      }])
+    }
     const boundPort = ctx.get('webServer')?.port
     const boundPort = ctx.get('webServer')?.port
     if (boundPort === undefined) {
     if (boundPort === undefined) {
       throw new Error('web e2e scaffold: webServer service missing after settled boot')
       throw new Error('web e2e scaffold: webServer service missing after settled boot')

+ 5 - 2
apps/web/tests/snapshots/onboarding-deepseek-config/missing.expected.md

@@ -1,5 +1,8 @@
-- region "添加一个 API Key 开始使用":
+- dialog "添加一个 API Key 开始使用":
   - heading "添加一个 API Key 开始使用" [level=2]
   - heading "添加一个 API Key 开始使用" [level=2]
   - paragraph: 配置 DeepSeek 官方模型,即可开始使用。
   - paragraph: 配置 DeepSeek 官方模型,即可开始使用。
+  - text: API 密钥
+  - textbox "API 密钥":
+    - /placeholder: 输入 API 密钥
   - button "稍后配置"
   - button "稍后配置"
-  - button "前往配置"
+  - button "保存并继续" [disabled]

+ 5 - 0
apps/web/tests/snapshots/onboarding-deepseek-config/welcome.expected.md

@@ -0,0 +1,5 @@
+- dialog "内测声明":
+  - heading "内测声明" [level=2]
+  - paragraph: DeepSeek Harness 目前的 0.1 版本仍处在面向 Harness 开发者进行测试的阶段,还有许多地方需要持续改进和打磨,希望听取广大开发者的反馈建议。预计 DeepSeek Harness 的核心插件以及基础 API 都会在接下来的一段时间内快速迭代、持续演化。
+  - paragraph: 我们期待与全球开发者一起,在开源、开放、可复用、可组合的基础设施之上,共同探索智能上限。欢迎全球 Harness 开发者加入 DSH 插件生态。
+  - button "继续"

+ 2 - 2
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;
 # 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:
 # 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
 #   pnpm run verify-translation-pairing --write packages/client/ui-settings-general/README.md
-README.md: 6669442f322ee38f14fff45e4344b59dffb3d61a
-README.zh.md: 53d006dd521fbc69e83c91914418bf98f0342bf9
+README.md: c59b77617cfd5848553ba340e97bafe37b7b2a2f
+README.zh.md: ea4896284b677a7b430cceb949b53aa6e3a5a242

+ 3 - 3
packages/client/ui-settings-general/README.md

@@ -2,13 +2,13 @@
 
 
 English | [中文](README.zh.md)
 English | [中文](README.zh.md)
 
 
-Settings shell, ownerless-copy, and product-onboarding plugin. It occupies `sidebar.settings` with the trigger chrome and modal settings panel, projects the `settings.section` ledger into the navigation and the `settings.onboarding` ledger into one mounted page at a time, and registers everything on the Settings pages that belongs to no single feature — the trigger/header/close chrome content, the local configuration-file action, the General section and its `settings.general.item` slot, and the `settings` dictionaries. The slot types it renders into belong to ui-settings, the settings domain base; only the shell's own contract types live here, because they reference ui-sidebar's slot type and the base layer must depend on no `ui-*` package. Feature-owned rows (Permission, Language, Appearance), sections (Models), and conditional onboarding steps stay with their feature packages.
+Settings shell, ownerless copy, and durable product-onboarding namespace. It occupies `sidebar.settings` with the trigger chrome and modal settings panel, projects the `settings.section` ledger into the navigation and the `settings.onboarding` ledger into one mounted step at a time, and registers everything on the Settings pages that belongs to no single feature — the trigger/header/close chrome content, the local configuration-file action, the General section and its `settings.general.item` slot, and the `settings` dictionaries. The slot types it renders into belong to ui-settings, the settings domain base; only the shell's own contract types live here, because they reference ui-sidebar's slot type and the base layer must depend on no `ui-*` package. Feature-owned rows (Permission, Language, Appearance), sections (Models), and conditional onboarding steps stay with their feature packages.
 
 
-The shell ships no copy of its own — all text arrives from registrants. 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 onboarding ledger projects in ascending order and mounts exactly one page at a time; the takeover chrome (body-level stage, mask, app-root `inert`) belongs to the step itself through ui-primitives' `OnboardingSurface`, so a mounted step still resolving its private facts renders null and neither paints nor blocks anything — the shell shows no empty stage while a step decides. 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, mutations, and the surface wrap, so independently registered flows cannot stack and the shell does not become a second configuration fact source.
+The shell ships no onboarding copy of its own — all text arrives from registrants. 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 onboarding ledger projects in ascending order and mounts exactly one step at a time. Visible steps own their dialog chrome and app-root `inert` lifecycle; a mounted step still resolving private facts renders null, so nothing paints or blocks while it decides. 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, mutations, and their visible wrapper, so independently registered flows cannot stack and the shell does not become a second configuration fact source.
 
 
 A loopback browser loads the provider's `hasDocument` capability through `settings.describe` and renders **Open configuration file** only when the Host confirms that a provider-owned local document can be prepared. 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; Windows association after `wslpath -w` translation on WSL). Open failures keep the action available and render a localized error. Reopening the dialog or reconnecting refreshes availability after a transient read failure or Host topology change. Remote browsers never register the action and never issue the privileged settings read.
 A loopback browser loads the provider's `hasDocument` capability through `settings.describe` and renders **Open configuration file** only when the Host confirms that a provider-owned local document can be prepared. 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; Windows association after `wslpath -w` translation on WSL). Open failures keep the action available and render a localized error. Reopening the dialog or reconnecting refreshes availability after a transient read failure or Host topology change. Remote browsers never register the action and never issue the privileged settings read.
 
 
-The Host half registers `ui-onboarding` in the user-settings seam. Its `welcomeNoticeVersion` field keeps acknowledgements already present in stored settings documents valid and has no reader; no shipped step writes it.
+The Host half registers `ui-onboarding` in the user-settings seam. The welcome step contributed by `ui-settings-models` reads and writes its `welcomeNoticeVersion` through the existing public settings boundary; the shell itself remains policy-free.
 
 
 ## Model Experience
 ## Model Experience
 
 

+ 3 - 3
packages/client/ui-settings-general/README.zh.md

@@ -2,13 +2,13 @@
 
 
 [English](README.md) | 中文
 [English](README.md) | 中文
 
 
-设置外壳、无特定功能归属文案与产品引导插件。它以触发控件和模态设置面板占用 `sidebar.settings`,把 `settings.section` 账本投影成导航、把 `settings.onboarding` 账本投影成每次只挂载一页的引导流程,并在设置页面上注册所有不属于单一功能的内容:触发器、标题栏与关闭控件内容、本地配置文件操作,「通用」分区及其 `settings.general.item` slot,以及 `settings` 字典。它渲染进的那些 slot 类型归 ui-settings——设置领域底座——所有;只有外壳自身的契约类型放在这里,因为它们引用 ui-sidebar 的 slot 类型,而底座不得依赖任何 `ui-*` 包。归具体功能所有的行(「权限」、「语言」、「外观」)、分区(「模型」)和条件式首次使用引导步骤仍由各自的功能包提供。
+设置外壳、无特定功能归属文案与持久化产品引导 namespace。它以触发控件和模态设置面板占用 `sidebar.settings`,把 `settings.section` 账本投影成导航、把 `settings.onboarding` 账本投影成每次只挂载一个步骤的引导流程,并在设置页面上注册所有不属于单一功能的内容:触发器、标题栏与关闭控件内容、本地配置文件操作,「通用」分区及其 `settings.general.item` slot,以及 `settings` 字典。它渲染进的那些 slot 类型归 ui-settings——设置领域底座——所有;只有外壳自身的契约类型放在这里,因为它们引用 ui-sidebar 的 slot 类型,而底座不得依赖任何 `ui-*` 包。归具体功能所有的行(「权限」、「语言」、「外观」)、分区(「模型」)和条件式首次使用引导步骤仍由各自的功能包提供。
 
 
-外壳不自带文案:所有文本都来自注册方。导航 label 可以是跟随语言的 thunk,因此导航投影经 `resolveSlotLabel` 解析,并在分区账本更新或 locale revision 变化时重新渲染(`ctx.get('locale')` 可选读取,无硬 locale 依赖)。首次使用引导记录按升序投影,每次只挂载一个页面;接管界面框架(body 层级的展示层、遮罩、应用根节点 `inert`)经 ui-primitives 的 `OnboardingSurface` 由步骤自身持有,因此已挂载但仍在判定私有事实的步骤渲染 null 时不绘制也不阻塞任何内容——步骤判定期间外壳不会露出空白展示层。当前注册方会收到该条目的 id、`complete()` 和 `openSection(id)` 回调;完成或跳过当前页面后,所有权转交给下一项。持久化完成状态、能力就绪状态、文案、变更操作以及展示层包装均由注册方持有,因此独立注册的流程无法堆叠,外壳也不会成为第二个配置事实来源。
+外壳不自带引导文案:所有文本都来自注册方。导航 label 可以是跟随语言的 thunk,因此导航投影经 `resolveSlotLabel` 解析,并在分区账本更新或 locale revision 变化时重新渲染(`ctx.get('locale')` 可选读取,无硬 locale 依赖)。首次使用引导记录按升序投影,每次只挂载一个步骤;可见步骤自行持有弹窗框架和应用根节点 `inert` 生命周期。已挂载但仍在判定私有事实的步骤渲染 null,因此判定期间不绘制也不阻塞任何内容。当前注册方会收到该条目的 id、`complete()` 和 `openSection(id)` 回调;完成或跳过当前步骤后,所有权转交给下一项。持久化完成状态、能力就绪状态、文案、变更操作以及可见包装均由注册方持有,因此独立注册的流程无法堆叠,外壳也不会成为第二个配置事实来源。
 
 
 回环浏览器通过 `settings.describe` 加载提供方的 `hasDocument` 能力,且只有在 Host 确认可准备好一份由提供方持有的本地文档时才渲染**打开配置文件**。该操作发送无路径参数且仅限回环访问的 `settings.openDocument` 请求;Host 会再次解析提供方路径、在文档缺失时将其创建出来,并交给原生文本编辑器(macOS 上使用 `open -t`,绕过浏览器文件关联;Linux 和 Windows 上使用桌面文件关联;WSL 上经 `wslpath -w` 转换后使用 Windows 文件关联)。打开失败时该操作仍可使用,并渲染本地化错误。临时读取失败或 Host 拓扑变化后,重新打开对话框或重新连接会刷新可用性。远程浏览器从不注册该操作,也从不发起这项特权设置读取。
 回环浏览器通过 `settings.describe` 加载提供方的 `hasDocument` 能力,且只有在 Host 确认可准备好一份由提供方持有的本地文档时才渲染**打开配置文件**。该操作发送无路径参数且仅限回环访问的 `settings.openDocument` 请求;Host 会再次解析提供方路径、在文档缺失时将其创建出来,并交给原生文本编辑器(macOS 上使用 `open -t`,绕过浏览器文件关联;Linux 和 Windows 上使用桌面文件关联;WSL 上经 `wslpath -w` 转换后使用 Windows 文件关联)。打开失败时该操作仍可使用,并渲染本地化错误。临时读取失败或 Host 拓扑变化后,重新打开对话框或重新连接会刷新可用性。远程浏览器从不注册该操作,也从不发起这项特权设置读取。
 
 
-宿主端在用户设置 seam 中注册 `ui-onboarding`。其中的 `welcomeNoticeVersion` 字段让既有设置文档中已写入的确认记录保持有效;它没有读取方,也没有任何已发布的引导步骤写入它。
+宿主端在用户设置 seam 中注册 `ui-onboarding`。`ui-settings-models` 提供的欢迎步骤通过既有公开 settings 边界读写其中的 `welcomeNoticeVersion`;外壳本身仍不持有产品策略。
 
 
 ## 模型体验
 ## 模型体验
 
 

+ 5 - 7
packages/client/ui-settings-general/src/client/SettingsRoot.tsx

@@ -7,9 +7,8 @@
  * aria-labelledby the title node; close: visually-hidden slot text). Modal
  * aria-labelledby the title node; close: visually-hidden slot text). Modal
  * open state and the active section id are component-local viewing state;
  * open state and the active section id are component-local viewing state;
  * the onboarding coordinator mounts exactly one ordered registrant while the
  * the onboarding coordinator mounts exactly one ordered registrant while the
- * sessions-derived empty-Hero fact is active — the takeover chrome
- * (OnboardingSurface) belongs to the step, so a mounted-but-deciding step
- * paints nothing here.
+ * sessions-derived empty-Hero fact is active. Visible dialog chrome belongs
+ * to the step, so a mounted-but-deciding step paints nothing here.
  */
  */
 import { useCallback, useEffect, useId, useRef, useState } from 'react'
 import { useCallback, useEffect, useId, useRef, useState } from 'react'
 import clsx from 'clsx'
 import clsx from 'clsx'
@@ -160,10 +159,9 @@ export function SettingsRoot(props: SettingsRootComponentProps) {
           onClose={close}
           onClose={close}
         />
         />
       )}
       )}
-      {/* The takeover chrome (OnboardingSurface: mask, opaque stage, `#root`
-          inert) lives inside the step component, wrapped around its visible
-          content — a step still deciding (private facts loading) renders
-          null, so nothing paints or blocks while it decides. */}
+      {/* Dialog chrome and `#root` inert ownership live inside each step's
+          visible branch. A step still deciding (private facts loading)
+          renders null, so nothing paints or blocks while it decides. */}
       {onboardingStep !== undefined && renderSlot('settings.onboarding', {
       {onboardingStep !== undefined && renderSlot('settings.onboarding', {
         stepId: onboardingStep.id,
         stepId: onboardingStep.id,
         complete: () => { completeOnboardingStep(onboardingStep.id) },
         complete: () => { completeOnboardingStep(onboardingStep.id) },

+ 1 - 1
packages/client/ui-settings-general/src/index.ts

@@ -8,7 +8,7 @@ import { settingsNamespace } from '@deepseek-ai/dsh-settings'
 const ONBOARDING_SETTINGS_NAMESPACE = 'ui-onboarding'
 const ONBOARDING_SETTINGS_NAMESPACE = 'ui-onboarding'
 
 
 interface OnboardingSettings {
 interface OnboardingSettings {
-  /** Last welcome-notice version acknowledged by a since-removed first-run notice; kept so stored documents stay valid. */
+  /** Last version acknowledged by the current product welcome step. */
   welcomeNoticeVersion?: string
   welcomeNoticeVersion?: string
 }
 }
 
 

+ 1 - 1
packages/client/ui-settings-general/tests/settings-root.client.spec.tsx

@@ -229,7 +229,7 @@ describe('SettingsPanel navigation', () => {
 
 
   it('paints no takeover chrome of its own around the mounted step', () => {
   it('paints no takeover chrome of its own around the mounted step', () => {
     // The chrome (mask, opaque stage, #root inert) belongs to the step via
     // The chrome (mask, opaque stage, #root inert) belongs to the step via
-    // the OnboardingSurface primitive — a mounted-but-deciding step that
+    // the step-owned dialog surface — a mounted-but-deciding step that
     // renders null must show and block nothing (the reload white-flash fix;
     // renders null must show and block nothing (the reload white-flash fix;
     // onboarding-surface.spec.tsx pins the primitive's half).
     // onboarding-surface.spec.tsx pins the primitive's half).
     const appRoot = document.createElement('div')
     const appRoot = document.createElement('div')

+ 2 - 2
packages/client/ui-settings-models/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-settings-models/README.md
 #   pnpm run verify-translation-pairing --write packages/client/ui-settings-models/README.md
-README.md: 642dc3ffc7f3f45a523cd6e84b13d34e2d25dc2a
-README.zh.md: 408f2017636ef2d9a5a57fef253f2f05b61a22fb
+README.md: f0094c4f2687beaf6a5e5f7a1a8dc3d4ebdbcbc0
+README.zh.md: 68bb64ddeebfd65644edbb5e11fd2e147214d44a

Datei-Diff unterdrückt, da er zu groß ist
+ 1 - 1
packages/client/ui-settings-models/README.md


Datei-Diff unterdrückt, da er zu groß ist
+ 1 - 1
packages/client/ui-settings-models/README.zh.md


+ 1 - 1
packages/client/ui-settings-models/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@deepseek-ai/dsh-client-ui-settings-models",
   "name": "@deepseek-ai/dsh-client-ui-settings-models",
-  "description": "Models settings and official-DeepSeek first-run routing over one live provider/settings/credential join",
+  "description": "Models settings and shared product-onboarding dialogs over existing settings and credential joins",
   "version": "0.1.0-rc.1",
   "version": "0.1.0-rc.1",
   "publishConfig": {
   "publishConfig": {
     "access": "restricted"
     "access": "restricted"

+ 7 - 90
packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.module.css

@@ -1,99 +1,16 @@
-.page {
-  position: relative;
-  z-index: 1;
-  width: min(640px, calc(100vw - 64px));
-  max-height: 100vh;
-  padding: clamp(104px, 18vh, 156px) 0 40px;
-  box-sizing: border-box;
-  overflow-y: auto;
-  color: var(--dsw-alias-label-primary);
-}
-
-.brand {
-  display: flex;
-  align-items: center;
-  margin-bottom: 42px;
-  color: var(--dsw-alias-label-primary);
-}
-
-.title {
-  margin: 0;
-  font-size: 28px;
-  line-height: 36px;
-  font-weight: 600;
-  letter-spacing: -0.02em;
-  outline: none;
-}
-
 .description {
 .description {
-  margin: 16px 0 0;
-  font-size: 16px;
-  line-height: 28px;
+  margin: 0;
+  font-size: 14px;
+  line-height: 24px;
   color: var(--dsw-alias-label-secondary);
   color: var(--dsw-alias-label-secondary);
 }
 }
 
 
-.actions {
-  display: flex;
-  align-items: center;
-  justify-content: flex-end;
-  gap: 12px;
-  margin-top: 32px;
-}
-
-.primary {
-  min-width: 132px;
-}
-
-.brand,
-.title,
-.description,
-.actions {
-  animation: credential-enter 280ms cubic-bezier(0.23, 1, 0.32, 1) both;
-}
-
-.title { animation-delay: 40ms; }
-.description { animation-delay: 80ms; }
-.actions { animation-delay: 120ms; }
-
-@keyframes credential-enter {
-  from {
-    opacity: 0;
-    transform: translateY(8px);
-  }
-
-  to {
-    opacity: 1;
-    transform: translateY(0);
-  }
-}
-
-@media (prefers-reduced-motion: reduce) {
-  .brand,
-  .title,
-  .description,
-  .actions {
-    animation: none;
-  }
+.editor {
+  margin-top: 24px;
 }
 }
 
 
 @media (max-width: 560px) {
 @media (max-width: 560px) {
-  .page {
-    width: calc(100vw - 40px);
-    padding-top: 64px;
-  }
-
-  .brand {
-    margin-bottom: 30px;
-  }
-
-  .actions {
-    align-items: stretch;
-    flex-direction: column-reverse;
-    margin-top: 32px;
-  }
-
-  .primary,
-  .later {
-    width: 100%;
+  .editor {
+    margin-top: 20px;
   }
   }
 }
 }

+ 59 - 50
packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx

@@ -2,33 +2,39 @@
  * Official-DeepSeek first-run step. Readiness comes from the same
  * Official-DeepSeek first-run step. Readiness comes from the same
  * provider/settings/credential join as the Models page: any provider the user
  * provider/settings/credential join as the Models page: any provider the user
  * can already talk to ends the step, and only a user with none is offered the
  * can already talk to ends the step, and only a user with none is offered the
- * official DeepSeek route. The prompt itself only routes to that page's single
- * credential editor.
+ * official DeepSeek route. The step reuses that page's credential editor in
+ * the onboarding plugin's shared modal, so the key is entered once.
  */
  */
 
 
-import { useEffect, useRef } from 'react'
+import { useEffect } from 'react'
 import type { ReactNode } from 'react'
 import type { ReactNode } from 'react'
-import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
-import { BrandWordmark, Button, OnboardingSurface } from '@deepseek-ai/dsh-client-ui-primitives'
-import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
+import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client'
+import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
+import type { InjectFace, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
 import type { ModelsSettingsState, ModelsSettingsStore } from './store.ts'
 import type { ModelsSettingsState, ModelsSettingsStore } from './store.ts'
 import { onboardingReadiness } from './store.ts'
 import { onboardingReadiness } from './store.ts'
+import { ProviderEditor } from './ProviderEditor.tsx'
 import type { en } from './locales.ts'
 import type { en } from './locales.ts'
+import { OnboardingModal } from './OnboardingModal.tsx'
 import styles from './DeepSeekOnboardingDialog.module.css'
 import styles from './DeepSeekOnboardingDialog.module.css'
 
 
-/** Injected dependencies of {@link DeepSeekOnboardingDialog}. */
+/** Registration-side dependencies of {@link DeepSeekOnboardingDialog}. */
 export interface DeepSeekOnboardingInjected {
 export interface DeepSeekOnboardingInjected {
+  hooks: {
+    /** Shared Models-page join state, bound by the slot renderer. */
+    models: SnapshotStore<ModelsSettingsState>
+  }
   /** Shared Models-page join controller. */
   /** Shared Models-page join controller. */
   controller: ModelsSettingsStore
   controller: ModelsSettingsStore
-  /** Subscription hook bound to the shared join snapshot. */
-  useSnapshot: SnapshotSelectorHook<ModelsSettingsState>
+  /** Existing wire face reused by the Models credential editor. */
+  api: Pick<IApiClient, 'settings' | 'credentials' | 'llm'>
   /** Feature copy. */
   /** Feature copy. */
   t: (key: keyof typeof en) => string
   t: (key: keyof typeof en) => string
 }
 }
 
 
 /** Slot owner props plus the feature's injected dependencies. */
 /** Slot owner props plus the feature's injected dependencies. */
 export type DeepSeekOnboardingDialogProps =
 export type DeepSeekOnboardingDialogProps =
-  PropsRuntime<'settings.onboarding'> & DeepSeekOnboardingInjected
+  PropsRuntime<'settings.onboarding'> & InjectFace<DeepSeekOnboardingInjected>
 
 
 /* v8 ignore next 3 -- closed-union defaults only defend future source widening */
 /* v8 ignore next 3 -- closed-union defaults only defend future source widening */
 function assertNever(_value: never): never {
 function assertNever(_value: never): never {
@@ -36,16 +42,15 @@ function assertNever(_value: never): never {
 }
 }
 
 
 /**
 /**
- * Prompt a first-run user to open Models while no provider can serve requests
- * and the official adapter exists with an unconfigured effective credential.
+ * Prompt a first-run user for the official DeepSeek credential while no
+ * provider can serve requests and that credential is writable.
  * @param props - settings-shell owner state and Models feature dependencies.
  * @param props - settings-shell owner state and Models feature dependencies.
- * @returns the onboarding page or null when onboarding needs no intervention.
+ * @returns the onboarding modal or null when onboarding needs no intervention.
  */
  */
 export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps): ReactNode {
 export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps): ReactNode {
-  const { complete, openSection, controller, useSnapshot, t } = props
-  const state = useSnapshot(snapshot => snapshot)
+  const { complete, controller, useModels, api, t } = props
+  const state = useModels(snapshot => snapshot)
   const readiness = onboardingReadiness(state)
   const readiness = onboardingReadiness(state)
-  const titleRef = useRef<HTMLHeadingElement | null>(null)
 
 
   useEffect(() => {
   useEffect(() => {
     if (state.status === 'idle') void controller.load()
     if (state.status === 'idle') void controller.load()
@@ -59,18 +64,6 @@ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps):
     ) complete()
     ) complete()
   }, [complete, readiness.kind])
   }, [complete, readiness.kind])
 
 
-  useEffect(() => {
-    if (readiness.kind === 'credential-missing') titleRef.current?.focus()
-  }, [readiness.kind])
-
-  const openModels = (): void => {
-    complete()
-    openSection('models')
-  }
-
-  // Null covers the still-deciding and nothing-to-do states alike: the
-  // takeover chrome below is part of THIS render, so declining paints and
-  // blocks nothing while the shared join is in flight.
   switch (readiness.kind) {
   switch (readiness.kind) {
     case 'loading':
     case 'loading':
     case 'adapter-absent':
     case 'adapter-absent':
@@ -84,28 +77,44 @@ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps):
       return assertNever(readiness)
       return assertNever(readiness)
   }
   }
 
 
+  const row = state.rows.find(candidate =>
+    candidate.entry.provider === 'deepseek-official'
+    && candidate.entry.settingsNs === 'llm-deepseek'
+    && candidate.entry.settingsPath.length === 0)
+  const namespace = state.namespaces.get('llm-deepseek')
+  /* v8 ignore next 2 -- credential-missing is derived only from this exact joined row. */
+  if (row === undefined || namespace === undefined) return null
+
+  const finishCredential = (changed: boolean): void => {
+    if (!changed) {
+      complete()
+      return
+    }
+    void controller.load()
+  }
+
   return (
   return (
-    <OnboardingSurface>
-      <section className={styles['page']} role="region" aria-labelledby="deepseek-onboarding-title">
-        <div className={styles['brand']} aria-hidden="true"><BrandWordmark size={24} /></div>
-        <h2
-          ref={titleRef}
-          id="deepseek-onboarding-title"
-          className={styles['title']}
-          tabIndex={-1}
-        >
-          {t('onboardingTitle')}
-        </h2>
-        <p className={styles['description']}>{t('onboardingDescription')}</p>
-        <div className={styles['actions']}>
-          <Button variant="ghost" className={styles['later']} onClick={complete}>
-            {t('onboardingLater')}
-          </Button>
-          <Button variant="primary" className={styles['primary']} onClick={openModels}>
-            {t('onboardingGoToSettings')}
-          </Button>
-        </div>
-      </section>
-    </OnboardingSurface>
+    <OnboardingModal title={t('onboardingTitle')}>
+      <p className={styles.description}>{t('onboardingDescription')}</p>
+      <div className={styles.editor}>
+        <ProviderEditor
+          provider={row.entry.provider}
+          displayName={row.entry.displayName}
+          namespace={namespace}
+          settingsPath={row.entry.settingsPath}
+          api={api}
+          t={t}
+          readOnly={false}
+          hideTitle
+          credentialOnly
+          credentialRequired
+          autoFocusCredential
+          cancelLabel="onboardingLater"
+          submitLabel="onboardingSave"
+          submitBusyLabel="onboardingSaving"
+          onClose={finishCredential}
+        />
+      </div>
+    </OnboardingModal>
   )
   )
 }
 }

+ 3 - 1
packages/client/ui-settings-models/src/client/EditorFooter.tsx

@@ -29,6 +29,8 @@ export interface EditorFooterProps {
   submitLabel: keyof typeof en
   submitLabel: keyof typeof en
   /** Commit label while a commit is in flight. */
   /** Commit label while a commit is in flight. */
   submitBusyLabel: keyof typeof en
   submitBusyLabel: keyof typeof en
+  /** Dismiss label; defaults to the settings editor copy. */
+  cancelLabel?: keyof typeof en
   /** Dismiss the card without committing. */
   /** Dismiss the card without committing. */
   onCancel: () => void
   onCancel: () => void
   /** Run the card's commit. */
   /** Run the card's commit. */
@@ -50,7 +52,7 @@ export function EditorFooter(props: EditorFooterProps): ReactNode {
         disabled={props.busy}
         disabled={props.busy}
         onClick={props.onCancel}
         onClick={props.onCancel}
       >
       >
-        {t('cancel')}
+        {t(props.cancelLabel ?? 'cancel')}
       </button>
       </button>
       <button
       <button
         type="button"
         type="button"

+ 32 - 0
packages/client/ui-settings-models/src/client/OnboardingModal.module.css

@@ -0,0 +1,32 @@
+.dialog {
+  width: min(600px, 100%);
+  padding: 0;
+}
+
+.content {
+  display: flex;
+  flex-direction: column;
+  max-height: calc(100vh - 48px);
+  padding: 28px;
+  box-sizing: border-box;
+  overflow-y: auto;
+}
+
+.title {
+  margin: 0;
+  font-size: 20px;
+  line-height: 28px;
+  font-weight: 500;
+  color: var(--dsw-alias-label-primary);
+  outline: none;
+}
+
+.body {
+  margin-top: 20px;
+}
+
+@media (max-width: 560px) {
+  .content {
+    padding: 24px;
+  }
+}

+ 52 - 0
packages/client/ui-settings-models/src/client/OnboardingModal.tsx

@@ -0,0 +1,52 @@
+/** Shared modal chrome for every step registered by this onboarding plugin. */
+
+import { useEffect, useRef } from 'react'
+import type { ReactNode } from 'react'
+import { Modal } from '@deepseek-ai/dsh-client-ui-primitives'
+import css from './OnboardingModal.module.css'
+
+const ignoreImplicitDismiss = (): void => {}
+
+/**
+ * Render a blocking onboarding dialog and keep the application root inert.
+ * @param props.title - accessible and visible dialog title.
+ * @param props.focusTitle - focus the title when the step has no form control.
+ * @param props.children - step-owned body and actions.
+ * @returns the body-portaled modal.
+ */
+export function OnboardingModal({
+  title, focusTitle = false, children,
+}: {
+  title: string
+  focusTitle?: boolean
+  children: ReactNode
+}): ReactNode {
+  const titleRef = useRef<HTMLHeadingElement | null>(null)
+
+  useEffect(() => {
+    const appRoot = document.getElementById('root')
+    if (appRoot === null) return
+    const previous = appRoot.inert === true
+    appRoot.inert = true
+    return () => { appRoot.inert = previous }
+  }, [])
+
+  useEffect(() => {
+    if (focusTitle) titleRef.current?.focus()
+  }, [focusTitle])
+
+  return (
+    <Modal
+      open
+      title={title}
+      onClose={ignoreImplicitDismiss}
+      headless
+      className={css['dialog'] ?? ''}
+    >
+      <div className={css.content}>
+        <h2 ref={titleRef} className={css.title} tabIndex={focusTitle ? -1 : undefined}>{title}</h2>
+        <div className={css.body}>{children}</div>
+      </div>
+    </Modal>
+  )
+}

+ 40 - 15
packages/client/ui-settings-models/src/client/ProviderEditor.tsx

@@ -69,6 +69,18 @@ export interface ProviderEditorProps {
   t: (key: keyof typeof en) => string
   t: (key: keyof typeof en) => string
   /** Disable writes (read-only settings provider). */
   /** Disable writes (read-only settings provider). */
   readOnly: boolean
   readOnly: boolean
+  /** Render only the credential field and actions, without provider settings. */
+  credentialOnly?: boolean
+  /** Require a newly entered credential before this editor can submit. */
+  credentialRequired?: boolean
+  /** Give the credential field initial focus when this editor mounts. */
+  autoFocusCredential?: boolean
+  /** Override the dismiss action copy. */
+  cancelLabel?: keyof typeof en
+  /** Override the idle commit action copy. */
+  submitLabel?: keyof typeof en
+  /** Override the in-flight commit action copy. */
+  submitBusyLabel?: keyof typeof en
   /** Close the editor; `changed` reports whether an Apply committed. */
   /** Close the editor; `changed` reports whether an Apply committed. */
   onClose: (changed: boolean) => void
   onClose: (changed: boolean) => void
 }
 }
@@ -198,6 +210,11 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
   // as "no key supplied" rather than as a key — that is how a card whose
   // as "no key supplied" rather than as a key — that is how a card whose
   // provider already has a stored key is edited without re-entering it.
   // provider already has a stored key is edited without re-entering it.
   const keyValue = keyDraft.trim()
   const keyValue = keyDraft.trim()
+  const credentialRequiredFailure = props.credentialRequired === true
+    && keyDraft.length > 0 && keyValue.length === 0
+    ? 'keyRequired' as const
+    : undefined
+  const shownKeyFailure = credentialRequiredFailure ?? keyFailure
   // What the form currently shows, which is what an interrogation must ask:
   // What the form currently shows, which is what an interrogation must ask:
   // an edited-but-unsaved endpoint, and a key typed but not yet stored.
   // an edited-but-unsaved endpoint, and a key typed but not yet stored.
   const probeApi = stringAt(draft, 'api') ?? stringAt(fallback, 'api')
   const probeApi = stringAt(draft, 'api') ?? stringAt(fallback, 'api')
@@ -225,7 +242,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
       && stringAt(fallback, 'apiKeyEnv') === undefined && keyValue.length > 0
       && stringAt(fallback, 'apiKeyEnv') === undefined && keyValue.length > 0
       ? setPath(draft, ['apiKeyEnv'], keyRef)
       ? setPath(draft, ['apiKeyEnv'], keyRef)
       : draft
       : draft
-    {
+    if (props.credentialOnly !== true) {
       // The same checker gates the submit button, so a card cannot reach this
       // The same checker gates the submit button, so a card cannot reach this
       // with a bad row; it stays because the schema check below would refuse
       // with a bad row; it stays because the schema check below would refuse
       // the write with a message naming a path instead of the row, and because
       // the write with a message naming a path instead of the row, and because
@@ -237,7 +254,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
       }
       }
     }
     }
     /* v8 ignore next -- apply is only reachable from the rendered card, which required a resolved node */
     /* v8 ignore next -- apply is only reachable from the rendered card, which required a resolved node */
-    if (node !== undefined && settingsPath.length === 0) {
+    if (props.credentialOnly !== true && node !== undefined && settingsPath.length === 0) {
       const sectionError = validateDraft(node, next)
       const sectionError = validateDraft(node, next)
       if (sectionError !== undefined) return sectionError
       if (sectionError !== undefined) return sectionError
     }
     }
@@ -245,9 +262,11 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
       && fallback === undefined
       && fallback === undefined
       && committedOriginal === undefined
       && committedOriginal === undefined
       && Object.keys(next).length === 0
       && Object.keys(next).length === 0
-    const ops: SettingsPathOpView[] = materializesNativeProfile
-      ? [{ op: 'set', path: [...settingsPath], value: {} }]
-      : pathOps(settingsPath, committedOriginal, next)
+    const ops: SettingsPathOpView[] = props.credentialOnly === true
+      ? []
+      : materializesNativeProfile
+        ? [{ op: 'set', path: [...settingsPath], value: {} }]
+        : pathOps(settingsPath, committedOriginal, next)
     if (ops.length > 0) {
     if (ops.length > 0) {
       const response = await api.settings.mutate({ ns, ops, expectedRevision })
       const response = await api.settings.mutate({ ns, ops, expectedRevision })
       if (!response.result.ok) {
       if (!response.result.ok) {
@@ -324,7 +343,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
     const defaultMaxTokens = getPath(fallback, ['maxTokens'])
     const defaultMaxTokens = getPath(fallback, ['maxTokens'])
     const keyPlaceholder = keyLocked
     const keyPlaceholder = keyLocked
       ? t('keyEnvLocked')
       ? t('keyEnvLocked')
-      : keyState?.configured === true
+      : keyState?.configured === true && props.credentialRequired !== true
         ? t('keyStored')
         ? t('keyStored')
         : family === 'pi-ai' ? t('keyPlaceholderNative') : t('keyPlaceholder')
         : family === 'pi-ai' ? t('keyPlaceholderNative') : t('keyPlaceholder')
     /** What both family editors take: the rows, whose layer owns them, and the two writes. */
     /** What both family editors take: the rows, whose layer owns them, and the two writes. */
@@ -349,12 +368,15 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
             value={keyDraft}
             value={keyDraft}
             placeholder={keyPlaceholder}
             placeholder={keyPlaceholder}
             aria-label={t('keyInput')}
             aria-label={t('keyInput')}
+            aria-invalid={shownKeyFailure !== undefined}
+            required={props.credentialRequired === true}
+            autoFocus={props.autoFocusCredential === true}
             disabled={disabled || keyLocked}
             disabled={disabled || keyLocked}
             onChange={(event) => { setKeyDraft(event.target.value) }}
             onChange={(event) => { setKeyDraft(event.target.value) }}
           />
           />
-          {keyFailure === undefined ? null : <p className={styles['error']}>{t(keyFailure)}</p>}
+          {shownKeyFailure === undefined ? null : <p className={styles['error']}>{t(shownKeyFailure)}</p>}
         </div>
         </div>
-        <details className={styles['customized']}>
+        {props.credentialOnly === true ? null : <details className={styles['customized']}>
           <summary className={styles['customizedSummary']}>{t('customized')}</summary>
           <summary className={styles['customizedSummary']}>{t('customized')}</summary>
           <div className={styles['customizedBody']}>
           <div className={styles['customizedBody']}>
             {/* The name and the protocol are the create card's two remaining
             {/* The name and the protocol are the create card's two remaining
@@ -440,13 +462,13 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
               )
               )
               : <ModelListEditor {...catalogProps} probe={probe} probeBlocked={keyFailure} api={api} />}
               : <ModelListEditor {...catalogProps} probe={probe} probeBlocked={keyFailure} api={api} />}
           </div>
           </div>
-        </details>
+        </details>}
       </>
       </>
     )
     )
   }
   }
 
 
   return (
   return (
-    <div className={styles['editor']}>
+    <div className={props.credentialOnly === true ? styles['addBlock'] : styles['editor']}>
       {props.hideTitle === true
       {props.hideTitle === true
         ? null
         ? null
         : (
         : (
@@ -461,7 +483,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
         ? <p className={styles['advancedHint']}>{`${t('advancedHint')} (${namespace.ns})`}</p>
         ? <p className={styles['advancedHint']}>{`${t('advancedHint')} (${namespace.ns})`}</p>
         : curatedFields(layout)}
         : curatedFields(layout)}
       {failure !== undefined ? <p className={styles['error']}>{failure}</p> : null}
       {failure !== undefined ? <p className={styles['error']}>{failure}</p> : null}
-      {modelFailure === undefined
+      {props.credentialOnly === true || modelFailure === undefined
         ? null
         ? null
         : (
         : (
           <p className={styles['advancedHint']}>
           <p className={styles['advancedHint']}>
@@ -471,10 +493,13 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
       <EditorFooter
       <EditorFooter
         t={t}
         t={t}
         busy={busy}
         busy={busy}
-        submitDisabled={disabled || layout === 'unknown' || modelFailure !== undefined
-          || keyFailure !== undefined}
-        submitLabel="apply"
-        submitBusyLabel="applying"
+        submitDisabled={disabled || layout === 'unknown'
+          || (props.credentialOnly !== true && modelFailure !== undefined)
+          || shownKeyFailure !== undefined
+          || (props.credentialRequired === true && keyValue.length === 0)}
+        submitLabel={props.submitLabel ?? 'apply'}
+        submitBusyLabel={props.submitBusyLabel ?? 'applying'}
+        {...props.cancelLabel === undefined ? {} : { cancelLabel: props.cancelLabel }}
         onCancel={() => { props.onClose(false) }}
         onCancel={() => { props.onClose(false) }}
         onSubmit={() => { void apply() }}
         onSubmit={() => { void apply() }}
       />
       />

+ 36 - 0
packages/client/ui-settings-models/src/client/WelcomeNotice.module.css

@@ -0,0 +1,36 @@
+.copy {
+  font-size: 14px;
+  line-height: 24px;
+  color: var(--dsw-alias-label-secondary);
+}
+
+.copy p {
+  margin: 0;
+}
+
+.copy p + p {
+  margin-top: 12px;
+}
+
+.error {
+  margin: 16px 0 0;
+  font-size: 14px;
+  line-height: 22px;
+  color: var(--dsw-alias-state-error-primary);
+}
+
+.actions {
+  display: flex;
+  justify-content: flex-end;
+  margin-top: 24px;
+}
+
+.primary {
+  min-width: 120px;
+}
+
+@media (max-width: 560px) {
+  .primary {
+    width: 100%;
+  }
+}

+ 77 - 0
packages/client/ui-settings-models/src/client/WelcomeNotice.tsx

@@ -0,0 +1,77 @@
+/** Product-wide, versioned internal-testing notice. */
+
+import { useCallback, useEffect, useRef } from 'react'
+import type { ReactNode } from 'react'
+import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
+import type { InjectFace, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
+import { Button } from '@deepseek-ai/dsh-client-ui-primitives'
+import type { WelcomeNoticeState, WelcomeNoticeStore } from './welcome-store.ts'
+import type { en } from './locales.ts'
+import { OnboardingModal } from './OnboardingModal.tsx'
+import css from './WelcomeNotice.module.css'
+
+/** Registration-side dependencies of {@link WelcomeNotice}. */
+export interface WelcomeNoticeInjected {
+  hooks: {
+    /** Durable or process-local acknowledgement state. */
+    welcome: SnapshotStore<WelcomeNoticeState>
+  }
+  /** Welcome acknowledgement controller. */
+  controller: WelcomeNoticeStore
+  /** Onboarding copy. */
+  t: (key: keyof typeof en) => string
+}
+
+/** Coordinator owner props plus this step's injected face. */
+export type WelcomeNoticeProps =
+  PropsRuntime<'settings.onboarding'> & InjectFace<WelcomeNoticeInjected>
+
+/**
+ * Render the current notice until its exact copy version is acknowledged.
+ * @param props - settings-shell owner state and welcome dependencies.
+ * @returns the welcome modal or null while the step decides not to show.
+ */
+export function WelcomeNotice(props: WelcomeNoticeProps): ReactNode {
+  const { complete, controller, useWelcome, t } = props
+  const state = useWelcome(snapshot => snapshot)
+  const finished = useRef(false)
+  const finish = useCallback((): void => {
+    if (finished.current) return
+    finished.current = true
+    complete()
+  }, [complete])
+
+  useEffect(() => {
+    if (state.status === 'idle') void controller.load()
+  }, [controller, state.status])
+
+  useEffect(() => {
+    if (state.acknowledged) finish()
+  }, [finish, state.acknowledged])
+
+  if (state.status === 'idle' || state.status === 'loading' || state.acknowledged) return null
+
+  const acknowledge = async (): Promise<void> => {
+    if (await controller.acknowledge()) finish()
+  }
+  const paragraphs = t('welcomeBody').split('\n\n')
+
+  return (
+    <OnboardingModal title={t('welcomeTitle')} focusTitle>
+      <div className={css.copy}>
+        {paragraphs.map(paragraph => <p key={paragraph}>{paragraph}</p>)}
+      </div>
+      {state.error === null ? null : <p className={css.error} role="alert">{t('welcomeError')}</p>}
+      <div className={css.actions}>
+        <Button
+          variant="primary"
+          className={css.primary}
+          disabled={state.status === 'saving'}
+          onClick={() => { void acknowledge() }}
+        >
+          {t('welcomeContinue')}
+        </Button>
+      </div>
+    </OnboardingModal>
+  )
+}

+ 41 - 13
packages/client/ui-settings-models/src/client/index.ts

@@ -1,8 +1,9 @@
 /**
 /**
- * Models settings plugin, browser half. Registers the `models` nav entry and
- * official-DeepSeek first-run overlay into shell-declared slots. Both consume
- * one provider/settings/credential join; the overlay routes missing-key users
- * to the full page's single credential editor. Export discipline:
+ * Models settings and product-onboarding plugin, browser half. It registers
+ * the Models page plus the ordered internal-testing and official-DeepSeek
+ * onboarding dialogs, whose UI shares this package's modal wrapper. The Host
+ * settings and credential contracts stay behind their existing wire APIs.
+ * Export discipline:
  * packages/client/AGENTS.md.
  * packages/client/AGENTS.md.
  */
  */
 import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
 import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
@@ -19,15 +20,19 @@ import { ModelsSection } from './ModelsSection.tsx'
 import type { ModelsSectionInjected } from './ModelsSection.tsx'
 import type { ModelsSectionInjected } from './ModelsSection.tsx'
 import { DeepSeekOnboardingDialog } from './DeepSeekOnboardingDialog.tsx'
 import { DeepSeekOnboardingDialog } from './DeepSeekOnboardingDialog.tsx'
 import type { DeepSeekOnboardingInjected } from './DeepSeekOnboardingDialog.tsx'
 import type { DeepSeekOnboardingInjected } from './DeepSeekOnboardingDialog.tsx'
+import { WelcomeNotice } from './WelcomeNotice.tsx'
+import type { WelcomeNoticeInjected } from './WelcomeNotice.tsx'
+import { refreshWelcomeIfLoaded, WelcomeNoticeStore } from './welcome-store.ts'
 import { ModelsSettingsStore } from './store.ts'
 import { ModelsSettingsStore } from './store.ts'
 import { en, zh, type ModelsKey } from './locales.ts'
 import { en, zh, type ModelsKey } from './locales.ts'
+import { WELCOME_NOTICE_SETTINGS_NAMESPACE } from '../onboarding-copy.ts'
 
 
 export type { ModelsSectionInjected, ModelsSectionProps } from './ModelsSection.tsx'
 export type { ModelsSectionInjected, ModelsSectionProps } from './ModelsSection.tsx'
 export type { ModelsKey } from './locales.ts'
 export type { ModelsKey } from './locales.ts'
 
 
 declare module '@deepseek-ai/dsh-client-ui-slots' {
 declare module '@deepseek-ai/dsh-client-ui-slots' {
   interface LocaleNamespaceMap {
   interface LocaleNamespaceMap {
-    /** The Models page + onboarding overlay copy. */
+    /** The Models page + product-onboarding copy. */
     'settings.models': ModelsKey
     'settings.models': ModelsKey
   }
   }
 }
 }
@@ -74,21 +79,38 @@ export function apply(ctx: ClientContext): void {
     api: connection.api,
     api: connection.api,
     t,
     t,
   })
   })
-  const onboardingInjected = (): DeepSeekOnboardingInjected => ({
+  const deepSeekOnboardingInjected = (): DeepSeekOnboardingInjected => ({
     controller,
     controller,
-    useSnapshot,
+    hooks: { models: controller.store },
+    api: connection.api,
+    t,
+  })
+  const welcomeController = new WelcomeNoticeStore(
+    connection.api,
+    connection.isLoopback ? 'host' : 'memory',
+  )
+  const welcomeInjected = (): WelcomeNoticeInjected => ({
+    controller: welcomeController,
+    hooks: { welcome: welcomeController.store },
     t,
     t,
   })
   })
 
 
   // Pushed invalidations converge every open surface without polling: any
   // Pushed invalidations converge every open surface without polling: any
   // settings/credentials/topology change refetches once the page loaded.
   // settings/credentials/topology change refetches once the page loaded.
   ctx.effect(() => {
   ctx.effect(() => {
-    const refresh = (): void => { refreshIfLoaded(controller) }
+    const refreshModels = (): void => { refreshIfLoaded(controller) }
+    const refreshAll = (): void => {
+      refreshModels()
+      refreshWelcomeIfLoaded(welcomeController)
+    }
     const disposers = [
     const disposers = [
-      ctx.remote.$on('settings/document-updated', refresh),
-      ctx.remote.$on('credentials/updated', refresh),
-      ctx.remote.$on('llm/adapters-updated', refresh),
-      ctx.on('connection/reset', refresh),
+      ctx.remote.$on('settings/document-updated', (ns) => {
+        refreshModels()
+        if (ns === WELCOME_NOTICE_SETTINGS_NAMESPACE) refreshWelcomeIfLoaded(welcomeController)
+      }),
+      ctx.remote.$on('credentials/updated', refreshModels),
+      ctx.remote.$on('llm/adapters-updated', refreshModels),
+      ctx.on('connection/reset', refreshAll),
     ]
     ]
     return () => { for (const dispose of disposers) dispose() }
     return () => { for (const dispose of disposers) dispose() }
   }, 'ui-settings-models: pushed invalidations')
   }, 'ui-settings-models: pushed invalidations')
@@ -100,10 +122,16 @@ export function apply(ctx: ClientContext): void {
     label: () => t('nav'),
     label: () => t('nav'),
     inject: injected,
     inject: injected,
   }, ModelsSection))
   }, ModelsSection))
+  ctx.slots.inject('settings.onboarding', () => ctx.slots.register({
+    name: 'settings.onboarding',
+    id: 'welcome-notice',
+    order: -100,
+    inject: welcomeInjected,
+  }, WelcomeNotice))
   ctx.slots.inject('settings.onboarding', () => ctx.slots.register({
   ctx.slots.inject('settings.onboarding', () => ctx.slots.register({
     name: 'settings.onboarding',
     name: 'settings.onboarding',
     id: 'deepseek-official',
     id: 'deepseek-official',
     order: 0,
     order: 0,
-    inject: onboardingInjected,
+    inject: deepSeekOnboardingInjected,
   }, DeepSeekOnboardingDialog))
   }, DeepSeekOnboardingDialog))
 }
 }

+ 16 - 2
packages/client/ui-settings-models/src/client/locales.ts

@@ -1,5 +1,7 @@
 /** Copy dictionaries for the Models settings section. */
 /** Copy dictionaries for the Models settings section. */
 
 
+import { WELCOME_NOTICE_COPY } from '../onboarding-copy.ts'
+
 /** English strings (the key-set source of truth for this pair). */
 /** English strings (the key-set source of truth for this pair). */
 export const en = {
 export const en = {
   nav: 'Models',
   nav: 'Models',
@@ -85,10 +87,16 @@ export const en = {
   customNeedsModels: 'A custom provider needs at least one model.',
   customNeedsModels: 'A custom provider needs at least one model.',
   create: 'Create provider',
   create: 'Create provider',
   creating: 'Creating\u2026',
   creating: 'Creating\u2026',
+  welcomeTitle: WELCOME_NOTICE_COPY.en.title,
+  welcomeBody: WELCOME_NOTICE_COPY.en.body,
+  welcomeContinue: WELCOME_NOTICE_COPY.en.continueLabel,
+  welcomeError: 'The acknowledgement could not be saved. Please try again.',
   onboardingTitle: 'Add an API key to get started',
   onboardingTitle: 'Add an API key to get started',
   onboardingDescription: 'Configure the official DeepSeek provider to start building.',
   onboardingDescription: 'Configure the official DeepSeek provider to start building.',
-  onboardingGoToSettings: 'Go to settings',
   onboardingLater: 'Configure later',
   onboardingLater: 'Configure later',
+  onboardingSave: 'Save and continue',
+  onboardingSaving: 'Saving…',
+  keyRequired: 'Enter an API key to continue.',
 }
 }
 
 
 /** The settings.models namespace key union. */
 /** The settings.models namespace key union. */
@@ -179,8 +187,14 @@ export const zh: typeof en = {
   customNeedsModels: '自定义提供方至少需要一个模型。',
   customNeedsModels: '自定义提供方至少需要一个模型。',
   create: '创建提供方',
   create: '创建提供方',
   creating: '创建中\u2026',
   creating: '创建中\u2026',
+  welcomeTitle: WELCOME_NOTICE_COPY.zh.title,
+  welcomeBody: WELCOME_NOTICE_COPY.zh.body,
+  welcomeContinue: WELCOME_NOTICE_COPY.zh.continueLabel,
+  welcomeError: '暂时无法保存确认状态,请重试。',
   onboardingTitle: '添加一个 API Key 开始使用',
   onboardingTitle: '添加一个 API Key 开始使用',
   onboardingDescription: '配置 DeepSeek 官方模型,即可开始使用。',
   onboardingDescription: '配置 DeepSeek 官方模型,即可开始使用。',
-  onboardingGoToSettings: '前往配置',
   onboardingLater: '稍后配置',
   onboardingLater: '稍后配置',
+  onboardingSave: '保存并继续',
+  onboardingSaving: '保存中…',
+  keyRequired: '请输入 API 密钥后继续。',
 }
 }

+ 126 - 0
packages/client/ui-settings-models/src/client/welcome-store.ts

@@ -0,0 +1,126 @@
+/** Welcome-notice state, durable when the browser may use Host settings. */
+
+import type { IApiClient, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client'
+import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
+import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
+import {
+  WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE, WELCOME_NOTICE_VERSION,
+} from '../onboarding-copy.ts'
+
+/** State rendered by the welcome step. */
+export interface WelcomeNoticeState {
+  status: 'idle' | 'loading' | 'ready' | 'saving' | 'error'
+  acknowledged: boolean
+  error: string | null
+}
+
+function messageOf(error: unknown): string {
+  return error instanceof Error ? error.message : String(error)
+}
+
+function acknowledgementOf(view: SettingsNamespaceView): string | undefined {
+  if (typeof view.value !== 'object' || view.value === null) return undefined
+  const value = (view.value as Record<string, unknown>)[WELCOME_NOTICE_ACK_FIELD]
+  return typeof value === 'string' ? value : undefined
+}
+
+/** Coordinates durable Host acknowledgement or a process-local remote fallback. */
+export class WelcomeNoticeStore {
+  /** uSES-safe state source shared by the registered welcome step. */
+  readonly store: SnapshotStore<WelcomeNoticeState> = createSnapshotStore({
+    status: 'idle', acknowledged: false, error: null,
+  })
+
+  private generation = 0
+
+  /**
+   * @param api - settings wire face used for durable reads and writes.
+   * @param persistence - remote browsers use memory because settings is loopback-only.
+   */
+  constructor(
+    private readonly api: Pick<IApiClient, 'settings'>,
+    private readonly persistence: 'host' | 'memory' = 'host',
+  ) {}
+
+  /** Load the acknowledgement from Host settings or initialize process-local state. */
+  async load(): Promise<void> {
+    const generation = ++this.generation
+    if (this.persistence === 'memory') {
+      this.store.update((state) => { state.status = 'ready'; state.error = null })
+      return
+    }
+    this.store.update((state) => { state.status = 'loading'; state.error = null })
+    try {
+      const response = await this.api.settings.describe({})
+      if (!response.result.ok) throw new Error(response.result.error.message)
+      const view = response.result.value.namespaces.find(
+        candidate => candidate.ns === WELCOME_NOTICE_SETTINGS_NAMESPACE,
+      )
+      if (view === undefined) throw new Error('welcome acknowledgement settings are unavailable')
+      if (generation !== this.generation) return
+      this.store.update((state) => {
+        state.status = 'ready'
+        state.acknowledged = acknowledgementOf(view) === WELCOME_NOTICE_VERSION
+        state.error = null
+      })
+    } catch (error) {
+      if (generation !== this.generation) return
+      this.store.update((state) => {
+        state.status = 'error'
+        state.acknowledged = false
+        state.error = messageOf(error)
+      })
+    }
+  }
+
+  /**
+   * Persist this copy version, or advance only this process for a remote browser.
+   * @returns true when the selected persistence mode accepted the acknowledgement.
+   */
+  async acknowledge(): Promise<boolean> {
+    const generation = ++this.generation
+    if (this.persistence === 'memory') {
+      this.store.update((state) => {
+        state.status = 'ready'
+        state.acknowledged = true
+        state.error = null
+      })
+      return true
+    }
+    this.store.update((state) => { state.status = 'saving'; state.error = null })
+    try {
+      const response = await this.api.settings.mutate({
+        ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
+        ops: [{ op: 'set', path: [WELCOME_NOTICE_ACK_FIELD], value: WELCOME_NOTICE_VERSION }],
+      })
+      if (!response.result.ok) throw new Error(response.result.error.message)
+      if (generation === this.generation) {
+        this.store.update((state) => {
+          state.status = 'ready'
+          state.acknowledged = true
+          state.error = null
+        })
+      }
+      return true
+    } catch (error) {
+      if (generation === this.generation) {
+        this.store.update((state) => {
+          state.status = 'error'
+          state.acknowledged = false
+          state.error = messageOf(error)
+        })
+      }
+      return false
+    }
+  }
+}
+
+/**
+ * Refresh only after welcome state has left idle. A memory-mode load retains
+ * acknowledgement so reconnect does not reopen a process-local notice.
+ * @param controller - welcome state owner whose current status decides whether to load.
+ */
+export function refreshWelcomeIfLoaded(controller: WelcomeNoticeStore): void {
+  if (controller.store.getSnapshot().status === 'idle') return
+  void controller.load()
+}

+ 25 - 0
packages/client/ui-settings-models/src/onboarding-copy.ts

@@ -0,0 +1,25 @@
+/** Durable settings namespace for product-wide GUI onboarding facts. */
+export const WELCOME_NOTICE_SETTINGS_NAMESPACE = 'ui-onboarding'
+
+/** Field storing the last welcome notice version the user acknowledged. */
+export const WELCOME_NOTICE_ACK_FIELD = 'welcomeNoticeVersion'
+
+/**
+ * Bump only when the notice changes materially and every user should see it
+ * again. The acknowledgement is compared for exact equality.
+ */
+export const WELCOME_NOTICE_VERSION = '2026-08-13.1'
+
+/** The complete editable internal-testing notice in both supported GUI locales. */
+export const WELCOME_NOTICE_COPY = {
+  zh: {
+    title: '内测声明',
+    body: 'DeepSeek Harness 目前的 0.1 版本仍处在面向 Harness 开发者进行测试的阶段,还有许多地方需要持续改进和打磨,希望听取广大开发者的反馈建议。预计 DeepSeek Harness 的核心插件以及基础 API 都会在接下来的一段时间内快速迭代、持续演化。\n\n我们期待与全球开发者一起,在开源、开放、可复用、可组合的基础设施之上,共同探索智能上限。欢迎全球 Harness 开发者加入 DSH 插件生态。',
+    continueLabel: '继续',
+  },
+  en: {
+    title: '内测声明',
+    body: 'DeepSeek Harness 目前的 0.1 版本仍处在面向 Harness 开发者进行测试的阶段,还有许多地方需要持续改进和打磨,希望听取广大开发者的反馈建议。预计 DeepSeek Harness 的核心插件以及基础 API 都会在接下来的一段时间内快速迭代、持续演化。\n\n我们期待与全球开发者一起,在开源、开放、可复用、可组合的基础设施之上,共同探索智能上限。欢迎全球 Harness 开发者加入 DSH 插件生态。',
+    continueLabel: '继续',
+  },
+} as const

+ 42 - 7
packages/client/ui-settings-models/tests/apply.client.spec.ts

@@ -8,6 +8,7 @@ import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-t
 import { apply, inject, refreshIfLoaded } from '@deepseek-ai/dsh-client-ui-settings-models/client'
 import { apply, inject, refreshIfLoaded } from '@deepseek-ai/dsh-client-ui-settings-models/client'
 import { ModelsSection } from '../src/client/ModelsSection.tsx'
 import { ModelsSection } from '../src/client/ModelsSection.tsx'
 import { DeepSeekOnboardingDialog } from '../src/client/DeepSeekOnboardingDialog.tsx'
 import { DeepSeekOnboardingDialog } from '../src/client/DeepSeekOnboardingDialog.tsx'
+import { WelcomeNotice } from '../src/client/WelcomeNotice.tsx'
 
 
 // The service reads its initial locale from the browser; these specs assert
 // The service reads its initial locale from the browser; these specs assert
 // the shipped Chinese copy, so they state the browser they assume.
 // the shipped Chinese copy, so they state the browser they assume.
@@ -23,7 +24,7 @@ async function bench() {
   new TestRemote(ctx)
   new TestRemote(ctx)
   // The apply path only captures the wire face; no call leaves this fake
   // The apply path only captures the wire face; no call leaves this fake
   // until a section actually loads.
   // until a section actually loads.
-  ctx.provide('connection', { api: {} } as never)
+  ctx.provide('connection', { api: {}, isLoopback: true } as never)
   return { ctx, slots: ctx.get('slots') as SlotRegistry, locale }
   return { ctx, slots: ctx.get('slots') as SlotRegistry, locale }
 }
 }
 
 
@@ -60,9 +61,20 @@ describe('ui-settings-models apply', () => {
     expect(typeof injected.controller.load).toBe('function')
     expect(typeof injected.controller.load).toBe('function')
     expect(typeof injected.useSnapshot).toBe('function')
     expect(typeof injected.useSnapshot).toBe('function')
     expect(injected.api).toBeDefined()
     expect(injected.api).toBeDefined()
-    const onboarding = before.slots.entries('settings.onboarding')[0]!
-    expect(onboarding.component).toBe(DeepSeekOnboardingDialog)
-    expect(onboarding.options).toMatchObject({ id: 'deepseek-official', order: 0 })
+    const onboarding = before.slots.entries('settings.onboarding')
+    expect(onboarding).toHaveLength(2)
+    expect(onboarding.find(entry => entry.options.id === 'welcome-notice')).toMatchObject({
+      component: WelcomeNotice,
+      options: { id: 'welcome-notice', order: -100 },
+    })
+    const deepSeek = onboarding.find(entry => entry.options.id === 'deepseek-official')!
+    expect(deepSeek.component).toBe(DeepSeekOnboardingDialog)
+    expect(deepSeek.options).toMatchObject({ id: 'deepseek-official', order: 0 })
+    const deepSeekInjected = (
+      deepSeek.inject as unknown as () => import('../src/client/DeepSeekOnboardingDialog.tsx').DeepSeekOnboardingInjected
+    )()
+    expect(deepSeekInjected.hooks.models).toBe(injected.controller.store)
+    expect(deepSeekInjected.api).toBeDefined()
 
 
     const after = await bench()
     const after = await bench()
     await after.ctx.plugin({ inject: [...inject], apply }).await()
     await after.ctx.plugin({ inject: [...inject], apply }).await()
@@ -71,7 +83,7 @@ describe('ui-settings-models apply', () => {
     declare(after.slots)
     declare(after.slots)
     await Promise.resolve()
     await Promise.resolve()
     expect(after.slots.entries('settings.section')[0]!.component).toBe(ModelsSection)
     expect(after.slots.entries('settings.section')[0]!.component).toBe(ModelsSection)
-    expect(after.slots.entries('settings.onboarding')[0]!.component).toBe(DeepSeekOnboardingDialog)
+    expect(after.slots.entries('settings.onboarding')).toHaveLength(2)
     // The self-inflicted ledger notifications hit the duplicate guard.
     // The self-inflicted ledger notifications hit the duplicate guard.
     expect(after.slots.entries('settings.section')).toHaveLength(1)
     expect(after.slots.entries('settings.section')).toHaveLength(1)
   })
   })
@@ -110,7 +122,7 @@ describe('ui-settings-models apply', () => {
     declare(b.slots)
     declare(b.slots)
     await Promise.resolve()
     await Promise.resolve()
     expect(b.slots.entries('settings.section')[0]!.component).toBe(ModelsSection)
     expect(b.slots.entries('settings.section')[0]!.component).toBe(ModelsSection)
-    expect(b.slots.entries('settings.onboarding')[0]!.component).toBe(DeepSeekOnboardingDialog)
+    expect(b.slots.entries('settings.onboarding')).toHaveLength(2)
     // The locale path also recovers through the same ledger re-check.
     // The locale path also recovers through the same ledger re-check.
     b.locale.setLocale('en')
     b.locale.setLocale('en')
     expect(resolveSlotLabel(b.slots.entries('settings.section')[0]!.options.label)).toBe('Models')
     expect(resolveSlotLabel(b.slots.entries('settings.section')[0]!.options.label)).toBe('Models')
@@ -164,8 +176,10 @@ describe('pushed invalidations', () => {
     const b = await bench()
     const b = await bench()
     declare(b.slots)
     declare(b.slots)
     await b.ctx.plugin({ inject: [...inject], apply }).await()
     await b.ctx.plugin({ inject: [...inject], apply }).await()
+    const entry = b.slots.entries('settings.onboarding')
+      .find(candidate => candidate.options.id === 'deepseek-official')!
     const injected = (
     const injected = (
-      b.slots.entries('settings.onboarding')[0]!.inject as unknown as
+      entry.inject as unknown as
       () => import('../src/client/DeepSeekOnboardingDialog.tsx').DeepSeekOnboardingInjected
       () => import('../src/client/DeepSeekOnboardingDialog.tsx').DeepSeekOnboardingInjected
     )()
     )()
     injected.controller.store.update((state) => { state.status = 'ready' })
     injected.controller.store.update((state) => { state.status = 'ready' })
@@ -173,4 +187,25 @@ describe('pushed invalidations', () => {
     b.ctx.remote.$dispatch('credentials/updated', ['DEEPSEEK_API_KEY'])
     b.ctx.remote.$dispatch('credentials/updated', ['DEEPSEEK_API_KEY'])
     expect(load).toHaveBeenCalledTimes(1)
     expect(load).toHaveBeenCalledTimes(1)
   })
   })
+
+  it('routes only the onboarding namespace invalidation into welcome state', async () => {
+    const b = await bench()
+    declare(b.slots)
+    await b.ctx.plugin({ inject: [...inject], apply }).await()
+    const entry = b.slots.entries('settings.onboarding')
+      .find(candidate => candidate.options.id === 'welcome-notice')!
+    const injected = (
+      entry.inject as unknown as
+      () => import('../src/client/WelcomeNotice.tsx').WelcomeNoticeInjected
+    )()
+    injected.hooks.welcome.update((state) => { state.status = 'ready' })
+    const load = vi.spyOn(injected.controller, 'load').mockResolvedValue()
+
+    b.ctx.remote.$dispatch('settings/document-updated', ['llm-deepseek', 1])
+    expect(load).not.toHaveBeenCalled()
+    b.ctx.remote.$dispatch('settings/document-updated', ['ui-onboarding', 2])
+    expect(load).toHaveBeenCalledOnce()
+    b.ctx.emit('connection/reset')
+    expect(load).toHaveBeenCalledTimes(2)
+  })
 })
 })

+ 59 - 0
packages/client/ui-settings-models/tests/components.client.spec.tsx

@@ -355,6 +355,65 @@ describe('ModelsSection', () => {
     expect(screen.queryByRole('status')).toBeNull()
     expect(screen.queryByRole('status')).toBeNull()
   })
   })
 
 
+  it('reuses the provider editor as a required credential-only onboarding form', async () => {
+    let finishSet: ((response: RpcResponse<Record<string, never>>) => void) | undefined
+    const set = vi.fn(() => new Promise<RpcResponse<Record<string, never>>>((resolve) => {
+      finishSet = resolve
+    }))
+    const { face, mutate } = scriptedFace({ set })
+    const onClose = vi.fn()
+    const { ProviderEditor } = await import('../src/client/ProviderEditor.tsx')
+
+    render(<ProviderEditor
+      provider="deepseek-official"
+      displayName="DeepSeek"
+      hideTitle
+      namespace={wireNamespaces()[0]!}
+      settingsPath={[]}
+      api={face as never}
+      t={t}
+      readOnly={false}
+      credentialOnly
+      credentialRequired
+      autoFocusCredential
+      cancelLabel="onboardingLater"
+      submitLabel="onboardingSave"
+      submitBusyLabel="onboardingSaving"
+      onClose={onClose}
+    />)
+
+    const key = screen.getByLabelText<HTMLInputElement>(en.keyInput)
+    const save = screen.getByText<HTMLButtonElement>(en.onboardingSave)
+    expect(document.activeElement).toBe(key)
+    expect(key.required).toBe(true)
+    expect(save.disabled).toBe(true)
+    expect(screen.getByText(en.onboardingLater)).toBeTruthy()
+    expect(screen.queryByText(en.customized)).toBeNull()
+    expect(screen.queryByLabelText(en.baseUrl)).toBeNull()
+
+    fireEvent.change(key, { target: { value: '   ' } })
+    expect(screen.getByText(en.keyRequired)).toBeTruthy()
+    expect(key.getAttribute('aria-invalid')).toBe('true')
+    expect(save.disabled).toBe(true)
+
+    fireEvent.change(key, { target: { value: '  sk-onboarding  ' } })
+    expect(screen.queryByText(en.keyRequired)).toBeNull()
+    expect(save.disabled).toBe(false)
+    fireEvent.click(save)
+
+    expect(await screen.findByText(en.onboardingSaving)).toBeTruthy()
+    expect(set).toHaveBeenCalledWith({ ref: 'DEEPSEEK_API_KEY', value: 'sk-onboarding' })
+    expect(mutate).not.toHaveBeenCalled()
+    expect(onClose).not.toHaveBeenCalled()
+
+    if (finishSet === undefined) throw new Error('credential write did not start')
+    await act(async () => {
+      finishSet?.(ok({}))
+      await Promise.resolve()
+    })
+    expect(onClose).toHaveBeenCalledWith(true)
+  })
+
   it('applies customized deepseek fields as path ops', async () => {
   it('applies customized deepseek fields as path ops', async () => {
     const { mutate } = await mountDeepSeekCard({
     const { mutate } = await mountDeepSeekCard({
       mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
       mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),

+ 130 - 32
packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx

@@ -2,14 +2,18 @@
 /** First-run DeepSeek prompt behavior over the shared Models join. */
 /** First-run DeepSeek prompt behavior over the shared Models join. */
 import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
 import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { afterEach, describe, expect, it, vi } from 'vitest'
-import type { RpcResponse } from '@deepseek-ai/dsh-api-remotes/client'
+import Schema from '@deepseek-ai/schemastery'
+import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client'
 import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
 import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
 import { DeepSeekOnboardingDialog } from '../src/client/DeepSeekOnboardingDialog.tsx'
 import { DeepSeekOnboardingDialog } from '../src/client/DeepSeekOnboardingDialog.tsx'
 import type { DeepSeekOnboardingDialogProps } from '../src/client/DeepSeekOnboardingDialog.tsx'
 import type { DeepSeekOnboardingDialogProps } from '../src/client/DeepSeekOnboardingDialog.tsx'
 import { ModelsSettingsStore } from '../src/client/store.ts'
 import { ModelsSettingsStore } from '../src/client/store.ts'
 import { en } from '../src/client/locales.ts'
 import { en } from '../src/client/locales.ts'
 
 
-afterEach(cleanup)
+afterEach(() => {
+  cleanup()
+  document.getElementById('root')?.remove()
+})
 
 
 let nextRpc = 0
 let nextRpc = 0
 function ok<T>(value: T): RpcResponse<T> {
 function ok<T>(value: T): RpcResponse<T> {
@@ -22,6 +26,33 @@ function fail<T>(message: string): RpcResponse<T> {
   }
   }
 }
 }
 
 
+const DeepSeekConfig = Schema.object({
+  apiKeyEnv: Schema.string().role('credential-ref'),
+  baseURL: Schema.string().pattern(/^https:\/\//),
+  reasoningEffort: Schema.union(['off', 'high', 'max']),
+  defaultContextWindow: Schema.number().step(1).min(1),
+  models: Schema.array(Schema.object({
+    id: Schema.string().required(),
+    name: Schema.string(),
+    description: Schema.string(),
+    contextWindow: Schema.number().step(1).min(1),
+  })),
+})
+
+function deepSeekNamespace(apiKeyEnv: string | null): SettingsNamespaceView {
+  const value = apiKeyEnv === null ? {} : { apiKeyEnv }
+  return {
+    ns: 'llm-deepseek',
+    schema: JSON.parse(JSON.stringify(DeepSeekConfig.toJSON())) as unknown,
+    value,
+    base: value,
+    user: {},
+    applies: 'live',
+    secrets: [],
+    revision: 0,
+  }
+}
+
 function harness(options: {
 function harness(options: {
   provider?: boolean
   provider?: boolean
   providerSettingsNs?: string
   providerSettingsNs?: string
@@ -33,9 +64,24 @@ function harness(options: {
   describeFailure?: string
   describeFailure?: string
   settingsWritable?: boolean
   settingsWritable?: boolean
   providersReject?: boolean
   providersReject?: boolean
+  setFailure?: string
+  setReject?: string
 } = {}) {
 } = {}) {
+  if (document.getElementById('root') === null) {
+    const appRoot = document.createElement('div')
+    appRoot.id = 'root'
+    document.body.append(appRoot)
+  }
   let fileConfigured = false
   let fileConfigured = false
   const configured = options.configured ?? (() => fileConfigured)
   const configured = options.configured ?? (() => fileConfigured)
+  const apiKeyEnv = options.apiKeyEnv === undefined ? 'DEEPSEEK_API_KEY' : options.apiKeyEnv
+  const mutate = vi.fn(() => Promise.resolve(ok(deepSeekNamespace(apiKeyEnv))))
+  const set = vi.fn((_payload: { ref: string; value: string }) => {
+    if (options.setReject !== undefined) return Promise.reject(new Error(options.setReject))
+    if (options.setFailure !== undefined) return Promise.resolve(fail(options.setFailure))
+    fileConfigured = true
+    return Promise.resolve(ok({}))
+  })
   const face = {
   const face = {
     llm: {
     llm: {
       providers: () => {
       providers: () => {
@@ -56,19 +102,10 @@ function harness(options: {
     settings: {
     settings: {
       describe: () => Promise.resolve(ok({
       describe: () => Promise.resolve(ok({
         writable: options.settingsWritable ?? true,
         writable: options.settingsWritable ?? true,
-        namespaces: options.settingsNamespace === false
-          ? []
-          : [{
-            ns: 'llm-deepseek',
-            schema: {},
-            value: options.apiKeyEnv === null
-              ? {}
-              : { apiKeyEnv: options.apiKeyEnv ?? 'DEEPSEEK_API_KEY' },
-            applies: 'live' as const,
-            secrets: [],
-            revision: 0,
-          }],
+        hasDocument: false,
+        namespaces: options.settingsNamespace === false ? [] : [deepSeekNamespace(apiKeyEnv)],
       })),
       })),
+      mutate,
     },
     },
     credentials: {
     credentials: {
       describe: () => options.describeFailure === undefined
       describe: () => options.describeFailure === undefined
@@ -84,6 +121,7 @@ function harness(options: {
           },
           },
         }))
         }))
         : Promise.resolve(fail(options.describeFailure)),
         : Promise.resolve(fail(options.describeFailure)),
+      set,
     },
     },
   }
   }
   const controller = new ModelsSettingsStore(face as never)
   const controller = new ModelsSettingsStore(face as never)
@@ -97,40 +135,100 @@ function harness(options: {
     useSessions: unusedHook,
     useSessions: unusedHook,
     useWorkspaces: unusedHook,
     useWorkspaces: unusedHook,
     controller,
     controller,
-    useSnapshot: bindSnapshotSelector(controller.store),
+    useModels: bindSnapshotSelector(controller.store),
+    api: face as never,
     t: key => en[key],
     t: key => en[key],
   }
   }
-  return { controller, complete, openSection, props, configure: () => { fileConfigured = true } }
+  return {
+    controller, complete, openSection, props, mutate, set,
+    configure: () => { fileConfigured = true },
+  }
 }
 }
 
 
 describe('DeepSeekOnboardingDialog', () => {
 describe('DeepSeekOnboardingDialog', () => {
-  it('loads on first entry and presents one accessible route to Models', async () => {
+  it('loads a credential-only modal, inerts the product, and focuses the key', async () => {
     const h = harness()
     const h = harness()
     render(<DeepSeekOnboardingDialog {...h.props} />)
     render(<DeepSeekOnboardingDialog {...h.props} />)
-    expect(await screen.findByRole('region', { name: en.onboardingTitle })).toBeTruthy()
+    expect(await screen.findByRole('dialog', { name: en.onboardingTitle })).toBeTruthy()
+    expect(document.getElementById('root')?.inert).toBe(true)
     expect(screen.getByText(en.onboardingDescription)).toBeTruthy()
     expect(screen.getByText(en.onboardingDescription)).toBeTruthy()
-    const action = screen.getByRole('button', { name: en.onboardingGoToSettings })
-    expect(action).toBeTruthy()
-    expect(document.activeElement).toBe(screen.getByRole('heading', { name: en.onboardingTitle }))
-    expect(screen.queryByRole('textbox')).toBeNull()
+    const key = screen.getByLabelText<HTMLInputElement>(en.keyInput)
+    await waitFor(() => { expect(document.activeElement).toBe(key) })
+    expect(screen.queryByText(en.customized)).toBeNull()
   })
   })
 
 
-  it('opens the Models section and dismisses the prompt', async () => {
+  it('cannot be dismissed implicitly and restores the previous inert state', async () => {
+    const h = harness()
+    const appRoot = document.getElementById('root')!
+    appRoot.inert = true
+    const view = render(<DeepSeekOnboardingDialog {...h.props} />)
+    await screen.findByRole('dialog')
+
+    fireEvent.keyDown(document, { key: 'Escape' })
+    fireEvent.click(document.querySelector('[class*="mask"]')!)
+    expect(screen.getByRole('dialog')).toBeTruthy()
+    expect(h.complete).not.toHaveBeenCalled()
+
+    view.unmount()
+    expect(appRoot.inert).toBe(true)
+  })
+
+  it('requires a non-blank key before Save and continue is available', async () => {
     const h = harness()
     const h = harness()
     render(<DeepSeekOnboardingDialog {...h.props} />)
     render(<DeepSeekOnboardingDialog {...h.props} />)
-    await screen.findByRole('region')
-    fireEvent.click(screen.getByRole('button', { name: en.onboardingGoToSettings }))
-    expect(h.complete).toHaveBeenCalledOnce()
-    expect(h.openSection).toHaveBeenCalledWith('models')
+    await screen.findByRole('dialog')
+    const save = screen.getByRole<HTMLButtonElement>('button', { name: en.onboardingSave })
+    expect(save.disabled).toBe(true)
+    fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: '   ' } })
+    expect(save.disabled).toBe(true)
+    expect(screen.getByText(en.keyRequired)).toBeTruthy()
+    expect(h.set).not.toHaveBeenCalled()
+  })
+
+  it('stores only the official credential, refreshes, and completes without opening Settings', async () => {
+    const h = harness()
+    render(<DeepSeekOnboardingDialog {...h.props} />)
+    await screen.findByRole('dialog')
+    fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: '  sk-live  ' } })
+    fireEvent.click(screen.getByRole('button', { name: en.onboardingSave }))
+    await waitFor(() => {
+      expect(h.set).toHaveBeenCalledWith({ ref: 'DEEPSEEK_API_KEY', value: 'sk-live' })
+    })
+    expect(h.mutate).not.toHaveBeenCalled()
+    expect(h.openSection).not.toHaveBeenCalled()
+    await waitFor(() => { expect(h.complete).toHaveBeenCalledOnce() })
+    expect(screen.queryByRole('dialog')).toBeNull()
+    expect(document.getElementById('root')?.inert).toBe(false)
+  })
+
+  it('keeps the modal open and reports rejected and failed credential writes', async () => {
+    for (const [options, message] of [
+      [{ setFailure: 'credential was rejected' }, 'credential was rejected'],
+      [{ setReject: 'connection lost' }, 'connection lost'],
+    ] as const) {
+      const h = harness(options)
+      const view = render(<DeepSeekOnboardingDialog {...h.props} />)
+      await screen.findByRole('dialog')
+      fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'sk-live' } })
+      fireEvent.click(screen.getByRole('button', { name: en.onboardingSave }))
+      expect(await screen.findByText(message)).toBeTruthy()
+      expect(screen.getByRole('dialog')).toBeTruthy()
+      expect(screen.getByRole<HTMLButtonElement>('button', { name: en.onboardingSave }).disabled).toBe(false)
+      expect(h.complete).not.toHaveBeenCalled()
+      expect(h.mutate).not.toHaveBeenCalled()
+      view.unmount()
+    }
   })
   })
 
 
   it('allows configure-later dismissal without opening settings', async () => {
   it('allows configure-later dismissal without opening settings', async () => {
     const h = harness()
     const h = harness()
     render(<DeepSeekOnboardingDialog {...h.props} />)
     render(<DeepSeekOnboardingDialog {...h.props} />)
-    await screen.findByRole('region')
+    await screen.findByRole('dialog')
     fireEvent.click(screen.getByRole('button', { name: en.onboardingLater }))
     fireEvent.click(screen.getByRole('button', { name: en.onboardingLater }))
     expect(h.complete).toHaveBeenCalledOnce()
     expect(h.complete).toHaveBeenCalledOnce()
     expect(h.openSection).not.toHaveBeenCalled()
     expect(h.openSection).not.toHaveBeenCalled()
+    expect(h.set).not.toHaveBeenCalled()
+    expect(h.mutate).not.toHaveBeenCalled()
   })
   })
 
 
   it('does not block the product when DeepSeek setup is unavailable', async () => {
   it('does not block the product when DeepSeek setup is unavailable', async () => {
@@ -145,7 +243,7 @@ describe('DeepSeekOnboardingDialog', () => {
     ]) {
     ]) {
       const view = render(<DeepSeekOnboardingDialog {...h.props} />)
       const view = render(<DeepSeekOnboardingDialog {...h.props} />)
       await act(async () => { await h.controller.load() })
       await act(async () => { await h.controller.load() })
-      expect(screen.queryByRole('region')).toBeNull()
+      expect(screen.queryByRole('dialog')).toBeNull()
       await waitFor(() => { expect(h.complete).toHaveBeenCalledOnce() })
       await waitFor(() => { expect(h.complete).toHaveBeenCalledOnce() })
       expect(h.openSection).not.toHaveBeenCalled()
       expect(h.openSection).not.toHaveBeenCalled()
       view.unmount()
       view.unmount()
@@ -160,7 +258,7 @@ describe('DeepSeekOnboardingDialog', () => {
     ]) {
     ]) {
       const view = render(<DeepSeekOnboardingDialog {...h.props} />)
       const view = render(<DeepSeekOnboardingDialog {...h.props} />)
       await act(async () => { await h.controller.load() })
       await act(async () => { await h.controller.load() })
-      expect(screen.queryByRole('region')).toBeNull()
+      expect(screen.queryByRole('dialog')).toBeNull()
       await waitFor(() => { expect(h.complete).toHaveBeenCalledOnce() })
       await waitFor(() => { expect(h.complete).toHaveBeenCalledOnce() })
       view.unmount()
       view.unmount()
     }
     }
@@ -169,10 +267,10 @@ describe('DeepSeekOnboardingDialog', () => {
   it('closes when an external credential invalidation refreshes the shared join', async () => {
   it('closes when an external credential invalidation refreshes the shared join', async () => {
     const h = harness()
     const h = harness()
     render(<DeepSeekOnboardingDialog {...h.props} />)
     render(<DeepSeekOnboardingDialog {...h.props} />)
-    await screen.findByRole('region')
+    await screen.findByRole('dialog')
     h.configure()
     h.configure()
     await act(async () => { await h.controller.load() })
     await act(async () => { await h.controller.load() })
-    await waitFor(() => { expect(screen.queryByRole('region')).toBeNull() })
+    await waitFor(() => { expect(screen.queryByRole('dialog')).toBeNull() })
     expect(h.complete).toHaveBeenCalledOnce()
     expect(h.complete).toHaveBeenCalledOnce()
   })
   })
 })
 })

+ 126 - 0
packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx

@@ -0,0 +1,126 @@
+// @vitest-environment jsdom
+import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
+import { WelcomeNotice } from '../src/client/WelcomeNotice.tsx'
+import type { WelcomeNoticeProps } from '../src/client/WelcomeNotice.tsx'
+import { WelcomeNoticeStore } from '../src/client/welcome-store.ts'
+import { en, zh } from '../src/client/locales.ts'
+import {
+  WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_COPY, WELCOME_NOTICE_SETTINGS_NAMESPACE,
+  WELCOME_NOTICE_VERSION,
+} from '../src/onboarding-copy.ts'
+
+afterEach(() => {
+  cleanup()
+  document.getElementById('root')?.remove()
+})
+
+function response<T>(value: T) {
+  return { rpcId: 'welcome-rpc' as never, result: { ok: true as const, value } }
+}
+
+function mount(version?: string, mutateImpl: () => Promise<unknown> = () => Promise.resolve(response({}))) {
+  const appRoot = document.createElement('div')
+  appRoot.id = 'root'
+  document.body.append(appRoot)
+  const mutate = vi.fn(mutateImpl)
+  const api = {
+    settings: {
+      describe: () => Promise.resolve(response({
+        writable: true,
+        hasDocument: false,
+        namespaces: [{
+          ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
+          schema: {},
+          value: version === undefined ? {} : { [WELCOME_NOTICE_ACK_FIELD]: version },
+          base: {},
+          user: {},
+          applies: 'live' as const,
+          secrets: [],
+          revision: 0,
+        }],
+      })),
+      mutate,
+    },
+  }
+  const controller = new WelcomeNoticeStore(api as never)
+  const complete = vi.fn()
+  const unusedHook = (() => { throw new Error('unused standard hook') }) as never
+  const props: WelcomeNoticeProps = {
+    stepId: 'welcome-notice',
+    complete,
+    openSection: vi.fn(),
+    useSessions: unusedHook,
+    useWorkspaces: unusedHook,
+    controller,
+    useWelcome: bindSnapshotSelector(controller.store),
+    t: key => zh[key],
+  }
+  return { ...render(<WelcomeNotice {...props} />), complete, controller, mutate, appRoot }
+}
+
+describe('WelcomeNotice', () => {
+  it('uses the exact owner copy in both GUI locales', () => {
+    expect(WELCOME_NOTICE_COPY.en).toEqual(WELCOME_NOTICE_COPY.zh)
+    expect(en.welcomeBody).toBe(WELCOME_NOTICE_COPY.en.body)
+    expect(zh.welcomeBody).toBe(WELCOME_NOTICE_COPY.zh.body)
+  })
+
+  it('renders one blocking modal action and focuses the title', async () => {
+    const h = mount()
+    const dialog = await screen.findByRole('dialog', { name: WELCOME_NOTICE_COPY.zh.title })
+    for (const paragraph of WELCOME_NOTICE_COPY.zh.body.split('\n\n')) {
+      expect(screen.getByText(paragraph, { exact: true })).toBeTruthy()
+    }
+    expect(dialog.querySelectorAll('p')).toHaveLength(2)
+    expect(dialog.querySelectorAll('button')).toHaveLength(1)
+    expect(screen.getByRole('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel })).toBeTruthy()
+    expect(document.activeElement).toBe(screen.getByRole('heading', { name: WELCOME_NOTICE_COPY.zh.title }))
+    expect(h.appRoot.inert).toBe(true)
+
+    fireEvent.keyDown(document, { key: 'Escape' })
+    fireEvent.click(document.querySelector('[class*="mask"]')!)
+    expect(h.complete).not.toHaveBeenCalled()
+    expect(screen.getByRole('dialog')).toBeTruthy()
+  })
+
+  it('completes only after the acknowledgement write commits', async () => {
+    const h = mount()
+    await screen.findByRole('dialog')
+    fireEvent.click(screen.getByRole('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel }))
+    await act(async () => { await Promise.resolve() })
+    expect(h.mutate).toHaveBeenCalledOnce()
+    expect(h.complete).toHaveBeenCalledOnce()
+  })
+
+  it('skips itself when this exact version was already acknowledged', async () => {
+    const h = mount(WELCOME_NOTICE_VERSION)
+    await act(async () => { await h.controller.load() })
+    expect(screen.queryByRole('dialog')).toBeNull()
+    expect(h.complete).toHaveBeenCalledOnce()
+  })
+
+  it('keeps the sole action disabled while saving and reports a refused write', async () => {
+    let resolveWrite!: (value: unknown) => void
+    const write = new Promise<unknown>((resolve) => { resolveWrite = resolve })
+    const h = mount(undefined, () => write)
+    await screen.findByRole('dialog')
+    const action = screen.getByRole<HTMLButtonElement>('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel })
+    fireEvent.click(action)
+    expect(action.disabled).toBe(true)
+    resolveWrite({
+      rpcId: 'welcome-refused' as never,
+      result: {
+        ok: false,
+        error: {
+          code: 'settings-rejected',
+          message: 'read only',
+          details: { ns: WELCOME_NOTICE_SETTINGS_NAMESPACE },
+        },
+      },
+    })
+    expect((await screen.findByRole('alert')).textContent).toBe(zh.welcomeError)
+    expect(h.complete).not.toHaveBeenCalled()
+  })
+})

+ 199 - 0
packages/client/ui-settings-models/tests/welcome-store.client.spec.ts

@@ -0,0 +1,199 @@
+import { describe, expect, it, vi } from 'vitest'
+import type { RpcResponse } from '@deepseek-ai/dsh-api-remotes/client'
+import { refreshWelcomeIfLoaded, WelcomeNoticeStore } from '../src/client/welcome-store.ts'
+import {
+  WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE, WELCOME_NOTICE_VERSION,
+} from '../src/onboarding-copy.ts'
+
+let rpc = 0
+function ok<T>(value: T): RpcResponse<T> {
+  return { rpcId: `welcome-${rpc++}` as never, result: { ok: true, value } }
+}
+
+function namespace(version?: string) {
+  return {
+    ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
+    schema: {},
+    value: version === undefined ? {} : { [WELCOME_NOTICE_ACK_FIELD]: version },
+    base: {},
+    user: {},
+    applies: 'live' as const,
+    secrets: [],
+    revision: 0,
+  }
+}
+
+function deferred<T>() {
+  let resolve!: (value: T) => void
+  let reject!: (reason: unknown) => void
+  const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej })
+  return { promise, resolve, reject }
+}
+
+describe('WelcomeNoticeStore', () => {
+  it('acknowledges in memory without calling loopback-only settings APIs', async () => {
+    const describe = vi.fn()
+    const mutate = vi.fn()
+    const controller = new WelcomeNoticeStore({ settings: { describe, mutate } } as never, 'memory')
+
+    await controller.load()
+    expect(controller.store.getSnapshot()).toEqual({ status: 'ready', acknowledged: false, error: null })
+    await expect(controller.acknowledge()).resolves.toBe(true)
+    expect(controller.store.getSnapshot()).toEqual({ status: 'ready', acknowledged: true, error: null })
+    await controller.load()
+    expect(controller.store.getSnapshot()).toEqual({ status: 'ready', acknowledged: true, error: null })
+    expect(describe).not.toHaveBeenCalled()
+    expect(mutate).not.toHaveBeenCalled()
+  })
+
+  it('acknowledges only the exact current copy version', async () => {
+    for (const [version, acknowledged] of [
+      [undefined, false],
+      ['older-copy', false],
+      [WELCOME_NOTICE_VERSION, true],
+    ] as const) {
+      const api = {
+        settings: {
+          describe: vi.fn(() => Promise.resolve(ok({
+            writable: true, hasDocument: false, namespaces: [namespace(version)],
+          }))),
+        },
+      }
+      const controller = new WelcomeNoticeStore(api as never)
+      await controller.load()
+      expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged })
+    }
+  })
+
+  it('persists the owner version through one idempotent path mutation', async () => {
+    const mutate = vi.fn(() => Promise.resolve(ok(namespace(WELCOME_NOTICE_VERSION))))
+    const controller = new WelcomeNoticeStore({ settings: { mutate } } as never)
+    await expect(controller.acknowledge()).resolves.toBe(true)
+    expect(mutate).toHaveBeenCalledWith({
+      ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
+      ops: [{ op: 'set', path: [WELCOME_NOTICE_ACK_FIELD], value: WELCOME_NOTICE_VERSION }],
+    })
+    expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: true })
+  })
+
+  it('keeps the notice pending when loading or persistence fails', async () => {
+    const load = new WelcomeNoticeStore({
+      settings: { describe: () => Promise.reject(new Error('offline')) },
+    } as never)
+    await load.load()
+    expect(load.store.getSnapshot()).toEqual({ status: 'error', acknowledged: false, error: 'offline' })
+
+    const save = new WelcomeNoticeStore({
+      settings: { mutate: () => Promise.reject(new Error('disk full')) },
+    } as never)
+    await expect(save.acknowledge()).resolves.toBe(false)
+    expect(save.store.getSnapshot()).toEqual({ status: 'error', acknowledged: false, error: 'disk full' })
+
+    const nonError = new WelcomeNoticeStore({
+      // Durable/wire failures are unknown; exercise containment of a non-Error rejection.
+      settings: { describe: () => Promise.reject('offline string') },
+    } as never)
+    await nonError.load()
+    expect(nonError.store.getSnapshot().error).toBe('offline string')
+  })
+
+  it('reports business failures, missing namespaces, and malformed durable values', async () => {
+    for (const describe of [
+      () => Promise.resolve({
+        rpcId: 'failed' as never,
+        result: { ok: false as const, error: { code: 'internal' as const, message: 'denied', details: {} } },
+      }),
+      () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [] })),
+    ]) {
+      const controller = new WelcomeNoticeStore({ settings: { describe } } as never)
+      await controller.load()
+      expect(controller.store.getSnapshot().status).toBe('error')
+    }
+
+    for (const value of [null, 42, { [WELCOME_NOTICE_ACK_FIELD]: 42 }]) {
+      const controller = new WelcomeNoticeStore({
+        settings: { describe: () => Promise.resolve(ok({
+          writable: true,
+          hasDocument: false,
+          namespaces: [{ ...namespace(), value }],
+        })) },
+      } as never)
+      await controller.load()
+      expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: false })
+    }
+
+    const save = new WelcomeNoticeStore({
+      settings: { mutate: () => Promise.resolve({
+        rpcId: 'failed-save' as never,
+        result: {
+          ok: false,
+          error: {
+            code: 'settings-rejected',
+            message: 'denied',
+            details: { ns: WELCOME_NOTICE_SETTINGS_NAMESPACE },
+          },
+        },
+      }) },
+    } as never)
+    await expect(save.acknowledge()).resolves.toBe(false)
+    expect(save.store.getSnapshot().error).toBe('denied')
+  })
+
+  it('lets the latest load win over stale success and failure', async () => {
+    const first = deferred<ReturnType<typeof ok>>()
+    const describe = vi.fn()
+      .mockImplementationOnce(() => first.promise)
+      .mockImplementationOnce(() => Promise.resolve(ok({
+        writable: true, hasDocument: false, namespaces: [namespace()],
+      })))
+    const controller = new WelcomeNoticeStore({ settings: { describe } } as never)
+    const stale = controller.load()
+    await controller.load()
+    first.resolve(ok({
+      writable: true, hasDocument: false, namespaces: [namespace(WELCOME_NOTICE_VERSION)],
+    }))
+    await stale
+    expect(controller.store.getSnapshot().acknowledged).toBe(false)
+
+    const failed = deferred<ReturnType<typeof ok>>()
+    describe
+      .mockImplementationOnce(() => failed.promise)
+      .mockImplementationOnce(() => Promise.resolve(ok({
+        writable: true, hasDocument: false, namespaces: [namespace(WELCOME_NOTICE_VERSION)],
+      })))
+    const staleFailure = controller.load()
+    await controller.load()
+    failed.reject('stale failure')
+    await staleFailure
+    expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: true, error: null })
+  })
+
+  it('contains stale acknowledgement settlements and refreshes only a loaded store', async () => {
+    const write = deferred<ReturnType<typeof ok>>()
+    const describe = vi.fn(() => Promise.resolve(ok({
+      writable: true, hasDocument: false, namespaces: [namespace()],
+    })))
+    const controller = new WelcomeNoticeStore({
+      settings: { mutate: () => write.promise, describe },
+    } as never)
+    refreshWelcomeIfLoaded(controller)
+    expect(describe).not.toHaveBeenCalled()
+    const staleWrite = controller.acknowledge()
+    await controller.load()
+    write.resolve(ok(namespace(WELCOME_NOTICE_VERSION)))
+    await expect(staleWrite).resolves.toBe(true)
+    expect(controller.store.getSnapshot().acknowledged).toBe(false)
+    refreshWelcomeIfLoaded(controller)
+    await vi.waitFor(() => { expect(describe).toHaveBeenCalledTimes(2) })
+
+    const failedWrite = deferred<ReturnType<typeof ok>>()
+    const staleFailure = new WelcomeNoticeStore({
+      settings: { mutate: () => failedWrite.promise, describe },
+    } as never)
+    const pending = staleFailure.acknowledge()
+    await staleFailure.load()
+    failedWrite.reject('late failure')
+    await expect(pending).resolves.toBe(false)
+    expect(staleFailure.store.getSnapshot().status).toBe('ready')
+  })
+})

+ 5 - 7
packages/client/ui-settings/src/client/contract/slots.ts

@@ -64,13 +64,11 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
      * Root-scoped onboarding steps contributed by settings features. The
      * Root-scoped onboarding steps contributed by settings features. The
      * shell mounts one ordered step at a time; the active registrant either
      * shell mounts one ordered step at a time; the active registrant either
      * completes itself or keeps ownership until the user completes its sole
      * completes itself or keeps ownership until the user completes its sole
-     * path. Registrants own readiness, copy, dialog behavior, AND the
-     * takeover chrome: a step wraps its visible content in the
-     * OnboardingSurface primitive (mask, opaque stage, `#root` inert) and
-     * renders null while its private facts are still loading — the shell
-     * paints no chrome of its own, so a mounted-but-deciding step shows and
-     * blocks nothing (prevents a white flash on reload: a bare unwrapped step
-     * would render without mask or stage).
+     * path. Registrants own readiness, copy, dialog behavior, AND visible
+     * chrome: a step wraps its visible content in its modal surface (including
+     * `#root` inert ownership) and renders null while private facts are still
+     * loading. The shell paints no chrome of its own, so a mounted-but-deciding
+     * step shows and blocks nothing.
      */
      */
     'settings.onboarding': { kind: 'list'; scope: 'root'; owner: SettingsOnboardingOwnerProps }
     'settings.onboarding': { kind: 'list'; scope: 'root'; owner: SettingsOnboardingOwnerProps }
     /**
     /**

+ 4 - 3
packages/extensions/cordis-client-runner/src/client/slot-catalog.ts

@@ -1192,7 +1192,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     ],
     replaceRisk: 'none',
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'settings.general.item\', () => ctx.slots.register(\n      { name: \'settings.general.item\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'settings.general.item\', () => ctx.slots.register(\n      { name: \'settings.general.item\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-settings/src/client/contract/slots.ts:90',
+    source: 'packages/client/ui-settings/src/client/contract/slots.ts:88',
   },
   },
   {
   {
     key: 'settings.header',
     key: 'settings.header',
@@ -1225,7 +1225,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     kind: 'list',
     kind: 'list',
     scope: 'root',
     scope: 'root',
     summary: 'Root-scoped onboarding steps contributed by settings features.',
     summary: 'Root-scoped onboarding steps contributed by settings features.',
-    doc: 'Root-scoped onboarding steps contributed by settings features. The\nshell mounts one ordered step at a time; the active registrant either\ncompletes itself or keeps ownership until the user completes its sole\npath. Registrants own readiness, copy, dialog behavior, AND the\ntakeover chrome: a step wraps its visible content in the\nOnboardingSurface primitive (mask, opaque stage, `#root` inert) and\nrenders null while its private facts are still loading — the shell\npaints no chrome of its own, so a mounted-but-deciding step shows and\nblocks nothing (prevents a white flash on reload: a bare unwrapped step\nwould render without mask or stage).',
+    doc: 'Root-scoped onboarding steps contributed by settings features. The\nshell mounts one ordered step at a time; the active registrant either\ncompletes itself or keeps ownership until the user completes its sole\npath. Registrants own readiness, copy, dialog behavior, AND visible\nchrome: a step wraps its visible content in its modal surface (including\n`#root` inert ownership) and renders null while private facts are still\nloading. The shell paints no chrome of its own, so a mounted-but-deciding\nstep shows and blocks nothing.',
     registerOptions: [
     registerOptions: [
       {
       {
         name: 'id',
         name: 'id',
@@ -1259,11 +1259,12 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     slotInject: '',
     slotInject: '',
     declaredBy: 'an entry in \'sidebar.settings\' (client-ui-settings-general), so it exists while that entry is mounted',
     declaredBy: 'an entry in \'sidebar.settings\' (client-ui-settings-general), so it exists while that entry is mounted',
     occupants: [
     occupants: [
+      'client-ui-settings-models WelcomeNotice id \'welcome-notice\'',
       'client-ui-settings-models DeepSeekOnboardingDialog id \'deepseek-official\'',
       'client-ui-settings-models DeepSeekOnboardingDialog id \'deepseek-official\'',
     ],
     ],
     replaceRisk: 'none',
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'settings.onboarding\', () => ctx.slots.register(\n      { name: \'settings.onboarding\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'settings.onboarding\', () => ctx.slots.register(\n      { name: \'settings.onboarding\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-settings/src/client/contract/slots.ts:75',
+    source: 'packages/client/ui-settings/src/client/contract/slots.ts:73',
   },
   },
   {
   {
     key: 'settings.plugin.item',
     key: 'settings.plugin.item',

Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.