From 8e5e034df18c22521d5b8a0f724445000da56233 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 21 Apr 2026 11:37:37 +0000 Subject: [PATCH] feat(B.1 #4 UX): debounce hotkey press events by 120ms A rapid double-tap of the global hotkey, evdev autorepeat, or a sticky-key compositor quirk (KDE's 'slow keys') can all deliver the same press twice within ~100ms. Without a guard, the recording toggles into and out of the same frame and the capture is lost. Gates the evdev 'kon:hotkey-pressed' forwarder in +layout.svelte behind a 120ms debounce (Date.now()-based; no timers, so no tail latency for a legitimate single press). The debounce is intentionally shorter than a deliberate double-press cadence but longer than any autorepeat interval we've seen in the wild. The audio-stream-warming half of brief item #4 (Handy #1143) lives in Workstream A's Phase A.3 warm-up WAV; this covers the UX side. Co-authored-by: jars --- src/routes/+layout.svelte | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 5c25d88..45f1c30 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -136,12 +136,25 @@ } } - // Listen for evdev hotkey events from the Rust backend + // Listen for evdev hotkey events from the Rust backend. + // + // Debounce window: evdev autorepeat, a sticky-key compositor quirk, + // or a user's nervous double-tap can all deliver the same press + // twice within ~100 ms — which, without debouncing, toggles the + // recording into and out of the same frame and loses the capture. + // Matches Handy #1143 ('first press records nothing, second works'). + // This is the UX side of brief item #4; the audio-stream warm-up + // side is owned by Workstream A. + const HOTKEY_DEBOUNCE_MS = 120; + let lastHotkeyAtMs = 0; let unlistenEvdev = null; async function setupEvdevListener() { if (!tauriRuntimeAvailable) return; const { listen } = await import("@tauri-apps/api/event"); unlistenEvdev = await listen("kon:hotkey-pressed", () => { + const now = Date.now(); + if (now - lastHotkeyAtMs < HOTKEY_DEBOUNCE_MS) return; + lastHotkeyAtMs = now; if (page.current !== "dictation") page.current = "dictation"; requestAnimationFrame(() => { window.dispatchEvent(new CustomEvent("kon:toggle-recording"));