From f46adce3178ca1bcfe754ca1f99c5be1c5d18049 Mon Sep 17 00:00:00 2001 From: baiqing Date: Sun, 10 May 2026 01:35:10 +0800 Subject: [PATCH] fix(coordinator): don't route hotkey edges to QA while dictation runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handle_pressed_edge previously routed the dictation hotkey to the QA panel whenever qa_state.panel_visible was true, regardless of whether a main dictation session was already active. If the user opened the QA panel mid-dictation (or while polishing/inserting), the next dictation- hotkey edge was routed into begin_qa_session, which calls Recorder::start a second time on the same mic device. cpal usually rejects the second build_input_stream on macOS/Windows but the dictation session keeps running with no UX path to stop it from the QA panel; on Linux/PipeWire the second open can succeed and you get two concurrent capture streams competing for the audio device. Symmetrically, handle_released_edge swallowed Released entirely when the panel was visible — fine when QA owned the press, but if dictation owned the press, Hold-mode dictation could never end because the Released edge was eaten. Fix: read inner.state.phase alongside panel_visible. If a dictation session is non-Idle (Starting / Listening / Processing / Inserting), both edges go to dictation regardless of panel_visible. The QA panel stays open, just doesn't capture this hotkey. 3.3.4 (same PR, same files): open_qa_panel always emit'd CapsuleState::Idle to sweep stale Done residue, but if dictation was mid-flight that clobbered the in-flight capsule (Recording bar, Polishing progress, the brief Done toast). Guard the sweep on dictation phase == Idle. Audit IDs 3.3.1 + 3.3.4 (CONFIRMED 高 + 中). Test: 183/183 lib tests pass. Manual verification (open QA panel during dictation, press dictation hotkey — should stop dictation, not begin QA; also Hold-mode dictation while panel visible should still stop on release) requires the running app, to be done after merge. --- .../app/src-tauri/src/coordinator/dictation.rs | 14 ++++++++++++-- openless-all/app/src-tauri/src/coordinator/qa.rs | 15 +++++++++++---- 2 files changed, 23 insertions(+), 6 deletions(-) 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(