diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index b250410b..67e756c8 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -12,8 +12,14 @@ pub(super) async fn handle_pressed_edge(inner: &Arc) { let was_held = inner.hotkey_trigger_held.swap(true, Ordering::SeqCst); if !was_held { // 路由:QA 浮窗可见时,rightOption 边沿走 QA;否则走主听写。详见 issue #118 v2。 + // 例外:dictation session 已经在跑(Starting / Listening / Processing / Inserting), + // 即使 QA 浮窗被打开了,这条边沿也必须先走 dictation。否则 begin_qa_session 会 + // 第二次抢同一个麦克风 device —— 在 Linux/PipeWire 上甚至会成功打开两路捕获, + // dictation 的 recorder 没人停;在 macOS/Windows 上 cpal 会拒绝第二次 build_input_stream + // 但 dictation session 仍在跑、用户找不到从 QA 面板停掉它的入口。审计 3.3.1。 + let dictation_active = !matches!(inner.state.lock().phase, SessionPhase::Idle); let panel_visible = inner.qa_state.lock().panel_visible; - if panel_visible { + if panel_visible && !dictation_active { handle_qa_option_edge(inner).await; } else { handle_pressed(inner).await; @@ -48,8 +54,12 @@ pub(super) async fn handle_released_edge(inner: &Arc) { let was_held = inner.hotkey_trigger_held.swap(false, Ordering::SeqCst); if was_held { // QA 浮窗可见时,Option 行为是 press-toggle(不分 hold/release),release 边沿忽略。 + // 与 handle_pressed_edge 的路由对称:dictation session 在跑时 Pressed 已经被路由到 + // dictation,那 Released 必须也路由到 dictation —— 否则 Hold 模式松开热键时 + // end_session 不会触发,dictation 永远停不下来。审计 3.3.1。 + let dictation_active = !matches!(inner.state.lock().phase, SessionPhase::Idle); let panel_visible = inner.qa_state.lock().panel_visible; - if panel_visible { + if panel_visible && !dictation_active { return; } handle_released(inner).await; diff --git a/openless-all/app/src-tauri/src/coordinator/qa.rs b/openless-all/app/src-tauri/src/coordinator/qa.rs index 14ca1376..149756ca 100644 --- a/openless-all/app/src-tauri/src/coordinator/qa.rs +++ b/openless-all/app/src-tauri/src/coordinator/qa.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use tauri::Emitter; -use crate::coordinator_state::{initial_session_id, SessionId}; +use crate::coordinator_state::{initial_session_id, SessionId, SessionPhase}; use crate::selection::SelectionContext; use crate::types::CapsuleState; @@ -86,9 +86,16 @@ pub(super) fn open_qa_panel(inner: &Arc) { state.selection = None; state.front_app = capture_frontmost_app(); } - // 先把胶囊清干净,避免主听写上一次 Done 状态残留的 message/insertedChars - // 在 QA Done 阶段被 capsule UI 错误复用("已之一粘贴这个 0" 那种)。 - emit_capsule(inner, CapsuleState::Idle, 0.0, 0, None, None); + // 主听写 phase 是 Idle 才需要 sweep capsule —— 这里的语义是清掉「上一次 dictation + // Done 状态残留」的 message / insertedChars,让 QA 自己的 capsule 状态从干净起跑 + // (否则 capsule UI 会出现 "已粘贴这个 0" 之类把上一次 inserted_chars 错误复用的 + // 显示)。但如果 dictation 当前正处于 Recording / Polishing / Inserting / Done toast + // 显示中,强行 emit Idle 会把用户没看完的反馈抹掉、或者把 Polishing 中的进度条 + // 卡死。审计 3.3.4。 + let dictation_idle = matches!(inner.state.lock().phase, SessionPhase::Idle); + if dictation_idle { + emit_capsule(inner, CapsuleState::Idle, 0.0, 0, None, None); + } if let Some(app) = inner.app.lock().clone() { crate::show_qa_window(&app, "idle"); let _ = app.emit_to(