merge: integrate desktop shortcuts and refresh onboarding decisions

This commit is contained in:
zhc committed 2026-09-23 23:34:17 +08:00
commit 4fdcaf16b5
404 files changed
+14794 -789

No files matched your search

@@ -0,0 +1,19 @@
# Bilingual-pair consistency record for 2026-09-20-device-local-shortcut-preferences.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-20-device-local-shortcut-preferences.md
/agent-note-keep-shortcut-preferences-device-local-and-commit-before-activation:
en: 0f033ac1e9acec35
zh: 06f3bf59003446ea
/agent-note-keep-shortcut-preferences-device-local-and-commit-before-activation/problem:
en: d1d3ef4f6e66e5a7
zh: 5691f2b106617a86
/agent-note-keep-shortcut-preferences-device-local-and-commit-before-activation/decision:
en: 21f8da8d396c3f55
zh: 46de3df8141bcb0a
/agent-note-keep-shortcut-preferences-device-local-and-commit-before-activation/alternatives-considered:
en: 202dcaddc6c9c400
zh: 3f8e3576c2324636
/agent-note-keep-shortcut-preferences-device-local-and-commit-before-activation/consequences:
en: 9dcfdef13faa374f
zh: 1951198555fcc9ea
@@ -0,0 +1,27 @@
# Agent Note: Keep shortcut preferences device-local and commit before activation
Status: implemented
English | [中文](2026-09-20-device-local-shortcut-preferences.zh.md)
## Problem
Keyboard bindings depend on the receiving device's operating system and browser restrictions. A Host can serve several devices, while a local save can fail after a user records a new combination. Activating an unpersisted binding makes the interface disagree with the next application launch. Rewriting unreadable or future-version preferences can destroy choices an older client cannot interpret.
## Decision
[The shortcut service](../../../../packages/client/shortcuts/README.md) stores only versioned overrides per runtime/platform. Web owns origin-local browser storage; Electron main owns `userData/keybindings.json`, independently of Harness home. Both adapters share validation and transaction logic. Successful persistence publishes one accepted configuration from which command matching, keycaps, and native interception derive.
Missing commands retain dormant overrides. Explicit overrides win over newly introduced defaults; conflicting explicit overrides are all disabled. Unreadable configuration blocks ordinary edits and preserves the last accepted values. Restore All clears only the accepted current profile and cannot overwrite unreadable or future-version data.
## Alternatives considered
**Host settings.** A server's operating system and settings scope do not identify the visitor's device or browser restrictions. Host storage would also couple remote visitors' preferences.
**Optimistic activation.** Updating bindings before persistence makes a failed save appear successful and leaves restart behavior inconsistent with the visible controls.
**Cross-tab atomic merging.** localStorage has no compare-and-swap transaction. Web rereads before each operation and rejects known stale drafts, but accepts last-writer-wins for simultaneous writes. Electron main serializes edits and rejects stale revisions.
## Consequences
Devices and Web/Desktop do not synchronize bindings automatically. Repairing unreadable preferences requires correcting the stored document; application edits preserve those bytes. Browser concurrency does not promise lossless merging. Atomic Desktop replacement uses the shared writer's Windows retry behavior without promising fsync durability. Deterministic transaction, browser-storage, and Desktop-file tests cover failure retention, conflicts, stale drafts, and refusal to replace unreadable data; platform keyboard acceptance remains separate from persistence correctness.
@@ -0,0 +1,27 @@
# Agent Note: 快捷键偏好按设备保存,提交后才激活
Status: implemented
[English](2026-09-20-device-local-shortcut-preferences.md) | 中文
## 问题
快捷键取决于接收输入设备的操作系统和浏览器限制。一个 Host 可服务多个设备,而用户录入新组合后,本地保存仍可能失败。激活未持久化的绑定会使界面与下一次启动不一致。重写不可读或未来版本的偏好可能破坏旧客户端无法解释的选择。
## 决策
[快捷键服务](../../../../packages/client/shortcuts/README.zh.md) 只保存按运行端/平台区分的版本化覆盖。Web 使用同 origin 的浏览器存储;Electron main 管理 `userData/keybindings.json`,与 Harness home 独立。两端适配器共享校验和事务逻辑。持久化成功后发布一个已接受配置,命令匹配、键帽和原生拦截都由此派生。
未加载命令保留休眠覆盖。显式覆盖优先于新增默认值,相互冲突的显式覆盖全部禁用。不可读配置阻止普通编辑,并保留上次接受的值。全部恢复只清除已接受的当前平台覆盖,不会覆盖不可读或未来版本的数据。
## 考虑过的替代方案
**Host 设置。** 服务端操作系统和设置范围不能标识访问者设备或浏览器限制,Host 存储还会耦合远程访问者的偏好。
**乐观激活。** 在持久化之前更新绑定会让保存失败看起来像成功,并使重启行为与可见控件不一致。
**跨标签页原子合并。** localStorage 没有比较并交换事务。Web 每次操作前重新读取并拒绝已知过期草稿,但同时写入采用最后成功写入生效。Electron main 串行处理编辑并拒绝旧 revision。
## 影响
不同设备以及 Web/Desktop 不自动同步绑定。修复不可读偏好需要更正存储文档;应用内编辑保留这些原始内容。浏览器并发不承诺无损合并。Desktop 原子替换使用共享写入器的 Windows 重试行为,不承诺 fsync 持久性。确定性的事务、浏览器存储和桌面文件测试覆盖失败保留、冲突、过期草稿以及拒绝替换不可读数据;平台键盘验收与持久化正确性分别验证。
@@ -0,0 +1,19 @@
# Bilingual-pair consistency record for 2026-09-21-desktop-page-close-shortcuts.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-21-desktop-page-close-shortcuts.md
/agent-note-desktop-close-shortcuts-follow-page-focus:
en: e6ce0bc9592af8ba
zh: b43fa4c48c634e27
/agent-note-desktop-close-shortcuts-follow-page-focus/problem:
en: feb9d491ad4a9016
zh: 291f0e6c913b0b19
/agent-note-desktop-close-shortcuts-follow-page-focus/decision:
en: c587d88575b6ce7e
zh: 2287cf4d6d2e7587
/agent-note-desktop-close-shortcuts-follow-page-focus/alternatives-considered:
en: 10c7bb2fc87bdd16
zh: 796ecde8bcf09d34
/agent-note-desktop-close-shortcuts-follow-page-focus/consequences:
en: aea659e749840caa
zh: e75d757165055a55
@@ -0,0 +1,27 @@
# Agent Note: Desktop close shortcuts follow page focus
Status: implemented
English | [中文](2026-09-21-desktop-page-close-shortcuts.zh.md)
## Problem
The [shortcut PRD](https://trtgsjkv6r.feishu.cn/wiki/APvtwgbztiBLzvk9DM4cQbccnhf) gives the close command two targets: the focused right-sidebar page, or the Desktop window when no page can close. Electron's native close role bypasses the page owner. On Windows, closing the last window also quits the Desktop instance and stops its Host tasks, so this fallback is a lifecycle choice rather than a menu-label change.
## Decision
The sidebar page owner resolves close against live focus and captures the target's Session, pane, tab occurrence, and navigation revision. It closes that page through its resource cleanup handler, or collapses the sole docked guide. Stale sidebar targets do not fall back to another page or the window. Without a closeable page, Desktop requests native window closure; Web has no window fallback.
The macOS File menu routes Close Page or Window through the same owner and displays the accepted single-key binding. Native closure requires the current configuration revision, a focused and enabled product window, and inactive shortcut recording. The existing window lifecycle applies without a shortcut-specific confirmation: macOS keeps the application alive after its last window closes; Windows and Linux quit and stop the Host.
This decision owns File-menu and close behavior in place of the close-role choice in [the standard macOS menu note](../bug-fix/2026-09-16-desktop-window-menus.md). That note retains the rationale for native Window and application hide commands. [Shortcut preference persistence](2026-09-20-device-local-shortcut-preferences.md) owns accepted bindings and revision publication.
## Alternatives considered
**Use Electron's native close role.** It closes the window without asking the focused page owner and cannot preserve page cleanup or the accepted custom binding's routing.
**Disable the Windows window fallback.** This avoids a task-stopping close shortcut but omits the PRD's explicit Desktop fallback. The product keeps native window-close semantics; users who need the application to remain running can minimize it.
## Consequences
Close behaves consistently across the shortcut and macOS File menu. On Windows and Linux, pressing it outside a closeable sidebar page can stop active tasks by quitting the instance. The command does not add a background Host lifetime or a task-aware shutdown confirmation. Desktop keyboard tests cover revision and focus guards; sidebar focus tests cover captured page identity and stale targets; startup tests cover the platform menu declarations.
@@ -0,0 +1,27 @@
# Agent Note: Desktop 关闭快捷键跟随页面焦点
Status: implemented
[English](2026-09-21-desktop-page-close-shortcuts.md) | 中文
## 问题
[快捷键 PRD](https://trtgsjkv6r.feishu.cn/wiki/APvtwgbztiBLzvk9DM4cQbccnhf) 为关闭命令规定了两类目标:获得焦点的右侧栏页面,或没有可关闭页面时的 Desktop 窗口。Electron 原生 close role 绕过页面 owner。Windows 关闭最后一个窗口还会退出 Desktop 实例并停止其 Host 任务,因此该回退属于生命周期选择,不只是菜单标签变更。
## 决策
侧栏页面 owner 根据实时焦点解析关闭操作,并捕获目标的 Session、窗格、tab occurrence 和导航 revision。它通过资源清理处理器关闭该页面,或收起唯一的停靠引导页。过期侧栏目标不会回退到其他页面或窗口。没有可关闭页面时,Desktop 请求原生窗口关闭;Web 没有窗口回退。
macOS File 菜单通过同一个 owner 路由“关闭页面或窗口”,并显示已接受的单键绑定。原生关闭要求配置 revision 仍为当前值、产品窗口已聚焦且启用,并且没有在录制快捷键。现有窗口生命周期直接生效,不增加快捷键专用确认:macOS 最后一个窗口关闭后保留应用;Windows 和 Linux 退出并停止 Host。
本决策接管[标准 macOS 菜单记录](../bug-fix/2026-09-16-desktop-window-menus.zh.md)中 close role 选择所涉及的 File 菜单和关闭行为。该记录保留原生 Window 与应用隐藏命令的理由。[快捷键偏好持久化](2026-09-20-device-local-shortcut-preferences.zh.md)负责已接受绑定及 revision 发布。
## 考虑过的替代方案
**使用 Electron 原生 close role。** 它不询问获得焦点的页面 owner 就关闭窗口,无法保留页面清理和已接受自定义绑定的路由。
**禁用 Windows 窗口回退。** 这能避免关闭快捷键停止任务,但会遗漏 PRD 明确要求的 Desktop 回退。产品保留原生关窗语义;需要应用继续运行时,用户可以最小化窗口。
## 影响
快捷键与 macOS File 菜单的关闭行为一致。在 Windows 和 Linux 上,焦点不在可关闭侧栏页面时按下该快捷键,可能因退出实例而停止活动任务。该命令不增加后台 Host 生命周期或感知任务状态的退出确认。Desktop 键盘测试覆盖 revision 和焦点保护;侧栏焦点测试覆盖捕获的页面身份和过期目标;启动测试覆盖各平台菜单声明。
@@ -9,14 +9,14 @@
en: 75c201a0a687b59d
zh: 34593038f673d06f
/agent-note-desktop-standard-macos-window-menus/decision:
en: ea0310537d0251d9
zh: daf3f8a745bb2872
en: 1797b5695e873e5a
zh: 3376fda4fc36aaae
/agent-note-desktop-standard-macos-window-menus/alternatives-considered:
en: e2d3ed82f6247910
zh: 7e449d21f0601f65
en: 957382dbfb6cac48
zh: 2db98b7dbf3e01c0
/agent-note-desktop-standard-macos-window-menus/consequences:
en: 54527f9422525fb8
zh: 285fd6ffda5ff4fd
en: 1fa44c82ca450bcd
zh: b618f5ec0a90ed13
/agent-note-desktop-standard-macos-window-menus/testing:
en: 0c05c0044532f891
zh: cff254b44b3c7a2d
@@ -10,9 +10,9 @@ The Desktop shell replaces Electron's default application menu with a custom tem
## Decision
On macOS the template declares `{ role: 'fileMenu' }` before the Edit menu and `{ role: 'windowMenu' }` after it, and a separator-delimited run of `hide`, `hideOthers`, and `unhide` before Quit in the application submenu. Those roles contribute only the standard items; no Services submenu, window list, or other macOS default is declared. Windows and Linux keep the application and Edit menus.
On macOS the template declares `{ role: 'windowMenu' }` after the Edit menu, and a separator-delimited run of `hide`, `hideOthers`, and `unhide` before Quit in the application submenu. Those roles contribute only the standard items; no Services submenu, window list, or other macOS default is declared. Windows and Linux keep the application and Edit menus. The [page-close decision](../architecture/2026-09-21-desktop-page-close-shortcuts.md) owns the custom File menu and close behavior.
Electron supplies English defaults for the File, Window, and Edit roles. Explicit labels override role defaults while retaining native actions and shortcuts; the localized application commands are documented in the [Desktop README](../../../../apps/desktop/README.md). No custom close, minimize, or hide code is added: ⌘W destroys the window through Electron's own role.
Electron supplies English defaults for the Window and Edit roles. Explicit labels override role defaults while retaining native actions and shortcuts; the localized application commands are documented in the [Desktop README](../../../../apps/desktop/README.md). Minimize and hide execute through Electron's roles without custom handlers.
## Alternatives considered
@@ -22,13 +22,13 @@ Electron supplies English defaults for the File, Window, and Edit roles. Explici
**Add a bare `{ role: 'close' }` item to the application submenu.** It avoids a File menu containing a single command, but places a window command among the app-wide Plugins, Updates, Hide, and Quit items where no macOS application puts it.
**Declare the File and Window menus on every platform.** The same template declares Ctrl+W there; with one window, closing it invokes `window-all-closed` and quits the application, turning a window shortcut into an unrequested quit path.
**Declare the File and Window menus on every platform.** The same template declares Ctrl+W there; with one window, closing it invokes `window-all-closed` and quits the application. The menu-restoration task did not request this quit path; the page-close decision explicitly specifies the Desktop fallback.
**Recreate the main window on Dock activation regardless of other windows.** The plugin window can outlive the main window, so gating `activate` on `mainWindow` instead of `BrowserWindow.getAllWindows()` would let a Dock click always restore the main window. That changes window lifecycle beyond restoring the suppressed platform commands, so the existing `activate` condition stays.
## Consequences
macOS regains the window and application commands the custom menu suppressed. File, Window, and Edit retain Electron’s default English labels; application commands use the Desktop locale.
macOS retains native Window and application hide commands through four menu roles. Window and Edit retain Electron’s default English labels; application commands use the Desktop locale.
## Testing
@@ -10,9 +10,9 @@ Desktop shell 用自定义模板替换了 Electron 的默认应用菜单,该
## 决策
macOS 上的模板在 Edit 菜单之前声明 `{ role: 'fileMenu' }`,在其之后声明 `{ role: 'windowMenu' }`,并在应用子菜单的 Quit 之前声明由分隔符隔开的 `hide`、`hideOthers` 和 `unhide`。这些 role 只提供标准菜单项;不声明 Services 子菜单、窗口列表或其他 macOS 默认项。Windows 和 Linux 保留应用菜单和 Edit 菜单。
macOS 上的模板在 Edit 菜单之后声明 `{ role: 'windowMenu' }`,并在应用子菜单的 Quit 之前声明由分隔符隔开的 `hide`、`hideOthers` 和 `unhide`。这些 role 只提供标准菜单项;不声明 Services 子菜单、窗口列表或其他 macOS 默认项。Windows 和 Linux 保留应用菜单和 Edit 菜单。[页面关闭决策](../architecture/2026-09-21-desktop-page-close-shortcuts.zh.md)负责自定义 File 菜单和关闭行为。
Electron 为 File、Window 和 Edit role 提供英文默认标签。显式标签会覆盖 role 的默认标签,同时保留原生命令和快捷键;本地化的应用命令见 [Desktop README](../../../../apps/desktop/README.zh.md)。这里不新增任何自定义的关闭、最小化或隐藏代码:⌘W 通过 Electron 自身的 role 销毁窗口。
Electron 为 Window 和 Edit role 提供英文默认标签。显式标签会覆盖 role 的默认标签,同时保留原生命令和快捷键;本地化的应用命令见 [Desktop README](../../../../apps/desktop/README.zh.md)。最小化和隐藏通过 Electron 的 role 执行,没有自定义处理器。
## 考虑过的替代方案
@@ -22,13 +22,13 @@ Electron 为 File、Window 和 Edit role 提供英文默认标签。显式标签
**在应用子菜单里直接加一个 `{ role: 'close' }` 项。** 这样可以避免只有一个命令的 File 菜单,但会把窗口命令放到 Plugins、Updates、Hide、Quit 这些应用级命令中间,没有 macOS 应用这样排布。
**在所有平台都声明 File 和 Window 菜单。** 同一份模板在那些平台会把 Ctrl+W 声明为关闭;应用只有一个窗口,关闭它会触发 `window-all-closed` 并退出应用,把窗口快捷键变成用户没有要求的退出路径。
**在所有平台都声明 File 和 Window 菜单。** 同一份模板在那些平台会把 Ctrl+W 声明为关闭;应用只有一个窗口,关闭它会触发 `window-all-closed` 并退出应用。恢复菜单的任务没有要求这条退出路径;页面关闭决策明确规定了 Desktop 回退。
**让 Dock 激活时无论其他窗口是否存在都重建主窗口。** 插件窗口可以比主窗口存活更久,因此把 `activate` 的判断从 `BrowserWindow.getAllWindows()` 改为 `mainWindow` 能让点击 Dock 总可以恢复主窗口。但这超出了恢复被压掉的平台命令,属于窗口生命周期的行为变更,因此保留现有的 `activate` 判断。
## 影响
macOS 恢复了自定义菜单压掉的窗口和应用命令。File、Window 和 Edit 保留 Electron 的默认英文标签;应用命令使用 Desktop 的语言。
macOS 通过四个菜单 role 保留原生 Window 和应用隐藏命令。Window 和 Edit 保留 Electron 的默认英文标签;应用命令使用 Desktop 的语言。
## 测试
@@ -9,8 +9,8 @@
en: 50c7c883a2115b7e
zh: 60533d618a3b2da7
/agent-note-device-local-desktop-onboarding/decision:
en: e2b564bd7f89f868
zh: c1dbc0c00e07c8df
en: e228d2a308f8ad8a
zh: c5eee26119226f32
/agent-note-device-local-desktop-onboarding/alternatives-considered:
en: 82debfeba02ecfdb
zh: c15d8fcc012058e3
@@ -10,11 +10,11 @@ Account users need an introduction to credit and display choices after native au
## 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.
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 standard 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.
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 standard 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, standard, and detailed 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.
@@ -10,11 +10,11 @@ Status: implemented
## 决策
[账号 UI](../../../../packages/client/ui-settings-account/README.zh.md#desktop-onboarding)仅在存在 Desktop preload 标记、已存储账号凭证且本地引导未完成时,挂载一个 shell 覆盖层。Host 设置持有当前安装的版本化进度与完成状态,不使用 Platform 完成标记或逐账号历史。重启恢复已保存的步骤;退出登录会隐藏引导,但不清除进度。未登录且已配置模型凭证的安装会记录 API Key 完成状态,并应用详细过程、详细用量和开启开发者工具的配置。账号登录时丢弃失败的 API Key 完成写入,避免重试绕过必需的欢迎页。
[账号 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` 值。
用途和过程选择会作为草稿持久化。仅选办公用途时采用简洁展示并完成。选择开发或两种用途时必须选择过程详情。任意可跳过步骤均采用标准过程、简洁用量和关闭开发者工具。完成时先应用 Chat 和共享开发者工具偏好,再保存完成标记,避免偏好写入被拒绝后静默完成引导。compact、standard 和 detailed 各自持久化为对应的 `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 字体。保存选择时保留焦点,减少动态效果偏好会禁用动画,应用在退出覆盖层卸载前保持不可交互。确认弹窗模糊源页面,避免在半透明窗口内再次采样背景。
+7 -7
View File
@@ -6,20 +6,20 @@
en: 907e8bc5ebef2678
zh: f23af881b27cad94
/deepseek-harness-desktop/key-technical-decisions:
en: 47ec880441223749
zh: 3be7731db888b4a4
en: 069fdfd72d2ea26a
zh: 81c683ada1fe3e8d
/deepseek-harness-desktop/key-technical-decisions/bundled-workspace-dependencies:
en: c2eb16f568cd8554
zh: 686d64af17b10375
/deepseek-harness-desktop/installation-ownership:
en: 3fe85101acf17b0a
zh: 70cd207cadc85b94
en: 8307baf6c5b691d0
zh: 86e46b9552017ff6
/deepseek-harness-desktop/installation-ownership/runtime-and-plugin-activation:
en: 0d11af8639977a6d
zh: 86b086b12d2210d3
/deepseek-harness-desktop/develop:
en: 99f5299ebcf69b97
zh: 0411074daee0cd79
en: 9b9b362a9865e763
zh: c748901168e2db8c
/deepseek-harness-desktop/develop/startup-onboarding:
en: f01d0dbafe8b1613
zh: 91ad92f5d7e505c4
@@ -52,7 +52,7 @@
zh: 031e15f931479f27
/deepseek-harness-desktop/updates/mandatory-update-policy:
en: b99d35abf25d5347
zh: ed250334da3f149d
zh: 2f39041146268a20
/deepseek-harness-desktop/updates/local-updater-qualification:
en: bf34de891411c618
zh: aa6923fd5060174b
+7 -3
View File
@@ -24,6 +24,10 @@ Press F12 (Fn+F12 on media-key keyboards), Command+Option+I on macOS, or Ctrl+Sh
The original artwork lives in `resources/icon.png` and `resources/icon.svg`; platform adaptations retain the whale and gradients in `resources/icon-windows.*` and `resources/icon-macos.*`. Export each platform SVG as a transparent 1024×1024 PNG. Electron-builder generates the multi-size ICO for the Windows application, installer, and uninstaller ([Windows icon requirements](https://learn.microsoft.com/en-us/windows/apps/design/iconography/app-icon-construction)). The installation pages use matching artwork in both themes; the uninstaller's welcome and finish pages share `installer/assets/uninstaller-sidebar.png`, converted to a 164×314 BMP during preparation.
Shortcut overrides are stored in `app.getPath('userData')/keybindings.json`, separately from `DSH_HOME`. The main process validates and serializes changes before publishing accepted bindings. Failed reads retain the last accepted bindings and block edits, including Restore All; unreadable and future-version files remain unchanged. Development can isolate these preferences with `DSH_DESKTOP_USER_DATA_DIR`; the launcher prints its resolved path. See the [shortcut service](../../packages/client/shortcuts/README.md) for format and conflict semantics.
The macOS File menu displays the accepted single-key binding, including arrow keys, and routes Close Page or Window through the client page owner. On Windows and macOS, all complete accepted bindings are intercepted before main-document, embedded-frame, and browser-guest input, including editing and terminal input. Recording and input-method composition remain protected. Update overlays block product shortcuts and editing-key delivery to the parent window and its browser guests from creation until the last overlay closes. Every overlay opening or closing invalidates pending chord state. The main process shares one overlay owner between update dialogs and shortcut input through explicit creation and input-state capabilities. Two-key chords leave the first key’s initial press available to the page and do not intercept its release; complete chords and their repeats are consumed. The renderer leaves configurable binding dispatch to the native adapter. Chords have no native menu accelerator. Accepted commands are forwarded once through the trusted preload. Linux dispatches main-document shortcuts through the DOM and forwards accepted embedded-frame bindings to the client resolver. Closing the last window preserves macOS application lifetime; Windows exits the desktop instance and stops its tasks. The [close decision](../../.agents/notes/implemented/architecture/2026-09-21-desktop-page-close-shortcuts.md) records this lifecycle choice.
The macOS PNG uses an inset rounded background for legacy ICNS packaging, with representations up to 1024 pixels. It is a flattened icon, not an Icon Composer document. Apple's [app icon guidance](https://developer.apple.com/design/human-interface-guidelines/app-icons) describes unmasked layers for Icon Composer; those inputs require a separate macOS export and must not reuse the rounded ICNS artwork. Verify Finder and Dock appearance on supported macOS versions before release.
### Bundled workspace dependencies
@@ -68,9 +72,9 @@ The product UI retains Web actions, including "Open In..." through the shared au
Electron chooses typed English or Chinese shell copy from its application locale and falls back to English. The macOS application bundle declares English and Simplified Chinese in `CFBundleLocalizations`, allowing macOS to match the initial application language to the user’s preferred languages. Saved Client UI language preferences still take precedence for the main interface. On Windows, the main document's language updates desktop menus, recovery and update prompts. The repository Client UI i18n gate checks desktop sources.
Windows uses a 40-DIP caption with native window controls and colors synchronized from the application palette. Localized Application and Edit entries beside the sidebar toggle open native popup menus. They mount only after the application frame publishes its shell overlay seat, and remain absent during startup loading. Application provides Check for Updates and Exit; Edit provides undo, redo, cut, copy, paste, delete, and select all by sending the corresponding keys to the focused editor. Plugin management uses the main application's Plugins page. No separate native menu row appears on Alt. Other platforms retain their native menus. Editable fields retain keyboard commands and a context menu without shortcut labels; Chromium supplies command availability, and selected read-only text offers Copy.
Windows uses a 40-DIP caption with native window controls and colors synchronized from the application palette. Localized Application and Edit entries beside the sidebar toggle open native popup menus. They mount only after the application frame publishes its shell overlay seat, and remain absent during startup loading. Application provides Check for Updates and Exit; Edit provides undo, redo, cut, copy, paste, delete, and select all by sending the corresponding keys to the focused editor, independently of custom shortcut bindings. Plugin management uses the main application's Plugins page. No separate native menu row appears on Alt. Other platforms retain their native menus. Editable fields retain keyboard commands and a context menu without shortcut labels; Chromium supplies command availability, and selected read-only text offers Copy.
On macOS the custom application menu also declares the standard File, Window, and application menus, because replacing Electron's default menu drops Close Window (⌘W), Minimize (⌘M), and Hide (⌘H). Linux keeps the application and Edit menus.
On macOS the custom menu retains Electron's standard Window menu and application hide commands, including Minimize (⌘M) and Hide (⌘H). Linux keeps the application and Edit menus.
### Runtime and plugin activation
@@ -92,7 +96,6 @@ Before the first fatal dialog opens, Electron writes a crash report to the platf
Recovery waits for Host shutdown before changing plugin activation. The native recovery action calls the shared app-boot recovery function under the profile transaction lock. It disables third-party bundles and renames the profile’s `cordis.patch.yml` to `cordis.patch.yml.bak-<timestamp>` (with an ordinal on collisions) without parsing it; the next startup creates an empty patch. Installed packages and earlier backups remain. The home-level patch is unchanged. The Electron console records the backup path (or its absence) and the unchanged home-level patch. Invalid profile data, rename failures, or write failures are reported as recovery-operation errors; completed changes remain, and Desktop does not restart as though recovery succeeded. Desktop has no profile-reset action or emergency HTML document.
## Develop
The development application menu offers Reload Page (Cmd+R on macOS, Ctrl+R elsewhere) and Restart App and Host. Restart waits for Host shutdown before relaunching Electron and starting a new Host; neither action rebuilds source files.
@@ -113,6 +116,7 @@ pnpm run start:desktop
The Web counterparts are `pnpm run dev:web` and `pnpm run start:web`, documented in the [development guide](../../docs/development.md). Workspace development runs the current CLI and private Desktop Host packages under Electron RunAsNode. Plugin management and recovery use `$DSH_HOME/profiles/desktop`, separate from the disposable workspace runtime. The Host uses runtime module resolution in both development and packaged builds without creating official-package fallback links; developer-installed packages, including links, retain native priority. Use an unpacked application to exercise Electron RunAsNode, bundled pnpm, bundled dsh resources, plugin installation and repair paths.
The [native/renderer keyboard tests](tests/keyboard.spec.ts) compile in [their own Client test project](../../tsconfig.desktop-keyboard-tests.json), reached by the repository Client typecheck. Its Desktop imports are limited to Cordis-free input, persistence, IPC, browser-guest, and overlay modules.
### Startup onboarding
+9 -5
View File
@@ -24,6 +24,10 @@ Desktop Host 的 Platform API 请求与更新策略请求使用相同的 `x-clie
设计师原稿位于 `resources/icon.png` 和 `resources/icon.svg`;平台适配保留鲸鱼与渐变,分别位于 `resources/icon-windows.*` 和 `resources/icon-macos.*`。将各平台 SVG 导出为透明的 1024×1024 PNG。electron-builder 为 Windows 应用、安装程序和卸载程序生成多尺寸 ICO([Windows 图标要求](https://learn.microsoft.com/en-us/windows/apps/design/iconography/app-icon-construction))。安装页面在两种主题下使用匹配的图案;卸载程序的欢迎和完成页共用 `installer/assets/uninstaller-sidebar.png`,准备阶段将其转换为 164×314 BMP。
快捷键覆盖保存在 `app.getPath('userData')/keybindings.json`,与 `DSH_HOME` 分离。主进程校验并串行保存修改后才发布已接受键位。读取失败保留上次接受的键位并阻止编辑,包括全部恢复;不可读和未来版本的文件保持不变。开发时可通过 `DSH_DESKTOP_USER_DATA_DIR` 隔离这些偏好,启动器会输出解析后的路径。格式和冲突语义见[快捷键服务](../../packages/client/shortcuts/README.zh.md)。
macOS“文件”菜单显示已接受的单键绑定(包括方向键),并通过 Client 页面 owner 路由“关闭页面或窗口”。Windows 和 macOS 在主文档、内嵌 frame 和浏览器 guest 输入之前拦截所有已接受的完整绑定,包括编辑和终端输入。录制和输入法组合状态仍受保护。更新蒙层从创建到最后一个蒙层关闭期间阻挡父窗口及其浏览器 guest 的产品快捷键和编辑按键递送。每次打开或关闭蒙层都会作废待完成的组合键状态。主进程通过显式的创建能力和输入状态读取能力,让更新对话框与快捷键输入共享同一个蒙层管理实例。双键组合会将首键的初次按下事件交给页面,且不拦截其松开事件;完整组合及其重复事件会被消费。渲染进程将可配置绑定的分发交给原生适配器。双键组合不注册原生菜单快捷键。已接受的命令通过可信 preload 转发一次。Linux 通过 DOM 分发主文档快捷键,并将已接受的内嵌 frame 绑定转发给 Client 解析器。关闭最后一个窗口后 macOS 保留应用生命周期;Windows 退出桌面实例并停止其任务。[关窗决策](../../.agents/notes/implemented/architecture/2026-09-21-desktop-page-close-shortcuts.zh.md)记录了这一生命周期选择。
macOS PNG 使用带留白的圆角底板,供传统 ICNS 打包使用,包含最高 1024 像素的表示。它是扁平图标,并非 Icon Composer 文档。Apple 的[应用图标指南](https://developer.apple.com/design/human-interface-guidelines/app-icons)要求向 Icon Composer 提供未遮罩的图层;这些输入需要在 macOS 上单独导出,不能复用已做圆角的 ICNS 图案。发布前须在支持的 macOS 版本中验收 Finder 和 Dock 的显示效果。
### 内置工作区依赖
@@ -58,7 +62,7 @@ Welcome 加载共享 Toast 的配色和阴影变量,挂载在 body 下的通
Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 包含 pnpm 安装的包;`dsh.profile.bundles` 包含内置 bundle,后接已启用插件。签名应用从 `resources/app.asar/dsh` 提供 dsh、私有 Desktop Host 及其生产依赖。打包应用选择 runtime profile 解析,不创建包链接;开发 profile 使用文件系统链接。宿主与插件在同一个 Electron Node 模式进程中执行;Desktop 不启用 `--preserve-symlinks`。CLI 不能启动或修改此 profile。
应用 preload 只向 `dsh-app://app` 文档暴露启动就绪、致命启动失败上报、原生目录选择、用于 composer 路径引用的 `__DSH_HOST_PATHS__` 桥接和租约范围内的 Browser 桥接。产品页面还获得 Desktop 标记、更新展示数据和打开原生确认的操作,不能选择安装产物或授权安装。插件管理使用 Web 应用经过认证的 HTTP API;Electron 在 `dsh-app://shell/` 本地提供更新弹窗文档和资源,不依赖 Host 就绪。Electron 不提供插件管理 IPC 或独立管理页面。
应用 preload 只向 `dsh-app://app` 文档暴露启动就绪、致命启动失败上报、原生目录选择、用于 composer 路径引用的 `__DSH_HOST_PATHS__` 桥接和租约范围内的 Browser 桥接。产品页面还获得 Desktop 标记、更新展示数据和打开原生确认的操作,不能选择安装产物或授权安装。插件管理使用 Web 应用经过认证的 HTTP API;Electron 在 `dsh-app://shell/` 本地提供更新弹窗文档和资源,不依赖 Host 就绪。Electron 不提供插件管理 IPC 或独立管理页面。任何渲染进程都不会获得文件系统访问、原始 Electron IPC、shell 或任意 pnpm 参数。
只有主应用窗口启用 `<webview>`。guest 挂载必须匹配主进程签发的租约和分区;guest 保持 sandbox、context isolation 和 Web security,不启用 Node integration 或 guest preload。Browser IPC 监听只为应用文档创建。[Sidebar Browser](../../packages/client/ui-sidebar-browser/README.zh.md) 说明存储分组和 guest 限制;Host 鉴权仍独立于 URL 过滤而必需。
@@ -68,9 +72,9 @@ Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 包含 pnpm
Electron 根据应用语言选择类型化的英文或中文 shell 文案,并回退到英文。macOS 应用包通过 `CFBundleLocalizations` 声明支持英语和简体中文,让 macOS 根据用户的首选语言匹配初始应用语言。主界面仍优先使用已保存的 Client UI 语言偏好。在 Windows 上,主文档的语言会更新桌面菜单、恢复与更新提示。仓库 Client UI i18n 检查覆盖桌面端源码。
Windows 使用 40 DIP 顶栏,保留原生窗口按钮,颜色随应用调色板同步。侧栏开关旁的本地化“应用”和“编辑”入口打开原生弹出菜单。仅当应用框架发布 shell overlay 席位后才挂载菜单,启动加载期间不显示。“应用”提供检查更新和退出;“编辑”向当前编辑器发送对应按键,提供撤销、重做、剪切、复制、粘贴、删除和全选。插件管理使用主应用的“插件”页面。按 Alt 不会出现额外的原生菜单行。其他平台保留原生菜单。可编辑区域保留快捷键和不带快捷键标注的右键菜单;命令可用状态由 Chromium 提供,选中的只读文本提供“复制”命令。 顶栏菜单保留鼠标和键盘操作,顶栏中的 Web 控件仍可触达。共享 DOM 只约束展示,不构成安全边界:产品脚本可以隐藏蒙层,但不能借此清除主进程策略或授权安装。更新操作与状态通过隔离预加载和壳 frame 之间的私有 MessageChannel 传递。
Windows 使用 40 DIP 顶栏,保留原生窗口按钮,颜色随应用调色板同步。侧栏开关旁的本地化“应用”和“编辑”入口打开原生弹出菜单。仅当应用框架发布 shell overlay 席位后才挂载菜单,启动加载期间不显示。“应用”提供检查更新和退出;“编辑”向当前编辑器发送对应按键,提供撤销、重做、剪切、复制、粘贴、删除和全选,不受自定义快捷键绑定影响。插件管理使用主应用的“插件”页面。按 Alt 不会出现额外的原生菜单行。其他平台保留原生菜单。可编辑区域保留快捷键和不带快捷键标注的右键菜单;命令可用状态由 Chromium 提供,选中的只读文本提供“复制”命令。
macOS 上自定义应用菜单还会声明标准的 File、Window 和应用菜单,因为替换 Electron 的默认菜单会丢掉 Close Window(⌘W)、Minimize(⌘M)和 Hide(⌘H)。Linux 保留应用菜单和 Edit 菜单。
macOS 上自定义菜单保留 Electron 的标准 Window 菜单及应用隐藏命令,包括 Minimize(⌘M)和 Hide(⌘H)。Linux 保留应用菜单和 Edit 菜单。
### 运行时与插件激活
@@ -92,7 +96,6 @@ macOS 上自定义应用菜单还会声明标准的 File、Window 和应用菜
恢复操作等待 Host 关闭后才修改插件启用状态。原生恢复操作在 profile 事务锁内调用共享 app-boot 恢复函数。它禁用第三方 bundle,并将 profile 的 `cordis.patch.yml` 重命名为 `cordis.patch.yml.bak-<timestamp>`(重名时追加序号),无需解析;下次启动创建空 patch。已安装包和已有备份保留。home 级 patch 不变。Electron 控制台记录备份路径(或原文件不存在)以及 home 级 patch 未修改。profile 数据无效、重命名失败或写入失败会作为恢复操作错误报告;已完成的修改保留,Desktop 不会假装恢复成功后重启。Desktop 不提供 profile 重置操作或应急 HTML 文档。
## 开发
开发环境应用菜单提供“刷新页面”(macOS 为 Cmd+R,其他平台为 Ctrl+R)和“重启应用与 Host”。重启会等待 Host 关闭,再重新启动 Electron 和新的 Host;这两项操作都不会重新构建源码。
@@ -113,6 +116,7 @@ pnpm run start:desktop
Web 侧的对应命令是 `pnpm run dev:web` 与 `pnpm run start:web`,见[开发指南](../../docs/development.zh.md)。Workspace 开发使用 Electron RunAsNode 运行当前 CLI 与私有 Desktop Host 包,插件管理和恢复使用 `$DSH_HOME/profiles/desktop`,与一次性工作区运行时分离。Host 在开发与打包构建中都使用 runtime 模块解析,不创建官方包的 fallback 链接;开发者安装的包(包括链接)保留原生优先级。需要验证 Electron RunAsNode、内置 pnpm、内置 dsh 资源、插件安装和修复时,应运行未封装安装器的应用目录。
[原生输入与渲染进程键盘测试](tests/keyboard.spec.ts)在[独立的 Client 测试项目](../../tsconfig.desktop-keyboard-tests.json)中编译,由仓库 Client 类型检查纳入。它只导入不依赖 Cordis 的 Desktop 输入、持久化、IPC、浏览器 guest 和蒙层模块。
### 启动引导
@@ -383,7 +387,7 @@ macOS 打包在组装 App 时、代码签名前写入 `Contents/Resources/app-up
登录和策略请求共用内存 Session,与产品窗口及 updater 隔离;应用重启后需要重新登录。关闭窗口取消登录,导航失败提供本地化重试提示。返回服务后重新查询策略;重定向、Cookie 或 HTTP 422 都不是有效策略决定。取消、登录过期及无效响应均保留已知强更阻塞。固定登录结果写入进程诊断及可选更新日志;登录控制器不记录 Cookie、OAuth 参数或远程错误原文。真实 Harness 网关/API 联调及 macOS 登录验收仍未完成。
扁平化的 `40005` 打开壳拥有的模态窗口,并拒绝后续插件修改,不停止现有 Host 任务。服务端标题与详情是可选纯文本,缺失时使用客户端兜底文案;缺少下载地址或地址未获批准时隐藏外部页面操作,不解除阻塞。macOS 强更蒙层原位渐入渐出,在更新状态切换时保留蒙层,并将父窗口焦点和键盘输入重定向到蒙层。Windows 由隔离的应用 preload 在主窗口内挂载 shell 来源的 frame,以蒙层和弹窗覆盖 40 DIP 顶栏下方的内容区域。它阻止背景页面输入,不创建额外的原生窗口;移动和最大化只作用于主窗口。父窗口保持启用,原生移动、缩放、最小化、最大化和关闭控件仍可操作。退出应用会完成清理,不会解除更新要求;Esc 不会关闭覆盖层。批准安装后,安装器接管的退出流程会在 Electron 关闭窗口前释放模态窗口。下载、含准备步骤的文件校验、任务检查和安装确认共用同一弹窗。只有第二次用户批准才允许任务收尾和安装;稍后更新保留阻塞与安装包。仅存在受影响任务时,重启文案才提示正在停止任务。策略不跨应用重启持久化,策略响应也不作废或替换 updater 产物。
扁平化的 `40005` 打开壳拥有的模态窗口,并拒绝后续插件修改,不停止现有 Host 任务。服务端标题与详情是可选纯文本,缺失时使用客户端兜底文案;缺少下载地址或地址未获批准时隐藏外部页面操作,不解除阻塞。macOS 强更蒙层原位渐入渐出,在更新状态切换时保留蒙层,并将父窗口焦点和键盘输入重定向到蒙层。Windows 由隔离的应用 preload 在主窗口内挂载 shell 来源的 frame,以蒙层和弹窗覆盖 40 DIP 顶栏下方的内容区域。它阻止背景页面输入,不创建额外的原生窗口;移动和最大化只作用于主窗口。顶栏菜单保留鼠标和键盘操作,顶栏中的 Web 控件仍可触达。共享 DOM 只约束展示,不构成安全边界:产品脚本可以隐藏蒙层,但不能借此清除主进程策略或授权安装。更新操作与状态通过隔离 preload 和壳 frame 之间的私有 MessageChannel 传递。父窗口保持启用,原生移动、缩放、最小化、最大化和关闭控件仍可操作。退出应用会完成清理,不会解除更新要求;Esc 不会关闭覆盖层。批准安装后,安装器接管的退出流程会在 Electron 关闭窗口前释放模态窗口。下载、含准备步骤的文件校验、任务检查和安装确认共用同一弹窗。只有第二次用户批准才允许任务收尾和安装;稍后更新保留阻塞与安装包。仅存在受影响任务时,重启文案才提示正在停止任务。策略不跨应用重启持久化,策略响应也不作废或替换 updater 产物。
失败时在同一弹窗内保留阻塞、本地化重试提示和折叠诊断。白名单下载页面操作只在恢复状态出现,不与正常下载或安装并列。请求打开浏览器后立即提供复制替代入口,即使系统请求尚未返回;请求成功不证明网页已打开。复制失败时展示完整、只读的地址供手动复制。浏览器与剪贴板结果不覆盖 updater 错误。只有新的有效无需强更响应才解除阻塞;阻塞期间仍可使用顶部菜单检查。
+3 -1
View File
@@ -70,6 +70,8 @@
"@types/react": "~18.3.1",
"@types/react-dom": "~18.3.0",
"vite": "^6.0.0",
"@deepseek-ai/dsh-client-ui-theme": "workspace:*"
"@deepseek-ai/dsh-client-ui-theme": "workspace:*",
"@deepseek-ai/dsh-client-shortcuts": "workspace:*",
"@deepseek-ai/dsh-atomic-write": "workspace:*"
}
}
+2 -1
View File
@@ -64,7 +64,7 @@ async function launchElectron(): Promise<void> {
const rendererPort = debugPort('DSH_DESKTOP_RENDERER_DEBUG_PORT', 9222)
const hostPort = debugPort('DSH_DESKTOP_HOST_INSPECT_PORT', 9230)
const home = resolve(process.env.DSH_HOME ?? join(DEVELOPMENT_ROOT, 'home'))
const userData = join(DEVELOPMENT_ROOT, 'electron-user-data')
const userData = resolve(process.env.DSH_DESKTOP_USER_DATA_DIR ?? join(DEVELOPMENT_ROOT, 'electron-user-data'))
const environment: NodeJS.ProcessEnv = {
...process.env,
DSH_HOME: home,
@@ -74,6 +74,7 @@ async function launchElectron(): Promise<void> {
ELECTRON_ENABLE_LOGGING: process.env.ELECTRON_ENABLE_LOGGING ?? '1',
}
console.log(`desktop development: DSH_HOME=${home}`)
console.log(`desktop development: userData=${userData}`)
console.log(`desktop development: inspectors main=${String(mainPort)}, renderer=${String(rendererPort)}, host=${String(hostPort)}`)
if (process.platform === 'darwin') {
const executable = prepareDevelopmentApp({ electron, appRoot: APP_ROOT, directory: DEVELOPMENT_ROOT, home, userData,
+6 -2
View File
@@ -9,6 +9,7 @@ interface GuestLease {
readonly partition: string
attached: boolean
guest?: WebContents
releaseInput?: () => void
}
/** Owns workspace storage partitions independently from individual tab guests. */
@@ -51,6 +52,7 @@ export class DesktopBrowserGuests {
const lease = this.leases.get(key)
if (lease === undefined) return
if (lease.owner !== owner) throw new Error('desktop browser: guest belongs to another window')
lease.releaseInput?.()
this.leases.delete(key)
const guest = lease.guest
if (guest !== undefined && !guest.isDestroyed()) {
@@ -63,8 +65,9 @@ export class DesktopBrowserGuests {
/**
* Install attachment checks before the application document can create a webview.
* @param window - primary application window.
* @param attachInput - attaches native input after guest ownership is verified and returns its disposer.
*/
bind(window: BrowserWindow): void {
bind(window: BrowserWindow, attachInput: (guest: WebContents, name: DesktopBrowserLeaseId) => () => void): void {
const owner = window.webContents
owner.on('will-attach-webview', (event, preferences, params) => {
const id = typeof params.src === 'string' && params.src.startsWith('about:blank#')
@@ -102,7 +105,8 @@ export class DesktopBrowserGuests {
}
lease.guest = guest
attachedLease = id
guest.once('destroyed', () => { this.leases.delete(id) })
lease.releaseInput = attachInput(guest, id)
guest.once('destroyed', () => { lease.releaseInput?.(); this.leases.delete(id) })
})
guest.setWindowOpenHandler(({ url, postBody }) => {
const lease = attachedLease === undefined ? undefined : this.leases.get(attachedLease)
+9
View File
@@ -1,10 +1,17 @@
/** Typed preload operations exposed only by the Electron shell. */
import type { DesktopKeyboardApi, DesktopShortcutsApi } from '@deepseek-ai/dsh-client-shortcuts/protocol'
import type { IpcMainInvokeEvent } from 'electron'
import type { DesktopBrowserBridge } from '@deepseek-ai/dsh-client-ui-sidebar-browser/types'
/** IPC channel names kept private to the desktop application bundle. */
export const DESKTOP_IPC = {
shortcutsInput: 'dsh-desktop:shortcuts-input',
shortcutsCloseWindow: 'dsh-desktop:shortcuts-close-window',
shortcutsGet: 'dsh-desktop:shortcuts-get',
shortcutsEdit: 'dsh-desktop:shortcuts-edit',
shortcutsChanged: 'dsh-desktop:shortcuts-changed',
shortcutsRecording: 'dsh-desktop:shortcuts-recording',
boot: 'dsh-desktop:boot',
enterWorkspace: 'dsh-desktop:enter-workspace',
onboardingActive: 'dsh-desktop:onboarding-active',
@@ -64,6 +71,8 @@ export interface DesktopUpdatePresentation {
export interface DshDesktopProductApi {
readonly protocolVersion: 1
readonly browser: DesktopBrowserBridge
readonly keyboard: DesktopKeyboardApi
readonly shortcuts: DesktopShortcutsApi
readonly updates: {
status(): Promise<DesktopUpdatePresentation>
open(): Promise<void>
+27
View File
@@ -0,0 +1,27 @@
/** Device-local shortcut preferences in Electron userData; writes share one serialized coordinator. */
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
import { ShortcutPersistence } from '@deepseek-ai/dsh-client-shortcuts/protocol'
import type { ShortcutConfigSnapshot, ShortcutPlatform } from '@deepseek-ai/dsh-client-shortcuts/protocol'
/**
* Open the device configuration with atomic replacement.
* @param userData - Electron-owned userData directory, never a Renderer-supplied path.
* @param platform - local input platform.
* @param publish - updates the native binding index and trusted product page after commit.
* @returns the single-writer transaction coordinator.
*/
export function desktopKeybindings(userData: string, platform: ShortcutPlatform,
publish: (snapshot: ShortcutConfigSnapshot) => void): ShortcutPersistence {
const path = join(userData, 'keybindings.json')
return new ShortcutPersistence({
read: async () => {
try { return await readFile(path, 'utf8') } catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null
throw error
}
},
write: raw => writeFileAtomic(path, raw, { mode: 0o600, dirMode: 0o700 }),
}, 'desktop', platform, false, publish)
}
+261
View File
@@ -0,0 +1,261 @@
/** Product-window preference IPC and native menu interception during physical-key dispatch/recording. */
import { ipcMain, type BrowserWindow, type IpcMainInvokeEvent, type Input, type MenuItemConstructorOptions, type WebContents } from 'electron'
import { bindingKey, effectiveShortcuts, presentBinding, parseShortcutDefinitions, parseShortcutEdit } from '@deepseek-ai/dsh-client-shortcuts/protocol'
import type { NormalizedBinding, ShortcutConfigSnapshot, ShortcutDefinition, ShortcutPlatform, ShortcutRevision } from '@deepseek-ai/dsh-client-shortcuts/protocol'
import type { DesktopBrowserLeaseId } from '@deepseek-ai/dsh-client-ui-sidebar-browser/types'
import { desktopKeybindings } from './keybindings.ts'
import { DESKTOP_IPC, assertDesktopSender } from './ipc.ts'
/**
* Install application-owned configuration handlers and attach each product window's input lifecycle.
* @param getWindow - current product window.
* @param userData - Electron-resolved device preference directory.
* @param platform - local device platform.
* @param updateMenu - rebuild the application menu when the close accelerator or availability changes.
* @param overlayInput - Current shell-owned input blocking state for the product window.
* @returns menu construction, editor key delivery, window attachment, and teardown operations.
*/
export function installDesktopShortcuts(
getWindow: () => BrowserWindow | undefined, userData: string, platform: ShortcutPlatform, updateMenu: () => void,
overlayInput: (window: BrowserWindow) => { readonly revision: number; readonly blocked: boolean },
): {
fileMenu(labels: { fileMenu: string; closePage: string }): MenuItemConstructorOptions
/**
* Send a native Edit action to the editor without matching user shortcuts.
* @param keyCode - edit key.
* @param modifiers - edit modifiers.
*/
sendEditingKey(keyCode: string, modifiers: Array<'control'>): void
attach(window: BrowserWindow): void
/**
* Intercept an approved browser guest's keys until its lease ends.
* @param window - owning product window.
* @param guest - approved browser contents.
* @param name - main-issued lease used as the webview element name.
* @returns idempotent listener disposer.
*/
attachGuest(window: BrowserWindow, guest: WebContents, name: DesktopBrowserLeaseId): () => void
dispose(): void
} {
let definitions: readonly ShortcutDefinition[] = []
let recording = false
let revision: ShortcutRevision | undefined
let closeBinding: NormalizedBinding | null = null
let editingInput: BrowserWindow['webContents'] | undefined
const scopedDesktop = platform === 'windows' || platform === 'macos'
const disposers = new Set<() => void>()
const guestInputs = new Map<WebContents, { window: BrowserWindow; reset(): void }>()
const closeAccelerator = (): string | undefined => presentBinding(closeBinding, platform).aria
?.replace('Meta+', 'Command+').replace(/Arrow(Up|Down|Left|Right)$/u, '$1')
const sendMenuClose = (): void => {
const window = getWindow()
if (window === undefined || window.isDestroyed() || !window.isFocused() || !window.isEnabled()
|| revision === undefined || recording || overlayInput(window).blocked) return
window.webContents.send(DESKTOP_IPC.shortcutsInput, { kind: 'menu', commandId: 'page.close', revision })
}
let keys = new Set<string>()
const publish = (snapshot: ShortcutConfigSnapshot): void => {
const wasEnabled = revision !== undefined
const previousAccelerator = closeAccelerator()
revision = definitions.length === 0 || snapshot.status === 'loading' ? undefined : snapshot.revision
const rows = snapshot.status === 'loading' ? [] : effectiveShortcuts(definitions, snapshot.document, 'desktop', platform)
keys = new Set(rows.flatMap(row => row.binding !== null && row.issue === null && row.conflicts.length === 0
? [bindingKey(row.binding)] : []))
const close = rows.find(row => row.id === 'page.close')
closeBinding = close?.issue === null && close.conflicts.length === 0 ? close.binding : null
if (wasEnabled !== (revision !== undefined) || previousAccelerator !== closeAccelerator()) updateMenu()
const window = getWindow()
if (window !== undefined && !window.isDestroyed() && window.webContents.mainFrame.url.startsWith('dsh-app://app/')) {
window.webContents.send(DESKTOP_IPC.shortcutsChanged, snapshot)
}
}
const persistence = desktopKeybindings(userData, platform, publish)
const assertSender = (event: IpcMainInvokeEvent): BrowserWindow => {
const window = getWindow()
if (window === undefined || window.isDestroyed() || event.sender !== window.webContents
|| event.senderFrame !== window.webContents.mainFrame) throw new Error('desktop shortcuts: rejected sender')
assertDesktopSender(event, ['app'])
return window
}
ipcMain.handle(DESKTOP_IPC.shortcutsGet, async (event, input: unknown) => {
assertSender(event)
definitions = parseShortcutDefinitions(input)
persistence.setDefinitions(definitions)
return persistence.readCurrent()
})
ipcMain.handle(DESKTOP_IPC.shortcutsEdit, async (event, input: unknown, expectedRevision: unknown) => {
assertSender(event)
if (typeof expectedRevision !== 'string') throw new Error('desktop shortcuts: invalid revision')
return persistence.edit(parseShortcutEdit(input), expectedRevision as ShortcutRevision)
})
ipcMain.handle(DESKTOP_IPC.shortcutsRecording, (event, active: unknown) => {
const window = assertSender(event)
if (typeof active !== 'boolean') throw new Error('desktop shortcuts: invalid recording state')
recording = active
window.webContents.setIgnoreMenuShortcuts(active)
})
ipcMain.handle(DESKTOP_IPC.shortcutsCloseWindow, (event, expected: unknown) => {
const window = assertSender(event)
if (expected !== revision || revision === undefined || recording || !window.isFocused() || !window.isEnabled()
|| overlayInput(window).blocked) return
window.close()
})
function attachInput(window: BrowserWindow, contents: WebContents, guestName?: DesktopBrowserLeaseId): () => void {
let deadKey = false
const held = new Set<string>()
const consumed = new Map<string, 'press' | 'repeat'>()
let inputFrame: typeof contents.focusedFrame = null
let inputRevision: ShortcutRevision | undefined
let overlayRevision = overlayInput(window).revision
const resetInput = (): void => {
deadKey = false; held.clear(); consumed.clear(); inputFrame = null; inputRevision = undefined
if (!contents.isDestroyed()) contents.setIgnoreMenuShortcuts(false)
}
if (guestName !== undefined) guestInputs.set(contents, { window, reset: resetInput })
const resetWindow = (): void => {
resetInput()
for (const guest of guestInputs.values()) if (guest.window === window) guest.reset()
}
const clear = (): void => {
resetInput()
if (guestName !== undefined) return
definitions = []; keys.clear(); recording = false
persistence.setDefinitions(null)
}
const navigation = (event: Electron.Event<Electron.WebContentsDidStartNavigationEventParams>): void => {
if (event.isMainFrame && !event.isSameDocument) clear()
}
const beforeInput = (event: Electron.Event, input: Input): void => {
const overlay = overlayInput(window)
if (overlay.revision !== overlayRevision) { resetInput(); overlayRevision = overlay.revision }
if (overlay.blocked) { event.preventDefault(); return }
if (event.defaultPrevented) { resetInput(); return }
if (editingInput === contents) {
held.clear()
consumed.clear()
contents.setIgnoreMenuShortcuts(true)
return
}
if (window !== getWindow() || !window.isFocused() || !window.isEnabled() || revision === undefined
|| (guestName !== undefined && !contents.isFocused())) {
contents.setIgnoreMenuShortcuts(false)
held.clear()
consumed.clear()
return
}
const modifiers = (['control', 'alt', 'shift', 'meta'] as const).filter(modifier => input[modifier])
const key = bindingKey({ code: input.code, modifiers })
const match = keys.has(key)
const menuMatch = closeBinding !== null && closeBinding.secondCode === undefined && modifiers.join('+') === closeBinding.modifiers.join('+')
&& input.key.toUpperCase() === (closeBinding.code.startsWith('Key') ? closeBinding.code.slice(3) : input.code === closeBinding.code ? input.key.toUpperCase() : '')
contents.setIgnoreMenuShortcuts(recording || match || menuMatch)
const frame = contents.focusedFrame
const composing = input.isComposing || input.key === 'Dead' || deadKey || input.modifiers.includes('altgr')
if (input.type === 'keyDown') deadKey = input.key === 'Dead'
if (recording || composing || frame === null) { held.clear(); consumed.clear(); return }
let binding: NormalizedBinding = { code: input.code, modifiers }
let priority = false
if (scopedDesktop) {
if (frame !== inputFrame || inputRevision !== revision) { held.clear(); inputFrame = frame; inputRevision = revision }
const modifierKey = /^(Control|Alt|Shift|Meta)(Left|Right)$/u.test(input.code)
if (input.type === 'keyUp') {
// A chord's first key reached the renderer, so its release must reach the same input handlers.
if (consumed.get(input.code) === 'press') event.preventDefault()
consumed.delete(input.code)
held.delete(input.code)
if (modifierKey) held.clear()
return
}
if (!input.isAutoRepeat) consumed.delete(input.code)
if (modifierKey) { held.clear(); return }
if (input.isAutoRepeat && consumed.has(input.code) && !match) { event.preventDefault(); return }
if (input.isAutoRepeat && !held.has(input.code) && !match) return
held.add(input.code)
const codes: [string, ...string[]] = [input.code, ...[...held].filter(value => value !== input.code)]
codes.sort()
const pair = { code: codes[0], ...(codes[1] === undefined ? {} : { secondCode: codes[1] }), modifiers }
priority = keys.has(key)
if (codes.length === 2 && keys.has(bindingKey(pair))) { binding = pair; priority = true }
}
const main = guestName === undefined && frame === contents.mainFrame
if (!priority && (main || !match)) return
event.preventDefault()
if (input.type !== 'keyDown') return
if (scopedDesktop) {
if (!input.isAutoRepeat) {
if (binding.secondCode !== undefined) {
consumed.set(binding.code, 'repeat')
consumed.set(binding.secondCode, 'repeat')
}
consumed.set(input.code, 'press')
}
// Electron can omit both keyups after interception; completed presses cannot seed another chord.
held.clear()
}
let embedding = frame
while (guestName === undefined && !main && embedding.parent !== null && embedding.parent !== contents.mainFrame) {
embedding = embedding.parent
}
window.webContents.send(DESKTOP_IPC.shortcutsInput, { kind: guestName === undefined ? main ? 'keyboard' : 'iframe' : 'webview',
revision, frameName: guestName ?? (main ? '' : embedding.name),
code: binding.code, ...(binding.secondCode === undefined ? {} : { secondCode: binding.secondCode }),
repeat: input.isAutoRepeat, control: input.control, alt: input.alt, shift: input.shift, meta: input.meta })
}
const dispose = (): void => {
contents.off('did-start-navigation', navigation)
contents.off('before-input-event', beforeInput)
contents.off('blur', resetInput)
contents.off('destroyed', dispose)
if (guestName === undefined) { window.off('blur', resetWindow); window.off('closed', closed) }
disposers.delete(dispose)
guestInputs.delete(contents)
if (!contents.isDestroyed()) contents.setIgnoreMenuShortcuts(false)
}
const closed = (): void => { clear(); dispose() }
contents.on('did-start-navigation', navigation)
contents.on('before-input-event', beforeInput)
contents.on('blur', resetInput)
contents.once('destroyed', dispose)
if (guestName === undefined) { window.on('closed', closed); window.on('blur', resetWindow) }
disposers.add(dispose)
return dispose
}
return {
sendEditingKey(keyCode, modifiers) {
const window = getWindow()
if (window === undefined || window.isDestroyed() || overlayInput(window).blocked) return
const contents = [...guestInputs].find(([guest, owner]) => owner.window === window && !guest.isDestroyed() && guest.isFocused())?.[0]
?? window.webContents
contents.focus()
const previous = editingInput
editingInput = contents
try {
// Electron emits before-input-event synchronously for these editor-owned keys.
contents.sendInputEvent({ type: 'keyDown', keyCode, modifiers })
contents.sendInputEvent({ type: 'keyUp', keyCode, modifiers })
} finally {
editingInput = previous
if (!contents.isDestroyed()) contents.setIgnoreMenuShortcuts(recording)
}
},
fileMenu: (labels) => {
const accelerator = closeAccelerator()
return { label: labels.fileMenu, submenu: [{
id: 'dsh-page-close', label: labels.closePage, enabled: revision !== undefined,
...accelerator === undefined ? {} : { accelerator },
click: sendMenuClose,
}] }
},
attach(window) { attachInput(window, window.webContents) },
attachGuest: attachInput,
dispose() {
for (const dispose of disposers) dispose()
persistence.dispose()
for (const channel of [DESKTOP_IPC.shortcutsGet,
DESKTOP_IPC.shortcutsEdit, DESKTOP_IPC.shortcutsRecording, DESKTOP_IPC.shortcutsCloseWindow]) {
ipcMain.removeHandler(channel)
}
},
}
}
+4
View File
@@ -2,6 +2,8 @@
export const en = {
application: 'Application',
fileMenu: 'File',
closePage: 'Close Page or Window',
aboutMenu: 'About DeepSeek Harness',
aboutProduct: 'DeepSeek Harness',
aboutVersion: 'Version V{version}',
@@ -133,6 +135,8 @@ export type DesktopMessages = { readonly [Key in keyof typeof en]: string }
export const zh = {
application: '应用',
fileMenu: '文件',
closePage: '关闭页面或窗口',
aboutMenu: '关于 DeepSeek Harness',
aboutProduct: 'DeepSeek Harness',
aboutVersion: '版本 V{version}',
+18 -12
View File
@@ -48,6 +48,8 @@ import { DesktopPolicyTestAuth } from './policy-test-auth.ts'
import { DesktopUpdateDialog, type UpdateDialogOptions } from './update-dialog.ts'
import { readDesktopRuntime } from './runtime-tree.ts'
import { DesktopBrowserGuests } from './browser-guests.ts'
import { installDesktopShortcuts } from './keyboard.ts'
import { DesktopUpdateOverlays } from './update-overlay.ts'
let focusPrimaryWindow = (): void => {}
let stopForRecovery = async (): Promise<void> => {}
@@ -323,7 +325,8 @@ async function main(): Promise<void> {
const currentMainWindow = (): BrowserWindow | undefined => mainWindow
const ordinaryDialogs = new Set<AbortController>()
const currentDialogWindow = (): BrowserWindow | undefined => welcomeWindow ?? mainWindow
const updateDialog = new DesktopUpdateDialog(fileURLToPath(new URL('./preload-update-dialog.cjs', import.meta.url)), () => locale)
const updateOverlays = new DesktopUpdateOverlays()
const updateDialog = new DesktopUpdateDialog(fileURLToPath(new URL('./preload-update-dialog.cjs', import.meta.url)), () => locale, updateOverlays)
const isMandatory = (): boolean => mandatoryPolicy?.state.blocking === true
const ordinaryMessageBox = async (options: UpdateDialogOptions): Promise<Electron.MessageBoxReturnValue> => {
const controller = new AbortController()
@@ -592,6 +595,9 @@ async function main(): Promise<void> {
installDesktopDirectoryPicker(() => mainWindow)
installMicrophonePermissions(session.defaultSession, () => mainWindow?.webContents)
const shortcuts = installDesktopShortcuts(() => mainWindow, app.getPath('userData'),
process.platform === 'darwin' ? 'macos' : process.platform === 'win32' ? 'windows' : 'linux', () => { refreshApplicationMenu() }, window => updateOverlays.input(window))
app.on('will-quit', () => { shortcuts.dispose() })
ipcMain.handle(DESKTOP_IPC.boot, async (event) => {
assertDesktopSender(event, ['app'])
@@ -673,7 +679,7 @@ async function main(): Promise<void> {
locale = current
platformView.notifyLocaleChanged()
windowsLanguage = locale.id
installMenu()
refreshApplicationMenu()
})
ipcMain.handle(DESKTOP_IPC.updatesStatus, (event) => {
assertProductSender(event)
@@ -840,8 +846,8 @@ async function main(): Promise<void> {
// its standard menus and application hide commands declared explicitly.
// Keep app.name stable: Electron derives its default userData directory from it.
const darwin = process.platform === 'darwin'
const platformMenus: MenuItemConstructorOptions[] = darwin
? [{ role: 'fileMenu' }, { role: 'editMenu' }, { role: 'windowMenu' }]
const platformMenus = (): MenuItemConstructorOptions[] => darwin
? [shortcuts.fileMenu(currentDesktopLocale().messages), { role: 'editMenu' }, { role: 'windowMenu' }]
: [{ role: 'editMenu' }]
const hideCommands: MenuItemConstructorOptions[] = darwin
? [{ role: 'hide', label: currentDesktopLocale().messages.hideApplication },
@@ -875,13 +881,13 @@ async function main(): Promise<void> {
{ role: 'toggleDevTools', visible: false },
{ role: 'toggleDevTools', visible: false, accelerator: 'F12' },
]
const installMenu = (): void => {
const refreshApplicationMenu = (): void => {
Menu.setApplicationMenu(Menu.buildFromTemplate(process.platform === 'win32' ? devToolsItems : [{
label: darwin ? app.name : currentDesktopLocale().messages.application,
submenu: [...applicationItems(), ...devToolsItems],
}, ...platformMenus]))
}, ...platformMenus()]))
}
installMenu()
refreshApplicationMenu()
if (process.platform === 'win32') {
ipcMain.handle(DESKTOP_IPC.windowsMenu, (event, name: unknown, x: unknown, y: unknown) => {
@@ -897,9 +903,7 @@ async function main(): Promise<void> {
label,
...(accelerator === undefined ? {} : { accelerator }),
click: () => {
window.webContents.focus()
window.webContents.sendInputEvent({ type: 'keyDown', keyCode, modifiers })
window.webContents.sendInputEvent({ type: 'keyUp', keyCode, modifiers })
shortcuts.sendEditingKey(keyCode, modifiers)
},
})
const items: MenuItemConstructorOptions[] = name === 'application' ? applicationItems() : [
@@ -935,7 +939,8 @@ async function main(): Promise<void> {
const createMainWindow = (): BrowserWindow => {
const window = createWindow(appPreload, false, true)
mainWindow = window
browserGuests.bind(window)
browserGuests.bind(window, (guest, name) => shortcuts.attachGuest(window, guest, name))
shortcuts.attach(window)
window.on('focus', automaticCheck)
window.on('closed', () => { if (mainWindow === window) mainWindow = undefined })
window.webContents.on('console-message', (details) => {
@@ -1036,7 +1041,7 @@ async function main(): Promise<void> {
if (isQuitting() || backend.state.phase !== 'ready') return
locale = resolveDesktopStartupLocale(state.localePreference, systemLanguages)
windowsLanguage = locale.id
installMenu()
refreshApplicationMenu()
if (!enteredWorkspace && needsWelcome({ loggedIn: state.loggedIn, hasApiKey: state.hasApiKey })) {
await showWelcome()
} else {
@@ -1128,6 +1133,7 @@ async function main(): Promise<void> {
}, policyAuth?.request)
const policy = mandatoryPolicy
mandatoryUI = new DesktopMandatoryUpdateWindow({
overlays: updateOverlays,
preload: fileURLToPath(new URL('./preload-mandatory.cjs', import.meta.url)), locale,
allowedPageOrigins: policyConfig.allowedPageOrigins, parent: () => mainWindow,
policy: () => policy.state, update: () => updates.state,
+3 -2
View File
@@ -5,7 +5,7 @@ import type { DesktopLocale } from './locale.ts'
import { assertDesktopSender, type DesktopUpdateState } from './ipc.ts'
import { desktopPolicyPage, type DesktopPolicyState } from './mandatory-update-policy.ts'
import { MANDATORY_IPC } from './mandatory-update-ipc.ts'
import { createUpdateOverlay } from './update-overlay.ts'
import type { DesktopUpdateOverlays } from './update-overlay.ts'
import { DesktopUpdateAttention } from './update-attention.ts'
/** A renderer action never carries a URL or authorizes a different version. */
@@ -33,6 +33,7 @@ export interface MandatoryUpdateApi {
/** Main-process operations owned by the policy client, updater, and application lifecycle. */
export interface MandatoryUpdateWindowOptions {
readonly preload: string
readonly overlays: Pick<DesktopUpdateOverlays, 'create'>
readonly locale: DesktopLocale
readonly allowedPageOrigins: readonly string[]
readonly parent: () => BrowserWindow | undefined
@@ -190,7 +191,7 @@ export class DesktopMandatoryUpdateWindow {
if (this.window === undefined) {
const parent = this.options.parent()
if (parent === undefined) return
const window = createUpdateOverlay(parent, this.options.preload, this.options.locale.messages.mandatoryTitle, false)
const window = this.options.overlays.create(parent, this.options.preload, this.options.locale.messages.mandatoryTitle, false)
this.window = window
window.setMenu(null)
window.on('close', (event) => { if (!this.disposed && this.options.policy().blocking) { event.preventDefault(); app.quit() } })
+33 -1
View File
@@ -1,5 +1,6 @@
/** Origin-scoped boot, native directory selection, host paths of picked files, and update presentation with native confirmation actions. */
import type { DesktopShortcutInput, ShortcutConfigSnapshot, ShortcutSaveResult } from '@deepseek-ai/dsh-client-shortcuts/protocol'
import { contextBridge, ipcRenderer, webUtils } from 'electron'
import { DESKTOP_IPC, SCHEME, type DshDesktopProductApi, type DesktopUpdatePresentation } from './ipc.ts'
import { PLATFORM_IPC } from './platform-ipc.ts'
@@ -13,6 +14,37 @@ function createProductApi(): DshDesktopProductApi {
return {
protocolVersion: 1,
browser: createDesktopBrowserBridge(),
keyboard: {
closeWindow: revision => ipcRenderer.invoke(DESKTOP_IPC.shortcutsCloseWindow, revision) as Promise<void>,
subscribe: (listener) => {
const handle = (_event: Electron.IpcRendererEvent, input: DesktopShortcutInput): void => {
if (input.kind === 'iframe') {
const element = document.activeElement
if (!(element instanceof HTMLIFrameElement) || !element.isConnected
|| !element.matches('iframe[data-sidebar-browser-frame], iframe[data-html-preview]')) return
if (input.frameName === '' || element.name !== input.frameName) return
}
if (input.kind === 'webview') {
const element = document.activeElement
if (element?.matches('webview[data-sidebar-browser-frame]') !== true || !element.isConnected
|| input.frameName === '' || element.getAttribute('name') !== input.frameName) return
}
listener(input)
}
ipcRenderer.on(DESKTOP_IPC.shortcutsInput, handle)
return () => { ipcRenderer.off(DESKTOP_IPC.shortcutsInput, handle) }
},
},
shortcuts: {
get: definitions => ipcRenderer.invoke(DESKTOP_IPC.shortcutsGet, definitions) as Promise<ShortcutConfigSnapshot>,
edit: (edit, revision) => ipcRenderer.invoke(DESKTOP_IPC.shortcutsEdit, edit, revision) as Promise<ShortcutSaveResult>,
recording: active => ipcRenderer.invoke(DESKTOP_IPC.shortcutsRecording, active) as Promise<void>,
subscribe(listener) {
const handle = (_event: Electron.IpcRendererEvent, snapshot: ShortcutConfigSnapshot): void => { listener(snapshot) }
ipcRenderer.on(DESKTOP_IPC.shortcutsChanged, handle)
return () => { ipcRenderer.off(DESKTOP_IPC.shortcutsChanged, handle) }
},
},
updates: {
status: () => ipcRenderer.invoke(DESKTOP_IPC.updatesStatus) as Promise<DesktopUpdatePresentation>,
open: () => ipcRenderer.invoke(DESKTOP_IPC.updatesOpen) as Promise<void>,
@@ -64,7 +96,7 @@ markDocumentPlatform()
syncWindowFullscreen()
syncNativeTheme()
// Main-process IPC also verifies the owning window and top frame.
contextBridge.exposeInMainWorld('dshDesktop', location.protocol === `${SCHEME}:` && location.hostname === 'app' ? createProductApi() : { protocolVersion: 1 })
contextBridge.exposeInMainWorld('dshDesktop', location.protocol === `${SCHEME}:` && location.hostname === 'app' && process.isMainFrame ? createProductApi() : { protocolVersion: 1 })
if (location.protocol === `${SCHEME}:` && location.hostname === 'app') {
contextBridge.exposeInMainWorld('__DSH_LOCALE__', {
+5 -3
View File
@@ -1,7 +1,7 @@
/** Main-owned update confirmations; closing or replacing a dialog never grants installation permission. */
import { ipcMain, type BrowserWindow, type IpcMainInvokeEvent, type MessageBoxOptions, type MessageBoxReturnValue } from 'electron'
import type { DesktopLocale } from './locale.ts'
import { createUpdateOverlay } from './update-overlay.ts'
import type { DesktopUpdateOverlays } from './update-overlay.ts'
/** Channels available only to the isolated update-dialog document. */
export const UPDATE_DIALOG_IPC = { status: 'dsh-update-dialog:status', changed: 'dsh-update-dialog:changed', respond: 'dsh-update-dialog:respond' } as const
@@ -51,8 +51,10 @@ export class DesktopUpdateDialog {
/**
* @param preload - Bundled isolated preload.
* @param locale - Shell-owned copy or a reader of the current UI language.
* @param overlays - Application-owned overlay creation and input tracking.
*/
constructor(private readonly preload: string, private readonly locale: DesktopLocale | (() => DesktopLocale)) {
constructor(private readonly preload: string, private readonly locale: DesktopLocale | (() => DesktopLocale),
private readonly overlays: Pick<DesktopUpdateOverlays, 'create'>) {
ipcMain.handle(UPDATE_DIALOG_IPC.status, (event) => { this.owned(event); return this.active?.view ?? null })
ipcMain.handle(UPDATE_DIALOG_IPC.respond, (event, revision: unknown, index: unknown) => {
this.owned(event)
@@ -83,7 +85,7 @@ export class DesktopUpdateDialog {
clearTimeout(this.closing)
this.closing = undefined
const existing = this.window
const window = existing ?? createUpdateOverlay(parent, this.preload, options.title ?? locale.messages.updateTitle, false)
const window = existing ?? this.overlays.create(parent, this.preload, options.title ?? locale.messages.updateTitle, false)
this.window = window
this.parent = parent
const view: UpdateDialogView = { revision: ++this.revision, locale: locale.id, title: options.title ?? '', message: options.message,
+69 -49
View File
@@ -1,55 +1,75 @@
/** Shell-owned modal windows cover the parent's content without replacing its native window controls. */
import { BrowserWindow } from 'electron'
/**
* @param parent - Product window whose content is blocked while the overlay is open.
* @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 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({
parent, modal: nativeModal || process.platform !== 'darwin', show: false, frame: false, transparent: true,
...parent.getContentBounds(), resizable: false, minimizable: false, maximizable: false,
skipTaskbar: true, hasShadow: false, title,
webPreferences: { preload, contextIsolation: true, sandbox: true, nodeIntegration: false, webSecurity: true },
})
// macOS native modals animate the entire viewport as a sheet.
const focus = (): void => { if (!window.isDestroyed()) window.focus() }
const blockInput = (event: Electron.Event): void => { event.preventDefault(); focus() }
if (!nativeModal && process.platform === 'darwin') {
parent.on('focus', focus)
parent.webContents.on('before-input-event', blockInput)
window.once('closed', () => {
parent.off('focus', focus)
if (!parent.isDestroyed()) parent.webContents.off('before-input-event', blockInput)
const unblockedInput = { revision: 0, blocked: false } as const
/** Tracks application-owned update overlays and their parent input state. */
export class DesktopUpdateOverlays {
private readonly inputStates = new WeakMap<BrowserWindow, { revision: number; active: number; readonly blocked: boolean }>()
/**
* @param parent - Product window whose input may belong to an update dialog.
* @returns Current blocking state; its revision changes whenever an overlay opens or closes.
*/
input(parent: BrowserWindow): { readonly revision: number; readonly blocked: boolean } {
return this.inputStates.get(parent) ?? unblockedInput
}
/**
* @param parent - Product window whose content is blocked while the overlay is open.
* @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 bounds and visibility after loading and releases its listeners on close.
*/
create(parent: BrowserWindow, preload: string, title: string, nativeModal = true): BrowserWindow {
const window = new BrowserWindow({
parent, modal: nativeModal || process.platform !== 'darwin', show: false, frame: false, transparent: true,
...parent.getContentBounds(), resizable: false, minimizable: false, maximizable: false,
skipTaskbar: true, hasShadow: false, title,
webPreferences: { preload, contextIsolation: true, sandbox: true, nodeIntegration: false, webSecurity: true },
})
const inputState = this.inputStates.get(parent) ?? { revision: 0, active: 0, get blocked() { return this.active > 0 } }
this.inputStates.set(parent, inputState)
inputState.active++; inputState.revision++
window.once('closed', () => { inputState.active--; inputState.revision++ })
// macOS native modals animate the entire viewport as a sheet.
const focus = (): void => { if (!window.isDestroyed()) window.focus() }
const blockInput = (event: Electron.Event): void => { event.preventDefault(); focus() }
if (!nativeModal && process.platform === 'darwin') {
parent.on('focus', focus)
// Shell dialogs block input before product shortcut listeners can dispatch it.
parent.webContents.prependListener('before-input-event', blockInput)
window.once('closed', () => {
parent.off('focus', focus)
if (!parent.isDestroyed()) parent.webContents.off('before-input-event', blockInput)
})
}
const follow = (): void => { if (!window.isDestroyed()) window.setBounds(parent.getContentBounds()) }
parent.on('move', follow)
parent.on('resize', follow)
let closed = false
let blur: string | undefined
const unblur = (): void => {
if (blur === undefined || parent.isDestroyed()) return
void parent.webContents.removeInsertedCSS(blur).catch((error: unknown) => { console.warn('desktop update: could not remove background blur', error) })
blur = undefined
}
void parent.webContents.insertCSS('body { filter: blur(2px) !important; }').then((key) => {
blur = key
if (closed) unblur()
}).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) })
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
}
const follow = (): void => { if (!window.isDestroyed()) window.setBounds(parent.getContentBounds()) }
parent.on('move', follow)
parent.on('resize', follow)
let closed = false
let blur: string | undefined
const unblur = (): void => {
if (blur === undefined || parent.isDestroyed()) return
void parent.webContents.removeInsertedCSS(blur).catch((error: unknown) => { console.warn('desktop update: could not remove background blur', error) })
blur = undefined
}
void parent.webContents.insertCSS('body { filter: blur(2px) !important; }').then((key) => {
blur = key
if (closed) unblur()
}).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) })
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
}
+3 -3
View File
@@ -6,7 +6,7 @@ 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'
import { DesktopUpdateOverlays } from '../../lib/types/update-overlay.js'
const directory = fileURLToPath(new URL('../../.desktop-build/qualification/', import.meta.url))
mkdirSync(directory, { recursive: true })
@@ -34,7 +34,7 @@ async function main() {
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)
const overlay = new DesktopUpdateOverlays().create(parent, undefined, 'Overlay qualification', false)
windows.push(overlay)
const ready = once(overlay, 'ready-to-show')
const initiallyShown = initiallyVisible ? once(overlay, 'show') : undefined
@@ -43,7 +43,7 @@ async function main() {
phase = `overlay ready (${initiallyVisible})`
await ready
await initiallyShown
// A renderer round trip observes the CSS request already queued by createUpdateOverlay.
// A renderer round trip observes the CSS request already queued by the overlay.
const filter = () => parent.webContents.executeJavaScript('getComputedStyle(document.body).filter')
const loaded = { parent: parent.isVisible(), overlay: overlay.isVisible(), filter: await filter() }
if (initiallyVisible) {
+35
View File
@@ -0,0 +1,35 @@
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { expect, it, onTestFinished } from 'vitest'
import { desktopKeybindings } from '../src/keybindings.ts'
import type { ShortcutCommandId, ShortcutConfigSnapshot } from '@deepseek-ai/dsh-client-shortcuts/protocol'
it('persists into the supplied userData, reloads null bindings, and retains original future-version bytes', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-keybindings-'))
onTestFinished(async () => { await rm(root, { recursive: true, force: true }) })
let state!: ShortcutConfigSnapshot
const first = desktopKeybindings(root, 'windows', (value) => { state = value })
onTestFinished(() => { first.dispose() })
const id = 'test.toggle' as ShortcutCommandId
const definitions = [{ id, defaults: {
'desktop:macos': { code: 'KeyB', modifiers: ['primary'] as const },
'desktop:windows': { code: 'KeyB', modifiers: ['primary'] as const },
'desktop:linux': { code: 'KeyB', modifiers: ['primary'] as const },
} }]
first.setDefinitions(definitions)
await first.readCurrent()
expect((await first.edit({ type: 'set', id, binding: null }, state.revision)).status).toBe('saved')
const path = join(root, 'keybindings.json')
const saved: unknown = JSON.parse(await readFile(path, 'utf8'))
expect(saved).toMatchObject({ profiles: { 'desktop:windows': { [id]: null } } })
const second = desktopKeybindings(root, 'windows', (value) => { state = value })
onTestFinished(() => { second.dispose() })
second.setDefinitions(definitions)
expect((await second.readCurrent()).document.profiles['desktop:windows']?.[id]).toBeNull()
const original = '{"schemaVersion":7,"custom":"retain exactly"}\n'
await writeFile(path, original)
expect((await second.readCurrent()).error).toBe('future')
expect((await second.edit({ type: 'reset-all' }, state.revision)).status).toBe('unreadable')
expect(await readFile(path, 'utf8')).toBe(original)
})
+826
View File
@@ -0,0 +1,826 @@
// @vitest-environment jsdom
import { EventEmitter } from 'node:events'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, expect, it, onTestFinished, vi } from 'vitest'
import type { BrowserWindow, WebContents, WebFrameMain } from 'electron'
import type { DesktopShortcutInput, ShortcutBinding, ShortcutCommandId, ShortcutConfigSnapshot,
ShortcutDefinition, ShortcutSaveResult } from '@deepseek-ai/dsh-client-shortcuts/protocol'
import { ShortcutRegistry } from '@deepseek-ai/dsh-client-shortcuts/src/client/registry.ts'
import { installKeyboard } from '@deepseek-ai/dsh-client-shortcuts/src/client/dom.ts'
import { installNativeKeyboard } from '@deepseek-ai/dsh-client-shortcuts/src/client/native.ts'
import type { DesktopBrowserLeaseId, DesktopBrowserReservation } from '@deepseek-ai/dsh-client-ui-sidebar-browser/types'
import { DESKTOP_IPC } from '../src/ipc.ts'
const ipc = vi.hoisted(() => ({ handle: vi.fn(), removeHandler: vi.fn() }))
const overlays = await vi.hoisted(async () => {
const { EventEmitter } = await import('node:events')
class Window extends EventEmitter {
private destroyed = false
readonly webContents = { setWindowOpenHandler: vi.fn() }
readonly focus = vi.fn()
readonly show = vi.fn()
readonly setBounds = vi.fn()
setMenu() {}
isDestroyed() { return this.destroyed }
destroy() { this.destroyed = true; this.emit('closed') }
}
return { Window }
})
vi.mock('electron', () => ({ ipcMain: ipc, BrowserWindow: overlays.Window, app: { isPackaged: true }, session: { fromPartition: () => ({
setPermissionRequestHandler: vi.fn(), setPermissionCheckHandler: vi.fn(), setDevicePermissionHandler: vi.fn(),
setDisplayMediaRequestHandler: vi.fn(), on: vi.fn(), webRequest: { onBeforeRequest: vi.fn() },
}) } }))
const { installDesktopShortcuts } = await import('../src/keyboard.ts')
const { DesktopBrowserGuests } = await import('../src/browser-guests.ts')
const { DesktopUpdateOverlays } = await import('../src/update-overlay.ts')
afterEach(() => { vi.clearAllMocks() })
type FrameFixture = { url: WebFrameMain['url']; name: WebFrameMain['name']; parent: FrameFixture | null }
type ContentsFixture = EventEmitter & Pick<WebContents,
'isDestroyed' | 'isFocused' | 'send' | 'setIgnoreMenuShortcuts' | 'focus' | 'sendInputEvent'> & {
mainFrame: FrameFixture
focusedFrame: FrameFixture | null
}
type WindowFixture = EventEmitter & Pick<BrowserWindow, 'isDestroyed' | 'isFocused' | 'isEnabled' | 'close'> & {
webContents: ContentsFixture
}
type InvokeFixture = { sender: object; senderFrame: object | null }
type KeyboardFixture = Omit<ReturnType<typeof installDesktopShortcuts>, 'attach' | 'attachGuest'> & {
attach(window: WindowFixture): void
attachGuest(window: WindowFixture, guest: ContentsFixture, name: DesktopBrowserLeaseId): () => void
}
// Electron is substituted at module load; fixtures implement the native members exercised by this installer.
const installFixture = installDesktopShortcuts as (
getWindow: () => WindowFixture | undefined, userData: string,
platform: Parameters<typeof installDesktopShortcuts>[2], updateMenu: () => void,
overlayInput: (window: WindowFixture) => { readonly revision: number; readonly blocked: boolean },
) => KeyboardFixture
type GuestsFixture = {
acquire(owner: ContentsFixture, workspace: unknown): DesktopBrowserReservation
release(owner: ContentsFixture, id: unknown): Promise<void>
bind(window: WindowFixture, attachInput: (guest: ContentsFixture, name: DesktopBrowserLeaseId) => () => void): void
}
function desktopDefaults(binding: ShortcutBinding): ShortcutDefinition['defaults'] {
return { 'desktop:macos': binding, 'desktop:windows': binding, 'desktop:linux': binding }
}
function browserGuest(reservation: DesktopBrowserReservation) {
const frame: FrameFixture = { url: `about:blank#${reservation.lease}`, name: '', parent: null }
const guest = Object.assign(new EventEmitter(), { mainFrame: frame, focusedFrame: frame,
getURL: () => frame.url, isDestroyed: () => false, isFocused: vi.fn(() => true),
setWindowOpenHandler: vi.fn(), setIgnoreMenuShortcuts: vi.fn(), send: vi.fn(), close: vi.fn(),
focus: vi.fn(), sendInputEvent: vi.fn() })
onTestFinished(() => { guest.emit('destroyed') })
return { frame, guest }
}
async function fixture(platform: 'macos' | 'windows' | 'linux' = 'macos') {
const root = await mkdtemp(join(tmpdir(), 'dsh-keyboard-'))
onTestFinished(async () => { await rm(root, { recursive: true, force: true }) })
const frame: FrameFixture = { url: 'dsh-app://app/', name: '', parent: null }
const contents = Object.assign(new EventEmitter(), { mainFrame: frame, focusedFrame: frame,
isDestroyed: () => false, isFocused: () => true, send: vi.fn(),
setIgnoreMenuShortcuts: vi.fn(), focus: vi.fn(), sendInputEvent: vi.fn() })
const window = Object.assign(new EventEmitter(), { webContents: contents, isDestroyed: vi.fn(() => false),
isFocused: vi.fn(() => true), isEnabled: vi.fn(() => true), close: vi.fn() })
let current: WindowFixture | undefined = window
const updateMenu = vi.fn()
const updateOverlays = new DesktopUpdateOverlays()
const keyboard = installFixture(() => current, root, platform, updateMenu, window => updateOverlays.input(window as BrowserWindow))
keyboard.attach(current)
onTestFinished(() => { keyboard.dispose() })
const handlers = new Map<string, (event: InvokeFixture, ...args: unknown[]) => unknown>(
ipc.handle.mock.calls.map(([channel, handler]) => [channel, handler]))
const event: InvokeFixture = { sender: contents, senderFrame: frame }
const call = async <T>(channel: string, ...args: unknown[]): Promise<T> => await handlers.get(channel)!(event, ...args) as T
const definitions: readonly ShortcutDefinition[] = [
{ id: 'sidebar.left.toggle' as ShortcutCommandId, defaults: desktopDefaults({ code: 'KeyB', modifiers: ['primary'] }) },
]
return { keyboard, window, updateOverlays, contents, frame, event, handlers, call, definitions, updateMenu,
detach: () => { current = undefined } }
}
function updateOverlayFixture(f: Awaited<ReturnType<typeof fixture>>) {
const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
onTestFinished(() => { platform.mockRestore() })
const parent: WindowFixture & Pick<BrowserWindow, 'getContentBounds'> = Object.assign(f.window, {
getContentBounds: () => ({ x: 0, y: 0, width: 900, height: 650 }),
})
Object.assign(f.contents, { insertCSS: vi.fn(async () => 'blur'), removeInsertedCSS: vi.fn(async () => {}) })
return () => {
const overlay = f.updateOverlays.create(parent as BrowserWindow, 'test-overlay-preload', 'Update', false)
onTestFinished(() => { if (!overlay.isDestroyed()) overlay.destroy() })
expect(vi.spyOn(overlay, 'show')).not.toHaveBeenCalled()
return overlay
}
}
it('mirrors only successful bindings, suppresses recording menus, and invalidates pre-navigation drafts', async () => {
const f = await fixture()
const initial = await f.call<ShortcutConfigSnapshot>(DESKTOP_IPC.shortcutsGet, f.definitions)
const press = (code: string) => f.contents.emit('before-input-event', { preventDefault: vi.fn() },
{ type: 'keyDown', modifiers: [], code, key: code.replace('Key', ''), meta: true, control: false, alt: false, shift: false })
press('KeyB'); expect(f.contents.setIgnoreMenuShortcuts).toHaveBeenLastCalledWith(true)
press('KeyJ'); expect(f.contents.setIgnoreMenuShortcuts).toHaveBeenLastCalledWith(false)
const saved = await f.call<ShortcutSaveResult>(DESKTOP_IPC.shortcutsEdit, { type: 'set', id: 'sidebar.left.toggle', binding: { code: 'KeyJ', modifiers: ['primary'] } }, initial.revision)
expect(saved.status).toBe('saved')
press('KeyB'); expect(f.contents.setIgnoreMenuShortcuts).toHaveBeenLastCalledWith(false)
press('KeyJ'); expect(f.contents.setIgnoreMenuShortcuts).toHaveBeenLastCalledWith(true)
expect((await f.call<ShortcutSaveResult>(DESKTOP_IPC.shortcutsEdit, { type: 'reset-all' }, initial.revision)).status).toBe('stale')
await f.call(DESKTOP_IPC.shortcutsRecording, true)
press('KeyW'); expect(f.contents.setIgnoreMenuShortcuts).toHaveBeenLastCalledWith(true)
f.window.isFocused.mockReturnValue(false)
press('KeyW'); expect(f.contents.setIgnoreMenuShortcuts).toHaveBeenLastCalledWith(false)
f.contents.emit('did-start-navigation', { isMainFrame: true, isSameDocument: false }, 'dsh-app://app/', false, true)
expect((await f.call<ShortcutSaveResult>(DESKTOP_IPC.shortcutsEdit, { type: 'reset-all' }, saved.snapshot.revision)).status).toBe('not-ready')
await f.call(DESKTOP_IPC.shortcutsGet, f.definitions)
expect((await f.call<ShortcutSaveResult>(DESKTOP_IPC.shortcutsEdit, { type: 'reset-all' }, saved.snapshot.revision)).status).toBe('stale')
})
it('blocks macOS shortcuts while an update overlay loads and clears held and consumed keys before dismissal', async () => {
const f = await fixture()
const open = updateOverlayFixture(f)
const snapshot = await f.call<ShortcutConfigSnapshot>(DESKTOP_IPC.shortcutsGet, [
{ id: 'page.close', defaults: { 'desktop:macos': { code: 'KeyA', secondCode: 'KeyB', modifiers: [] } } },
{ id: 'sidebar.left.toggle', defaults: desktopDefaults({ code: 'KeyK', modifiers: ['primary'] }) },
])
const menu = f.keyboard.fileMenu({ fileMenu: 'File', closePage: 'Close' }).submenu as Electron.MenuItemConstructorOptions[]
const closeMenu = menu[0]!.click as () => void
const press = (code: string, type = 'keyDown', meta = false) => {
const event = { defaultPrevented: false, preventDefault() { this.defaultPrevented = true } }
f.contents.emit('before-input-event', event, { type, code, key: code.slice(3), modifiers: meta ? ['meta'] : [],
meta, control: false, alt: false, shift: false, isAutoRepeat: false, isComposing: false })
return event.defaultPrevented
}
f.contents.send.mockClear()
expect(press('KeyA')).toBe(false)
const first = open()
expect(press('KeyB')).toBe(true)
expect(press('KeyK', 'keyDown', true)).toBe(true)
closeMenu()
await f.call(DESKTOP_IPC.shortcutsCloseWindow, snapshot.revision)
f.keyboard.sendEditingKey('C', ['control'])
expect(f.window.close).not.toHaveBeenCalled()
expect(f.contents.sendInputEvent).not.toHaveBeenCalled()
expect(f.contents.send).not.toHaveBeenCalled()
first.destroy()
expect(press('KeyB')).toBe(false)
expect(press('KeyB', 'keyUp')).toBe(false)
expect(f.contents.send).not.toHaveBeenCalled()
expect(press('KeyA')).toBe(false)
expect(press('KeyB')).toBe(true)
expect(f.contents.send).toHaveBeenCalledOnce()
expect(f.contents.send).toHaveBeenLastCalledWith(DESKTOP_IPC.shortcutsInput,
expect.objectContaining({ code: 'KeyA', secondCode: 'KeyB' }))
const second = open()
expect(press('KeyB', 'keyUp')).toBe(true)
second.destroy()
expect(press('KeyB', 'keyUp')).toBe(false)
expect(press('KeyK', 'keyDown', true)).toBe(true)
expect(f.contents.send).toHaveBeenCalledTimes(2)
expect(f.contents.send).toHaveBeenLastCalledWith(DESKTOP_IPC.shortcutsInput,
expect.objectContaining({ code: 'KeyK', meta: true }))
closeMenu()
expect(f.contents.send).toHaveBeenLastCalledWith(DESKTOP_IPC.shortcutsInput, expect.objectContaining({ kind: 'menu' }))
await f.call(DESKTOP_IPC.shortcutsCloseWindow, snapshot.revision)
expect(f.window.close).toHaveBeenCalledOnce()
f.keyboard.sendEditingKey('C', ['control'])
expect(f.contents.sendInputEvent).toHaveBeenCalledTimes(2)
})
it.each([false, true])('blocks approved browser guest input across update overlays, including guests attached while blocked: %s', async (attachWhileBlocked) => {
const f = await fixture()
const open = updateOverlayFixture(f)
await f.call(DESKTOP_IPC.shortcutsGet, [
{ id: 'page.close', defaults: { 'desktop:macos': { code: 'KeyA', secondCode: 'KeyB', modifiers: [] } } },
{ id: 'sidebar.left.toggle', defaults: desktopDefaults({ code: 'KeyK', modifiers: ['primary'] }) },
])
const guests = new DesktopBrowserGuests(() => undefined) as GuestsFixture
guests.bind(f.window, (guest, name) => f.keyboard.attachGuest(f.window, guest, name))
const reservation = guests.acquire(f.contents, 'session:test')
const { frame, guest } = browserGuest(reservation)
const attach = () => {
const event = { preventDefault: vi.fn() }
f.contents.emit('will-attach-webview', event, {}, { src: frame.url, partition: reservation.partition })
expect(event.preventDefault).not.toHaveBeenCalled()
f.contents.emit('did-attach-webview', {}, guest)
guest.emit('dom-ready')
}
if (!attachWhileBlocked) attach()
const first = open()
if (attachWhileBlocked) attach()
const press = (code: string, type = 'keyDown', meta = false) => {
const event = { defaultPrevented: false, preventDefault() { this.defaultPrevented = true } }
guest.emit('before-input-event', event, { type, code, key: code.slice(3), modifiers: meta ? ['meta'] : [],
meta, control: false, alt: false, shift: false, isAutoRepeat: false, isComposing: false })
return event.defaultPrevented
}
f.contents.send.mockClear()
expect(press('KeyK', 'keyDown', true)).toBe(true)
f.keyboard.sendEditingKey('C', ['control'])
expect(guest.sendInputEvent).not.toHaveBeenCalled()
expect(f.contents.send).not.toHaveBeenCalled()
const second = open()
first.destroy()
expect(press('KeyA')).toBe(true)
expect(press('KeyB')).toBe(true)
expect(f.contents.send).not.toHaveBeenCalled()
second.destroy()
expect(press('KeyB')).toBe(false)
expect(press('KeyB', 'keyUp')).toBe(false)
expect(press('KeyA')).toBe(false)
open().destroy()
expect(press('KeyB')).toBe(false)
expect(press('KeyB', 'keyUp')).toBe(false)
expect(f.contents.send).not.toHaveBeenCalled()
expect(press('KeyA')).toBe(false)
expect(press('KeyB')).toBe(true)
expect(f.contents.send).toHaveBeenCalledOnce()
expect(f.contents.send).toHaveBeenLastCalledWith(DESKTOP_IPC.shortcutsInput,
expect.objectContaining({ kind: 'webview', frameName: reservation.lease, code: 'KeyA', secondCode: 'KeyB' }))
open().destroy()
expect(press('KeyB', 'keyUp')).toBe(false)
expect(press('KeyK', 'keyDown', true)).toBe(true)
expect(f.contents.send).toHaveBeenCalledTimes(2)
f.keyboard.sendEditingKey('C', ['control'])
expect(guest.sendInputEvent).toHaveBeenCalledTimes(2)
})
it('rejects other windows, subframes, remote/shell pages, and malformed edits', async () => {
const f = await fixture()
const get = f.handlers.get(DESKTOP_IPC.shortcutsGet)!
for (const event of [{ ...f.event, sender: {} }, { ...f.event, senderFrame: null }, { ...f.event, senderFrame: {} }]) {
await expect(get(event, f.definitions)).rejects.toThrow('rejected sender')
}
for (const url of ['dsh-app://shell/', 'https://example.com/', 'http://localhost/']) {
f.frame.url = url
await expect(get(f.event, f.definitions)).rejects.toThrow('unowned renderer')
}
f.frame.url = 'dsh-app://app/'
const ready = await f.call<ShortcutConfigSnapshot>(DESKTOP_IPC.shortcutsGet, f.definitions)
await expect(f.call(DESKTOP_IPC.shortcutsEdit, { type: 'reset-all', path: '/tmp' }, ready.revision)).rejects.toThrow('Invalid')
await expect(f.call(DESKTOP_IPC.shortcutsEdit, { type: 'reset-all' }, 1)).rejects.toThrow('revision')
await expect(f.call(DESKTOP_IPC.shortcutsRecording, 'true')).rejects.toThrow('recording')
f.window.isDestroyed.mockReturnValue(true)
await expect(get(f.event, f.definitions)).rejects.toThrow('rejected sender')
f.detach(); await expect(get(f.event, f.definitions)).rejects.toThrow('rejected sender')
})
it('routes embedded input once, follows rebindings, and guards native window closure by revision and focus', async () => {
const f = await fixture()
const closeItem = () => (f.keyboard.fileMenu({ fileMenu: 'File', closePage: 'Close' }).submenu as Electron.MenuItemConstructorOptions[])[0]!
const initial = await f.call<ShortcutConfigSnapshot>(DESKTOP_IPC.shortcutsGet, [
{ id: 'page.close', defaults: desktopDefaults({ code: 'KeyW', modifiers: ['primary'] }) },
{ id: 'page.refresh', defaults: desktopDefaults({ code: 'KeyR', modifiers: ['primary'] }) },
])
expect(closeItem().accelerator).toBe('Command+W')
const preventDefault = vi.fn()
const input = { modifiers: [], type: 'keyDown', code: 'KeyW', key: 'w', meta: true, control: false, alt: false, shift: false, isAutoRepeat: false, isComposing: false }
f.contents.send.mockClear()
f.contents.emit('before-input-event', { preventDefault }, input)
expect(preventDefault).toHaveBeenCalledOnce()
expect(f.contents.send).toHaveBeenLastCalledWith(DESKTOP_IPC.shortcutsInput,
expect.objectContaining({ kind: 'keyboard', code: 'KeyW' }))
preventDefault.mockClear(); f.contents.send.mockClear()
Object.assign(f.contents, { focusedFrame: { name: 'embedded', parent: f.frame } })
f.contents.emit('before-input-event', { preventDefault }, input)
expect(preventDefault).toHaveBeenCalledTimes(1)
expect(f.contents.send).toHaveBeenLastCalledWith(DESKTOP_IPC.shortcutsInput, expect.objectContaining({ kind: 'iframe', revision: initial.revision, frameName: 'embedded', code: 'KeyW' }))
f.contents.emit('before-input-event', { preventDefault }, { ...input, type: 'keyUp' })
expect(f.contents.send).toHaveBeenCalledTimes(1)
const saved = await f.call<ShortcutSaveResult>(DESKTOP_IPC.shortcutsEdit,
{ type: 'set', id: 'page.close', binding: { code: 'KeyJ', modifiers: ['primary'] } }, initial.revision)
expect(closeItem().accelerator).toBe('Command+J')
f.contents.send.mockClear()
f.contents.emit('before-input-event', { preventDefault }, input)
expect(f.contents.send).not.toHaveBeenCalled()
f.contents.emit('before-input-event', { preventDefault }, { ...input, code: 'KeyZ', key: 'j' })
expect(f.contents.setIgnoreMenuShortcuts).toHaveBeenLastCalledWith(true)
expect(f.contents.send).not.toHaveBeenCalled()
await f.call(DESKTOP_IPC.shortcutsCloseWindow, initial.revision)
expect(f.window.close).not.toHaveBeenCalled()
f.window.isEnabled.mockReturnValue(false)
await f.call(DESKTOP_IPC.shortcutsCloseWindow, saved.snapshot.revision)
expect(f.window.close).not.toHaveBeenCalled()
f.window.isEnabled.mockReturnValue(true)
await f.call(DESKTOP_IPC.shortcutsCloseWindow, saved.snapshot.revision)
expect(f.window.close).toHaveBeenCalledTimes(1)
await f.call(DESKTOP_IPC.shortcutsEdit, { type: 'set', id: 'page.close', binding: null }, saved.snapshot.revision)
expect(closeItem().accelerator).toBeUndefined()
f.keyboard.dispose()
expect(f.contents.listenerCount('before-input-event')).toBe(0)
})
it.each([
['browser.new', 'KeyT'], ['session.new', 'KeyN'],
])('forwards Linux %s from embedded frames while leaving main-document input to the DOM', async (id, code) => {
const f = await fixture('linux')
const initial = await f.call<ShortcutConfigSnapshot>(DESKTOP_IPC.shortcutsGet,
[{ id, defaults: desktopDefaults({ code, modifiers: ['primary'] }) }])
const input = { modifiers: ['control'], type: 'keyDown', code, key: code.slice(3).toLowerCase(),
control: true, meta: false, alt: false, shift: false, isAutoRepeat: false, isComposing: false }
const preventDefault = vi.fn()
f.contents.send.mockClear()
f.contents.emit('before-input-event', { preventDefault }, input)
expect(preventDefault).not.toHaveBeenCalled()
expect(f.contents.send).not.toHaveBeenCalled()
Object.assign(f.contents, { focusedFrame: { name: 'browser', parent: f.frame } })
f.contents.emit('before-input-event', { preventDefault }, input)
expect(preventDefault).toHaveBeenCalledOnce()
expect(f.contents.send).toHaveBeenCalledExactlyOnceWith(DESKTOP_IPC.shortcutsInput,
expect.objectContaining({ kind: 'iframe', frameName: 'browser', code, revision: initial.revision }))
f.contents.emit('before-input-event', { preventDefault }, { ...input, type: 'keyUp' })
expect(f.contents.send).toHaveBeenCalledOnce()
const cleared = await f.call<ShortcutSaveResult>(DESKTOP_IPC.shortcutsEdit,
{ type: 'set', id, binding: null }, initial.revision)
expect(cleared.status).toBe('saved')
preventDefault.mockClear(); f.contents.send.mockClear()
f.contents.emit('before-input-event', { preventDefault }, input)
expect(preventDefault).not.toHaveBeenCalled()
expect(f.contents.send).not.toHaveBeenCalled()
})
it.each([
['ArrowUp', 'Command+Up'], ['ArrowDown', 'Command+Down'],
['ArrowLeft', 'Command+Left'], ['ArrowRight', 'Command+Right'],
])('uses the Electron accelerator for a saved %s binding', async (code, accelerator) => {
const f = await fixture()
const initial = await f.call<ShortcutConfigSnapshot>(DESKTOP_IPC.shortcutsGet,
[{ id: 'page.close', defaults: desktopDefaults({ code: 'KeyW', modifiers: ['primary'] }) }])
const saved = await f.call<ShortcutSaveResult>(DESKTOP_IPC.shortcutsEdit,
{ type: 'set', id: 'page.close', binding: { code, modifiers: ['primary'] } }, initial.revision)
expect(saved.status).toBe('saved')
const menu = f.keyboard.fileMenu({ fileMenu: 'File', closePage: 'Close' }).submenu as Electron.MenuItemConstructorOptions[]
expect(menu[0]?.accelerator).toBe(accelerator)
})
it('refreshes the native menu only when its accelerator or availability changes', async () => {
const f = await fixture()
const close = { id: 'page.close', defaults: desktopDefaults({ code: 'KeyW', modifiers: ['primary'] }) }
expect(f.updateMenu).not.toHaveBeenCalled()
await f.call(DESKTOP_IPC.shortcutsGet, [close])
expect(f.updateMenu).toHaveBeenCalledOnce()
const menu = f.keyboard.fileMenu({ fileMenu: 'File', closePage: 'Close' }).submenu as Electron.MenuItemConstructorOptions[]
const registered = await f.call<ShortcutConfigSnapshot>(DESKTOP_IPC.shortcutsGet, [close, ...f.definitions])
const edited = await f.call<ShortcutSaveResult>(DESKTOP_IPC.shortcutsEdit,
{ type: 'set', id: 'sidebar.left.toggle', binding: { code: 'KeyJ', modifiers: ['primary'] } }, registered.revision)
expect(edited.status).toBe('saved')
expect(f.updateMenu).toHaveBeenCalledOnce()
f.contents.send.mockClear()
const click = menu[0]!.click as () => void
click()
expect(f.contents.send).toHaveBeenCalledExactlyOnceWith(DESKTOP_IPC.shortcutsInput,
{ kind: 'menu', commandId: 'page.close', revision: edited.snapshot.revision })
await f.call(DESKTOP_IPC.shortcutsEdit,
{ type: 'set', id: 'page.close', binding: { code: 'KeyQ', modifiers: ['primary'] } }, edited.snapshot.revision)
expect(f.updateMenu).toHaveBeenCalledTimes(2)
f.contents.emit('did-start-navigation', { isMainFrame: true, isSameDocument: false })
expect(f.updateMenu).toHaveBeenCalledTimes(3)
f.contents.emit('did-start-navigation', { isMainFrame: true, isSameDocument: false })
expect(f.updateMenu).toHaveBeenCalledTimes(3)
})
it.each(['macos', 'windows'] as const)('prioritizes %s custom editing bindings before both main and embedded frame handlers', async (platform) => {
const f = await fixture(platform)
const initial = await f.call<ShortcutConfigSnapshot>(DESKTOP_IPC.shortcutsGet,
[{ id: 'session.new', defaults: desktopDefaults({ code: 'KeyN', modifiers: ['primary'] }) }])
const saved = await f.call<ShortcutSaveResult>(DESKTOP_IPC.shortcutsEdit,
{ type: 'set', id: 'session.new', binding: { code: 'KeyC', modifiers: ['primary'] } }, initial.revision)
expect(saved.status).toBe('saved')
const input = { modifiers: [], type: 'keyDown', code: 'KeyC', key: 'c', meta: platform === 'macos',
control: platform === 'windows', alt: false, shift: false, isAutoRepeat: false, isComposing: false }
const preventDefault = vi.fn()
const press = (changes = {}) => f.contents.emit('before-input-event', { preventDefault }, { ...input, ...changes })
f.contents.send.mockClear()
press()
expect(preventDefault).toHaveBeenCalledOnce()
expect(f.contents.send).toHaveBeenLastCalledWith(DESKTOP_IPC.shortcutsInput, expect.objectContaining({ kind: 'keyboard', code: 'KeyC' }))
press({ type: 'keyUp' })
expect(preventDefault).toHaveBeenCalledTimes(2)
expect(f.contents.send).toHaveBeenCalledOnce()
Object.assign(f.contents, { focusedFrame: { name: 'embedded', parent: f.frame } })
press()
expect(f.contents.send).toHaveBeenLastCalledWith(DESKTOP_IPC.shortcutsInput, expect.objectContaining({ kind: 'iframe', frameName: 'embedded' }))
await f.call(DESKTOP_IPC.shortcutsRecording, true)
preventDefault.mockClear(); f.contents.send.mockClear()
press()
expect(preventDefault).not.toHaveBeenCalled()
expect(f.contents.send).not.toHaveBeenCalled()
await f.call(DESKTOP_IPC.shortcutsRecording, false)
press({ isComposing: true })
press({ modifiers: ['altgr'] })
press({ key: 'Dead' })
press()
expect(preventDefault).not.toHaveBeenCalled()
await f.call(DESKTOP_IPC.shortcutsEdit, { type: 'set', id: 'session.new', binding: null }, saved.snapshot.revision)
f.contents.send.mockClear()
press()
expect(preventDefault).not.toHaveBeenCalled()
expect(f.contents.send).not.toHaveBeenCalled()
expect(f.contents.setIgnoreMenuShortcuts).toHaveBeenLastCalledWith(false)
})
it.each(['macos', 'windows'] as const)('intercepts complete %s chords once and leaves their first key untouched', async (platform) => {
const f = await fixture(platform)
const initial = await f.call<ShortcutConfigSnapshot>(DESKTOP_IPC.shortcutsGet,
[{ id: 'page.close', defaults: desktopDefaults({ code: 'KeyW', modifiers: ['primary'] }) }])
const saved = await f.call<ShortcutSaveResult>(DESKTOP_IPC.shortcutsEdit,
{ type: 'set', id: 'page.close', binding: { code: 'KeyA', secondCode: 'KeyB', modifiers: [] } }, initial.revision)
expect(saved.status).toBe('saved')
const menu = f.keyboard.fileMenu({ fileMenu: 'File', closePage: 'Close' }).submenu as Electron.MenuItemConstructorOptions[]
expect(menu[0]?.accelerator).toBeUndefined()
const preventDefault = vi.fn()
const press = (code: string, type = 'keyDown', isAutoRepeat = false) => {
f.contents.emit('before-input-event', { preventDefault }, { code, key: code.slice(3), type, modifiers: [],
control: false, alt: false, shift: false, meta: false, isAutoRepeat, isComposing: false })
}
for (const embedded of [false, true]) {
Object.assign(f.contents, { focusedFrame: embedded ? { name: 'browser', parent: f.frame } : f.frame })
f.contents.send.mockClear(); preventDefault.mockClear()
press('KeyA'); press('KeyA', 'keyUp'); press('KeyB'); press('KeyB', 'keyUp')
expect(preventDefault).not.toHaveBeenCalled()
expect(f.contents.send).not.toHaveBeenCalled()
press('KeyB')
expect(preventDefault).not.toHaveBeenCalled()
press('KeyA')
expect(preventDefault).toHaveBeenCalledOnce()
expect(f.contents.send).toHaveBeenLastCalledWith(DESKTOP_IPC.shortcutsInput,
expect.objectContaining({ kind: embedded ? 'iframe' : 'keyboard', code: 'KeyA', secondCode: 'KeyB', repeat: false }))
press('KeyA', 'keyUp'); press('KeyB', 'keyUp')
}
for (const reset of [() => { f.window.emit('blur') },
() => { press('MetaLeft', 'keyUp') },
() => { Object.assign(f.contents, { focusedFrame: f.frame }) }]) {
press('KeyA'); reset(); f.contents.send.mockClear(); press('KeyB')
expect(f.contents.send).not.toHaveBeenCalled()
press('KeyA', 'keyUp'); press('KeyB', 'keyUp')
}
f.contents.send.mockClear()
press('KeyC'); press('KeyA'); press('KeyB')
expect(f.contents.send).not.toHaveBeenCalled()
})
it.each(['C', 'V', 'Z'])('delivers Windows Edit %s to the editor and physical input to its shortcut', async (keyCode) => {
const f = await fixture('windows')
const initial = await f.call<ShortcutConfigSnapshot>(DESKTOP_IPC.shortcutsGet, f.definitions)
const saved = await f.call<ShortcutSaveResult>(DESKTOP_IPC.shortcutsEdit,
{ type: 'set', id: 'sidebar.left.toggle', binding: { code: `Key${keyCode}`, modifiers: ['control'] } }, initial.revision)
const registry = new ShortcutRegistry('desktop', 'windows', saved.snapshot)
const run = vi.fn()
registry.register({ ...f.definitions[0]!, label: () => 'Toggle sidebar', aliases: [], regions: ['page', 'editable'], modals: [],
resolve: () => ({ status: 'handled', run }) })
let nativeInput: (input: DesktopShortcutInput) => void = () => {}
onTestFinished(installNativeKeyboard(window, {
closeWindow: vi.fn(), subscribe: (listener) => { nativeInput = listener; return () => {} },
}, registry, () => registry.config.getSnapshot()))
onTestFinished(installKeyboard(window, registry, undefined, true))
f.contents.send.mockImplementation((channel: string, input: DesktopShortcutInput) => {
if (channel === DESKTOP_IPC.shortcutsInput) nativeInput(input)
})
const editorInput: string[] = []
const deliver = (type: 'keyDown' | 'keyUp'): void => {
const preventDefault = vi.fn()
f.contents.emit('before-input-event', { preventDefault }, { type, code: `Key${keyCode}`, key: keyCode.toLowerCase(),
modifiers: ['control'], control: true, meta: false, alt: false, shift: false, isAutoRepeat: false, isComposing: false })
if (!preventDefault.mock.calls.length) {
editorInput.push(type)
document.body.dispatchEvent(new KeyboardEvent(type === 'keyDown' ? 'keydown' : 'keyup',
{ code: `Key${keyCode}`, key: keyCode.toLowerCase(), ctrlKey: true, bubbles: true, cancelable: true }))
}
}
f.contents.sendInputEvent.mockImplementation((input: { type: 'keyDown' | 'keyUp' }) => { deliver(input.type) })
f.contents.send.mockClear()
f.keyboard.sendEditingKey(keyCode, ['control'])
expect(f.contents.focus).toHaveBeenCalledOnce()
expect(editorInput).toEqual(['keyDown', 'keyUp'])
expect(f.contents.send).not.toHaveBeenCalled()
expect(run).not.toHaveBeenCalled()
expect(f.contents.sendInputEvent.mock.calls).toEqual([
[{ type: 'keyDown', keyCode, modifiers: ['control'] }],
[{ type: 'keyUp', keyCode, modifiers: ['control'] }],
])
editorInput.length = 0
deliver('keyDown')
deliver('keyUp')
expect(editorInput).toEqual([])
expect(f.contents.send).toHaveBeenCalledExactlyOnceWith(DESKTOP_IPC.shortcutsInput,
expect.objectContaining({ kind: 'keyboard', code: `Key${keyCode}` }))
expect(run).toHaveBeenCalledOnce()
})
it('restores shortcut interception after native Edit delivery fails', async () => {
const f = await fixture('windows')
await f.call<ShortcutConfigSnapshot>(DESKTOP_IPC.shortcutsGet, f.definitions)
f.contents.sendInputEvent.mockImplementationOnce(() => { throw new Error('editor unavailable') })
expect(() => { f.keyboard.sendEditingKey('C', ['control']) }).toThrow('editor unavailable')
const preventDefault = vi.fn()
f.contents.emit('before-input-event', { preventDefault }, { type: 'keyDown', code: 'KeyB', key: 'b', modifiers: ['control'],
control: true, meta: false, alt: false, shift: false, isAutoRepeat: false, isComposing: false })
expect(preventDefault).toHaveBeenCalledOnce()
f.window.isDestroyed.mockReturnValue(true)
expect(() => { f.keyboard.sendEditingKey('C', ['control']) }).not.toThrow()
f.detach()
expect(() => { f.keyboard.sendEditingKey('C', ['control']) }).not.toThrow()
expect(f.contents.sendInputEvent).toHaveBeenCalledOnce()
})
it.each(['macos', 'windows'] as const)('intercepts %s standalone custom keys and clears held state when preferences change', async (platform) => {
const f = await fixture(platform)
let snapshot = await f.call<ShortcutConfigSnapshot>(DESKTOP_IPC.shortcutsGet, f.definitions)
for (const code of ['KeyA', 'F1', 'ArrowLeft', 'Escape', 'Tab', 'Enter']) {
const saved = await f.call<ShortcutSaveResult>(DESKTOP_IPC.shortcutsEdit,
{ type: 'set', id: 'sidebar.left.toggle', binding: { code, modifiers: [] } }, snapshot.revision)
expect(saved.status).toBe('saved'); snapshot = saved.snapshot
const preventDefault = vi.fn()
f.contents.send.mockClear()
f.contents.emit('before-input-event', { preventDefault }, { type: 'keyDown', code, key: code, modifiers: [],
control: false, alt: false, shift: false, meta: false, isAutoRepeat: false, isComposing: false })
expect(preventDefault).toHaveBeenCalledOnce()
expect(f.contents.send).toHaveBeenLastCalledWith(DESKTOP_IPC.shortcutsInput,
expect.objectContaining({ code, revision: snapshot.revision }))
}
})
it.each(['macos', 'windows'] as const)('keeps %s native interception identical for inherited, saved and reset bindings', async (platform) => {
const f = await fixture(platform)
let snapshot = await f.call<ShortcutConfigSnapshot>(DESKTOP_IPC.shortcutsGet, f.definitions)
for (const operation of [null,
{ type: 'set', id: 'sidebar.left.toggle', binding: { code: 'KeyB', modifiers: ['primary'] } },
{ type: 'reset', id: 'sidebar.left.toggle' }]) {
if (operation !== null) {
const result = await f.call<ShortcutSaveResult>(DESKTOP_IPC.shortcutsEdit, operation, snapshot.revision)
expect(result.status).toBe('saved'); snapshot = result.snapshot
}
for (const embedded of [false, true]) {
Object.assign(f.contents, { focusedFrame: embedded ? { name: 'browser', parent: f.frame } : f.frame })
const preventDefault = vi.fn()
f.contents.send.mockClear()
f.contents.emit('before-input-event', { preventDefault }, { type: 'keyDown', code: 'KeyB', key: 'b', modifiers: [],
control: platform === 'windows', meta: platform === 'macos', alt: false, shift: false, isAutoRepeat: false, isComposing: false })
expect(preventDefault).toHaveBeenCalledOnce()
expect(f.contents.send).toHaveBeenLastCalledWith(DESKTOP_IPC.shortcutsInput,
expect.objectContaining({ kind: embedded ? 'iframe' : 'keyboard', code: 'KeyB', revision: snapshot.revision }))
}
}
})
it('consumes a held single-key repeat after focus reset without treating it as a fresh press', async () => {
const f = await fixture('macos')
await f.call<ShortcutConfigSnapshot>(DESKTOP_IPC.shortcutsGet, f.definitions)
const preventDefault = vi.fn()
const input = { type: 'keyDown', code: 'KeyB', key: 'b', modifiers: [],
control: false, meta: true, alt: false, shift: false, isAutoRepeat: false, isComposing: false }
f.contents.emit('before-input-event', { preventDefault }, input)
f.window.emit('blur')
f.contents.send.mockClear(); preventDefault.mockClear()
f.contents.emit('before-input-event', { preventDefault }, { ...input, isAutoRepeat: true })
expect(preventDefault).toHaveBeenCalledOnce()
expect(f.contents.send).toHaveBeenLastCalledWith(DESKTOP_IPC.shortcutsInput, expect.objectContaining({ code: 'KeyB', repeat: true }))
})
it('delivers a fresh unbound key release after macOS omitted the previous Command character release', async () => {
const f = await fixture('macos')
await f.call<ShortcutConfigSnapshot>(DESKTOP_IPC.shortcutsGet, f.definitions)
const preventDefault = vi.fn()
const press = (code: string, type: string, meta: boolean): void => {
f.contents.emit('before-input-event', { preventDefault }, { type, code, key: code, modifiers: [],
control: false, meta, alt: false, shift: false, isAutoRepeat: false, isComposing: false })
}
press('KeyB', 'keyDown', true)
expect(preventDefault).toHaveBeenCalledOnce()
press('MetaLeft', 'keyUp', false)
preventDefault.mockClear()
press('KeyB', 'keyDown', false); press('KeyB', 'keyUp', false)
expect(preventDefault).not.toHaveBeenCalled()
})
it.each(['macos', 'windows'] as const)('releases %s native input when a saved binding overlaps a mounted fixed action', async (platform) => {
const f = await fixture(platform)
const initial = await f.call<ShortcutConfigSnapshot>(DESKTOP_IPC.shortcutsGet, f.definitions)
const saved = await f.call<ShortcutSaveResult>(DESKTOP_IPC.shortcutsEdit,
{ type: 'set', id: 'sidebar.left.toggle', binding: { code: 'Escape', modifiers: [] } }, initial.revision)
expect(saved.status).toBe('saved')
await f.call(DESKTOP_IPC.shortcutsGet, [...f.definitions,
{ id: 'menu.dismiss', defaults: {}, fixed: [{ code: 'Escape', modifiers: [] }] }])
const preventDefault = vi.fn()
f.contents.send.mockClear()
f.contents.emit('before-input-event', { preventDefault }, { type: 'keyDown', code: 'Escape', key: 'Escape', modifiers: [],
control: false, meta: false, alt: false, shift: false, isAutoRepeat: false, isComposing: false })
expect(preventDefault).not.toHaveBeenCalled()
expect(f.contents.send).not.toHaveBeenCalled()
})
it.each(['macos', 'windows'] as const)('keeps %s chord presses singular across native interception and renderer key delivery', async (platform) => {
const f = await fixture(platform)
const initial = await f.call<ShortcutConfigSnapshot>(DESKTOP_IPC.shortcutsGet, f.definitions)
const saved = await f.call<ShortcutSaveResult>(DESKTOP_IPC.shortcutsEdit,
{ type: 'set', id: 'sidebar.left.toggle', binding: { code: 'KeyF', secondCode: 'KeyG', modifiers: [] } }, initial.revision)
const registry = new ShortcutRegistry('desktop', platform, saved.snapshot)
const run = vi.fn()
registry.register({ ...f.definitions[0]!, label: () => 'Toggle sidebar', aliases: [], regions: ['page'], modals: [],
resolve: () => ({ status: 'handled', run }) })
let deliver: (input: DesktopShortcutInput) => void = () => {}
onTestFinished(installNativeKeyboard(window, { closeWindow: vi.fn(), subscribe: (listener) => { deliver = listener; return () => {} } },
registry, () => registry.config.getSnapshot()))
onTestFinished(installKeyboard(window, registry, undefined, true))
f.contents.send.mockImplementation((channel: string, input: DesktopShortcutInput) => {
if (channel === DESKTOP_IPC.shortcutsInput) deliver(input)
})
const rendererReleases: string[] = []
const press = (code: string, type: 'keyDown' | 'keyUp', repeat = false): void => {
const preventDefault = vi.fn()
f.contents.emit('before-input-event', { preventDefault }, { code, key: code.slice(3).toLowerCase(), type, modifiers: [],
control: false, meta: false, alt: false, shift: false, isAutoRepeat: repeat, isComposing: false })
// Electron only delivers input to the document when the main process did not intercept it.
if (!preventDefault.mock.calls.length) {
if (type === 'keyUp') rendererReleases.push(code)
document.body.dispatchEvent(new KeyboardEvent(type === 'keyDown' ? 'keydown' : 'keyup',
{ code, key: code.slice(3).toLowerCase(), repeat, bubbles: true, cancelable: true }))
}
}
for (const [index, [first, second]] of ([['KeyF', 'KeyG'], ['KeyG', 'KeyF']] as const).entries()) {
press(first, 'keyDown'); press(first, 'keyUp'); press(second, 'keyDown'); press(second, 'keyUp')
expect(run).toHaveBeenCalledTimes(index)
press(first, 'keyDown'); press(second, 'keyDown'); press(second, 'keyDown', true); press(first, 'keyDown', true)
expect(run).toHaveBeenCalledTimes(index + 1)
if (index === 0) { press(first, 'keyUp'); press(second, 'keyUp') }
else { press(second, 'keyUp'); press(first, 'keyUp') }
press(second, 'keyDown'); press(second, 'keyUp'); press(first, 'keyDown'); press(first, 'keyUp')
expect(run).toHaveBeenCalledTimes(index + 1)
}
expect(rendererReleases.filter(code => code === 'KeyF')).toHaveLength(5)
expect(rendererReleases.filter(code => code === 'KeyG')).toHaveLength(5)
})
it.each(['macos', 'windows'] as const)('requires fresh %s chord presses when Electron omits intercepted key releases', async (platform) => {
const f = await fixture(platform)
const bindings = [
{ id: 'sidebar.left.toggle', binding: { code: 'KeyJ', secondCode: 'KeyK', modifiers: [] } },
{ id: 'sidebar.right.toggle', binding: { code: 'KeyF', secondCode: 'KeyG', modifiers: [] } },
{ id: 'shortcuts.open', binding: { code: 'KeyV', modifiers: [] } },
]
let snapshot = await f.call<ShortcutConfigSnapshot>(DESKTOP_IPC.shortcutsGet,
bindings.map(({ id }) => ({ id, defaults: {} })))
for (const binding of bindings) {
const saved = await f.call<ShortcutSaveResult>(DESKTOP_IPC.shortcutsEdit, { type: 'set', ...binding }, snapshot.revision)
expect(saved.status).toBe('saved')
snapshot = saved.snapshot
}
const press = (code: string, type = 'keyDown', repeat = false): boolean => {
const preventDefault = vi.fn()
f.contents.emit('before-input-event', { preventDefault }, { code, key: code.slice(3).toLowerCase(), type, modifiers: [],
control: false, meta: false, alt: false, shift: false, isAutoRepeat: repeat, isComposing: false })
return preventDefault.mock.calls.length > 0
}
for (const embedded of [false, true]) {
Object.assign(f.contents, { focusedFrame: embedded ? { name: 'browser', parent: f.frame } : f.frame })
f.contents.send.mockClear()
expect(press('KeyJ')).toBe(false)
expect(press('KeyK')).toBe(true)
expect(f.contents.send).toHaveBeenCalledOnce()
// Electron on macOS can omit both releases after intercepting the second keydown.
for (const code of ['KeyJ', 'KeyK']) {
expect(press(code)).toBe(false)
expect(press(code, 'keyUp')).toBe(false)
}
expect(f.contents.send).toHaveBeenCalledOnce()
expect(press('KeyK')).toBe(false)
expect(press('KeyJ')).toBe(true)
expect(f.contents.send).toHaveBeenCalledTimes(2)
expect(press('KeyJ', 'keyDown', true)).toBe(true)
expect(press('KeyK', 'keyDown', true)).toBe(true)
expect(f.contents.send).toHaveBeenCalledTimes(2)
expect(press('KeyF')).toBe(false)
expect(press('KeyG')).toBe(true)
expect(f.contents.send).toHaveBeenCalledTimes(3)
expect(press('KeyV')).toBe(true)
expect(press('KeyJ')).toBe(false)
expect(press('KeyK')).toBe(true)
expect(f.contents.send).toHaveBeenCalledTimes(5)
}
})
it.each(['macos', 'windows', 'linux'] as const)('routes approved %s browser guest input to its owner and stops delivery before guest destruction', async (platform) => {
const f = await fixture(platform)
const snapshot = await f.call<ShortcutConfigSnapshot>(DESKTOP_IPC.shortcutsGet,
[{ id: 'browser.new', defaults: desktopDefaults({ code: 'KeyT', modifiers: ['primary'] }) }])
const guests = new DesktopBrowserGuests(() => undefined) as GuestsFixture
const attach = vi.fn((guest: ContentsFixture, name: DesktopBrowserLeaseId) => f.keyboard.attachGuest(f.window, guest, name))
guests.bind(f.window, attach)
const reservation = guests.acquire(f.contents, 'session:test')
const { frame, guest } = browserGuest(reservation)
const rejected = { preventDefault: vi.fn() }
f.contents.emit('will-attach-webview', rejected, {}, { src: 'about:blank#unknown', partition: reservation.partition })
expect(rejected.preventDefault).toHaveBeenCalledOnce()
const invalid = Object.assign(new EventEmitter(), { getURL: () => 'about:blank#unknown', setWindowOpenHandler: vi.fn(), close: vi.fn() })
f.contents.emit('did-attach-webview', {}, invalid)
invalid.emit('dom-ready')
expect(invalid.close).toHaveBeenCalledExactlyOnceWith({ waitForBeforeUnload: false })
expect(attach).not.toHaveBeenCalled()
const approved = { preventDefault: vi.fn() }
f.contents.emit('will-attach-webview', approved, {}, { src: frame.url, partition: reservation.partition })
expect(approved.preventDefault).not.toHaveBeenCalled()
f.contents.emit('did-attach-webview', {}, guest)
guest.emit('dom-ready')
expect(attach).toHaveBeenCalledExactlyOnceWith(guest, reservation.lease)
const input = { modifiers: [], type: 'keyDown', code: 'KeyT', key: 't', meta: platform === 'macos',
control: platform !== 'macos', alt: false, shift: false, isAutoRepeat: false, isComposing: false }
const preventDefault = vi.fn()
f.contents.send.mockClear()
guest.emit('before-input-event', { preventDefault }, input)
expect(preventDefault).toHaveBeenCalledOnce()
expect(guest.send).not.toHaveBeenCalled()
expect(f.contents.send).toHaveBeenCalledExactlyOnceWith(DESKTOP_IPC.shortcutsInput, {
kind: 'webview', frameName: reservation.lease, revision: snapshot.revision, code: 'KeyT',
meta: platform === 'macos', control: platform !== 'macos', alt: false, shift: false, repeat: false,
})
guest.isFocused.mockReturnValue(false)
guest.emit('before-input-event', { preventDefault }, input)
expect(f.contents.send).toHaveBeenCalledOnce()
guest.isFocused.mockReturnValue(true)
const release = guests.release(f.contents, reservation.lease)
expect(guest.close).toHaveBeenCalledExactlyOnceWith({ waitForBeforeUnload: false })
expect(guest.listenerCount('before-input-event')).toBe(0)
guest.emit('before-input-event', { preventDefault }, input)
expect(f.contents.send).toHaveBeenCalledOnce()
guest.emit('destroyed')
await release
})
it('keeps browser guest chord state local and leaves accepted bindings active through guest navigation', async () => {
const f = await fixture('macos')
const initial = await f.call<ShortcutConfigSnapshot>(DESKTOP_IPC.shortcutsGet, f.definitions)
await f.call(DESKTOP_IPC.shortcutsEdit, { type: 'set', id: 'sidebar.left.toggle',
binding: { code: 'KeyF', secondCode: 'KeyG', modifiers: [] } }, initial.revision)
const frame: FrameFixture = { url: 'https://example.test/', name: '', parent: null }
const guest = Object.assign(new EventEmitter(), { mainFrame: frame, focusedFrame: frame,
isDestroyed: () => false, isFocused: () => true, setIgnoreMenuShortcuts: vi.fn(),
send: vi.fn(), focus: vi.fn(), sendInputEvent: vi.fn() })
const blurListeners = f.window.listenerCount('blur')
const closedListeners = f.window.listenerCount('closed')
const dispose = f.keyboard.attachGuest(f.window, guest, 'guest' as DesktopBrowserLeaseId)
onTestFinished(dispose)
expect(f.window.listenerCount('blur')).toBe(blurListeners)
expect(f.window.listenerCount('closed')).toBe(closedListeners)
const press = (contents: EventEmitter, code: string): void => {
contents.emit('before-input-event', { preventDefault: vi.fn() }, { modifiers: [], type: 'keyDown', code, key: code,
meta: false, control: false, alt: false, shift: false, isAutoRepeat: false, isComposing: false })
}
f.contents.send.mockClear()
press(guest, 'KeyF')
guest.emit('blur')
press(guest, 'KeyG')
expect(f.contents.send).not.toHaveBeenCalled()
f.window.emit('blur')
press(guest, 'KeyF')
expect(f.contents.send).not.toHaveBeenCalled()
guest.emit('did-start-navigation', { isMainFrame: true, isSameDocument: false })
press(guest, 'KeyF')
press(f.contents, 'KeyG')
expect(f.contents.send).not.toHaveBeenCalled()
press(guest, 'KeyG')
expect(f.contents.send).toHaveBeenCalledExactlyOnceWith(DESKTOP_IPC.shortcutsInput,
expect.objectContaining({ kind: 'webview', frameName: 'guest', code: 'KeyF', secondCode: 'KeyG' }))
guest.emit('destroyed')
press(f.contents, 'KeyF')
expect(f.contents.send).toHaveBeenCalledTimes(2)
expect(f.contents.send).toHaveBeenLastCalledWith(DESKTOP_IPC.shortcutsInput,
expect.objectContaining({ kind: 'keyboard', code: 'KeyF', secondCode: 'KeyG' }))
})
it('delivers Windows Edit actions to the focused browser guest without invoking its custom shortcut', async () => {
const f = await fixture('windows')
const initial = await f.call<ShortcutConfigSnapshot>(DESKTOP_IPC.shortcutsGet, f.definitions)
await f.call(DESKTOP_IPC.shortcutsEdit, { type: 'set', id: 'sidebar.left.toggle',
binding: { code: 'KeyC', modifiers: ['control'] } }, initial.revision)
const frame: FrameFixture = { url: 'https://example.test/', name: '', parent: null }
const guest = Object.assign(new EventEmitter(), { mainFrame: frame, focusedFrame: frame,
isDestroyed: () => false, isFocused: () => true, setIgnoreMenuShortcuts: vi.fn(),
send: vi.fn(), focus: vi.fn(), sendInputEvent: vi.fn() })
const dispose = f.keyboard.attachGuest(f.window, guest, 'guest' as DesktopBrowserLeaseId)
onTestFinished(dispose)
const events: string[] = []
guest.sendInputEvent.mockImplementation((input: { type: 'keyDown' | 'keyUp' }) => {
const preventDefault = vi.fn()
guest.emit('before-input-event', { preventDefault }, { ...input, code: 'KeyC', key: 'c', modifiers: ['control'],
control: true, meta: false, alt: false, shift: false, isAutoRepeat: false, isComposing: false })
if (!preventDefault.mock.calls.length) events.push(input.type)
})
f.contents.send.mockClear()
f.keyboard.sendEditingKey('C', ['control'])
expect(guest.focus).toHaveBeenCalledOnce()
expect(events).toEqual(['keyDown', 'keyUp'])
expect(f.contents.send).not.toHaveBeenCalled()
expect(f.contents.sendInputEvent).not.toHaveBeenCalled()
dispose()
f.keyboard.sendEditingKey('C', ['control'])
expect(f.contents.sendInputEvent).toHaveBeenCalledTimes(2)
})
+9 -4
View File
@@ -1,6 +1,6 @@
import type { AccountView } from '@deepseek-ai/dsh-deepseek-account/types'
import { WINDOWS_TITLEBAR_HEIGHT } from '../src/windows-layout.ts'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'
import type { IpcMainInvokeEvent } from 'electron'
import { join } from 'node:path'
import { mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs'
@@ -69,6 +69,8 @@ const harness = await vi.hoisted(async () => {
getURL: () => this.urls.at(-1) ?? '',
mainFrame: { url: '' },
getZoomFactor: () => 1,
isDestroyed: () => this.destroyed,
setIgnoreMenuShortcuts: vi.fn(),
focus: vi.fn(),
sendInputEvent: vi.fn(),
send: vi.fn((channel: string, state: { policy?: { blocking: boolean } }) => {
@@ -142,7 +144,7 @@ const harness = await vi.hoisted(async () => {
getVersion: () => '1.0.0',
getAppPath: (): string => 'desktop-test-app',
setAppLogsPath: vi.fn(),
getPath: (name: string): string => `desktop-test-${name}`,
getPath: vi.fn<(name: string) => string>(),
setAboutPanelOptions: vi.fn<(options: Electron.AboutPanelOptionsOptions) => void>(),
requestSingleInstanceLock: () => true,
setAsDefaultProtocolClient: vi.fn(),
@@ -326,6 +328,9 @@ beforeEach(() => {
testAuth.login.mockResolvedValue('cancelled')
vi.useFakeTimers()
harness.reset()
const userData = mkdtempSync(join(tmpdir(), 'dsh-main-user-data-'))
onTestFinished(() => { rmSync(userData, { recursive: true, force: true }) })
harness.app.getPath.mockImplementation(name => name === 'userData' ? userData : `desktop-test-${name}`)
harness.dialog.showMessageBox.mockImplementation((options: { title?: string }) => {
if (options.title !== en.startupFailed) return Promise.resolve({ response: 1 })
harness.dialogShown.resolve()
@@ -784,7 +789,7 @@ describe('desktop main startup', () => {
await edit
})
it.each(['darwin', 'linux'] as const)('adds the standard macOS window commands only on macOS (%s)', async (platform) => {
it.each(['darwin', 'linux'] as const)('adds the product File menu and standard window commands only on macOS (%s)', async (platform) => {
vi.spyOn(process, 'platform', 'get').mockReturnValue(platform)
await import('../src/main.ts')
await harness.preparing.promise
@@ -795,7 +800,7 @@ describe('desktop main startup', () => {
.find(items => items.some(item => item.role === 'editMenu'))
if (template === undefined) throw new Error('application menu missing')
expect(template.map(describeItem)).toEqual(platform === 'darwin'
? ['Desktop test', 'fileMenu', 'editMenu', 'windowMenu']
? ['Desktop test', en.fileMenu, 'editMenu', 'windowMenu']
: ['Application', 'editMenu'])
const application = template[0]!.submenu as MenuItemConstructorOptions[]
expect(application.filter(item => item.visible !== false).map(describeItem)).toEqual(platform === 'darwin'
@@ -1,6 +1,6 @@
import { EventEmitter } from 'node:events'
import { afterEach, expect, it, vi } from 'vitest'
import type { BrowserWindow } from 'electron'
import type { BrowserWindow, WebContents, WebFrameMain } from 'electron'
import { DesktopMandatoryUpdateWindow, type MandatoryUpdateView } from '../src/mandatory-update-window.ts'
import { MANDATORY_IPC } from '../src/mandatory-update-ipc.ts'
import { resolveDesktopLocale } from '../src/locale.ts'
@@ -13,7 +13,11 @@ vi.mock('electron', () => ({ ipcMain: {
handle: (channel: string, handler: (...args: unknown[]) => unknown) => native.handlers.set(channel, handler),
removeHandler: (channel: string) => native.handlers.delete(channel),
}, app: { quit: native.quit }, shell: { openExternal: native.open }, clipboard: { writeText: native.write, readText: native.read } }))
vi.mock('../src/update-overlay.ts', () => ({ createUpdateOverlay: vi.fn(() => window) }))
type WindowFixture = EventEmitter & Pick<BrowserWindow, 'setMenu' | 'setTitle' | 'loadURL' | 'destroy' | 'isDestroyed'
| 'isFocused' | 'isMinimized' | 'focus' | 'show' | 'restore'> & {
webContents: EventEmitter & Pick<WebContents, 'send' | 'setWindowOpenHandler'> & { mainFrame: Pick<WebFrameMain, 'url'> }
}
let window: ReturnType<typeof fakeWindow>
let ui: DesktopMandatoryUpdateWindow | undefined
@@ -32,8 +36,10 @@ function setup(platform: NodeJS.Platform = 'darwin') {
let policy: DesktopPolicyState = { blocking: true, checking: false, page: 'https://downloads.example.com/desktop' }
let update: DesktopUpdateState = { phase: 'ready', version: '1.0.1-nightly.1' }
const install = vi.fn(async () => update)
ui = new DesktopMandatoryUpdateWindow({ preload: 'owned', locale: resolveDesktopLocale('zh'),
allowedPageOrigins: ['https://downloads.example.com'], parent: () => window as unknown as BrowserWindow,
const createOverlay = (): WindowFixture => window
const parent = createOverlay as () => BrowserWindow
ui = new DesktopMandatoryUpdateWindow({ overlays: { create: parent }, preload: 'owned', locale: resolveDesktopLocale('zh'),
allowedPageOrigins: ['https://downloads.example.com'], parent,
policy: () => policy, update: () => update, refresh: async () => {}, download: async () => update, install })
ui.sync()
const event = { sender: window.webContents, senderFrame: window.webContents.mainFrame }
+94 -3
View File
@@ -1,5 +1,6 @@
// @vitest-environment jsdom
import { JSDOM } from 'jsdom'
import { afterEach, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { installMandatoryUpdateOverlay } from '../src/preload-mandatory-overlay.ts'
import { syncWindowsAppearance } from '../src/preload-windows.ts'
import { DESKTOP_IPC, type DshDesktopProductApi } from '../src/ipc.ts'
@@ -15,7 +16,8 @@ vi.mock('../src/preload-theme.ts', () => ({ syncNativeTheme: vi.fn() }))
vi.mock('../src/preload-windows.ts', () => ({ syncWindowsAppearance: vi.fn() }))
vi.mock('../src/preload-mandatory-overlay.ts', () => ({ installMandatoryUpdateOverlay: vi.fn() }))
afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); vi.clearAllMocks(); vi.resetModules() })
beforeEach(() => { vi.stubGlobal('process', { ...process, isMainFrame: true }) })
afterEach(() => { document.body.replaceChildren(); 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/'))
@@ -139,7 +141,7 @@ it('moves welcome-entry focus to the document without changing keyboard tab orde
})
it.each(['win32', 'darwin'] as const)('installs the embedded mandatory UI only in the Windows app document (%s)', async (platform) => {
vi.spyOn(process, 'platform', 'get').mockReturnValue(platform)
vi.stubGlobal('process', { ...process, platform })
for (const url of ['dsh-app://app/', 'dsh-app://shell/mandatory-update.html', 'https://example.com/']) {
vi.resetModules()
vi.mocked(installMandatoryUpdateOverlay).mockClear()
@@ -148,3 +150,92 @@ it.each(['win32', 'darwin'] as const)('installs the embedded mandatory UI only i
expect(installMandatoryUpdateOverlay).toHaveBeenCalledTimes(platform === 'win32' && url === 'dsh-app://app/' ? 1 : 0)
}
})
it('exposes constrained shortcut operations and releases configuration subscriptions', 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 === 'dshDesktop')?.[1] as DshDesktopProductApi
await api.shortcuts.get([])
expect(api.shortcuts).not.toHaveProperty('reload')
await api.shortcuts.recording(true)
expect(electron.ipcRenderer.invoke).toHaveBeenCalledWith(DESKTOP_IPC.shortcutsGet, [])
expect(electron.ipcRenderer.invoke).toHaveBeenCalledWith(DESKTOP_IPC.shortcutsRecording, true)
const listener = vi.fn()
const off = api.shortcuts.subscribe(listener)
const handler = electron.ipcRenderer.on.mock.calls.find(([name]) => name === DESKTOP_IPC.shortcutsChanged)![1] as
(event: unknown, state: unknown) => void
handler({}, { status: 'ready' })
expect(listener).toHaveBeenCalledWith({ status: 'ready' })
off(); expect(electron.ipcRenderer.off).toHaveBeenCalledWith(DESKTOP_IPC.shortcutsChanged, handler)
})
it('withholds the product API from same-origin child frames', async () => {
vi.stubGlobal('location', new URL('dsh-app://app/'))
vi.stubGlobal('process', { ...process, isMainFrame: false })
await import('../src/preload-app.ts')
expect(electron.contextBridge.exposeInMainWorld).toHaveBeenCalledWith('dshDesktop', { protocolVersion: 1 })
})
it('forwards only the focused product iframe and releases native input subscriptions', 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 === 'dshDesktop')?.[1] as DshDesktopProductApi
const listener = vi.fn()
const off = api.keyboard.subscribe(listener)
const handler = electron.ipcRenderer.on.mock.calls.find(([name]) => name === DESKTOP_IPC.shortcutsInput)![1] as
(event: unknown, state: unknown) => void
const input = { kind: 'iframe', frameName: 'preview', revision: 'current' }
handler({}, input)
const frame = document.createElement('iframe')
document.body.append(frame)
frame.name = 'preview'
frame.focus()
handler({}, input)
expect(listener).not.toHaveBeenCalled()
frame.setAttribute('data-html-preview', '')
handler({}, { ...input, frameName: 'stale' })
handler({}, { ...input, frameName: '' })
expect(listener).not.toHaveBeenCalled()
handler({}, input)
expect(listener).toHaveBeenCalledExactlyOnceWith(input)
frame.remove()
handler({}, input)
expect(listener).toHaveBeenCalledTimes(1)
const menu = { kind: 'menu', commandId: 'page.close', revision: 'current' }
handler({}, menu)
expect(listener).toHaveBeenLastCalledWith(menu)
off()
expect(electron.ipcRenderer.off).toHaveBeenCalledWith(DESKTOP_IPC.shortcutsInput, handler)
})
it('forwards browser guest input only for the focused live webview lease', 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 === 'dshDesktop')?.[1] as DshDesktopProductApi
const listener = vi.fn()
const off = api.keyboard.subscribe(listener)
const handler = electron.ipcRenderer.on.mock.calls.find(([name]) => name === DESKTOP_IPC.shortcutsInput)![1] as
(event: unknown, state: unknown) => void
const input = { kind: 'webview', frameName: 'guest', revision: 'current' }
const frame = document.createElement('webview')
frame.tabIndex = 0
frame.setAttribute('name', 'guest')
document.body.append(frame)
frame.focus()
handler({}, input)
expect(listener).not.toHaveBeenCalled()
frame.setAttribute('data-sidebar-browser-frame', 'webview')
handler({}, { ...input, frameName: '' })
handler({}, { ...input, frameName: 'old' })
expect(listener).not.toHaveBeenCalled()
handler({}, input)
expect(listener).toHaveBeenCalledExactlyOnceWith(input)
const other = document.createElement('input')
document.body.append(other)
other.focus()
handler({}, input)
frame.remove()
handler({}, input)
expect(listener).toHaveBeenCalledOnce()
off()
})
+2 -1
View File
@@ -1,6 +1,7 @@
import { afterEach, expect, it, vi } from 'vitest'
import type { BrowserWindow } from 'electron'
import { DesktopUpdateDialog, UPDATE_DIALOG_IPC } from '../src/update-dialog.ts'
import { DesktopUpdateOverlays } from '../src/update-overlay.ts'
import { resolveDesktopLocale, type DesktopLocale } from '../src/locale.ts'
const fixture = await vi.hoisted(async () => {
@@ -46,7 +47,7 @@ afterEach(() => {
function setup(locale: DesktopLocale | (() => DesktopLocale) = resolveDesktopLocale('zh-CN')) {
const parent = new fixture.FakeWindow({})
dialogs = new DesktopUpdateDialog('preload-update-dialog.cjs', locale)
dialogs = new DesktopUpdateDialog('preload-update-dialog.cjs', locale, new DesktopUpdateOverlays())
const show = (signal?: AbortSignal) => dialogs!.show(parent as unknown as BrowserWindow, {
message: '下载完成', buttons: ['安装并重启'], cancelId: 1, ...(signal === undefined ? {} : { signal }),
})
+60 -5
View File
@@ -1,7 +1,7 @@
import { EventEmitter } from 'node:events'
import { afterEach, expect, it, vi } from 'vitest'
import type { BrowserWindow, BrowserWindowConstructorOptions } from 'electron'
import { createUpdateOverlay } from '../src/update-overlay.ts'
import { afterEach, expect, it, onTestFinished, vi } from 'vitest'
import type { BrowserWindow, BrowserWindowConstructorOptions, WebContents } from 'electron'
import { DesktopUpdateOverlays } 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) } }))
@@ -22,7 +22,7 @@ function visibilityFixture(visible = true) {
setMenu: vi.fn(), setBounds: vi.fn(), isDestroyed: () => window.destroyed,
})
native.create.mockReturnValue(window)
createUpdateOverlay(parent as unknown as BrowserWindow, 'owned', 'Update required', false)
new DesktopUpdateOverlays().create(parent as BrowserWindow, 'owned', 'Update required', false)
return { parent, window }
}
@@ -87,10 +87,24 @@ it('releases a macOS overlay after its parent has already been destroyed', async
for (const event of ['focus', 'move', 'resize', 'show']) expect(parent.listenerCount(event)).toBe(0)
})
type ParentFixture = EventEmitter & Pick<BrowserWindow, 'getContentBounds' | 'isDestroyed'> & {
webContents: EventEmitter & Pick<WebContents, 'insertCSS' | 'removeInsertedCSS'>
}
it('keeps the macOS mandatory overlay stationary and blocks parent keyboard input until close', () => {
const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
const parent: ParentFixture = Object.assign(new EventEmitter(), {
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,
})
const window = Object.assign(new EventEmitter(), {
webContents: { setWindowOpenHandler: vi.fn() }, show: vi.fn(), focus: vi.fn(),
setMenu: vi.fn(), setBounds: vi.fn(), isDestroyed: () => false,
})
native.create.mockReturnValue(window)
try {
const { parent, window } = visibilityFixture()
new DesktopUpdateOverlays().create(parent as BrowserWindow, 'owned', 'Update required', false)
expect(native.create).toHaveBeenLastCalledWith(expect.objectContaining({ modal: false, transparent: true, frame: false }))
const event = { preventDefault: vi.fn() }
parent.webContents.emit('before-input-event', event)
@@ -101,3 +115,44 @@ it('keeps the macOS mandatory overlay stationary and blocks parent keyboard inpu
expect(parent.webContents.listenerCount('before-input-event')).toBe(0)
} finally { platform.mockRestore() }
})
it('blocks each parent until its last owned overlay closes and invalidates each transition', () => {
const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
onTestFinished(() => { platform.mockRestore() })
const parent = (): ParentFixture => Object.assign(new EventEmitter(), {
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,
})
const firstParent = parent() as BrowserWindow
const secondParent = parent() as BrowserWindow
const overlays = new DesktopUpdateOverlays()
const isolated = new DesktopUpdateOverlays()
native.create.mockImplementation(() => {
const window = Object.assign(new EventEmitter(), {
webContents: { setWindowOpenHandler: vi.fn() }, show: vi.fn(), focus: vi.fn(),
setMenu: vi.fn(), setBounds: vi.fn(), isDestroyed: () => false,
})
onTestFinished(() => { window.emit('closed') })
return window
})
expect(overlays.input(firstParent)).toEqual({ revision: 0, blocked: false })
const first = overlays.create(firstParent, 'owned', 'Update required', false)
const second = overlays.create(firstParent, 'owned', 'Confirm installation', false)
const other = overlays.create(secondParent, 'owned', 'Update required', false)
expect(overlays.input(firstParent)).toMatchObject({ revision: 2, blocked: true })
expect(overlays.input(secondParent)).toMatchObject({ revision: 1, blocked: true })
expect(isolated.input(firstParent)).toEqual({ revision: 0, blocked: false })
first.emit('closed')
expect(overlays.input(firstParent)).toMatchObject({ revision: 3, blocked: true })
expect(firstParent.webContents.listenerCount('before-input-event')).toBe(1)
second.emit('closed')
expect(overlays.input(firstParent)).toMatchObject({ revision: 4, blocked: false })
expect(firstParent.listenerCount('move')).toBe(0)
expect(firstParent.listenerCount('resize')).toBe(0)
expect(firstParent.listenerCount('focus')).toBe(0)
expect(firstParent.webContents.listenerCount('before-input-event')).toBe(0)
expect(overlays.input(secondParent)).toMatchObject({ revision: 1, blocked: true })
other.emit('closed')
expect(overlays.input(secondParent)).toMatchObject({ revision: 2, blocked: false })
})
+1 -1
View File
@@ -57,7 +57,7 @@ vi.mock('electron', () => ({
getVersion: () => '1.0.0',
setAboutPanelOptions: vi.fn(),
getAppPath: () => '/development-app',
getPath: (name: string) => `/development-${name}`,
getPath: (name: string) => name === 'userData' ? '/desktop-user-data' : `/development-${name}`,
setAppLogsPath: vi.fn(),
getPreferredSystemLanguages: () => ['en-US'],
on: (name: string, callback: (...args: unknown[]) => void) => { state.appListeners.set(name, callback) },
+6
View File
@@ -22,6 +22,12 @@
},
{
"path": "../../packages/client/ui-sidebar-browser/tsconfig.host.json"
},
{
"path": "../../packages/client/shortcuts/tsconfig.host.json"
},
{
"path": "../../packages/util/atomic-write"
}
],
"exclude": [
+6 -2
View File
@@ -30,7 +30,7 @@ const PROMPT = `Write a file named notes.txt in the workspace containing exactly
/** Draft used to measure the composer's own text cap: enough lines to pass it. */
const CAP_PROBE = Array.from({ length: 40 }, (_, index) => `line ${index}`).join('\n')
describe('web e2e: approval takeover keeps its actions reachable', () => {
describe.each(MODE === 'record' ? ['button'] as const : ['button', 'keyboard'] as const)('web e2e: approval takeover through %s', (method) => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
@@ -119,7 +119,11 @@ describe('web e2e: approval takeover keeps its actions reachable', () => {
await page.setViewportSize(original)
}
await panel.getByRole('button', { name: 'Allow once' }).click()
if (method === 'button') await panel.getByRole('button', { name: 'Allow once' }).click()
else {
await scroll.focus()
await page.keyboard.press('Enter')
}
const sessionId = await settled
if (MODE === 'record') {
+6 -6
View File
@@ -49,9 +49,6 @@ it('places dynamic Session menu rows by order among the shipped ones and removes
await trigger.click()
const menu = page.getByRole('menu')
await menu.waitFor()
expect(await menu.getByRole('menuitem').allTextContents()).toEqual([
'Pin session', 'Rename', 'Fork session', 'Archive session', 'Export session', 'Copy session ID',
])
expect(await menu.getByRole('separator').count()).toBe(1)
await compareOrRefreshGolden(
SESSION_ACTION_EXPECTED,
@@ -74,9 +71,12 @@ it('places dynamic Session menu rows by order among the shipped ones and removes
await row.hover()
await trigger.click()
await menu.waitFor()
expect(await menu.getByRole('menuitem').allTextContents()).toEqual([
'Pin session', 'Rename', 'Fork session', 'Archive session',
])
const remainingNames = ['Pin session', 'Rename', 'Fork session', 'Archive session']
const remainingItems = menu.getByRole('menuitem')
expect(await remainingItems.count()).toBe(remainingNames.length)
for (const [index, name] of remainingNames.entries()) {
expect(await remainingItems.nth(index).and(menu.getByRole('menuitem', { name, exact: true })).count()).toBe(1)
}
expect(await menu.getByRole('separator').count()).toBe(0)
expect(console.pageErrors).toEqual([])
await assertFixtureInventory(EXPECTED, [
+48
View File
@@ -0,0 +1,48 @@
/** The shipped composer preserves drafts when an application Enter chord has extra modifiers. */
import { chromium, type Page } from 'playwright'
import { expect, it } from 'vitest'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import { launchWebScaffold, watchConsole } from './scaffold.ts'
import { connectFreshWorkspace, newEnglishPage, saveFailureShot, writeComposerDraft } from './support.ts'
it('preserves the composer draft and menu for application Enter combinations', async () => {
const scaffold = await launchWebScaffold({})
const events: SessionEvent[] = []
const off = scaffold.ctx.on('session/event', (_session, event) => { events.push(event) })
try {
const browser = await chromium.launch()
let failurePage: Page | undefined
try {
const page = await newEnglishPage(browser)
failurePage = page
const tripwire = watchConsole(page)
await page.goto(scaffold.authenticatedUrl)
await connectFreshWorkspace(page, scaffold.workspaceCwd, 'composer-shortcuts')
const input = page.locator('[data-composer-input][contenteditable="true"]').first()
for (const draft of ['unsent draft', '/']) {
await writeComposerDraft(page, input, draft)
const markup = await input.innerHTML()
for (const chord of ['Alt+Enter', 'Meta+Alt+Enter', 'Control+Alt+Enter', 'Control+Meta+Enter', 'Meta+Shift+Enter', 'Control+Shift+Enter']) {
await input.press(chord)
expect(await input.innerHTML(), `${draft}: ${chord}`).toBe(markup)
}
await input.press('Escape')
}
await writeComposerDraft(page, input, 'first line')
await input.press('Shift+Enter')
await page.keyboard.type('second line')
expect(await input.innerText()).toBe('first line\nsecond line')
expect(events.filter(event => event.type === 'user/message')).toHaveLength(0)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
} catch (error) {
if (failurePage !== undefined) await saveFailureShot(failurePage, 'web-e2e-composer-shortcuts')
throw error
} finally {
await browser.close()
}
} finally {
off()
await scaffold.close()
}
})
@@ -186,15 +186,23 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
const blankViewport = page.viewportSize()!
try {
await blankColumn.locator('[data-sidebar-right-toggle]').click()
await blankColumn.locator('[data-dockkit-pane]').first().waitFor({ state: 'hidden' })
await page.setViewportSize({ width: 767, height: blankViewport.height })
await expect.poll(() => columns(page)).toEqual([56, 711, 0])
await page.locator('[data-sidebar-right-expand]').click()
// The fullscreen shell keeps its viewport box while its docked content closes.
await expect.poll(() => blankColumn.locator('[data-sidebar-right-open]').count()).toBe(1)
await expect.poll(() => blankColumn.locator('[data-dockkit-host="dock"]:not([hidden])').evaluate(element => getComputedStyle(element).transform))
.toBe('none')
await expect.poll(() => blankColumn.locator('[data-sidebar-right-panel]').boundingBox())
.toEqual({ x: 0, y: 0, width: 767, height: blankViewport.height })
await blankColumn.getByText('Workspace preview is available.', { exact: true }).waitFor()
await blankColumn.locator('[data-sidebar-right-toggle]').click()
await blankColumn.locator('[data-dockkit-pane]').first().waitFor({ state: 'hidden' })
} finally {
await page.setViewportSize(blankViewport)
}
await expect.poll(() => columns(page)).toEqual([280, blankViewport.width - 280, 0])
await page.locator('[data-sidebar-right-expand]').click()
await blankColumn.locator('[data-dockkit-add-tab]').click()
await blankColumn.locator('[data-sidebar-right-guide-entry="terminal"]')
@@ -24,6 +24,9 @@
- button "详细"
- text: 开发者工具 显示用于调试和排查问题的工具与信息
- switch "开发者工具" [checked]
- text: 快捷键
- paragraph: 查看当前可用的快捷键和输入操作
- button "查看快捷键"
- text: 繁忙时的发送行为 智能体运行时 Enter 键和发送按钮的行为;Cmd/Ctrl+Enter 使用另一行为
- button "排队发送"
- text: 当前版本:{{version}}
@@ -24,6 +24,9 @@
- button "Detailed"
- text: Developer tools Show tools and information for debugging and troubleshooting
- switch "Developer tools" [checked]
- text: Keyboard shortcuts
- paragraph: Browse available shortcuts and input actions
- button "View shortcuts"
- text: Send behavior while busy What Enter and the Send button do while the agent is running; Cmd/Ctrl+Enter uses the other behavior
- button "Queue"
- text: "Current version: {{version}}"
@@ -24,6 +24,9 @@
- button "Detailed"
- text: Developer tools Show tools and information for debugging and troubleshooting
- switch "Developer tools"
- text: Keyboard shortcuts
- paragraph: Browse available shortcuts and input actions
- button "View shortcuts"
- text: Send behavior while busy What Enter and the Send button do while the agent is running; Cmd/Ctrl+Enter uses the other behavior
- button "Queue"
- text: "Current version: {{version}}"
@@ -24,6 +24,9 @@
- button "Detailed"
- text: Developer tools Show tools and information for debugging and troubleshooting
- switch "Developer tools"
- text: Keyboard shortcuts
- paragraph: Browse available shortcuts and input actions
- button "View shortcuts"
- text: Send behavior while busy What Enter and the Send button do while the agent is running; Cmd/Ctrl+Enter uses the other behavior
- button "Queue"
- text: "Current version: {{version}}"
@@ -24,6 +24,9 @@
- button "详细"
- text: 开发者工具 显示用于调试和排查问题的工具与信息
- switch "开发者工具"
- text: 快捷键
- paragraph: 查看当前可用的快捷键和输入操作
- button "查看快捷键"
- text: 繁忙时的发送行为 智能体运行时 Enter 键和发送按钮的行为;Cmd/Ctrl+Enter 使用另一行为
- button "排队发送"
- text: 当前版本:{{version}}
@@ -0,0 +1,5 @@
- listitem:
- text: New Session
- button "Remove shortcut for New Session"
- button "Edit shortcut for New Session"
- button "New Session A B": A B
@@ -0,0 +1,5 @@
- group "New Session":
- button "Restore default"
- button "Remove"
- button "Press a shortcut"
- text: Already used by “Open reference menu”
@@ -0,0 +1,5 @@
- listitem:
- text: New Session
- button "Remove shortcut for New Session"
- button "Edit shortcut for New Session"
- button "New Session ⌘ C": ⌘ C
@@ -0,0 +1,28 @@
# Modal layers
{
"settingsInitialFocus": "General",
"settingsAfterEnter": {
"focused": "Settings",
"foreground": "Settings"
},
"dialogsAfterSettingsToggle": 0,
"dialogsAfterReferenceToggle": 0,
"referenceBlocksSettings": {
"focused": "Keyboard shortcuts",
"foreground": "Keyboard shortcuts"
},
"referenceOnTop": {
"focused": "Keyboard shortcuts",
"foreground": "Keyboard shortcuts"
},
"settingsAfterReferenceToggle": {
"focused": "Settings",
"foreground": "Settings"
},
"settingsAfterClose": {
"focused": "Settings",
"foreground": "Settings"
},
"closedWindows": 0
}
@@ -0,0 +1,4 @@
- group "New Session":
- button "Restore default"
- button "Remove"
- button "Press a shortcut"
@@ -0,0 +1,5 @@
- listitem:
- text: New Session
- button "Remove shortcut for New Session"
- button "Edit shortcut for New Session"
- button "New Session Ctrl + C": Ctrl + C
@@ -0,0 +1,18 @@
- tablist:
- tab "Browser Close":
- text: Browser
- button "Close"
- tab "Browser Close" [selected]:
- text: Browser
- button "Close"
- button "New tab"
- button "Split"
- button "Fullscreen"
- button "Collapse right sidebar"
- button "Back" [disabled]
- button "Forward" [disabled]
- button "Reload" [disabled]
- textbox "Enter an HTTP(S) address"
- button "Open in system browser" [disabled]
- button "Disable sandbox restrictions"
- text: Enter an HTTP(S) address to start browsing
@@ -0,0 +1,9 @@
- tablist:
- tab "Start" [selected]
- button "Split"
- button "Exit fullscreen"
- button "Collapse right sidebar"
- button "Workspace files Browse files in this session's workspace"
- button "New terminal Run commands in the Session workspace"
- button "Choose shell"
- button "Browser Browse web pages"
@@ -0,0 +1,23 @@
- tablist:
- tab "Files Close":
- text: Files
- button "Close"
- tab "bash Close" [selected]:
- text: bash
- button "Close"
- button "New tab"
- button "Split" [disabled]
- button "Fullscreen"
- button "Collapse right sidebar"
- textbox "Terminal"
- tablist:
- tab "Files Close" [selected]:
- text: Files
- button "Close"
- button "New tab"
- button "Split" [disabled]
- text: {{cwd}}/workspace
- button "Reload"
- list:
- listitem:
- button "t6-refresh.txt"
@@ -0,0 +1,25 @@
- tablist:
- tab "Files Close":
- text: Files
- button "Close"
- tab "bash Close":
- text: bash
- button "Close"
- tab "bash Close" [selected]:
- text: bash
- button "Close"
- button "New tab"
- button "Split" [disabled]
- button "Exit fullscreen"
- button "Collapse right sidebar"
- textbox "Terminal"
- tablist:
- tab "Files Close" [selected]:
- text: Files
- button "Close"
- button "New tab"
- button "Split" [disabled]
- text: {{cwd}}/workspace
- button "Reload"
- list:
- listitem: Empty directory
@@ -0,0 +1,2 @@
- menu:
- menuitem "Close"
@@ -0,0 +1 @@
- alert: This session has no completed turn
@@ -0,0 +1,9 @@
- text: Workspaces
- button "Search sessions"
- textbox "Search session names"
- button "View options"
- button "Add workspace"
- tree "Sessions":
- treeitem "Ungrouped" [expanded]
- treeitem "T4 source (1) now"
- treeitem "T4 source 1min" [selected]
@@ -0,0 +1,132 @@
- dialog "Keyboard shortcuts":
- banner:
- heading "Keyboard shortcuts" [level=2]
- button "Close keyboard shortcuts"
- search "Search shortcuts":
- searchbox "Search shortcuts"
- region "Application":
- list:
- listitem:
- text: Open keyboard shortcuts
- button "Remove shortcut for Open keyboard shortcuts"
- button "Edit shortcut for Open keyboard shortcuts"
- button "Open keyboard shortcuts ⌘ /": ⌘ /
- listitem:
- text: New Session
- button "Remove shortcut for New Session"
- button "Edit shortcut for New Session"
- button "New Session ⌥ ⌘ N": ⌥ ⌘ N
- listitem:
- text: Toggle left sidebar
- button "Remove shortcut for Toggle left sidebar"
- button "Edit shortcut for Toggle left sidebar"
- button "Toggle left sidebar ⌥ ⌘ B": ⌥ ⌘ B
- listitem:
- text: Search sessions
- button "Remove shortcut for Search sessions"
- button "Edit shortcut for Search sessions"
- button "Search sessions ⌥ ⌘ K": ⌥ ⌘ K
- listitem:
- text: Add workspace
- button "Remove shortcut for Add workspace"
- button "Edit shortcut for Add workspace"
- button "Add workspace ⌥ ⌘ O": ⌥ ⌘ O
- listitem:
- text: Rename session
- button "Remove shortcut for Rename session"
- button "Edit shortcut for Rename session"
- button "Rename session ⇧ ⌘ R": ⇧ ⌘ R
- listitem:
- text: Fork session
- button "Remove shortcut for Fork session"
- button "Edit shortcut for Fork session"
- button "Fork session ⇧ ⌘ F": ⇧ ⌘ F
- listitem:
- text: Archive session
- button "Remove shortcut for Archive session"
- button "Edit shortcut for Archive session"
- button "Archive session ⌥ ⌘ A": ⌥ ⌘ A
- listitem:
- text: Open settings
- button "Remove shortcut for Open settings"
- button "Edit shortcut for Open settings"
- button "Open settings ⌘ ,": ⌘ ,
- listitem:
- text: Toggle right sidebar
- button "Remove shortcut for Toggle right sidebar"
- button "Edit shortcut for Toggle right sidebar"
- button "Toggle right sidebar ⇧ ⌘ B": ⇧ ⌘ B
- listitem:
- text: Workspace files
- button "Remove shortcut for Workspace files"
- button "Edit shortcut for Workspace files"
- button "Workspace files ⌥ ⌘ P": ⌥ ⌘ P
- listitem:
- text: New terminal
- button "Remove shortcut for New terminal"
- button "Edit shortcut for New terminal"
- 'button "New terminal ⌃ `"': "⌃ `"
- listitem:
- text: Split
- button "Remove shortcut for Split"
- button "Edit shortcut for Split"
- button "Split ⌘ \\": ⌘ \
- listitem:
- text: Toggle panel fullscreen
- button "Remove shortcut for Toggle panel fullscreen"
- button "Edit shortcut for Toggle panel fullscreen"
- button "Toggle panel fullscreen ⌥ ⌘ Enter": ⌥ ⌘ Enter
- listitem:
- text: Refresh current page
- button "Edit shortcut for Refresh current page"
- button "Refresh current page No shortcut": No shortcut
- listitem:
- text: Close current page or window
- button "Remove shortcut for Close current page or window"
- button "Edit shortcut for Close current page or window"
- button "Close current page or window ⌥ ⌘ W": ⌥ ⌘ W
- listitem:
- text: Stop generating
- button "Stop generating Esc Esc" [disabled]: Esc Esc
- region "Message input":
- heading "Message input" [level=3]
- list:
- listitem:
- text: Use the complementary Queue / Steer action
- button "Use the complementary Queue / Steer action ⌘ Enter" [disabled]: ⌘ Enter
- listitem:
- text: Open reference menu
- button "Open reference menu @" [disabled]: "@"
- listitem:
- text: New line
- button "New line ⇧ Enter" [disabled]: ⇧ Enter
- listitem:
- text: Send message
- button "Send message Enter" [disabled]: Enter
- listitem:
- text: Open command menu
- button "Open command menu /" [disabled]: /
- region "Menus and dialogs":
- heading "Menus and dialogs" [level=3]
- list:
- listitem:
- text: Close menu or top dialog
- button "Close menu or top dialog Esc" [disabled]: Esc
- listitem:
- text: Move menu selection
- button "Move menu selection ↑ ↓" [disabled]: ↑ ↓
- listitem:
- text: Select menu item
- button "Select menu item Enter" [disabled]: Enter
- region "Approval area":
- heading "Approval area" [level=3]
- list:
- listitem:
- text: Allow once
- button "Allow once Enter" [disabled]: Enter
- listitem:
- text: Reject
- button "Reject Esc" [disabled]: Esc
- contentinfo:
- button "Restore all defaults" [disabled]
- text: 0 customized
@@ -0,0 +1,5 @@
- menu:
- menuitem "Pin session"
- menuitem "Rename"
- menuitem "Fork session"
- menuitem "Archive session"
@@ -0,0 +1,5 @@
- group "Toggle left sidebar":
- button "Restore default" [disabled]
- button "Press a shortcut"
- text: Shortcut configuration or available commands changed. Review the latest bindings before saving. Shortcut configuration or available commands changed. Review the latest bindings before saving.
- button "I have reviewed the latest configuration"
@@ -0,0 +1 @@
- alert: Already used by “Open keyboard shortcuts”
@@ -0,0 +1,10 @@
- dialog "Keyboard shortcuts":
- banner:
- heading "Keyboard shortcuts" [level=2]
- button "Close keyboard shortcuts"
- search "Search shortcuts":
- searchbox "Search shortcuts": abc
- status: No matching shortcuts
- contentinfo:
- button "Restore all defaults"
- text: 1 customized
@@ -0,0 +1,5 @@
- group "Toggle left sidebar":
- button "Restore default"
- button "Remove"
- button "Press a shortcut"
- text: Use Ctrl+/, Ctrl+,, Ctrl+Alt+key, or Ctrl+Shift+key. Combinations with three or four distinct modifiers are also supported. Browser or system shortcuts may not reach the page.
@@ -0,0 +1,6 @@
- dialog "Restore all default shortcuts?":
- heading "Restore all default shortcuts?" [level=2]
- button "Close confirmation"
- paragraph: Restore the default shortcuts for this platform. All modified or removed shortcuts will be restored. Other platforms are unaffected.
- button "Cancel"
- button "Restore default"
@@ -0,0 +1 @@
- alert: Modified
@@ -0,0 +1,133 @@
- dialog "Keyboard shortcuts":
- banner:
- heading "Keyboard shortcuts" [level=2]
- button "Close keyboard shortcuts"
- search "Search shortcuts":
- searchbox "Search shortcuts"
- region "Application":
- list:
- listitem:
- text: Open keyboard shortcuts
- button "Remove shortcut for Open keyboard shortcuts"
- button "Edit shortcut for Open keyboard shortcuts"
- button "Open keyboard shortcuts Ctrl + /": Ctrl + /
- listitem:
- text: New Session
- button "Remove shortcut for New Session"
- button "Edit shortcut for New Session"
- button "New Session Ctrl + Alt + N": Ctrl + Alt + N
- listitem:
- text: Toggle left sidebar
- button "Remove shortcut for Toggle left sidebar"
- button "Edit shortcut for Toggle left sidebar"
- button "Toggle left sidebar Ctrl + Alt + B": Ctrl + Alt + B
- listitem:
- text: Search sessions
- button "Remove shortcut for Search sessions"
- button "Edit shortcut for Search sessions"
- button "Search sessions Ctrl + Alt + K": Ctrl + Alt + K
- listitem:
- text: Add workspace
- button "Remove shortcut for Add workspace"
- button "Edit shortcut for Add workspace"
- button "Add workspace Ctrl + Alt + O": Ctrl + Alt + O
- listitem:
- text: Rename session
- button "Remove shortcut for Rename session"
- button "Edit shortcut for Rename session"
- button "Rename session Ctrl + Shift + R": Ctrl + Shift + R
- listitem:
- text: Fork session
- button "Remove shortcut for Fork session"
- button "Edit shortcut for Fork session"
- button "Fork session Ctrl + Shift + F": Ctrl + Shift + F
- listitem:
- text: Archive session
- button "Remove shortcut for Archive session"
- button "Edit shortcut for Archive session"
- button "Archive session Ctrl + Alt + A": Ctrl + Alt + A
- listitem:
- text: Open settings
- button "Remove shortcut for Open settings"
- button "Edit shortcut for Open settings"
- button "Open settings Ctrl + ,": Ctrl + ,
- listitem:
- text: Toggle right sidebar
- button "Remove shortcut for Toggle right sidebar"
- button "Edit shortcut for Toggle right sidebar"
- button "Toggle right sidebar Ctrl + Shift + B": Ctrl + Shift + B
- listitem:
- text: Workspace files
- button "Remove shortcut for Workspace files"
- button "Edit shortcut for Workspace files"
- button "Workspace files Ctrl + Alt + P": Ctrl + Alt + P
- listitem:
- text: New terminal
- button "Remove shortcut for New terminal"
- button "Edit shortcut for New terminal"
- 'button "New terminal Ctrl + `"': "Ctrl + `"
- listitem:
- text: Split
- button "Remove shortcut for Split"
- button "Edit shortcut for Split"
- button "Split Ctrl + \\": Ctrl + \
- listitem:
- text: Toggle panel fullscreen
- button "Remove shortcut for Toggle panel fullscreen"
- button "Edit shortcut for Toggle panel fullscreen"
- button "Toggle panel fullscreen Ctrl + Alt + Enter": Ctrl + Alt + Enter
- listitem:
- text: Refresh current page
- button "Remove shortcut for Refresh current page"
- button "Edit shortcut for Refresh current page"
- button "Refresh current page Ctrl + Alt + R": Ctrl + Alt + R
- listitem:
- text: Close current page or window
- button "Remove shortcut for Close current page or window"
- button "Edit shortcut for Close current page or window"
- button "Close current page or window Ctrl + Alt + W": Ctrl + Alt + W
- listitem:
- text: Stop generating
- button "Stop generating Esc Esc" [disabled]: Esc Esc
- region "Message input":
- heading "Message input" [level=3]
- list:
- listitem:
- text: Use the complementary Queue / Steer action
- button "Use the complementary Queue / Steer action Ctrl + Enter" [disabled]: Ctrl + Enter
- listitem:
- text: Open reference menu
- button "Open reference menu @" [disabled]: "@"
- listitem:
- text: New line
- button "New line Shift + Enter" [disabled]: Shift + Enter
- listitem:
- text: Send message
- button "Send message Enter" [disabled]: Enter
- listitem:
- text: Open command menu
- button "Open command menu /" [disabled]: /
- region "Menus and dialogs":
- heading "Menus and dialogs" [level=3]
- list:
- listitem:
- text: Close menu or top dialog
- button "Close menu or top dialog Esc" [disabled]: Esc
- listitem:
- text: Move menu selection
- button "Move menu selection ↑ ↓" [disabled]: ↑ ↓
- listitem:
- text: Select menu item
- button "Select menu item Enter" [disabled]: Enter
- region "Approval area":
- heading "Approval area" [level=3]
- list:
- listitem:
- text: Allow once
- button "Allow once Enter" [disabled]: Enter
- listitem:
- text: Reject
- button "Reject Esc" [disabled]: Esc
- contentinfo:
- button "Restore all defaults" [disabled]
- text: 0 customized
@@ -0,0 +1 @@
- alert: 已被「快捷键速查」占用
@@ -0,0 +1,10 @@
- dialog "快捷键":
- banner:
- heading "快捷键" [level=2]
- button "关闭快捷键"
- search "搜索快捷键":
- searchbox "搜索快捷键": abc
- status: 没有匹配的快捷键
- contentinfo:
- button "恢复全部默认"
- text: 1 项已自定义
@@ -0,0 +1,5 @@
- group "展开/收起左侧栏":
- button "恢复默认"
- button "移除"
- button "按下快捷键"
- text: 可设置 Command+/、Command+,、Command+反斜杠、Control+反引号、Command+Option+按键或 Command+Shift+按键。也支持三个或四个不同修饰键的组合。浏览器或系统占用的组合可能无法送达页面。
@@ -0,0 +1,6 @@
- dialog "恢复全部默认快捷键?":
- heading "恢复全部默认快捷键?" [level=2]
- button "关闭确认"
- paragraph: 将恢复当前平台的默认快捷键。你修改或移除的快捷键都会恢复,其他平台不受影响。
- button "取消"
- button "恢复默认"
@@ -0,0 +1 @@
- alert: 已修改
@@ -0,0 +1,132 @@
- dialog "快捷键":
- banner:
- heading "快捷键" [level=2]
- button "关闭快捷键"
- search "搜索快捷键":
- searchbox "搜索快捷键"
- region "应用操作":
- list:
- listitem:
- text: 快捷键速查
- button "移除快捷键速查快捷键"
- button "修改快捷键速查快捷键"
- button "快捷键速查 ⌘ /": ⌘ /
- listitem:
- text: 新会话
- button "移除新会话快捷键"
- button "修改新会话快捷键"
- button "新会话 ⌥ ⌘ N": ⌥ ⌘ N
- listitem:
- text: 展开/收起左侧栏
- button "移除展开/收起左侧栏快捷键"
- button "修改展开/收起左侧栏快捷键"
- button "展开/收起左侧栏 ⌥ ⌘ B": ⌥ ⌘ B
- listitem:
- text: 搜索会话
- button "移除搜索会话快捷键"
- button "修改搜索会话快捷键"
- button "搜索会话 ⌥ ⌘ K": ⌥ ⌘ K
- listitem:
- text: 添加工作区
- button "移除添加工作区快捷键"
- button "修改添加工作区快捷键"
- button "添加工作区 ⌥ ⌘ O": ⌥ ⌘ O
- listitem:
- text: 重命名会话
- button "移除重命名会话快捷键"
- button "修改重命名会话快捷键"
- button "重命名会话 ⇧ ⌘ R": ⇧ ⌘ R
- listitem:
- text: 分叉会话
- button "移除分叉会话快捷键"
- button "修改分叉会话快捷键"
- button "分叉会话 ⇧ ⌘ F": ⇧ ⌘ F
- listitem:
- text: 归档会话
- button "移除归档会话快捷键"
- button "修改归档会话快捷键"
- button "归档会话 ⌥ ⌘ A": ⌥ ⌘ A
- listitem:
- text: 打开设置
- button "移除打开设置快捷键"
- button "修改打开设置快捷键"
- button "打开设置 ⌘ ,": ⌘ ,
- listitem:
- text: 展开/收起右侧栏
- button "移除展开/收起右侧栏快捷键"
- button "修改展开/收起右侧栏快捷键"
- button "展开/收起右侧栏 ⇧ ⌘ B": ⇧ ⌘ B
- listitem:
- text: 工作区文件
- button "移除工作区文件快捷键"
- button "修改工作区文件快捷键"
- button "工作区文件 ⌥ ⌘ P": ⌥ ⌘ P
- listitem:
- text: 新建终端
- button "移除新建终端快捷键"
- button "修改新建终端快捷键"
- 'button "新建终端 ⌃ `"': "⌃ `"
- listitem:
- text: 分栏
- button "移除分栏快捷键"
- button "修改分栏快捷键"
- button "分栏 ⌘ \\": ⌘ \
- listitem:
- text: 面板全屏/退出全屏
- button "移除面板全屏/退出全屏快捷键"
- button "修改面板全屏/退出全屏快捷键"
- button "面板全屏/退出全屏 ⌥ ⌘ Enter": ⌥ ⌘ Enter
- listitem:
- text: 刷新当前页面
- button "修改刷新当前页面快捷键"
- button "刷新当前页面 暂无快捷键": 暂无快捷键
- listitem:
- text: 关闭当前页面/窗口
- button "移除关闭当前页面/窗口快捷键"
- button "修改关闭当前页面/窗口快捷键"
- button "关闭当前页面/窗口 ⌥ ⌘ W": ⌥ ⌘ W
- listitem:
- text: 停止生成
- button "停止生成 Esc Esc" [disabled]: Esc Esc
- region "消息输入":
- heading "消息输入" [level=3]
- list:
- listitem:
- text: 使用互补的 Queue/Steer 方式
- button "使用互补的 Queue/Steer 方式 ⌘ Enter" [disabled]: ⌘ Enter
- listitem:
- text: 打开引用菜单
- button "打开引用菜单 @" [disabled]: "@"
- listitem:
- text: 换行
- button "换行 ⇧ Enter" [disabled]: ⇧ Enter
- listitem:
- text: 发送消息
- button "发送消息 Enter" [disabled]: Enter
- listitem:
- text: 打开命令菜单
- button "打开命令菜单 /" [disabled]: /
- region "菜单与弹层":
- heading "菜单与弹层" [level=3]
- list:
- listitem:
- text: 关闭菜单或顶层弹窗
- button "关闭菜单或顶层弹窗 Esc" [disabled]: Esc
- listitem:
- text: 移动菜单选择
- button "移动菜单选择 ↑ ↓" [disabled]: ↑ ↓
- listitem:
- text: 选择菜单项
- button "选择菜单项 Enter" [disabled]: Enter
- region "审批区域":
- heading "审批区域" [level=3]
- list:
- listitem:
- text: 允许一次
- button "允许一次 Enter" [disabled]: Enter
- listitem:
- text: 拒绝
- button "拒绝 Esc" [disabled]: Esc
- contentinfo:
- button "恢复全部默认" [disabled]
- text: 0 项已自定义
+1 -1
View File
@@ -187,7 +187,7 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
const stopButton = page.getByRole('button', { name: 'Stop generating' })
await stopButton.hover()
await page.getByRole('tooltip', { name: 'Stop generating', exact: true }).waitFor()
await page.getByRole('tooltip', { name: 'Stop generating Esc Esc', exact: true }).waitFor()
await stopButton.click()
await settled
expect(turnEndReasons(sessionEvents).at(-1)).toBe('aborted')
+1
View File
@@ -271,6 +271,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con
expect(geometry.descriptionFontSize).toBe('10px')
expect(geometry.openFontSize).toBe('11px')
await page.setViewportSize({ width: 480, height: 900 })
await page.locator('[data-sidebar-collapsed="true"]').waitFor({ state: 'attached' })
const row = page.locator('[data-presented-files-row]')
await scrollIntoView(row)
for (const card of await row.getByRole('button').all()) {
+8 -3
View File
@@ -80,7 +80,7 @@ describe('web e2e: queue row actions', () => {
await expect.poll(() => row.getByRole('button', { name: 'Remove queued message' }).isEnabled()).toBe(true)
}
it.skipIf(MODE === 'record')('edits and removes exact occurrences and preserves Queue across stop', async () => {
it.skipIf(MODE === 'record').each(['button', 'keyboard'] as const)('edits and removes exact occurrences and preserves Queue across %s stop', async (method) => {
overrideDir = await mkdtemp(join(tmpdir(), 'dsh-web-queue-actions-'))
const readyFile = join(overrideDir, '.hang-ready')
const overridePath = join(overrideDir, 'replay.override.json')
@@ -290,8 +290,13 @@ describe('web e2e: queue row actions', () => {
const stopButton = page.getByRole('button', { name: 'Stop generating' })
await stopButton.hover()
await page.getByRole('tooltip', { name: 'Stop generating', exact: true }).waitFor()
await stopButton.click()
await page.getByRole('tooltip', { name: 'Stop generating Esc Esc', exact: true }).waitFor()
if (method === 'button') await stopButton.click()
else {
await input.focus()
await page.keyboard.press('Escape')
await page.keyboard.press('Escape')
}
await firstSettled
await expect.poll(() => page.getByRole('button', { name: 'Stop generating' }).count())
.toBe(0)
+9 -2
View File
@@ -3,6 +3,7 @@ import { chromium, type Browser } from 'playwright'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
import { initialShortcutConfig } from '@deepseek-ai/dsh-client-shortcuts/protocol'
let scaffold: WebScaffold
let browser: Browser
@@ -22,11 +23,17 @@ describe('navigation without a selected Session', () => {
const page = await newEnglishPage(browser)
try {
if (platform !== 'web') {
await page.addInitScript((value) => {
await page.addInitScript(({ value, snapshot }) => {
Object.assign(window, { dshDesktop: { protocolVersion: 1,
keyboard: { subscribe: () => () => {}, closeWindow: async () => {} },
shortcuts: { get: async () => ({ ...snapshot, status: 'ready' }),
subscribe: () => () => {}, recording: async () => {},
edit: async () => ({ status: 'not-ready', snapshot }) },
} })
const mark = () => { document.documentElement.setAttribute('data-platform', value) }
if (document.documentElement === null) document.addEventListener('DOMContentLoaded', mark, { once: true })
else mark()
}, platform)
}, { value: platform, snapshot: initialShortcutConfig() })
}
const tripwire = watchConsole(page)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
+372
View File
@@ -0,0 +1,372 @@
/** Real application composition and Desktop persistence with a substituted Electron preload transport. */
import { mkdir, mkdtemp, readFile, rename, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { chromium } from 'playwright'
import { expect, it } from 'vitest'
import { SessionId } from '@deepseek-ai/dsh-session'
import { initialShortcutConfig, parseShortcutDefinitions, parseShortcutEdit } from '@deepseek-ai/dsh-client-shortcuts/protocol'
import type { DesktopShortcutInput, DesktopShortcutsApi, ShortcutCommandId, ShortcutRevision } from '@deepseek-ai/dsh-client-shortcuts/protocol'
import { desktopKeybindings } from '../../desktop/src/keybindings.ts'
import { compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole, webSnapshotMode } from './scaffold.ts'
import { writeComposerDraft } from './support.ts'
declare global {
interface Window {
desktopShortcutsGet: DesktopShortcutsApi['get']
desktopShortcutsEdit: DesktopShortcutsApi['edit']
shortcutFixture: { deliver(input: DesktopShortcutInput): void; recording: boolean; closedWindows: number }
}
}
const expected = fileURLToPath(new URL('./expected/shortcuts-desktop', import.meta.url))
const seed = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url))
const mode = webSnapshotMode()
it.each([
{ platform: 'macos', marker: 'darwin', primary: 'Meta' },
{ platform: 'windows', marker: 'win32', primary: 'Control' },
] as const)('records, persists and prioritizes $platform Desktop single keys and chords over recorded history', async ({ platform, marker, primary }) => {
const userData = await mkdtemp(join(tmpdir(), 'dsh-shortcuts-desktop-'))
let snapshot = initialShortcutConfig()
const persistence = desktopKeybindings(userData, platform, (value) => { snapshot = value })
try {
const scaffold = await launchWebScaffold({ developerTools: false })
try {
await seedSession(scaffold, await readFile(seed, 'utf8'), SessionId('desktop-shortcuts-source'))
const browser = await chromium.launch()
try {
const page = await browser.newPage({ locale: 'en-US', viewport: { width: 1440, height: 1000 } })
await page.exposeFunction('desktopShortcutsGet', async (definitions: unknown) => {
persistence.setDefinitions(parseShortcutDefinitions(definitions))
return persistence.readCurrent()
})
await page.exposeFunction('desktopShortcutsEdit', (edit: unknown, revision: ShortcutRevision) => persistence.edit(parseShortcutEdit(edit), revision))
// Only the Electron transport is substituted; preference storage and all Client plugins are real.
await page.addInitScript((device) => {
const mark = () => { document.documentElement.dataset.platform = device }
if (document.documentElement === null) window.addEventListener('DOMContentLoaded', mark)
else mark()
const listeners = new Set<(input: DesktopShortcutInput) => void>()
const fixture = { recording: false, closedWindows: 0,
deliver(input: DesktopShortcutInput) { for (const listener of listeners) listener(input) },
}
Object.assign(window, { shortcutFixture: fixture, dshDesktop: { protocolVersion: 1,
shortcuts: {
get: window.desktopShortcutsGet, edit: window.desktopShortcutsEdit,
recording: async (active: boolean) => { fixture.recording = active }, subscribe: () => () => {},
},
keyboard: { subscribe: (listener: (input: DesktopShortcutInput) => void) => {
listeners.add(listener); return () => { listeners.delete(listener) }
}, closeWindow: async () => { fixture.closedWindows++ } },
} })
}, marker)
const console = watchConsole(page)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
const deliverPrimary = (code: string, alt = false) => page.evaluate((input) => {
window.shortcutFixture.deliver(input)
}, {
kind: 'keyboard', frameName: '', revision: snapshot.revision, code,
control: platform === 'windows', meta: platform === 'macos', alt, shift: false, repeat: false,
} satisfies DesktopShortcutInput)
const openReference = () => deliverPrimary('Slash')
const group = page.getByRole('treeitem').first()
await group.waitFor()
if (await group.getAttribute('aria-expanded') !== 'true') await group.click()
await page.getByRole('treeitem').nth(1).click()
await page.getByText('DONE', { exact: true }).waitFor()
const composer = page.locator('[data-composer-input]').first()
await composer.focus()
await page.keyboard.insertText('Desktop focus draft')
const panel = page.locator('[data-sidebar-right-panel][data-sidebar-right-open]')
const files = panel.locator('[data-dockkit-tab]').filter({ hasText: 'Files' })
for (const opening of [{ code: 'KeyP', alt: false }, { code: 'KeyB', alt: true }]) {
await composer.focus()
await deliverPrimary(opening.code, opening.alt)
await panel.waitFor()
const focusedAfterOpening = await panel.locator('[data-dockkit-pane]').evaluate(pane => document.activeElement === pane)
expect(await panel.locator('[data-dockkit-pane]').evaluate(pane => getComputedStyle(pane).outlineStyle)).toBe('none')
await deliverPrimary('KeyW')
await expect.poll(() => page.evaluate(() => ({
closedWindows: window.shortcutFixture.closedWindows,
openPanels: document.querySelectorAll('[data-sidebar-right-open]').length,
}))).toEqual({ closedWindows: 0, openPanels: 0 })
expect(focusedAfterOpening).toBe(true)
expect(await composer.innerText()).toBe('Desktop focus draft')
}
await composer.focus()
const selectDraft = () => composer.evaluate((element) => {
const text = element.querySelector('[data-lexical-text]')!.firstChild!
document.getSelection()!.setBaseAndExtent(text, 0, text, 7)
})
await selectDraft()
await deliverPrimary('KeyP')
await files.waitFor()
expect(await panel.locator('[data-dockkit-pane]').evaluate(pane => document.activeElement === pane)).toBe(true)
expect(await page.evaluate(() => document.getSelection()?.toString())).toBe('Desktop')
await composer.focus()
await selectDraft()
await deliverPrimary('KeyP')
expect(await files.count()).toBe(1)
expect(await panel.locator('[data-dockkit-pane]').evaluate(pane => document.activeElement === pane)).toBe(true)
expect(await page.evaluate(() => document.getSelection()?.toString())).toBe('Desktop')
await composer.click()
const pane = panel.locator('[data-dockkit-pane]')
await pane.click({ position: { x: 20, y: 2 } })
expect(await pane.evaluate(element => ({
focused: document.activeElement === element, outline: getComputedStyle(element).outlineStyle,
}))).toEqual({ focused: true, outline: 'none' })
await page.keyboard.press('Tab')
expect(await files.evaluate(element => element === document.activeElement && element.matches(':focus-visible'))).toBe(true)
expect(await files.evaluate(element => getComputedStyle(element).outlineStyle)).not.toBe('none')
await deliverPrimary('KeyW')
await expect.poll(() => page.locator('[data-sidebar-right-open]').count()).toBe(0)
expect(await page.evaluate(() => window.shortcutFixture.closedWindows)).toBe(0)
expect(await composer.innerText()).toBe('Desktop focus draft')
await writeComposerDraft(page, composer, '')
await expect.poll(() => composer.textContent()).toBe('')
const dialog = page.getByRole('dialog', { name: 'Keyboard shortcuts', exact: true })
const settings = page.getByRole('dialog', { name: 'Settings', exact: true })
const automaticFocusStyle = () => page.evaluate(() => {
const style = getComputedStyle(document.activeElement!)
return { outline: style.outlineStyle, shadow: style.boxShadow }
})
const noFocusRing = { outline: 'none', shadow: 'none' }
const modalState = () => page.evaluate(() => {
const name = (node: Element | null) => node?.getAttribute('aria-label')
?? document.getElementById(node?.getAttribute('aria-labelledby') ?? '')?.textContent ?? null
return {
focused: name(document.activeElement?.closest('[role="dialog"]') ?? null),
foreground: name(document.elementFromPoint(innerWidth / 2, innerHeight / 2)?.closest('[role="dialog"]') ?? null),
}
})
const closeModal = async (name: 'Settings' | 'Keyboard shortcuts', source: 'keyboard' | 'menu' = 'keyboard') => {
if (source === 'menu') {
await page.evaluate((input) => { window.shortcutFixture.deliver(input) }, {
kind: 'menu', commandId: 'page.close' as ShortcutCommandId, revision: snapshot.revision,
} satisfies DesktopShortcutInput)
} else {
await deliverPrimary('KeyW')
}
await page.getByRole('dialog', { name, exact: true }).waitFor({ state: 'hidden' })
expect(await page.evaluate(() => window.shortcutFixture.closedWindows)).toBe(0)
expect(await page.evaluate(() => document.activeElement?.hasAttribute('data-dsh-automatic-focus'))).toBe(true)
expect((await automaticFocusStyle()).outline).toBe('none')
}
await deliverPrimary('Comma')
await settings.waitFor()
const selectedCategory = settings.locator('nav button[aria-current="true"]')
expect(await selectedCategory.evaluate(node => node === document.activeElement)).toBe(true)
expect(await automaticFocusStyle()).toEqual(noFocusRing)
const settingsShadow = await settings.evaluate(node => getComputedStyle(node).boxShadow)
const settingsInitialFocus = await selectedCategory.innerText()
await page.keyboard.press('Enter')
expect(await settings.isVisible()).toBe(true)
expect(await automaticFocusStyle()).toEqual(noFocusRing)
const settingsAfterEnter = await modalState()
expect(settingsAfterEnter).toEqual({ focused: 'Settings', foreground: 'Settings' })
await page.keyboard.press('Tab')
expect((await automaticFocusStyle()).outline).not.toBe('none')
expect(await page.evaluate(() => Number.parseFloat(getComputedStyle(document.activeElement!).outlineWidth))).toBeGreaterThan(0)
await deliverPrimary('Comma')
await settings.waitFor({ state: 'hidden' })
expect(await page.evaluate(() => document.activeElement?.hasAttribute('data-dsh-automatic-focus'))).toBe(true)
expect((await automaticFocusStyle()).outline).toBe('none')
const dialogsAfterSettingsToggle = await page.getByRole('dialog').count()
expect(dialogsAfterSettingsToggle).toBe(0)
await deliverPrimary('Comma')
await settings.waitFor()
expect(await automaticFocusStyle()).toEqual(noFocusRing)
await closeModal('Settings')
expect(await dialog.isVisible()).toBe(false)
await openReference()
await dialog.waitFor()
expect(await dialog.getByRole('searchbox').evaluate(node => node === document.activeElement)).toBe(true)
expect(await automaticFocusStyle()).toEqual(noFocusRing)
const referenceShadow = await dialog.evaluate(node => getComputedStyle(node).boxShadow)
await deliverPrimary('Slash')
await dialog.waitFor({ state: 'hidden' })
expect(await page.evaluate(() => document.activeElement?.hasAttribute('data-dsh-automatic-focus'))).toBe(true)
expect((await automaticFocusStyle()).outline).toBe('none')
const dialogsAfterReferenceToggle = await page.getByRole('dialog').count()
expect(dialogsAfterReferenceToggle).toBe(0)
await openReference()
await dialog.waitFor()
await deliverPrimary('Comma')
expect(await settings.isVisible()).toBe(false)
expect(await dialog.isVisible()).toBe(true)
const referenceBlocksSettings = await modalState()
expect(referenceBlocksSettings).toEqual({ focused: 'Keyboard shortcuts', foreground: 'Keyboard shortcuts' })
expect(await automaticFocusStyle()).toEqual(noFocusRing)
expect(await dialog.evaluate(node => getComputedStyle(node).boxShadow)).toBe(referenceShadow)
await closeModal('Keyboard shortcuts')
expect(await settings.isVisible()).toBe(false)
await deliverPrimary('Comma')
await settings.waitFor()
await openReference()
await dialog.waitFor()
const referenceOnTop = await modalState()
expect(referenceOnTop).toEqual({ focused: 'Keyboard shortcuts', foreground: 'Keyboard shortcuts' })
expect(await automaticFocusStyle()).toEqual(noFocusRing)
expect(await settings.evaluate(node => getComputedStyle(node).boxShadow)).toBe(settingsShadow)
await deliverPrimary('Comma')
expect(await page.getByRole('dialog').count()).toBe(2)
expect(await settings.isVisible()).toBe(true)
expect(await modalState()).toEqual(referenceOnTop)
expect(await automaticFocusStyle()).toEqual(noFocusRing)
await deliverPrimary('Slash')
await dialog.waitFor({ state: 'hidden' })
expect(await settings.isVisible()).toBe(true)
const settingsAfterReferenceToggle = await modalState()
expect(settingsAfterReferenceToggle).toEqual({ focused: 'Settings', foreground: 'Settings' })
expect(await selectedCategory.getAttribute('data-dsh-automatic-focus')).toBe('')
expect(await automaticFocusStyle()).toEqual(noFocusRing)
await openReference()
await dialog.waitFor()
await closeModal('Keyboard shortcuts', 'menu')
expect(await settings.isVisible()).toBe(true)
const settingsAfterClose = await modalState()
expect(settingsAfterClose).toEqual({ focused: 'Settings', foreground: 'Settings' })
expect(await automaticFocusStyle()).toEqual(noFocusRing)
expect(await selectedCategory.getAttribute('data-dsh-automatic-focus')).toBe('')
expect(await settings.evaluate(node => getComputedStyle(node).boxShadow)).toBe(settingsShadow)
await closeModal('Settings')
const closedWindows = await page.evaluate(() => window.shortcutFixture.closedWindows)
await compareOrRefreshGolden(join(expected, 'modal-layers.expected.md'),
`# Modal layers\n\n${JSON.stringify({ settingsInitialFocus, settingsAfterEnter, dialogsAfterSettingsToggle, dialogsAfterReferenceToggle, referenceBlocksSettings, referenceOnTop, settingsAfterReferenceToggle, settingsAfterClose, closedWindows }, null, 2)}`, mode)
await openReference()
await dialog.waitFor()
const rowHeights = () => dialog.getByRole('listitem').evaluateAll(rows => rows.map(row => row.getBoundingClientRect().height))
const heights = await rowHeights()
expect(new Set(heights)).toEqual(new Set([42]))
await dialog.getByRole('button', { name: 'Edit shortcut for New Session', exact: true }).click()
await expect.poll(() => page.evaluate(() => window.shortcutFixture.recording)).toBe(true)
expect(await rowHeights()).toEqual(heights)
const firstRecorder = dialog.getByRole('button', { name: 'Press a shortcut', exact: true })
expect(await firstRecorder.evaluate(node => node === document.activeElement)).toBe(true)
expect(await automaticFocusStyle()).toEqual(noFocusRing)
expect(await dialog.getByRole('button', { name: 'Cancel recording', exact: true }).count()).toBe(0)
await compareOrRefreshGolden(join(expected, 'recording.expected.md'), await dialog.getByRole('group').ariaSnapshot(), mode)
await page.keyboard.press(`${primary}+C`)
await dialog.getByRole('group').waitFor({ state: 'hidden' })
expect(await dialog.evaluate(node => node === document.activeElement)).toBe(true)
expect(await automaticFocusStyle()).toEqual({ ...noFocusRing, shadow: referenceShadow })
const saved: unknown = JSON.parse(await readFile(join(userData, 'keybindings.json'), 'utf8'))
expect(saved).toMatchObject({ schemaVersion: 2, profiles: { [`desktop:${platform}`]: {
'session.new': { code: 'KeyC', modifiers: [platform === 'macos' ? 'meta' : 'control'] },
} } })
await page.reload({ waitUntil: 'load' })
await page.getByText('DONE', { exact: true }).waitFor()
await openReference()
await dialog.getByRole('searchbox').fill('New Session')
const row = dialog.getByRole('listitem')
await expect.poll(() => row.count()).toBe(1)
await compareOrRefreshGolden(join(expected, `${platform}-copy-override.expected.md`), await row.ariaSnapshot(), mode)
await page.keyboard.press('Escape')
await dialog.waitFor({ state: 'hidden' })
await composer.focus()
await page.evaluate((input) => { window.shortcutFixture.deliver(input) }, {
kind: 'keyboard', frameName: '', revision: snapshot.revision, code: 'KeyC',
control: platform === 'windows', meta: platform === 'macos', alt: false, shift: false, repeat: false,
} satisfies DesktopShortcutInput)
await page.getByText('DONE', { exact: true }).waitFor({ state: 'hidden' })
await expect.poll(() => page.getByRole('treeitem', { selected: true }).count()).toBe(0)
await page.getByRole('treeitem').nth(1).click()
await page.getByText('DONE', { exact: true }).waitFor()
await openReference()
await dialog.getByRole('searchbox').fill('New Session')
for (const code of ['a', 'F1', 'ArrowLeft', 'Tab']) {
await dialog.getByRole('button', { name: 'Edit shortcut for New Session', exact: true }).click()
const recorder = dialog.getByRole('button', { name: 'Press a shortcut', exact: true })
await expect.poll(() => recorder.isEnabled()).toBe(true)
await recorder.focus()
await page.keyboard.press(code)
await dialog.getByRole('group').waitFor({ state: 'hidden' })
expect(snapshot.document.profiles[`desktop:${platform}`]?.['session.new']).toEqual({
code: code === 'a' ? 'KeyA' : code, modifiers: [],
})
}
await dialog.getByRole('button', { name: 'Edit shortcut for New Session', exact: true }).click()
const recorder = dialog.getByRole('button', { name: 'Press a shortcut', exact: true })
await expect.poll(() => recorder.isEnabled()).toBe(true)
await recorder.focus()
const accepted = snapshot.document
for (const key of [`${primary}+Slash`, 'Escape', 'Enter', 'ArrowUp', 'ArrowDown', 'Shift+Enter', `${primary}+Enter`, 'Slash', 'Shift+Digit2']) {
expect(await recorder.evaluate(node => node === document.activeElement)).toBe(true)
const keys = key.split('+')
for (const held of keys) await page.keyboard.down(held)
await expect.poll(() => recorder.getAttribute('aria-invalid')).toBe('false')
expect(await recorder.textContent()).not.toBe('Press a shortcut')
for (const held of keys.toReversed()) await page.keyboard.up(held)
await expect.poll(() => recorder.getAttribute('aria-invalid')).toBe('true')
if (key === `${primary}+Slash`) {
await dialog.getByText('Already used by “Open keyboard shortcuts”', { exact: true }).waitFor()
}
expect(await recorder.evaluate(node => node === document.activeElement)).toBe(true)
expect(snapshot.document).toEqual(accepted)
expect(await dialog.getByRole('listitem').evaluateAll(rows => rows.map(row => row.getBoundingClientRect().height)))
.toEqual([42])
}
await compareOrRefreshGolden(join(expected, 'fixed-conflict.expected.md'), await dialog.getByRole('group').ariaSnapshot(), mode)
const preferencePath = join(userData, 'keybindings.json')
const savedPreferencePath = join(userData, 'keybindings.saved.json')
await rename(preferencePath, savedPreferencePath)
try {
// A directory prevents atomic file replacement on every supported filesystem.
await mkdir(preferencePath)
await page.keyboard.press('j')
await dialog.getByText('Could not save. Your previous shortcuts and current draft are preserved. Please retry.', { exact: true }).waitFor()
expect(snapshot.document).toEqual(accepted)
expect(await recorder.getAttribute('aria-invalid')).toBe('true')
expect(await recorder.evaluate(node => node === document.activeElement)).toBe(true)
} finally {
await rm(preferencePath, { recursive: true, force: true })
await rename(savedPreferencePath, preferencePath)
}
await page.keyboard.down('b')
await page.keyboard.down('a')
await expect.poll(() => recorder.getAttribute('aria-invalid')).toBe('false')
expect(await recorder.textContent()).toContain('A')
expect(await recorder.textContent()).toContain('B')
await page.keyboard.up('a')
await page.keyboard.up('b')
await dialog.getByRole('group').waitFor({ state: 'hidden' })
expect(snapshot.document.profiles[`desktop:${platform}`]?.['session.new']).toEqual({ code: 'KeyA', secondCode: 'KeyB', modifiers: [] })
await compareOrRefreshGolden(join(expected, 'chord.expected.md'), await dialog.getByRole('listitem').ariaSnapshot(), mode)
await page.reload({ waitUntil: 'load' })
await page.getByText('DONE', { exact: true }).waitFor()
await composer.focus()
await page.keyboard.press('a'); await page.keyboard.press('b')
await expect.poll(() => composer.textContent()).toBe('ab')
await expect.poll(() => page.getByText('DONE', { exact: true }).isVisible()).toBe(true)
await page.keyboard.down('a')
await expect.poll(() => composer.textContent()).toBe('aba')
await page.evaluate((input) => { window.shortcutFixture.deliver(input) }, {
kind: 'keyboard', frameName: '', revision: snapshot.revision, code: 'KeyA', secondCode: 'KeyB',
control: false, meta: false, alt: false, shift: false, repeat: false,
} satisfies DesktopShortcutInput)
await page.keyboard.up('a')
await page.getByText('DONE', { exact: true }).waitFor({ state: 'hidden' })
await expect.poll(() => page.getByRole('treeitem', { selected: true }).count()).toBe(0)
await page.getByRole('treeitem').nth(1).click()
await page.getByText('DONE', { exact: true }).waitFor()
await openReference()
await dialog.waitFor()
await dialog.getByRole('searchbox').focus()
await page.evaluate((input) => { window.shortcutFixture.deliver(input) }, {
kind: 'keyboard', frameName: '', revision: snapshot.revision, code: 'KeyA',
control: platform === 'windows', meta: platform === 'macos', alt: false, shift: true, repeat: false,
} satisfies DesktopShortcutInput)
await page.getByText('DONE', { exact: true }).waitFor({ state: 'hidden' })
await expect.poll(() => page.getByRole('treeitem', { selected: true }).count()).toBe(0)
expect(await dialog.isVisible()).toBe(true)
expect(console.pageErrors).toEqual([])
expect(console.warnings).toEqual([])
} finally { await browser.close() }
} finally { await scaffold.close() }
} finally {
persistence.dispose()
await rm(userData, { recursive: true, force: true })
}
})
+65
View File
@@ -0,0 +1,65 @@
/** Fixed approval keys through the real browser, Remote waterfall and recorded model command. */
import { access, readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import { chromium, type Browser, type Page } from 'playwright'
import { afterEach, describe, expect, it } from 'vitest'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-user-approval'
import { fixtureUserPrompts, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts'
import { connectFreshWorkspace, newEnglishPage } from './support.ts'
const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/approval-composer/session.v3.jsonl', import.meta.url))
describe('web e2e: fixed approval rejection keys', () => {
let scaffold: WebScaffold | undefined
let browser: Browser | undefined
let page: Page
afterEach(async () => {
const results = await Promise.allSettled([browser?.close(), scaffold?.close()])
browser = undefined
scaffold = undefined
const failed = results.filter(result => result.status === 'rejected')
if (failed.length > 0) throw new AggregateError(failed.map((result): unknown => result.reason), 'fixed shortcut teardown failed')
})
it.skipIf(webSnapshotMode() === 'record').each(['reject-button-enter', 'container-escape'] as const)(
'rejects through %s without running the requested write', async (method) => {
const events: SessionEvent[] = []
scaffold = await launchWebScaffold({ replayFixture: FIXTURE, compareReplaySession: false })
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { events.push(event) })
browser = await chromium.launch()
page = await newEnglishPage(browser)
const tripwire = watchConsole(page)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await connectFreshWorkspace(page, scaffold.workspaceCwd)
await page.locator('[aria-label^="Access mode"]').click()
await page.getByRole('menuitem', { name: 'Read Only' }).click()
await page.locator('[aria-label="Access mode, current: Read Only"]').waitFor()
const input = page.locator('[data-composer-input]').first()
const prompts = fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))
expect(prompts).toHaveLength(1)
const settled = scaffold.whenTurnSettled()
await input.fill(prompts[0]!)
await input.press('Enter')
const panel = page.locator('[data-approval-key]')
await panel.waitFor()
if (method === 'reject-button-enter') {
await panel.getByRole('button', { name: 'Reject', exact: true }).focus()
await page.keyboard.press('Enter')
} else {
await panel.locator('[data-approval-scroll]').focus()
await page.keyboard.press('Escape')
}
await settled
expect(events.filter(event => event.type === 'approval/decided').map(event => event.data))
.toMatchObject([{ outcome: 'rejected' }])
expect(events.filter(event => event.type === 'turn/end').map(event => event.data.reason.kind))
.toEqual(['completed'])
await expect(access(join(scaffold.workspaceCwd, 'workspace', 'notes.txt'))).rejects.toMatchObject({ code: 'ENOENT' })
expect(await panel.count()).toBe(0)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
},
)
})
+287
View File
@@ -0,0 +1,287 @@
/** Sidebar commands through the shipped Loader, real sidebar/file/terminal owners, and Web keybinding editor. */
import { writeFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import { chromium, type Browser, type Page } from 'playwright'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
import { captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts'
import { connectFreshWorkspace } from './support.ts'
const expected = fileURLToPath(new URL('./expected/shortcuts-panels', import.meta.url))
const mode = webSnapshotMode()
/** Reassign a spare Web combination through the actual editor, leaving browser-reserved defaults untouched. */
async function bind(page: Page, primary: string, label: string, previous?: string): Promise<void> {
await page.keyboard.press(`${primary}+/`)
const dialog = page.getByRole('dialog', { name: 'Keyboard shortcuts', exact: true })
await dialog.waitFor()
if (label === 'Split' || label === 'Toggle panel fullscreen') {
const row = dialog.getByRole('listitem').filter({ has: page.getByRole('button', { name: `Edit shortcut for ${label}`, exact: true }) })
expect(await row.getByText(label, { exact: true }).count()).toBe(1)
expect(await row.getByText('Focus a right sidebar pane first', { exact: true }).count()).toBe(0)
expect(await row.getByText('Unavailable', { exact: true }).count()).toBe(0)
}
if (previous !== undefined) {
const remove = dialog.getByRole('button', { name: `Remove shortcut for ${previous}`, exact: true })
await remove.click()
await remove.waitFor({ state: 'hidden' })
}
await dialog.getByRole('button', { name: `Edit shortcut for ${label}`, exact: true }).click()
const recorder = dialog.getByRole('group', { name: label, exact: true })
await recorder.waitFor()
await page.keyboard.press(`${primary}+Shift+,`)
await recorder.waitFor({ state: 'hidden' })
await page.keyboard.press('Escape')
await dialog.waitFor({ state: 'detached' })
}
// The fixture owns a POSIX shell; Windows has a separate physical-input acceptance run.
describe.skipIf(process.platform === 'win32')('Web sidebar shortcuts', () => {
let browser: Browser
beforeAll(async () => {
browser = await chromium.launch()
}, 120_000)
afterAll(async () => { await browser?.close() })
it.each([
{ platform: 'MacIntel', primary: 'Meta', aria: 'Shift+Meta+,' },
{ platform: 'Win32', primary: 'Control', aria: 'Control+Shift+,' },
].flatMap(platform => (['left', 'right'] as const).map(closeFirst => ({ ...platform, closeFirst }))))('targets panes and closes $closeFirst first with $platform bindings', async ({ platform, primary, aria, closeFirst }) => {
const scaffold: WebScaffold = await launchWebScaffold({
extraOverlayPath: [
fileURLToPath(new URL('./fixtures/sidebar-terminal.patch.yml', import.meta.url)),
fileURLToPath(new URL('./sidebar-browser.overlay.yml', import.meta.url)),
],
})
const context = await browser.newContext({ locale: 'en-US', timezoneId: 'Asia/Shanghai', viewport: { width: 1680, height: 1000 } })
try {
// Device-label/DOM branch evidence only; physical Windows input has its own acceptance checklist.
await context.addInitScript((value) => { Object.defineProperty(navigator, 'platform', { value }) }, platform)
const page = await context.newPage()
const tripwire = watchConsole(page)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
const before = new Set(scaffold.ctx.agents.list().map(agent => agent.id))
await connectFreshWorkspace(page, scaffold.workspaceCwd)
const agent = scaffold.ctx.agents.list().find(agent => !before.has(agent.id))
if (agent === undefined) throw new Error('The workspace did not create a Session')
// A settled fixture activates the ordinary Session UI; this scenario issues no model requests.
agent.session.append('turn/start', { turn: 1 })
agent.session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'Prepare panel actions.' }], source: { kind: 'user' } }), { surfaceOp: 'append' })
agent.session.append('step/start', { turn: 1, step: 1 })
agent.session.append('assistant/message', { turn: 1, step: 1, stream: [], message: createMessage({ role: 'assistant', content: [{ type: 'text', text: 'Panel actions are ready.' }], source: { kind: 'model', provider: 'fixture', model: 'fixture' } }) }, { surfaceOp: 'append' })
agent.session.append('step/end', { turn: 1, step: 1 })
agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
await scaffold.ctx.sessions.flush(agent.session)
await page.getByText('Panel actions are ready.', { exact: true }).waitFor()
const composer = page.locator('[data-composer-input]')
await composer.click()
await page.keyboard.insertText('T5 draft remains unsent')
await page.keyboard.press(`${primary}+Alt+Enter`)
expect(await composer.innerText()).toContain('T5 draft remains unsent')
const eventCount = agent.session.seq
const panel = page.locator('[data-sidebar-right-panel][data-sidebar-right-open]')
const paneAppearance = () => panel.locator('[data-dockkit-pane]').evaluateAll(panes => panes.map((pane) => {
const style = getComputedStyle(pane)
return { outline: style.outlineStyle, shadow: style.boxShadow }
}))
const plainPane = { outline: 'none', shadow: 'none' }
for (const { label, previous } of [
{ label: 'Browser', previous: undefined },
{ label: 'New terminal', previous: 'Browser' },
]) {
await bind(page, primary, label, previous)
await composer.click()
await page.keyboard.press(`${primary}+Shift+,`)
const openedPage = label === 'Browser' ? panel.getByPlaceholder('Enter an HTTP(S) address')
: panel.locator('[data-sidebar-terminal]')
await openedPage.waitFor()
const focusedAfterOpening = await panel.locator('[data-dockkit-pane]').evaluate(pane => pane.contains(document.activeElement))
expect(await paneAppearance()).toEqual([plainPane])
await page.keyboard.press(`${primary}+Alt+W`)
await expect.poll(() => page.locator('[data-sidebar-right-open]').count(), { message: label }).toBe(0)
expect(focusedAfterOpening, label).toBe(true)
expect(await composer.innerText()).toBe('T5 draft remains unsent')
}
await bind(page, primary, 'Toggle right sidebar', 'New terminal')
await composer.click()
await page.keyboard.press(`${primary}+Shift+,`)
await panel.waitFor()
expect(await paneAppearance()).toEqual([plainPane])
expect(await panel.getByRole('button', { name: 'Collapse right sidebar' }).getAttribute('aria-keyshortcuts')).toBe(aria)
await panel.getByRole('tab').focus()
await page.keyboard.press(`${primary}+Shift+,`)
await expect.poll(() => page.locator('[data-sidebar-right-open]').count()).toBe(0)
await page.keyboard.press(`${primary}+Shift+,`)
await panel.waitFor()
const terminalGuide = panel.locator('[data-sidebar-right-guide-entry="terminal"]').getByRole('button').first()
await terminalGuide.hover()
expect(await page.getByRole('tooltip').count()).toBe(0)
await page.mouse.move(10, 10)
await terminalGuide.focus()
expect(await page.getByRole('tooltip').count()).toBe(0)
await bind(page, primary, 'Browser', 'Toggle right sidebar')
const browserGuide = panel.locator('[data-sidebar-right-guide-entry="browser"]')
await browserGuide.hover()
await browserGuide.focus()
expect(await browserGuide.getAttribute('aria-keyshortcuts')).toBe(aria)
expect(await page.getByRole('tooltip').count()).toBe(0)
await composer.click()
const selectedDraft = await composer.evaluate((element) => {
const text = element.querySelector('[data-lexical-text]')!.firstChild!
const selection = document.getSelection()!
selection.setBaseAndExtent(text, 0, text, text.textContent!.length)
return selection.toString()
})
await page.keyboard.press(`${primary}+Shift+,`)
await panel.getByPlaceholder('Enter an HTTP(S) address').waitFor()
expect(await page.evaluate(() => document.getSelection()?.toString())).toBe(selectedDraft)
await panel.getByPlaceholder('Enter an HTTP(S) address').focus()
await page.keyboard.press(`${primary}+Shift+,`)
await expect.poll(() => panel.getByRole('tab', { name: /Browser/ }).count()).toBe(2)
expect(await panel.locator('[data-dockkit-pane]').evaluate(pane => document.activeElement === pane)).toBe(true)
expect(await paneAppearance()).toEqual([plainPane])
await page.keyboard.press(`${primary}+Alt+W`)
await expect.poll(() => panel.getByRole('tab', { name: /Browser/ }).count()).toBe(1)
await page.keyboard.press(`${primary}+Shift+,`)
await expect.poll(() => panel.getByRole('tab', { name: /Browser/ }).count()).toBe(2)
await expect.poll(() => panel.getByRole('button', { name: 'Disable sandbox restrictions', exact: true }).isEnabled()).toBe(true)
await compareOrRefreshGolden(join(expected, 'browser.expected.md'),
await captureStableAria(page, '[data-sidebar-right-panel]', scaffold.workspaceCwd), mode)
await panel.locator('[data-dockkit-tab-close]').last().click()
await panel.locator('[data-dockkit-tab-close]').click()
await page.getByRole('button', { name: 'Open right sidebar', exact: true }).click()
await bind(page, primary, 'Workspace files', 'Browser')
await composer.click()
await page.keyboard.press(`${primary}+Shift+,`)
await panel.locator('[data-dockkit-tab]').filter({ hasText: 'Files' }).waitFor()
expect(await panel.locator('[data-dockkit-pane]').evaluate(pane => document.activeElement === pane)).toBe(true)
expect(await paneAppearance()).toEqual([plainPane])
await page.keyboard.press(`${primary}+Shift+,`)
expect(await panel.locator('[data-dockkit-tab]').filter({ hasText: 'Files' }).count()).toBe(1)
expect(await panel.locator('[data-dockkit-pane]').evaluate(pane => document.activeElement === pane)).toBe(true)
expect(await paneAppearance()).toEqual([plainPane])
await bind(page, primary, 'Toggle panel fullscreen', 'Workspace files')
await composer.click()
await page.keyboard.press(`${primary}+Shift+,`)
expect(await page.locator('[data-sidebar-right-panel="fullscreen"]').count()).toBe(0)
const filesTab = panel.locator('[data-dockkit-tab]').filter({ hasText: 'Files' })
await filesTab.focus()
await expect.poll(() => filesTab.evaluate(element => document.activeElement === element)).toBe(true)
await page.keyboard.press(`${primary}+Shift+,`)
await page.locator('[data-sidebar-right-panel="fullscreen"]').waitFor()
expect(await paneAppearance()).toEqual([plainPane])
expect(await composer.innerText()).toContain('T5 draft remains unsent')
expect(agent.session.seq).toBe(eventCount)
await bind(page, primary, 'Split', 'Toggle panel fullscreen')
await panel.locator('[data-dockkit-tab]').filter({ hasText: 'Files' }).focus()
await page.keyboard.press(`${primary}+Shift+,`)
await expect.poll(() => panel.locator('[data-dockkit-pane]').count()).toBe(2)
const left = panel.locator('[data-dockkit-pane][data-dockkit-column="0"]')
const right = panel.locator('[data-dockkit-pane][data-dockkit-column="1"]')
expect(await right.evaluate(pane => document.activeElement === pane)).toBe(true)
expect(await paneAppearance()).toEqual([plainPane, plainPane])
const disabled = right.locator('[data-dockkit-split-button]')
expect(await disabled.isDisabled()).toBe(true)
await disabled.locator('..').focus()
const splitKeys = primary === 'Meta' ? '⇧ ⌘ ,' : 'Ctrl + Shift + ,'
await page.getByRole('tooltip', { name: `Two panes is the limit ${splitKeys}`, exact: true }).waitFor()
await bind(page, primary, 'Workspace files', 'Split')
await right.getByRole('tab').focus()
await page.keyboard.press(`${primary}+Shift+,`)
await expect.poll(() => panel.locator('[data-dockkit-tab]').filter({ hasText: 'Files' }).count()).toBe(2)
await page.keyboard.press(`${primary}+Shift+,`)
expect(await right.locator('[data-dockkit-tab]').filter({ hasText: 'Files' }).count()).toBe(1)
await bind(page, primary, 'New terminal', 'Workspace files')
await right.locator('[data-dockkit-tab]').filter({ hasText: 'Files' }).focus()
await page.keyboard.press(`${primary}+Shift+,`)
await right.locator('[data-sidebar-terminal]').waitFor()
await expect.poll(() => right.locator('.xterm-rows').innerText()).toContain('bash-')
await right.locator('.xterm-helper-textarea').focus()
await page.keyboard.press(`${primary}+Shift+,`)
await expect.poll(() => right.getByRole('tab').count()).toBe(3)
await expect.poll(() => right.locator('.xterm-rows').innerText()).toContain('bash-')
expect(await left.getByRole('tab').count()).toBe(1)
await compareOrRefreshGolden(join(expected, 'panels.expected.md'),
await captureStableAria(page, '[data-sidebar-right-panel]', scaffold.workspaceCwd), mode)
await bind(page, primary, 'Refresh current page', 'New terminal')
await right.locator('.xterm-helper-textarea').focus()
await page.keyboard.press(`${primary}+Shift+,`)
expect(await right.getByRole('tab').count()).toBe(3)
await left.getByRole('tab', { name: /Files/ }).click()
await writeFile(join(scaffold.workspaceCwd, 'workspace', 't6-refresh.txt'), 'page refresh evidence\n')
await left.getByRole('tab', { name: /Files/ }).focus()
await page.keyboard.press(`${primary}+Shift+,`)
await left.getByText('t6-refresh.txt', { exact: true }).waitFor()
expect(await left.getByRole('button', { name: 'Reload', exact: true }).getAttribute('aria-keyshortcuts')).toBe(aria)
await bind(page, primary, 'Close current page or window', 'Refresh current page')
await right.getByRole('tab').last().focus()
await page.keyboard.press(`${primary}+Shift+,`)
await expect.poll(() => right.getByRole('tab').count()).toBe(2)
expect(await left.getByRole('tab').count()).toBe(1)
await panel.getByRole('button', { name: 'Exit fullscreen', exact: true }).click()
await composer.click()
await page.keyboard.press(`${primary}+Shift+,`)
expect(await right.getByRole('tab').count()).toBe(2)
await compareOrRefreshGolden(join(expected, 'page-actions.expected.md'),
await captureStableAria(page, '[data-sidebar-right-panel]', scaffold.workspaceCwd), mode)
const menuTab = left.getByRole('tab', { name: /Files/ })
await menuTab.locator('[data-dockkit-tab-close]').focus()
await page.getByRole('tooltip').filter({ hasText: 'Close' }).waitFor()
for (let attempt = 0; attempt < 2; attempt++) {
await menuTab.click({ button: 'right' })
await page.getByRole('menu').waitFor()
expect(await page.getByRole('tooltip').count()).toBe(0)
expect(await page.getByRole('menuitem').getAttribute('aria-keyshortcuts')).toBeNull()
if (attempt === 0) {
await compareOrRefreshGolden(join(expected, 'tab-menu.expected.md'),
await captureStableAria(page, '[data-dockkit-tab-menu]', scaffold.workspaceCwd), mode)
} else {
await page.getByRole('menuitem').focus()
}
await page.keyboard.press('Escape')
await page.getByRole('menu').waitFor({ state: 'detached' })
expect(await left.getByRole('tab').count()).toBe(1)
expect(await right.getByRole('tab').count()).toBe(2)
}
expect(await menuTab.evaluate(tab => tab === document.activeElement)).toBe(true)
expect(await menuTab.evaluate(tab => getComputedStyle(tab).outlineStyle)).toBe('none')
await (closeFirst === 'right' ? right : left).getByRole('tab').last().focus()
await page.keyboard.press(`${primary}+Shift+,`)
await expect.poll(() => panel.getByRole('tab').count()).toBe(2)
await page.keyboard.press(`${primary}+Shift+,`)
await expect.poll(() => panel.getByRole('tab').count()).toBe(1)
expect(await panel.locator('[data-dockkit-pane]').count()).toBe(1)
expect(await panel.locator('[data-dockkit-pane]').evaluate(pane => pane === document.activeElement)).toBe(true)
expect(await paneAppearance()).toEqual([plainPane])
await page.keyboard.press(`${primary}+Shift+,`)
await expect.poll(() => page.locator('[data-sidebar-right-open]').count()).toBe(0)
await page.getByRole('button', { name: 'Open right sidebar', exact: true }).click()
await panel.getByRole('button', { name: 'Fullscreen', exact: true }).click()
await panel.locator('[data-dockkit-split-button]').click()
await expect.poll(() => panel.locator('[data-dockkit-pane]').count()).toBe(2)
await (closeFirst === 'left' ? left : right).getByRole('tab').focus()
await page.keyboard.press(`${primary}+Shift+,`)
await expect.poll(() => panel.getByRole('tab').count()).toBe(1)
expect(await panel.locator('[data-dockkit-pane]').evaluate(pane => pane === document.activeElement)).toBe(true)
expect(await paneAppearance()).toEqual([plainPane])
await compareOrRefreshGolden(join(expected, 'guide-close.expected.md'),
await captureStableAria(page, '[data-sidebar-right-panel]', scaffold.workspaceCwd), mode)
await page.keyboard.press(`${primary}+Shift+,`)
await expect.poll(() => page.locator('[data-sidebar-right-open]').count()).toBe(0)
expect(await composer.innerText()).toContain('T5 draft remains unsent')
expect(agent.session.seq).toBe(eventCount)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
} finally { try { await context.close() } finally { await scaffold.close() } }
})
})
+319
View File
@@ -0,0 +1,319 @@
/** Workspace keyboard commands over the real Web composition and a recorded Session. */
import { mkdir, readFile } from 'node:fs/promises'
import { basename, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { chromium, type Browser, type Page } from 'playwright'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { SessionId } from '@deepseek-ai/dsh-session'
import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import {
captureStableAria, compareOrRefreshGolden, launchWebScaffold, readPersistedEvents,
seedSession, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace } from './support.ts'
const root = fileURLToPath(new URL('./expected/shortcuts-workspace', import.meta.url))
const seed = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url))
const sourceId = SessionId('shortcuts-workspace-source')
const mode = webSnapshotMode()
describe.skipIf(mode === 'record')('web e2e: workspace shortcuts', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let previous: string | undefined
const launched: { app: string; path: string }[] = []
beforeAll(async () => {
scaffold = await launchWebScaffold({ developerTools: false, openInAppEnvironment: createLaunchEnvironmentSnapshot([]) })
await seedSession(scaffold, await readFile(seed, 'utf8'), sourceId)
await mkdir(join(scaffold.workspaceCwd, 'keyboard-project'))
browser = await chromium.launch()
const context = await browser.newContext({ locale: 'en-US', viewport: { width: 1440, height: 1000 } })
await context.addInitScript(() => {
Object.defineProperty(navigator, 'platform', { value: 'MacIntel' })
localStorage.setItem('dsh.open-in-app.choice', JSON.stringify('vscode'))
})
page = await context.newPage()
// OS application discovery and launch are the external boundary; the command and HTTP carrier stay real.
await page.route('**/open-in-app/apps', route => route.fulfill({ json: { apps: ['finder', 'vscode'] } }))
await page.route('**/open-in-app/open', async (route) => {
launched.push(route.request().postDataJSON() as { app: string; path: string })
await route.fulfill({ json: { ok: true } })
})
const startup = watchConsole(page)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
const group = page.getByRole('treeitem').first()
await group.waitFor({ timeout: 10_000 }).catch((error: unknown) => { throw new Error(JSON.stringify(startup), { cause: error }) })
if (await group.getAttribute('aria-expanded') !== 'true') await group.click()
await page.getByRole('treeitem').nth(1).click()
await page.getByText('DONE', { exact: true }).waitFor()
})
afterAll(async () => { await browser?.close(); await scaffold?.close() })
async function bind(label: string): Promise<void> {
await page.keyboard.press('Meta+Slash')
const reference = page.getByRole('dialog', { name: 'Keyboard shortcuts', exact: true })
await reference.waitFor()
if (['Rename session', 'Fork session', 'Archive session'].includes(label)) {
const row = reference.getByRole('listitem').filter({ has: page.getByText(label, { exact: true }) })
expect(await row.count()).toBe(1)
expect(await row.getByText('Unavailable', { exact: true }).count()).toBe(0)
}
if (previous !== undefined) {
await reference.getByRole('button', { name: `Remove shortcut for ${previous}`, exact: true }).click()
await expect.poll(() => reference.getByRole('button', { name: `Remove shortcut for ${previous}`, exact: true }).count()).toBe(0)
}
await reference.getByRole('button', { name: `Edit shortcut for ${label}`, exact: true }).click()
await page.keyboard.press('Meta+Shift+Comma')
await expect.poll(() => reference.getByRole('group').count()).toBe(0)
await page.keyboard.press('Escape')
await reference.waitFor({ state: 'hidden' })
previous = label
}
it('runs seven owner operations with saved bindings and preserves the completed fork prefix', async () => {
const console = watchConsole(page)
await bind('Search sessions')
await page.keyboard.press('Meta+Shift+Comma')
const search = page.getByPlaceholder('Search session names', { exact: true })
await expect.poll(() => search.evaluate(element => element === document.activeElement)).toBe(true)
expect(await page.getByRole('button', { name: 'Search sessions', exact: true }).getAttribute('aria-keyshortcuts')).toBe('Shift+Meta+,')
await page.keyboard.press('Escape')
await bind('Rename session')
await page.keyboard.press('Meta+Shift+Comma')
const rename = page.getByRole('dialog', { name: 'Rename session', exact: true })
await rename.getByLabel('Session name').fill('T4 source')
await rename.getByRole('button', { name: 'Rename', exact: true }).click()
await rename.waitFor({ state: 'hidden' })
await expect.poll(async () => (await readPersistedEvents(scaffold, sourceId)).some(event => event.type === 'session/title' && event.data.title === 'T4 source')).toBe(true)
await bind('Fork session')
const original = await readPersistedEvents(scaffold, sourceId)
const lastEnd = original.findLastIndex(event => event.type === 'turn/end')
await page.keyboard.press('Meta+Shift+Comma')
await expect.poll(() => scaffold.ctx.agents.list().find(agent => agent.session.header.parentSession === sourceId)).toBeDefined()
const child = scaffold.ctx.agents.list().find(agent => agent.session.header.parentSession === sourceId)!
const childId = child.session.header.id
await expect.poll(async () => (await readPersistedEvents(scaffold, childId)).some(event => event.type === 'session/title' && event.data.title === 'T4 source (1)')).toBe(true)
const copied = await readPersistedEvents(scaffold, childId)
expect(copied.slice(0, lastEnd + 1)).toEqual(original.slice(0, lastEnd + 1))
const sourceRow = page.getByRole('treeitem').filter({ has: page.getByText('T4 source', { exact: true }) })
const childRow = page.getByRole('treeitem').filter({ has: page.getByText('T4 source (1)', { exact: true }) })
await childRow.waitFor()
expect(await sourceRow.getAttribute('aria-selected')).toBe('true')
expect(await childRow.getAttribute('aria-selected')).toBe('false')
await compareOrRefreshGolden(join(root, 'fork.expected.md'),
await captureStableAria(page, '[data-slot="sidebar.workspaces"]', scaffold.workspaceCwd), mode)
await childRow.click()
await expect.poll(() => childRow.getAttribute('aria-selected')).toBe('true')
await bind('Archive session')
await sourceRow.hover()
await page.getByRole('button', { name: 'Session actions for T4 source', exact: true }).click()
await page.getByRole('menu').waitFor()
await compareOrRefreshGolden(join(root, 'session-menu.expected.md'),
await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd), mode)
expect(await page.getByRole('menuitem', { name: /^Archive session/ }).getAttribute('aria-keyshortcuts')).toBe('Shift+Meta+,')
await page.keyboard.press('Escape')
await page.getByRole('menu').waitFor({ state: 'hidden' })
await page.getByRole('treeitem', { selected: true }).filter({ hasText: 'T4 source (1)' }).waitFor()
await page.keyboard.press('Meta+Shift+Comma')
await expect.poll(() => scaffold.ctx.workspaceRegistry.archivedSessionIds).toContain(childId)
expect(scaffold.ctx.workspaceRegistry.archivedSessionIds).not.toContain(sourceId)
expect((await readPersistedEvents(scaffold, sourceId)).length).toBeGreaterThan(0)
await bind('Add workspace')
await page.keyboard.press('Meta+Shift+Comma')
const picker = page.getByRole('dialog', { name: 'Select Workspace Directory', exact: true })
await picker.waitFor()
await page.keyboard.press('Meta+Shift+Comma')
expect(await page.getByRole('dialog', { name: 'Select Workspace Directory', exact: true }).count()).toBe(1)
await picker.getByRole('button', { name: 'Edit path', exact: true }).click()
await picker.getByRole('textbox', { name: 'Edit path', exact: true }).fill(join(scaffold.workspaceCwd, 'keyboard-project'))
await page.keyboard.press('Enter')
await picker.getByRole('button', { name: 'Open', exact: true }).click()
await picker.waitFor({ state: 'hidden' })
await expect.poll(() => scaffold.ctx.workspaceRegistry.resolveByPath(join(scaffold.workspaceCwd, 'keyboard-project'))).toBeDefined()
const workspace = (await scaffold.ctx.workspaceRegistry.resolveByPath(join(scaffold.workspaceCwd, 'keyboard-project')))!
const workspaceLabel = basename(workspace.path)
const workspaceRow = page.locator('[role="treeitem"][aria-expanded]')
.filter({ has: page.getByText(workspaceLabel, { exact: true }) })
const details = page.getByRole('button', { name: `Copy: ${workspace.path}`, exact: true })
await workspaceRow.getByText(workspaceLabel, { exact: true }).hover()
await details.waitFor()
await workspaceRow.getByRole('button', { name: `New session in ${workspaceLabel}`, exact: true }).hover()
await page.getByRole('tooltip').filter({ hasText: 'New Session' }).waitFor()
await expect.poll(() => details.count()).toBe(0)
await workspaceRow.getByText(workspaceLabel, { exact: true }).hover()
await details.waitFor()
await page.mouse.move(700, 100)
await bind('New Session')
await page.keyboard.press('Meta+Shift+Comma')
await expect.poll(() => page.getByRole('treeitem', { selected: true }).textContent()).toContain('New Session')
expect(workspace.sessionIds.length).toBe(1)
await bind('Open locally')
await page.keyboard.press('Meta+Shift+Comma')
await expect.poll(() => launched).toEqual([{ app: 'vscode', path: workspace.path }])
expect(console.pageErrors).toEqual([])
})
})
it.skipIf(mode === 'record')('explains a first-turn fork refusal and retries from the completed prefix', async () => {
const scaffold = await launchWebScaffold({ developerTools: false })
let browser: Browser | undefined
try {
browser = await chromium.launch()
const context = await browser.newContext({ locale: 'en-US', viewport: { width: 1440, height: 1000 } })
await context.addInitScript(() => { Object.defineProperty(navigator, 'platform', { value: 'MacIntel' }) })
const page = await context.newPage()
const tripwire = watchConsole(page)
const forkWarnings: string[] = []
page.on('console', (message) => { if (message.text().startsWith('session fork rejected:')) forkWarnings.push(message.text()) })
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
const before = new Set(scaffold.ctx.agents.list().map(agent => agent.id))
await connectFreshWorkspace(page, scaffold.workspaceCwd)
const source = scaffold.ctx.agents.list().find(agent => !before.has(agent.id))
if (source === undefined) throw new Error('The workspace did not create a Session')
source.session.append('turn/start', { turn: 1 })
const user = source.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'The first turn has not ended.' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
source.session.append('session/title', { title: 'First turn', messageSeqs: [user.seq], source: { kind: 'fallback' } })
await scaffold.ctx.sessions.flush(source.session)
await page.getByText('The first turn has not ended.', { exact: true }).waitFor()
const selected = page.getByRole('treeitem', { selected: true })
await selected.filter({ hasText: 'First turn' }).waitFor()
await page.keyboard.press('Meta+Slash')
const reference = page.getByRole('dialog', { name: 'Keyboard shortcuts', exact: true })
await reference.getByRole('button', { name: 'Edit shortcut for Fork session', exact: true }).click()
await page.keyboard.press('Meta+Shift+Comma')
await reference.getByRole('group').waitFor({ state: 'hidden' })
await page.keyboard.press('Escape')
await reference.waitFor({ state: 'hidden' })
await page.keyboard.press('Meta+Shift+Comma')
const unavailable = page.getByRole('alert').filter({ hasText: 'This session has no completed turn' })
await unavailable.waitFor()
await compareOrRefreshGolden(join(root, 'fork-unavailable.expected.md'),
await unavailable.ariaSnapshot(), mode)
expect(await selected.textContent()).toContain('First turn')
expect(scaffold.ctx.agents.list().filter(agent => agent.session.header.parentSession === source.id)).toHaveLength(0)
expect(forkWarnings).toEqual([])
source.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
const completed = source.session.snapshotEvents()
source.session.append('turn/start', { turn: 2 })
source.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'The second turn has not ended.' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
await scaffold.ctx.sessions.flush(source.session)
await page.getByText('The second turn has not ended.', { exact: true }).waitFor()
await page.keyboard.press('Meta+Shift+Comma')
await expect.poll(() => scaffold.ctx.agents.list().find(agent => agent.session.header.parentSession === source.id)).toBeDefined()
const child = scaffold.ctx.agents.list().find(agent => agent.session.header.parentSession === source.id)!
const childRow = page.getByRole('treeitem').filter({ has: page.getByText('First turn (1)', { exact: true }) })
await childRow.waitFor()
expect(await childRow.getAttribute('aria-selected')).toBe('false')
expect(await selected.getByText('First turn', { exact: true }).count()).toBe(1)
expect((await readPersistedEvents(scaffold, child.id)).slice(0, completed.length)).toEqual(completed)
await page.getByText('The second turn has not ended.', { exact: true }).waitFor()
expect(tripwire.warnings).toEqual([])
expect(tripwire.pageErrors).toEqual([])
} finally {
await browser?.close()
await scaffold.close()
}
})
it.skipIf(mode === 'record').each([
{ platform: 'Win32', primary: 'Control', file: '../shortcuts/en-US', custom: 'Control+Alt+Shift+Meta+J' },
{ platform: 'MacIntel', primary: 'Meta', file: 'macos-defaults', custom: 'Meta+Alt+Shift+J' },
])('runs $platform defaults over recorded history and restores them after a multi-modifier binding', async ({ platform, primary, file, custom }) => {
const scaffold = await launchWebScaffold({ developerTools: false })
let browser: Browser | undefined
try {
await seedSession(scaffold, await readFile(seed, 'utf8'), SessionId('shortcut-defaults-source'))
browser = await chromium.launch()
const context = await browser.newContext({ locale: 'en-US', viewport: { width: 1440, height: 1000 } })
await context.addInitScript((value) => { Object.defineProperty(navigator, 'platform', { value }) }, platform)
const page = await context.newPage()
const console = watchConsole(page)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
const group = page.getByRole('treeitem').first()
await group.waitFor()
if (await group.getAttribute('aria-expanded') !== 'true') await group.click()
await page.getByRole('treeitem').nth(1).click()
await page.getByText('DONE', { exact: true }).waitFor()
await page.keyboard.press(`${primary}+Slash`)
const reference = page.getByRole('dialog', { name: 'Keyboard shortcuts', exact: true })
await reference.waitFor()
await compareOrRefreshGolden(join(root, `${file}.expected.md`),
await captureStableAria(page, '[data-shortcut-modal="shortcuts"]', scaffold.workspaceCwd), mode)
await page.keyboard.press('Escape')
await reference.waitFor({ state: 'hidden' })
await page.keyboard.press(`${primary}+Alt+K`)
const search = page.getByPlaceholder('Search session names', { exact: true })
await expect.poll(() => search.evaluate(element => element === document.activeElement)).toBe(true)
const searchAppearance = await search.evaluate((element) => {
const input = getComputedStyle(element)
const surface = getComputedStyle(element.parentElement!)
return { outline: input.outlineStyle, shadow: input.boxShadow,
surfaceOutline: surface.outlineStyle, surfaceShadow: surface.boxShadow }
})
expect(searchAppearance).toEqual({ outline: 'none', shadow: 'none', surfaceOutline: 'none', surfaceShadow: 'none' })
await page.keyboard.press('Escape')
expect(await search.evaluate(element => getComputedStyle(element).boxShadow)).toBe(searchAppearance.shadow)
await page.keyboard.press(`${primary}+Alt+B`)
await page.getByRole('button', { name: 'Open sidebar', exact: true }).waitFor()
await page.keyboard.press(`${primary}+Alt+B`)
await page.getByRole('button', { name: 'Collapse sidebar', exact: true }).waitFor()
await page.keyboard.press(`${primary}+Slash`)
await reference.getByRole('button', { name: 'Edit shortcut for Toggle left sidebar', exact: true }).click()
await page.keyboard.press(custom)
await reference.getByRole('group').waitFor({ state: 'hidden' })
await page.keyboard.press('Escape')
await page.keyboard.press(custom)
await page.getByRole('button', { name: 'Open sidebar', exact: true }).waitFor()
await page.keyboard.press(custom)
await page.keyboard.press(`${primary}+Slash`)
await reference.getByRole('button', { name: 'Edit shortcut for Toggle left sidebar', exact: true }).click()
await reference.getByRole('button', { name: 'Restore default', exact: true }).click()
await reference.getByRole('group').waitFor({ state: 'hidden' })
await page.keyboard.press('Escape')
expect(await page.getByRole('button', { name: 'Collapse sidebar', exact: true }).getAttribute('aria-keyshortcuts')).toBe(primary === 'Meta' ? 'Alt+Meta+B' : 'Control+Alt+B')
if (platform === 'MacIntel') {
await page.keyboard.press('Meta+Slash')
await reference.getByRole('button', { name: 'Edit shortcut for New Session', exact: true }).click()
const recorder = reference.getByRole('button', { name: 'Press a shortcut', exact: true })
// Reproduce the macOS Dead-key payload through the real recorder and command owners.
expect(await recorder.evaluate((element) => {
const event = new KeyboardEvent('keydown', { key: 'Dead', code: 'KeyN', metaKey: true, altKey: true, bubbles: true, cancelable: true })
element.dispatchEvent(event)
element.dispatchEvent(new KeyboardEvent('keyup', { key: 'Meta', code: 'MetaLeft', bubbles: true }))
return event.defaultPrevented
})).toBe(true)
await reference.getByRole('group').waitFor({ state: 'hidden' })
await page.keyboard.press('Escape')
const composer = page.locator('[data-composer-input]').first()
await composer.focus()
expect(await composer.evaluate((element) => {
const event = new KeyboardEvent('keydown', { key: 'Dead', code: 'KeyN', metaKey: true, altKey: true, bubbles: true, cancelable: true })
element.dispatchEvent(event)
return event.defaultPrevented
})).toBe(true)
} else {
await page.keyboard.press(`${primary}+Alt+N`)
}
await page.getByText('DONE', { exact: true }).waitFor({ state: 'hidden' })
await expect.poll(() => page.getByRole('treeitem', { selected: true }).count()).toBe(0)
await page.locator('[data-composer-input]').first().waitFor()
expect(console.pageErrors).toEqual([])
} finally {
await browser?.close()
await scaffold.close()
}
})
+395
View File
@@ -0,0 +1,395 @@
/** Real Web composition: keyboard routing, inline editing, and localized system feedback. */
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import { chromium, type Browser } from 'playwright'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { openSettings } from './support.ts'
import { captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts'
const expected = fileURLToPath(new URL('./expected/shortcuts', import.meta.url))
const mode = webSnapshotMode()
describe('web e2e: shortcut reference', () => {
let scaffold: WebScaffold
let browser: Browser
beforeAll(async () => {
scaffold = await launchWebScaffold({ developerTools: false })
browser = await chromium.launch()
}, 120_000)
afterAll(async () => { await browser?.close(); await scaffold?.close() })
it.each([
{ locale: 'zh-CN', platform: 'MacIntel', title: '快捷键', settings: '设置', view: '查看快捷键', search: '搜索快捷键', key: 'Meta' },
{ locale: 'en-US', platform: 'Win32', title: 'Keyboard shortcuts', settings: 'Settings', view: 'View shortcuts', search: 'Search shortcuts', key: 'Control' },
])('opens, searches, and restores nested focus in $locale', async ({ locale, platform, title, settings, view, search, key }) => {
const referenceKey = platform === 'Win32' ? 'Control+Slash' : 'Meta+Slash'
const context = await browser.newContext({ locale, colorScheme: 'light', viewport: { width: 1440, height: 1000 } })
try {
// Branch coverage for device labels; native Windows input is separately verified on Windows.
await context.addInitScript((value) => { Object.defineProperty(navigator, 'platform', { value }) }, platform)
const page = await context.newPage()
const console = watchConsole(page)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
const trigger = page.getByRole('button', { name: settings, exact: true })
expect(await trigger.getAttribute('aria-keyshortcuts')).toBe(`${key}+,`)
const settingsHint = `${settings} ${key === 'Meta' ? '⌘ ,' : 'Ctrl + ,'}`
await trigger.hover()
await page.getByRole('tooltip', { name: settingsHint, exact: true }).waitFor()
await trigger.click()
await page.getByRole('button', { name: view, exact: true }).click()
const dialog = page.getByRole('dialog', { name: title, exact: true })
await dialog.waitFor()
const referenceNode = await dialog.elementHandle()
expect(await page.getByRole('dialog').count()).toBe(2)
expect(await dialog.locator('footer').count()).toBe(1)
const resetAll = dialog.getByRole('button', { name: locale === 'zh-CN' ? '恢复全部默认' : 'Restore all defaults', exact: true })
expect(await resetAll.isDisabled()).toBe(true)
expect(await dialog.boundingBox()).toMatchObject({ width: 480, height: 600 })
expect(await dialog.getByRole('search').boundingBox()).toMatchObject({ width: 432, height: 36 })
const rows = dialog.getByRole('listitem')
const stopRow = dialog.getByRole('region', { name: locale === 'zh-CN' ? '应用操作' : 'Application', exact: true })
.getByRole('listitem').last()
await stopRow.getByText(locale === 'zh-CN' ? '停止生成' : 'Stop generating', { exact: true }).waitFor()
const stopButtons = stopRow.getByRole('button')
expect(await stopButtons.count()).toBe(1)
expect(await stopButtons.isDisabled()).toBe(true)
expect(new Set(await rows.evaluateAll(items => items.map(row => row.getBoundingClientRect().height))))
.toEqual(new Set([42]))
expect(await rows.evaluateAll(items => items.every(row => row.getBoundingClientRect().height >= 42
&& row.scrollWidth <= row.clientWidth))).toBe(true)
const searchInput = page.getByRole('searchbox', { name: search })
expect(await searchInput.evaluate(element => element === document.activeElement)).toBe(true)
expect(await searchInput.evaluate(element => ({
outline: getComputedStyle(element).outlineStyle, shadow: getComputedStyle(element).boxShadow,
}))).toEqual({ outline: 'none', shadow: 'none' })
const searchSurfaceStyle = () => dialog.getByRole('search').evaluate((element) => {
const style = getComputedStyle(element)
return { border: style.borderColor, outline: style.outlineStyle, shadow: style.boxShadow }
})
const focusedSearchSurface = await searchSurfaceStyle()
expect(focusedSearchSurface).toMatchObject({ outline: 'none', shadow: 'none' })
const dialogShadow = await dialog.evaluate(element => getComputedStyle(element).boxShadow)
await compareOrRefreshGolden(join(expected, `${locale}.expected.md`),
await captureStableAria(page, '[data-shortcut-modal="shortcuts"]', scaffold.workspaceCwd), mode)
const fixedButtons = dialog.getByRole('button', { disabled: true })
const fixedButton = fixedButtons.first()
expect(await fixedButton.evaluate(element => getComputedStyle(element).opacity)).toBe('0.45')
const fixedBackground = await fixedButton.locator('span').evaluate(element => getComputedStyle(element).backgroundColor)
await fixedButton.hover()
expect(await fixedButton.locator('span').evaluate(element => getComputedStyle(element).backgroundColor)).toBe(fixedBackground)
const editLabel = locale === 'zh-CN' ? '修改展开/收起左侧栏快捷键' : 'Edit shortcut for Toggle left sidebar'
const removeLabel = locale === 'zh-CN' ? '移除展开/收起左侧栏快捷键' : 'Remove shortcut for Toggle left sidebar'
const row = dialog.getByRole('listitem').filter({ has: page.getByRole('button', { name: editLabel, exact: true }) })
const editButton = page.getByRole('button', { name: editLabel, exact: true })
expect(await editButton.evaluate(element => getComputedStyle(element.parentElement!).opacity)).toBe('0')
const boundRow = dialog.getByRole('listitem').filter({ has: page.getByText(
locale === 'zh-CN' ? '快捷键速查' : 'Open keyboard shortcuts', { exact: true },
) })
const badge = boundRow.getByRole('button').last()
const badgeColor = () => badge.locator('span').evaluate(element => getComputedStyle(element).backgroundColor)
const restingColor = await badgeColor()
await boundRow.locator(':scope > span').first().hover()
expect(await badgeColor()).toBe(restingColor)
await badge.hover()
expect(await badgeColor()).toBe('rgba(38, 49, 72, 0.14)')
await boundRow.getByRole('button').first().hover()
expect(await badgeColor()).toBe(restingColor)
await badge.click()
await boundRow.getByRole('group').waitFor()
expect(await searchSurfaceStyle()).toEqual(focusedSearchSurface)
expect(new Set(await rows.evaluateAll(items => items.map(row => row.getBoundingClientRect().height))))
.toEqual(new Set([42]))
expect(await boundRow.getByRole('button', { name: locale === 'zh-CN' ? '移除' : 'Remove', exact: true }).count()).toBe(1)
await page.keyboard.press('Escape')
await boundRow.getByRole('group').waitFor({ state: 'hidden' })
await dialog.getByRole('heading', { name: title, exact: true }).hover()
const boundActionsOpacity = () => boundRow.getByRole('button').first().evaluate(element => getComputedStyle(element.parentElement!).opacity)
expect(await boundActionsOpacity()).toBe('0')
expect(await dialog.evaluate(element => element === document.activeElement)).toBe(true)
expect(await dialog.evaluate(element => getComputedStyle(element).outlineStyle)).toBe('none')
expect(await dialog.evaluate(element => getComputedStyle(element).boxShadow)).toBe(dialogShadow)
await page.keyboard.press('Shift+Tab')
expect(await dialog.getByRole('button', { disabled: false }).last().evaluate(element => element === document.activeElement)).toBe(true)
expect(await dialog.getByRole('button', { disabled: false }).last().evaluate(element => getComputedStyle(element).outlineStyle)).not.toBe('none')
await badge.click()
await boundRow.getByRole('button', { name: locale === 'zh-CN' ? '恢复默认' : 'Restore default', exact: true }).click()
await boundRow.getByRole('group').waitFor({ state: 'hidden' })
await dialog.getByRole('heading', { name: title, exact: true }).hover()
expect(await boundActionsOpacity()).toBe('0')
await row.hover()
expect(await editButton.evaluate(element => getComputedStyle(element.parentElement!).opacity)).toBe('1')
expect(await row.getByRole('button', { name: removeLabel, exact: true }).count()).toBe(1)
await editButton.click()
const inline = dialog.getByRole('group', { name: locale === 'zh-CN' ? '展开/收起左侧栏' : 'Toggle left sidebar', exact: true })
const recorder = inline.getByRole('button', { name: locale === 'zh-CN' ? '按下快捷键' : 'Press a shortcut', exact: true })
expect(await inline.getByRole('button', { name: locale === 'zh-CN' ? '移除' : 'Remove', exact: true }).count()).toBe(1)
expect(await page.getByRole('dialog').count()).toBe(2)
expect(await recorder.evaluate(element => getComputedStyle(element).color)).toBe('rgb(129, 133, 140)')
expect(await recorder.evaluate(element => ({
focused: element === document.activeElement,
outline: getComputedStyle(element).outlineStyle, shadow: getComputedStyle(element).boxShadow,
}))).toEqual({ focused: true, outline: 'none', shadow: 'none' })
await compareOrRefreshGolden(join(expected, `${locale}-inline.expected.md`),
await captureStableAria(page, '[data-shortcut-modal="shortcut-edit"]', scaffold.workspaceCwd), mode)
await page.keyboard.press(referenceKey)
const errorToast = page.getByRole('alert').filter({ hasText: locale === 'zh-CN' ? '已被「快捷键速查」占用' : 'Already used by “Open keyboard shortcuts”' })
await errorToast.waitFor()
expect(await errorToast.evaluate(element => ({
background: getComputedStyle(element).backgroundColor, color: getComputedStyle(element).color,
})))
.toEqual({ background: 'rgb(53, 54, 56)', color: 'rgb(255, 255, 255)' })
expect(await errorToast.evaluate(element => element.closest('[role="dialog"]') === null)).toBe(true)
expect(await recorder.evaluate(element => ({
color: getComputedStyle(element).borderColor, width: getComputedStyle(element).borderWidth,
})))
.toEqual({ color: 'rgb(236, 19, 19)', width: '1px' })
await compareOrRefreshGolden(join(expected, `${locale}-conflict.expected.md`), await errorToast.ariaSnapshot(), mode)
await page.keyboard.press('i')
await page.getByRole('alert').filter({ hasText: locale === 'zh-CN'
? '请同时按下 Command、Ctrl 或 Alt 修饰键。' : 'Include Command, Ctrl, or Alt in the combination.' }).waitFor()
await page.keyboard.press(`${key}+Shift+.`)
await inline.waitFor({ state: 'hidden' })
expect(await dialog.evaluate(element => element === document.activeElement)).toBe(true)
expect(await dialog.evaluate(element => getComputedStyle(element).outlineStyle)).toBe('none')
expect(await dialog.evaluate(element => getComputedStyle(element).boxShadow)).toBe(dialogShadow)
expect(await editButton.evaluate(element => element.matches(':focus-visible'))).toBe(false)
await dialog.getByRole('heading', { name: title, exact: true }).hover()
expect(await editButton.evaluate(element => getComputedStyle(element.parentElement!).opacity)).toBe('0')
await page.keyboard.press('Shift+Tab')
expect(await resetAll.evaluate(element => element === document.activeElement)).toBe(true)
expect(await resetAll.evaluate(element => getComputedStyle(element).outlineStyle)).not.toBe('none')
const successToast = page.getByRole('alert').filter({ hasText: locale === 'zh-CN' ? '已修改' : 'Modified' })
await successToast.waitFor()
await compareOrRefreshGolden(join(expected, `${locale}-saved.expected.md`), await successToast.ariaSnapshot(), mode)
await page.emulateMedia({ colorScheme: 'dark' })
await expect.poll(() => successToast.evaluate(element => ({
background: getComputedStyle(element).backgroundColor, color: getComputedStyle(element).color,
}))).toEqual({ background: 'rgb(67, 69, 74)', color: 'rgb(255, 255, 255)' })
await page.emulateMedia({ colorScheme: 'light' })
await expect.poll(() => successToast.evaluate(element => getComputedStyle(element).backgroundColor)).toBe('rgb(53, 54, 56)')
await page.getByRole('button', { name: removeLabel, exact: true }).click()
const unbound = row.getByText(locale === 'zh-CN' ? '暂无快捷键' : 'No shortcut', { exact: true })
await unbound.waitFor()
expect(await unbound.evaluate(element => getComputedStyle(element).color)).toBe('rgb(129, 133, 140)')
expect(await row.getByRole('button', { name: removeLabel, exact: true }).count()).toBe(0)
await unbound.click()
await inline.waitFor()
expect(await inline.getByRole('button', { name: locale === 'zh-CN' ? '移除' : 'Remove', exact: true }).count()).toBe(0)
await page.keyboard.press('Escape')
await inline.waitFor({ state: 'hidden' })
expect(await dialog.evaluate(element => element === document.activeElement)).toBe(true)
expect(await dialog.evaluate(element => getComputedStyle(element).outlineStyle)).toBe('none')
expect(await dialog.evaluate(element => getComputedStyle(element).boxShadow)).toBe(dialogShadow)
await dialog.getByRole('heading', { name: title, exact: true }).hover()
expect(await editButton.evaluate(element => getComputedStyle(element.parentElement!).opacity)).toBe('0')
expect(await page.getByRole('dialog').count()).toBe(2)
for (const query of ['abc', 'sendEnter']) {
await page.getByRole('searchbox').fill(query)
expect(await dialog.getByRole('listitem').count()).toBe(0)
expect(await dialog.getByRole('status').textContent()).toBe(locale === 'zh-CN' ? '没有匹配的快捷键' : 'No matching shortcuts')
if (query === 'abc') await compareOrRefreshGolden(join(expected, `${locale}-empty.expected.md`),
await captureStableAria(page, '[data-shortcut-modal="shortcuts"]', scaffold.workspaceCwd), mode)
}
for (const query of [locale === 'zh-CN' ? '左侧栏' : 'toggle left', 'tgllft', 'toggle left sidebar']) {
await page.getByRole('searchbox').fill(query)
expect(await dialog.getByRole('listitem').count()).toBe(1)
expect(await dialog.getByRole('listitem').textContent()).toContain(locale === 'zh-CN' ? '展开/收起左侧栏' : 'Toggle left sidebar')
}
await page.keyboard.press(`${key}+Comma`)
expect(await referenceNode!.evaluate(element => element.isConnected)).toBe(true)
expect(await page.getByRole('dialog').count()).toBe(2)
expect(await page.getByRole('searchbox').inputValue()).toBe('toggle left sidebar')
expect(await searchInput.evaluate(element => getComputedStyle(element).outlineStyle)).toBe('none')
expect(await searchSurfaceStyle()).toEqual(focusedSearchSurface)
await page.keyboard.press(referenceKey)
await dialog.waitFor({ state: 'hidden' })
expect(await page.getByRole('dialog').count()).toBe(1)
expect(await page.getByRole('button', { name: view, exact: true }).evaluate(element => element === document.activeElement)).toBe(true)
expect(await page.getByRole('button', { name: view, exact: true }).getAttribute('data-dsh-automatic-focus')).toBe('')
expect(await page.getByRole('button', { name: view, exact: true }).evaluate(element => getComputedStyle(element).outlineStyle)).toBe('none')
await page.keyboard.press(`${key}+Comma`)
await page.getByRole('dialog', { name: settings, exact: true }).waitFor({ state: 'hidden' })
expect(await page.getByRole('dialog').count()).toBe(0)
expect(await trigger.evaluate(element => element === document.activeElement)).toBe(true)
expect(await trigger.getAttribute('data-dsh-automatic-focus')).toBe('')
expect(await trigger.evaluate(element => getComputedStyle(element).outlineStyle)).toBe('none')
await page.keyboard.press(referenceKey)
await dialog.waitFor()
await page.keyboard.press(`${key}+Comma`)
expect(await page.getByRole('dialog', { name: settings, exact: true }).isVisible()).toBe(false)
expect(await page.getByRole('dialog').count()).toBe(1)
expect(await searchInput.evaluate(element => element === document.activeElement)).toBe(true)
expect(await searchInput.evaluate(element => getComputedStyle(element).outlineStyle)).toBe('none')
await page.keyboard.press(`${key}+/`)
await dialog.waitFor({ state: 'hidden' })
expect(await page.getByRole('dialog').count()).toBe(0)
await page.keyboard.press(referenceKey)
await dialog.waitFor()
expect(await page.getByRole('dialog').count()).toBe(1)
expect(await page.getByRole('searchbox').inputValue()).toBe('')
await page.setViewportSize({ width: 420, height: 500 })
const compactBounds = (await dialog.boundingBox())!
expect(compactBounds.x).toBeGreaterThanOrEqual(24)
expect(compactBounds.x + compactBounds.width).toBeLessThanOrEqual(396)
expect(compactBounds.y + compactBounds.height).toBeLessThanOrEqual(476)
await dialog.getByRole('region').last().scrollIntoViewIfNeeded()
expect(await dialog.getByRole('heading', { name: title }).isVisible()).toBe(true)
await page.keyboard.press('Escape')
// Observe preventDefault without invoking destructive browser defaults in the test runner.
expect(await page.evaluate(modifier => ['KeyN', 'KeyP', 'KeyO', 'KeyR', 'KeyW', 'KeyB'].map((code) => {
const event = new KeyboardEvent('keydown', { code, ctrlKey: modifier === 'Control', metaKey: modifier === 'Meta', bubbles: true, cancelable: true })
document.body.dispatchEvent(event)
return event.defaultPrevented
}), key)).toEqual(Array<boolean>(6).fill(false))
await page.setViewportSize({ width: 1440, height: 1000 })
await openSettings(page, locale === 'zh-CN' ? 'zh' : 'en')
const viewButton = page.getByRole('button', { name: view, exact: true })
const viewHint = `${view} ${key === 'Meta' ? '⌘ /' : 'Ctrl + /'}`
await viewButton.hover()
expect(await page.getByRole('tooltip').textContent()).toBe(viewHint)
await viewButton.click()
await boundRow.getByRole('button').first().click()
await boundRow.getByText(locale === 'zh-CN' ? '暂无快捷键' : 'No shortcut', { exact: true }).waitFor()
await page.keyboard.press('Escape')
await dialog.waitFor({ state: 'hidden' })
expect(await viewButton.evaluate(element => element === document.activeElement)).toBe(true)
expect(await viewButton.getAttribute('data-dsh-automatic-focus')).toBe('')
expect(await viewButton.evaluate(element => getComputedStyle(element).outlineStyle)).toBe('none')
await viewButton.hover()
expect(await page.getByRole('tooltip').count()).toBe(0)
expect(await viewButton.getAttribute('aria-keyshortcuts')).toBeNull()
await viewButton.click()
await badge.click()
await boundRow.getByRole('button', { name: locale === 'zh-CN' ? '恢复默认' : 'Restore default', exact: true }).click()
await boundRow.getByRole('group').waitFor({ state: 'hidden' })
await page.keyboard.press('Escape')
await dialog.waitFor({ state: 'hidden' })
await viewButton.hover()
expect(await page.getByRole('tooltip').textContent()).toBe(viewHint)
await viewButton.click()
await page.getByRole('searchbox').fill('abc')
expect(await dialog.getByRole('listitem').count()).toBe(0)
expect(await dialog.locator('footer').textContent()).toContain(locale === 'zh-CN' ? '1 项已自定义' : '1 customized')
await resetAll.click()
const confirm = page.getByRole('dialog', { name: locale === 'zh-CN' ? '恢复全部默认快捷键?' : 'Restore all default shortcuts?', exact: true })
const cancel = confirm.getByRole('button', { name: locale === 'zh-CN' ? '取消' : 'Cancel', exact: true })
expect(await cancel.evaluate(element => element === document.activeElement)).toBe(true)
expect(await cancel.evaluate(element => getComputedStyle(element).outlineStyle)).toBe('none')
await compareOrRefreshGolden(join(expected, `${locale}-reset-confirm.expected.md`), await confirm.ariaSnapshot(), mode)
await cancel.click()
expect(await resetAll.evaluate(element => element === document.activeElement)).toBe(true)
expect(await resetAll.getAttribute('data-dsh-automatic-focus')).toBe('')
expect(await resetAll.evaluate(element => getComputedStyle(element).outlineStyle)).toBe('none')
expect(await dialog.locator('footer').textContent()).toContain(locale === 'zh-CN' ? '1 项已自定义' : '1 customized')
await resetAll.click()
await confirm.getByRole('button', { name: locale === 'zh-CN' ? '恢复默认' : 'Restore default', exact: true }).click()
await confirm.waitFor({ state: 'hidden' })
expect(await resetAll.isDisabled()).toBe(true)
expect(await dialog.locator('footer').textContent()).toContain(locale === 'zh-CN' ? '0 项已自定义' : '0 customized')
expect(await page.getByRole('searchbox').evaluate(element => element === document.activeElement)).toBe(true)
expect(await searchInput.evaluate(element => getComputedStyle(element).outlineStyle)).toBe('none')
expect(await searchSurfaceStyle()).toEqual(focusedSearchSurface)
await page.keyboard.press('Escape')
await page.keyboard.press('Escape')
await page.keyboard.press(referenceKey)
const settingsCommand = locale === 'zh-CN' ? '打开设置' : 'Open settings'
await dialog.getByRole('button', { name: locale === 'zh-CN' ? `修改${settingsCommand}快捷键` : `Edit shortcut for ${settingsCommand}`, exact: true }).click()
await page.keyboard.press(`${key}+Shift+Comma`)
await dialog.getByRole('group').waitFor({ state: 'hidden' })
await page.keyboard.press('Escape')
expect(await trigger.getAttribute('aria-keyshortcuts')).toBe(key === 'Meta' ? 'Shift+Meta+,' : 'Control+Shift+,')
await trigger.hover()
await page.getByRole('tooltip', { name: `${settings} ${key === 'Meta' ? '⇧ ⌘ ,' : 'Ctrl + Shift + ,'}`, exact: true }).waitFor()
await trigger.click()
await page.keyboard.press('Escape')
expect(await trigger.evaluate(element => element === document.activeElement)).toBe(true)
await page.keyboard.press(referenceKey)
await dialog.getByRole('button', { name: locale === 'zh-CN' ? `修改${settingsCommand}快捷键` : `Edit shortcut for ${settingsCommand}`, exact: true }).click()
await dialog.getByRole('button', { name: locale === 'zh-CN' ? '恢复默认' : 'Restore default', exact: true }).click()
await dialog.getByRole('group').waitFor({ state: 'hidden' })
await page.keyboard.press('Escape')
expect(await trigger.getAttribute('aria-keyshortcuts')).toBe(`${key}+,`)
await trigger.hover()
await page.getByRole('tooltip', { name: settingsHint, exact: true }).waitFor()
const sidebar = page.getByRole('button', { name: locale === 'zh-CN' ? '收起侧边栏' : 'Collapse sidebar', exact: true })
expect(await sidebar.getAttribute('aria-keyshortcuts')).toBe(key === 'Meta' ? 'Alt+Meta+B' : 'Control+Alt+B')
await page.reload({ waitUntil: 'load' })
await sidebar.waitFor()
expect(await sidebar.getAttribute('aria-keyshortcuts')).toBe(key === 'Meta' ? 'Alt+Meta+B' : 'Control+Alt+B')
expect(console.pageErrors).toEqual([])
expect(console.warnings).toEqual([])
} finally { await context.close() }
})
it.each([
{ platform: 'Win32', modifier: 'Control', defaultBinding: 'Control+Alt+B' },
{ platform: 'MacIntel', modifier: 'Meta', defaultBinding: 'Alt+Meta+B' },
{ platform: 'Linux x86_64', modifier: 'Control', defaultBinding: null },
])('records, rejects conflicts, persists across reload, and synchronizes stale drafts between tabs on $platform', async ({ platform, modifier, defaultBinding }) => {
const context = await browser.newContext({ locale: 'en-US', viewport: { width: 1440, height: 1000 } })
try {
await context.addInitScript((value) => { Object.defineProperty(navigator, 'platform', { value }) }, platform)
const page = await context.newPage()
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await page.getByRole('button', { name: 'Settings', exact: true }).waitFor()
const referenceKey = `${modifier}+Slash`
const open = async () => {
await page.keyboard.press(referenceKey)
await page.getByRole('dialog', { name: 'Keyboard shortcuts', exact: true }).waitFor()
}
await open()
await page.getByRole('button', { name: 'Edit shortcut for Toggle left sidebar', exact: true }).click()
const editor = page.getByRole('group', { name: 'Toggle left sidebar', exact: true })
await editor.getByRole('button', { name: 'Press a shortcut', exact: true }).focus()
await page.keyboard.press(referenceKey)
await page.getByRole('alert').filter({ hasText: 'Already used by “Open keyboard shortcuts”' }).waitFor()
expect(await editor.getByRole('button', { name: 'Press a shortcut', exact: true }).getAttribute('aria-invalid')).toBe('true')
await page.keyboard.press(`${modifier}+Shift+.`)
await editor.waitFor({ state: 'hidden' })
await page.keyboard.press('Escape')
const sidebarKey = `${modifier}+Shift+.`
expect(await page.getByRole('button', { name: 'Collapse sidebar', exact: true }).getAttribute('aria-keyshortcuts'))
.toBe(modifier === 'Meta' ? 'Shift+Meta+.' : 'Control+Shift+.')
await page.keyboard.press(sidebarKey)
await page.getByRole('button', { name: 'Open sidebar', exact: true }).waitFor()
await page.reload({ waitUntil: 'load' })
await page.getByRole('button', { name: 'Settings', exact: true }).waitFor()
const toggleAfterReload = page.getByRole('button', { name: /^(Open|Collapse) sidebar$/ })
await expect.poll(() => toggleAfterReload.getAttribute('aria-keyshortcuts'))
.toBe(modifier === 'Meta' ? 'Shift+Meta+.' : 'Control+Shift+.')
const wasOpen = await page.getByRole('button', { name: 'Collapse sidebar', exact: true }).isVisible()
await page.keyboard.press(sidebarKey)
await page.getByRole('button', { name: wasOpen ? 'Open sidebar' : 'Collapse sidebar', exact: true }).waitFor()
if (wasOpen) await page.getByRole('button', { name: 'Open sidebar', exact: true }).click()
await open()
await page.getByRole('button', { name: 'Edit shortcut for Toggle left sidebar', exact: true }).click()
await editor.getByRole('button', { name: 'Press a shortcut', exact: true }).focus()
const other = await context.newPage()
await other.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await other.getByRole('button', { name: 'Settings', exact: true }).waitFor()
await other.keyboard.press(referenceKey)
await other.getByRole('button', { name: 'Remove shortcut for Toggle left sidebar', exact: true }).click()
await editor.getByText('Shortcut configuration or available commands changed. Review the latest bindings before saving.', { exact: true }).waitFor()
await page.bringToFront()
await editor.getByRole('button', { name: 'Press a shortcut', exact: true }).focus()
await page.keyboard.press(`${modifier}+Shift+,`)
expect(await editor.getByRole('button', { name: 'Retry save', exact: true }).count()).toBe(0)
await compareOrRefreshGolden(join(expected, 'edit-stale.expected.md'),
await captureStableAria(page, '[data-shortcut-modal="shortcut-edit"]', scaffold.workspaceCwd), mode)
await editor.getByRole('button', { name: 'I have reviewed the latest configuration', exact: true }).click()
await editor.getByRole('button', { name: 'Retry save', exact: true }).click()
await editor.waitFor({ state: 'hidden' })
await page.getByRole('button', { name: 'Edit shortcut for Toggle left sidebar', exact: true }).click()
await editor.getByRole('button', { name: 'Restore default', exact: true }).click()
await editor.waitFor({ state: 'hidden' })
await page.keyboard.press('Escape')
expect(await page.getByRole('button', { name: 'Collapse sidebar', exact: true }).getAttribute('aria-keyshortcuts'))
.toBe(defaultBinding)
const stored = await page.evaluate((): unknown => JSON.parse(localStorage.getItem('dsh.keybindings.v1')!))
const profile = platform === 'MacIntel' ? 'macos' : platform === 'Win32' ? 'windows' : 'linux'
expect(stored).toEqual({ schemaVersion: 1, profiles: { [`web:${profile}`]: {} } })
} finally { await context.close() }
})
})
+37 -4
View File
@@ -206,6 +206,33 @@ async function tabTitles(root: Locator): Promise<string[]> {
return await root.locator('[data-dockkit-tab-title]').allInnerTexts()
}
/** Compare the rendered glyph and text rather than their containing boxes. */
async function expectTitleAlignment(title: Locator): Promise<void> {
const geometry = await title.evaluate((node) => {
const icon = node.querySelector('svg')
const walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT)
let textBox: DOMRect | undefined
while (walker.nextNode()) {
if (!walker.currentNode.textContent?.trim()) continue
const range = document.createRange()
range.selectNodeContents(walker.currentNode)
const box = range.getBoundingClientRect()
if (box.width > 0 && box.height > 0) {
textBox = box
break
}
}
if (icon === null || textBox === undefined) throw new Error('tab icon or visible title text is missing')
const iconBox = icon.getBoundingClientRect()
return {
gap: textBox.left - iconBox.right,
centreOffset: Math.abs(iconBox.y + iconBox.height / 2 - textBox.y - textBox.height / 2),
}
})
expect(geometry.gap).toBeCloseTo(5, 2)
expect(geometry.centreOffset).toBeLessThan(1)
}
/**
* A rendered width, read once the frame's track transition has settled.
*
@@ -416,6 +443,7 @@ describe('web e2e: shipped right Sidebar', () => {
}
await expect.poll(async () => await tabTitles(column)).toEqual(['Start'])
await expectTitleAlignment(column.locator('[data-dockkit-tab-title]'))
await expect.poll(async () => await column.locator('[data-sidebar-right-guide-entry]').count()).toBe(2)
expect(await column.locator('[data-sidebar-right-guide-entry="browser"]').count()).toBe(0)
await column.locator('[data-sidebar-right-guide-entry="files"]').click()
@@ -425,6 +453,7 @@ describe('web e2e: shipped right Sidebar', () => {
const addTab = column.locator('[data-dockkit-add-tab]')
const filesTab = column.locator('[data-dockkit-tab]').filter({ hasText: 'Files' })
await expect.poll(async () => await tabTitles(column)).toEqual(['Files'])
await expectTitleAlignment(filesTab.locator('[data-dockkit-tab-title]'))
await column.locator('[data-files-state="tree"]').waitFor({ state: 'visible' })
expect(await filesTab.locator('[data-dockkit-tab-close]').count()).toBe(1)
await expect.poll(async () => await addTab.count()).toBe(1)
@@ -934,10 +963,11 @@ describe('web e2e: shipped right Sidebar', () => {
await expect.poll(async () => await tabTitles(paneAt(column, 1))).toContain(title)
const splitButtons = column.locator('[data-dockkit-split-button]')
await expect.poll(async () => await splitButtons.count()).toBe(0)
await expect.poll(async () => await splitButtons.count()).toBe(2)
expect(await splitButtons.evaluateAll(buttons => buttons.every(button => (button as HTMLButtonElement).disabled))).toBe(true)
expect(await panes.count()).toBe(2)
await setPanelWidth(page, 560)
await expect.poll(async () => await splitButtons.count()).toBe(0)
expect(await splitButtons.evaluateAll(buttons => buttons.every(button => (button as HTMLButtonElement).disabled))).toBe(true)
const outer = column.locator('[data-dockkit-divider]').first()
const before = await width(paneAt(column, 1))
@@ -956,12 +986,13 @@ describe('web e2e: shipped right Sidebar', () => {
await dragElement(page, outer, { x: surfaceBox.x + surfaceBox.width / 2, y: grip.y })
await expect.poll(ratio).toBeCloseTo(0.5, 2)
expect(await panes.count()).toBe(2)
expect(await splitButtons.count()).toBe(0)
expect(await splitButtons.evaluateAll(buttons => buttons.every(button => (button as HTMLButtonElement).disabled))).toBe(true)
// 5. The split's guide and the document float while Files stays docked.
const floatOne = paneAt(column, 1).locator('[data-dockkit-tab]').filter({ hasText: SAMPLE_NAME })
await floatByDrag(page, floatOne)
await expect.poll(async () => await floats.count()).toBe(1)
await expectTitleAlignment(floats.first().locator('[data-dockkit-tab-title]'))
const box = await floats.first().boundingBox()
if (box === null) throw new Error('float is not rendered')
await dragElement(page, floats.first().locator('[data-dockkit-float-grip]'), { x: box.x + 140, y: box.y + 90 })
@@ -971,6 +1002,7 @@ describe('web e2e: shipped right Sidebar', () => {
const second = paneAt(column, 1).locator('[data-dockkit-tab]').filter({ hasText: 'Start' })
await floatByDrag(page, second)
await expect.poll(async () => await floats.count()).toBe(2)
await expectTitleAlignment(floats.last().locator('[data-dockkit-tab-title]'))
// 6. Dock one back: the docked tree takes it, the other float stays. Dock
// the TOPMOST float — floats render bottom-to-top, so the newest one
@@ -1096,6 +1128,7 @@ describe('web e2e: shipped right Sidebar', () => {
// depends on a sibling block's setup passes only in the right order.
it('renders the shipped Chinese copy on a Chinese page', async () => {
const zhPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
await zhPage.addInitScript(() => { Object.defineProperty(navigator, 'platform', { configurable: true, value: 'MacIntel' }) })
const zhTripwire = watchConsole(zhPage)
onTestFailed(() => saveFailureShot(zhPage, 'web-e2e-sidebar-right-zh'))
try {
@@ -1121,7 +1154,7 @@ describe('web e2e: shipped right Sidebar', () => {
expect(await width(column)).toBeGreaterThan(300)
await expect.poll(async () => await tabTitles(column)).toEqual(['文件', '开始'])
await expect.poll(async () => await guide.locator('[data-sidebar-right-guide-entry="files"]').innerText())
.toBe('工作区文件\n浏览会话工作区的文件')
.toBe('工作区文件\n浏览会话工作区的文件\n⌥\n⌘\nP')
await shot(zhPage, '05-guide-copy-zh')
expect(zhTripwire.pageErrors).toEqual([])
+17 -2
View File
@@ -78,6 +78,8 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => {
scaffold = await launchWebScaffold({ extraOverlayPath: fileURLToPath(new URL('./fixtures/sidebar-terminal.patch.yml', import.meta.url)) })
browser = await chromium.launch()
const context = await browser.newContext({ viewport: { width: 1680, height: 1000 }, locale: 'en-US', timezoneId: 'Asia/Shanghai' })
// These snapshots use the Linux Web shortcut defaults.
await context.addInitScript(() => { Object.defineProperty(navigator, 'platform', { configurable: true, value: 'Linux x86_64' }) })
page = await context.newPage()
tripwire = watchConsole(page)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
@@ -473,9 +475,22 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => {
expect(alive(original)).toBe(false)
transport.reconnect()
await page.context().setOffline(false)
await page.reload({ waitUntil: 'load' })
await page.locator('[data-sidebar-right-expand]').click()
const environmentReady = Promise.withResolvers<undefined>()
const environmentUrl = new URL('/api/terminal/environment', scaffold.baseUrl).href
await page.route(environmentUrl, async (route) => {
await environmentReady.promise
await route.continue()
})
const terminal = page.locator('[data-sidebar-terminal]')
try {
await page.reload({ waitUntil: 'load' })
await page.locator('[data-sidebar-right-expand]').click()
// The title starts discovery before the lazy body mounts; keep that request pending through mounting.
await terminal.getByRole('status').getByText('Reading terminal environment…', { exact: true }).waitFor()
} finally {
environmentReady.resolve(undefined)
await page.unrouteAll({ behavior: 'wait' })
}
let unavailableSnapshot = ''
// Mounting can refresh a failed recovered view; readiness and comparison use the same DOM sample.
await expect.poll(async () => {
+17 -3
View File
@@ -19,6 +19,7 @@ import { fileURLToPath } from 'node:url'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { initialShortcutConfig } from '@deepseek-ai/dsh-client-shortcuts/protocol'
import {
INTERACTIVE_SELECTOR, RECALL_MARK, isDraggableAt, type RegionRect,
} from '@deepseek-ai/dsh-client-web/src/window-drag/regions.ts'
@@ -106,6 +107,11 @@ async function collectedRegions(page: Page, selector: string): Promise<Collected
const hits = interactive
? samples.filter(([x, y]) => {
const top = document.elementFromPoint(x, y)
// A pane's programmatic focus target also contains its window-drag strip.
// The strip owns those pixels; controls inside it still need their own subtraction.
const strip = top?.closest('[data-dockkit-strip][data-window-drag]')
if (element.matches('[data-dockkit-pane][tabindex="-1"]')
&& strip != null && element.contains(strip)) return false
return top !== null && (top === element || element.contains(top))
})
: []
@@ -197,11 +203,18 @@ describe('web e2e: macOS window drag coverage', () => {
*/
async function darwinPage(): Promise<{ page: Page; tripwire: ReturnType<typeof watchConsole> }> {
const page = await newEnglishPage(browser)
await page.addInitScript((value: string) => {
await page.addInitScript(({ value, snapshot }) => {
// The Desktop platform marker requires the preload's keyboard and preference capabilities.
Object.assign(window, { dshDesktop: { protocolVersion: 1,
keyboard: { subscribe: () => () => {}, closeWindow: async () => {} },
shortcuts: { get: async () => ({ ...snapshot, status: 'ready' }),
subscribe: () => () => {}, recording: async () => {},
edit: async () => ({ status: 'not-ready', snapshot }) },
} })
const mark = (): void => { document.documentElement.setAttribute('data-platform', value) }
if (document.documentElement === null) document.addEventListener('DOMContentLoaded', mark, { once: true })
else mark()
}, 'darwin')
}, { value: 'darwin', snapshot: initialShortcutConfig() })
const tripwire = watchConsole(page)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
@@ -461,7 +474,8 @@ describe('web e2e: macOS window drag coverage', () => {
it('keeps a covering overlay out of the drag surface', async () => {
const { page, tripwire } = await darwinPage()
try {
await page.locator('button[aria-haspopup="dialog"]').first().click()
await page.getByRole('button', { name: 'Account menu', exact: true }).click()
await page.getByRole('menuitem', { name: 'Settings', exact: true }).click()
const dialog = page.locator('[role="dialog"]').first()
await dialog.waitFor({ timeout: 15_000 })
await settled(page)
+8 -2
View File
@@ -22,12 +22,18 @@
// this client-registered project must not also hold them (one program
// cannot see both sides of the cordis Context merges).
"exclude": [
"tests/connection-recovery.e2e.ts",
"tests/server-restart.e2e.ts",
"tests/shortcuts-desktop.e2e.ts",
"tests/shortcuts-workspace.e2e.ts",
"tests/shortcuts-panels.e2e.ts",
"tests/shortcuts-fixed.e2e.ts",
"tests/shortcuts.e2e.ts",
"tests/composer-shortcuts.e2e.ts",
"tests/default-product-isolation.e2e.ts",
"tests/diff-context.e2e.ts",
"tests/code-card-layout.e2e.ts",
"tests/scaffold.ts",
"tests/connection-recovery.e2e.ts",
"tests/server-restart.e2e.ts",
"tests/voice-input.e2e.ts",
"tests/voice-setup.e2e.ts",
"tests/voice-download.e2e.ts",
+17
View File
@@ -495,6 +495,22 @@ export interface Config {
```
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-client-hmr -->
<!-- BEGIN GENERATED config-catalog:@deepseek-ai/dsh-client-shortcuts -->
<a id="deepseek-aidsh-client-shortcuts"></a>
## `@deepseek-ai/dsh-client-shortcuts`
- `source`: [`packages/client/shortcuts/src/config.ts:5`](../packages/client/shortcuts/src/config.ts)
```ts config-catalog
/** Fixed shortcut sequence settings. */
export interface Config {
/** Maximum interval between independent Escape presses for stopping a reply, in milliseconds. */
stopSequenceMs: number
}
```
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-client-shortcuts -->
<!-- BEGIN GENERATED config-catalog:@deepseek-ai/dsh-client-ui-plugin-manager -->
<a id="deepseek-aidsh-client-ui-plugin-manager"></a>
@@ -4268,6 +4284,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
| `@deepseek-ai/dsh-client-ui-settings-shell` | — | [`packages/client/ui-settings-shell/src/index.ts`](../packages/client/ui-settings-shell/src/index.ts) |
| `@deepseek-ai/dsh-client-ui-settings-subagent` | — | [`packages/client/ui-settings-subagent/src/index.ts`](../packages/client/ui-settings-subagent/src/index.ts) |
| `@deepseek-ai/dsh-client-ui-settings-web-search` | — | [`packages/client/ui-settings-web-search/src/index.ts`](../packages/client/ui-settings-web-search/src/index.ts) |
| `@deepseek-ai/dsh-client-ui-shortcuts` | — | [`packages/client/ui-shortcuts/src/index.ts`](../packages/client/ui-shortcuts/src/index.ts) |
| `@deepseek-ai/dsh-client-ui-sidebar` | — | [`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/src/index.ts) |
| `@deepseek-ai/dsh-client-ui-sidebar-browser` | — | [`packages/client/ui-sidebar-browser/src/index.ts`](../packages/client/ui-sidebar-browser/src/index.ts) |
| `@deepseek-ai/dsh-client-ui-sidebar-files` | — | [`packages/client/ui-sidebar-files/src/index.ts`](../packages/client/ui-sidebar-files/src/index.ts) |
+17
View File
@@ -497,6 +497,22 @@ export interface Config {
```
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-client-hmr -->
<!-- BEGIN GENERATED config-catalog:@deepseek-ai/dsh-client-shortcuts -->
<a id="deepseek-aidsh-client-shortcuts"></a>
## `@deepseek-ai/dsh-client-shortcuts`
- `source`: [`packages/client/shortcuts/src/config.ts:5`](../packages/client/shortcuts/src/config.ts)
```ts config-catalog
/** Fixed shortcut sequence settings. */
export interface Config {
/** Maximum interval between independent Escape presses for stopping a reply, in milliseconds. */
stopSequenceMs: number
}
```
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-client-shortcuts -->
<!-- BEGIN GENERATED config-catalog:@deepseek-ai/dsh-client-ui-plugin-manager -->
<a id="deepseek-aidsh-client-ui-plugin-manager"></a>
@@ -4270,6 +4286,7 @@ export interface Config {
| `@deepseek-ai/dsh-client-ui-settings-shell` | — | [`packages/client/ui-settings-shell/src/index.ts`](../packages/client/ui-settings-shell/src/index.ts) |
| `@deepseek-ai/dsh-client-ui-settings-subagent` | — | [`packages/client/ui-settings-subagent/src/index.ts`](../packages/client/ui-settings-subagent/src/index.ts) |
| `@deepseek-ai/dsh-client-ui-settings-web-search` | — | [`packages/client/ui-settings-web-search/src/index.ts`](../packages/client/ui-settings-web-search/src/index.ts) |
| `@deepseek-ai/dsh-client-ui-shortcuts` | — | [`packages/client/ui-shortcuts/src/index.ts`](../packages/client/ui-shortcuts/src/index.ts) |
| `@deepseek-ai/dsh-client-ui-sidebar` | — | [`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/src/index.ts) |
| `@deepseek-ai/dsh-client-ui-sidebar-browser` | — | [`packages/client/ui-sidebar-browser/src/index.ts`](../packages/client/ui-sidebar-browser/src/index.ts) |
| `@deepseek-ai/dsh-client-ui-sidebar-files` | — | [`packages/client/ui-sidebar-files/src/index.ts`](../packages/client/ui-sidebar-files/src/index.ts) |
+1 -1
View File
@@ -79,7 +79,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:190`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:198`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`tool-present`](../packages/deliverables/tool-present) |
| `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:88`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` |
| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `ui-sidebar-documentpreview`, `ui-theme` |
| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` |
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
| `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |
+1 -1
View File
@@ -81,7 +81,7 @@
| `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:190`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:198`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`tool-present`](../packages/deliverables/tool-present) |
| `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:88`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` |
| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `ui-sidebar-documentpreview`, `ui-theme` |
| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` |
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
| `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |
+4
View File
@@ -154,6 +154,7 @@ flowchart TD
pkg_client_locale["client-locale"]
pkg_client_modules["client-modules"]
pkg_client_resources["client-resources"]
pkg_client_shortcuts["client-shortcuts"]
pkg_client_store["client-store"]
pkg_client_ui_agent_preset["client-ui-agent-preset"]
pkg_client_ui_approval["client-ui-approval"]
@@ -191,6 +192,7 @@ flowchart TD
pkg_client_ui_settings_shell["client-ui-settings-shell"]
pkg_client_ui_settings_subagent["client-ui-settings-subagent"]
pkg_client_ui_settings_web_search["client-ui-settings-web-search"]
pkg_client_ui_shortcuts["client-ui-shortcuts"]
pkg_client_ui_sidebar["client-ui-sidebar"]
pkg_client_ui_sidebar_browser["client-ui-sidebar-browser"]
pkg_client_ui_sidebar_documentpreview["client-ui-sidebar-documentpreview"]
@@ -1386,6 +1388,7 @@ flowchart TD
| [`client-locale`](../packages/client/locale) | `client` | — |
| [`client-modules`](../packages/client/modules) | `client` | — |
| [`client-resources`](../packages/client/resources) | `client` | — |
| [`client-shortcuts`](../packages/client/shortcuts) | `client` | — |
| [`client-store`](../packages/client/store) | `client` | — |
| [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | — |
| [`client-ui-approval`](../packages/client/ui-approval) | `client` | — |
@@ -1423,6 +1426,7 @@ flowchart TD
| [`client-ui-settings-shell`](../packages/client/ui-settings-shell) | `client` | — |
| [`client-ui-settings-subagent`](../packages/client/ui-settings-subagent) | `client` | — |
| [`client-ui-settings-web-search`](../packages/client/ui-settings-web-search) | `client` | — |
| [`client-ui-shortcuts`](../packages/client/ui-shortcuts) | `client` | — |
| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | — |
| [`client-ui-sidebar-browser`](../packages/client/ui-sidebar-browser) | `client` | — |
| [`client-ui-sidebar-documentpreview`](../packages/client/ui-sidebar-documentpreview) | `client` | — |
+4
View File
@@ -156,6 +156,7 @@ flowchart TD
pkg_client_locale["client-locale"]
pkg_client_modules["client-modules"]
pkg_client_resources["client-resources"]
pkg_client_shortcuts["client-shortcuts"]
pkg_client_store["client-store"]
pkg_client_ui_agent_preset["client-ui-agent-preset"]
pkg_client_ui_approval["client-ui-approval"]
@@ -193,6 +194,7 @@ flowchart TD
pkg_client_ui_settings_shell["client-ui-settings-shell"]
pkg_client_ui_settings_subagent["client-ui-settings-subagent"]
pkg_client_ui_settings_web_search["client-ui-settings-web-search"]
pkg_client_ui_shortcuts["client-ui-shortcuts"]
pkg_client_ui_sidebar["client-ui-sidebar"]
pkg_client_ui_sidebar_browser["client-ui-sidebar-browser"]
pkg_client_ui_sidebar_documentpreview["client-ui-sidebar-documentpreview"]
@@ -1388,6 +1390,7 @@ flowchart TD
| [`client-locale`](../packages/client/locale) | `client` | — |
| [`client-modules`](../packages/client/modules) | `client` | — |
| [`client-resources`](../packages/client/resources) | `client` | — |
| [`client-shortcuts`](../packages/client/shortcuts) | `client` | — |
| [`client-store`](../packages/client/store) | `client` | — |
| [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | — |
| [`client-ui-approval`](../packages/client/ui-approval) | `client` | — |
@@ -1425,6 +1428,7 @@ flowchart TD
| [`client-ui-settings-shell`](../packages/client/ui-settings-shell) | `client` | — |
| [`client-ui-settings-subagent`](../packages/client/ui-settings-subagent) | `client` | — |
| [`client-ui-settings-web-search`](../packages/client/ui-settings-web-search) | `client` | — |
| [`client-ui-shortcuts`](../packages/client/ui-shortcuts) | `client` | — |
| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | — |
| [`client-ui-sidebar-browser`](../packages/client/ui-sidebar-browser) | `client` | — |
| [`client-ui-sidebar-documentpreview`](../packages/client/ui-sidebar-documentpreview) | `client` | — |
+4 -4
View File
@@ -15,11 +15,11 @@
en: 1e01c817c5879c40
zh: a79abd92ee4c8f85
/right-sidebar/navigation-ctx-sidebarright:
en: a78d2265ade73602
zh: d1a927f5e25db43e
en: 0f80b5364cbad804
zh: db81652a5374fad1
/right-sidebar/slots-and-owner-props:
en: 45a42c40269a2f43
zh: 8f9bfdacb43fe79a
en: f58dc6ff6b0027a7
zh: 707722fcbd096efc
/right-sidebar/document-renderers:
en: 2bf47a890fce175e
zh: 45f81c579b8939a8
+4
View File
@@ -90,6 +90,8 @@ Placement is the caller's option, never a type's property. The conversation call
`focus(tabId)` makes a tab its pane's active tab; `split(paneId?)` splits the active docked pane, or the named one, and returns the new pane's id — or `undefined`, recording nothing, when the pane budget or the column's width forbids a split; `float(tabId, rect?)` lifts a tab into a floating pane; `dock(paneId)` returns a floating pane to the docked area. All four run the store's existing actions and record one history entry each; a target that does not exist or is already in the requested state is a no-op, and like `open` they throw without a mounted Session surface. `TabId`, `PaneId`, `TabRecord`, and `FloatRect` are re-exported from the package's `/client` entry so a caller needs no dockkit import.
`focusedTarget(element?)` captures the visible page under live DOM focus, including an embedding iframe; outside or stale sidebar markup yields no target. `commandTarget(element?)` also permits the mounted Session's active dock pane for an open initiated outside the sidebar. A `SidebarRightTarget` captures Session, pane, host, tab occurrence, and navigation revision. `isTargetCurrent(target)` checks that identity before execution, so reopening a record or navigating it invalidates a captured action; it never retargets to another page. See the [controller methods](../../packages/client/ui-sidebar-right/src/client/service.ts) for close and placement outcomes.
## Slots and owner props
The Sidebar declares four extension slots; its document tab declares the additional keyed document body below ([hierarchy](slots.md)).
@@ -106,6 +108,8 @@ A body, title and guide replacement receive the framework-injected `useTabInfo()
`navigation.revision` increments on every navigation to the tab whether or not `params` changed, so a body can act on "navigated again" alone; it is `1` for a tab opened by address and `0` for a record nobody opened by address — a seeded guide, or a tab restored by undo. The Tab domain holds one occurrence per open record: a record that appears is pinned in the resource model, so switching tabs unmounts a body without dropping its content; a record that vanishes is aborted and dropped; a record restored by undo is a new occurrence ([Tab domain](../../packages/client/ui-sidebar-right/README.md#the-tab-domain)).
`tab.actions.bindCommands(commands)` contributes `SidebarRightTabCommands`, currently an optional `refresh` callback, for the mounted body. Omitted operations are unavailable; the returned disposer releases that registration without removing a newer body's commands. The tab lifetime also releases them. `tab.refreshShortcut` supplies the effective refresh catalog entry for page controls; it is optional when no entry is available. These fields are declared in the [slot owner types](../../packages/client/ui-sidebar-right/src/client/contract/slots.ts).
## Document renderers
The `text` tab is the shared Document Preview owner. Its [root registration](../../packages/client/ui-sidebar-documentpreview/src/client/index.ts) declares `sidebar.right.tab.document` and provides `ctx.documentPreviews`. A renderer registers `DocumentPreviewDefinition` metadata in its own effect, then waits through `ctx.slots.inject('sidebar.right.tab.document', ...)` and registers its component with `key: definition.id` and its locale namespace. A renderer registers its own body and can reuse shared presentation through its child slots. Changing the renderer does not change the tab or resource address; the [extension decision](../../.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md) separates preview policy from resource ownership.
+4
View File
@@ -90,6 +90,8 @@ export function apply(ctx: Context): void {
`focus(tabId)` 让一个 tab 成为其 pane 的活动 tab;`split(paneId?)` 分割活动的停靠 pane 或点名的 pane,返回新 pane 的 id——pane 数预算或列宽不允许时返回 `undefined` 且不记账;`float(tabId, rect?)` 把一个 tab 浮出为浮窗 pane;`dock(paneId)` 把浮窗 pane 收回停靠区。四者都走 store 既有动作、各记一条历史;目标不存在或已处于目标状态时是空操作,与 `open` 一样在没有已挂载会话面时抛错。`TabId`、`PaneId`、`TabRecord`、`FloatRect` 自本包 `/client` 入口再导出,调用方无需引 dockkit。
`focusedTarget(element?)` 从实时 DOM 焦点(包括内嵌 iframe)捕获可见页面;侧栏外部或过期的侧栏标记不产生目标。`commandTarget(element?)` 还允许侧栏外部发起的打开操作使用已挂载 Session 的活动停靠窗格。`SidebarRightTarget` 捕获 Session、窗格、host、tab occurrence 和导航 revision。`isTargetCurrent(target)` 在执行前检查这些身份,因此重新打开记录或再次导航都会使已捕获的动作失效;它不会改为操作其他页面。关闭与落位结果见[控制器方法](../../packages/client/ui-sidebar-right/src/client/service.ts)。
## Slot 与 owner props
Sidebar 声明四个扩展 slot;其文档 tab 另行声明下表中的 keyed 文档正文 slot([层级](slots.zh.md))。
@@ -106,6 +108,8 @@ Sidebar 声明四个扩展 slot;其文档 tab 另行声明下表中的 keyed
`navigation.revision` 在每次导航到该 tab 时递增,`params` 不变也递增,正文可仅凭「又被导航了」行动;按地址打开的 tab 为 `1`,没有人按地址打开的记录——种入的引导、撤销恢复的 tab——为 `0`。Tab 域为每条打开的记录保有一个 occurrence:记录出现即在资源模型里钉住,因此切换 tab 卸载正文也不丢内容;记录消失即中止并丢弃;撤销恢复的记录是新的 occurrence([Tab 域](../../packages/client/ui-sidebar-right/README.zh.md#the-tab-domain))。
`tab.actions.bindCommands(commands)` 为已挂载正文提供 `SidebarRightTabCommands`,目前包含可选的 `refresh` 回调。省略的操作不可用;返回的 disposer 释放本次注册,不会移除较新正文的命令。标签生命周期结束时也会释放这些命令。`tab.refreshShortcut` 为页面控件提供生效的刷新目录项;没有可用条目时该字段可省略。这些字段声明在 [slot owner 类型](../../packages/client/ui-sidebar-right/src/client/contract/slots.ts)中。
## 文档渲染器
`text` tab 是共享的 Document Preview 所有者。其[根注册](../../packages/client/ui-sidebar-documentpreview/src/client/index.ts)声明 `sidebar.right.tab.document` 并提供 `ctx.documentPreviews`。渲染器在自己的 effect 中注册 `DocumentPreviewDefinition` 元数据,再通过 `ctx.slots.inject('sidebar.right.tab.document', ...)` 等待 slot,以 `key: definition.id` 和自己的 locale 命名空间注册组件。渲染器注册自己的正文,并可通过子 slot 复用共享展示组件。切换渲染器不改变 tab 或资源地址;[扩展决议](../../.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md)将预览策略与资源归属分开。
+2 -2
View File
@@ -3,8 +3,8 @@
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/web-client.md
/web-client-architecture:
en: b7d2de3798122a9e
zh: 62598228a4241d8c
en: 4936655bc5d3ba5b
zh: 062419330dea65b3
/web-client-architecture/layers-and-ownership:
en: c0454bca818f86ee
zh: 427582150fd3b976
+2
View File
@@ -4,6 +4,8 @@ English | [中文](web-client.zh.md)
The Web Client is a browser-side Cordis application assembled from independently loaded plugins. Its architecture has four reusable foundations: [Client Modules](client-modules.md) loads the plugin graph, the [API Gateway](../api-gateway.md) provides typed Host communication, [Slots](slots.md) composes React UI, and [Conversation](conversation.md) turns a Session history window into target-owned views. This page connects those systems and defines where Client models and feature packages belong.
[Keyboard shortcuts](../../packages/client/shortcuts/README.md) owns window-local command registration and physical-key dispatch; [the shortcut reference](../../packages/client/ui-shortcuts/README.md) presents available commands and local input actions. Command owners declare each runtime/platform default and share their existing actions with mouse controls. The shared modal primitive arbitrates top-layer Escape and restores focus.
## Layers and ownership
| Layer | Main owners | Responsibility |
+2
View File
@@ -4,6 +4,8 @@
Web Client 是由独立加载插件组装而成的浏览器侧 Cordis 应用。它有四个可复用底座:[Client Modules](client-modules.zh.md) 加载插件图,[API Gateway](../api-gateway.zh.md) 提供类型化 Host 通信,[Slots](slots.zh.md) 组合 React UI,[Conversation](conversation.zh.md) 把 Session 历史窗口变成各 target 自有的视图。本文串联这些系统,并规定 Client model 与功能包各自所在的位置。
[快捷键](../../packages/client/shortcuts/README.zh.md)负责窗口内命令注册和物理键分发;[快捷键速查](../../packages/client/ui-shortcuts/README.zh.md)展示可用命令及局部输入操作。命令 owner 声明各运行端/平台的默认键位,并与鼠标控件共用既有操作。共用模态组件裁决顶层 Esc 并恢复焦点。
## 分层与所有权
| 层 | 主要 owner | 职责 |
+6
View File
@@ -220,6 +220,12 @@
- id: locale
name: '@deepseek-ai/dsh-client-locale'
- id: shortcuts
name: '@deepseek-ai/dsh-client-shortcuts'
- id: ui-shortcuts
name: '@deepseek-ai/dsh-client-ui-shortcuts'
- id: ui-layout
name: '@deepseek-ai/dsh-client-ui-layout'
Loaded 100 of 404 files, more files were not shown because too many files have changed in this diff. Show more