Skip to content

refactor: migrate shortcut system to Preference architecture#14086

Merged
0xfullex merged 42 commits intov2from
refactor/v2/shortcuts
Apr 16, 2026
Merged

refactor: migrate shortcut system to Preference architecture#14086
0xfullex merged 42 commits intov2from
refactor/v2/shortcuts

Conversation

@kangfenmao
Copy link
Copy Markdown
Collaborator

@kangfenmao kangfenmao commented Apr 7, 2026

Summary

  • Migrate shortcut management from Redux + ConfigManager dual data source to unified Preference system
  • Introduce packages/shared/shortcuts/ as single source of truth (definitions, types, utils)
  • Rewrite ShortcutService (main process) with incremental globalShortcut registration instead of unregisterAll()
  • Rewrite useShortcut / useShortcutDisplay / useAllShortcuts hooks to read from Preference
  • Enhance ShortcutSettings UI: real-time key preview during recording, conflict detection with visual hint, search/filter
  • Add 4 missing shortcut migration mappings in PreferencesMappings.ts
  • Fix bug: cleared shortcuts (key: []) no longer fallback to default binding
  • Add technical architecture doc at v2-refactor-temp/docs/shortcuts/

Changes

Shared Layer (packages/shared/shortcuts/)

  • definitions.tsSHORTCUT_DEFINITIONS array as the single source of truth for all 20 shortcuts
  • types.tsShortcutPreferenceKey, ShortcutKey, ShortcutPreferenceValue, ShortcutDefinition
  • utils.tscoerceShortcutPreference, convertKeyToAccelerator, formatShortcutDisplay, isValidShortcut

Main Process

  • ShortcutService — incremental globalShortcut registration via registeredAccelerators Set (diff-based add/remove instead of unregisterAll)
  • Removed configManager.getShortcuts/setShortcuts, IpcChannel.Shortcuts_Update, preload bridge

Renderer

  • useShortcuts.ts — reads from Preference, supports short key (chat.clear) and full key (shortcut.chat.clear)
  • ShortcutSettings.tsx — real-time key preview, conflict hint ("Already used by XXX"), search filter
  • Updated all 18 call sites to use new shortcut key format

Migration

  • Added 4 missing mappings: rename_topic, toggle_show_topics, edit_last_user_message, select_model

Test plan

  • pnpm build:check passes (280 test files, 4913 tests, 0 failures)
  • Manual: Open Settings → Shortcuts, verify display/edit/disable/enable/reset
  • Manual: Record shortcut — verify real-time preview shows modifier keys immediately
  • Manual: Record duplicate shortcut — verify red border + conflict hint appears
  • Manual: Clear a shortcut — verify it no longer triggers
  • Manual: Search shortcuts by name and by key binding
  • Manual: Verify main process shortcuts (zoom in/out/reset, show main window, mini window)
  • Manual: Verify multi-window scenario — focus/blur doesn't break persistent shortcuts

@kangfenmao
Copy link
Copy Markdown
Collaborator Author

kangfenmao commented Apr 7, 2026

Note

This comment was translated by Claude.

Cherry Studio Shortcut System Refactoring Design

Version: v2.0
Updated: 2026-04-14
Branch: refactor/v2/shortcuts

Background

The v1 shortcut system has 5 main problems:

  • Data sources scattered between Redux and configManager
  • Manual IPC sync between main and renderer processes
  • Adding new shortcuts requires modifying multiple switch-case statements
  • Definitions are scattered, lacking a unified entry point
  • Weak type constraints, prone to dirty data at runtime

The goal for v2 is clear:

  • Use SHORTCUT_DEFINITIONS to uniformly describe all shortcut metadata
  • Store user configuration with Preference, no longer maintaining a second set of state
  • main/renderer register according to their responsibilities but share the same definitions and utilities
  • Ensure read results are always complete and usable through resolveShortcutPreference
  • Make adding new shortcuts converge to just three steps: "definition + default value + usage"

Core Model

The system consists of 4 layers:

  1. Definition layer: packages/shared/shortcuts/definitions.ts
  2. Utility layer: packages/shared/shortcuts/utils.ts
  3. Storage layer: packages/shared/data/preference/preferenceSchemas.ts
  4. Consumer layer: ShortcutService and useShortcuts.ts

Each has a simple responsibility:

Layer Purpose
Definition layer Describe what shortcuts are
Utility layer Handle format conversion, validation, defaults, and normalization
Storage layer Save truly mutable user configuration
Consumer layer Register and use shortcuts in main/renderer

1. Definition Layer

SHORTCUT_DEFINITIONS is the single source of truth for the shortcut system. Each definition describes static metadata for a shortcut, for example:

{
  key: 'shortcut.feature.quick_assistant.toggle_window',
  scope: 'main',
  category: 'general',
  labelKey: 'mini_window',
  global: true,
  supportedPlatforms: ['darwin', 'win32']
}

Common fields:

Field Description
key Preference key, typically in format shortcut.{category}.{name}
scope main, renderer, or both
category Settings page grouping, such as general, chat, topic, feature.selection
labelKey i18n text key
editable Whether to allow user modification of binding
global Whether to keep registration when window loses focus
variants Additional bindings for the same shortcut
supportedPlatforms Platform restrictions

2. Storage Layer

Shortcut preferences only save what users actually change:

type PreferenceShortcutType = {
  binding: string[]
  enabled: boolean
}

Defaults are defined in preferenceSchemas.ts. For example:

'shortcut.chat.clear': { enabled: true, binding: ['CommandOrControl', 'L'] }
'shortcut.general.show_main_window': { enabled: false, binding: [] }

Static info like editable, scope, labelKey is not stored here - it's always read from SHORTCUT_DEFINITIONS.

3. Utility Layer

The most important capabilities in utils.ts fall into 4 categories:

  • Format conversion: Convert between Electron accelerator and renderer hotkey
  • Display formatting: Convert bindings to ⌘L / Ctrl+L
  • Validation: Restrict invalid single keys
  • Preference normalization: Organize arbitrary raw values into stable ResolvedShortcut

The core function is resolveShortcutPreference(definition, value). It handles:

  • Fallback to schema defaults when value is missing
  • Preserve user-cleared binding: []
  • Fallback to default when enabled field is abnormal

For callers, the result is always:

type ResolvedShortcut = {
  binding: string[]
  enabled: boolean
}

Runtime Behavior

Main Process

ShortcutService is responsible for all shortcuts where scope \!== 'renderer'.

Its implementation focuses on just 3 things:

  1. Register built-in handlers: Use Map<ShortcutPreferenceKey, ShortcutHandler> instead of switch-case
  2. Listen for Preference changes: Recalculate shortcuts to register after configuration changes
  3. Manage window lifecycle: Switch between "all shortcuts" and "global shortcuts only" on window focus/blur

Current registration logic is incremental diff:

  • First calculate target accelerator set based on definitions, platform, feature toggles, and preferences
  • Then compare with already registered set
  • Only unregister or rebind items that actually changed

This avoids meaningless full unregister/register.

Renderer Process

useShortcuts.ts provides 3 hooks:

Hook Purpose
useShortcut Register a single renderer shortcut
useShortcutDisplay Return formatted display text
useAllShortcuts Provide complete shortcut list and unified update entry for settings page

useAllShortcuts() now directly handles:

  • Platform filtering
  • Feature toggle dependency filtering
  • Label generation
  • Display-state convergence for "no binding means not enabled"

The settings page only needs to continue handling search, recording, conflict hints, and rendering.

Key Rules

Several rules are critical to stable system operation:

Single Source of Truth

Static shortcut information comes only from SHORTCUT_DEFINITIONS. Don't maintain another description in pages, services, or migration code.

Preference First

Runtime state reads/writes only from Preference. Don't introduce Redux, temporary config files, or additional IPC as a second data source.

No Binding Means Not Triggerable

Even if enabled is true, if binding is empty, it should not be registered or shown as enabled in settings.

Hide Shortcuts When Features Disabled

Currently there are two types of feature-state-dependent shortcuts:

  • shortcut.feature.quick_assistant.toggle_window depends on feature.quick_assistant.enabled
  • feature.selection.* depends on feature.selection.enabled

When features are disabled, these shortcuts won't register and won't show in settings.

Platform Restrictions Apply to Both Registration and Display

supportedPlatforms is not just a UI filter - it determines whether shortcuts will register on the current system.

Default Shortcuts

The table below keeps only the most essential information: key, default binding, scope, default enabled state.

general

Key Default Binding Scope Default Enabled
shortcut.general.show_main_window (none) main No
shortcut.feature.quick_assistant.toggle_window Cmd/Ctrl+E main No
shortcut.general.show_settings Cmd/Ctrl+, main Yes
shortcut.general.toggle_sidebar Cmd/Ctrl+[ renderer Yes
shortcut.general.exit_fullscreen Escape renderer Yes
shortcut.general.zoom_in Cmd/Ctrl+= main Yes
shortcut.general.zoom_out Cmd/Ctrl+- main Yes
shortcut.general.zoom_reset Cmd/Ctrl+0 main Yes
shortcut.general.search Cmd/Ctrl+Shift+F renderer Yes

chat

Key Default Binding Scope Default Enabled
shortcut.chat.clear Cmd/Ctrl+L renderer Yes
shortcut.chat.search_message Cmd/Ctrl+F renderer Yes
shortcut.chat.toggle_new_context Cmd/Ctrl+K renderer Yes
shortcut.chat.copy_last_message Cmd/Ctrl+Shift+C renderer No
shortcut.chat.edit_last_user_message Cmd/Ctrl+Shift+E renderer No
shortcut.chat.select_model Cmd/Ctrl+Shift+M renderer Yes

topic

Key Default Binding Scope Default Enabled
shortcut.topic.new Cmd/Ctrl+N renderer Yes
shortcut.topic.rename Cmd/Ctrl+T renderer No
shortcut.topic.toggle_show_topics Cmd/Ctrl+] renderer Yes

feature.selection

Key Default Binding Scope Default Enabled
shortcut.feature.selection.toggle_enabled (none) main No
shortcut.feature.selection.get_text (none) main No

Extension Method

Adding a new shortcut requires just 3 steps in principle.

1. Add Default Value

Add schema default in preferenceSchemas.ts:

'shortcut.chat.regenerate': {
  enabled: true,
  binding: ['CommandOrControl', 'Shift', 'R']
}

2. Add Definition

Add static metadata in definitions.ts:

{
  key: 'shortcut.chat.regenerate',
  scope: 'renderer',
  category: 'chat',
  labelKey: 'regenerate'
}

3. Use in Target Location

Renderer process:

useShortcut('chat.regenerate', () => regenerateLastMessage())

Main process:

this.handlers.set('shortcut.chat.regenerate', () => {
  // ...
})

For conditional shortcuts, don't write conditions into the definition layer. Filter at the consumer layer or do early return in the handler.

Migration and Testing

Migration Status

  • Old Redux shortcuts slice is retained only as migration input
  • IpcChannel.Shortcuts_Update, old preload bridge, configManager shortcut interfaces are all removed
  • Old data is mapped to new shortcut.* keys via PreferenceMigrator

Current Testing Focus

Existing tests primarily cover:

  • Format conversion, validation, and normalization in utils.ts
  • Migration mapping from old keys to new keys
  • Re-registration behavior of ShortcutService

If continuing to expand, prioritize adding these two test types:

  • Recording, conflicts, and display logic for settings page
  • End-to-end behavior for main process global shortcuts

Summary

The core of this refactoring is not "making shortcuts complex," but converging complexity into shared definitions, unified normalization, and clear layering.

For daily development, just remember 3 things:

  1. Static info: look at SHORTCUT_DEFINITIONS
  2. User config: look at Preference
  3. main/renderer consume the same definitions according to their responsibilities

Original Content

Cherry Studio 快捷键系统重构设计

版本:v2.0
更新日期:2026-04-14
分支:refactor/v2/shortcuts

背景

v1 快捷键系统的主要问题有 5 个:

  • 数据源分散在 Redux 和 configManager
  • 主进程与渲染进程靠手动 IPC 同步
  • 新增快捷键需要改多处 switch-case
  • 定义分散,缺少统一入口
  • 类型约束弱,运行时容易出现脏数据

v2 的目标很明确:

  • SHORTCUT_DEFINITIONS 统一描述所有快捷键元数据
  • 用 Preference 存储用户配置,不再维护第二套状态
  • main / renderer 各自按职责注册,但共用同一套定义和工具
  • 通过 resolveShortcutPreference 保证读取结果始终完整可用
  • 让新增快捷键尽量收敛为"定义 + 默认值 + 使用"三步

核心模型

系统由 4 层组成:

  1. 定义层:packages/shared/shortcuts/definitions.ts
  2. 工具层:packages/shared/shortcuts/utils.ts
  3. 存储层:packages/shared/data/preference/preferenceSchemas.ts
  4. 消费层:ShortcutServiceuseShortcuts.ts

它们各自负责的事情很简单:

作用
定义层 描述快捷键是什么
工具层 做格式转换、校验、默认值和归一化
存储层 保存用户真正可变的配置
消费层 在 main / renderer 中注册并使用快捷键

1. 定义层

SHORTCUT_DEFINITIONS 是快捷键系统的单一真相源。每条定义描述一个快捷键的静态元数据,例如:

{
  key: 'shortcut.feature.quick_assistant.toggle_window',
  scope: 'main',
  category: 'general',
  labelKey: 'mini_window',
  global: true,
  supportedPlatforms: ['darwin', 'win32']
}

常用字段:

字段 说明
key Preference key,格式通常为 shortcut.{category}.{name}
scope mainrendererboth
category 设置页分组,如 generalchattopicfeature.selection
labelKey i18n 文案 key
editable 是否允许用户修改绑定
global 是否在窗口失焦后仍需保留注册
variants 同一快捷键的额外绑定
supportedPlatforms 平台限制

2. 存储层

快捷键偏好只保存用户真正会改的部分:

type PreferenceShortcutType = {
  binding: string[]
  enabled: boolean
}

默认值定义在 preferenceSchemas.ts。例如:

'shortcut.chat.clear': { enabled: true, binding: ['CommandOrControl', 'L'] }
'shortcut.general.show_main_window': { enabled: false, binding: [] }

这里不存 editablescopelabelKey 这类静态信息,它们始终从 SHORTCUT_DEFINITIONS 读取。

3. 工具层

utils.ts 里最重要的是 4 类能力:

  • 格式转换:Electron accelerator 与 renderer hotkey 之间互转
  • 显示格式化:把绑定转成 ⌘L / Ctrl+L
  • 合法性校验:限制无效单键
  • 偏好归一化:把任意原始值整理成稳定的 ResolvedShortcut

核心函数是 resolveShortcutPreference(definition, value)。它负责:

  • 值缺失时回退到 schema 默认值
  • 保留用户显式清空的 binding: []
  • enabled 字段异常时回退到默认值

对调用侧来说,拿到的始终是:

type ResolvedShortcut = {
  binding: string[]
  enabled: boolean
}

运行方式

主进程

ShortcutService 负责所有 scope \!== 'renderer' 的快捷键。

它的实现重点只有 3 件事:

  1. 注册内置 handler:用 Map<ShortcutPreferenceKey, ShortcutHandler> 代替 switch-case
  2. 监听 Preference 变化:配置变更后重算当前应注册的快捷键
  3. 管理窗口生命周期:窗口 focus / blur 时切换"全部快捷键"与"仅全局快捷键"

当前注册逻辑是增量 diff:

  • 先根据定义、平台、功能开关和偏好算出目标 accelerator 集合
  • 再和已注册集合比较
  • 只卸载或重绑真正变化的项

这样可以避免无意义的全量 unregister / register。

渲染进程

useShortcuts.ts 提供 3 个 Hook:

Hook 作用
useShortcut 注册单个 renderer 快捷键
useShortcutDisplay 返回格式化后的展示文案
useAllShortcuts 为设置页提供完整快捷键列表和统一更新入口

其中 useAllShortcuts() 现在会直接处理:

  • 平台过滤
  • 依赖功能开关的过滤
  • label 生成
  • "无绑定不算启用"的展示态收敛

设置页只需要继续做搜索、录制、冲突提示和渲染。

关键规则

有几条规则是这个系统稳定运行的关键:

单一真相源

快捷键的静态信息只来自 SHORTCUT_DEFINITIONS,不要在页面、服务或迁移代码里再维护另一份描述。

Preference 优先

运行时状态只从 Preference 读写。不要再引入 Redux、临时配置文件或额外 IPC 作为第二数据源。

无绑定即不可触发

即使某项 enabledtrue,只要 binding 为空,它也不应被注册,也不应在设置页显示为启用。

功能关闭时不显示对应快捷键

当前有两类依赖功能状态的快捷键:

  • shortcut.feature.quick_assistant.toggle_window 依赖 feature.quick_assistant.enabled
  • feature.selection.* 依赖 feature.selection.enabled

功能关闭时,这些快捷键不会注册,也不会在设置页显示。

平台限制要同时作用于注册与展示

supportedPlatforms 不只是 UI 过滤条件,也决定快捷键是否会在当前系统上注册。

默认快捷键

下表只保留最常用的信息:key、默认绑定、scope、默认启用状态。

general

Key 默认绑定 Scope 默认启用
shortcut.general.show_main_window (无) main
shortcut.feature.quick_assistant.toggle_window Cmd/Ctrl+E main
shortcut.general.show_settings Cmd/Ctrl+, main
shortcut.general.toggle_sidebar Cmd/Ctrl+[ renderer
shortcut.general.exit_fullscreen Escape renderer
shortcut.general.zoom_in Cmd/Ctrl+= main
shortcut.general.zoom_out Cmd/Ctrl+- main
shortcut.general.zoom_reset Cmd/Ctrl+0 main
shortcut.general.search Cmd/Ctrl+Shift+F renderer

chat

Key 默认绑定 Scope 默认启用
shortcut.chat.clear Cmd/Ctrl+L renderer
shortcut.chat.search_message Cmd/Ctrl+F renderer
shortcut.chat.toggle_new_context Cmd/Ctrl+K renderer
shortcut.chat.copy_last_message Cmd/Ctrl+Shift+C renderer
shortcut.chat.edit_last_user_message Cmd/Ctrl+Shift+E renderer
shortcut.chat.select_model Cmd/Ctrl+Shift+M renderer

topic

Key 默认绑定 Scope 默认启用
shortcut.topic.new Cmd/Ctrl+N renderer
shortcut.topic.rename Cmd/Ctrl+T renderer
shortcut.topic.toggle_show_topics Cmd/Ctrl+] renderer

feature.selection

Key 默认绑定 Scope 默认启用
shortcut.feature.selection.toggle_enabled (无) main
shortcut.feature.selection.get_text (无) main

扩展方式

新增一个快捷键,原则上只需要 3 步。

1. 添加默认值

preferenceSchemas.ts 中增加 schema 默认值:

'shortcut.chat.regenerate': {
  enabled: true,
  binding: ['CommandOrControl', 'Shift', 'R']
}

2. 添加定义

definitions.ts 中加入静态元数据:

{
  key: 'shortcut.chat.regenerate',
  scope: 'renderer',
  category: 'chat',
  labelKey: 'regenerate'
}

3. 在目标位置使用

渲染进程:

useShortcut('chat.regenerate', () => regenerateLastMessage())

主进程:

this.handlers.set('shortcut.chat.regenerate', () => {
  // ...
})

如果是条件型快捷键,不要把条件写进定义层,应该在消费层做过滤或在 handler 内做早返回。

迁移与测试

迁移现状

  • 旧的 Redux shortcuts slice 只保留为迁移输入
  • IpcChannel.Shortcuts_Update、旧 preload bridge、configManager 快捷键接口都已移除
  • 旧数据通过 PreferenceMigrator 映射到新的 shortcut.* key

当前测试重点

现有测试主要覆盖:

  • utils.ts 的格式转换、校验和归一化
  • 旧 key 到新 key 的迁移映射
  • ShortcutService 的重注册行为

后续如果继续扩展,优先补下面两类测试:

  • 设置页的录制、冲突和显示逻辑
  • 主进程全局快捷键的端到端行为

总结

这套重构的核心不是"把快捷键做复杂",而是把复杂度收拢到共享定义、统一归一化和清晰分层里。

对日常开发来说,只需要记住 3 件事:

  1. 静态信息看 SHORTCUT_DEFINITIONS
  2. 用户配置看 Preference
  3. main / renderer 按各自职责消费同一套定义

@DeJeune DeJeune added the v2 label Apr 7, 2026
@DeJeune DeJeune added this to the v2.0.0 milestone Apr 7, 2026
kangfenmao

This comment was marked as outdated.

@kangfenmao kangfenmao force-pushed the refactor/v2/shortcuts branch from b6f7fab to 015feaa Compare April 7, 2026 04:47
@kangfenmao kangfenmao marked this pull request as ready for review April 7, 2026 04:55
@kangfenmao kangfenmao requested a review from a team April 7, 2026 04:55
Copy link
Copy Markdown
Collaborator

@DeJeune DeJeune left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR #14086 Review: Shortcut System Migration

Overall this is a well-architected migration. The shared definitions layer, incremental globalShortcut registration, and full consumer migration are solid improvements. Below are findings from 4 parallel review agents.

Critical (2)

  • C1: Escape key during recording saves ['Escape'] as binding instead of canceling (ShortcutSettings.tsx:269)
  • C2: Conflict detection uses displayKeys instead of resolved binding (ShortcutSettings.tsx:150)

Warning (6)

  • W1: useHotkeys re-registers on every render due to unstable callback ref (useShortcuts.ts:94)
  • W2: Rewritten ShortcutSettings.tsx still uses antd/styled-components (v2 violation)
  • W3: ready-to-show listener not tracked for lifecycle cleanup (ShortcutService.ts:118)
  • W4: Preference subscriptions not using registerDisposable (ShortcutService.ts:49)
  • W5: Type assertions bypass null safety in coerceShortcutPreference (utils.ts:124)
  • W6: Redux actions (updateShortcut, toggleShortcut, resetShortcuts in store/shortcuts.ts:173) still exported but are now no-ops. If dispatched, state mutates in Redux but does NOT propagate to Preference — silent data inconsistency. Consider removing exports.

Suggestion (3)

  • S1: show_settings handler too complex for ShortcutService, delegate to WindowService
  • S2: antd Table render signature parameter mismatch (ShortcutSettings.tsx:390)
  • S3: isValidShortcut doesn't check for duplicate keys

See inline comments for details.

Comment thread src/renderer/src/pages/settings/ShortcutSettings.tsx Outdated
Comment thread src/renderer/src/pages/settings/ShortcutSettings.tsx Outdated
Comment thread src/renderer/src/pages/settings/ShortcutSettings.tsx Outdated
Comment thread src/renderer/src/hooks/useShortcuts.ts Outdated
Comment thread src/main/services/ShortcutService.ts Outdated
Comment thread src/main/services/ShortcutService.ts Outdated
Comment thread packages/shared/shortcuts/utils.ts Outdated
Comment thread src/main/services/ShortcutService.ts Outdated
Comment thread packages/shared/shortcuts/utils.ts
Comment thread packages/shared/data/preference/preferenceSchemas.ts
Comment thread packages/shared/data/preference/preferenceTypes.ts
@DeJeune DeJeune requested a review from 0xfullex April 7, 2026 07:30
Copy link
Copy Markdown
Collaborator

@EurFelux EurFelux left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

This issue/comment/review was translated by Claude.

Overall, the direction of this refactoring is correct (single source of truth + Preference hosting + processor registry are all reasonable), but this PR crams not yet fully clear design and concrete implementation all at once, suggest splitting meow~

Design issues exposed in current review:

  1. Data model redundancy and not truly implemented — The binding / rawBinding / hasCustomBinding triplet has the same values in all scenarios in the current implementation (verified scenario by scenario), the design intention of "distinguishing cleared from unset" only停留在字段名上. The root cause is that the basic design point of "clear vs disable" is not yet clear — since there is enabled, the independent value of "clear" operation is very weak
  2. Placeholder fields lack consumerscategory is marked in every definition, but nowhere in the code reads it, the intention is unclear
  3. Type boundaries not convergedsupportedPlatforms uses NodeJS.Platform[] to accept 11 values but actually only uses 3
  4. Naming questionablepersistOnBlur is essentially global
  5. Core types missing JSDocShortcutDefinition / ShortcutPreferenceValue are the single source of truth, but no field has JSDoc
  6. Documentation inconsistent with code — The definitions.ts section's field descriptions are missing several fields

Suggested split approach:

  • PR 1: Pure infrastructure migration — migrate existing shortcuts from Redux/configManager to Preference architecture, do not introduce new data model concepts (no hasCustomBinding, no binding/rawBinding separation), keep current state working
  • PR 2: On top of PR 1, do data model design, first discuss "clear vs disable", "category usage", "platform type" these design questions in the issue, then implement
  • PR 3: UI layer (settings page recording, conflict detection, category grouping, etc.)

This way, each PR's review focus is more concentrated, and avoids the dilemma of "design still under discussion but code already written" leading to either having to live with it or major changes meow~

Specific design / docs / suggestion / question are all written in inline threads 🐱


Original Content

整体上这次重构方向是对的(单一真相源 + Preference 托管 + 处理器注册表都很合理),但这个 PR 把还没完全明朗的设计和具体实现一次性塞进来了,建议拆分喵~

当前 review 中暴露出的设计问题:

  1. 数据模型存在冗余且未真正落实binding / rawBinding / hasCustomBinding 三元组在当前实现里所有场景下值都相同(已逐场景验证),「区分清空与未设置」的设计意图只停留在字段名上。根源是「清空 vs 禁用」这一基础设计点尚未明确——既然有了 enabled,「清空」操作的独立价值很弱
  2. 占位字段缺乏消费方category 每个定义都标了,但代码里没有任何地方读它,意图不明
  3. 类型边界不收敛supportedPlatformsNodeJS.Platform[] 接受 11 个值但实际只用 3 个
  4. 命名待商榷persistOnBlur 实质就是 global
  5. 核心类型缺失 JSDocShortcutDefinition / ShortcutPreferenceValue 是单一真相源,但每个字段都没有 JSDoc
  6. 文档与代码不一致definitions.ts 小节的字段说明缺失多个字段

建议的拆分方式

  • PR 1:纯基础设施迁移——把现有快捷键从 Redux/configManager 平迁到 Preference 架构,不引入新的数据模型概念(不要 hasCustomBinding、不要 binding/rawBinding 分离),保持现状能跑
  • PR 2:在 PR 1 之上做数据模型设计,先在 issue 里把「清空 vs 禁用」「category 用途」「平台类型」这些 design question 讨论清楚,再落地
  • PR 3:UI 层(设置页录制、冲突检测、分类分组等)

这样每个 PR 的 review 焦点更集中,也避免「设计还在讨论但代码已经写完了」导致后续要么将就要么大改的窘境喵~

具体的 design / docs / suggestion / question 都写在内联 thread 里了 🐱

Comment thread packages/shared/shortcuts/types.ts Outdated
Comment thread packages/shared/shortcuts/types.ts Outdated
Comment thread packages/shared/shortcuts/types.ts
Comment thread v2-refactor-temp/docs/shortcuts/shortcut-system-refactor.md Outdated
Comment thread packages/shared/shortcuts/utils.ts Outdated
Comment thread packages/shared/shortcuts/types.ts Outdated
Comment thread packages/shared/shortcuts/types.ts Outdated
- Removed serialization logic from shortcuts slice as shortcuts are now managed via PreferenceService.
- Updated comments to reflect the migration and the purpose of the shortcuts slice.
- Added a new design document detailing the refactor of the shortcut system, including architecture, data flow, and extension guidelines.
- Fix Escape key saving as binding instead of canceling recording (C1)
- Fix conflict detection using displayKeys instead of normalized binding (C2)
- Stabilize useHotkeys callback with useRef to avoid re-registration (W1)
- Add guard for ready-to-show callback on stopped service (W3)
- Use registerDisposable for preference subscriptions (W4)
- Fix antd Table render signature parameter mismatch (S2)
- Add duplicate key check in isValidShortcut (S3)

Signed-off-by: kangfenmao <[email protected]>
- Updated shortcut keys in AppMenuService, ShortcutService, and various components to follow the new structure: 'shortcut.app.core' for core shortcuts and 'shortcut.app' for app-specific shortcuts.
- Adjusted preference keys in classification JSON to reflect the new naming convention.
- Ensured all related components and services are aligned with the updated shortcut keys for consistency and clarity.
…s and translations

- Updated shortcut key mapping from 'toggle_show_assistants' to 'toggle_sidebar' in label.ts.
- Changed corresponding translations in en-us.json, zh-cn.json, zh-tw.json, de-de.json, el-gr.json, es-es.json, fr-fr.json, ja-jp.json, pt-pt.json, ro-ro.json, and ru-ru.json.
- Modified usage of the shortcut in AgentPage.tsx and HomePage.tsx to reflect the new sidebar toggle logic.
- Adjusted migration and initial state in migrate.ts and shortcuts.ts to use 'toggle_sidebar'.
- Updated documentation and classification files to align with the new shortcut key.
@kangfenmao kangfenmao force-pushed the refactor/v2/shortcuts branch from 5c40a83 to f934bd0 Compare April 9, 2026 05:14
Comment thread src/main/services/ShortcutService.ts Outdated
Comment thread src/main/services/ConfigManager.ts
Comment thread src/main/services/AppMenuService.ts
Comment thread src/renderer/src/store/shortcuts.ts
Comment thread src/renderer/src/store/migrate.ts
…d update shortcut management logic

- Changed the key of the shortcut from 'toggle_sidebar' to 'toggle_show_assistants'.
- Introduced a new function getSerializableShortcuts to prepare shortcuts for serialization.
- Updated the updateShortcut, toggleShortcut, and resetShortcuts reducers to use the new serialization function when updating shortcuts.
0xfullex

This comment was marked as duplicate.

Copy link
Copy Markdown
Member

@0xfullex 0xfullex left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

This issue/comment/review was translated by Claude.

Overall direction is approved, but the following 4 points need to be addressed before merging.


🚨 Critical

C2. onStop() does not reset isRegisterOnBoot, so first window after hot restart no longer registers boot-state shortcuts

Location: src/main/services/ShortcutService.ts:48-51

The framework supports stop → start to trigger onInit() again, but isRegisterOnBoot is a class field with default value, only initialized once during new; onStop() only does unregisterAll() and mainWindow = null, neither resetting isRegisterOnBoot = true nor handlers.clear(). After service restart, registerForWindow will take the false branch of if (this.isRegisterOnBoot), and the tray startup registration path for ready-to-show will never execute again.

protected async onStop() {
  this.unregisterAll()
  this.mainWindow = null
  this.isRegisterOnBoot = true   // +
  this.handlers.clear()          // + avoid duplicate set after restart registerBuiltInHandlers
}

Suggest adding a ShortcutService.test.ts test case to cover shortcuts rebinding after stop → start.


C3. No user feedback for shortcut registration conflicts (occupied by system/other applications)

Location: src/main/services/ShortcutService.ts:238-245

if (success) {
  this.registeredAccelerators.set(accelerator, { handler, window: win })
} else {
  logger.warn(`Failed to register shortcut ${accelerator}: accelerator is held by another application`)
}

globalShortcut.register() returning false is a normal signal from Electron, indicating the accelerator is occupied by the system/other applications (users will frequently encounter this when binding Ctrl+Space, Cmd+Tab combinations). The current implementation only logs a logger.warn, with no red indication/toast on the settings page. Users will see "Save successful" but pressing does nothing—this is the most core user feedback scenario for the shortcut system.

Additionally, findDuplicateLabel in ShortcutSettings.tsx only covers "Cherry Studio internal" binding conflicts, excluding "system-level conflicts".

Suggestions:

  1. After registration completes, push failed keys to renderer via IPC (can refer to AppUpdaterService.ts's IpcChannel.UpdateError pattern).
  2. ShortcutSettings displays "This shortcut is occupied by system or other application" hint on the corresponding row, with a red border.
  3. The catch on lines 243-244 that actually throws exceptions suggests upgrading to logger.error (throwing exceptions is a real bug, different from "occupied", should not be same level).

⚠️ Important

I1. window.once('ready-to-show', ...) not registered as disposable

Location: src/main/services/ShortcutService.ts:147-152

window.once('ready-to-show', () => {
  if (\!this.mainWindow || this.mainWindow.isDestroyed()) return
  if (application.get('PreferenceService').get('app.tray.on_launch')) {
    this.registerShortcuts(window, true)
  }
})

The current guard only defends against window being destroyed. If service has already onStop, but window is still alive (hot restart scenario that only stops ShortcutService without stopping WindowService), this callback will still call this.registerShortcuts—at this point registeredAccelerators and other states have been cleared in unregisterAll, but globalShortcut will register again.

Suggest registering as a disposable:

const onReadyToShow = () => {
  if (\!this.mainWindow || this.mainWindow.isDestroyed()) return
  if (application.get('PreferenceService').get('app.tray.on_launch')) {
    this.registerShortcuts(window, true)
  }
}
window.once('ready-to-show', onReadyToShow)
this.registerDisposable(() => window.off('ready-to-show', onReadyToShow))

Incidentally addresses the onStop reset issue mentioned in C2.


I2. windowOnHandlers (focus/blur/closed) do not use registerDisposable

Location: src/main/services/ShortcutService.ts:31, 156-169, 260-279

Manually storing listeners with windowOnHandlers Map, manually window.off in onStop, is inconsistent with the lifecycle specification convention that "all resources requiring cleanup should go through registerDisposable". Additionally:

  • BaseService._doStop calls _cleanupDisposables() after onStop() returns—this means if unregisterAll throws an error mid-way (e.g., window.off fails under some edge condition), remaining listeners won't be cleaned up again.
  • The outer try...catch wrapping the entire unregisterAll (lines 261-278) swallows unexpected errors, state may stop at an inconsistent point (e.g., windowOnHandlers.clear() executed but registeredAccelerators.clear() didn't).

Suggest registering directly in registerForWindow:

if (\!this.windowOnHandlers.has(window)) {
  const onFocus = () => this.registerShortcuts(window, false)
  const onBlur = () => this.registerShortcuts(window, true)
  const onClosed = () => { /* ... */ }
  window.on('focus', onFocus)
  window.on('blur', onBlur)
  window.once('closed', onClosed)
  this.windowOnHandlers.set(window, { onFocus, onBlur, onClosed })

  this.registerDisposable(() => {
    if (window.isDestroyed()) return
    window.off('focus', onFocus)
    window.off('blur', onBlur)
    window.off('closed', onClosed)
  })
}

unregisterAll should only keep the globalShortcut.unregister part, remove the outer try's broad catch, let unexpected errors bubble to lifecycle manager's unified error path.


Other issues found in the review (antd icons, dead Redux actions, category field has no consumer, test coverage gaps, migration silent skips, etc.) are not being made as mandatory items for this request-changes, can be followed up later.


Original Content

整体方向认可,但以下 4 点需要处理后再合并。


🚨 Critical

C2. onStop() 未重置 isRegisterOnBoot,热重启后首次窗口不再注册启动态 shortcut

位置src/main/services/ShortcutService.ts:48-51

框架支持 stop → start 再次触发 onInit(),但 isRegisterOnBoot 是类字段默认值,只在 new 时初始化一次;onStop() 只做了 unregisterAll()mainWindow = null,既没复位 isRegisterOnBoot = true,也没 handlers.clear()。service 被重启后,registerForWindow 会走 if (this.isRegisterOnBoot) 的 false 分支,ready-to-show 的 tray 启动注册路径永远不会再执行

protected async onStop() {
  this.unregisterAll()
  this.mainWindow = null
  this.isRegisterOnBoot = true   // +
  this.handlers.clear()          // + 避免重启后 registerBuiltInHandlers 重复 set
}

建议补一个 ShortcutService.test.ts 用例覆盖 stop → start 后 shortcuts 能重新绑定。


C3. 快捷键注册冲突(被系统/其他应用占用)无任何用户反馈

位置src/main/services/ShortcutService.ts:238-245

if (success) {
  this.registeredAccelerators.set(accelerator, { handler, window: win })
} else {
  logger.warn(`Failed to register shortcut ${accelerator}: accelerator is held by another application`)
}

globalShortcut.register() 返回 false 是 Electron 的常规信号,表示 accelerator 被系统/其他应用占用(用户绑定 Ctrl+SpaceCmd+Tab 等组合时会频繁遇到)。当前实现只打一条 logger.warn,设置页面也没有任何红色提示/toast。用户会看到"保存成功",但按下去毫无反应——这是 shortcut 系统最核心的用户反馈场景

此外,ShortcutSettings.tsx 里的 findDuplicateLabel 只覆盖"Cherry Studio 内部"的 binding 冲突,不包含"系统级冲突"

建议:

  1. 注册完成后把失败的 key 通过 IPC 推给 renderer(可参考 AppUpdaterService.tsIpcChannel.UpdateError 模式)。
  2. ShortcutSettings 在对应行显示 "该快捷键已被系统或其他应用占用" 的提示,并加红色边框。
  3. 第 243-244 行真正抛异常的 catch 建议升级为 logger.error(抛异常是真正的 bug,与"被占用"不同,不应同一 level)。

⚠️ Important

I1. window.once('ready-to-show', ...) 未登记到 disposable

位置src/main/services/ShortcutService.ts:147-152

window.once('ready-to-show', () => {
  if (\!this.mainWindow || this.mainWindow.isDestroyed()) return
  if (application.get('PreferenceService').get('app.tray.on_launch')) {
    this.registerShortcuts(window, true)
  }
})

当前 guard 只防御 window 被销毁的情况。若 service 已 onStop,但 window 仍然存活(只停掉 ShortcutService 而不停 WindowService 的热重启场景),这个 callback 仍会调用 this.registerShortcuts——此时 registeredAccelerators 等状态已在 unregisterAll 中被清空,但 globalShortcut 仍会再注册。

建议登记为 disposable:

const onReadyToShow = () => {
  if (\!this.mainWindow || this.mainWindow.isDestroyed()) return
  if (application.get('PreferenceService').get('app.tray.on_launch')) {
    this.registerShortcuts(window, true)
  }
}
window.once('ready-to-show', onReadyToShow)
this.registerDisposable(() => window.off('ready-to-show', onReadyToShow))

附带解决 C2 中提到的 onStop 复位问题。


I2. windowOnHandlers(focus/blur/closed)未走 registerDisposable

位置src/main/services/ShortcutService.ts:31, 156-169, 260-279

手动用 windowOnHandlers Map 存 listener、onStop 里手动 window.off,与 lifecycle 规范约定的"所有需要清理的资源统一通过 registerDisposable"不一致。并且:

  • BaseService._doStoponStop() 返回之后_cleanupDisposables()——这意味着如果 unregisterAll 中途抛错(例如 window.off 在某种边界条件下挂掉),剩余 listener 不会被二次清理。
  • 外层 try...catch 包住整个 unregisterAll(第 261-278 行),吞掉非预期错误,状态可能停在不一致点(例如 windowOnHandlers.clear() 执行了但 registeredAccelerators.clear() 没执行)。

建议在 registerForWindow 里直接登记:

if (\!this.windowOnHandlers.has(window)) {
  const onFocus = () => this.registerShortcuts(window, false)
  const onBlur = () => this.registerShortcuts(window, true)
  const onClosed = () => { /* ... */ }
  window.on('focus', onFocus)
  window.on('blur', onBlur)
  window.once('closed', onClosed)
  this.windowOnHandlers.set(window, { onFocus, onBlur, onClosed })

  this.registerDisposable(() => {
    if (window.isDestroyed()) return
    window.off('focus', onFocus)
    window.off('blur', onBlur)
    window.off('closed', onClosed)
  })
}

unregisterAll 里只保留 globalShortcut.unregister 部分,去掉外层 try 的大范围 catch,让非预期错误冒泡到 lifecycle manager 的统一错误路径。


其他 review 中发现的问题(antd icon、dead Redux actions、category 字段无消费方、test 覆盖缺口、迁移静默跳过等),先不作为本次 request-changes 的强制项,可以后续跟进。

@kangfenmao kangfenmao force-pushed the refactor/v2/shortcuts branch from 79dc514 to e3dd377 Compare April 14, 2026 06:52
@kangfenmao kangfenmao requested review from 0xfullex and EurFelux April 14, 2026 07:43
0xfullex

This comment was marked as duplicate.

@kangfenmao
Copy link
Copy Markdown
Collaborator Author

@0xfullex Check if there are any parts that need to be modified

# Conflicts:
#	src/renderer/src/i18n/translate/de-de.json
#	src/renderer/src/i18n/translate/el-gr.json
#	src/renderer/src/i18n/translate/es-es.json
#	src/renderer/src/i18n/translate/fr-fr.json
#	src/renderer/src/i18n/translate/ja-jp.json
#	src/renderer/src/i18n/translate/pt-pt.json
#	src/renderer/src/i18n/translate/ro-ro.json
#	src/renderer/src/i18n/translate/ru-ru.json
Copy link
Copy Markdown
Member

@0xfullex 0xfullex left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Request change] labelKey is still hand-duplicated in 3 places

Thanks for the iteration. The ShortcutLabelKey union type was added (good — it catches typos at compile time), but the underlying drift this was meant to fix is still there: the labelKey list now lives in three hand-written places that must stay in sync.

Current state

  1. packages/shared/shortcuts/definitions.tsSHORTCUT_DEFINITIONS[].labelKey (20 entries, source of truth)
  2. packages/shared/shortcuts/types.ts:25-43ShortcutLabelKey union (20 string literals, hand-written)
  3. src/renderer/src/i18n/label.tsshortcutLabelKeyMap: Record<ShortcutLabelKey, string> (20 entries, hand-written)

The Record<ShortcutLabelKey, ...> constraint forces (3) to stay exhaustive w.r.t. (2), which is an improvement. But (2) itself is still a manual copy of the labelKey column from (1), and (3) rewrites the settings.shortcuts.{labelKey} pattern entry-by-entry. Adding a new shortcut requires editing 4 places (definitions + union + map + locale JSON) instead of 2 (definitions + locale JSON).

Suggested fix

Make SHORTCUT_DEFINITIONS the single source of truth by preserving its literal shape and deriving everything else.

Step 1 — definitions.ts: switch from a widening annotation to as const satisfies:

```ts
export const SHORTCUT_DEFINITIONS = [
{ key: 'shortcut.general.show_main_window', scope: 'main', category: 'general', labelKey: 'show_app', global: true },
// ...
] as const satisfies readonly ShortcutDefinition[]
```

Step 2 — types.ts: derive the union from the constant:

```ts
import { SHORTCUT_DEFINITIONS } from './definitions'
export type ShortcutLabelKey = (typeof SHORTCUT_DEFINITIONS)[number]['labelKey']
```

(If the circular-import risk between `types.ts` and `definitions.ts` is a concern, move the derived type into `definitions.ts` or a new `derived.ts`.)

Step 3 — label.ts: derive the map from the constant:

```ts
import { SHORTCUT_DEFINITIONS } from '@shared/shortcuts/definitions'

const shortcutLabelKeyMap = Object.fromEntries(
SHORTCUT_DEFINITIONS.map((d) => [d.labelKey, `settings.shortcuts.${d.labelKey}`])
) as Record<ShortcutLabelKey, string>
```

After

  • Adding a new shortcut = add 1 entry to `SHORTCUT_DEFINITIONS` + add 1 i18n entry. That's it.
  • No possibility of forgetting `ShortcutLabelKey` or `shortcutLabelKeyMap` — they can't drift, they're derived.
  • Runtime assertion for "every `labelKey` actually has an i18n entry" can be added as a unit test if desired, but the derivation already eliminates the common bug class.

This closes the loop on the original [50] comment.

@0xfullex 0xfullex merged commit 4197117 into v2 Apr 16, 2026
6 checks passed
@0xfullex 0xfullex deleted the refactor/v2/shortcuts branch April 16, 2026 03:38
DeJeune pushed a commit that referenced this pull request Apr 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants