From da931c9ea822f301cd668dc5b701e9860ddda181 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Thu, 24 Sep 2026 23:12:30 -0500 Subject: [PATCH] fix(desktop): ignore auto-repeat and post-focus Ctrl+W/Ctrl+R Holding W through a browser's own Ctrl+W keeps sending auto-repeat keyDowns once Hermes inherits focus, and each one closed a tab; a keydown synthesized on window activation could do the same. Close Tab and Reload now swallow auto-repeats and anything within 200ms of the window gaining focus. They are still claimed so the renderer's mod+w keybind can't act on them. Zoom keeps repeating. Fixes #105498 Co-authored-by: kokhlo --- apps/desktop/electron/main.ts | 17 ++++++++- .../electron/window-accelerator.test.ts | 23 ++++++++++- apps/desktop/electron/window-accelerator.ts | 38 +++++++++++++++---- 3 files changed, 69 insertions(+), 9 deletions(-) diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 0b79236420..9831feed21 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -6624,8 +6624,23 @@ function installDevToolsShortcut(window) { } function installPreviewShortcut(window) { + let focusedAt = 0 + + window.on('focus', () => { + focusedAt = Date.now() + }) + window.webContents.on('before-input-event', (event, input) => { - const action = windowAcceleratorAction(input, IS_MAC) + const action = windowAcceleratorAction(input, IS_MAC, Date.now() - focusedAt) + + // A ⌘W/⌘R that auto-repeats or lands right as focus arrives belongs to + // the app that just lost focus (#105498). Claim it so the renderer's + // keybind doesn't act on it either, and do nothing. + if (action === 'swallow') { + event.preventDefault() + + return + } // Always claim ⌘W here (the File>Close item deliberately has no // accelerator, so nothing else does). The renderer decides tab-vs-window diff --git a/apps/desktop/electron/window-accelerator.test.ts b/apps/desktop/electron/window-accelerator.test.ts index e6ab4c2daa..f8e3d600a5 100644 --- a/apps/desktop/electron/window-accelerator.test.ts +++ b/apps/desktop/electron/window-accelerator.test.ts @@ -2,7 +2,7 @@ import assert from 'node:assert/strict' import { test } from 'vitest' -import { windowAcceleratorAction, type WindowAcceleratorInput } from './window-accelerator' +import { FOCUS_GRACE_MS, windowAcceleratorAction, type WindowAcceleratorInput } from './window-accelerator' function chord(overrides: Partial = {}): WindowAcceleratorInput { return { @@ -115,3 +115,24 @@ test('folds letter case for Close Tab and Reload only', () => { assert.equal(windowAcceleratorAction(chord({ key: 'W', control: true }), false), 'close-tab') assert.equal(windowAcceleratorAction(chord({ key: 'R', meta: true }), true), 'reload') }) + +test('swallows an auto-repeating Ctrl+W or Ctrl+R instead of acting on it (#105498)', () => { + assert.equal(windowAcceleratorAction(chord({ key: 'w', control: true, isAutoRepeat: true }), false), 'swallow') + assert.equal(windowAcceleratorAction(chord({ key: 'w', meta: true, isAutoRepeat: true }), true), 'swallow') + assert.equal(windowAcceleratorAction(chord({ key: 'r', control: true, isAutoRepeat: true }), false), 'swallow') +}) + +test('swallows Ctrl+W and Ctrl+R that land inside the post-focus grace window (#105498)', () => { + assert.equal(windowAcceleratorAction(chord({ key: 'w', control: true }), false, 0), 'swallow') + assert.equal(windowAcceleratorAction(chord({ key: 'r', control: true }), false, FOCUS_GRACE_MS - 1), 'swallow') +}) + +test('acts on Ctrl+W and Ctrl+R once the post-focus grace window has passed', () => { + assert.equal(windowAcceleratorAction(chord({ key: 'w', control: true }), false, FOCUS_GRACE_MS), 'close-tab') + assert.equal(windowAcceleratorAction(chord({ key: 'r', meta: true }), true, FOCUS_GRACE_MS + 50), 'reload') +}) + +test('keeps zoom on auto-repeat and inside the focus grace window (holding Ctrl+= zooms)', () => { + assert.equal(windowAcceleratorAction(chord({ key: '=', control: true, isAutoRepeat: true }), false), 'zoom-in') + assert.equal(windowAcceleratorAction(chord({ key: '-', control: true }), false, 0), 'zoom-out') +}) diff --git a/apps/desktop/electron/window-accelerator.ts b/apps/desktop/electron/window-accelerator.ts index 4fa734d321..933236aefd 100644 --- a/apps/desktop/electron/window-accelerator.ts +++ b/apps/desktop/electron/window-accelerator.ts @@ -1,8 +1,22 @@ -export type WindowAcceleratorAction = 'close-tab' | 'reload' | 'zoom-in' | 'zoom-out' | 'zoom-reset' | 'ignore' +export type WindowAcceleratorAction = + | 'close-tab' + | 'reload' + | 'zoom-in' + | 'zoom-out' + | 'zoom-reset' + | 'swallow' + | 'ignore' + +/** + * How long after the window gains focus a Close Tab / Reload keyDown is + * treated as belonging to the app that just lost focus (#105498). + */ +export const FOCUS_GRACE_MS = 200 export interface WindowAcceleratorInput { alt?: boolean control?: boolean + isAutoRepeat?: boolean key?: string meta?: boolean shift?: boolean @@ -15,8 +29,18 @@ export interface WindowAcceleratorInput { * `before-input-event` fires for keydown and keyup. A keyup that arrives after * Windows transfers focus (Ctrl+W started in another app) is not a chord this * window owns, so only `keyDown` is an accelerator. + * + * Close Tab and Reload are destructive, so two more keyDowns are claimed but + * not acted on ('swallow'): auto-repeats (W held through a browser's own + * Ctrl+W keeps repeating once this window is foreground) and anything within + * FOCUS_GRACE_MS of focus arriving (a keydown synthesized on activation). + * They are still claimed so the renderer's `mod+w` keybind can't act on them. */ -export function windowAcceleratorAction(input: WindowAcceleratorInput, isMac: boolean): WindowAcceleratorAction { +export function windowAcceleratorAction( + input: WindowAcceleratorInput, + isMac: boolean, + msSinceFocus = Number.POSITIVE_INFINITY +): WindowAcceleratorAction { if (input.type !== 'keyDown') { return 'ignore' } @@ -29,12 +53,12 @@ export function windowAcceleratorAction(input: WindowAcceleratorInput, isMac: bo return 'ignore' } - if (folded === 'w' && !input.shift) { - return 'close-tab' - } + if ((folded === 'w' || folded === 'r') && !input.shift) { + if (input.isAutoRepeat || msSinceFocus < FOCUS_GRACE_MS) { + return 'swallow' + } - if (folded === 'r' && !input.shift) { - return 'reload' + return folded === 'w' ? 'close-tab' : 'reload' } if (key === '0') {