diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 12ce3cf0..e57abd1e 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -231,6 +231,10 @@ struct Inner { /// 与 `hotkey_trigger_held` 互补 —— held 防 press-without-release,本字段防 /// press-release-press 三连过快。 last_hotkey_dispatch_at: Mutex>, + /// end_session 成功收尾后将 phase 设为 Idle 时记录的时间戳 + POST_SESSION_COOLDOWN_MS。 + /// handle_pressed 在 (Toggle, Idle) 分支检查此字段:未过期则忽略该次按键, + /// 防止胶囊离场动画期间误激活新听写(issue #545)。 + session_cooldown_until: Mutex>, shortcut_recording_active: AtomicBool, /// 自定义组合键监听器(global-hotkey crate)。当 `prefs.hotkey.trigger == Custom` 时 /// 代替 modifier-only 的 hotkey monitor。`None` 表示不使用自定义组合键或还没成功安装。 @@ -318,6 +322,7 @@ impl Coordinator { hotkey_status: Mutex::new(HotkeyStatus::default()), hotkey_trigger_held: AtomicBool::new(false), last_hotkey_dispatch_at: Mutex::new(None), + session_cooldown_until: Mutex::new(None), shortcut_recording_active: AtomicBool::new(false), combo_hotkey: Mutex::new(None), translation_hotkey: Mutex::new(None), @@ -379,6 +384,7 @@ impl Coordinator { hotkey_status: Mutex::new(HotkeyStatus::default()), hotkey_trigger_held: AtomicBool::new(false), last_hotkey_dispatch_at: Mutex::new(None), + session_cooldown_until: Mutex::new(None), shortcut_recording_active: AtomicBool::new(false), combo_hotkey: Mutex::new(None), translation_hotkey: Mutex::new(None), @@ -4501,6 +4507,11 @@ fn enabled_phrases(inner: &Arc) -> Vec { /// 用户点 ✕ / ✓ / 中途出错 / 按 Esc 都走这里,统一 2 秒。 const CAPSULE_AUTO_HIDE_DELAY_MS: u64 = 2000; +/// Toggle 模式下,end_session 将 phase 设为 Idle 后在此时间内禁止新的 begin_session。 +/// 避免用户三连按时第 3 次按下误激活新听写(此时胶囊仍在离场动画周期内)。 +/// 值取 capsule EXIT_ANIM_MS (360ms) + 余量 ≈ 600ms。 +const POST_SESSION_COOLDOWN_MS: u64 = 600; + /// Coordinator 全局超时保护:防止 ASR await_final_result() 永远挂起。 /// 设置为 15 秒(比 ASR 的 12 秒 FINAL_RESULT_TIMEOUT 稍长), /// 只在 ASR 超时机制失效时作为最后的防线触发。 diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index 67c151ee..68cb38e3 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -460,6 +460,20 @@ pub(super) async fn handle_pressed(inner: &Arc) { log::info!("[coord] hotkey pressed (mode={mode:?}, phase={phase:?})"); match (mode, phase) { (HotkeyMode::Toggle, SessionPhase::Idle) => { + // 冷却检查:end_session 刚收尾时禁止短时间内再次激活, + // 避免三连按第 3 次误触(此时胶囊仍在离场动画周期内,issue #545)。 + let now = std::time::Instant::now(); + let on_cooldown = inner + .session_cooldown_until + .lock() + .map(|deadline| now < deadline) + .unwrap_or(false); + if on_cooldown { + log::info!( + "[coord] toggle activation blocked by cooldown (session still winding down)" + ); + return; + } let _ = begin_session(inner).await; } (HotkeyMode::Toggle, SessionPhase::Listening) => { @@ -1814,6 +1828,13 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { state.phase = SessionPhase::Idle; state.focus_target = None; } + // Toggle 模式冷却:设冷却时间戳,POST_SESSION_COOLDOWN_MS 内禁止新的 activate。 + // 覆盖胶囊离场动画周期,避免三连按第 3 次误激活(issue #545)。 + { + let now = std::time::Instant::now(); + *inner.session_cooldown_until.lock() = + Some(now + std::time::Duration::from_millis(POST_SESSION_COOLDOWN_MS)); + } schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); Ok(()) @@ -1861,6 +1882,10 @@ pub(super) fn cancel_session(inner: &Arc) { if decision.phase != SessionPhase::Processing { let mut state = inner.state.lock(); finish_cancel_session_state(&mut state, decision); + // 只有真正把 phase 设为 Idle 时才设冷却(避免离场动画期间误激活)。 + let now = std::time::Instant::now(); + *inner.session_cooldown_until.lock() = + Some(now + std::time::Duration::from_millis(POST_SESSION_COOLDOWN_MS)); } emit_capsule(inner, CapsuleState::Cancelled, 0.0, 0, None, None); log::info!("[coord] session cancelled (was {:?})", decision.phase);