fix(coordinator): don't route hotkey edges to QA while dictation runs

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.
This commit is contained in:
baiqing committed 2026-05-10 01:35:10 +08:00
1 parent b1c387265b
commit f46adce317
2 files changed
+23 -6

No files matched your search

@@ -12,8 +12,14 @@ pub(super) async fn handle_pressed_edge(inner: &Arc<Inner>) {
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<Inner>) {
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;
@@ -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<Inner>) {
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(