From 06717aebbb79d78f69e2511eb63dcac26d49abe0 Mon Sep 17 00:00:00 2001 From: winewill Date: Tue, 22 Sep 2026 20:11:51 +0800 Subject: [PATCH 01/60] build(desktop): bundle the main process after the workspace tsdown pass The root tsdown workspace pass builds every member concurrently, so the Desktop main bundle could resolve its inlined workspace devDependencies (dsh-home-paths, dsh-app-boot, dsh-deepseek-account) before their lib/ output existed. rolldown then kept them as external imports, which the asar does not ship, and the packaged application failed at launch with ERR_MODULE_NOT_FOUND on a clean checkout; stale lib/ output on long-lived checkouts hid the defect. apps/desktop leaves the root workspace list and build:lib:host bundles it after the Host pass. Every Desktop bundle now runs a rolldown plugin that fails the build when a chunk imports a bare specifier the packaged application cannot resolve: electron and the manifest dependencies for the main bundle, electron alone for sandboxed preloads. --- ...in-bundle-after-workspace-tsdown.i18n.yaml | 6 ++ ...ktop-main-bundle-after-workspace-tsdown.md | 29 +++++++ ...p-main-bundle-after-workspace-tsdown.zh.md | 29 +++++++ apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 2 + apps/desktop/README.zh.md | 4 + apps/desktop/package.json | 3 +- .../scripts/desktop-bundle-imports.d.mts | 24 ++++++ .../scripts/desktop-bundle-imports.mjs | 66 ++++++++++++++++ .../tests/desktop-bundle-imports.spec.ts | 78 +++++++++++++++++++ apps/desktop/tsdown.config.ts | 15 ++++ docs/api-gateway.i18n.yaml | 4 +- docs/api-gateway.md | 2 +- docs/api-gateway.zh.md | 2 +- docs/development.i18n.yaml | 4 +- docs/development.md | 3 +- docs/development.zh.md | 3 +- package.json | 2 +- tsdown.config.ts | 7 +- 19 files changed, 273 insertions(+), 14 deletions(-) create mode 100644 .agents/notes/implemented/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.md create mode 100644 .agents/notes/implemented/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.zh.md create mode 100644 apps/desktop/scripts/desktop-bundle-imports.d.mts create mode 100644 apps/desktop/scripts/desktop-bundle-imports.mjs create mode 100644 apps/desktop/tests/desktop-bundle-imports.spec.ts diff --git a/.agents/notes/implemented/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.i18n.yaml b/.agents/notes/implemented/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.i18n.yaml new file mode 100644 index 0000000000..d1ecdce5be --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.md +2026-09-22-desktop-main-bundle-after-workspace-tsdown.md: 4aea81fd11873a53d54b16bc8fd4326d923d714b +2026-09-22-desktop-main-bundle-after-workspace-tsdown.zh.md: 0f2fd83835254c241f9ccfffab93d5a0abd2c17b diff --git a/.agents/notes/implemented/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.md b/.agents/notes/implemented/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.md new file mode 100644 index 0000000000..4aea81fd11 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.md @@ -0,0 +1,29 @@ +# Agent Note: Bundle the Desktop main process after the workspace tsdown pass + +Status: implemented + +English | [中文](2026-09-22-desktop-main-bundle-after-workspace-tsdown.zh.md) + +## Problem + +A macOS Desktop package built from a fresh worktree crashed at launch with `ERR_MODULE_NOT_FOUND: Cannot find package '@deepseek-ai/dsh-home-paths' imported from app.asar/lib/main.js`. The packaging log showed why: rolldown had reported `[UNRESOLVED_IMPORT] Could not resolve '@deepseek-ai/dsh-home-paths' … treating it as an external dependency`, and the same for `@deepseek-ai/dsh-app-boot` and `@deepseek-ai/dsh-deepseek-account`. The three packages are workspace devDependencies of `apps/desktop` that the main-process bundle must inline, because electron-builder copies only the manifest's `dependencies` into `app.asar/node_modules`. + +The root tsdown config builds every matched workspace member concurrently (`Promise.all` over the resolved configs in tsdown 0.22, with no dependency ordering), and `apps/desktop` was one of those members. Its bundle resolved the three packages through their `exports` to `lib/index.js` files that other concurrent builds had not written yet. On a checkout that had been built before, the stale `lib/` outputs existed and the bundle inlined them, which is why the defect surfaced only on a clean tree; `clean: false` in both configs kept it hidden. The broken bundle was also smaller (189 KB instead of 315 KB) and the packaging run passed every existing check, because the runtime payload smoke exercises the dsh runtime, not the Electron main process. + +## Decision + +`apps/desktop` leaves the root tsdown `workspace` list. The root `build:lib:host` script runs the Host tsdown pass and then `pnpm --filter @deepseek-ai/dsh-desktop run bundle`, so the main bundle resolves workspace `lib/` outputs that already exist. The package's own `build` script keeps the same `tsc -b` then `bundle` order for local use. A standalone run of the Desktop config produces byte-identical output to the former workspace-mode run: the config was already self-contained, and the root plugins contributed nothing to it. + +The Desktop config also asserts the invariant the crash violated. [`desktop-bundle-imports`](../../../../apps/desktop/scripts/desktop-bundle-imports.mjs) is a rolldown plugin on every Desktop bundle whose `generateBundle` hook fails the build when a chunk imports a bare specifier the packaged application cannot resolve: the main bundle may import `electron` and the manifest's `dependencies`, sandboxed preloads only `electron`, and Node builtins are always allowed. The check names the actual requirement rather than the symptom, so it also rejects a workspace package moved out of `dependencies` or a broken `exports` entry, and a rejected bundle writes no output. + +## Alternatives considered + +**Declare the three packages as runtime `dependencies` so electron-builder ships them in the asar.** This is how `@deepseek-ai/cordis` and `@deepseek-ai/dsh-api-gateway` reach the asar today, and it removes the ordering problem. `dsh-app-boot` would bring a large dependency tree into `app.asar/node_modules`, duplicating the copy already inside the packed dsh runtime and enlarging the payload the runtime file policy must screen. Inlining the main-process helpers was a deliberate choice; the fix keeps it. + +**Fail on rolldown's `UNRESOLVED_IMPORT` warning** through `inputOptions.onLog` or `failOnWarn`. It catches only the unresolved case and would not have covered a dependency misdeclared as a devDependency, which produces a resolved, inlined bundle when the output exists and an external import when it does not. The packaged-imports check covers both with one rule; a warning-based check remains an optional addition. + +**Order the workspace build by dependency graph.** tsdown offers no such ordering, and every other member either inlines nothing from the workspace or externalizes declared `dependencies`, which need no ordering. One extra step for the one package that needs it is smaller than a general scheduler. + +## Consequences + +A clean checkout, including CI and a fresh worktree, produces the same Desktop main bundle as a long-lived one. A missing or misdeclared runtime import now fails `pnpm run build` and every packaging command with the offending file and specifiers, before signing and notarization spend their time. The Host build gains one sequential step of about a second on a development machine. `docs/development.md` lists the desktop bundle step in the root build order, and the [Desktop README](../../../../apps/desktop/README.md#bundled-workspace-dependencies) owns the import rule. diff --git a/.agents/notes/implemented/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.zh.md b/.agents/notes/implemented/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.zh.md new file mode 100644 index 0000000000..0f2fd83835 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.zh.md @@ -0,0 +1,29 @@ +# Agent Note:Desktop 主进程在工作区 tsdown 阶段之后再打 bundle + +Status: implemented + +[English](2026-09-22-desktop-main-bundle-after-workspace-tsdown.md) | 中文 + +## 问题 + +从全新 worktree 打出的 macOS Desktop 安装包启动即崩:`ERR_MODULE_NOT_FOUND: Cannot find package '@deepseek-ai/dsh-home-paths' imported from app.asar/lib/main.js`。打包日志给出了原因:rolldown 报告了 `[UNRESOLVED_IMPORT] Could not resolve '@deepseek-ai/dsh-home-paths' … treating it as an external dependency`,`@deepseek-ai/dsh-app-boot` 与 `@deepseek-ai/dsh-deepseek-account` 也是同样情况。这三个包是 `apps/desktop` 的工作区 devDependencies,主进程 bundle 必须把它们内联,因为 electron-builder 只把清单中的 `dependencies` 复制进 `app.asar/node_modules`。 + +根 tsdown 配置并发构建所有匹配到的 workspace 成员(tsdown 0.22 对解析出的配置做 `Promise.all`,没有任何依赖排序),而 `apps/desktop` 是其中一员。它的 bundle 通过这三个包的 `exports` 解析到 `lib/index.js`,而其他并发构建此时还没有写出这些文件。在此前构建过的检出目录里,陈旧的 `lib/` 产物存在,bundle 会把它们内联,因此缺陷只在干净树上出现;两份配置里的 `clean: false` 一直掩盖着它。坏的 bundle 也更小(189 KB 而不是 315 KB),并且打包流程通过了所有已有检查,因为运行时载荷 smoke 检验的是 dsh 运行时,不是 Electron 主进程。 + +## 决策 + +`apps/desktop` 退出根 tsdown 的 `workspace` 列表。根 `build:lib:host` 脚本先跑 Host tsdown 阶段,再执行 `pnpm --filter @deepseek-ai/dsh-desktop run bundle`,让主进程 bundle 解析的是已经存在的工作区 `lib/` 产物。该包自己的 `build` 脚本为本地使用保留同样的先 `tsc -b` 再 `bundle` 的顺序。单独运行 Desktop 配置得到的产物与此前 workspace 模式下的产物逐字节一致:该配置本来就是自足的,根配置的插件没有为它贡献任何内容。 + +Desktop 配置同时断言这次崩溃所违反的不变量。[`desktop-bundle-imports`](../../../../apps/desktop/scripts/desktop-bundle-imports.mjs) 是挂在每个 Desktop bundle 上的 rolldown 插件,其 `generateBundle` 钩子在某个 chunk 导入了打包应用无法解析的裸说明符时让构建失败:主进程 bundle 可以导入 `electron` 与清单的 `dependencies`,沙箱 preload 只能导入 `electron`,Node 内置模块始终允许。这项检查陈述的是真正的要求而非症状,因此它同样拒绝被移出 `dependencies` 的工作区包或损坏的 `exports` 条目,且被拒绝的 bundle 不写出任何产物。 + +## 备选方案 + +**把这三个包声明为运行时 `dependencies`,让 electron-builder 装进 asar。** `@deepseek-ai/cordis` 与 `@deepseek-ai/dsh-api-gateway` 今天就是这样进入 asar 的,这也消除了顺序问题。但 `dsh-app-boot` 会把一棵很大的依赖树带进 `app.asar/node_modules`,与 packed dsh 运行时内已有的副本重复,并扩大运行时文件策略必须筛查的载荷。内联主进程辅助包是有意的选择;本修复保留它。 + +**通过 `inputOptions.onLog` 或 `failOnWarn` 让 rolldown 的 `UNRESOLVED_IMPORT` warning 直接失败。** 它只覆盖未解析这一种情况,覆盖不了被误声明为 devDependency 的依赖:产物存在时它会被解析并内联,不存在时才留成外部导入。打包导入检查用一条规则同时覆盖两者;基于 warning 的检查仍可作为可选补充。 + +**按依赖图给 workspace 构建排序。** tsdown 不提供这种排序,而其他每个成员要么不从工作区内联任何内容,要么把已声明的 `dependencies` 留成外部导入,都不需要排序。为唯一需要它的包多加一步,比通用调度器更小。 + +## 后果 + +干净的检出目录(包括 CI 与全新 worktree)产出的 Desktop 主进程 bundle 与长期使用的检出目录一致。缺失或误声明的运行时导入现在会让 `pnpm run build` 和每条打包命令在签名与公证耗时之前失败,并报出违规文件与说明符。Host 构建多出一个串行步骤,在开发机上约一秒。`docs/development.md` 在根构建顺序中列出了 desktop bundle 步骤,导入规则由 [Desktop README](../../../../apps/desktop/README.zh.md#bundled-workspace-dependencies) 负责。 diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 77500865f6..628c27e58b 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/desktop/README.md -README.md: 2b07b2b9d95c3d16a3aaa58b6d52f55cb1242e2e -README.zh.md: 4ebae34332b1d622a35d0bf857f1f3252736512c +README.md: e534a9443be4fdc576878f39f132fff34973db2f +README.zh.md: 8123832ac97185539bff87257cdb8921e332d2d9 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 2b07b2b9d9..e534a9443b 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -26,6 +26,8 @@ The macOS PNG uses an inset rounded background for legacy ICNS packaging, with r ### Bundled workspace dependencies +electron-builder copies only the manifest's `dependencies` into `app.asar/node_modules`, so the Electron main bundle `lib/main.js` inlines its workspace devDependencies and leaves `electron` and those `dependencies` as its only bare imports; sandboxed preloads import only `electron`. The main bundle resolves the inlined packages from their `lib/` output, so the root `build:lib:host` bundles `apps/desktop` after the concurrent workspace tsdown pass, and [`desktop-bundle-imports`](scripts/desktop-bundle-imports.mjs) fails any Desktop bundle whose external imports the packaged application cannot resolve. Without that check an import rolldown could not resolve ships as an external specifier and fails at launch with `ERR_MODULE_NOT_FOUND`. The [bundle-order decision](../../.agents/notes/implemented/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.md) records the alternatives. + Signed Windows packaging scans the primary runtime and application production dependencies for PE content, including files without a conventional extension. The final scan covers the entire unpacked application. Directory links, malformed `MZ` files and non-PE `.exe`, `.dll` or `.pyd` files stop packaging; data files beginning with `MZ` are also rejected unless they contain a valid PE header. It preserves valid vendor signatures and signs unsigned code before sealing runtime hashes or executing smoke checks. Public-key inspection uses batches of 32 files with at most four processes; hardware-token signing remains serial, and each new signature must match the configured certificate and carry a timestamp. Hardware signing and verification failures stop the run; isolated timestamp requests follow the bounded policy below. Electron-builder preserves copied runtime executables only after signature and exact-byte verification. A final PE-signature audit and a fresh-cache ASAR payload and Host smoke must pass before the release completion record is written. Development, preparation-only and unsigned builds do not use the hardware token and can be blocked by Windows code-integrity policy; no build mode disables that policy. Passing smoke checks does not establish compatibility with every enterprise policy. Desktop carries independent Python, Node.js and pnpm distributions. Python includes numpy, pandas, python-docx, python-pptx, openpyxl, Pillow, lxml and XlsxWriter with their complete dependencies. The `load_workspace_dependencies` tool installs this payload offline on first use under `$DSH_HOME/dsh-runtimes/dsh-primary-runtime` (normally `~/.dsh/dsh-runtimes/dsh-primary-runtime`) and returns absolute interpreter, pnpm script and library paths plus `pythonDistributions`, the bundled distribution names and versions. The version report excludes user-installed additions. Office tasks prefer these libraries unless user or workspace instructions select another environment. Execute the pnpm script with the returned Node executable. The returned Node library directory is reserved for bundled libraries, not pnpm's global installation directory. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 4ebae34332..8123832ac9 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -24,8 +24,12 @@ Desktop Host 的 Platform API 请求与更新策略请求使用相同的 `x-clie macOS PNG 使用带留白的圆角底板,供传统 ICNS 打包使用,包含最高 1024 像素的表示。它是扁平图标,并非 Icon Composer 文档。Apple 的[应用图标指南](https://developer.apple.com/design/human-interface-guidelines/app-icons)要求向 Icon Composer 提供未遮罩的图层;这些输入需要在 macOS 上单独导出,不能复用已做圆角的 ICNS 图案。发布前须在支持的 macOS 版本中验收 Finder 和 Dock 的显示效果。 + + ### 内置工作区依赖 +electron-builder 只把清单中的 `dependencies` 复制进 `app.asar/node_modules`,因此 Electron 主进程 bundle `lib/main.js` 内联其工作区 devDependencies,裸导入只剩 `electron` 与这些 `dependencies`;沙箱 preload 只导入 `electron`。主进程 bundle 从被内联包的 `lib/` 产物解析它们,所以根 `build:lib:host` 在并发的工作区 tsdown 阶段之后才为 `apps/desktop` 打 bundle,并由 [`desktop-bundle-imports`](scripts/desktop-bundle-imports.mjs) 让任何外部导入无法在打包应用内解析的 Desktop bundle 直接失败。没有这项检查时,rolldown 无法解析的导入会作为外部说明符进入产物,并在启动时以 `ERR_MODULE_NOT_FOUND` 失败。[bundle 顺序决策](../../.agents/notes/implemented/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.zh.md)记录了备选方案。 + Windows 签名打包按 PE 文件内容扫描第一方运行时和应用生产依赖,包括没有常规扩展名的文件。最终扫描覆盖整个解包应用。目录链接、格式错误的 `MZ` 文件以及非 PE 的 `.exe`、`.dll` 或 `.pyd` 文件会使打包停止;以 `MZ` 开头的数据文件也会被拒绝,除非包含有效 PE 头。它保留有效的上游签名,并在记录运行时哈希或执行冒烟检查前为未签名代码补签。公钥验签每个进程处理最多 32 个文件,同时最多运行四个进程;硬件令牌签名仍串行执行,每个新签名必须匹配配置的证书且带时间戳。硬件签名或验签失败会停止本轮执行;独立的时间戳请求遵循下文的有界重试规则。electron-builder 只有在验签和逐字节比对通过后,才保留复制后运行时可执行文件的签名。写入发布完成记录前,必须通过最终 PE 签名检查,以及使用全新缓存的 ASAR 载荷和 Host 冒烟检查。开发、仅准备和未签名构建不使用硬件令牌,可能被 Windows 代码完整性策略阻止;任何构建模式都不会关闭该策略。冒烟检查通过不代表兼容所有企业策略。 Desktop 携带独立的 Python、Node.js 和 pnpm 分发包。Python 包含 numpy、pandas、python-docx、python-pptx、openpyxl、Pillow、lxml、XlsxWriter 及其完整依赖。`load_workspace_dependencies` 工具首次使用时,将该产物离线安装到 `$DSH_HOME/dsh-runtimes/dsh-primary-runtime`(通常为 `~/.dsh/dsh-runtimes/dsh-primary-runtime`),并返回解释器、pnpm 脚本和库目录的绝对路径,以及记录内置分发包名称与版本的 `pythonDistributions`。版本报告不包含用户自行安装的包。Office 任务默认使用这些库,用户或工作区指令指定其他环境时遵循其要求。pnpm 脚本通过返回的 Node 可执行文件运行。返回的 Node 库目录为随包交付的库预留,不是 pnpm 的全局安装目录。 diff --git a/apps/desktop/package.json b/apps/desktop/package.json index d7c8fbdd09..fa87ea8ad7 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -8,7 +8,8 @@ "main": "lib/main.js", "scripts": { "test:installer": "node scripts/test-windows-installer.mjs", - "build": "tsc -b && tsdown", + "build": "tsc -b && pnpm run bundle", + "bundle": "tsdown", "dev": "tsx scripts/dev.ts", "start": "tsx scripts/dev.ts --skip-build", "test:updates:local": "pnpm run build && node scripts/test-local-updater.mjs", diff --git a/apps/desktop/scripts/desktop-bundle-imports.d.mts b/apps/desktop/scripts/desktop-bundle-imports.d.mts new file mode 100644 index 0000000000..abb95a8474 --- /dev/null +++ b/apps/desktop/scripts/desktop-bundle-imports.d.mts @@ -0,0 +1,24 @@ +import type { Rolldown } from 'tsdown' + +/** + * Package name of a bare import specifier. + * @param specifier - Bare specifier as rolldown reports it, such as `@scope/name/subpath`. + * @returns The package name without its subpath. + */ +export function importPackageName(specifier: string): string + +/** + * Bare imports the packaged application cannot resolve. + * @param imports - External specifiers a chunk imports, statically or dynamically. + * @param packaged - Package names available to the bundle at runtime. + * @returns Offending specifiers in import order, each once. + */ +export function unpackagedImports(imports: readonly string[], packaged: ReadonlySet): string[] + +/** + * Rolldown plugin that fails a bundle whose external imports the packaged + * application cannot resolve. + * @param packaged - Package names available to the bundle at runtime. + * @returns The plugin. + */ +export function packagedImportsPlugin(packaged: ReadonlySet): Rolldown.Plugin diff --git a/apps/desktop/scripts/desktop-bundle-imports.mjs b/apps/desktop/scripts/desktop-bundle-imports.mjs new file mode 100644 index 0000000000..16d8c45990 --- /dev/null +++ b/apps/desktop/scripts/desktop-bundle-imports.mjs @@ -0,0 +1,66 @@ +/** + * Keep every bare import a Desktop bundle leaves to the runtime resolvable + * inside the packaged application. + * + * electron-builder copies only the manifest's `dependencies` into + * `app.asar/node_modules`, so a bare import of anything else bundles without + * complaint and fails at launch with `ERR_MODULE_NOT_FOUND`. The main-process + * bundle inlines its workspace devDependencies; when one of their `lib/` + * outputs is missing at bundle time, rolldown reports `UNRESOLVED_IMPORT` as a + * warning and keeps the specifier as an external import, which is exactly the + * broken artifact. Sandboxed preloads may `require` only `electron`. This + * check fails the bundle instead of the installed application. + */ + +import { isBuiltin } from 'node:module' + +/** + * Package name of a bare import specifier. + * @param {string} specifier - Bare specifier as rolldown reports it, such as `@scope/name/subpath`. + * @returns {string} The package name without its subpath. + */ +export function importPackageName(specifier) { + const segments = specifier.split('/') + return specifier.startsWith('@') ? segments.slice(0, 2).join('/') : segments[0] +} + +/** + * Bare imports the packaged application cannot resolve. + * @param {readonly string[]} imports - External specifiers a chunk imports, statically or dynamically. + * @param {ReadonlySet} packaged - Package names available to the bundle at runtime. + * @returns {string[]} Offending specifiers in import order, each once. + */ +export function unpackagedImports(imports, packaged) { + const offending = new Set() + for (const specifier of imports) { + if (isBuiltin(specifier)) continue + if (packaged.has(importPackageName(specifier))) continue + offending.add(specifier) + } + return [...offending] +} + +/** + * Rolldown plugin that fails a bundle whose external imports the packaged + * application cannot resolve. + * @param {ReadonlySet} packaged - Package names available to the bundle at runtime. + * @returns {import('tsdown').Rolldown.Plugin} The plugin. + */ +export function packagedImportsPlugin(packaged) { + return { + name: 'desktop-packaged-imports', + generateBundle(_options, bundle) { + for (const output of Object.values(bundle)) { + if (output.type !== 'chunk') continue + const external = [...output.imports, ...output.dynamicImports].filter(specifier => !(specifier in bundle)) + const offending = unpackagedImports(external, packaged) + if (offending.length === 0) continue + this.error( + `desktop bundle: ${output.fileName} imports ${offending.join(', ')}, which the packaged application does not ship. ` + + `A bare import must name electron or a package in this manifest's dependencies; anything else must be bundled, ` + + 'which requires its lib/ output to exist before this bundle runs (pnpm run build:lib:host).', + ) + } + }, + } +} diff --git a/apps/desktop/tests/desktop-bundle-imports.spec.ts b/apps/desktop/tests/desktop-bundle-imports.spec.ts new file mode 100644 index 0000000000..c37564020c --- /dev/null +++ b/apps/desktop/tests/desktop-bundle-imports.spec.ts @@ -0,0 +1,78 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Rolldown } from 'tsdown' +import { afterAll, describe, expect, it } from 'vitest' +import { + importPackageName, + packagedImportsPlugin, + unpackagedImports, +} from '../scripts/desktop-bundle-imports.mjs' + +const PACKAGED = new Set(['electron', 'electron-updater', '@deepseek-ai/dsh-api-gateway']) + +describe('desktop bundle imports', () => { + it('names the package behind a bare specifier', () => { + expect(importPackageName('electron-updater/out/electronHttpExecutor.js')).toBe('electron-updater') + expect(importPackageName('@deepseek-ai/dsh-api-gateway/stream-protocol')).toBe('@deepseek-ai/dsh-api-gateway') + expect(importPackageName('ws')).toBe('ws') + }) + + it('accepts Node builtins and packaged dependencies, including their subpaths', () => { + expect(unpackagedImports([ + 'node:fs/promises', + 'crypto', + 'electron', + 'electron-updater/out/electronHttpExecutor.js', + '@deepseek-ai/dsh-api-gateway/stream-protocol', + ], PACKAGED)).toEqual([]) + }) + + it('reports each specifier the packaged application cannot resolve once, in import order', () => { + expect(unpackagedImports([ + '@deepseek-ai/dsh-home-paths', + 'ws', + '@deepseek-ai/dsh-home-paths', + 'electron', + ], PACKAGED)).toEqual(['@deepseek-ai/dsh-home-paths', 'ws']) + }) +}) + +describe('packaged imports plugin', () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-desktop-bundle-imports-')) + const entry = join(root, 'entry.js') + writeFileSync(entry, [ + 'import { app } from "electron";', + 'import { resolveDshHome } from "@deepseek-ai/dsh-home-paths";', + 'export const home = resolveDshHome(app);', + ].join('\n')) + afterAll(() => { + rmSync(root, { recursive: true, force: true }) + }) + + async function generate(packaged: ReadonlySet): Promise { + // Every import stays external, which is what an unresolved workspace lib/ output produces. + const bundle = await Rolldown.rolldown({ + input: entry, + external: () => true, + logLevel: 'silent', + plugins: [packagedImportsPlugin(packaged)], + }) + try { + return await bundle.generate({ format: 'esm' }) + } finally { + await bundle.close() + } + } + + it('fails the bundle that leaves an import the packaged application does not ship', async () => { + await expect(generate(new Set(['electron']))).rejects.toThrow( + /entry\.js imports @deepseek-ai\/dsh-home-paths, which the packaged application does not ship/u, + ) + }) + + it('passes the bundle whose external imports are all packaged', async () => { + const output = await generate(new Set(['electron', '@deepseek-ai/dsh-home-paths'])) + expect(output.output.map(chunk => chunk.fileName)).toEqual(['entry.js']) + }) +}) diff --git a/apps/desktop/tsdown.config.ts b/apps/desktop/tsdown.config.ts index 6db212aee7..8af998c518 100644 --- a/apps/desktop/tsdown.config.ts +++ b/apps/desktop/tsdown.config.ts @@ -1,11 +1,25 @@ import { defineConfig } from 'tsdown' import { build } from 'vite' import { fileURLToPath } from 'node:url' +import { readFileSync } from 'node:fs' import { readFile } from 'node:fs/promises' +import { packagedImportsPlugin } from './scripts/desktop-bundle-imports.mjs' + +// This config runs after the workspace tsdown pass, not inside it: the main bundle inlines +// workspace devDependencies from their lib/ output, which the concurrent workspace pass does +// not order ahead of this package (root package.json build:lib:host). +const manifest = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')) as { + dependencies: Record +} +/** electron-builder ships these next to the main bundle; Electron itself provides `electron`. */ +const mainProcessImports = new Set(['electron', ...Object.keys(manifest.dependencies)]) +/** Sandboxed preloads can require nothing but `electron`. */ +const preloadImports = new Set(['electron']) export default defineConfig([ { entry: ['lib/types/main.js'], + plugins: [packagedImportsPlugin(mainProcessImports)], onSuccess: async () => { await build({ configFile: false, @@ -49,6 +63,7 @@ export default defineConfig([ ...(['preload-app', 'preload-welcome', 'preload-platform-account', 'preload-mandatory', 'preload-update-dialog'] as const).map(name => ({ // Sandboxed Electron preloads run as CommonJS even though the application package is ESM. entry: { [name]: `lib/types/${name}.js` }, + plugins: [packagedImportsPlugin(preloadImports)], outDir: 'lib', format: 'cjs' as const, codeSplitting: false, diff --git a/docs/api-gateway.i18n.yaml b/docs/api-gateway.i18n.yaml index e11ee881f4..f515b0eff1 100644 --- a/docs/api-gateway.i18n.yaml +++ b/docs/api-gateway.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/api-gateway.md -api-gateway.md: 957f40775857d8f57af436a44690fd5a453aeeae -api-gateway.zh.md: 1062adcc89909d5ce3701ac82b55c5d0ece21ab0 +api-gateway.md: d6a8444c9f18a7c3df9cd64e4a836d1831f8a0d8 +api-gateway.zh.md: 23c7c18d3d784fc2b6abe4c1df75e08447100b98 diff --git a/docs/api-gateway.md b/docs/api-gateway.md index 957f407758..d6a8444c9f 100644 --- a/docs/api-gateway.md +++ b/docs/api-gateway.md @@ -98,7 +98,7 @@ The API Gateway package owns the Host dispatcher and Client Remote endpoint as p The root build runs `build:lib:host`, `build:lib:client`, and `build:web` in order. The Host lib phase first runs `tsc -b tsconfig.host.json`, then `tsdown --env.DSH_BUILD_FACE host`; the normal Host Project Reference graph compiles the Typert generator, which runs during this tsdown pass with the Host aggregate as its only `ts.Program` seed. The Client lib phase then runs `tsc -b tsconfig.client.json` and `tsdown --env.DSH_BUILD_FACE client`, consuming the newly generated Remote Client declarations and runtime contributions without starting Typert again. -Both tsdown passes receive the complete workspace and bundle only JavaScript emitted to `lib/types` by the corresponding tsc phase. The root config does not scan Client artifacts, classify package names, or pass a maintained filter to tsdown; package-local configs return entries for the current phase based on `DSH_BUILD_FACE`. An ordinary Client plugin produces both its Node loader entry and browser bundle during the Client phase. +Both tsdown passes receive the same workspace match ([build order](development.md#typescript-project-layout)) and bundle only JavaScript emitted to `lib/types` by the corresponding tsc phase. The root config does not scan Client artifacts, classify package names, or pass a maintained filter to tsdown; package-local configs return entries for the current phase based on `DSH_BUILD_FACE`. An ordinary Client plugin produces both its Node loader entry and browser bundle during the Client phase. `api/remotes`, `api/gateway`, `api/session-controller`, and `api/workspace-controller` (plus `client/connection`) split TypeScript faces. `api/remotes`' Client project depends on `/remote` declarations generated for business packages during Host tsdown; root aggregates and direct consumers must reference each split package's `tsconfig.host.json` or `tsconfig.client.json` respectively. `api-remotes`' `clientBundle(..., { hostPhase: true })` produces its Host entry during Host tsdown and leaves only the browser entry for Client tsdown. The Agent/Session lookup policy lives in `@deepseek-ai/dsh-api-session-controller`, not in `api-remotes`. diff --git a/docs/api-gateway.zh.md b/docs/api-gateway.zh.md index 1062adcc89..23c7c18d3d 100644 --- a/docs/api-gateway.zh.md +++ b/docs/api-gateway.zh.md @@ -98,7 +98,7 @@ API Gateway 包同时拥有 Host dispatcher 与 Client Remote endpoint 两个对 根构建依次执行 `build:lib:host`、`build:lib:client` 与 `build:web`。Host lib 阶段先运行 `tsc -b tsconfig.host.json`,再运行 `tsdown --env.DSH_BUILD_FACE host`;Typert generator 由正常 Host Project Reference 图编译,并在这次 tsdown 中以 Host aggregate 为唯一 `ts.Program` 种子运行。Client lib 阶段随后运行 `tsc -b tsconfig.client.json` 与 `tsdown --env.DSH_BUILD_FACE client`,使用刚生成的 Remote Client 声明和运行时贡献,但不再次启动 Typert。 -两次 tsdown 都接收完整 workspace,且都只打包 `lib/types` 中由对应 tsc 阶段发射的 JavaScript。根配置不扫描 Client 产物、不按包名分类,也不向 tsdown 传维护式 filter;各包的本地配置根据 `DSH_BUILD_FACE` 返回当前阶段的入口。普通 Client 插件在 Client 阶段一起生成 Node loader 入口与 browser bundle。 +两次 tsdown 都接收同一组 workspace 匹配([构建顺序](development.zh.md#typescript-project-layout)),且都只打包 `lib/types` 中由对应 tsc 阶段发射的 JavaScript。根配置不扫描 Client 产物、不按包名分类,也不向 tsdown 传维护式 filter;各包的本地配置根据 `DSH_BUILD_FACE` 返回当前阶段的入口。普通 Client 插件在 Client 阶段一起生成 Node loader 入口与 browser bundle。 `api/remotes`、`api/gateway`、`api/session-controller` 与 `api/workspace-controller`(外加 `client/connection`)都拆分 TypeScript face。`api/remotes` 的 Client project 依赖业务包在 Host tsdown 中生成的 `/remote` 声明;根 aggregate 与直接消费方必须分别引用各拆分包自己的 `tsconfig.host.json` 或 `tsconfig.client.json`。`api-remotes` 的 `clientBundle(..., { hostPhase: true })` 让 Host 入口在 Host tsdown 中生成,让 Client tsdown 只生成 browser 入口。Agent/Session lookup 策略位于 `@deepseek-ai/dsh-api-session-controller`,而非 `api-remotes`。 diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index fd6198a8ca..a14f6e2fd7 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/development.md -development.md: 7e8d0ded70ca880ce986cb3879d959fbe2a04304 -development.zh.md: 84d0d76d8c76f829c0ed928d28336df3e58ed7f0 +development.md: 812cabbe3e2af5965033ef0c73301e5cd28cba28 +development.zh.md: b45e8e3e7b3c5fc0ee05967273a1c73e407b32fc diff --git a/docs/development.md b/docs/development.md index 7e8d0ded70..812cabbe3e 100644 --- a/docs/development.md +++ b/docs/development.md @@ -74,12 +74,13 @@ The root build follows the generated dependency order: ```sh tsc -b tsconfig.host.json tsdown --env.DSH_BUILD_FACE host +pnpm --filter @deepseek-ai/dsh-desktop run bundle tsc -b tsconfig.client.json tsdown --env.DSH_BUILD_FACE client pnpm run build:web ``` -Both tsdown passes use the same complete workspace match. They neither scan build artifacts to discover Client packages nor maintain a Host/Client package filter list. Package-local tsdown configs select entries for the current phase through `DSH_BUILD_FACE`: an ordinary Client plugin produces both its Node loader and browser bundle during the Client phase; `api-remotes` uses `hostPhase: true` to produce its Host entry early and only its browser bundle during the Client phase. Tsdown consumes only the JavaScript emitted to `lib/types` by the preceding tsc phase. +Both tsdown passes use the same workspace match. They neither scan build artifacts to discover Client packages nor maintain a Host/Client package filter list. Package-local tsdown configs select entries for the current phase through `DSH_BUILD_FACE`: an ordinary Client plugin produces both its Node loader and browser bundle during the Client phase; `api-remotes` uses `hostPhase: true` to produce its Host entry early and only its browser bundle during the Client phase. Tsdown consumes only the JavaScript emitted to `lib/types` by the preceding tsc phase. Tsdown builds the matched workspace members concurrently, so `apps/desktop`, whose main bundle inlines workspace devDependencies from their `lib/` output, bundles in its own step after the Host pass ([Desktop README](../apps/desktop/README.md#bundled-workspace-dependencies)). Typert runs only during Host tsdown, seeded by `tsconfig.host.json`. It analyzes Host types and generates both Host reflection artifacts and the Host-for-Client Remote projection; Client tsdown does not start Typert. Consequently, `pnpm run typecheck` runs the complete Host lib phase before Client tsc, while `pnpm run build` continues through Client tsdown and the Web build. diff --git a/docs/development.zh.md b/docs/development.zh.md index 84d0d76d8c..b45e8e3e7b 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -78,12 +78,13 @@ Host 与 Client 保持两个 aggregate program,是因为两侧在相同键下 ```sh tsc -b tsconfig.host.json tsdown --env.DSH_BUILD_FACE host +pnpm --filter @deepseek-ai/dsh-desktop run bundle tsc -b tsconfig.client.json tsdown --env.DSH_BUILD_FACE client pnpm run build:web ``` -两次 tsdown 都使用同一组完整 workspace 匹配,不扫描构建产物来发现 Client 包,也不维护 Host/Client 包过滤表。包内 tsdown 配置根据 `DSH_BUILD_FACE` 决定当前阶段的入口:普通 Client 插件在 Client 阶段同时生成 Node loader 与 browser bundle;`api-remotes` 通过 `hostPhase: true` 提前生成 Host 入口,再在 Client 阶段只生成 browser bundle。tsdown 只消费 `lib/types` 中由前置 tsc 发射的 JavaScript。 +两次 tsdown 都使用同一组 workspace 匹配,不扫描构建产物来发现 Client 包,也不维护 Host/Client 包过滤表。包内 tsdown 配置根据 `DSH_BUILD_FACE` 决定当前阶段的入口:普通 Client 插件在 Client 阶段同时生成 Node loader 与 browser bundle;`api-remotes` 通过 `hostPhase: true` 提前生成 Host 入口,再在 Client 阶段只生成 browser bundle。tsdown 只消费 `lib/types` 中由前置 tsc 发射的 JavaScript。tsdown 并发构建匹配到的 workspace 成员,因此主进程 bundle 要从工作区 devDependencies 的 `lib/` 产物内联它们的 `apps/desktop` 在 Host 阶段之后单独一步打 bundle([Desktop README](../apps/desktop/README.zh.md#bundled-workspace-dependencies))。 Typert 只在 Host tsdown 中以 `tsconfig.host.json` 为种子运行。它分析 Host 类型并生成 Host 反射产物及 Host-for-Client Remote 投影;Client tsdown 不启动 Typert。`pnpm run typecheck` 因此先执行完整 Host lib 阶段,再运行 Client tsc;`pnpm run build` 继续执行 Client tsdown 和 Web 构建。 diff --git a/package.json b/package.json index a7acc3ab12..d71d45d55f 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "build:bench": "npm run build:native-system && npm run build:lib && tsdown --config benchmarks/tsdown.config.ts", "build:official": "tsx scripts/build.ts --profile official", "build:lib": "pnpm run build:lib:host && pnpm run build:lib:client", - "build:lib:host": "node --max-old-space-size=4096 ./node_modules/typescript/bin/tsc -b tsconfig.host.json && tsdown --env.DSH_BUILD_FACE host", + "build:lib:host": "node --max-old-space-size=4096 ./node_modules/typescript/bin/tsc -b tsconfig.host.json && tsdown --env.DSH_BUILD_FACE host && pnpm --filter @deepseek-ai/dsh-desktop run bundle", "build:lib:client": "tsc -b tsconfig.client.json && tsdown --env.DSH_BUILD_FACE client", "build:web": "pnpm --filter @deepseek-ai/dsh-web-frontend run build", "build:desktop": "pnpm --filter @deepseek-ai/dsh-desktop run build", diff --git a/tsdown.config.ts b/tsdown.config.ts index 2f24d4910e..2435127329 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -11,14 +11,17 @@ function isBuildFaceClient(value: unknown): boolean { * The ordinary workspace build consumes JavaScript emitted by the Host * TypeScript project and runs Typert. The Client pass selects packages that * declare a browser bundle and lets their package-local configs emit both - * their Node loader entry and browser artifact. + * their Node loader entry and browser artifact. `apps/desktop` bundles after + * this pass (root package.json `build:lib:host`): its main bundle inlines + * workspace devDependencies from their lib/ output, and tsdown builds + * workspace members concurrently without ordering them. */ export default defineConfig(({ env }) => { const client = isBuildFaceClient(env?.DSH_BUILD_FACE) return { workspace: client ? ['vendor/*', 'packages/*/*', 'apps/cli'] - : ['vendor/*', 'packages/*/*', 'apps/cli', 'apps/desktop', 'apps/desktop-host'], + : ['vendor/*', 'packages/*/*', 'apps/cli', 'apps/desktop-host'], entry: client ? '' : ['lib/types/{index,invariant,startup}.js'], outDir: 'lib', format: ['esm'], From 91423ea1b44c3332b574f633f9e8f227dbbf4036 Mon Sep 17 00:00:00 2001 From: Xinyu Yang Date: Tue, 22 Sep 2026 15:16:57 +0800 Subject: [PATCH 02/60] revert: restore configurable Web and desktop shortcuts Restore the feature removed by #4886 on current master. Preserve the current settings entry point, moving-menu behavior, and desktop update UI. --- ...evice-local-shortcut-preferences.i18n.yaml | 6 + ...09-20-device-local-shortcut-preferences.md | 27 + ...20-device-local-shortcut-preferences.zh.md | 27 + ...-21-desktop-page-close-shortcuts.i18n.yaml | 6 + ...2026-09-21-desktop-page-close-shortcuts.md | 27 + ...6-09-21-desktop-page-close-shortcuts.zh.md | 27 + .../2026-09-16-desktop-window-menus.i18n.yaml | 4 +- .../2026-09-16-desktop-window-menus.md | 8 +- .../2026-09-16-desktop-window-menus.zh.md | 8 +- apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 9 +- apps/desktop/README.zh.md | 13 +- apps/desktop/package.json | 4 +- apps/desktop/scripts/dev.ts | 3 +- apps/desktop/src/browser-guests.ts | 8 +- apps/desktop/src/ipc.ts | 9 + apps/desktop/src/keybindings.ts | 27 + apps/desktop/src/keyboard.ts | 253 +++++++ apps/desktop/src/locale.ts | 4 + apps/desktop/src/main.ts | 25 +- apps/desktop/src/preload-app.ts | 34 +- apps/desktop/tests/keybindings.spec.ts | 35 + apps/desktop/tests/keyboard.spec.ts | 679 ++++++++++++++++++ apps/desktop/tests/main-startup.spec.ts | 13 +- apps/desktop/tests/preload-app.spec.ts | 97 ++- apps/desktop/tests/welcome-startup.spec.ts | 2 +- apps/desktop/tsconfig.host.json | 6 + apps/web/tests/approval-composer.e2e.ts | 8 +- apps/web/tests/composer-shortcuts.e2e.ts | 48 ++ .../desktop-locale/automatic.expected.md | 3 + .../desktop-locale/selected.expected.md | 3 + .../settings-chrome/dialog-en.expected.md | 3 + .../dialog-no-browser.expected.md | 3 + .../settings-chrome/dialog.expected.md | 3 + .../shortcuts-desktop/chord.expected.md | 5 + .../fixed-conflict.expected.md | 5 + .../macos-copy-override.expected.md | 5 + .../modal-layers.expected.md | 12 + .../shortcuts-desktop/recording.expected.md | 4 + .../windows-copy-override.expected.md | 5 + .../shortcuts-panels/browser.expected.md | 18 + .../shortcuts-panels/guide-close.expected.md | 9 + .../shortcuts-panels/page-actions.expected.md | 23 + .../shortcuts-panels/panels.expected.md | 25 + .../shortcuts-panels/tab-menu.expected.md | 2 + .../fork-unavailable.expected.md | 1 + .../shortcuts-workspace/fork.expected.md | 9 + .../macos-defaults.expected.md | 132 ++++ .../session-menu.expected.md | 5 + .../expected/shortcuts/edit-stale.expected.md | 5 + .../shortcuts/en-US-conflict.expected.md | 1 + .../shortcuts/en-US-empty.expected.md | 10 + .../shortcuts/en-US-inline.expected.md | 5 + .../shortcuts/en-US-reset-confirm.expected.md | 6 + .../shortcuts/en-US-saved.expected.md | 1 + .../expected/shortcuts/en-US.expected.md | 133 ++++ .../shortcuts/zh-CN-conflict.expected.md | 1 + .../shortcuts/zh-CN-empty.expected.md | 10 + .../shortcuts/zh-CN-inline.expected.md | 5 + .../shortcuts/zh-CN-reset-confirm.expected.md | 6 + .../shortcuts/zh-CN-saved.expected.md | 1 + .../expected/shortcuts/zh-CN.expected.md | 132 ++++ apps/web/tests/live-interactions.e2e.ts | 2 +- apps/web/tests/queue-actions.e2e.ts | 11 +- apps/web/tests/sessionless-header.e2e.ts | 11 +- apps/web/tests/shortcuts-desktop.e2e.ts | 258 +++++++ apps/web/tests/shortcuts-fixed.e2e.ts | 65 ++ apps/web/tests/shortcuts-panels.e2e.ts | 254 +++++++ apps/web/tests/shortcuts-workspace.e2e.ts | 311 ++++++++ apps/web/tests/shortcuts.e2e.ts | 336 +++++++++ apps/web/tests/sidebar-right.e2e.ts | 10 +- apps/web/tsconfig.json | 10 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 15 + docs/config-catalog.zh.md | 15 + docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 2 +- docs/event-producer-consumer.zh.md | 2 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 4 + docs/module-graph.zh.md | 4 + docs/subsystems/sidebar-right.i18n.yaml | 4 +- docs/subsystems/sidebar-right.md | 4 + docs/subsystems/sidebar-right.zh.md | 4 + docs/subsystems/web-client.i18n.yaml | 4 +- docs/subsystems/web-client.md | 2 + docs/subsystems/web-client.zh.md | 2 + packages/bundle/web-app/cordis.patch.yml | 6 + packages/bundle/web-app/package.json | 4 +- packages/client/README.i18n.yaml | 4 +- packages/client/README.md | 2 + packages/client/README.zh.md | 2 + packages/client/shortcuts/README.i18n.yaml | 6 + packages/client/shortcuts/README.md | 92 +++ packages/client/shortcuts/README.zh.md | 92 +++ packages/client/shortcuts/package.json | 67 ++ packages/client/shortcuts/src/binding.ts | 112 +++ packages/client/shortcuts/src/client/dom.ts | 118 +++ packages/client/shortcuts/src/client/index.ts | 152 ++++ .../client/shortcuts/src/client/native.ts | 44 ++ .../client/shortcuts/src/client/registry.ts | 180 +++++ .../client/shortcuts/src/client/storage.ts | 40 ++ packages/client/shortcuts/src/client/types.ts | 130 ++++ packages/client/shortcuts/src/config.ts | 13 + .../client/shortcuts/src/configuration.ts | 244 +++++++ packages/client/shortcuts/src/index.ts | 20 + packages/client/shortcuts/src/native.ts | 29 + packages/client/shortcuts/src/persistence.ts | 144 ++++ packages/client/shortcuts/src/protocol.ts | 5 + .../shortcuts/tests/config.host.spec.ts | 17 + .../tests/configuration.client.spec.ts | 174 +++++ .../tests/desktop-chords.client.spec.ts | 119 +++ .../client/shortcuts/tests/dom.client.spec.ts | 201 ++++++ .../tests/fixed-input.client.spec.ts | 150 ++++ .../shortcuts/tests/native.client.spec.ts | 167 +++++ .../tests/persistence.client.spec.ts | 144 ++++ .../shortcuts/tests/registry.client.spec.ts | 205 ++++++ .../shortcuts/tests/service.client.spec.ts | 182 +++++ .../shortcuts/tests/storage.client.spec.ts | 57 ++ .../client/shortcuts/tsconfig.client.json | 37 + packages/client/shortcuts/tsconfig.host.json | 37 + packages/client/shortcuts/tsconfig.json | 11 + packages/client/shortcuts/tsdown.config.ts | 3 + packages/client/ui-approval/README.i18n.yaml | 4 +- packages/client/ui-approval/README.md | 2 + packages/client/ui-approval/README.zh.md | 2 + packages/client/ui-approval/package.json | 3 +- .../ui-approval/src/client/ApprovalPanel.tsx | 37 +- .../ui-approval/src/client/contract/slots.ts | 8 + .../client/ui-approval/src/client/index.ts | 10 + .../tests/ui-approval.client.spec.tsx | 115 ++- packages/client/ui-approval/tsconfig.json | 3 + .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 8 +- packages/client/ui-conversation/README.zh.md | 8 +- packages/client/ui-conversation/package.json | 3 +- .../ui-conversation/src/client/apply.ts | 46 +- .../src/client/contract/slots.ts | 2 + .../src/client/conversation/assembler.ts | 8 +- .../src/client/conversation/assembly.ts | 8 +- .../src/client/input/editor/keymap.ts | 9 +- .../src/client/input/editor/runtime.ts | 18 +- .../ui-conversation/src/client/locales.ts | 10 + .../client/skeleton/ConversationContent.tsx | 4 +- .../src/client/skeleton/InputBar.tsx | 8 +- .../src/client/stop-sequence.ts | 52 ++ .../src/client/stop-shortcut.ts | 68 ++ .../conversation-registry.client.spec.ts | 33 + .../tests/editor-focus.client.spec.ts | 152 ++++ .../tests/input-bar.client.spec.tsx | 29 +- .../tests/input-matrix.client.spec.tsx | 1 + .../tests/input-scenarios.client.spec.tsx | 1 + .../tests/keymap-routing.client.spec.tsx | 44 +- .../tests/skeleton.client.spec.tsx | 1 + .../tests/stop-sequence.client.spec.ts | 71 ++ .../tests/stop-shortcut.client.spec.ts | 210 ++++++ .../ui-conversation/tsconfig.client.json | 3 + packages/client/ui-dockkit/README.i18n.yaml | 4 +- packages/client/ui-dockkit/README.md | 6 + packages/client/ui-dockkit/README.zh.md | 6 + .../ui-dockkit/src/components/FloatLayer.tsx | 5 +- .../ui-dockkit/src/components/TabLayout.tsx | 2 +- .../ui-dockkit/src/components/TabMenu.tsx | 25 +- .../ui-dockkit/src/components/TabPanel.tsx | 72 +- .../client/ui-dockkit/src/contract/adapter.ts | 8 + .../tests/components.client.spec.tsx | 76 +- packages/client/ui-layout/README.i18n.yaml | 4 +- packages/client/ui-layout/README.md | 4 +- packages/client/ui-layout/README.zh.md | 4 +- packages/client/ui-layout/package.json | 6 +- packages/client/ui-layout/src/client/index.ts | 31 +- .../client/ui-layout/src/client/service.ts | 6 +- .../ui-layout/src/client/shortcut-locales.ts | 4 + .../ui-layout/tests/apply.client.spec.ts | 5 +- .../ui-layout/tests/service.client.spec.ts | 15 +- packages/client/ui-layout/tsconfig.json | 3 + .../client/ui-open-in-app/README.i18n.yaml | 4 +- packages/client/ui-open-in-app/README.md | 4 +- packages/client/ui-open-in-app/README.zh.md | 4 +- packages/client/ui-open-in-app/package.json | 6 +- .../src/client/OpenInAppAction.tsx | 51 +- .../src/client/OpenTargetButton.tsx | 5 +- .../ui-open-in-app/src/client/applications.ts | 40 ++ .../ui-open-in-app/src/client/controller.ts | 44 +- .../client/ui-open-in-app/src/client/index.ts | 37 +- .../ui-open-in-app/src/client/locales.ts | 8 + .../tests/browser-plugin.client.spec.ts | 128 +++- .../tests/controller.client.spec.ts | 30 + .../tests/open-in-app-action.client.spec.tsx | 24 +- packages/client/ui-open-in-app/tsconfig.json | 6 + .../tests/browser-plugin.client.spec.ts | 2 + .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 4 +- packages/client/ui-primitives/README.zh.md | 4 +- .../client/ui-primitives/src/HoverCard.tsx | 14 +- .../client/ui-primitives/src/Menu.module.css | 13 + packages/client/ui-primitives/src/Menu.tsx | 33 +- .../client/ui-primitives/src/Modal.module.css | 1 + packages/client/ui-primitives/src/Modal.tsx | 21 +- .../ui-primitives/src/ShortcutKeys.module.css | 16 + .../client/ui-primitives/src/ShortcutKeys.tsx | 12 + .../client/ui-primitives/src/Toast.module.css | 2 +- packages/client/ui-primitives/src/Tooltip.tsx | 7 +- packages/client/ui-primitives/src/index.ts | 3 + .../ui-primitives/src/keyboard-composition.ts | 36 + .../client/ui-primitives/src/useModalLayer.ts | 77 ++ .../ui-primitives/tests/atoms.client.spec.tsx | 9 +- .../tests/hover-card.client.spec.tsx | 43 +- .../tests/modal-layer.client.spec.tsx | 125 ++++ .../ui-settings-account/README.i18n.yaml | 4 +- packages/client/ui-settings-account/README.md | 2 +- .../client/ui-settings-account/README.zh.md | 2 +- .../src/client/AccountMenu.tsx | 12 +- .../tests/account.client.spec.tsx | 29 + .../ui-settings-general/README.i18n.yaml | 4 +- packages/client/ui-settings-general/README.md | 2 + .../client/ui-settings-general/README.zh.md | 2 + .../client/ui-settings-general/package.json | 8 +- .../src/client/SettingsRoot.tsx | 83 +-- .../ui-settings-general/src/client/index.ts | 53 +- .../ui-settings-general/src/client/locales.ts | 4 + .../src/client/shell-contract.ts | 10 +- .../src/client/shell-store.ts | 26 + .../tests/apply.client.spec.ts | 2 +- .../tests/settings-root.client.spec.tsx | 56 +- .../tests/shell.client.spec.ts | 2 +- .../ui-settings-general/tsconfig.client.json | 3 +- packages/client/ui-settings/README.i18n.yaml | 4 +- packages/client/ui-settings/README.md | 2 +- packages/client/ui-settings/README.zh.md | 2 +- .../ui-settings/src/client/contract/slots.ts | 2 + packages/client/ui-shortcuts/README.i18n.yaml | 6 + packages/client/ui-shortcuts/README.md | 85 +++ packages/client/ui-shortcuts/README.zh.md | 85 +++ packages/client/ui-shortcuts/package.json | 68 ++ .../client/ui-shortcuts/src/client/Editor.tsx | 178 +++++ .../client/ui-shortcuts/src/client/Icons.tsx | 17 + .../src/client/Reference.module.css | 207 ++++++ .../ui-shortcuts/src/client/Reference.tsx | 196 +++++ .../ui-shortcuts/src/client/feedback.ts | 32 + .../client/ui-shortcuts/src/client/fixed.ts | 17 + .../client/ui-shortcuts/src/client/index.ts | 65 ++ .../client/ui-shortcuts/src/client/locales.ts | 70 ++ .../client/ui-shortcuts/src/client/store.ts | 20 + .../client/ui-shortcuts/src/css-modules.d.ts | 6 + packages/client/ui-shortcuts/src/index.ts | 4 + .../tests/commands.client.spec.ts | 97 +++ .../ui-shortcuts/tests/editor.client.spec.tsx | 563 +++++++++++++++ .../tests/reference.client.spec.tsx | 451 ++++++++++++ packages/client/ui-shortcuts/tsconfig.json | 42 ++ packages/client/ui-shortcuts/tsdown.config.ts | 3 + .../ui-sidebar-browser/README.i18n.yaml | 4 +- packages/client/ui-sidebar-browser/README.md | 4 + .../client/ui-sidebar-browser/README.zh.md | 4 + .../client/ui-sidebar-browser/package.json | 1 + .../src/client/definition.tsx | 3 +- .../electron/ElectronWebviewPresentation.ts | 1 + .../ui-sidebar-browser/src/client/index.ts | 22 + .../ui-sidebar-browser/src/client/locales.ts | 4 + .../src/client/view/BrowserBody.tsx | 9 +- .../tests/apply.client.spec.ts | 40 +- .../tests/browser-body.client.spec.tsx | 14 +- .../tests/electron-lifecycle.client.spec.ts | 1 + .../ui-sidebar-browser/tsconfig.client.json | 1 + .../README.i18n.yaml | 4 +- .../ui-sidebar-documentpreview/README.md | 4 + .../ui-sidebar-documentpreview/README.zh.md | 4 + .../src/client/TextPreview.tsx | 7 +- .../src/client/html/HtmlBody.tsx | 14 +- .../src/client/locales.ts | 2 + .../tests/document-seat.client.spec.tsx | 2 + .../tests/fixtures.client.ts | 2 +- .../tests/html-body.client.spec.tsx | 4 +- .../tests/text-preview.client.spec.tsx | 10 + .../client/ui-sidebar-files/README.i18n.yaml | 4 +- packages/client/ui-sidebar-files/README.md | 4 + packages/client/ui-sidebar-files/README.zh.md | 4 + packages/client/ui-sidebar-files/package.json | 6 +- .../ui-sidebar-files/src/client/FilesBody.tsx | 25 +- .../src/client/definition.tsx | 2 + .../ui-sidebar-files/src/client/index.ts | 24 +- .../ui-sidebar-files/src/client/locales.ts | 4 + .../tests/apply.client.spec.ts | 25 +- .../tests/files-body.client.spec.tsx | 16 +- .../ui-sidebar-files/tests/mount.client.tsx | 11 +- .../client/ui-sidebar-files/tsconfig.json | 3 + .../client/ui-sidebar-right/README.i18n.yaml | 4 +- packages/client/ui-sidebar-right/README.md | 14 +- packages/client/ui-sidebar-right/README.zh.md | 14 +- packages/client/ui-sidebar-right/package.json | 7 +- .../src/client/contract/slots.ts | 20 +- .../ui-sidebar-right/src/client/focus.ts | 137 ++++ .../ui-sidebar-right/src/client/index.ts | 28 +- .../ui-sidebar-right/src/client/labels.ts | 14 +- .../ui-sidebar-right/src/client/locales.ts | 28 + .../ui-sidebar-right/src/client/service.ts | 142 +++- .../src/client/shell/ExpandButton.tsx | 9 +- .../src/client/shell/SidebarRight.module.css | 9 + .../src/client/shell/SidebarRight.tsx | 73 +- .../src/client/shell/close-focus.ts | 35 + .../ui-sidebar-right/src/client/shortcuts.ts | 95 +++ .../ui-sidebar-right/src/client/tab-domain.ts | 26 +- .../ui-sidebar-right/src/client/tab-info.ts | 7 +- .../src/client/tab-registry.ts | 3 + .../client/tabs/guide/GuideBody.module.css | 3 +- .../src/client/tabs/guide/GuideBody.tsx | 17 +- .../tests/apply.client.spec.ts | 55 +- .../tests/close-focus.client.spec.ts | 72 ++ .../tests/expand-button.client.spec.tsx | 33 +- .../tests/focus.client.spec.ts | 591 +++++++++++++++ .../tests/guide-body.client.spec.tsx | 47 +- .../tests/seat.client.spec.tsx | 149 +++- .../tests/service.client.spec.ts | 15 +- .../tests/tab-info.client.spec.tsx | 2 +- .../client/ui-sidebar-right/tsconfig.json | 6 + .../ui-sidebar-terminal/README.i18n.yaml | 4 +- packages/client/ui-sidebar-terminal/README.md | 2 + .../client/ui-sidebar-terminal/README.zh.md | 2 + .../client/ui-sidebar-terminal/package.json | 6 +- .../src/client/TerminalGuide.module.css | 3 +- .../src/client/TerminalGuide.tsx | 28 +- .../ui-sidebar-terminal/src/client/index.ts | 20 +- .../ui-sidebar-terminal/src/client/locales.ts | 2 + .../tests/apply.client.spec.ts | 28 +- .../tests/terminal-guide.client.spec.tsx | 65 +- .../client/ui-sidebar-terminal/tsconfig.json | 3 + packages/client/ui-sidebar/README.i18n.yaml | 4 +- packages/client/ui-sidebar/README.md | 4 +- packages/client/ui-sidebar/README.zh.md | 4 +- packages/client/ui-sidebar/package.json | 6 +- .../src/client/HeaderLeadingControls.tsx | 11 +- .../ui-sidebar/src/client/SidebarRoot.tsx | 32 +- .../ui-sidebar/src/client/contract/slots.ts | 3 +- .../client/ui-sidebar/src/client/index.ts | 4 +- .../client/ui-sidebar/src/client/locales.ts | 2 + .../ui-sidebar/tests/apply.client.spec.tsx | 4 +- .../tests/panel-list.client.spec.tsx | 3 + .../tests/pointer-scrollbars.client.spec.tsx | 2 +- .../tests/sidebar-root.client.spec.tsx | 54 +- .../tests/sidebar-snapshot.client.spec.tsx | 2 + packages/client/ui-sidebar/tsconfig.json | 3 + packages/client/ui-theme/README.i18n.yaml | 4 +- packages/client/ui-theme/README.md | 2 + packages/client/ui-theme/README.zh.md | 2 + .../ui-theme/src/styles/design-platform.css | 2 + .../ui-trajectory/tests/views.client.spec.tsx | 1 + packages/client/ui-workspace/README.i18n.yaml | 4 +- packages/client/ui-workspace/README.md | 2 + packages/client/ui-workspace/README.zh.md | 2 + packages/client/ui-workspace/package.json | 4 +- .../src/client/WorkspacePicker.tsx | 4 + .../ui-workspace/src/client/contract/slots.ts | 19 +- .../client/ui-workspace/src/client/index.ts | 24 +- .../client/ui-workspace/src/client/locales.ts | 12 + .../ui-workspace/src/client/rows/Rows.tsx | 7 +- .../src/client/rows/WorkspaceBrowser.tsx | 59 +- .../client/session-actions/ArchiveSession.tsx | 4 +- .../client/session-actions/ForkSession.tsx | 6 +- .../client/session-actions/RenameSession.tsx | 4 +- .../ui-workspace/src/client/shortcuts.ts | 122 ++++ .../ui-workspace/tests/apply.client.spec.ts | 7 +- .../tests/host-home-staleness.client.spec.tsx | 5 +- .../tests/rename-assembly.client.spec.tsx | 4 +- .../ui-workspace/tests/rows.client.spec.tsx | 62 +- .../tests/session-actions.client.spec.tsx | 34 +- .../tests/shortcuts.client.spec.ts | 170 +++++ .../tests/workspace-browser.client.spec.tsx | 29 +- .../tests/workspaces-service.client.spec.ts | 3 +- packages/client/ui-workspace/tsconfig.json | 3 + .../src/client/slot-catalog.ts | 50 +- .../references/packages.md | 2 + pnpm-lock.yaml | 133 ++++ scripts/check-workspace-constraints.spec.ts | 13 + scripts/check-workspace-constraints.ts | 2 + scripts/gen-cordis-catalog.ts | 1 + scripts/no-unknown-casts.baseline.json | 9 - .../verify-package-readme-model-experience.ts | 2 + tsconfig.base.json | 3 + tsconfig.client.json | 2 + tsconfig.host.json | 14 +- 380 files changed, 13421 insertions(+), 634 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-20-device-local-shortcut-preferences.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-20-device-local-shortcut-preferences.md create mode 100644 .agents/notes/implemented/architecture/2026-09-20-device-local-shortcut-preferences.zh.md create mode 100644 .agents/notes/implemented/architecture/2026-09-21-desktop-page-close-shortcuts.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-21-desktop-page-close-shortcuts.md create mode 100644 .agents/notes/implemented/architecture/2026-09-21-desktop-page-close-shortcuts.zh.md create mode 100644 apps/desktop/src/keybindings.ts create mode 100644 apps/desktop/src/keyboard.ts create mode 100644 apps/desktop/tests/keybindings.spec.ts create mode 100644 apps/desktop/tests/keyboard.spec.ts create mode 100644 apps/web/tests/composer-shortcuts.e2e.ts create mode 100644 apps/web/tests/expected/shortcuts-desktop/chord.expected.md create mode 100644 apps/web/tests/expected/shortcuts-desktop/fixed-conflict.expected.md create mode 100644 apps/web/tests/expected/shortcuts-desktop/macos-copy-override.expected.md create mode 100644 apps/web/tests/expected/shortcuts-desktop/modal-layers.expected.md create mode 100644 apps/web/tests/expected/shortcuts-desktop/recording.expected.md create mode 100644 apps/web/tests/expected/shortcuts-desktop/windows-copy-override.expected.md create mode 100644 apps/web/tests/expected/shortcuts-panels/browser.expected.md create mode 100644 apps/web/tests/expected/shortcuts-panels/guide-close.expected.md create mode 100644 apps/web/tests/expected/shortcuts-panels/page-actions.expected.md create mode 100644 apps/web/tests/expected/shortcuts-panels/panels.expected.md create mode 100644 apps/web/tests/expected/shortcuts-panels/tab-menu.expected.md create mode 100644 apps/web/tests/expected/shortcuts-workspace/fork-unavailable.expected.md create mode 100644 apps/web/tests/expected/shortcuts-workspace/fork.expected.md create mode 100644 apps/web/tests/expected/shortcuts-workspace/macos-defaults.expected.md create mode 100644 apps/web/tests/expected/shortcuts-workspace/session-menu.expected.md create mode 100644 apps/web/tests/expected/shortcuts/edit-stale.expected.md create mode 100644 apps/web/tests/expected/shortcuts/en-US-conflict.expected.md create mode 100644 apps/web/tests/expected/shortcuts/en-US-empty.expected.md create mode 100644 apps/web/tests/expected/shortcuts/en-US-inline.expected.md create mode 100644 apps/web/tests/expected/shortcuts/en-US-reset-confirm.expected.md create mode 100644 apps/web/tests/expected/shortcuts/en-US-saved.expected.md create mode 100644 apps/web/tests/expected/shortcuts/en-US.expected.md create mode 100644 apps/web/tests/expected/shortcuts/zh-CN-conflict.expected.md create mode 100644 apps/web/tests/expected/shortcuts/zh-CN-empty.expected.md create mode 100644 apps/web/tests/expected/shortcuts/zh-CN-inline.expected.md create mode 100644 apps/web/tests/expected/shortcuts/zh-CN-reset-confirm.expected.md create mode 100644 apps/web/tests/expected/shortcuts/zh-CN-saved.expected.md create mode 100644 apps/web/tests/expected/shortcuts/zh-CN.expected.md create mode 100644 apps/web/tests/shortcuts-desktop.e2e.ts create mode 100644 apps/web/tests/shortcuts-fixed.e2e.ts create mode 100644 apps/web/tests/shortcuts-panels.e2e.ts create mode 100644 apps/web/tests/shortcuts-workspace.e2e.ts create mode 100644 apps/web/tests/shortcuts.e2e.ts create mode 100644 packages/client/shortcuts/README.i18n.yaml create mode 100644 packages/client/shortcuts/README.md create mode 100644 packages/client/shortcuts/README.zh.md create mode 100644 packages/client/shortcuts/package.json create mode 100644 packages/client/shortcuts/src/binding.ts create mode 100644 packages/client/shortcuts/src/client/dom.ts create mode 100644 packages/client/shortcuts/src/client/index.ts create mode 100644 packages/client/shortcuts/src/client/native.ts create mode 100644 packages/client/shortcuts/src/client/registry.ts create mode 100644 packages/client/shortcuts/src/client/storage.ts create mode 100644 packages/client/shortcuts/src/client/types.ts create mode 100644 packages/client/shortcuts/src/config.ts create mode 100644 packages/client/shortcuts/src/configuration.ts create mode 100644 packages/client/shortcuts/src/index.ts create mode 100644 packages/client/shortcuts/src/native.ts create mode 100644 packages/client/shortcuts/src/persistence.ts create mode 100644 packages/client/shortcuts/src/protocol.ts create mode 100644 packages/client/shortcuts/tests/config.host.spec.ts create mode 100644 packages/client/shortcuts/tests/configuration.client.spec.ts create mode 100644 packages/client/shortcuts/tests/desktop-chords.client.spec.ts create mode 100644 packages/client/shortcuts/tests/dom.client.spec.ts create mode 100644 packages/client/shortcuts/tests/fixed-input.client.spec.ts create mode 100644 packages/client/shortcuts/tests/native.client.spec.ts create mode 100644 packages/client/shortcuts/tests/persistence.client.spec.ts create mode 100644 packages/client/shortcuts/tests/registry.client.spec.ts create mode 100644 packages/client/shortcuts/tests/service.client.spec.ts create mode 100644 packages/client/shortcuts/tests/storage.client.spec.ts create mode 100644 packages/client/shortcuts/tsconfig.client.json create mode 100644 packages/client/shortcuts/tsconfig.host.json create mode 100644 packages/client/shortcuts/tsconfig.json create mode 100644 packages/client/shortcuts/tsdown.config.ts create mode 100644 packages/client/ui-conversation/src/client/stop-sequence.ts create mode 100644 packages/client/ui-conversation/src/client/stop-shortcut.ts create mode 100644 packages/client/ui-conversation/tests/editor-focus.client.spec.ts create mode 100644 packages/client/ui-conversation/tests/stop-sequence.client.spec.ts create mode 100644 packages/client/ui-conversation/tests/stop-shortcut.client.spec.ts create mode 100644 packages/client/ui-layout/src/client/shortcut-locales.ts create mode 100644 packages/client/ui-open-in-app/src/client/applications.ts create mode 100644 packages/client/ui-primitives/src/ShortcutKeys.module.css create mode 100644 packages/client/ui-primitives/src/ShortcutKeys.tsx create mode 100644 packages/client/ui-primitives/src/keyboard-composition.ts create mode 100644 packages/client/ui-primitives/src/useModalLayer.ts create mode 100644 packages/client/ui-primitives/tests/modal-layer.client.spec.tsx create mode 100644 packages/client/ui-settings-general/src/client/shell-store.ts create mode 100644 packages/client/ui-shortcuts/README.i18n.yaml create mode 100644 packages/client/ui-shortcuts/README.md create mode 100644 packages/client/ui-shortcuts/README.zh.md create mode 100644 packages/client/ui-shortcuts/package.json create mode 100644 packages/client/ui-shortcuts/src/client/Editor.tsx create mode 100644 packages/client/ui-shortcuts/src/client/Icons.tsx create mode 100644 packages/client/ui-shortcuts/src/client/Reference.module.css create mode 100644 packages/client/ui-shortcuts/src/client/Reference.tsx create mode 100644 packages/client/ui-shortcuts/src/client/feedback.ts create mode 100644 packages/client/ui-shortcuts/src/client/fixed.ts create mode 100644 packages/client/ui-shortcuts/src/client/index.ts create mode 100644 packages/client/ui-shortcuts/src/client/locales.ts create mode 100644 packages/client/ui-shortcuts/src/client/store.ts create mode 100644 packages/client/ui-shortcuts/src/css-modules.d.ts create mode 100644 packages/client/ui-shortcuts/src/index.ts create mode 100644 packages/client/ui-shortcuts/tests/commands.client.spec.ts create mode 100644 packages/client/ui-shortcuts/tests/editor.client.spec.tsx create mode 100644 packages/client/ui-shortcuts/tests/reference.client.spec.tsx create mode 100644 packages/client/ui-shortcuts/tsconfig.json create mode 100644 packages/client/ui-shortcuts/tsdown.config.ts create mode 100644 packages/client/ui-sidebar-right/src/client/focus.ts create mode 100644 packages/client/ui-sidebar-right/src/client/shell/close-focus.ts create mode 100644 packages/client/ui-sidebar-right/src/client/shortcuts.ts create mode 100644 packages/client/ui-sidebar-right/tests/close-focus.client.spec.ts create mode 100644 packages/client/ui-sidebar-right/tests/focus.client.spec.ts create mode 100644 packages/client/ui-workspace/src/client/shortcuts.ts create mode 100644 packages/client/ui-workspace/tests/shortcuts.client.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-09-20-device-local-shortcut-preferences.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-20-device-local-shortcut-preferences.i18n.yaml new file mode 100644 index 0000000000..539358dc7c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-20-device-local-shortcut-preferences.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-20-device-local-shortcut-preferences.md +2026-09-20-device-local-shortcut-preferences.md: 3da3d3a29c5fd068f3498de78c2de84d3f30fe45 +2026-09-20-device-local-shortcut-preferences.zh.md: 3c5420e8708bf168a61d3d60e6a6a2add60a9455 diff --git a/.agents/notes/implemented/architecture/2026-09-20-device-local-shortcut-preferences.md b/.agents/notes/implemented/architecture/2026-09-20-device-local-shortcut-preferences.md new file mode 100644 index 0000000000..3da3d3a29c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-20-device-local-shortcut-preferences.md @@ -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. diff --git a/.agents/notes/implemented/architecture/2026-09-20-device-local-shortcut-preferences.zh.md b/.agents/notes/implemented/architecture/2026-09-20-device-local-shortcut-preferences.zh.md new file mode 100644 index 0000000000..3c5420e870 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-20-device-local-shortcut-preferences.zh.md @@ -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 持久性。确定性的事务、浏览器存储和桌面文件测试覆盖失败保留、冲突、过期草稿以及拒绝替换不可读数据;平台键盘验收与持久化正确性分别验证。 diff --git a/.agents/notes/implemented/architecture/2026-09-21-desktop-page-close-shortcuts.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-21-desktop-page-close-shortcuts.i18n.yaml new file mode 100644 index 0000000000..b10a560c64 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-21-desktop-page-close-shortcuts.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-21-desktop-page-close-shortcuts.md +2026-09-21-desktop-page-close-shortcuts.md: dc976806445f9b78c3dbad83d4cd9f3b0b2f4799 +2026-09-21-desktop-page-close-shortcuts.zh.md: 6ef60c084fe15cdc0f608992a700084cbf81b4c1 diff --git a/.agents/notes/implemented/architecture/2026-09-21-desktop-page-close-shortcuts.md b/.agents/notes/implemented/architecture/2026-09-21-desktop-page-close-shortcuts.md new file mode 100644 index 0000000000..dc97680644 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-21-desktop-page-close-shortcuts.md @@ -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. diff --git a/.agents/notes/implemented/architecture/2026-09-21-desktop-page-close-shortcuts.zh.md b/.agents/notes/implemented/architecture/2026-09-21-desktop-page-close-shortcuts.zh.md new file mode 100644 index 0000000000..6ef60c084f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-21-desktop-page-close-shortcuts.zh.md @@ -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 和焦点保护;侧栏焦点测试覆盖捕获的页面身份和过期目标;启动测试覆盖各平台菜单声明。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.i18n.yaml index 9c2cdd061a..edd7fa1440 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.md -2026-09-16-desktop-window-menus.md: 8a5343404e0cb3d2e7c4e20248a8b29f3bfbec47 -2026-09-16-desktop-window-menus.zh.md: d6ee6412462aecf49557a7994a683d37bf8e7253 +2026-09-16-desktop-window-menus.md: 34eff83707440719182f4ebe61aa552919bd44ee +2026-09-16-desktop-window-menus.zh.md: c3cb3e5d89497ae15210c034c0077fc8019e5688 diff --git a/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.md b/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.md index 8a5343404e..34eff83707 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.md +++ b/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.md @@ -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 diff --git a/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.zh.md b/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.zh.md index d6ee641246..c3cb3e5d89 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.zh.md @@ -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 的语言。 ## 测试 diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 77500865f6..1e05757729 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/desktop/README.md -README.md: 2b07b2b9d95c3d16a3aaa58b6d52f55cb1242e2e -README.zh.md: 4ebae34332b1d622a35d0bf857f1f3252736512c +README.md: 08c32be1d519b9d5185decf6811dc92939649f6e +README.zh.md: 70d8f969a1e3f2e7fd8af90c3d170a9bf60adefa diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 2b07b2b9d9..08c32be1d5 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -22,6 +22,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. 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 @@ -64,9 +68,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 @@ -88,7 +92,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-` (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. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 4ebae34332..70d8f969a1 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -22,6 +22,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 输入之前拦截所有已接受的完整绑定,包括编辑和终端输入。录制和输入法组合状态仍受保护。双键组合会将首键的初次按下事件交给页面,且不拦截其松开事件;完整组合及其重复事件会被消费。渲染进程将可配置绑定的分发交给原生适配器。双键组合不注册原生菜单快捷键。已接受的命令通过可信 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 的显示效果。 ### 内置工作区依赖 @@ -54,7 +58,7 @@ Node 准备内置解释器和 Python 库,无需系统 Python 或 pip。[下载 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 参数。 只有主应用窗口启用 ``。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 过滤而必需。 @@ -64,9 +68,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 菜单。 ### 运行时与插件激活 @@ -88,7 +92,6 @@ macOS 上自定义应用菜单还会声明标准的 File、Window 和应用菜 恢复操作等待 Host 关闭后才修改插件启用状态。原生恢复操作在 profile 事务锁内调用共享 app-boot 恢复函数。它禁用第三方 bundle,并将 profile 的 `cordis.patch.yml` 重命名为 `cordis.patch.yml.bak-`(重名时追加序号),无需解析;下次启动创建空 patch。已安装包和已有备份保留。home 级 patch 不变。Electron 控制台记录备份路径(或原文件不存在)以及 home 级 patch 未修改。profile 数据无效、重命名失败或写入失败会作为恢复操作错误报告;已完成的修改保留,Desktop 不会假装恢复成功后重启。Desktop 不提供 profile 重置操作或应急 HTML 文档。 - ## 开发 开发环境应用菜单提供“刷新页面”(macOS 为 Cmd+R,其他平台为 Ctrl+R)和“重启应用与 Host”。重启会等待 Host 关闭,再重新启动 Electron 和新的 Host;这两项操作都不会重新构建源码。 @@ -377,7 +380,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 错误。只有新的有效无需强更响应才解除阻塞;阻塞期间仍可使用顶部菜单检查。 diff --git a/apps/desktop/package.json b/apps/desktop/package.json index d7c8fbdd09..0a805dde09 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -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:^" } } diff --git a/apps/desktop/scripts/dev.ts b/apps/desktop/scripts/dev.ts index 74840eae03..36d57325dc 100644 --- a/apps/desktop/scripts/dev.ts +++ b/apps/desktop/scripts/dev.ts @@ -63,7 +63,7 @@ async function launchElectron(): Promise { 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, @@ -72,6 +72,7 @@ async function launchElectron(): Promise { 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, diff --git a/apps/desktop/src/browser-guests.ts b/apps/desktop/src/browser-guests.ts index 027b7cee5b..c3f0b10eea 100644 --- a/apps/desktop/src/browser-guests.ts +++ b/apps/desktop/src/browser-guests.ts @@ -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) diff --git a/apps/desktop/src/ipc.ts b/apps/desktop/src/ipc.ts index cf03b7e6c2..8cd7d8dddc 100644 --- a/apps/desktop/src/ipc.ts +++ b/apps/desktop/src/ipc.ts @@ -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', bootFailed: 'dsh-desktop:boot-failed', @@ -62,6 +69,8 @@ export interface DesktopUpdatePresentation { export interface DshDesktopProductApi { readonly protocolVersion: 1 readonly browser: DesktopBrowserBridge + readonly keyboard: DesktopKeyboardApi + readonly shortcuts: DesktopShortcutsApi readonly updates: { status(): Promise open(): Promise diff --git a/apps/desktop/src/keybindings.ts b/apps/desktop/src/keybindings.ts new file mode 100644 index 0000000000..0825906c27 --- /dev/null +++ b/apps/desktop/src/keybindings.ts @@ -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) +} diff --git a/apps/desktop/src/keyboard.ts b/apps/desktop/src/keyboard.ts new file mode 100644 index 0000000000..4cd994f8f7 --- /dev/null +++ b/apps/desktop/src/keyboard.ts @@ -0,0 +1,253 @@ +/** 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. + * @returns menu construction, editor key delivery, window attachment, and teardown operations. + */ +export function installDesktopShortcuts( + getWindow: () => BrowserWindow | undefined, userData: string, platform: ShortcutPlatform, updateMenu: () => void, +): { + 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() + 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) return + window.webContents.send(DESKTOP_IPC.shortcutsInput, { kind: 'menu', commandId: 'page.close', revision }) + } + let keys = new Set() + 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, revision: unknown) => { + assertSender(event) + if (typeof revision !== 'string') throw new Error('desktop shortcuts: invalid revision') + return persistence.edit(parseShortcutEdit(input), revision 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()) return + window.close() + }) + function attachInput(window: BrowserWindow, contents: WebContents, guestName?: DesktopBrowserLeaseId): () => void { + let deadKey = false + const held = new Set() + const consumed = new Map() + let inputFrame: typeof contents.focusedFrame = null + let inputRevision: ShortcutRevision | undefined + 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): void => { + if (event.isMainFrame && !event.isSameDocument) clear() + } + const beforeInput = (event: Electron.Event, input: Input): void => { + 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()) 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) + } + }, + } +} diff --git a/apps/desktop/src/locale.ts b/apps/desktop/src/locale.ts index 7a1c935a8d..becf7eafda 100644 --- a/apps/desktop/src/locale.ts +++ b/apps/desktop/src/locale.ts @@ -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}', @@ -132,6 +134,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}', diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 9f8ad5c22f..2b93427bf1 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -48,6 +48,7 @@ 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' let focusPrimaryWindow = (): void => {} let stopForRecovery = async (): Promise => {} @@ -567,6 +568,9 @@ async function main(): Promise { installDesktopDirectoryPicker(() => mainWindow) installMicrophonePermissions(session.defaultSession, () => mainWindow?.webContents) + const shortcuts = installDesktopShortcuts(() => mainWindow, app.getPath('userData'), + process.platform === 'darwin' ? 'macos' : process.platform === 'win32' ? 'windows' : 'linux', () => { refreshApplicationMenu() }) + app.on('will-quit', () => { shortcuts.dispose() }) ipcMain.handle(DESKTOP_IPC.boot, async (event) => { assertDesktopSender(event, ['app']) @@ -648,7 +652,7 @@ async function main(): Promise { locale = current platformView.notifyLocaleChanged() windowsLanguage = locale.id - installMenu() + refreshApplicationMenu() }) ipcMain.handle(DESKTOP_IPC.updatesStatus, (event) => { assertProductSender(event) @@ -800,8 +804,8 @@ async function main(): Promise { // 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 }, @@ -835,13 +839,13 @@ async function main(): Promise { { 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) => { @@ -857,9 +861,7 @@ async function main(): Promise { 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() : [ @@ -895,7 +897,8 @@ async function main(): Promise { 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) => { @@ -990,7 +993,7 @@ async function main(): Promise { 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 { diff --git a/apps/desktop/src/preload-app.ts b/apps/desktop/src/preload-app.ts index 13bbc20690..a500f6ed93 100644 --- a/apps/desktop/src/preload-app.ts +++ b/apps/desktop/src/preload-app.ts @@ -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, + 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, + edit: (edit, revision) => ipcRenderer.invoke(DESKTOP_IPC.shortcutsEdit, edit, revision) as Promise, + recording: active => ipcRenderer.invoke(DESKTOP_IPC.shortcutsRecording, active) as Promise, + 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, open: () => ipcRenderer.invoke(DESKTOP_IPC.updatesOpen) as Promise, @@ -60,7 +92,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__', { diff --git a/apps/desktop/tests/keybindings.spec.ts b/apps/desktop/tests/keybindings.spec.ts new file mode 100644 index 0000000000..d826a36151 --- /dev/null +++ b/apps/desktop/tests/keybindings.spec.ts @@ -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) +}) diff --git a/apps/desktop/tests/keyboard.spec.ts b/apps/desktop/tests/keyboard.spec.ts new file mode 100644 index 0000000000..210ba9852b --- /dev/null +++ b/apps/desktop/tests/keyboard.spec.ts @@ -0,0 +1,679 @@ +// @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 '../../../packages/client/shortcuts/src/client/registry.ts' +import { installKeyboard } from '../../../packages/client/shortcuts/src/client/dom.ts' +import { installNativeKeyboard } from '../../../packages/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() })) +vi.mock('electron', () => ({ ipcMain: ipc, 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') +afterEach(() => { vi.clearAllMocks() }) + +type FrameFixture = { url: WebFrameMain['url']; name: WebFrameMain['name']; parent: FrameFixture | null } +type ContentsFixture = EventEmitter & Pick & { + mainFrame: FrameFixture + focusedFrame: FrameFixture | null + } +type WindowFixture = EventEmitter & Pick & { + webContents: ContentsFixture +} +type InvokeFixture = { sender: object; senderFrame: object | null } +type KeyboardFixture = Omit, '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[2], updateMenu: () => void, +) => KeyboardFixture +type GuestsFixture = { + acquire(owner: ContentsFixture, workspace: unknown): DesktopBrowserReservation + release(owner: ContentsFixture, id: unknown): Promise + 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 } +} + +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 keyboard = installFixture(() => current, root, platform, updateMenu) + keyboard.attach(current) + onTestFinished(() => { keyboard.dispose() }) + const handlers = new Map unknown>( + ipc.handle.mock.calls.map(([channel, handler]) => [channel, handler])) + const event: InvokeFixture = { sender: contents, senderFrame: frame } + const call = async (channel: string, ...args: unknown[]): Promise => 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, contents, frame, event, handlers, call, definitions, updateMenu, detach: () => { current = undefined } } +} + +it('mirrors only successful bindings, suppresses recording menus, and invalidates pre-navigation drafts', async () => { + const f = await fixture() + const initial = await f.call(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(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(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(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(DESKTOP_IPC.shortcutsEdit, { type: 'reset-all' }, saved.snapshot.revision)).status).toBe('stale') +}) + +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(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(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(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(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(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(DESKTOP_IPC.shortcutsGet, + [{ id: 'page.close', defaults: desktopDefaults({ code: 'KeyW', modifiers: ['primary'] }) }]) + const saved = await f.call(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(DESKTOP_IPC.shortcutsGet, [close, ...f.definitions]) + const edited = await f.call(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(DESKTOP_IPC.shortcutsGet, + [{ id: 'session.new', defaults: desktopDefaults({ code: 'KeyN', modifiers: ['primary'] }) }]) + const saved = await f.call(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(DESKTOP_IPC.shortcutsGet, + [{ id: 'page.close', defaults: desktopDefaults({ code: 'KeyW', modifiers: ['primary'] }) }]) + const saved = await f.call(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(DESKTOP_IPC.shortcutsGet, f.definitions) + const saved = await f.call(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(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(DESKTOP_IPC.shortcutsGet, f.definitions) + for (const code of ['KeyA', 'F1', 'ArrowLeft', 'Escape', 'Tab', 'Enter']) { + const saved = await f.call(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(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(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(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(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(DESKTOP_IPC.shortcutsGet, f.definitions) + const saved = await f.call(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(DESKTOP_IPC.shortcutsGet, f.definitions) + const saved = await f.call(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(DESKTOP_IPC.shortcutsGet, + bindings.map(({ id }) => ({ id, defaults: {} }))) + for (const binding of bindings) { + const saved = await f.call(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(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: 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') }) + 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(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(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) +}) diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index 0242d214a3..06509c7ae4 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -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 } }) => { @@ -140,7 +142,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(), @@ -324,6 +326,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() @@ -781,7 +786,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 @@ -792,7 +797,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' diff --git a/apps/desktop/tests/preload-app.spec.ts b/apps/desktop/tests/preload-app.spec.ts index 16c94456a4..0564fdf91a 100644 --- a/apps/desktop/tests/preload-app.spec.ts +++ b/apps/desktop/tests/preload-app.spec.ts @@ -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('limits product documents to update status and a native confirmation action', async () => { vi.stubGlobal('location', new URL('dsh-app://app/index.html')) @@ -121,7 +123,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() @@ -130,3 +132,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() +}) diff --git a/apps/desktop/tests/welcome-startup.spec.ts b/apps/desktop/tests/welcome-startup.spec.ts index 4a823afdf9..942d30b488 100644 --- a/apps/desktop/tests/welcome-startup.spec.ts +++ b/apps/desktop/tests/welcome-startup.spec.ts @@ -51,7 +51,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) }, diff --git a/apps/desktop/tsconfig.host.json b/apps/desktop/tsconfig.host.json index 37a5be420d..370ee0a448 100644 --- a/apps/desktop/tsconfig.host.json +++ b/apps/desktop/tsconfig.host.json @@ -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": [ diff --git a/apps/web/tests/approval-composer.e2e.ts b/apps/web/tests/approval-composer.e2e.ts index d1bc84a488..8ffee0001a 100644 --- a/apps/web/tests/approval-composer.e2e.ts +++ b/apps/web/tests/approval-composer.e2e.ts @@ -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') { diff --git a/apps/web/tests/composer-shortcuts.e2e.ts b/apps/web/tests/composer-shortcuts.e2e.ts new file mode 100644 index 0000000000..ce38a1b5f6 --- /dev/null +++ b/apps/web/tests/composer-shortcuts.e2e.ts @@ -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() + } +}) diff --git a/apps/web/tests/expected/desktop-locale/automatic.expected.md b/apps/web/tests/expected/desktop-locale/automatic.expected.md index dcb0ed4d1b..9e60aaae27 100644 --- a/apps/web/tests/expected/desktop-locale/automatic.expected.md +++ b/apps/web/tests/expected/desktop-locale/automatic.expected.md @@ -24,6 +24,9 @@ - button "详细" - text: 开发者工具 显示用于调试和排查问题的工具与信息 - switch "开发者工具" [checked] + - text: 快捷键 + - paragraph: 查看当前可用的快捷键和输入操作 + - button "查看快捷键" - text: 繁忙时的发送行为 智能体运行时 Enter 键和发送按钮的行为;Cmd/Ctrl+Enter 使用另一行为 - button "排队发送" - text: 当前版本:{{version}} diff --git a/apps/web/tests/expected/desktop-locale/selected.expected.md b/apps/web/tests/expected/desktop-locale/selected.expected.md index eab4f4e977..85683ee2c1 100644 --- a/apps/web/tests/expected/desktop-locale/selected.expected.md +++ b/apps/web/tests/expected/desktop-locale/selected.expected.md @@ -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}}" diff --git a/apps/web/tests/expected/settings-chrome/dialog-en.expected.md b/apps/web/tests/expected/settings-chrome/dialog-en.expected.md index 3f945d9cce..7e8776fa0c 100644 --- a/apps/web/tests/expected/settings-chrome/dialog-en.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog-en.expected.md @@ -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}}" diff --git a/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md b/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md index 3f945d9cce..7e8776fa0c 100644 --- a/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md @@ -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}}" diff --git a/apps/web/tests/expected/settings-chrome/dialog.expected.md b/apps/web/tests/expected/settings-chrome/dialog.expected.md index c0a1b2ed35..37d224d2a2 100644 --- a/apps/web/tests/expected/settings-chrome/dialog.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog.expected.md @@ -24,6 +24,9 @@ - button "详细" - text: 开发者工具 显示用于调试和排查问题的工具与信息 - switch "开发者工具" + - text: 快捷键 + - paragraph: 查看当前可用的快捷键和输入操作 + - button "查看快捷键" - text: 繁忙时的发送行为 智能体运行时 Enter 键和发送按钮的行为;Cmd/Ctrl+Enter 使用另一行为 - button "排队发送" - text: 当前版本:{{version}} diff --git a/apps/web/tests/expected/shortcuts-desktop/chord.expected.md b/apps/web/tests/expected/shortcuts-desktop/chord.expected.md new file mode 100644 index 0000000000..3bf739802b --- /dev/null +++ b/apps/web/tests/expected/shortcuts-desktop/chord.expected.md @@ -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 diff --git a/apps/web/tests/expected/shortcuts-desktop/fixed-conflict.expected.md b/apps/web/tests/expected/shortcuts-desktop/fixed-conflict.expected.md new file mode 100644 index 0000000000..256f346bd7 --- /dev/null +++ b/apps/web/tests/expected/shortcuts-desktop/fixed-conflict.expected.md @@ -0,0 +1,5 @@ +- group "New Session": + - button "Restore default" + - button "Remove" + - button "Press a shortcut" + - text: Already used by “Open reference menu” diff --git a/apps/web/tests/expected/shortcuts-desktop/macos-copy-override.expected.md b/apps/web/tests/expected/shortcuts-desktop/macos-copy-override.expected.md new file mode 100644 index 0000000000..0881936876 --- /dev/null +++ b/apps/web/tests/expected/shortcuts-desktop/macos-copy-override.expected.md @@ -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 diff --git a/apps/web/tests/expected/shortcuts-desktop/modal-layers.expected.md b/apps/web/tests/expected/shortcuts-desktop/modal-layers.expected.md new file mode 100644 index 0000000000..33afa9e582 --- /dev/null +++ b/apps/web/tests/expected/shortcuts-desktop/modal-layers.expected.md @@ -0,0 +1,12 @@ +# Modal layers + +{ + "settingsOnTop": { + "focused": "Settings", + "foreground": "Settings" + }, + "referenceOnTop": { + "focused": "Keyboard shortcuts", + "foreground": "Keyboard shortcuts" + } +} diff --git a/apps/web/tests/expected/shortcuts-desktop/recording.expected.md b/apps/web/tests/expected/shortcuts-desktop/recording.expected.md new file mode 100644 index 0000000000..4f4e54a0c5 --- /dev/null +++ b/apps/web/tests/expected/shortcuts-desktop/recording.expected.md @@ -0,0 +1,4 @@ +- group "New Session": + - button "Restore default" + - button "Remove" + - button "Press a shortcut" diff --git a/apps/web/tests/expected/shortcuts-desktop/windows-copy-override.expected.md b/apps/web/tests/expected/shortcuts-desktop/windows-copy-override.expected.md new file mode 100644 index 0000000000..fd3ec59c13 --- /dev/null +++ b/apps/web/tests/expected/shortcuts-desktop/windows-copy-override.expected.md @@ -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 diff --git a/apps/web/tests/expected/shortcuts-panels/browser.expected.md b/apps/web/tests/expected/shortcuts-panels/browser.expected.md new file mode 100644 index 0000000000..40c2378f41 --- /dev/null +++ b/apps/web/tests/expected/shortcuts-panels/browser.expected.md @@ -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 diff --git a/apps/web/tests/expected/shortcuts-panels/guide-close.expected.md b/apps/web/tests/expected/shortcuts-panels/guide-close.expected.md new file mode 100644 index 0000000000..c255700433 --- /dev/null +++ b/apps/web/tests/expected/shortcuts-panels/guide-close.expected.md @@ -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" diff --git a/apps/web/tests/expected/shortcuts-panels/page-actions.expected.md b/apps/web/tests/expected/shortcuts-panels/page-actions.expected.md new file mode 100644 index 0000000000..63ee48e589 --- /dev/null +++ b/apps/web/tests/expected/shortcuts-panels/page-actions.expected.md @@ -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" diff --git a/apps/web/tests/expected/shortcuts-panels/panels.expected.md b/apps/web/tests/expected/shortcuts-panels/panels.expected.md new file mode 100644 index 0000000000..7b6537870e --- /dev/null +++ b/apps/web/tests/expected/shortcuts-panels/panels.expected.md @@ -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 diff --git a/apps/web/tests/expected/shortcuts-panels/tab-menu.expected.md b/apps/web/tests/expected/shortcuts-panels/tab-menu.expected.md new file mode 100644 index 0000000000..c8ab6426ab --- /dev/null +++ b/apps/web/tests/expected/shortcuts-panels/tab-menu.expected.md @@ -0,0 +1,2 @@ +- menu: + - menuitem "Close" diff --git a/apps/web/tests/expected/shortcuts-workspace/fork-unavailable.expected.md b/apps/web/tests/expected/shortcuts-workspace/fork-unavailable.expected.md new file mode 100644 index 0000000000..074a26e764 --- /dev/null +++ b/apps/web/tests/expected/shortcuts-workspace/fork-unavailable.expected.md @@ -0,0 +1 @@ +- alert: This session has no completed turn diff --git a/apps/web/tests/expected/shortcuts-workspace/fork.expected.md b/apps/web/tests/expected/shortcuts-workspace/fork.expected.md new file mode 100644 index 0000000000..a906aa0e5b --- /dev/null +++ b/apps/web/tests/expected/shortcuts-workspace/fork.expected.md @@ -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] diff --git a/apps/web/tests/expected/shortcuts-workspace/macos-defaults.expected.md b/apps/web/tests/expected/shortcuts-workspace/macos-defaults.expected.md new file mode 100644 index 0000000000..7c9861aec6 --- /dev/null +++ b/apps/web/tests/expected/shortcuts-workspace/macos-defaults.expected.md @@ -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: 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: 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: 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: 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: Refresh current page + - button "Edit shortcut for Refresh current page" + - button "Refresh current page No shortcut": No shortcut + - listitem: + - text: New Session + - button "Remove shortcut for New Session" + - button "Edit shortcut for New Session" + - button "New Session ⌥ ⌘ N": ⌥ ⌘ N + - 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: 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 ⌃ `"': "⌃ `" + - region "Message input": + - heading "Message input" [level=3] + - list: + - listitem: + - text: Send message + - button "Send message Enter" [disabled]: Enter + - listitem: + - text: New line + - button "New line ⇧ Enter" [disabled]: ⇧ Enter + - listitem: + - text: Use the complementary Queue / Steer action + - button "Use the complementary Queue / Steer action ⌘ Enter" [disabled]: ⌘ Enter + - listitem: + - text: Open command menu + - button "Open command menu /" [disabled]: / + - listitem: + - text: Open reference menu + - button "Open reference menu @" [disabled]: "@" + - listitem: + - text: Stop generating + - button "Stop generating Esc Esc" [disabled]: Esc Esc + - region "Menus and dialogs": + - heading "Menus and dialogs" [level=3] + - list: + - listitem: + - text: Move menu selection + - button "Move menu selection ↑ ↓" [disabled]: ↑ ↓ + - listitem: + - text: Select menu item + - button "Select menu item Enter" [disabled]: Enter + - listitem: + - text: Close menu or top dialog + - button "Close menu or top dialog Esc" [disabled]: Esc + - 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 diff --git a/apps/web/tests/expected/shortcuts-workspace/session-menu.expected.md b/apps/web/tests/expected/shortcuts-workspace/session-menu.expected.md new file mode 100644 index 0000000000..88405f3950 --- /dev/null +++ b/apps/web/tests/expected/shortcuts-workspace/session-menu.expected.md @@ -0,0 +1,5 @@ +- menu: + - menuitem "Pin session" + - menuitem "Rename" + - menuitem "Fork session" + - menuitem "Archive session" diff --git a/apps/web/tests/expected/shortcuts/edit-stale.expected.md b/apps/web/tests/expected/shortcuts/edit-stale.expected.md new file mode 100644 index 0000000000..0293a87c04 --- /dev/null +++ b/apps/web/tests/expected/shortcuts/edit-stale.expected.md @@ -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" diff --git a/apps/web/tests/expected/shortcuts/en-US-conflict.expected.md b/apps/web/tests/expected/shortcuts/en-US-conflict.expected.md new file mode 100644 index 0000000000..f359af0fc7 --- /dev/null +++ b/apps/web/tests/expected/shortcuts/en-US-conflict.expected.md @@ -0,0 +1 @@ +- alert: Already used by “Open keyboard shortcuts” diff --git a/apps/web/tests/expected/shortcuts/en-US-empty.expected.md b/apps/web/tests/expected/shortcuts/en-US-empty.expected.md new file mode 100644 index 0000000000..0661c89929 --- /dev/null +++ b/apps/web/tests/expected/shortcuts/en-US-empty.expected.md @@ -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 diff --git a/apps/web/tests/expected/shortcuts/en-US-inline.expected.md b/apps/web/tests/expected/shortcuts/en-US-inline.expected.md new file mode 100644 index 0000000000..5361718dc6 --- /dev/null +++ b/apps/web/tests/expected/shortcuts/en-US-inline.expected.md @@ -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. diff --git a/apps/web/tests/expected/shortcuts/en-US-reset-confirm.expected.md b/apps/web/tests/expected/shortcuts/en-US-reset-confirm.expected.md new file mode 100644 index 0000000000..5ad9adc331 --- /dev/null +++ b/apps/web/tests/expected/shortcuts/en-US-reset-confirm.expected.md @@ -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" diff --git a/apps/web/tests/expected/shortcuts/en-US-saved.expected.md b/apps/web/tests/expected/shortcuts/en-US-saved.expected.md new file mode 100644 index 0000000000..be3933029d --- /dev/null +++ b/apps/web/tests/expected/shortcuts/en-US-saved.expected.md @@ -0,0 +1 @@ +- alert: Modified diff --git a/apps/web/tests/expected/shortcuts/en-US.expected.md b/apps/web/tests/expected/shortcuts/en-US.expected.md new file mode 100644 index 0000000000..5a2447642e --- /dev/null +++ b/apps/web/tests/expected/shortcuts/en-US.expected.md @@ -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: 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: 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: 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: 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: 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: 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: 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: 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 + `" + - region "Message input": + - heading "Message input" [level=3] + - list: + - listitem: + - text: Send message + - button "Send message Enter" [disabled]: Enter + - listitem: + - text: New line + - button "New line Shift + Enter" [disabled]: Shift + Enter + - listitem: + - text: Use the complementary Queue / Steer action + - button "Use the complementary Queue / Steer action Ctrl + Enter" [disabled]: Ctrl + Enter + - listitem: + - text: Open command menu + - button "Open command menu /" [disabled]: / + - listitem: + - text: Open reference menu + - button "Open reference menu @" [disabled]: "@" + - listitem: + - text: Stop generating + - button "Stop generating Esc Esc" [disabled]: Esc Esc + - region "Menus and dialogs": + - heading "Menus and dialogs" [level=3] + - list: + - listitem: + - text: Move menu selection + - button "Move menu selection ↑ ↓" [disabled]: ↑ ↓ + - listitem: + - text: Select menu item + - button "Select menu item Enter" [disabled]: Enter + - listitem: + - text: Close menu or top dialog + - button "Close menu or top dialog Esc" [disabled]: Esc + - 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 diff --git a/apps/web/tests/expected/shortcuts/zh-CN-conflict.expected.md b/apps/web/tests/expected/shortcuts/zh-CN-conflict.expected.md new file mode 100644 index 0000000000..91f6c75f5f --- /dev/null +++ b/apps/web/tests/expected/shortcuts/zh-CN-conflict.expected.md @@ -0,0 +1 @@ +- alert: 已被「快捷键速查」占用 diff --git a/apps/web/tests/expected/shortcuts/zh-CN-empty.expected.md b/apps/web/tests/expected/shortcuts/zh-CN-empty.expected.md new file mode 100644 index 0000000000..8bc73cf849 --- /dev/null +++ b/apps/web/tests/expected/shortcuts/zh-CN-empty.expected.md @@ -0,0 +1,10 @@ +- dialog "快捷键": + - banner: + - heading "快捷键" [level=2] + - button "关闭快捷键" + - search "搜索快捷键": + - searchbox "搜索快捷键": abc + - status: 没有匹配的快捷键 + - contentinfo: + - button "恢复全部默认" + - text: 1 项已自定义 diff --git a/apps/web/tests/expected/shortcuts/zh-CN-inline.expected.md b/apps/web/tests/expected/shortcuts/zh-CN-inline.expected.md new file mode 100644 index 0000000000..72256d0b99 --- /dev/null +++ b/apps/web/tests/expected/shortcuts/zh-CN-inline.expected.md @@ -0,0 +1,5 @@ +- group "展开/收起左侧栏": + - button "恢复默认" + - button "移除" + - button "按下快捷键" + - text: 可设置 Command+/、Command+,、Command+反斜杠、Control+反引号、Command+Option+按键或 Command+Shift+按键。也支持三个或四个不同修饰键的组合。浏览器或系统占用的组合可能无法送达页面。 diff --git a/apps/web/tests/expected/shortcuts/zh-CN-reset-confirm.expected.md b/apps/web/tests/expected/shortcuts/zh-CN-reset-confirm.expected.md new file mode 100644 index 0000000000..2974c54bb9 --- /dev/null +++ b/apps/web/tests/expected/shortcuts/zh-CN-reset-confirm.expected.md @@ -0,0 +1,6 @@ +- dialog "恢复全部默认快捷键?": + - heading "恢复全部默认快捷键?" [level=2] + - button "关闭确认" + - paragraph: 将恢复当前平台的默认快捷键。你修改或移除的快捷键都会恢复,其他平台不受影响。 + - button "取消" + - button "恢复默认" diff --git a/apps/web/tests/expected/shortcuts/zh-CN-saved.expected.md b/apps/web/tests/expected/shortcuts/zh-CN-saved.expected.md new file mode 100644 index 0000000000..bc0fb851da --- /dev/null +++ b/apps/web/tests/expected/shortcuts/zh-CN-saved.expected.md @@ -0,0 +1 @@ +- alert: 已修改 diff --git a/apps/web/tests/expected/shortcuts/zh-CN.expected.md b/apps/web/tests/expected/shortcuts/zh-CN.expected.md new file mode 100644 index 0000000000..9eb7945d00 --- /dev/null +++ b/apps/web/tests/expected/shortcuts/zh-CN.expected.md @@ -0,0 +1,132 @@ +- dialog "快捷键": + - banner: + - heading "快捷键" [level=2] + - button "关闭快捷键" + - search "搜索快捷键": + - searchbox "搜索快捷键" + - region "应用操作": + - list: + - listitem: + - text: 快捷键速查 + - button "移除快捷键速查快捷键" + - button "修改快捷键速查快捷键" + - button "快捷键速查 ⌘ /": ⌘ / + - listitem: + - text: 展开/收起左侧栏 + - button "移除展开/收起左侧栏快捷键" + - button "修改展开/收起左侧栏快捷键" + - button "展开/收起左侧栏 ⌥ ⌘ B": ⌥ ⌘ B + - listitem: + - text: 展开/收起右侧栏 + - button "移除展开/收起右侧栏快捷键" + - button "修改展开/收起右侧栏快捷键" + - button "展开/收起右侧栏 ⇧ ⌘ B": ⇧ ⌘ B + - listitem: + - text: 分栏 + - button "移除分栏快捷键" + - button "修改分栏快捷键" + - button "分栏 ⌘ \\": ⌘ \ + - listitem: + - text: 面板全屏/退出全屏 + - button "移除面板全屏/退出全屏快捷键" + - button "修改面板全屏/退出全屏快捷键" + - button "面板全屏/退出全屏 ⌥ ⌘ Enter": ⌥ ⌘ Enter + - listitem: + - text: 关闭当前页面/窗口 + - button "移除关闭当前页面/窗口快捷键" + - button "修改关闭当前页面/窗口快捷键" + - button "关闭当前页面/窗口 ⌥ ⌘ W": ⌥ ⌘ W + - listitem: + - text: 刷新当前页面 + - button "修改刷新当前页面快捷键" + - button "刷新当前页面 暂无快捷键": 暂无快捷键 + - listitem: + - text: 新会话 + - button "移除新会话快捷键" + - button "修改新会话快捷键" + - button "新会话 ⌥ ⌘ N": ⌥ ⌘ N + - 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 "工作区文件 ⌥ ⌘ P": ⌥ ⌘ P + - listitem: + - text: 新建终端 + - button "移除新建终端快捷键" + - button "修改新建终端快捷键" + - 'button "新建终端 ⌃ `"': "⌃ `" + - region "消息输入": + - heading "消息输入" [level=3] + - list: + - listitem: + - text: 发送消息 + - button "发送消息 Enter" [disabled]: Enter + - listitem: + - text: 换行 + - button "换行 ⇧ Enter" [disabled]: ⇧ Enter + - listitem: + - text: 使用互补的 Queue/Steer 方式 + - button "使用互补的 Queue/Steer 方式 ⌘ Enter" [disabled]: ⌘ Enter + - listitem: + - text: 打开命令菜单 + - button "打开命令菜单 /" [disabled]: / + - listitem: + - text: 打开引用菜单 + - button "打开引用菜单 @" [disabled]: "@" + - listitem: + - text: 停止生成 + - button "停止生成 Esc Esc" [disabled]: Esc Esc + - region "菜单与弹层": + - heading "菜单与弹层" [level=3] + - list: + - listitem: + - text: 移动菜单选择 + - button "移动菜单选择 ↑ ↓" [disabled]: ↑ ↓ + - listitem: + - text: 选择菜单项 + - button "选择菜单项 Enter" [disabled]: Enter + - listitem: + - text: 关闭菜单或顶层弹窗 + - button "关闭菜单或顶层弹窗 Esc" [disabled]: Esc + - region "审批区域": + - heading "审批区域" [level=3] + - list: + - listitem: + - text: 允许一次 + - button "允许一次 Enter" [disabled]: Enter + - listitem: + - text: 拒绝 + - button "拒绝 Esc" [disabled]: Esc + - contentinfo: + - button "恢复全部默认" [disabled] + - text: 0 项已自定义 diff --git a/apps/web/tests/live-interactions.e2e.ts b/apps/web/tests/live-interactions.e2e.ts index 6592322148..e3c91a03a4 100644 --- a/apps/web/tests/live-interactions.e2e.ts +++ b/apps/web/tests/live-interactions.e2e.ts @@ -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') diff --git a/apps/web/tests/queue-actions.e2e.ts b/apps/web/tests/queue-actions.e2e.ts index c5317cb3fb..d1cf569346 100644 --- a/apps/web/tests/queue-actions.e2e.ts +++ b/apps/web/tests/queue-actions.e2e.ts @@ -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') @@ -285,8 +285,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) diff --git a/apps/web/tests/sessionless-header.e2e.ts b/apps/web/tests/sessionless-header.e2e.ts index b2c1a85fb0..142777bfcd 100644 --- a/apps/web/tests/sessionless-header.e2e.ts +++ b/apps/web/tests/sessionless-header.e2e.ts @@ -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' }) diff --git a/apps/web/tests/shortcuts-desktop.e2e.ts b/apps/web/tests/shortcuts-desktop.e2e.ts new file mode 100644 index 0000000000..172d919126 --- /dev/null +++ b/apps/web/tests/shortcuts-desktop.e2e.ts @@ -0,0 +1,258 @@ +/** 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, 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) => page.evaluate((input) => { + window.shortcutFixture.deliver(input) + }, { + kind: 'keyboard', frameName: '', revision: snapshot.revision, code, + control: platform === 'windows', meta: platform === 'macos', alt: false, 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 selectDraft = () => composer.evaluate((element) => { + const text = element.querySelector('[data-lexical-text]')!.firstChild! + document.getSelection()!.setBaseAndExtent(text, 0, text, 7) + }) + await selectDraft() + await deliverPrimary('KeyP') + const panel = page.locator('[data-sidebar-right-panel][data-sidebar-right-open]') + const files = panel.locator('[data-dockkit-tab]').filter({ hasText: 'Files' }) + 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 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('') + await openReference() + const dialog = page.getByRole('dialog', { name: 'Keyboard shortcuts', exact: true }) + await dialog.waitFor() + const settings = page.getByRole('dialog', { name: 'Settings', exact: true }) + 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), + } + }) + await deliverPrimary('Comma') + await settings.waitFor() + const settingsOnTop = await modalState() + expect(settingsOnTop).toEqual({ focused: 'Settings', foreground: 'Settings' }) + await page.keyboard.press('Escape') + await settings.waitFor({ state: 'hidden' }) + expect(await dialog.isVisible()).toBe(true) + await page.keyboard.press('Escape') + await dialog.waitFor({ state: 'hidden' }) + await deliverPrimary('Comma') + await settings.waitFor() + await openReference() + await dialog.waitFor() + const referenceOnTop = await modalState() + expect(referenceOnTop).toEqual({ focused: 'Keyboard shortcuts', foreground: 'Keyboard shortcuts' }) + await compareOrRefreshGolden(join(expected, 'modal-layers.expected.md'), + `# Modal layers\n\n${JSON.stringify({ settingsOnTop, referenceOnTop }, null, 2)}`, mode) + await page.keyboard.press('Escape') + await dialog.waitFor({ state: 'hidden' }) + expect(await settings.isVisible()).toBe(true) + await page.keyboard.press('Escape') + await settings.waitFor({ state: 'hidden' }) + 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 => getComputedStyle(node).boxShadow)).toBe('none') + 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' }) + 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) + 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 }) + } +}) diff --git a/apps/web/tests/shortcuts-fixed.e2e.ts b/apps/web/tests/shortcuts-fixed.e2e.ts new file mode 100644 index 0000000000..02e76a4566 --- /dev/null +++ b/apps/web/tests/shortcuts-fixed.e2e.ts @@ -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([]) + }, + ) +}) diff --git a/apps/web/tests/shortcuts-panels.e2e.ts b/apps/web/tests/shortcuts-panels.e2e.ts new file mode 100644 index 0000000000..7369666245 --- /dev/null +++ b/apps/web/tests/shortcuts-panels.e2e.ts @@ -0,0 +1,254 @@ +/** 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 { + 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 + + await bind(page, primary, 'Toggle right sidebar') + await composer.click() + await page.keyboard.press(`${primary}+Shift+,`) + const panel = page.locator('[data-sidebar-right-panel][data-sidebar-right-open]') + await panel.waitFor() + 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) + 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) + 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) + + 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 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"]') + 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) + 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) + 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) + 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() } } + }) +}) diff --git a/apps/web/tests/shortcuts-workspace.e2e.ts b/apps/web/tests/shortcuts-workspace.e2e.ts new file mode 100644 index 0000000000..58a84096a2 --- /dev/null +++ b/apps/web/tests/shortcuts-workspace.e2e.ts @@ -0,0 +1,311 @@ +/** 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 { + 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) + await page.keyboard.press('Escape') + 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() + } +}) diff --git a/apps/web/tests/shortcuts.e2e.ts b/apps/web/tests/shortcuts.e2e.ts new file mode 100644 index 0000000000..bd21136cb3 --- /dev/null +++ b/apps/web/tests/shortcuts.e2e.ts @@ -0,0 +1,336 @@ +/** 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') + 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) + expect(await page.getByRole('searchbox', { name: search }).evaluate(element => element === document.activeElement)).toBe(true) + 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(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) + 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)') + 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 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') + 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) + 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(referenceKey) + 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') + await page.keyboard.press('Escape') + expect(await page.getByRole('dialog').count()).toBe(1) + expect(await page.getByRole('button', { name: view, exact: true }).evaluate(element => element === document.activeElement)).toBe(true) + await page.keyboard.press('Escape') + expect(await page.getByRole('dialog').count()).toBe(0) + expect(await trigger.evaluate(element => element === document.activeElement)).toBe(true) + await page.keyboard.press(referenceKey) + await dialog.waitFor() + await page.keyboard.press(`${key}+/`) + 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(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' }) + 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) + 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 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) + 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() } + }) + +}) diff --git a/apps/web/tests/sidebar-right.e2e.ts b/apps/web/tests/sidebar-right.e2e.ts index af37b968e1..f6c0c4f596 100644 --- a/apps/web/tests/sidebar-right.e2e.ts +++ b/apps/web/tests/sidebar-right.e2e.ts @@ -934,10 +934,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,7 +957,7 @@ 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 }) @@ -1096,6 +1097,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 +1123,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([]) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 4554f81ee0..d65483176b 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -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", diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 106db59c55..8148c4f57c 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: b9e75f6679d43aef41808a30ed370e26070c1331 -config-catalog.zh.md: d76c88fb806998abee414347822a3d11c3b2b7de +config-catalog.md: a3c879b60271b4d6c707a0dc91400be0e63152de +config-catalog.zh.md: 960066e8524375cfb42c26a34478e5cc83b15e5c diff --git a/docs/config-catalog.md b/docs/config-catalog.md index b9e75f6679..a3c879b602 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -481,6 +481,20 @@ export interface Config { Source: [`packages/client/hmr/src/index.ts:30`](../packages/client/hmr/src/index.ts) + + +## `@deepseek-ai/dsh-client-shortcuts` + +```ts config-catalog +/** Fixed shortcut sequence settings. */ +export interface Config { + /** Maximum interval between independent Escape presses for stopping a reply, in milliseconds. */ + stopSequenceMs: number +} +``` + +Source: [`packages/client/shortcuts/src/config.ts:5`](../packages/client/shortcuts/src/config.ts) + ## `@deepseek-ai/dsh-client-ui-plugin-manager` @@ -4142,6 +4156,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)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index d76c88fb80..960066e852 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -483,6 +483,20 @@ export interface Config { 来源: [`packages/client/hmr/src/index.ts:30`](../packages/client/hmr/src/index.ts) + + +## `@deepseek-ai/dsh-client-shortcuts` + +```ts config-catalog +/** Fixed shortcut sequence settings. */ +export interface Config { + /** Maximum interval between independent Escape presses for stopping a reply, in milliseconds. */ + stopSequenceMs: number +} +``` + +来源: [`packages/client/shortcuts/src/config.ts:5`](../packages/client/shortcuts/src/config.ts) + ## `@deepseek-ai/dsh-client-ui-plugin-manager` @@ -4144,6 +4158,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)) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 5f3fa3d374..c74bb1441d 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/event-producer-consumer.md -event-producer-consumer.md: 13ac82b2c9fb0b2aeeaa2bec9c736db5b5b63992 -event-producer-consumer.zh.md: fce8463041181139e45fb5f3a9ab14c87117135f +event-producer-consumer.md: bb80e1a51bb5e718d2bef7fdf24b3a0048645df2 +event-producer-consumer.zh.md: ccdf22451656aa80493dc060170ca6e5e3f4b46f diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 13ac82b2c9..bb80e1a51b 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -75,7 +75,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) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index fce8463041..ccdf224516 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -77,7 +77,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) | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 6ae367c64a..98c36bdbb0 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: bfe3e5faa7168b03848ac76d2dd2584d4aa31f5e -module-graph.zh.md: bfa0f83e14cbcf89599fa207ec919195128db49e +module-graph.md: da3bd944c7926cc293b302887e4cea0de183f1ad +module-graph.zh.md: 5887f3a9ac0439525a72911187721dbd6d309a35 diff --git a/docs/module-graph.md b/docs/module-graph.md index bfe3e5faa7..da3bd944c7 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -152,6 +152,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"] @@ -189,6 +190,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"] @@ -1375,6 +1377,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` | — | @@ -1412,6 +1415,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` | — | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index bfa0f83e14..5887f3a9ac 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -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"] @@ -1377,6 +1379,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` | — | @@ -1414,6 +1417,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` | — | diff --git a/docs/subsystems/sidebar-right.i18n.yaml b/docs/subsystems/sidebar-right.i18n.yaml index 07ae54182f..a1fe394b21 100644 --- a/docs/subsystems/sidebar-right.i18n.yaml +++ b/docs/subsystems/sidebar-right.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/sidebar-right.md -sidebar-right.md: 7ec6d931ef92a23cf5cbd647d97e03fb55c32f84 -sidebar-right.zh.md: 413ea2145de19738e939f24778da0765c7aa5424 +sidebar-right.md: 6315827c2777f6b20c726d55d2fb40f36a55b090 +sidebar-right.zh.md: 1df6007e12980b0c563e670ac1a58a628d3e5848 diff --git a/docs/subsystems/sidebar-right.md b/docs/subsystems/sidebar-right.md index 7ec6d931ef..6315827c27 100644 --- a/docs/subsystems/sidebar-right.md +++ b/docs/subsystems/sidebar-right.md @@ -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. diff --git a/docs/subsystems/sidebar-right.zh.md b/docs/subsystems/sidebar-right.zh.md index 413ea2145d..1df6007e12 100644 --- a/docs/subsystems/sidebar-right.zh.md +++ b/docs/subsystems/sidebar-right.zh.md @@ -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)将预览策略与资源归属分开。 diff --git a/docs/subsystems/web-client.i18n.yaml b/docs/subsystems/web-client.i18n.yaml index f89b3c13dc..344c102ba6 100644 --- a/docs/subsystems/web-client.i18n.yaml +++ b/docs/subsystems/web-client.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/web-client.md -web-client.md: 80d233b634f57bb150d8eb6604428557e1e16033 -web-client.zh.md: f14cdbe9b1d5605b90bd595a384ef7ac9c0d7286 +web-client.md: f07094d64993fd00c27f3c485ea5bd89bb92d5be +web-client.zh.md: 5a0069abb44cbd373eb3a4e31a9d7354347e95cf diff --git a/docs/subsystems/web-client.md b/docs/subsystems/web-client.md index 80d233b634..f07094d649 100644 --- a/docs/subsystems/web-client.md +++ b/docs/subsystems/web-client.md @@ -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 | diff --git a/docs/subsystems/web-client.zh.md b/docs/subsystems/web-client.zh.md index f14cdbe9b1..5a0069abb4 100644 --- a/docs/subsystems/web-client.zh.md +++ b/docs/subsystems/web-client.zh.md @@ -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 | 职责 | diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 5ed86cceea..ba61e4835c 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -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' diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 6d3db15d18..a7ac5f7275 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -172,7 +172,9 @@ "commander": "^15.0.0", "open": "^11.0.0", "@deepseek-ai/dsh-api-account-controller": "workspace:^", - "@deepseek-ai/dsh-client-ui-settings-account": "workspace:^" + "@deepseek-ai/dsh-client-ui-settings-account": "workspace:^", + "@deepseek-ai/dsh-client-shortcuts": "workspace:^", + "@deepseek-ai/dsh-client-ui-shortcuts": "workspace:^" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", diff --git a/packages/client/README.i18n.yaml b/packages/client/README.i18n.yaml index b7d80f9227..398ed88e7c 100644 --- a/packages/client/README.i18n.yaml +++ b/packages/client/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/README.md -README.md: 8a5f789a4ee6343e00ee2b9cdc68d6bf7e7f8d79 -README.zh.md: ae5dbb237f504ca0cd66a8105dd772493b356326 +README.md: a7e4228fac5e4e9c7b50a67b8d66cb244df945f7 +README.zh.md: d5c33dc99d40e1e203848aa14a8c23c492b011a8 diff --git a/packages/client/README.md b/packages/client/README.md index 8a5f789a4e..a7e4228fac 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -30,6 +30,8 @@ The kernel packages boot and serve the page; the UI feature packages present it. | [`modules/`](modules/README.md) | Loads browser-side client modules | `ctx.clientModules` / `ctx.modules` | | [`connection/`](connection/README.md) | Maintains browser-host RPC communication and event delivery | `ctx.connection` | | [`file-upload/`](file-upload/README.md) | Sends raw Blob and byte-stream request bodies outside the page thread | `ctx.fileUpload` | +| [`shortcuts/`](shortcuts/README.md) | Registers application keyboard commands | `ctx.shortcuts` | +| [`ui-shortcuts/`](ui-shortcuts/README.md) | Presents the keyboard shortcut reference | — | | [`store/`](store/README.md) | Provides React-free observable and snapshot-store primitives | — | | [`hmr/`](hmr/README.md) | Refreshes client plugins during development | — | | [`locale/`](locale/README.md) | Provides localization preferences and message dictionaries | `ctx.locale` | diff --git a/packages/client/README.zh.md b/packages/client/README.zh.md index ae5dbb237f..d5c33dc99d 100644 --- a/packages/client/README.zh.md +++ b/packages/client/README.zh.md @@ -30,6 +30,8 @@ kind: "package-group" | [`modules/`](modules/README.zh.md) | 加载浏览器侧客户端模块 | `ctx.clientModules` / `ctx.modules` | | [`connection/`](connection/README.zh.md) | 维护浏览器与宿主之间的 RPC 通信与事件投递 | `ctx.connection` | | [`file-upload/`](file-upload/README.zh.md) | 在页面线程之外发送原始 Blob 与字节流请求体 | `ctx.fileUpload` | +| [`shortcuts/`](shortcuts/README.zh.md) | 注册应用键盘命令 | `ctx.shortcuts` | +| [`ui-shortcuts/`](ui-shortcuts/README.zh.md) | 展示快捷键速查 | — | | [`store/`](store/README.zh.md) | 提供不依赖 React 的 observable 与快照存储原语 | — | | [`hmr/`](hmr/README.zh.md) | 在开发期间刷新客户端插件 | — | | [`locale/`](locale/README.zh.md) | 提供本地化偏好与消息词典 | `ctx.locale` | diff --git a/packages/client/shortcuts/README.i18n.yaml b/packages/client/shortcuts/README.i18n.yaml new file mode 100644 index 0000000000..9f91920d60 --- /dev/null +++ b/packages/client/shortcuts/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/client/shortcuts/README.md +README.md: 3583a31046946a29d3f8ddb1b263d4b676d45dc0 +README.zh.md: 0f1fef10405509740b2e0ce1f672946c1d898abb diff --git a/packages/client/shortcuts/README.md b/packages/client/shortcuts/README.md new file mode 100644 index 0000000000..3583a31046 --- /dev/null +++ b/packages/client/shortcuts/README.md @@ -0,0 +1,92 @@ +--- +description: "Customize application keyboard commands for each device" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-shortcuts + +English | [中文](README.zh.md) + +## Summary + +Customize application keyboard commands for each device. Desktop and Web choose separate defaults for the receiving device. Custom bindings survive reloads on the same device. Commands disappear when their owning plugin unloads, while their saved overrides remain available for reinstallation. + +Windows uses ` + ` between modifiers and the first ordinary key. Two simultaneous ordinary keys appear side by side, such as `F G`, on Windows and macOS Desktop, including tooltips and the shortcut reference. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +The Web app bundle mounts this package automatically. Feature plugins register commands through `ctx.shortcuts` inside `ctx.effect()`. Each command declares defaults for explicit `desktop:macos`, `desktop:windows`, `desktop:linux`, `web:macos`, `web:windows`, and `web:linux` profiles; an omitted profile is unbound. The service accepts physical `code` values, expands `primary` to the device modifier, and rejects duplicate ids and overlapping default combinations across supported profiles. Labels, keycaps, modified flags, and binding conflicts share one observable catalog. Command owners resolve the current target before executing an action. + +Feature plugins contribute read-only sequences through `registerFixed()` and observe locally arbitrated input through `observeFixedInput()`. Fixed rows follow the owning registration and locale. Each action declares at least one physical combination and has no saved overrides. These combinations participate in conflict checks and cannot be assigned to editable commands. The Host's `stopSequenceMs` setting controls the maximum double-Escape interval, defaults to 500 milliseconds, and accepts integers from 1 to 2,147,483,646 so expiry stays within the browser timer limit; pages adopt the validated value on load. + +Preferences store only overrides: a missing command inherits its default, `null` clears its binding, and restoring a default removes its override. Restore All affects only the current runtime/platform profile. Web uses origin-local `dsh.keybindings.v1` storage; Desktop uses Electron's device-local `userData/keybindings.json`, independently of Harness home and workspace settings. The schema version is independent of Session data. macOS and Windows Desktop read version 1 without rewriting it and save version 2 on the next successful edit; version 2 adds optional `secondCode`. Web and Linux retain their existing binding restrictions. + +Windows and macOS Desktop accept one or two distinct supported non-modifier keys, with zero to four modifiers. Two-key bindings require overlapping presses in either order; sequential presses do not match. Modifier-only bindings are rejected. A single key and a pair containing that key conflict when their modifiers match, including keys reserved by fixed actions. Previously saved bindings that overlap a mounted fixed action remain visible but cannot execute. All effective bindings, including Command+C and Ctrl+C, take priority over native actions, editors, terminals, embedded pages, and modal controls. Recording and input-method composition remain protected. A pair takes priority only once both keys are held; the first key retains its normal behavior and may insert a character. + +Bindings activate after the first read. Failed reads preserve the last accepted configuration, or use defaults when none exists. Edits, including Restore All, remain disabled while preferences are unreadable. Repair the stored document and reopen the application; reset operations never replace unreadable or future-version data. Write failures preserve active bindings and drafts. Another browser tab's update invalidates open drafts; saving requires reviewing the latest configuration. Browser writes reread first, but simultaneous cross-tab writes remain last-writer-wins. + +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +Windows and macOS Desktop intercept complete accepted combinations before local handlers and forward them through the trusted preload. Desktop startup requires the native keyboard bridge on every platform. On Windows and macOS, only native input dispatches configurable commands; DOM input continues to feed fixed actions. The adapter intercepts a key release only when it intercepted that key’s initial press. Default and user-saved bindings have identical priority, including over modal controls. Other environments dispatch after local handlers and respect their declared regions. Composition, dead keys, AltGraph, and consumed input pass through; macOS Web can consume a bound Option+Command+N reported as a dead key outside composition without guarding the next input. Web and Linux modal layers block background commands. Owned repeats remain consumed without rerunning the action. The context carries the original DOM input element, including elements inside a shadow root. Owners synchronously resolve their target and retain responsibility for action errors. The pure `./protocol` export has no React, DOM, or Electron dependency and shares validation and serialized persistence between adapters. Explicit overrides displace conflicting new defaults; conflicting explicit overrides are all disabled. Desktop rejects stale revisions and restricts IPC to the current product window and top frame. Rejected adapter calls are logged and returned as save failures; errors while publishing an accepted command catalog propagate to the caller. + +DOM fixed-input observers receive composition and consumption flags before application dispatch. Consuming an event makes it unavailable to subsequent observers and commands. Focus, pointer, window blur, composition, modal changes, and locally stopped propagation reset pending DOM sequences. Native chords reset on window blur, focused-frame or configuration changes, composition, recording, and modifier release. Electron can omit keyups after native interception; completing a native binding discards held-key state, so each chord activation needs two fresh presses. Releasing a modifier clears held-key state because macOS may omit character keyup events while Command is held. The adapter removes its listeners and modal observer when disposed; one feature's observer failure does not prevent the remaining observers from receiving input. + +Desktop native menus and embedded frames use the same command registry as DOM input. Preload and the client match the focused iframe name or browser webview lease against native input; the client rejects messages from an older configuration. Browser guest input ends when its lease is released, before guest destruction finishes. Native menu selection is explicit and works without a bound accelerator. Two-key chords have no native menu accelerator; their labels remain visible in the shortcut reference. Terminal Ctrl+W/R remains local input outside Windows and macOS Desktop. Explicit native-menu actions retain their modal checks; keyboard bindings use the device dispatch policy. + +
+ +----- + + +## Further Exploration + +- [Web Client](../../../docs/subsystems/web-client.md) +- [UI primitives](../ui-primitives/README.md) +- [Web app bundle](../../bundle/web-app/README.md) + +----- + + +## Model Experience + +None, as the package is a browser-side UI plugin layer that registers nothing model-facing. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + + + +- Feature owners declare the Windows and macOS Web defaults explicitly: Mod+Slash, Mod+Comma, Mod+Backslash, and Control+Backquote stay two-key combinations; other defaults without Alt use Mod+Alt, and defaults with Alt use Mod+Shift except fullscreen uses Mod+Alt+Enter. macOS Web leaves Refresh current page unbound by default; its button and explicit user bindings remain available. Mod is Command on macOS and Control on Windows. Windows and macOS accept any three or four distinct modifiers in Web and Desktop. Web also accepts the listed combinations and Mod+Shift combinations; browser or system delivery requires platform testing. Linux Web accepts Mod+Slash, Mod+Shift+Comma, and Mod+Shift+Period. Operating-system shortcuts that do not reach the window cannot be intercepted. A chord does not delay or consume its first key while waiting for the second; a native action or focus change can therefore prevent completion. Command registration or disposal invalidates open edit drafts even when saved preferences are unchanged. Desktop rereads its file on every catalog update; there is no file watcher. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
+ +**Runtime invariant:** No companion is published. The catalog and binding index are derived together from the private registry; there is no independently maintained runtime relationship to inspect. diff --git a/packages/client/shortcuts/README.zh.md b/packages/client/shortcuts/README.zh.md new file mode 100644 index 0000000000..0f1fef1040 --- /dev/null +++ b/packages/client/shortcuts/README.zh.md @@ -0,0 +1,92 @@ +--- +description: "为每台设备自定义应用键盘命令" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-shortcuts + +[English](README.md) | 中文 + +## 概述 + +为每台设备自定义应用键盘命令。桌面端和 Web 按接收输入的设备选择不同默认键位。自定义键位在同一设备重新加载后保留。命令随所属插件卸载而移除,保存的覆盖仍保留供重新安装使用。 + +Windows 在修饰键之间及第一个普通键之前使用 ` + `。Windows 和 macOS Desktop 上同时按下的两个普通键均并排显示,如 `F G`,提示和快捷键速查保持一致。 + +## 目录 + +- [使用此包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [进一步阅读](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用此包 + +Web 应用 bundle 自动挂载此包。功能插件在 `ctx.effect()` 中通过 `ctx.shortcuts` 注册命令。每个命令为 `desktop:macos`、`desktop:windows`、`desktop:linux`、`web:macos`、`web:windows` 和 `web:linux` 显式声明默认值;省略的配置不绑定按键。服务接收物理 `code`,按设备展开 `primary`,并拒绝重复 ID 和任一支持配置中重叠的默认键位。同一可观察目录提供名称、键帽、已修改标记和键位冲突。命令 owner 在执行操作前解析当前目标。 + +功能插件通过 `registerFixed()` 贡献只读序列,通过 `observeFixedInput()` 观察局部控件处理后的输入。固定操作行随所属注册和语言变化。每个操作至少声明一个物理按键组合,不保存覆盖配置。这些组合参与冲突校验,不能分配给可编辑命令。Host 的 `stopSequenceMs` 配置控制连续两次 Esc 的最大间隔,默认为 500 毫秒,只接受 1 至 2,147,483,646 的整数,确保过期计时不超过浏览器定时器上限;页面加载时采用已校验的值。 + +偏好只保存覆盖:缺少命令表示继承默认,`null` 表示清除绑定,恢复默认则删除覆盖。全部恢复只影响当前运行端/平台配置。Web 使用同 origin 的 `dsh.keybindings.v1` 存储;Desktop 使用 Electron 设备本地的 `userData/keybindings.json`,与 Harness home 和工作区设置独立。其 schema 版本与 Session 数据无关。 macOS 和 Windows Desktop 读取版本 1 时不重写,在下次成功编辑时保存为版本 2;版本 2 增加可选的 `secondCode`。Web 和 Linux 保留原有绑定限制。 + +Windows 和 macOS Desktop 接受一至两个不同的受支持非修饰键,可搭配零至四个修饰键。双键绑定要求按住时间重叠,顺序不限;先后按键不匹配。仅含修饰键的绑定会被拒绝。同一修饰键集合下,单键与包含该键的双键组合冲突,包括固定操作占用的按键。已保存的绑定若与当前挂载的固定操作冲突,会保留显示但不能执行。包括 Command+C 和 Ctrl+C 在内的所有有效绑定优先于原生操作、编辑器、终端、内嵌页面和模态控件。录制和输入法组合状态仍受保护。只有两个键都按住时,双键组合才优先接管;第一个键仍正常处理,可能输入一个字符。 + +首次读取完成后激活键位。读取失败保留上次接受的配置;没有已接受配置时使用默认值。偏好不可读时,编辑和全部恢复均保持禁用。修复存储文档后重新打开应用;重置操作不会替换不可读或未来版本的数据。写入失败保留有效键位和草稿。其他浏览器标签页的更新使打开的草稿过期,保存前需要核对最新配置。浏览器写入前重新读取,但同时跨标签页写入仍采用最后一次成功写入。 + +----- + + +## 理解实现 + +
+实现细节——点击展开 + +Windows 和 macOS Desktop 在局部处理器之前拦截已接受的完整组合,通过可信 preload 转发。所有平台的 Desktop 启动都要求原生键盘桥接。Windows 和 macOS 的可配置命令只由原生输入分发;DOM 输入继续交给固定操作。适配器仅拦截初次按下事件已被拦截的按键的松开事件。默认绑定与用户保存的绑定优先级一致,均优先于模态控件。其他环境在局部处理器之后分发,并遵守声明的区域。输入法组合、死键、AltGraph 和已消费的输入继续传递;macOS Web 可在输入法组合之外消费已绑定且报告为死键的 Option+Command+N,不保护后续输入。Web 和 Linux 的模态层阻止后台命令。已接管的重复事件仍被消费,但不重复执行。上下文携带原始 DOM 输入元素,包括 shadow root 内的元素。owner 同步解析目标,并负责操作错误。纯 `./protocol` 导出不依赖 React、DOM 或 Electron,在两个适配器间共享校验和串行持久化。显式覆盖取代冲突的新默认值;互相冲突的显式覆盖全部禁用。Desktop 拒绝过期版本,并将 IPC 限制在当前产品窗口和顶层 frame。适配器调用被拒绝时会记录日志并返回保存失败;发布已接受命令目录时的错误会向调用方抛出。 + +DOM 固定输入观察者在应用分发前收到输入法组合和消费标记。消费事件后,后续观察者和命令不能使用它。焦点、指针、窗口失焦、输入法组合、模态变化和局部停止传播会重置待完成的 DOM 序列。原生双键状态在窗口失焦、焦点 frame 或配置变化、输入法组合、录制和修饰键释放时重置。Electron 在原生拦截后可能省略 keyup;原生绑定完成时立即清空按住状态,因此每次触发双键组合都需要重新按下两个键。释放修饰键会清空按住状态,因为 macOS 在按住 Command 时可能省略字符键的 keyup。适配器释放时移除监听器和模态观察者;单个功能观察者失败不妨碍其他观察者接收输入。 + +Desktop 原生菜单和内嵌 frame 使用与 DOM 输入相同的命令注册表。Preload 和 Client 将获焦 iframe 的名称或浏览器 webview 租约与原生输入匹配;Client 拒绝旧配置消息。浏览器 guest 的输入监听在租约释放时结束,无需等待 guest 销毁完成。原生菜单选择是显式操作,不依赖快捷键绑定。双键组合没有原生菜单快捷键,其键位仍显示在快捷键速查中。Windows 和 macOS Desktop 之外的终端 Ctrl+W/R 保留为局部输入。显式原生菜单操作保留模态检查;键盘绑定使用对应设备的分发策略。 + +
+ +----- + + +## 进一步阅读 + +- [Web Client](../../../docs/subsystems/web-client.zh.md) +- [UI primitives](../ui-primitives/README.zh.md) +- [Web app bundle](../../bundle/web-app/README.zh.md) + +----- + + +## 模型体验 + +无;此包是浏览器侧 UI 插件,不注册面向模型的内容。 + +#### KV Cache 影响 + +无;此包不组装或发送模型提供方请求。 + +## 已知限制与延期工作 + + + +- 功能 owner 显式声明 Windows 和 macOS Web 的默认键位:Mod+Slash、Mod+Comma、Mod+Backslash 和 Control+Backquote 保持双键;其他不含 Alt 的默认键位使用 Mod+Alt,含 Alt 的使用 Mod+Shift,全屏除外,使用 Mod+Alt+Enter。macOS Web 的“刷新当前页”默认不绑定快捷键,刷新按钮和用户显式绑定仍然可用。Mod 在 macOS 上为 Command,在 Windows 上为 Control。Windows 和 macOS 的 Web、Desktop 均接受任意三个或四个不同修饰键的组合。Web 还接受上述组合及 Mod+Shift 组合;浏览器或系统能否送达按键需在对应平台测试。Linux Web 接受 Mod+Slash、Mod+Shift+Comma 和 Mod+Shift+Period。未送达窗口的操作系统快捷键无法被拦截。双键组合等待第二个键时,不延迟或消费第一个键;原生操作或焦点变化可能使组合无法完成。命令注册或注销会使打开的编辑草稿过期,即使保存的偏好未改变。Desktop 在每次命令目录更新时重新读取文件,不设置文件 watcher。 + + +### 开发备注 + +
+维护者工作上下文——点击展开 + +无。 + +
+ +**Runtime invariant:** 不发布配套检查模块。目录和键位索引由私有注册表同步派生,没有独立维护、需要检查的运行时关系。 diff --git a/packages/client/shortcuts/package.json b/packages/client/shortcuts/package.json new file mode 100644 index 0000000000..7bd6786771 --- /dev/null +++ b/packages/client/shortcuts/package.json @@ -0,0 +1,67 @@ +{ + "name": "@deepseek-ai/dsh-client-shortcuts", + "description": "Application keyboard command registry and physical-key routing", + "version": "0.1.7-alpha.1", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/shortcuts" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json", + "./protocol": { + "types": "./lib/types/protocol.d.ts", + "default": "./lib/protocol.js" + } + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-client-locale" + ], + "platform": "web" + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-client-test-runtime": "workspace:^", + "@deepseek-ai/dsh-host-webserver": "workspace:^" + }, + "files": [ + "lib/index.js", + "lib/client.js", + "lib/protocol.js", + "lib/types/**/*.d.ts" + ], + "dependencies": { + "@deepseek-ai/dsh-util-crypto": "workspace:^", + "@deepseek-ai/dsh-util-values": "workspace:^", + "@deepseek-ai/schemastery": "workspace:^" + } +} diff --git a/packages/client/shortcuts/src/binding.ts b/packages/client/shortcuts/src/binding.ts new file mode 100644 index 0000000000..8a7bed818e --- /dev/null +++ b/packages/client/shortcuts/src/binding.ts @@ -0,0 +1,112 @@ +/** Physical-key protocol shared by browser commands and desktop adapters; no DOM or runtime state. */ +import type { Branded } from '@deepseek-ai/dsh-brand' + +/** Stable command identity owned by the registering feature. */ +export type ShortcutCommandId = Branded<'ShortcutCommandId'> +/** Operating system of the device receiving input. */ +export type ShortcutPlatform = 'macos' | 'windows' | 'linux' +/** Application shell selecting the default bindings. */ +export type ShortcutRuntime = 'desktop' | 'web' +/** Runtime and operating system selecting one explicit default and preference profile. */ +export type ShortcutProfile = `${ShortcutRuntime}:${ShortcutPlatform}` +/** Logical primary expands to Meta on macOS and Control elsewhere. */ +export type ShortcutModifier = 'primary' | 'control' | 'alt' | 'shift' | 'meta' +/** One or two distinct physical keys held together, plus an exact set of modifiers. */ +export interface ShortcutBinding { + readonly code: string + readonly secondCode?: string + readonly modifiers: readonly ShortcutModifier[] +} +/** Normalized modifier order is also the keycap order. */ +export interface NormalizedBinding { + readonly code: string + readonly secondCode?: string + readonly modifiers: readonly ('control' | 'alt' | 'shift' | 'meta')[] +} + +const keyNames: Readonly> = { + Slash: '/', Comma: ',', Period: '.', Backslash: '\\', Backquote: '`', Minus: '-', Equal: '=', + BracketLeft: '[', BracketRight: ']', Semicolon: ';', Quote: "'", Enter: 'Enter', + Escape: 'Esc', Space: 'Space', Tab: 'Tab', Backspace: 'Backspace', Delete: 'Delete', + ArrowUp: '↑', ArrowDown: '↓', ArrowLeft: '←', ArrowRight: '→', +} +const modifierOrder = ['control', 'alt', 'shift', 'meta'] as const + +/** + * Expand logical modifiers, deduplicate, and validate the physical code. + * @param binding - declared binding. + * @param platform - receiving device platform. + * @returns canonical binding; unsupported codes throw during registration. + */ +export function normalizeBinding(binding: ShortcutBinding, platform: ShortcutPlatform): NormalizedBinding { + const codes: [string, ...string[]] = [binding.code, ...binding.secondCode === undefined ? [] : [binding.secondCode]] + codes.sort() + for (const code of codes) { + if (!/^(Key[A-Z]|Digit[0-9]|F([1-9]|1[0-9]|2[0-4]))$/u.test(code) + && !Object.hasOwn(keyNames, code)) throw new Error(`Unsupported shortcut code: ${code}`) + } + if (codes.length === 2 && codes[0] === codes[1]) throw new Error('Shortcut keys must be distinct') + const modifiers = new Set(binding.modifiers.map(value => value === 'primary' + ? platform === 'macos' ? 'meta' : 'control' : value)) + return { code: codes[0], ...(codes[1] === undefined ? {} : { secondCode: codes[1] }), + modifiers: modifierOrder.filter(value => modifiers.has(value)) } +} + +/** + * Produce an exact-match index from a normalized binding. + * @param binding - normalized physical key and modifiers. + * @returns stable index used for both matching and conflict checks. + */ +export function bindingKey(binding: NormalizedBinding): string { + const codes = binding.secondCode === undefined ? [binding.code] : [binding.code, binding.secondCode].sort() + return [...binding.modifiers, ...codes].join('+') +} + +/** + * Format keycaps and ARIA; Windows separates modifiers with plus signs, while chord keys remain adjacent. + * @param binding - normalized binding, or null for an unbound command. + * @param platform - receiving device platform. + * @returns visible keycaps; two-key chords omit ARIA shortcuts, which only support one non-modifier key. + */ +export function presentBinding(binding: NormalizedBinding | null, platform: ShortcutPlatform): { + keys: readonly string[] + aria: string | undefined +} { + if (binding === null) return { keys: [], aria: undefined } + const key = keyNames[binding.code] ?? binding.code.replace(/^(Key|Digit)/u, '') + const symbols = platform === 'macos' + ? { control: '⌃', alt: '⌥', shift: '⇧', meta: '⌘' } + : { control: 'Ctrl', alt: 'Alt', shift: 'Shift', meta: 'Meta' } + const ariaNames = { control: 'Control', alt: 'Alt', shift: 'Shift', meta: 'Meta' } + const ariaKey = binding.code === 'Space' ? 'Space' : binding.code === 'Escape' ? 'Escape' + : binding.code.startsWith('Arrow') ? binding.code : key + const second = binding.secondCode === undefined ? [] + : [keyNames[binding.secondCode] ?? binding.secondCode.replace(/^(Key|Digit)/u, '')] + const keys = [...binding.modifiers.map(value => symbols[value]), key] + return { keys: [...platform === 'windows' ? keys.flatMap((label, index) => index === 0 ? [label] : ['+', label]) : keys, ...second], + aria: binding.secondCode === undefined ? [...binding.modifiers.map(value => ariaNames[value]), ariaKey].join('+') : undefined } +} + +/** + * Check Web combinations: Windows and macOS also admit any three or four modifiers; Linux retains the limited set. + * @param binding - normalized candidate. + * @param platform - receiving device platform. + * @returns whether this combination is admitted; admission does not guarantee browser or system delivery. + */ +export function isWebBindingAllowed(binding: NormalizedBinding, platform: ShortcutPlatform): boolean { + if (binding.secondCode !== undefined) return false + if (platform === 'windows' || platform === 'macos') { + if (binding.modifiers.length >= 3) return true + const primary = platform === 'macos' ? 'meta' : 'control' + if (binding.modifiers.length === 1 + && ((['Comma', 'Backslash'].includes(binding.code) && binding.modifiers[0] === primary) + || (binding.code === 'Backquote' && binding.modifiers[0] === 'control'))) return true + if (binding.modifiers.length === 2 && binding.modifiers.includes(primary) + && (binding.modifiers.includes('alt') || binding.modifiers.includes('shift'))) return true + } + return [ + { code: 'Slash', modifiers: ['primary'] as const }, + { code: 'Comma', modifiers: ['primary', 'shift'] as const }, + { code: 'Period', modifiers: ['primary', 'shift'] as const }, + ].some(candidate => bindingKey(binding) === bindingKey(normalizeBinding(candidate, platform))) +} diff --git a/packages/client/shortcuts/src/client/dom.ts b/packages/client/shortcuts/src/client/dom.ts new file mode 100644 index 0000000000..83f9641370 --- /dev/null +++ b/packages/client/shortcuts/src/client/dom.ts @@ -0,0 +1,118 @@ +/** Main-document keyboard adapter; local controls arbitrate before window bubbling. */ +import type { ShortcutContext, ShortcutFixedInput } from './types.ts' +import type { ShortcutRegistry } from './registry.ts' +import type { ShortcutPlatform, ShortcutRuntime } from '../protocol.ts' + +/** + * Detect the visiting device, never the server operating system. + * @param document - product document, marked by Electron preload when present. + * @param navigator - browser device identification. + * @returns explicit runtime and platform for default resolution. + */ +export function detectEnvironment(document: Document, navigator: Navigator): { + runtime: ShortcutRuntime + platform: ShortcutPlatform +} { + const desktop = document.documentElement.dataset.platform + const device = desktop ?? navigator.platform + return { runtime: desktop === undefined ? 'web' : 'desktop', + platform: /darwin|mac|iphone|ipad/iu.test(device) ? 'macos' : /win/iu.test(device) ? 'windows' : 'linux' } +} + +/** + * Install document composition tracking and application dispatch after local handlers. + * @param window - input window owned by the client plugin. + * @param shortcuts - command registry for this window. + * @param fixed - optional fixed-sequence consumer after local controls. + * @param native - native input owns configurable bindings; DOM delivery only feeds fixed actions. + * @returns disposer releasing every listener. + */ +export function installKeyboard(window: Window, shortcuts: Pick, + fixed?: (input: ShortcutFixedInput) => void, native = false): () => void { + const document = window.document + let pending = false + let pendingTimer: number | undefined + const reset = (): void => { fixed?.({ type: 'reset' }) } + let composing = false + let compositionEnded = false + let deadKey = false + const start = (): void => { composing = true; reset() } + const end = (): void => { composing = false; compositionEnded = true; reset() } + const release = (): void => { compositionEnded = false } + const blur = (): void => { composing = false; compositionEnded = false; deadKey = false; reset() } + const modalSelector = '[role="dialog"][aria-modal="true"], [role="menu"]' + const containsModal = (node: Node): boolean => node instanceof Element + && (node.matches(modalSelector) || node.querySelector(modalSelector) !== null) + const changedModals = (records: MutationRecord[]): void => { + if (records.some(record => record.type === 'attributes' + ? record.oldValue === 'dialog' || record.oldValue === 'true' || containsModal(record.target) + : [...record.addedNodes, ...record.removedNodes].some(containsModal))) reset() + } + const observer = fixed === undefined ? undefined : new MutationObserver(changedModals) + observer?.observe(document.documentElement, { childList: true, subtree: true, + attributes: true, attributeFilter: ['role', 'aria-modal'], attributeOldValue: true }) + const capture = (): void => { + if (pending) reset() + window.clearTimeout(pendingTimer) + if (observer !== undefined) changedModals(observer.takeRecords()) + pending = true + // Native event listeners can yield a microtask checkpoint before bubbling. + pendingTimer = window.setTimeout(() => { + pending = false + pendingTimer = undefined + reset() + }, 0) + } + const keydown = (event: KeyboardEvent): void => { + window.clearTimeout(pendingTimer) + pendingTimer = undefined + pending = false + const target = event.composedPath().find(value => value instanceof Element) + const element = target instanceof Element ? target : document.activeElement + const region = element?.closest('.xterm') ? 'terminal' + : element?.closest('input, textarea, select, [contenteditable="true"], [contenteditable=""]') ? 'editable' : 'page' + const dialogs = document.querySelectorAll(modalSelector) + const top = [...dialogs].at(-1) + const context: ShortcutContext = { region, modal: top === undefined ? null : top.dataset.shortcutModal ?? 'other', target: element } + // oxlint-disable-next-line typescript/no-deprecated -- IME 229 covers engines without isComposing. + const guarded = composing || compositionEnded || deadKey || event.isComposing || event.keyCode === 229 + || event.getModifierState('AltGraph') + const isDead = event.key === 'Dead' + // macOS can report Option+Command+N as Dead outside input-method composition. + const commandDeadKey = isDead && shortcuts.runtime === 'web' && shortcuts.platform === 'macos' + && event.code === 'KeyN' && event.metaKey && event.altKey && !event.ctrlKey && !event.shiftKey + compositionEnded = false + deadKey = isDead + const gesture = { code: event.code, control: event.ctrlKey, alt: event.altKey, shift: event.shiftKey, + meta: event.metaKey, repeat: event.repeat, composing: guarded || isDead, defaultPrevented: event.defaultPrevented } + const consume = (): void => { + event.preventDefault() + if (commandDeadKey) deadKey = false + } + fixed?.({ type: 'keydown', gesture, context, consume }) + if (native) return + shortcuts.dispatch({ ...gesture, composing: guarded || (isDead && !commandDeadKey), + defaultPrevented: event.defaultPrevented }, context, consume) + } + document.addEventListener('compositionstart', start, true) + document.addEventListener('compositionend', end, true) + document.addEventListener('focusin', reset, true) + document.addEventListener('pointerdown', reset, true) + window.addEventListener('keydown', capture, true) + window.addEventListener('keydown', keydown) + window.addEventListener('keyup', release) + window.addEventListener('blur', blur) + return () => { + pending = false + window.clearTimeout(pendingTimer) + observer?.disconnect() + document.removeEventListener('compositionstart', start, true) + document.removeEventListener('compositionend', end, true) + document.removeEventListener('focusin', reset, true) + document.removeEventListener('pointerdown', reset, true) + window.removeEventListener('keydown', capture, true) + window.removeEventListener('keydown', keydown) + window.removeEventListener('keyup', release) + window.removeEventListener('blur', blur) + } +} diff --git a/packages/client/shortcuts/src/client/index.ts b/packages/client/shortcuts/src/client/index.ts new file mode 100644 index 0000000000..6947690abe --- /dev/null +++ b/packages/client/shortcuts/src/client/index.ts @@ -0,0 +1,152 @@ +/** Browser command service, with one keyboard adapter per plugin lifetime. */ +import { Service } from '@deepseek-ai/cordis' +import type { Context } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-client-locale/client' +import { ShortcutRegistry } from './registry.ts' +import { detectEnvironment, installKeyboard } from './dom.ts' +import { bindingIssue, initialShortcutConfig, normalizeBinding, overlappingBindings, presentBinding } from '../protocol.ts' +import type { DesktopKeyboardApi, DesktopShortcutsApi, ShortcutSaveResult } from '../protocol.ts' +import { desktopShortcutStorage, webShortcutStorage } from './storage.ts' +import type { Shortcuts } from './types.ts' +import type { ShortcutFixedInput } from './types.ts' +import { installNativeKeyboard } from './native.ts' +import { Config } from '../config.ts' + +export type { ShortcutCatalogEntry, ShortcutCommand, ShortcutContext, ShortcutGesture, Shortcuts } from './types.ts' +export type { ShortcutFixedInput, ShortcutFixedCommand, ShortcutFixedCatalogEntry } from './types.ts' +export type { ShortcutBinding, ShortcutCommandId, ShortcutPlatform, ShortcutRuntime } from '../protocol.ts' + +declare module '@deepseek-ai/cordis' { + interface Context { + /** Window-local application commands and effective keycap catalog. */ + shortcuts: Shortcuts + } +} + +/** Cordis keyboard provider; Desktop startup requires its native keyboard bridge. */ +export default class ShortcutsService extends Service implements Shortcuts { + static inject = ['locale'] + readonly runtime: Shortcuts['runtime'] + readonly platform: Shortcuts['platform'] + readonly catalog: Shortcuts['catalog'] + readonly config: Shortcuts['config'] + readonly fixedCatalog: Shortcuts['fixedCatalog'] + readonly stopSequenceMs: number + private readonly fixedListeners = new Set<(input: ShortcutFixedInput) => void>() + private readonly adapter: DesktopShortcutsApi | undefined + private active = true + private connected = false + private readonly registry: ShortcutRegistry + + constructor(ctx: Context) { + const environment = detectEnvironment(document, navigator) + const keyboard = environment.runtime === 'desktop' + ? (window as Window & { dshDesktop?: { keyboard?: DesktopKeyboardApi } }).dshDesktop?.keyboard : undefined + if (environment.runtime === 'desktop' && keyboard === undefined) throw new Error('Desktop keyboard bridge unavailable') + super(ctx, 'shortcuts') + this.runtime = environment.runtime + this.platform = environment.platform + const config = Config((globalThis as { __DSH_SHORTCUTS_CONFIG__?: unknown }).__DSH_SHORTCUTS_CONFIG__ ?? {}) + this.stopSequenceMs = config.stopSequenceMs + this.registry = new ShortcutRegistry(this.runtime, this.platform, initialShortcutConfig()) + this.catalog = this.registry.catalog + this.config = this.registry.config + this.fixedCatalog = this.registry.fixedCatalog + const publish = (snapshot: ReturnType): void => { + if (this.active && this.connected && snapshot.sequence >= this.config.getSnapshot().sequence) this.registry.configure(snapshot) + } + const web = this.runtime === 'web' ? webShortcutStorage(window, this.platform, publish) : undefined + this.adapter = web ?? desktopShortcutStorage(window) + if (keyboard !== undefined) ctx.effect(() => installNativeKeyboard(window, keyboard, this.registry, + () => this.config.getSnapshot(), () => { this.fixedInput({ type: 'reset' }) }), 'shortcuts: native keyboard') + ctx.effect(() => { + const off = this.adapter?.subscribe(publish) + return () => { this.active = false; off?.(); web?.dispose() } + }, 'shortcuts: preferences') + this.syncDefinitions() + ctx.effect(() => { + const off = installKeyboard(window, this.registry, (input) => { this.fixedInput(input) }, + keyboard !== undefined && (this.platform === 'macos' || this.platform === 'windows')) + return () => { off(); this.fixedListeners.clear() } + }, 'shortcuts: keyboard') + ctx.effect(() => ctx.locale.subscribe(() => { this.registry.refreshLabels() }), 'shortcuts: locale') + } + + register(command: Parameters[0]): () => void { + const off = this.registry.register(command) + this.syncDefinitions() + return () => { off(); this.syncDefinitions() } + } + + registerFixed(command: Parameters[0]): () => void { + const off = this.registry.registerFixed(command) + this.syncDefinitions() + return () => { off(); this.syncDefinitions() } + } + + observeFixedInput(listener: (input: ShortcutFixedInput) => void): () => void { + this.fixedListeners.add(listener) + return () => { this.fixedListeners.delete(listener) } + } + + private fixedInput(input: ShortcutFixedInput): void { + let consumed = false + for (const listener of [...this.fixedListeners]) { + if (!this.fixedListeners.has(listener)) continue + try { + listener(input.type === 'reset' ? input : { ...input, + gesture: { ...input.gesture, defaultPrevented: input.gesture.defaultPrevented || consumed }, + consume: () => { consumed = true; input.consume() } }) + } catch (error) { + console.error('Fixed shortcut handler failed', error) + } + } + } + + describeBinding(binding: Parameters[0]): ReturnType { + const normalized = binding === null ? null : normalizeBinding(binding, this.platform) + return { binding: normalized, + keys: presentBinding(normalized, this.platform).keys, + issue: normalized === null ? null : bindingIssue(normalized, this.runtime, this.platform), + conflicts: normalized === null ? [] : [...this.catalog.getSnapshot().filter(row => row.binding !== null + && overlappingBindings(row.binding, normalized)).map(row => row.id), + ...this.fixedCatalog.getSnapshot().filter(row => row.bindings.some(binding => overlappingBindings(binding, normalized))) + .map(row => row.id)] } + } + + private syncDefinitions(): void { + if (!this.active) return + if (this.adapter === undefined) { this.failRead(); return } + void this.adapter.get(this.registry.definitions()).then((snapshot) => { + this.connected = true + if (this.active && snapshot.sequence >= this.config.getSnapshot().sequence) this.registry.configure(snapshot) + }, () => { this.failRead() }) + } + + private failRead(): void { + if (this.active) this.registry.configure({ ...this.config.getSnapshot(), status: 'unreadable', error: 'read' }) + } + + /** + * Persist one reviewed operation while retaining accepted bindings on failure. + * @param args - edit and expected revision supplied by the editor. + * @returns classified save outcome and accepted snapshot. + */ + async edit(...args: Parameters): ReturnType { + if (this.adapter === undefined) return { status: 'unreadable', snapshot: this.config.getSnapshot() } + let result: ShortcutSaveResult + try { + result = await this.adapter.edit(...args) + } catch (error) { + if (this.active) console.error('Shortcut preference save failed', error) + return { status: 'write-failed', snapshot: this.config.getSnapshot() } + } + if (this.active && result.snapshot.sequence >= this.config.getSnapshot().sequence) this.registry.configure(result.snapshot) + return result + } + + async recording(active: boolean): Promise { + if (this.adapter === undefined) throw new Error('Desktop shortcuts bridge unavailable') + await this.adapter.recording(active) + } +} diff --git a/packages/client/shortcuts/src/client/native.ts b/packages/client/shortcuts/src/client/native.ts new file mode 100644 index 0000000000..faf162f63c --- /dev/null +++ b/packages/client/shortcuts/src/client/native.ts @@ -0,0 +1,44 @@ +/** Desktop gestures use live focus, modal state, and verified embedding ownership. */ +import type { DesktopKeyboardApi, ShortcutConfigSnapshot } from '../protocol.ts' +import type { ShortcutRegistry } from './registry.ts' + +/** + * Route native menu, main-frame, and embedded-frame input through the shared command registry. + * @param window - trusted product document. + * @param keyboard - top-frame preload capability. + * @param registry - window-local command owner. + * @param snapshot - latest accepted configuration. + * @param reset - clears pending fixed sequences when native input bypasses DOM delivery. + * @returns disposer releasing native input. + */ +export function installNativeKeyboard(window: Window, keyboard: DesktopKeyboardApi, registry: ShortcutRegistry, + snapshot: () => ShortcutConfigSnapshot, reset?: () => void): () => void { + return keyboard.subscribe((input) => { + if (input.revision !== snapshot().revision) return + reset?.() + let target = window.document.activeElement + while (target?.shadowRoot?.activeElement != null && !target.matches('webview[data-sidebar-browser-frame]')) { + target = target.shadowRoot.activeElement + } + const top = [...window.document.querySelectorAll('[role="dialog"][aria-modal="true"], [role="menu"]')].at(-1) + const region = target?.closest('.xterm') ? 'terminal' as const + : target?.matches('input, textarea, select, [contenteditable="true"], [contenteditable=""]') ? 'editable' as const : 'page' as const + const context = { target, region, modal: top?.dataset.shortcutModal ?? (top === undefined ? null : 'other') } + if (input.kind === 'menu') { registry.invoke(input.commandId, context); return } + if (input.kind === 'keyboard') { + registry.dispatch({ ...input, composing: false, defaultPrevented: false }, context, () => {}) + return + } + // Renderer focus and embedding identity may change after preload verification. + if (input.kind === 'webview') { + if (target?.matches('webview[data-sidebar-browser-frame]') !== true || !target.isConnected + || input.frameName === '' || target.getAttribute('name') !== input.frameName) return + registry.dispatch({ ...input, composing: false, defaultPrevented: false }, { ...context, source: 'webview' }, () => {}) + return + } + if (!(target instanceof HTMLIFrameElement) || !target.isConnected + || !target.matches('iframe[data-sidebar-browser-frame], iframe[data-html-preview]') + || input.frameName === '' || target.name !== input.frameName) return + registry.dispatch({ ...input, composing: false, defaultPrevented: false }, { ...context, source: 'iframe' }, () => {}) + }) +} diff --git a/packages/client/shortcuts/src/client/registry.ts b/packages/client/shortcuts/src/client/registry.ts new file mode 100644 index 0000000000..b0c3be724d --- /dev/null +++ b/packages/client/shortcuts/src/client/registry.ts @@ -0,0 +1,180 @@ +/** Command registration, normalized default bindings, and synchronous dispatch. */ +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import { bindingIssue, bindingKey, effectiveShortcuts, initialShortcutConfig, isWebBindingAllowed, normalizeBinding, overlappingBindings, presentBinding, resolveShortcutDefault } from '../protocol.ts' +import type { ShortcutCommandId, ShortcutConfigSnapshot, ShortcutDefinition, ShortcutPlatform, ShortcutRuntime } from '../protocol.ts' +import type { ShortcutCatalogEntry, ShortcutCommand, ShortcutContext, ShortcutGesture, + ShortcutFixedCommand, ShortcutFixedCatalogEntry } from './types.ts' + +/** Dispatch consumption is independent of later business-operation success. */ +type ShortcutDispatch = { status: 'handled'; commandId: ShortcutCommandId } + | { status: 'blocked'; commandId: ShortcutCommandId; reason: string } + | { status: 'pass' } + +/** Application command registry; adapters own event listeners, feature plugins own actions. */ +export class ShortcutRegistry { + private readonly commands = new Map() + private readonly fixedCommands = new Map() + private readonly conflicts = new Map() + private readonly bindings = new Map() + private readonly state + /** Effective localized rows derived from the accepted configuration. */ + readonly catalog + /** Accepted preferences and visible read diagnostics. */ + readonly config + /** Fixed local operations whose owning plugins are mounted. */ + readonly fixedCatalog = createSnapshotStore([]) + + constructor(readonly runtime: ShortcutRuntime, readonly platform: ShortcutPlatform, + config: ShortcutConfigSnapshot = { ...initialShortcutConfig(), status: 'ready' }) { + this.state = createSnapshotStore<{ catalog: readonly ShortcutCatalogEntry[]; config: ShortcutConfigSnapshot }>({ catalog: [], config }) + this.catalog = { getSnapshot: () => this.state.getSnapshot().catalog, + subscribe: (listener: () => void) => this.state.subscribe(listener) } + this.config = { getSnapshot: () => this.state.getSnapshot().config, + subscribe: (listener: () => void) => this.state.subscribe(listener) } + } + + /** + * Return the serializable active catalog for storage validation. + * @returns definitions without callbacks or localized labels. + */ + definitions(): readonly ShortcutDefinition[] { + return [...[...this.commands.values()].map(({ id, defaults }) => ({ id, defaults })), + ...[...this.fixedCommands.values()].map(({ id, bindings }) => ({ id, defaults: {}, fixed: bindings }))] + } + + /** + * Register a read-only input action whose keys cannot be assigned to editable commands. + * @param command - owner-localized action and readable sequence. + * @returns idempotent disposer removing its reference row. + */ + registerFixed(command: ShortcutFixedCommand): () => void { + if (this.fixedCommands.has(command.id) || this.commands.has(command.id)) throw new Error(`Duplicate shortcut command: ${command.id}`) + for (const binding of command.bindings) normalizeBinding(binding, this.platform) + this.fixedCommands.set(command.id, command) + this.refreshLabels() + return () => { + if (this.fixedCommands.get(command.id) !== command) return + this.fixedCommands.delete(command.id) + this.refreshLabels() + } + } + + private refreshFixedLabels(): void { + this.fixedCatalog.set([...this.fixedCommands.values()].map(command => ({ + id: command.id, label: command.label(), keys: command.keys, group: command.group, + bindings: command.bindings.map(binding => normalizeBinding(binding, this.platform)), + }))) + } + + /** + * Publish accepted preferences and all derived labels atomically. + * @param config - storage owner's latest accepted snapshot. + */ + configure(config: ShortcutConfigSnapshot): void { + const current = this.config.getSnapshot() + if (config.revision === current.revision && config.status === current.status && config.error === current.error) return + this.refreshLabels(config) + } + + /** + * Register atomically after checking defaults for all supported platforms and shells. + * @param command - feature-owned command definition. + * @returns idempotent disposer removing both matching and catalog entries. + */ + register(command: ShortcutCommand): () => void { + if (this.commands.has(command.id) || this.fixedCommands.has(command.id)) throw new Error(`Duplicate shortcut command: ${command.id}`) + for (const runtime of ['desktop', 'web'] as const) { + for (const platform of ['macos', 'windows', 'linux'] as const) { + const candidate = resolveShortcutDefault(command, runtime, platform) + if (candidate === undefined) continue + const binding = normalizeBinding(candidate, platform) + if (runtime === 'web' && !isWebBindingAllowed(binding, platform)) { + throw new Error(`Unsupported Web shortcut: ${command.id}`) + } + if (bindingIssue(binding, runtime, platform) !== null) throw new Error(`Reserved shortcut default: ${command.id}`) + for (const existing of this.commands.values()) { + const other = resolveShortcutDefault(existing, runtime, platform) + if (other !== undefined && overlappingBindings(binding, normalizeBinding(other, platform))) { + throw new Error(`Conflicting shortcut defaults: ${command.id} and ${existing.id} (${runtime}:${platform})`) + } + } + } + } + this.commands.set(command.id, command) + this.refreshLabels() + return () => { + if (this.commands.get(command.id) !== command) return + this.commands.delete(command.id) + this.refreshLabels() + } + } + + /** + * Recompute effective bindings when preferences, commands, or locale change. + * @param config - accepted configuration, defaulting to the current snapshot. + */ + refreshLabels(config: ShortcutConfigSnapshot = this.config.getSnapshot()): void { + this.bindings.clear() + this.conflicts.clear() + const rows = effectiveShortcuts(this.definitions(), config.document, this.runtime, this.platform) + const catalog = rows.map((row) => { + const command = this.commands.get(row.id) as ShortcutCommand + const enabled = config.status !== 'loading' && row.issue === null && row.conflicts.length === 0 + if (enabled && row.binding !== null) this.bindings.set(bindingKey(row.binding), command) + if (config.status !== 'loading' && row.issue === null && row.conflicts.length > 0 && row.binding !== null) { + this.conflicts.set(bindingKey(row.binding), command) + } + return { ...row, label: command.label(), aliases: command.aliases, + ...presentBinding(row.binding, this.platform), + aria: enabled ? presentBinding(row.binding, this.platform).aria : undefined } + }) + this.state.set({ config, catalog }) + this.refreshFixedLabels() + } + + /** + * Invoke a native menu selection independently of its optional key binding. + * @param id - registered product command. + * @param context - live input owner and modal state. + */ + invoke(id: ShortcutCommandId, context: ShortcutContext): void { + const command = this.commands.get(id) + if (command === undefined || (context.modal !== null && !command.modals.includes(context.modal))) return + const result = command.resolve({ ...context, source: 'menu' }) + if (result.status === 'handled') result.run() + } + + /** + * Windows/macOS Desktop bindings override local regions and modal controls independently of their configuration source. + * @param gesture - normalized DOM/native input facts. + * @param context - synchronous input and modal owner. + * @param consume - adapter's preventDefault, called before business execution. + * @returns handled, blocked with a reason, or pass for local/system input. + */ + dispatch(gesture: ShortcutGesture, context: ShortcutContext, consume: () => void): ShortcutDispatch { + if (gesture.defaultPrevented || gesture.composing) return { status: 'pass' } + const modifiers = (['control', 'alt', 'shift', 'meta'] as const).filter(value => gesture[value]) + const pairKey = bindingKey({ code: gesture.code, modifiers, + ...(gesture.secondCode === undefined ? {} : { secondCode: gesture.secondCode }) }) + const key = this.bindings.has(pairKey) || this.conflicts.has(pairKey) ? pairKey : bindingKey({ code: gesture.code, modifiers }) + const command = this.bindings.get(key) ?? this.conflicts.get(key) + const priority = this.runtime === 'desktop' && (this.platform === 'windows' || this.platform === 'macos') + if (command === undefined || (!priority && !command.regions.includes(context.region))) return { status: 'pass' } + if (!priority && context.region === 'terminal' && gesture.control && !gesture.meta && !gesture.alt && !gesture.shift + && (gesture.code === 'KeyW' || gesture.code === 'KeyR')) return { status: 'pass' } + if (!this.bindings.has(key)) { + consume() + return { status: 'blocked', commandId: command.id, reason: 'conflict' } + } + if (!priority && context.modal !== null && !command.modals.includes(context.modal)) { + consume() + return { status: 'blocked', commandId: command.id, reason: 'modal' } + } + const resolution = command.resolve(context) + if (resolution.status === 'pass') return resolution + consume() + if (resolution.status === 'blocked') return { ...resolution, commandId: command.id } + if (!gesture.repeat) resolution.run() + return { status: 'handled', commandId: command.id } + } +} diff --git a/packages/client/shortcuts/src/client/storage.ts b/packages/client/shortcuts/src/client/storage.ts new file mode 100644 index 0000000000..2810833fee --- /dev/null +++ b/packages/client/shortcuts/src/client/storage.ts @@ -0,0 +1,40 @@ +/** Explicit browser and Desktop preference adapters; Desktop never falls back to browser storage. */ +import { ShortcutPersistence } from '../protocol.ts' +import type { DesktopShortcutsApi, ShortcutConfigSnapshot, ShortcutDefinition, ShortcutPlatform } from '../protocol.ts' + +/** Browser-profile and origin-local preference key. */ +export const SHORTCUT_STORAGE_KEY = 'dsh.keybindings.v1' + +/** + * Connect localStorage and same-origin external updates to the shared transaction coordinator. + * @param window - owning browser window. + * @param platform - visiting device platform. + * @param publish - accepts complete configuration snapshots. + * @returns adapter and lifecycle disposal. + */ +export function webShortcutStorage(window: Window, platform: ShortcutPlatform, + publish: (snapshot: ShortcutConfigSnapshot) => void): DesktopShortcutsApi & { dispose(): void } { + const persistence = new ShortcutPersistence({ + read: () => window.localStorage.getItem(SHORTCUT_STORAGE_KEY), + write: (raw) => { window.localStorage.setItem(SHORTCUT_STORAGE_KEY, raw) }, + }, 'web', platform, true, publish) + const changed = (event: StorageEvent): void => { + if (event.key === null || event.key === SHORTCUT_STORAGE_KEY) void persistence.readCurrent() + } + window.addEventListener('storage', changed) + return { + get: (definitions: readonly ShortcutDefinition[]) => { persistence.setDefinitions(definitions); return persistence.readCurrent() }, + edit: (edit, revision) => persistence.edit(edit, revision), + subscribe: () => () => {}, recording: async () => {}, + dispose: () => { window.removeEventListener('storage', changed); persistence.dispose() }, + } +} + +/** + * Read the origin-scoped preload capability; a missing bridge is an explicit configuration failure. + * @param window - product window. + * @returns the restricted Desktop API, or undefined while the preload is unavailable. + */ +export function desktopShortcutStorage(window: Window): DesktopShortcutsApi | undefined { + return (window as Window & { dshDesktop?: { shortcuts?: DesktopShortcutsApi } }).dshDesktop?.shortcuts +} diff --git a/packages/client/shortcuts/src/client/types.ts b/packages/client/shortcuts/src/client/types.ts new file mode 100644 index 0000000000..d374b89618 --- /dev/null +++ b/packages/client/shortcuts/src/client/types.ts @@ -0,0 +1,130 @@ +/** Command contributions and immutable catalog values. */ +import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' +import type { BindingIssue, NormalizedBinding, ShortcutConfigSnapshot, ShortcutEdit, ShortcutRevision, ShortcutSaveResult, ShortcutBinding, ShortcutCommandId, ShortcutPlatform, ShortcutProfile, ShortcutRuntime } from '../protocol.ts' + +/** Local input owner resolved before an application command. */ +export type ShortcutRegion = 'page' | 'editable' | 'terminal' +/** Synchronous DOM context; owners capture business targets in resolve(). */ +export interface ShortcutContext { + readonly source?: 'keyboard' | 'menu' | 'iframe' | 'webview' + readonly region: ShortcutRegion + readonly modal: string | null + /** Original input element, or null when no document target is available. */ + readonly target: Element | null +} +/** A resolved action captures its target; failures remain the owner's responsibility. */ +export type ShortcutResolution = { status: 'handled'; run(): void } + | { status: 'blocked'; reason: string } + | { status: 'pass' } +/** One command's labels, defaults, input ownership, and target resolver. */ +export interface ShortcutCommand { + readonly id: ShortcutCommandId + readonly label: () => string + readonly aliases: readonly string[] + readonly defaults: Readonly>> + readonly regions: readonly ShortcutRegion[] + /** Modal identifiers in which this command may resolve; others block it. */ + readonly modals: readonly string[] + resolve(context: ShortcutContext): ShortcutResolution +} +/** JSON-compatible catalog row, shared by reference, tooltips, and controls. */ +export interface ShortcutCatalogEntry { + readonly id: ShortcutCommandId + readonly label: string + readonly aliases: readonly string[] + readonly keys: readonly string[] + readonly aria: string | undefined + readonly binding: NormalizedBinding | null + readonly modified: boolean + readonly conflicts: readonly ShortcutCommandId[] + readonly issue: BindingIssue | null +} +/** Keyboard adapter input, with composition and local consumption already resolved. */ +export interface ShortcutGesture { + readonly code: string + /** Second physical key currently held with code; matching ignores pair order. */ + readonly secondCode?: string + readonly control: boolean + readonly alt: boolean + readonly shift: boolean + readonly meta: boolean + readonly repeat: boolean + readonly composing: boolean + readonly defaultPrevented: boolean +} +/** Local fixed input delivered after controls, or a sequence-invalidating interaction. */ +export type ShortcutFixedInput = { + readonly type: 'keydown' + readonly gesture: ShortcutGesture + readonly context: ShortcutContext + consume(): void +} | { readonly type: 'reset' } +/** Read-only input action contributed while its owning feature is mounted. */ +export interface ShortcutFixedCommand { + readonly id: ShortcutCommandId + readonly label: () => string + readonly keys: readonly string[] + /** One or more physical combinations reserved by this action, including individual steps of a sequence. */ + readonly bindings: readonly [ShortcutBinding, ...ShortcutBinding[]] + readonly group: 'input' | 'menus' | 'approval' +} +/** Localized fixed-action row; its key sequence is not an editable binding. */ +export interface ShortcutFixedCatalogEntry { + readonly id: ShortcutCommandId + readonly label: string + readonly keys: readonly string[] + readonly bindings: readonly NormalizedBinding[] + readonly group: ShortcutFixedCommand['group'] +} +/** Feature-facing keyboard registry. */ +export interface Shortcuts { + readonly runtime: ShortcutRuntime + readonly platform: ShortcutPlatform + readonly catalog: ObservableSnapshot + readonly config: ObservableSnapshot + readonly fixedCatalog: ObservableSnapshot + readonly stopSequenceMs: number + /** + * Contribute one fixed action and reserve its keys against editable bindings. + * @param command - owner-localized name, group, readable keys, and reserved physical combinations. + * @returns idempotent disposer removing the fixed row. + */ + registerFixed(command: ShortcutFixedCommand): () => void + /** + * Observe locally arbitrated input and interaction resets for fixed sequences. + * @param listener - owner handler; composing or consumed input must not trigger actions. + * @returns disposer releasing the handler. + */ + observeFixedInput(listener: (input: ShortcutFixedInput) => void): () => void + /** + * Describe a candidate using the device's physical-key and reservation rules; invalid codes throw. + * @param binding - candidate combination, or null for an unbound command. + * @returns canonical binding, visible keys, rejection reason, and overlapping editable/fixed command IDs. + */ + describeBinding(binding: ShortcutBinding | null): { + binding: NormalizedBinding | null + keys: readonly string[] + issue: BindingIssue | null + conflicts: readonly ShortcutCommandId[] + } + /** + * Save a reviewed edit; failures preserve both accepted preferences and the caller's draft. + * @param edit - set, clear, reset, or reset-all operation. + * @param revision - accepted revision reviewed by the user. + * @returns persistence outcome and current accepted configuration. + * @throws when publishing the accepted command catalog fails. + */ + edit(edit: ShortcutEdit, revision: ShortcutRevision): Promise + /** + * Suppress native menu accelerators while the recording layer owns keyboard input; reject if Desktop cannot acknowledge it. + * @param active - whether the recording layer is mounted. + * @returns completion of the native interception update. + */ + recording(active: boolean): Promise + /** + * Register a command; duplicate ids and overlapping default bindings throw. + * @param command - feature-owned labels, defaults, and target resolver. + * @returns disposer removing the binding and catalog entry. + */ + register(command: ShortcutCommand): () => void +} diff --git a/packages/client/shortcuts/src/config.ts b/packages/client/shortcuts/src/config.ts new file mode 100644 index 0000000000..ca8c838d0c --- /dev/null +++ b/packages/client/shortcuts/src/config.ts @@ -0,0 +1,13 @@ +/** Window-local timing accepted by the Host and browser keyboard service. */ +import z from '@deepseek-ai/schemastery' + +/** Fixed shortcut sequence settings. */ +export interface Config { + /** Maximum interval between independent Escape presses for stopping a reply, in milliseconds. */ + stopSequenceMs: number +} + +/** Validated deployment settings for fixed keyboard sequences. */ +export const Config: z, Config> = z.object({ + stopSequenceMs: z.natural().min(1).max(2_147_483_646).default(500), +}) diff --git a/packages/client/shortcuts/src/configuration.ts b/packages/client/shortcuts/src/configuration.ts new file mode 100644 index 0000000000..9a63ec87e1 --- /dev/null +++ b/packages/client/shortcuts/src/configuration.ts @@ -0,0 +1,244 @@ +/** Validated preference documents and deterministic conflict resolution, without browser dependencies. */ +import { assertNever } from '@deepseek-ai/dsh-util-values' +import { bindingKey, isWebBindingAllowed, normalizeBinding } from './binding.ts' +import type { NormalizedBinding, ShortcutBinding, ShortcutCommandId, ShortcutModifier, ShortcutPlatform, ShortcutProfile, ShortcutRuntime } from './binding.ts' + +/** Overrides are platform-local; absent commands inherit defaults and null explicitly unbinds. */ +export interface ShortcutDocument { + readonly schemaVersion: 1 | 2 + readonly profiles: Readonly>>>> +} +/** Serializable command definitions accepted from the trusted product frame. */ +export interface ShortcutDefinition { + readonly id: ShortcutCommandId + readonly defaults: Readonly>> + /** Fixed actions reserve one or more combinations instead of exposing an editable default. */ + readonly fixed?: readonly [ShortcutBinding, ...ShortcutBinding[]] +} +/** Validation failures select localized copy in the UI. */ +export type BindingIssue = 'reserved' | 'unsupported-browser' | 'modifier-required' | 'unsupported-key' +/** A conflicting row keeps its requested binding visible but cannot execute it. */ +export interface EffectiveShortcut { + readonly id: ShortcutCommandId + readonly binding: NormalizedBinding | null + readonly modified: boolean + readonly conflicts: readonly ShortcutCommandId[] + readonly issue: BindingIssue | null +} +/** One revision-checked preference edit, limited to the current runtime and platform. */ +export type ShortcutEdit = { type: 'set'; id: ShortcutCommandId; binding: ShortcutBinding | null } + | { type: 'reset'; id: ShortcutCommandId } | { type: 'reset-all' } + +const record = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value) +const commandPattern = /^[a-z][a-zA-Z0-9-]*(?:\.[a-zA-Z][a-zA-Z0-9-]*)+$/u + +/** + * Validate JSON binding fields before normalization; unknown fields are rejected to prevent lossy rewrites. + * @param value - file or IPC input. + * @returns a binding with a supported physical code, or null for explicit removal. + */ +export function parseBinding(value: unknown): ShortcutBinding | null { + if (value === null) return null + if (!record(value) || Object.keys(value).some(key => key !== 'code' && key !== 'secondCode' && key !== 'modifiers') + || typeof value.code !== 'string' || !Array.isArray(value.modifiers) + || (Object.hasOwn(value, 'secondCode') && typeof value.secondCode !== 'string') + || !value.modifiers.every((modifier: unknown): modifier is ShortcutModifier => + typeof modifier === 'string' && ['primary', 'control', 'alt', 'shift', 'meta'].includes(modifier))) { + throw new Error('Invalid shortcut binding') + } + const binding: ShortcutBinding = { + code: value.code, modifiers: value.modifiers, + ...(typeof value.secondCode === 'string' ? { secondCode: value.secondCode } : {}), + } + normalizeBinding(binding, 'windows') + return binding +} + +/** + * Decode the complete document while preserving dormant command overrides. + * @param raw - stored JSON, or null for a missing document. + * @returns the accepted document or a classified read failure. + */ +export function parseShortcutDocument(raw: string | null): ShortcutDocument | 'invalid' | 'future' { + if (raw === null) return { schemaVersion: 1, profiles: {} } + try { + const value: unknown = JSON.parse(raw) + if (!record(value)) return 'invalid' + if (typeof value.schemaVersion === 'number' && value.schemaVersion > 2) return 'future' + if ((value.schemaVersion !== 1 && value.schemaVersion !== 2) || !record(value.profiles) + || Object.keys(value).some(key => key !== 'schemaVersion' && key !== 'profiles')) return 'invalid' + const profiles: Record> = {} + for (const [profile, overrides] of Object.entries(value.profiles)) { + if (!/^(desktop|web):(macos|windows|linux)$/u.test(profile) || !record(overrides)) return 'invalid' + const bindings: Record = {} + for (const [id, binding] of Object.entries(overrides)) { + if (!commandPattern.test(id)) return 'invalid' + const parsed = parseBinding(binding) + if (value.schemaVersion === 1 && parsed?.secondCode !== undefined) return 'invalid' + bindings[id] = parsed + } + profiles[profile] = bindings + } + return { schemaVersion: value.schemaVersion, profiles } + } catch (_error) { + // Invalid JSON and invalid binding fields both preserve the original document. + return 'invalid' + } +} + +/** + * Check system, editor, and browser reservations using expanded physical modifiers. + * @param binding - normalized candidate. + * @param runtime - receiving application shell. + * @param platform - receiving device. + * @returns the rejection reason, or null when this combination is allowed. + */ +export function bindingIssue(binding: NormalizedBinding, runtime: ShortcutRuntime, platform: ShortcutPlatform): BindingIssue | null { + const { code, modifiers } = binding + if (runtime === 'desktop' && (platform === 'windows' || platform === 'macos')) return null + if (binding.secondCode !== undefined) return 'unsupported-key' + if (modifiers.length === 0 || modifiers.every(modifier => modifier === 'shift')) return 'modifier-required' + if ((platform === 'windows' || platform === 'macos') && modifiers.length >= 3) return null + if (runtime === 'web' && (platform === 'windows' || platform === 'macos') && isWebBindingAllowed(binding, platform)) return null + const primary = modifiers.includes(platform === 'macos' ? 'meta' : 'control') + if (['Escape', 'Tab', 'Space', 'Backspace', 'Delete', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(code) + || (code === 'Enter' && !modifiers.includes('alt')) + || (primary && ['KeyC', 'KeyV', 'KeyX', 'KeyZ', 'KeyY', 'KeyQ', 'KeyH'].includes(code)) + || (primary && code === 'KeyA' && !modifiers.includes('shift')) + || (platform !== 'macos' && (modifiers.includes('meta') || (modifiers.includes('alt') && ['F4', 'F2'].includes(code)))) + || (platform === 'macos' && modifiers.includes('control') && modifiers.includes('meta')) + || (platform === 'macos' && modifiers.includes('alt') && !primary)) return 'reserved' + if (runtime === 'web' && !isWebBindingAllowed(binding, platform)) return 'unsupported-browser' + return null +} + +/** + * Select the command owner's explicit default for one device profile. + * @param definition - command identity and per-profile defaults. + * @param runtime - receiving shell. + * @param platform - receiving device. + * @returns the declared physical binding, or undefined for an unbound action. + */ +export function resolveShortcutDefault(definition: ShortcutDefinition, runtime: ShortcutRuntime, + platform: ShortcutPlatform): ShortcutBinding | undefined { + return definition.defaults[`${runtime}:${platform}`] +} + +/** + * Resolve overrides and conflicts independently of registration order. Explicit overrides displace defaults. + * @param definitions - active commands. + * @param document - accepted preferences. + * @param runtime - receiving shell. + * @param platform - receiving device. + * @returns every active command, including unavailable conflicting bindings. + */ +export function effectiveShortcuts(definitions: readonly ShortcutDefinition[], document: ShortcutDocument, + runtime: ShortcutRuntime, platform: ShortcutPlatform): readonly EffectiveShortcut[] { + const overrides = document.profiles[`${runtime}:${platform}`] ?? {} + const fixed = definitions.flatMap(row => row.fixed?.map(binding => ({ id: row.id, binding: normalizeBinding(binding, platform) })) ?? []) + const rows = definitions.filter(row => row.fixed === undefined).map(({ id, defaults }) => { + const modified = Object.hasOwn(overrides, id) + const candidate = modified ? overrides[id] : resolveShortcutDefault({ id, defaults }, runtime, platform) + const binding = candidate == null ? null : normalizeBinding(candidate, platform) + return { id, binding, modified, issue: binding === null ? null : bindingIssue(binding, runtime, platform) } + }) + return rows.map((row) => { + const binding = row.binding + return { ...row, conflicts: binding === null ? [] : [...new Set([...rows.filter(other => other.id !== row.id + && other.binding !== null && other.issue === null && overlappingBindings(other.binding, binding) + && (!row.modified || other.modified)).map(other => other.id), + ...fixed.filter(other => overlappingBindings(other.binding, binding)).map(other => other.id)])] } + }) +} + +/** + * Detect identical combinations or a single key contained in a two-key chord. + * @param left - normalized candidate. + * @param right - normalized occupied binding. + * @returns whether both bindings require the same modifiers and overlap. + */ +export function overlappingBindings(left: NormalizedBinding, right: NormalizedBinding): boolean { + if (left.modifiers.join('+') !== right.modifiers.join('+')) return false + if (left.secondCode !== undefined && right.secondCode !== undefined) return bindingKey(left) === bindingKey(right) + return [left.code, left.secondCode].some(code => code !== undefined && (code === right.code || code === right.secondCode)) +} + +/** + * Apply an edit without modifying other profiles or dormant overrides. + * @param document - accepted document. + * @param edit - validated operation. + * @param runtime - current shell. + * @param platform - current device. + * @returns the candidate document, pending conflict checks and durable storage. + */ +export function editShortcutDocument(document: ShortcutDocument, edit: ShortcutEdit, + runtime: ShortcutRuntime, platform: ShortcutPlatform): ShortcutDocument { + const schemaVersion = runtime === 'desktop' && (platform === 'macos' || platform === 'windows') ? 2 : document.schemaVersion + const profile = `${runtime}:${platform}` as const + let overrides = { ...document.profiles[profile] } + switch (edit.type) { + case 'set': overrides[edit.id] = edit.binding; break + case 'reset': { + const { [edit.id]: _removed, ...remaining } = overrides + overrides = remaining + break + } + case 'reset-all': overrides = {}; break + /* v8 ignore next -- parseShortcutEdit validates this closed union before persistence. */ + default: return assertNever(edit, 'shortcut edit') + } + return { schemaVersion, profiles: { ...document.profiles, [profile]: overrides } } +} + +/** + * Validate a preference edit at the Desktop IPC boundary. + * @param value - untrusted renderer request. + * @returns the constrained operation; malformed requests throw. + */ +export function parseShortcutEdit(value: unknown): ShortcutEdit { + if (!record(value)) throw new Error('Invalid shortcut edit') + if (value.type === 'reset-all' && Object.keys(value).length === 1) return { type: value.type } + if (typeof value.id !== 'string' || !commandPattern.test(value.id)) throw new Error('Invalid shortcut command') + if (value.type === 'reset' && Object.keys(value).length === 2) return { type: 'reset', id: value.id as ShortcutCommandId } + if (value.type === 'set' && Object.keys(value).length === 3) return { type: 'set', id: value.id as ShortcutCommandId, binding: parseBinding(value.binding) } + throw new Error('Invalid shortcut edit') +} + +/** + * Validate the trusted product's serializable command catalog at IPC ingress. + * @param value - renderer-supplied active command definitions. + * @returns validated definitions; duplicate IDs, overlapping defaults, and unsupported combinations throw. + */ +export function parseShortcutDefinitions(value: unknown): readonly ShortcutDefinition[] { + if (!Array.isArray(value)) throw new Error('Invalid shortcut catalog') + const ids = new Set() + for (const entry of value) { + if (!record(entry) || typeof entry.id !== 'string' || !commandPattern.test(entry.id) || ids.has(entry.id) + || !record(entry.defaults) || Object.keys(entry).some(key => key !== 'id' && key !== 'defaults' && key !== 'fixed')) throw new Error('Invalid shortcut definition') + ids.add(entry.id) + if (Object.hasOwn(entry, 'fixed')) { + if (!Array.isArray(entry.fixed) || entry.fixed.length === 0 || Object.keys(entry.defaults).length > 0) throw new Error('Invalid fixed shortcut definition') + for (const binding of entry.fixed) if (parseBinding(binding) === null) throw new Error('Invalid fixed shortcut binding') + } + for (const [profile, candidate] of Object.entries(entry.defaults)) { + if (!/^(desktop|web):(macos|windows|linux)$/u.test(profile)) throw new Error('Invalid shortcut profile') + const binding = parseBinding(candidate) + if (binding === null) throw new Error('Invalid shortcut default') + } + } + const definitions = value as ShortcutDefinition[] + for (const runtime of ['desktop', 'web'] as const) { + for (const platform of ['macos', 'windows', 'linux'] as const) { + const bindings: NormalizedBinding[] = [] + for (const entry of definitions) { + const binding = resolveShortcutDefault(entry, runtime, platform) + if (binding === undefined) continue + const normalized = normalizeBinding(binding, platform) + if (bindings.some(other => overlappingBindings(other, normalized)) + || bindingIssue(normalized, runtime, platform) !== null) throw new Error('Conflicting or reserved shortcut default') + bindings.push(normalized) + } + } + } + return definitions +} diff --git a/packages/client/shortcuts/src/index.ts b/packages/client/shortcuts/src/index.ts new file mode 100644 index 0000000000..75fb62db79 --- /dev/null +++ b/packages/client/shortcuts/src/index.ts @@ -0,0 +1,20 @@ +/** Host configuration for window-local keyboard sequences. */ +// Host configuration injection stays with each plugin's validated Config and browser global. +/* jscpd:ignore-start */ +import type { Context } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-host-webserver' +import type { Config } from './config.ts' + +export { Config } from './config.ts' + +/** + * Embed validated keyboard settings in product pages. + * @param ctx - Host context serving browser pages. + * @param config - sequence timing adopted when the page loads. + */ +export function apply(ctx: Context, config: Config): void { + ctx.on('webserver/index-inject', (table) => { + table.push({ kind: 'global', name: '__DSH_SHORTCUTS_CONFIG__', value: config }) + }) +} +/* jscpd:ignore-end */ diff --git a/packages/client/shortcuts/src/native.ts b/packages/client/shortcuts/src/native.ts new file mode 100644 index 0000000000..45e0817488 --- /dev/null +++ b/packages/client/shortcuts/src/native.ts @@ -0,0 +1,29 @@ +/** Trusted product-window input exchanged with Electron's top-frame preload. */ +import type { ShortcutRevision } from './persistence.ts' +import type { ShortcutCommandId } from './binding.ts' + +/** Native input carries the accepted configuration identity; frame names identify the focused iframe or browser guest embedding element. */ +export type DesktopShortcutInput = { + readonly revision: ShortcutRevision +} & ({ readonly kind: 'menu'; readonly commandId: ShortcutCommandId } + | { readonly kind: 'keyboard' | 'iframe' | 'webview' + readonly frameName: string + readonly code: string + readonly secondCode?: string + readonly control: boolean + readonly alt: boolean + readonly shift: boolean + readonly meta: boolean + readonly repeat: boolean }) + +/** Window-scoped capabilities exposed only to the product main frame. */ +export interface DesktopKeyboardApi { + /** Subscribe to verified native input. @param listener - current document consumer. @returns listener disposer. */ + subscribe(listener: (input: DesktopShortcutInput) => void): () => void + /** + * Close the owning window if its configuration is still current. + * @param revision - accepted configuration identity. + * @returns IPC completion. + */ + closeWindow(revision: ShortcutRevision): Promise +} diff --git a/packages/client/shortcuts/src/persistence.ts b/packages/client/shortcuts/src/persistence.ts new file mode 100644 index 0000000000..49335a4935 --- /dev/null +++ b/packages/client/shortcuts/src/persistence.ts @@ -0,0 +1,144 @@ +/** Serialized preference transactions; storage owners publish only accepted writes or read diagnostics. */ +import { randomUUID } from '@deepseek-ai/dsh-util-crypto' +import type { Branded } from '@deepseek-ai/dsh-brand' +import { editShortcutDocument, effectiveShortcuts, parseShortcutDocument } from './configuration.ts' +import type { BindingIssue, ShortcutDefinition, ShortcutDocument, ShortcutEdit } from './configuration.ts' +import type { ShortcutCommandId, ShortcutPlatform, ShortcutRuntime } from './binding.ts' + +/** Opaque accepted-state identity, invalidated by configuration and catalog changes. */ +export type ShortcutRevision = Branded<'ShortcutRevision'> +/** Read state retains the last accepted document on failure; loading does not enable commands. */ +export interface ShortcutConfigSnapshot { + readonly revision: ShortcutRevision + /** Monotonic within one storage-owner lifetime; clients discard out-of-order IPC replies. */ + readonly sequence: number + readonly document: ShortcutDocument + readonly status: 'loading' | 'ready' | 'unreadable' + readonly error: 'read' | 'invalid' | 'future' | null + readonly usingDefaults: boolean +} +/** Save failures never replace effective preferences. */ +export interface ShortcutSaveResult { + readonly status: 'saved' | 'stale' | 'unreadable' | 'write-failed' | 'not-ready' | 'conflict' + readonly snapshot: ShortcutConfigSnapshot + readonly issue?: BindingIssue + readonly conflicts?: readonly ShortcutCommandId[] +} +/** Storage adapter owns path/origin isolation and complete replacement on successful writes. */ +export interface ShortcutStorage { + read(): string | null | Promise + write(raw: string): void | Promise +} +/** Restricted Desktop preload API; file paths and accelerators never cross from Renderer. */ +export interface DesktopShortcutsApi { + get(definitions: readonly ShortcutDefinition[]): Promise + edit(edit: ShortcutEdit, revision: ShortcutRevision): Promise + subscribe(listener: (snapshot: ShortcutConfigSnapshot) => void): () => void + recording(active: boolean): Promise +} + +/** + * Create a disabled initial snapshot for asynchronous adapter startup. + * @returns a fresh configuration with no accepted persisted state. + */ +export function initialShortcutConfig(): ShortcutConfigSnapshot { + return { revision: randomUUID() as ShortcutRevision, sequence: 0, document: { schemaVersion: 1, profiles: {} }, + status: 'loading', error: null, usingDefaults: true } +} + +/** Single-writer coordinator shared by localStorage and Electron's atomic file adapter. */ +export class ShortcutPersistence { + private snapshot = initialShortcutConfig() + private raw: string | null | undefined + private queue: Promise = Promise.resolve() + private definitions: readonly ShortcutDefinition[] | null = null + private active = true + + constructor(private readonly storage: ShortcutStorage, private readonly runtime: ShortcutRuntime, + private readonly platform: ShortcutPlatform, private readonly rereadBeforeWrite: boolean, + private readonly publish: (snapshot: ShortcutConfigSnapshot) => void) {} + + /** + * Install or revoke a product catalog and invalidate drafts from its previous lifetime. + * @param definitions - current trusted definitions, or null while the product is not ready. + */ + setDefinitions(definitions: readonly ShortcutDefinition[] | null): void { + this.definitions = definitions + this.accept({ ...this.snapshot }) + } + + /** Stop accepting edits or publishing late completions. */ + dispose(): void { this.active = false } + + /** + * Read the current file; failures retain the last accepted document and disable ordinary writes. + * @returns the accepted snapshot or diagnostic snapshot. + */ + readCurrent(): Promise { + return this.serialize(() => this.read()) + } + + /** + * Compare the draft revision, validate the complete candidate, then persist before publishing. + * @param edit - constrained preference operation. + * @param revision - state against which the user reviewed the edit. + * @returns a classified outcome and the currently accepted snapshot. + */ + edit(edit: ShortcutEdit, revision: ShortcutRevision): Promise { + return this.serialize(async () => { + if (this.rereadBeforeWrite) await this.read() + const result = (status: ShortcutSaveResult['status']): ShortcutSaveResult => ({ status, snapshot: this.snapshot }) + if (!this.active || this.definitions === null || this.snapshot.status === 'loading') return result('not-ready') + if (revision !== this.snapshot.revision) return result('stale') + if (this.snapshot.status === 'unreadable') return result('unreadable') + if ((edit.type === 'set' || edit.type === 'reset') && !this.definitions.some(row => row.id === edit.id && row.fixed === undefined)) return result('not-ready') + const document = editShortcutDocument(this.snapshot.document, edit, this.runtime, this.platform) + const rows = effectiveShortcuts(this.definitions, document, this.runtime, this.platform) + const invalid = rows.find(row => (edit.type === 'reset-all' || row.id === edit.id) + && (row.issue !== null || row.conflicts.length > 0)) + // An explicit edit must not silently disable another command's default binding. + const displaced = edit.type === 'set' ? rows.find(row => row.conflicts.includes(edit.id)) : undefined + if (invalid !== undefined || displaced !== undefined) return { ...result('conflict'), + ...(invalid?.issue ? { issue: invalid.issue } : {}), + conflicts: invalid?.conflicts.length ? invalid.conflicts : displaced === undefined ? [] : [displaced.id] } + try { + const raw = `${JSON.stringify(document, null, 2)}\n` + await this.storage.write(raw) + this.raw = raw + this.accept({ ...this.snapshot, document, status: 'ready', error: null, usingDefaults: false }) + return result('saved') + } catch (_error) { + // The adapter retains the previous complete document when a write fails. + return result('write-failed') + } + }) + } + + private serialize(operation: () => Promise): Promise { + const next = this.queue.then(operation) + this.queue = next.catch(() => undefined) + return next + } + + private accept(snapshot: ShortcutConfigSnapshot): void { + this.snapshot = { ...snapshot, sequence: this.snapshot.sequence + 1, revision: randomUUID() as ShortcutRevision } + if (!this.active) return + try { this.publish(this.snapshot) } catch (error) { console.error('Shortcut configuration subscriber failed:', error) } + } + + private async read(): Promise { + let raw: string | null + try { + raw = await this.storage.read() + } catch (_error) { + if (this.snapshot.error !== 'read') this.accept({ ...this.snapshot, status: 'unreadable', error: 'read' }) + return this.snapshot + } + if (raw === this.raw && this.snapshot.error !== 'read') return this.snapshot + this.raw = raw + const document = parseShortcutDocument(raw) + if (typeof document === 'string') this.accept({ ...this.snapshot, status: 'unreadable', error: document }) + else this.accept({ ...this.snapshot, document, status: 'ready', error: null, usingDefaults: raw === null }) + return this.snapshot + } +} diff --git a/packages/client/shortcuts/src/protocol.ts b/packages/client/shortcuts/src/protocol.ts new file mode 100644 index 0000000000..fad8ab60bd --- /dev/null +++ b/packages/client/shortcuts/src/protocol.ts @@ -0,0 +1,5 @@ +/** Physical-key and versioned preference protocol shared by Client and Desktop main. */ +export * from './binding.ts' +export * from './configuration.ts' +export * from './persistence.ts' +export * from './native.ts' diff --git a/packages/client/shortcuts/tests/config.host.spec.ts b/packages/client/shortcuts/tests/config.host.spec.ts new file mode 100644 index 0000000000..13ad7bc00a --- /dev/null +++ b/packages/client/shortcuts/tests/config.host.spec.ts @@ -0,0 +1,17 @@ +import { Context } from '@deepseek-ai/cordis' +import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver' +import { expect, it } from 'vitest' +import { Config, apply } from '../src/index.ts' + +it('validates positive sequence timing and publishes it through the product page injection', async () => { + expect(Config({}).stopSequenceMs).toBe(500) + expect(Config({ stopSequenceMs: 2_147_483_646 }).stopSequenceMs).toBe(2_147_483_646) + for (const stopSequenceMs of [0, -1, 1.5, 2_147_483_647, Infinity, NaN]) expect(() => Config({ stopSequenceMs })).toThrow() + const ctx = new Context() + try { + apply(ctx, Config({ stopSequenceMs: 800 })) + const table: IndexInjection[] = [] + await ctx.parallel('webserver/index-inject', table) + expect(table).toEqual([{ kind: 'global', name: '__DSH_SHORTCUTS_CONFIG__', value: { stopSequenceMs: 800 } }]) + } finally { await ctx.fiber.dispose() } +}) diff --git a/packages/client/shortcuts/tests/configuration.client.spec.ts b/packages/client/shortcuts/tests/configuration.client.spec.ts new file mode 100644 index 0000000000..63d49dc38e --- /dev/null +++ b/packages/client/shortcuts/tests/configuration.client.spec.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from 'vitest' +import { bindingIssue, editShortcutDocument, effectiveShortcuts, normalizeBinding, parseBinding, parseShortcutDefinitions, + parseShortcutDocument, parseShortcutEdit } from '../src/protocol.ts' +import type { ShortcutCommandId, ShortcutDefinition, ShortcutDocument } from '../src/protocol.ts' + +const id = (value: string) => value as ShortcutCommandId +it('reserves the macOS Web control-command combination', () => { + expect(bindingIssue({ code: 'KeyB', modifiers: ['control', 'meta'] }, 'web', 'macos')).toBe('reserved') +}) +const primary = (code: string) => ({ code, modifiers: ['primary'] as const }) +const definitions: readonly ShortcutDefinition[] = [ + { id: id('test.one'), defaults: { + 'desktop:macos': primary('KeyB'), + 'desktop:windows': primary('KeyB'), + 'desktop:linux': primary('KeyB'), + } }, + { id: id('test.two'), defaults: { + 'desktop:macos': primary('KeyJ'), + 'desktop:windows': primary('KeyJ'), + 'desktop:linux': primary('KeyJ'), + } }, +] + +describe('preference document validation and conflict resolution', () => { + it('distinguishes inherited, cleared, reset, and dormant preferences without touching other profiles', () => { + const document: ShortcutDocument = { schemaVersion: 1, profiles: { + 'desktop:macos': { 'dormant.command': primary('KeyU'), 'test.one': null }, + 'web:windows': { 'test.one': primary('Slash') }, + } } + expect(parseShortcutDocument(JSON.stringify(document))).toEqual(document) + expect(effectiveShortcuts(definitions, document, 'desktop', 'macos')[0]).toMatchObject({ modified: true, binding: null }) + const reset = editShortcutDocument(document, { type: 'reset', id: id('test.one') }, 'desktop', 'macos') + expect(reset.profiles['desktop:macos']).toEqual({ 'dormant.command': primary('KeyU') }) + expect(effectiveShortcuts(definitions, reset, 'desktop', 'macos')[0]).toMatchObject({ modified: false, binding: { code: 'KeyB' } }) + expect(editShortcutDocument(document, { type: 'reset-all' }, 'desktop', 'macos').profiles) + .toEqual({ 'desktop:macos': {}, 'web:windows': document.profiles['web:windows'] }) + }) + + it.each(['{', 'null', '[]', '{"schemaVersion":0,"profiles":{}}', '{"schemaVersion":1,"profiles":{"alien":{}}}', + '{"schemaVersion":1,"profiles":{"web:macos":{"bad":null}}}', '{"schemaVersion":1,"profiles":{},"extra":1}', + '{"schemaVersion":1,"profiles":{"web:macos":{"test.one":{"code":"KeyB","modifiers":["bogus"]}}}}'])('retains invalid documents: %s', (raw) => { + expect(parseShortcutDocument(raw)).toBe('invalid') + }) + it('refuses future versions without interpreting their fields', () => { + expect(parseShortcutDocument('{"schemaVersion":3,"profiles":17}')).toBe('future') + expect(parseShortcutDocument(null)).toEqual({ schemaVersion: 1, profiles: {} }) + expect(() => parseBinding({ code: 'KeyB', modifiers: ['primary'], extra: true })).toThrow() + expect(() => parseBinding({ code: 'Unknown', modifiers: [] })).toThrow() + }) + + it('lets explicit overrides displace new defaults, but disables both conflicting explicit overrides', () => { + const document: ShortcutDocument = { schemaVersion: 1, profiles: { 'desktop:windows': { 'test.one': primary('KeyJ') } } } + const rows = effectiveShortcuts(definitions, document, 'desktop', 'windows') + expect(rows.map(row => row.conflicts)).toEqual([[], [id('test.one')]]) + const both = editShortcutDocument(document, { type: 'set', id: id('test.two'), binding: { code: 'KeyJ', modifiers: ['control'] } }, 'desktop', 'windows') + expect(effectiveShortcuts([...definitions].reverse(), both, 'desktop', 'windows').map(row => row.conflicts)) + .toEqual([[id('test.one')], [id('test.two')]]) + }) + + it('keeps Linux desktop and browser reservations', () => { + const platform = 'linux' + const check = (code: string, modifiers: readonly ('primary' | 'control' | 'alt' | 'shift' | 'meta')[], runtime: 'web' | 'desktop' = 'desktop') => + bindingIssue(normalizeBinding({ code, modifiers }, platform), runtime, platform) + for (const code of ['KeyC', 'KeyV', 'KeyX', 'KeyZ', 'KeyY', 'KeyQ', 'Tab', 'Escape', 'Space', 'Enter']) expect(check(code, ['primary'])).toBe('reserved') + expect(check('KeyA', ['primary', 'shift'])).toBeNull() + expect(check('KeyA', ['primary'])).toBe('reserved') + expect(check('KeyJ', [])).toBe('modifier-required') + expect(check('KeyJ', ['shift'])).toBe('modifier-required') + expect(check('KeyJ', ['primary'])).toBeNull() + for (const code of ['KeyN', 'KeyP', 'KeyW', 'KeyR', 'KeyO', 'KeyJ']) expect(check(code, ['primary'], 'web')).toBe('unsupported-browser') + for (const code of ['Comma', 'Period']) expect(check(code, ['primary', 'shift'], 'web')).toBeNull() + expect(check('Slash', ['primary'], 'web')).toBeNull() + expect(check('F4', ['alt'], 'desktop')).toBe('reserved') + expect(check('KeyL', ['meta', 'control'], 'desktop')).toBe('reserved') + }) + + it.each(['macos', 'windows'] as const)('accepts %s desktop editing and system combinations, and single keys', (platform) => { + for (const [code, modifiers] of [ + ...['KeyC', 'KeyV', 'KeyX', 'KeyZ', 'KeyY', 'KeyA', 'KeyQ', 'KeyH', 'Tab', 'Space'].map(code => [code, ['primary']] as const), + ['KeyC', ['control']], ['KeyX', ['control']], ['KeyN', ['shift']], ['Tab', ['control']], ['Tab', ['shift']], + ['Escape', ['alt']], ['F4', ['alt']], ['KeyL', ['meta']], ['Delete', ['control', 'alt']], + ['KeyL', ['control', 'meta']], ['ArrowLeft', ['alt']], + ] as const) expect(bindingIssue(normalizeBinding({ code, modifiers }, platform), 'desktop', platform)).toBeNull() + expect(bindingIssue(normalizeBinding({ code: 'KeyC', modifiers: [] }, platform), 'desktop', platform)).toBeNull() + expect(bindingIssue(normalizeBinding(primary('KeyC'), platform), 'web', platform)).toBe('reserved') + let document: ShortcutDocument = { schemaVersion: 1, profiles: {} } + for (const entry of definitions) document = editShortcutDocument(document, { type: 'set', id: entry.id, binding: primary('KeyC') }, 'desktop', platform) + expect(effectiveShortcuts(definitions, document, 'desktop', platform).map(row => row.conflicts)) + .toEqual([[id('test.two')], [id('test.one')]]) + }) + + it.each([ + ['web', 'macos'], ['web', 'windows'], ['desktop', 'macos'], ['desktop', 'windows'], + ] as const)('accepts three and four distinct modifiers in %s on %s and still detects conflicts', (runtime, platform) => { + for (const modifiers of [ + ['control', 'alt', 'shift'], ['control', 'alt', 'meta'], ['control', 'shift', 'meta'], + ['alt', 'shift', 'meta'], ['control', 'alt', 'shift', 'meta'], + ] as const) { + for (const code of ['KeyN', 'KeyR', 'KeyX', 'Tab', 'F4']) { + expect(bindingIssue(normalizeBinding({ code, modifiers }, platform), runtime, platform)).toBeNull() + } + } + const binding = { code: 'KeyX', modifiers: ['primary', 'alt', 'shift'] as const } + let document: ShortcutDocument = { schemaVersion: 1, profiles: {} } + for (const entry of definitions) document = editShortcutDocument(document, { type: 'set', id: entry.id, binding }, runtime, platform) + expect(effectiveShortcuts(definitions, document, runtime, platform).map(row => row.conflicts)) + .toEqual([[id('test.two')], [id('test.one')]]) + expect(bindingIssue(normalizeBinding({ code: 'KeyN', modifiers: [] }, platform), runtime, platform)).toBe(runtime === 'desktop' ? null : 'modifier-required') + }) + + it('counts expanded distinct modifiers and keeps Linux reservations', () => { + expect(normalizeBinding({ code: 'KeyX', modifiers: ['primary', 'meta', 'shift'] }, 'macos').modifiers).toEqual(['shift', 'meta']) + expect(bindingIssue(normalizeBinding({ code: 'KeyN', modifiers: ['primary', 'control', 'control'] }, 'windows'), 'web', 'windows')).toBe('unsupported-browser') + expect(bindingIssue(normalizeBinding({ code: 'KeyN', modifiers: ['control', 'alt', 'shift'] }, 'linux'), 'web', 'linux')).toBe('unsupported-browser') + }) + + it('selects each owner-declared profile without inheriting or rewriting another profile', () => { + const entry: ShortcutDefinition = { id: id('page.refresh'), defaults: { + 'desktop:macos': primary('KeyR'), 'desktop:windows': primary('KeyR'), + 'web:macos': { code: 'KeyP', modifiers: ['primary', 'shift'] }, + 'web:windows': { code: 'KeyJ', modifiers: ['primary', 'alt'] }, + } } + const document: ShortcutDocument = { schemaVersion: 1, profiles: {} } + const effective = (runtime: 'desktop' | 'web', platform: 'macos' | 'windows' | 'linux', source = document) => + effectiveShortcuts([entry], source, runtime, platform)[0]?.binding + expect(effective('desktop', 'macos')).toEqual({ code: 'KeyR', modifiers: ['meta'] }) + expect(effective('desktop', 'windows')).toEqual({ code: 'KeyR', modifiers: ['control'] }) + expect(effective('web', 'macos')).toEqual({ code: 'KeyP', modifiers: ['shift', 'meta'] }) + expect(effective('web', 'windows')).toEqual({ code: 'KeyJ', modifiers: ['control', 'alt'] }) + expect(effective('desktop', 'linux')).toBeNull() + expect(effective('web', 'linux')).toBeNull() + const override = editShortcutDocument(document, { type: 'set', id: entry.id, binding: null }, 'web', 'macos') + expect(effective('web', 'macos', override)).toBeNull() + const reset = editShortcutDocument(override, { type: 'reset', id: entry.id }, 'web', 'macos') + expect(effective('web', 'macos', reset)).toEqual({ code: 'KeyP', modifiers: ['shift', 'meta'] }) + expect(() => parseShortcutDefinitions([entry])).not.toThrow() + expect(() => parseShortcutDefinitions([entry, { ...entry, id: id('other.refresh') }])).toThrow('Conflicting') + }) + + it('keeps browser admission separate from per-profile default selection', () => { + for (const platform of ['macos', 'windows'] as const) { + const primary = platform === 'macos' ? 'meta' : 'control' + for (const other of ['alt', 'shift'] as const) { + expect(bindingIssue(normalizeBinding({ code: 'KeyX', modifiers: [primary, other] }, platform), 'web', platform)).toBeNull() + } + } + expect(bindingIssue(normalizeBinding(primary('Backquote'), 'macos'), 'web', 'macos')).toBe('unsupported-browser') + expect(bindingIssue(normalizeBinding({ code: 'KeyB', modifiers: ['alt'] }, 'macos'), 'web', 'macos')).toBe('reserved') + expect(bindingIssue(normalizeBinding(primary('KeyN'), 'windows'), 'web', 'windows')).toBe('unsupported-browser') + }) + + it('validates IPC edits and active definitions rather than trusting renderer fields', () => { + expect(parseShortcutDefinitions(definitions)).toEqual(definitions) + expect(parseShortcutEdit({ type: 'set', id: 'test.one', binding: null })).toEqual({ type: 'set', id: 'test.one', binding: null }) + expect(parseShortcutEdit({ type: 'reset', id: 'test.one' }).type).toBe('reset') + expect(parseShortcutEdit({ type: 'reset-all' }).type).toBe('reset-all') + for (const value of [null, {}, { type: 'recover' }, { type: 'reset', id: '../x' }, { type: 'reset-all', path: '/tmp' }, { type: 'other', id: 'test.one' }]) expect(() => parseShortcutEdit(value)).toThrow() + for (const value of [null, [{ ...definitions[0], defaults: { + 'desktop:macos': null, + 'desktop:windows': null, + 'desktop:linux': null, + } }], [...definitions, definitions[0]], + [{ ...definitions[0], defaults: { other: primary('KeyJ') } }], [{ ...definitions[0], defaults: { + 'web:macos': primary('KeyN'), + 'web:windows': primary('KeyN'), + 'web:linux': primary('KeyN'), + } }], + [{ ...definitions[0], defaults: { + 'desktop:macos': primary('KeyC'), + 'desktop:windows': primary('KeyC'), + 'desktop:linux': primary('KeyC'), + } }]]) expect(() => parseShortcutDefinitions(value)).toThrow() + }) +}) diff --git a/packages/client/shortcuts/tests/desktop-chords.client.spec.ts b/packages/client/shortcuts/tests/desktop-chords.client.spec.ts new file mode 100644 index 0000000000..e926f5f0ac --- /dev/null +++ b/packages/client/shortcuts/tests/desktop-chords.client.spec.ts @@ -0,0 +1,119 @@ +/** Physical shortcut chords share normalization, conflicts, and persistence rules. */ +import { expect, it, onTestFinished, vi } from 'vitest' +import { bindingIssue, bindingKey, effectiveShortcuts, normalizeBinding, parseBinding, parseShortcutDocument, + parseShortcutDefinitions, presentBinding, ShortcutPersistence } from '../src/protocol.ts' +import type { ShortcutBinding, ShortcutCommandId, ShortcutDefinition } from '../src/protocol.ts' +import { ShortcutRegistry } from '../src/client/registry.ts' + +const id = 'test.action' as ShortcutCommandId +const other = 'test.other' as ShortcutCommandId +const pair = { code: 'KeyA', secondCode: 'KeyB', modifiers: [] } as const +const definitions = [{ id, defaults: {} }, { id: other, defaults: {} }] + +it.each(['macos', 'windows'] as const)('rejects overlapping %s defaults at registration and IPC ingress without disabling the active command', (platform) => { + const modifier = platform === 'macos' ? 'meta' : 'control' + for (const code of ['KeyA', 'KeyB']) { + const single: ShortcutBinding = { code, modifiers: ['primary'] } + const chord: ShortcutBinding = { ...pair, modifiers: [modifier] } + for (const [first, second] of [[single, chord], [chord, single]] as const) { + const catalog: ShortcutDefinition[] = [ + { id, defaults: { [`desktop:${platform}`]: first } }, + { id: other, defaults: { [`desktop:${platform}`]: second } }, + ] + expect(() => parseShortcutDefinitions(catalog)).toThrow('Conflicting') + const registry = new ShortcutRegistry('desktop', platform) + const run = vi.fn() + const register = (definition: ShortcutDefinition) => registry.register({ ...definition, label: () => definition.id, + aliases: [], regions: ['page'], modals: [], resolve: () => ({ status: 'handled', run }) }) + register(catalog[0]!) + const accepted = registry.catalog.getSnapshot() + expect(() => register(catalog[1]!)).toThrow('Conflicting') + expect(registry.catalog.getSnapshot()).toBe(accepted) + expect(registry.dispatch({ ...first, meta: platform === 'macos', control: platform === 'windows', + alt: false, shift: false, repeat: false, composing: false, defaultPrevented: false }, + { region: 'page', modal: null, target: null }, vi.fn()).status).toBe('handled') + expect(run).toHaveBeenCalledOnce() + expect(parseShortcutDefinitions([catalog[0], { id: other, defaults: { [`desktop:${platform}`]: { + ...second, modifiers: [modifier, 'shift'], + } } }])).toHaveLength(2) + } + } +}) + +it.each([['web', 'macos'], ['web', 'windows'], ['web', 'linux'], ['desktop', 'linux']] as const)( + 'rejects bare keys and chords in %s on %s', (runtime, platform) => { + for (const binding of [pair, { code: 'KeyA', modifiers: [] }, { ...pair, modifiers: ['control', 'alt', 'shift', 'meta'] }] as const) { + expect(bindingIssue(normalizeBinding(binding, platform), runtime, platform)).not.toBeNull() + } + }) + +it('canonicalizes unordered pairs, rejects invalid main keys, and omits unsupported ARIA accelerators', () => { + const reversed = normalizeBinding({ ...pair, code: 'KeyB', secondCode: 'KeyA' }, 'macos') + expect(reversed).toEqual(pair) + expect(bindingKey(reversed)).toBe('KeyA+KeyB') + expect(presentBinding(reversed, 'macos')).toEqual({ keys: ['A', 'B'], aria: undefined }) + expect(presentBinding(reversed, 'windows')).toEqual({ keys: ['A', 'B'], aria: undefined }) + expect(presentBinding({ ...reversed, modifiers: ['control', 'shift'] }, 'windows').keys) + .toEqual(['Ctrl', '+', 'Shift', '+', 'A', 'B']) + for (const input of [ + { ...pair, secondCode: 'KeyA' }, { ...pair, secondCode: 'ShiftLeft' }, { ...pair, thirdCode: 'KeyC' }, + { ...pair, secondCode: ['KeyB', 'KeyC'] }, { code: 'MetaLeft', modifiers: ['control'] }, { modifiers: ['meta'] }, + ]) expect(() => parseBinding(input)).toThrow() +}) + +it.each(['macos', 'windows'] as const)('migrates %s preferences on write and rejects both directions of single/chord conflicts', async (platform) => { + let raw = JSON.stringify({ schemaVersion: 1, profiles: { 'web:macos': { 'dormant.web': null } } }) + const original = raw + const store = new ShortcutPersistence({ read: () => raw, write: (next) => { raw = next } }, 'desktop', platform, false, () => {}) + onTestFinished(() => { store.dispose() }) + store.setDefinitions(definitions) + let state = await store.readCurrent() + expect(raw).toBe(original) + const saved = await store.edit({ type: 'set', id, binding: pair }, state.revision) + expect(saved.status).toBe('saved') + expect(JSON.parse(raw)).toEqual({ schemaVersion: 2, profiles: { 'web:macos': { 'dormant.web': null }, [`desktop:${platform}`]: { [id]: pair } } }) + expect(parseShortcutDocument(raw)).toEqual(saved.snapshot.document) + expect(parseShortcutDocument(raw.replace('"schemaVersion": 2', '"schemaVersion": 1'))).toBe('invalid') + state = saved.snapshot + for (const code of ['KeyA', 'KeyB']) { + const rejected = await store.edit({ type: 'set', id: other, binding: { code, modifiers: [] } }, state.revision) + expect(rejected).toMatchObject({ status: 'conflict', conflicts: [id] }) + } + const cleared = await store.edit({ type: 'reset-all' }, state.revision) + const single = await store.edit({ type: 'set', id, binding: { code: 'KeyB', modifiers: [] } }, cleared.snapshot.revision) + expect((await store.edit({ type: 'set', id: other, binding: pair }, single.snapshot.revision)).status).toBe('conflict') + const overlapping = { schemaVersion: 2 as const, profiles: { [`desktop:${platform}`]: { [id]: pair, [other]: { code: 'KeyA', modifiers: [] } } } } + expect(effectiveShortcuts(definitions, overlapping, 'desktop', platform).map(row => row.conflicts)).toEqual([[other], [id]]) +}) + +it.each(['macos', 'windows'] as const)('rejects direct %s writes that overlap fixed actions and disables previously saved conflicts', async (platform) => { + const fixedId = 'menu.dismiss' as ShortcutCommandId + const catalog = parseShortcutDefinitions([...definitions, + { id: fixedId, defaults: {}, fixed: [{ code: 'Escape', modifiers: [] }] }]) + const storage = { read: () => null, write: vi.fn() } + const store = new ShortcutPersistence(storage, 'desktop', platform, false, () => {}) + onTestFinished(() => { store.dispose() }) + store.setDefinitions(catalog) + const state = await store.readCurrent() + for (const binding of [{ code: 'Escape', modifiers: [] }, { code: 'KeyA', secondCode: 'Escape', modifiers: [] }] as const) { + expect(await store.edit({ type: 'set', id, binding }, state.revision)).toMatchObject({ status: 'conflict', conflicts: [fixedId] }) + expect(effectiveShortcuts(catalog, { schemaVersion: 2, profiles: { [`desktop:${platform}`]: { [id]: binding } } }, + 'desktop', platform).find(row => row.id === id)?.conflicts).toEqual([fixedId]) + } + expect(storage.write).not.toHaveBeenCalled() + expect((await store.edit({ type: 'set', id: fixedId, binding: null }, state.revision)).status).toBe('not-ready') + expect((await store.edit({ type: 'set', id, binding: { code: 'Escape', modifiers: ['control', 'alt'] } }, state.revision)).status).toBe('saved') + store.setDefinitions(definitions) + expect((await store.edit({ type: 'set', id, binding: { code: 'Escape', modifiers: [] } }, (await store.readCurrent()).revision)).status).toBe('saved') +}) + +it('rejects malformed fixed reservations at catalog IPC ingress', () => { + for (const fixed of [[], null, [null], [{ code: 'MetaLeft', modifiers: [] }], [{ code: 'Escape', modifiers: [], extra: true }]]) { + expect(() => parseShortcutDefinitions([{ id, defaults: {}, fixed }])).toThrow() + } + expect(() => parseShortcutDefinitions([{ id, defaults: { + 'desktop:macos': pair, + 'desktop:windows': pair, + 'desktop:linux': pair, + }, fixed: [{ code: 'Escape', modifiers: [] }] }])).toThrow() +}) diff --git a/packages/client/shortcuts/tests/dom.client.spec.ts b/packages/client/shortcuts/tests/dom.client.spec.ts new file mode 100644 index 0000000000..e8c93c6e61 --- /dev/null +++ b/packages/client/shortcuts/tests/dom.client.spec.ts @@ -0,0 +1,201 @@ +// @vitest-environment jsdom +import { afterEach, expect, it, vi } from 'vitest' +import { fireEvent } from '@testing-library/dom' +import { installKeyboard, detectEnvironment } from '../src/client/dom.ts' +import { ShortcutRegistry } from '../src/client/registry.ts' +import type { ShortcutFixedInput } from '../src/client/types.ts' +import type { ShortcutCommandId } from '../src/protocol.ts' + +let dispose: (() => void) | undefined +afterEach(() => { dispose?.(); document.body.replaceChildren(); vi.restoreAllMocks() }) + +function mount(runtime: 'desktop' | 'web' = 'web', platform: 'macos' | 'windows' = 'windows') { + const registry = new ShortcutRegistry(runtime, platform) + const run = vi.fn() + registry.register({ id: 'shortcuts.open' as ShortcutCommandId, label: () => 'Shortcuts', aliases: [], + defaults: { + 'desktop:macos': { code: 'Slash', modifiers: ['primary'] }, + 'desktop:windows': { code: 'Slash', modifiers: ['primary'] }, + 'desktop:linux': { code: 'Slash', modifiers: ['primary'] }, + 'web:macos': { code: 'Slash', modifiers: ['primary'] }, + 'web:windows': { code: 'Slash', modifiers: ['primary'] }, + 'web:linux': { code: 'Slash', modifiers: ['primary'] }, + }, + regions: ['page', 'editable'], modals: ['shortcuts'], resolve: () => ({ status: 'handled', run }) }) + dispose = installKeyboard(window, registry) + const input = document.createElement('input'); document.body.append(input); input.focus() + return { registry, input, run } +} +const press = (target: EventTarget, extra: KeyboardEventInit = {}) => { + const event = new KeyboardEvent('keydown', { key: '/', code: 'Slash', ctrlKey: true, bubbles: true, cancelable: true, ...extra }) + target.dispatchEvent(event) + return event +} + +it.each(['macos', 'windows'] as const)('leaves configurable %s shortcuts to native input while retaining DOM fixed-action observation', (platform) => { + const { registry, input, run } = mount('desktop', platform) + dispose?.() + const fixed = vi.fn<(input: ShortcutFixedInput) => void>() + dispose = installKeyboard(window, registry, fixed, true) + expect(press(input, { ctrlKey: platform === 'windows', metaKey: platform === 'macos' }).defaultPrevented).toBe(false) + registry.configure({ ...registry.config.getSnapshot(), revision: 'pair' as never, + document: { schemaVersion: 2, profiles: { [`desktop:${platform}`]: { + 'shortcuts.open': { code: 'KeyF', secondCode: 'KeyG', modifiers: [] }, + } } } }) + press(input, { code: 'KeyF', key: 'f', ctrlKey: false }) + press(input, { code: 'KeyG', key: 'g', ctrlKey: false }) + expect(run).not.toHaveBeenCalled() + expect(fixed.mock.lastCall?.[0]).toMatchObject({ type: 'keydown', gesture: { code: 'KeyG' } }) +}) + +const optionCommandN = { key: 'Dead', code: 'KeyN', ctrlKey: false, altKey: true, metaKey: true } as const +const commandSlash = { ctrlKey: false, metaKey: true } as const +function mountMacNewSession() { + const fixture = mount('web', 'macos') + const newSession = vi.fn() + const unregister = fixture.registry.register({ id: 'session.new' as ShortcutCommandId, label: () => 'New session', aliases: [], + defaults: { + 'desktop:macos': { code: 'KeyN', modifiers: ['primary'] }, + 'desktop:windows': { code: 'KeyN', modifiers: ['primary'] }, + 'desktop:linux': { code: 'KeyN', modifiers: ['primary'] }, + 'web:macos': { code: 'KeyN', modifiers: ['primary', 'alt'] }, + }, regions: ['page', 'editable'], modals: [], + resolve: () => ({ status: 'handled', run: newSession }) }) + return { ...fixture, newSession, unregister } +} + +it('consumes bound macOS Web Option+Command+N without leaving dead-key state on repeats or subsequent commands', () => { + const { input, newSession, run } = mountMacNewSession() + expect(press(input, optionCommandN).defaultPrevented).toBe(true) + expect(press(input, { ...optionCommandN, repeat: true }).defaultPrevented).toBe(true) + expect(newSession).toHaveBeenCalledOnce() + expect(press(input, commandSlash).defaultPrevented).toBe(true) + expect(run).toHaveBeenCalledOnce() + expect(press(input, optionCommandN).defaultPrevented).toBe(true) + expect(newSession).toHaveBeenCalledTimes(2) +}) + +it('preserves an unbound Dead key and the following input', () => { + const { input, unregister, newSession, run } = mountMacNewSession() + unregister() + expect(press(input, optionCommandN).defaultPrevented).toBe(false) + expect(press(input, commandSlash).defaultPrevented).toBe(false) + expect(press(input, commandSlash).defaultPrevented).toBe(true) + expect(newSession).not.toHaveBeenCalled() + expect(run).toHaveBeenCalledOnce() +}) + +it('keeps real composition, composition-ending input, legacy IME, AltGraph and accent input guarded for Option+Command+N', () => { + const { input, newSession } = mountMacNewSession() + fireEvent.compositionStart(input) + expect(press(input, optionCommandN).defaultPrevented).toBe(false) + fireEvent.compositionEnd(input) + expect(press(input, optionCommandN).defaultPrevented).toBe(false) + for (const extra of [ + { isComposing: true }, + { keyCode: 229 }, + { metaKey: false }, { code: 'KeyE' }, { ctrlKey: true }, { shiftKey: true }, + ]) { + fireEvent.blur(window) + expect(press(input, { ...optionCommandN, ...extra }).defaultPrevented).toBe(false) + } + fireEvent.blur(window) + const altGraph = new KeyboardEvent('keydown', { ...optionCommandN, bubbles: true, cancelable: true }) + vi.spyOn(altGraph, 'getModifierState').mockReturnValue(true) + input.dispatchEvent(altGraph) + expect(altGraph.defaultPrevented).toBe(false) + fireEvent.blur(window) + expect(press(input, { ...optionCommandN, metaKey: false }).defaultPrevented).toBe(false) + expect(press(input, optionCommandN).defaultPrevented).toBe(false) + expect(newSession).not.toHaveBeenCalled() +}) + +it.each([['desktop', 'macos'], ['web', 'windows']] as const)('keeps Dead key protection in %s on %s', (runtime, platform) => { + const { input, run } = mount(runtime, platform) + const keys = { metaKey: platform === 'macos', ctrlKey: platform === 'windows' } + expect(press(input, { ...keys, key: 'Dead' }).defaultPrevented).toBe(false) + expect(press(input, keys).defaultPrevented).toBe(false) + expect(run).not.toHaveBeenCalled() + expect(press(input, keys).defaultPrevented).toBe(true) + expect(run).toHaveBeenCalledOnce() +}) + +it('detects the client OS and separates Web from the preload marker', () => { + expect(detectEnvironment(document, { platform: 'MacIntel' } as Navigator)).toEqual({ runtime: 'web', platform: 'macos' }) + expect(detectEnvironment(document, { platform: 'Win32' } as Navigator)).toEqual({ runtime: 'web', platform: 'windows' }) + const html = document.documentElement + const previous = html.dataset.platform + try { + html.dataset.platform = 'linux' + expect(detectEnvironment(document, navigator)).toEqual({ runtime: 'desktop', platform: 'linux' }) + } finally { + if (previous === undefined) delete html.dataset.platform + else html.dataset.platform = previous + } +}) + +it('passes browser navigation/editing and local consumption; disposes the listener', () => { + const { input, run } = mount() + for (const key of ['N', 'P', 'O', 'R', 'W', 'C', 'V', 'Z']) expect(press(input, { code: `Key${key}` }).defaultPrevented).toBe(false) + const local = (event: Event) => { event.preventDefault() } + input.addEventListener('keydown', local) + expect(press(input).defaultPrevented).toBe(true) + expect(run).not.toHaveBeenCalled() + input.removeEventListener('keydown', local) + expect(press(input).defaultPrevented).toBe(true) + expect(press(input, { repeat: true }).defaultPrevented).toBe(true) + expect(run).toHaveBeenCalledOnce() + dispose?.() + expect(press(input).defaultPrevented).toBe(false) +}) + +it('preserves composition, late closing keys, 229, AltGraph, and dead-key input', () => { + const { input, run } = mount() + fireEvent.compositionStart(input) + expect(press(input).defaultPrevented).toBe(false) + fireEvent.compositionEnd(input) + expect(press(input).defaultPrevented).toBe(false) + expect(press(input, { isComposing: true }).defaultPrevented).toBe(false) + // oxlint-disable-next-line typescript/no-deprecated -- Exercise legacy IME input. + expect(press(input, { keyCode: 229 }).defaultPrevented).toBe(false) + expect(press(input, { key: 'Dead' }).defaultPrevented).toBe(false) + const event = new KeyboardEvent('keydown', { code: 'Slash', ctrlKey: true, bubbles: true, cancelable: true }) + vi.spyOn(event, 'getModifierState').mockReturnValue(true) + input.dispatchEvent(event) + expect(event.defaultPrevented).toBe(false) + expect(run).not.toHaveBeenCalled() + fireEvent.keyUp(input) + expect(press(input).defaultPrevented).toBe(true) +}) + +it('blocks background commands during dialogs and gives terminal input ownership', () => { + const { input, run } = mount() + const dialog = document.createElement('div'); dialog.setAttribute('role', 'dialog'); dialog.setAttribute('aria-modal', 'true') + document.body.append(dialog) + expect(press(input).defaultPrevented).toBe(true) + expect(run).not.toHaveBeenCalled() + dialog.dataset.shortcutModal = 'shortcuts' + expect(press(input).defaultPrevented).toBe(true) + expect(run).toHaveBeenCalledOnce() + const terminal = document.createElement('div'); terminal.className = 'xterm'; terminal.append(input); document.body.append(terminal) + expect(press(input).defaultPrevented).toBe(false) + fireEvent.blur(window) +}) + +it('routes body focus and window-delivered input through the same application command', () => { + const { input, run } = mount() + input.remove() + expect(press(document.body).defaultPrevented).toBe(true) + expect(press(window).defaultPrevented).toBe(true) + expect(run).toHaveBeenCalledTimes(2) +}) + +it('passes the original input element to a feature target resolver', () => { + const { registry, input } = mount() + const dispatch = vi.spyOn(registry, 'dispatch') + press(input) + expect(dispatch).toHaveBeenLastCalledWith(expect.anything(), { region: 'editable', modal: null, target: input }, expect.any(Function)) + input.remove() + press(window) + expect(dispatch).toHaveBeenLastCalledWith(expect.anything(), { region: 'page', modal: null, target: document.body }, expect.any(Function)) +}) diff --git a/packages/client/shortcuts/tests/fixed-input.client.spec.ts b/packages/client/shortcuts/tests/fixed-input.client.spec.ts new file mode 100644 index 0000000000..07b2b93692 --- /dev/null +++ b/packages/client/shortcuts/tests/fixed-input.client.spec.ts @@ -0,0 +1,150 @@ +// @vitest-environment jsdom +import { afterEach, expect, it, vi } from 'vitest' +import { fireEvent } from '@testing-library/dom' +import { Context } from '@deepseek-ai/cordis' +import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' +import { installKeyboard } from '../src/client/dom.ts' +import ShortcutsService from '../src/client/index.ts' +import { ShortcutRegistry } from '../src/client/registry.ts' +import type { ShortcutFixedCommand, ShortcutFixedInput } from '../src/client/types.ts' +import type { ShortcutCommandId } from '../src/protocol.ts' + +let dispose: (() => void) | undefined +afterEach(() => { dispose?.(); document.body.replaceChildren(); vi.restoreAllMocks(); vi.useRealTimers(); localStorage.clear() }) +const press = (target: EventTarget, extra: KeyboardEventInit = {}) => { + const event = new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', bubbles: true, cancelable: true, ...extra }) + target.dispatchEvent(event) + return event +} +function mount() { + const dispatch = vi.fn(() => ({ status: 'pass' as const })) + const fixed = vi.fn<(input: ShortcutFixedInput) => void>() + const input = document.createElement('input') + document.body.append(input) + input.focus() + dispose = installKeyboard(window, { dispatch, runtime: 'web', platform: 'windows' }, fixed) + return { input, dispatch, fixed } +} + +it('runs fixed handlers after local ownership and preserves their consumption for application dispatch', () => { + const { input, dispatch, fixed } = mount() + input.addEventListener('keydown', (event) => { event.preventDefault() }, { once: true }) + press(input) + expect(fixed.mock.lastCall?.[0]).toMatchObject({ type: 'keydown', gesture: { defaultPrevented: true } }) + fixed.mockImplementation((event) => { if (event.type === 'keydown') event.consume() }) + expect(press(input).defaultPrevented).toBe(true) + expect(dispatch).toHaveBeenLastCalledWith(expect.objectContaining({ defaultPrevented: true }), expect.anything(), expect.any(Function)) + fireEvent.compositionStart(input) + press(input) + expect(fixed.mock.lastCall?.[0]).toMatchObject({ type: 'keydown', gesture: { composing: true } }) + fireEvent.compositionEnd(input) + press(input) + expect(fixed.mock.lastCall?.[0]).toMatchObject({ type: 'keydown', gesture: { composing: true } }) +}) + +it('resets sequences when local controls stop propagation, including two inputs in the same task', async () => { + vi.useFakeTimers() + const { input, fixed } = mount() + input.addEventListener('keydown', (event) => { event.stopPropagation() }, { once: true }) + press(input) + expect(fixed).not.toHaveBeenCalled() + press(input) + expect(fixed.mock.calls.map(([event]) => event.type)).toEqual(['reset', 'keydown']) + input.addEventListener('keydown', (event) => { event.stopPropagation() }, { once: true }) + press(input) + await vi.runOnlyPendingTimersAsync() + expect(fixed).toHaveBeenLastCalledWith({ type: 'reset' }) +}) + +it('resets on focus, pointer, blur, and modal transitions even when focus stays unchanged', async () => { + const { input, fixed } = mount() + fireEvent.focusIn(input); fireEvent.pointerDown(input); fireEvent.blur(window) + expect(fixed.mock.calls.map(([event]) => event.type)).toEqual(['reset', 'reset', 'reset']) + const dialog = document.createElement('div') + dialog.setAttribute('role', 'dialog'); dialog.setAttribute('aria-modal', 'true') + const wrapper = document.createElement('div'); wrapper.append(dialog); document.body.append(wrapper) + await Promise.resolve() + expect(fixed).toHaveBeenCalledTimes(4) + dialog.setAttribute('aria-modal', 'false') + await Promise.resolve() + expect(fixed).toHaveBeenCalledTimes(5) + dialog.setAttribute('aria-modal', 'true') + press(input) + expect(fixed.mock.calls.slice(-2).map(([event]) => event.type)).toEqual(['reset', 'keydown']) + wrapper.remove() + await Promise.resolve() + expect(fixed).toHaveBeenLastCalledWith({ type: 'reset' }) + fixed.mockClear() + document.body.append(document.createTextNode('stream')) + await Promise.resolve() + expect(fixed).not.toHaveBeenCalled() +}) + +it('releases listeners, modal observation, and pending local-input resets on disposal', async () => { + vi.useFakeTimers() + const { input, fixed } = mount() + input.addEventListener('keydown', (event) => { event.stopPropagation() }, { once: true }) + press(input) + dispose?.() + fireEvent.focusIn(input); fireEvent.pointerDown(input); fireEvent.blur(window); press(input) + const dialog = document.createElement('div'); dialog.setAttribute('role', 'dialog'); dialog.setAttribute('aria-modal', 'true') + document.body.append(dialog) + await vi.runOnlyPendingTimersAsync() + expect(fixed).not.toHaveBeenCalled() +}) + +it('publishes fixed reservations and removes them with their owning registration', () => { + const registry = new ShortcutRegistry('web', 'windows') + let label = 'Stop reply' + const command: ShortcutFixedCommand = { id: 'response.stop' as ShortcutCommandId, label: () => label, keys: ['Esc', 'Esc'], bindings: [{ code: 'Escape', modifiers: [] }], group: 'input' } + const off = registry.registerFixed(command) + expect(registry.fixedCatalog.getSnapshot()).toEqual([{ id: 'response.stop', label, keys: ['Esc', 'Esc'], bindings: [{ code: 'Escape', modifiers: [] }], group: 'input' }]) + expect(registry.definitions()).toEqual([{ id: 'response.stop', defaults: {}, fixed: [{ code: 'Escape', modifiers: [] }] }]) + expect(() => registry.registerFixed(command)).toThrow('Duplicate') + expect(() => registry.register({ ...command, aliases: [], defaults: {}, regions: [], modals: [], resolve: () => ({ status: 'pass' }) })).toThrow('Duplicate') + label = '停止回复'; registry.refreshLabels() + expect(registry.fixedCatalog.getSnapshot()[0]?.label).toBe(label) + off(); off() + expect(registry.fixedCatalog.getSnapshot()).toEqual([]) + expect(registry.definitions()).toEqual([]) +}) + +it('contains observer failures and makes prior consumption visible to later observers', async () => { + const ctx = new Context() + ctx.provide('locale', new LocaleRuntime(ctx)) + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + const service = new ShortcutsService(ctx) + const offFixed = service.registerFixed({ id: 'response.stop' as ShortcutCommandId, label: () => 'Stop', keys: ['Esc', 'Esc'], bindings: [{ code: 'Escape', modifiers: [] }], group: 'input' }) + expect(service.stopSequenceMs).toBe(500) + expect(service.fixedCatalog.getSnapshot()).toHaveLength(1) + service.observeFixedInput(() => { throw new Error('owner failure') }) + const off = service.observeFixedInput((event) => { if (event.type === 'keydown') event.consume() }) + const last = vi.fn<(input: ShortcutFixedInput) => void>() + service.observeFixedInput(last) + expect(press(document.body).defaultPrevented).toBe(true) + expect(last.mock.lastCall?.[0]).toMatchObject({ gesture: { defaultPrevented: true } }) + expect(error).toHaveBeenCalled() + off(); offFixed() + expect(press(document.body).defaultPrevented).toBe(false) + await ctx.fiber.dispose() + last.mockClear(); press(document.body) + expect(last).not.toHaveBeenCalled() + } finally { await ctx.fiber.dispose() } +}) + +it('delivers resets and skips an observer removed earlier in the same dispatch', async () => { + const ctx = new Context() + ctx.provide('locale', new LocaleRuntime(ctx)) + try { + const service = new ShortcutsService(ctx) + let off = (): void => {} + const first = vi.fn(() => { off() }) + service.observeFixedInput(first) + const removed = vi.fn() + off = service.observeFixedInput(removed) + fireEvent.focusIn(document.body) + expect(first).toHaveBeenLastCalledWith({ type: 'reset' }) + expect(removed).not.toHaveBeenCalled() + } finally { await ctx.fiber.dispose() } +}) diff --git a/packages/client/shortcuts/tests/native.client.spec.ts b/packages/client/shortcuts/tests/native.client.spec.ts new file mode 100644 index 0000000000..03bd2d378a --- /dev/null +++ b/packages/client/shortcuts/tests/native.client.spec.ts @@ -0,0 +1,167 @@ +// @vitest-environment jsdom +/** Trusted native inputs preserve current configuration, frame identity, and modal arbitration. */ +import { afterEach, expect, it, vi } from 'vitest' +import { installNativeKeyboard } from '../src/client/native.ts' +import { ShortcutRegistry } from '../src/client/registry.ts' +import type { DesktopShortcutInput, ShortcutCommandId } from '../src/protocol.ts' + +afterEach(() => { document.body.replaceChildren() }) + +it.each(['macos', 'windows'] as const)('prioritizes %s custom native bindings across editable and terminal regions and modals with revision checks', (platform) => { + const registry = new ShortcutRegistry('desktop', platform) + const run = vi.fn() + registry.register({ id: 'session.new' as ShortcutCommandId, label: () => 'New', aliases: [], + defaults: { + 'desktop:macos': { code: 'KeyN', modifiers: ['primary'] }, + 'desktop:windows': { code: 'KeyN', modifiers: ['primary'] }, + 'desktop:linux': { code: 'KeyN', modifiers: ['primary'] }, + }, regions: ['page', 'editable'], modals: [], + resolve: () => ({ status: 'handled', run }) }) + registry.configure({ ...registry.config.getSnapshot(), revision: 'custom' as never, + document: { schemaVersion: 1, profiles: { [`desktop:${platform}`]: { 'session.new': { code: 'KeyC', modifiers: ['primary'] } } } } }) + let deliver: (input: DesktopShortcutInput) => void = () => {} + const reset = vi.fn() + const off = installNativeKeyboard(window, { subscribe: (listener) => { deliver = listener; return () => {} }, closeWindow: vi.fn() }, + registry, () => registry.config.getSnapshot(), reset) + const input: DesktopShortcutInput = { kind: 'keyboard', frameName: '', revision: registry.config.getSnapshot().revision, + code: 'KeyC', control: platform === 'windows', alt: false, shift: false, meta: platform === 'macos', repeat: false } + const target = document.createElement('textarea') + const terminal = document.createElement('div') + terminal.className = 'xterm'; terminal.append(target); document.body.append(terminal) + target.focus() + deliver(input) + expect(run).toHaveBeenCalledTimes(1) + expect(reset).toHaveBeenCalledTimes(1) + deliver({ ...input, repeat: true }) + deliver({ ...input, revision: 'old' as typeof input.revision }) + expect(run).toHaveBeenCalledTimes(1) + expect(reset).toHaveBeenCalledTimes(2) + terminal.className = '' + deliver(input) + expect(run).toHaveBeenCalledTimes(2) + terminal.setAttribute('role', 'dialog'); terminal.setAttribute('aria-modal', 'true') + deliver(input) + expect(run).toHaveBeenCalledTimes(3) + terminal.remove() + deliver(input) + expect(run).toHaveBeenCalledTimes(4) + off() +}) + +it('rejects old configuration and iframe identities, consumes repeats, and invokes unbound menu actions', () => { + const registry = new ShortcutRegistry('desktop', 'macos') + const run = vi.fn() + const offCommand = registry.register({ id: 'page.close' as ShortcutCommandId, label: () => 'Close', aliases: [], + defaults: { + 'desktop:macos': { code: 'KeyW', modifiers: ['primary'] }, + 'desktop:windows': { code: 'KeyW', modifiers: ['primary'] }, + 'desktop:linux': { code: 'KeyW', modifiers: ['primary'] }, + }, regions: ['page'], modals: [], + resolve: () => ({ status: 'handled', run }) }) + let deliver: (input: DesktopShortcutInput) => void = () => {} + const release = vi.fn() + const off = installNativeKeyboard(window, { + subscribe: (listener) => { deliver = listener; return release }, closeWindow: vi.fn() }, registry, + () => registry.config.getSnapshot()) + const revision = registry.config.getSnapshot().revision + const input = { kind: 'iframe' as const, revision, frameName: 'current', code: 'KeyW', meta: true, + control: false, alt: false, shift: false, repeat: false } + deliver(input) + const frame = document.createElement('iframe') + document.body.append(frame) + frame.focus() + deliver(input) + expect(run).not.toHaveBeenCalled() + frame.name = 'current' + deliver(input) + expect(run).not.toHaveBeenCalled() + frame.setAttribute('data-html-preview', '') + deliver({ ...input, revision: 'old' as typeof revision }) + deliver({ ...input, repeat: true }) + expect(run).not.toHaveBeenCalled() + deliver(input) + expect(run).toHaveBeenCalledTimes(1) + const modal = document.createElement('div') + modal.setAttribute('role', 'dialog'); modal.setAttribute('aria-modal', 'true') + document.body.append(modal) + deliver(input) + deliver({ kind: 'menu', revision, commandId: 'page.close' as ShortcutCommandId }) + expect(run).toHaveBeenCalledTimes(2) + modal.remove() + registry.configure({ ...registry.config.getSnapshot(), revision: 'unbound' as typeof revision, + document: { schemaVersion: 1, profiles: { 'desktop:macos': { 'page.close': null } } } }) + deliver({ ...input, revision: 'unbound' as typeof revision }) + expect(run).toHaveBeenCalledTimes(2) + deliver({ kind: 'menu', revision: 'unbound' as typeof revision, commandId: 'page.close' as ShortcutCommandId }) + expect(run).toHaveBeenCalledTimes(3) + offCommand() + deliver({ kind: 'menu', revision: 'unbound' as typeof revision, commandId: 'page.close' as ShortcutCommandId }) + expect(run).toHaveBeenCalledTimes(3) + off() + expect(release).toHaveBeenCalledTimes(1) +}) + +it.each(['macos', 'windows'] as const)('runs a custom %s chord across text fields and verified embedded pages, once per press', (platform) => { + const registry = new ShortcutRegistry('desktop', platform) + const run = vi.fn() + registry.register({ id: 'session.new' as ShortcutCommandId, label: () => 'New', aliases: [], defaults: {}, + regions: ['page'], modals: [], resolve: () => ({ status: 'handled', run }) }) + registry.configure({ ...registry.config.getSnapshot(), revision: 'chord' as never, + document: { schemaVersion: 2, profiles: { [`desktop:${platform}`]: { 'session.new': { code: 'KeyA', secondCode: 'KeyB', modifiers: [] } } } } }) + let deliver: (input: DesktopShortcutInput) => void = () => {} + const off = installNativeKeyboard(window, { subscribe: (listener) => { deliver = listener; return () => {} }, closeWindow: vi.fn() }, + registry, () => registry.config.getSnapshot()) + try { + const field = document.createElement('textarea'); document.body.append(field); field.focus() + const input: DesktopShortcutInput = { kind: 'keyboard', frameName: '', revision: registry.config.getSnapshot().revision, + code: 'KeyA', secondCode: 'KeyB', control: false, alt: false, shift: false, meta: false, repeat: false } + deliver(input); deliver({ ...input, repeat: true }) + expect(run).toHaveBeenCalledOnce() + const frame = document.createElement('iframe'); frame.name = 'browser'; frame.setAttribute('data-sidebar-browser-frame', '') + document.body.append(frame); frame.focus() + deliver({ ...input, kind: 'iframe', frameName: 'browser' }) + expect(run).toHaveBeenCalledTimes(2) + deliver({ ...input, kind: 'iframe', frameName: 'old' }) + expect(run).toHaveBeenCalledTimes(2) + } finally { off() } +}) + +it('checks the focused browser guest lease before dispatch and retains its embedding context across shadow focus', () => { + const registry = new ShortcutRegistry('desktop', 'macos') + const run = vi.fn() + const resolve = vi.fn(() => ({ status: 'handled' as const, run })) + registry.register({ id: 'browser.new' as ShortcutCommandId, label: () => 'Browser', aliases: [], + defaults: { 'desktop:macos': { code: 'KeyT', modifiers: ['primary'] } }, regions: ['page'], modals: [], resolve }) + let deliver: (input: DesktopShortcutInput) => void = () => {} + const off = installNativeKeyboard(window, { subscribe: (listener) => { deliver = listener; return () => {} }, closeWindow: vi.fn() }, + registry, () => registry.config.getSnapshot()) + const input: DesktopShortcutInput = { kind: 'webview', frameName: 'guest', revision: registry.config.getSnapshot().revision, + code: 'KeyT', control: false, alt: false, shift: false, meta: true, repeat: false } + const frame = document.createElement('webview') + frame.tabIndex = 0 + frame.setAttribute('name', 'guest') + document.body.append(frame) + frame.focus() + const shadow = document.createElement('div').attachShadow({ mode: 'open' }) + Object.defineProperty(shadow, 'activeElement', { value: document.createElement('input') }) + const shadowRoot = vi.spyOn(frame, 'shadowRoot', 'get').mockReturnValue(shadow) + try { + deliver(input) + expect(run).not.toHaveBeenCalled() + frame.setAttribute('data-sidebar-browser-frame', 'webview') + deliver({ ...input, frameName: '' }) + deliver({ ...input, frameName: 'old' }) + deliver({ ...input, revision: 'old' as typeof input.revision }) + expect(run).not.toHaveBeenCalled() + deliver(input) + expect(run).toHaveBeenCalledOnce() + expect(resolve).toHaveBeenCalledWith(expect.objectContaining({ source: 'webview', target: frame })) + deliver({ ...input, repeat: true }) + frame.remove() + deliver(input) + expect(run).toHaveBeenCalledOnce() + } finally { + shadowRoot.mockRestore() + off() + } +}) diff --git a/packages/client/shortcuts/tests/persistence.client.spec.ts b/packages/client/shortcuts/tests/persistence.client.spec.ts new file mode 100644 index 0000000000..41698ba551 --- /dev/null +++ b/packages/client/shortcuts/tests/persistence.client.spec.ts @@ -0,0 +1,144 @@ +import { describe, expect, it, vi } from 'vitest' +import { ShortcutPersistence } from '../src/protocol.ts' +import type { ShortcutCommandId, ShortcutConfigSnapshot, ShortcutDefinition, ShortcutEdit } from '../src/protocol.ts' + +const one = 'test.one' as ShortcutCommandId +it('rejects an unsupported Web binding without writing preferences', async () => { + const storage = { read: () => null, write: vi.fn() } + const store = new ShortcutPersistence(storage, 'web', 'linux', false, () => {}) + store.setDefinitions([{ id: one, defaults: {} }]) + const snapshot = await store.readCurrent() + expect(await store.edit({ type: 'set', id: one, binding: { code: 'KeyB', modifiers: ['control'] } }, snapshot.revision)) + .toMatchObject({ status: 'conflict', issue: 'unsupported-browser', conflicts: [] }) + expect(storage.write).not.toHaveBeenCalled() +}) +const two = 'test.two' as ShortcutCommandId +const binding = { code: 'KeyJ', modifiers: ['primary'] as const } +const definitions: readonly ShortcutDefinition[] = [{ id: one, defaults: { + 'desktop:macos': { ...binding, code: 'KeyB' }, + 'desktop:windows': { ...binding, code: 'KeyB' }, + 'desktop:linux': { ...binding, code: 'KeyB' }, +} }, +{ id: two, defaults: { + 'desktop:macos': { ...binding, code: 'Comma' }, + 'desktop:windows': { ...binding, code: 'Comma' }, + 'desktop:linux': { ...binding, code: 'Comma' }, +} }] +const edit: ShortcutEdit = { type: 'set', id: one, binding } + +function fixture(reread = false) { + let raw: string | null = null + let state!: ShortcutConfigSnapshot + const publish = vi.fn((snapshot: ShortcutConfigSnapshot) => { state = snapshot }) + const storage = { read: vi.fn(async () => raw), + write: vi.fn(async (next: string) => { raw = next }) } + const store = new ShortcutPersistence(storage, 'desktop', 'windows', reread, publish) + store.setDefinitions(definitions) + return { store, storage, publish, state: () => state, raw: () => raw, external: (value: string) => { raw = value } } +} + +describe('serialized preference persistence', () => { + it('publishes a save once and only after durable completion; rejects the competing old revision', async () => { + const f = fixture() + const initial = await f.store.readCurrent() + let release!: () => void + let started!: () => void + const writing = new Promise((resolve) => { started = resolve }) + const barrier = new Promise((resolve) => { release = resolve }) + f.storage.write.mockImplementationOnce(async () => { started(); await barrier }) + const first = f.store.edit(edit, initial.revision) + const second = f.store.edit({ type: 'reset-all' }, initial.revision) + await writing + f.publish.mockClear() + expect(f.state()).toBe(initial) + release() + expect((await first).status).toBe('saved') + expect((await second).status).toBe('stale') + expect(f.publish).toHaveBeenCalledOnce() + expect(f.state().document.profiles['desktop:windows']?.[one]).toEqual(binding) + }) + + it('keeps the previous configuration and revision on write failure, allowing the same draft to retry', async () => { + const f = fixture(); const initial = await f.store.readCurrent() + f.storage.write.mockRejectedValueOnce(new Error('disk full')) + expect((await f.store.edit(edit, initial.revision)).status).toBe('write-failed') + expect(f.state()).toBe(initial) + expect(f.raw()).toBeNull() + expect((await f.store.edit(edit, initial.revision)).status).toBe('saved') + const stored: unknown = JSON.parse(f.raw()!) + expect(stored).toMatchObject({ profiles: { 'desktop:windows': { [one]: binding } } }) + const restarted = fixture(); restarted.external(f.raw()!) + expect((await restarted.store.readCurrent()).document).toEqual(f.state().document) + }) + + it.each(['{broken', '{"schemaVersion":3,"profiles":{}}'])('retains accepted bindings and original bytes after reading %s', async (raw) => { + const f = fixture(); await f.store.readCurrent(); await f.store.edit(edit, f.state().revision) + const accepted = f.state().document + f.external(raw) + const bad = await f.store.readCurrent() + expect(bad.status).toBe('unreadable'); expect(bad.document).toBe(accepted) + for (const operation of [edit, { type: 'reset-all' }, { type: 'reset', id: one }] as ShortcutEdit[]) { + expect((await f.store.edit(operation, bad.revision)).status).toBe('unreadable') + } + expect(f.raw()).toBe(raw) + + }) + + it('uses defaults on the first bad read and prevents writes until the source becomes readable', async () => { + const f = fixture(); f.storage.read.mockRejectedValueOnce(new Error('denied')) + expect((await f.store.readCurrent()).usingDefaults).toBe(true) + f.storage.read.mockRejectedValueOnce(new Error('still denied')) + expect((await f.store.readCurrent()).error).toBe('read') + expect((await f.store.edit({ type: 'reset-all' }, f.state().revision)).status).toBe('unreadable') + expect(f.storage.write).not.toHaveBeenCalled() + f.external('{'); expect((await f.store.readCurrent()).error).toBe('invalid') + f.external('{"schemaVersion":1,"profiles":{}}'); expect((await f.store.readCurrent()).status).toBe('ready') + }) + + it('rereads before a browser write and requires an explicit review of external updates', async () => { + const f = fixture(true); const initial = await f.store.readCurrent() + f.external('{"schemaVersion":1,"profiles":{"web:macos":{"sleeping.command":null}}}') + expect((await f.store.edit(edit, initial.revision)).status).toBe('stale') + expect(f.storage.write).not.toHaveBeenCalled() + expect((await f.store.edit(edit, f.state().revision)).status).toBe('saved') + expect(f.state().document.profiles['web:macos']).toEqual({ 'sleeping.command': null }) + }) + + it('accepts Windows single keys and editing combinations but rejects explicit/default conflicts', async () => { + const f = fixture(); await f.store.readCurrent() + expect((await f.store.edit({ ...edit, binding: { code: 'KeyC', modifiers: ['control'] } }, f.state().revision)).status).toBe('saved') + expect((await f.store.edit({ ...edit, binding: { code: 'KeyC', modifiers: [] } }, f.state().revision)).status).toBe('saved') + expect((await f.store.edit({ ...edit, binding: { ...binding, code: 'Comma' } }, f.state().revision)).status).toBe('conflict') + await f.store.edit(edit, f.state().revision) + expect((await f.store.edit({ ...edit, id: two }, f.state().revision)).conflicts).toEqual([one]) + expect((await f.store.edit({ type: 'set', id: two, binding: { ...binding, code: 'KeyB' } }, f.state().revision)).status).toBe('saved') + expect((await f.store.edit({ type: 'reset', id: one }, f.state().revision)).status).toBe('conflict') + expect((await f.store.edit({ type: 'reset-all' }, f.state().revision)).status).toBe('saved') + }) + + it('rejects early/unknown/stale-owner edits and does not publish a late read after disposal', async () => { + const f = fixture() + expect((await f.store.edit(edit, f.state().revision)).status).toBe('not-ready') + await f.store.readCurrent() + expect((await f.store.edit({ ...edit, id: 'missing.command' as ShortcutCommandId }, f.state().revision)).status).toBe('not-ready') + f.store.setDefinitions(null) + expect((await f.store.edit(edit, f.state().revision)).status).toBe('not-ready') + f.store.dispose(); f.publish.mockClear(); f.external('{') + await f.store.readCurrent() + expect(f.publish).not.toHaveBeenCalled() + }) +}) + + +it('continues transactions after a rejected unsupported code and contains subscriber failures', async () => { + const f = fixture(); await f.store.readCurrent() + await expect(f.store.edit({ ...edit, binding: { ...binding, code: 'Unidentified' } }, f.state().revision)).rejects.toThrow('Unsupported') + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + f.publish.mockImplementationOnce(() => { throw new Error('subscriber failed') }) + const result = await f.store.edit(edit, f.state().revision) + expect(result.status).toBe('saved') + expect(error).toHaveBeenCalledWith('Shortcut configuration subscriber failed:', expect.any(Error)) + expect((await f.store.readCurrent()).document).toEqual(result.snapshot.document) + } finally { error.mockRestore() } +}) diff --git a/packages/client/shortcuts/tests/registry.client.spec.ts b/packages/client/shortcuts/tests/registry.client.spec.ts new file mode 100644 index 0000000000..b87f40c361 --- /dev/null +++ b/packages/client/shortcuts/tests/registry.client.spec.ts @@ -0,0 +1,205 @@ +import { describe, expect, it, vi } from 'vitest' +import { bindingKey, initialShortcutConfig, normalizeBinding, presentBinding } from '../src/protocol.ts' +import type { ShortcutCommandId } from '../src/protocol.ts' +import { ShortcutRegistry } from '../src/client/registry.ts' +import { isWebBindingAllowed } from '../src/binding.ts' +import type { ShortcutCommand, ShortcutGesture } from '../src/client/types.ts' + +const context = { region: 'page', modal: null, target: null } as const +it('admits modified Web combinations and refuses two-key chords', () => { + expect(isWebBindingAllowed({ code: 'KeyB', modifiers: ['control', 'alt', 'shift'] }, 'windows')).toBe(true) + expect(isWebBindingAllowed({ code: 'KeyB', secondCode: 'KeyJ', modifiers: [] }, 'macos')).toBe(false) +}) + +it('leaves Linux terminal control-W and control-R with the terminal while dispatching other bindings', () => { + for (const [code, modifiers, status] of [ + ['KeyW', ['control'], 'pass'], ['KeyR', ['control'], 'pass'], ['KeyB', ['control'], 'handled'], + ['KeyB', ['alt'], 'handled'], + ['KeyB', ['control', 'alt'], 'handled'], ['KeyB', ['control', 'shift'], 'handled'], + ] as const) { + const registry = new ShortcutRegistry('desktop', 'linux') + const run = vi.fn(), consume = vi.fn() + registry.register(command({ defaults: { 'desktop:linux': { code, modifiers } }, regions: ['terminal'], + resolve: () => ({ status: 'handled', run }) })) + expect(registry.dispatch({ ...gesture, code, control: modifiers.some(value => value === 'control'), + meta: false, alt: modifiers.some(value => value === 'alt'), + shift: modifiers.some(value => value === 'shift') }, { ...context, region: 'terminal' }, consume).status).toBe(status) + expect(run).toHaveBeenCalledTimes(status === 'handled' ? 1 : 0) + expect(consume).toHaveBeenCalledTimes(status === 'handled' ? 1 : 0) + } +}) + +it('dispatches a Windows Web terminal binding with Control, Meta, and Shift', () => { + const registry = new ShortcutRegistry('web', 'windows') + const run = vi.fn(), consume = vi.fn() + registry.register(command({ defaults: { 'web:windows': { code: 'KeyB', modifiers: ['control', 'meta', 'shift'] } }, + regions: ['terminal'], resolve: () => ({ status: 'handled', run }) })) + expect(registry.dispatch({ ...gesture, control: true, shift: true }, { ...context, region: 'terminal' }, consume).status).toBe('handled') + expect(run).toHaveBeenCalledOnce() + expect(consume).toHaveBeenCalledOnce() +}) + +it.each(['pass', 'blocked'] as const)('does not execute a menu command whose owner returns %s', (status) => { + const registry = new ShortcutRegistry('desktop', 'macos') + const resolve = vi.fn(() => status === 'pass' ? { status } : { status, reason: 'unavailable' }) + registry.register(command({ resolve })) + registry.invoke('test.toggle' as ShortcutCommandId, context) + expect(resolve).toHaveBeenCalledWith({ ...context, source: 'menu' }) +}) +const gesture: ShortcutGesture = { code: 'KeyB', control: false, alt: false, shift: false, meta: true, + repeat: false, composing: false, defaultPrevented: false } +function command(overrides: Partial = {}): ShortcutCommand { + return { id: 'test.toggle' as ShortcutCommandId, label: () => 'Toggle', aliases: ['toggle'], + defaults: { + 'desktop:macos': { code: 'KeyB', modifiers: ['primary'] }, + 'desktop:windows': { code: 'KeyB', modifiers: ['primary'] }, + 'desktop:linux': { code: 'KeyB', modifiers: ['primary'] }, + }, regions: ['page', 'editable'], modals: [], + resolve: () => ({ status: 'handled', run: () => {} }), ...overrides } +} + +describe('physical key registry', () => { + it('normalizes primary/control together on Windows and presents platform-specific keys', () => { + const binding = { code: 'KeyB', modifiers: ['primary', 'control', 'shift'] as const } + expect(bindingKey(normalizeBinding(binding, 'windows'))).toBe('control+shift+KeyB') + expect(presentBinding(normalizeBinding(binding, 'macos'), 'macos')).toEqual({ keys: ['⌃', '⇧', '⌘', 'B'], aria: 'Control+Shift+Meta+B' }) + expect(presentBinding(normalizeBinding({ code: 'Slash', modifiers: ['primary'] }, 'windows'), 'windows')) + .toEqual({ keys: ['Ctrl', '+', '/'], aria: 'Control+/' }) + expect(presentBinding(null, 'linux')).toEqual({ keys: [], aria: undefined }) + expect(() => normalizeBinding({ code: 'Unidentified', modifiers: [] }, 'macos')).toThrow('Unsupported') + }) + + it('rejects duplicate ids, cross-platform default conflicts, and unverified Web combinations atomically', () => { + const registry = new ShortcutRegistry('desktop', 'macos') + const off = registry.register(command()) + expect(() => registry.register(command())).toThrow('Duplicate') + expect(() => registry.register(command({ id: 'other.toggle' as ShortcutCommandId, + defaults: { + 'desktop:macos': { code: 'KeyB', modifiers: ['control'] }, + 'desktop:windows': { code: 'KeyB', modifiers: ['control'] }, + 'desktop:linux': { code: 'KeyB', modifiers: ['control'] }, + } }))).toThrow('windows') + expect(() => registry.register(command({ id: 'web.new' as ShortcutCommandId, + defaults: { + 'web:macos': { code: 'KeyN', modifiers: ['primary'] }, + 'web:windows': { code: 'KeyN', modifiers: ['primary'] }, + 'web:linux': { code: 'KeyN', modifiers: ['primary'] }, + } }))).toThrow('Unsupported Web') + expect(() => registry.register(command({ id: 'system.copy' as ShortcutCommandId, + defaults: { + 'desktop:macos': { code: 'KeyC', modifiers: ['primary'] }, + 'desktop:windows': { code: 'KeyC', modifiers: ['primary'] }, + 'desktop:linux': { code: 'KeyC', modifiers: ['primary'] }, + } }))).toThrow('Reserved') + expect(registry.catalog.getSnapshot()).toHaveLength(1) + const changed = vi.fn() + registry.catalog.subscribe(changed) + off(); off() + expect(changed).toHaveBeenCalledOnce() + expect(registry.catalog.getSnapshot()).toEqual([]) + expect(registry.dispatch(gesture, context, vi.fn())).toEqual({ status: 'pass' }) + }) + + it('consumes before executing, ignores repeats, and matches every modifier exactly', () => { + const registry = new ShortcutRegistry('desktop', 'macos') + const consumed = vi.fn() + const run = vi.fn(() => { expect(consumed).toHaveBeenCalled() }) + registry.register(command({ resolve: () => ({ status: 'handled', run }) })) + expect(registry.dispatch(gesture, context, consumed).status).toBe('handled') + expect(registry.dispatch({ ...gesture, repeat: true }, context, consumed).status).toBe('handled') + expect(run).toHaveBeenCalledOnce() + expect(consumed).toHaveBeenCalledTimes(2) + const passed = [{ shift: true }, { alt: true }, { control: true }, { meta: false }, { composing: true }, { defaultPrevented: true }] + for (const extra of passed) { + expect(registry.dispatch({ ...gesture, ...extra }, context, consumed).status).toBe('pass') + } + expect(consumed).toHaveBeenCalledTimes(2) + }) + + it('prioritizes desktop bindings in terminals and modals while retaining owner refusal reasons', () => { + const registry = new ShortcutRegistry('desktop', 'macos') + const consumed = vi.fn() + const resolve = vi.fn(() => ({ status: 'blocked', reason: 'No target' })) + registry.register(command({ resolve })) + expect(registry.dispatch(gesture, { ...context, modal: 'settings' }, consumed)).toMatchObject({ status: 'blocked', reason: 'No target' }) + expect(resolve).toHaveBeenCalledOnce() + expect(registry.dispatch(gesture, { region: 'terminal', modal: null, target: null }, consumed).status).toBe('blocked') + expect(registry.dispatch(gesture, context, consumed)).toMatchObject({ status: 'blocked', reason: 'No target' }) + resolve.mockReturnValue({ status: 'pass' }) + expect(registry.dispatch(gesture, context, consumed).status).toBe('pass') + expect(consumed).toHaveBeenCalledTimes(3) + }) + + it('publishes stable effective rows with Windows Web three-key defaults', () => { + const registry = new ShortcutRegistry('web', 'windows') + let label = 'Settings' + registry.register(command({ label: () => label, defaults: { 'web:windows': { code: 'KeyB', modifiers: ['primary', 'alt'] } } })) + const first = registry.catalog.getSnapshot() + expect(registry.catalog.getSnapshot()).toBe(first) + expect(first[0]?.keys).toEqual(['Ctrl', '+', 'Alt', '+', 'B']) + label = '设置' + registry.refreshLabels() + expect(registry.catalog.getSnapshot()[0]?.label).toBe('设置') + expect(registry.dispatch({ ...gesture, control: true, meta: false }, context, vi.fn()).status).toBe('pass') + }) +}) + +it.each(['Space', 'Escape', 'ArrowUp'])('uses standard ARIA names for %s', (code) => { + expect(presentBinding(normalizeBinding({ code, modifiers: [] }, 'windows'), 'windows').aria).toBe(code) +}) + + +it('keeps commands disabled during startup and publishes config/catalog together before dispatch changes', () => { + const config = initialShortcutConfig() + const registry = new ShortcutRegistry('desktop', 'macos', config) + const run = vi.fn() + registry.register(command({ resolve: () => ({ status: 'handled', run }) })) + expect(registry.dispatch(gesture, context, vi.fn()).status).toBe('pass') + const snapshot = { ...config, status: 'ready' as const, document: { schemaVersion: 1 as const, + profiles: { 'desktop:macos': { 'test.toggle': { code: 'KeyJ', modifiers: ['primary'] as const } } } } } + registry.catalog.subscribe(() => { + expect(registry.config.getSnapshot()).toBe(snapshot) + expect(registry.catalog.getSnapshot()[0]?.keys).toEqual(['⌘', 'J']) + }) + registry.configure(snapshot) + expect(registry.dispatch(gesture, context, vi.fn()).status).toBe('pass') + expect(registry.dispatch({ ...gesture, code: 'KeyJ' }, context, vi.fn()).status).toBe('handled') + expect(run).toHaveBeenCalledOnce() +}) + +it('blocks conflicting dormant overrides when their plugins return, then releases every row on unload', () => { + const registry = new ShortcutRegistry('desktop', 'macos') + registry.configure({ ...initialShortcutConfig(), status: 'ready', document: { schemaVersion: 1, + profiles: { 'desktop:macos': { 'test.toggle': { code: 'KeyJ', modifiers: ['primary'] }, 'other.toggle': { code: 'KeyJ', modifiers: ['primary'] } } } } }) + const run = vi.fn() + const off = registry.register(command({ resolve: () => ({ status: 'handled', run }) })) + expect(registry.dispatch({ ...gesture, code: 'KeyJ' }, context, vi.fn()).status).toBe('handled') + const other = registry.register(command({ id: 'other.toggle' as ShortcutCommandId, defaults: {}, resolve: () => ({ status: 'handled', run }) })) + const consumed = vi.fn() + expect(registry.dispatch({ ...gesture, code: 'KeyJ' }, context, consumed).status).toBe('blocked') + expect(consumed).toHaveBeenCalledOnce() + expect(run).toHaveBeenCalledOnce() + other(); off() + expect(registry.catalog.getSnapshot()).toEqual([]) + expect(registry.dispatch({ ...gesture, code: 'KeyJ' }, context, vi.fn()).status).toBe('pass') +}) + +it.each(['macos', 'windows'] as const)('keeps %s priority identical before saving, after saving the same default, and after resetting', (platform) => { + const registry = new ShortcutRegistry('desktop', platform) + const run = vi.fn() + registry.register(command({ regions: ['page'], modals: [], resolve: () => ({ status: 'handled', run }) })) + for (const [index, modified] of [false, true, false].entries()) { + registry.configure({ ...registry.config.getSnapshot(), revision: `revision-${index}` as never, + document: { schemaVersion: 2, profiles: modified ? { + [`desktop:${platform}`]: { 'test.toggle': { code: 'KeyB', modifiers: ['primary'] } }, + } : {} } }) + expect(registry.catalog.getSnapshot()[0]?.modified).toBe(modified) + const input = { ...gesture, meta: platform === 'macos', control: platform === 'windows' } + for (const region of ['page', 'editable', 'terminal'] as const) { + for (const modal of [null, 'settings']) { + expect(registry.dispatch(input, { region, modal, target: null }, vi.fn()).status).toBe('handled') + } + } + } + expect(run).toHaveBeenCalledTimes(18) +}) diff --git a/packages/client/shortcuts/tests/service.client.spec.ts b/packages/client/shortcuts/tests/service.client.spec.ts new file mode 100644 index 0000000000..8848d4b42c --- /dev/null +++ b/packages/client/shortcuts/tests/service.client.spec.ts @@ -0,0 +1,182 @@ +// @vitest-environment jsdom +import { afterEach, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' +import ShortcutsService from '../src/client/index.ts' +import { initialShortcutConfig } from '../src/protocol.ts' +import type { DesktopKeyboardApi, DesktopShortcutsApi, ShortcutConfigSnapshot, ShortcutCommandId } from '../src/protocol.ts' + +const disposers: (() => Promise)[] = [] +afterEach(async () => { + for (const dispose of disposers.splice(0)) await dispose() + delete document.documentElement.dataset.platform + Reflect.deleteProperty(window, 'dshDesktop') + localStorage.clear() + vi.restoreAllMocks() +}) +const snapshot = (sequence: number): ShortcutConfigSnapshot => ({ ...initialShortcutConfig(), sequence, status: 'ready' }) +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason: Error) => void + const promise = new Promise((yes, no) => { resolve = yes; reject = no }) + return { promise, resolve, reject } +} +function desktop() { + document.documentElement.dataset.platform = 'darwin' + let publish!: (value: ShortcutConfigSnapshot) => void + const unsubscribe = vi.fn() + const api = { + get: vi.fn().mockResolvedValue(snapshot(1)), + edit: vi.fn().mockResolvedValue({ status: 'saved', snapshot: snapshot(3) }), + recording: vi.fn().mockResolvedValue(), + subscribe: vi.fn((listener) => { publish = listener; return unsubscribe }), + } + const keyboard = { + closeWindow: vi.fn().mockResolvedValue(), + subscribe: vi.fn(() => () => {}), + } + Object.defineProperty(window, 'dshDesktop', { configurable: true, value: { shortcuts: api, keyboard } }) + return { api, keyboard, publish: (value: ShortcutConfigSnapshot) => { publish(value) }, unsubscribe } +} +function mount() { + const ctx = new Context() + ctx.provide('locale', new LocaleRuntime(ctx)) + const dispose = async () => { await ctx.fiber.dispose() } + disposers.push(dispose) + const service = new ShortcutsService(ctx) + return { ctx, service, dispose } +} + +it('describes registered binding conflicts on Linux Desktop', async () => { + desktop() + document.documentElement.dataset.platform = 'linux' + const { service } = mount() + const off = service.register(command) + await vi.waitFor(() => { expect(service.config.getSnapshot().status).toBe('ready') }) + expect(service.describeBinding({ code: 'KeyB', modifiers: ['control'] }).conflicts).toEqual([command.id]) + off() + expect(service.describeBinding({ code: 'KeyB', modifiers: ['control'] }).conflicts).toEqual([]) +}) +const command = { id: 'test.toggle' as ShortcutCommandId, label: () => 'Toggle', aliases: [], + 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 }, + }, regions: ['page'] as const, modals: [], + resolve: () => ({ status: 'handled' as const, run() {} }) } + +it('waits for the Desktop handshake and ignores older get, edit, and broadcast replies', async () => { + const f = desktop(), first = deferred() + f.api.get.mockReturnValueOnce(first.promise) + const { service } = mount() + f.publish(snapshot(20)) + expect(service.config.getSnapshot().status).toBe('loading') + const off = service.register(command) + await vi.waitFor(() => { expect(service.config.getSnapshot().status).toBe('ready') }) + f.publish(snapshot(10)); first.resolve(snapshot(1)); await first.promise + expect(service.config.getSnapshot().sequence).toBe(10) + f.publish(snapshot(9)) + await service.edit({ type: 'reset-all' }, service.config.getSnapshot().revision) + expect(service.config.getSnapshot().sequence).toBe(10) + f.api.edit.mockResolvedValueOnce({ status: 'saved', snapshot: snapshot(12) }) + await service.edit({ type: 'reset-all' }, service.config.getSnapshot().revision) + expect(service.config.getSnapshot().sequence).toBe(12) + await service.recording(true); expect(f.api.recording).toHaveBeenCalledWith(true) + off() + expect(f.api.get).toHaveBeenLastCalledWith([]) +}) + +it.each(['darwin', 'win32', 'linux'])('rejects %s Desktop startup without its native keyboard bridge', (platform) => { + const { api } = desktop() + document.documentElement.dataset.platform = platform + Object.defineProperty(window, 'dshDesktop', { configurable: true, value: { shortcuts: api } }) + const read = vi.spyOn(Storage.prototype, 'getItem'), write = vi.spyOn(Storage.prototype, 'setItem') + expect(() => mount()).toThrow('Desktop keyboard bridge unavailable') + expect(api.get).not.toHaveBeenCalled() + expect(read).not.toHaveBeenCalled(); expect(write).not.toHaveBeenCalled() +}) + +it('reports unavailable Desktop storage without reading or writing localStorage', async () => { + const { keyboard } = desktop() + Object.defineProperty(window, 'dshDesktop', { configurable: true, value: { keyboard } }) + const read = vi.spyOn(Storage.prototype, 'getItem'), write = vi.spyOn(Storage.prototype, 'setItem') + const { service } = mount() + expect(service.config.getSnapshot().status).toBe('unreadable') + expect((await service.edit({ type: 'reset-all' }, service.config.getSnapshot().revision)).status).toBe('unreadable') + await expect(service.recording(true)).rejects.toThrow('bridge unavailable') + expect(read).not.toHaveBeenCalled(); expect(write).not.toHaveBeenCalled() +}) + +it('retains accepted bindings when the Desktop bridge rejects reads and writes', async () => { + const f = desktop(); const { service } = mount() + await vi.waitFor(() => { expect(service.config.getSnapshot().status).toBe('ready') }) + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + const failure = new Error('IPC disconnected') + f.api.edit.mockRejectedValueOnce(failure) + const accepted = service.config.getSnapshot() + expect((await service.edit({ type: 'reset-all' }, accepted.revision)).status).toBe('write-failed') + expect(service.config.getSnapshot()).toBe(accepted) + expect(error).toHaveBeenCalledExactlyOnceWith('Shortcut preference save failed', failure) + f.api.get.mockRejectedValueOnce(new Error('handshake failed')) + service.register(command) + await vi.waitFor(() => { expect(service.config.getSnapshot().error).toBe('read') }) +}) + +it.each(['resolve', 'reject'] as const)('suppresses %s completions after disposal and releases the bridge subscription', async (outcome) => { + const f = desktop(), pending = deferred() + f.api.get.mockReturnValue(pending.promise) + f.api.edit.mockImplementation(async () => ({ status: 'saved', snapshot: await pending.promise })) + const { service, dispose } = mount() + const off = service.register(command), edit = service.edit({ type: 'reset-all' }, service.config.getSnapshot().revision) + const accepted = service.config.getSnapshot() + await dispose(); off(); f.publish(snapshot(50)) + if (outcome === 'resolve') pending.resolve(snapshot(30)) + else pending.reject(new Error('window closed')) + await edit + expect(service.config.getSnapshot()).toBe(accepted) + expect(f.unsubscribe).toHaveBeenCalledOnce() +}) + +it('persists Web edits and presents normalized bindings through the public service', async () => { + const { service, ctx } = mount() + await vi.waitFor(() => { expect(service.config.getSnapshot().status).toBe('ready') }) + expect(service.describeBinding(null)).toEqual({ binding: null, keys: [], issue: null, conflicts: [] }) + expect(service.describeBinding({ code: 'Slash', modifiers: ['primary'] })) + .toMatchObject({ issue: null, binding: { code: 'Slash', modifiers: ['control'] } }) + const off = service.register({ ...command, defaults: { + 'web:macos': { code: 'Slash', modifiers: ['primary'] }, + 'web:windows': { code: 'Slash', modifiers: ['primary'] }, + 'web:linux': { code: 'Slash', modifiers: ['primary'] }, + } }) + await vi.waitFor(() => { expect(service.config.getSnapshot().status).toBe('ready') }) + const changed = vi.fn(); service.catalog.subscribe(changed) + ctx.locale.setLocale('zh'); expect(changed).toHaveBeenCalled() + expect((await service.edit({ type: 'set', id: command.id, binding: null }, service.config.getSnapshot().revision)).status).toBe('saved') + expect(service.catalog.getSnapshot()[0]?.keys).toEqual([]) + await service.recording(true); off() +}) + +it('publishes fixed reservations to Desktop and reports their conflicts while their owner is mounted', () => { + const f = desktop(), { service } = mount() + const off = service.registerFixed({ id: 'menu.close' as ShortcutCommandId, label: () => 'Close', keys: ['Esc'], + bindings: [{ code: 'Escape', modifiers: [] }], group: 'menus' }) + expect(f.api.get).toHaveBeenLastCalledWith([{ id: 'menu.close', defaults: {}, fixed: [{ code: 'Escape', modifiers: [] }] }]) + expect(service.describeBinding({ code: 'Escape', modifiers: [] }).conflicts).toEqual(['menu.close']) + expect(service.describeBinding({ code: 'KeyA', secondCode: 'Escape', modifiers: [] }).conflicts).toEqual(['menu.close']) + expect(service.describeBinding({ code: 'Escape', modifiers: ['alt'] }).conflicts).toEqual([]) + off() + expect(service.describeBinding({ code: 'Escape', modifiers: [] }).conflicts).toEqual([]) + expect(f.api.get).toHaveBeenLastCalledWith([]) +}) + +it('propagates catalog publication failures after a successful preference save', async () => { + const f = desktop() + const { service } = mount() + const label = vi.fn(() => 'Toggle') + service.register({ ...command, label }) + await vi.waitFor(() => { expect(service.config.getSnapshot().status).toBe('ready') }) + label.mockImplementationOnce(() => { throw new Error('Command label unavailable') }) + await expect(service.edit({ type: 'reset-all' }, service.config.getSnapshot().revision)) + .rejects.toThrow('Command label unavailable') + expect(f.api.edit).toHaveBeenCalledOnce() +}) diff --git a/packages/client/shortcuts/tests/storage.client.spec.ts b/packages/client/shortcuts/tests/storage.client.spec.ts new file mode 100644 index 0000000000..1c4afce2a9 --- /dev/null +++ b/packages/client/shortcuts/tests/storage.client.spec.ts @@ -0,0 +1,57 @@ +// @vitest-environment jsdom +import { afterEach, expect, it, vi } from 'vitest' +import { webShortcutStorage, SHORTCUT_STORAGE_KEY } from '../src/client/storage.ts' +import type { ShortcutCommandId, ShortcutConfigSnapshot } from '../src/protocol.ts' + +afterEach(() => { localStorage.clear(); vi.restoreAllMocks() }) +const id = 'shortcuts.open' as ShortcutCommandId +const definitions = [{ id, defaults: { + 'web:macos': { code: 'Slash', modifiers: ['primary'] as const }, + 'web:windows': { code: 'Slash', modifiers: ['primary'] as const }, + 'web:linux': { code: 'Slash', modifiers: ['primary'] as const }, +} }] + +it('syncs same-origin updates, rejects stale drafts, and releases the storage listener', async () => { + let latest!: ShortcutConfigSnapshot + const publish = vi.fn((value: ShortcutConfigSnapshot) => { latest = value }) + const adapter = webShortcutStorage(window, 'macos', publish) + try { + const initial = await adapter.get(definitions) + adapter.subscribe(publish)(); await adapter.recording(true) + publish.mockClear() + window.dispatchEvent(new StorageEvent('storage', { key: 'unrelated' })) + await Promise.resolve() + expect(publish).not.toHaveBeenCalled() + window.dispatchEvent(new StorageEvent('storage', { key: null })) + localStorage.setItem(SHORTCUT_STORAGE_KEY, JSON.stringify({ schemaVersion: 1, profiles: { 'web:macos': { [id]: null } } })) + window.dispatchEvent(new StorageEvent('storage', { key: SHORTCUT_STORAGE_KEY })) + await vi.waitFor(() => { expect(latest.document.profiles['web:macos']?.[id]).toBeNull() }) + expect((await adapter.edit({ type: 'reset', id }, initial.revision)).status).toBe('stale') + expect((await adapter.edit({ type: 'reset', id }, latest.revision)).status).toBe('saved') + const stored: unknown = JSON.parse(localStorage.getItem(SHORTCUT_STORAGE_KEY)!) + expect(stored).toMatchObject({ profiles: { 'web:macos': {} } }) + adapter.dispose(); publish.mockClear() + localStorage.removeItem(SHORTCUT_STORAGE_KEY) + window.dispatchEvent(new StorageEvent('storage', { key: null })) + await Promise.resolve() + expect(publish).not.toHaveBeenCalled() + } finally { adapter.dispose() } +}) + +it('retains drafts after quota failure and refuses to replace future data', async () => { + let latest!: ShortcutConfigSnapshot + const adapter = webShortcutStorage(window, 'windows', (value) => { latest = value }) + try { + await adapter.get(definitions) + const write = vi.spyOn(Storage.prototype, 'setItem').mockImplementationOnce(() => { throw new Error('quota') }) + expect((await adapter.edit({ type: 'set', id, binding: null }, latest.revision)).status).toBe('write-failed') + expect(localStorage.getItem(SHORTCUT_STORAGE_KEY)).toBeNull() + write.mockRestore() + const raw = '{"schemaVersion":9}' + localStorage.setItem(SHORTCUT_STORAGE_KEY, raw) + await adapter.get(definitions) + expect((await adapter.edit({ type: 'reset-all' }, latest.revision)).status).toBe('unreadable') + expect(localStorage.getItem(SHORTCUT_STORAGE_KEY)).toBe(raw) + + } finally { adapter.dispose() } +}) diff --git a/packages/client/shortcuts/tsconfig.client.json b/packages/client/shortcuts/tsconfig.client.json new file mode 100644 index 0000000000..f795e9fd2e --- /dev/null +++ b/packages/client/shortcuts/tsconfig.client.json @@ -0,0 +1,37 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../util/brand" + }, + { + "path": "../locale/tsconfig.client.json" + }, + { + "path": "../store" + }, + { + "path": "../../util/crypto" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../util/values" + } + ], + "exclude": [ + "src/index.ts" + ] +} diff --git a/packages/client/shortcuts/tsconfig.host.json b/packages/client/shortcuts/tsconfig.host.json new file mode 100644 index 0000000000..21e5b991ed --- /dev/null +++ b/packages/client/shortcuts/tsconfig.host.json @@ -0,0 +1,37 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo" + }, + "include": [ + "src/protocol.ts", + "src/native.ts", + "src/binding.ts", + "src/configuration.ts", + "src/persistence.ts", + "src/index.ts", + "src/config.ts" + ], + "references": [ + { + "path": "../../util/brand" + }, + { + "path": "../../util/crypto" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../host/webserver" + }, + { + "path": "../../util/values" + } + ] +} diff --git a/packages/client/shortcuts/tsconfig.json b/packages/client/shortcuts/tsconfig.json new file mode 100644 index 0000000000..2eca820546 --- /dev/null +++ b/packages/client/shortcuts/tsconfig.json @@ -0,0 +1,11 @@ +{ + "files": [], + "references": [ + { + "path": "./tsconfig.host.json" + }, + { + "path": "./tsconfig.client.json" + } + ] +} diff --git a/packages/client/shortcuts/tsdown.config.ts b/packages/client/shortcuts/tsdown.config.ts new file mode 100644 index 0000000000..66ffe581c0 --- /dev/null +++ b/packages/client/shortcuts/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-shortcuts', ['lib/types/index.js', 'lib/types/protocol.js']) diff --git a/packages/client/ui-approval/README.i18n.yaml b/packages/client/ui-approval/README.i18n.yaml index d8a94812cb..b414c3e630 100644 --- a/packages/client/ui-approval/README.i18n.yaml +++ b/packages/client/ui-approval/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-approval/README.md -README.md: 1d5a4a56830c34693ee8cc93c6805ec8dd9a8191 -README.zh.md: f5f932ed59834cd9b805700fa58dd0f552bc8454 +README.md: ce2ccdbbecef629771d2f4b7e2391668c3c3e9a0 +README.zh.md: d7a94ae9156c61b8ec8a6e18ce261bedf0ede48d diff --git a/packages/client/ui-approval/README.md b/packages/client/ui-approval/README.md index 1d5a4a5683..ce2ccdbbec 100644 --- a/packages/client/ui-approval/README.md +++ b/packages/client/ui-approval/README.md @@ -18,6 +18,8 @@ Browser approval presentation over the Agent-scoped Remote Event waterfall. The ----- +Focus the approval detail region to approve with Enter or reject with Escape. The mounted plugin reserves both keys against editable shortcuts. Enter on the focused Reject button retains its native reject action. Input controls and IME candidates keep their own keys. Keyboard and pointer actions share one pending-request lock; a withdrawn or replaced request cannot accept another answer, and an earlier failed answer cannot unlock its replacement. + ## Model Experience diff --git a/packages/client/ui-approval/README.zh.md b/packages/client/ui-approval/README.zh.md index f5f932ed59..d7a94ae915 100644 --- a/packages/client/ui-approval/README.zh.md +++ b/packages/client/ui-approval/README.zh.md @@ -18,6 +18,8 @@ kind: "package-reference" ----- +聚焦审批详情区域后,Enter 批准,Esc 拒绝。插件挂载期间,这两个按键不能分配给可编辑快捷键。聚焦“拒绝”按钮后,Enter 保留按钮原生拒绝操作。输入控件与输入法候选保留各自的按键。键盘和指针操作共用同一待处理请求锁;已撤销或替换的请求不能再次作答,较早请求的失败也不会解锁替代请求。 + ## 模型体验 diff --git a/packages/client/ui-approval/package.json b/packages/client/ui-approval/package.json index acb0a0c9ed..bc7152279f 100644 --- a/packages/client/ui-approval/package.json +++ b/packages/client/ui-approval/package.json @@ -60,7 +60,8 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@types/react": "~18.3.1", - "react": "^18.2.0" + "react": "^18.2.0", + "@deepseek-ai/dsh-client-shortcuts": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-approval/src/client/ApprovalPanel.tsx b/packages/client/ui-approval/src/client/ApprovalPanel.tsx index a7c0b33477..9dce13573c 100644 --- a/packages/client/ui-approval/src/client/ApprovalPanel.tsx +++ b/packages/client/ui-approval/src/client/ApprovalPanel.tsx @@ -1,5 +1,5 @@ /** Composer takeover for one pending approval waterfall. */ -import { useState, type ReactNode } from 'react' +import { useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from 'react' import { Button, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' import type { ApprovalComposerProps, PendingApproval } from './contract/slots.ts' import css from './ApprovalPanel.module.css' @@ -23,12 +23,43 @@ function ApprovalFlow({ pending, detail, t }: { t: ApprovalComposerProps['t'] }) { const [answered, setAnswered] = useState(false) + const waiting = useRef(false) + const active = useRef(true) + const composing = useRef(false) + const compositionEnded = useRef(false) + useEffect(() => { + active.current = true + return () => { active.current = false } + }, []) const answer = (outcome: 'allowed-once' | 'rejected'): void => { + if (waiting.current || !pending.answerable) return + waiting.current = true setAnswered(true) - void pending.answer(outcome).catch(() => { setAnswered(false) }) + void pending.answer(outcome).catch(() => { + if (!active.current || !pending.answerable) return + waiting.current = false + setAnswered(false) + }) + } + const keydown = (event: KeyboardEvent): void => { + const element = event.target as Element + if (event.defaultPrevented || !event.currentTarget.contains(document.activeElement) + || element.closest('input, textarea, select, [contenteditable="true"], [contenteditable=""]') !== null) return + if (event.key !== 'Enter' && event.key !== 'Escape') return + if (event.key === 'Enter' && element.closest('button, a[href], [role="button"]') !== null) return + if (event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return + event.preventDefault() + event.stopPropagation() + // oxlint-disable-next-line typescript/no-deprecated -- IME 229 covers engines without isComposing. + if (event.repeat || composing.current || compositionEnded.current || event.nativeEvent.isComposing || event.keyCode === 229) return + answer(event.key === 'Enter' ? 'allowed-once' : 'rejected') } return ( -
+
{ compositionEnded.current = false }} + onCompositionStartCapture={() => { composing.current = true }} + onCompositionEndCapture={() => { composing.current = false; compositionEnded.current = true }}>
{t('waiting')}
ctx.locale.register(NS, { zh, en }), 'ui-approval: dictionaries') + ctx.inject(['shortcuts'], (scope) => { + const t = ctx.locale.bind(NS) + scope.effect(() => scope.shortcuts.registerFixed({ + id: 'approval.allow' as ShortcutCommandId, label: () => t('allowOnce'), keys: ['Enter'], bindings: [{ code: 'Enter', modifiers: [] }], group: 'approval', + }), 'ui-approval: fixed allow reference') + scope.effect(() => scope.shortcuts.registerFixed({ + id: 'approval.reject' as ShortcutCommandId, label: () => t('reject'), keys: ['Esc'], bindings: [{ code: 'Escape', modifiers: [] }], group: 'approval', + }), 'ui-approval: fixed reject reference') + }) const registerPendingInteraction = ctx.uiSession.registerPendingInteraction( () => 0, ) diff --git a/packages/client/ui-approval/tests/ui-approval.client.spec.tsx b/packages/client/ui-approval/tests/ui-approval.client.spec.tsx index 7ae0dbc11a..6e18ace13c 100644 --- a/packages/client/ui-approval/tests/ui-approval.client.spec.tsx +++ b/packages/client/ui-approval/tests/ui-approval.client.spec.tsx @@ -3,7 +3,7 @@ import { Context } from '@deepseek-ai/cordis' import { createScope, scopeOf } from '@deepseek-ai/dsh-api-session-controller/client' import type { ToolCallId } from '@deepseek-ai/dsh-llm' import type { SessionId } from '@deepseek-ai/dsh-session/types' -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { ApprovalPanel } from '../src/client/ApprovalPanel.tsx' import type { ApprovalComposerProps } from '../src/client/contract/slots.ts' @@ -40,7 +40,7 @@ interface PluginBench { } } -function setupPlugin(): PluginBench { +async function setupPlugin(): Promise { const ctx = new Context() let listener: ApprovalListener | undefined let registration: { @@ -84,7 +84,8 @@ function setupPlugin(): PluginBench { register: vi.fn(() => disposeLocale), } as never) - apply(ctx) + const fiber = ctx.plugin({ apply }) + await fiber.await() if (listener === undefined) throw new Error('approval listener was not registered') return { ctx, @@ -198,7 +199,7 @@ describe('PendingApproval', () => { describe('approval Remote Event consumer', () => { it('delegates an event that has no Agent scope', async () => { - const bench = setupPlugin() + const bench = await setupPlugin() const next = vi.fn(() => Promise.resolve<'unavailable'>('unavailable')) await expect(bench.listener.call(bench.ctx, { toolName: 'bash' }, next)) @@ -209,7 +210,7 @@ describe('approval Remote Event consumer', () => { }) it('publishes one scoped takeover, returns the answer, and keeps stable registrations', async () => { - const bench = setupPlugin() + const bench = await setupPlugin() const scope = createScope(bench.ctx, id('s1')) await scope.fiber.await() const controller = new AbortController() @@ -245,7 +246,7 @@ describe('approval Remote Event consumer', () => { }) it('propagates request cancellation after removing the pending object', async () => { - const bench = setupPlugin() + const bench = await setupPlugin() const scope = createScope(bench.ctx, id('s1')) await scope.fiber.await() const controller = new AbortController() @@ -265,7 +266,7 @@ describe('approval Remote Event consumer', () => { }) it('delegates an active request when its interaction domain unloads', async () => { - const bench = setupPlugin() + const bench = await setupPlugin() const scope = createScope(bench.ctx, id('s1')) await scope.fiber.await() const next = vi.fn(() => Promise.resolve<'unavailable'>('unavailable')) @@ -281,7 +282,7 @@ describe('approval Remote Event consumer', () => { }) it('publishes a scoped request without optional request metadata', async () => { - const bench = setupPlugin() + const bench = await setupPlugin() const scope = createScope(bench.ctx, id('s1')) await scope.fiber.await() const result = bench.listener.call(scope.ctx, { toolName: 'read' }, () => Promise.resolve('unavailable')) @@ -297,7 +298,7 @@ describe('approval Remote Event consumer', () => { }) it('removes stable registrations with the plugin lifetime', async () => { - const bench = setupPlugin() + const bench = await setupPlugin() await bench.ctx.fiber.dispose() expect(bench.disposeSlot).toHaveBeenCalledOnce() expect(bench.disposeLocale).toHaveBeenCalledOnce() @@ -361,6 +362,102 @@ describe('ApprovalPanel', () => { await expect(pending.result).resolves.toBe('allowed-once') }) + it.each([['Enter', 'allowed-once'], ['Escape', 'rejected']] as const)( + 'answers the focused container with %s through the pending request', async (key, outcome) => { + const pending = new PendingApproval(id('s1'), { toolName: 'bash' }) + render() + const group = screen.getByRole('group', { name: 'Approval details' }) + group.focus() + const bubbled = vi.fn() + window.addEventListener('keydown', bubbled) + try { + fireEvent.keyDown(group, { key, code: key }) + await expect(pending.result).resolves.toBe(outcome) + expect(bubbled).not.toHaveBeenCalled() + expect(pending.answerable).toBe(false) + } finally { window.removeEventListener('keydown', bubbled) } + }, + ) + + it('leaves button Enter to its native click and rejects from the reject button', async () => { + const pending = new PendingApproval(id('s1'), { toolName: 'bash' }) + render() + const reject = screen.getByRole('button', { name: 'Reject' }) + reject.focus() + expect(fireEvent.keyDown(reject, { key: 'Enter', code: 'Enter' })).toBe(true) + expect(pending.answerable).toBe(true) + // jsdom does not synthesize the browser's native button activation. + fireEvent.click(reject) + await expect(pending.result).resolves.toBe('rejected') + }) + + it('ignores unowned input, modified keys, repeats and IME candidate keys', async () => { + const pending = new PendingApproval(id('s1'), { toolName: 'bash', callId: 'call-1' as ToolCallId }) + const renderSlot = () => + render() + const group = screen.getByRole('group', { name: 'Approval details' }) + fireEvent.keyDown(group, { key: 'Enter', code: 'Enter' }) + expect(pending.answerable).toBe(true) + const input = screen.getByRole('textbox', { name: 'Approval input' }) + input.focus() + fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' }) + fireEvent.keyDown(input, { key: 'Escape', code: 'Escape' }) + group.focus() + expect(fireEvent.keyDown(group, { key: 'Tab', code: 'Tab' })).toBe(true) + fireEvent.keyDown(group, { key: 'Enter', code: 'Enter', ctrlKey: true }) + fireEvent.keyDown(group, { key: 'Escape', code: 'Escape', repeat: true }) + fireEvent.keyDown(group, { key: 'Enter', code: 'Enter', isComposing: true }) + fireEvent.keyDown(group, { key: 'Enter', code: 'Enter', keyCode: 229 }) + fireEvent.compositionStart(group) + fireEvent.keyDown(group, { key: 'Enter', code: 'Enter' }) + fireEvent.compositionEnd(group) + fireEvent.keyDown(group, { key: 'Escape', code: 'Escape' }) + expect(pending.answerable).toBe(true) + fireEvent.keyUp(group, { key: 'Escape', code: 'Escape' }) + fireEvent.keyDown(group, { key: 'Enter', code: 'Enter' }) + await expect(pending.result).resolves.toBe('allowed-once') + }) + + it('locks keyboard and click submissions synchronously while the answer is pending', async () => { + const pending = new PendingApproval(id('s1'), { toolName: 'bash' }) + const gate = Promise.withResolvers() + const answer = vi.spyOn(pending, 'answer').mockImplementation(() => gate.promise) + render() + const group = screen.getByRole('group', { name: 'Approval details' }) + group.focus() + fireEvent.keyDown(group, { key: 'Enter', code: 'Enter' }) + fireEvent.keyDown(group, { key: 'Escape', code: 'Escape' }) + fireEvent.click(screen.getByRole('button', { name: 'Reject' })) + expect(answer).toHaveBeenCalledExactlyOnceWith('allowed-once') + await act(async () => { gate.resolve(undefined); await gate.promise }) + pending.abort(new Error('test cleanup')) + await pending.result.catch(() => {}) + }) + + it('does not settle an aborted request or let its late rejection unlock its replacement', async () => { + const first = new PendingApproval(id('s1'), { toolName: 'bash' }) + const gate = Promise.withResolvers() + vi.spyOn(first, 'answer').mockImplementation(() => gate.promise) + const view = render() + let group = screen.getByRole('group', { name: 'Approval details' }) + group.focus() + fireEvent.keyDown(group, { key: 'Enter', code: 'Enter' }) + first.abort(new Error('request withdrawn')) + await first.result.catch(() => {}) + const second = new PendingApproval(id('s1'), { toolName: 'read' }) + view.rerender() + await act(async () => { gate.reject(new Error('late transport failure')); await gate.promise.catch(() => {}) }) + expect(screen.getByRole('button', { name: 'Reject' }).disabled).toBe(false) + second.abort(new Error('request withdrawn')) + await second.result.catch(() => {}) + const answer = vi.spyOn(second, 'answer') + group = screen.getByRole('group', { name: 'Approval details' }) + group.focus() + fireEvent.keyDown(group, { key: 'Enter', code: 'Enter' }) + fireEvent.click(screen.getByRole('button', { name: 'Reject' })) + expect(answer).not.toHaveBeenCalled() + }) + it('re-enables actions when answering fails', async () => { const pending = new PendingApproval(id('s1'), { toolName: 'bash' }) vi.spyOn(pending, 'answer').mockRejectedValue(new Error('transport closed')) diff --git a/packages/client/ui-approval/tsconfig.json b/packages/client/ui-approval/tsconfig.json index 6c10dfc080..5bad85d9c5 100644 --- a/packages/client/ui-approval/tsconfig.json +++ b/packages/client/ui-approval/tsconfig.json @@ -40,6 +40,9 @@ }, { "path": "../ui-slots" + }, + { + "path": "../shortcuts/tsconfig.client.json" } ] } diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 99eba4e710..62c359f0c7 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: 48df40483632a7c63ec59d389f920d4dd663155b -README.zh.md: f5c34657264f97d8f0178859dcba5e3fc981f17d +README.md: 2c0e192c6a92ffdc7edba7cebe9632a1443556cf +README.zh.md: ba7b6d32630ac81be8ef52c9983284bf725a960a diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 48df404836..2c0e192c6a 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -27,6 +27,8 @@ English | [中文](README.zh.md) `UiConversation.events` is the single registry for event Definitions, and `UiConversation.views` is the single registry for target snapshot builders. Both registries reject duplicate keys, preserve registration order, return idempotent disposers, and rebuild existing bindings when their contribution roster changes. `UiConversation.binding(bindingOrSessionId)` returns one identity-stable Conversation binding for the current Session Controller binding. It does not open another event source. A View Definition may declare `toolCallFocus` to translate a tool-call id into its focus identity. Conversation supplies an Inspect callback only while such a target has a visible View entry; Chat consumes this callback without selecting a target. +`ctx.uiConversation.binding(binding).timeline` exposes the same immutable loaded Turn/Step timeline that target builders receive. Turn and Step lifecycle events publish synchronously, including when no View is active; unloading the Session binding detaches its source subscription. Consumers bind cancellation to the current Session and turn. + The adapter passes each `SessionEventLikeEntry` directly to the assembler. Its outer `type` distinguishes durable events from Client-only transient events, while its inner `event` always exposes `type`, `seq`, `time`, and `data`; Definitions receive that inner `SessionEventLike`. Replacement windows may include both entry variants, while historical prepends carry durable entries and live appends may carry either. Every Definition uses the same `match` and `update` methods for both event forms, while `start` receives only a durable event and the assembler rejects a transient start. Definitions that do not consume Assistant deltas return `null` for `assistant/live-chunk`. Replacement windows and revision gaps rebuild from the complete loaded window; contiguous append, prepend, and Assistant-settlement revisions use incremental assembly. Settlement removes only the named attempt's transient matches, applies its optional durable entry, and replays the affected Contexts and dependents without replacing unrelated target nodes. The assembler owns Context matching, Turn/Step locations, target node materialization, target activity, and stable target sources. `ConversationSnapshot` contains only target-neutral views and active-target facts; Session lifecycle state remains in `SessionSnapshot`. A target becomes active when shell selection resolves it or when its source receives a first subscriber. The assembler replaces that target from current Contexts once and keeps it active for later incremental flushes; creating a source does not activate it and unsubscription does not deactivate it. @@ -50,7 +52,7 @@ With Developer tools off, the shell hides only Trajectory; other plugin-contribu The composer registers the File command action and owns its label, availability, and native file-dialog callback. Menu availability and invocation both consult the mounted composer's current attachment-intake policy. Unmounting or locking the composer disables that action; disposing the plugin removes its registration. The callback binding stays inside the input module. -`SessionInputShell` owns one Lexical editor per Session through its private [DraftEditorRuntime](src/client/input/editor/runtime.ts), while retaining submission, attachment selection, and recovery decisions. [DraftEditor](src/client/input/editor/DraftEditor.tsx) renders the borrowed editor; InputBar retains its Hooks and refs and installs DOM behavior through [view-binding](src/client/input/editor/view-binding.ts). Editor-facing types live in [draft-editor.ts](src/client/contract/draft-editor.ts), with shared input and submission types in [input.ts](src/client/contract/input.ts). This separation does not support simultaneous editable roots for one Session; [the two-stage isolation proposal](../../../.agents/notes/proposed/architecture/2026-09-14-composer-model-and-draft-editor.md) defines the remaining work. +`SessionInputShell` owns one Lexical editor per Session through its private [DraftEditorRuntime](src/client/input/editor/runtime.ts), while retaining submission, attachment selection, and recovery decisions. [DraftEditor](src/client/input/editor/DraftEditor.tsx) renders the borrowed editor; InputBar retains its Hooks and refs and installs DOM behavior through [view-binding](src/client/input/editor/view-binding.ts). Editor-facing types live in [draft-editor.ts](src/client/contract/draft-editor.ts), with shared input and submission types in [input.ts](src/client/contract/input.ts). Background editor updates retain the draft selection without changing the document selection or reclaiming focus from another control; explicit Composer focus restores its selection. This separation does not support simultaneous editable roots for one Session; [the two-stage isolation proposal](../../../.agents/notes/proposed/architecture/2026-09-14-composer-model-and-draft-editor.md) defines the remaining work. Claimed commands retain their identity and highlight when only their arguments and trailing separator are deleted; editing the command name releases the claim. The same rules apply to every command and locale, including `/goal`, `/目标`, `/plan`, and `/计划`. Command hints and ordinary placeholders remain hidden throughout IME composition and reappear only after the editor commits the final text and the corresponding input is empty. @@ -70,6 +72,8 @@ The width-handle indicator follows the pointer only during a captured drag; ordi The resident composer survives no-Session and Session transitions. Whitespace hides its placeholder; a whitespace-only draft without attachments cannot be sent. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. QueueDock reads `next-turn` directly from the Session `inbox` projection, including cold recovered messages. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show local or durable images and files in original attachment order. Images use thumbnails; files use compact name-and-size cards. An edit exposes the literal sent text in a textarea that grows with its content, so line breaks survive re-editing; Enter saves, Shift+Enter breaks the line, and Escape cancels. Durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. The composer keymap arbitrates the trigger menu's keys through the slash pipeline — Tab settles the highlighted completion (or drills a drillable one), Escape and Shift+Tab leave the menu without settling — and leaves every other key to the editor. An overlay that takes the keyboard hands it back through `SessionInput.focus()`, which rides Lexical's own focus so the caret returns where the draft left it rather than at the start. +Plain Enter uses the configured delivery mode, exactly Ctrl+Enter or Cmd+Enter uses its complement, and Shift+Enter inserts a line break. Enter with Alt, AltGraph, both Ctrl and Cmd, or Shift plus Ctrl/Cmd leaves the draft and command menu unchanged and keeps the DOM event available to application shortcuts. The Conversation plugin registers the fixed send, newline, complementary-delivery, command-menu, and reference-menu rows and reserves their bindings until it unloads. + Default sends commit optimistically: Enter clears the draft, occurrence table, and undo history in the same transaction, keeps the composer in `plain`, and runs the send as a detached attempt, so typing and further sends continue during the flight. `sendSession` registers a Session submission echo (`session.beginSubmission`) with the delivery mode before serializing, preserving selected image and file order in `pendingSubmissions`; Session derives the placement from that mode and its current running state, so idle sends use the transcript, busy Queue sends use QueueDock, and busy Steer sends use the pending-steering surface. It then yields one paint, encodes images through the browser's native `FileReader` data-URL path, and cites staged file receipts. Command submissions use the same receipts for generic files, so sending `/goal` or `/plan` never reads those browser files again. The prompt reuses the submission `requestId`; queue and history observation by that `rpcId` retires the echo once. Concurrent failures are restored together in submission order until the user edits the restored content; command submissions keep the frozen `submitting` phase. Detached attempts retain their attachment ids through admission and Session scope disposal. An observed retirement immediately exposes each image preview through the durable cache, replaces it with the canonical URL after fetching the admitted attachment, revokes each URL after its use ends, and releases file cards. Selected generic files enter one FIFO background-upload queue; `maxConcurrentFileUploads` defaults to two active Worker transports, the Conversation service retains queued and active operations plus byte progress across Session navigation, and removing a draft skips its queued transfer or aborts its active transport. Where the browser shell exposes `__DSH_HOST_PATHS__` (the Desktop application), dropped or pasted folders and dropped, picked, or pasted non-image files with a real path become `@path` chips; images keep uploading. Drag-and-drop and paste identify directories through the browser entry API; when that API is absent or returns no entry, pasted items retain ordinary file handling. The file picker cannot select directories. References require the `ui-reference` plugin and the original paths to remain readable by the model's file tools. Paths inside the workspace are relative; others remain absolute. The whole batch is validated before insertion, retains source order and selected text, and uses whitespace-separated, closed-quote mentions. A browser without the bridge refuses dropped or pasted folders; a Desktop folder without a reported path is rejected separately. Continuable subagents disable attachment intake and skip local echoes because their transport does not preserve the browser request id. Queued submission echoes show “Sending…” beside disabled edit, remove, and steer buttons; a collapsed dock keeps the sending status in its header. A matching Host queue row replaces the echo and enables each action according to its normal text-content and running-state requirements. Prompt acknowledgement alone does not enable queue actions. A failed submission removes its echo and displays an error; the composer restores the failed draft when it is empty or still contains the previous automatic restoration, preserving subsequently typed text. @@ -80,6 +84,8 @@ File chips and editable skill references share a whole-reference hover backgroun When another writer owns the Session, the send-error toast asks the user to quit other running DSH instances and retry. +Two independent Escape presses in the focused Chat or Composer stop its current running turn and preserve queued messages. The interval comes from the shortcuts plugin’s `stopSequenceMs` configuration (500 ms by default). A menu, approval, modal, terminal, embedded webpage, composition, repeated key, changed input region, Session or turn breaks the sequence. The shortcut uses the same scoped cancellation as the Stop button. Its fixed-action registration reserves plain Escape against editable shortcuts and supplies the `Esc Esc` sequence shown in the Stop button’s hover and keyboard-focus tooltip. + ## Temporary composer entries diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index f5c3465726..ba7b6d3263 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -27,6 +27,8 @@ kind: "package-reference" `UiConversation.events` 是 event Definition 的唯一 registry,`UiConversation.views` 是 target snapshot builder 的唯一 registry。两者都拒绝重复 key、保持注册顺序、返回幂等 disposer,并在 contribution roster 变化时重建现有 binding。`UiConversation.binding(bindingOrSessionId)` 为当前 Session Controller binding 返回 identity 稳定的 Conversation binding,不会另开事件源。 View Definition 可以声明 `toolCallFocus`,将工具调用 id 转换为自身的焦点标识。仅当此目标拥有可见的 View 条目时,Conversation 才提供 Inspect 回调;Chat 直接使用回调,不选择目标。 +`ctx.uiConversation.binding(binding).timeline` 暴露与目标构建器相同的不可变已加载轮次/步骤时间线。轮次和步骤生命周期事件同步发布,即使没有活动视图也是如此;卸载会话绑定会解除源订阅。消费者将取消操作绑定到当前会话和轮次。 + 适配器把每个 `SessionEventLikeEntry` 直接交给 assembler。外层 `type` 区分持久事件与 Client-only transient event,内部 `event` 则统一公开 `type`、`seq`、`time` 与 `data`;Definition 接收这个内部 `SessionEventLike`。replacement window 可以包含两种 entry,历史 prepend 携带持久 entry,实时 append 则可以携带任一种。两种事件都使用 Definition 的同一组 `match` 与 `update` 方法,`start` 只接收持久 event,assembler 会拒绝 transient start。不消费 Assistant delta 的 Definition 对 `assistant/live-chunk` 返回 `null`。replace window 或 revision 断档从完整已加载窗口重建;连续 revision 的 append、prepend 与 Assistant settlement 使用增量组装。settlement 只删除具名 attempt 的 transient match,应用可选持久 entry,并重放受影响的 Context 及其 dependent,不替换无关 target node。assembler 拥有 Context 匹配、Turn/Step location、target node 物化、target activity 和稳定 target source。`ConversationSnapshot` 只包含与 target 无关的 View 与 active-target 事实;Session lifecycle 状态仍属于 `SessionSnapshot`。 shell 选择解析出 target 或 target source 收到首个 subscriber 时,该 target 进入 active 状态。assembler 从当前 Context 对它执行一次 replace,并使它参与后续增量 flush;创建 source 不会激活 target,取消订阅也不会停用 target。 @@ -50,7 +52,7 @@ target package 通过 declaration merge 扩展 snapshot 与 Location data map, 输入框注册「文件」命令动作,负责其标题、可用性和原生文件选择器回调。菜单可用性与实际调用都读取已挂载输入框当前的附件接收策略。输入框卸载或锁定后该动作不可用,插件 dispose(资源释放)时移除注册。回调绑定留在输入模块内部。 -`SessionInputShell` 通过私有 [DraftEditorRuntime](src/client/input/editor/runtime.ts) 为每个 Session 持有一个 Lexical editor,同时保留提交、附件选择和恢复决策。[DraftEditor](src/client/input/editor/DraftEditor.tsx) 呈现借用的 editor;InputBar 保留钩子与 refs,并通过 [view-binding](src/client/input/editor/view-binding.ts) 安装 DOM 行为。编辑器类型位于 [draft-editor.ts](src/client/contract/draft-editor.ts),共享输入和提交类型位于 [input.ts](src/client/contract/input.ts)。这一拆分不支持同一 Session 同时挂载多个可编辑 root;[两阶段隔离提案](../../../.agents/notes/proposed/architecture/2026-09-14-composer-model-and-draft-editor.zh.md) 定义剩余工作。 +`SessionInputShell` 通过私有 [DraftEditorRuntime](src/client/input/editor/runtime.ts) 为每个 Session 持有一个 Lexical editor,同时保留提交、附件选择和恢复决策。[DraftEditor](src/client/input/editor/DraftEditor.tsx) 呈现借用的 editor;InputBar 保留钩子与 refs,并通过 [view-binding](src/client/input/editor/view-binding.ts) 安装 DOM 行为。编辑器类型位于 [draft-editor.ts](src/client/contract/draft-editor.ts),共享输入和提交类型位于 [input.ts](src/client/contract/input.ts)。编辑器后台更新会保留草稿选区,不修改文档选区或从其他控件夺回焦点;显式聚焦 Composer 时恢复其选区。这一拆分不支持同一 Session 同时挂载多个可编辑 root;[两阶段隔离提案](../../../.agents/notes/proposed/architecture/2026-09-14-composer-model-and-draft-editor.zh.md) 定义剩余工作。 已认领的命令在仅删除参数和末尾分隔空格时保留身份与高亮,改动命令名才会释放认领。所有命令和语言使用相同规则,包括 `/goal`、`/目标`、`/plan` 和 `/计划`。输入法组合输入期间,命令提示和普通占位文字持续隐藏,直到编辑器提交最终文字且对应输入为空时才重新显示。 @@ -70,6 +72,8 @@ Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染 常驻 composer 在无 Session 与有 Session 之间保持挂载。输入空白字符会隐藏占位提示;没有附件的纯空白草稿无法发送。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。QueueDock 直接从 Session 的 `inbox` 投影读取 `next-turn`,包含从冷状态恢复的消息。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并按原始附件顺序展示本地或持久化的图片和文件。图片使用缩略图,文件使用紧凑的名称与大小卡片。编辑态在可随内容增高的 textarea 中展示字面发送文本,因此重新编辑不会丢失换行;Enter 保存,Shift+Enter 换行,Escape 取消。持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 composer 键盘映射经斜杠流水线裁决触发菜单的按键——Tab 确认高亮补全项(可下钻项则下钻),Escape 与 Shift+Tab 离开菜单且不选定——其余按键交给编辑器自身。 接管键盘的浮层通过 `SessionInput.focus()` 把键盘还回来,该路径走 Lexical 自己的 focus,因此光标回到草稿原来的位置而不是开头。 +普通 Enter 使用已配置的投递模式,严格的 Ctrl+Enter 或 Cmd+Enter 使用互补模式,Shift+Enter 插入换行。带 Alt、AltGraph、同时带 Ctrl 与 Cmd,或 Shift 与 Ctrl/Cmd 的 Enter 保持草稿和指令菜单不变,并将 DOM 事件留给应用快捷键。Conversation 插件注册发送、换行、互补投递、命令菜单和引用菜单的固定条目,并在卸载前保留这些键位。 + 默认发送采用乐观提交:Enter 在同一事务里清空草稿、occurrence 表和撤销历史,composer 保持 `plain`,发送作为 detached attempt 运行,发送期间可以继续输入和提交。`sendSession` 在序列化之前用投递模式注册 Session 提交回显(`session.beginSubmission`),并在 `pendingSubmissions` 中保留图片与文件的选择顺序;Session 根据该模式与当前运行状态推导位置,因此空闲发送进入 transcript(文本记录),繁忙时 Queue 进入 QueueDock,繁忙时 Steer 进入 pending-steering 区域。随后让出一帧,图片经浏览器原生 `FileReader` data-URL 路径编码,文件则引用已暂存凭证。命令提交也用同一凭证表示通用文件,因此发送 `/goal` 或 `/plan` 时不会再次读取这些浏览器文件。提示词复用提交 `requestId`;queue 或历史以同一 `rpcId` 被观察后,回显只退休一次。多个并发发送失败时,在用户编辑还原内容之前按提交顺序合并还原;命令提交保持冻结的 `submitting` 阶段。Detached attempt 持有附件 id,直到 admission 完成或 Session scope 销毁。回显以 observed 退休时,durable 图片缓存立即公开每个预览 URL,读取 admitted 附件后用规范化 URL 替换预览,并在各 URL 停止使用后撤销,同时释放文件卡。选中的通用文件进入同一个先进先出的后台上传队列;`maxConcurrentFileUploads` 默认允许两个 Worker transport 同时运行,Conversation 服务在切换 Session 时继续持有排队和运行中的传输操作及字节进度,移除草稿会跳过排队中的传输或中止正在运行的传输。浏览器 shell 暴露 `__DSH_HOST_PATHS__` 时(桌面应用),拖入或粘贴的文件夹以及拖入、选择或粘贴的带真实路径的非图片文件会成为 `@路径` chip;图片仍然上传。拖放和粘贴通过浏览器 entry API 识别目录;该 API 不可用或没有返回 entry 时,粘贴项沿用普通文件处理。文件选择器不能选择目录。引用需要启用 `ui-reference` 插件,原路径也必须仍可由模型的文件工具读取。工作区内的路径使用相对形式,其他路径保留绝对形式。整批文件先校验再插入,保留来源顺序和已选中的文字,引用之间有空白分隔,含空格的路径使用闭合引号。没有该桥的浏览器会拒绝拖入或粘贴的文件夹,桌面端无法获取文件夹路径时单独报错。continuable 子代理禁用附件入口,也不创建本地回显,因为其 transport 不保留浏览器 request id。 排队提交的本地回显在禁用的编辑、删除、插话按钮旁显示“发送中…”;折叠后的队列在标题栏保留发送状态。匹配的 Host 队列行替换回显后,各操作按原有的纯文本内容和运行状态要求启用。仅收到提示词确认不会启用队列操作。提交失败会移除回显并显示错误;输入框为空或仍保留上一次自动恢复的内容时,composer 恢复失败草稿,保留用户随后输入的文字。 @@ -80,6 +84,8 @@ Send 和 Stop 按钮禁用时不显示提示气泡,轮次结束后由 Stop 切 当会话被其他写句柄占用时,发送失败的 toast 提示用户退出其他正在运行的 DSH 后重试。 +在获得焦点的 Chat 或 Composer 中连续独立按下两次 Esc,可停止当前运行轮次并保留排队消息。间隔由 shortcuts 插件的 `stopSequenceMs` 配置决定,默认 500 ms。菜单、审批、模态层、终端、内嵌网页、输入法、重复按键,以及输入区域、Session 或轮次变化会清空序列。快捷键与 Stop 按钮调用同一作用域取消操作。固定操作注册使普通 Esc 不能分配给可编辑快捷键,并为 Stop 按钮的悬停和键盘聚焦提示提供 `Esc Esc` 序列。 + ## 临时 composer entry diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index 37093c371d..1207106728 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -95,7 +95,8 @@ "@lexical/plain-text": "^0.49.0", "@lexical/history": "^0.49.0", "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-settings": "workspace:^" + "@deepseek-ai/dsh-settings": "workspace:^", + "@deepseek-ai/dsh-client-shortcuts": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index f4a5ff4780..737cee8329 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -11,6 +11,7 @@ import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type {} from '@deepseek-ai/dsh-client-ui-session/client' import type {} from '@deepseek-ai/dsh-client-ui-settings/client' +import type { ShortcutCommandId, ShortcutFixedCommand } from '@deepseek-ai/dsh-client-shortcuts/client' import { UiConversation } from './conversation/assembly.ts' import type { ViewTab } from './contract/views.ts' import type { @@ -38,6 +39,7 @@ import { ConversationHeader } from './skeleton/ConversationHeader.tsx' import { ConversationSession, ConversationSessionHeader } from './skeleton/ConversationSession.tsx' import { InputBar } from './skeleton/InputBar.tsx' import { todoDockEntry } from './skeleton/TodoPanel.tsx' +import { installStopShortcut } from './stop-shortcut.ts' import { DEVELOPER_TOOLS_VIEW_ID, resolveActiveView } from './view-selection.ts' import { en, NS, zh, type ConversationKey } from './locales.ts' import { CONVERSATION_SETTINGS_NAMESPACE, type ConversationSettings } from '../submission-settings.ts' @@ -225,6 +227,42 @@ export function apply(ctx: Context, config: Config = Config({})): void { } }, 'ui-conversation: View selection') + const stop = (sessionId: SessionId): void => { + scopedConversation(sessions, sessionId).cancel().catch((_error: unknown) => { + // Stop failure is published through Session promptError. + }) + } + const stopShortcut = createSnapshotStore([]) + ctx.inject(['shortcuts'], (scope) => { + const fixedInputs: readonly ShortcutFixedCommand[] = [ + { id: 'fixed.send' as ShortcutCommandId, label: () => t('input.send'), keys: ['Enter'], + bindings: [{ code: 'Enter', modifiers: [] }], group: 'input' }, + { id: 'fixed.newline' as ShortcutCommandId, label: () => t('shortcut.newline'), + keys: scope.shortcuts.describeBinding({ code: 'Enter', modifiers: ['shift'] }).keys, + bindings: [{ code: 'Enter', modifiers: ['shift'] }], group: 'input' }, + { id: 'fixed.complementary' as ShortcutCommandId, label: () => t('shortcut.complementary'), + keys: scope.shortcuts.describeBinding({ code: 'Enter', modifiers: ['primary'] }).keys, + bindings: [{ code: 'Enter', modifiers: ['control'] }, { code: 'Enter', modifiers: ['meta'] }], group: 'input' }, + { id: 'fixed.slash' as ShortcutCommandId, label: () => t('shortcut.slash'), keys: ['/'], + bindings: [{ code: 'Slash', modifiers: [] }], group: 'input' }, + { id: 'fixed.mention' as ShortcutCommandId, label: () => t('shortcut.mention'), keys: ['@'], + bindings: [{ code: 'Digit2', modifiers: ['shift'] }], group: 'input' }, + ] + for (const command of fixedInputs) { + scope.effect(() => scope.shortcuts.registerFixed(command), `ui-conversation: ${command.id}`) + } + scope.effect(() => installStopShortcut(scope.shortcuts, sessions, uiConversation, ctx.uiSession, stop), + 'ui-conversation: fixed stop input') + scope.effect(() => { + const command: ShortcutFixedCommand = { + id: 'response.stop' as ShortcutCommandId, label: () => t('input.stop'), keys: ['Esc', 'Esc'], bindings: [{ code: 'Escape', modifiers: [] }], group: 'input', + } + const dispose = scope.shortcuts.registerFixed(command) + stopShortcut.set(command.keys) + return () => { stopShortcut.set([]); dispose() } + }, 'ui-conversation: fixed stop reference') + }) + const inputHub = new InputHub(ctx, t) const composerBlocks = new ComposerBlockRegistry() @@ -402,6 +440,7 @@ export function apply(ctx: Context, config: Config = Config({})): void { toggleCommandMenu: undefined, stop: undefined, hooks: { + stopShortcut, busyEnter: submissionPolicy.busyEnter, fileUploads: ABSENT_FILE_UPLOADS, notices: ABSENT_NOTICES, @@ -475,12 +514,9 @@ export function apply(ctx: Context, config: Config = Config({})): void { span: { ...selection, draftRev: snapshot.draftRev }, }) }, - stop: () => { - scopedConversation(sessions, sessionId).cancel().catch(() => { - // Stop failure is published through Session promptError. - }) - }, + stop: () => { stop(sessionId) }, hooks: { + stopShortcut, busyEnter: submissionPolicy.busyEnter, fileUploads: conversation.fileUploads, notices: shell.notices, diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 001becdfde..61f7d8ff8b 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -391,6 +391,8 @@ export interface ComposerBarInjected { toggleCommandMenu: ((selection: EditSelection) => void) | undefined stop: (() => void) | undefined hooks: { + /** Readable sequence while the fixed Stop command is registered. */ + stopShortcut: ObservableSnapshot /** * Live busy-state submission preference: the delivery mode plain Enter * and the primary Send button use while the addressed agent is busy. diff --git a/packages/client/ui-conversation/src/client/conversation/assembler.ts b/packages/client/ui-conversation/src/client/conversation/assembler.ts index d51216de32..c94fac7c75 100644 --- a/packages/client/ui-conversation/src/client/conversation/assembler.ts +++ b/packages/client/ui-conversation/src/client/conversation/assembler.ts @@ -6,7 +6,7 @@ import type { ConversationContextReader, ConversationLocationData, ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, ConversationPreviousContext, ConversationLocationDataScope, ConversationPublication, ConversationViewBuilder, - ConversationStartMatch, + ConversationStartMatch, ConversationTimelineSnapshot, ConversationViewDefinition, ConversationViewNode, ConversationViewSnapshotMap, ConversationViewSnapshotStore, } from '../contract/conversation.ts' @@ -209,6 +209,12 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore this.resetViewBuilders() } + /** + * Read the assembled Turn/Step timeline without activating a presentation target. + * @returns the immutable timeline shared with target builders. + */ + timeline(): ConversationTimelineSnapshot { return this.locationIndex.snapshot() } + /** * Replace the complete loaded window after open, resync, or gap repair. * @param entries - complete contiguous window. diff --git a/packages/client/ui-conversation/src/client/conversation/assembly.ts b/packages/client/ui-conversation/src/client/conversation/assembly.ts index fe4d49505c..3107c194f8 100644 --- a/packages/client/ui-conversation/src/client/conversation/assembly.ts +++ b/packages/client/ui-conversation/src/client/conversation/assembly.ts @@ -10,7 +10,7 @@ import { createSnapshotStore, type ObservableSnapshot, type SnapshotStore, } from '@deepseek-ai/dsh-client-store' import type { - ConversationPublication, ConversationViewSnapshotMap, + ConversationPublication, ConversationTimelineSnapshot, ConversationViewSnapshotMap, ConversationViewSnapshotStore, } from '../contract/conversation.ts' import type { ConversationSnapshot } from '../contract/snapshot.ts' @@ -28,6 +28,8 @@ import { ConversationGroupRegistry } from './group-registry.ts' /** Observable faces published for one Session's Conversation assembly. */ export interface ConversationBinding { readonly snapshot: ObservableSnapshot + /** Loaded Turn/Step timeline; lifecycle events publish synchronously even without an active View. */ + readonly timeline: ObservableSnapshot /** * Add one selected target to the Session's monotonic active set. * @param target - registered or subsequently registered Conversation target. @@ -47,6 +49,7 @@ export interface ConversationBinding { class BoundConversation implements ConversationBinding { readonly snapshot: SnapshotStore + readonly timeline: SnapshotStore private readonly viewStore: ConversationViewSnapshotStore private readonly targetSources = new Map>() private revision = -1 @@ -59,6 +62,7 @@ class BoundConversation implements ConversationBinding { ) { this.viewStore = assembler this.snapshot = createSnapshotStore(this.currentSnapshot()) + this.timeline = createSnapshotStore(assembler.timeline()) this.replace(feed.getSnapshot()) this.disposeFeed = feed.subscribe(() => { this.accept(feed.getSnapshot()) @@ -86,6 +90,7 @@ class BoundConversation implements ConversationBinding { activate(target: string): void { if (this.assembler.activateTarget(target)) this.snapshot.set(this.currentSnapshot()) + this.timeline.set(this.assembler.timeline()) } rebuild(): void { this.publish(this.assembler.rebuildRegistry()) } @@ -157,6 +162,7 @@ class BoundConversation implements ConversationBinding { private flush(): void { if (this.assembler.flush()) this.snapshot.set(this.currentSnapshot()) + this.timeline.set(this.assembler.timeline()) } private currentSnapshot(): ConversationSnapshot { diff --git a/packages/client/ui-conversation/src/client/input/editor/keymap.ts b/packages/client/ui-conversation/src/client/input/editor/keymap.ts index 7291f2c25a..e105a1d6c0 100644 --- a/packages/client/ui-conversation/src/client/input/editor/keymap.ts +++ b/packages/client/ui-conversation/src/client/input/editor/keymap.ts @@ -32,7 +32,7 @@ export interface ComposerKeymapHandlers { dismissPopup(): void /** Whether Enter may submit right now (locked/busy states refuse). */ canSubmit(): boolean - /** The Enter gesture after every guard passed; `accelerated` = Ctrl/Cmd held. */ + /** Plain Enter submits; exactly Ctrl+Enter or Cmd+Enter selects accelerated delivery. */ submit(accelerated: boolean): void /** * Pasted files with directory metadata supplied by the clipboard entry API. @@ -133,8 +133,11 @@ export function registerComposerKeymap(editor: LexicalEditor, handlers: Composer return false }, COMMAND_PRIORITY_CRITICAL), editor.registerCommand(KEY_ENTER_COMMAND, (event) => { - // Shift+Enter is the native line break UNCONDITIONALLY — decided before - // the IME guard so a composition-closing Shift+Enter still breaks the line. + // Returning true stops Lexical's fallback line break without consuming + // the DOM event that application shortcuts need. + if (event !== null && (event.altKey || event.getModifierState('AltGraph') + || (event.ctrlKey && event.metaKey) || (event.shiftKey && (event.ctrlKey || event.metaKey)))) return true + // Plain Shift+Enter keeps its native line break, including during composition. if (event?.shiftKey === true) return false if (event !== null && isComposingEvent(event, recentlyComposing)) { // The IME consumes this Enter (candidate pick); neither submit nor diff --git a/packages/client/ui-conversation/src/client/input/editor/runtime.ts b/packages/client/ui-conversation/src/client/input/editor/runtime.ts index f59a6d947d..cf83316fea 100644 --- a/packages/client/ui-conversation/src/client/input/editor/runtime.ts +++ b/packages/client/ui-conversation/src/client/input/editor/runtime.ts @@ -3,7 +3,8 @@ import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { LexicalEditor, NodeKey } from 'lexical' import { $addUpdateTag, $createParagraphNode, $createTextNode, $getRoot, $getSelection, $isRangeSelection, - CLEAR_HISTORY_COMMAND, createEditor, HISTORY_MERGE_TAG, PASTE_TAG, + BLUR_COMMAND, CLEAR_HISTORY_COMMAND, COMMAND_PRIORITY_CRITICAL, createEditor, HISTORY_MERGE_TAG, PASTE_TAG, + RootNode, SELECTION_CHANGE_COMMAND, SKIP_DOM_SELECTION_TAG, } from 'lexical' import { registerPlainText } from '@lexical/plain-text' import { createEmptyHistoryState, registerHistory } from '@lexical/history' @@ -65,8 +66,23 @@ export class DraftEditorRuntime { * @returns unregister callback that also detaches the editor root. */ register(): () => void { + // A retained draft selection must not move keyboard focus back from another control. + const preserveExternalSelection = (): false => { + const root = this.editor.getRootElement() + if (root !== null && !root.contains(root.ownerDocument.activeElement)) { + $addUpdateTag(SKIP_DOM_SELECTION_TAG) + } + return false + } const unregister = mergeRegister( registerPlainText(this.editor), + this.editor.registerCommand(BLUR_COMMAND, () => { + // Finish this batch before an explicit focus can restore its updated selection. + this.editor.update(preserveExternalSelection, { discrete: true }) + return false + }, COMMAND_PRIORITY_CRITICAL), + this.editor.registerCommand(SELECTION_CHANGE_COMMAND, preserveExternalSelection, COMMAND_PRIORITY_CRITICAL), + this.editor.registerNodeTransform(RootNode, preserveExternalSelection), registerReferenceActivation(this.editor, (source, reference) => this.deps.openReference(source, reference)), registerHistory(this.editor, createEmptyHistoryState(), HISTORY_MERGE_DELAY_MS), diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index aab4735316..cf5c846800 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -9,6 +9,11 @@ const PLAN_NEXT_ACTION_EN = 'describe your task to generate plan' /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { + 'shortcut.hint': '{label} {keys}', + 'shortcut.newline': '换行', + 'shortcut.complementary': '使用互补的 Queue/Steer 方式', + 'shortcut.slash': '打开命令菜单', + 'shortcut.mention': '打开引用菜单', 'hint.plan': PLAN_NEXT_ACTION_ZH, 'hint.goal': '输入目标,智能体将持续执行', 'hint.goal.active': '当前目标进行中。可输入 edit 修改 / pause 暂停 / resume 继续 / clear 清除', @@ -361,6 +366,11 @@ export type ConversationKey = keyof typeof zh /** English dictionary, checked complete against the zh key set. */ export const en = { + 'shortcut.hint': '{label} {keys}', + 'shortcut.newline': 'New line', + 'shortcut.complementary': 'Use the complementary Queue / Steer action', + 'shortcut.slash': 'Open command menu', + 'shortcut.mention': 'Open reference menu', 'hint.plan': PLAN_NEXT_ACTION_EN, 'hint.goal': 'describe the objective for a long-running task', 'hint.goal.active': 'goal active — edit / pause / resume / clear', diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx index adace1c113..23c1c3042d 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx @@ -174,7 +174,7 @@ export function ConversationContent(props: ConversationContentProps) { // on the fallback alone would leave a business-owned takeover at the content // end off-screen when the user is not pinned to the floor. const composerSeat = ( -
+
{composer}
) @@ -184,6 +184,8 @@ export function ConversationContent(props: ConversationContentProps) { ref={setBody} className={clsx(css.body, props.variant === 'embedded' && css.embeddedBody)} data-conversation-content="" + data-conversation-session={sessionId} + data-conversation-region="chat" data-content-phase={phase} >
diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index dea8a86934..9139aa9ac8 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -46,7 +46,7 @@ export const InputBar = memo(function InputBar({ useSession, useInput, inputActions, keyboard, addFiles, removeAttachment, resolveDraftAttachments, retryFileUpload, toggleCommandMenu, stop, t, - renderSlot, useBusyEnter, useFileUploads, useNotices, useLexicon, useMenuLauncher, + renderSlot, useBusyEnter, useFileUploads, useNotices, useLexicon, useMenuLauncher, useStopShortcut, useProjection, sessionId, variant, disabled: inert = false, blocked, workspacePickerOpen = false, onRequestWorkspace, placeholder, accessory, @@ -54,6 +54,7 @@ export const InputBar = memo(function InputBar({ const input = useInput(s => s) const notice = useNotices(s => s) const busyEnter = useBusyEnter(s => s) + const stopKeys = useStopShortcut(keys => keys) void useLexicon // hook seat stays bound by the inject compartment; text-ref decoration rides the shell's editor transforms const commandMenuOpen = useMenuLauncher(source => source === 'command') const [activity, setActivity] = useState(false) @@ -310,6 +311,7 @@ export const InputBar = memo(function InputBar({ : running && steeringAvailable && !disabled && !uploadsPending && plainMessageDraft ? t(primarySubmitMode === 'steer' ? 'input.send.steer' : 'input.send.queue') : t('input.send') + const stopHint = stopKeys.length ? t('shortcut.hint', { label: t('input.stop'), keys: stopKeys.join(' ') }) : t('input.stop') const onPrimary = (): void => { if (primaryStops) { stop?.() @@ -457,7 +459,7 @@ export const InputBar = memo(function InputBar({ {renderSlot('conversation.input.activity', { locked, onActiveChange: setActivity })}
} {interruptible && ( - + {(canCloseTab?.(tab.id) ?? true) && ( - - + + + )} {menu?.tabId === tabId && ( {!(callbacks.hideSplitWhenBlocked && block !== undefined) && ( - - + + )} {/* The embedder's surface-wide controls, in the top-right pane only: the @@ -416,6 +423,7 @@ export function TabPanel({ state, pane, callbacks }: TabPanelProps): ReactNode {
{ if (state.activePaneId !== pane.id) callbacks.onFocusPane(pane.id) diff --git a/packages/client/ui-dockkit/src/contract/adapter.ts b/packages/client/ui-dockkit/src/contract/adapter.ts index 81239e816e..b35fbb18d7 100644 --- a/packages/client/ui-dockkit/src/contract/adapter.ts +++ b/packages/client/ui-dockkit/src/contract/adapter.ts @@ -19,12 +19,20 @@ export interface DockLabels { readonly emptyPane: string /** The split control, while splitting is allowed. */ readonly splitPane: string + /** Effective localized action and shortcut tooltip, when supplied. */ + readonly splitPaneTooltip?: string + /** ARIA combination from the effective binding, when bound. */ + readonly splitPaneShortcut?: string | undefined /** The split control, once the pane budget is spent. */ readonly splitPaneDisabled: string /** The split control, while the pane is too narrow for two working halves. */ readonly splitPaneNarrow: string /** Destroy a tab: the chip's close control and the menu's close item. */ readonly closeTab: string + /** Effective close label and keycaps supplied by the embedder. */ + readonly closeTabTooltip?: string + /** Effective close combination, when bound. */ + readonly closeTabShortcut?: string | undefined /** The strip's add control, which seats the embedder's seeded tab. */ readonly addTab: string /** Send a floating panel back into the docked tree. */ diff --git a/packages/client/ui-dockkit/tests/components.client.spec.tsx b/packages/client/ui-dockkit/tests/components.client.spec.tsx index 85768ebb55..33d0d67130 100644 --- a/packages/client/ui-dockkit/tests/components.client.spec.tsx +++ b/packages/client/ui-dockkit/tests/components.client.spec.tsx @@ -256,7 +256,7 @@ describe('DockSurface', () => { renderSurface(controller, intents, false) const disabled = screen.getByRole('button', { name: TEST_LABELS.splitPane }) expect(disabled.hasAttribute('disabled')).toBe(true) - expect(disabled.getAttribute('title')).toBe(TEST_LABELS.splitPaneDisabled) + expect(disabled.parentElement?.getAttribute('aria-label')).toBe(TEST_LABELS.splitPaneDisabled) expect(disabled.getAttribute('data-dockkit-split-blocked')).toBe('budget') }) @@ -336,7 +336,7 @@ describe('DockSurface', () => { expect(narrowButton).toBeNull() } else { expect(narrowButton?.hasAttribute('disabled')).toBe(true) - expect(narrowButton?.getAttribute('title')).toBe(TEST_LABELS.splitPaneNarrow) + expect(narrowButton?.parentElement?.getAttribute('aria-label')).toBe(TEST_LABELS.splitPaneNarrow) expect(narrowButton?.getAttribute('data-dockkit-split-blocked')).toBe('width') } }) @@ -815,6 +815,64 @@ describe('DockSurface', () => { expect(screen.queryByRole('menu')).toBeNull() }) + it.each(['tab', 'item'])('Escape dismisses a reopened menu from the %s without closing its tab', (focus) => { + const intents = spyIntents() + const { unmount } = renderSurface(seededController(), intents) + const tab = screen.getByRole('tab') + for (let attempt = 0; attempt < 2; attempt++) { + fireEvent.contextMenu(tab) + const target = focus === 'tab' ? tab : screen.getByRole('menuitem') + target.focus() + expect(fireEvent.keyDown(target, { key: 'Escape', code: 'Escape' })).toBe(false) + expect(screen.queryByRole('menu')).toBeNull() + expect(document.activeElement).toBe(tab) + expect(intents.closeTab).not.toHaveBeenCalled() + } + unmount() + expect(fireEvent.keyDown(document.body, { key: 'Escape', code: 'Escape' })).toBe(true) + }) + + it('keeps the menu during composition, modified Escape and key repeat', () => { + renderSurface(seededController(), spyIntents()) + const tab = screen.getByRole('tab') + fireEvent.contextMenu(tab) + for (const modifier of ['ctrlKey', 'altKey', 'metaKey', 'shiftKey', 'isComposing']) { + expect(fireEvent.keyDown(tab, { key: 'Escape', [modifier]: true })).toBe(true) + expect(screen.getByRole('menu')).toBeDefined() + } + fireEvent.compositionStart(tab) + fireEvent.keyDown(tab, { key: 'Escape' }) + expect(screen.getByRole('menu')).toBeDefined() + fireEvent.compositionEnd(tab) + fireEvent.keyDown(tab, { key: 'Escape' }) + expect(screen.getByRole('menu')).toBeDefined() + fireEvent.keyUp(tab, { key: 'Escape' }) + expect(fireEvent.keyDown(tab, { key: 'Escape', repeat: true })).toBe(false) + expect(screen.getByRole('menu')).toBeDefined() + fireEvent.keyDown(tab, { key: 'Escape' }) + expect(screen.queryByRole('menu')).toBeNull() + }) + + it('yields Escape to a foreground modal and consumes it before a parent handler', () => { + renderSurface(seededController(), spyIntents()) + const tab = screen.getByRole('tab') + fireEvent.contextMenu(tab) + const modal = document.createElement('div') + modal.setAttribute('role', 'dialog') + modal.setAttribute('aria-modal', 'true') + document.body.append(modal) + onTestFinished(() => { modal.remove() }) + expect(fireEvent.keyDown(modal, { key: 'Escape' })).toBe(true) + expect(screen.getByRole('menu')).toBeDefined() + modal.remove() + const parent = vi.fn((event: KeyboardEvent) => { expect(event.defaultPrevented).toBe(true) }) + document.addEventListener('keydown', parent) + onTestFinished(() => { document.removeEventListener('keydown', parent) }) + fireEvent.keyDown(tab, { key: 'Escape' }) + expect(parent).toHaveBeenCalledOnce() + expect(screen.queryByRole('menu')).toBeNull() + }) + it('hangs the menu below the chip\'s left edge, or from its right edge when it would run off the viewport', () => { const controller = seededController() let left = 10 @@ -1118,6 +1176,18 @@ describe('keyboard tabs', () => { expect(intents.focusTab).toHaveBeenCalledTimes(2) }) + it('leaves modified and composing keys available to their owners', () => { + const { intents, chip } = threeChips() + const tab = chip('a.txt') + tab.focus() + for (const modifiers of [{ ctrlKey: true, altKey: true }, { metaKey: true, altKey: true }, { shiftKey: true }, { isComposing: true }]) { + expect(fireEvent.keyDown(tab, { key: 'Enter', ...modifiers })).toBe(true) + expect(fireEvent.keyDown(tab, { key: 'ArrowRight', ...modifiers })).toBe(true) + expect(document.activeElement).toBe(tab) + } + expect(intents.focusTab).not.toHaveBeenCalled() + }) + it('records nothing for Enter on the active pane\'s selected chip, and leaves keys on the close control alone', () => { const { intents, chip } = threeChips() fireEvent.keyDown(chip('b.txt'), { key: 'Enter' }) @@ -1483,7 +1553,7 @@ describe('surface chrome', () => { const role = (child: Element): string => { if (child.hasAttribute('data-dockkit-strip-tabs')) return 'tabs' if (child.hasAttribute('data-dockkit-add-tab')) return 'add' - if (child.hasAttribute('data-dockkit-split-button')) return 'split' + if (child.hasAttribute('data-dockkit-split-button') || child.querySelector('[data-dockkit-split-button]')) return 'split' if (child.hasAttribute('data-dockkit-strip-chrome')) return 'chrome' return 'fill' } diff --git a/packages/client/ui-layout/README.i18n.yaml b/packages/client/ui-layout/README.i18n.yaml index 57e2a79c7a..329f825ce9 100644 --- a/packages/client/ui-layout/README.i18n.yaml +++ b/packages/client/ui-layout/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-layout/README.md -README.md: b26b182f5ea2c727411910cc5f85819b9dfbcb2c -README.zh.md: 58ef066ab05389898dc912500cd6b37f1a07824d +README.md: c3747fd855fe8bd9a622b4859e49f7746ad56147 +README.zh.md: 169a9c250da3fd4f3c4a43b5c76ffdfe45d16f0c diff --git a/packages/client/ui-layout/README.md b/packages/client/ui-layout/README.md index b26b182f5e..c3747fd855 100644 --- a/packages/client/ui-layout/README.md +++ b/packages/client/ui-layout/README.md @@ -25,6 +25,8 @@ This package provides the Web GUI's three-column AppFrame, edge-column widths, a ## Use this package +Desktop Mod+B toggles the left sidebar through the same layout action used by its controls. The shortcut command is unavailable behind modal dialogs and yields to terminal input. + The root slot composes the sidebar, main content, and right column. The sidebar spans 264–420px, defaults to 280px, and retains a 56px rail when collapsed; below 1024px it collapses automatically, and opening the right panel collapses a manually expanded sidebar. The right panel first opens at 45% of the viewport, then retains the user's pixel preference, capped at 70%. To protect 400px for the center, the frame first reduces the right panel to 300px, then reports insufficient room so its occupant closes it, and only then compresses the center further. Dragging has no transition delay; the right handle is absent while closed or fullscreen. Global panels occupy the root-scoped `main` keyed slot; `conversation` is the reserved key for the Conversation. `ctx.layout.selectPanel(id)` selects a registered panel, and `null` selects the Conversation without changing the current Session. No global panel is registered by the shipped composition. @@ -49,7 +51,7 @@ The presenter consumes resolved theme snapshots and projects them onto the docum `selectPanel(id)` checks the live `main` registry before changing selection; an absent key throws and leaves the current panel intact. `beginNavigation()` returns an abort signal for an asynchronous UI navigation. A later call, a valid panel selection (including repeated selection), or layout disposal aborts that signal without cancelling underlying Session creation. Consumers check the signal before committing navigation or moving drafts. -One registration declares five child slots and binds `ctx.layout` methods `selectPanel`, `toggleSidebar`, `openRightbar(track, fullscreen)`, and `closeRightbar`. One root store separates `panelInfo` selection from `layoutInfo` measurements, width preferences, and presentation reports. `usePanelInfo` subscribes to the stable selection object; AppFrame subscribes to the stable layout object. The `rightbar` owner supplies actual `width`, `viewportWidth`, and normal-presentation eligibility `canShow`; insufficient room causes a deterministic close, never automatic reopening on widening. Fullscreen hides the width handle without releasing a track the occupant retains. AppFrame keeps the column containers mounted. The right column's root controller renders `rightbar.session` through `SessionProvider` only while the Conversation is selected; its unmount report releases the track. The independent title component uses the selected Session title only while the Conversation is visible, with the build-configured product title or localized `common.brand.localBuild` as its fallback; locale revisions update that fallback. The theme presenter is a second effect: pure DOM writes from resolved snapshots — initial state through the getter once, then event-driven only, with no React path. It applies palette, font-size, and token variables before measuring the rendered background as the single color authority. Fullscreen presentation suppresses grid and handle transitions; its occupant reports the new columns only after covering the frame. Fullscreen exit keeps transitions suppressed while the frame installs its destination geometry: close removes the right track, and restore retains it. Subsequent normal geometry actions restore ordinary transitions. +One registration declares five child slots and binds `ctx.layout` methods `selectPanel`, `toggleSidebar`, `openRightbar(track, fullscreen)`, and `closeRightbar`. One root store separates `panelInfo` selection from `layoutInfo` measurements, width preferences, and presentation reports. `ctx.layout.panelInfo` and the standard `usePanelInfo` hook share the same selection source; AppFrame subscribes to the stable layout object. The `rightbar` owner supplies actual `width`, `viewportWidth`, and normal-presentation eligibility `canShow`; insufficient room causes a deterministic close, never automatic reopening on widening. Fullscreen hides the width handle without releasing a track the occupant retains. AppFrame keeps the column containers mounted. The right column's root controller renders `rightbar.session` through `SessionProvider` only while the Conversation is selected; its unmount report releases the track. The independent title component uses the selected Session title only while the Conversation is visible, with the build-configured product title or localized `common.brand.localBuild` as its fallback; locale revisions update that fallback. The theme presenter is a second effect: pure DOM writes from resolved snapshots — initial state through the getter once, then event-driven only, with no React path. It applies palette, font-size, and token variables before measuring the rendered background as the single color authority. Fullscreen presentation suppresses grid and handle transitions; its occupant reports the new columns only after covering the frame. Fullscreen exit keeps transitions suppressed while the frame installs its destination geometry: close removes the right track, and restore retains it. Subsequent normal geometry actions restore ordinary transitions. diff --git a/packages/client/ui-layout/README.zh.md b/packages/client/ui-layout/README.zh.md index 58ef066ab0..169a9c250d 100644 --- a/packages/client/ui-layout/README.zh.md +++ b/packages/client/ui-layout/README.zh.md @@ -25,6 +25,8 @@ kind: "package-reference" ## 使用本包 +桌面 Mod+B 通过与控件共用的布局操作切换左侧栏。模态弹窗打开时此快捷键不可用;终端输入优先。 + 本插件在 root slot 中组合侧边栏、主内容和右栏。侧边栏宽度为 264~420px,默认为 280px,收起后保留 56px 控制栏;窗口宽度低于 1024px 时自动收起,打开右侧面板也会收起手动展开的侧边栏。右侧面板首次打开时使用视口宽度的 45%,之后保留用户的像素宽度偏好,上限为 70%。为给中栏保留 400px,框架先将右侧面板缩减至 300px,再报告空间不足,使占用方将其关闭,最后才进一步压缩中栏。拖动没有过渡延迟;右侧手柄在关闭或全屏时不显示。 全局面板占据 root 作用域的 `main` keyed slot;`conversation` 是为会话界面保留的 key。`ctx.layout.selectPanel(id)` 选中已注册面板,`null` 则选中会话界面,但不改变当前会话。默认组合不注册任何全局面板。 @@ -49,7 +51,7 @@ Windows Electron 的 `data-windows-titlebar` 标记在所有列上方预留顶 `selectPanel(id)` 在改变选中态前检查实时 `main` 注册表;缺失的 key 会抛错并保留当前面板。`beginNavigation()` 为异步 UI 导航返回 abort signal。后续调用、有效面板选择(包括重复选择)或布局释放会中止该 signal,但不取消底层会话创建。消费方在提交导航或搬移草稿前检查 signal。 -一次注册声明五个子 slot,并绑定 `ctx.layout` 的 `selectPanel`、`toggleSidebar`、`openRightbar(track, fullscreen)` 与 `closeRightbar`。同一个 root 存储把 `panelInfo` 选中态与 `layoutInfo` 测量、宽度偏好、呈现报告分开。`usePanelInfo` 订阅引用稳定的选中态对象,AppFrame 订阅引用稳定的布局对象。`rightbar` owner 提供实际 `width`、`viewportWidth`,以及表示能否以普通模式呈现的 `canShow`;占用方在空间不足时执行确定性的收起,变宽不自行重新展开。全屏隐藏宽度手柄,但不自行释放占用方要求保留的轨道。AppFrame 保持各列容器挂载。右栏的 root 控制器仅在选中会话界面时,经 `SessionProvider` 渲染 `rightbar.session`;内容卸载时的报告释放轨道。独立的标题组件仅在会话界面可见时使用所选会话标题,以构建配置的产品标题或本地化 `common.brand.localBuild` 为回退值;语言变化会更新该回退值。主题呈现器是第二个 effect:从解析后的快照做纯 DOM 写入——初始状态经 getter 读取一次,此后仅事件驱动,不经过 React。它先应用调色板、字号与 token 变量,再把渲染出的背景测量为唯一的颜色依据。全屏呈现禁用网格和手柄过渡;占用方完全覆盖框架后才报告新的列布局。退出全屏时,框架先保持无过渡并安装目标布局:关闭移除右轨道,恢复保留右轨道。后续普通几何操作恢复正常过渡。 +一次注册声明五个子 slot,并绑定 `ctx.layout` 的 `selectPanel`、`toggleSidebar`、`openRightbar(track, fullscreen)` 与 `closeRightbar`。同一个 root 存储把 `panelInfo` 选中态与 `layoutInfo` 测量、宽度偏好、呈现报告分开。`ctx.layout.panelInfo` 与标准 `usePanelInfo` hook 共用同一个选中态来源;AppFrame 订阅引用稳定的布局对象。`rightbar` owner 提供实际 `width`、`viewportWidth`,以及表示能否以普通模式呈现的 `canShow`;占用方在空间不足时执行确定性的收起,变宽不自行重新展开。全屏隐藏宽度手柄,但不自行释放占用方要求保留的轨道。AppFrame 保持各列容器挂载。右栏的 root 控制器仅在选中会话界面时,经 `SessionProvider` 渲染 `rightbar.session`;内容卸载时的报告释放轨道。独立的标题组件仅在会话界面可见时使用所选会话标题,以构建配置的产品标题或本地化 `common.brand.localBuild` 为回退值;语言变化会更新该回退值。主题呈现器是第二个 effect:从解析后的快照做纯 DOM 写入——初始状态经 getter 读取一次,此后仅事件驱动,不经过 React。它先应用调色板、字号与 token 变量,再把渲染出的背景测量为唯一的颜色依据。全屏呈现禁用网格和手柄过渡;占用方完全覆盖框架后才报告新的列布局。退出全屏时,框架先保持无过渡并安装目标布局:关闭移除右轨道,恢复保留右轨道。后续普通几何操作恢复正常过渡。 diff --git a/packages/client/ui-layout/package.json b/packages/client/ui-layout/package.json index 3ae2159164..1c4c7ea22b 100644 --- a/packages/client/ui-layout/package.json +++ b/packages/client/ui-layout/package.json @@ -31,7 +31,8 @@ "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-renderer", "@deepseek-ai/dsh-client-ui-session", - "@deepseek-ai/dsh-client-ui-theme" + "@deepseek-ai/dsh-client-ui-theme", + "@deepseek-ai/dsh-client-shortcuts" ], "platform": "web" } @@ -54,7 +55,8 @@ "@deepseek-ai/dsh-client-ui-theme": "workspace:^", "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:^", - "react": "^18.2.0" + "react": "^18.2.0", + "@deepseek-ai/dsh-client-shortcuts": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-layout/src/client/index.ts b/packages/client/ui-layout/src/client/index.ts index 045fcb8a39..410c8b0977 100644 --- a/packages/client/ui-layout/src/client/index.ts +++ b/packages/client/ui-layout/src/client/index.ts @@ -17,6 +17,8 @@ import type { PanelInfo } from './service.ts' import { AppFrame } from './AppFrame.tsx' import { createLayoutStore } from './stores.ts' import { LayoutController } from './service.ts' +import type { ShortcutCommandId } from '@deepseek-ai/dsh-client-shortcuts/client' +import { en, zh } from './shortcut-locales.ts' import { ThemePresenter } from './theme-presenter.ts' // Contract exports only (export-convergence rule: cross-package consumers @@ -38,6 +40,11 @@ declare module '@deepseek-ai/cordis' { } declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Layout keyboard command labels. */ + 'shortcuts.layout': keyof typeof zh + } + interface GlobalStandardProps { /** Subscribe to the selected main panel independently of parent renders. */ usePanelInfo: UsePanelInfo @@ -133,7 +140,7 @@ export interface RightbarOwnerProps { } /** Required services (cordis fiber inject — the loader passes all module exports as an object plugin). */ -export const inject = ['slots', 'theme', 'locale'] +export const inject = ['slots', 'theme', 'locale', 'shortcuts'] /** * Client plugin body: provide ctx.layout, then one register() call — AppFrame @@ -142,12 +149,13 @@ export const inject = ['slots', 'theme', 'locale'] * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { + ctx.effect(() => ctx.locale.register('shortcuts.layout', { zh, en }), 'layout: command labels') + const t = ctx.locale.bind('shortcuts.layout') + ctx.effect(() => { const handle = createLayoutStore() const instance = handle.create() const store: typeof handle = { ...handle, create: () => instance } - const layout = new LayoutController(instance.actions, id => - ctx.slots.entries('main').some(entry => entry.options.key === id)) const retainMainPanels = (): void => { instance.actions.retainMainPanels(ctx.slots.entries('main').flatMap(entry => entry.options.key === undefined ? [] : [entry.options.key])) @@ -156,7 +164,9 @@ export function apply(ctx: ClientContext): void { getSnapshot: () => instance.getSnapshot().panelInfo, subscribe: listener => instance.subscribe(listener), } - const disposePanelInfo = ctx.slots.provideRoot({ hooks: { panelInfo } }) + const layout = new LayoutController(instance.actions, id => + ctx.slots.entries('main').some(entry => entry.options.key === id), panelInfo) + const disposePanelInfo = ctx.slots.provideRoot({ hooks: { panelInfo: layout.panelInfo } }) const disposeService = ctx.reflect.provide('layout', layout) const disposeRegistration = ctx.slots.register({ name: 'root', @@ -170,9 +180,22 @@ export function apply(ctx: ClientContext): void { }, store, }, AppFrame) + const disposeShortcut = ctx.shortcuts.register({ + id: 'sidebar.left.toggle' as ShortcutCommandId, label: () => t('toggle'), aliases: ['sidebar', 'toggle left sidebar'], + defaults: { + 'desktop:macos': { code: 'KeyB', modifiers: ['primary'] }, + 'desktop:windows': { code: 'KeyB', modifiers: ['primary'] }, + 'desktop:linux': { code: 'KeyB', modifiers: ['primary'] }, + 'web:macos': { code: 'KeyB', modifiers: ['primary', 'alt'] }, + 'web:windows': { code: 'KeyB', modifiers: ['primary', 'alt'] }, + }, + regions: ['page', 'editable'], modals: [], + resolve: () => ({ status: 'handled', run: () => { layout.toggleSidebar() } }), + }) const disposePanels = ctx.slots.subscribe('main', retainMainPanels) retainMainPanels() return () => { + disposeShortcut() layout.dispose() disposePanels() disposeRegistration() diff --git a/packages/client/ui-layout/src/client/service.ts b/packages/client/ui-layout/src/client/service.ts index 14a3924d63..22dd77a4e6 100644 --- a/packages/client/ui-layout/src/client/service.ts +++ b/packages/client/ui-layout/src/client/service.ts @@ -8,7 +8,7 @@ * right-panel show/hide from ui-sidebar-right) — writes stay inside the * store's declared action set, shared with the root registration. */ -import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots' +import type { BoundActions, HostObservable } from '@deepseek-ai/dsh-client-ui-slots' import type { Branded } from '@deepseek-ai/dsh-brand' import type { createLayoutStore } from './stores.ts' @@ -26,6 +26,8 @@ export type PanelActions = BoundActions> /** Panel navigation and geometry actions exposed through ctx.layout. */ export interface ILayout { + /** Selected central panel from the same root store used by `usePanelInfo`. */ + readonly panelInfo: HostObservable /** * Select a global central panel without changing the current Session. * @param panelId - registered main key, or null to show the Conversation. @@ -58,10 +60,12 @@ export class LayoutController implements ILayout { /** * @param panels - actions of the instance shared with the root entry. * @param hasMainPanel - checks the live main-slot registry for a panel id. + * @param panelInfo - root store's shared central-panel selection source. */ constructor( private readonly panels: PanelActions, private readonly hasMainPanel: (id: MainPanelId) => boolean, + readonly panelInfo: HostObservable, ) {} /** Select a global panel or return to the Conversation. */ diff --git a/packages/client/ui-layout/src/client/shortcut-locales.ts b/packages/client/ui-layout/src/client/shortcut-locales.ts new file mode 100644 index 0000000000..7c90e1ae9a --- /dev/null +++ b/packages/client/ui-layout/src/client/shortcut-locales.ts @@ -0,0 +1,4 @@ +/** Layout command labels. */ +export const zh = { toggle: '展开/收起左侧栏' } +/** English labels for the same layout commands. */ +export const en: Record = { toggle: 'Toggle left sidebar' } diff --git a/packages/client/ui-layout/tests/apply.client.spec.ts b/packages/client/ui-layout/tests/apply.client.spec.ts index 0e29721622..c38719c68d 100644 --- a/packages/client/ui-layout/tests/apply.client.spec.ts +++ b/packages/client/ui-layout/tests/apply.client.spec.ts @@ -1,5 +1,6 @@ // @vitest-environment jsdom +import ShortcutsService from '../../shortcuts/src/client/index.ts' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { Context, type Fiber } from '@deepseek-ai/cordis' import { stubConfigForm } from '@deepseek-ai/dsh-client-test-runtime' @@ -58,6 +59,7 @@ async function bench() { // Theme registers its Appearance settings row and requires the connection // seam for persistence; model this bench as a remote, memory-only browser. ctx.provide('locale', new LocaleRuntime(ctx)) + await ctx.plugin(ShortcutsService).await() ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) // ui-theme's Appearance row binds a durable scope through these two. ctx.provide('remote', { $on: () => () => {} } as never) @@ -77,7 +79,7 @@ async function bench() { describe('ui-layout client apply', () => { it('declares its service dependencies', () => { - expect(inject).toEqual(['slots', 'theme', 'locale']) + expect(inject).toEqual(['slots', 'theme', 'locale', 'shortcuts']) }) it('provides ctx.layout and declares the five root-scoped frame slots', async () => { @@ -109,6 +111,7 @@ describe('ui-layout client apply', () => { const host = rendererHost() expect(host.storeOf(entry, undefined)).toBe(instance) const panelInfo = host.root.getSnapshot().hooks.panelInfo! + expect(panelInfo).toBe(layout.panelInfo) expect(panelInfo.getSnapshot()).toBe(instance.getSnapshot().panelInfo) const panelId = 'panel-a' as MainPanelId const disposePanel = slots.register({ name: 'main', key: panelId }, () => null) diff --git a/packages/client/ui-layout/tests/service.client.spec.ts b/packages/client/ui-layout/tests/service.client.spec.ts index 7ccadb5c3c..b3e3680d5c 100644 --- a/packages/client/ui-layout/tests/service.client.spec.ts +++ b/packages/client/ui-layout/tests/service.client.spec.ts @@ -1,3 +1,4 @@ +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { describe, expect, it, vi } from 'vitest' import { LayoutController } from '../src/client/service.ts' import type { MainPanelId, PanelActions } from '../src/client/service.ts' @@ -18,7 +19,7 @@ function fakePanels(): PanelActions { describe('LayoutController', () => { it('forwards right column transitions to the constructor-supplied actions', () => { const panels = fakePanels() - const service = new LayoutController(panels, () => true) + const service = new LayoutController(panels, () => true, createSnapshotStore({ activePanelId: null })) service.openRightbar(true, false) service.openRightbar(true, true) @@ -35,7 +36,7 @@ describe('LayoutController', () => { it('can toggle the sidebar immediately after construction', () => { const panels = fakePanels() - const service = new LayoutController(panels, () => true) + const service = new LayoutController(panels, () => true, createSnapshotStore({ activePanelId: null })) service.toggleSidebar() @@ -45,7 +46,7 @@ describe('LayoutController', () => { it('forwards panel selection and returning to the Conversation without changing geometry', () => { const panels = fakePanels() - const service = new LayoutController(panels, () => true) + const service = new LayoutController(panels, () => true, createSnapshotStore({ activePanelId: null })) const panelId = 'panel-a' as MainPanelId service.selectPanel(panelId) service.selectPanel(panelId) @@ -61,8 +62,8 @@ describe('LayoutController', () => { it('keeps separately constructed controllers bound to their own instances', () => { const first = fakePanels() const second = fakePanels() - const firstService = new LayoutController(first, () => true) - const secondService = new LayoutController(second, () => true) + const firstService = new LayoutController(first, () => true, createSnapshotStore({ activePanelId: null })) + const secondService = new LayoutController(second, () => true, createSnapshotStore({ activePanelId: null })) firstService.toggleSidebar() expect(first.toggleSidebar).toHaveBeenCalledTimes(1) expect(second.toggleSidebar).not.toHaveBeenCalled() @@ -74,7 +75,7 @@ describe('LayoutController', () => { it('rejects an absent main entry without changing selection or cancelling pending navigation', () => { const panels = fakePanels() const present = new Set(['panel-a']) - const service = new LayoutController(panels, id => present.has(id)) + const service = new LayoutController(panels, id => present.has(id), createSnapshotStore({ activePanelId: null })) service.selectPanel('panel-a' as MainPanelId) const navigation = service.beginNavigation() present.delete('panel-a') @@ -86,7 +87,7 @@ describe('LayoutController', () => { }) it('supersedes asynchronous navigation on another request, any valid selection, and disposal', () => { - const service = new LayoutController(fakePanels(), () => true) + const service = new LayoutController(fakePanels(), () => true, createSnapshotStore({ activePanelId: null })) const first = service.beginNavigation() const second = service.beginNavigation() expect(first.aborted).toBe(true) diff --git a/packages/client/ui-layout/tsconfig.json b/packages/client/ui-layout/tsconfig.json index 8893b1800d..b2daaaf054 100644 --- a/packages/client/ui-layout/tsconfig.json +++ b/packages/client/ui-layout/tsconfig.json @@ -34,6 +34,9 @@ }, { "path": "../ui-session" + }, + { + "path": "../shortcuts/tsconfig.client.json" } ] } diff --git a/packages/client/ui-open-in-app/README.i18n.yaml b/packages/client/ui-open-in-app/README.i18n.yaml index b0fa75db3e..e426bd97fe 100644 --- a/packages/client/ui-open-in-app/README.i18n.yaml +++ b/packages/client/ui-open-in-app/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-open-in-app/README.md -README.md: 138be128c291fc4a0611295c54768a95f33f3ae1 -README.zh.md: b81f62f9dbf8ccfbc1822b8a72df95e7169228c0 +README.md: 4a00f11998b2b3761c561508cd3f409d6c4cd3a8 +README.zh.md: 72475f9deb34d1ee65711b00f0069255a4d9a08f diff --git a/packages/client/ui-open-in-app/README.md b/packages/client/ui-open-in-app/README.md index 138be128c2..4a00f11998 100644 --- a/packages/client/ui-open-in-app/README.md +++ b/packages/client/ui-open-in-app/README.md @@ -31,6 +31,8 @@ Mount this plugin in the Web composition beside [`dsh-host-open-in-app`](../../h The Session header and document header use the same 24px-high split button with 9px corners. Both headers show only the icon; their tooltip names the default application or the reveal action. Both show the default action’s icon, mark its application with “(default)” in the menu, disable while their own gesture runs, and report failures through a transient toast. The directory adapter uses the existing cross-platform application catalog and remembers the last successful choice in `dsh.open-in-app.choice`; a missing choice falls back to the first available application. +The **Open locally** shortcut captures the main Session's directory and the same remembered application as the header button. The effective binding appears in the button tooltip and `aria-keyshortcuts`; Web follows the [shortcut service’s platform defaults](../shortcuts/README.md). The command runs only while the Conversation is selected and the header button has a directory and installed application to open. An in-flight launch also blocks the command. Pointer and keyboard gestures share the controller's launch state, so repeated gestures cannot launch twice or change the remembered choice mid-launch. + File menus list the discovered associated applications without a separate “Open in default app” row. “Show file location” stays in a fixed footer separated from the scrolling applications. A successful query with only one available action renders a single button without a dropdown. Initial file-association loading uses a gray skeleton icon. When no default application is identified, that row reads “Show file location (default)” and the main button reveals the file. Directory menus have no reveal row. Choosing a file application does not change the system default. An unpreviewable file uses the same menu in a 40px-high split button with an icon and action text; its label is “Open” or “Show file location” according to the default action. Mounted controls with the same association reader and file share one query and its results; opening either menu refreshes all of them. Releasing the last control cancels the query and discards its state. Cancelled queries cannot replace another file’s results. A failed query displays a menu message and leaves reveal available as the default action. File-association discovery follows [native-command](../../util/native-command/README.md); an empty result, including a platform without a discovery adapter, uses the same reveal fallback without platform branches in the control. @@ -43,7 +45,7 @@ Mounted controls with the same association reader and file share one query and i
Implementation internals — click to expand -The plugin registers the split button on `conversation.session.header.utilities` through the standard slot/inject currency and registers the `open-in-app` dictionaries as one effect. A page-lifetime controller ([`src/client/controller.ts`](src/client/controller.ts)) owns the once-per-page availability read, the persisted choice snapshot store, and the launch POST; the component receives both stores through the inject `hooks` compartment, so every Session header shares one truth. Document-relative route forms and wire payload types come from the host package's browser-safe `@deepseek-ai/dsh-host-open-in-app/shared` subpath. In-flight launches are guarded by a ref — repeat clicks and menu picks during a launch are ignored whole (a pick would otherwise persist a choice the gesture never opened) — and the busy/error dress is timer-driven around the `launch` promise. +The plugin registers the split button on `conversation.session.header.utilities` through the standard slot/inject currency and registers the `open-in-app` dictionaries as one effect. A page-lifetime controller ([`src/client/controller.ts`](src/client/controller.ts)) owns the once-per-page availability read, the persisted choice snapshot store, and the launch POST; the component receives the shared sources through the inject `hooks` compartment, so every Session header shares one truth. Document-relative route forms and wire payload types come from the host package's browser-safe `@deepseek-ai/dsh-host-open-in-app/shared` subpath. The controller guards in-flight launches and publishes their captured directory and status; header controls derive delayed busy and transient error visuals from that source. The directory and file adapters supply application metadata and operations to [`OpenTargetButton`](src/client/OpenTargetButton.tsx), which owns menu ordering, default markers, icons, sizing, and gesture feedback. The file header and empty state share `FileOpenTarget`, while `OpenPathInjected.applications` queries `session.workspacePathApplications` through [`open-path.ts`](src/client/open-path.ts). Opening uses `session.openWorkspacePath`; the Host revalidates an explicitly selected handler before launch. The directory adapter keeps the existing catalog routes. `FileRouteAction` supplies the same control to delivery cards and change review through `deliverables.file.actions` and `deliverables.review.file.actions`; their authenticated routes retain Session file authorization. A failed or unavailable file query therefore needs no platform-specific UI implementation. diff --git a/packages/client/ui-open-in-app/README.zh.md b/packages/client/ui-open-in-app/README.zh.md index b81f62f9db..72475f9deb 100644 --- a/packages/client/ui-open-in-app/README.zh.md +++ b/packages/client/ui-open-in-app/README.zh.md @@ -31,6 +31,8 @@ kind: "package-reference" 会话标题栏和文档标题栏共用同一个高 24px、圆角 9px 的分体按钮。两个标题栏都只显示图标,悬停提示显示默认应用名称或文件定位动作。两者都显示默认动作的图标,在菜单的默认应用后标注“(默认)”,仅在自己的操作执行期间禁用,并通过短暂提示报告失败。目录适配器使用已有的跨平台应用列表,将最后一次成功选择保存在 `dsh.open-in-app.choice` 中;原选择不可用时回退到第一个可用应用。 +**在本地打开**快捷键捕获主会话的目录,并使用与头部按钮相同的已记住应用。按钮 tooltip 和 `aria-keyshortcuts` 显示当前有效绑定;Web 遵循[快捷键服务的平台默认值](../shortcuts/README.zh.md)。只有选中会话界面、且头部按钮有可打开的目录和已安装应用时,命令才执行。启动尚未结束时也会阻止命令。鼠标与键盘操作共享 controller 的启动状态,重复操作不会启动两次,也不会在启动期间更改已记住的选择。 + 文件菜单列出已发现的关联应用,不单列“用默认应用打开”。“显示文件位置”固定在菜单底部,通过分隔线与滚动的应用列表分开。查询成功且只有一个可用操作时显示单按钮,不再显示下拉箭头。文件关联首次加载时使用灰色骨架图标。未识别到默认应用时,该项显示为“显示文件位置(默认)”,主按钮也执行文件定位。目录菜单不包含定位项。选择文件应用不修改系统默认应用。无法预览文件时,空态使用同一菜单,按钮增大到 40px 高并显示图标和动作文字;根据默认动作显示“打开”或“显示文件位置”。 使用同一查询函数和文件的已挂载控件共用查询与结果,打开任一菜单会刷新所有相关控件。最后一个控件释放后取消查询并清除状态。已取消的查询不会覆盖其他文件的结果。查询失败时菜单显示提示,并以文件定位作为默认动作。文件关联查询的平台支持范围见 [native-command](../../util/native-command/README.zh.md);查询结果为空时,包括平台尚无查询适配器的情况,控件统一使用定位动作,不按操作系统分支处理。 @@ -43,7 +45,7 @@ kind: "package-reference"
实现内幕——点击展开 -插件通过标准 slot/inject 机制把分体按钮注册到 `conversation.session.header.utilities`,并以一个 effect 注册 `open-in-app` 词典。一个页面生命周期的 controller([`src/client/controller.ts`](src/client/controller.ts))拥有每页一次的可用性读取、持久化选择的 snapshot store 与启动 POST;组件经 inject 的 `hooks` 隔间接收两个 store,因此所有会话头部共享同一份事实。文档相对的路由形式与 wire 载荷类型来自主机包的浏览器安全子路径 `@deepseek-ai/dsh-host-open-in-app/shared`。飞行中的启动由 ref 守卫——启动期间的重复点击与菜单选择被整体忽略(否则会持久化一个该手势从未打开的选择)——busy/error 视觉由围绕 `launch` promise 的定时器驱动。 +插件通过标准 slot/inject 机制把分体按钮注册到 `conversation.session.header.utilities`,并以一个 effect 注册 `open-in-app` 词典。一个页面生命周期的 controller([`src/client/controller.ts`](src/client/controller.ts))拥有每页一次的可用性读取、持久化选择的 snapshot store 与启动 POST;组件经 inject 的 `hooks` 隔间接收共享源,因此所有会话头部共享同一份事实。文档相对的路由形式与 wire 载荷类型来自主机包的浏览器安全子路径 `@deepseek-ai/dsh-host-open-in-app/shared`。controller 守卫执行中的启动,并发布所捕获的目录与状态;头部控件从该源派生延迟出现的等待态和短暂错误态。 目录和文件适配器把应用信息与操作交给 [`OpenTargetButton`](src/client/OpenTargetButton.tsx),由它统一管理菜单顺序、默认标记、图标、尺寸和操作反馈。文件标题栏和空态共用 `FileOpenTarget`,`OpenPathInjected.applications` 通过 [`open-path.ts`](src/client/open-path.ts) 查询 `session.workspacePathApplications`。打开操作使用 `session.openWorkspacePath`,Host 在启动前重新验证指定的关联应用。`FileRouteAction` 通过 `deliverables.file.actions` 和 `deliverables.review.file.actions` 为交付卡片和变更对比页提供同一控件,其认证路由保留会话文件校验。目录适配器继续使用已有的应用列表路由,文件查询失败或不可用时无需增加平台专用的界面实现。 diff --git a/packages/client/ui-open-in-app/package.json b/packages/client/ui-open-in-app/package.json index 1d4d53f0de..8ebf61173f 100644 --- a/packages/client/ui-open-in-app/package.json +++ b/packages/client/ui-open-in-app/package.json @@ -42,7 +42,9 @@ "@deepseek-ai/dsh-api-remotes", "@deepseek-ai/dsh-api-session-controller", "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-shortcuts", "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-layout", "@deepseek-ai/dsh-client-ui-renderer", "@deepseek-ai/dsh-client-ui-session", "@deepseek-ai/dsh-client-ui-sidebar-documentpreview" @@ -62,6 +64,7 @@ "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-layout": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-session": "workspace:^", @@ -72,6 +75,7 @@ "@types/react": "~18.3.1", "react": "^18.2.0", "@deepseek-ai/dsh-client-ui-deliverables": "workspace:^", - "@deepseek-ai/dsh-native-command": "workspace:^" + "@deepseek-ai/dsh-native-command": "workspace:^", + "@deepseek-ai/dsh-client-shortcuts": "workspace:^" } } diff --git a/packages/client/ui-open-in-app/src/client/OpenInAppAction.tsx b/packages/client/ui-open-in-app/src/client/OpenInAppAction.tsx index a58ed63933..532508c557 100644 --- a/packages/client/ui-open-in-app/src/client/OpenInAppAction.tsx +++ b/packages/client/ui-open-in-app/src/client/OpenInAppAction.tsx @@ -1,7 +1,10 @@ import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' -import { NS, type OpenInAppKey } from './locales.ts' +import { APP_LABEL_KEY } from './applications.ts' +import type { OpenInAppLaunchState } from './controller.ts' +import type { ShortcutCatalogEntry } from '@deepseek-ai/dsh-client-shortcuts/client' +import { NS } from './locales.ts' import { OpenTargetButton } from './OpenTargetButton.tsx' /** Browser operations and state injected into the Session Header contribution. */ @@ -9,6 +12,8 @@ export interface OpenInAppActionInjected { hooks: { openInAppApps: ObservableSnapshot openInAppChoice: ObservableSnapshot + openInAppLaunch: ObservableSnapshot + shortcuts: ObservableSnapshot } launch: (appId: string, path: string) => Promise choose: (appId: string) => void @@ -21,47 +26,6 @@ export type OpenInAppActionProps = & PropsLocale & InjectFace -/** - * Label keys per catalog id: the browser renders only ids it can name, so a - * host catalog extension without a matching dictionary entry stays invisible - * instead of showing a raw id. - */ -const APP_LABEL_KEY: Record = { - finder: 'app.finder', - explorer: 'app.explorer', - filemanager: 'app.filemanager', - cursor: 'app.cursor', - vscode: 'app.vscode', - vscodeinsiders: 'app.vscodeinsiders', - windsurf: 'app.windsurf', - zed: 'app.zed', - sublimetext: 'app.sublimetext', - xcode: 'app.xcode', - androidstudio: 'app.androidstudio', - intellij: 'app.intellij', - pycharm: 'app.pycharm', - webstorm: 'app.webstorm', - phpstorm: 'app.phpstorm', - goland: 'app.goland', - rider: 'app.rider', - rustrover: 'app.rustrover', - fork: 'app.fork', - sourcetree: 'app.sourcetree', - github: 'app.github', - tower: 'app.tower', - gitkraken: 'app.gitkraken', - smartgit: 'app.smartgit', - sublimemerge: 'app.sublimemerge', - ghostty: 'app.ghostty', - warp: 'app.warp', - iterm: 'app.iterm', - kitty: 'app.kitty', - terminal: 'app.terminal', - windowsterminal: 'app.windowsterminal', - gitbash: 'app.gitbash', - gnometerminal: 'app.gnometerminal', - konsole: 'app.konsole', -} /** * Adapt the installed directory catalog to the shared opening control. @@ -73,6 +37,8 @@ export function OpenInAppAction(props: OpenInAppActionProps): React.JSX.Element const cwd = useSessions(state => state.byId[sessionId]?.cwd) const available = useOpenInAppApps(apps => apps) const choice = useOpenInAppChoice(id => id) + const operation = props.useOpenInAppLaunch(value => value) + const shortcut = props.useShortcuts(rows => rows.find(row => row.id === 'workspace.openLocal')) const apps = (available ?? []).flatMap((id) => { const key = APP_LABEL_KEY[id] return key === undefined ? [] : [{ id, name: t(key), icon: props.iconUrl(id) }] @@ -82,6 +48,7 @@ export function OpenInAppAction(props: OpenInAppActionProps): React.JSX.Element return ( { const id = operation.kind === 'application' ? operation.id : preferred.id try { diff --git a/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx b/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx index 9546c13153..87195baa7d 100644 --- a/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx +++ b/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx @@ -4,6 +4,7 @@ import type { ReactNode } from 'react' import { IconChevronDownOutlineRegular, IconFolderOpenOutlineRegular, IconRightUpOutlineRegular, Menu, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ShortcutCatalogEntry } from '@deepseek-ai/dsh-client-shortcuts/client' import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' import type { OpenInAppPathFailure } from './open-path.ts' import { useOpenFailureToast } from './open-failure-toast.tsx' @@ -22,6 +23,7 @@ export type OpenTargetOperation = { readonly kind: 'default' } | { readonly kind /** Inputs shared by both target adapters and the file empty-state action. */ export interface OpenTargetButtonProps { + readonly shortcut?: Pick | undefined readonly kind: 'file' | 'directory' readonly applications: readonly OpenTargetApplication[] readonly defaultId: string | undefined @@ -117,8 +119,9 @@ export function OpenTargetButton(props: OpenTargetButtonProps): ReactNode { anchor={(
- +
) @@ -307,6 +317,7 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI walkIndex.current = null return } + const composition = observeComposition(document) const onPointerDown = (e: PointerEvent) => { if (!(e.target instanceof Node)) return // The portaled list is outside the anchor subtree; check both. @@ -315,12 +326,15 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI onClose() } const onKeyDown = (e: KeyboardEvent) => { + if (composition.guards(e) || isBehindModal(rootRef.current) || e.defaultPrevented || e.ctrlKey || e.altKey || e.metaKey) return // Where the keyboard is, computed once: the menu owns it when it holds a // row or sits on its anchor region. const focused = document.activeElement const insideList = listRef.current?.contains(focused) === true const anchored = rootRef.current?.contains(focused) === true || insideList - if (e.key === 'Escape') { + if (e.key === 'Escape' && !e.shiftKey) { + e.preventDefault() + if (e.repeat) return // Closing hands the keyboard back when the menu had it — and, as this // primitive always did for autoFocus menus, when it held the keyboard // and lost it again (a row that unmounted under it). @@ -389,11 +403,16 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI if (document.activeElement instanceof HTMLIFrameElement) onClose() } document.addEventListener('pointerdown', onPointerDown) - document.addEventListener('keydown', onKeyDown) + const onEscape = (event: KeyboardEvent): void => { if (event.key === 'Escape') onKeyDown(event) } + const onOtherKey = (event: KeyboardEvent): void => { if (event.key !== 'Escape') onKeyDown(event) } + document.addEventListener('keydown', onOtherKey) + document.addEventListener('keydown', onEscape, true) window.addEventListener('blur', onWindowBlur) return () => { + composition.dispose() document.removeEventListener('pointerdown', onPointerDown) - document.removeEventListener('keydown', onKeyDown) + document.removeEventListener('keydown', onOtherKey) + document.removeEventListener('keydown', onEscape, true) window.removeEventListener('blur', onWindowBlur) } }, [open, onClose, autoFocus]) @@ -432,6 +451,7 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI role="menuitem" className={clsx(css.item, selected && (selection === 'fill' ? css.selectedFill : css.selected), entry.danger === true && css.danger)} disabled={entry.disabled} + aria-keyshortcuts={entry.shortcut?.aria} aria-haspopup={hasSub ? 'menu' : undefined} aria-expanded={hasSub ? subOpen : undefined} onFocus={hasSub ? () => { setOpenSubmenuId(entry.id) } : undefined} @@ -445,6 +465,7 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI > {entry.icon !== undefined && {entry.icon}} {entry.label} + {entry.shortcut !== undefined && } {/* Selection marker is a trailing check (figma .Menu_cell) unless the fill mode carries it. */} {selected && selection === 'check' && } @@ -457,10 +478,12 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI role="menuitem" className={css.item} disabled={sub.disabled} - onClick={() => { onSelect?.(sub.id) }} + aria-keyshortcuts={sub.shortcut?.aria} + onClick={() => { onSelect?.(sub.id); refocusAfterSelection() }} > {sub.icon !== undefined && {sub.icon}} {sub.label} + {sub.shortcut !== undefined && } ))}
diff --git a/packages/client/ui-primitives/src/Modal.module.css b/packages/client/ui-primitives/src/Modal.module.css index 8611494214..977bc6372c 100644 --- a/packages/client/ui-primitives/src/Modal.module.css +++ b/packages/client/ui-primitives/src/Modal.module.css @@ -4,6 +4,7 @@ Cards size against this padding box: consumers cap growth with `max-height: 100%`, never their own viewport calc. */ .root { + pointer-events: auto; position: fixed; inset: 0; z-index: 1000; diff --git a/packages/client/ui-primitives/src/Modal.tsx b/packages/client/ui-primitives/src/Modal.tsx index 58438873a8..430e44adfa 100644 --- a/packages/client/ui-primitives/src/Modal.tsx +++ b/packages/client/ui-primitives/src/Modal.tsx @@ -1,8 +1,9 @@ -import { useEffect } from 'react' +import { useRef } from 'react' import type { ReactNode } from 'react' import { createPortal } from 'react-dom' import clsx from 'clsx' import { IconCloseOutlineRegular } from './icons/index.tsx' +import { useModalLayer } from './useModalLayer.ts' import css from './Modal.module.css' interface ModalBaseProps { @@ -14,6 +15,7 @@ interface ModalBaseProps { footer?: ReactNode className?: string contentClassName?: string + shortcutModal?: string } type ModalProps = ModalBaseProps & ( @@ -32,21 +34,17 @@ type ModalProps = ModalBaseProps & ( * @param props.children - body (inputs, etc.). * @param props.footer - action row (Cancel / Create). * @param props.contentClassName - optional class for a scrollable content region. + * @param props.shortcutModal - command scope allowed by shortcut owners; unnamed + * dialogs block application commands unless their owner allows the "other" scope. * @param props.headless - render children directly in the card (no default * header/close/body chrome); mask, card, Escape, and aria-label remain. * @returns null when closed; otherwise the overlay tree. */ export function Modal({ - open, onClose, title, closeLabel, description, children, footer, className, contentClassName, headless = false, + open, onClose, title, closeLabel, description, children, footer, className, contentClassName, headless = false, shortcutModal, }: ModalProps) { - useEffect(() => { - if (!open) return - const onKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose() - } - document.addEventListener('keydown', onKeyDown) - return () => { document.removeEventListener('keydown', onKeyDown) } - }, [open, onClose]) + const dialog = useRef(null) + useModalLayer(dialog, open, onClose) if (!open) return null @@ -54,6 +52,9 @@ export function Modal({