[eric] settings: the shortcut recorder owns the keyboard while armed (window-level capture, hotkeys suppressed), so rebinding no longer snaps back to fn

This commit is contained in:
ciregenz
2026-08-09 13:51:55 -07:00
parent d050517c4e
commit 8fdc1fa2f9
2 changed files with 46 additions and 21 deletions
@@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import KeyboardIcon from '@mui/icons-material/Keyboard'; import KeyboardIcon from '@mui/icons-material/Keyboard';
@@ -26,31 +26,53 @@ export function comboDisplay(combo: string): string {
.join(IS_MAC ? '' : '+'); .join(IS_MAC ? '' : '+');
} }
/** Click-to-record shortcut chip: click arms it, the next non-modifier keydown becomes the combo ("Meta+Shift+d" parts format, same as new_agent_shortcut). */ /** Click-to-record shortcut chip: click arms it, the next non-modifier keydown becomes the combo
* ("Meta+Shift+d" parts format, same as new_agent_shortcut). While armed, the WINDOW owns the
* keyboard at capture phase and app hotkeys are suppressed: the old chip-local listener lost the
* keys to global shortcuts (pressing the current dictation combo started a dictation, stole focus,
* and snapped the chip back before any combo could land, ENG-183). */
const ShortcutRecorderChip: React.FC<{ value: string; onChange: (combo: string) => void }> = ({ value, onChange }) => { const ShortcutRecorderChip: React.FC<{ value: string; onChange: (combo: string) => void }> = ({ value, onChange }) => {
const c = useClaudeTokens(); const c = useClaudeTokens();
const [recording, setRecording] = useState(false); const [recording, setRecording] = useState(false);
const hostRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!recording) return undefined;
const w = window as unknown as Record<string, unknown>;
w.__OSW_SHORTCUT_RECORDING__ = true;
const onKey = (e: KeyboardEvent): void => {
// Swallow EVERYTHING while armed so no app shortcut fires mid-recording.
e.preventDefault();
e.stopImmediatePropagation();
if (['Meta', 'Control', 'Shift', 'Alt'].includes(e.key)) return;
if (e.key === 'Escape') { setRecording(false); return; }
const parts: string[] = [];
if (e.metaKey) parts.push('Meta');
if (e.ctrlKey) parts.push('Ctrl');
if (e.altKey) parts.push('Alt');
if (e.shiftKey) parts.push('Shift');
parts.push(e.key.length === 1 ? e.key.toLowerCase() : e.key);
onChange(parts.join('+'));
setRecording(false);
};
// Click-away cancels; blur alone must not (a global hotkey stealing focus was the snap-back).
const onPointerDown = (e: PointerEvent): void => {
if (hostRef.current && e.target instanceof Node && hostRef.current.contains(e.target)) return;
setRecording(false);
};
window.addEventListener('keydown', onKey, true);
window.addEventListener('pointerdown', onPointerDown, true);
return () => {
w.__OSW_SHORTCUT_RECORDING__ = false;
window.removeEventListener('keydown', onKey, true);
window.removeEventListener('pointerdown', onPointerDown, true);
};
}, [recording, onChange]);
return ( return (
<Box <Box
tabIndex={0} ref={hostRef}
onKeyDown={(e) => { onClick={() => setRecording(true)}
if (!recording) return;
if (['Meta', 'Control', 'Shift', 'Alt'].includes(e.key)) return;
e.preventDefault();
if (e.key === 'Escape') { setRecording(false); return; }
const parts: string[] = [];
if (e.metaKey) parts.push('Meta');
if (e.ctrlKey) parts.push('Ctrl');
if (e.altKey) parts.push('Alt');
if (e.shiftKey) parts.push('Shift');
parts.push(e.key.length === 1 ? e.key.toLowerCase() : e.key);
onChange(parts.join('+'));
setRecording(false);
}}
onBlur={() => setRecording(false)}
// Arming without taking focus meant the next blur disarmed it before any key could land, so
// the chip snapped back to its old value and looked like it refused to be rebound.
onClick={(e) => { (e.currentTarget as HTMLElement).focus(); setRecording(true); }}
sx={{ sx={{
display: 'inline-flex', display: 'inline-flex',
alignItems: 'center', alignItems: 'center',
@@ -62,6 +62,9 @@ export function VoiceDictationProvider({ children }: { children: React.ReactNode
const latchedRef = useRef(false); const latchedRef = useRef(false);
const pressStart = useCallback((): void => { const pressStart = useCallback((): void => {
// The Settings recorder chip owns the keyboard while it's armed: without this, pressing the
// CURRENT hotkey to rebind it started a dictation, which stole focus and snapped the chip back.
if ((window as unknown as Record<string, unknown>).__OSW_SHORTCUT_RECORDING__) return;
if (holdMode) { if (holdMode) {
// A TAP while recording must stop: the quick tap's press-end fires before the async start // A TAP while recording must stop: the quick tap's press-end fires before the async start
// flips state to 'recording', so without this the mic could be started by a click but never // flips state to 'recording', so without this the mic could be started by a click but never