Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# 统一应用内快捷键的窗口生命周期

Status: implemented
Translation: pending

## 摘要

应用内快捷键已有统一 registry,但 DOM 匹配、监听生命周期、命令决策和持久化刷新混在同一个类中;认证工作区的一条云端成功路径也没有挂载命令实现,多窗口不会刷新其他 renderer 中缓存的用户绑定。现在每个 renderer 由一个 React 宿主统一接入快捷键,`@tanstack/hotkeys` 负责解析、标准化、匹配和展示,registry 只负责 Lody 的命令语义,持久化模块负责跨窗口刷新。认证成功布局同时共用同一个命令宿主。快捷键配置仍是设备级本地状态,命令只在接收按键的窗口执行;操作系统级全局快捷键继续由 Electron 主进程独立管理。

## 决策与边界

`AppInitializer` 是每个 renderer 唯一的 `CommandShortcutHost` 挂载入口。宿主根据 registry 快照用 TanStack Hotkeys 创建多键位 handler,再建立一个 capture-phase `keydown` 监听;React 清理阶段对称卸载,命令注册、注销或用户改绑后自动替换映射。没有直接使用库内置 manager,因为它只建立 bubble-phase listener,不能保证 Radix focus trap 等局部组件停止冒泡后应用级命令仍可到达。Lody 仍拥有命令 ID 栈、最近挂载优先、`when`、`KeyScope`、文本输入让渡、用户覆盖、palette/settings 和 analytics 等领域规则。

绑定采用库原生的单 chord 语法,平台主修饰键由 `Mod` 表示;运行时不再维护第二套 parser,也不再把 Lody binding 编译成第三方正则。TanStack Hotkeys 提供相同的 canonical normalization 给 registry 冲突索引和 DOM matcher,并通过 `event.code` fallback 处理 macOS Option 字形及 Shift 标点。录制入口使用库的 event/normalization API,并仅在 DOM 边界把字母、数字和库已定义的标点 code 还原成物理键,确保录制结果与 matcher 的 fallback 得到同一个 canonical binding。旧 localStorage 与 Electron global-shortcut 配置中的 `$mod` 在读取边界迁移为 `Mod`;新默认值和新写入只生成 `Mod`。

TanStack Hotkeys 0.8.0 仍为 alpha,因此依赖精确锁定,不允许 semver 自动升级。采用它是为了把 parser、平台标准化、匹配、校验和展示交给同一个库;升级必须先复跑命令域、改键和全局快捷键契约测试,并确认 capture host 仍有必要。没有直接采用 `react-hotkeys-hook`,因为它不提供可供 registry、设置页和 Electron 共享的 canonical/format API;也没有保留 tinykeys,因为那需要一层自定义语法编译器才能维持现有物理键行为。

`user-bindings.ts` 独立拥有 `storage` 订阅。宿主挂载时重新读取用户绑定,本窗口通过设置页写入后直接更新 registry,其他窗口收到事件后从经过校验的 localStorage 重建映射。没有引入额外 IPC 或广播协议,因为同一 Electron session 的浏览器原生事件已经覆盖所需通知。

认证工作区的本地、带本地令牌和普通云端成功路径共用 `AuthenticatedWorkspaceContent`。该宿主只负责长期存在的工作区命令和辅助组件;可配置的路由级命令继续通过 `useCommand` 在各自组件挂载和卸载时注册。系统级快捷键、窗口关闭菜单,以及弹层 Escape、焦点导航和编辑器键位等局部交互不迁入 renderer registry。

本修复是 Issue #288 的基础设施部分,不增加工作区切换命令或滑动手势。应用级快捷键只有 `useCommand`/registry 一个注册口;弹层 Escape、列表导航、编辑器键位和首次交互解锁等组件局部按键仍留在所有者内部。后续工作区命令复用 `useCommand`,不创建新的全局监听器。

## 验证

命令域测试覆盖 registry 决策、真实 DOM 捕获、TanStack 标准化与匹配、物理键录制、动态改绑、宿主卸载、挂载时刷新持久化状态、旧数据迁移,以及模拟另一窗口写入后旧绑定停止触发、新绑定立即触发;shared 契约测试覆盖 `Mod` 到 Electron accelerator 的转换和 `$mod` 兼容。`AppInitializer` 平台时间测试继续覆盖唯一宿主入口。桌面 P0 E2E 通过产品 `app.openWindow` IPC 打开共享同一 Electron session/localStorage 的第二个真实窗口,覆盖两个 renderer 中的默认 `Mod+K` 和物理标点 `Mod+,`、在主窗口把侧栏改绑到产生移位字符的 `Mod+Shift+9`、两个窗口中的旧绑定立即失效与新绑定生效,以及辅助 renderer 重载后用户绑定恢复。

需求:[Issue #288](https://github.com/LodyAI/Lody/issues/288)。PR:[#572](https://github.com/LodyAI/Lody/pull/572)。
6 changes: 6 additions & 0 deletions .github/scripts/pr-policy.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@ Run policy tests.
- **Deliberately not done or tested:** No live API writes.
- **Unknowns / confidence:** Policy behavior is deterministic.

### Original user prompt

\`\`\`text
Simplify the pull request contribution policy.
\`\`\`

<!-- context-handoff:end -->
`;

Expand Down
4 changes: 3 additions & 1 deletion apps/electron/src/main/services/global-shortcuts-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Conf from 'conf'
import {
GLOBAL_SHORTCUT_DEFAULTS,
bindingToElectronAccelerator,
migrateLegacyShortcutBinding,
type GlobalShortcutBinding,
type GlobalShortcutId,
type GlobalShortcutTriggeredPayload,
Expand Down Expand Up @@ -174,9 +175,10 @@ export class GlobalShortcutsService {

private effectiveBinding(id: GlobalShortcutId): string | null {
const overrides = this.store.get('overrides')
return Object.prototype.hasOwnProperty.call(overrides, id)
const binding = Object.prototype.hasOwnProperty.call(overrides, id)
? (overrides[id] ?? null)
: GLOBAL_SHORTCUT_DEFAULTS[id]
return binding === null ? null : migrateLegacyShortcutBinding(binding)
}

private persistOverride(id: GlobalShortcutId, binding: string | null): void {
Expand Down
11 changes: 6 additions & 5 deletions e2e/COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@ Backlog rows are evidence-backed gaps, not executable or promised scenarios.

## Active P0 journeys

| Stable id | Journey | Renderer | Electron / IPC | Bundled CLI | Durable state | External wire |
| --------------------- | ------------------------------------------------------------------- | --------------------- | ------------------------------ | ------------------ | ----------------------------------------------- | ------------- |
| `LODY-ONBOARDING-001` | New user enters an isolated local workspace through the bundled CLI | Intro and local entry | Real window and invoke bridge | Real owned runtime | Isolated workspace catalog and onboarding state | None |
| `LODY-SESSION-001` | Stop and permanently delete a running ACP Session | Session lifecycle | Real window and invoke bridge | Real owned runtime | Create, stop, archive, and permanent delete | Scripted ACP |
| `LODY-WORK-001` | Delete a worktree Session with ACP and Terminal resources | Work lifecycle | Real window, IPC, and Terminal | Real owned runtime | Session, worktree, and terminal cleanup | Scripted ACP |
| Stable id | Journey | Renderer | Electron / IPC | Bundled CLI | Durable state | External wire |
| --------------------- | ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | ---------------------------------------------------------------------- | -------------------------------------------- | ----------------------------------------------- | ------------- |
| `LODY-ONBOARDING-001` | New user enters an isolated local workspace through the bundled CLI | Intro and local entry | Real window and invoke bridge | Real owned runtime | Isolated workspace catalog and onboarding state | None |
| `LODY-SESSION-001` | Stop and permanently delete a running ACP Session | Session lifecycle | Real window and invoke bridge | Real owned runtime | Create, stop, archive, and permanent delete | Scripted ACP |
| `LODY-SHORTCUT-001` | Synchronize user-rebound desktop shortcuts across windows and renderer reload | Two command hosts, settings recorder, sidebars, and secondary renderer reload | Real primary and auxiliary Electron windows through app.openWindow IPC | Real owned runtime for local workspace entry | User shortcut override in isolated localStorage | None |
| `LODY-WORK-001` | Delete a worktree Session with ACP and Terminal resources | Work lifecycle | Real window, IPC, and Terminal | Real owned runtime | Session, worktree, and terminal cleanup | Scripted ACP |

## Active P1 journeys

Expand Down
49 changes: 49 additions & 0 deletions e2e/journeys/registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,55 @@
"blockedReason": null,
"estimatedMinutes": 2
},
{
"id": "LODY-SHORTCUT-001",
"state": "active",
"priority": "P0",
"runtime": "none",
"title": "Synchronize user-rebound desktop shortcuts across windows and renderer reload",
"owner": "desktop-shortcuts",
"feature": "src/features/shortcuts.feature",
"fixture": "fresh-local-workspace",
"fingerprint": "ca40a75b29ba41bb0e96b2e86fbbff3d18b42f40878b35d631857d2addb3844f",
"ownerPaths": [
"packages/components/src/lib/commands/",
"packages/components/src/components/settings/keyboard-shortcuts-setting.tsx",
"packages/components/src/hooks/use-keyboard-navigation.ts"
],
"actions": [
{ "id": "shortcut.openPaletteWithDefaultBinding" },
{ "id": "shortcut.openSettingsWithPhysicalPunctuation" },
{ "id": "window.openSecondaryWorkspace" },
{ "id": "shortcut.verifySecondaryDefaults" },
{ "id": "shortcut.rebindSidebarWithShiftedDigit" },
{ "id": "shortcut.verifyOldBindingRemovedInBothWindows" },
{ "id": "renderer.reloadSecondary" },
{ "id": "shortcut.verifyUserBindingRestoredInSecondary" }
],
"checkpoints": [
"default command palette and settings bindings execute in both Electron windows",
"the old sidebar binding stops while the shifted-digit physical key works immediately in both windows",
"the user binding is restored in the secondary window after its renderer reload"
],
"cleanup": ["isolated desktop profile and shortcut overrides are deleted"],
"coverage": {
"renderer": "Two command hosts, settings recorder, sidebars, and secondary renderer reload",
"electronIpc": "Real primary and auxiliary Electron windows through app.openWindow IPC",
"bundledCli": "Real owned runtime for local workspace entry",
"durableState": "User shortcut override in isolated localStorage",
"externalWire": "None"
},
"signals": {
"criticality": 5,
"boundaryRisk": 4,
"changeFrequency": 4,
"escapedDefect": false
},
"freshness": 5,
"scoutJourneys": [],
"blockedReason": null,
"estimatedMinutes": 2
},
{
"id": "LODY-WORK-001",
"state": "active",
Expand Down
3 changes: 2 additions & 1 deletion e2e/src/features/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Feature index

The active suite contains 14 scenarios: 3 `@P0` smoke journeys and 11 `@P1` deeper journeys.
The active suite contains 15 scenarios: 4 `@P0` smoke journeys and 11 `@P1` deeper journeys.

| Feature | Scope |
| ----------------------------- | --------------------------------------------------------------------------------- |
Expand All @@ -16,3 +16,4 @@ The active suite contains 14 scenarios: 3 `@P0` smoke journeys and 11 `@P1` deep
| `session-read-state.feature` | Session unread marking, navigation-based read clearing, and permanent deletion |
| `session-fork.feature` | Completed Session fork, origin, independent worktree, and deletion isolation |
| `settings-appearance.feature` | Theme commit, preview isolation, cancellation, and settings-reopen persistence |
| `shortcuts.feature` | Default bindings, cross-window rebinding, physical keys, and renderer reload |
13 changes: 13 additions & 0 deletions e2e/src/features/shortcuts.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# language: zh-CN
@lody @essence @P0 @runtime-none @LODY-SHORTCUT-001
功能: 桌面应用快捷键

场景: 用户改绑同步到另一个窗口并在 renderer 重载后保持一致
假如 用户已进入一个隔离的本地 workspace
那么 默认命令面板和设置快捷键可用
当 用户打开第二个 workspace 窗口
那么 第二个窗口中的默认快捷键也可用
当 用户把切换侧栏改绑到一个带 Shift 的数字键组合
那么 两个窗口中的旧绑定都停止生效且新绑定立即生效
当 第二个 renderer 重新加载
那么 第二个窗口中的用户改绑仍按相同的物理键生效
1 change: 1 addition & 0 deletions e2e/src/steps/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@
| `session-read-state.steps.ts` | Exercises unread marking, navigation-based clearing, and UI cleanup |
| `session-fork.steps.ts` | Forks a completed Session to a worktree and verifies origin and cleanup |
| `settings-appearance.steps.ts` | Commits, previews, cancels, and reopens a desktop theme selection |
| `shortcuts.steps.ts` | Verifies default shortcuts, cross-window rebinding, and renderer reload |
35 changes: 35 additions & 0 deletions e2e/src/steps/shortcuts.steps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { Given, Then, When } from '@cucumber/cucumber';
import type { LodyWorld } from '../support/world.js';

Given('用户已进入一个隔离的本地 workspace', async function (this: LodyWorld) {
await this.onboarding!.waitForLocalBootstrap();
await this.onboarding!.skipConfigurationAndEnterProduct();
});

Then('默认命令面板和设置快捷键可用', async function (this: LodyWorld) {
await this.shortcutPage!.expectDefaultBindings();
});

When('用户打开第二个 workspace 窗口', async function (this: LodyWorld) {
await this.shortcutPage!.openSecondaryRenderer();
});

Then('第二个窗口中的默认快捷键也可用', async function (this: LodyWorld) {
await this.shortcutPage!.expectSecondaryDefaultBindings();
});

When('用户把切换侧栏改绑到一个带 Shift 的数字键组合', async function (this: LodyWorld) {
await this.shortcutPage!.rebindSidebarToggle();
});

Then('两个窗口中的旧绑定都停止生效且新绑定立即生效', async function (this: LodyWorld) {
await this.shortcutPage!.expectOnlyUserBindingTogglesSidebarInBothRenderers();
});

When('第二个 renderer 重新加载', async function (this: LodyWorld) {
await this.shortcutPage!.reloadSecondaryRenderer();
});

Then('第二个窗口中的用户改绑仍按相同的物理键生效', async function (this: LodyWorld) {
await this.shortcutPage!.expectOnlyUserBindingTogglesSidebarInSecondaryRenderer();
});
1 change: 1 addition & 0 deletions e2e/src/support/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
| `pages/session-read-state-page.ts` | Unread state, sidebar navigation, and two-Session cleanup |
| `pages/session-fork-page.ts` | Native ACP fork, origin, worktree, and source isolation |
| `pages/settings-appearance-page.ts` | Theme commit, live preview, cancellation, and persisted state |
| `pages/shortcut-page.ts` | Default shortcuts, cross-window rebinding, and renderer reload |
| `fixtures/work-session-fixture.ts` | Synthetic Git workspace and scripted ACP evidence |
| `fixtures/agent-role-fixture.ts` | File-signaled scripted ACP and Role execution evidence |
| `fixtures/mcp-catalog-editing-fixture.ts` | Synthetic editable MCP catalog values |
Expand Down
Loading
Loading