From 865f0b8f97fc7a8ffe52fcfe07a7f586c9e6acbd Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Fri, 25 Sep 2026 07:29:28 -0700 Subject: [PATCH 1/4] docs(plan): shortcut recorder releases capture on outside click and blur (#1272) Co-Authored-By: Claude Opus 5.5 --- docs/plans/2026-09-25-shortcut-recorder-release.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 docs/plans/2026-09-25-shortcut-recorder-release.md diff --git a/docs/plans/2026-09-25-shortcut-recorder-release.md b/docs/plans/2026-09-25-shortcut-recorder-release.md new file mode 100644 index 000000000..8da093542 --- /dev/null +++ b/docs/plans/2026-09-25-shortcut-recorder-release.md @@ -0,0 +1,12 @@ +# Shortcut recorder releases capture on outside click and window blur (#1272) + +## Evidence +`CommandKeybindingsRow.tsx`: while recording, a capture-phase window keydown listener calls preventDefault and stopPropagation on every key. It releases only on Escape or a finished chord. `keybindingFromEvent` accepts a bare letter. + +Sequence: click Add, then click the Settings search box, then type `t`. The key is swallowed and saved as that command's shortcut. The sibling recorders (`HotkeyInput`, `MouseButtonInput`) already release on an outside mousedown and on blur. + +## Change +While recording, a mousedown outside the recorder's own button, or a window blur, ends the recording. This matches HotkeyInput. + +## Tests +A renderer test on the real component with the real store. The first test clicks elsewhere and asserts that recording ended and that a typed `t` is neither swallowed nor saved. The second blurs the window. Both are red on main. From de4b0fe79414f044838f976f0ac982190da137e2 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Fri, 25 Sep 2026 07:30:11 -0700 Subject: [PATCH 2/4] fix(settings): the shortcut recorder stops on an outside click or window blur (#1272) Recording captured every key until Escape or a finished chord, so clicking the Settings search box and typing 't' silently bound 'T' to the command. It now ends on a mousedown outside the recorder button or a window blur, like HotkeyInput and MouseButtonInput. Co-Authored-By: Claude Opus 5.5 --- ...ndKeybindingsRow.capture.renderer.test.tsx | 39 +++++++++++++++++++ .../settings/ui/CommandKeybindingsRow.tsx | 23 +++++++++++ 2 files changed, 62 insertions(+) create mode 100644 src/renderer/src/features/settings/ui/CommandKeybindingsRow.capture.renderer.test.tsx diff --git a/src/renderer/src/features/settings/ui/CommandKeybindingsRow.capture.renderer.test.tsx b/src/renderer/src/features/settings/ui/CommandKeybindingsRow.capture.renderer.test.tsx new file mode 100644 index 000000000..9cc9c9f72 --- /dev/null +++ b/src/renderer/src/features/settings/ui/CommandKeybindingsRow.capture.renderer.test.tsx @@ -0,0 +1,39 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { afterEach, expect, it } from 'vitest' + +import { useAppStore } from '@renderer/app-state/store' +import { CommandKeybindingsRow } from './CommandKeybindingsRow' + +// #1272 (C4 hunt): the recorder captured every key on the window until +// Escape or a finished chord. Clicking elsewhere (the Settings search box) or +// leaving the window did not release it, so the next bare letter typed was +// swallowed AND saved as that command's shortcut. +const original = useAppStore.getState() +afterEach(() => { useAppStore.setState(original, true) }) + +function startRecording() { + render() + const add = screen.getAllByRole('button', { name: 'Add' })[0]! + fireEvent.click(add) + expect(screen.getByRole('button', { name: 'Press keys… (Esc)' })).toBeInTheDocument() +} + +it('stops recording when the user clicks somewhere else', () => { + startRecording() + const elsewhere = document.createElement('input') + document.body.appendChild(elsewhere) + fireEvent.mouseDown(elsewhere) + expect(screen.queryByRole('button', { name: 'Press keys… (Esc)' })).toBeNull() + // The next key is ordinary typing again: not swallowed, not saved. + const before = JSON.stringify(useAppStore.getState().settings.commandKeybindingOverrides ?? {}) + const typed = fireEvent.keyDown(elsewhere, { key: 't', code: 'KeyT' }) + expect(typed).toBe(true) + expect(JSON.stringify(useAppStore.getState().settings.commandKeybindingOverrides ?? {})).toBe(before) + elsewhere.remove() +}) + +it('stops recording when the window loses focus', () => { + startRecording() + fireEvent.blur(window) + expect(screen.queryByRole('button', { name: 'Press keys… (Esc)' })).toBeNull() +}) diff --git a/src/renderer/src/features/settings/ui/CommandKeybindingsRow.tsx b/src/renderer/src/features/settings/ui/CommandKeybindingsRow.tsx index 7a0ce9271..7dc13f415 100644 --- a/src/renderer/src/features/settings/ui/CommandKeybindingsRow.tsx +++ b/src/renderer/src/features/settings/ui/CommandKeybindingsRow.tsx @@ -395,6 +395,26 @@ export function CommandKeybindingsRow() { return () => window.removeEventListener('keydown', onKeyDown, true) }, [capturingFor, contextForCommandId, effective, effectiveAsDefaults, settings.dictationShortcut, commit]) + // The recording owns every key, so it must end the moment the user's + // attention does (#1272, C4 hunt): a click anywhere but the recorder's own + // button, or the window losing focus. Escape alone was not enough, because + // clicking the Settings search box and typing "t" silently bound "T" to the + // command. Same release rule as HotkeyInput and MouseButtonInput. + useEffect(() => { + if (!capturingFor) return + const release = () => setCapturingFor(null) + const onMouseDown = (event: MouseEvent) => { + const target = event.target as Element | null + if (!target?.closest?.('[data-shortcut-recorder]')) release() + } + window.addEventListener('mousedown', onMouseDown, true) + window.addEventListener('blur', release) + return () => { + window.removeEventListener('mousedown', onMouseDown, true) + window.removeEventListener('blur', release) + } + }, [capturingFor]) + /** * The ONLY override path. Removes the binding from every command that owns * it and installs it on the requester, in ONE settings write — so the store @@ -506,6 +526,9 @@ export function CommandKeybindingsRow() { )}