From 93b832f1b07ec00763883ef7d9995d134d529661 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Wed, 23 Sep 2026 21:48:07 +0800 Subject: [PATCH] Merge pull request #4459 from deepseek-harness/feat/desktop-onboarding feat(desktop): add device-local account onboarding --- ...-desktop-welcome-window-material.i18n.yaml | 4 +- ...6-09-08-desktop-welcome-window-material.md | 6 +- ...9-08-desktop-welcome-window-material.zh.md | 6 +- ...-desktop-mandatory-update-client.i18n.yaml | 4 +- ...6-09-11-desktop-mandatory-update-client.md | 2 + ...9-11-desktop-mandatory-update-client.zh.md | 2 + .../2026-09-16-desktop-onboarding.i18n.yaml | 6 + .../feature/2026-09-16-desktop-onboarding.md | 33 ++ .../2026-09-16-desktop-onboarding.zh.md | 33 ++ ...6-09-17-developer-tools-settings.i18n.yaml | 2 +- .../2026-09-17-developer-tools-settings.zh.md | 2 +- apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 4 + apps/desktop/README.zh.md | 4 + apps/desktop/src/ipc.ts | 2 + apps/desktop/src/main.ts | 15 + apps/desktop/src/preload-app.ts | 4 + apps/desktop/src/update-overlay.ts | 12 +- apps/desktop/tests/README.i18n.yaml | 4 +- apps/desktop/tests/README.md | 14 + apps/desktop/tests/README.zh.md | 14 + .../expected/update-overlay-visibility.json | 15 + .../fixtures/update-overlay-visibility.mjs | 95 +++ apps/desktop/tests/main-startup.spec.ts | 35 ++ apps/desktop/tests/preload-app.spec.ts | 18 + apps/desktop/tests/update-dialog.spec.ts | 1 + apps/desktop/tests/update-overlay.spec.ts | 83 ++- apps/web/package.json | 1 + apps/web/src/main.ts | 1 + apps/web/tests/desktop-onboarding.e2e.ts | 423 ++++++++++++++ .../credit-en-dark.expected.md | 7 + .../credit-funded-en-dark.expected.md | 7 + .../desktop-onboarding/credit.expected.md | 7 + .../process-en-dark.expected.md | 9 + .../desktop-onboarding/process.expected.md | 10 + .../purpose-en-dark.expected.md | 12 + .../desktop-onboarding/purpose.expected.md | 12 + .../welcome-en-dark.expected.md | 6 + .../desktop-onboarding/welcome.expected.md | 6 + apps/web/tests/onboarding-native.e2e.ts | 8 +- apps/web/tsconfig.json | 1 + apps/web/vite.config.ts | 16 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 35 +- docs/config-catalog.zh.md | 35 +- .../src/client/AgentPresetSection.tsx | 8 +- .../ui-agent-preset/src/client/index.ts | 2 +- .../tests/section.client.spec.tsx | 4 +- .../ui-conversation/src/client/apply.ts | 4 +- .../src/client/view-selection.ts | 4 +- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 3 +- packages/client/ui-primitives/README.zh.md | 3 +- packages/client/ui-primitives/src/Button.tsx | 2 +- .../client/ui-primitives/src/Checkbox.tsx | 3 +- .../client/ui-primitives/src/Modal.module.css | 19 +- packages/client/ui-primitives/src/Modal.tsx | 7 +- .../src/OnboardingSurface.module.css | 28 - .../ui-primitives/src/OnboardingSurface.tsx | 26 - packages/client/ui-primitives/src/index.ts | 1 - .../ui-primitives/tests/atoms.client.spec.tsx | 3 +- .../tests/checkbox.client.spec.tsx | 10 + .../tests/onboarding-surface.client.spec.tsx | 47 -- .../ui-settings-account/README.i18n.yaml | 4 +- packages/client/ui-settings-account/README.md | 15 +- .../client/ui-settings-account/README.zh.md | 15 +- .../client/ui-settings-account/package.json | 5 +- .../src/client/DesktopOnboarding.module.css | 328 +++++++++++ .../src/client/DesktopOnboarding.tsx | 86 +++ .../src/client/DesktopOnboardingEntry.tsx | 60 ++ .../src/client/OnboardingConfirmation.tsx | 48 ++ .../src/client/OnboardingCreditStep.tsx | 34 ++ .../src/client/OnboardingIllustration.tsx | 10 + .../src/client/OnboardingProcessStep.tsx | 55 ++ .../src/client/OnboardingPurposeStep.tsx | 65 +++ .../src/client/OnboardingSurface.module.css | 50 ++ .../src/client/OnboardingSurface.tsx | 42 ++ .../src/client/OnboardingWelcomeStep.tsx | 28 + .../src/client/PlatformOverlay.module.css | 1 + .../src/client/PlatformOverlay.tsx | 6 +- .../src/client/account-refresh.ts | 15 + .../src/client/assets.d.ts | 9 + .../src/client/assets/onboarding-back.svg | 3 + .../src/client/assets/onboarding-code.svg | 7 + .../src/client/assets/onboarding-compact.svg | 6 + .../src/client/assets/onboarding-detailed.svg | 8 + .../src/client/assets/onboarding-expanded.svg | 7 + .../src/client/assets/onboarding-office.svg | 8 + .../assets/onboarding-recharge-dark.png | Bin 0 -> 383841 bytes .../assets/onboarding-recharge-zh-dark.png | Bin 0 -> 427219 bytes .../client/assets/onboarding-recharge-zh.png | Bin 0 -> 341269 bytes .../src/client/assets/onboarding-recharge.png | Bin 0 -> 344710 bytes .../client/assets/onboarding-welcome-dark.png | Bin 0 -> 588729 bytes .../assets/onboarding-welcome-zh-dark.png | Bin 0 -> 559659 bytes .../client/assets/onboarding-welcome-zh.png | Bin 0 -> 563824 bytes .../src/client/assets/onboarding-welcome.png | Bin 0 -> 595262 bytes .../src/client/contact-url.ts | 4 +- .../ui-settings-account/src/client/index.ts | 43 +- .../ui-settings-account/src/client/locales.ts | 5 + .../src/client/locales/onboarding.ts | 85 +++ .../src/client/onboarding-balance.ts | 12 + .../src/client/onboarding-contract.ts | 34 ++ .../src/client/onboarding-credentials.ts | 13 + .../src/client/onboarding-state.ts | 245 ++++++++ .../src/client/overlay-inert.ts | 23 + .../ui-settings-account/src/contact-config.ts | 8 +- .../client/ui-settings-account/src/index.ts | 45 +- .../src/onboarding-settings.ts | 43 ++ .../tests/account-refresh.client.spec.ts | 24 + .../tests/apply.client.spec.ts | 51 ++ .../tests/config.client.spec.ts | 21 +- .../tests/contact-url.client.spec.ts | 10 +- .../desktop-onboarding-entry.client.spec.tsx | 169 ++++++ .../tests/desktop-onboarding.client.spec.tsx | 325 +++++++++++ .../tests/expected/onboarding-credit-en.txt | 1 + .../tests/expected/onboarding-credit.txt | 1 + .../expected/onboarding-preferences.json | 101 ++++ .../tests/expected/onboarding-process-en.txt | 1 + .../tests/expected/onboarding-process.txt | 1 + .../tests/expected/onboarding-purpose-en.txt | 1 + .../tests/expected/onboarding-purpose.txt | 1 + .../tests/expected/onboarding-welcome-en.txt | 1 + .../tests/expected/onboarding-welcome.txt | 1 + .../onboarding-credentials.client.spec.ts | 21 + .../tests/onboarding-state.client.spec.ts | 551 ++++++++++++++++++ .../tests/onboarding-surface.client.spec.tsx | 105 ++++ .../client/ui-settings-account/tsconfig.json | 9 + .../ui-settings-account/tsdown.config.ts | 25 +- .../ui-settings-models/README.i18n.yaml | 4 +- packages/client/ui-settings-models/README.md | 2 +- .../client/ui-settings-models/README.zh.md | 2 +- .../ui-settings-models/src/client/index.ts | 2 +- packages/client/ui-theme/README.i18n.yaml | 4 +- packages/client/ui-theme/README.md | 4 + packages/client/ui-theme/README.zh.md | 4 + packages/client/ui-theme/src/client/styles.ts | 2 + packages/client/ui-theme/src/styles/base.css | 1 + .../client/ui-theme/src/styles/onboarding.css | 16 + .../tests/app-region-styles.client.spec.ts | 6 + .../tests/client-styles.client.spec.ts | 1 + .../README.i18n.yaml | 4 +- .../deepseek-account-platform/README.md | 4 +- .../deepseek-account-platform/README.zh.md | 4 +- .../deepseek-account-platform/src/details.ts | 2 +- .../deepseek-account-platform/src/index.ts | 11 +- .../tests/account.spec.ts | 25 +- .../src/client/slot-catalog.ts | 1 + pnpm-lock.yaml | 334 +++++------ scripts/clean.spec.ts | 6 + tsconfig.base.json | 1 + tsconfig.host.json | 1 + 151 files changed, 4153 insertions(+), 385 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-09-16-desktop-onboarding.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-09-16-desktop-onboarding.md create mode 100644 .agents/notes/implemented/feature/2026-09-16-desktop-onboarding.zh.md create mode 100644 apps/desktop/tests/expected/update-overlay-visibility.json create mode 100644 apps/desktop/tests/fixtures/update-overlay-visibility.mjs create mode 100644 apps/web/tests/desktop-onboarding.e2e.ts create mode 100644 apps/web/tests/expected/desktop-onboarding/credit-en-dark.expected.md create mode 100644 apps/web/tests/expected/desktop-onboarding/credit-funded-en-dark.expected.md create mode 100644 apps/web/tests/expected/desktop-onboarding/credit.expected.md create mode 100644 apps/web/tests/expected/desktop-onboarding/process-en-dark.expected.md create mode 100644 apps/web/tests/expected/desktop-onboarding/process.expected.md create mode 100644 apps/web/tests/expected/desktop-onboarding/purpose-en-dark.expected.md create mode 100644 apps/web/tests/expected/desktop-onboarding/purpose.expected.md create mode 100644 apps/web/tests/expected/desktop-onboarding/welcome-en-dark.expected.md create mode 100644 apps/web/tests/expected/desktop-onboarding/welcome.expected.md delete mode 100644 packages/client/ui-primitives/src/OnboardingSurface.module.css delete mode 100644 packages/client/ui-primitives/src/OnboardingSurface.tsx delete mode 100644 packages/client/ui-primitives/tests/onboarding-surface.client.spec.tsx create mode 100644 packages/client/ui-settings-account/src/client/DesktopOnboarding.module.css create mode 100644 packages/client/ui-settings-account/src/client/DesktopOnboarding.tsx create mode 100644 packages/client/ui-settings-account/src/client/DesktopOnboardingEntry.tsx create mode 100644 packages/client/ui-settings-account/src/client/OnboardingConfirmation.tsx create mode 100644 packages/client/ui-settings-account/src/client/OnboardingCreditStep.tsx create mode 100644 packages/client/ui-settings-account/src/client/OnboardingIllustration.tsx create mode 100644 packages/client/ui-settings-account/src/client/OnboardingProcessStep.tsx create mode 100644 packages/client/ui-settings-account/src/client/OnboardingPurposeStep.tsx create mode 100644 packages/client/ui-settings-account/src/client/OnboardingSurface.module.css create mode 100644 packages/client/ui-settings-account/src/client/OnboardingSurface.tsx create mode 100644 packages/client/ui-settings-account/src/client/OnboardingWelcomeStep.tsx create mode 100644 packages/client/ui-settings-account/src/client/account-refresh.ts create mode 100644 packages/client/ui-settings-account/src/client/assets.d.ts create mode 100644 packages/client/ui-settings-account/src/client/assets/onboarding-back.svg create mode 100644 packages/client/ui-settings-account/src/client/assets/onboarding-code.svg create mode 100644 packages/client/ui-settings-account/src/client/assets/onboarding-compact.svg create mode 100644 packages/client/ui-settings-account/src/client/assets/onboarding-detailed.svg create mode 100644 packages/client/ui-settings-account/src/client/assets/onboarding-expanded.svg create mode 100644 packages/client/ui-settings-account/src/client/assets/onboarding-office.svg create mode 100644 packages/client/ui-settings-account/src/client/assets/onboarding-recharge-dark.png create mode 100644 packages/client/ui-settings-account/src/client/assets/onboarding-recharge-zh-dark.png create mode 100644 packages/client/ui-settings-account/src/client/assets/onboarding-recharge-zh.png create mode 100644 packages/client/ui-settings-account/src/client/assets/onboarding-recharge.png create mode 100644 packages/client/ui-settings-account/src/client/assets/onboarding-welcome-dark.png create mode 100644 packages/client/ui-settings-account/src/client/assets/onboarding-welcome-zh-dark.png create mode 100644 packages/client/ui-settings-account/src/client/assets/onboarding-welcome-zh.png create mode 100644 packages/client/ui-settings-account/src/client/assets/onboarding-welcome.png create mode 100644 packages/client/ui-settings-account/src/client/locales/onboarding.ts create mode 100644 packages/client/ui-settings-account/src/client/onboarding-balance.ts create mode 100644 packages/client/ui-settings-account/src/client/onboarding-contract.ts create mode 100644 packages/client/ui-settings-account/src/client/onboarding-credentials.ts create mode 100644 packages/client/ui-settings-account/src/client/onboarding-state.ts create mode 100644 packages/client/ui-settings-account/src/client/overlay-inert.ts create mode 100644 packages/client/ui-settings-account/src/onboarding-settings.ts create mode 100644 packages/client/ui-settings-account/tests/account-refresh.client.spec.ts create mode 100644 packages/client/ui-settings-account/tests/desktop-onboarding-entry.client.spec.tsx create mode 100644 packages/client/ui-settings-account/tests/desktop-onboarding.client.spec.tsx create mode 100644 packages/client/ui-settings-account/tests/expected/onboarding-credit-en.txt create mode 100644 packages/client/ui-settings-account/tests/expected/onboarding-credit.txt create mode 100644 packages/client/ui-settings-account/tests/expected/onboarding-preferences.json create mode 100644 packages/client/ui-settings-account/tests/expected/onboarding-process-en.txt create mode 100644 packages/client/ui-settings-account/tests/expected/onboarding-process.txt create mode 100644 packages/client/ui-settings-account/tests/expected/onboarding-purpose-en.txt create mode 100644 packages/client/ui-settings-account/tests/expected/onboarding-purpose.txt create mode 100644 packages/client/ui-settings-account/tests/expected/onboarding-welcome-en.txt create mode 100644 packages/client/ui-settings-account/tests/expected/onboarding-welcome.txt create mode 100644 packages/client/ui-settings-account/tests/onboarding-credentials.client.spec.ts create mode 100644 packages/client/ui-settings-account/tests/onboarding-state.client.spec.ts create mode 100644 packages/client/ui-settings-account/tests/onboarding-surface.client.spec.tsx create mode 100644 packages/client/ui-theme/src/styles/onboarding.css diff --git a/.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.i18n.yaml index 744cb14c8d..40700024d0 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.md -2026-09-08-desktop-welcome-window-material.md: 4ee5f6d2934378b06754c4a8f134a4ca07a80840 -2026-09-08-desktop-welcome-window-material.zh.md: 1689af8481925c787c1d2412173fbd7d8c2f4b64 +2026-09-08-desktop-welcome-window-material.md: e3961481f8e39afee0550798975f22564f885799 +2026-09-08-desktop-welcome-window-material.zh.md: 17504368e80b36ddd3e103eed812754ca9c85c1e diff --git a/.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.md b/.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.md index 4ee5f6d293..e3961481f8 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.md +++ b/.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.md @@ -12,7 +12,7 @@ The desktop welcome design blurs the desktop behind its entire window. Chromium The [Electron welcome window](../../../../apps/desktop/src/welcome-window.ts) owns native material and window controls. Its independently bundled React renderer supplies the entry, account sign-in states and API-key form. It shares the `StateDot` loading component with the Web UI without booting the Web plugin graph. The Desktop build emits local JavaScript and CSS under `lib/welcome`, included in packaged applications; the document keeps its network-denying content security policy. A narrow preload supplies typed shell copy, a write-only key operation, and a skip operation; IPC rejects other windows and subframes. Each sandboxed preload is bundled independently because Electron’s restricted require cannot load sibling chunks. The Electron main process authenticates through the shared Web launch URL and resolves the official provider's reference through the existing settings and credential RPC methods. The [thin Web wrapper](2026-09-10-desktop-web-wrapper.md) owns the HTTP server; onboarding adds no Host endpoint or child IPC operation. Responses contain metadata or a safe outcome, never a key or private provider diagnostics. -Cold startup opens the entry when neither an account credential nor a model API key is stored. Saving enters the workspace after persistence succeeds; skipping enters without saving a draft or a completion flag. The next process launch checks the credential store again. The Desktop preload marker suppresses only the Web credential step; settings and the welcome notice remain mounted. Other native shells can set `credentialOnboarding` through the Models Host plugin, which publishes that boolean through the page-injection event before dialogs register. The module graph carries package identities rather than arbitrary Host config, so a Host row alone does not configure its Client half. Startup reads account presence and API-key configuration independently. Account sign-in enters the workspace; sign-out reopens the welcome window only when no model API key remains configured. +Cold startup opens the entry when neither an account credential nor a model API key is configured. Saving enters the workspace after persistence succeeds; skipping enters without saving a draft or a credential-entry completion flag. The next process launch checks credentials again. The Desktop preload marker suppresses the automatic Web credential step and welcome notice; settings and explicit API-key editing remain available. The [Desktop onboarding decision](../feature/2026-09-16-desktop-onboarding.md) owns the separate device-local introduction after account login. Other native shells can disable only the credential step through the Models Host plugin's `credentialOnboarding` page-injection value. The module graph carries package identities rather than arbitrary Host config, so a Host row alone does not configure its Client half. The [account provider](2026-09-14-deepseek-account-login.md) supplies login and sign-out state while preserving independently stored API keys. The generated project follows declared workspace dependencies because pnpm’s hoist index alone can omit configured plugins. @@ -24,7 +24,7 @@ The welcome flow shares the Desktop backend controller with startup recovery. Th **A CSS-only backdrop filter.** It cannot blur other application windows or the desktop, so it cannot provide the required native effect. -**Persisting a completed-onboarding flag.** A skip is valid only for the current process; retaining it would suppress the next cold-start choice while the user still has no credentials. +**Persisting a credential-entry completion flag.** A native credential skip is valid only for the current process; retaining it would suppress the next cold-start choice while the user still has no credentials. The account introduction's separate completion flag does not bypass this authentication decision. **Keeping both native and Web credential dialogs.** Their separate completion state would ask for a key twice after a native skip. The composition assigns credential onboarding to one owner. @@ -34,4 +34,4 @@ The welcome flow shares the Desktop backend controller with startup recovery. Th ## Consequences -Native blur strength and font fallback vary by system. macOS uses menu vibrancy with the onboarding window tint: 40% white in light mode and 50% rgb(24 25 28) in dark mode. Native materials own the blur strength. Windows compositing needs platform QA. Owner-local text expectations cover both pages and locales, and a built-Host acceptance test proves credential persistence across restarts. Account sign-out preserves independently configured API keys. Onboarding presentation produces no Session events. +Native blur strength and font fallback vary by system. The main window uses the shared sidebar vibrancy; full-window onboarding retains a transparent renderer without an additional tint. Windows compositing needs platform QA. Owner-local text expectations cover both pages and locales, and built-Host acceptance covers credential persistence across restarts. Account sign-out preserves independent API keys. Onboarding presentation produces no Session events. diff --git a/.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.zh.md b/.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.zh.md index 1689af8481..17504368e8 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.zh.md @@ -12,7 +12,7 @@ Status: implemented [Electron 欢迎窗口](../../../../apps/desktop/src/welcome-window.ts)负责原生材质和窗口控件。独立打包的 React 渲染器提供入口、账号登录状态和 API Key 表单。它与 Web UI 共用 `StateDot` 加载组件,不启动 Web 插件图。Desktop 构建将本地 JavaScript 和 CSS 输出到 `lib/welcome`,并纳入应用安装包;文档继续使用禁止网络访问的内容安全策略。窄接口 preload 提供强类型桌面文案、仅写密钥操作和跳过操作;IPC 拒绝其他窗口和子 frame。每个沙箱 preload 都独立打包,因为 Electron 受限的 require 无法加载同目录中的拆分模块。Electron 主进程通过共享 Web 启动 URL 认证,并通过现有设置和凭证 RPC 方法解析官方提供方的引用。[Web 薄壳](2026-09-10-desktop-web-wrapper.zh.md)负责 HTTP 服务;引导不增加 Host 端点或子进程 IPC 操作。响应只包含元数据或安全结果,不包含密钥或提供方的私有诊断。 -冷启动在既无已存账号凭证、也无已配置的模型 API Key 时打开入口。保存会在持久化成功后进入工作区;跳过会直接进入,不保存草稿或完成标记。下次进程启动时会重新检查凭证存储。Desktop preload 标记只抑制 Web 凭证步骤;设置和欢迎须知仍然挂载。其他原生壳可以通过 Models Host 插件设置 `credentialOnboarding`,由页面注入事件在注册弹窗前发布该布尔值。模块图传递的是包标识,而不是任意 Host 配置,因此单独配置 Host 行不会配置其 Client 半部。启动分别读取账号凭证是否存在和 API Key 配置状态。账号登录后进入工作区;退登后仅在没有已配置的模型 API Key 时重新打开欢迎窗口。 +冷启动在账号凭证与模型 API Key 均未配置时打开入口。保存会在持久化成功后进入工作区;跳过会直接进入,不保存草稿或凭证入口完成标记。下次进程启动时会重新检查凭证。Desktop preload 标记抑制自动 Web 凭证步骤和欢迎须知;设置与显式 API Key 编辑仍然可用。[Desktop 引导决策](../feature/2026-09-16-desktop-onboarding.zh.md)负责账号登录后独立的设备本地介绍流程。其他原生壳可以通过 Models Host 插件注入页面的 `credentialOnboarding` 值仅禁用凭证步骤。模块图传递的是包标识,而不是任意 Host 配置,因此单独配置 Host 行不会配置其 Client 半部。[账号提供方](2026-09-14-deepseek-account-login.zh.md)提供登录与退出登录状态,同时保留独立存储的 API Key。 生成的开发项目沿已声明的 workspace 依赖建立链接,因为单独依赖 pnpm 的提升索引会漏掉配置插件。 @@ -24,7 +24,7 @@ Desktop 在打开欢迎窗口前读取共享的 `locale.preference`。Client 在 **仅使用 CSS 背景滤镜。** 它无法模糊其他应用窗口或桌面,因此不能提供所需的原生效果。 -**持久化已完成引导标记。** 跳过只对当前进程有效;保存该标记会在用户仍无凭证时抑制下次冷启动的选择。 +**持久化凭证入口完成标记。** 跳过原生凭证入口只对当前进程有效;保留标记会在用户仍无凭证时抑制下次冷启动选择。账号介绍流程的独立完成标记不会绕过此认证判断。 **同时保留原生与 Web 凭证弹窗。** 两者独立的完成状态会在跳过原生引导后再次要求输入密钥。组合将凭证引导交给单一归属方。 @@ -34,4 +34,4 @@ Desktop 在打开欢迎窗口前读取共享的 `locale.preference`。Client 在 ## 后果 -原生模糊强度和字体回退因系统而异。macOS 使用 menu vibrancy,叠加 onboarding 的窗口背景色:浅色模式为 40% 白色,深色模式为 50% rgb(24 25 28)。模糊强度由原生材质控制。Windows 合成效果需要平台验证。所属目录内的文本预期覆盖两页和两种语言,构建后 Host 的验收测试证明凭证可跨重启持久化。账号退登保留独立配置的 API Key。引导展示不会生成 Session 事件。 +原生模糊强度和字体回退因系统而异。主窗口沿用共享的 sidebar vibrancy;全窗引导保持渲染器透明,不额外叠加底色。Windows 合成效果需要平台验证。所属目录内的文本预期覆盖两页和两种语言,构建后 Host 的验收覆盖凭证跨重启持久化。账号退出登录保留独立的 API Key。引导展示不会生成 Session 事件。 diff --git a/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.i18n.yaml b/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.i18n.yaml index 32ae05e725..7f7911e2fc 100644 --- a/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.md -2026-09-11-desktop-mandatory-update-client.md: ff55f30836a37e49229adac6414cb3a98bca4d6c -2026-09-11-desktop-mandatory-update-client.zh.md: 6c17a9fbd0ce2741b635b742be682d4c5fd9f8c6 +2026-09-11-desktop-mandatory-update-client.md: 85a2584ed495d93eeca12f83a191713f87241920 +2026-09-11-desktop-mandatory-update-client.zh.md: 1c12cd9b4adf04e4b26820dce96c30bbc55fa5c1 diff --git a/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.md b/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.md index ff55f30836..85a2584ed4 100644 --- a/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.md +++ b/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.md @@ -10,6 +10,8 @@ A local business server does not deliver remote minimum-version policy. The Desk ## Decision +Native update overlays own their parent-visibility subscription until closure. On macOS, hiding a parent hides its child windows without closing them; showing the parent does not restore those children. The overlay waits for both document readiness and a visible parent, then restores itself on subsequent parent show events. Keeping this in the shared overlay preserves ordinary and mandatory presentation without coupling update policy to onboarding or clearing an active block. + The shell polls the guest protocol in the [API proposal](../../proposed/feature/2026-09-08-desktop-mandatory-update-api.md), independently of business traffic. Configuration and application identity are embedded at packaging; packaged applications ignore environment overrides. The [Desktop README](../../../../apps/desktop/README.md) owns configuration fields and defaults. Packaging requires the policy origin selected by the updater deployment before artifact preparation or signing. Test uses isolated Feishu authentication; production remains anonymous. The test login window opens detached DevTools on F12 while retaining its sandbox, navigation allowlist, and memory-only Session. Only unpackaged development can omit policy configuration. This prevents a missing policy setting from silently producing a release without mandatory checks or a test build querying production policy. A coordinator owns one immutable installed-client identity, one in-flight request, a deadline, interval jitter, and bounded failure backoff. Manual checks bypass scheduling but join in-flight work. Disposal aborts the request, awaits settlement, and prevents late publication. A flattened `40005` establishes blocking independently of optional title, detail, and download-page fields; localized fallback text and a retry action remain available, while an absent or unapproved page is not offered. Transport, JSON, business-code, or HTTP failures cannot clear a known block. Only a valid fresh no-force response clears it. Same-version offline persistence is not implemented because its product behavior remains undecided. diff --git a/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.zh.md b/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.zh.md index 6c17a9fbd0..1c12cd9b4a 100644 --- a/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.zh.md +++ b/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.zh.md @@ -10,6 +10,8 @@ Status: implemented ## 决策 +原生更新浮层在关闭前持有父窗口可见性监听。在 macOS 上,隐藏父窗口会隐藏但不会关闭其子窗口;重新显示父窗口不会恢复这些子窗口。浮层等待文档就绪且父窗口可见,并在后续父窗口显示事件中恢复自身。由公共浮层处理这一行为,既保持普通更新和强更的展示,也不将更新策略与 onboarding 耦合或清除有效阻塞。 + 壳独立于业务流量轮询[接口提案](../../proposed/feature/2026-09-08-desktop-mandatory-update-api.zh.md)中的游客协议。配置和应用身份在打包时嵌入;打包应用忽略环境覆盖。[Desktop README](../../../../apps/desktop/README.zh.md)负责配置字段与默认值。打包在准备产物或签名前要求提供由 updater 部署环境选定的策略源站。测试环境使用隔离的飞书鉴权;正式环境保持匿名。测试登录窗口按 F12 会打开独立的 DevTools,同时保留沙箱、导航允许列表和仅驻内存的 Session。只有未打包开发模式可以省略策略配置。这避免因遗漏策略配置而静默产出没有强更检查的发布包,或让测试包查询正式环境策略。 协调器拥有一份不可变的已安装客户端身份、单一在途请求、截止时间、间隔抖动和有上限的失败退避。手动检查绕过调度,但复用在途请求。dispose(资源释放)中止请求、等待结算,并阻止迟到发布。扁平化 `40005` 建立阻塞不依赖可选的标题、详情和下载页面字段;本地化兜底文案与重试操作仍可使用,缺失或未获批准的页面地址不会展示。传输、JSON、业务码或 HTTP 错误都不能清除已知阻塞。只有新的有效无需强更响应才能清除它。同版本离线持久化未实现,因为其产品行为尚未确定。 diff --git a/.agents/notes/implemented/feature/2026-09-16-desktop-onboarding.i18n.yaml b/.agents/notes/implemented/feature/2026-09-16-desktop-onboarding.i18n.yaml new file mode 100644 index 0000000000..a3d0a5fb91 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-16-desktop-onboarding.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-16-desktop-onboarding.md +2026-09-16-desktop-onboarding.md: 4dbce360b5a3c9db472cc3444b46fc6af0f382b6 +2026-09-16-desktop-onboarding.zh.md: a3d5f305c69701e58cc3f9a983e28911911f99a0 diff --git a/.agents/notes/implemented/feature/2026-09-16-desktop-onboarding.md b/.agents/notes/implemented/feature/2026-09-16-desktop-onboarding.md new file mode 100644 index 0000000000..4dbce360b5 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-16-desktop-onboarding.md @@ -0,0 +1,33 @@ +# Agent Note: Device-local Desktop onboarding + +Status: implemented + +English | [中文](2026-09-16-desktop-onboarding.zh.md) + +## Problem + +Account users need an introduction to credit and display choices after native authentication. The native credential entry already has a separate startup responsibility; repeating browser onboarding or treating missing account data as a new user would couple presentation to authentication failures. + +## Decision + +The [account UI](../../../../packages/client/ui-settings-account/README.md#desktop-onboarding) mounts one shell overlay only when the Desktop preload marker exists, an account credential is stored, and the local introduction is unfinished. Host settings own versioned progress and completion for the installation, without a Platform completion flag or per-account history. Restart resumes the saved step; signing out hides the introduction without erasing it. A signed-out installation with configured model credentials records API-key completion after applying detailed process, detailed usage, and enabled developer tools. Account sign-in discards a failed API-key completion write so retry cannot bypass the required welcome page. + +Welcome requires an explicit start. The credit page always appears, including for positive balances. Only a ready balance with no positive wallet triggers the additional continue-without-credit confirmation; failed or pending queries never imply zero. Returning from native recharge retains the credit page while the account owner refreshes details. Closing the application during recharge retains the credit step. + +Purpose and process selections persist as drafts. Office-only completion selects compact presentation. Development or both purposes require a process choice. Skipping any supported step selects detailed process with compact usage and developer tools off. Completion applies Chat and shared developer-tool preferences before saving the done marker so a refused preference write cannot silently finish onboarding. Compact, detailed, and expanded persist their corresponding `ui-chat.transcriptView` values. + +The existing [native welcome decision](../architecture/2026-09-08-desktop-welcome-window-material.md) owns credential entry, IPC restrictions, and window material. Desktop suppresses automatic Web credential onboarding while retaining explicit API-key editing. Onboarding creates no workspace, demonstration task, or Session event. Static Figma illustrations use transparent, palette-compressed 3× PNG assets; welcome layers are merged with local sidebar blur; recharge uses a 70% opaque foreground window with local blur of the covered illustration. The account package owns the full-window overlay and its control styling; shared primitives retain generic behavior. Brand headings load a bundled, licensed Montserrat WOFF2 font through the Web entry. Choices retain focus during writes, reduced-motion preferences disable animation, and the application remains inert until the exit overlay unmounts. The confirmation dialog blurs its source page instead of sampling another backdrop in the translucent window. + +## Alternatives considered + +**Cloud or per-account completion.** The current release requires device-local progress and has no account completion API. Reusing login state as completion would conflate successful authentication with finished setup. + +**Skipping credit for positive balances or waiting for recharge confirmation.** The introduction explains billing independently of wallet state. Waiting for a balance query would turn return navigation into a network-dependent action without establishing whether a payment completed. + +**Duplicating preference controls.** Onboarding writes work details, usage, and developer tools through their existing owners. + +## Consequences + +An existing API-key installation without an onboarding completion marker also receives these defaults on its first upgraded launch, replacing any prior Chat display, usage, and developer-tools preferences. Installations already marked complete retain their preferences. + +One installation's completion applies across account changes, and another installation runs its own introduction. Preference and completion writes are ordered but are not one transaction; retry can reapply the same preference after a failed completion write. Completed installations do not reapply preferences on subsequent launches. The [controller tests](../../../../packages/client/ui-settings-account/tests/onboarding-state.client.spec.ts) and [UI tests](../../../../packages/client/ui-settings-account/tests/desktop-onboarding.client.spec.tsx) own state and interaction coverage; production authorization and payment behavior remain separate integration validation. diff --git a/.agents/notes/implemented/feature/2026-09-16-desktop-onboarding.zh.md b/.agents/notes/implemented/feature/2026-09-16-desktop-onboarding.zh.md new file mode 100644 index 0000000000..a3d5f305c6 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-16-desktop-onboarding.zh.md @@ -0,0 +1,33 @@ +# Agent Note: 设备本地 Desktop 引导 + +Status: implemented + +[English](2026-09-16-desktop-onboarding.md) | 中文 + +## 问题 + +账号用户在完成原生认证后,需要了解额度并选择展示方式。原生凭证入口已经承担独立的启动职责;重复运行浏览器引导,或将缺失的账号数据当成新用户,会把展示流程与认证故障耦合。 + +## 决策 + +[账号 UI](../../../../packages/client/ui-settings-account/README.zh.md#desktop-onboarding)仅在存在 Desktop preload 标记、已存储账号凭证且本地引导未完成时,挂载一个 shell 覆盖层。Host 设置持有当前安装的版本化进度与完成状态,不使用 Platform 完成标记或逐账号历史。重启恢复已保存的步骤;退出登录会隐藏引导,但不清除进度。未登录且已配置模型凭证的安装会记录 API Key 完成状态,并应用详细过程、详细用量和开启开发者工具的配置。账号登录时丢弃失败的 API Key 完成写入,避免重试绕过必需的欢迎页。 + +欢迎页必须显式开始。额度页始终出现,包括余额为正的情况。仅在余额已就绪且没有正余额钱包时,才额外确认是否继续而不充值;查询失败或等待中绝不表示余额为零。从原生充值页面返回后保留额度页,由账号属主在后台刷新资料。充值期间关闭应用会保留额度步骤。 + +用途和过程选择会作为草稿持久化。仅选办公用途时采用简洁展示并完成。选择开发或两种用途时必须选择过程详情。任意可跳过步骤均采用详细过程、简洁用量和关闭开发者工具。完成时先应用 Chat 和共享开发者工具偏好,再保存完成标记,避免偏好写入被拒绝后静默完成引导。compact、detailed 和 expanded 各自持久化为对应的 `ui-chat.transcriptView` 值。 + +既有[原生欢迎决策](../architecture/2026-09-08-desktop-welcome-window-material.zh.md)负责凭证入口、IPC 限制和窗口材质。Desktop 抑制自动 Web 凭证引导,保留显式 API Key 编辑。引导不创建工作区、示例任务或 Session 事件。静态 Figma 插图使用透明、调色板压缩的 3× PNG 资源;欢迎页图层合并并烘焙侧栏局部模糊;充值页前景窗口不透明度为 70%,仅烘焙其覆盖插图区域的模糊。账号包负责整窗覆盖层及其控件样式,共享基础组件保留通用行为。品牌标题通过 Web 入口加载附带许可的 Montserrat WOFF2 字体。保存选择时保留焦点,减少动态效果偏好会禁用动画,应用在退出覆盖层卸载前保持不可交互。确认弹窗模糊源页面,避免在半透明窗口内再次采样背景。 + +## 考虑过的替代方案 + +**云端或逐账号完成状态。** 当前版本要求设备本地进度,且没有账号完成 API。将登录状态用作完成标记,会混淆认证成功与设置完成。 + +**正余额时跳过额度页,或等待充值确认。** 引导独立于钱包状态说明计费。等待余额查询会使返回导航依赖网络,却无法确定是否完成支付。 + +**重复实现偏好控制。** 引导通过现有属主写入工作过程、用量与开发者工具配置。 + +## 后果 + +尚无引导完成标记的已有 API Key 安装,在升级后首次启动时也会应用这些默认值,覆盖之前的 Chat 展示、用量和开发者工具偏好;已经标记完成的安装保留原偏好。 + +同一安装的完成状态适用于切换后的账号,另一安装则运行自己的引导。偏好与完成状态按顺序写入,但不属于同一事务;完成标记写入失败后,重试可能再次应用同一偏好。已完成引导的安装不会在后续启动时重新应用偏好。[控制器测试](../../../../packages/client/ui-settings-account/tests/onboarding-state.client.spec.ts)与 [UI 测试](../../../../packages/client/ui-settings-account/tests/desktop-onboarding.client.spec.tsx)负责状态和交互覆盖;生产授权与支付行为仍需独立的集成验证。 diff --git a/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.i18n.yaml b/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.i18n.yaml index 6485deaade..a97cefb3e3 100644 --- a/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-17-developer-tools-settings.md 2026-09-17-developer-tools-settings.md: 5ae8861efa701042e0730310ddb23f6f559d14d7 -2026-09-17-developer-tools-settings.zh.md: e7a9d3c28d53ba9fe14c970bd48f2a6031248e58 +2026-09-17-developer-tools-settings.zh.md: d6e8e138a371d73c70d06c26188375950eee3278 diff --git a/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.zh.md b/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.zh.md index e7a9d3c28d..d6e8e138a3 100644 --- a/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.zh.md +++ b/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.zh.md @@ -12,7 +12,7 @@ Status: implemented 设置域内的 `ctx.configForms.developerTools` 偏好对象使用共享条目表单,提供一个已接受的 `ui-settings.enabled` 偏好。最初默认关闭的选择用于降低日常任务的复杂度;[偏好解析后默认开启开发者工具](2026-09-20-developer-tools-default-on.zh.md)取代该默认值并负责启动时的启用策略。Web 和桌面端的通用设置提供同一个开关。现有设置传输负责验证、有序写入、持久化和恢复;回环 Web 和桌面端跟随 Host 接受的状态。远程 Web 持有一个所有消费者共享的浏览器本地可观察值,刷新后重置,不发送 Host 写入。 -关闭时隐藏 Trajectory View,在没有可见 View 声明工具调用检查能力时隐藏 Inspect 操作,可见 View 少于两个时隐藏会话 View 标签栏,隐藏新会话预设入口和「允许切换agent模式」设置行,并省略改动文件卡片及其摘要读取。其他插件注册的 View 保持可用。在 Trajectory 中关闭开关会激活 Chat。预设组合、保存的默认值、Session 事件和显式交付卡片保持不变。此偏好是展示策略,不是 Host 授权。 +关闭时隐藏 Trajectory View,在没有可见 View 声明工具调用检查能力时隐藏 Inspect 操作,可见 View 少于两个时隐藏会话 View 标签栏,隐藏新会话预设入口和「允许切换 agent 模式」设置行,并省略改动文件卡片及其摘要读取。其他插件注册的 View 保持可用。在 Trajectory 中关闭开关会激活 Chat。预设组合、保存的默认值、Session 事件和显式交付卡片保持不变。此偏好是展示策略,不是 Host 授权。 插件组装层将共享偏好转换为组件自身的能力(`showCodeDiff`、`showPresetPicker` 和 `interactivePreview`)。Conversation 根据可见目标的 `toolCallFocus` 提供检查回调;Chat 不再指定目标名称。内置 HTML 渲染器使用 DOMPurify 清理完整文档并防护解析变异。关闭时保留根节点样式,移除主动文档、资源提示、SVG 动画及所有 `href` 属性,再放入不授予沙箱权限的 iframe,并通过 CSP 禁止脚本和外部资源。不读取关联文件。开启时保留不透明源的脚本 Blob 渲染器及其有界关联文件读取。切换模式会替换浏览上下文并清理待处理读取。[文档预览决策](../architecture/2026-09-08-document-preview-operations.zh.md)和[文件系统读取权限](../architecture/2026-09-09-workspace-file-read-authority.zh.md)继续定义高级渲染与 Host 读取访问。 diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 6a224c8ba2..950dbe1210 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/desktop/README.md -README.md: fb83b0fc66c281a596d743b561e0ee33f32b2c04 -README.zh.md: 490eff4b35a3e7b52108ff529ed9d3b432f68bb3 +README.md: 5218650f9ed437ec2794f03bb3c54169720b8bf1 +README.zh.md: 573b76ce2b706d5c4ccd106b757a9f41ba80117a diff --git a/apps/desktop/README.md b/apps/desktop/README.md index fb83b0fc66..5218650f9e 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -348,6 +348,8 @@ An unpacked artifact contains Electron, the materialized dsh production tree, pn ## Updates +Native update overlays wait for a ready document and a visible parent, and reappear when that parent is shown again. Closing an overlay releases its background blur, input interception, and parent listeners. The [local window qualification](tests/README.md#verification-overlay) exercises these transitions without starting a workspace. + Packaged applications check fixed Nightly asynchronously at startup. Ordinary polling uses a ten-minute base interval with independently sampled ±20% jitter. Each check failure doubles the base delay up to one hour; success resets it. The randomized delay is bounded by that cap and starts after all joined callers settle. Foreground and system-resume checks respect the same monotonic deadline; the localized Check for Updates menu item, including in the Windows caption's Application menu, runs immediately and joins an in-flight check. A newly received mandatory policy also requests an immediate feed check. Automatic checks never open dialogs or download packages. Manual checks display checking, failure, or no-update feedback with the installed version. Ordinary update dialogs fade in and out in place; consecutive prompts replace the card content and reset its scroll position while retaining the backdrop and background blur. `DSH_DESKTOP_UPDATE_CHECK_INTERVAL_MS` configures the ordinary base interval, and `DSH_DESKTOP_UPDATE_CHECK_MAX_BACKOFF_MS` configures the cap; both accept integer milliseconds from 1000 through 2147483647, with the cap at least the interval. An omitted cap defaults to the larger of one hour and the interval. `DSH_DESKTOP_UPDATE_CHECK_JITTER` sets the fractional jitter from 0 through 1, defaulting to `0.2`; the final delay is at least one second and never exceeds the cap. These settings do not change mandatory-policy polling or authorize download retries. @@ -414,6 +416,8 @@ An unpackaged Electron process uses `.desktop-build/development/project` under i - The desktop shell shares sessions, settings, credentials, workspaces, and storage under `$DSH_HOME` with CLI dsh, while executable packages, plugin activation, and lockfiles remain separate. - Unpackaged startup on an Electron win32-arm64 host now succeeds, but the payload remains x64: the architecture check in `packages/skill/tool-workspace-dependencies/src/index.ts` compares the recorded payload architecture against the host `process.arch`, so the `load_workspace_dependencies` tool can still reject the primary runtime. +The app-only `dshOnboarding.hasApiKey()` preload method returns the welcome backend’s current API-key presence boolean; native login and onboarding share credential discovery, and only the owned application main frame may invoke it. + Sign in opens the configured platform page in the system browser. The Host owns PKCE and a temporary loopback callback, saves the credential before entering the workspace, and redirects the browser to the platform completion page. Opened and copied authorization links carry the effective Desktop theme as `theme=light` or `theme=dark`; `system` resolves at the time of the action. Cancel withdraws the local attempt even if the platform page later approves it. Settings offers Account sign-out; without a separate API key, sign-out returns to the welcome window. Successful browser sign-in switches Welcome to the workspace without activating the application; the completion page’s dsh://open link brings it to the foreground. Packaged applications register dsh://open to show the window without passing credentials. On macOS, the development launcher prepares an ad-hoc-signed `Harness Dev.app` under `.desktop-build/development`, declares `dsh` in its Info.plist, and registers it with Launch Services. It loads the current workspace and records the selected development home, browser-data path, and debug settings for cold starts. Starting this bundle makes it the default `dsh://` handler; starting the packaged application registers the packaged handler again. The generated bundle does not contain account tokens and requires the workspace and prepared runtime to remain available. When an expired account returns to Welcome, the main process retains a one-time notification until the renderer requests it over window-owned IPC. Reloading Welcome does not repeat the notice; manual sign-out and cold startup do not create it. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 490eff4b35..573b76ce2b 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -348,6 +348,8 @@ macOS 打包在组装 App 时、代码签名前写入 `Contents/Resources/app-up ## 更新 +原生更新浮层在文档就绪且父窗口可见时显示,并在父窗口再次显示时恢复。关闭浮层会释放背景模糊、输入拦截和父窗口监听。[本地窗口验证](tests/README.zh.md#verification-overlay)无需启动工作区即可检查这些切换。 + 打包应用在启动后异步检查固定 Nightly。常规轮询以十分钟为基础间隔,每次独立采样 ±20% 的随机抖动。每次检查失败将基础延迟翻倍,上限为一小时;成功后重置。随机延迟不超过该上限,并从全部复用调用结算后开始计时。本地化的“检查更新…”菜单项(Windows 可从顶栏的“应用”菜单进入)立即执行,并复用正在进行的检查。回到前台和系统恢复时遵守相同的单调时钟截止时间。新收到的强更策略也会立即请求检查更新清单。自动检查从不弹窗或下载安装包。手动检查显示正在检查、失败或包含已安装版本号的无更新反馈。常规更新弹窗原位渐入渐出;连续弹窗替换卡片内容并重置其滚动位置,保留蒙层与背景模糊。 `DSH_DESKTOP_UPDATE_CHECK_INTERVAL_MS` 配置常规基础间隔,`DSH_DESKTOP_UPDATE_CHECK_MAX_BACKOFF_MS` 配置上限;两者均接受 1000 至 2147483647 的整数毫秒数,且上限不能小于间隔。省略上限时取一小时与间隔中的较大值。`DSH_DESKTOP_UPDATE_CHECK_JITTER` 配置 0 至 1 的抖动比例,默认 `0.2`;最终延迟至少一秒,且不超过上限。这些配置不改变强更策略轮询,也不授权下载重试。 @@ -414,6 +416,8 @@ node apps/desktop/node_modules/pnpm/bin/pnpm.mjs --dir apps/desktop run test:upd - 桌面壳与 CLI dsh 共享 `$DSH_HOME` 下的会话、设置、凭据、工作区和存储,但可执行包、插件激活和锁文件彼此隔离。 - 在 Electron win32-arm64 宿主上,未打包启动现在可以成功,但载荷仍为 x64:`packages/skill/tool-workspace-dependencies/src/index.ts` 的架构校验会把载荷记录的架构与宿主 `process.arch` 比较,因此 `load_workspace_dependencies` 工具仍可能拒绝 primary runtime。 +仅向应用提供的 `dshOnboarding.hasApiKey()` preload 方法返回欢迎后端当前的 API Key 存在状态布尔值;原生登录与引导共用凭证发现逻辑,且只有受管理的应用主 frame 可以调用。 + 登录会在系统浏览器中打开配置的平台页面。Host 负责 PKCE 和临时本机回调,在进入工作区前保存凭证,再将浏览器跳转到平台完成页。打开和复制的授权链接通过 `theme=light` 或 `theme=dark` 携带当前生效的 Desktop 主题;`system` 在执行操作时解析。即使平台页面随后批准,取消仍会撤销本地尝试。设置中的账号页面提供退出;没有独立 API Key 时,退出后返回欢迎窗。浏览器登录成功后,Welcome 切换到工作区但不激活应用;完成页的 dsh://open 链接负责将客户端置前。打包应用注册 dsh://open,只显示窗口而不传递凭证。macOS 开发启动器在 `.desktop-build/development` 下准备经临时签名的 `Harness Dev.app`,在 Info.plist 中声明 `dsh` 并注册到 Launch Services。它加载当前工作区,并记录选定的开发 home、浏览器数据路径和调试设置,以供冷启动使用。启动此应用会将其设为 `dsh://` 默认处理程序;启动打包应用会重新注册打包版处理程序。生成的应用包不包含账号 token,依赖工作区和已准备的运行环境继续存在。 账号失效并返回 Welcome 时,主进程保留一次性通知,直到渲染器通过所属窗口的 IPC 领取。重新加载 Welcome 不会重复提示;主动退登和冷启动不会生成该通知。 diff --git a/apps/desktop/src/ipc.ts b/apps/desktop/src/ipc.ts index cf03b7e6c2..7347f7c714 100644 --- a/apps/desktop/src/ipc.ts +++ b/apps/desktop/src/ipc.ts @@ -7,6 +7,8 @@ import type { DesktopBrowserBridge } from '@deepseek-ai/dsh-client-ui-sidebar-br export const DESKTOP_IPC = { boot: 'dsh-desktop:boot', enterWorkspace: 'dsh-desktop:enter-workspace', + onboardingActive: 'dsh-desktop:onboarding-active', + onboardingApiKey: 'dsh-desktop:onboarding-api-key', bootFailed: 'dsh-desktop:boot-failed', browserAcquire: 'dsh-desktop:browser-acquire', browserRelease: 'dsh-desktop:browser-release', diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 2d9b8d4e32..8b8dc6b1b1 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -679,6 +679,21 @@ async function main(): Promise { assertProductSender(event) return presentDesktopUpdate(updates.state) }) + ipcMain.handle(DESKTOP_IPC.onboardingApiKey, async (event) => { + assertProductSender(event) + return (await readWelcomeState()).hasApiKey + }) + ipcMain.on(DESKTOP_IPC.onboardingActive, (event, active: unknown) => { + const window = mainWindow + if (window === undefined || window.isDestroyed() || event.sender !== window.webContents + || event.senderFrame !== window.webContents.mainFrame + || !event.senderFrame.url.startsWith(`${SCHEME}://app/`) || typeof active !== 'boolean') return + window.setMinimumSize(active ? 960 : 520, 600) + if (active) { + const { width, height } = window.getBounds() + if (width < 960) window.setSize(960, height) + } + }) ipcMain.handle(DESKTOP_IPC.updatesOpen, async (event) => { assertProductSender(event) await openUpdatePrompt() diff --git a/apps/desktop/src/preload-app.ts b/apps/desktop/src/preload-app.ts index 13bbc20690..bd6b09ed4c 100644 --- a/apps/desktop/src/preload-app.ts +++ b/apps/desktop/src/preload-app.ts @@ -26,6 +26,10 @@ function createProductApi(): DshDesktopProductApi { } if (location.protocol === `${SCHEME}:` && location.hostname === 'app') { + contextBridge.exposeInMainWorld('dshOnboarding', { + hasApiKey: () => ipcRenderer.invoke(DESKTOP_IPC.onboardingApiKey) as Promise, + setActive: (active: boolean) => { ipcRenderer.send(DESKTOP_IPC.onboardingActive, active) }, + }) ipcRenderer.on(DESKTOP_IPC.enterWorkspace, () => { const body = document.body const previous = body.getAttribute('tabindex') diff --git a/apps/desktop/src/update-overlay.ts b/apps/desktop/src/update-overlay.ts index a6e341ecb9..d61e817a31 100644 --- a/apps/desktop/src/update-overlay.ts +++ b/apps/desktop/src/update-overlay.ts @@ -6,7 +6,7 @@ import { BrowserWindow } from 'electron' * @param preload - Isolated shell-only preload. * @param title - Localized window title. * @param nativeModal - Use a native modal; false keeps overlays out of macOS sheets. - * @returns A transparent child that follows its parent's content bounds and releases its listeners on close. + * @returns A transparent child that follows its parent's bounds and visibility after loading, releasing its listeners on close. */ export function createUpdateOverlay(parent: BrowserWindow, preload: string, title: string, nativeModal = true): BrowserWindow { const window = new BrowserWindow({ @@ -23,7 +23,7 @@ export function createUpdateOverlay(parent: BrowserWindow, preload: string, titl parent.webContents.on('before-input-event', blockInput) window.once('closed', () => { parent.off('focus', focus) - parent.webContents.off('before-input-event', blockInput) + if (!parent.isDestroyed()) parent.webContents.off('before-input-event', blockInput) }) } const follow = (): void => { if (!window.isDestroyed()) window.setBounds(parent.getContentBounds()) } @@ -42,7 +42,13 @@ export function createUpdateOverlay(parent: BrowserWindow, preload: string, titl }).catch((error: unknown) => { console.warn('desktop update: could not blur background', error) }) window.once('closed', () => { closed = true; unblur() }) window.once('closed', () => { parent.off('move', follow); parent.off('resize', follow) }) - window.once('ready-to-show', () => { if (!window.isDestroyed()) window.show() }) + let ready = false + const show = (): void => { + if (ready && !window.isDestroyed() && !parent.isDestroyed() && parent.isVisible()) window.show() + } + parent.on('show', show) + window.once('closed', () => { parent.off('show', show) }) + window.once('ready-to-show', () => { ready = true; show() }) window.setMenu(null) window.webContents.setWindowOpenHandler(() => ({ action: 'deny' })) return window diff --git a/apps/desktop/tests/README.i18n.yaml b/apps/desktop/tests/README.i18n.yaml index 6dd6065d3b..624fb8630e 100644 --- a/apps/desktop/tests/README.i18n.yaml +++ b/apps/desktop/tests/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/desktop/tests/README.md -README.md: a2684a2bced4326bdc316ff56d80003061d0136d -README.zh.md: fba5b65aa41aa14b9971f80676c8f0fef4d7be9e +README.md: 6064f4d3b9c6cf96be9051eb1006c124865fca26 +README.zh.md: d72883d0fabd3176d266a6e7215c08a82d6a1ee0 diff --git a/apps/desktop/tests/README.md b/apps/desktop/tests/README.md index a2684a2bce..6064f4d3b9 100644 --- a/apps/desktop/tests/README.md +++ b/apps/desktop/tests/README.md @@ -10,10 +10,24 @@ Installed qualification can opt into `DSH_DESKTOP_UPDATE_JOURNAL_DIR`, an absolu ## Table of Contents +- [Native overlay visibility](#verification-overlay) - [Evidence](#verification-evidence) - [Manual walkthrough](#verification-interactive) - [Open verification](#verification-open) + + +## Native overlay visibility + +On macOS, reuse the cached Electron runtime after compiling the Desktop Host sources: + +```sh +pnpm exec tsc -b apps/desktop/tsconfig.host.json +apps/desktop/.desktop-build/targets/mac-arm64/electron/Electron.app/Contents/MacOS/Electron apps/desktop/tests/fixtures/update-overlay-visibility.mjs +``` + +The fixture owns a unique profile and writes `result.json` under `.desktop-build/qualification/update-overlay-*`. It compares native visibility, background blur, and listener cleanup with the owner-local expected output for parent hide/show and document readiness while the parent is hidden. It uses no network, product login, or dsh Host. This qualifies native window restoration rather than the full first-login flow. + ## Manual walkthrough diff --git a/apps/desktop/tests/README.zh.md b/apps/desktop/tests/README.zh.md index fba5b65aa4..d72883d0fa 100644 --- a/apps/desktop/tests/README.zh.md +++ b/apps/desktop/tests/README.zh.md @@ -10,10 +10,24 @@ ## 目录 +- [原生浮层可见性](#verification-overlay) - [验证证据](#verification-evidence) - [手动演练](#verification-interactive) - [待验证事项](#verification-open) + + +## 原生浮层可见性 + +在 macOS 上,编译 Desktop Host 源码后复用缓存的 Electron 运行时: + +```sh +pnpm exec tsc -b apps/desktop/tsconfig.host.json +apps/desktop/.desktop-build/targets/mac-arm64/electron/Electron.app/Contents/MacOS/Electron apps/desktop/tests/fixtures/update-overlay-visibility.mjs +``` + +夹具使用独立 profile,在 `.desktop-build/qualification/update-overlay-*` 下写入 `result.json`。它对照所属测试目录中的预期输出,检查父窗口隐藏/显示、父窗口隐藏期间文档就绪两种顺序中的原生可见性、背景模糊和监听清理。它不使用网络、产品登录或 dsh Host。这项验证覆盖原生窗口恢复,不覆盖完整的首次登录流程。 + ## 手动演练 diff --git a/apps/desktop/tests/expected/update-overlay-visibility.json b/apps/desktop/tests/expected/update-overlay-visibility.json new file mode 100644 index 0000000000..6efbafdc4b --- /dev/null +++ b/apps/desktop/tests/expected/update-overlay-visibility.json @@ -0,0 +1,15 @@ +[ + { + "initiallyVisible": true, + "loaded": { "parent": true, "overlay": true, "filter": "blur(2px)" }, + "restored": { "parent": true, "overlay": true, "filter": "blur(2px)" }, + "released": { "filter": "none", "showListenersRestored": true, "keyboardListeners": 0 } + }, + { + "initiallyVisible": false, + "loaded": { "parent": false, "overlay": false, "filter": "blur(2px)" }, + "restored": { "parent": true, "overlay": true, "filter": "blur(2px)" }, + "released": { "filter": "none", "showListenersRestored": true, "keyboardListeners": 0 } + }, + { "parentDestroyed": true, "overlayDestroyed": true } +] diff --git a/apps/desktop/tests/fixtures/update-overlay-visibility.mjs b/apps/desktop/tests/fixtures/update-overlay-visibility.mjs new file mode 100644 index 0000000000..77b2c6f08d --- /dev/null +++ b/apps/desktop/tests/fixtures/update-overlay-visibility.mjs @@ -0,0 +1,95 @@ +/** Native overlay visibility qualification without a Host, network, or user profile. */ +import assert from 'node:assert/strict' +import { once } from 'node:events' +import { mkdirSync, mkdtempSync } from 'node:fs' +import { readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { app, BrowserWindow } from 'electron' +import { createUpdateOverlay } from '../../lib/types/update-overlay.js' + +const directory = fileURLToPath(new URL('../../.desktop-build/qualification/', import.meta.url)) +mkdirSync(directory, { recursive: true }) +const root = mkdtempSync(join(directory, 'update-overlay-')) +mkdirSync(join(root, 'profile')) +app.setPath('userData', join(root, 'profile')) +app.on('window-all-closed', () => {}) + +async function main() { + const cases = [] + const windows = [] + const closeWindows = () => { + for (const window of [...windows].reverse()) if (!window.isDestroyed()) window.destroy() + } + let phase = 'application ready' + const deadline = setTimeout(() => { + console.error(`Update overlay qualification exceeded 30 seconds: ${phase}`) + closeWindows() + app.exit(1) + }, 30_000) + + async function qualify(initiallyVisible, closeParentFirst = false) { + const parent = new BrowserWindow({ show: initiallyVisible, width: 800, height: 600 }) + windows.push(parent) + phase = `parent load (${initiallyVisible})` + await parent.loadURL('data:text/html,

Update overlay visibility

') + const initialShowListeners = parent.listenerCount('show') + const overlay = createUpdateOverlay(parent, undefined, 'Overlay qualification', false) + windows.push(overlay) + const ready = once(overlay, 'ready-to-show') + const initiallyShown = initiallyVisible ? once(overlay, 'show') : undefined + phase = `overlay load (${initiallyVisible})` + await overlay.loadURL('data:text/html,

Update confirmation

') + phase = `overlay ready (${initiallyVisible})` + await ready + await initiallyShown + // A renderer round trip observes the CSS request already queued by createUpdateOverlay. + const filter = () => parent.webContents.executeJavaScript('getComputedStyle(document.body).filter') + const loaded = { parent: parent.isVisible(), overlay: overlay.isVisible(), filter: await filter() } + if (initiallyVisible) { + const hidden = once(parent, 'hide') + parent.hide() + await hidden + } + const shown = once(parent, 'show') + phase = `overlay restored (${initiallyVisible})` + parent.show() + await shown + const restoredFilter = await filter() + const restored = { parent: parent.isVisible(), overlay: overlay.isVisible(), filter: restoredFilter } + phase = `overlay release (${initiallyVisible})` + if (closeParentFirst) { + parent.destroy() + if (!overlay.isDestroyed()) overlay.destroy() + cases.push({ parentDestroyed: parent.isDestroyed(), overlayDestroyed: overlay.isDestroyed() }) + return + } + overlay.destroy() + const released = { filter: await filter(), showListenersRestored: parent.listenerCount('show') === initialShowListeners, + keyboardListeners: parent.webContents.listenerCount('before-input-event') } + parent.hide() + parent.show() + cases.push({ initiallyVisible, loaded, restored, released }) + parent.destroy() + } + + try { + await app.whenReady() + await qualify(true) + await qualify(false) + await qualify(true, true) + await writeFile(join(root, 'result.json'), `${JSON.stringify(cases, null, 2)}\n`) + const expected = JSON.parse(await readFile(new URL('../expected/update-overlay-visibility.json', import.meta.url), 'utf8')) + assert.deepEqual(cases, expected) + console.log(`Update overlay qualification passed: ${root}`) + } catch (error) { + console.error(error) + process.exitCode = 1 + } finally { + clearTimeout(deadline) + closeWindows() + app.exit(process.exitCode ?? 0) + } +} + +void main().catch(error => { console.error(error); app.exit(1) }) diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index 9cd00f74c4..a91c21e53a 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -80,6 +80,8 @@ const harness = await vi.hoisted(async () => { readonly focus = vi.fn() readonly restore = vi.fn() readonly setSize = vi.fn() + readonly getBounds = vi.fn(() => ({ x: 0, y: 0, width: 800, height: 700 })) + readonly setMinimumSize = vi.fn() readonly setTitleBarOverlay = vi.fn() readonly setVibrancy = vi.fn() readonly setBackgroundColor = vi.fn() @@ -844,6 +846,39 @@ describe('desktop main startup', () => { expect(callback).toHaveBeenLastCalledWith({}) }) + it('shares login key discovery with onboarding and rejects foreign renderers', async () => { + await readyForUpdate() + const window = harness.windows[0]! + const handler = harness.handlers.get(DESKTOP_IPC.onboardingApiKey)! + const event = { sender: window.webContents, senderFrame: window.webContents.mainFrame } + await vi.waitFor(async () => { expect(await handler(event)).toBe(true) }) + harness.hosts.at(-1)!.fetch.mockResolvedValueOnce(Response.json({ hasApiKey: false })) + await expect(handler(event)).resolves.toBe(false) + await expect(handler({ ...event, sender: {} })).rejects.toThrow() + }) + + it('enlarges only an active onboarding window and keeps its size after completion', async () => { + await import('../src/main.ts') + await harness.preparing.promise + const window = harness.windows[0]! + const listener = harness.ipcOn.mock.calls.find(([channel]) => channel === DESKTOP_IPC.onboardingActive)![1] + const event = { sender: window.webContents, senderFrame: window.webContents.mainFrame } + expect(window.setMinimumSize).not.toHaveBeenCalled() + listener({ ...event, sender: {} }, true) + listener(event, 'true') + expect(window.setMinimumSize).not.toHaveBeenCalled() + listener(event, true) + expect(window.setMinimumSize).toHaveBeenLastCalledWith(960, 600) + expect(window.setSize).toHaveBeenLastCalledWith(960, 700) + window.setSize.mockClear() + window.getBounds.mockReturnValue({ x: 0, y: 0, width: 1200, height: 800 }) + listener(event, true) + expect(window.setSize).not.toHaveBeenCalled() + listener(event, false) + expect(window.setMinimumSize).toHaveBeenLastCalledWith(520, 600) + expect(window.setSize).not.toHaveBeenCalled() + }) + it('registers the window-owned directory picker during startup and rejects foreign callers', async () => { await import('../src/main.ts') await harness.preparing.promise diff --git a/apps/desktop/tests/preload-app.spec.ts b/apps/desktop/tests/preload-app.spec.ts index 16c94456a4..f1f76185fb 100644 --- a/apps/desktop/tests/preload-app.spec.ts +++ b/apps/desktop/tests/preload-app.spec.ts @@ -17,6 +17,24 @@ vi.mock('../src/preload-mandatory-overlay.ts', () => ({ installMandatoryUpdateOv afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); vi.clearAllMocks(); vi.resetModules() }) +it('reads only native login API-key presence through the onboarding bridge', async () => { + vi.stubGlobal('location', new URL('dsh-app://app/')) + electron.ipcRenderer.invoke.mockResolvedValueOnce(true) + await import('../src/preload-app.ts') + const api = electron.contextBridge.exposeInMainWorld.mock.calls.find(([name]) => name === 'dshOnboarding')?.[1] as { hasApiKey(): Promise } + expect(await api.hasApiKey()).toBe(true) + expect(electron.ipcRenderer.invoke).toHaveBeenCalledWith(DESKTOP_IPC.onboardingApiKey) +}) + +it('exposes onboarding size activation to the application document', async () => { + vi.stubGlobal('location', new URL('dsh-app://app/')) + await import('../src/preload-app.ts') + const api = electron.contextBridge.exposeInMainWorld.mock.calls.find(([name]) => name === 'dshOnboarding')?.[1] as { setActive(active: boolean): void } + api.setActive(true) + api.setActive(false) + expect(electron.ipcRenderer.send.mock.calls).toEqual([[DESKTOP_IPC.onboardingActive, true], [DESKTOP_IPC.onboardingActive, false]]) +}) + it('limits product documents to update status and a native confirmation action', async () => { vi.stubGlobal('location', new URL('dsh-app://app/index.html')) await import('../src/preload-app.ts') diff --git a/apps/desktop/tests/update-dialog.spec.ts b/apps/desktop/tests/update-dialog.spec.ts index 781fd7317e..aa22934eb1 100644 --- a/apps/desktop/tests/update-dialog.spec.ts +++ b/apps/desktop/tests/update-dialog.spec.ts @@ -23,6 +23,7 @@ const fixture = await vi.hoisted(async () => { constructor(readonly options: unknown) { super(); windows.push(this) } getContentBounds() { return { x: 10, y: 20, width: 900, height: 650 } } isDestroyed() { return this.destroyed } + isVisible() { return true } destroy() { this.destroyed = true; this.emit('closed') } setMenu() {} } diff --git a/apps/desktop/tests/update-overlay.spec.ts b/apps/desktop/tests/update-overlay.spec.ts index 9f4a79878d..b53db17669 100644 --- a/apps/desktop/tests/update-overlay.spec.ts +++ b/apps/desktop/tests/update-overlay.spec.ts @@ -1,25 +1,96 @@ import { EventEmitter } from 'node:events' -import { expect, it, vi } from 'vitest' +import { afterEach, expect, it, vi } from 'vitest' import type { BrowserWindow, BrowserWindowConstructorOptions } from 'electron' import { createUpdateOverlay } from '../src/update-overlay.ts' const native = vi.hoisted(() => ({ create: vi.fn<(options: BrowserWindowConstructorOptions) => object>() })) vi.mock('electron', () => ({ BrowserWindow: function (options: object) { return native.create(options) } })) -it('keeps the macOS mandatory overlay stationary and blocks parent keyboard input until close', () => { - const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') +afterEach(() => { vi.restoreAllMocks() }) + +function visibilityFixture(visible = true) { const parent = Object.assign(new EventEmitter(), { + visible, getContentBounds: () => ({ x: 0, y: 0, width: 1000, height: 700 }), webContents: Object.assign(new EventEmitter(), { insertCSS: vi.fn(async () => 'blur'), removeInsertedCSS: vi.fn(async () => {}) }), - isDestroyed: () => false, + isDestroyed: (): boolean => false, + isVisible: () => parent.visible, }) const window = Object.assign(new EventEmitter(), { + destroyed: false, webContents: { setWindowOpenHandler: vi.fn() }, show: vi.fn(), focus: vi.fn(), - setMenu: vi.fn(), setBounds: vi.fn(), isDestroyed: () => false, + setMenu: vi.fn(), setBounds: vi.fn(), isDestroyed: () => window.destroyed, }) native.create.mockReturnValue(window) + createUpdateOverlay(parent as unknown as BrowserWindow, 'owned', 'Update required', false) + return { parent, window } +} + +it('restores a ready overlay each time its parent is shown and releases visibility ownership on close', async () => { + const { parent, window } = visibilityFixture() + window.emit('ready-to-show') + expect(window.show).toHaveBeenCalledOnce() + for (let index = 0; index < 2; index++) { + parent.visible = false + parent.emit('hide') + parent.visible = true + parent.emit('show') + await Promise.resolve() + } + expect(window.show).toHaveBeenCalledTimes(3) + await Promise.resolve() + expect(parent.webContents.removeInsertedCSS).not.toHaveBeenCalled() + window.destroyed = true + window.emit('closed') + expect(parent.listenerCount('show')).toBe(0) + expect(parent.webContents.removeInsertedCSS).toHaveBeenCalledWith('blur') + parent.emit('show') + expect(window.show).toHaveBeenCalledTimes(3) +}) + +it('waits for both a visible parent and a ready document, in either order', async () => { + for (const readyFirst of [true, false]) { + const { parent, window } = visibilityFixture(false) + if (readyFirst) window.emit('ready-to-show') + else { parent.visible = true; parent.emit('show') } + expect(window.show).not.toHaveBeenCalled() + if (readyFirst) { parent.visible = true; parent.emit('show') } + else window.emit('ready-to-show') + await Promise.resolve() + expect(window.show).toHaveBeenCalledOnce() + window.destroyed = true + window.emit('closed') + } +}) + +it('does not show or retain blur when closed before its document and CSS insertion settle', async () => { + const { parent, window } = visibilityFixture(false) + window.destroyed = true + window.emit('closed') + window.emit('ready-to-show') + parent.visible = true + parent.emit('show') + await Promise.resolve() + expect(window.show).not.toHaveBeenCalled() + expect(parent.listenerCount('show')).toBe(0) + expect(parent.webContents.removeInsertedCSS).toHaveBeenCalledWith('blur') +}) + +it('releases a macOS overlay after its parent has already been destroyed', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + const { parent, window } = visibilityFixture() + await Promise.resolve() + vi.spyOn(parent, 'isDestroyed').mockReturnValue(true) + Object.defineProperty(parent, 'webContents', { get() { throw new Error('Object has been destroyed') } }) + window.destroyed = true + expect(() => window.emit('closed')).not.toThrow() + for (const event of ['focus', 'move', 'resize', 'show']) expect(parent.listenerCount(event)).toBe(0) +}) + +it('keeps the macOS mandatory overlay stationary and blocks parent keyboard input until close', () => { + const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') try { - createUpdateOverlay(parent as unknown as BrowserWindow, 'owned', 'Update required', false) + const { parent, window } = visibilityFixture() expect(native.create).toHaveBeenLastCalledWith(expect.objectContaining({ modal: false, transparent: true, frame: false })) const event = { preventDefault: vi.fn() } parent.webContents.emit('before-input-event', event) diff --git a/apps/web/package.json b/apps/web/package.json index f8c064f3db..d161811059 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -59,6 +59,7 @@ "vite": "^6.0.0", "vitest": "^4.1.8", "ws": "8.21.0", + "@deepseek-ai/dsh-client-ui-theme": "workspace:*", "@deepseek-ai/dsh-launch-environment": "workspace:*", "@deepseek-ai/dsh-workspace-changes": "workspace:*", "@deepseek-ai/dsh-subprocess-local": "workspace:*", diff --git a/apps/web/src/main.ts b/apps/web/src/main.ts index 1a84cd5f63..5f4786acb7 100644 --- a/apps/web/src/main.ts +++ b/apps/web/src/main.ts @@ -1,4 +1,5 @@ /** Browser entry for the Web client. */ +import '@deepseek-ai/dsh-client-ui-theme/brand-font.css' import { AppWebEntry, applyIndexInjections } from '@deepseek-ai/dsh-client-web' interface DesktopBootGlobal { diff --git a/apps/web/tests/desktop-onboarding.e2e.ts b/apps/web/tests/desktop-onboarding.e2e.ts new file mode 100644 index 0000000000..a5de95364e --- /dev/null +++ b/apps/web/tests/desktop-onboarding.e2e.ts @@ -0,0 +1,423 @@ +// Shipped Loader composition, real account/settings APIs, and an isolated external Platform fixture. +import { createServer, type Server } from 'node:http' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { connectDesktopWelcome } from '../../desktop/src/welcome-backend.ts' +import { chromium, type Browser, type Page } from 'playwright' +import { afterEach, beforeEach, describe, expect, it, onTestFailed } from 'vitest' +import { credentialKey, credentialRef } from '@deepseek-ai/dsh-credentials' +import type {} from '@deepseek-ai/dsh-settings' +import { + acknowledgeReloadConnectionLoss, captureStableAria, compareOrRefreshGolden, + launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { REPO_ROOT, saveFailureShot, ZH_BROWSER_LOCALE } from './support.ts' + +const MODE = webSnapshotMode() +const EXPECTED = fileURLToPath(new URL('./expected/desktop-onboarding', import.meta.url)) +const NS = 'ui-settings-account' +const INITIAL = { version: 1, step: 'welcome', purpose: null, process: null, completion: null, usage: 'compact', developerTools: false } + +describe.skipIf(MODE === 'record')('web e2e: App-only desktop onboarding', () => { + let root: string + let server: Server + let origin: string + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + let balanceFailure = false + let balanceReads = 0 + let walletBalance = '0E-16' + let bonusBalance = '0' + let screenshots: string | undefined + + beforeEach(async () => { + balanceFailure = false + balanceReads = 0 + walletBalance = '0E-16' + bonusBalance = '0' + screenshots = undefined + root = await mkdtemp(join(tmpdir(), 'dsh-desktop-onboarding-')) + server = createServer((request, response) => { + if (request.url !== '/auth-api/v0/users/current' && request.url !== '/api/v0/users/get_user_summary') { + response.writeHead(404).end() + return + } + const balance = request.url === '/api/v0/users/get_user_summary' + if (balance) balanceReads++ + const value = balance + ? { bonus_wallets: [{ currency: 'CNY', balance: bonusBalance }], normal_wallets: [{ currency: 'CNY', balance: walletBalance }] } + : { id: 'onboarding-fixture-user', email: 'f***@example.invalid', id_profile: { name: 'Onboarding Fixture' } } + response.setHeader('content-type', 'application/json') + response.end(JSON.stringify({ code: 0, data: { biz_code: balance && balanceFailure ? 17 : 0, biz_data: value } })) + }) + await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve) }) + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('fixture server address unavailable') + origin = `http://127.0.0.1:${address.port}` + const overlay = join(root, 'account.patch.yml') + await writeFile(overlay, `- id: deepseek-account\n config:\n platformOrigin: ${origin}\n allowLoopbackHttp: true\n`) + scaffold = await launchWebScaffold({ deepSeekMissingCredential: true, extraOverlayPath: overlay }) + await scaffold.ctx.credentials.modifyRecord(credentialKey('deepseek-account-platform', 'default'), async () => ({ + kind: 'grant', payload: { version: 1, issuer: origin, token: 'onboarding-fixture-token' }, + })) + browser = await chromium.launch() + }) + + afterEach(async () => { + try { await browser?.close() } finally { + try { await scaffold?.close() } finally { + try { + if (server?.listening) await new Promise((resolve, reject) => { + server.close((error) => { if (error) reject(error); else resolve() }) + }) + } finally { + if (root !== undefined) await rm(root, { recursive: true, force: true }) + } + } + } + }) + + async function desktopPage(locale = ZH_BROWSER_LOCALE): Promise { + const opened = await browser.newPage({ viewport: { width: 1280, height: 840 }, locale, reducedMotion: 'reduce' }) + const welcome = await connectDesktopWelcome(scaffold.authenticatedUrl, (input, init) => scaffold.hostFetch(input, init)) + await opened.exposeFunction('__onboardingHasApiKey', async () => (await welcome.read()).hasApiKey) + await opened.addInitScript(() => { + Object.defineProperty(globalThis, 'dshOnboarding', { value: { + hasApiKey: () => (globalThis as typeof globalThis & { __onboardingHasApiKey(): Promise }).__onboardingHasApiKey(), + setActive: () => {}, + } }) + Object.defineProperty(globalThis, 'dshDesktop', { value: { protocolVersion: 1 } }) + Object.defineProperty(globalThis, 'dshPlatform', { value: { + open: async () => {}, setBounds: async () => {}, close: async () => {}, + } }) + }) + return opened + } + + async function snapshot(name: string): Promise { + await expect.poll(() => page.locator('[data-desktop-onboarding]').getAttribute('aria-busy')).toBe('false') + const aria = await captureStableAria(page, '[data-desktop-onboarding]', scaffold.workspaceCwd) + await mkdir(EXPECTED, { recursive: true }) + await compareOrRefreshGolden(join(EXPECTED, `${name}.expected.md`), aria, MODE) + if (MODE === 'refresh' && screenshots === undefined) { + const artifacts = join(REPO_ROOT, '.artifacts') + await mkdir(artifacts, { recursive: true }) + screenshots = await mkdtemp(join(artifacts, 'desktop-onboarding-')) + console.log(`Onboarding screenshots: ${screenshots}`) + } + for (const viewport of [ + { width: 880, height: 600 }, { width: 960, height: 600 }, { width: 1000, height: 780 }, { width: 1200, height: 920 }, + { width: 1440, height: 920 }, { width: 2200, height: 1200 }, + ]) { + await page.setViewportSize(viewport) + await page.evaluate(async () => { await document.fonts.ready }) + const geometry = await page.locator('[data-desktop-onboarding]').evaluate(region => ({ + scrollWidth: document.documentElement.scrollWidth, + viewportWidth: window.innerWidth, + clippedControls: [...region.querySelectorAll('h1, button, label')].filter((element) => { + const box = element.getBoundingClientRect() + const outside = box.left < 0 || box.top < 0 || box.right > window.innerWidth + 1 || box.bottom > window.innerHeight + 1 + return box.width > 0 && box.height > 0 && outside + }).map(element => element.textContent?.trim() || element.getAttribute('aria-label')), + })) + expect(geometry.scrollWidth, `${name} at ${viewport.width}: document width`).toBeLessThanOrEqual(geometry.viewportWidth) + expect(geometry.clippedControls, `${name} at ${viewport.width}: clipped controls`).toEqual([]) + const primary = page.locator('[class*="primaryAction"] > button') + if (await primary.count()) { + expect(await primary.evaluate((button) => { + const box = button.getBoundingClientRect() + return [2, box.height / 2, box.height - 2] + .every(y => button.contains(document.elementFromPoint(box.x + box.width / 2, box.y + y))) + }), `${name}: primary action receives clicks across its height`).toBe(true) + } + if (name.includes('-en-')) { + const headingFont = await page.locator('h1').evaluate(element => ({ + font: getComputedStyle(element).fontFamily, weight: getComputedStyle(element).fontWeight, + })) + expect(headingFont.font).toContain('Montserrat') + expect(headingFont.weight).toBe('300') + const brandWeights = await page.locator('h1 em').evaluateAll(elements => elements.map(element => getComputedStyle(element).fontWeight)) + expect(brandWeights.every(weight => weight === '500')).toBe(true) + expect(await page.evaluate(async () => (await document.fonts.load('500 28px Montserrat')).length)).toBeGreaterThan(0) + const copyFonts = await page.locator('[class*="heroDescription"], [class*="subtitle"], [class*="cardTitle"], [class*="cardDescription"]') + .evaluateAll(elements => elements.map(element => getComputedStyle(element).fontFamily)) + expect(copyFonts.every(font => font.includes('Montserrat'))).toBe(true) + const buttonFonts = await page.locator('[class*="navigation"] button').evaluateAll(elements => + elements.map(element => getComputedStyle(element).fontFamily)) + expect(buttonFonts.every(font => font.includes('Montserrat'))).toBe(true) + expect(await page.evaluate(async () => (await document.fonts.load('300 28px Montserrat')).length)).toBeGreaterThan(0) + } + if ((name === 'purpose' || name === 'process') && viewport.height > 760) { + const card = await page.locator('[class*="cardTitle"]').first().locator('..').boundingBox() + const action = await page.locator('[class*="primaryAction"]').boundingBox() + expect(card).not.toBeNull() + expect(action).not.toBeNull() + expect(action!.y - (card!.y + card!.height)).toBeCloseTo(name === 'process' ? 101.5 : 100, 0) + if (viewport.width === 1440 && viewport.height === 920) { + expect(card!.y).toBeCloseTo(name === 'purpose' ? 396.5 : 398, 0) + expect(card!.height).toBeCloseTo(222, 0) + } + } + if (name === 'process' && viewport.width === 960) { + const card = await page.getByRole('radio').first().boundingBox() + const action = await page.getByRole('button', { name: '进入应用', exact: true }).boundingBox() + expect(card?.x).toBeCloseTo(40, 0) + expect(card?.y).toBeCloseTo(244, 0) + expect(card?.height).toBeCloseTo(187, 0) + expect(action?.width).toBeCloseTo(240, 0) + expect(action!.y - (card!.y + card!.height)).toBeCloseTo(40, 0) + } + if (name === 'welcome' || name === 'credit') { + const illustrations = await page.locator('[class*="illustration"] > img:visible').evaluateAll(elements => elements.map((element) => { + const image = element as HTMLImageElement + const box = image.getBoundingClientRect() + return { + width: box.width, ratio: box.width / box.height, exportedRatio: image.naturalWidth / image.naturalHeight, + loaded: image.complete && image.naturalWidth > 0, + } + })) + expect(illustrations).toHaveLength(1) + for (const illustration of illustrations) { + expect(illustration.loaded).toBe(true) + expect(illustration.width).toBeGreaterThan(0) + expect(illustration.ratio).toBeCloseTo(illustration.exportedRatio, 2) + } + + } + if (screenshots !== undefined) await page.screenshot({ path: join(screenshots, `${name}-${viewport.width}x${viewport.height}.png`) }) + } + await page.setViewportSize({ width: 1280, height: 840 }) + } + + it('keeps Web unchanged, resumes App progress, and returns to credit even when balance refresh fails', async () => { + const web = await browser.newPage({ viewport: { width: 1280, height: 840 }, locale: ZH_BROWSER_LOCALE }) + try { + await web.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await web.getByText('选择一个工作区开始', { exact: true }).waitFor() + expect(await web.locator('[data-desktop-onboarding]').count()).toBe(0) + expect((scaffold.ctx.settings.describe().find(row => row.ns === NS)?.value as { step: string }).step).toBe('welcome') + } finally { await web.close() } + + page = await desktopPage() + tripwire = watchConsole(page) + onTestFailed(() => saveFailureShot(page, 'desktop-onboarding')) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.getByRole('button', { name: '开始设置', exact: true }).waitFor() + expect(await page.getByRole('button', { name: '跳过', exact: true }).count()).toBe(0) + expect(await page.locator('[data-desktop-onboarding]').count()).toBe(1) + await snapshot('welcome') + for (const platform of ['darwin', 'win32']) { + await page.evaluate((value) => { document.documentElement.dataset.platform = value }, platform) + expect(await page.locator('[data-desktop-onboarding]').evaluate(element => getComputedStyle(element).getPropertyValue('-webkit-app-region'))).toBe('none') + expect(await page.getByRole('button', { name: '开始设置', exact: true }).evaluate(element => getComputedStyle(element).getPropertyValue('-webkit-app-region'))).toBe(platform === 'darwin' ? 'no-drag' : 'none') + } + await page.evaluate(() => { delete document.documentElement.dataset.platform }) + await page.getByRole('button', { name: '开始设置', exact: true }).click() + await page.getByRole('heading', { name: '准备可用额度', exact: true }).waitFor() + expect(await page.getByRole('button', { name: '下一步', exact: true }) + .evaluate(element => getComputedStyle(element).backgroundColor)).toBe('rgb(255, 255, 255)') + await snapshot('credit') + const laterButton = page.getByRole('button', { name: '下一步', exact: true }) + await laterButton.hover() + const laterHover = await laterButton.evaluate(element => ({ + fill: getComputedStyle(element).backgroundColor, overlay: getComputedStyle(element).backgroundImage, + })) + expect(laterHover.fill).toBe('rgb(255, 255, 255)') + expect(laterHover.overlay).toContain('rgba(38, 49, 72, 0.06)') + if (screenshots !== undefined) await page.screenshot({ path: join(screenshots, 'credit-later-hover.png') }) + const warningsBefore = tripwire.warnings.length + const [balanceResponse] = await Promise.all([ + page.waitForResponse('**/api/account/getBalance'), + page.reload({ waitUntil: 'load' }), + ]) + await balanceResponse.finished() + acknowledgeReloadConnectionLoss(tripwire, warningsBefore) + await page.getByRole('heading', { name: '准备可用额度', exact: true }).waitFor() + expect(await page.getByRole('button', { name: '开始设置', exact: true }).count()).toBe(0) + await page.getByRole('button', { name: '下一步', exact: true }).click() + await page.getByRole('heading', { name: '暂时跳过充值?', exact: true }).waitFor() + expect(await page.locator('[data-desktop-onboarding]').evaluate(element => getComputedStyle(element).filter)).toBe('blur(2px)') + expect(await page.getByRole('dialog').evaluate(element => getComputedStyle(element.previousElementSibling!).backdropFilter)).toBe('none') + await page.getByRole('button', { name: '前往充值', exact: true }).click() + await page.getByRole('button', { name: '返回 DeepSeek Harness', exact: true }).waitFor() + expect((scaffold.ctx.settings.describe().find(row => row.ns === NS)?.value as { step: string }).step).toBe('credit') + balanceFailure = true + const readsBefore = balanceReads + await page.getByRole('button', { name: '返回 DeepSeek Harness', exact: true }).click() + await page.getByRole('heading', { name: '准备可用额度', exact: true }).waitFor() + await expect.poll(() => balanceReads).toBeGreaterThan(readsBefore) + await expect.poll(() => page.getByRole('button', { name: '下一步', exact: true }).isEnabled()).toBe(true) + await page.getByRole('button', { name: '下一步', exact: true }).click() + await page.getByRole('checkbox', { name: '办公与创作', exact: true }).waitFor() + await snapshot('purpose') + await page.emulateMedia({ reducedMotion: 'no-preference' }) + const development = page.getByRole('checkbox', { name: '代码与开发', exact: true }) + const description = page.locator('[class*="purposeCards"] > div').last().locator('[class*="cardDescription"]') + for (const selected of [true, false]) { + const box = await description.boundingBox() + if (box === null) throw new Error('development description is not visible') + await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2) + await expect.poll(() => development.isChecked()).toBe(selected) + await expect.poll(() => page.locator('[data-desktop-onboarding]').getAttribute('aria-busy')).toBe('false') + } + const title = await page.getByText('代码与开发', { exact: true }).boundingBox() + if (title === null) throw new Error('development card title is not visible') + await page.mouse.click(title.x + title.width / 2, title.y + title.height / 2) + await expect.poll(() => development.isChecked()).toBe(true) + const office = page.getByRole('checkbox', { name: '办公与创作', exact: true }) + await office.locator('..').click() + await expect.poll(() => office.isChecked()).toBe(true) + const icons = page.locator('[class*="purposeCards"] > div').first().locator('[class*="fileIcons"] > svg') + const iconMotion = await icons.evaluateAll(elements => elements.map(e => ({ + name: getComputedStyle(e).animationName, delay: getComputedStyle(e).animationDelay, + origin: getComputedStyle(e).transformOrigin, + }))) + expect(iconMotion.every(icon => icon.name.includes('iconEnter') && icon.origin.startsWith('0px'))).toBe(true) + expect(iconMotion.map(icon => icon.delay)).toEqual(['0s', '0.03s', '0.05s', '0.062s', '0.07s']) + const officeCard = page.locator('[class*="purposeCards"] > div').first() + await office.focus() + await page.keyboard.press('Tab') + await page.keyboard.press('Shift+Tab') + const focusStyles = await officeCard.evaluate((card) => { + const checkbox = card.querySelector('input')! + return { + drag: getComputedStyle(card).getPropertyValue('-webkit-app-region'), + card: getComputedStyle(card).outlineStyle, + checkbox: getComputedStyle(checkbox).outlineStyle, + indicator: getComputedStyle(checkbox.nextElementSibling!).outlineStyle, + } + }) + expect(focusStyles).toEqual({ drag: 'no-drag', card: 'solid', checkbox: 'none', indicator: 'none' }) + await expect.poll(() => officeCard.evaluate(card => getComputedStyle(card, '::after').opacity)).toBe('1') + await page.emulateMedia({ reducedMotion: 'reduce' }) + const purposeBorders = await page.locator('[class*="purposeCards"] > div').evaluateAll(cards => cards.map(card => ({ + gradient: getComputedStyle(card, '::after').backgroundImage, + opacity: getComputedStyle(card, '::after').opacity, + }))) + expect(purposeBorders[0]?.gradient).toContain('conic-gradient(') + expect(purposeBorders[0]?.gradient).toContain('rgb(42, 47, 182)') + expect(purposeBorders[1]?.gradient).toContain('rgb(57, 100, 254)') + expect(purposeBorders.map(border => border.opacity)).toEqual(['1', '1']) + const back = page.getByRole('button', { name: '上一步', exact: true }) + const restingBackColor = await back.evaluate(element => getComputedStyle(element).color) + await back.hover() + const hoverBack = await back.evaluate(element => ({ + text: getComputedStyle(element).color, + arrow: getComputedStyle(element.querySelector('span')!).backgroundColor, + })) + expect(hoverBack.text).not.toBe(restingBackColor) + expect(hoverBack.arrow).toBe(hoverBack.text) + if (screenshots !== undefined) await page.screenshot({ path: join(screenshots, 'purpose-selected-back-hover.png') }) + await page.getByRole('button', { name: '继续', exact: true }).click() + await page.getByRole('radiogroup').waitFor() + await page.getByRole('button', { name: '上一步', exact: true }).click() + await office.waitFor() + await page.emulateMedia({ reducedMotion: 'no-preference' }) + expect(await icons.evaluateAll(elements => elements.map(e => getComputedStyle(e).animationName))).toEqual(Array(5).fill('none')) + await page.emulateMedia({ reducedMotion: 'reduce' }) + await page.getByRole('button', { name: '继续', exact: true }).click() + await page.getByRole('radiogroup').waitFor() + await page.getByRole('radio', { name: /聚焦结果/ }).focus() + for (const [name, process] of [['关键细节', 'detailed'], ['完整过程', 'expanded']] as const) { + const [saved] = await Promise.all([ + page.waitForResponse('**/api/settings/mutate'), + page.keyboard.press('ArrowRight'), + ]) + await saved.finished() + const selected = page.getByRole('radio', { name: new RegExp(name) }) + await expect.poll(() => selected.getAttribute('aria-checked')).toBe('true') + await expect.poll(() => page.getByRole('button', { name: '进入应用', exact: true }).isEnabled()).toBe(true) + expect(scaffold.ctx.settings.describe().find(row => row.ns === NS)?.value).toMatchObject({ process }) + expect(await selected.evaluate(element => document.activeElement === element)).toBe(true) + } + await page.getByRole('button', { name: '进入应用', exact: true }).waitFor() + const processBorders = await page.getByRole('radio').evaluateAll(cards => cards.map(card => getComputedStyle(card, '::after').backgroundImage)) + expect(processBorders.every(border => border.startsWith('conic-gradient('))).toBe(true) + expect(new Set(processBorders).size).toBe(3) + await snapshot('process') + await page.getByRole('button', { name: '进入应用', exact: true }).click() + await page.locator('[data-desktop-onboarding]').waitFor({ state: 'detached' }) + expect(scaffold.ctx.settings.describe().find(row => row.ns === NS)?.value).toMatchObject({ step: 'done', process: 'expanded', usage: 'detailed', developerTools: true }) + expect(scaffold.ctx.settings.describe().find(row => row.ns === 'ui-chat')?.value).toMatchObject({ transcriptView: 'expanded', performanceUsage: 'detailed' }) + expect(scaffold.ctx.settings.describe().find(row => row.ns === 'ui-settings')?.value).toMatchObject({ enabled: true }) + const persisted = await readFile(join(scaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml'), 'utf8') + expect(persisted).toContain('id: ui-settings-account') + expect(persisted).toContain('expanded') + const warningsAfter = tripwire.warnings.length + await page.reload({ waitUntil: 'load' }) + acknowledgeReloadConnectionLoss(tripwire, warningsAfter) + await page.getByText('选择一个工作区开始', { exact: true }).waitFor() + expect(await page.locator('[data-desktop-onboarding]').count()).toBe(0) + expect(tripwire.pageErrors).toEqual([]) + await page.close() + }) + + it('keeps funded credit actions stable and rechecks on reload in English dark mode', async () => { + bonusBalance = '12.50' + await scaffold.ctx.settings.mutate('ui-theme', [{ op: 'set', path: ['preference'], value: 'dark' }]) + page = await desktopPage('en-US') + const response = page.waitForResponse('**/api/account/getBalance') + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await (await response).finished() + await snapshot('welcome-en-dark') + await page.getByRole('button', { name: 'Get started', exact: true }).click() + await snapshot('credit-funded-en-dark') + await page.getByRole('button', { name: 'Continue', exact: true }).click() + await page.getByRole('checkbox', { name: 'Office & creative work', exact: true }).waitFor() + await snapshot('purpose-en-dark') + await page.getByRole('button', { name: 'Back', exact: true }).click() + await page.getByRole('button', { name: 'Continue', exact: true }).waitFor() + await page.getByRole('button', { name: 'Back', exact: true }).click() + await page.getByRole('button', { name: 'Get started', exact: true }).waitFor() + expect(scaffold.ctx.settings.describe().find(row => row.ns === NS)?.value).not.toHaveProperty('creditFunded') + bonusBalance = '0' + const recheck = page.waitForResponse('**/api/account/getBalance') + await page.reload({ waitUntil: 'load' }) + await (await recheck).finished() + await page.getByRole('button', { name: 'Get started', exact: true }).click() + await page.getByRole('heading', { name: 'Add credits', exact: true }).waitFor() + expect(await page.getByRole('button', { name: 'Next', exact: true }) + .evaluate(element => getComputedStyle(element).backgroundColor)).toBe('rgb(72, 73, 76)') + await snapshot('credit-en-dark') + expect(await page.locator('[class*="backIcon"]').evaluate(element => getComputedStyle(element).maskImage)).toContain('data:image/svg+xml') + await page.getByRole('button', { name: 'Next', exact: true }).click() + await page.getByRole('button', { name: 'Got it', exact: true }).click() + await page.getByRole('checkbox', { name: 'Coding & development', exact: true }).locator('..').click() + await page.getByRole('button', { name: 'Continue', exact: true }).click() + await page.getByRole('radiogroup').waitFor() + await snapshot('process-en-dark') + await page.close() + }) + + it('applies API-key defaults without opening App onboarding or repeating after account login', async () => { + await scaffold.ctx.credentials.deleteRecord(credentialKey('deepseek-account-platform', 'default')) + await scaffold.ctx.credentials.set(credentialRef('DEEPSEEK_API_KEY'), 'fixture-api-key') + await scaffold.ctx.settings.mutate(NS, Object.entries(INITIAL).map(([field, value]) => ({ op: 'set', path: [field], value }))) + page = await desktopPage() + // Completion follows client activation, credential metadata, and three ordered Host writes. + const completed = page.waitForResponse((response) => { + if (!response.url().endsWith('/api/settings/mutate')) return false + const request = response.request().postDataJSON() as { payload: { args: { ns: string } } } + return request.payload.args.ns === NS + }) + const [response] = await Promise.all([completed, page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })]) + await response.finished() + expect(await response.json()).toMatchObject({ result: { ok: true } }) + expect((scaffold.ctx.settings.describe().find(row => row.ns === NS)?.value as { step: string }).step).toBe('done') + expect(await page.locator('[data-desktop-onboarding]').count()).toBe(0) + expect(scaffold.ctx.settings.describe().find(row => row.ns === NS)?.value).toMatchObject({ completion: 'api-key', process: 'detailed', usage: 'detailed', developerTools: true }) + expect(scaffold.ctx.settings.describe().find(row => row.ns === 'ui-chat')?.value).toMatchObject({ transcriptView: 'detailed', performanceUsage: 'detailed' }) + expect(scaffold.ctx.settings.describe().find(row => row.ns === 'ui-settings')?.value).toMatchObject({ enabled: true }) + await scaffold.ctx.credentials.modifyRecord(credentialKey('deepseek-account-platform', 'default'), async () => ({ + kind: 'grant', payload: { version: 1, issuer: origin, token: 'onboarding-fixture-token' }, + })) + await page.reload({ waitUntil: 'load' }) + await page.getByText('选择一个工作区开始', { exact: true }).waitFor() + expect(await page.locator('[data-desktop-onboarding]').count()).toBe(0) + await page.close() + }) +}) diff --git a/apps/web/tests/expected/desktop-onboarding/credit-en-dark.expected.md b/apps/web/tests/expected/desktop-onboarding/credit-en-dark.expected.md new file mode 100644 index 0000000000..82f3bcf136 --- /dev/null +++ b/apps/web/tests/expected/desktop-onboarding/credit-en-dark.expected.md @@ -0,0 +1,7 @@ +- region "Add credits": + - heading "Add credits" [level=1] + - paragraph: DeepSeek Harness charges credits based on the tokens used by models and tools. Add credits in advance to keep your tasks running without interruption. Your balance is only used while the agent is working on a task. + - button "Add credits" + - button "Next" + - button "Back" + - button "Skip" diff --git a/apps/web/tests/expected/desktop-onboarding/credit-funded-en-dark.expected.md b/apps/web/tests/expected/desktop-onboarding/credit-funded-en-dark.expected.md new file mode 100644 index 0000000000..0beb2890c9 --- /dev/null +++ b/apps/web/tests/expected/desktop-onboarding/credit-funded-en-dark.expected.md @@ -0,0 +1,7 @@ +- region "Add credits": + - heading "Add credits" [level=1] + - paragraph: DeepSeek Harness charges credits based on the tokens used by models and tools. Add credits in advance to keep your tasks running without interruption. Your balance is only used while the agent is working on a task. + - button "Continue" + - button "Add credits" + - button "Back" + - button "Skip" diff --git a/apps/web/tests/expected/desktop-onboarding/credit.expected.md b/apps/web/tests/expected/desktop-onboarding/credit.expected.md new file mode 100644 index 0000000000..0775ae3ff3 --- /dev/null +++ b/apps/web/tests/expected/desktop-onboarding/credit.expected.md @@ -0,0 +1,7 @@ +- region "准备可用额度": + - heading "准备可用额度" [level=1] + - paragraph: DeepSeek Harness 执行任务时,会根据模型与工具使用的 token 量扣除额度。请提前添加额度,避免任务在执行过程中被迫中断。你的余额只会在 Agent 实际执行任务时消耗。 + - button "去充值" + - button "下一步" + - button "上一步" + - button "跳过" diff --git a/apps/web/tests/expected/desktop-onboarding/process-en-dark.expected.md b/apps/web/tests/expected/desktop-onboarding/process-en-dark.expected.md new file mode 100644 index 0000000000..c8c5a79aa6 --- /dev/null +++ b/apps/web/tests/expected/desktop-onboarding/process-en-dark.expected.md @@ -0,0 +1,9 @@ +- region "How much detail would you like to see?": + - heading "How much detail would you like to see?" [level=1] + - paragraph: This only changes how progress is shown, not what DeepSeek Harness can do. + - radiogroup "How much detail would you like to see?": + - radio "Results only See just the results in a clean, simple interface" + - radio "Key details Focus on results, with only the most important steps and actions shown" + - radio "Full details See the full process for easier debugging and troubleshooting" + - button "Back" + - button "Skip" diff --git a/apps/web/tests/expected/desktop-onboarding/process.expected.md b/apps/web/tests/expected/desktop-onboarding/process.expected.md new file mode 100644 index 0000000000..a2c726dd07 --- /dev/null +++ b/apps/web/tests/expected/desktop-onboarding/process.expected.md @@ -0,0 +1,10 @@ +- region "你希望看到多少工作过程?": + - heading "你希望看到多少工作过程?" [level=1] + - paragraph: 这只会影响工作过程的展示方式,不会影响 DeepSeek Harness 的工作能力。 + - radiogroup "你希望看到多少工作过程?": + - radio "聚焦结果 只看结果,保持界面简洁明了" + - radio "关键细节 结果优先,仅展示重要步骤和关键操作" + - radio "完整过程 查看完整工作过程,方便调试和排查问题" [checked] + - button "进入应用" + - button "上一步" + - button "跳过" diff --git a/apps/web/tests/expected/desktop-onboarding/purpose-en-dark.expected.md b/apps/web/tests/expected/desktop-onboarding/purpose-en-dark.expected.md new file mode 100644 index 0000000000..4a9d6caf0a --- /dev/null +++ b/apps/web/tests/expected/desktop-onboarding/purpose-en-dark.expected.md @@ -0,0 +1,12 @@ +- region "What would you like DeepSeek Harness to help with?": + - heading "What would you like DeepSeek Harness to help with?" [level=1]: + - text: What would you like + - emphasis: DeepSeek Harness + - text: to help with? + - paragraph: We’ll tailor the interface and tools to fit the way you work. + - checkbox "Office & creative work" + - text: Office & creative work Edit documents, organize data, create presentations, and more + - checkbox "Coding & development" + - text: Coding & development Edit code, debug issues, run commands, manage project files, and more + - button "Back" + - button "Skip" diff --git a/apps/web/tests/expected/desktop-onboarding/purpose.expected.md b/apps/web/tests/expected/desktop-onboarding/purpose.expected.md new file mode 100644 index 0000000000..6230a763b2 --- /dev/null +++ b/apps/web/tests/expected/desktop-onboarding/purpose.expected.md @@ -0,0 +1,12 @@ +- region "你希望 DeepSeek Harness 帮你做什么?": + - heading "你希望 DeepSeek Harness 帮你做什么?" [level=1]: + - text: 你希望 + - emphasis: DeepSeek Harness + - text: 帮你做什么? + - paragraph: 我们会根据你的选择调整界面和工具,以更适合你的工作方式。 + - checkbox "办公与创作" + - text: 办公与创作 编辑文档、整理数据和制作演示文稿等 + - checkbox "代码与开发" + - text: 代码与开发 修改代码、调试问题、运行命令和管理项目文件等 + - button "上一步" + - button "跳过" diff --git a/apps/web/tests/expected/desktop-onboarding/welcome-en-dark.expected.md b/apps/web/tests/expected/desktop-onboarding/welcome-en-dark.expected.md new file mode 100644 index 0000000000..609856d7ec --- /dev/null +++ b/apps/web/tests/expected/desktop-onboarding/welcome-en-dark.expected.md @@ -0,0 +1,6 @@ +- region "Welcome to DeepSeek Harness": + - heading "Welcome to DeepSeek Harness" [level=1]: + - text: Welcome to + - emphasis: DeepSeek Harness + - paragraph: DeepSeek Harness works in a local folder and uses tools to read and write files on your computer. It can help you research and organize information, create documents and spreadsheets, write code, troubleshoot issues, and more. + - button "Get started" diff --git a/apps/web/tests/expected/desktop-onboarding/welcome.expected.md b/apps/web/tests/expected/desktop-onboarding/welcome.expected.md new file mode 100644 index 0000000000..65fe7c298b --- /dev/null +++ b/apps/web/tests/expected/desktop-onboarding/welcome.expected.md @@ -0,0 +1,6 @@ +- region "欢迎使用 DeepSeek Harness": + - heading "欢迎使用 DeepSeek Harness" [level=1]: + - text: 欢迎使用 + - emphasis: DeepSeek Harness + - paragraph: DeepSeek Harness 会以本地文件夹作为工作区,通过调用各种工具,读写本机文件,完成搜索整理资料、制作文档表格、编写代码、排查问题等各种任务。 + - button "开始设置" diff --git a/apps/web/tests/onboarding-native.e2e.ts b/apps/web/tests/onboarding-native.e2e.ts index c59c7f238a..33104d40c8 100644 --- a/apps/web/tests/onboarding-native.e2e.ts +++ b/apps/web/tests/onboarding-native.e2e.ts @@ -43,9 +43,11 @@ describe.skipIf(MODE === 'record').each([false, true])('web e2e: native credenti const credentials = await readFile(credentialPath, 'utf8') await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) const welcome = page.getByRole('dialog', { name: WELCOME_NOTICE_COPY.zh.title }) - await welcome.waitFor() - await welcome.getByRole('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel }).click() - await welcome.waitFor({ state: 'detached' }) + if (!desktop) { + await welcome.waitFor() + await welcome.getByRole('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel }).click() + await welcome.waitFor({ state: 'detached' }) + } for (const reload of [false, true]) { if (reload) { diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index d519b86ba3..6b43e9c4f0 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -82,6 +82,7 @@ "tests/declared-reasoning.e2e.ts", "tests/onboarding-deepseek-config.e2e.ts", "tests/onboarding-native.e2e.ts", + "tests/desktop-onboarding.e2e.ts", "tests/onboarding-usable-provider.e2e.ts", "tests/remote-welcome.e2e.ts", "tests/public-mount.e2e.ts", diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 9984bf38de..010e4307f7 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -29,6 +29,20 @@ function clientDocumentTitle(): Plugin { } } +/** Keep the redistribution license beside the bundled brand font. */ +function brandFontLicense(): Plugin { + return { + name: 'dsh-brand-font-license', + async generateBundle() { + this.emitFile({ + type: 'asset', + fileName: 'assets/fonts/Montserrat-OFL.txt', + source: await readFile(src('../../packages/client/ui-theme/src/styles/Montserrat-OFL.txt')), + }) + }, + } +} + /** Fail before a Vite dev or preview server can expose the boot-manifest-free shell. */ function rejectStandaloneServe(): Plugin { return { @@ -156,7 +170,7 @@ export default defineConfig({ // directory, and the served index resolves identically from the site root. base: './', plugins: [ - rejectStandaloneServe(), clientDocumentTitle(), react(), emitPreviewPage(), + rejectStandaloneServe(), clientDocumentTitle(), brandFontLicense(), react(), emitPreviewPage(), productWebBundleIsolation(src('../..'), src('.')), ], build: { diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 669913fd69..1da72467f0 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: e3161d1e72e5a586b9b9662e1f8005caa8d320ad -config-catalog.zh.md: eeba33336e3a6d3d699068835521d13867a34427 +config-catalog.md: 89487825486c21a2a2fee4c27d09c29aa0349b88 +config-catalog.zh.md: a9c2ddb817bc9e56cb73272c5e8d4d4f082d6881 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index e3161d1e72..8948782548 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -504,16 +504,45 @@ Source: [`packages/client/ui-plugin-manager/src/index.ts:15`](../packages/client ## `@deepseek-ai/dsh-client-ui-settings-account` ```ts config-catalog +/** Public contact options and live device-local onboarding progress. */ +export interface Config extends ContactConfig { + /** Onboarding progress format version. */ + version: Volatile<1> + /** Last accepted onboarding page. */ + step: Volatile + /** Selected work scenario. */ + purpose?: Volatile + /** Selected transcript detail. */ + process?: Volatile + /** Completion reason, absent until completion. */ + completion?: Volatile<'completed' | 'skipped' | 'api-key' | null | undefined> + /** Selected usage detail. */ + usage: Volatile<'compact' | 'detailed'> + /** Selected developer-tool visibility. */ + developerTools: Volatile +} + /** Questionnaire destination and its supported source option. */ -export interface Config { +export interface ContactConfig { /** HTTPS questionnaire URL; override for a test form. */ contactFormUrl: string /** Questionnaire source option; empty until Harness is supported by the form. */ contactSource: string } + +/** Persisted steps; a native top-up page leaves the durable step at credit. */ +export type OnboardingStep = 'welcome' | 'credit' | 'purpose' | 'process' | 'done' + +/** Work scenarios offered by the desktop introduction. */ +export type OnboardingPurpose = 'office' | 'development' | 'both' + +/** Work-detail mode applied to Chat when onboarding completes. */ +export type OnboardingProcess = 'compact' | 'detailed' | 'expanded' ``` -Source: [`packages/client/ui-settings-account/src/contact-config.ts:5`](../packages/client/ui-settings-account/src/contact-config.ts) +Depends on: `Volatile` (`@deepseek-ai/cordis`) + +Source: [`packages/client/ui-settings-account/src/index.ts:9`](../packages/client/ui-settings-account/src/index.ts) @@ -713,6 +742,8 @@ export interface Config { accountRequestHeaders?: Record /** Deadline for each platform HTTP request. */ requestTimeoutMs?: number + /** Deadline for recharge-wallet queries; timeout returns a failed balance outcome. */ + balanceTimeoutMs?: number /** Additional logout attempts after the first request fails, at most five. */ logoutMaxRetries?: number /** Delay before the first logout retry; each later delay doubles. */ diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index eeba33336e..a9c2ddb817 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -506,16 +506,45 @@ export interface Config { ## `@deepseek-ai/dsh-client-ui-settings-account` ```ts config-catalog +/** Public contact options and live device-local onboarding progress. */ +export interface Config extends ContactConfig { + /** Onboarding progress format version. */ + version: Volatile<1> + /** Last accepted onboarding page. */ + step: Volatile + /** Selected work scenario. */ + purpose?: Volatile + /** Selected transcript detail. */ + process?: Volatile + /** Completion reason, absent until completion. */ + completion?: Volatile<'completed' | 'skipped' | 'api-key' | null | undefined> + /** Selected usage detail. */ + usage: Volatile<'compact' | 'detailed'> + /** Selected developer-tool visibility. */ + developerTools: Volatile +} + /** Questionnaire destination and its supported source option. */ -export interface Config { +export interface ContactConfig { /** HTTPS questionnaire URL; override for a test form. */ contactFormUrl: string /** Questionnaire source option; empty until Harness is supported by the form. */ contactSource: string } + +/** Persisted steps; a native top-up page leaves the durable step at credit. */ +export type OnboardingStep = 'welcome' | 'credit' | 'purpose' | 'process' | 'done' + +/** Work scenarios offered by the desktop introduction. */ +export type OnboardingPurpose = 'office' | 'development' | 'both' + +/** Work-detail mode applied to Chat when onboarding completes. */ +export type OnboardingProcess = 'compact' | 'detailed' | 'expanded' ``` -来源: [`packages/client/ui-settings-account/src/contact-config.ts:5`](../packages/client/ui-settings-account/src/contact-config.ts) +依赖: `Volatile` (`@deepseek-ai/cordis`) + +来源: [`packages/client/ui-settings-account/src/index.ts:9`](../packages/client/ui-settings-account/src/index.ts) @@ -715,6 +744,8 @@ export interface Config { accountRequestHeaders?: Record /** Deadline for each platform HTTP request. */ requestTimeoutMs?: number + /** Deadline for recharge-wallet queries; timeout returns a failed balance outcome. */ + balanceTimeoutMs?: number /** Additional logout attempts after the first request fails, at most five. */ logoutMaxRetries?: number /** Delay before the first logout retry; each later delay doubles. */ diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx index d15cb50649..ec23cac75e 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx @@ -16,7 +16,7 @@ export interface AgentPresetSectionInjected { hooks: { agentPresetSection: SnapshotStore /** Shared preference controlling the picker-policy row. */ - developerTools: ObservableSnapshot + showPickerPolicy: ObservableSnapshot } /** Stage the `cordis` preset and start a Creator-mode task; absent without a conversation flow. */ startCreatorDraft?: () => void @@ -63,10 +63,10 @@ function CardDescription({ text }: { text: string }): ReactNode { */ export function AgentPresetSection({ useAgentPresetSection, load, view, closeView, makeDefault, setPickerVisible, startCreatorDraft, - close: closeSettings, useDeveloperTools, t, + close: closeSettings, useShowPickerPolicy, t, }: AgentPresetSectionProps) { const state = useAgentPresetSection(value => value) - const developerTools = useDeveloperTools(enabled => enabled) + const showPickerPolicy = useShowPickerPolicy(enabled => enabled) const [guide, setGuide] = useState<{ content: NonNullable> page: PresetGuidePage @@ -102,7 +102,7 @@ export function AgentPresetSection({ return

{t('nav')}

{t('sectionIntro')}

- {developerTools && ( + {showPickerPolicy && (
diff --git a/packages/client/ui-agent-preset/src/client/index.ts b/packages/client/ui-agent-preset/src/client/index.ts index a253ac3888..700da800ff 100644 --- a/packages/client/ui-agent-preset/src/client/index.ts +++ b/packages/client/ui-agent-preset/src/client/index.ts @@ -198,7 +198,7 @@ export function apply(ctx: ClientContext): void { } const sectionInjected = (): AgentPresetSectionInjected => ({ - hooks: { agentPresetSection: section.store, developerTools: ctx.configForms.developerTools.enabled }, + hooks: { agentPresetSection: section.store, showPickerPolicy: ctx.configForms.developerTools.enabled }, load: () => section.load(), view: (id: string) => section.view(id), closeView: () => { section.closeView() }, diff --git a/packages/client/ui-agent-preset/tests/section.client.spec.tsx b/packages/client/ui-agent-preset/tests/section.client.spec.tsx index 8bad88af50..b5e049bf35 100644 --- a/packages/client/ui-agent-preset/tests/section.client.spec.tsx +++ b/packages/client/ui-agent-preset/tests/section.client.spec.tsx @@ -12,7 +12,7 @@ const translations: ReadonlyMap = new Map(Object.entries(en)) function unusedHook(): never { throw new Error('This section does not read global slot sources') } -function view(partial: Partial = {}, startCreatorDraft?: () => void, developerTools = true, +function view(partial: Partial = {}, startCreatorDraft?: () => void, showPickerPolicy = true, outerClose?: () => void) { const store = createSnapshotStore({ status: 'ready', error: null, showPicker: true, policySaving: false, rows: [{ id: 'standard', isDefault: true }, { id: 'mine', name: 'Mine', isDefault: false }], @@ -24,7 +24,7 @@ function view(partial: Partial = {}, startCreatorDraft? usePanelInfo: unusedHook, useSessions: unusedHook, useSessionStatus: unusedHook, useSessionRetainInfo: unusedHook, useWorkspaces: unusedHook, useResource: unusedHook, useAgentPresetSection: bindSnapshotSelector(store), - useDeveloperTools: bindSnapshotSelector(createSnapshotStore(developerTools)), + useShowPickerPolicy: bindSnapshotSelector(createSnapshotStore(showPickerPolicy)), t: key => translations.get(key) ?? key } render(outerClose === undefined ? : ) diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index f4a5ff4780..9de8781842 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -38,7 +38,7 @@ import { ConversationHeader } from './skeleton/ConversationHeader.tsx' import { ConversationSession, ConversationSessionHeader } from './skeleton/ConversationSession.tsx' import { InputBar } from './skeleton/InputBar.tsx' import { todoDockEntry } from './skeleton/TodoPanel.tsx' -import { DEVELOPER_TOOLS_VIEW_ID, resolveActiveView } from './view-selection.ts' +import { TRAJECTORY_VIEW_ID, resolveActiveView } from './view-selection.ts' import { en, NS, zh, type ConversationKey } from './locales.ts' import { CONVERSATION_SETTINGS_NAMESPACE, type ConversationSettings } from '../submission-settings.ts' @@ -179,7 +179,7 @@ export function apply(ctx: Context, config: Config = Config({})): void { for (const entry of slots.entries('conversation.view')) { /* v8 ignore next -- list registration validates id at load. */ if (entry.options.id === undefined) continue - if (!ctx.configForms.developerTools.enabled.getSnapshot() && entry.options.id === DEVELOPER_TOOLS_VIEW_ID) continue + if (!ctx.configForms.developerTools.enabled.getSnapshot() && entry.options.id === TRAJECTORY_VIEW_ID) continue tabs.push({ id: entry.options.id, label: resolveSlotLabel(entry.options.label) ?? entry.options.id, diff --git a/packages/client/ui-conversation/src/client/view-selection.ts b/packages/client/ui-conversation/src/client/view-selection.ts index 6d0589759c..1698e26314 100644 --- a/packages/client/ui-conversation/src/client/view-selection.ts +++ b/packages/client/ui-conversation/src/client/view-selection.ts @@ -3,8 +3,8 @@ import type { ViewTab } from './contract/views.ts' /** Fallback View selected when no stored preference resolves to a registered View. */ export const DEFAULT_VIEW_ID = 'chat' -/** The one View the Developer tools preference gates; other Views stay registered by their owners. */ -export const DEVELOPER_TOOLS_VIEW_ID = 'trajectory' +/** Registered identity of the trajectory View. */ +export const TRAJECTORY_VIEW_ID = 'trajectory' /** * Resolve a preferred registered View, then Chat, without choosing another View. diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 1747da058c..1a1570b069 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md -README.md: fbb8ef676e55d1ef2c3c5c8a6c91a12a3c8fe556 -README.zh.md: 822ff17c3c4c6b02339cdacd03d9c52c67a738c7 +README.md: 6f6f9709ec741307b8c3b6435f375472527df114 +README.zh.md: 616b8b551573018cb668f0c669534fa139d3e54b diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index fbb8ef676e..6f6f9709ec 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -49,9 +49,8 @@ Check this table before writing a control in a feature package. A plugin cannot | `StateDot` | Solid green `done`, amber `warning`, red `error`, and neutral-grey `idle` marks in a 10px slot, plus a tertiary-grey 14px rotating `ongoing` loader whose animations pin to document time zero so every visible loader rotates in phase. `aria-hidden`, so the render site owns the name. `appearance="step"` shows a filled check for completion and a hollow pending circle. | | `ConnectionIndicator` | Inline connection-recovery control across outage, retry, and recovered states. | | `DisclosureRow` | 24px compact disclosure that lays title and content side by side. Memoized with shallow prop comparison; keep callbacks and React-node props stable when their content is unchanged. | -| `Modal` | Centered dialog over a page mask. A nested dialog can intercept keys with `onKeyDownCapture` before document Escape handlers. | +| `Modal` | Centered dialog over a page mask. A nested dialog can intercept keys with `onKeyDownCapture` before document Escape handlers. The tint and dialog fade in while backdrop blur stays fully applied, honoring reduced motion. Set `backdropBlur={false}` when the caller already blurs the source page. | | `RiskConfirmation` | Sensitive action gated behind an explicit checkbox. | -| `OnboardingSurface` | First-run stage that holds the application root inert. | | `Tooltip` | Hover text anchored to a cloned child; optional `portal` rendering escapes clipping containers and ancestor stacking contexts that cap the bubble's z-index. | | `HoverCard` | Hover preview the pointer can rest on and select from; optional copy button. | | `ImageLightbox` | Shared image modal with focus restoration and Escape dismissal. | diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 822ff17c3c..616b8b5515 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -49,9 +49,8 @@ kind: "package-library" | `StateDot` | 10px 槽内的绿色 `done`、琥珀色 `warning`、红色 `error`、中性灰色 `idle` 圆点,以及 tertiary 灰色 14px 旋转 `ongoing` loading,其动画固定到文档时间零点,所以所有可见 loading 同相旋转。它是 `aria-hidden` 的,名称由渲染点提供。 `appearance="step"` 以实心勾表示完成、空心圆表示等待。 | | `ConnectionIndicator` | 行内连接恢复控件,覆盖断线、重试与已恢复三种状态。 | | `DisclosureRow` | 24px 紧凑折叠行,标题与内容左右排列。使用浅层 prop 比较进行 memo;内容未变时,保持回调与 React 节点 prop 的引用稳定。 | -| `Modal` | 页面遮罩之上的居中对话框。嵌套对话框可通过 `onKeyDownCapture` 在文档级 Escape 处理器之前拦截按键。 | +| `Modal` | 页面遮罩之上的居中对话框。嵌套对话框可通过 `onKeyDownCapture` 在文档级 Escape 处理器之前拦截按键。 色层与弹窗淡入,背景模糊始终完整生效,并遵循减少动态效果偏好。调用方已模糊源页面时设置 `backdropBlur={false}`。 | | `RiskConfirmation` | 以显式复选框把关的敏感操作确认。 | -| `OnboardingSurface` | 首次运行的引导舞台,期间保持应用根节点 inert。 | | `Tooltip` | 锚定在克隆子元素上的悬停文本;可通过 `portal` 渲染到外层,避免被容器裁剪,或受祖先层叠上下文限制其 z-index。 | | `HoverCard` | 指针可停留、可选中的悬停预览;可选带复制按钮。 | | `ImageLightbox` | 共享图片浮层,支持焦点恢复与 Esc 关闭。 | diff --git a/packages/client/ui-primitives/src/Button.tsx b/packages/client/ui-primitives/src/Button.tsx index d2e39dbf23..7bdba0baa0 100644 --- a/packages/client/ui-primitives/src/Button.tsx +++ b/packages/client/ui-primitives/src/Button.tsx @@ -11,7 +11,7 @@ export type ButtonVariant = 'primary' | 'ghost' | 'outline' | 'toolbar' /** * Render a button. * @param props.variant - visual family (default 'ghost'). - * @param props.size - 'md' 36px capsule (figma Button) or 'sm' 28px compact. + * @param props.size - 'md' 36px capsule, 'sm' 28px compact. * @param props.icon - optional leading 16px icon node. * @returns the button element; native button attributes pass through. */ diff --git a/packages/client/ui-primitives/src/Checkbox.tsx b/packages/client/ui-primitives/src/Checkbox.tsx index bccdf4e454..bf1cbf9a65 100644 --- a/packages/client/ui-primitives/src/Checkbox.tsx +++ b/packages/client/ui-primitives/src/Checkbox.tsx @@ -23,7 +23,8 @@ export function Checkbox({ checked, onChange, label, disabled = false, title, cl }) { return ( ) diff --git a/packages/client/ui-primitives/src/Modal.module.css b/packages/client/ui-primitives/src/Modal.module.css index 8611494214..eee6edf608 100644 --- a/packages/client/ui-primitives/src/Modal.module.css +++ b/packages/client/ui-primitives/src/Modal.module.css @@ -18,10 +18,17 @@ .mask { position: absolute; inset: 0; - background: var(--dsw-alias-bg-mask-1); backdrop-filter: var(--dsw-mask-blur); } +.mask::after { + content: ''; + position: absolute; + inset: 0; + background: var(--dsw-alias-bg-mask-1); + animation: modalEnter var(--ds-transition-duration) var(--ds-ease-in-out); +} + /* Dialog card: r24, elevation-prominent, layer-2 fill, pb 24. */ .dialog { position: relative; @@ -36,6 +43,16 @@ border-radius: 24px; background: var(--dsw-alias-bg-layer-2); box-shadow: var(--dsw-elevation-prominent); + animation: modalEnter var(--ds-transition-duration) var(--ds-ease-in-out); +} + +@keyframes modalEnter { + from { opacity: 0; } + to { opacity: 1; } +} + +@media (prefers-reduced-motion: reduce) { + .mask::after, .dialog { animation: none; } } .content { diff --git a/packages/client/ui-primitives/src/Modal.tsx b/packages/client/ui-primitives/src/Modal.tsx index dcb5da4535..d9163503f9 100644 --- a/packages/client/ui-primitives/src/Modal.tsx +++ b/packages/client/ui-primitives/src/Modal.tsx @@ -15,6 +15,7 @@ interface ModalBaseProps { className?: string contentClassName?: string onKeyDownCapture?: KeyboardEventHandler + backdropBlur?: boolean } type ModalProps = ModalBaseProps & ( @@ -33,13 +34,15 @@ type ModalProps = ModalBaseProps & ( * @param props.children - body (inputs, etc.). * @param props.footer - action row (Cancel / Create). * @param props.contentClassName - optional class for a scrollable content region. + * @param props.backdropBlur - disable when the caller already blurs the page; defaults to true. * @param props.headless - render children directly in the card (no default * header/close/body chrome); mask, card, Escape, and aria-label remain. * @param props.onKeyDownCapture - handle a nested dialog's keys before the document Escape listeners. * @returns null when closed; otherwise the overlay tree. */ export function Modal({ - open, onClose, title, closeLabel, description, children, footer, className, contentClassName, onKeyDownCapture, headless = false, + open, onClose, title, closeLabel, description, children, footer, className, contentClassName, + onKeyDownCapture, headless = false, backdropBlur = true, }: ModalProps) { useEffect(() => { if (!open) return @@ -54,7 +57,7 @@ export function Modal({ return createPortal((
-