From bef8db3addac9b9cd28a069c39a56ab9d68a1089 Mon Sep 17 00:00:00 2001 From: NI0317 Date: Thu, 30 Jul 2026 22:17:56 +0800 Subject: [PATCH 01/19] feat(web): add versioned first-run welcome --- ...seek-onboarding-credential-setup.i18n.yaml | 4 +- ...30-deepseek-onboarding-credential-setup.md | 6 +- ...deepseek-onboarding-credential-setup.zh.md | 6 +- ...versioned-gui-welcome-onboarding.i18n.yaml | 6 + ...-07-30-versioned-gui-welcome-onboarding.md | 35 ++++ ...-30-versioned-gui-welcome-onboarding.zh.md | 35 ++++ .../tests/onboarding-deepseek-config.e2e.ts | 74 +++++++- .../welcome.expected.md | 6 + docs/event-producer-consumer.md | 4 +- docs/module-graph.md | 4 +- packages/client/connection/src/index.ts | 1 + .../client/connection/tests/node-half.spec.ts | 4 +- packages/client/ui-models/README.i18n.yaml | 4 +- packages/client/ui-models/README.md | 4 +- packages/client/ui-models/README.zh.md | 4 +- .../src/client/DeepSeekOnboardingDialog.tsx | 21 +-- .../tests/onboarding-dialog.spec.tsx | 21 +-- .../ui-settings-general/README.i18n.yaml | 6 +- packages/client/ui-settings-general/README.md | 4 +- .../client/ui-settings-general/README.zh.md | 4 +- .../client/ui-settings-general/package.json | 13 +- .../src/client/WelcomeNotice.module.css | 70 ++++++++ .../src/client/WelcomeNotice.tsx | 69 ++++++++ .../ui-settings-general/src/client/index.ts | 41 ++++- .../ui-settings-general/src/client/locales.ts | 13 ++ .../src/client/welcome-store.ts | 108 ++++++++++++ .../client/ui-settings-general/src/index.ts | 31 +++- .../ui-settings-general/src/invariant.ts | 7 +- .../src/onboarding-copy.ts | 33 ++++ .../ui-settings-general/tests/apply.spec.ts | 47 ++++- .../ui-settings-general/tests/host.spec.ts | 29 +++ .../tests/invariant.spec.ts | 6 - .../tests/welcome-notice.spec.tsx | 101 +++++++++++ .../tests/welcome-store.spec.ts | 166 ++++++++++++++++++ .../client/ui-settings-general/tsconfig.json | 9 + packages/client/ui-settings/README.i18n.yaml | 4 +- packages/client/ui-settings/README.md | 4 +- packages/client/ui-settings/README.zh.md | 4 +- .../ui-settings/src/client/SettingsRoot.tsx | 29 ++- .../ui-settings/src/client/contract/slots.ts | 24 ++- .../client/ui-settings/src/client/index.ts | 26 ++- .../client/ui-settings/tests/apply.spec.ts | 23 +++ .../ui-settings/tests/settings-root.spec.tsx | 32 +++- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- packages/host/apiproxy/src/api-proxy.ts | 31 ++-- .../apiproxy/tests/api-proxy-config.spec.ts | 17 +- pnpm-lock.yaml | 13 ++ 49 files changed, 1096 insertions(+), 115 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md create mode 100644 .agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.zh.md create mode 100644 apps/web/tests/snapshots/onboarding-deepseek-config/welcome.expected.md create mode 100644 packages/client/ui-settings-general/src/client/WelcomeNotice.module.css create mode 100644 packages/client/ui-settings-general/src/client/WelcomeNotice.tsx create mode 100644 packages/client/ui-settings-general/src/client/welcome-store.ts create mode 100644 packages/client/ui-settings-general/src/onboarding-copy.ts create mode 100644 packages/client/ui-settings-general/tests/host.spec.ts create mode 100644 packages/client/ui-settings-general/tests/welcome-notice.spec.tsx create mode 100644 packages/client/ui-settings-general/tests/welcome-store.spec.ts diff --git a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.i18n.yaml index 8beabfa66e..47ce2b206e 100644 --- a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.i18n.yaml +++ b/.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; # 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 -2026-07-30-deepseek-onboarding-credential-setup.md: 3f75a0893623afc0908cb48f2b838321ed9dedd3 -2026-07-30-deepseek-onboarding-credential-setup.zh.md: 62f8f0b99f167b22051aaddf7331a043bd2ea812 +2026-07-30-deepseek-onboarding-credential-setup.md: 253800b7d94c80f1809c211ad0b3788b4ae4e07c +2026-07-30-deepseek-onboarding-credential-setup.zh.md: 2dd4de8185d0b6c8c33ad381a1b1aa358b07e872 diff --git a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.md b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.md index 3f75a08936..253800b7d9 100644 --- a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.md +++ b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.md @@ -12,11 +12,11 @@ The [web configuration plane](../architecture/2026-07-30-web-config-plane.md) ma **One readiness projection owns both Models and onboarding facts.** `ui-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, resolves its `settingsNs` and `settingsPath`, reads the effective `apiKeyEnv`, and evaluates the matching credential descriptor. A configured literal `apiKey` secret sidecar is also ready, so compatibility configuration does not trigger a false prompt; a configured process-environment credential is ready and remains read-only. -**The settings shell contributes navigation state, not provider policy.** `ui-settings` declares a root-scoped `settings.onboarding` list slot and tells registrants whether the current surface is the empty Hero. Its private `openSection(id)` callback opens the settings panel on one registered section. `ui-models` registers the DeepSeek overlay through the same declaration-aware deferred-registration path as its Models section, so plugin load order does not become a contract. +**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-models` registers the DeepSeek step through the same declaration-aware deferred-registration path as its Models section, so plugin load order does not become 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 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. -**Unavailable states stay honest.** An absent configurable-provider entry suppresses the prompt because navigation cannot repair the composition. A present provider whose settings or credential capability cannot be resolved renders an actionable deployment diagnostic; a failed initial join names the connection problem and leads to the Models retry surface. Configure later dismisses the overlay for the current mounted surface and writes no completion fact. Settings, credential, provider-topology, and connection invalidations all refresh the shared join, so an external credential update closes an open prompt without a reload. +**Unavailable states stay honest.** An absent configurable-provider entry completes the step because navigation cannot repair the composition. A present provider whose settings or credential capability cannot be resolved renders an actionable deployment diagnostic; a failed initial join names the connection problem and leads to the Models retry surface. Configure later completes only this 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. ## Alternatives considered @@ -30,4 +30,4 @@ The [web configuration plane](../architecture/2026-07-30-web-config-plane.md) ma ## Consequences -The first-run flow now leads to the shipped adapter's existing editor without restarting: a keyless browser test boots the real Web composition under an isolated harness home, follows the prompt to Models, stores a generated key through that page into the home's `.env`, verifies no key reaches DOM, ARIA, or browser console output, and confirms the running page reports configured. Pure readiness and React tests pin literal, file, process-environment, missing-provider, missing-capability, navigation, cancellation, and external-invalidation behavior. 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 welcome step 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 welcome notice, follows the DeepSeek step to Models, stores a generated key through that page into the home's `.env`, verifies no key reaches DOM, ARIA, or browser console output, and confirms the running page reports configured. Pure readiness and React tests pin literal, file, process-environment, missing-provider, missing-capability, navigation, cancellation, external-invalidation, and coordinator-transfer behavior. The flow deliberately inherits the configuration plane's documented base limitations rather than adding local secret storage, redaction, or settings replacement workarounds. diff --git a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.zh.md b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.zh.md index 62f8f0b99f..2dd4de8185 100644 --- a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.zh.md @@ -12,11 +12,11 @@ Status: implemented **Models 与首次使用引导共享同一个就绪状态投影。**`ui-models` 维护一个 store,把 `llm.providers({})`、脱敏后的 `settings.describe({})` 和批量调用的 `credentials.describe({refs})` 联接为同一份状态。首次使用投影选取 `deepseek-official` 可配置提供方条目,解析其 `settingsNs` 与 `settingsPath`,读取生效的 `apiKeyEnv`,并检查对应的凭据描述符。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,也会判定为就绪,兼容配置因此不会误触发浮层;通过进程环境提供的凭据若已配置,同样判定为就绪并保持只读。 -**设置外壳只贡献导航状态,不持有提供方策略。**`ui-settings` 声明一个根作用域的 `settings.onboarding` list slot,并告知注册方当前界面是否为空白 Hero。其私有 `openSection(id)` 回调会打开设置面板并切换到一个已注册分区。`ui-models` 沿用 Models 分区所使用、感知 slot 声明的延迟注册路径来注册 DeepSeek 浮层,因此插件加载顺序不会成为契约。 +**设置外壳只贡献排序与导航,不持有提供方策略。** `ui-settings` 声明一个根作用域的 `settings.onboarding` list slot,并在当前界面为空白 Hero 时,每次只挂载一个有序步骤。当前注册方会收到 `complete()` 和私有 `openSection(id)` 回调;完成当前步骤后,所有权转交给下一项。`ui-models` 沿用 Models 分区所使用、感知 slot 声明的延迟注册路径来注册 DeepSeek 步骤,因此插件加载顺序不会成为契约,独立贡献的对话框也无法堆叠。排在它之前的产品级欢迎步骤由[版本化欢迎决策](2026-07-30-versioned-gui-welcome-onboarding.md)单独持有。 **浮层只负责跳转到唯一的凭据编辑器。**适配器已挂载且处于活跃状态,其引用可解析、可写但尚未配置时,界面会显示一个操作按钮,用于打开「设置」的 Models 分区。该分区已有的 DeepSeek 设置卡片全权负责密码输入框、`credentials.set({ref, value})`、写入失败处理和写入后刷新;首次使用浮层绝不持有或提交 secret。设置或凭据能力不可用时会保留部署诊断,并提供前往同一页面的入口;适配器缺失时仍直接跳过,因为导航无法挂载 Cordis 插件。 -**不可用状态如实呈现。**可配置提供方条目缺失时不显示浮层,因为导航无法修复当前组合。提供方存在,但设置或凭据能力无法解析时,界面会显示可采取操作的部署诊断;初始联接失败时会明确指出连接问题,并引导前往 Models 的重试界面。「稍后配置」只会在当前已挂载界面中关闭浮层,不写入任何完成状态。设置、凭据、提供方拓扑和连接失效事件都会刷新共享联接,因此外部凭据更新无需重新加载页面即可关闭已打开的浮层。 +**不可用状态如实呈现。** 可配置提供方条目缺失时会完成当前步骤,因为导航无法修复当前组合。提供方存在,但设置或凭据能力无法解析时,界面会显示可采取操作的部署诊断;初始联接失败时会明确指出连接问题,并引导前往 Models 的重试界面。「稍后配置」只会完成协调器当前这一次挂载流程,不写入任何完成状态。设置、凭据、提供方拓扑和连接失效事件都会刷新共享联接,因此外部凭据更新无需重新加载页面即可完成已打开的步骤。 ## 曾考虑的替代方案 @@ -30,4 +30,4 @@ Status: implemented ## 后果 -首次使用流程现在无需重启即可引导用户前往随产品提供的适配器已有的编辑器:无密钥浏览器测试在隔离的 harness 家目录下启动真实 Web 组合,依照浮层操作前往 Models,通过该页面把生成的密钥存入该目录的 `.env`,验证密钥未进入 DOM、ARIA 或浏览器控制台输出,并确认运行中的页面报告已配置。纯就绪状态测试与 React 测试固化了字面量凭据、文件凭据、进程环境凭据、提供方缺失、能力缺失、导航、取消和外部失效行为。该流程直接继承配置平面已记录的基础限制,不会另加局部的机密存储、脱敏或设置替换变通方案。 +有序流程从产品欢迎步骤开始,无需重启即可引导用户前往随产品提供的适配器已有的编辑器:无密钥浏览器测试在隔离的 harness 家目录下启动真实 Web 组合,确认欢迎通知后依照 DeepSeek 步骤前往 Models,通过该页面把生成的密钥存入该目录的 `.env`,验证密钥未进入 DOM、ARIA 或浏览器控制台输出,并确认运行中的页面报告已配置。纯就绪状态测试与 React 测试固化了字面量凭据、文件凭据、进程环境凭据、提供方缺失、能力缺失、导航、取消、外部失效和协调器移交行为。该流程直接继承配置平面已记录的基础限制,不会另加局部的机密存储、脱敏或设置替换变通方案。 diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.i18n.yaml new file mode 100644 index 0000000000..24bdee3b68 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-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-07-30-versioned-gui-welcome-onboarding.md +2026-07-30-versioned-gui-welcome-onboarding.md: 405c6fe833d995123cd15e5694cd5ef75a0cd03d +2026-07-30-versioned-gui-welcome-onboarding.zh.md: ea83aa958866ab3dcca749f362d43e4b29408e02 diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md new file mode 100644 index 0000000000..405c6fe833 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md @@ -0,0 +1,35 @@ +# Agent Note: Versioned GUI welcome onboarding + +Status: implemented + +English | [中文](2026-07-30-versioned-gui-welcome-onboarding.zh.md) + +## Problem + +The GUI's credential onboarding begins with a DeepSeek-specific readiness check, but the internal-test notice applies to every user and must precede provider setup even when a credential is already configured. Treating both as independent overlays permits simultaneous dialogs, while a process-local dismissal cannot distinguish a completed notice from a window closed before acknowledgement or intentionally present revised copy once. + +## 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. The product welcome registers at order `-100`, while `ui-models` retains only the conditional DeepSeek readiness and credential-routing step at order `0`. + +**Ownerless product onboarding belongs to `ui-settings-general`.** `src/onboarding-copy.ts` is the single editable source for the complete Chinese notice, its faithful English counterpart, the Continue labels, and `WELCOME_NOTICE_VERSION`. Runtime locale dictionaries derive their welcome values from that file, and tests import the same owner instead of repeating paragraph text. The notice is browser UI only: it creates no Session event and contributes no model-visible content. + +**Acknowledgement is durable per Harness profile.** The Host half registers a `ui-onboarding` section in the user-settings seam, stored under the active `$DSH_HOME/settings.yaml`. The browser shows the notice unless `welcomeNoticeVersion` equals the owner constant exactly. Continue applies one path mutation with the current version and calls `complete()` only after the Host commits it; a failed write leaves the notice open, and closing the page or process writes nothing. Bumping the constant intentionally makes every profile acknowledge the revised copy once. + +**Concurrent views converge without stale replacement.** The acknowledgement write omits `expectedRevision` deliberately: every tab writes the same version to one path, so the operation is idempotent and preserves sibling fields instead of rebuilding the section. `settings/document-updated` becomes `host/settings-changed`; an already mounted tab refetches and advances when another tab or an external editor commits the current version. 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 welcome modal has one completion path.** It renders no close icon or secondary action, installs no Escape handler, and assigns no click handler to the mask. Its mask starts below the 80 px top chrome and preserves `position:absolute`, zero left/right/bottom offsets, `rgba(0, 0, 0, 0.24)`, and `backdrop-filter: blur(2px)`. Continue is the sole button and receives initial focus. + +## Alternatives considered + +**Browser local storage** — rejected because acknowledgement would follow one browser profile rather than `$DSH_HOME`; a fresh Harness profile could incorrectly inherit a prior acknowledgement, and external profile edits would have no authoritative update stream. + +**A second independent modal in `ui-settings-general`** — rejected because list registrants would still stack whenever welcome and credential readiness were both true. Ordered ownership belongs to the shell that declares and renders the list. + +**Persisting on render or window close** — rejected because observation is not acknowledgement and close delivery is unreliable. Only the explicit Continue commit may suppress the next launch. + +**A generic public settings-exposure flag** — rejected because one product namespace does not justify widening every settings registrant's public configuration surface. The gateway keeps an explicit closed allowlist. + +## Consequences + +A fresh profile always sees the welcome notice before provider-specific onboarding; an already configured credential skips only the later DeepSeek step. Reloading after Continue stays past the acknowledged version, changing the owner version presents it again, and closing before Continue leaves the next launch unchanged. Focused store and React tests pin exact-version comparison, write failure, sole-action behavior, no-dismiss paths, 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, reloads before and after acknowledgement, continues into missing-credential setup, confirms an acknowledged-version mismatch returns while the credential is configured, and checks the browser console. diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.zh.md b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.zh.md new file mode 100644 index 0000000000..ea83aa9588 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.zh.md @@ -0,0 +1,35 @@ +# Agent Note: 版本化 GUI 欢迎引导 + +Status: implemented + +[English](2026-07-30-versioned-gui-welcome-onboarding.md) | 中文 + +## 问题 + +GUI 的凭据引导从 DeepSeek 专用的就绪状态检查开始,但内部测试通知适用于每位用户,即使凭据已经配置,也必须先于提供方设置显示。若把两者作为独立浮层处理,多个对话框可能同时出现;仅存于进程内的关闭标记既无法区分通知已完成确认还是窗口在确认前已关闭,也无法在文案有意修订后重新显示一次通知。 + +## 决策 + +**设置外壳协调有序步骤。** `settings.onboarding` 仍是根作用域 list,但 `ui-settings` 会把其中各条目的 id 和顺序投影到一个协调器中,并且只挂载第一个未完成的步骤。当前注册方会收到 `complete()` 和 `openSection(id)`;所有权转移前,不会挂载后续步骤。产品欢迎步骤的顺序为 `-100`,`ui-models` 则只保留顺序为 `0` 的 DeepSeek 条件式就绪状态与凭据跳转步骤。 + +**不属于单一功能的产品引导由 `ui-settings-general` 持有。** `src/onboarding-copy.ts` 是完整中文通知、忠实英文对侧文案、两种语言的「继续」按钮文案和 `WELCOME_NOTICE_VERSION` 的唯一可编辑来源。运行时 locale 字典从该文件派生欢迎文案,测试也导入同一个所有者,而不重复段落文本。该通知只存在于浏览器 UI:它不会创建会话事件,也不会贡献任何模型可见内容。 + +**确认状态按 Harness profile 持久化。** 宿主端在 user-settings seam 中注册 `ui-onboarding` 分节,并存入当前 `$DSH_HOME/settings.yaml`。除非 `welcomeNoticeVersion` 与文案所有者文件中的常量精确相等,否则浏览器会显示通知。「继续」会以当前版本执行一次路径变更,并且仅在宿主端提交成功后调用 `complete()`;写入失败时通知保持打开,关闭页面或进程则不会写入任何内容。提升该常量会有意要求每个 profile 对修订后的文案重新确认一次。 + +**并发视图无需陈旧的整体替换即可收敛。** 确认写入有意省略 `expectedRevision`:每个标签页都向同一路径写入相同版本,因此该操作是幂等的,并会保留同级字段,而不是重建整个分节。`settings/document-updated` 会转为 `host/settings-changed`;另一个标签页或外部编辑器提交当前版本后,已挂载的标签页会重新拉取状态并推进。API 网关在可配置提供方 namespace 之外,通过封闭的允许列表暴露这一个产品 namespace,同时不会把它的变更视为模型目录失效事件。 + +**欢迎模态窗口只有一条完成路径。** 界面不渲染关闭图标或次要操作,不安装 Escape 处理器,也不为遮罩添加点击处理器。遮罩从顶部 80 px 的界面框架下方开始,并保留 `position:absolute`、left/right/bottom 偏移量为零、`rgba(0, 0, 0, 0.24)` 和 `backdrop-filter: blur(2px)`。「继续」是唯一按钮,并会获得初始焦点。 + +## 曾考虑的替代方案 + +**浏览器本地存储**:不予采用,因为确认状态会跟随某个浏览器 profile,而不是 `$DSH_HOME`;全新的 Harness profile 可能错误继承此前的确认状态,外部 profile 编辑也没有权威更新流。 + +**在 `ui-settings-general` 中再增加一个独立模态窗口**:不予采用,因为欢迎通知和凭据就绪状态同时为真时,list 注册方仍会堆叠。声明并渲染该 list 的外壳应当持有有序所有权。 + +**在渲染或窗口关闭时持久化**:不予采用,因为看见通知不等于确认,窗口关闭事件也无法可靠送达。只有显式提交「继续」才能阻止通知在下次启动时再次显示。 + +**通用的公开设置暴露标志**:不予采用,因为一个产品 namespace 不足以证明应当扩大每个 settings 注册方的公开配置面。网关保留显式的封闭允许列表。 + +## 后果 + +全新 profile 始终会在提供方专用引导之前看到欢迎通知;凭据已经配置时,只会跳过后续 DeepSeek 步骤。点击「继续」后重新加载不会再次显示已确认版本,更改文案所有者文件中的版本值会让通知重新出现,而确认前关闭窗口不会改变下次启动。针对性的 store 与 React 测试固化了精确版本比较、写入失败、单一操作、不可关闭路径、协调器顺序、按条件移交 DeepSeek 步骤和 HMR(热模块替换)清理行为。真实 Chromium 场景会使用隔离的 harness 家目录启动随产品提供的 Web 组合,验证遮罩的精确几何尺寸和计算样式,在确认前后分别重新加载,继续进入凭据缺失设置流程,确认凭据已配置时确认版本不匹配仍会使通知重新出现,并检查浏览器控制台。 diff --git a/apps/web/tests/onboarding-deepseek-config.e2e.ts b/apps/web/tests/onboarding-deepseek-config.e2e.ts index 62dd129982..f372910a61 100644 --- a/apps/web/tests/onboarding-deepseek-config.e2e.ts +++ b/apps/web/tests/onboarding-deepseek-config.e2e.ts @@ -9,12 +9,18 @@ import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { - assertFixtureInventory, captureStableAria, compareOrRefreshGolden, + acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { saveFailureShot } from './support.ts' +import { settingsNamespace } from '@deepseek-ai/dsh-settings' +import { + WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_COPY, WELCOME_NOTICE_SETTINGS_NAMESPACE, + WELCOME_NOTICE_VERSION, +} from '@deepseek-ai/dsh-client-ui-settings-general' 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 MODE = webSnapshotMode() @@ -42,6 +48,47 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup it('stores a key write-only and observes configured state without restarting', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-deepseek-config')) + const welcome = page.getByRole('dialog', { name: WELCOME_NOTICE_COPY.zh.paragraphs[0] }) + await welcome.waitFor({ timeout: 15_000 }) + const welcomeAria = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(WELCOME_EXPECTED, welcomeAria, MODE) + expect(await welcome.getByRole('button').allTextContents()).toEqual([WELCOME_NOTICE_COPY.zh.continueLabel]) + expect(await welcome.locator('button').count()).toBe(1) + + const maskStyles = await welcome.locator('xpath=..').locator(':scope > div').first().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 }, + }) + + // Closing the process/page before acknowledgement writes nothing, so the + // same durable profile presents the notice again after reload. + const firstReloadWarnings = tripwire.warnings.length + await page.reload({ waitUntil: 'load' }) + acknowledgeReloadConnectionLoss(tripwire, firstReloadWarnings) + 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 }) const dialog = page.getByRole('dialog', { name: '添加一个 API Key 开始使用' }) await dialog.waitFor({ timeout: 15_000 }) expect(await dialog.getByRole('textbox').count()).toBe(0) @@ -78,6 +125,29 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup { timeout: 10_000 }, ).toBe('已配置——输入新值可替换') + const acknowledgedSettings = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8') + expect(acknowledgedSettings).toContain(`${WELCOME_NOTICE_ACK_FIELD}: ${WELCOME_NOTICE_VERSION}`) + + const secondReloadWarnings = tripwire.warnings.length + await page.reload({ waitUntil: 'load' }) + acknowledgeReloadConnectionLoss(tripwire, secondReloadWarnings) + await page.waitForSelector('[class*="frame"]', { timeout: 15_000 }) + expect(await page.getByRole('dialog', { name: WELCOME_NOTICE_COPY.zh.paragraphs[0] }).count()).toBe(0) + expect(await page.getByRole('dialog', { name: '添加一个 API Key 开始使用' }).count()).toBe(0) + + // A different stored copy version represents an intentional version bump: + // the welcome step returns even though the credential is already ready. + 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.locator('body').ariaSnapshot()).includes(secret)).toBe(false) expect(browserConsole.some(line => line.includes(secret))).toBe(false) @@ -86,6 +156,6 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup }, 60_000) it('keeps the fixture inventory closed', async () => { - await assertFixtureInventory(SNAPSHOT_DIR, ['missing.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, ['missing.expected.md', 'welcome.expected.md']) }) }) diff --git a/apps/web/tests/snapshots/onboarding-deepseek-config/welcome.expected.md b/apps/web/tests/snapshots/onboarding-deepseek-config/welcome.expected.md new file mode 100644 index 0000000000..370737df6b --- /dev/null +++ b/apps/web/tests/snapshots/onboarding-deepseek-config/welcome.expected.md @@ -0,0 +1,6 @@ +- dialog "感谢您愿意拨冗试用 DeepSeek Harness。": + - heading "感谢您愿意拨冗试用 DeepSeek Harness。" [level=2] + - paragraph: 目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗粝。 + - paragraph: “如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中暴露的问题,也可能促使我们重新审视,甚至推翻已有的设计。 + - paragraph: 我们尤其希望听见那些失败、困惑与不顺手的时刻——如果它未能帮到您,甚至反而为工作平添了麻烦,请在企业微信群中留言,将使用感受告诉我们。每一条反馈,都会帮助我们把它打磨得更好。 + - button "继续" diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 14da68499b..4690a0ca2d 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -66,14 +66,14 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event string | Dispatchers | Listeners | | --- | --- | --- | | `commands/changed` | `runtime` (`emit`) | `ui-command` | -| `connection/reset` | `runtime` (`emit`) | `ui-command`, `ui-models` | +| `connection/reset` | `runtime` (`emit`) | `ui-command`, `ui-models`, `ui-settings-general` | | `credentials/changed` | `runtime` (`emit`) | `ui-models` | | `internal/dispatch` | - | [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workflow`](../packages/workflow/workflow) | | `internal/plugin` | - | `hmr`, `loader`, `modules`, `webserver` | | `internal/status` | - | [`agent`](../packages/core/agent) | | `locale/change` | `locale` (`emit`) | `locale`, `ui-models`, `ui-settings-general` | | `models/changed` | `runtime` (`emit`) | `ui-models` | -| `settings/changed` | `runtime` (`emit`) | `ui-models` | +| `settings/changed` | `runtime` (`emit`) | `ui-models`, `ui-settings-general` | | `slash/input-begin-command` | - | `ui-conversation` | | `slash/input-consume-token` | - | `ui-conversation` | | `slash/input-insert-reference` | - | `ui-conversation` | diff --git a/docs/module-graph.md b/docs/module-graph.md index 5827cf458d..602dff45c4 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -367,11 +367,13 @@ flowchart TD pkg_client_ui_models --> pkg_invariants pkg_client_ui_question --> pkg_client_locale pkg_client_ui_question --> pkg_invariants + pkg_client_ui_settings_general --> pkg_client_connection pkg_client_ui_settings_general --> pkg_client_locale pkg_client_ui_settings_general --> pkg_client_runtime pkg_client_ui_settings_general --> pkg_client_ui_primitives pkg_client_ui_settings_general --> pkg_client_ui_settings pkg_client_ui_settings_general --> pkg_client_ui_slots + pkg_client_ui_settings_general --> pkg_client_web_react pkg_client_ui_settings_general --> pkg_invariants pkg_client_ui_sidebar --> pkg_client_locale pkg_client_ui_sidebar --> pkg_client_runtime @@ -1067,7 +1069,7 @@ flowchart TD | [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`client-ui-models`](../packages/client/ui-models) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | | [`client-ui-question`](../packages/client/ui-question) | `client` | [`client-locale`](../packages/client/locale), [`invariants`](../packages/support/invariants) | -| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index cedc7d86e7..ed4af2d21f 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -54,6 +54,7 @@ const PRIVILEGED_METHODS = new Set([ 'settings.describe', 'settings.update', 'settings.replace', + 'settings.mutate', 'credentials.describe', 'credentials.set', 'credentials.unset', diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.spec.ts index 6839d8b3ca..4910ba990c 100644 --- a/packages/client/connection/tests/node-half.spec.ts +++ b/packages/client/connection/tests/node-half.spec.ts @@ -107,7 +107,7 @@ describe('connection node half', () => { // passed), but each privileged method stays loopback-only and 403s. for (const method of [ 'host.pickDirectory', 'host.openPath', - 'settings.describe', 'settings.update', 'settings.replace', + 'settings.describe', 'settings.update', 'settings.replace', 'settings.mutate', 'credentials.describe', 'credentials.set', 'credentials.unset', ]) { const denied = fakeResponse() @@ -191,7 +191,7 @@ describe('connection node half over a real HTTP server', () => { // Reads are as privileged as writes: describe returns the exposed // configuration, and credentials.describe probes arbitrary env-var names. for (const method of [ - 'settings.describe', 'settings.update', 'settings.replace', + 'settings.describe', 'settings.update', 'settings.replace', 'settings.mutate', 'credentials.describe', 'credentials.set', 'credentials.unset', 'host.pickDirectory', 'host.openPath', ]) { diff --git a/packages/client/ui-models/README.i18n.yaml b/packages/client/ui-models/README.i18n.yaml index 0355080b11..3bc6c94ec4 100644 --- a/packages/client/ui-models/README.i18n.yaml +++ b/packages/client/ui-models/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-models/README.md -README.md: aac437a13f6465196fcf1f8908b1d9ea2ccef401 -README.zh.md: 468537ac217a46395f2ec78174efa1f5d75a679d +README.md: 2f53024df95a79862d5461d3514987a6e8257f9d +README.zh.md: 7c95709933fa29a8fd6ed773696e9657d959f753 diff --git a/packages/client/ui-models/README.md b/packages/client/ui-models/README.md index aac437a13f..2f53024df9 100644 --- a/packages/client/ui-models/README.md +++ b/packages/client/ui-models/README.md @@ -2,11 +2,11 @@ English | [中文](README.zh.md) -Models settings plugin: the provider configuration page and official-DeepSeek first-run routing overlay. It joins three wire domains into one shared snapshot — `llm.providers` (the configurable-provider directory with each route's live/dormant state), `settings.describe` (serialized schemas, layered redacted values, secret slots), and `credentials.describe` (value-free configured/source/writable badges) — and renders provider rows with one editor card at a time. +Models settings plugin: the provider configuration page and official-DeepSeek conditional onboarding step. It joins three wire domains into one shared snapshot — `llm.providers` (the configurable-provider directory with each route's live/dormant state), `settings.describe` (serialized schemas, layered redacted values, secret slots), and `credentials.describe` (value-free configured/source/writable badges) — and renders provider rows with one editor card at a time. Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), plus `reasoningEffort` (deepseek) or `reasoning` (pi-ai); every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base). -The first-run overlay projects `deepseek-official` readiness from that same joined snapshot. A configured literal `apiKey` secret sidecar or configured credential reference suppresses the prompt, including a read-only launch-environment credential. A mounted adapter with a missing writable reference shows one action that opens Settings on the Models section, whose existing setup card exclusively owns key input and `credentials.set`; the overlay never holds a secret. An absent adapter is skipped because browser navigation cannot mount Cordis plugins, while an unusable settings or credential capability produces a deployment diagnostic with the same route to Models. +The DeepSeek step projects `deepseek-official` readiness from that same joined snapshot after earlier onboarding steps complete. A configured literal `apiKey` secret sidecar or configured credential reference completes the step without rendering, including a read-only launch-environment credential. A mounted adapter with a missing writable reference shows one action that opens Settings on the Models section, whose existing setup card exclusively owns key input and `credentials.set`; the step never holds a secret. An absent adapter is skipped because browser navigation cannot mount Cordis plugins, while an unusable settings or credential capability produces a deployment diagnostic with the same route to Models. Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted row. The page only ever holds the REDACTED descriptor, so it names the fields it can see rather than rebuilding a section: a stored literal secret it never received is mentioned by no op and survives. Each write carries the `revision` the card opened at, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict` and the card asks the user to reopen instead of replaying its stale snapshot. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling. diff --git a/packages/client/ui-models/README.zh.md b/packages/client/ui-models/README.zh.md index 468537ac21..7c95709933 100644 --- a/packages/client/ui-models/README.zh.md +++ b/packages/client/ui-models/README.zh.md @@ -2,11 +2,11 @@ [English](README.md) | 中文 -模型设置插件:提供方配置页和 DeepSeek 官方首次使用跳转浮层。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片。 +模型设置插件:提供方配置页和按条件显示的 DeepSeek 官方首次使用引导步骤。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片。 行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点),另加 `reasoningEffort`(deepseek)或 `reasoning`(pi-ai);其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base)。 -首次使用浮层从同一个联接快照得出 `deepseek-official` 的就绪状态。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,或凭据引用已配置,浮层就不再显示,其中包括来自启动环境且只读的凭据。适配器已挂载、引用可写但尚未配置时,浮层只显示一个操作按钮,用于打开「设置」的 Models 分区;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,浮层绝不持有 secret。适配器缺失时直接跳过,因为浏览器导航无法挂载 Cordis 插件;设置或凭据能力不可用时则显示部署诊断,并提供同一个前往 Models 的入口。 +前序首次使用引导步骤完成后,DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,或凭据引用已配置,该步骤会直接完成而不渲染,其中包括来自启动环境且只读的凭据。适配器已挂载、引用可写但尚未配置时,该步骤只显示一个操作按钮,用于打开「设置」的 Models 分区;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失时直接跳过,因为浏览器导航无法挂载 Cordis 插件;设置或凭据能力不可用时则显示部署诊断,并提供同一个前往 Models 的入口。 每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除整行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它点名自己看得见的字段,而不是重建分节:一个它从未收到过的已存字面机密不会被任何 op 提及,也就得以留存。每次写入都携带该卡片打开时的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝,卡片会请用户重新打开,而不是把自己的陈旧快照重放上去。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。 diff --git a/packages/client/ui-models/src/client/DeepSeekOnboardingDialog.tsx b/packages/client/ui-models/src/client/DeepSeekOnboardingDialog.tsx index 3d43bf2033..31ae571272 100644 --- a/packages/client/ui-models/src/client/DeepSeekOnboardingDialog.tsx +++ b/packages/client/ui-models/src/client/DeepSeekOnboardingDialog.tsx @@ -4,7 +4,7 @@ * routes the user to that page's single credential editor. */ -import { useEffect, useState } from 'react' +import { useEffect } from 'react' import type { ReactNode } from 'react' import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import { Button, Modal } from '@deepseek-ai/dsh-client-ui-primitives' @@ -64,26 +64,23 @@ function unavailableDiagnostic( * @returns the controlled modal or null when onboarding needs no intervention. */ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps): ReactNode { - const { active, openSection, controller, useSnapshot, t } = props + const { complete, openSection, controller, useSnapshot, t } = props const state = useSnapshot(snapshot => snapshot) const readiness = deepSeekReadiness(state) - const [dismissed, setDismissed] = useState(false) useEffect(() => { - if (active && !dismissed && state.status === 'idle') void controller.load() - }, [active, controller, dismissed, state.status]) + if (state.status === 'idle') void controller.load() + }, [controller, state.status]) - const close = (): void => { - setDismissed(true) - } + useEffect(() => { + if (readiness.kind === 'adapter-absent' || readiness.kind === 'configured') complete() + }, [complete, readiness.kind]) const openModels = (): void => { - close() + complete() openSection('models') } - if (!active || dismissed) return null - let unavailableReason: UnavailableReason | undefined switch (readiness.kind) { case 'loading': @@ -108,7 +105,7 @@ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps): return ( { throw new Error('unused standard hook') }) as never const props: DeepSeekOnboardingDialogProps = { - active: true, + stepId: 'deepseek-official', + complete, openSection, useSessions: unusedHook, useWorkspaces: unusedHook, @@ -102,7 +104,7 @@ function harness(options: { useSnapshot: bindSnapshotSelector(controller.store), t: key => en[key], } - return { controller, openSection, props, configure: () => { fileConfigured = true } } + return { controller, complete, openSection, props, configure: () => { fileConfigured = true } } } describe('DeepSeekOnboardingDialog', () => { @@ -122,8 +124,8 @@ describe('DeepSeekOnboardingDialog', () => { render() await screen.findByRole('dialog') fireEvent.click(screen.getByRole('button', { name: en.onboardingGoToSettings })) + expect(h.complete).toHaveBeenCalledOnce() expect(h.openSection).toHaveBeenCalledWith('models') - expect(screen.queryByRole('dialog', { name: en.onboardingTitle })).toBeNull() }) it('allows configure-later dismissal without opening settings', async () => { @@ -131,7 +133,7 @@ describe('DeepSeekOnboardingDialog', () => { render() await screen.findByRole('dialog') fireEvent.click(screen.getByRole('button', { name: en.onboardingLater })) - expect(screen.queryByRole('dialog')).toBeNull() + expect(h.complete).toHaveBeenCalledOnce() expect(h.openSection).not.toHaveBeenCalled() }) @@ -187,6 +189,7 @@ describe('DeepSeekOnboardingDialog', () => { const view = render() await act(async () => { await h.controller.load() }) expect(screen.queryByRole('dialog')).toBeNull() + await waitFor(() => { expect(h.complete).toHaveBeenCalledOnce() }) view.unmount() } }) @@ -198,14 +201,6 @@ describe('DeepSeekOnboardingDialog', () => { h.configure() await act(async () => { await h.controller.load() }) await waitFor(() => { expect(screen.queryByRole('dialog')).toBeNull() }) - }) - - it('stays hidden while the onboarding owner is inactive', async () => { - const h = harness() - const view = render() - await act(async () => { await h.controller.load() }) - expect(screen.queryByRole('dialog')).toBeNull() - view.rerender() - expect(await screen.findByRole('dialog', { name: en.onboardingTitle })).toBeTruthy() + expect(h.complete).toHaveBeenCalledOnce() }) }) diff --git a/packages/client/ui-settings-general/README.i18n.yaml b/packages/client/ui-settings-general/README.i18n.yaml index 9377fc73b8..b8fd2d4025 100644 --- a/packages/client/ui-settings-general/README.i18n.yaml +++ b/packages/client/ui-settings-general/README.i18n.yaml @@ -1,6 +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 -README.md: c392d745021c0fc6a752cf71dd0506a435106c50 -README.zh.md: 83ab81e01eae435a74b50fa363a4de203c483002 +# pnpm run verify-translation-pairing --write packages/client/ui-settings-general/README.md +README.md: 3ae3f58bd00172ed9b547c01a354023c224f6a1c +README.zh.md: ffdaf0e4314daa947a4183e2b23b7b1650de7611 diff --git a/packages/client/ui-settings-general/README.md b/packages/client/ui-settings-general/README.md index c392d74502..3ae3f58bd0 100644 --- a/packages/client/ui-settings-general/README.md +++ b/packages/client/ui-settings-general/README.md @@ -2,7 +2,9 @@ English | [中文](README.zh.md) -Settings ownerless-copy plugin: registers everything on the Settings surface that belongs to no single feature — the shell's trigger/header/close chrome content, the General section (Permission/Tool Call skeleton rows + the `settings.general.item` slot declaration), and the `settings` dictionaries. Feature-owned rows (Language, Appearance) and sections (Models) stay with their feature packages. +Settings ownerless-copy and product-onboarding plugin: registers everything on the Settings surface that belongs to no single feature — the shell's trigger/header/close chrome content, the General section (Permission/Tool Call skeleton rows + the `settings.general.item` slot declaration), the `settings` dictionaries, and the first ordered welcome step. Feature-owned rows (Language, Appearance), sections (Models), and conditional onboarding steps stay with their feature packages. + +`src/onboarding-copy.ts` is the single editable owner of the complete Chinese and English notice plus `WELCOME_NOTICE_VERSION`. The Host half registers `ui-onboarding` in the user-settings seam; the browser compares `welcomeNoticeVersion` for exact equality and writes the current value only after Continue succeeds. The path mutation is idempotent across tabs and preserves sibling settings, while `host/settings-changed` makes an externally acknowledged notice advance without a reload. A different version deliberately presents the notice again. The welcome UI has no close, Escape, mask-click, or secondary path, and none of its copy or acknowledgement enters a Session log or model request. ## Model Experience diff --git a/packages/client/ui-settings-general/README.zh.md b/packages/client/ui-settings-general/README.zh.md index 83ab81e01e..ffdaf0e431 100644 --- a/packages/client/ui-settings-general/README.zh.md +++ b/packages/client/ui-settings-general/README.zh.md @@ -2,7 +2,9 @@ [English](README.md) | 中文 -设置界面文案插件:在设置界面注册所有不属于单一功能的内容,包括外壳的触发器、标题栏与关闭控件内容,「通用」分区(「权限」/「工具调用」骨架行和 `settings.general.item` slot 声明),以及 `settings` 字典。归具体功能所有的行(「语言」、「外观」)和分区(「模型」)仍由各自的功能包提供。 +设置界面无特定功能归属的文案与产品引导插件:在设置界面注册所有不属于单一功能的内容,包括外壳的触发器、标题栏与关闭控件内容,「通用」分区(「权限」/「工具调用」骨架行和 `settings.general.item` slot 声明)、`settings` 字典,以及第一个有序欢迎步骤。归具体功能所有的行(「语言」、「外观」)、分区(「模型」)和条件式首次使用引导步骤仍由各自的功能包提供。 + +`src/onboarding-copy.ts` 是完整中英文通知文案和 `WELCOME_NOTICE_VERSION` 的唯一可编辑来源。宿主端在 user-settings seam 中注册 `ui-onboarding`;浏览器比较 `welcomeNoticeVersion` 是否精确相等,仅在「继续」操作成功后写入当前值。该路径变更在不同标签页间幂等,并会保留同级设置;`host/settings-changed` 则让页面在通知被外部确认后,无需重新加载即可推进。版本不同时,系统会有意重新显示通知。欢迎界面没有关闭操作、Escape、点击遮罩或次要操作路径,其文案和确认状态均不会进入会话日志或模型请求。 ## 模型体验 diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json index 798a7710f0..fa380cfc83 100644 --- a/packages/client/ui-settings-general/package.json +++ b/packages/client/ui-settings-general/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-ui-settings-general", - "description": "Settings ownerless-copy plugin: the General section (skeleton rows + item slot), the shell trigger/header chrome content, and the settings dictionaries", + "description": "Settings ownerless-copy and product onboarding plugin: General, shell chrome, dictionaries, and the versioned welcome notice", "version": "0.0.1", "private": true, "type": "module", @@ -26,7 +26,8 @@ "inject": [ "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-ui-settings", - "@deepseek-ai/dsh-client-locale" + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-connection" ], "platform": "web" }, @@ -35,22 +36,30 @@ "watch": "tsdown --watch" }, "license": "BSD-3-Clause", + "dependencies": { + "@deepseek-ai/dsh-settings": "workspace:^", + "schemastery": "^3.18.0" + }, "peerDependencies": { + "@deepseek-ai/dsh-client-connection": "^0.0.1", "@deepseek-ai/dsh-client-locale": "^0.0.1", "@deepseek-ai/dsh-client-runtime": "^0.0.1", "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", "@deepseek-ai/dsh-client-ui-settings": "^0.0.1", "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", + "@deepseek-ai/dsh-client-web-react": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "cordis": "^4.0.0-rc.7", "react": "^18.2.0" }, "devDependencies": { + "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-client-web-react": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", "cordis": "^4.0.0-rc.7", diff --git a/packages/client/ui-settings-general/src/client/WelcomeNotice.module.css b/packages/client/ui-settings-general/src/client/WelcomeNotice.module.css new file mode 100644 index 0000000000..8ad90b5fe2 --- /dev/null +++ b/packages/client/ui-settings-general/src/client/WelcomeNotice.module.css @@ -0,0 +1,70 @@ +.overlay { + position: fixed; + inset: 0; + z-index: 1100; + display: flex; + align-items: center; + justify-content: center; + padding-top: 80px; + box-sizing: border-box; +} + +/* Mask */ +.mask { + position: absolute; + left: 0px; + right: 0px; + top: 80px; + bottom: 0px; + background: rgba(0, 0, 0, 0.24); + /* Mask-blur */ + backdrop-filter: blur(2px); +} + +.dialog { + position: relative; + z-index: 1; + width: min(640px, calc(100vw - 48px)); + max-height: calc(100vh - 128px); + padding: 32px; + box-sizing: border-box; + overflow-y: auto; + border-radius: 24px; + background: var(--dsw-alias-bg-layer-2); + box-shadow: var(--dsw-shadow-lv3); + color: var(--dsw-alias-label-primary); +} + +.title { + margin: 0; + font-size: 20px; + line-height: 30px; + font-weight: 600; +} + +.copy { + display: flex; + flex-direction: column; + gap: 14px; + margin-top: 18px; + font-size: 14px; + line-height: 24px; + color: var(--dsw-alias-label-secondary); +} + +.copy p, +.error { + margin: 0; +} + +.error { + margin-top: 14px; + font-size: 13px; + line-height: 20px; + color: var(--dsw-alias-state-error-primary); +} + +.primary { + width: 100%; + margin-top: 24px; +} diff --git a/packages/client/ui-settings-general/src/client/WelcomeNotice.tsx b/packages/client/ui-settings-general/src/client/WelcomeNotice.tsx new file mode 100644 index 0000000000..6255405f83 --- /dev/null +++ b/packages/client/ui-settings-general/src/client/WelcomeNotice.tsx @@ -0,0 +1,69 @@ +/** Product-wide, versioned first-run welcome step. */ + +import { useCallback, useEffect, useRef } from 'react' +import type { ReactNode } from 'react' +import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import { Button } from '@deepseek-ai/dsh-client-ui-primitives' +import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react' +import type { WelcomeNoticeState, WelcomeNoticeStore } from './welcome-store.ts' +import css from './WelcomeNotice.module.css' + +/** Registrant-owned dependencies of {@link WelcomeNotice}. */ +export interface WelcomeNoticeInjected { + controller: WelcomeNoticeStore + useSnapshot: SnapshotSelectorHook + t: (key: string) => string +} + +/** Coordinator owner props plus the welcome step's injected face. */ +export type WelcomeNoticeProps = PropsRuntime<'settings.onboarding'> & WelcomeNoticeInjected + +/** Render the mandatory notice until its current version commits durably. */ +export function WelcomeNotice(props: WelcomeNoticeProps): ReactNode { + const { complete, controller, useSnapshot, t } = props + const state = useSnapshot(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 => { + if (await controller.acknowledge()) finish() + } + + return ( +
+ + ) +} diff --git a/packages/client/ui-settings-general/src/client/index.ts b/packages/client/ui-settings-general/src/client/index.ts index afb37d8b92..d2dd820ed9 100644 --- a/packages/client/ui-settings-general/src/client/index.ts +++ b/packages/client/ui-settings-general/src/client/index.ts @@ -8,13 +8,19 @@ */ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots' +import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' // Type-only: pulls the shell's SlotMap merges (trigger/header/section/item). import type {} from '@deepseek-ai/dsh-client-ui-settings/client' import type { ChromeInjected } from './chrome.tsx' import { CloseLabel, HeaderContent, TriggerContent } from './chrome.tsx' import type { GeneralSectionInjected } from './GeneralSection.tsx' import { GeneralSection } from './GeneralSection.tsx' +import type { WelcomeNoticeInjected } from './WelcomeNotice.tsx' +import { WelcomeNotice } from './WelcomeNotice.tsx' +import { refreshWelcomeIfLoaded, WelcomeNoticeStore } from './welcome-store.ts' import { en, zh } from './locales.ts' +import { WELCOME_NOTICE_SETTINGS_NAMESPACE } from '../onboarding-copy.ts' export type { ChromeInjected, CloseLabelProps, HeaderContentProps, TriggerContentProps, @@ -22,6 +28,8 @@ export type { export type { GeneralSectionComponentProps, GeneralSectionInjected, } from './GeneralSection.tsx' +export type { WelcomeNoticeInjected, WelcomeNoticeProps } from './WelcomeNotice.tsx' +export type { WelcomeNoticeState } from './welcome-store.ts' /** Dictionary namespace owned by this plugin (shell chrome + General copy). */ const NS = 'settings' @@ -31,7 +39,7 @@ const NS = 'settings' * ui-settings' apply, whose activation order relative to this one is NOT * constrained; registration goes through declaration-aware deferral. */ -export const inject = ['slots', 'locale'] +export const inject = ['slots', 'locale', 'connection'] /** * Register the `settings` dictionaries, the chrome content, and the General @@ -48,8 +56,28 @@ export function apply(ctx: ClientContext): void { }, 'ui-settings-general: dictionaries') const t = ctx.locale.bind(NS) + const connection = ctx.get('connection') as ConnectionHandle + const welcomeController = new WelcomeNoticeStore(connection.api) + const useWelcomeSnapshot = bindSnapshotSelector(welcomeController.store) const chromeInjected = (): ChromeInjected => ({ t }) const generalInjected = (): GeneralSectionInjected => ({ t }) + const welcomeInjected = (): WelcomeNoticeInjected => ({ + controller: welcomeController, + useSnapshot: useWelcomeSnapshot, + t, + }) + + ctx.effect(() => { + const refresh = (ns?: string): void => { + if (ns !== undefined && ns !== WELCOME_NOTICE_SETTINGS_NAMESPACE) return + refreshWelcomeIfLoaded(welcomeController) + } + const disposers = [ + ctx.on('settings/changed', refresh), + ctx.on('connection/reset', () => { refresh() }), + ] + return () => { for (const dispose of disposers) dispose() } + }, 'ui-settings-general: welcome invalidations') // All four seats refresh on locale change: re-registration bumps each // slot's ledger version, which re-renders the outlets through their own @@ -70,11 +98,19 @@ export function apply(ctx: ClientContext): void { children: { 'settings.general.item': { kind: 'list', scope: 'root' } }, inject: generalInjected, }, GeneralSection)) + const welcome = deferRegistration(ctx.slots, 'settings.onboarding', WelcomeNotice, () => + ctx.slots.register({ + name: 'settings.onboarding', + id: 'welcome-notice', + order: -100, + inject: welcomeInjected, + }, WelcomeNotice)) const offLocale = ctx.on('locale/change', () => { trigger.refresh() header.refresh() close.refresh() general.refresh() + welcome.refresh() }) return () => { offLocale() @@ -82,6 +118,7 @@ export function apply(ctx: ClientContext): void { header.dispose() close.dispose() general.dispose() + welcome.dispose() } - }, 'ui-settings-general: chrome and section registrations') + }, 'ui-settings-general: chrome, section, and onboarding registrations') } diff --git a/packages/client/ui-settings-general/src/client/locales.ts b/packages/client/ui-settings-general/src/client/locales.ts index 6fc3295561..73c0daab58 100644 --- a/packages/client/ui-settings-general/src/client/locales.ts +++ b/packages/client/ui-settings-general/src/client/locales.ts @@ -6,6 +6,7 @@ * (Language, Appearance) ship their copy in their own packages. */ import type { LocaleDict } from '@deepseek-ai/dsh-client-locale/client' +import { WELCOME_NOTICE_COPY } from '../onboarding-copy.ts' const SHARED = { 'permission.value': 'Read only', @@ -25,6 +26,12 @@ export const zh: LocaleDict = { 'permission.title': '权限', 'permission.desc': '选择默认权限模式', 'toolcall.title': '工具调用', + 'welcome.paragraph.0': WELCOME_NOTICE_COPY.zh.paragraphs[0], + 'welcome.paragraph.1': WELCOME_NOTICE_COPY.zh.paragraphs[1], + 'welcome.paragraph.2': WELCOME_NOTICE_COPY.zh.paragraphs[2], + 'welcome.paragraph.3': WELCOME_NOTICE_COPY.zh.paragraphs[3], + 'welcome.continue': WELCOME_NOTICE_COPY.zh.continueLabel, + 'welcome.error': '暂时无法保存确认状态,请重试。', } /** English dictionary. */ @@ -37,4 +44,10 @@ export const en: LocaleDict = { 'permission.title': 'Permission', 'permission.desc': 'Choose default permission mode', 'toolcall.title': 'Tool Call', + 'welcome.paragraph.0': WELCOME_NOTICE_COPY.en.paragraphs[0], + 'welcome.paragraph.1': WELCOME_NOTICE_COPY.en.paragraphs[1], + 'welcome.paragraph.2': WELCOME_NOTICE_COPY.en.paragraphs[2], + 'welcome.paragraph.3': WELCOME_NOTICE_COPY.en.paragraphs[3], + 'welcome.continue': WELCOME_NOTICE_COPY.en.continueLabel, + 'welcome.error': 'The acknowledgement could not be saved. Please try again.', } diff --git a/packages/client/ui-settings-general/src/client/welcome-store.ts b/packages/client/ui-settings-general/src/client/welcome-store.ts new file mode 100644 index 0000000000..ad0e18305c --- /dev/null +++ b/packages/client/ui-settings-general/src/client/welcome-store.ts @@ -0,0 +1,108 @@ +/** Durable welcome-notice state over the Host settings document. */ + +import type { IApiClient, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/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)[WELCOME_NOTICE_ACK_FIELD] + return typeof value === 'string' ? value : undefined +} + +/** Coordinates welcome acknowledgement reads and the sole durable write. */ +export class WelcomeNoticeStore { + /** uSES-safe state source shared by the registered welcome step. */ + readonly store: SnapshotStore = createSnapshotStore({ + status: 'idle', acknowledged: false, error: null, + }) + + private generation = 0 + + /** @param api - settings wire face used for durable reads and writes. */ + constructor(private readonly api: Pick) {} + + /** Load the current acknowledgement from the Host settings document. */ + async load(): Promise { + const generation = ++this.generation + 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. The path mutation is idempotent across tabs and + * preserves every sibling setting; failure leaves the step unacknowledged. + * @returns true only when the Host committed the acknowledgement. + */ + async acknowledge(): Promise { + const generation = ++this.generation + 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 the welcome step has begun reading durable state. + * @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() +} diff --git a/packages/client/ui-settings-general/src/index.ts b/packages/client/ui-settings-general/src/index.ts index 94b9bdf674..18518c2835 100644 --- a/packages/client/ui-settings-general/src/index.ts +++ b/packages/client/ui-settings-general/src/index.ts @@ -1,4 +1,31 @@ /** Host loader entry for the browser implementation exported from `./client`. */ -/** Host plugin body — no host-side behavior for the general settings plugin. */ -export function apply(): void {} +import type { Context } from 'cordis' +import z from 'schemastery' +import { settingsNamespace } from '@deepseek-ai/dsh-settings' +import { + WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE, +} from './onboarding-copy.ts' + +export { + WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_COPY, WELCOME_NOTICE_SETTINGS_NAMESPACE, + WELCOME_NOTICE_VERSION, +} from './onboarding-copy.ts' + +interface OnboardingSettings { + welcomeNoticeVersion?: string +} + +const OnboardingSettingsSchema: z = z.object({ + [WELCOME_NOTICE_ACK_FIELD]: z.string(), +}) + +/** Register the durable GUI-onboarding section when a settings provider exists. */ +export function apply(ctx: Context): void { + ctx.inject(['settings'], (settingsCtx) => { + settingsCtx.settings.register( + settingsNamespace(WELCOME_NOTICE_SETTINGS_NAMESPACE), + OnboardingSettingsSchema, + ) + }) +} diff --git a/packages/client/ui-settings-general/src/invariant.ts b/packages/client/ui-settings-general/src/invariant.ts index 29f762834d..d13ecc5cb8 100644 --- a/packages/client/ui-settings-general/src/invariant.ts +++ b/packages/client/ui-settings-general/src/invariant.ts @@ -15,10 +15,9 @@ export const name = 'client-ui-settings-general-invariant' export const inject = ['invariants'] /** - * No runtime invariant: a copy-owning registrant contributing chrome content - * and the General section into shell-declared slots — it emits no cordis - * events and owns no cross-plugin mutable relation; slot conflicts already - * fail loud in the slot core at load time. + * No runtime invariant: the settings seam validates and publishes the durable + * welcome section, while slot conflicts fail loud in the slot core; this + * package owns no additional event/data relationship between those systems. */ const install: InvariantInstaller = () => {} diff --git a/packages/client/ui-settings-general/src/onboarding-copy.ts b/packages/client/ui-settings-general/src/onboarding-copy.ts new file mode 100644 index 0000000000..04a075783e --- /dev/null +++ b/packages/client/ui-settings-general/src/onboarding-copy.ts @@ -0,0 +1,33 @@ +/** 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-07-30.1' + +/** The complete editable welcome notice in both supported GUI locales. */ +export const WELCOME_NOTICE_COPY = { + zh: { + paragraphs: [ + '感谢您愿意拨冗试用 DeepSeek Harness。', + '目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗粝。', + '“如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中暴露的问题,也可能促使我们重新审视,甚至推翻已有的设计。', + '我们尤其希望听见那些失败、困惑与不顺手的时刻——如果它未能帮到您,甚至反而为工作平添了麻烦,请在企业微信群中留言,将使用感受告诉我们。每一条反馈,都会帮助我们把它打磨得更好。', + ], + continueLabel: '继续', + }, + en: { + paragraphs: [ + 'Thank you for taking the time to try DeepSeek Harness.', + 'This version is still in internal testing. Some features remain unfinished, and parts of the experience may feel rough.', + '“As one cuts and files, as one chisels and polishes.” A product grows through real encounters and candid feedback. Problems you uncover in real use may prompt us to reconsider—or even overturn—our existing designs.', + 'We especially want to hear about failures, confusion, and friction. If it did not help you, or even made your work harder, please leave a message in the company WeChat group and tell us about your experience. Every piece of feedback helps us refine it.', + ], + continueLabel: 'Continue', + }, +} as const diff --git a/packages/client/ui-settings-general/tests/apply.spec.ts b/packages/client/ui-settings-general/tests/apply.spec.ts index d01be576b7..9d03ac03b8 100644 --- a/packages/client/ui-settings-general/tests/apply.spec.ts +++ b/packages/client/ui-settings-general/tests/apply.spec.ts @@ -1,12 +1,15 @@ /** Ownerless-copy registrations: the four seats, the dictionaries, locale refresh, and HMR recovery. */ import { Context } from 'cordis' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-general/client' import type { GeneralSectionInjected } from '@deepseek-ai/dsh-client-ui-settings-general/client' import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx' import { GeneralSection } from '../src/client/GeneralSection.tsx' +import { WelcomeNotice } from '../src/client/WelcomeNotice.tsx' +import type { WelcomeNoticeInjected } from '../src/client/WelcomeNotice.tsx' +import { WELCOME_NOTICE_SETTINGS_NAMESPACE } from '../src/onboarding-copy.ts' /** The four seats this plugin fills (slot name → expected component). */ const SEATS = [ @@ -14,6 +17,7 @@ const SEATS = [ ['settings.header', HeaderContent], ['settings.close', CloseLabel], ['settings.section', GeneralSection], + ['settings.onboarding', WelcomeNotice], ] as const async function bench() { @@ -21,7 +25,25 @@ async function bench() { await ctx.plugin(SlotsService).await() const locale = new LocaleService(ctx) ctx.provide('locale', locale) - return { ctx, slots: ctx.get('slots') as SlotsService, locale } + const settingsDescribe = vi.fn(() => Promise.resolve({ + rpcId: 'settings-general' as never, + result: { + ok: true as const, + value: { + writable: true, + namespaces: [{ + ns: WELCOME_NOTICE_SETTINGS_NAMESPACE, + schema: {}, + value: {}, + applies: 'live' as const, + secrets: [], + revision: 0, + }], + }, + }, + })) + ctx.provide('connection', { api: { settings: { describe: settingsDescribe } } } as never) + return { ctx, slots: ctx.get('slots') as SlotsService, locale, settingsDescribe } } /** Declare the shell's four child slots the way ui-settings' entry does. */ @@ -34,6 +56,7 @@ function declare(slots: SlotsService): () => void { 'settings.header': { kind: 'single', scope: 'root' }, 'settings.close': { kind: 'single', scope: 'root' }, 'settings.section': { kind: 'list', scope: 'root' }, + 'settings.onboarding': { kind: 'list', scope: 'root' }, }, } as never, () => null, @@ -46,7 +69,7 @@ function generalEntry(slots: SlotsService) { describe('ui-settings-general apply', () => { it('declares the services it uses', () => { - expect(inject).toEqual(['slots', 'locale']) + expect(inject).toEqual(['slots', 'locale', 'connection']) }) it('fills all four seats for declarations before or after apply', async () => { @@ -61,6 +84,8 @@ describe('ui-settings-general apply', () => { expect(before.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' }) const injected = (entry.inject as unknown as () => GeneralSectionInjected)() expect(injected.t('permission.title')).toBe('权限') + const welcome = before.slots.entries('settings.onboarding')[0]! + expect(welcome.options).toEqual({ id: 'welcome-notice', order: -100 }) // The chrome seats share one inject face: the settings-ns translate. const chrome = (before.slots.entries('settings.trigger')[0]!.inject as unknown as () => GeneralSectionInjected)() expect(chrome.t('trigger')).toBe('设置') @@ -116,6 +141,22 @@ describe('ui-settings-general apply', () => { b.locale.setLocale('zh') }) + it('refreshes loaded welcome state only for its settings namespace or a reconnect', async () => { + const b = await bench() + declare(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + const entry = b.slots.entries('settings.onboarding')[0]! + const { controller } = (entry.inject as unknown as () => WelcomeNoticeInjected)() + await controller.load() + expect(b.settingsDescribe).toHaveBeenCalledOnce() + b.ctx.emit('settings/changed', 'unrelated') + expect(b.settingsDescribe).toHaveBeenCalledOnce() + b.ctx.emit('settings/changed', WELCOME_NOTICE_SETTINGS_NAMESPACE) + await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledTimes(2) }) + b.ctx.emit('connection/reset') + await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledTimes(3) }) + }) + it('re-registers after an HMR collapse of the declaring chain (stale disposers must not block)', async () => { const b = await bench() const redeclare = declare(b.slots) diff --git a/packages/client/ui-settings-general/tests/host.spec.ts b/packages/client/ui-settings-general/tests/host.spec.ts new file mode 100644 index 0000000000..6434bc833a --- /dev/null +++ b/packages/client/ui-settings-general/tests/host.spec.ts @@ -0,0 +1,29 @@ +import { Context } from 'cordis' +import { describe, expect, it } from 'vitest' +import { Settings, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings' +import { apply } from '../src/index.ts' +import { WELCOME_NOTICE_SETTINGS_NAMESPACE } from '../src/onboarding-copy.ts' + +class MemorySettings extends Settings { + readonly writable = true + protected load(): Promise> { return Promise.resolve({}) } + protected persist(_ns: SettingsNamespace, _section: Record): Promise { + return Promise.resolve() + } +} + +describe('ui-settings-general host', () => { + it('registers and disposes the durable onboarding namespace with its fiber', async () => { + const ctx = new Context() + await ctx.plugin(MemorySettings).await() + const fiber = ctx.plugin({ apply }) + await fiber.await() + expect(ctx.settings.describe().map(row => row.ns)).toContain( + settingsNamespace(WELCOME_NOTICE_SETTINGS_NAMESPACE), + ) + await fiber.dispose() + expect(ctx.settings.describe().map(row => row.ns)).not.toContain( + settingsNamespace(WELCOME_NOTICE_SETTINGS_NAMESPACE), + ) + }) +}) diff --git a/packages/client/ui-settings-general/tests/invariant.spec.ts b/packages/client/ui-settings-general/tests/invariant.spec.ts index 7b0527c0ff..59863a5794 100644 --- a/packages/client/ui-settings-general/tests/invariant.spec.ts +++ b/packages/client/ui-settings-general/tests/invariant.spec.ts @@ -9,10 +9,4 @@ describe('invariant companion', () => { await ctx.plugin(InvariantService, { enabled: true }) await expect(ctx.plugin(GeneralInvariant).await()).resolves.toBeDefined() }) - - it('node-half apply is a no-op host placeholder', async () => { - const { apply } = await import('@deepseek-ai/dsh-client-ui-settings-general') - apply() - expect(true).toBe(true) // reaching here without throw is the contract - }) }) diff --git a/packages/client/ui-settings-general/tests/welcome-notice.spec.tsx b/packages/client/ui-settings-general/tests/welcome-notice.spec.tsx new file mode 100644 index 0000000000..5925cf048b --- /dev/null +++ b/packages/client/ui-settings-general/tests/welcome-notice.spec.tsx @@ -0,0 +1,101 @@ +// @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 { 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) + +function response(value: T) { + return { rpcId: 'welcome-rpc' as never, result: { ok: true as const, value } } +} + +function mount(version?: string, mutateImpl: () => Promise = () => Promise.resolve(response({}))) { + const mutate = vi.fn(mutateImpl) + const api = { + settings: { + describe: () => Promise.resolve(response({ + writable: true, + namespaces: [{ + ns: WELCOME_NOTICE_SETTINGS_NAMESPACE, + schema: {}, + value: version === undefined ? {} : { [WELCOME_NOTICE_ACK_FIELD]: version }, + 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, + useSnapshot: bindSnapshotSelector(controller.store), + t: key => zh[key] ?? key, + } + return { ...render(), complete, controller, mutate } +} + +describe('WelcomeNotice', () => { + it('renders the owner copy with one primary action and no dismissal control', async () => { + const h = mount() + const dialog = await screen.findByRole('dialog', { name: WELCOME_NOTICE_COPY.zh.paragraphs[0] }) + for (const paragraph of WELCOME_NOTICE_COPY.zh.paragraphs) { + expect(screen.getByText(paragraph)).toBeTruthy() + } + const buttons = dialog.querySelectorAll('button') + expect(buttons).toHaveLength(1) + expect(screen.getByRole('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel })).toBeTruthy() + fireEvent.keyDown(document, { key: 'Escape' }) + fireEvent.click(dialog.parentElement!.firstElementChild!) + 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((resolve) => { resolveWrite = resolve }) + const h = mount(undefined, () => write) + await screen.findByRole('dialog') + const action = screen.getByRole('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('暂时无法保存确认状态,请重试。') + expect(h.complete).not.toHaveBeenCalled() + }) +}) diff --git a/packages/client/ui-settings-general/tests/welcome-store.spec.ts b/packages/client/ui-settings-general/tests/welcome-store.spec.ts new file mode 100644 index 0000000000..28c7b0509c --- /dev/null +++ b/packages/client/ui-settings-general/tests/welcome-store.spec.ts @@ -0,0 +1,166 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RpcResponse } from '@deepseek-ai/dsh-client-connection/client' +import { WelcomeNoticeStore } from '../src/client/welcome-store.ts' +import { refreshWelcomeIfLoaded } 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(value: T): RpcResponse { + 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 }, + applies: 'live' as const, + secrets: [], + revision: 0, + } +} + +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason: unknown) => void + const promise = new Promise((res, rej) => { resolve = res; reject = rej }) + return { promise, resolve, reject } +} + +describe('WelcomeNoticeStore', () => { + 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, 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. + // oxlint-disable-next-line typescript/prefer-promise-reject-errors + 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, 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, + 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>() + const describe = vi.fn() + .mockImplementationOnce(() => first.promise) + .mockImplementationOnce(() => Promise.resolve(ok({ writable: true, namespaces: [namespace()] }))) + const controller = new WelcomeNoticeStore({ settings: { describe } } as never) + const stale = controller.load() + await controller.load() + first.resolve(ok({ writable: true, namespaces: [namespace(WELCOME_NOTICE_VERSION)] })) + await stale + expect(controller.store.getSnapshot().acknowledged).toBe(false) + + const failed = deferred>() + describe + .mockImplementationOnce(() => failed.promise) + .mockImplementationOnce(() => Promise.resolve(ok({ writable: true, 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>() + const describe = vi.fn(() => Promise.resolve(ok({ writable: true, 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>() + 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') + }) +}) diff --git a/packages/client/ui-settings-general/tsconfig.json b/packages/client/ui-settings-general/tsconfig.json index 5ef01ba51c..5e37578f91 100644 --- a/packages/client/ui-settings-general/tsconfig.json +++ b/packages/client/ui-settings-general/tsconfig.json @@ -14,6 +14,9 @@ { "path": "../ui-slots" }, + { + "path": "../connection" + }, { "path": "../ui-primitives" }, @@ -23,9 +26,15 @@ { "path": "../ui-settings" }, + { + "path": "../web-react" + }, { "path": "../locale" }, + { + "path": "../../settings/settings" + }, { "path": "../../support/invariants" } diff --git a/packages/client/ui-settings/README.i18n.yaml b/packages/client/ui-settings/README.i18n.yaml index c09aed1d0a..41247a370b 100644 --- a/packages/client/ui-settings/README.i18n.yaml +++ b/packages/client/ui-settings/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-settings/README.md -README.md: 9388e9dd3a984bfcebc85b6b1a35bcce4b9b116e -README.zh.md: 57c91ac5dd0bcc0a3e5e029359bf6c3a2be58ec7 +README.md: 02d8f0e5fdc169d3a45f59d7b42d873943df2b52 +README.zh.md: 465d57847588e9ccbccc9d9067099773de63c0d0 diff --git a/packages/client/ui-settings/README.md b/packages/client/ui-settings/README.md index 9388e9dd3a..02d8f0e5fd 100644 --- a/packages/client/ui-settings/README.md +++ b/packages/client/ui-settings/README.md @@ -2,9 +2,9 @@ English | [中文](README.zh.md) -Settings shell plugin: a pure composition face. It occupies `sidebar.settings` with the trigger chrome and modal settings panel, and declares the slots registrants fill: `settings.trigger` / `settings.header` / `settings.close` (chrome content), `settings.section` (one page per feature), and `settings.onboarding` (feature-owned overlays on the empty Hero). The shell ships no copy and reads no locale state — all text arrives from registrants (ui-settings-general owns chrome and General; features own their sections, rows, and onboarding overlays). +Settings shell plugin: a pure composition face. It occupies `sidebar.settings` with the trigger chrome and modal settings panel, and declares the slots registrants fill: `settings.trigger` / `settings.header` / `settings.close` (chrome content), `settings.section` (one page per feature), and `settings.onboarding` (ordered feature-owned steps on the empty Hero). The shell ships no copy and reads no locale state — all text arrives from registrants (ui-settings-general owns chrome, General, and the product welcome step; features own their sections, rows, and conditional onboarding steps). -The shell supplies onboarding registrants only two navigation facts: whether the session surface is the empty Hero and an `openSection(id)` callback that opens the panel on a registered section. Registrants own capability readiness, dismissal, copy, and mutations; the shell therefore does not become a second configuration fact source. +The shell projects the onboarding ledger into ascending order and mounts exactly one step at a time. The active registrant receives its id, `complete()`, and an `openSection(id)` callback; completing or skipping transfers ownership to the next entry. Registrants own durable completion, capability readiness, copy, and mutations, so two independently registered dialogs cannot stack and the shell does not become a second configuration fact source. ## Model Experience diff --git a/packages/client/ui-settings/README.zh.md b/packages/client/ui-settings/README.zh.md index 57c91ac5dd..465d578475 100644 --- a/packages/client/ui-settings/README.zh.md +++ b/packages/client/ui-settings/README.zh.md @@ -2,9 +2,9 @@ [English](README.md) | 中文 -设置外壳插件:一个纯组合表层。它以触发控件和模态设置面板占用 `sidebar.settings`,并声明由注册方填充的 slot:`settings.trigger`/`settings.header`/`settings.close`(界面框架内容)、`settings.section`(每项功能一页)和 `settings.onboarding`(由各功能持有、覆盖在空白 Hero 之上的浮层)。外壳不自带文案,也不读取 locale 状态:所有文本都来自注册方(ui-settings-general 拥有界面框架和「通用」分区;各功能拥有各自的分区、行和首次使用浮层)。 +设置外壳插件:一个纯组合表层。它以触发控件和模态设置面板占用 `sidebar.settings`,并声明由注册方填充的 slot:`settings.trigger`/`settings.header`/`settings.close`(界面框架内容)、`settings.section`(每项功能一页)和 `settings.onboarding`(由各功能持有、显示在空白 Hero 上的有序步骤)。外壳不自带文案,也不读取 locale 状态:所有文本都来自注册方(ui-settings-general 拥有界面框架、「通用」分区和产品欢迎步骤;各功能拥有各自的分区、行和条件式首次使用引导步骤)。 -外壳只向首次使用注册方提供两个导航事实:当前会话界面是否为空白 Hero,以及一个 `openSection(id)` 回调;后者会打开设置面板并切换到已注册的指定分区。能力就绪状态、浮层关闭、文案和变更操作均由注册方持有,因此外壳不会成为第二个配置事实来源。 +外壳将首次使用引导记录按升序投影,并且每次只挂载一个步骤。当前注册方会收到该条目的 id、`complete()` 和 `openSection(id)` 回调;完成或跳过当前步骤后,所有权转交给下一项。持久化完成状态、能力就绪状态、文案和变更操作均由注册方持有,因此两个独立注册的对话框无法堆叠,外壳也不会成为第二个配置事实来源。 ## 模型体验 diff --git a/packages/client/ui-settings/src/client/SettingsRoot.tsx b/packages/client/ui-settings/src/client/SettingsRoot.tsx index cfa3ac6cef..528a633810 100644 --- a/packages/client/ui-settings/src/client/SettingsRoot.tsx +++ b/packages/client/ui-settings/src/client/SettingsRoot.tsx @@ -6,8 +6,8 @@ * names resolve to that content (trigger: its own text; dialog: * aria-labelledby the title node; close: visually-hidden slot text). Modal * open state and the active section id are component-local viewing state; - * the onboarding slot receives the sessions-derived empty-Hero fact and a - * private callback that opens one registered section. + * the onboarding coordinator mounts exactly one ordered registrant while the + * sessions-derived empty-Hero fact is active. */ import { useCallback, useEffect, useId, useRef, useState } from 'react' import clsx from 'clsx' @@ -95,9 +95,10 @@ function SettingsPanel({ rows, renderSlot, activeId, onSelect, onClose }: PanelP * @returns the settings shell element tree. */ export function SettingsRoot(props: SettingsRootComponentProps) { - const { wide, useSections, useSessions, renderSlot } = props + const { wide, useSections, useOnboardingSteps, useSessions, renderSlot } = props const [open, setOpen] = useState(false) const [activeId, setActiveId] = useState(undefined) + const [completedOnboarding, setCompletedOnboarding] = useState>(() => new Set()) const close = useCallback(() => { setOpen(false) setActiveId(undefined) @@ -111,9 +112,25 @@ export function SettingsRoot(props: SettingsRootComponentProps) { // freshly localized text on locale change, and the trigger/header/close // seats re-render through their own outlets' subscriptions. const rows = useSections(s => s) + const onboardingSteps = useOnboardingSteps(s => s) const onboardingActive = useSessions(state => state.phase === 'ready' && (state.current === undefined || state.byId[state.current]?.blank === true)) + const onboardingStep = onboardingActive + ? onboardingSteps.find(step => !completedOnboarding.has(step.id)) + : undefined + + useEffect(() => { + if (onboardingActive) return + setCompletedOnboarding(new Set()) + }, [onboardingActive]) + + const completeOnboardingStep = useCallback((id: string) => { + setCompletedOnboarding((previous) => { + if (previous.has(id)) return previous + return new Set([...previous, id]) + }) + }, []) return ( <> @@ -135,7 +152,11 @@ export function SettingsRoot(props: SettingsRootComponentProps) { onClose={close} /> )} - {renderSlot('settings.onboarding', { active: onboardingActive, openSection })} + {onboardingStep !== undefined && renderSlot('settings.onboarding', { + stepId: onboardingStep.id, + complete: () => { completeOnboardingStep(onboardingStep.id) }, + openSection, + }, { only: onboardingStep.id })} ) } diff --git a/packages/client/ui-settings/src/client/contract/slots.ts b/packages/client/ui-settings/src/client/contract/slots.ts index 37847832bf..4d5e48d80f 100644 --- a/packages/client/ui-settings/src/client/contract/slots.ts +++ b/packages/client/ui-settings/src/client/contract/slots.ts @@ -48,10 +48,10 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { */ 'settings.section': { kind: 'list'; scope: 'root'; owner: SettingsSectionOwnerProps } /** - * Root-scoped onboarding overlays contributed by settings features. The - * shell supplies whether the current navigation state is the empty Hero - * and a private callback that opens one settings section; registrants own - * readiness, copy, and dialog behavior. + * Root-scoped onboarding steps contributed by settings features. The + * shell mounts one ordered step at a time; the active registrant either + * completes itself or keeps ownership until the user completes its sole + * path. Registrants own readiness, copy, and dialog behavior. */ 'settings.onboarding': { kind: 'list'; scope: 'root'; owner: SettingsOnboardingOwnerProps } } @@ -79,10 +79,12 @@ export interface SettingsSectionOwnerProps { children?: never } -/** Owner share of a settings-backed onboarding overlay. */ +/** Owner share of the currently active settings-backed onboarding step. */ export interface SettingsOnboardingOwnerProps { - /** Whether the current UI is in its empty Hero/onboarding state. */ - active: boolean + /** Stable id of the step currently selected by the coordinator. */ + stepId: string + /** Complete or skip this step and transfer ownership to the next entry. */ + complete: () => void /** Open the settings panel directly on one registered section. */ openSection: (id: string) => void } @@ -94,6 +96,12 @@ export interface SettingsSectionRow { label: string } +/** One ordered onboarding step projected from a slot registration. */ +export interface SettingsOnboardingStep { + id: string + order: number +} + /** * Registrant-private injected share of the settings shell (assembled in * apply): the ledger's nav-row projection as a hooks-compartment source — @@ -103,6 +111,8 @@ export type SettingsRootInjected = { hooks: { /** settings.section ledger projected into ordered nav rows. */ sections: HostObservable + /** settings.onboarding ledger projected into coordinator order. */ + onboardingSteps: HostObservable } } diff --git a/packages/client/ui-settings/src/client/index.ts b/packages/client/ui-settings/src/client/index.ts index dad2f89e77..5660ef389c 100644 --- a/packages/client/ui-settings/src/client/index.ts +++ b/packages/client/ui-settings/src/client/index.ts @@ -9,12 +9,15 @@ */ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots' -import type { SettingsRootInjected, SettingsSectionRow } from './contract/slots.ts' +import type { + SettingsOnboardingStep, SettingsRootInjected, SettingsSectionRow, +} from './contract/slots.ts' import { SettingsRoot } from './SettingsRoot.tsx' export type { SettingsHeaderOwnerProps, SettingsRootComponentProps, SettingsRootInjected, - SettingsOnboardingOwnerProps, SettingsSectionOwnerProps, SettingsSectionRow, SettingsTriggerOwnerProps, + SettingsOnboardingOwnerProps, SettingsOnboardingStep, SettingsSectionOwnerProps, + SettingsSectionRow, SettingsTriggerOwnerProps, } from './contract/slots.ts' /** @@ -35,6 +38,8 @@ export function apply(ctx: ClientContext): void { // getSnapshot returns the cached rows until the ledger version moves). let rowsVersion = -1 let rows: readonly SettingsSectionRow[] = [] + let onboardingVersion = -1 + let onboardingSteps: readonly SettingsOnboardingStep[] = [] const injected = (): SettingsRootInjected => ({ hooks: { sections: { @@ -55,6 +60,23 @@ export function apply(ctx: ClientContext): void { }, subscribe: listener => ctx.slots.subscribe('settings.section', listener), }, + onboardingSteps: { + getSnapshot: () => { + const version = ctx.slots.getVersion('settings.onboarding') + if (version !== onboardingVersion) { + onboardingVersion = version + onboardingSteps = ctx.slots.entries('settings.onboarding') + .map(e => ({ + /* v8 ignore next -- list-slot registration requires id */ + id: e.options.id ?? '', + order: e.options.order ?? 0, + })) + .sort((a, b) => a.order - b.order) + } + return onboardingSteps + }, + subscribe: listener => ctx.slots.subscribe('settings.onboarding', listener), + }, }, }) ctx.effect(() => { diff --git a/packages/client/ui-settings/tests/apply.spec.ts b/packages/client/ui-settings/tests/apply.spec.ts index de50c88d87..48b63bc0a0 100644 --- a/packages/client/ui-settings/tests/apply.spec.ts +++ b/packages/client/ui-settings/tests/apply.spec.ts @@ -83,6 +83,29 @@ describe('ui-settings apply', () => { off() }) + it('projects onboarding entries into stable coordinator order', async () => { + const b = await bench() + declare(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + const { onboardingSteps } = injectedOf(b.slots).hooks + b.slots.register({ name: 'settings.onboarding', id: 'credential', order: 0 } as never, () => null) + b.slots.register({ name: 'settings.onboarding', id: 'welcome', order: -100 } as never, () => null) + b.slots.register({ name: 'settings.onboarding', id: 'default-order' } as never, () => null) + const steps = onboardingSteps.getSnapshot() + expect(steps).toEqual([ + { id: 'welcome', order: -100 }, + { id: 'credential', order: 0 }, + { id: 'default-order', order: 0 }, + ]) + expect(onboardingSteps.getSnapshot()).toBe(steps) + const listener = vi.fn() + const off = onboardingSteps.subscribe(listener) + b.slots.register({ name: 'settings.onboarding', id: 'later', order: 10 } as never, () => null) + await Promise.resolve() + expect(listener).toHaveBeenCalledOnce() + off() + }) + it('re-registers after an HMR collapse re-declares the slot (stale disposer must not block)', async () => { const b = await bench() const redeclare = declare(b.slots) diff --git a/packages/client/ui-settings/tests/settings-root.spec.tsx b/packages/client/ui-settings/tests/settings-root.spec.tsx index a7df311672..3d0d2e97a4 100644 --- a/packages/client/ui-settings/tests/settings-root.spec.tsx +++ b/packages/client/ui-settings/tests/settings-root.spec.tsx @@ -8,6 +8,7 @@ import { SettingsRoot } from '../src/client/SettingsRoot.tsx' afterEach(cleanup) type Row = { id: string; order: number; label: string } +type Step = { id: string; order: number } /** Slot-content stand-ins: the shell renders whatever the seats contribute. */ const SEAT_CONTENT: Record = { @@ -23,7 +24,11 @@ function mount({ { id: 'general', order: 0, label: 'General' }, { id: 'models', order: 10, label: 'Models' }, ], -}: { wide?: boolean; onboardingActive?: boolean; rows?: Row[] } = {}) { + steps = [ + { id: 'welcome', order: -100 }, + { id: 'credential', order: 0 }, + ], +}: { wide?: boolean; onboardingActive?: boolean; rows?: Row[]; steps?: Step[] } = {}) { // Mutable row source standing in for the bound useSections hook; bump() // plays a ledger change through the same observable contract. let current = rows @@ -46,6 +51,7 @@ function mount({ useSessions, useWorkspaces: unusedHook, wide, + useOnboardingSteps: select => select(steps), useSections: (select) => { const [, force] = useState(0) useEffect(() => { @@ -164,20 +170,30 @@ describe('SettingsPanel navigation', () => { expect(screen.queryByTestId('section-general')).toBeNull() }) - it('hands Hero readiness and a direct section opener to onboarding registrants', () => { + it('mounts onboarding steps in order and transfers ownership only on completion', () => { const { renderSlot } = mount() - const onboardingCall = renderSlot.mock.calls.find(call => call[0] === 'settings.onboarding') - expect(onboardingCall?.[1]).toMatchObject({ active: true }) + const first = renderSlot.mock.calls.find(call => call[0] === 'settings.onboarding') + expect(first?.[1]).toMatchObject({ stepId: 'welcome' }) + expect(first?.[2]).toEqual({ only: 'welcome' }) act(() => { - (onboardingCall?.[1] as { openSection: (id: string) => void }).openSection('models') + (first?.[1] as { complete: () => void }).complete() + ;(first?.[1] as { complete: () => void }).complete() + }) + const onboardingCalls = renderSlot.mock.calls.filter(call => call[0] === 'settings.onboarding') + const second = onboardingCalls.at(-1) + expect(second?.[1]).toMatchObject({ stepId: 'credential' }) + expect(second?.[2]).toEqual({ only: 'credential' }) + + act(() => { + (second?.[1] as { openSection: (id: string) => void }).openSection('models') }) expect(screen.getByRole('dialog')).toBeTruthy() expect(screen.getByTestId('section-models')).toBeTruthy() cleanup() - const active = mount({ onboardingActive: false }).renderSlot.mock.calls - .find(call => call[0] === 'settings.onboarding') - expect(active?.[1]).toMatchObject({ active: false }) + const inactive = mount({ onboardingActive: false }).renderSlot.mock.calls + .filter(call => call[0] === 'settings.onboarding') + expect(inactive).toHaveLength(0) }) it('falls back to the first row when the active entry unregisters', () => { diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 990bb14305..7b97762c41 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: 0abbeced0902471f7ff8ce9a73d188619ad2b64a -README.zh.md: 315ddb06791b8ce7723f8a5349b6cf576fe971de +README.md: bdf67f7d993df3e0ea7ccd7753c7d492d9d2d904 +README.zh.md: 7b758cfc888dc74ad136a2fc139b856705ee8168 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 0abbeced09..bdf67f7d99 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -26,7 +26,7 @@ Directory picking delegates to the composed `ctx.directoryPicker` backend ([the The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the browser's user-selected model-reference path, so it returns only skills that are both model-invocable and user-invocable; this domain has no direct skill-loading RPC. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream; the carrier's request signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. -The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves exactly the namespaces a registered configurable provider addresses (`ctx.llm.listConfigurableProviders()`): the seam is general, but this plane is the model-provider surface, so a namespace nothing in the directory names is neither described nor writable here and answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, and the section's `revision`. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired both by `llm/adapters-updated` and by a change to an exposed provider namespace, whose settings carry that provider's catalog and endpoint. The browser carrier restricts the whole configuration plane, reads included (`settings.describe`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin. +The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves a closed allowlist: namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus product-owned `ui-onboarding`. The seam remains general, so any other namespace is neither described nor writable here and answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, and the section's `revision`. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired both by `llm/adapters-updated` and by a change to a provider namespace, whose settings carry that provider's catalog and endpoint; `ui-onboarding` changes do not invalidate models. The browser carrier restricts the whole configuration plane, reads included (`settings.describe`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin. ## Carrier layer (`/client` + root) diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 315ddb0679..7b758cfc88 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -26,7 +26,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr `command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于浏览器中由用户选择的模型引用路径,因此仅返回模型和用户均可调用的 skill;该领域没有直接加载 skill 的 RPC。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载;载体的请求信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 -`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域只服务于已注册可配置提供方所指向的那些 namespace(`ctx.llm.listConfigurableProviders()`):seam 本身是通用的,但这个面是模型提供方表层,因此目录中无人点名的 namespace 在这里既不会被描述也不可写入,只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表,以及该分节的 `revision`。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;过期的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它既由 `llm/adapters-updated` 触发,也由某个已暴露提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点。浏览器载体把整个配置面(含读取:`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。 +`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域只服务于一个封闭的允许列表:已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),以及产品持有的 `ui-onboarding`。seam 本身仍是通用的,因此其他 namespace 在这里既不会被描述也不可写入,只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表,以及该分节的 `revision`。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;过期的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它既由 `llm/adapters-updated` 触发,也由提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`ui-onboarding` 的变更不会触发模型失效事件。浏览器载体把整个配置面(含读取:`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。 ## 载体层(`/client` + 根路径) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 0990c5253c..364ec1c77d 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -69,6 +69,9 @@ const DEFAULT_MAX_MESSAGES = 50 /** Surface message event types (the pagination counting unit). */ const MESSAGE_TYPES = new Set(['user/message', 'assistant/message', 'steering/message']) +/** Product settings intentionally exposed beside model-provider namespaces. */ +const PRODUCT_SETTINGS_NAMESPACES = new Set(['ui-onboarding']) + /** * Message-boundary pagination: count maxMessages surface messages backwards from * the window tail; the cut is the starting seq of the oldest message group @@ -1014,24 +1017,26 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } } - /** - * The settings namespaces this proxy serves: exactly those a registered - * configurable provider addresses. The settings seam itself is general — - * any plugin may register a namespace for its own configuration — but the - * Web configuration plane is scoped to model providers, and that boundary - * has to be enforced here rather than assumed from the current plugin set. - * Without it, every future `settings.register()` would silently become - * remotely readable and writable configuration. - */ - function exposedNamespaces(): Set { + /** Settings namespaces whose values can change the model directory. */ + function providerSettingsNamespaces(): Set { return new Set(ctx.llm.listConfigurableProviders().map(entry => entry.settingsNs)) } - /** Refuse a namespace outside the model-provider boundary, naming why. */ + /** + * The settings namespaces this proxy serves: registered configurable + * providers plus a closed product-owned allowlist. The settings seam itself + * is general, so exposure stays explicit here; registering a future + * namespace never makes it remotely readable or writable by accident. + */ + function exposedNamespaces(): Set { + return new Set([...providerSettingsNamespaces(), ...PRODUCT_SETTINGS_NAMESPACES]) + } + + /** Refuse a namespace outside the explicit Web configuration boundary. */ function notExposed(request: RpcRequest, ns: string): RpcResponse { return err(request, { code: 'settings-not-exposed', - message: `settings namespace "${ns}" is not exposed to configuration clients; only a namespace a registered model provider addresses is`, + message: `settings namespace "${ns}" is not exposed to configuration clients`, details: { ns }, }) } @@ -1909,7 +1914,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro // A provider's own settings carry its model catalog and endpoint, // so a change there invalidates the model list even when the route // set is untouched — `llm/adapters-updated` alone misses it. - if (exposedNamespaces().has(String(ns))) queue.push(frame({ type: 'host/models-changed' })) + if (providerSettingsNamespaces().has(String(ns))) queue.push(frame({ type: 'host/models-changed' })) }), ctx.on('credentials/updated', (ref) => { queue.push(frame({ type: 'host/credentials-changed', ref: String(ref) })) diff --git a/packages/host/apiproxy/tests/api-proxy-config.spec.ts b/packages/host/apiproxy/tests/api-proxy-config.spec.ts index a505f72018..bf077f4f0c 100644 --- a/packages/host/apiproxy/tests/api-proxy-config.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-config.spec.ts @@ -222,7 +222,7 @@ describe('settings domain', () => { expect(JSON.stringify(value)).not.toContain('user-secret') }) - it('serves only namespaces a registered model provider addresses', async () => { + it('keeps arbitrary plugin namespaces outside the explicit Web allowlist', async () => { // The settings seam is general: any plugin may register a namespace for // its own configuration. The Web configuration plane is not — it is the // model-provider surface, and a namespace nothing in the provider @@ -248,6 +248,21 @@ describe('settings domain', () => { expect(ctx.settings.describe().find(d => String(d.ns) === 'some-other-plugin')?.value).toEqual({}) }) + it('serves the product onboarding namespace without invalidating the model catalog', async () => { + const ctx = await harness() + ctx.settings.register(settingsNamespace('ui-onboarding'), z.object({ welcomeNoticeVersion: z.string() })) + const api = createApiProxy(ctx, DEFAULTS) + expect(expectOk(await api.settings.describe(request({}))).namespaces.map(view => view.ns)) + .toEqual(['ui-onboarding']) + const frames = await collectHost(api, ['host/settings-changed'], 1, async () => { + expectOk(await api.settings.mutate(request({ + ns: 'ui-onboarding', + ops: [{ op: 'set', path: ['welcomeNoticeVersion'], value: 'v1' }], + }))) + }) + expect(frames).toEqual([{ type: 'host/settings-changed', ns: 'ui-onboarding' }]) + }) + it('refuses even a model-provider namespace once its directory entry is gone', async () => { const ctx = await harness({ configurableProviders: false }) ctx.settings.register(NS, AdapterConfig) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6dd8389a6f..5b03fca2a5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1559,7 +1559,17 @@ importers: version: 18.3.1 packages/client/ui-settings-general: + dependencies: + '@deepseek-ai/dsh-settings': + specifier: workspace:^ + version: link:../../settings/settings + schemastery: + specifier: ^3.18.0 + version: 3.18.0 devDependencies: + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../connection '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale @@ -1575,6 +1585,9 @@ importers: '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots + '@deepseek-ai/dsh-client-web-react': + specifier: workspace:^ + version: link:../web-react '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants From 04f8b30db151462e9f21ecee9176c30cbfb70d36 Mon Sep 17 00:00:00 2001 From: NI0317 Date: Thu, 30 Jul 2026 22:58:52 +0800 Subject: [PATCH 02/19] fix(web): make the welcome notice scan-first --- ...versioned-gui-welcome-onboarding.i18n.yaml | 4 +- ...-07-30-versioned-gui-welcome-onboarding.md | 2 +- ...-30-versioned-gui-welcome-onboarding.zh.md | 2 +- .../tests/onboarding-deepseek-config.e2e.ts | 4 +- .../welcome.expected.md | 12 ++- .../src/client/WelcomeNotice.module.css | 96 ++++++++++++++++--- .../src/client/WelcomeNotice.tsx | 32 ++++--- .../ui-settings-general/src/client/locales.ts | 20 ++-- .../src/onboarding-copy.ts | 26 ++--- .../tests/welcome-notice.spec.tsx | 14 ++- 10 files changed, 150 insertions(+), 62 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.i18n.yaml index 24bdee3b68..db1397809e 100644 --- a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.i18n.yaml +++ b/.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; # 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 -2026-07-30-versioned-gui-welcome-onboarding.md: 405c6fe833d995123cd15e5694cd5ef75a0cd03d -2026-07-30-versioned-gui-welcome-onboarding.zh.md: ea83aa958866ab3dcca749f362d43e4b29408e02 +2026-07-30-versioned-gui-welcome-onboarding.md: 06ac9fbe5c10db872c7ea3989ff2e14f756965a0 +2026-07-30-versioned-gui-welcome-onboarding.zh.md: e2d726368e6282e4f6043c225b665c1945228f2d diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md index 405c6fe833..06ac9fbe5c 100644 --- a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md +++ b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md @@ -18,7 +18,7 @@ The GUI's credential onboarding begins with a DeepSeek-specific readiness check, **Concurrent views converge without stale replacement.** The acknowledgement write omits `expectedRevision` deliberately: every tab writes the same version to one path, so the operation is idempotent and preserves sibling fields instead of rebuilding the section. `settings/document-updated` becomes `host/settings-changed`; an already mounted tab refetches and advances when another tab or an external editor commits the current version. 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 welcome modal has one completion path.** It renders no close icon or secondary action, installs no Escape handler, and assigns no click handler to the mask. Its mask starts below the 80 px top chrome and preserves `position:absolute`, zero left/right/bottom offsets, `rgba(0, 0, 0, 0.24)`, and `backdrop-filter: blur(2px)`. Continue is the sole button and receives initial focus. +**The welcome modal is scan-first and has one completion path.** Its hierarchy is a declaration title, one status sentence, one emphasized feedback callout, one consequence sentence, and a restrained quotation; the notice version changes whenever that authored copy changes materially. It renders no close icon or secondary action, installs no Escape handler, and assigns no click handler to the mask. Its mask starts below the 80 px top chrome and preserves `position:absolute`, zero left/right/bottom offsets, `rgba(0, 0, 0, 0.24)`, and `backdrop-filter: blur(2px)`. Continue is the sole button and receives initial focus. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.zh.md b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.zh.md index ea83aa9588..e2d726368e 100644 --- a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.zh.md @@ -18,7 +18,7 @@ GUI 的凭据引导从 DeepSeek 专用的就绪状态检查开始,但内部测 **并发视图无需陈旧的整体替换即可收敛。** 确认写入有意省略 `expectedRevision`:每个标签页都向同一路径写入相同版本,因此该操作是幂等的,并会保留同级字段,而不是重建整个分节。`settings/document-updated` 会转为 `host/settings-changed`;另一个标签页或外部编辑器提交当前版本后,已挂载的标签页会重新拉取状态并推进。API 网关在可配置提供方 namespace 之外,通过封闭的允许列表暴露这一个产品 namespace,同时不会把它的变更视为模型目录失效事件。 -**欢迎模态窗口只有一条完成路径。** 界面不渲染关闭图标或次要操作,不安装 Escape 处理器,也不为遮罩添加点击处理器。遮罩从顶部 80 px 的界面框架下方开始,并保留 `position:absolute`、left/right/bottom 偏移量为零、`rgba(0, 0, 0, 0.24)` 和 `backdrop-filter: blur(2px)`。「继续」是唯一按钮,并会获得初始焦点。 +**欢迎模态窗口以便于扫读为先,且只有一条完成路径。** 其信息层级依次为声明标题、一句状态说明、一则重点突出的反馈提示、一句影响说明和一则克制的引语;只要这份文案发生实质变化,就同步提升通知版本。界面不渲染关闭图标或次要操作,不安装 Escape 处理器,也不为遮罩添加点击处理器。遮罩从顶部 80 px 的界面框架下方开始,并保留 `position:absolute`、left/right/bottom 偏移量为零、`rgba(0, 0, 0, 0.24)` 和 `backdrop-filter: blur(2px)`。「继续」是唯一按钮,并会获得初始焦点。 ## 曾考虑的替代方案 diff --git a/apps/web/tests/onboarding-deepseek-config.e2e.ts b/apps/web/tests/onboarding-deepseek-config.e2e.ts index f372910a61..8731bf10e0 100644 --- a/apps/web/tests/onboarding-deepseek-config.e2e.ts +++ b/apps/web/tests/onboarding-deepseek-config.e2e.ts @@ -48,7 +48,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup it('stores a key write-only and observes configured state without restarting', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-deepseek-config')) - const welcome = page.getByRole('dialog', { name: WELCOME_NOTICE_COPY.zh.paragraphs[0] }) + const welcome = page.getByRole('dialog', { name: WELCOME_NOTICE_COPY.zh.title }) await welcome.waitFor({ timeout: 15_000 }) const welcomeAria = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) await compareOrRefreshGolden(WELCOME_EXPECTED, welcomeAria, MODE) @@ -132,7 +132,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup await page.reload({ waitUntil: 'load' }) acknowledgeReloadConnectionLoss(tripwire, secondReloadWarnings) await page.waitForSelector('[class*="frame"]', { timeout: 15_000 }) - expect(await page.getByRole('dialog', { name: WELCOME_NOTICE_COPY.zh.paragraphs[0] }).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) // A different stored copy version represents an intentional version bump: diff --git a/apps/web/tests/snapshots/onboarding-deepseek-config/welcome.expected.md b/apps/web/tests/snapshots/onboarding-deepseek-config/welcome.expected.md index 370737df6b..d0fcb2b68d 100644 --- a/apps/web/tests/snapshots/onboarding-deepseek-config/welcome.expected.md +++ b/apps/web/tests/snapshots/onboarding-deepseek-config/welcome.expected.md @@ -1,6 +1,8 @@ -- dialog "感谢您愿意拨冗试用 DeepSeek Harness。": - - heading "感谢您愿意拨冗试用 DeepSeek Harness。" [level=2] - - paragraph: 目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗粝。 - - paragraph: “如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中暴露的问题,也可能促使我们重新审视,甚至推翻已有的设计。 - - paragraph: 我们尤其希望听见那些失败、困惑与不顺手的时刻——如果它未能帮到您,甚至反而为工作平添了麻烦,请在企业微信群中留言,将使用感受告诉我们。每一条反馈,都会帮助我们把它打磨得更好。 +- dialog "内测声明": + - heading "内测声明" [level=2] + - paragraph: 感谢您试用 DeepSeek Harness。目前仍处于内部测试阶段,部分功能与体验还在持续打磨。 + - strong: 我们最想听见:失败、困惑和不顺手 + - paragraph: 如果它没帮到您,甚至给工作添了麻烦,请在企业微信群告诉我们。 + - paragraph: 真实使用中的每一个问题,都可能促使我们重新审视,甚至推翻已有设计。 + - paragraph: “如切如磋,如琢如磨。” - button "继续" diff --git a/packages/client/ui-settings-general/src/client/WelcomeNotice.module.css b/packages/client/ui-settings-general/src/client/WelcomeNotice.module.css index 8ad90b5fe2..805f411918 100644 --- a/packages/client/ui-settings-general/src/client/WelcomeNotice.module.css +++ b/packages/client/ui-settings-general/src/client/WelcomeNotice.module.css @@ -24,7 +24,7 @@ .dialog { position: relative; z-index: 1; - width: min(640px, calc(100vw - 48px)); + width: min(600px, calc(100vw - 48px)); max-height: calc(100vh - 128px); padding: 32px; box-sizing: border-box; @@ -40,21 +40,65 @@ font-size: 20px; line-height: 30px; font-weight: 600; + letter-spacing: -0.01em; } -.copy { - display: flex; - flex-direction: column; - gap: 14px; - margin-top: 18px; - font-size: 14px; - line-height: 24px; +.lead, +.closing, +.quote, +.feedback p, +.error { + margin: 0; +} + +.lead { + margin-top: 12px; + font-size: 16px; + line-height: 25px; color: var(--dsw-alias-label-secondary); } -.copy p, -.error { - margin: 0; +.feedback { + margin-top: 20px; + padding: 16px 18px; + border-radius: 14px; + border: 1px solid var(--dsw-alias-border-l1); + background: var(--dsw-alias-bg-module-platform); + font-size: 15px; + line-height: 24px; +} + +.feedback strong { + display: block; + margin-bottom: 4px; + font-weight: 600; +} + +.feedback p, +.closing { + color: var(--dsw-alias-label-secondary); +} + +.closing { + margin-top: 16px; + font-size: 15px; + line-height: 24px; +} + +.quote { + font-size: 14px; + line-height: 22px; + color: var(--dsw-alias-label-secondary); +} + +.footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + margin-top: 24px; + padding-top: 20px; + border-top: 1px solid var(--dsw-alias-border-l1); } .error { @@ -65,6 +109,32 @@ } .primary { - width: 100%; - margin-top: 24px; + min-width: 104px; + transition: transform 140ms cubic-bezier(0.23, 1, 0.32, 1); +} + +.primary:active:not(:disabled) { + transform: scale(0.97); +} + +@media (prefers-reduced-motion: reduce) { + .primary { + transition: none; + } +} + +@media (max-width: 560px) { + .dialog { + padding: 24px; + } + + .footer { + align-items: stretch; + flex-direction: column; + gap: 14px; + } + + .primary { + width: 100%; + } } diff --git a/packages/client/ui-settings-general/src/client/WelcomeNotice.tsx b/packages/client/ui-settings-general/src/client/WelcomeNotice.tsx index 6255405f83..ebdab519a4 100644 --- a/packages/client/ui-settings-general/src/client/WelcomeNotice.tsx +++ b/packages/client/ui-settings-general/src/client/WelcomeNotice.tsx @@ -47,22 +47,26 @@ export function WelcomeNotice(props: WelcomeNoticeProps): ReactNode {
) diff --git a/packages/client/ui-settings-general/src/client/locales.ts b/packages/client/ui-settings-general/src/client/locales.ts index 73c0daab58..f2486faea2 100644 --- a/packages/client/ui-settings-general/src/client/locales.ts +++ b/packages/client/ui-settings-general/src/client/locales.ts @@ -26,10 +26,12 @@ export const zh: LocaleDict = { 'permission.title': '权限', 'permission.desc': '选择默认权限模式', 'toolcall.title': '工具调用', - 'welcome.paragraph.0': WELCOME_NOTICE_COPY.zh.paragraphs[0], - 'welcome.paragraph.1': WELCOME_NOTICE_COPY.zh.paragraphs[1], - 'welcome.paragraph.2': WELCOME_NOTICE_COPY.zh.paragraphs[2], - 'welcome.paragraph.3': WELCOME_NOTICE_COPY.zh.paragraphs[3], + 'welcome.title': WELCOME_NOTICE_COPY.zh.title, + 'welcome.lead': WELCOME_NOTICE_COPY.zh.lead, + 'welcome.feedbackTitle': WELCOME_NOTICE_COPY.zh.feedbackTitle, + 'welcome.feedbackBody': WELCOME_NOTICE_COPY.zh.feedbackBody, + 'welcome.closing': WELCOME_NOTICE_COPY.zh.closing, + 'welcome.quote': WELCOME_NOTICE_COPY.zh.quote, 'welcome.continue': WELCOME_NOTICE_COPY.zh.continueLabel, 'welcome.error': '暂时无法保存确认状态,请重试。', } @@ -44,10 +46,12 @@ export const en: LocaleDict = { 'permission.title': 'Permission', 'permission.desc': 'Choose default permission mode', 'toolcall.title': 'Tool Call', - 'welcome.paragraph.0': WELCOME_NOTICE_COPY.en.paragraphs[0], - 'welcome.paragraph.1': WELCOME_NOTICE_COPY.en.paragraphs[1], - 'welcome.paragraph.2': WELCOME_NOTICE_COPY.en.paragraphs[2], - 'welcome.paragraph.3': WELCOME_NOTICE_COPY.en.paragraphs[3], + 'welcome.title': WELCOME_NOTICE_COPY.en.title, + 'welcome.lead': WELCOME_NOTICE_COPY.en.lead, + 'welcome.feedbackTitle': WELCOME_NOTICE_COPY.en.feedbackTitle, + 'welcome.feedbackBody': WELCOME_NOTICE_COPY.en.feedbackBody, + 'welcome.closing': WELCOME_NOTICE_COPY.en.closing, + 'welcome.quote': WELCOME_NOTICE_COPY.en.quote, 'welcome.continue': WELCOME_NOTICE_COPY.en.continueLabel, 'welcome.error': 'The acknowledgement could not be saved. Please try again.', } diff --git a/packages/client/ui-settings-general/src/onboarding-copy.ts b/packages/client/ui-settings-general/src/onboarding-copy.ts index 04a075783e..805a9d35d0 100644 --- a/packages/client/ui-settings-general/src/onboarding-copy.ts +++ b/packages/client/ui-settings-general/src/onboarding-copy.ts @@ -8,26 +8,26 @@ 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-07-30.1' +export const WELCOME_NOTICE_VERSION = '2026-07-30.2' /** The complete editable welcome notice in both supported GUI locales. */ export const WELCOME_NOTICE_COPY = { zh: { - paragraphs: [ - '感谢您愿意拨冗试用 DeepSeek Harness。', - '目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗粝。', - '“如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中暴露的问题,也可能促使我们重新审视,甚至推翻已有的设计。', - '我们尤其希望听见那些失败、困惑与不顺手的时刻——如果它未能帮到您,甚至反而为工作平添了麻烦,请在企业微信群中留言,将使用感受告诉我们。每一条反馈,都会帮助我们把它打磨得更好。', - ], + title: '内测声明', + lead: '感谢您试用 DeepSeek Harness。目前仍处于内部测试阶段,部分功能与体验还在持续打磨。', + feedbackTitle: '我们最想听见:失败、困惑和不顺手', + feedbackBody: '如果它没帮到您,甚至给工作添了麻烦,请在企业微信群告诉我们。', + closing: '真实使用中的每一个问题,都可能促使我们重新审视,甚至推翻已有设计。', + quote: '“如切如磋,如琢如磨。”', continueLabel: '继续', }, en: { - paragraphs: [ - 'Thank you for taking the time to try DeepSeek Harness.', - 'This version is still in internal testing. Some features remain unfinished, and parts of the experience may feel rough.', - '“As one cuts and files, as one chisels and polishes.” A product grows through real encounters and candid feedback. Problems you uncover in real use may prompt us to reconsider—or even overturn—our existing designs.', - 'We especially want to hear about failures, confusion, and friction. If it did not help you, or even made your work harder, please leave a message in the company WeChat group and tell us about your experience. Every piece of feedback helps us refine it.', - ], + title: 'Internal Testing Notice', + lead: 'Thank you for trying DeepSeek Harness. This version is still in internal testing, and some features and experiences remain under refinement.', + feedbackTitle: 'What we most want to hear: failures, confusion, and friction', + feedbackBody: 'If it did not help—or even made your work harder—please tell us in the company WeChat group.', + closing: 'Every problem found in real use may prompt us to reconsider, or even overturn, an existing design.', + quote: '“As one cuts and files, as one chisels and polishes.”', continueLabel: 'Continue', }, } as const diff --git a/packages/client/ui-settings-general/tests/welcome-notice.spec.tsx b/packages/client/ui-settings-general/tests/welcome-notice.spec.tsx index 5925cf048b..d414146e47 100644 --- a/packages/client/ui-settings-general/tests/welcome-notice.spec.tsx +++ b/packages/client/ui-settings-general/tests/welcome-notice.spec.tsx @@ -54,10 +54,18 @@ function mount(version?: string, mutateImpl: () => Promise = () => Prom describe('WelcomeNotice', () => { it('renders the owner copy with one primary action and no dismissal control', async () => { const h = mount() - const dialog = await screen.findByRole('dialog', { name: WELCOME_NOTICE_COPY.zh.paragraphs[0] }) - for (const paragraph of WELCOME_NOTICE_COPY.zh.paragraphs) { - expect(screen.getByText(paragraph)).toBeTruthy() + const dialog = await screen.findByRole('dialog', { name: WELCOME_NOTICE_COPY.zh.title }) + for (const text of [ + WELCOME_NOTICE_COPY.zh.title, + WELCOME_NOTICE_COPY.zh.lead, + WELCOME_NOTICE_COPY.zh.feedbackTitle, + WELCOME_NOTICE_COPY.zh.feedbackBody, + WELCOME_NOTICE_COPY.zh.closing, + WELCOME_NOTICE_COPY.zh.quote, + ]) { + expect(screen.getByText(text)).toBeTruthy() } + expect(dialog.textContent?.match(/感谢您试用 DeepSeek Harness/g) ?? []).toHaveLength(1) const buttons = dialog.querySelectorAll('button') expect(buttons).toHaveLength(1) expect(screen.getByRole('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel })).toBeTruthy() From 75a0366a526021f6e4e3164eaf5ebe030248952b Mon Sep 17 00:00:00 2001 From: NI0317 Date: Fri, 31 Jul 2026 00:28:17 +0800 Subject: [PATCH 03/19] feat(web): present onboarding as a continuous page --- ...versioned-gui-welcome-onboarding.i18n.yaml | 4 +- ...-07-30-versioned-gui-welcome-onboarding.md | 2 +- ...-30-versioned-gui-welcome-onboarding.zh.md | 2 +- .../tests/onboarding-deepseek-config.e2e.ts | 28 +-- .../missing.expected.md | 6 +- .../welcome.expected.md | 14 +- .../DeepSeekOnboardingDialog.module.css | 136 +++++++++++- .../src/client/DeepSeekOnboardingDialog.tsx | 53 +++-- .../tests/onboarding-dialog.spec.tsx | 22 +- .../ui-settings-general/README.i18n.yaml | 4 +- packages/client/ui-settings-general/README.md | 2 +- .../client/ui-settings-general/README.zh.md | 2 +- .../src/client/WelcomeNotice.module.css | 194 ++++++++++-------- .../src/client/WelcomeNotice.tsx | 66 +++--- .../ui-settings-general/src/client/locales.ts | 20 +- .../src/onboarding-copy.ts | 26 ++- .../tests/welcome-notice.spec.tsx | 28 +-- packages/client/ui-settings/README.i18n.yaml | 4 +- packages/client/ui-settings/README.md | 4 +- packages/client/ui-settings/README.zh.md | 4 +- packages/client/ui-settings/package.json | 9 +- .../src/client/SettingsRoot.module.css | 30 +++ .../ui-settings/src/client/SettingsRoot.tsx | 26 ++- .../ui-settings/tests/settings-root.spec.tsx | 11 + pnpm-lock.yaml | 6 + 25 files changed, 475 insertions(+), 228 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.i18n.yaml index db1397809e..cdf0c3a817 100644 --- a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.i18n.yaml +++ b/.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; # 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 -2026-07-30-versioned-gui-welcome-onboarding.md: 06ac9fbe5c10db872c7ea3989ff2e14f756965a0 -2026-07-30-versioned-gui-welcome-onboarding.zh.md: e2d726368e6282e4f6043c225b665c1945228f2d +2026-07-30-versioned-gui-welcome-onboarding.md: 0705469e02ddb9068722ae5d500c151f077c83fd +2026-07-30-versioned-gui-welcome-onboarding.zh.md: bdd21d635f824b8c4a4813e6bff7798b34ec9677 diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md index 06ac9fbe5c..0705469e02 100644 --- a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md +++ b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md @@ -18,7 +18,7 @@ The GUI's credential onboarding begins with a DeepSeek-specific readiness check, **Concurrent views converge without stale replacement.** The acknowledgement write omits `expectedRevision` deliberately: every tab writes the same version to one path, so the operation is idempotent and preserves sibling fields instead of rebuilding the section. `settings/document-updated` becomes `host/settings-changed`; an already mounted tab refetches and advances when another tab or an external editor commits the current version. 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 welcome modal is scan-first and has one completion path.** Its hierarchy is a declaration title, one status sentence, one emphasized feedback callout, one consequence sentence, and a restrained quotation; the notice version changes whenever that authored copy changes materially. It renders no close icon or secondary action, installs no Escape handler, and assigns no click handler to the mask. Its mask starts below the 80 px top chrome and preserves `position:absolute`, zero left/right/bottom offsets, `rgba(0, 0, 0, 0.24)`, and `backdrop-filter: blur(2px)`. Continue is the sole button and receives initial focus. +**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)`. Welcome and conditional credential setup render as successive pages in this stage instead of independent modals. Both pages reuse the Web UI's black `BrandWordmark`. The welcome page preserves the four authored paragraphs verbatim under the `内测声明` title; every paragraph uses one 16/28 body scale, and only the requested action clause inside the final paragraph receives a subtle 500 weight. A short staggered opacity/vertical entrance supplies pacing without blocking interaction and disappears under reduced motion. The title receives initial focus, Continue is the sole button, and no close, Escape, or mask-click path exists. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.zh.md b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.zh.md index e2d726368e..bdd21d635f 100644 --- a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.zh.md @@ -18,7 +18,7 @@ GUI 的凭据引导从 DeepSeek 专用的就绪状态检查开始,但内部测 **并发视图无需陈旧的整体替换即可收敛。** 确认写入有意省略 `expectedRevision`:每个标签页都向同一路径写入相同版本,因此该操作是幂等的,并会保留同级字段,而不是重建整个分节。`settings/document-updated` 会转为 `host/settings-changed`;另一个标签页或外部编辑器提交当前版本后,已挂载的标签页会重新拉取状态并推进。API 网关在可配置提供方 namespace 之外,通过封闭的允许列表暴露这一个产品 namespace,同时不会把它的变更视为模型目录失效事件。 -**欢迎模态窗口以便于扫读为先,且只有一条完成路径。** 其信息层级依次为声明标题、一句状态说明、一则重点突出的反馈提示、一句影响说明和一则克制的引语;只要这份文案发生实质变化,就同步提升通知版本。界面不渲染关闭图标或次要操作,不安装 Escape 处理器,也不为遮罩添加点击处理器。遮罩从顶部 80 px 的界面框架下方开始,并保留 `position:absolute`、left/right/bottom 偏移量为零、`rgba(0, 0, 0, 0.24)` 和 `backdrop-filter: blur(2px)`。「继续」是唯一按钮,并会获得初始焦点。 +**引导流程会暂时接管视口,形成一个连续阶段。** 纯色产品界面通过挂载到 `body` 的 portal 取代完整的应用视图,并将底层应用根节点标记为 inert;严格符合要求的遮罩仍挂载在该界面后方,并保留 `position:absolute`、left/right/bottom 偏移量为零、`top:80px`、`rgba(0, 0, 0, 0.24)` 和 `backdrop-filter: blur(2px)`。欢迎页和按条件显示的凭据设置页在这一阶段中依次呈现,而不是各自作为独立的模态窗口。两个页面都复用 Web UI 的黑色 `BrandWordmark`。欢迎页在 `内测声明` 标题下逐字保留既定的四段文案;所有段落统一采用 16/28 的正文字号与行高,只有最后一段中指定的行动语句使用较为克制的 500 字重。短暂的错落式透明度与纵向位移动画营造出舒缓节奏,但不会阻碍交互,并会在用户启用减少动态效果时禁用。初始焦点落在标题上,「继续」是唯一按钮,且不存在关闭、Escape 或点击遮罩的退出路径。 ## 曾考虑的替代方案 diff --git a/apps/web/tests/onboarding-deepseek-config.e2e.ts b/apps/web/tests/onboarding-deepseek-config.e2e.ts index 8731bf10e0..f7a91e8d3e 100644 --- a/apps/web/tests/onboarding-deepseek-config.e2e.ts +++ b/apps/web/tests/onboarding-deepseek-config.e2e.ts @@ -48,14 +48,17 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup it('stores a key write-only and observes configured state without restarting', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-deepseek-config')) - const welcome = page.getByRole('dialog', { name: WELCOME_NOTICE_COPY.zh.title }) + const welcome = page.getByRole('region', { name: WELCOME_NOTICE_COPY.zh.title }) await welcome.waitFor({ timeout: 15_000 }) - const welcomeAria = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) + expect(await page.locator('#root').evaluate(root => (root as HTMLElement).inert)).toBe(true) + const welcomeAria = await captureStableAria(page, '[role="region"]', scaffold.workspaceCwd) await compareOrRefreshGolden(WELCOME_EXPECTED, welcomeAria, MODE) expect(await welcome.getByRole('button').allTextContents()).toEqual([WELCOME_NOTICE_COPY.zh.continueLabel]) expect(await welcome.locator('button').count()).toBe(1) - const maskStyles = await welcome.locator('xpath=..').locator(':scope > div').first().evaluate((mask) => { + 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 { @@ -89,16 +92,17 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup await welcome.getByRole('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel }).click() await welcome.waitFor({ state: 'detached', timeout: 15_000 }) - const dialog = page.getByRole('dialog', { name: '添加一个 API Key 开始使用' }) - await dialog.waitFor({ timeout: 15_000 }) - expect(await dialog.getByRole('textbox').count()).toBe(0) - const initial = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) + const credentialStep = page.getByRole('region', { name: '添加一个 API Key 开始使用' }) + await credentialStep.waitFor({ 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 dialog.getByRole('button', { name: '前往配置' }).click() - await dialog.waitFor({ state: 'detached', timeout: 15_000 }) + 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 }) await keyInput.waitFor({ timeout: 10_000 }) @@ -132,8 +136,8 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup await page.reload({ waitUntil: 'load' }) acknowledgeReloadConnectionLoss(tripwire, secondReloadWarnings) await page.waitForSelector('[class*="frame"]', { timeout: 15_000 }) - 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(await page.getByRole('region', { name: WELCOME_NOTICE_COPY.zh.title }).count()).toBe(0) + expect(await page.getByRole('region', { name: '添加一个 API Key 开始使用' }).count()).toBe(0) // A different stored copy version represents an intentional version bump: // the welcome step returns even though the credential is already ready. @@ -146,7 +150,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup 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.getByRole('region', { name: '添加一个 API Key 开始使用' }).count()).toBe(0) expect((await page.content()).includes(secret)).toBe(false) expect((await page.locator('body').ariaSnapshot()).includes(secret)).toBe(false) diff --git a/apps/web/tests/snapshots/onboarding-deepseek-config/missing.expected.md b/apps/web/tests/snapshots/onboarding-deepseek-config/missing.expected.md index 102b6a7fab..89f3e009f5 100644 --- a/apps/web/tests/snapshots/onboarding-deepseek-config/missing.expected.md +++ b/apps/web/tests/snapshots/onboarding-deepseek-config/missing.expected.md @@ -1,6 +1,6 @@ -- dialog "添加一个 API Key 开始使用": +- region "添加一个 API Key 开始使用": - heading "添加一个 API Key 开始使用" [level=2] - - button "稍后配置": - - img - paragraph: 配置 DeepSeek 官方模型,即可开始使用。 + - text: DeepSeek deepseek-official + - button "稍后配置" - button "前往配置" diff --git a/apps/web/tests/snapshots/onboarding-deepseek-config/welcome.expected.md b/apps/web/tests/snapshots/onboarding-deepseek-config/welcome.expected.md index d0fcb2b68d..1fe30502c1 100644 --- a/apps/web/tests/snapshots/onboarding-deepseek-config/welcome.expected.md +++ b/apps/web/tests/snapshots/onboarding-deepseek-config/welcome.expected.md @@ -1,8 +1,10 @@ -- dialog "内测声明": +- region "内测声明": - heading "内测声明" [level=2] - - paragraph: 感谢您试用 DeepSeek Harness。目前仍处于内部测试阶段,部分功能与体验还在持续打磨。 - - strong: 我们最想听见:失败、困惑和不顺手 - - paragraph: 如果它没帮到您,甚至给工作添了麻烦,请在企业微信群告诉我们。 - - paragraph: 真实使用中的每一个问题,都可能促使我们重新审视,甚至推翻已有设计。 - - paragraph: “如切如磋,如琢如磨。” + - paragraph: 感谢您愿意拨冗试用 DeepSeek Harness。 + - paragraph: 目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗粝。 + - blockquote: “如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中暴露的问题,也可能促使我们重新审视,甚至推翻已有的设计。 + - paragraph: + - text: 我们尤其希望听见那些失败、困惑与不顺手的时刻—— + - strong: 如果它未能帮到您,甚至反而为工作平添了麻烦,请在企业微信群中留言 + - text: ,将使用感受告诉我们。每一条反馈,都会帮助我们把它打磨得更好。 - button "继续" diff --git a/packages/client/ui-models/src/client/DeepSeekOnboardingDialog.module.css b/packages/client/ui-models/src/client/DeepSeekOnboardingDialog.module.css index 6823556903..6d8b77f8ab 100644 --- a/packages/client/ui-models/src/client/DeepSeekOnboardingDialog.module.css +++ b/packages/client/ui-models/src/client/DeepSeekOnboardingDialog.module.css @@ -1,14 +1,136 @@ -.dialog { - width: min(420px, 100%); +.page { + position: relative; + z-index: 1; + width: min(640px, calc(100vw - 64px)); + max-height: 100vh; + padding: clamp(64px, 9vh, 108px) 0 40px; + box-sizing: border-box; + overflow-y: auto; + color: var(--dsw-alias-label-primary); } -.diagnostic { +.brand { + display: flex; + align-items: center; + margin-bottom: 42px; + color: var(--dsw-alias-label-primary); +} + +.title { + max-width: 620px; margin: 0; - font-size: 13px; - line-height: 20px; + font-size: clamp(30px, 4vw, 42px); + line-height: 1.15; + font-weight: 600; + letter-spacing: -0.035em; + outline: none; +} + +.description, +.diagnostic { + max-width: 600px; + margin: 22px 0 0; + font-size: 17px; + line-height: 29px; color: var(--dsw-alias-label-secondary); } -.primary { - width: 100%; +.provider { + display: flex; + align-items: center; + justify-content: space-between; + max-width: 600px; + margin-top: 36px; + padding: 18px 20px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 16px; + background: var(--dsw-alias-bg-module-platform); +} + +.providerName { + font-size: 16px; + line-height: 24px; + font-weight: 600; +} + +.providerRoute { + font-size: 13px; + line-height: 20px; + color: var(--dsw-alias-label-tertiary); +} + +.actions { + display: flex; + align-items: center; + gap: 12px; + margin-top: 40px; +} + +.primary { + min-width: 132px; +} + +.brand, +.title, +.description, +.diagnostic, +.provider, +.actions { + animation: credential-enter 280ms cubic-bezier(0.23, 1, 0.32, 1) both; +} + +.title { animation-delay: 40ms; } +.description, +.diagnostic { animation-delay: 80ms; } +.provider { animation-delay: 120ms; } +.actions { animation-delay: 160ms; } + +@keyframes credential-enter { + from { + opacity: 0; + transform: translateY(8px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (prefers-reduced-motion: reduce) { + .brand, + .title, + .description, + .diagnostic, + .provider, + .actions { + animation: none; + } +} + +@media (max-width: 560px) { + .page { + width: calc(100vw - 40px); + padding-top: 48px; + } + + .brand { + margin-bottom: 30px; + } + + .description, + .diagnostic { + font-size: 16px; + line-height: 27px; + } + + .actions { + align-items: stretch; + flex-direction: column-reverse; + } + + .primary, + .later { + width: 100%; + } } diff --git a/packages/client/ui-models/src/client/DeepSeekOnboardingDialog.tsx b/packages/client/ui-models/src/client/DeepSeekOnboardingDialog.tsx index 31ae571272..54055d4bf0 100644 --- a/packages/client/ui-models/src/client/DeepSeekOnboardingDialog.tsx +++ b/packages/client/ui-models/src/client/DeepSeekOnboardingDialog.tsx @@ -1,13 +1,13 @@ /** - * Official-DeepSeek first-run dialog. Readiness comes from the same + * Official-DeepSeek first-run step. Readiness comes from the same * provider/settings/credential join as the Models page; the prompt only * routes the user to that page's single credential editor. */ -import { useEffect } from 'react' +import { useEffect, useRef } from 'react' import type { ReactNode } from 'react' import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -import { Button, Modal } from '@deepseek-ai/dsh-client-ui-primitives' +import { BrandWordmark, Button } from '@deepseek-ai/dsh-client-ui-primitives' import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react' import type { DeepSeekReadiness, ModelsSettingsState, ModelsSettingsStore } from './store.ts' import { deepSeekReadiness } from './store.ts' @@ -61,12 +61,13 @@ function unavailableDiagnostic( * Prompt a first-run user to open Models while the official adapter exists * and its effective credential is not configured. * @param props - settings-shell owner state and Models feature dependencies. - * @returns the controlled modal or null when onboarding needs no intervention. + * @returns the onboarding page or null when onboarding needs no intervention. */ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps): ReactNode { const { complete, openSection, controller, useSnapshot, t } = props const state = useSnapshot(snapshot => snapshot) const readiness = deepSeekReadiness(state) + const titleRef = useRef(null) useEffect(() => { if (state.status === 'idle') void controller.load() @@ -81,6 +82,12 @@ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps): openSection('models') } + useEffect(() => { + if (readiness.kind === 'credential-missing' || readiness.kind === 'unavailable') { + titleRef.current?.focus() + } + }, [readiness.kind]) + let unavailableReason: UnavailableReason | undefined switch (readiness.kind) { case 'loading': @@ -102,26 +109,38 @@ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps): ? undefined : unavailableDiagnostic(unavailableReason, t) + const title = unavailable ? t('onboardingUnavailableTitle') : t('onboardingTitle') + return ( - + +

+ {title} +

+ {unavailable + ?

{diagnostic}

+ :

{t('onboardingDescription')}

} +
+ DeepSeek + deepseek-official +
+
+ - )} - > - {diagnostic === undefined ? undefined :

{diagnostic}

} - +
+ ) } diff --git a/packages/client/ui-models/tests/onboarding-dialog.spec.tsx b/packages/client/ui-models/tests/onboarding-dialog.spec.tsx index 035faf31d0..731fa2406d 100644 --- a/packages/client/ui-models/tests/onboarding-dialog.spec.tsx +++ b/packages/client/ui-models/tests/onboarding-dialog.spec.tsx @@ -111,18 +111,18 @@ describe('DeepSeekOnboardingDialog', () => { it('loads on first entry and presents one accessible route to Models', async () => { const h = harness() render() - expect(await screen.findByRole('dialog', { name: en.onboardingTitle })).toBeTruthy() + expect(await screen.findByRole('region', { name: en.onboardingTitle })).toBeTruthy() expect(screen.getByText(en.onboardingDescription)).toBeTruthy() const action = screen.getByRole('button', { name: en.onboardingGoToSettings }) expect(action).toBeTruthy() - expect(document.activeElement).toBe(action) + expect(document.activeElement).toBe(screen.getByRole('heading', { name: en.onboardingTitle })) expect(screen.queryByRole('textbox')).toBeNull() }) it('opens the Models section and dismisses the prompt', async () => { const h = harness() render() - await screen.findByRole('dialog') + await screen.findByRole('region') fireEvent.click(screen.getByRole('button', { name: en.onboardingGoToSettings })) expect(h.complete).toHaveBeenCalledOnce() expect(h.openSection).toHaveBeenCalledWith('models') @@ -131,7 +131,7 @@ describe('DeepSeekOnboardingDialog', () => { it('allows configure-later dismissal without opening settings', async () => { const h = harness() render() - await screen.findByRole('dialog') + await screen.findByRole('region') fireEvent.click(screen.getByRole('button', { name: en.onboardingLater })) expect(h.complete).toHaveBeenCalledOnce() expect(h.openSection).not.toHaveBeenCalled() @@ -140,7 +140,7 @@ describe('DeepSeekOnboardingDialog', () => { it('routes an unavailable credential deployment to Models with a diagnostic', async () => { const h = harness({ describeFailure: 'credentials service is absent' }) render() - await screen.findByRole('dialog', { name: en.onboardingUnavailableTitle }) + await screen.findByRole('region', { name: en.onboardingUnavailableTitle }) expect(screen.getByText(en.onboardingCredentialsUnavailable)).toBeTruthy() fireEvent.click(screen.getByRole('button', { name: en.onboardingGoToSettings })) expect(h.openSection).toHaveBeenCalledWith('models') @@ -152,7 +152,7 @@ describe('DeepSeekOnboardingDialog', () => { harness({ settingsWritable: false }), ]) { const view = render() - await screen.findByRole('dialog', { name: en.onboardingUnavailableTitle }) + await screen.findByRole('region', { name: en.onboardingUnavailableTitle }) expect(screen.getByText(en.onboardingReadOnly)).toBeTruthy() view.unmount() } @@ -161,7 +161,7 @@ describe('DeepSeekOnboardingDialog', () => { it('distinguishes an initial transport failure from deployment misconfiguration', async () => { const h = harness({ providersRejectOnce: true }) render() - await screen.findByRole('dialog', { name: en.onboardingUnavailableTitle }) + await screen.findByRole('region', { name: en.onboardingUnavailableTitle }) expect(screen.getByText(en.onboardingLoadFailed)).toBeTruthy() fireEvent.click(screen.getByRole('button', { name: en.onboardingGoToSettings })) expect(h.openSection).toHaveBeenCalledWith('models') @@ -174,7 +174,7 @@ describe('DeepSeekOnboardingDialog', () => { harness({ apiKeyEnv: null }), ]) { const view = render() - await screen.findByRole('dialog', { name: en.onboardingUnavailableTitle }) + await screen.findByRole('region', { name: en.onboardingUnavailableTitle }) expect(screen.getByText(en.onboardingConfigurationUnavailable)).toBeTruthy() view.unmount() } @@ -188,7 +188,7 @@ describe('DeepSeekOnboardingDialog', () => { ]) { const view = render() await act(async () => { await h.controller.load() }) - expect(screen.queryByRole('dialog')).toBeNull() + expect(screen.queryByRole('region')).toBeNull() await waitFor(() => { expect(h.complete).toHaveBeenCalledOnce() }) view.unmount() } @@ -197,10 +197,10 @@ describe('DeepSeekOnboardingDialog', () => { it('closes when an external credential invalidation refreshes the shared join', async () => { const h = harness() render() - await screen.findByRole('dialog') + await screen.findByRole('region') h.configure() await act(async () => { await h.controller.load() }) - await waitFor(() => { expect(screen.queryByRole('dialog')).toBeNull() }) + await waitFor(() => { expect(screen.queryByRole('region')).toBeNull() }) expect(h.complete).toHaveBeenCalledOnce() }) }) diff --git a/packages/client/ui-settings-general/README.i18n.yaml b/packages/client/ui-settings-general/README.i18n.yaml index b8fd2d4025..73bae23ebd 100644 --- a/packages/client/ui-settings-general/README.i18n.yaml +++ b/packages/client/ui-settings-general/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-settings-general/README.md -README.md: 3ae3f58bd00172ed9b547c01a354023c224f6a1c -README.zh.md: ffdaf0e4314daa947a4183e2b23b7b1650de7611 +README.md: 0ec2e14bc4f483a23607de7f33ca4c35687c7c4f +README.zh.md: 9ce7136ce9a0bf384bd2b6419d52cd0da5c7dc83 diff --git a/packages/client/ui-settings-general/README.md b/packages/client/ui-settings-general/README.md index 3ae3f58bd0..0ec2e14bc4 100644 --- a/packages/client/ui-settings-general/README.md +++ b/packages/client/ui-settings-general/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Settings ownerless-copy and product-onboarding plugin: registers everything on the Settings surface that belongs to no single feature — the shell's trigger/header/close chrome content, the General section (Permission/Tool Call skeleton rows + the `settings.general.item` slot declaration), the `settings` dictionaries, and the first ordered welcome step. Feature-owned rows (Language, Appearance), sections (Models), and conditional onboarding steps stay with their feature packages. -`src/onboarding-copy.ts` is the single editable owner of the complete Chinese and English notice plus `WELCOME_NOTICE_VERSION`. The Host half registers `ui-onboarding` in the user-settings seam; the browser compares `welcomeNoticeVersion` for exact equality and writes the current value only after Continue succeeds. The path mutation is idempotent across tabs and preserves sibling settings, while `host/settings-changed` makes an externally acknowledged notice advance without a reload. A different version deliberately presents the notice again. The welcome UI has no close, Escape, mask-click, or secondary path, and none of its copy or acknowledgement enters a Session log or model request. +`src/onboarding-copy.ts` is the single editable owner of the complete Chinese and English notice plus `WELCOME_NOTICE_VERSION`. The Host half registers `ui-onboarding` in the user-settings seam; the browser compares `welcomeNoticeVersion` for exact equality and writes the current value only after Continue succeeds. The path mutation is idempotent across tabs and preserves sibling settings, while `host/settings-changed` makes an externally acknowledged notice advance without a reload. A different version deliberately presents the notice again. The welcome page preserves every authored paragraph, gives the requested clause in the final paragraph the sole emphasis, initially focuses the title, and has no close, Escape, mask-click, or secondary path. None of its copy or acknowledgement enters a Session log or model request. ## Model Experience diff --git a/packages/client/ui-settings-general/README.zh.md b/packages/client/ui-settings-general/README.zh.md index ffdaf0e431..9ce7136ce9 100644 --- a/packages/client/ui-settings-general/README.zh.md +++ b/packages/client/ui-settings-general/README.zh.md @@ -4,7 +4,7 @@ 设置界面无特定功能归属的文案与产品引导插件:在设置界面注册所有不属于单一功能的内容,包括外壳的触发器、标题栏与关闭控件内容,「通用」分区(「权限」/「工具调用」骨架行和 `settings.general.item` slot 声明)、`settings` 字典,以及第一个有序欢迎步骤。归具体功能所有的行(「语言」、「外观」)、分区(「模型」)和条件式首次使用引导步骤仍由各自的功能包提供。 -`src/onboarding-copy.ts` 是完整中英文通知文案和 `WELCOME_NOTICE_VERSION` 的唯一可编辑来源。宿主端在 user-settings seam 中注册 `ui-onboarding`;浏览器比较 `welcomeNoticeVersion` 是否精确相等,仅在「继续」操作成功后写入当前值。该路径变更在不同标签页间幂等,并会保留同级设置;`host/settings-changed` 则让页面在通知被外部确认后,无需重新加载即可推进。版本不同时,系统会有意重新显示通知。欢迎界面没有关闭操作、Escape、点击遮罩或次要操作路径,其文案和确认状态均不会进入会话日志或模型请求。 +`src/onboarding-copy.ts` 是完整中英文通知文案和 `WELCOME_NOTICE_VERSION` 的唯一可编辑来源。宿主端在 user-settings seam 中注册 `ui-onboarding`;浏览器比较 `welcomeNoticeVersion` 是否精确相等,仅在「继续」操作成功后写入当前值。该路径变更在不同标签页间幂等,并会保留同级设置;`host/settings-changed` 则让页面在通知被外部确认后,无需重新加载即可推进。版本不同时,系统会有意重新显示通知。欢迎页保留原文的每个段落,仅强调最后一段中指定的句段,初始焦点落在标题上,并且没有关闭操作、Escape、点击遮罩或次要操作路径。其文案和确认状态均不会进入会话日志或模型请求。 ## 模型体验 diff --git a/packages/client/ui-settings-general/src/client/WelcomeNotice.module.css b/packages/client/ui-settings-general/src/client/WelcomeNotice.module.css index 805f411918..843606605e 100644 --- a/packages/client/ui-settings-general/src/client/WelcomeNotice.module.css +++ b/packages/client/ui-settings-general/src/client/WelcomeNotice.module.css @@ -1,137 +1,161 @@ -.overlay { - position: fixed; - inset: 0; - z-index: 1100; - display: flex; - align-items: center; - justify-content: center; - padding-top: 80px; - box-sizing: border-box; -} - -/* Mask */ -.mask { - position: absolute; - left: 0px; - right: 0px; - top: 80px; - bottom: 0px; - background: rgba(0, 0, 0, 0.24); - /* Mask-blur */ - backdrop-filter: blur(2px); -} - -.dialog { +.page { position: relative; z-index: 1; - width: min(600px, calc(100vw - 48px)); - max-height: calc(100vh - 128px); - padding: 32px; + width: min(640px, calc(100vw - 64px)); + max-height: 100vh; + padding: clamp(64px, 9vh, 104px) 0 40px; box-sizing: border-box; overflow-y: auto; - border-radius: 24px; - background: var(--dsw-alias-bg-layer-2); - box-shadow: var(--dsw-shadow-lv3); + color: var(--dsw-alias-label-primary); + --welcome-ease-out: cubic-bezier(0.23, 1, 0.32, 1); +} + +.brand { + display: flex; + align-items: center; + margin-bottom: 42px; color: var(--dsw-alias-label-primary); } .title { margin: 0; - font-size: 20px; - line-height: 30px; + font-size: 28px; + line-height: 36px; font-weight: 600; - letter-spacing: -0.01em; + letter-spacing: -0.02em; + outline: none; } -.lead, -.closing, -.quote, -.feedback p, +.opening, +.status, +.reflection, +.feedback, .error { margin: 0; } -.lead { - margin-top: 12px; - font-size: 16px; - line-height: 25px; - color: var(--dsw-alias-label-secondary); +.opening { + margin-top: 30px; +} + +.status { + margin-top: 18px; +} + +.reflection { + margin-top: 36px; + padding: 0; } .feedback { - margin-top: 20px; - padding: 16px 18px; - border-radius: 14px; - border: 1px solid var(--dsw-alias-border-l1); - background: var(--dsw-alias-bg-module-platform); - font-size: 15px; - line-height: 24px; + margin-top: 30px; +} + +.opening, +.status, +.reflection, +.feedback { + font-size: 16px; + line-height: 28px; + color: var(--dsw-alias-label-secondary); } .feedback strong { - display: block; - margin-bottom: 4px; - font-weight: 600; -} - -.feedback p, -.closing { - color: var(--dsw-alias-label-secondary); -} - -.closing { - margin-top: 16px; - font-size: 15px; - line-height: 24px; -} - -.quote { - font-size: 14px; - line-height: 22px; - color: var(--dsw-alias-label-secondary); + color: inherit; + font-weight: 500; } .footer { display: flex; - align-items: center; - justify-content: space-between; - gap: 24px; - margin-top: 24px; - padding-top: 20px; - border-top: 1px solid var(--dsw-alias-border-l1); + justify-content: flex-end; + margin-top: 32px; } .error { - margin-top: 14px; - font-size: 13px; - line-height: 20px; + margin-top: 20px; + font-size: 14px; + line-height: 22px; color: var(--dsw-alias-state-error-primary); } .primary { - min-width: 104px; - transition: transform 140ms cubic-bezier(0.23, 1, 0.32, 1); + min-width: 120px; + transition: transform 140ms var(--welcome-ease-out); } .primary:active:not(:disabled) { transform: scale(0.97); } +.brand, +.title, +.opening, +.status, +.reflection, +.feedback, +.footer { + animation: welcome-enter 280ms var(--welcome-ease-out) both; +} + +.title { animation-delay: 40ms; } +.opening { animation-delay: 80ms; } +.status { animation-delay: 120ms; } +.reflection { animation-delay: 160ms; } +.feedback { animation-delay: 200ms; } +.footer { animation-delay: 240ms; } + +@keyframes welcome-enter { + from { + opacity: 0; + transform: translateY(8px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + @media (prefers-reduced-motion: reduce) { + .brand, + .title, + .opening, + .status, + .reflection, + .feedback, + .footer { + animation: none; + } + .primary { transition: none; } } @media (max-width: 560px) { - .dialog { - padding: 24px; + .page { + width: calc(100vw - 40px); + padding-top: 38px; + } + + .brand { + margin-bottom: 30px; + } + + .opening { + margin-top: 24px; + } + + .reflection { + margin-top: 28px; + } + + .feedback { + margin-top: 28px; } .footer { - align-items: stretch; - flex-direction: column; - gap: 14px; + margin-top: 30px; } .primary { diff --git a/packages/client/ui-settings-general/src/client/WelcomeNotice.tsx b/packages/client/ui-settings-general/src/client/WelcomeNotice.tsx index ebdab519a4..f1e14d6815 100644 --- a/packages/client/ui-settings-general/src/client/WelcomeNotice.tsx +++ b/packages/client/ui-settings-general/src/client/WelcomeNotice.tsx @@ -3,11 +3,24 @@ import { useCallback, useEffect, useRef } from 'react' import type { ReactNode } from 'react' import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -import { Button } from '@deepseek-ai/dsh-client-ui-primitives' +import { BrandWordmark, Button } from '@deepseek-ai/dsh-client-ui-primitives' import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react' import type { WelcomeNoticeState, WelcomeNoticeStore } from './welcome-store.ts' import css from './WelcomeNotice.module.css' +function emphasizedFeedback(paragraph: string, emphasis: string): ReactNode { + const index = paragraph.indexOf(emphasis) + /* v8 ignore next -- both locale values derive from one owner object that contains the emphasis */ + if (index < 0) return paragraph + return ( + <> + {paragraph.slice(0, index)} + {emphasis} + {paragraph.slice(index + emphasis.length)} + + ) +} + /** Registrant-owned dependencies of {@link WelcomeNotice}. */ export interface WelcomeNoticeInjected { controller: WelcomeNoticeStore @@ -23,6 +36,7 @@ export function WelcomeNotice(props: WelcomeNoticeProps): ReactNode { const { complete, controller, useSnapshot, t } = props const state = useSnapshot(snapshot => snapshot) const finished = useRef(false) + const titleRef = useRef(null) const finish = useCallback((): void => { if (finished.current) return finished.current = true @@ -37,6 +51,10 @@ export function WelcomeNotice(props: WelcomeNoticeProps): ReactNode { if (state.acknowledged) finish() }, [finish, state.acknowledged]) + useEffect(() => { + if (state.status === 'ready' && !state.acknowledged) titleRef.current?.focus() + }, [state.acknowledged, state.status]) + if (state.status === 'idle' || state.status === 'loading' || state.acknowledged) return null const acknowledge = async (): Promise => { @@ -44,30 +62,26 @@ export function WelcomeNotice(props: WelcomeNoticeProps): ReactNode { } return ( -
- +
+ +

{t('welcome.title')}

+

{t('welcome.paragraph.0')}

+

{t('welcome.paragraph.1')}

+
{t('welcome.paragraph.2')}
+

+ {emphasizedFeedback(t('welcome.paragraph.3'), t('welcome.feedbackEmphasis'))} +

+ {state.error === null ? null :

{t('welcome.error')}

} +
+ +
+
) } diff --git a/packages/client/ui-settings-general/src/client/locales.ts b/packages/client/ui-settings-general/src/client/locales.ts index f2486faea2..c3432cfe72 100644 --- a/packages/client/ui-settings-general/src/client/locales.ts +++ b/packages/client/ui-settings-general/src/client/locales.ts @@ -27,11 +27,11 @@ export const zh: LocaleDict = { 'permission.desc': '选择默认权限模式', 'toolcall.title': '工具调用', 'welcome.title': WELCOME_NOTICE_COPY.zh.title, - 'welcome.lead': WELCOME_NOTICE_COPY.zh.lead, - 'welcome.feedbackTitle': WELCOME_NOTICE_COPY.zh.feedbackTitle, - 'welcome.feedbackBody': WELCOME_NOTICE_COPY.zh.feedbackBody, - 'welcome.closing': WELCOME_NOTICE_COPY.zh.closing, - 'welcome.quote': WELCOME_NOTICE_COPY.zh.quote, + 'welcome.paragraph.0': WELCOME_NOTICE_COPY.zh.paragraphs[0], + 'welcome.paragraph.1': WELCOME_NOTICE_COPY.zh.paragraphs[1], + 'welcome.paragraph.2': WELCOME_NOTICE_COPY.zh.paragraphs[2], + 'welcome.paragraph.3': WELCOME_NOTICE_COPY.zh.paragraphs[3], + 'welcome.feedbackEmphasis': WELCOME_NOTICE_COPY.zh.feedbackEmphasis, 'welcome.continue': WELCOME_NOTICE_COPY.zh.continueLabel, 'welcome.error': '暂时无法保存确认状态,请重试。', } @@ -47,11 +47,11 @@ export const en: LocaleDict = { 'permission.desc': 'Choose default permission mode', 'toolcall.title': 'Tool Call', 'welcome.title': WELCOME_NOTICE_COPY.en.title, - 'welcome.lead': WELCOME_NOTICE_COPY.en.lead, - 'welcome.feedbackTitle': WELCOME_NOTICE_COPY.en.feedbackTitle, - 'welcome.feedbackBody': WELCOME_NOTICE_COPY.en.feedbackBody, - 'welcome.closing': WELCOME_NOTICE_COPY.en.closing, - 'welcome.quote': WELCOME_NOTICE_COPY.en.quote, + 'welcome.paragraph.0': WELCOME_NOTICE_COPY.en.paragraphs[0], + 'welcome.paragraph.1': WELCOME_NOTICE_COPY.en.paragraphs[1], + 'welcome.paragraph.2': WELCOME_NOTICE_COPY.en.paragraphs[2], + 'welcome.paragraph.3': WELCOME_NOTICE_COPY.en.paragraphs[3], + 'welcome.feedbackEmphasis': WELCOME_NOTICE_COPY.en.feedbackEmphasis, 'welcome.continue': WELCOME_NOTICE_COPY.en.continueLabel, 'welcome.error': 'The acknowledgement could not be saved. Please try again.', } diff --git a/packages/client/ui-settings-general/src/onboarding-copy.ts b/packages/client/ui-settings-general/src/onboarding-copy.ts index 805a9d35d0..21e27114d4 100644 --- a/packages/client/ui-settings-general/src/onboarding-copy.ts +++ b/packages/client/ui-settings-general/src/onboarding-copy.ts @@ -8,26 +8,30 @@ 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-07-30.2' +export const WELCOME_NOTICE_VERSION = '2026-07-30.3' /** The complete editable welcome notice in both supported GUI locales. */ export const WELCOME_NOTICE_COPY = { zh: { title: '内测声明', - lead: '感谢您试用 DeepSeek Harness。目前仍处于内部测试阶段,部分功能与体验还在持续打磨。', - feedbackTitle: '我们最想听见:失败、困惑和不顺手', - feedbackBody: '如果它没帮到您,甚至给工作添了麻烦,请在企业微信群告诉我们。', - closing: '真实使用中的每一个问题,都可能促使我们重新审视,甚至推翻已有设计。', - quote: '“如切如磋,如琢如磨。”', + paragraphs: [ + '感谢您愿意拨冗试用 DeepSeek Harness。', + '目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗粝。', + '“如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中暴露的问题,也可能促使我们重新审视,甚至推翻已有的设计。', + '我们尤其希望听见那些失败、困惑与不顺手的时刻——如果它未能帮到您,甚至反而为工作平添了麻烦,请在企业微信群中留言,将使用感受告诉我们。每一条反馈,都会帮助我们把它打磨得更好。', + ], + feedbackEmphasis: '如果它未能帮到您,甚至反而为工作平添了麻烦,请在企业微信群中留言', continueLabel: '继续', }, en: { title: 'Internal Testing Notice', - lead: 'Thank you for trying DeepSeek Harness. This version is still in internal testing, and some features and experiences remain under refinement.', - feedbackTitle: 'What we most want to hear: failures, confusion, and friction', - feedbackBody: 'If it did not help—or even made your work harder—please tell us in the company WeChat group.', - closing: 'Every problem found in real use may prompt us to reconsider, or even overturn, an existing design.', - quote: '“As one cuts and files, as one chisels and polishes.”', + paragraphs: [ + 'Thank you for taking the time to try DeepSeek Harness.', + 'This version is still in internal testing. Some features remain unfinished, and parts of the experience may feel rough.', + '“As one cuts and files, as one chisels and polishes.” A product grows through real encounters and candid feedback. Problems you uncover in real use may prompt us to reconsider—or even overturn—our existing designs.', + 'We especially want to hear about failures, confusion, and friction. If it did not help you, or even made your work harder, please leave a message in the company WeChat group and tell us about your experience. Every piece of feedback helps us refine it.', + ], + feedbackEmphasis: 'If it did not help you, or even made your work harder, please leave a message in the company WeChat group', continueLabel: 'Continue', }, } as const diff --git a/packages/client/ui-settings-general/tests/welcome-notice.spec.tsx b/packages/client/ui-settings-general/tests/welcome-notice.spec.tsx index d414146e47..767f35ef33 100644 --- a/packages/client/ui-settings-general/tests/welcome-notice.spec.tsx +++ b/packages/client/ui-settings-general/tests/welcome-notice.spec.tsx @@ -54,30 +54,22 @@ function mount(version?: string, mutateImpl: () => Promise = () => Prom describe('WelcomeNotice', () => { it('renders the owner copy with one primary action and no dismissal control', async () => { const h = mount() - const dialog = await screen.findByRole('dialog', { name: WELCOME_NOTICE_COPY.zh.title }) - for (const text of [ - WELCOME_NOTICE_COPY.zh.title, - WELCOME_NOTICE_COPY.zh.lead, - WELCOME_NOTICE_COPY.zh.feedbackTitle, - WELCOME_NOTICE_COPY.zh.feedbackBody, - WELCOME_NOTICE_COPY.zh.closing, - WELCOME_NOTICE_COPY.zh.quote, - ]) { - expect(screen.getByText(text)).toBeTruthy() - } - expect(dialog.textContent?.match(/感谢您试用 DeepSeek Harness/g) ?? []).toHaveLength(1) - const buttons = dialog.querySelectorAll('button') + const page = await screen.findByRole('region', { name: WELCOME_NOTICE_COPY.zh.title }) + expect(screen.getByText(WELCOME_NOTICE_COPY.zh.title)).toBeTruthy() + for (const text of WELCOME_NOTICE_COPY.zh.paragraphs) expect(page.textContent).toContain(text) + expect(page.textContent?.match(/感谢您愿意拨冗试用 DeepSeek Harness/g) ?? []).toHaveLength(1) + const buttons = page.querySelectorAll('button') expect(buttons).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 })) fireEvent.keyDown(document, { key: 'Escape' }) - fireEvent.click(dialog.parentElement!.firstElementChild!) expect(h.complete).not.toHaveBeenCalled() - expect(screen.getByRole('dialog')).toBeTruthy() + expect(screen.getByRole('region')).toBeTruthy() }) it('completes only after the acknowledgement write commits', async () => { const h = mount() - await screen.findByRole('dialog') + await screen.findByRole('region') fireEvent.click(screen.getByRole('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel })) await act(async () => { await Promise.resolve() }) expect(h.mutate).toHaveBeenCalledOnce() @@ -87,7 +79,7 @@ describe('WelcomeNotice', () => { 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(screen.queryByRole('region')).toBeNull() expect(h.complete).toHaveBeenCalledOnce() }) @@ -95,7 +87,7 @@ describe('WelcomeNotice', () => { let resolveWrite!: (value: unknown) => void const write = new Promise((resolve) => { resolveWrite = resolve }) const h = mount(undefined, () => write) - await screen.findByRole('dialog') + await screen.findByRole('region') const action = screen.getByRole('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel }) fireEvent.click(action) expect(action.disabled).toBe(true) diff --git a/packages/client/ui-settings/README.i18n.yaml b/packages/client/ui-settings/README.i18n.yaml index 41247a370b..b02b945303 100644 --- a/packages/client/ui-settings/README.i18n.yaml +++ b/packages/client/ui-settings/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-settings/README.md -README.md: 02d8f0e5fdc169d3a45f59d7b42d873943df2b52 -README.zh.md: 465d57847588e9ccbccc9d9067099773de63c0d0 +README.md: 6d784e906b937e912b56b2e85bfa32866d8cb9b8 +README.zh.md: 3c627c185db3d1d80915f19df56a8fe7257fa828 diff --git a/packages/client/ui-settings/README.md b/packages/client/ui-settings/README.md index 02d8f0e5fd..6d784e906b 100644 --- a/packages/client/ui-settings/README.md +++ b/packages/client/ui-settings/README.md @@ -2,9 +2,9 @@ English | [中文](README.zh.md) -Settings shell plugin: a pure composition face. It occupies `sidebar.settings` with the trigger chrome and modal settings panel, and declares the slots registrants fill: `settings.trigger` / `settings.header` / `settings.close` (chrome content), `settings.section` (one page per feature), and `settings.onboarding` (ordered feature-owned steps on the empty Hero). The shell ships no copy and reads no locale state — all text arrives from registrants (ui-settings-general owns chrome, General, and the product welcome step; features own their sections, rows, and conditional onboarding steps). +Settings shell plugin: a pure composition face. It occupies `sidebar.settings` with the trigger chrome and modal settings panel, and declares the slots registrants fill: `settings.trigger` / `settings.header` / `settings.close` (chrome content), `settings.section` (one page per feature), and `settings.onboarding` (ordered feature-owned pages in a full-viewport stage). The shell ships no copy and reads no locale state — all text arrives from registrants (ui-settings-general owns chrome, General, and the product notice; features own their sections, rows, and conditional onboarding pages). -The shell projects the onboarding ledger into ascending order and mounts exactly one step at a time. The active registrant receives its id, `complete()`, and an `openSection(id)` callback; completing or skipping transfers ownership to the next entry. Registrants own durable completion, capability readiness, copy, and mutations, so two independently registered dialogs cannot stack and the shell does not become a second configuration fact source. +The shell projects the onboarding ledger into ascending order and mounts exactly one page at a time in a body-level stage while marking the underlying app root inert. The active registrant receives its id, `complete()`, and an `openSection(id)` callback; completing or skipping transfers ownership to the next entry. Registrants own durable completion, capability readiness, copy, and mutations, so independently registered flows cannot stack and the shell does not become a second configuration fact source. ## Model Experience diff --git a/packages/client/ui-settings/README.zh.md b/packages/client/ui-settings/README.zh.md index 465d578475..3c627c185d 100644 --- a/packages/client/ui-settings/README.zh.md +++ b/packages/client/ui-settings/README.zh.md @@ -2,9 +2,9 @@ [English](README.md) | 中文 -设置外壳插件:一个纯组合表层。它以触发控件和模态设置面板占用 `sidebar.settings`,并声明由注册方填充的 slot:`settings.trigger`/`settings.header`/`settings.close`(界面框架内容)、`settings.section`(每项功能一页)和 `settings.onboarding`(由各功能持有、显示在空白 Hero 上的有序步骤)。外壳不自带文案,也不读取 locale 状态:所有文本都来自注册方(ui-settings-general 拥有界面框架、「通用」分区和产品欢迎步骤;各功能拥有各自的分区、行和条件式首次使用引导步骤)。 +设置外壳插件:一个纯组合表层。它以触发控件和模态设置面板占用 `sidebar.settings`,并声明由注册方填充的 slot:`settings.trigger`/`settings.header`/`settings.close`(界面框架内容)、`settings.section`(每项功能一页)和 `settings.onboarding`(由各功能持有、显示在全视口展示层中的有序页面)。外壳不自带文案,也不读取 locale 状态:所有文本都来自注册方(ui-settings-general 拥有界面框架、「通用」分区和产品声明;各功能拥有各自的分区、行和条件式首次使用引导页面)。 -外壳将首次使用引导记录按升序投影,并且每次只挂载一个步骤。当前注册方会收到该条目的 id、`complete()` 和 `openSection(id)` 回调;完成或跳过当前步骤后,所有权转交给下一项。持久化完成状态、能力就绪状态、文案和变更操作均由注册方持有,因此两个独立注册的对话框无法堆叠,外壳也不会成为第二个配置事实来源。 +外壳将首次使用引导记录按升序投影,在 body 层级的展示层中每次只挂载一个页面,同时将下层应用根节点标记为 `inert`。当前注册方会收到该条目的 id、`complete()` 和 `openSection(id)` 回调;完成或跳过当前页面后,所有权转交给下一项。持久化完成状态、能力就绪状态、文案和变更操作均由注册方持有,因此独立注册的流程无法堆叠,外壳也不会成为第二个配置事实来源。 ## 模型体验 diff --git a/packages/client/ui-settings/package.json b/packages/client/ui-settings/package.json index 8c65eee5b2..fa8bf78b9f 100644 --- a/packages/client/ui-settings/package.json +++ b/packages/client/ui-settings/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-ui-settings", - "description": "Settings shell plugin: sidebar trigger, modal panel, feature sections, and root-scoped onboarding overlays", + "description": "Settings shell plugin: sidebar trigger, modal panel, feature sections, and an ordered full-page onboarding stage", "version": "0.0.1", "private": true, "type": "module", @@ -43,7 +43,8 @@ "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "cordis": "^4.0.0-rc.7", - "react": "^18.2.0" + "react": "^18.2.0", + "react-dom": "^18.2.0" }, "devDependencies": { "@deepseek-ai/dsh-client-runtime": "workspace:^", @@ -51,9 +52,11 @@ "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@types/react-dom": "~18.3.0", "@types/react": "~18.3.1", "cordis": "^4.0.0-rc.7", - "react": "^18.2.0" + "react": "^18.2.0", + "react-dom": "^18.2.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-settings/src/client/SettingsRoot.module.css b/packages/client/ui-settings/src/client/SettingsRoot.module.css index 04eaf14d2b..817ab38d9a 100644 --- a/packages/client/ui-settings/src/client/SettingsRoot.module.css +++ b/packages/client/ui-settings/src/client/SettingsRoot.module.css @@ -209,3 +209,33 @@ clip: rect(0 0 0 0); white-space: nowrap; } + +/* First-run stage: keep the product top bar visible, then let onboarding own + the complete workspace instead of presenting another settings modal. */ +.onboardingOverlay { + position: fixed; + inset: 0; + z-index: 1100; +} + +/* Mask */ +.onboardingMask { + position: absolute; + left: 0px; + right: 0px; + top: 80px; + bottom: 0px; + background: rgba(0, 0, 0, 0.24); + /* Mask-blur */ + backdrop-filter: blur(2px); +} + +.onboardingStage { + position: absolute; + z-index: 1; + inset: 0; + display: flex; + justify-content: center; + overflow: hidden; + background: var(--dsw-alias-bg-layer-1); +} diff --git a/packages/client/ui-settings/src/client/SettingsRoot.tsx b/packages/client/ui-settings/src/client/SettingsRoot.tsx index 528a633810..3eefbd4ef1 100644 --- a/packages/client/ui-settings/src/client/SettingsRoot.tsx +++ b/packages/client/ui-settings/src/client/SettingsRoot.tsx @@ -10,6 +10,7 @@ * sessions-derived empty-Hero fact is active. */ import { useCallback, useEffect, useId, useRef, useState } from 'react' +import { createPortal } from 'react-dom' import clsx from 'clsx' import { IconCloseOutline16, IconDataOutline16, IconSettingsOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' import type { SettingsRootComponentProps, SettingsSectionRow } from './contract/slots.ts' @@ -132,6 +133,14 @@ export function SettingsRoot(props: SettingsRootComponentProps) { }) }, []) + useEffect(() => { + if (onboardingStep === undefined) return + const appRoot = document.getElementById('root') + if (appRoot === null) return + appRoot.inert = true + return () => { appRoot.inert = false } + }, [onboardingStep]) + return ( <>