Merge pull request #2470 from deepseek-harness/worktree/drop-beta-welcome-notice
Remove the first-run beta notice and keep telemetry disabled by default
This commit is contained in:
+2
-2
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||||
# after editing either side, bring the other along and re-record with:
|
# after editing either side, bring the other along and re-record with:
|
||||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md
|
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md
|
||||||
2026-07-30-versioned-gui-welcome-onboarding.md: 8897e28413df77d127a4d846f2a3e42789454490
|
2026-07-30-versioned-gui-welcome-onboarding.md: 7a8c5c5b9ba1ec62ad8dd0f5bdf67c516ba0ddec
|
||||||
2026-07-30-versioned-gui-welcome-onboarding.zh.md: 9f5eb99033cd62154937808f26b0e1aa40966c9f
|
2026-07-30-versioned-gui-welcome-onboarding.zh.md: 646080b893bb6264a3a92f49400cb9207e29755b
|
||||||
@@ -10,15 +10,13 @@ The GUI's credential onboarding begins with a DeepSeek-specific readiness check,
|
|||||||
|
|
||||||
## Decision
|
## Decision
|
||||||
|
|
||||||
**The Settings shell coordinates ordered steps.** `settings.onboarding` remains a root-scoped list, but `ui-settings` projects its entry ids and order into one coordinator and mounts only the first incomplete step. The active registrant receives `complete()` and `openSection(id)`; no later step mounts until ownership transfers. The product welcome registers at order `-100`, while `ui-settings-models` retains only the conditional DeepSeek readiness and credential-routing step at order `0`.
|
**The Settings shell coordinates ordered steps.** `settings.onboarding` remains a root-scoped list, but `ui-settings` projects its entry ids and order into one coordinator and mounts only the first incomplete step. The active registrant receives `complete()` and `openSection(id)`; no later step mounts until ownership transfers. `ui-settings-models` registers the conditional DeepSeek readiness and credential-routing step at order `0`, the only shipped occupant since the [first-run beta notice removal](../simplification/2026-08-13-remove-first-run-beta-notice.md).
|
||||||
|
|
||||||
**Ownerless product onboarding belongs to `ui-settings-general`.** `src/onboarding-copy.ts` is the single editable source for the complete notice, the Continue label, and `WELCOME_NOTICE_VERSION`; both supported GUI locales intentionally render the same Chinese owner copy. 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. The notice states that session telemetry is [disabled by default](2026-08-10-telemetry-default-off.md) and names the `FEEDBACK_ONLY` and `FULL` opt-in modes.
|
**The product welcome step is removed.** The versioned notice, its copy owner, and its acknowledgement store shipped from this decision until the [first-run beta notice removal](../simplification/2026-08-13-remove-first-run-beta-notice.md), which owns the removal rationale; `ui-settings-general` seats no onboarding step.
|
||||||
|
|
||||||
**Loopback 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 connection plugin publishes whether the current page uses a loopback authority as `ctx.connection.isLoopback`; hostname classification remains internal to the connection package, and other client plugins consume the service state instead of importing its implementation. A loopback 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. A non-loopback browser must not call the loopback-only settings API. It presents the same notice, but explicit Continue completes the step only in the current browser process; reload or a new process presents it again.
|
**The durable `ui-onboarding` section outlives the notice.** The Host half registers it in the user-settings seam under the active `$DSH_HOME/settings.yaml`; its `welcomeNoticeVersion` field keeps stored acknowledgements valid and has no reader. The connection plugin publishes whether the current page uses a loopback authority as `ctx.connection.isLoopback`; hostname classification remains internal to the connection package, and other client plugins consume the service state instead of importing its implementation. The API proxy exposes this one product namespace through a closed allowlist beside configurable-provider namespaces, without treating its changes as model-catalog invalidations.
|
||||||
|
|
||||||
**Concurrent loopback views converge without stale replacement.** The acknowledgement write omits `expectedRevision` deliberately: every loopback 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` reaches the client as an invalidation — through `host/settings-changed` then, and forwarded verbatim now ([forwarded Remote events](../architecture/2026-08-10-remote-event-delivery.md)); an already mounted loopback 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.
|
**Onboarding temporarily owns the viewport as one continuous stage.** A solid product surface replaces the complete application view through a body-level portal and marks the underlying app root inert; the exact required mask remains mounted behind that surface with `position:absolute`, zero left/right/bottom offsets, `top:80px`, `rgba(0, 0, 0, 0.24)`, and `backdrop-filter: blur(2px)`. Onboarding steps render as successive pages in this stage instead of independent modals, reusing the Web UI's black `BrandWordmark`; the conditional credential setup is the only shipped page.
|
||||||
|
|
||||||
**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
|
## Alternatives considered
|
||||||
|
|
||||||
@@ -32,4 +30,4 @@ The GUI's credential onboarding begins with a DeepSeek-specific readiness check,
|
|||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
A fresh profile always sees the welcome notice before provider-specific onboarding; an already configured credential skips only the later DeepSeek step. On loopback, reloading after Continue stays past the acknowledged version, changing the owner version presents it again, and closing before Continue leaves the next launch unchanged. On non-loopback, Continue advances the live process without a privileged settings request and reload presents the notice again. Focused store and React tests pin both persistence modes, 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.
|
A fresh profile proceeds directly to provider-specific onboarding: the conditional DeepSeek step mounts when its credential is missing, and an already configured credential shows no onboarding page at all. Focused store and React tests pin coordinator ordering, conditional DeepSeek transfer, and HMR cleanup. The real Chromium scenario boots the shipped Web composition with an isolated harness home, verifies the exact mask geometry and computed styles while the credential step owns the viewport, continues into missing-credential setup, and checks the browser console.
|
||||||
@@ -10,15 +10,13 @@ GUI 的凭据引导从 DeepSeek 专用的就绪状态检查开始,但内部测
|
|||||||
|
|
||||||
## 决策
|
## 决策
|
||||||
|
|
||||||
**设置外壳协调有序步骤。** `settings.onboarding` 仍是根作用域 list,但 `ui-settings` 会把其中各条目的 id 和顺序投影到一个协调器中,并且只挂载第一个未完成的步骤。当前注册方会收到 `complete()` 和 `openSection(id)`;所有权转移前,不会挂载后续步骤。产品欢迎步骤的顺序为 `-100`,`ui-settings-models` 则只保留顺序为 `0` 的 DeepSeek 条件式就绪状态与凭据跳转步骤。
|
**设置外壳协调有序步骤。** `settings.onboarding` 仍是根作用域 list,但 `ui-settings` 会把其中各条目的 id 和顺序投影到一个协调器中,并且只挂载第一个未完成的步骤。当前注册方会收到 `complete()` 和 `openSection(id)`;所有权转移前,不会挂载后续步骤。`ui-settings-models` 注册顺序为 `0` 的 DeepSeek 条件式就绪状态与凭据跳转步骤,自[移除首次启动内测声明](../simplification/2026-08-13-remove-first-run-beta-notice.md)起,它是当前唯一的注册方。
|
||||||
|
|
||||||
**不属于单一功能的产品引导由 `ui-settings-general` 持有。** `src/onboarding-copy.ts` 是完整通知、「继续」按钮文案和 `WELCOME_NOTICE_VERSION` 的唯一可编辑来源;GUI 支持的两种 locale 都有意渲染同一份中文所有者文案。运行时 locale 字典从该文件派生欢迎文案,测试也导入同一个所有者,而不重复段落文本。该通知只存在于浏览器 UI:它不会创建会话事件,也不会贡献任何模型可见内容。通知说明会话遥测[默认禁用](2026-08-10-telemetry-default-off.md),并列出 `FEEDBACK_ONLY` 和 `FULL` 两种显式启用模式。
|
**产品欢迎步骤已移除。** 版本化通知、其文案所有者文件和确认 store 自本决策起随产品发布,直至[移除首次启动内测声明](../simplification/2026-08-13-remove-first-run-beta-notice.md);移除理由由该 note 持有。`ui-settings-general` 不再注册任何引导步骤。
|
||||||
|
|
||||||
**loopback 确认状态按 Harness profile 持久化。** 宿主端在 user-settings seam 中注册 `ui-onboarding` 分节,并存入当前 `$DSH_HOME/settings.yaml`。connection 插件通过 `ctx.connection.isLoopback` 统一发布当前页面是否使用 loopback authority;hostname 判定留在 connection 包内,其他客户端插件只消费服务状态,而不导入其实现。除非 `welcomeNoticeVersion` 与文案所有者文件中的常量精确相等,否则 loopback 浏览器会显示通知。「继续」会以当前版本执行一次路径变更,并且仅在宿主端提交成功后调用 `complete()`;写入失败时通知保持打开,关闭页面或进程则不会写入任何内容。更新该常量会有意要求每个 profile 对修订后的文案重新确认一次。非 loopback 浏览器不能调用仅限 loopback 的 settings API;它仍显示同一通知,但显式点击「继续」只会在当前浏览器进程中完成该步骤,重新加载或新进程会再次显示通知。
|
**持久化的 `ui-onboarding` 分节在通知移除后继续存在。** 宿主端在 user-settings seam 中注册它,存入当前 `$DSH_HOME/settings.yaml`;其中的 `welcomeNoticeVersion` 字段让已存储的确认记录保持有效,没有读取方。connection 插件通过 `ctx.connection.isLoopback` 统一发布当前页面是否使用 loopback authority;hostname 判定留在 connection 包内,其他客户端插件只消费服务状态,而不导入其实现。API Proxy 在可配置提供方 namespace 之外,通过封闭的允许列表暴露这一个产品 namespace,同时不会把它的变更视为模型目录失效事件。
|
||||||
|
|
||||||
**并发 loopback 视图无需陈旧的整体替换即可收敛。** 确认写入有意省略 `expectedRevision`:每个 loopback 标签页都向同一路径写入相同版本,因此该操作是幂等的,并会保留同级字段,而不是重建整个分节。`settings/document-updated` 以失效通知形式到达客户端——当时经 `host/settings-changed`,现在则是原样转发([转发的 Remote 事件](../architecture/2026-08-10-remote-event-delivery.md));另一个标签页或外部编辑器提交当前版本后,已挂载的 loopback 标签页会重新拉取状态并推进。API Proxy(`@deepseek-ai/dsh-host-apiproxy`)在可配置提供方 namespace 之外,通过封闭的允许列表暴露这一个产品 namespace,同时不会把它的变更视为模型目录失效事件。
|
**引导流程会暂时接管视口,形成一个连续阶段。** 纯色产品界面通过挂载到 `body` 的 portal 取代完整的应用视图,并将底层应用根节点标记为 inert;严格符合要求的遮罩仍挂载在该界面后方,并保留 `position:absolute`、left/right/bottom 偏移量为零、`top:80px`、`rgba(0, 0, 0, 0.24)` 和 `backdrop-filter: blur(2px)`。引导步骤在这一阶段中依次呈现,而不是各自作为独立的模态窗口,并复用 Web UI 的黑色 `BrandWordmark`;按条件显示的凭据设置页是当前唯一的页面。
|
||||||
|
|
||||||
**引导流程会暂时接管视口,形成一个连续阶段。** 纯色产品界面通过挂载到 `body` 的 portal 取代完整的应用视图,并将底层应用根节点标记为 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 或点击遮罩的退出路径。
|
|
||||||
|
|
||||||
## 曾考虑的替代方案
|
## 曾考虑的替代方案
|
||||||
|
|
||||||
@@ -32,4 +30,4 @@ GUI 的凭据引导从 DeepSeek 专用的就绪状态检查开始,但内部测
|
|||||||
|
|
||||||
## 后果
|
## 后果
|
||||||
|
|
||||||
全新 profile 始终会在提供方专用引导之前看到欢迎通知;凭据已经配置时,只会跳过后续 DeepSeek 步骤。在 loopback 上,点击「继续」后重新加载不会再次显示已确认版本,更改文案所有者文件中的版本值会让通知重新出现,而确认前关闭窗口不会改变下次启动。在非 loopback 上,「继续」会在不发起受保护 settings 请求的情况下推进当前进程,重新加载则再次显示通知。针对性的 store 与 React 测试固化了两种持久化模式、精确版本比较、写入失败、单一操作、不可关闭路径、协调器顺序、按条件移交 DeepSeek 步骤和 HMR(热模块替换)清理行为。真实 Chromium 场景会使用隔离的 harness 家目录启动随产品提供的 Web 组合,验证遮罩的精确几何尺寸和计算样式,在确认前后分别重新加载,继续进入凭据缺失设置流程,确认凭据已配置时确认版本不匹配仍会使通知重新出现,并检查浏览器控制台。
|
全新 profile 直接进入提供方专用引导:DeepSeek 步骤仅在其凭据缺失时挂载,凭据已配置时不会出现任何引导页面。针对性的 store 与 React 测试固化了协调器顺序、按条件移交 DeepSeek 步骤和 HMR(热模块替换)清理行为。真实 Chromium 场景会使用隔离的 harness 家目录启动随产品提供的 Web 组合,在凭据步骤占据视口时验证遮罩的精确几何尺寸和计算样式,继续进入凭据缺失设置流程,并检查浏览器控制台。
|
||||||
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||||
# after editing either side, bring the other along and re-record with:
|
# after editing either side, bring the other along and re-record with:
|
||||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md
|
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md
|
||||||
2026-08-10-telemetry-default-off.md: a4f0099565bb240cf62774fab2d5ad5f7de90e95
|
2026-08-10-telemetry-default-off.md: b70fdbc64074936ae910cba305a59111d6f9b572
|
||||||
2026-08-10-telemetry-default-off.zh.md: 8e2544eb7dee8b9bd6b8a4c81a28a8a03d3404d4
|
2026-08-10-telemetry-default-off.zh.md: abb5cafa9f877659dca1d53c0be9df9414799cf9
|
||||||
@@ -14,7 +14,7 @@ Both feeds use `DSH_TELEMETRY_MODE` as their positive consent setting. Unset and
|
|||||||
|
|
||||||
The dsh-sdk launcher reads the same variable without parsing `cordis.yml` or booting Cordis. `FULL` permits reporting; `FEEDBACK_ONLY`, `DISABLED`, unset, and empty values deny it. Consent is frozen from the launching environment before the command runs, because `dsh-sdk start` loads a project `.env` and project code can mutate `process.env`: resolving afterwards would let a project grant reporting of its own configuration, which the [configuration source ownership decision](../architecture/2026-08-04-configuration-source-ownership.md) denies for the whole `DSH_*` namespace. An unsupported mode denies rather than throwing at that boundary, since telemetry may never change a command's result. This rule superseded the default-on launcher consent before the launcher and its proposal were deleted by the [SDK project toolchain removal](../simplification/2026-08-11-remove-sdk-project-toolchain.md).
|
The dsh-sdk launcher reads the same variable without parsing `cordis.yml` or booting Cordis. `FULL` permits reporting; `FEEDBACK_ONLY`, `DISABLED`, unset, and empty values deny it. Consent is frozen from the launching environment before the command runs, because `dsh-sdk start` loads a project `.env` and project code can mutate `process.env`: resolving afterwards would let a project grant reporting of its own configuration, which the [configuration source ownership decision](../architecture/2026-08-04-configuration-source-ownership.md) denies for the whole `DSH_*` namespace. An unsupported mode denies rather than throwing at that boundary, since telemetry may never change a command's result. This rule superseded the default-on launcher consent before the launcher and its proposal were deleted by the [SDK project toolchain removal](../simplification/2026-08-11-remove-sdk-project-toolchain.md).
|
||||||
|
|
||||||
The versioned Web welcome notice states that Session Log upload is off by default, names `DSH_TELEMETRY_MODE=FEEDBACK_ONLY` and `DSH_TELEMETRY_MODE=FULL` as the two opt-in choices, and discloses that `FULL` also enables dsh-sdk command telemetry. Its version changes with that material privacy statement so every profile acknowledges the current copy.
|
The [CLI reference README](../../../../apps/cli/reference/README.md) documents the deployment stance: Session Log upload is off by default, `DSH_TELEMETRY_MODE=FEEDBACK_ONLY` and `DSH_TELEMETRY_MODE=FULL` are the two opt-in choices, and explicitly enabled exports can contain complete session content. The product itself presents no prompt about enabling telemetry since the [first-run beta notice removal](../simplification/2026-08-13-remove-first-run-beta-notice.md).
|
||||||
|
|
||||||
## Alternatives considered
|
## Alternatives considered
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ DeepSeek Harness 有两路出站遥测数据流。在内测阶段,共享基础
|
|||||||
|
|
||||||
dsh-sdk 启动器读取同一变量,不解析 `cordis.yml`,也不启动 Cordis。`FULL` 允许上报;`FEEDBACK_ONLY`、`DISABLED`、未设置和空值都会拒绝。授权在命令执行前从启动环境冻结:`dsh-sdk start` 会加载项目 `.env`,项目代码也能修改 `process.env`,若在执行后解析,项目便能自行授权上报其自身配置,而[配置来源所有权决策](../architecture/2026-08-04-configuration-source-ownership.md)对整个 `DSH_*` 命名空间禁止这种行为。在该边界上,不受支持的模式按拒绝处理而非抛出,因为遥测不得改变命令结果。此规则在启动器及其提案被[SDK 项目工具链移除决策](../simplification/2026-08-11-remove-sdk-project-toolchain.md)删除之前,仅取代了启动器默认允许上报的规则。
|
dsh-sdk 启动器读取同一变量,不解析 `cordis.yml`,也不启动 Cordis。`FULL` 允许上报;`FEEDBACK_ONLY`、`DISABLED`、未设置和空值都会拒绝。授权在命令执行前从启动环境冻结:`dsh-sdk start` 会加载项目 `.env`,项目代码也能修改 `process.env`,若在执行后解析,项目便能自行授权上报其自身配置,而[配置来源所有权决策](../architecture/2026-08-04-configuration-source-ownership.md)对整个 `DSH_*` 命名空间禁止这种行为。在该边界上,不受支持的模式按拒绝处理而非抛出,因为遥测不得改变命令结果。此规则在启动器及其提案被[SDK 项目工具链移除决策](../simplification/2026-08-11-remove-sdk-project-toolchain.md)删除之前,仅取代了启动器默认允许上报的规则。
|
||||||
|
|
||||||
带版本的 Web 欢迎通知说明会话日志上传默认关闭,将 `DSH_TELEMETRY_MODE=FEEDBACK_ONLY` 和 `DSH_TELEMETRY_MODE=FULL` 列为两种显式启用选项,并披露 `FULL` 同时会启用 dsh-sdk 命令遥测。其版本随这项重要的隐私声明一同变更,使每个 profile 都确认当前文案。
|
[CLI reference README](../../../../apps/cli/reference/README.md) 记录了这一部署口径:会话日志上传默认关闭,`DSH_TELEMETRY_MODE=FEEDBACK_ONLY` 和 `DSH_TELEMETRY_MODE=FULL` 是两种显式启用选项,显式开启后的导出可能包含完整会话内容。自[移除首次启动内测声明](../simplification/2026-08-13-remove-first-run-beta-notice.md)起,产品本身不提供任何关于开启遥测的提示。
|
||||||
|
|
||||||
## 考虑过的替代方案
|
## 考虑过的替代方案
|
||||||
|
|
||||||
|
|||||||
+6
@@ -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/simplification/2026-08-13-remove-first-run-beta-notice.md
|
||||||
|
2026-08-13-remove-first-run-beta-notice.md: 69f47be445ebfa9f10e6ba1217e4674a98c747f1
|
||||||
|
2026-08-13-remove-first-run-beta-notice.zh.md: 51c91918baaf1ae3db28434d7d86093fbd5ffb4e
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Agent Note: Remove the first-run beta notice
|
||||||
|
|
||||||
|
Status: implemented
|
||||||
|
|
||||||
|
English | [中文](2026-08-13-remove-first-run-beta-notice.zh.md)
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Every GUI first launch opened with a full-viewport internal-test statement (内测声明): internal-beta framing plus instructions for enabling Session Log upload through `DSH_TELEMETRY_MODE`. Session telemetry already resolves to `DISABLED` when its mode is unset ([telemetry default-off](../feature/2026-08-10-telemetry-default-off.md)), so the only onboarding content about telemetry was a prompt explaining how to turn it on, and the internal-test framing itself must not ship in a release build.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
The first-run notice is removed from the assembled product rather than reworded. `ui-settings-general` seats no `settings.onboarding` step; the notice component, its durable acknowledgement store, its copy owner, and its locale keys are deleted. The `settings.onboarding` coordinator and its takeover stage stay ([ordered onboarding](../feature/2026-07-30-versioned-gui-welcome-onboarding.md)), and the conditional DeepSeek credential step is the only shipped occupant. The Host half still registers the `ui-onboarding` settings namespace: its `welcomeNoticeVersion` field keeps acknowledgements already stored in `$DSH_HOME/settings.yaml` valid, and nothing reads or writes it. Telemetry opt-in remains an explicit deployment environment choice documented in the [CLI reference README](../../../../apps/cli/reference/README.md); the product presents no prompt about enabling it.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
**Keep the notice and only drop its telemetry paragraph.** Rejected: the internal-test framing is what a release must not present, and a mandatory first-run interstitial with no material statement left is pure friction.
|
||||||
|
|
||||||
|
**Ask for upload consent instead (a versioned consent step).** Rejected for this release: a first-run question about enabling upload is still a telemetry prompt. A future consent flow can register through the unchanged `settings.onboarding` seam and use a fresh versioned field for re-acknowledgement.
|
||||||
|
|
||||||
|
**Deregister the `ui-onboarding` namespace as well.** Rejected: existing settings documents already carry the section, and the settings seam validates stored documents against registered namespaces; keeping the registration keeps those documents valid at no cost.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
A fresh profile boots into the credential step when the DeepSeek credential is missing and directly into the product otherwise; no full-viewport notice precedes either. The assembled onboarding scenario starts at the credential step, the remote-notice scenario is deleted with the feature, and the goal-bar fixture keeps the settings shell disabled because the fixture API client rejects settings traffic. Restoring a first-run notice requires a new onboarding registration and a new versioned field; the retained namespace does not resurrect the old acknowledgement semantics.
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
# Agent Note: 移除首次启动内测声明
|
||||||
|
|
||||||
|
Status: implemented
|
||||||
|
|
||||||
|
[English](2026-08-13-remove-first-run-beta-notice.md) | 中文
|
||||||
|
|
||||||
|
## 问题
|
||||||
|
|
||||||
|
GUI 每次首启都会先显示占满视口的内测声明:内部测试的定位表述,加上通过 `DSH_TELEMETRY_MODE` 开启 Session Log 上传的说明。会话遥测在 mode 未设置时已解析为 `DISABLED`([遥测默认关闭](../feature/2026-08-10-telemetry-default-off.md)),因此引导流程中关于遥测的全部内容就是一段教用户如何开启的提示,而内部测试的定位表述本身也不应出现在发布版本里。
|
||||||
|
|
||||||
|
## 决策
|
||||||
|
|
||||||
|
首启声明从组装后的产品中整体移除,而不是改写。`ui-settings-general` 不再注册任何 `settings.onboarding` 步骤;声明组件、其持久化确认 store、文案所有者文件和 locale 键全部删除。`settings.onboarding` 协调器及其接管式展示阶段保留([有序引导](../feature/2026-07-30-versioned-gui-welcome-onboarding.md)),按条件显示的 DeepSeek 凭据步骤是当前唯一的注册方。宿主端仍注册 `ui-onboarding` 设置 namespace:其中的 `welcomeNoticeVersion` 字段让 `$DSH_HOME/settings.yaml` 中已写入的确认记录保持有效,没有任何代码读取或写入它。遥测的开启仍是显式的部署环境变量选择,记录在 [CLI reference README](../../../../apps/cli/reference/README.md) 中;产品界面不出现任何关于开启遥测的提示。
|
||||||
|
|
||||||
|
## 曾考虑的替代方案
|
||||||
|
|
||||||
|
**保留声明,只删除其中的遥测段落。** 不予采用:发布版本不应呈现的正是内部测试的定位表述本身,而一个没有实质内容的强制首启插页只剩下打扰。
|
||||||
|
|
||||||
|
**改为询问上传同意(版本化的同意步骤)。** 本次发布不予采用:首启询问是否开启上传仍然是一个遥测提示。未来的同意流程可以通过保持不变的 `settings.onboarding` seam 注册,并使用新的版本化字段做重新确认。
|
||||||
|
|
||||||
|
**连 `ui-onboarding` namespace 一起注销。** 不予采用:既有设置文档已经包含该分节,而设置 seam 会用已注册的 namespace 校验存储文档;保留注册就能让这些文档继续有效,且没有额外成本。
|
||||||
|
|
||||||
|
## 后果
|
||||||
|
|
||||||
|
全新 profile 在缺少 DeepSeek 凭据时直接进入凭据步骤,凭据已配置时直接进入产品,两种情况都没有占满视口的声明。组装级引导场景从凭据步骤开始,远程声明场景随功能一并删除,goal-bar fixture 继续禁用设置外壳,因为 fixture API 客户端会拒绝设置请求。将来若要恢复首启声明,需要新的引导注册和新的版本化字段;保留的 namespace 不会复活旧的确认语义。
|
||||||
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||||
# after editing either side, bring the other along and re-record with:
|
# after editing either side, bring the other along and re-record with:
|
||||||
# pnpm run verify-translation-pairing --write apps/web/tests/README.md
|
# pnpm run verify-translation-pairing --write apps/web/tests/README.md
|
||||||
README.md: 68e5db5af5f816cc982bacb7989d996c859be204
|
README.md: 4d7236ef69189845dbc2847289d497fc54c94ae9
|
||||||
README.zh.md: f366c28024dab89d0243a60d93a706f220fa8fb8
|
README.zh.md: 21c2f2ce7ccc8fe5314d766c06235453aa292031
|
||||||
@@ -29,9 +29,8 @@ artifact it produces itself.
|
|||||||
|
|
||||||
When a scenario needs a Client-owned constant or pure function, mirror it here
|
When a scenario needs a Client-owned constant or pure function, mirror it here
|
||||||
instead, next to the commented-out import that names the source module. A drift
|
instead, next to the commented-out import that names the source module. A drift
|
||||||
then surfaces as a missed selector or an unsuppressed notice — a loud failure,
|
then surfaces as a missed selector or a stale mirrored value — a loud failure,
|
||||||
never a silent pass. `scaffold.ts` holds the mirrored welcome-notice values and
|
never a silent pass.
|
||||||
exports them for the scenarios that assert on them.
|
|
||||||
|
|
||||||
Two kinds of Client import stand. `assembled-boot.ts` drives the shell itself, so
|
Two kinds of Client import stand. `assembled-boot.ts` drives the shell itself, so
|
||||||
it imports `AppWebEntry` from `@deepseek-ai/dsh-client-web` and the boot-manifest
|
it imports `AppWebEntry` from `@deepseek-ai/dsh-client-web` and the boot-manifest
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ Client face,而该 face 必须等 Host tsdown 生成 `@deepseek-ai/dsh-goal/re
|
|||||||
于是 Host 构建阶段变成在等一个由它自己产出的产物。
|
于是 Host 构建阶段变成在等一个由它自己产出的产物。
|
||||||
|
|
||||||
当某个场景需要 Client 持有的常量或纯函数时,改为在此处镜像一份,并紧挨着一条注释掉的
|
当某个场景需要 Client 持有的常量或纯函数时,改为在此处镜像一份,并紧挨着一条注释掉的
|
||||||
import 点明源模块。这样漂移会表现为选择器未命中或提示未被抑制——是响亮的失败,绝不会是静默
|
import 点明源模块。这样漂移会表现为选择器未命中或镜像值过期——是响亮的失败,绝不会是静默
|
||||||
通过。`scaffold.ts` 持有镜像的 welcome-notice 取值,并导出给断言它们的场景使用。
|
通过。
|
||||||
|
|
||||||
有两类 Client import 是长期成立的。`assembled-boot.ts` 驱动 shell 本身,因此它从
|
有两类 Client import 是长期成立的。`assembled-boot.ts` 驱动 shell 本身,因此它从
|
||||||
`@deepseek-ai/dsh-client-web` import `AppWebEntry`、从
|
`@deepseek-ai/dsh-client-web` import `AppWebEntry`、从
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ describe('web e2e: goal bar clear convergence', () => {
|
|||||||
let tripwire: ReturnType<typeof watchConsole>
|
let tripwire: ReturnType<typeof watchConsole>
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY, welcomeNoticePending: true })
|
scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY })
|
||||||
browser = await chromium.launch()
|
browser = await chromium.launch()
|
||||||
page = await newEnglishPage(browser)
|
page = await newEnglishPage(browser)
|
||||||
tripwire = watchConsole(page)
|
tripwire = watchConsole(page)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# The client-side FixtureApiClient intentionally rejects settings writes, so
|
# The client-side FixtureApiClient intentionally rejects settings traffic, so
|
||||||
# this goal-only scenario omits the durable welcome step that would otherwise
|
# this goal-only scenario omits the settings shell and the onboarding steps it
|
||||||
# cover the page. Onboarding owns separate assembled-browser coverage.
|
# would mount. Onboarding owns separate assembled-browser coverage.
|
||||||
- id: ui-settings-general
|
- id: ui-settings-general
|
||||||
disabled: true
|
disabled: true
|
||||||
@@ -10,15 +10,11 @@ import { chromium } from 'playwright'
|
|||||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||||
import {
|
import {
|
||||||
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
|
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
|
||||||
WELCOME_NOTICE_SETTINGS_NAMESPACE, WELCOME_NOTICE_ACK_FIELD,
|
|
||||||
WELCOME_NOTICE_VERSION, WELCOME_NOTICE_COPY,
|
|
||||||
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
|
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
|
||||||
} from './scaffold.ts'
|
} from './scaffold.ts'
|
||||||
import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts'
|
import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts'
|
||||||
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
|
||||||
|
|
||||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/onboarding-deepseek-config', import.meta.url))
|
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/onboarding-deepseek-config', import.meta.url))
|
||||||
const WELCOME_EXPECTED = join(SNAPSHOT_DIR, 'welcome.expected.md')
|
|
||||||
const MISSING_EXPECTED = join(SNAPSHOT_DIR, 'missing.expected.md')
|
const MISSING_EXPECTED = join(SNAPSHOT_DIR, 'missing.expected.md')
|
||||||
const MODELS_EXPECTED = join(SNAPSHOT_DIR, 'models.expected.md')
|
const MODELS_EXPECTED = join(SNAPSHOT_DIR, 'models.expected.md')
|
||||||
const MODE = webSnapshotMode()
|
const MODE = webSnapshotMode()
|
||||||
@@ -31,7 +27,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
|
|||||||
const browserConsole: string[] = []
|
const browserConsole: string[] = []
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
scaffold = await launchWebScaffold({ deepSeekMissingCredential: true, welcomeNoticePending: true })
|
scaffold = await launchWebScaffold({ deepSeekMissingCredential: true })
|
||||||
browser = await chromium.launch()
|
browser = await chromium.launch()
|
||||||
// The scenario asserts the shipped Chinese copy, so the browser asks for it.
|
// The scenario asserts the shipped Chinese copy, so the browser asks for it.
|
||||||
page = await browser.newPage({ viewport: { width: 1440, height: 960 }, locale: ZH_BROWSER_LOCALE })
|
page = await browser.newPage({ viewport: { width: 1440, height: 960 }, locale: ZH_BROWSER_LOCALE })
|
||||||
@@ -48,13 +44,9 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
|
|||||||
|
|
||||||
it('stores a key write-only and observes configured state without restarting', async () => {
|
it('stores a key write-only and observes configured state without restarting', async () => {
|
||||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-deepseek-config'))
|
onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-deepseek-config'))
|
||||||
const welcome = page.getByRole('region', { name: WELCOME_NOTICE_COPY.zh.title })
|
const credentialStep = page.getByRole('region', { name: '添加一个 API Key 开始使用' })
|
||||||
await welcome.waitFor({ timeout: 15_000 })
|
await credentialStep.waitFor({ timeout: 15_000 })
|
||||||
expect(await page.locator('#root').evaluate(root => (root as HTMLElement).inert)).toBe(true)
|
expect(await page.locator('#root').evaluate(root => (root as HTMLElement).inert)).toBe(true)
|
||||||
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 mask = page.locator('[class*="onboardingMask"]')
|
const mask = page.locator('[class*="onboardingMask"]')
|
||||||
expect(await mask.count()).toBe(1)
|
expect(await mask.count()).toBe(1)
|
||||||
@@ -83,17 +75,6 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
|
|||||||
rect: { left: 0, top: 80, right: 1440, bottom: 960 },
|
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 credentialStep = page.getByRole('region', { name: '添加一个 API Key 开始使用' })
|
|
||||||
await credentialStep.waitFor({ timeout: 15_000 })
|
|
||||||
expect(await credentialStep.getByRole('textbox').count()).toBe(0)
|
expect(await credentialStep.getByRole('textbox').count()).toBe(0)
|
||||||
const initial = await captureStableAria(page, '[role="region"]', scaffold.workspaceCwd)
|
const initial = await captureStableAria(page, '[role="region"]', scaffold.workspaceCwd)
|
||||||
await compareOrRefreshGolden(MISSING_EXPECTED, initial, MODE)
|
await compareOrRefreshGolden(MISSING_EXPECTED, initial, MODE)
|
||||||
@@ -129,27 +110,10 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
|
|||||||
{ timeout: 10_000 },
|
{ timeout: 10_000 },
|
||||||
).toBe('已配置——输入新值可替换')
|
).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
|
const secondReloadWarnings = tripwire.warnings.length
|
||||||
await page.reload({ waitUntil: 'load' })
|
await page.reload({ waitUntil: 'load' })
|
||||||
acknowledgeReloadConnectionLoss(tripwire, secondReloadWarnings)
|
acknowledgeReloadConnectionLoss(tripwire, secondReloadWarnings)
|
||||||
await page.waitForSelector('[class*="frame"]', { timeout: 15_000 })
|
await page.waitForSelector('[class*="frame"]', { timeout: 15_000 })
|
||||||
expect(await page.getByRole('region', { name: 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.
|
|
||||||
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('region', { 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.content()).includes(secret)).toBe(false)
|
||||||
@@ -161,8 +125,8 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
|
|||||||
|
|
||||||
it('never paints the takeover chrome on a configured reload, even with the settings join held open', async () => {
|
it('never paints the takeover chrome on a configured reload, even with the settings join held open', async () => {
|
||||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-configured-reload'))
|
onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-configured-reload'))
|
||||||
// Regression pin for the reload white flash: both steps are satisfied
|
// Regression pin for the reload white flash: the credential step is
|
||||||
// (welcome acknowledged, credential configured), yet each must LOAD its
|
// satisfied (credential configured), yet it must LOAD its
|
||||||
// private join before it can decide not to show. The chrome lives inside
|
// private join before it can decide not to show. The chrome lives inside
|
||||||
// the step (OnboardingSurface), so the deciding window paints and blocks
|
// the step (OnboardingSurface), so the deciding window paints and blocks
|
||||||
// nothing. Holding settings.describe widens that window from loopback
|
// nothing. Holding settings.describe widens that window from loopback
|
||||||
@@ -214,7 +178,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
|
|||||||
it('configures arbitrary DeepSeek models and prompts after the selected model is removed', async () => {
|
it('configures arbitrary DeepSeek models and prompts after the selected model is removed', async () => {
|
||||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-deepseek-models'))
|
onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-deepseek-models'))
|
||||||
// Opened here rather than inherited: the credential test reloads the page
|
// Opened here rather than inherited: the credential test reloads the page
|
||||||
// to exercise the welcome step, so nothing carries an open dialog across.
|
// after configuring the key, so nothing carries an open dialog across.
|
||||||
await page.getByRole('button', { name: '设置', exact: true }).click()
|
await page.getByRole('button', { name: '设置', exact: true }).click()
|
||||||
const settings = page.getByRole('dialog', { name: '设置' })
|
const settings = page.getByRole('dialog', { name: '设置' })
|
||||||
await settings.waitFor({ timeout: 10_000 })
|
await settings.waitFor({ timeout: 10_000 })
|
||||||
@@ -264,7 +228,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
|
|||||||
it('keeps the fixture inventory closed', async () => {
|
it('keeps the fixture inventory closed', async () => {
|
||||||
await assertFixtureInventory(
|
await assertFixtureInventory(
|
||||||
SNAPSHOT_DIR,
|
SNAPSHOT_DIR,
|
||||||
['missing.expected.md', 'models.expected.md', 'welcome.expected.md'],
|
['missing.expected.md', 'models.expected.md'],
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
// Trusted non-loopback Web access must not wedge on the loopback-only
|
|
||||||
// settings API while the mandatory product notice owns the viewport.
|
|
||||||
import type { Browser, Page } from 'playwright'
|
|
||||||
import { chromium } from 'playwright'
|
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
|
||||||
import {
|
|
||||||
acknowledgeReloadConnectionLoss, launchWebScaffold, watchConsole, webSnapshotMode,
|
|
||||||
WELCOME_NOTICE_COPY,
|
|
||||||
type WebScaffold,
|
|
||||||
} from './scaffold.ts'
|
|
||||||
import { ZH_BROWSER_LOCALE } from './support.ts'
|
|
||||||
|
|
||||||
const MODE = webSnapshotMode()
|
|
||||||
|
|
||||||
describe.skipIf(MODE === 'record')('web e2e: remote welcome notice', () => {
|
|
||||||
let scaffold: WebScaffold
|
|
||||||
let browser: Browser
|
|
||||||
let page: Page
|
|
||||||
let tripwire: ReturnType<typeof watchConsole>
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
scaffold = await launchWebScaffold({ remoteAuthority: 'remote.localhost', welcomeNoticePending: true })
|
|
||||||
browser = await chromium.launch()
|
|
||||||
page = await browser.newPage({ viewport: { width: 1440, height: 960 }, locale: ZH_BROWSER_LOCALE })
|
|
||||||
tripwire = watchConsole(page)
|
|
||||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
|
||||||
await page.waitForSelector('#root', { timeout: 30_000 })
|
|
||||||
}, 120_000)
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await browser?.close()
|
|
||||||
await scaffold?.close()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('advances process-locally and presents the notice again after reload', async () => {
|
|
||||||
const welcome = page.getByRole('region', { name: WELCOME_NOTICE_COPY.zh.title })
|
|
||||||
await welcome.waitFor({ timeout: 15_000 })
|
|
||||||
expect(await page.locator('#root').evaluate(root => (root as HTMLElement).inert)).toBe(true)
|
|
||||||
|
|
||||||
await welcome.getByRole('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel }).click()
|
|
||||||
await welcome.waitFor({ state: 'detached', timeout: 15_000 })
|
|
||||||
await expect.poll(
|
|
||||||
() => page.locator('#root').evaluate(root => (root as HTMLElement).inert),
|
|
||||||
{ timeout: 15_000 },
|
|
||||||
).toBe(false)
|
|
||||||
const reloadWarnings = tripwire.warnings.length
|
|
||||||
await page.reload({ waitUntil: 'load' })
|
|
||||||
acknowledgeReloadConnectionLoss(tripwire, reloadWarnings)
|
|
||||||
await welcome.waitFor({ timeout: 15_000 })
|
|
||||||
expect(tripwire.warnings).toEqual([])
|
|
||||||
expect(tripwire.pageErrors).toEqual([])
|
|
||||||
}, 60_000)
|
|
||||||
})
|
|
||||||
@@ -41,20 +41,6 @@ import {
|
|||||||
loadOverlayPatches,
|
loadOverlayPatches,
|
||||||
} from '@deepseek-ai/dsh-app-boot'
|
} from '@deepseek-ai/dsh-app-boot'
|
||||||
import { dshHomePath } from '@deepseek-ai/dsh-home-paths'
|
import { dshHomePath } from '@deepseek-ai/dsh-home-paths'
|
||||||
// Client packages must not be imported here: these e2e type-check in the Host
|
|
||||||
// aggregate, so a Client import pulls that package's whole project — and every
|
|
||||||
// project it references — into the Host build graph. Mirrored from
|
|
||||||
// packages/client/ui-settings-general/src/onboarding-copy.ts; a drift makes the
|
|
||||||
// pre-acknowledgement stop suppressing the notice, which fails loudly.
|
|
||||||
// import {
|
|
||||||
// WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE, WELCOME_NOTICE_VERSION, WELCOME_NOTICE_COPY,
|
|
||||||
// } from '@deepseek-ai/dsh-client-ui-settings-general'
|
|
||||||
export const WELCOME_NOTICE_SETTINGS_NAMESPACE = 'ui-onboarding'
|
|
||||||
export const WELCOME_NOTICE_ACK_FIELD = 'welcomeNoticeVersion'
|
|
||||||
export const WELCOME_NOTICE_VERSION = '2026-08-11.1'
|
|
||||||
export const WELCOME_NOTICE_COPY = { zh: { title: '内测声明', continueLabel: '继续' } } as const
|
|
||||||
|
|
||||||
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
|
||||||
import { LlmAdapter } from '@deepseek-ai/dsh-llm'
|
import { LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||||
import type {
|
import type {
|
||||||
LlmModelInfo, LlmProviderInfo, LlmResolvedModelInfo, StreamChunk,
|
LlmModelInfo, LlmProviderInfo, LlmResolvedModelInfo, StreamChunk,
|
||||||
@@ -252,8 +238,6 @@ export interface LaunchOptions {
|
|||||||
/** The preset a session that names none is composed from. */
|
/** The preset a session that names none is composed from. */
|
||||||
default: string
|
default: string
|
||||||
}
|
}
|
||||||
/** Leave the current welcome notice unacknowledged; ordinary scenarios publish it as complete before browser boot. */
|
|
||||||
welcomeNoticePending?: boolean
|
|
||||||
/**
|
/**
|
||||||
* Mount the shipped telemetry row in FULL mode against this exporter URL
|
* Mount the shipped telemetry row in FULL mode against this exporter URL
|
||||||
* instead of disabling it. Used to pin a real backend disclosure in
|
* instead of disabling it. Used to pin a real backend disclosure in
|
||||||
@@ -526,11 +510,6 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
|||||||
})
|
})
|
||||||
await ctx.loader.await()
|
await ctx.loader.await()
|
||||||
assertEntriesLoaded(ctx, 'web e2e scaffold')
|
assertEntriesLoaded(ctx, 'web e2e scaffold')
|
||||||
if (options.welcomeNoticePending !== true) {
|
|
||||||
await ctx.settings.mutate(settingsNamespace(WELCOME_NOTICE_SETTINGS_NAMESPACE), [{
|
|
||||||
op: 'set', path: [WELCOME_NOTICE_ACK_FIELD], value: WELCOME_NOTICE_VERSION,
|
|
||||||
}])
|
|
||||||
}
|
|
||||||
const boundPort = ctx.get('webServer')?.port
|
const boundPort = ctx.get('webServer')?.port
|
||||||
if (boundPort === undefined) {
|
if (boundPort === undefined) {
|
||||||
throw new Error('web e2e scaffold: webServer service missing after settled boot')
|
throw new Error('web e2e scaffold: webServer service missing after settled boot')
|
||||||
|
|||||||
@@ -533,16 +533,9 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
|
|||||||
|
|
||||||
it('empty-state first send completes a real model round', async () => {
|
it('empty-state first send completes a real model round', async () => {
|
||||||
onTestFailed(() => saveFailureShot(page, 'w5-first-round'))
|
onTestFailed(() => saveFailureShot(page, 'w5-first-round'))
|
||||||
// This scenario spawns its own server against a fresh $DSH_HOME, so the
|
// This scenario spawns its own server against a fresh $DSH_HOME with the
|
||||||
// first-run welcome notice is unacknowledged and its overlay owns pointer
|
// DeepSeek credential inherited from the environment, so no onboarding
|
||||||
// events (the shared scaffold acknowledges it before boot instead). The
|
// step mounts and the page is immediately interactive.
|
||||||
// notice is anchored structurally, not by its copy: this spec sits in the
|
|
||||||
// client TypeScript program, which does not reference the package that
|
|
||||||
// owns the strings.
|
|
||||||
const welcome = page.locator('[class*="onboardingOverlay"]')
|
|
||||||
await welcome.waitFor({ timeout: 15_000 })
|
|
||||||
await welcome.getByRole('button').click()
|
|
||||||
await welcome.waitFor({ state: 'detached', timeout: 15_000 })
|
|
||||||
// Fresh world: connect a Workspace so the composer starts live.
|
// Fresh world: connect a Workspace so the composer starts live.
|
||||||
await connectFreshWorkspace(page, sessionsDir)
|
await connectFreshWorkspace(page, sessionsDir)
|
||||||
const input = page.locator('textarea').first()
|
const input = page.locator('textarea').first()
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
- region "内测声明":
|
|
||||||
- heading "内测声明" [level=2]
|
|
||||||
- paragraph: 感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部测试阶段,功能仍待完善,体验难免有些粗糙。
|
|
||||||
- blockquote: “如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的设计。
|
|
||||||
- paragraph:
|
|
||||||
- text: 内测版本默认不会上传 Session Log。如需在提交反馈时共享会话日志,可以设置环境变量 DSH_TELEMETRY_MODE=FEEDBACK_ONLY;如需持续上传,可以设置 DSH_TELEMETRY_MODE=FULL,但该模式同时会启用 dsh-sdk 命令遥测,上报匿名 ID、命令结果以及脱敏后的项目配置。另外,
|
|
||||||
- strong: 如果您有任何反馈与建议,请在企业微信群中留言告诉我们
|
|
||||||
- text: 。每一条反馈,都会帮助我们把它打磨得更好。
|
|
||||||
- button "继续"
|
|
||||||
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||||
# after editing either side, bring the other along and re-record with:
|
# after editing either side, bring the other along and re-record with:
|
||||||
# pnpm run verify-translation-pairing --write packages/client/ui-settings-general/README.md
|
# pnpm run verify-translation-pairing --write packages/client/ui-settings-general/README.md
|
||||||
README.md: d02230d281482d03545a7dd9bb06fd5f1085d017
|
README.md: 6669442f322ee38f14fff45e4344b59dffb3d61a
|
||||||
README.zh.md: 9e2011902227c8d656f57813d4ecec92147d0f6f
|
README.zh.md: 53d006dd521fbc69e83c91914418bf98f0342bf9
|
||||||
@@ -2,13 +2,13 @@
|
|||||||
|
|
||||||
English | [中文](README.zh.md)
|
English | [中文](README.zh.md)
|
||||||
|
|
||||||
Settings shell, ownerless-copy, and product-onboarding plugin. It occupies `sidebar.settings` with the trigger chrome and modal settings panel, projects the `settings.section` ledger into the navigation and the `settings.onboarding` ledger into one mounted page at a time, and registers everything on the Settings pages that belongs to no single feature — the trigger/header/close chrome content, the local configuration-file action, the General section and its `settings.general.item` slot, the `settings` dictionaries, and the first ordered welcome step. The slot types it renders into belong to ui-settings, the settings domain base; only the shell's own contract types live here, because they reference ui-sidebar's slot type and the base layer must depend on no `ui-*` package. Feature-owned rows (Permission, Language, Appearance), sections (Models), and conditional onboarding steps stay with their feature packages.
|
Settings shell, ownerless-copy, and product-onboarding plugin. It occupies `sidebar.settings` with the trigger chrome and modal settings panel, projects the `settings.section` ledger into the navigation and the `settings.onboarding` ledger into one mounted page at a time, and registers everything on the Settings pages that belongs to no single feature — the trigger/header/close chrome content, the local configuration-file action, the General section and its `settings.general.item` slot, and the `settings` dictionaries. The slot types it renders into belong to ui-settings, the settings domain base; only the shell's own contract types live here, because they reference ui-sidebar's slot type and the base layer must depend on no `ui-*` package. Feature-owned rows (Permission, Language, Appearance), sections (Models), and conditional onboarding steps stay with their feature packages.
|
||||||
|
|
||||||
The shell ships no copy of its own — all text arrives from registrants. Nav labels may be locale-following thunks, so the nav projection resolves them through `resolveSlotLabel` and re-renders on the section ledger bump or the locale revision (an optional `ctx.get('locale')` read; no hard locale dependency). The onboarding ledger projects in ascending order and mounts exactly one page at a time; the takeover chrome (body-level stage, mask, app-root `inert`) belongs to the step itself through ui-primitives' `OnboardingSurface`, so a mounted step still resolving its private facts renders null and neither paints nor blocks anything — the shell shows no empty stage while a step decides. The active registrant receives its id, `complete()`, and an `openSection(id)` callback; completing or skipping transfers ownership to the next entry. Registrants own durable completion, capability readiness, copy, mutations, and the surface wrap, so independently registered flows cannot stack and the shell does not become a second configuration fact source.
|
The shell ships no copy of its own — all text arrives from registrants. Nav labels may be locale-following thunks, so the nav projection resolves them through `resolveSlotLabel` and re-renders on the section ledger bump or the locale revision (an optional `ctx.get('locale')` read; no hard locale dependency). The onboarding ledger projects in ascending order and mounts exactly one page at a time; the takeover chrome (body-level stage, mask, app-root `inert`) belongs to the step itself through ui-primitives' `OnboardingSurface`, so a mounted step still resolving its private facts renders null and neither paints nor blocks anything — the shell shows no empty stage while a step decides. The active registrant receives its id, `complete()`, and an `openSection(id)` callback; completing or skipping transfers ownership to the next entry. Registrants own durable completion, capability readiness, copy, mutations, and the surface wrap, so independently registered flows cannot stack and the shell does not become a second configuration fact source.
|
||||||
|
|
||||||
A loopback browser loads the provider's `hasDocument` capability through `settings.describe` and renders **Open configuration file** only when the Host confirms that a provider-owned local document can be prepared. The action sends the pathless, loopback-only `settings.openDocument` request; the Host resolves the provider path again, materializes an absent document, and hands it to a native text editor (`open -t` on macOS, bypassing a browser file association; the desktop file association on Linux and Windows; Windows association after `wslpath -w` translation on WSL). Open failures keep the action available and render a localized error. Reopening the dialog or reconnecting refreshes availability after a transient read failure or Host topology change. Remote browsers never register the action and never issue the privileged settings read.
|
A loopback browser loads the provider's `hasDocument` capability through `settings.describe` and renders **Open configuration file** only when the Host confirms that a provider-owned local document can be prepared. The action sends the pathless, loopback-only `settings.openDocument` request; the Host resolves the provider path again, materializes an absent document, and hands it to a native text editor (`open -t` on macOS, bypassing a browser file association; the desktop file association on Linux and Windows; Windows association after `wslpath -w` translation on WSL). Open failures keep the action available and render a localized error. Reopening the dialog or reconnecting refreshes availability after a transient read failure or Host topology change. Remote browsers never register the action and never issue the privileged settings read.
|
||||||
|
|
||||||
`src/onboarding-copy.ts` is the single editable owner of the complete notice plus `WELCOME_NOTICE_VERSION`; both supported GUI locales intentionally render the same Chinese copy. The Host half registers `ui-onboarding` in the user-settings seam. A loopback browser compares `welcomeNoticeVersion` for exact equality and writes the current value only after Continue succeeds. The path mutation is idempotent across tabs and preserves sibling settings, while `host/settings-changed` makes an externally acknowledged notice advance without a reload. A non-loopback browser cannot access the privileged settings API: it still presents the notice, but Continue advances only the current browser process and a reload presents the notice again. A different version deliberately presents the notice again. The welcome page preserves every authored paragraph, gives the requested clause in the final paragraph the sole emphasis, initially focuses the title, and has no close, Escape, mask-click, or secondary path. None of its copy or acknowledgement enters a Session log or model request. The notice states that session telemetry is disabled by default, names the `FEEDBACK_ONLY` and `FULL` opt-in modes, and discloses that `FULL` also enables dsh-sdk command telemetry.
|
The Host half registers `ui-onboarding` in the user-settings seam. Its `welcomeNoticeVersion` field keeps acknowledgements already present in stored settings documents valid and has no reader; no shipped step writes it.
|
||||||
|
|
||||||
## Model Experience
|
## Model Experience
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,13 @@
|
|||||||
|
|
||||||
[English](README.md) | 中文
|
[English](README.md) | 中文
|
||||||
|
|
||||||
设置外壳、无特定功能归属文案与产品引导插件。它以触发控件和模态设置面板占用 `sidebar.settings`,把 `settings.section` 账本投影成导航、把 `settings.onboarding` 账本投影成每次只挂载一页的引导流程,并在设置页面上注册所有不属于单一功能的内容:触发器、标题栏与关闭控件内容、本地配置文件操作,「通用」分区及其 `settings.general.item` slot、`settings` 字典,以及第一个有序欢迎步骤。它渲染进的那些 slot 类型归 ui-settings——设置领域底座——所有;只有外壳自身的契约类型放在这里,因为它们引用 ui-sidebar 的 slot 类型,而底座不得依赖任何 `ui-*` 包。归具体功能所有的行(「权限」、「语言」、「外观」)、分区(「模型」)和条件式首次使用引导步骤仍由各自的功能包提供。
|
设置外壳、无特定功能归属文案与产品引导插件。它以触发控件和模态设置面板占用 `sidebar.settings`,把 `settings.section` 账本投影成导航、把 `settings.onboarding` 账本投影成每次只挂载一页的引导流程,并在设置页面上注册所有不属于单一功能的内容:触发器、标题栏与关闭控件内容、本地配置文件操作,「通用」分区及其 `settings.general.item` slot,以及 `settings` 字典。它渲染进的那些 slot 类型归 ui-settings——设置领域底座——所有;只有外壳自身的契约类型放在这里,因为它们引用 ui-sidebar 的 slot 类型,而底座不得依赖任何 `ui-*` 包。归具体功能所有的行(「权限」、「语言」、「外观」)、分区(「模型」)和条件式首次使用引导步骤仍由各自的功能包提供。
|
||||||
|
|
||||||
外壳不自带文案:所有文本都来自注册方。导航 label 可以是跟随语言的 thunk,因此导航投影经 `resolveSlotLabel` 解析,并在分区账本更新或 locale revision 变化时重新渲染(`ctx.get('locale')` 可选读取,无硬 locale 依赖)。首次使用引导记录按升序投影,每次只挂载一个页面;接管界面框架(body 层级的展示层、遮罩、应用根节点 `inert`)经 ui-primitives 的 `OnboardingSurface` 由步骤自身持有,因此已挂载但仍在判定私有事实的步骤渲染 null 时不绘制也不阻塞任何内容——步骤判定期间外壳不会露出空白展示层。当前注册方会收到该条目的 id、`complete()` 和 `openSection(id)` 回调;完成或跳过当前页面后,所有权转交给下一项。持久化完成状态、能力就绪状态、文案、变更操作以及展示层包装均由注册方持有,因此独立注册的流程无法堆叠,外壳也不会成为第二个配置事实来源。
|
外壳不自带文案:所有文本都来自注册方。导航 label 可以是跟随语言的 thunk,因此导航投影经 `resolveSlotLabel` 解析,并在分区账本更新或 locale revision 变化时重新渲染(`ctx.get('locale')` 可选读取,无硬 locale 依赖)。首次使用引导记录按升序投影,每次只挂载一个页面;接管界面框架(body 层级的展示层、遮罩、应用根节点 `inert`)经 ui-primitives 的 `OnboardingSurface` 由步骤自身持有,因此已挂载但仍在判定私有事实的步骤渲染 null 时不绘制也不阻塞任何内容——步骤判定期间外壳不会露出空白展示层。当前注册方会收到该条目的 id、`complete()` 和 `openSection(id)` 回调;完成或跳过当前页面后,所有权转交给下一项。持久化完成状态、能力就绪状态、文案、变更操作以及展示层包装均由注册方持有,因此独立注册的流程无法堆叠,外壳也不会成为第二个配置事实来源。
|
||||||
|
|
||||||
回环浏览器通过 `settings.describe` 加载提供方的 `hasDocument` 能力,且只有在 Host 确认可准备好一份由提供方持有的本地文档时才渲染**打开配置文件**。该操作发送无路径参数且仅限回环访问的 `settings.openDocument` 请求;Host 会再次解析提供方路径、在文档缺失时将其创建出来,并交给原生文本编辑器(macOS 上使用 `open -t`,绕过浏览器文件关联;Linux 和 Windows 上使用桌面文件关联;WSL 上经 `wslpath -w` 转换后使用 Windows 文件关联)。打开失败时该操作仍可使用,并渲染本地化错误。临时读取失败或 Host 拓扑变化后,重新打开对话框或重新连接会刷新可用性。远程浏览器从不注册该操作,也从不发起这项特权设置读取。
|
回环浏览器通过 `settings.describe` 加载提供方的 `hasDocument` 能力,且只有在 Host 确认可准备好一份由提供方持有的本地文档时才渲染**打开配置文件**。该操作发送无路径参数且仅限回环访问的 `settings.openDocument` 请求;Host 会再次解析提供方路径、在文档缺失时将其创建出来,并交给原生文本编辑器(macOS 上使用 `open -t`,绕过浏览器文件关联;Linux 和 Windows 上使用桌面文件关联;WSL 上经 `wslpath -w` 转换后使用 Windows 文件关联)。打开失败时该操作仍可使用,并渲染本地化错误。临时读取失败或 Host 拓扑变化后,重新打开对话框或重新连接会刷新可用性。远程浏览器从不注册该操作,也从不发起这项特权设置读取。
|
||||||
|
|
||||||
`src/onboarding-copy.ts` 是完整通知文案和 `WELCOME_NOTICE_VERSION` 的唯一可编辑来源;GUI 支持的两种 locale 都有意渲染同一份中文文案。宿主端在用户设置 seam 中注册 `ui-onboarding`。回环浏览器会比较 `welcomeNoticeVersion` 是否精确相等,仅在「继续」操作成功后写入当前值。该路径变更在不同标签页间幂等,并会保留同级设置;`host/settings-changed` 则让页面在通知被外部确认后,无需重新加载即可推进。非回环浏览器不能访问受保护的设置 API:它仍会显示通知,但「继续」只推进当前浏览器进程,重新加载后会再次显示通知。版本不同时,系统也会有意重新显示通知。欢迎页保留原文的每个段落,仅强调最后一段中指定的句段,初始焦点落在标题上,并且没有关闭操作、Escape、点击遮罩或次要操作路径。其文案和确认状态均不会进入会话日志或模型请求。通知说明会话遥测默认禁用,列出 `FEEDBACK_ONLY` 和 `FULL` 两种显式启用模式,并披露 `FULL` 同时会启用 dsh-sdk 命令遥测。
|
宿主端在用户设置 seam 中注册 `ui-onboarding`。其中的 `welcomeNoticeVersion` 字段让既有设置文档中已写入的确认记录保持有效;它没有读取方,也没有任何已发布的引导步骤写入它。
|
||||||
|
|
||||||
## 模型体验
|
## 模型体验
|
||||||
|
|
||||||
|
|||||||
@@ -1,155 +0,0 @@
|
|||||||
.page {
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
width: min(640px, calc(100vw - 64px));
|
|
||||||
max-height: 100vh;
|
|
||||||
padding: clamp(64px, 9vh, 104px) 0 40px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
overflow-y: auto;
|
|
||||||
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: 28px;
|
|
||||||
line-height: 36px;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.opening,
|
|
||||||
.reflection,
|
|
||||||
.feedback,
|
|
||||||
.error {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.opening {
|
|
||||||
margin-top: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reflection {
|
|
||||||
margin-top: 36px;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feedback {
|
|
||||||
margin-top: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.opening,
|
|
||||||
.reflection,
|
|
||||||
.feedback {
|
|
||||||
font-size: 16px;
|
|
||||||
line-height: 28px;
|
|
||||||
color: var(--dsw-alias-label-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.feedback strong {
|
|
||||||
color: inherit;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
margin-top: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error {
|
|
||||||
margin-top: 20px;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 22px;
|
|
||||||
color: var(--dsw-alias-state-error-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.primary {
|
|
||||||
min-width: 120px;
|
|
||||||
transition: transform 140ms var(--welcome-ease-out);
|
|
||||||
}
|
|
||||||
|
|
||||||
.primary:active:not(:disabled) {
|
|
||||||
transform: scale(0.97);
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand,
|
|
||||||
.title,
|
|
||||||
.opening,
|
|
||||||
.reflection,
|
|
||||||
.feedback,
|
|
||||||
.footer {
|
|
||||||
animation: welcome-enter 280ms var(--welcome-ease-out) both;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title { animation-delay: 40ms; }
|
|
||||||
.opening { animation-delay: 80ms; }
|
|
||||||
.reflection { animation-delay: 120ms; }
|
|
||||||
.feedback { animation-delay: 160ms; }
|
|
||||||
.footer { animation-delay: 200ms; }
|
|
||||||
|
|
||||||
@keyframes welcome-enter {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(8px);
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.brand,
|
|
||||||
.title,
|
|
||||||
.opening,
|
|
||||||
.reflection,
|
|
||||||
.feedback,
|
|
||||||
.footer {
|
|
||||||
animation: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.primary {
|
|
||||||
transition: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 560px) {
|
|
||||||
.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 {
|
|
||||||
margin-top: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.primary {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
/** Product-wide, versioned first-run welcome step. */
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef } from 'react'
|
|
||||||
import type { ReactNode } from 'react'
|
|
||||||
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
|
||||||
import { BrandWordmark, Button, OnboardingSurface } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
||||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
|
|
||||||
import type { 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)}
|
|
||||||
<strong>{emphasis}</strong>
|
|
||||||
{paragraph.slice(index + emphasis.length)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Registrant-owned dependencies of {@link WelcomeNotice}. */
|
|
||||||
export interface WelcomeNoticeInjected {
|
|
||||||
controller: WelcomeNoticeStore
|
|
||||||
useSnapshot: SnapshotSelectorHook<WelcomeNoticeState>
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Coordinator owner props plus the welcome step's injected face. */
|
|
||||||
export type WelcomeNoticeProps =
|
|
||||||
PropsRuntime<'settings.onboarding'> & PropsLocale<'settings'> & WelcomeNoticeInjected
|
|
||||||
|
|
||||||
/** Render the mandatory notice until its current version is acknowledged. */
|
|
||||||
export function WelcomeNotice(props: WelcomeNoticeProps): ReactNode {
|
|
||||||
const { complete, controller, useSnapshot, t } = props
|
|
||||||
const state = useSnapshot(snapshot => snapshot)
|
|
||||||
const finished = useRef(false)
|
|
||||||
const titleRef = useRef<HTMLHeadingElement | null>(null)
|
|
||||||
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])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (state.status === 'ready' && !state.acknowledged) titleRef.current?.focus()
|
|
||||||
}, [state.acknowledged, state.status])
|
|
||||||
|
|
||||||
// Null while the acknowledgement fact is still loading (or already given):
|
|
||||||
// the takeover chrome below is part of THIS render, so deciding not to
|
|
||||||
// show paints and blocks nothing.
|
|
||||||
if (state.status === 'idle' || state.status === 'loading' || state.acknowledged) return null
|
|
||||||
|
|
||||||
const acknowledge = async (): Promise<void> => {
|
|
||||||
if (await controller.acknowledge()) finish()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<OnboardingSurface>
|
|
||||||
<section className={css.page} role="region" aria-labelledby="welcome-notice-title">
|
|
||||||
<div className={css.brand} aria-hidden="true"><BrandWordmark size={24} /></div>
|
|
||||||
<h2 ref={titleRef} id="welcome-notice-title" className={css.title} tabIndex={-1}>{t('welcome.title')}</h2>
|
|
||||||
<p className={css.opening}>{t('welcome.paragraph.0')}</p>
|
|
||||||
<blockquote className={css.reflection}>{t('welcome.paragraph.1')}</blockquote>
|
|
||||||
<p className={css.feedback}>
|
|
||||||
{emphasizedFeedback(t('welcome.paragraph.2'), t('welcome.feedbackEmphasis'))}
|
|
||||||
</p>
|
|
||||||
{state.error === null ? null : <p className={css.error} role="alert">{t('welcome.error')}</p>}
|
|
||||||
<div className={css.footer}>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
className={css.primary}
|
|
||||||
disabled={state.status === 'saving'}
|
|
||||||
onClick={() => { void acknowledge() }}
|
|
||||||
>
|
|
||||||
{t('welcome.continue')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</OnboardingSurface>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -17,9 +17,6 @@ import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
|||||||
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||||
// Type-only: pulls ctx.locale into this program.
|
// Type-only: pulls ctx.locale into this program.
|
||||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||||
// Type-only: pulls the ctx.remote merge and the forwarded-event key face
|
|
||||||
// (the settings invalidation rides the allowlist) into this program.
|
|
||||||
import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
|
||||||
import type {
|
import type {
|
||||||
SettingsOnboardingStep, SettingsRootInjected, SettingsSectionRow,
|
SettingsOnboardingStep, SettingsRootInjected, SettingsSectionRow,
|
||||||
} from './shell-contract.ts'
|
} from './shell-contract.ts'
|
||||||
@@ -29,10 +26,6 @@ import { GeneralSection } from './GeneralSection.tsx'
|
|||||||
import { SettingsDocumentAction } from './SettingsDocumentAction.tsx'
|
import { SettingsDocumentAction } from './SettingsDocumentAction.tsx'
|
||||||
import type { SettingsDocumentActionInjected } from './SettingsDocumentAction.tsx'
|
import type { SettingsDocumentActionInjected } from './SettingsDocumentAction.tsx'
|
||||||
import { refreshDocumentIfLoaded, SettingsDocumentStore } from './settings-document-store.ts'
|
import { refreshDocumentIfLoaded, SettingsDocumentStore } from './settings-document-store.ts'
|
||||||
import type { WelcomeNoticeInjected } from './WelcomeNotice.tsx'
|
|
||||||
import { WelcomeNotice } from './WelcomeNotice.tsx'
|
|
||||||
import { refreshWelcomeIfLoaded, WelcomeNoticeStore } from './welcome-store.ts'
|
|
||||||
import { WELCOME_NOTICE_SETTINGS_NAMESPACE } from '../onboarding-copy.ts'
|
|
||||||
import { en, zh, type SettingsKey } from './locales.ts'
|
import { en, zh, type SettingsKey } from './locales.ts'
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
@@ -44,8 +37,6 @@ export type {
|
|||||||
export type { SettingsDocumentActionInjected, SettingsDocumentActionProps } from './SettingsDocumentAction.tsx'
|
export type { SettingsDocumentActionInjected, SettingsDocumentActionProps } from './SettingsDocumentAction.tsx'
|
||||||
export type { SettingsDocumentState } from './settings-document-store.ts'
|
export type { SettingsDocumentState } from './settings-document-store.ts'
|
||||||
export { SettingsDocumentStore } from './settings-document-store.ts'
|
export { SettingsDocumentStore } from './settings-document-store.ts'
|
||||||
export type { WelcomeNoticeInjected, WelcomeNoticeProps } from './WelcomeNotice.tsx'
|
|
||||||
export type { WelcomeNoticeState } from './welcome-store.ts'
|
|
||||||
export type { SettingsKey } from './locales.ts'
|
export type { SettingsKey } from './locales.ts'
|
||||||
|
|
||||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||||
@@ -63,7 +54,7 @@ const NS = 'settings'
|
|||||||
* ui-settings' apply, whose activation order relative to this one is NOT
|
* ui-settings' apply, whose activation order relative to this one is NOT
|
||||||
* constrained; registrations depend on their slots through `slots.inject()`.
|
* constrained; registrations depend on their slots through `slots.inject()`.
|
||||||
*/
|
*/
|
||||||
export const inject = ['slots', 'locale', 'connection', 'remote']
|
export const inject = ['slots', 'locale', 'connection']
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register the `settings` dictionaries, the chrome content, and the General
|
* Register the `settings` dictionaries, the chrome content, and the General
|
||||||
@@ -87,27 +78,9 @@ export function apply(ctx: ClientContext): void {
|
|||||||
const useSnapshot = bindSnapshotSelector(documentController.store)
|
const useSnapshot = bindSnapshotSelector(documentController.store)
|
||||||
return (): SettingsDocumentActionInjected => ({ controller: documentController, useSnapshot })
|
return (): SettingsDocumentActionInjected => ({ controller: documentController, useSnapshot })
|
||||||
})()
|
})()
|
||||||
const welcomeController = new WelcomeNoticeStore(connection.api, connection.isLoopback ? 'host' : 'memory')
|
ctx.effect(() => ctx.on('connection/reset', () => {
|
||||||
const useWelcomeSnapshot = bindSnapshotSelector(welcomeController.store)
|
refreshDocumentIfLoaded(documentController)
|
||||||
const welcomeInjected = (): WelcomeNoticeInjected => ({
|
}), 'ui-settings-general: metadata invalidations')
|
||||||
controller: welcomeController,
|
|
||||||
useSnapshot: useWelcomeSnapshot,
|
|
||||||
})
|
|
||||||
|
|
||||||
ctx.effect(() => {
|
|
||||||
const refresh = (): void => { refreshWelcomeIfLoaded(welcomeController) }
|
|
||||||
const disposers = [
|
|
||||||
ctx.remote.$on('settings/document-updated', (ns) => {
|
|
||||||
if (ns !== WELCOME_NOTICE_SETTINGS_NAMESPACE) return
|
|
||||||
refresh()
|
|
||||||
}),
|
|
||||||
ctx.on('connection/reset', () => {
|
|
||||||
refresh()
|
|
||||||
refreshDocumentIfLoaded(documentController)
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
return () => { for (const dispose of disposers) dispose() }
|
|
||||||
}, 'ui-settings-general: metadata invalidations')
|
|
||||||
// The settings shell: this package occupies the sidebar-owned hole and
|
// The settings shell: this package occupies the sidebar-owned hole and
|
||||||
// declares the settings slots. Ledger → nav-row projection as an observable
|
// declares the settings slots. Ledger → nav-row projection as an observable
|
||||||
// source (uSES contract: getSnapshot returns the cached rows until the
|
// source (uSES contract: getSnapshot returns the cached rows until the
|
||||||
@@ -202,11 +175,4 @@ export function apply(ctx: ClientContext): void {
|
|||||||
locale: NS,
|
locale: NS,
|
||||||
children: { 'settings.general.item': { kind: 'list', scope: 'root' } },
|
children: { 'settings.general.item': { kind: 'list', scope: 'root' } },
|
||||||
}, GeneralSection))
|
}, GeneralSection))
|
||||||
ctx.slots.inject('settings.onboarding', () => ctx.slots.register({
|
|
||||||
name: 'settings.onboarding',
|
|
||||||
id: 'welcome-notice',
|
|
||||||
order: -100,
|
|
||||||
locale: NS,
|
|
||||||
inject: welcomeInjected,
|
|
||||||
}, WelcomeNotice))
|
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
/** Shell chrome, General-nav, and welcome-notice dictionaries; feature rows own their copy. */
|
/** Shell chrome and General-nav dictionaries; feature rows own their copy. */
|
||||||
import { WELCOME_NOTICE_COPY } from '../onboarding-copy.ts'
|
|
||||||
|
|
||||||
/** Simplified Chinese dictionary (the key-set source of truth). */
|
/** Simplified Chinese dictionary (the key-set source of truth). */
|
||||||
export const zh = {
|
export const zh = {
|
||||||
@@ -9,13 +8,6 @@ export const zh = {
|
|||||||
'openDocument': '打开配置文件',
|
'openDocument': '打开配置文件',
|
||||||
'openDocument.error': '无法打开配置文件',
|
'openDocument.error': '无法打开配置文件',
|
||||||
'general.nav': '通用设置',
|
'general.nav': '通用设置',
|
||||||
'welcome.title': WELCOME_NOTICE_COPY.zh.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.feedbackEmphasis': WELCOME_NOTICE_COPY.zh.feedbackEmphasis,
|
|
||||||
'welcome.continue': WELCOME_NOTICE_COPY.zh.continueLabel,
|
|
||||||
'welcome.error': '暂时无法保存确认状态,请重试。',
|
|
||||||
} satisfies Record<string, string>
|
} satisfies Record<string, string>
|
||||||
|
|
||||||
/** The settings namespace key union. */
|
/** The settings namespace key union. */
|
||||||
@@ -29,11 +21,4 @@ export const en = {
|
|||||||
'openDocument': 'Open configuration file',
|
'openDocument': 'Open configuration file',
|
||||||
'openDocument.error': 'Could not open configuration file',
|
'openDocument.error': 'Could not open configuration file',
|
||||||
'general.nav': 'General',
|
'general.nav': 'General',
|
||||||
'welcome.title': WELCOME_NOTICE_COPY.en.title,
|
|
||||||
'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.feedbackEmphasis': WELCOME_NOTICE_COPY.en.feedbackEmphasis,
|
|
||||||
'welcome.continue': WELCOME_NOTICE_COPY.en.continueLabel,
|
|
||||||
'welcome.error': 'The acknowledgement could not be saved. Please try again.',
|
|
||||||
} satisfies Record<SettingsKey, string>
|
} satisfies Record<SettingsKey, string>
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
/** Welcome-notice state, durable when the browser may use Host settings. */
|
|
||||||
|
|
||||||
import type { IApiClient, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client'
|
|
||||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
|
||||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
|
||||||
import {
|
|
||||||
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE, WELCOME_NOTICE_VERSION,
|
|
||||||
} from '../onboarding-copy.ts'
|
|
||||||
|
|
||||||
/** State rendered by the welcome step. */
|
|
||||||
export interface WelcomeNoticeState {
|
|
||||||
status: 'idle' | 'loading' | 'ready' | 'saving' | 'error'
|
|
||||||
acknowledged: boolean
|
|
||||||
error: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
function messageOf(error: unknown): string {
|
|
||||||
return error instanceof Error ? error.message : String(error)
|
|
||||||
}
|
|
||||||
|
|
||||||
function acknowledgementOf(view: SettingsNamespaceView): string | undefined {
|
|
||||||
if (typeof view.value !== 'object' || view.value === null) return undefined
|
|
||||||
const value = (view.value as Record<string, unknown>)[WELCOME_NOTICE_ACK_FIELD]
|
|
||||||
return typeof value === 'string' ? value : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Coordinates durable Host acknowledgement or a process-local remote fallback. */
|
|
||||||
export class WelcomeNoticeStore {
|
|
||||||
/** uSES-safe state source shared by the registered welcome step. */
|
|
||||||
readonly store: SnapshotStore<WelcomeNoticeState> = createSnapshotStore({
|
|
||||||
status: 'idle', acknowledged: false, error: null,
|
|
||||||
})
|
|
||||||
|
|
||||||
private generation = 0
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param api - settings wire face used for durable reads and writes.
|
|
||||||
* @param persistence - remote browsers use memory because settings is loopback-only.
|
|
||||||
*/
|
|
||||||
constructor(
|
|
||||||
private readonly api: Pick<IApiClient, 'settings'>,
|
|
||||||
private readonly persistence: 'host' | 'memory' = 'host',
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/** Load the acknowledgement from Host settings or initialize process-local state. */
|
|
||||||
async load(): Promise<void> {
|
|
||||||
const generation = ++this.generation
|
|
||||||
if (this.persistence === 'memory') {
|
|
||||||
this.store.update((state) => { state.status = 'ready'; state.error = null })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this.store.update((state) => { state.status = 'loading'; state.error = null })
|
|
||||||
try {
|
|
||||||
const response = await this.api.settings.describe({})
|
|
||||||
if (!response.result.ok) throw new Error(response.result.error.message)
|
|
||||||
const view = response.result.value.namespaces.find(
|
|
||||||
candidate => candidate.ns === WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
|
||||||
)
|
|
||||||
if (view === undefined) throw new Error('welcome acknowledgement settings are unavailable')
|
|
||||||
if (generation !== this.generation) return
|
|
||||||
this.store.update((state) => {
|
|
||||||
state.status = 'ready'
|
|
||||||
state.acknowledged = acknowledgementOf(view) === WELCOME_NOTICE_VERSION
|
|
||||||
state.error = null
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
if (generation !== this.generation) return
|
|
||||||
this.store.update((state) => {
|
|
||||||
state.status = 'error'
|
|
||||||
state.acknowledged = false
|
|
||||||
state.error = messageOf(error)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Acknowledge this copy version. The Host path mutation is idempotent across
|
|
||||||
* tabs and preserves sibling settings; remote fallback changes only this store.
|
|
||||||
* @returns true when the selected persistence mode accepted the acknowledgement.
|
|
||||||
*/
|
|
||||||
async acknowledge(): Promise<boolean> {
|
|
||||||
const generation = ++this.generation
|
|
||||||
if (this.persistence === 'memory') {
|
|
||||||
this.store.update((state) => {
|
|
||||||
state.status = 'ready'
|
|
||||||
state.acknowledged = true
|
|
||||||
state.error = null
|
|
||||||
})
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
this.store.update((state) => { state.status = 'saving'; state.error = null })
|
|
||||||
try {
|
|
||||||
const response = await this.api.settings.mutate({
|
|
||||||
ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
|
||||||
ops: [{ op: 'set', path: [WELCOME_NOTICE_ACK_FIELD], value: WELCOME_NOTICE_VERSION }],
|
|
||||||
})
|
|
||||||
if (!response.result.ok) throw new Error(response.result.error.message)
|
|
||||||
if (generation === this.generation) {
|
|
||||||
this.store.update((state) => {
|
|
||||||
state.status = 'ready'
|
|
||||||
state.acknowledged = true
|
|
||||||
state.error = null
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
} catch (error) {
|
|
||||||
if (generation === this.generation) {
|
|
||||||
this.store.update((state) => {
|
|
||||||
state.status = 'error'
|
|
||||||
state.acknowledged = false
|
|
||||||
state.error = messageOf(error)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Refresh only after welcome state has left idle. A memory-mode load retains
|
|
||||||
* acknowledgement so reconnect and settings-change refreshes do not reopen a
|
|
||||||
* process-local notice.
|
|
||||||
* @param controller - welcome state owner whose current status decides whether to load.
|
|
||||||
*/
|
|
||||||
export function refreshWelcomeIfLoaded(controller: WelcomeNoticeStore): void {
|
|
||||||
if (controller.store.getSnapshot().status === 'idle') return
|
|
||||||
void controller.load()
|
|
||||||
}
|
|
||||||
@@ -3,28 +3,24 @@
|
|||||||
import type { Context } from '@deepseek-ai/cordis'
|
import type { Context } from '@deepseek-ai/cordis'
|
||||||
import z from '@deepseek-ai/schemastery'
|
import z from '@deepseek-ai/schemastery'
|
||||||
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||||
import {
|
|
||||||
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
|
||||||
} from './onboarding-copy.ts'
|
|
||||||
|
|
||||||
export {
|
/** Durable settings namespace for product-wide GUI onboarding facts. */
|
||||||
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_COPY, WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
const ONBOARDING_SETTINGS_NAMESPACE = 'ui-onboarding'
|
||||||
WELCOME_NOTICE_VERSION,
|
|
||||||
} from './onboarding-copy.ts'
|
|
||||||
|
|
||||||
interface OnboardingSettings {
|
interface OnboardingSettings {
|
||||||
|
/** Last welcome-notice version acknowledged by a since-removed first-run notice; kept so stored documents stay valid. */
|
||||||
welcomeNoticeVersion?: string
|
welcomeNoticeVersion?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const OnboardingSettingsSchema: z<OnboardingSettings> = z.object({
|
const OnboardingSettingsSchema: z<OnboardingSettings> = z.object({
|
||||||
[WELCOME_NOTICE_ACK_FIELD]: z.string(),
|
welcomeNoticeVersion: z.string(),
|
||||||
})
|
})
|
||||||
|
|
||||||
/** Register the durable GUI-onboarding section when a settings provider exists. */
|
/** Register the durable GUI-onboarding section when a settings provider exists. */
|
||||||
export function apply(ctx: Context): void {
|
export function apply(ctx: Context): void {
|
||||||
ctx.inject(['settings'], (settingsCtx) => {
|
ctx.inject(['settings'], (settingsCtx) => {
|
||||||
settingsCtx.settings.register(
|
settingsCtx.settings.register(
|
||||||
settingsNamespace(WELCOME_NOTICE_SETTINGS_NAMESPACE),
|
settingsNamespace(ONBOARDING_SETTINGS_NAMESPACE),
|
||||||
OnboardingSettingsSchema,
|
OnboardingSettingsSchema,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export const inject = ['invariants']
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* No runtime invariant: the settings seam validates and publishes the durable
|
* No runtime invariant: the settings seam validates and publishes the durable
|
||||||
* welcome section, while slot conflicts fail loud in the slot core. The local
|
* onboarding section, while slot conflicts fail loud in the slot core. The local
|
||||||
* document action is browser state over typed RPC responses and is covered by
|
* document action is browser state over typed RPC responses and is covered by
|
||||||
* store/component tests rather than a Cordis runtime relationship.
|
* store/component tests rather than a Cordis runtime relationship.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
/** Durable settings namespace for product-wide GUI onboarding facts. */
|
|
||||||
export const WELCOME_NOTICE_SETTINGS_NAMESPACE = 'ui-onboarding'
|
|
||||||
|
|
||||||
/** Field storing the last welcome notice version the user acknowledged. */
|
|
||||||
export const WELCOME_NOTICE_ACK_FIELD = 'welcomeNoticeVersion'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bump only when the notice changes materially and every user should see it
|
|
||||||
* again. The acknowledgement is compared for exact equality.
|
|
||||||
*/
|
|
||||||
export const WELCOME_NOTICE_VERSION = '2026-08-11.1'
|
|
||||||
|
|
||||||
/** The complete editable welcome notice in both supported GUI locales. */
|
|
||||||
export const WELCOME_NOTICE_COPY = {
|
|
||||||
zh: {
|
|
||||||
title: '内测声明',
|
|
||||||
paragraphs: [
|
|
||||||
'感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部测试阶段,功能仍待完善,体验难免有些粗糙。',
|
|
||||||
'“如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的设计。',
|
|
||||||
'内测版本默认不会上传 Session Log。如需在提交反馈时共享会话日志,可以设置环境变量 DSH_TELEMETRY_MODE=FEEDBACK_ONLY;如需持续上传,可以设置 DSH_TELEMETRY_MODE=FULL,但该模式同时会启用 dsh-sdk 命令遥测,上报匿名 ID、命令结果以及脱敏后的项目配置。另外,如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更好。',
|
|
||||||
],
|
|
||||||
feedbackEmphasis: '如果您有任何反馈与建议,请在企业微信群中留言告诉我们',
|
|
||||||
continueLabel: '继续',
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
title: '内测声明',
|
|
||||||
paragraphs: [
|
|
||||||
'感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部测试阶段,功能仍待完善,体验难免有些粗糙。',
|
|
||||||
'“如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的设计。',
|
|
||||||
'内测版本默认不会上传 Session Log。如需在提交反馈时共享会话日志,可以设置环境变量 DSH_TELEMETRY_MODE=FEEDBACK_ONLY;如需持续上传,可以设置 DSH_TELEMETRY_MODE=FULL,但该模式同时会启用 dsh-sdk 命令遥测,上报匿名 ID、命令结果以及脱敏后的项目配置。另外,如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更好。',
|
|
||||||
],
|
|
||||||
feedbackEmphasis: '如果您有任何反馈与建议,请在企业微信群中留言告诉我们',
|
|
||||||
continueLabel: '继续',
|
|
||||||
},
|
|
||||||
} as const
|
|
||||||
@@ -1,18 +1,15 @@
|
|||||||
/** Ownerless-copy registrations: the six seats, dictionaries, thunked labels, and HMR recovery. */
|
/** Ownerless-copy registrations: the five seats, dictionaries, thunked labels, and HMR recovery. */
|
||||||
import { Context } from '@deepseek-ai/cordis'
|
import { Context } from '@deepseek-ai/cordis'
|
||||||
import { describe, expect, it, vi } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
|
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
|
||||||
import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
|
import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
|
||||||
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
|
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
|
||||||
import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
|
import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
|
||||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-general/client'
|
import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-general/client'
|
||||||
import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx'
|
import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx'
|
||||||
import { GeneralSection } from '../src/client/GeneralSection.tsx'
|
import { GeneralSection } from '../src/client/GeneralSection.tsx'
|
||||||
import { SettingsDocumentAction } from '../src/client/SettingsDocumentAction.tsx'
|
import { SettingsDocumentAction } from '../src/client/SettingsDocumentAction.tsx'
|
||||||
import type { SettingsDocumentActionInjected } from '../src/client/SettingsDocumentAction.tsx'
|
import type { SettingsDocumentActionInjected } from '../src/client/SettingsDocumentAction.tsx'
|
||||||
import { WelcomeNotice } from '../src/client/WelcomeNotice.tsx'
|
|
||||||
import type { WelcomeNoticeInjected } from '../src/client/WelcomeNotice.tsx'
|
|
||||||
import { WELCOME_NOTICE_SETTINGS_NAMESPACE } from '../src/onboarding-copy.ts'
|
|
||||||
|
|
||||||
// The service reads its initial locale from the browser; these specs assert
|
// The service reads its initial locale from the browser; these specs assert
|
||||||
// the shipped Chinese copy, so they state the browser they assume.
|
// the shipped Chinese copy, so they state the browser they assume.
|
||||||
@@ -25,7 +22,6 @@ const SEATS = [
|
|||||||
['settings.action', SettingsDocumentAction],
|
['settings.action', SettingsDocumentAction],
|
||||||
['settings.close', CloseLabel],
|
['settings.close', CloseLabel],
|
||||||
['settings.section', GeneralSection],
|
['settings.section', GeneralSection],
|
||||||
['settings.onboarding', WelcomeNotice],
|
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
async function bench(isLoopback = true) {
|
async function bench(isLoopback = true) {
|
||||||
@@ -33,9 +29,6 @@ async function bench(isLoopback = true) {
|
|||||||
await ctx.plugin(SlotRegistry).await()
|
await ctx.plugin(SlotRegistry).await()
|
||||||
const locale = new LocaleRuntime(ctx)
|
const locale = new LocaleRuntime(ctx)
|
||||||
ctx.provide('locale', locale)
|
ctx.provide('locale', locale)
|
||||||
// The plugins inject `remote`; forwarded events reach them through the
|
|
||||||
// same `$dispatch` handoff the connection sink makes.
|
|
||||||
new TestRemote(ctx)
|
|
||||||
const settingsDescribe = vi.fn(() => Promise.resolve({
|
const settingsDescribe = vi.fn(() => Promise.resolve({
|
||||||
rpcId: 'settings-general' as never,
|
rpcId: 'settings-general' as never,
|
||||||
result: {
|
result: {
|
||||||
@@ -43,14 +36,7 @@ async function bench(isLoopback = true) {
|
|||||||
value: {
|
value: {
|
||||||
writable: true,
|
writable: true,
|
||||||
hasDocument: true,
|
hasDocument: true,
|
||||||
namespaces: [{
|
namespaces: [],
|
||||||
ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
|
||||||
schema: {},
|
|
||||||
value: {},
|
|
||||||
applies: 'live' as const,
|
|
||||||
secrets: [],
|
|
||||||
revision: 0,
|
|
||||||
}],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
@@ -89,10 +75,10 @@ function generalEntry(slots: SlotRegistry) {
|
|||||||
|
|
||||||
describe('ui-settings-general apply', () => {
|
describe('ui-settings-general apply', () => {
|
||||||
it('declares the services it uses', () => {
|
it('declares the services it uses', () => {
|
||||||
expect(inject).toEqual(['slots', 'locale', 'connection', 'remote'])
|
expect(inject).toEqual(['slots', 'locale', 'connection'])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('fills all six seats for declarations before or after apply', async () => {
|
it('fills all five seats for declarations before or after apply', async () => {
|
||||||
const before = await bench()
|
const before = await bench()
|
||||||
declare(before.slots)
|
declare(before.slots)
|
||||||
await before.ctx.plugin({ inject: [...inject], apply }).await()
|
await before.ctx.plugin({ inject: [...inject], apply }).await()
|
||||||
@@ -105,8 +91,9 @@ describe('ui-settings-general apply', () => {
|
|||||||
expect(resolveSlotLabel(entry.options.label)).toBe('通用设置')
|
expect(resolveSlotLabel(entry.options.label)).toBe('通用设置')
|
||||||
expect(before.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' })
|
expect(before.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' })
|
||||||
expect(before.slots.entries('settings.general.item')).toEqual([])
|
expect(before.slots.entries('settings.general.item')).toEqual([])
|
||||||
const welcome = before.slots.entries('settings.onboarding')[0]!
|
// The onboarding hole stays declared for feature-owned steps; this plugin
|
||||||
expect(welcome.options).toMatchObject({ id: 'welcome-notice', order: -100 })
|
// no longer seats one.
|
||||||
|
expect(before.slots.entries('settings.onboarding')).toEqual([])
|
||||||
const action = before.slots.entries('settings.action')[0]!
|
const action = before.slots.entries('settings.action')[0]!
|
||||||
const actionInjected = (action.inject as unknown as () => SettingsDocumentActionInjected)()
|
const actionInjected = (action.inject as unknown as () => SettingsDocumentActionInjected)()
|
||||||
expect(actionInjected.controller.store.getSnapshot().status).toBe('idle')
|
expect(actionInjected.controller.store.getSnapshot().status).toBe('idle')
|
||||||
@@ -162,22 +149,6 @@ describe('ui-settings-general apply', () => {
|
|||||||
expect(resolveSlotLabel(generalEntry(b.slots)!.options.label)).toBe('通用设置')
|
expect(resolveSlotLabel(generalEntry(b.slots)!.options.label)).toBe('通用设置')
|
||||||
})
|
})
|
||||||
|
|
||||||
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.remote.$dispatch('settings/document-updated', ['unrelated', 1])
|
|
||||||
expect(b.settingsDescribe).toHaveBeenCalledOnce()
|
|
||||||
b.ctx.remote.$dispatch('settings/document-updated', [WELCOME_NOTICE_SETTINGS_NAMESPACE, 1])
|
|
||||||
await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledTimes(2) })
|
|
||||||
b.ctx.emit('connection/reset')
|
|
||||||
await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledTimes(3) })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('refreshes loaded document availability on reconnect without reading it eagerly', async () => {
|
it('refreshes loaded document availability on reconnect without reading it eagerly', async () => {
|
||||||
const b = await bench()
|
const b = await bench()
|
||||||
declare(b.slots)
|
declare(b.slots)
|
||||||
@@ -192,19 +163,13 @@ describe('ui-settings-general apply', () => {
|
|||||||
await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledTimes(2) })
|
await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledTimes(2) })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps remote welcome acknowledgement process-local', async () => {
|
it('withholds the loopback-only document action off-loopback', async () => {
|
||||||
const b = await bench(false)
|
const b = await bench(false)
|
||||||
declare(b.slots)
|
declare(b.slots)
|
||||||
const fiber = b.ctx.plugin({ inject: [...inject], apply })
|
const fiber = b.ctx.plugin({ inject: [...inject], apply })
|
||||||
await fiber.await()
|
await fiber.await()
|
||||||
const entry = b.slots.entries('settings.onboarding')[0]!
|
|
||||||
const { controller } = (entry.inject as unknown as () => WelcomeNoticeInjected)()
|
|
||||||
|
|
||||||
await controller.load()
|
|
||||||
await expect(controller.acknowledge()).resolves.toBe(true)
|
|
||||||
expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: true })
|
|
||||||
expect(b.settingsDescribe).not.toHaveBeenCalled()
|
|
||||||
expect(b.slots.entries('settings.action')).toEqual([])
|
expect(b.slots.entries('settings.action')).toEqual([])
|
||||||
|
expect(b.settingsDescribe).not.toHaveBeenCalled()
|
||||||
await fiber.dispose()
|
await fiber.dispose()
|
||||||
for (const [name] of SEATS) expect(b.slots.entries(name)).toEqual([])
|
for (const [name] of SEATS) expect(b.slots.entries(name)).toEqual([])
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import { Context } from '@deepseek-ai/cordis'
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { SettingsProvider, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
import { SettingsProvider, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||||
import { apply } from '../src/index.ts'
|
import { apply } from '../src/index.ts'
|
||||||
import { WELCOME_NOTICE_SETTINGS_NAMESPACE } from '../src/onboarding-copy.ts'
|
|
||||||
|
/** Mirrors the module-local namespace id in src/index.ts. */
|
||||||
|
const ONBOARDING_SETTINGS_NAMESPACE = 'ui-onboarding'
|
||||||
|
|
||||||
class MemorySettings extends SettingsProvider {
|
class MemorySettings extends SettingsProvider {
|
||||||
readonly writable = true
|
readonly writable = true
|
||||||
@@ -19,11 +21,11 @@ describe('ui-settings-general host', () => {
|
|||||||
const fiber = ctx.plugin({ apply })
|
const fiber = ctx.plugin({ apply })
|
||||||
await fiber.await()
|
await fiber.await()
|
||||||
expect(ctx.settings.describe().map(row => row.ns)).toContain(
|
expect(ctx.settings.describe().map(row => row.ns)).toContain(
|
||||||
settingsNamespace(WELCOME_NOTICE_SETTINGS_NAMESPACE),
|
settingsNamespace(ONBOARDING_SETTINGS_NAMESPACE),
|
||||||
)
|
)
|
||||||
await fiber.dispose()
|
await fiber.dispose()
|
||||||
expect(ctx.settings.describe().map(row => row.ns)).not.toContain(
|
expect(ctx.settings.describe().map(row => row.ns)).not.toContain(
|
||||||
settingsNamespace(WELCOME_NOTICE_SETTINGS_NAMESPACE),
|
settingsNamespace(ONBOARDING_SETTINGS_NAMESPACE),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -49,7 +49,7 @@ const CHILD_SPECS = {
|
|||||||
|
|
||||||
describe('ui-settings apply', () => {
|
describe('ui-settings apply', () => {
|
||||||
it('declares only the slot registry (a pure composition face, no locale)', () => {
|
it('declares only the slot registry (a pure composition face, no locale)', () => {
|
||||||
expect(inject).toEqual(['slots', 'locale', 'connection', 'remote'])
|
expect(inject).toEqual(['slots', 'locale', 'connection'])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('registers the shell and declares every child slot, before or after the declaration', async () => {
|
it('registers the shell and declares every child slot, before or after the declaration', async () => {
|
||||||
@@ -110,8 +110,6 @@ describe('ui-settings apply', () => {
|
|||||||
b.slots.register({ name: 'settings.onboarding', id: 'default-order' } as never, () => null)
|
b.slots.register({ name: 'settings.onboarding', id: 'default-order' } as never, () => null)
|
||||||
const steps = onboardingSteps.getSnapshot()
|
const steps = onboardingSteps.getSnapshot()
|
||||||
expect(steps).toEqual([
|
expect(steps).toEqual([
|
||||||
// This package's own onboarding page, registered by the same apply.
|
|
||||||
{ id: 'welcome-notice', order: -100 },
|
|
||||||
{ id: 'welcome', order: -100 },
|
{ id: 'welcome', order: -100 },
|
||||||
{ id: 'credential', order: 0 },
|
{ id: 'credential', order: 0 },
|
||||||
{ id: 'default-order', order: 0 },
|
{ id: 'default-order', order: 0 },
|
||||||
|
|||||||
@@ -1,106 +0,0 @@
|
|||||||
// @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<T>(value: T) {
|
|
||||||
return { rpcId: 'welcome-rpc' as never, result: { ok: true as const, value } }
|
|
||||||
}
|
|
||||||
|
|
||||||
function mount(version?: string, mutateImpl: () => Promise<unknown> = () => Promise.resolve(response({}))) {
|
|
||||||
const mutate = vi.fn(mutateImpl)
|
|
||||||
const api = {
|
|
||||||
settings: {
|
|
||||||
describe: () => Promise.resolve(response({
|
|
||||||
writable: true,
|
|
||||||
hasDocument: false,
|
|
||||||
namespaces: [{
|
|
||||||
ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
|
||||||
schema: {},
|
|
||||||
value: version === undefined ? {} : { [WELCOME_NOTICE_ACK_FIELD]: version },
|
|
||||||
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 => key in zh ? zh[key as keyof typeof zh] : key,
|
|
||||||
}
|
|
||||||
return { ...render(<WelcomeNotice {...props} />), complete, controller, mutate }
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('WelcomeNotice', () => {
|
|
||||||
it('uses the same Chinese owner copy in both GUI locales', () => {
|
|
||||||
expect(WELCOME_NOTICE_COPY.en).toEqual(WELCOME_NOTICE_COPY.zh)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders the owner copy with one primary action and no dismissal control', async () => {
|
|
||||||
const h = mount()
|
|
||||||
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' })
|
|
||||||
expect(h.complete).not.toHaveBeenCalled()
|
|
||||||
expect(screen.getByRole('region')).toBeTruthy()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('completes only after the acknowledgement write commits', async () => {
|
|
||||||
const h = mount()
|
|
||||||
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()
|
|
||||||
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('region')).toBeNull()
|
|
||||||
expect(h.complete).toHaveBeenCalledOnce()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('keeps the sole action disabled while saving and reports a refused write', async () => {
|
|
||||||
let resolveWrite!: (value: unknown) => void
|
|
||||||
const write = new Promise<unknown>((resolve) => { resolveWrite = resolve })
|
|
||||||
const h = mount(undefined, () => write)
|
|
||||||
await screen.findByRole('region')
|
|
||||||
const action = screen.getByRole<HTMLButtonElement>('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel })
|
|
||||||
fireEvent.click(action)
|
|
||||||
expect(action.disabled).toBe(true)
|
|
||||||
resolveWrite({
|
|
||||||
rpcId: 'welcome-refused' as never,
|
|
||||||
result: { ok: false, error: { code: 'settings-rejected', message: 'read only', details: { ns: WELCOME_NOTICE_SETTINGS_NAMESPACE } } },
|
|
||||||
})
|
|
||||||
expect((await screen.findByRole('alert')).textContent).toBe('暂时无法保存确认状态,请重试。')
|
|
||||||
expect(h.complete).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest'
|
|
||||||
import type { RpcResponse } from '@deepseek-ai/dsh-api-remotes/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<T>(value: T): RpcResponse<T> {
|
|
||||||
return { rpcId: `welcome-${rpc++}` as never, result: { ok: true, value } }
|
|
||||||
}
|
|
||||||
|
|
||||||
function namespace(version?: string) {
|
|
||||||
return {
|
|
||||||
ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
|
||||||
schema: {},
|
|
||||||
value: version === undefined ? {} : { [WELCOME_NOTICE_ACK_FIELD]: version },
|
|
||||||
applies: 'live' as const,
|
|
||||||
secrets: [],
|
|
||||||
revision: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function deferred<T>() {
|
|
||||||
let resolve!: (value: T) => void
|
|
||||||
let reject!: (reason: unknown) => void
|
|
||||||
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej })
|
|
||||||
return { promise, resolve, reject }
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('WelcomeNoticeStore', () => {
|
|
||||||
it('acknowledges in memory without calling loopback-only settings APIs', async () => {
|
|
||||||
const describe = vi.fn()
|
|
||||||
const mutate = vi.fn()
|
|
||||||
const controller = new WelcomeNoticeStore({ settings: { describe, mutate } } as never, 'memory')
|
|
||||||
|
|
||||||
await controller.load()
|
|
||||||
expect(controller.store.getSnapshot()).toEqual({ status: 'ready', acknowledged: false, error: null })
|
|
||||||
await expect(controller.acknowledge()).resolves.toBe(true)
|
|
||||||
expect(controller.store.getSnapshot()).toEqual({ status: 'ready', acknowledged: true, error: null })
|
|
||||||
await controller.load()
|
|
||||||
expect(controller.store.getSnapshot()).toEqual({ status: 'ready', acknowledged: true, error: null })
|
|
||||||
expect(describe).not.toHaveBeenCalled()
|
|
||||||
expect(mutate).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('acknowledges only the exact current copy version', async () => {
|
|
||||||
for (const [version, acknowledged] of [
|
|
||||||
[undefined, false],
|
|
||||||
['older-copy', false],
|
|
||||||
[WELCOME_NOTICE_VERSION, true],
|
|
||||||
] as const) {
|
|
||||||
const api = {
|
|
||||||
settings: {
|
|
||||||
describe: vi.fn(() => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [namespace(version)] }))),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
const controller = new WelcomeNoticeStore(api as never)
|
|
||||||
await controller.load()
|
|
||||||
expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it('persists the owner version through one idempotent path mutation', async () => {
|
|
||||||
const mutate = vi.fn(() => Promise.resolve(ok(namespace(WELCOME_NOTICE_VERSION))))
|
|
||||||
const controller = new WelcomeNoticeStore({ settings: { mutate } } as never)
|
|
||||||
await expect(controller.acknowledge()).resolves.toBe(true)
|
|
||||||
expect(mutate).toHaveBeenCalledWith({
|
|
||||||
ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
|
||||||
ops: [{ op: 'set', path: [WELCOME_NOTICE_ACK_FIELD], value: WELCOME_NOTICE_VERSION }],
|
|
||||||
})
|
|
||||||
expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: true })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('keeps the notice pending when loading or persistence fails', async () => {
|
|
||||||
const load = new WelcomeNoticeStore({
|
|
||||||
settings: { describe: () => Promise.reject(new Error('offline')) },
|
|
||||||
} as never)
|
|
||||||
await load.load()
|
|
||||||
expect(load.store.getSnapshot()).toEqual({ status: 'error', acknowledged: false, error: 'offline' })
|
|
||||||
|
|
||||||
const save = new WelcomeNoticeStore({
|
|
||||||
settings: { mutate: () => Promise.reject(new Error('disk full')) },
|
|
||||||
} as never)
|
|
||||||
await expect(save.acknowledge()).resolves.toBe(false)
|
|
||||||
expect(save.store.getSnapshot()).toEqual({ status: 'error', acknowledged: false, error: 'disk full' })
|
|
||||||
|
|
||||||
const nonError = new WelcomeNoticeStore({
|
|
||||||
// Durable/wire failures are unknown; exercise containment of a non-Error rejection.
|
|
||||||
// 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, hasDocument: false, namespaces: [] })),
|
|
||||||
]) {
|
|
||||||
const controller = new WelcomeNoticeStore({ settings: { describe } } as never)
|
|
||||||
await controller.load()
|
|
||||||
expect(controller.store.getSnapshot().status).toBe('error')
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const value of [null, 42, { [WELCOME_NOTICE_ACK_FIELD]: 42 }]) {
|
|
||||||
const controller = new WelcomeNoticeStore({
|
|
||||||
settings: { describe: () => Promise.resolve(ok({
|
|
||||||
writable: true,
|
|
||||||
hasDocument: false,
|
|
||||||
namespaces: [{ ...namespace(), value }],
|
|
||||||
})) },
|
|
||||||
} as never)
|
|
||||||
await controller.load()
|
|
||||||
expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: false })
|
|
||||||
}
|
|
||||||
|
|
||||||
const save = new WelcomeNoticeStore({
|
|
||||||
settings: { mutate: () => Promise.resolve({
|
|
||||||
rpcId: 'failed-save' as never,
|
|
||||||
result: { ok: false, error: { code: 'settings-rejected', message: 'denied', details: { ns: WELCOME_NOTICE_SETTINGS_NAMESPACE } } },
|
|
||||||
}) },
|
|
||||||
} as never)
|
|
||||||
await expect(save.acknowledge()).resolves.toBe(false)
|
|
||||||
expect(save.store.getSnapshot().error).toBe('denied')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('lets the latest load win over stale success and failure', async () => {
|
|
||||||
const first = deferred<ReturnType<typeof ok>>()
|
|
||||||
const describe = vi.fn()
|
|
||||||
.mockImplementationOnce(() => first.promise)
|
|
||||||
.mockImplementationOnce(() => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [namespace()] })))
|
|
||||||
const controller = new WelcomeNoticeStore({ settings: { describe } } as never)
|
|
||||||
const stale = controller.load()
|
|
||||||
await controller.load()
|
|
||||||
first.resolve(ok({ writable: true, hasDocument: false, namespaces: [namespace(WELCOME_NOTICE_VERSION)] }))
|
|
||||||
await stale
|
|
||||||
expect(controller.store.getSnapshot().acknowledged).toBe(false)
|
|
||||||
|
|
||||||
const failed = deferred<ReturnType<typeof ok>>()
|
|
||||||
describe
|
|
||||||
.mockImplementationOnce(() => failed.promise)
|
|
||||||
.mockImplementationOnce(() => Promise.resolve(ok({
|
|
||||||
writable: true, hasDocument: false, namespaces: [namespace(WELCOME_NOTICE_VERSION)],
|
|
||||||
})))
|
|
||||||
const staleFailure = controller.load()
|
|
||||||
await controller.load()
|
|
||||||
failed.reject('stale failure')
|
|
||||||
await staleFailure
|
|
||||||
expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: true, error: null })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('contains stale acknowledgement settlements and refreshes only a loaded store', async () => {
|
|
||||||
const write = deferred<ReturnType<typeof ok>>()
|
|
||||||
const describe = vi.fn(() => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [namespace()] })))
|
|
||||||
const controller = new WelcomeNoticeStore({
|
|
||||||
settings: { mutate: () => write.promise, describe },
|
|
||||||
} as never)
|
|
||||||
refreshWelcomeIfLoaded(controller)
|
|
||||||
expect(describe).not.toHaveBeenCalled()
|
|
||||||
const staleWrite = controller.acknowledge()
|
|
||||||
await controller.load()
|
|
||||||
write.resolve(ok(namespace(WELCOME_NOTICE_VERSION)))
|
|
||||||
await expect(staleWrite).resolves.toBe(true)
|
|
||||||
expect(controller.store.getSnapshot().acknowledged).toBe(false)
|
|
||||||
refreshWelcomeIfLoaded(controller)
|
|
||||||
await vi.waitFor(() => { expect(describe).toHaveBeenCalledTimes(2) })
|
|
||||||
|
|
||||||
const failedWrite = deferred<ReturnType<typeof ok>>()
|
|
||||||
const staleFailure = new WelcomeNoticeStore({
|
|
||||||
settings: { mutate: () => failedWrite.promise, describe },
|
|
||||||
} as never)
|
|
||||||
const pending = staleFailure.acknowledge()
|
|
||||||
await staleFailure.load()
|
|
||||||
failedWrite.reject('late failure')
|
|
||||||
await expect(pending).resolves.toBe(false)
|
|
||||||
expect(staleFailure.store.getSnapshot().status).toBe('ready')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1259,7 +1259,6 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
|||||||
slotInject: '',
|
slotInject: '',
|
||||||
declaredBy: 'an entry in \'sidebar.settings\' (client-ui-settings-general), so it exists while that entry is mounted',
|
declaredBy: 'an entry in \'sidebar.settings\' (client-ui-settings-general), so it exists while that entry is mounted',
|
||||||
occupants: [
|
occupants: [
|
||||||
'client-ui-settings-general WelcomeNotice id \'welcome-notice\'',
|
|
||||||
'client-ui-settings-models DeepSeekOnboardingDialog id \'deepseek-official\'',
|
'client-ui-settings-models DeepSeekOnboardingDialog id \'deepseek-official\'',
|
||||||
],
|
],
|
||||||
replaceRisk: 'none',
|
replaceRisk: 'none',
|
||||||
|
|||||||
Reference in New Issue
Block a user