diff --git a/.kiro/refactor/issue-52-hotkey-dismiss-error.md b/.kiro/refactor/issue-52-hotkey-dismiss-error.md new file mode 100644 index 0000000..a916fc2 --- /dev/null +++ b/.kiro/refactor/issue-52-hotkey-dismiss-error.md @@ -0,0 +1,67 @@ +# Issue #52 — Hotkey should dismiss error overlay and restart dictation + +## Problem + +When the user presses the hotkey and releases too quickly (no speech captured), the app enters `.error("No speech was detected...")` state with a 5-second auto-dismiss timer. During those 5 seconds, pressing the hotkey again is **silently ignored** — the user is blocked from starting a new dictation. + +## Root Cause + +Two guard conditions in `StateManager.swift` reject hotkey presses unless the app is in `.idle` state: + +1. **`beginRecording()`** (push-to-talk mode): `guard appState == .idle else { return }` +2. **`toggleRecording()`** (hands-free mode): `case .error: break` + +Both silently drop the hotkey event when the app is in `.error` state. + +## Fix — 2 changes in `StateManager.swift` + +### 1. `beginRecording()` — handle `.error` state before the guard + +```swift +// Before the existing guard: +if case .error = appState { + await resetToIdle() +} +guard appState == .idle else { ... } +``` + +`resetToIdle()` cancels the error dismiss timer, clears the error message, and sets state to `.idle`. The existing guard then passes and recording starts normally. + +### 2. `toggleRecording()` — separate `.error` from `.loading`/`.processing` + +```swift +case .error: + await resetToIdle() + await beginRecording() +case .loading, .processing: + break +``` + +### No changes needed elsewhere + +- **HotkeyMonitor** — already fires callbacks unconditionally regardless of app state +- **RecordingOverlayView** — already renders all states correctly (error → recording transition is seamless) +- **wisprApp overlay visibility** — driven by state, will show/hide automatically +- **resetToIdle()** — already cancels the error dismiss timer and clears state + +## Testing + +- [x] Add test: hotkey during `.error` state transitions to `.recording` (push-to-talk) +- [x] Add test: hotkey during `.error` state transitions to `.recording` (hands-free / toggle) +- [ ] Add test: error dismiss timer is cancelled when hotkey interrupts error state (not directly testable — `errorDismissTask` is private) +- [x] Verify existing tests still pass + +## Implementation Summary + +### Changes in `StateManager.swift` + +1. **`beginRecording()`** — added `if case .error = appState { await resetToIdle() }` before the existing guard. Clears the error and lets recording proceed. +2. **`toggleRecording()`** — split `.error` out of `case .loading, .processing, .error: break` into its own case that calls `resetToIdle()` then `beginRecording()`. + +### Tests updated in `StateManagerTests.swift` + +1. **`testBeginRecordingDismissesError`** — replaced old `testConcurrentRecordingPreventionWhileError` that asserted error stays. Now verifies error is dismissed and `errorMessage` is cleared on hotkey press (push-to-talk). +2. **`testToggleRecordingDismissesError`** — new test verifying push-to-talk toggle path dismisses error. +3. **`testToggleRecordingDismissesErrorHandsFree`** — replaced old `testToggleRecordingIgnoredWhileError`. Now verifies hands-free toggle dismisses error. + +All StateManager tests pass (0 failures). diff --git a/wispr.xcodeproj/project.pbxproj b/wispr.xcodeproj/project.pbxproj index 37dedfe..9581ffe 100644 --- a/wispr.xcodeproj/project.pbxproj +++ b/wispr.xcodeproj/project.pbxproj @@ -528,7 +528,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 260412.271; + CURRENT_PROJECT_VERSION = 260417.273; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 56U756R2L2; ENABLE_APP_SANDBOX = YES; @@ -549,7 +549,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.9.0; + MARKETING_VERSION = 1.9.1; PRODUCT_BUNDLE_IDENTIFIER = com.stormacq.mac.wispr; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -574,7 +574,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 260412.271; + CURRENT_PROJECT_VERSION = 260417.273; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 56U756R2L2; ENABLE_APP_SANDBOX = YES; @@ -595,7 +595,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.9.0; + MARKETING_VERSION = 1.9.1; PRODUCT_BUNDLE_IDENTIFIER = com.stormacq.mac.wispr; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -616,12 +616,12 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 260412.271; + CURRENT_PROJECT_VERSION = 260417.273; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 56U756R2L2; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.2; - MARKETING_VERSION = 1.9.0; + MARKETING_VERSION = 1.9.1; PRODUCT_BUNDLE_IDENTIFIER = com.stormacq.mac.wisprTests; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -642,12 +642,12 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 260412.271; + CURRENT_PROJECT_VERSION = 260417.273; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 56U756R2L2; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.2; - MARKETING_VERSION = 1.9.0; + MARKETING_VERSION = 1.9.1; PRODUCT_BUNDLE_IDENTIFIER = com.stormacq.mac.wisprTests; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -667,11 +667,11 @@ buildSettings = { CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 260412.271; + CURRENT_PROJECT_VERSION = 260417.273; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 56U756R2L2; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.9.0; + MARKETING_VERSION = 1.9.1; PRODUCT_BUNDLE_IDENTIFIER = com.stormacq.mac.wisprUITests; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -690,11 +690,11 @@ buildSettings = { CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 260412.271; + CURRENT_PROJECT_VERSION = 260417.273; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 56U756R2L2; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.9.0; + MARKETING_VERSION = 1.9.1; PRODUCT_BUNDLE_IDENTIFIER = com.stormacq.mac.wisprUITests; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -714,14 +714,14 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CREATE_INFOPLIST_SECTION_IN_BINARY = YES; - CURRENT_PROJECT_VERSION = 260412.271; + CURRENT_PROJECT_VERSION = 260417.273; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 56U756R2L2; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.2; - MARKETING_VERSION = 1.9.0; + MARKETING_VERSION = 1.9.1; PRODUCT_BUNDLE_IDENTIFIER = "com.stormacq.mac.wispr-cli"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -741,14 +741,14 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CREATE_INFOPLIST_SECTION_IN_BINARY = YES; - CURRENT_PROJECT_VERSION = 260412.271; + CURRENT_PROJECT_VERSION = 260417.273; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 56U756R2L2; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.2; - MARKETING_VERSION = 1.9.0; + MARKETING_VERSION = 1.9.1; PRODUCT_BUNDLE_IDENTIFIER = "com.stormacq.mac.wispr-cli"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/wispr/Services/StateManager.swift b/wispr/Services/StateManager.swift index ed54a09..146bd25 100644 --- a/wispr/Services/StateManager.swift +++ b/wispr/Services/StateManager.swift @@ -166,7 +166,8 @@ final class StateManager { /// Toggles recording state for hands-free mode. /// If idle, starts recording (with EOU monitoring when supported). /// If recording, stops recording. - /// Ignores calls during .loading, .processing, or .error states. + /// If in error state, dismisses the error and starts a new recording (issue #52). + /// Ignores calls during .loading or .processing states. func toggleRecording() async { switch appState { case .idle: @@ -174,8 +175,12 @@ final class StateManager { case .recording: cancelEouMonitoring() await endRecording() - case .loading, .processing, .error: + case .loading, .processing: break + case .error: + // Issue #52: dismiss error and start new recording + await resetToIdle() + await beginRecording() } } @@ -351,7 +356,11 @@ final class StateManager { func beginRecording() async { // Requirement 12.5: Prevent concurrent recording sessions. // Only allow starting a new recording from the idle state. - // Ignore the request if already recording, processing, showing an error, or still loading. + // Ignore the request if already recording, processing, or still loading. + // Issue #52: If in error state, dismiss the error and proceed. + if case .error = appState { + await resetToIdle() + } guard appState == .idle else { if appState == .loading { Log.stateManager.debug("beginRecording — still loading, ignoring hotkey") diff --git a/wisprTests/StateManagerTests.swift b/wisprTests/StateManagerTests.swift index 610d357..b7dd273 100644 --- a/wisprTests/StateManagerTests.swift +++ b/wisprTests/StateManagerTests.swift @@ -123,21 +123,38 @@ struct StateManagerTests { #expect(sm.appState == .processing) } - @Test("beginRecording is ignored when in error state") - func testConcurrentRecordingPreventionWhileError() async { + @Test("beginRecording dismisses error state and attempts recording (issue #52)") + func testBeginRecordingDismissesError() async { let (sm, _) = createTestStateManager(permissionsGranted: true) // Force state to error sm.appState = .error("some error") + sm.errorMessage = "some error" await sm.beginRecording() - // Should remain in error state - if case .error = sm.appState { - // Expected - } else { - Issue.record("State should remain in error") + // The original error must be dismissed. beginRecording() may end in a + // new .error if AudioEngine.startCapture() fails in the test environment, + // so we assert the original error was cleared rather than requiring .recording. + if case let .error(message) = sm.appState { + #expect(message != "some error") + } + #expect(sm.errorMessage != "some error") + } + + @Test("toggleRecording dismisses error state and attempts recording (issue #52)") + func testToggleRecordingDismissesError() async { + let (sm, _) = createTestStateManager(permissionsGranted: true) + + sm.appState = .error("some error") + sm.errorMessage = "some error" + + await sm.toggleRecording() + + if case let .error(message) = sm.appState { + #expect(message != "some error") } + #expect(sm.errorMessage != "some error") } // MARK: - Permission Check on Recording @@ -739,18 +756,18 @@ struct StateManagerTests { #expect(sm.appState == .processing) } - @Test("toggleRecording is ignored during error state") - func testToggleRecordingIgnoredWhileError() async { + @Test("toggleRecording dismisses error and attempts new recording (issue #52)") + func testToggleRecordingDismissesErrorHandsFree() async { let (sm, _) = Self.makeHandsFreeStateManager() sm.appState = .error("test error") + sm.errorMessage = "test error" await sm.toggleRecording() - if case .error = sm.appState { - // Expected — unchanged - } else { - Issue.record("toggleRecording should not change error state") + if case let .error(message) = sm.appState { + #expect(message != "test error") } + #expect(sm.errorMessage != "test error") } @Test("toggleRecording from recording with no permissions still returns to idle")