Merge pull request #4459 from deepseek-harness/feat/desktop-onboarding

feat(desktop): add device-local account onboarding
This commit is contained in:
07akioni authored and zhc committed 2026-09-23 22:21:18 +08:00
1 parent d9ff18ec2a
commit 93b832f1b0
151 files changed
+4153 -385

No files matched your search

@@ -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
@@ -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.
@@ -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 事件。
@@ -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
@@ -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.
@@ -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 错误都不能清除已知阻塞。只有新的有效无需强更响应才能清除它。同版本离线持久化未实现,因为其产品行为尚未确定。
@@ -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
@@ -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.
@@ -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)负责状态和交互覆盖;生产授权与支付行为仍需独立的集成验证。
@@ -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
@@ -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 读取访问。
+2 -2
View File
@@ -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
+4
View File
@@ -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.
+4
View File
@@ -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 不会重复提示;主动退登和冷启动不会生成该通知。
+2
View File
@@ -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',
+15
View File
@@ -679,6 +679,21 @@ async function main(): Promise<void> {
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()
+4
View File
@@ -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<boolean>,
setActive: (active: boolean) => { ipcRenderer.send(DESKTOP_IPC.onboardingActive, active) },
})
ipcRenderer.on(DESKTOP_IPC.enterWorkspace, () => {
const body = document.body
const previous = body.getAttribute('tabindex')
+9 -3
View File
@@ -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
+2 -2
View File
@@ -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
+14
View File
@@ -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)
<a id="verification-overlay"></a>
## 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.
<a id="verification-interactive"></a>
## Manual walkthrough
+14
View File
@@ -10,10 +10,24 @@
## 目录
- [原生浮层可见性](#verification-overlay)
- [验证证据](#verification-evidence)
- [手动演练](#verification-interactive)
- [待验证事项](#verification-open)
<a id="verification-overlay"></a>
## 原生浮层可见性
在 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。这项验证覆盖原生窗口恢复,不覆盖完整的首次登录流程。
<a id="verification-interactive"></a>
## 手动演练
@@ -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 }
]
@@ -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,<h1>Update overlay visibility</h1><input aria-label="Keyboard probe">')
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,<h1>Update confirmation</h1>')
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) })
+35
View File
@@ -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
+18
View File
@@ -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<boolean> }
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')
+1
View File
@@ -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() {}
}
+77 -6
View File
@@ -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)
+1
View File
@@ -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:*",
+1
View File
@@ -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 {
+423
View File
@@ -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<typeof watchConsole>
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<void>((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<void>((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<Page> {
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<boolean> }).__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<void> {
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()
})
})
@@ -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"
@@ -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"
@@ -0,0 +1,7 @@
- region "准备可用额度":
- heading "准备可用额度" [level=1]
- paragraph: DeepSeek Harness 执行任务时,会根据模型与工具使用的 token 量扣除额度。请提前添加额度,避免任务在执行过程中被迫中断。你的余额只会在 Agent 实际执行任务时消耗。
- button "去充值"
- button "下一步"
- button "上一步"
- button "跳过"
@@ -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"
@@ -0,0 +1,10 @@
- region "你希望看到多少工作过程?":
- heading "你希望看到多少工作过程?" [level=1]
- paragraph: 这只会影响工作过程的展示方式,不会影响 DeepSeek Harness 的工作能力。
- radiogroup "你希望看到多少工作过程?":
- radio "聚焦结果 只看结果,保持界面简洁明了"
- radio "关键细节 结果优先,仅展示重要步骤和关键操作"
- radio "完整过程 查看完整工作过程,方便调试和排查问题" [checked]
- button "进入应用"
- button "上一步"
- button "跳过"
@@ -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"
@@ -0,0 +1,12 @@
- region "你希望 DeepSeek Harness 帮你做什么?":
- heading "你希望 DeepSeek Harness 帮你做什么?" [level=1]:
- text: 你希望
- emphasis: DeepSeek Harness
- text: 帮你做什么?
- paragraph: 我们会根据你的选择调整界面和工具,以更适合你的工作方式。
- checkbox "办公与创作"
- text: 办公与创作 编辑文档、整理数据和制作演示文稿等
- checkbox "代码与开发"
- text: 代码与开发 修改代码、调试问题、运行命令和管理项目文件等
- button "上一步"
- button "跳过"
@@ -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"
@@ -0,0 +1,6 @@
- region "欢迎使用 DeepSeek Harness":
- heading "欢迎使用 DeepSeek Harness" [level=1]:
- text: 欢迎使用
- emphasis: DeepSeek Harness
- paragraph: DeepSeek Harness 会以本地文件夹作为工作区,通过调用各种工具,读写本机文件,完成搜索整理资料、制作文档表格、编写代码、排查问题等各种任务。
- button "开始设置"
+5 -3
View File
@@ -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) {
+1
View File
@@ -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",
+15 -1
View File
@@ -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: {
+2 -2
View File
@@ -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
+33 -2
View File
@@ -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<OnboardingStep>
/** Selected work scenario. */
purpose?: Volatile<OnboardingPurpose | null | undefined>
/** Selected transcript detail. */
process?: Volatile<OnboardingProcess | null | undefined>
/** 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<boolean>
}
/** 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)
<a id="deepseek-aidsh-client-ui-settings-models"></a>
@@ -713,6 +742,8 @@ export interface Config {
accountRequestHeaders?: Record<string, string>
/** 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. */
+33 -2
View File
@@ -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<OnboardingStep>
/** Selected work scenario. */
purpose?: Volatile<OnboardingPurpose | null | undefined>
/** Selected transcript detail. */
process?: Volatile<OnboardingProcess | null | undefined>
/** 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<boolean>
}
/** 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)
<a id="deepseek-aidsh-client-ui-settings-models"></a>
@@ -715,6 +744,8 @@ export interface Config {
accountRequestHeaders?: Record<string, string>
/** 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. */
@@ -16,7 +16,7 @@ export interface AgentPresetSectionInjected {
hooks: {
agentPresetSection: SnapshotStore<AgentPresetSectionState>
/** Shared preference controlling the picker-policy row. */
developerTools: ObservableSnapshot<boolean>
showPickerPolicy: ObservableSnapshot<boolean>
}
/** 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<ReturnType<typeof presetGuide>>
page: PresetGuidePage
@@ -102,7 +102,7 @@ export function AgentPresetSection({
return <section className={css.section}>
<h2 className={css.title}>{t('nav')}</h2>
<p className={css.intro}>{t('sectionIntro')}</p>
{developerTools && (
{showPickerPolicy && (
<div className={css.pickerPreference}>
<div className={css.pickerPreferenceCopy}>
<span className={css.pickerPreferenceTitleRow}>
@@ -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() },
@@ -12,7 +12,7 @@ const translations: ReadonlyMap<string, string> = new Map(Object.entries(en))
function unusedHook(): never {
throw new Error('This section does not read global slot sources')
}
function view(partial: Partial<AgentPresetSectionState> = {}, startCreatorDraft?: () => void, developerTools = true,
function view(partial: Partial<AgentPresetSectionState> = {}, startCreatorDraft?: () => void, showPickerPolicy = true,
outerClose?: () => void) {
const store = createSnapshotStore<AgentPresetSectionState>({ 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<AgentPresetSectionState> = {}, 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 ? <AgentPresetSection {...props} />
: <Modal open onClose={outerClose} title="Settings" closeLabel="Close"><AgentPresetSection {...props} /></Modal>)
@@ -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,
@@ -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.
@@ -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
+1 -2
View File
@@ -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. |
+1 -2
View File
@@ -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 关闭。 |
+1 -1
View File
@@ -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.
*/
@@ -23,7 +23,8 @@ export function Checkbox({ checked, onChange, label, disabled = false, title, cl
}) {
return (
<label className={clsx(css.checkbox, className)} title={title}>
<input type="checkbox" checked={checked} disabled={disabled} onChange={(event) => { onChange(event.target.checked) }} />
<input type="checkbox" checked={checked} disabled={disabled}
onChange={(event) => { onChange(event.target.checked) }} />
<span>{label}</span>
</label>
)
@@ -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 {
+5 -2
View File
@@ -15,6 +15,7 @@ interface ModalBaseProps {
className?: string
contentClassName?: string
onKeyDownCapture?: KeyboardEventHandler<HTMLDivElement>
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((
<div className={css.root} role="presentation" onKeyDownCapture={onKeyDownCapture}>
<div className={css.mask} aria-hidden="true" onClick={onClose} />
<div className={css.mask} style={backdropBlur ? undefined : { backdropFilter: 'none' }} aria-hidden="true" onClick={onClose} />
<div
className={clsx(css.dialog, className)}
role="dialog"
@@ -1,28 +0,0 @@
/* First-run stage: keep the product top bar visible, then let onboarding own
the complete workspace instead of presenting another settings modal. */
.onboardingOverlay {
position: fixed;
inset: 0;
z-index: 1100;
}
/* Mask */
.onboardingMask {
position: absolute;
left: 0px;
right: 0px;
top: 80px;
bottom: 0px;
background: rgba(0, 0, 0, 0.24);
backdrop-filter: var(--dsw-mask-blur);
}
.onboardingStage {
position: absolute;
z-index: 1;
inset: 0;
display: flex;
justify-content: center;
overflow: hidden;
background: var(--dsw-alias-bg-layer-1);
}
@@ -1,26 +0,0 @@
import { useEffect } from 'react'
import type { ReactNode } from 'react'
import { createPortal } from 'react-dom'
import css from './OnboardingSurface.module.css'
/**
* Render a body-portaled onboarding stage and keep the application root inert
* while mounted.
* @param props.children - the step's page content, centered on the stage.
* @returns the body-portaled overlay tree.
*/
export function OnboardingSurface({ children }: { children: ReactNode }) {
useEffect(() => {
const appRoot = document.getElementById('root')
if (appRoot === null) return
appRoot.inert = true
return () => { appRoot.inert = false }
}, [])
return createPortal((
<div className={css.onboardingOverlay} role="presentation">
<div className={css.onboardingMask} aria-hidden="true" />
<div className={css.onboardingStage}>{children}</div>
</div>
), document.body)
}
@@ -30,7 +30,6 @@ export type { AnchoredPositionOptions } from './useAnchoredPosition.ts'
export { useDismissOnOutsidePointer } from './useDismissOnOutsidePointer.ts'
export { HoverCard } from './HoverCard.tsx'
export { Modal } from './Modal.tsx'
export { OnboardingSurface } from './OnboardingSurface.tsx'
export { RiskConfirmation } from './RiskConfirmation.tsx'
export type { RiskConfirmationProps } from './RiskConfirmation.tsx'
export { ConnectionIndicator } from './ConnectionIndicator.tsx'
@@ -618,11 +618,12 @@ describe('Modal', () => {
it('renders headless content without the default close chrome', () => {
render(
<Modal open onClose={() => {}} title="Custom surface" headless>
<Modal open onClose={() => {}} title="Custom surface" headless backdropBlur={false}>
<span>Custom body</span>
</Modal>,
)
expect(screen.getByRole('dialog', { name: 'Custom surface' })).toBeDefined()
expect((screen.getByRole('dialog').previousElementSibling as HTMLElement).style.backdropFilter).toBe('none')
expect(screen.getByText('Custom body')).toBeDefined()
expect(screen.queryByRole('button')).toBeNull()
})
@@ -24,3 +24,13 @@ it('refuses label activation while disabled', () => {
expect(onChange).not.toHaveBeenCalled()
expect(screen.getByRole<HTMLInputElement>('checkbox').checked).toBe(true)
})
it('preserves caller hover text', () => {
const onChange = vi.fn()
render(<Checkbox label="Office" checked={false} onChange={onChange} title="Office tools" />)
expect(screen.getByText('Office')).toBeTruthy()
const input = screen.getByRole('checkbox', { name: 'Office' })
expect(input.closest('label')?.title).toBe('Office tools')
fireEvent.click(input)
expect(onChange).toHaveBeenLastCalledWith(true)
})
@@ -1,47 +0,0 @@
// @vitest-environment jsdom
import { cleanup, render } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { OnboardingSurface } from '@deepseek-ai/dsh-client-ui-primitives'
let appRoot: HTMLDivElement
beforeEach(() => {
appRoot = document.createElement('div')
appRoot.id = 'root'
document.body.appendChild(appRoot)
})
afterEach(() => {
cleanup()
appRoot.remove()
})
describe('OnboardingSurface', () => {
it('portals the overlay chrome to document.body around its content', () => {
const view = render(<OnboardingSurface><p>step content</p></OnboardingSurface>)
// Portaled: the overlay is a body child, not inside the render container.
expect(view.container.querySelector('[class*="onboardingOverlay"]')).toBeNull()
const overlay = document.body.querySelector('[class*="onboardingOverlay"]')
expect(overlay).not.toBeNull()
// The onboarding e2e pins the mask by class substring; the stage carries
// the content.
expect(overlay!.querySelector('[class*="onboardingMask"]')).not.toBeNull()
const stage = overlay!.querySelector('[class*="onboardingStage"]')
expect(stage).not.toBeNull()
expect(stage!.textContent).toBe('step content')
})
it('holds #root inert for exactly its own lifetime', () => {
const view = render(<OnboardingSurface>x</OnboardingSurface>)
expect(appRoot.inert).toBe(true)
view.unmount()
expect(appRoot.inert).toBe(false)
})
it('renders without an #root element (compositions that mount elsewhere)', () => {
appRoot.remove()
const view = render(<OnboardingSurface>x</OnboardingSurface>)
expect(document.body.querySelector('[class*="onboardingStage"]')!.textContent).toBe('x')
view.unmount()
})
})
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-settings-account/README.md
README.md: b08dc6434a6cabcb1cb6da48fc5cc46f5f0f8368
README.zh.md: d912a92a4d8a5935c250182efd4326514784a10c
README.md: c5806362bf674c9f7a74077854917a36c4b1b840
README.zh.md: 2fd4847cc78edd1ade055eae27bdcacb7a4c3ae0
+14 -1
View File
@@ -11,7 +11,7 @@ Server-expired account credentials clear the displayed account details and emit
## Summary
Desktop Account settings display DeepSeek login state and offer browser authorization and cancellation; the sidebar account menu provides Platform sign-out.
The Account settings section displays DeepSeek login state and offers browser sign-in and cancellation; the sidebar account menu provides Platform sign-out. Desktop users also receive a resumable introduction to account credit and presentation preferences.
## Table of Contents
@@ -42,11 +42,20 @@ The account card’s More account information link opens the root of the Host-pr
Sign out first queries running account-token tasks and opens a confirmation dialog. The warning describes interruption when such tasks exist; otherwise it explains that data is retained and the account can be signed in again. Cancel, close, and Escape dismiss without signing out. Failed impact queries still open confirmation with an explicit unknown-task warning; failed sign-out keeps the dialog available for retry.
<a id="desktop-onboarding"></a>
### Desktop onboarding
The Desktop preload marker enables the introduction after account credentials are stored. Progress belongs to the local Host settings document, not the Platform account: unfinished steps resume after restart, and completing or skipping the flow prevents another run on that installation. Browser clients do not mount it. API-key presence comes from the native welcome backend through the boolean-only Desktop preload bridge, using the same credential discovery as login. A signed-out installation with a configured model API key bypasses the pages and applies detailed process, detailed usage, and enabled developer tools.
The welcome page requires Get started. The credit page always appears. A positive balance confirmed at first entry selects Continue as the primary action and Add credits as the secondary action. This choice stays fixed until the onboarding controller is recreated and is never persisted; late balance results cannot change the buttons. Only a confirmed absence of positive credit adds a confirmation before continuing without recharge; pending or failed queries do not block continuing. Purpose descriptions remain unchanged when selected. Returning from the native top-up view keeps the credit page and refreshes account details in the background, whether loading or payment succeeded. Next confirms only a known zero balance; Skip uses the credit warning on the credit page and the setup warning on the question pages. Office-only use completes with compact presentation; development or both purposes also ask for process detail. Back preserves selections, and explicit skip from any supported step applies detailed process, compact performance/usage, and disabled developer tools. This flow creates no workspace or demonstration task.
<a id="understand-the-implementation"></a>
## Understand the implementation
The Account section appears first in Settings only while signed in; it is hidden before account state loads and after sign-out. The plugin owns one Host snapshot stream shared through framework hooks by settings.section and settings.launcher. The launcher opens Settings and offers Sign out only while an account credential is stored. Platform failures leave the menu available for retry. It maintains no independent credential state, so no invariant companion is published.
The desktop controller writes progress to `ui-settings-account` through Host settings. Completion first applies `ui-chat.transcriptView` and `ui-chat.performanceUsage` together, then calls the shared developer-tools preference to persist `ui-settings.enabled`, and finally saves the done marker; a rejected write keeps the flow available for retry. Successful completion reveals the workspace through a 180ms fade; choices and ordinary navigation preview immediately while writes run in order. A failed final queued write restores saved progress and allows retry. Completion waits for queued choices and retains the page until preferences persist; reduced motion skips the fade. Compact, detailed, and expanded map directly to the corresponding Chat work-detail modes. Complete Figma illustration layers are bundled as 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, and headings use the bundled Montserrat brand font; English headings and descriptions use Light (300), except the DeepSeek Harness brand within headings uses Medium (500); card titles retain Regular (400) when selected. English footer navigation uses Light (300). Confirmed onboarding sets a 960px minimum window width and enlarges narrower windows; completion releases the minimum without restoring the previous size. Loading or already-completed onboarding does not resize the window. Empty footer navigation space passes pointer events through to the primary action. Cards exclude native window dragging and own the keyboard focus outline for their checkbox. The account package owns its onboarding overlay and control geometry. Each step and the confirmation dialog has a separate component; the flow coordinates navigation, transitions, and recharge. The overlay hides the workspace while initial account and progress state loads. Question headings and cards follow the Figma positions at 1440 × 920 and 960 × 600, independently of their action buttons. On macOS, content clears the body portal’s inherited no-drag region and a first-child drag band leaves an 8px native resize margin; interactive controls and later dialogs exclude their regions, and a native Platform page disables the underlying onboarding band. The upper workspace popup illustration has an opaque background in both languages and themes; the underlying sidebar omits its selected-row highlight. Welcome keeps its illustration at its original size, repositions and clips the artwork in narrow windows, and reveals the complete illustration on wide windows. The [onboarding decision](../../../.agents/notes/implemented/feature/2026-09-16-desktop-onboarding.md) owns persistence and presentation trade-offs.
<a id="further-exploration"></a>
## Further Exploration
@@ -75,6 +84,8 @@ No model request prefix changes.
<a id="known-limitations-and-deferred-work"></a>
- Profile and recharge-wallet balances use the existing Platform Web endpoints through Host getProfile / getBalance. The page refreshes when opened and after login or reconnect, preserves server-masked contact data, and shows query failures independently without manufacturing a zero balance. Usage and top-up use Host-provided links derived from platformOrigin and the browser’s own login; the links never carry a DSH token.
- Completed installations do not reapply defaults or overwrite later user preferences. Completion does not synchronize across devices or create a per-account history.
- Onboarding follows the interface locale and theme, with four Figma PNG illustration sets covering Chinese and English in light and dark appearance. Export canvases include the full illustration geometry; Finder artwork uses the same vertical fade in both themes.
- The Platform view’s dialog marks every other document child inert, including the Desktop-owned caption menu host, so the Application and Edit menu stays visible but cannot be operated until the view closes. Exempting that host needs an inertness contract owned by the Desktop.
@@ -86,3 +97,5 @@ The [desktop login decision](../../../.agents/notes/implemented/architecture/202
Usage and top-up show a centered 24px loading indicator without visible loading text until the native document loads; the return action remains available. The loading SVG is embedded locally from Figma node 2957:72553.
Default-model initialization runs after publishing and accepting the sign-in frame, without delaying subsequent account frames. Failures are recorded in diagnostics and do not mark the signed-in account as failed.
Read-only onboarding settings and a failed initial account stream leave the workspace accessible. A failed initial settings read exposes Retry, which reissues the shared settings read. Overlapping onboarding and Platform overlays release background interaction only after their last owner unmounts.
@@ -11,7 +11,7 @@ kind: "package-reference"
## 概述
Desktop 设置中的账号页面显示 DeepSeek 登录状态,并提供浏览器授权和取消;侧边栏账号菜单提供 Platform 退出登录。
设置中的账号页面显示 DeepSeek 登录状态,并提供浏览器登录和取消;侧边栏账号菜单提供 Platform 退出登录。Desktop 用户还会进入可恢复的引导,了解账号额度并选择展示偏好。
## 目录
@@ -42,11 +42,20 @@ Desktop 的用量和充值操作在 48px 返回栏下方打开隔离的原生 Pl
退出登录先查询正在使用账号 token 的任务,再打开确认弹窗。有此类任务时提示执行将被打断,否则提示数据保留且可重新登录。取消、关闭和 Escape 均不退登。影响查询失败仍打开确认弹窗,并明确提示无法确认任务状态;退登失败保留弹窗以便重试。
<a id="desktop-onboarding"></a>
### Desktop 引导
Desktop preload 标记在账号凭证已存储后启用引导。进度属于本地 Host 设置文档,不属于 Platform 账号:重启后恢复未完成的步骤,完成或跳过后不再于当前安装中重复运行。浏览器客户端不挂载此流程。API Key 是否存在由原生欢迎后端通过仅返回布尔值的 Desktop preload 桥接提供,与登录共用凭证发现逻辑。未登录但已配置模型 API Key 的安装会绕过这些页面,应用详细过程、详细用量和开启开发者工具的配置。
欢迎页必须点击“开始设置”。额度页始终展示。首次进入时若已确认有正余额,则主操作为“继续”,次操作为“去充值”。该选择在引导控制器重建前保持固定且不持久化,后到的余额结果不会改变按钮。仅在确认不存在正余额时,继续而不充值才需确认;查询中或失败时不阻止继续。用途卡片选中后描述不变。从原生充值视图返回后保留额度页,并在后台刷新账号资料,不受页面加载或支付结果影响。“下一步”仅在确认零余额时提示;额度页的“跳过”使用充值提示,答题页使用设置提示。仅选办公用途时以简洁展示完成;选择开发或两种用途时还会询问过程详情。上一步保留已有选择,任意可跳过步骤均采用详细工作过程、简洁性能与用量以及关闭开发者工具的默认值。此流程不创建工作区或示例任务。
<a id="understand-the-implementation"></a>
## 理解实现
仅登录后在设置导航首位显示账号页;账号状态加载完成前以及退出登录后隐藏该页。 插件持有一条 Host 快照流,通过框架 hook 供 settings.section 和 settings.launcher 共享。入口菜单可打开设置,并仅在已存储账号凭证时提供退出登录。Platform 请求失败时保留菜单,以供重试。插件不维护独立凭证状态,因此不发布 invariant。
桌面控制器通过 Host 设置将进度写入 `ui-settings-account`。完成时先一并应用 `ui-chat.transcriptView` 与 `ui-chat.performanceUsage`,再通过共享开发者工具偏好保存 `ui-settings.enabled`,最后保存完成标记;写入被拒绝时保留引导以供重试。成功完成后通过 180ms 淡出显露工作区;选择和普通翻页立即预览,写入在后台按顺序执行。队列最后一次写入失败时恢复已保存进度,并允许重试。完成操作会等待选择队列及偏好保存成功,再关闭页面;减少动态效果偏好会跳过淡出。compact、detailed 和 expanded 直接对应 Chat 的三个工作过程模式。完整 Figma 示意图层以透明、调色板压缩的 3× PNG 资源打包;欢迎页图层合并并烘焙侧栏局部模糊;充值页前景窗口不透明度为 70%,仅烘焙其覆盖插图区域的模糊,标题使用已打包的 Montserrat 品牌字体,英文标题和描述使用 Light(300),标题中的 DeepSeek Harness 使用 Medium(500);卡片标题选中前后均使用 Regular(400)。英文底部导航使用 Light(300)。确认进入引导后,窗口最小宽度设为 960px,较窄窗口自动放大;完成后解除宽度限制,但不恢复原尺寸。加载中或已完成引导的启动不改变窗口尺寸。底部导航空白区域透过鼠标事件,避免遮挡主操作。卡片区域不参与原生窗口拖动,复选框的键盘焦点仅在卡片外框展示。账号包负责引导覆盖层和控件尺寸样式。每个步骤和确认弹窗各有独立组件,流程组件协调导航、切换动画和充值。初始账号与进度加载期间,覆盖层隐藏工作区。答题标题和卡片独立于操作按钮,位置对齐 Figma 的 1440 × 920 和 960 × 600 断点。macOS 的内容层清除 body 浮层继承的 no-drag,位于内容之前的拖拽层保留 8px 原生缩放边缘;交互控件和后续弹窗排除自身区域;原生平台页面打开时禁用底层引导拖拽层。上方工作区弹窗插画在两种语言和主题下均使用不透明背景,下层侧边栏不绘制选中行高亮。欢迎页保持示意图的原始尺寸,在窄窗口内调整插图位置并裁切,在宽窗口内展示完整插图。[引导决策](../../../.agents/notes/implemented/feature/2026-09-16-desktop-onboarding.zh.md)记录持久化与展示方面的取舍。
<a id="further-exploration"></a>
## 深入探索
@@ -75,6 +84,8 @@ Desktop 的用量和充值操作在 48px 返回栏下方打开隔离的原生 Pl
<a id="known-limitations-and-deferred-work"></a>
- 资料和充值钱包余额通过 Host getProfile / getBalance 复用已有 Platform Web 接口。页面在打开、登录及重连后刷新,原样显示服务端脱敏后的联系方式,并分别处理查询失败,不将失败显示为零余额。用量和充值使用 Host 按 platformOrigin 派生的链接,并沿用浏览器自身登录态,不携带 DSH token。
- 已完成引导的安装不会重新应用默认值或覆盖用户后续调整的偏好。完成状态不跨设备同步,也不创建逐账号历史。
- 引导跟随界面语言和主题,使用中文、英文分别搭配浅色、深色外观的四套 Figma PNG 插图。导出画布包含完整插图范围,Finder 插图在两种主题下使用一致的纵向渐变。
- Platform 视图的弹窗会把文档内其它子节点设为 inert,其中包括 Desktop 拥有的顶栏菜单 host,因此 Application 和 Edit 菜单保持可见,但在视图关闭前无法操作。放行该 host 需要 Desktop 侧提供 inert 契约。
@@ -86,3 +97,5 @@ Desktop 的用量和充值操作在 48px 返回栏下方打开隔离的原生 Pl
用量和充值在原生页面加载完成前显示居中的 24px 加载图标,不显示加载文字;返回操作始终可用。加载 SVG 从 Figma 节点 2957:72553 内嵌到本地样式。
默认模型初始化在登录帧发布并确认接收后运行,不延迟后续账号帧。初始化失败会记录诊断日志,不会将已登录账号标记为失败。
引导设置只读或初始账号流失败时,工作区保持可访问。首次设置读取失败会显示重试入口,重新发起共享设置读取。引导与 Platform 覆盖层重叠时,最后一个持有者卸载后才恢复背景交互。
@@ -63,7 +63,10 @@
"@deepseek-ai/dsh-client-ui-settings-models": "workspace:*",
"@types/react-dom": "~18.3.0",
"@deepseek-ai/dsh-host-webserver": "workspace:*",
"big.js": "^7.0.1"
"big.js": "^7.0.1",
"@deepseek-ai/dsh-settings": "workspace:*",
"@deepseek-ai/dsh-client-store": "workspace:*",
"@deepseek-ai/dsh-client-ui-layout": "workspace:*"
},
"files": [
"lib/index.js",
@@ -0,0 +1,328 @@
.page {
--page-padding: clamp(40px, 8.333vw, 120px);
position: relative;
width: 100%;
height: 100%;
min-height: 480px;
color: var(--dsw-alias-label-primary);
overflow: hidden;
animation: pageEnter 100ms ease-out;
}
/* Filter the source once: native translucent windows can retain sharp content beneath backdrop copies. */
.blurred { filter: var(--dsw-mask-blur); }
@keyframes pageEnter {
from { opacity: 0; }
to { opacity: 1; }
}
.loading { display: grid; place-items: center; height: 100%; color: var(--dsw-alias-label-tertiary); }
.content {
position: absolute;
top: 50%;
left: 50%;
width: min(calc(100% - 2 * var(--page-padding)), 1200px);
transform: translate(-50%, -50%);
}
.copy h1 {
margin: 0;
font-size: 28px;
line-height: 48px;
font-weight: 300;
outline: none;
}
:global(html[data-platform='darwin']) .copy h1 {
-webkit-app-region: initial;
}
.brand {
font-family: var(--dsw-font-family-brand);
font-size: inherit;
font-weight: 500;
font-style: normal;
white-space: nowrap;
}
.page:lang(en) .copy h1 {
font-family: var(--dsw-font-family-brand);
font-weight: 300;
}
.page:lang(en) :is(.heroDescription, .subtitle, .cardTitle, .cardDescription) {
font-family: var(--dsw-font-family-brand);
}
.heroDescription {
margin: 24px 0 0;
font-size: 16px;
line-height: 28px;
font-weight: 300;
color: var(--dsw-alias-label-tertiary);
}
.welcome {
max-width: 2200px;
width: 100%;
height: 100%;
}
.welcome .copy {
position: absolute;
top: 50%;
left: clamp(64px, calc(41.667vw - 300px), 200px);
width: 520px;
transform: translateY(-50%);
z-index: 1;
}
.start {
display: flex;
width: 240px;
margin: 60px 0 0;
}
.welcomeIllustration {
position: absolute;
left: min(calc(41.667vw + 112.5px), 712.5px);
top: clamp(32px, calc(31.608vh - 157.65px), 133.145px);
width: 1247px;
height: 822px;
pointer-events: none;
}
.welcomeArtwork { position: absolute; left: -67px; top: -40px; width: 1331px; height: auto; }
.illustration img { display: block; width: 100%; height: auto; }
.illustration .darkIllustration { display: none; }
:global(body[data-ds-dark-theme]) .lightIllustration { display: none; }
:global(body[data-ds-dark-theme]) .darkIllustration { display: block; }
.credit {
display: grid;
grid-template-columns: minmax(0, 594px) minmax(0, 400px);
justify-content: center;
align-items: center;
gap: clamp(32px, calc((100vw - 994px) / 3), 164px);
max-width: 1196px;
transform: translate(-50%, calc(-50% - 5px));
}
.creditIllustration { width: 100%; height: auto; aspect-ratio: 594 / 509; object-fit: contain; }
.credit .copy { max-width: 400px; }
.creditActions { display: flex; gap: 24px; margin-top: 60px; }
.creditActions > button { flex: 1; max-width: 160px; }
.creditActions .laterAction { background: var(--dsw-alias-onboarding-secondary-fill); }
.creditActions .laterAction:hover:not(:disabled) {
background: linear-gradient(var(--dsw-alias-interactive-bg-hover), var(--dsw-alias-interactive-bg-hover)), var(--dsw-alias-onboarding-secondary-fill);
}
.page button { transition: background-color 120ms ease, color 120ms ease, opacity 120ms ease; }
.purpose, .process {
display: flex;
flex-direction: column;
align-items: center;
gap: 48px;
top: calc(50% - 188.5px);
transform: translateX(-50%);
}
.purpose { max-width: 843px; }
.process { max-width: 1130px; }
.purpose .copy, .process .copy { text-align: center; }
.purpose .copy h1, .process .copy h1 { font-size: 28px; line-height: 39px; }
.purpose .brand { line-height: 0; }
.subtitle { margin: 16px 0 0; font-size: 16px; line-height: 22px; font-weight: 300; color: var(--dsw-alias-label-tertiary); }
.purposeCards, .processCards { display: grid; width: 100%; gap: 16px; align-items: stretch; }
.purposeCards { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 17px; }
.processCards { grid-template-columns: repeat(3, minmax(0, 1fr)); min-height: 225px; padding-block: 1.5px; box-sizing: border-box; }
.card {
-webkit-app-region: no-drag;
--card-border-stops: var(--dsw-gradient-onboarding-blue-stops);
position: relative;
display: flex;
flex-direction: column;
align-items: flex-start;
box-sizing: border-box;
min-width: 0;
min-height: 222px;
padding: 48.5px 32.5px 64.5px;
border: 0;
border-radius: 32px;
background: var(--dsw-alias-onboarding-card-fill);
box-shadow: 8px 8px 24px color-mix(in srgb, var(--dsw-alias-onboarding-accent) 8%, transparent),
4px 4px 12px color-mix(in srgb, var(--dsw-alias-onboarding-accent) 4%, transparent);
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
backdrop-filter: blur(32.609px);
transition: background-color 120ms ease;
}
.card::before, .card::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
transition: opacity 120ms ease, border-color 120ms ease;
}
.card::before { border: 0.5px solid var(--dsw-alias-border-l2); }
.card::after {
padding: 2px;
background: conic-gradient(from var(--dsh-onboarding-border-angle), var(--card-border-stops));
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
mask-composite: exclude;
opacity: 0;
}
.card:hover::before { border-color: var(--dsw-alias-border-l4); }
.card:hover::after { opacity: 0.35; }
.selected::before { opacity: 0; }
.selected::after, .selected:hover::after { opacity: 1; }
.selected::after { animation: borderSweep 14s linear infinite; }
.officeCard, .card[data-process='compact'] { --card-border-stops: var(--dsw-gradient-onboarding-violet-stops); }
.card[data-process='expanded'] { --card-border-stops: var(--dsw-gradient-onboarding-cyan-stops); }
@property --dsh-onboarding-border-angle {
syntax: '<angle>';
inherits: false;
initial-value: 90deg;
}
@keyframes borderSweep {
from { --dsh-onboarding-border-angle: 90deg; }
to { --dsh-onboarding-border-angle: 450deg; }
}
.card:focus-visible, .card:has(:focus-visible) { outline: 2px solid var(--dsw-alias-onboarding-accent); outline-offset: 5px; }
.card[aria-disabled='true'] { cursor: wait; }
.cardTop { display: flex; align-items: center; justify-content: space-between; gap: 12px; width: 100%; height: 40px; margin-bottom: 12px; }
.cardCheckbox { position: static; }
.card .cardCheckbox input:focus-visible, .card .cardCheckbox input:focus-visible + span { outline: none; }
.cardCheckbox::after { content: ''; position: absolute; inset: 0; z-index: 1; cursor: pointer; }
.fileIcons { display: flex; align-items: center; gap: clamp(2px, 0.56vw, 8px); }
.fileIcons > svg { flex: none; transform-origin: left center; }
.iconsEntering > svg { animation: iconEnter var(--ds-transition-duration) var(--ds-ease-in-out) both; }
.iconsEntering > svg:nth-child(2) { animation-delay: 30ms; }
.iconsEntering > svg:nth-child(3) { animation-delay: 50ms; }
.iconsEntering > svg:nth-child(4) { animation-delay: 62ms; }
.iconsEntering > svg:nth-child(5) { animation-delay: 70ms; }
@keyframes iconEnter {
from { opacity: 0; transform: scale(0.8); }
to { opacity: 1; transform: scale(1); }
}
.cardTitle { font-size: 20px; line-height: 28px; font-weight: 400; }
.cardDescription { margin-top: 8px; font-size: 15px; line-height: 21px; font-weight: 300; color: var(--dsw-alias-label-tertiary); }
.processIcon { display: flex; align-items: center; justify-content: center; width: 40px; height: 40px; margin-bottom: 12px; }
.primaryAction { position: absolute; top: 100%; width: 100%; min-height: 44px; display: flex; justify-content: center; margin-top: 100px; }
.primaryAction > button { width: min(240px, 100%); }
.primaryAction > button[aria-disabled='true'] { cursor: wait; }
.navigation {
pointer-events: none;
position: absolute;
bottom: clamp(24px, 7.61vh, 70px);
left: 50%;
width: min(calc(100% - 2 * var(--page-padding)), 1200px);
transform: translateX(-50%);
display: flex;
justify-content: space-between;
}
.navigation button { pointer-events: auto; }
.navigation button { display: flex; align-items: center; gap: 20px; padding: 0; border: 0; background: transparent; color: var(--dsw-alias-label-tertiary); font: inherit; font-size: 16px; font-weight: 300; line-height: 32px; cursor: pointer; }
.navigation button:focus-visible { outline: 2px solid var(--dsw-alias-onboarding-accent); outline-offset: 5px; }
.navigation button:hover:not(:disabled) { color: var(--dsw-alias-label-primary); }
.backIcon { width: 17px; height: 14px; flex: none; background: currentColor; mask-size: contain; mask-repeat: no-repeat; mask-position: center; }
.status { position: absolute; left: 50%; bottom: 12px; transform: translateX(-50%); font-size: 14px; line-height: 22px; }
.confirmation { padding: 22px 24px 0; color: var(--dsw-alias-label-primary); }
.confirmationHeading { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 12px; }
.confirmation h2 { margin: 0; font-size: 16px; line-height: 24px; font-weight: 500; }
.confirmationClose { display: flex; align-items: center; justify-content: center; flex: none; width: 28px; height: 28px; border: 0; border-radius: 28px; background: transparent; color: var(--dsw-alias-label-secondary); cursor: pointer; }
.confirmationClose:hover { background: var(--dsw-alias-interactive-bg-hover); }
.confirmation p { margin: 0; font-size: 14px; line-height: 22px; }
.confirmationActions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 20px; }
.dialogButton { border-radius: 12px; font-family: var(--dsw-font-family); font-weight: 500; }
.dialogButton:first-child { font-weight: 400; }
@media (max-height: 760px) {
.purpose, .process { top: calc(50% - 180px); }
.purpose .copy h1, .process .copy h1 { font-size: 28px; line-height: 39px; }
.subtitle { margin-top: 16px; line-height: 21px; }
.card { padding: 24.5px 32.5px 32.5px; min-height: 187px; }
.processCards { min-height: 0; padding-block: 0; }
.primaryAction { margin-top: 40px; }
.creditActions { margin-top: 32px; }
}
@media (max-width: 1200px) {
.welcomeIllustration { left: calc(73.333vw - 267.5px); }
}
@media (min-height: 920px) {
.welcomeIllustration { top: calc(50% - 326.855px); }
}
@media (max-width: 1100px) {
.page { --page-padding: 40px; }
.copy h1 { font-size: 26px; line-height: 40px; }
.heroDescription { font-size: 16px; line-height: 28px; }
.welcome .copy h1 { font-size: 28px; line-height: 48px; }
.fileIcons { gap: 3px; }
}
@media (max-height: 560px) {
.page { overflow: auto; }
}
@media (prefers-reduced-motion: reduce) {
.page button, .card, .card::before, .card::after { transition: none; }
.page, .fileIcons > svg, .selected::after { animation: none; }
}
.page .action {
height: 44px;
border-radius: 12px;
padding: 0 24px;
font-family: var(--dsw-font-family);
font-size: 14px;
font-weight: 500;
line-height: 22px;
}
.page .action:focus-visible {
outline: 2px solid var(--dsw-alias-brand-primary);
outline-offset: 3px;
}
.page:lang(en) .navigation button {
font-family: var(--dsw-font-family-brand);
}
.cardCheckbox input { position: absolute; width: 24px; height: 24px; margin: 0; opacity: 0; }
.indicator {
box-sizing: border-box;
display: inline-flex;
flex: none;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: 2px solid var(--dsw-alias-onboarding-checkbox-border);
border-radius: 6px;
background: transparent;
color: var(--dsw-alias-label-primary-foreground);
pointer-events: none;
}
.indicator svg { visibility: hidden; }
.cardCheckbox input:checked + .indicator {
border-color: var(--dsw-alias-brand-primary);
background: var(--dsw-alias-brand-primary);
}
.cardCheckbox input:checked + .indicator svg { visibility: visible; }
@@ -0,0 +1,86 @@
/** Coordinates onboarding navigation, transitions and the native recharge page. */
import { useEffect, useRef, useState } from 'react'
import { Button } from '@deepseek-ai/dsh-client-ui-primitives'
import { hasOnboardingCredit } from './onboarding-balance.ts'
import { OnboardingSurface } from './OnboardingSurface.tsx'
import type { DesktopOnboardingProps } from './onboarding-contract.ts'
import { PlatformOverlay } from './PlatformOverlay.tsx'
import { OnboardingWelcomeStep } from './OnboardingWelcomeStep.tsx'
import { OnboardingCreditStep } from './OnboardingCreditStep.tsx'
import { OnboardingPurposeStep } from './OnboardingPurposeStep.tsx'
import { OnboardingProcessStep } from './OnboardingProcessStep.tsx'
import { OnboardingConfirmation, type OnboardingConfirmationKind } from './OnboardingConfirmation.tsx'
import backIcon from './assets/onboarding-back.svg'
import css from './DesktopOnboarding.module.css'
/** @param props - durable choices, account state, localized copy and native recharge commands. @returns the active first-run page. */
export function DesktopOnboarding({
state, account, platform, refresh, update, complete, retry, t, locale, exiting = false,
}: DesktopOnboardingProps) {
const [dialog, setDialog] = useState<OnboardingConfirmationKind | null>(null)
const [topUp, setTopUp] = useState(false)
const heading = useRef<HTMLHeadingElement>(null)
const page = useRef<HTMLElement>(null)
const progress = state.progress
const targetStep = progress.step
const [step, setStep] = useState(targetStep)
const busy = state.status === 'loading' || state.status === 'saving' || exiting || step !== targetStep
useEffect(() => {
if (step === targetStep) return
const element = page.current
if (!state.visible || element?.animate === undefined || matchMedia('(prefers-reduced-motion: reduce)').matches) {
setStep(targetStep)
return
}
const animation = element.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 40, fill: 'forwards', easing: 'ease-out' })
void animation.finished.then(() => { setStep(targetStep) }, (_error: unknown) => {
// A newer step or unmount cancels this visual transition.
})
return () => { animation.cancel() }
}, [targetStep, step, state.visible])
useEffect(() => { heading.current?.focus() }, [step])
if (state.visible && state.status === 'loading') return <OnboardingSurface>
<div className={css.loading} role="status">{t('onboardingLoading')}</div>
</OnboardingSurface>
if (!state.visible || step === 'done') return null
const go = (next: 'welcome' | 'credit' | 'purpose' | 'process') => { void update({ step: next }) }
const recharge = () => { setDialog(null); setTopUp(true) }
const returnFromRecharge = () => {
setTopUp(false)
void refresh().catch((_error: unknown) => {
// The account view owns refresh errors; returning keeps the credit page available.
})
}
const later = () => {
const balance = account.details?.balance
if (balance?.status === 'ready' && !hasOnboardingCredit(balance)) setDialog('credit')
else go('purpose')
}
const purposeNext = () => {
if (progress.purpose === 'office') void complete('completed')
else go('process')
}
return <>
<OnboardingSurface exiting={exiting}>
<section key={step} ref={page} className={`${css.page} ${dialog !== null ? css.blurred : ''}`} data-desktop-onboarding={step} lang={locale} aria-labelledby="desktop-onboarding-title" aria-busy={busy}>
{step === 'welcome' && <OnboardingWelcomeStep t={t} locale={locale} heading={heading} busy={busy} onStart={() => { go('credit') }} />}
{step === 'credit' && <OnboardingCreditStep t={t} locale={locale} heading={heading} busy={busy}
funded={state.creditFunded} canRecharge={platform !== undefined} onContinue={() => { go('purpose') }} onRecharge={recharge} onLater={later} />}
{step === 'purpose' && <OnboardingPurposeStep t={t} heading={heading} busy={busy} purpose={progress.purpose}
onSelect={(purpose) => { void update({ purpose }) }} onContinue={purposeNext} />}
{step === 'process' && <OnboardingProcessStep t={t} heading={heading} busy={busy} process={progress.process}
onSelect={(process) => { void update({ process }) }} onComplete={() => { void complete('completed') }} />}
{state.error !== null && <div className={css.status} role="alert">{t('onboardingSaveFailed')}<Button disabled={busy} onClick={() => { void retry() }}>{t('onboardingRetry')}</Button></div>}
{step !== 'welcome' && <footer className={css.navigation}>
<button type="button" disabled={busy} onClick={() => { go(step === 'credit' ? 'welcome' : step === 'purpose' ? 'credit' : 'purpose') }}><span className={css.backIcon} style={{ maskImage: `url(${backIcon})` }} aria-hidden="true" />{t('onboardingBack')}</button>
<button type="button" disabled={busy} onClick={() => { setDialog(step === 'credit' ? 'credit-skip' : 'skip') }}>{t('onboardingSkip')}</button>
</footer>}
</section>
</OnboardingSurface>
{dialog !== null && <OnboardingConfirmation kind={dialog} t={t} busy={busy} canRecharge={platform !== undefined}
onClose={() => { setDialog(null) }} onContinue={() => { setDialog(null); go('purpose') }} onRecharge={recharge}
onSkip={() => { void complete('skipped').then(() => { setDialog(null) }) }} />}
{topUp && platform !== undefined && <PlatformOverlay bridge={platform} page="top-up"
backLabel={t('backToHarness')} loadingLabel={t('loading')} failureLabel={t('failed')} retryLabel={t('platformRetry')} onClose={returnFromRecharge} />}
</>
}
@@ -0,0 +1,60 @@
/** Framework-hook projection into the desktop introduction's pure view. */
import { useEffect, useLayoutEffect, useState } from 'react'
import type { HostObservable, InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
import type { AccountSnapshot } from './AccountSection.tsx'
import type { DesktopOnboardingProps, DesktopOnboardingState } from './onboarding-contract.ts'
import { OnboardingSurface } from './OnboardingSurface.tsx'
import { DesktopOnboarding } from './DesktopOnboarding.tsx'
/** Observable sources and actions supplied by the account plugin's apply closure. */
export interface DesktopOnboardingInjected extends Omit<DesktopOnboardingProps, 'state' | 'account' | 't' | 'exiting' | 'locale'> {
hooks: {
onboarding: HostObservable<DesktopOnboardingState>
account: HostObservable<AccountSnapshot>
}
}
/**
* Read shared state through framework hooks; sessions do not control activation.
* @param props - shell overlay bindings and account-owned actions.
* @returns the unfinished flow, retained briefly after successful completion unless motion is reduced.
*/
export function DesktopOnboardingEntry({ useOnboarding, useAccount, ...props }:
PropsRuntime<'shell.overlay'> & PropsLocale<'settings.account'> & InjectFace<DesktopOnboardingInjected>) {
const locale = props.t('onboardingArtworkLocale')
const state = useOnboarding(value => value)
const account = useAccount(value => value)
useLayoutEffect(() => {
if (!state.visible) return
const bridge = (globalThis as typeof globalThis & {
dshOnboarding?: { setActive(active: boolean): void }
}).dshOnboarding
bridge?.setActive(true)
return () => { bridge?.setActive(false) }
}, [state.visible])
const [lastVisible, setLastVisible] = useState(state.visible ? state : null)
const done = !state.visible && state.progress.step === 'done'
&& account.view?.status === 'credential-stored'
const pendingCompletion = done && state.status === 'saving'
const completed = done && state.status === 'ready'
const exiting = completed && lastVisible !== null
&& !window.matchMedia('(prefers-reduced-motion: reduce)').matches
useLayoutEffect(() => {
if (state.visible) setLastVisible(state)
else if (!completed && !pendingCompletion) setLastVisible(null)
}, [state, completed, pendingCompletion])
useEffect(() => {
if (!exiting) return
// Matches OnboardingSurface's exit animation; cleanup also covers sign-out.
const timer = window.setTimeout(() => { setLastVisible(null) }, 180)
return () => { window.clearTimeout(timer) }
}, [exiting])
const displayed = state.visible ? state : exiting || pendingCompletion ? lastVisible : null
if (displayed === null) return state.status === 'loading'
? <OnboardingSurface><div role="status">{props.t('onboardingLoading')}</div></OnboardingSurface> : null
return <DesktopOnboarding {...props} locale={locale} state={pendingCompletion ? { ...displayed, status: 'saving' } : displayed}
account={account} exiting={exiting} />
}
@@ -0,0 +1,48 @@
/** Credit and setup confirmations with contained keyboard focus. */
import { useEffect, useRef } from 'react'
import { Button, IconCloseOutlineRegular, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
import type { DesktopOnboardingProps } from './onboarding-contract.ts'
import css from './DesktopOnboarding.module.css'
/** The action requiring confirmation, including skipping directly from the credit page. */
export type OnboardingConfirmationKind = 'credit' | 'credit-skip' | 'skip'
/** @param props - confirmation action, localized copy and navigation callbacks. @returns a focus-contained dialog. */
export function OnboardingConfirmation({ kind, t, busy, canRecharge, onClose, onContinue, onRecharge, onSkip }:
Pick<DesktopOnboardingProps, 't'> & {
kind: OnboardingConfirmationKind
busy: boolean
canRecharge: boolean
onClose: () => void
onContinue: () => void
onRecharge: () => void
onSkip: () => void
}) {
const content = useRef<HTMLDivElement>(null)
const credit = kind !== 'skip'
const title = t(credit ? 'onboardingNoCreditTitle' : 'onboardingSkipTitle')
useEffect(() => {
const previous = document.activeElement
content.current?.querySelector('button')?.focus()
return () => { if (previous instanceof HTMLElement && previous.isConnected) previous.focus() }
}, [])
return <Modal open headless backdropBlur={false} title={title} onClose={onClose}>
<div ref={content} className={css.confirmation} onKeyDown={(event) => {
if (event.key !== 'Tab') return
const buttons = content.current?.querySelectorAll<HTMLButtonElement>('button:not(:disabled)')
const first = buttons?.[0]
const last = buttons?.[buttons.length - 1]
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus() }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus() }
}}>
<div className={css.confirmationHeading}><h2>{title}</h2>
<button type="button" className={css.confirmationClose} aria-label={t('close')} onClick={onClose}><IconCloseOutlineRegular size={14} /></button>
</div>
<p>{t(credit ? 'onboardingNoCreditDescription' : 'onboardingSkipDescription')}</p>
<div className={css.confirmationActions}>
<Button className={css.dialogButton} variant="outline" disabled={busy} onClick={kind === 'credit-skip' ? onSkip : kind === 'credit' ? onContinue : onClose}>{t(credit ? 'onboardingUnderstood' : 'onboardingKeepSetting')}</Button>
<Button className={css.dialogButton} variant="primary" disabled={busy || (credit && !canRecharge)} onClick={credit ? onRecharge : onSkip}>{t(credit ? 'onboardingGoTopUp' : 'onboardingEnter')}</Button>
</div>
</div>
</Modal>
}
@@ -0,0 +1,34 @@
/** Credit introduction retains the action ordering selected on first entry. */
import type { RefObject } from 'react'
import { Button } from '@deepseek-ai/dsh-client-ui-primitives'
import type { DesktopOnboardingProps } from './onboarding-contract.ts'
import { OnboardingIllustration } from './OnboardingIllustration.tsx'
import art from './assets/onboarding-recharge.png'
import artDark from './assets/onboarding-recharge-dark.png'
import artZh from './assets/onboarding-recharge-zh.png'
import artZhDark from './assets/onboarding-recharge-zh-dark.png'
import css from './DesktopOnboarding.module.css'
/** @param props - credit facts, localized content and recharge/navigation actions. @returns the credit step. */
export function OnboardingCreditStep({ t, locale, heading, busy, funded, canRecharge, onContinue, onRecharge, onLater }:
Pick<DesktopOnboardingProps, 't' | 'locale'> & {
heading: RefObject<HTMLHeadingElement>
busy: boolean
funded: boolean
canRecharge: boolean
onContinue: () => void
onRecharge: () => void
onLater: () => void
}) {
return <div className={`${css.content} ${css.credit}`}>
<OnboardingIllustration className={css.creditIllustration} src={locale === 'zh' ? artZh : art} darkSrc={locale === 'zh' ? artZhDark : artDark} />
<div className={css.copy}>
<h1 id="desktop-onboarding-title" ref={heading} tabIndex={-1}>{t('onboardingCredit')}</h1>
<p className={css.heroDescription}>{t('onboardingCreditDescription')}</p>
<div className={css.creditActions}>
<Button className={css.action} variant="primary" disabled={busy || (!funded && !canRecharge)} onClick={funded ? onContinue : onRecharge}>{t(funded ? 'onboardingContinue' : 'onboardingTopUp')}</Button>
<Button variant="outline" className={`${css.action} ${css.laterAction}`} disabled={busy || (funded && !canRecharge)} onClick={funded ? onRecharge : onLater}>{t(funded ? 'onboardingFundedTopUp' : 'onboardingLater')}</Button>
</div>
</div>
</div>
}
@@ -0,0 +1,10 @@
/** Paired artwork follows the page's light and dark theme selectors. */
import css from './DesktopOnboarding.module.css'
/** @param props - artwork URLs and feature-owned geometry. @returns decorative theme variants. */
export function OnboardingIllustration({ className = '', src, darkSrc }: { className?: string | undefined; src: string; darkSrc: string }) {
return <div className={`${css.illustration} ${className}`} aria-hidden="true">
<img className={css.lightIllustration} src={src} alt="" />
<img className={css.darkIllustration} src={darkSrc} alt="" />
</div>
}
@@ -0,0 +1,55 @@
/** Work-detail choices with radio-group keyboard navigation. */
import type { KeyboardEvent, RefObject } from 'react'
import { Button } from '@deepseek-ai/dsh-client-ui-primitives'
import type { OnboardingProcess } from '../onboarding-settings.ts'
import type { DesktopOnboardingProps } from './onboarding-contract.ts'
import compactIcon from './assets/onboarding-compact.svg'
import detailedIcon from './assets/onboarding-detailed.svg'
import expandedIcon from './assets/onboarding-expanded.svg'
import css from './DesktopOnboarding.module.css'
const options = [
{ value: 'compact', titleKey: 'onboardingCompact', descriptionKey: 'onboardingCompactDescription', icon: compactIcon, size: 40 },
{ value: 'detailed', titleKey: 'onboardingDetailed', descriptionKey: 'onboardingDetailedDescription', icon: detailedIcon, size: 36 },
{ value: 'expanded', titleKey: 'onboardingExpanded', descriptionKey: 'onboardingExpandedDescription', icon: expandedIcon, size: 36 },
] as const
/** @param props - selected detail, localized copy and persistence callbacks. @returns the process step. */
export function OnboardingProcessStep({ t, heading, busy, process, onSelect, onComplete }: Pick<DesktopOnboardingProps, 't'> & {
heading: RefObject<HTMLHeadingElement>
busy: boolean
process: OnboardingProcess | null
onSelect: (value: OnboardingProcess) => void
onComplete: () => void
}) {
const onKeyDown = (event: KeyboardEvent, value: OnboardingProcess) => {
if (busy) return
const direction = event.key === 'ArrowRight' || event.key === 'ArrowDown' ? 1
: event.key === 'ArrowLeft' || event.key === 'ArrowUp' ? -1 : 0
if (direction === 0) return
event.preventDefault()
const nextIndex = (options.findIndex(option => option.value === value) + direction + options.length) % options.length
const next = options[nextIndex as 0 | 1 | 2]
onSelect(next.value)
event.currentTarget.parentElement?.querySelector<HTMLButtonElement>(`[data-process="${next.value}"]`)?.focus()
}
return <div className={`${css.content} ${css.process}`}>
<div className={css.copy}>
<h1 id="desktop-onboarding-title" ref={heading} tabIndex={-1}>{t('onboardingProcess')}</h1>
<p className={css.subtitle}>{t('onboardingProcessDescription')}</p>
</div>
<div className={css.processCards} role="radiogroup" aria-labelledby="desktop-onboarding-title">
{options.map(option => <button type="button" role="radio" key={option.value}
data-process={option.value} aria-checked={process === option.value}
tabIndex={process === option.value || (process === null && option.value === 'compact') ? 0 : -1}
className={`${css.card} ${process === option.value ? css.selected : ''}`}
aria-disabled={busy} onClick={() => { if (!busy) onSelect(option.value) }}
onKeyDown={(event) => { onKeyDown(event, option.value) }}>
<span className={css.processIcon}><img src={option.icon} width={option.size} height={option.size} alt="" /></span>
<span className={css.cardTitle}>{t(option.titleKey)}</span>
<span className={css.cardDescription}>{t(option.descriptionKey)}</span>
</button>)}
</div>
<div className={css.primaryAction}>{process !== null && <Button className={css.action} variant="primary" aria-disabled={busy} onClick={() => { if (!busy) onComplete() }}>{t('onboardingEnter')}</Button>}</div>
</div>
}
@@ -0,0 +1,65 @@
/** Work-purpose selection with controlled, independently selectable cards. */
import { useLayoutEffect, useRef, useState } from 'react'
import type { RefObject } from 'react'
import { Button, FileTypeIcon, IconCheckOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
import type { OnboardingPurpose } from '../onboarding-settings.ts'
import type { DesktopOnboardingProps } from './onboarding-contract.ts'
import officeIcon from './assets/onboarding-office.svg'
import codeIcon from './assets/onboarding-code.svg'
import css from './DesktopOnboarding.module.css'
/** @param props - selected purposes, localized copy and persistence callbacks. @returns the purpose step. */
export function OnboardingPurposeStep({ t, heading, busy, purpose, onSelect, onContinue }: Pick<DesktopOnboardingProps, 't'> & {
heading: RefObject<HTMLHeadingElement>
busy: boolean
purpose: OnboardingPurpose | null
onSelect: (value: OnboardingPurpose | null) => void
onContinue: () => void
}) {
const selected = (value: 'office' | 'development') => purpose === value || purpose === 'both'
const toggle = (value: 'office' | 'development') => {
if (busy) return
const office = value === 'office' ? !selected('office') : selected('office')
const development = value === 'development' ? !selected('development') : selected('development')
onSelect(office && development ? 'both' : office ? 'office' : development ? 'development' : null)
}
return <div className={`${css.content} ${css.purpose}`}>
<div className={css.copy}>
<h1 id="desktop-onboarding-title" ref={heading} tabIndex={-1}>{t('onboardingPurposePrefix')} <em className={css.brand}>{t('onboardingBrand')}</em> {t('onboardingPurposeSuffix')}</h1>
<p className={css.subtitle}>{t('onboardingPurposeDescription')}</p>
</div>
<div className={css.purposeCards}>
{(['office', 'development'] as const).map(value => <PurposeCard key={value} purpose={value} checked={selected(value)} t={t} onToggle={() => { toggle(value) }} />)}
</div>
<div className={css.primaryAction}>{purpose !== null && <Button className={css.action} variant="primary" aria-disabled={busy} onClick={() => { if (!busy) onContinue() }}>{t('onboardingContinue')}</Button>}</div>
</div>
}
function PurposeCard({ purpose, checked, t, onToggle }: {
purpose: 'office' | 'development'
checked: boolean
t: DesktopOnboardingProps['t']
onToggle: () => void
}) {
const previous = useRef(checked)
const [entering, setEntering] = useState(false)
useLayoutEffect(() => {
setEntering(checked && !previous.current)
previous.current = checked
}, [checked])
const titleKey = purpose === 'office' ? 'onboardingOffice' : 'onboardingDevelopment'
return <div className={`${css.card} ${checked ? css.selected : ''} ${purpose === 'office' ? css.officeCard : ''}`}>
<div className={css.cardTop}>
{checked ? <span className={`${css.fileIcons} ${entering ? css.iconsEntering : ''}`} aria-hidden="true">
{(purpose === 'office' ? ['word', 'ppt', 'excel', 'pdf', 'image'] as const : ['typescript', 'javascript', 'sql', 'python', 'cpp'] as const)
.map(kind => <FileTypeIcon key={kind} kind={kind} size={28} />)}
</span> : <img src={purpose === 'office' ? officeIcon : codeIcon} width={40} height={40} alt="" />}
<label className={css.cardCheckbox}>
<input type="checkbox" aria-label={t(titleKey)} checked={checked} onChange={onToggle} />
<span className={css.indicator} aria-hidden="true"><IconCheckOutlineRegular /></span>
</label>
</div>
<span className={css.cardTitle}>{t(titleKey)}</span>
<span className={css.cardDescription}>{t(purpose === 'office' ? 'onboardingOfficeDescription' : 'onboardingDevelopmentDescription')}</span>
</div>
}
@@ -0,0 +1,50 @@
.onboardingOverlay {
position: fixed;
inset: 0;
z-index: 900;
display: flex;
flex-direction: column;
background: var(--dsw-alias-bg-base);
user-select: none;
}
:global(html[data-platform='darwin']) .onboardingOverlay {
background: transparent;
}
.onboardingOverlay[data-exiting] {
animation: onboardingExit 180ms ease-out forwards;
pointer-events: none;
}
@keyframes onboardingExit {
from { opacity: 1; }
to { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.onboardingOverlay[data-exiting] {
animation: none;
}
}
/* First child: later content and dialogs subtract their interactive regions. */
:global(html[data-platform='darwin']) .dragBand {
position: absolute;
inset: 8px;
pointer-events: none;
}
.onboardingStage {
/* Clear the body portal's inherited no-drag before interactive descendants opt out. */
-webkit-app-region: initial;
display: flex;
flex: 1;
min-height: 0;
justify-content: center;
overflow: auto;
}
.onboardingOverlay[inert] .dragBand {
-webkit-app-region: no-drag;
}
@@ -0,0 +1,42 @@
/** Body-portaled introduction with shared background interaction ownership. */
import { acquireOverlayInert } from './overlay-inert.ts'
import { useLayoutEffect, useRef } from 'react'
import type { ReactNode } from 'react'
import { createPortal } from 'react-dom'
import css from './OnboardingSurface.module.css'
/**
* Render a body-portaled onboarding stage and keep the application root inert
* while mounted. Completion reveals the root beneath a fading stage;
* native titlebar controls, dialogs, and account pages remain above the stage.
* @param props.children - the step's page content, centered on the stage.
* @param props.exiting - reveal the application while the owner retains the stage for its 180ms exit.
* @returns the body-portaled overlay tree.
*/
export function OnboardingSurface({ children, exiting = false }: { children: ReactNode; exiting?: boolean }) {
const rootState = useRef<{ element: HTMLElement; opacity: string } | null>(null)
useLayoutEffect(() => {
const appRoot = document.getElementById('root')
if (appRoot === null) return
const releaseInert = acquireOverlayInert(appRoot)
const previousOpacity = appRoot.style.opacity
rootState.current = { element: appRoot, opacity: previousOpacity }
appRoot.style.opacity = '0'
return () => {
releaseInert()
appRoot.style.opacity = previousOpacity
rootState.current = null
}
}, [])
useLayoutEffect(() => {
const root = rootState.current
if (root !== null) root.element.style.opacity = exiting ? root.opacity : '0'
}, [exiting])
return createPortal((
<div className={css.onboardingOverlay} data-exiting={exiting || undefined} role="presentation">
<div className={css.dragBand} data-window-drag aria-hidden="true" />
<div className={css.onboardingStage}>{children}</div>
</div>
), document.body)
}
@@ -0,0 +1,28 @@
/** Welcome page with a required explicit start. */
import type { RefObject } from 'react'
import { Button } from '@deepseek-ai/dsh-client-ui-primitives'
import type { DesktopOnboardingProps } from './onboarding-contract.ts'
import { OnboardingIllustration } from './OnboardingIllustration.tsx'
import art from './assets/onboarding-welcome.png'
import artDark from './assets/onboarding-welcome-dark.png'
import artZh from './assets/onboarding-welcome-zh.png'
import artZhDark from './assets/onboarding-welcome-zh-dark.png'
import css from './DesktopOnboarding.module.css'
/** @param props - localized content, focus target and navigation. @returns the welcome step. */
export function OnboardingWelcomeStep({ t, locale, heading, busy, onStart }: Pick<DesktopOnboardingProps, 't' | 'locale'> & {
heading: RefObject<HTMLHeadingElement>
busy: boolean
onStart: () => void
}) {
return <div className={`${css.content} ${css.welcome}`}>
<div className={css.copy}>
<h1 id="desktop-onboarding-title" ref={heading} tabIndex={-1}>{t('onboardingWelcome')} <em className={css.brand}>{t('onboardingBrand')}</em></h1>
<p className={css.heroDescription}>{t('onboardingIntroduction')}</p>
<Button variant="primary" className={`${css.action} ${css.start}`} disabled={busy} onClick={onStart}>{t('onboardingStart')}</Button>
</div>
<div className={css.welcomeIllustration} aria-hidden="true">
<OnboardingIllustration className={css.welcomeArtwork} src={locale === 'zh' ? artZh : art} darkSrc={locale === 'zh' ? artZhDark : artDark} />
</div>
</div>
}
@@ -18,6 +18,7 @@
Windows keeps the native caption row as its drag surface instead — this bar
sits below that row (see above) and declares no drag of its own. */
.header {
-webkit-app-region: initial;
box-sizing: border-box;
height: 48px;
flex: none;
@@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Button, IconChevronLeftOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
import css from './PlatformOverlay.module.css'
import { acquireOverlayInert } from './overlay-inert.ts'
/** Non-secret commands supplied by the desktop application preload. */
export interface PlatformBridge {
@@ -33,8 +34,7 @@ export function PlatformOverlay({ bridge, page, backLabel, loadingLabel, failure
const element = viewport.current as HTMLDivElement
const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null
const background = Array.from(document.body.children).filter((element): element is HTMLElement =>
element instanceof HTMLElement && element !== layer.current).map(element => ({ element, inert: element.inert }))
for (const { element } of background) element.inert = true
element instanceof HTMLElement && element !== layer.current).map(element => acquireOverlayInert(element))
back.current?.focus()
let closed = false
const bounds = () => {
@@ -49,7 +49,7 @@ export function PlatformOverlay({ bridge, page, backLabel, loadingLabel, failure
return () => {
closed = true
observer.disconnect()
for (const { element, inert } of background) element.inert = inert
for (const release of background) release()
if (previousFocus?.isConnected) previousFocus.focus()
void bridge.close().catch(() => {
// Window teardown may remove the desktop IPC receiver before React unmounts.
@@ -0,0 +1,15 @@
/** Refresh ordering for returning from an external account operation. */
/**
* Wait for the pre-return read, then request a fresh account snapshot.
* @param pending - query already running before the user returned.
* @param refresh - account owner's normal refresh operation.
* @returns settlement of the new query, independent of the older query's outcome.
*/
export async function refreshAfterReturn(pending: Promise<void> | undefined, refresh: () => Promise<void>): Promise<void> {
try { await pending }
catch (_error) {
// A failed pre-return query must not prevent the user's post-operation refresh.
}
await refresh()
}
@@ -0,0 +1,9 @@
/** Illustration imports are embedded in the account client bundle. */
declare module '*.png' {
const svg: string
export default svg
}
declare module '*.svg' {
const url: string
export default url
}
@@ -0,0 +1,3 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="20.2792" height="16.825" viewBox="0 0 20.2792 16.825" fill="none" xmlns="http://www.w3.org/2000/svg">
<path id="Vector 5008" d="M1.01986 8.58958H20.2792M8.54303 0.407656L0.825554 8.58958L8.54303 16.4077" stroke="#81858C" stroke-width="1.18823"/>
</svg>

After

Width:  |  Height:  |  Size: 335 B

@@ -0,0 +1,7 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="LinkIcon--code 1">
<path id="Vector" d="M10.625 12.5L3.75 20L10.625 27.5" stroke="#3B86FF" stroke-width="2"/>
<path id="Vector_2" d="M29.375 12.5L36.25 20L29.375 27.5" stroke="#3B86FF" stroke-width="2"/>
<path id="Vector_3" d="M23.125 11.25L16.875 28.75" stroke="#3B86FF" stroke-width="2"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 476 B

@@ -0,0 +1,6 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="ExitFullscreenGlyph 1">
<path id="Vector" d="M22.5 6.25V15C22.5 15.663 22.7634 16.2989 23.2322 16.7678C23.7011 17.2366 24.337 17.5 25 17.5H33.75" stroke="#8370F1" stroke-width="2"/>
<path id="Vector_2" d="M17.5 33.75V25C17.5 24.337 17.2366 23.7011 16.7678 23.2322C16.2989 22.7634 15.663 22.5 15 22.5H6.25" stroke="#8370F1" stroke-width="2"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 527 B

@@ -0,0 +1,8 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="IconChecklistOutline14 1">
<path id="Vector" d="M8.4375 14.0625C10.6121 14.0625 12.375 12.2996 12.375 10.125C12.375 7.95038 10.6121 6.1875 8.4375 6.1875C6.26288 6.1875 4.5 7.95038 4.5 10.125C4.5 12.2996 6.26288 14.0625 8.4375 14.0625Z" stroke="#3B86FF" stroke-width="2"/>
<path id="Vector_2" d="M16.875 10.125H30.375" stroke="#3B86FF" stroke-width="2"/>
<path id="Vector_3" d="M8.4375 29.8125C10.6121 29.8125 12.375 28.0496 12.375 25.875C12.375 23.7004 10.6121 21.9375 8.4375 21.9375C6.26288 21.9375 4.5 23.7004 4.5 25.875C4.5 28.0496 6.26288 29.8125 8.4375 29.8125Z" stroke="#3B86FF" stroke-width="2"/>
<path id="Vector_4" d="M16.875 25.875H30.375" stroke="#3B86FF" stroke-width="2"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 871 B

@@ -0,0 +1,7 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="IconBrowseOutline16 1">
<path id="Vector" d="M28.125 3.375H7.875C6.63236 3.375 5.625 4.38236 5.625 5.625V30.375C5.625 31.6176 6.63236 32.625 7.875 32.625H28.125C29.3676 32.625 30.375 31.6176 30.375 30.375V5.625C30.375 4.38236 29.3676 3.375 28.125 3.375Z" stroke="#00A4C9" stroke-width="2"/>
<path id="Vector_2" d="M12.375 12.375H23.625" stroke="#00A4C9" stroke-width="2"/>
<path id="Vector_3" d="M12.375 21.375H19.125" stroke="#00A4C9" stroke-width="2"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 640 B

@@ -0,0 +1,8 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Frame 2117131378">
<path id="Vector" d="M10.5156 33.7727H29.4816C30.7242 33.7727 31.7316 32.7654 31.7316 31.5227V15.716C31.7316 15.1089 31.4863 14.5277 31.0516 14.1042L23.623 6.86866C23.2029 6.45945 22.6395 6.23047 22.0532 6.23047H10.5156C9.273 6.23047 8.26562 7.23782 8.26562 8.48047V31.5227C8.26562 32.7654 9.27297 33.7727 10.5156 33.7727Z" stroke="#8370F1" stroke-width="2"/>
<path id="Vector_2" d="M21.916 6.47656V13.3789C21.916 14.6216 22.9233 15.6289 24.166 15.6289H31.6317" stroke="#8370F1" stroke-width="2"/>
<path id="Vector_3" d="M12.1953 21.6016H19.6427" stroke="#8370F1" stroke-width="2"/>
<path id="Vector_4" d="M12.1953 27.1172H22.8924" stroke="#8370F1" stroke-width="2"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 872 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 375 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 417 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 333 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 337 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 575 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 547 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 551 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 581 KiB

@@ -1,5 +1,5 @@
/** Feishu questionnaire context follows the Platform Web ticket fields. */
import type { Config } from '../contact-config.ts'
import type { ContactConfig } from '../contact-config.ts'
/**
* Build an external questionnaire URL without authentication credentials.
@@ -7,7 +7,7 @@ import type { Config } from '../contact-config.ts'
* @param context - currently available build and browser environment.
* @returns questionnaire URL with hidden, optionally prefilled context fields.
*/
export function contactUrl(config: Config, context: {
export function contactUrl(config: ContactConfig, context: {
version: string | undefined
locale: string
width: number
@@ -6,13 +6,19 @@ import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
import type {} from '@deepseek-ai/dsh-client-ui-theme/client'
import type {} from '@deepseek-ai/dsh-api-remotes/client'
import type { AccountView, AccountDetails } from '@deepseek-ai/dsh-deepseek-account/types'
import type { OnboardingChange } from './onboarding-contract.ts'
import type { PlatformBridge } from './PlatformOverlay.tsx'
import { Config, CONTACT_CONFIG_GLOBAL } from '../contact-config.ts'
import { ContactConfig, CONTACT_CONFIG_GLOBAL } from '../contact-config.ts'
import { contactUrl } from './contact-url.ts'
import { AccountOnboarding } from './AccountOnboarding.tsx'
import { AccountMenu } from './AccountMenu.tsx'
import { AccountSection, type AccountSnapshot, type AccountSectionInjected } from './AccountSection.tsx'
import { en, zh, type AccountKey } from './locales.ts'
import { DESKTOP_ONBOARDING_NAMESPACE, type OnboardingSettings } from '../onboarding-settings.ts'
import { DesktopOnboardingController } from './onboarding-state.ts'
import { DesktopOnboardingEntry } from './DesktopOnboardingEntry.tsx'
import { readOnboardingApiKeyPresence } from './onboarding-credentials.ts'
import { refreshAfterReturn } from './account-refresh.ts'
export type { AccountSectionInjected, AccountSectionProps } from './AccountSection.tsx'
export type { AccountMenuProps } from './AccountMenu.tsx'
export type { AccountSnapshot } from './AccountSection.tsx'
@@ -22,14 +28,14 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap { 'settings.account': AccountKey }
}
/** Services required by account settings. */
export const inject = ['slots', 'locale', 'remote', 'remote.account', 'remote.session', 'theme']
export const inject = ['slots', 'locale', 'remote', 'remote.account', 'remote.session', 'theme', 'configForms']
/** Register account UI only in the Desktop renderer. @param ctx - client plugin context. */
export function apply(ctx: Context): void {
if (!('dshDesktop' in globalThis)) return
ctx.effect(() => ctx.locale.register('settings.account', { en, zh }), 'account: dictionaries')
const t = ctx.locale.bind('settings.account')
const page = globalThis as Partial<Record<typeof CONTACT_CONFIG_GLOBAL, unknown>>
const config = Config(page[CONTACT_CONFIG_GLOBAL] ?? {})
const config = ContactConfig(page[CONTACT_CONFIG_GLOBAL] ?? {})
let snapshot: AccountSnapshot = { view: undefined, details: undefined, failed: false, loginVisible: false }
const listeners = new Set<() => void>()
const publish = (value: AccountSnapshot) => { snapshot = value; for (const listener of listeners) listener() }
@@ -136,6 +142,37 @@ export function apply(ctx: Context): void {
},
async signOut() { const result = await ctx.remote.account.signOut(); if (!result.ok) throw new Error('account sign-out failed') },
}
if ('dshDesktop' in globalThis) {
const controller = new DesktopOnboardingController(
ctx.configForms.get<OnboardingSettings>(DESKTOP_ONBOARDING_NAMESPACE),
ctx.configForms.get<{ transcriptView: 'compact' | 'detailed' | 'expanded'; performanceUsage: 'compact' | 'detailed' }>('ui-chat'),
enabled => ctx.configForms.developerTools.setEnabled(enabled),
operations.hooks.account,
readOnboardingApiKeyPresence,
ctx.configForms.describe(),
)
ctx.effect(() => () => { controller.dispose() }, 'account: desktop onboarding lifetime')
ctx.effect(() => {
const refreshCredentials = () => { controller.invalidateCredentials() }
const disposers = [
ctx.remote.$on('credentials/reference-updated', refreshCredentials),
ctx.remote.$on('llm/adapters-updated', refreshCredentials),
ctx.configForms.describe().subscribe(refreshCredentials),
]
return () => { for (const dispose of disposers) dispose() }
}, 'account: desktop credential readiness')
ctx.slots.inject('shell.overlay', () => ctx.slots.register({
name: 'shell.overlay', id: 'desktop-onboarding', locale: 'settings.account',
inject: () => ({
hooks: { account: operations.hooks.account, onboarding: controller.state },
...nativePlatform === undefined ? {} : { platform: nativePlatform },
refresh: () => refreshAfterReturn(refreshing, refresh),
update: (change: OnboardingChange) => controller.update(change),
complete: (reason: 'completed' | 'skipped') => controller.complete(reason),
retry: () => controller.retry(),
}),
}, DesktopOnboardingEntry))
}
ctx.slots.inject('settings.models.sign-in', () => ctx.slots.register({
name: 'settings.models.sign-in', locale: 'settings.account', inject: () => operations,
}, AccountOnboarding))
@@ -1,7 +1,11 @@
/** Account settings copy, owned by the account feature. */
import { onboardingCopy, onboardingEnglishCopy } from './locales/onboarding.ts'
/** English account dictionary. */
export const en = {
modelSignInRequired: 'Model unavailable. Please sign in and try again.',
sessionExpired: 'You have signed out of your account, please log in again.',
...onboardingEnglishCopy,
close: 'Close', addApiKey: 'Add API Key', retry: 'Sign in again',
loginTitle: 'Get started', loginDescription: 'Sign in to your DeepSeek account or add an API Key to get started. Your projects and files are stored locally.',
browserTitle: 'Waiting for sign in', browserPrompt: 'Page did not open automatically? ', copyLink: 'Copy sign-in link', copiedLink: 'Link copied', copyFailed: 'Copy failed',
@@ -32,6 +36,7 @@ export type AccountKey = keyof typeof en
export const zh: Record<AccountKey, string> = {
modelSignInRequired: '当前模型暂不可用,请登录后再试',
sessionExpired: '登录信息已失效,请重新登录',
...onboardingCopy,
close: '关闭', addApiKey: '添加 API Key', retry: '重新登录',
loginTitle: '开始使用', loginDescription: '登录 DeepSeek 账号,或添加 API Key,即可开始使用。你的项目和文件保存在本地。',
browserTitle: '等待登录', browserPrompt: '没有自动打开新页面?', copyLink: '复制登录链接', copiedLink: '链接已复制', copyFailed: '复制失败',
@@ -0,0 +1,85 @@
/** Chinese first-run copy from the onboarding requirements. */
export const onboardingCopy = {
onboardingArtworkLocale: 'zh',
onboardingWelcome: '欢迎使用',
onboardingBrand: 'DeepSeek Harness',
onboardingIntroduction: 'DeepSeek Harness 会以本地文件夹作为工作区,通过调用各种工具,读写本机文件,完成搜索整理资料、制作文档表格、编写代码、排查问题等各种任务。',
onboardingStart: '开始设置',
onboardingCredit: '准备可用额度',
onboardingCreditDescription: 'DeepSeek Harness 执行任务时,会根据模型与工具使用的 token 量扣除额度。请提前添加额度,避免任务在执行过程中被迫中断。你的余额只会在 Agent 实际执行任务时消耗。',
onboardingTopUp: '去充值',
onboardingLater: '下一步',
onboardingFundedTopUp: '去充值',
onboardingPurposePrefix: '你希望',
onboardingPurposeSuffix: '帮你做什么?',
onboardingPurposeDescription: '我们会根据你的选择调整界面和工具,以更适合你的工作方式。',
onboardingOffice: '办公与创作',
onboardingOfficeDescription: '编辑文档、整理数据和制作演示文稿等',
onboardingDevelopment: '代码与开发',
onboardingDevelopmentDescription: '修改代码、调试问题、运行命令和管理项目文件等',
onboardingContinue: '继续',
onboardingProcess: '你希望看到多少工作过程?',
onboardingProcessDescription: '这只会影响工作过程的展示方式,不会影响 DeepSeek Harness 的工作能力。',
onboardingCompact: '聚焦结果',
onboardingCompactDescription: '只看结果,保持界面简洁明了',
onboardingDetailed: '关键细节',
onboardingDetailedDescription: '结果优先,仅展示重要步骤和关键操作',
onboardingExpanded: '完整过程',
onboardingExpandedDescription: '查看完整工作过程,方便调试和排查问题',
onboardingEnter: '进入应用',
onboardingBack: '上一步',
onboardingSkip: '跳过',
onboardingSkipTitle: '跳过设置?',
onboardingSkipDescription: '你可以随时前往 个人中心 → 通用设置 中调整工作过程、性能与用量展示方式或开启开发者工具。',
onboardingKeepSetting: '继续设置',
onboardingNoCreditTitle: '暂时跳过充值?',
onboardingNoCreditDescription: '没有可用额度时,DeepSeek Harness 将无法开始新的任务。你可以稍后前往 个人中心 → 账号与余额 进行充值。',
onboardingUnderstood: '我知道了',
onboardingGoTopUp: '前往充值',
onboardingSaveFailed: '未能保存设置,请重试。',
onboardingRetry: '重试',
onboardingLoading: '正在加载设置…',
} as const
/** English first-run copy with the same keys as the Chinese dictionary. */
export const onboardingEnglishCopy: Record<keyof typeof onboardingCopy, string> = {
onboardingArtworkLocale: 'en',
onboardingWelcome: 'Welcome to',
onboardingBrand: 'DeepSeek Harness',
onboardingIntroduction: '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.',
onboardingStart: 'Get started',
onboardingCredit: 'Add credits',
onboardingCreditDescription: '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.',
onboardingTopUp: 'Add credits',
onboardingLater: 'Next',
onboardingFundedTopUp: 'Add credits',
onboardingPurposePrefix: 'What would you like',
onboardingPurposeSuffix: 'to help with?',
onboardingPurposeDescription: 'We’ll tailor the interface and tools to fit the way you work.',
onboardingOffice: 'Office & creative work',
onboardingOfficeDescription: 'Edit documents, organize data, create presentations, and more',
onboardingDevelopment: 'Coding & development',
onboardingDevelopmentDescription: 'Edit code, debug issues, run commands, manage project files, and more',
onboardingContinue: 'Continue',
onboardingProcess: 'How much detail would you like to see?',
onboardingProcessDescription: 'This only changes how progress is shown, not what DeepSeek Harness can do.',
onboardingCompact: 'Results only',
onboardingCompactDescription: 'See just the results in a clean, simple interface',
onboardingDetailed: 'Key details',
onboardingDetailedDescription: 'Focus on results, with only the most important steps and actions shown',
onboardingExpanded: 'Full details',
onboardingExpandedDescription: 'See the full process for easier debugging and troubleshooting',
onboardingEnter: 'Open app',
onboardingBack: 'Back',
onboardingSkip: 'Skip',
onboardingSkipTitle: 'Skip setup?',
onboardingSkipDescription: 'You can change how progress, performance, and usage are displayed, or enable developer tools, anytime in Profile → General Settings.',
onboardingKeepSetting: 'Continue setup',
onboardingNoCreditTitle: 'Skip adding credits?',
onboardingNoCreditDescription: 'DeepSeek Harness can’t start new tasks without credits. You can add them later in Profile → Accounts.',
onboardingUnderstood: 'Got it',
onboardingGoTopUp: 'Add credits',
onboardingSaveFailed: 'Could not save settings. Try again.',
onboardingRetry: 'Retry',
onboardingLoading: 'Loading settings…',
}
Loaded 100 of 151 files, more files were not shown because too many files have changed in this diff. Show more