fix(dictation): make transcription capture actionable

This commit is contained in:
psiberfunk
2026-09-07 17:41:04 -04:00
parent 9790d28922
commit 0d7625ee1a
7 changed files with 259 additions and 51 deletions
+1
View File
@@ -50,6 +50,7 @@ the frozen-backend fallback mirror it for their toolchains.
### Fixed
- Transcriptions dictation wakes the desktop recorder, presents one contextual start action, and centers its microphone icon with the label (#1902)
- Install documentation help now prints correctly on Windows consoles using legacy encodings (#1815) — thanks @dajiaohuang!
- Saved transcriptions with missing or invalid timestamps now remain readable (#1799) — thanks @yunaremaia and @tvbht!
- Copying a saved transcription now uses the shared clipboard helper and reports failed copies accurately (#1803) — thanks @tvbht!
+7
View File
@@ -17,6 +17,13 @@ own microphone audio to the versioned WebSocket API. See the
3. Put the cursor in a text field, press the shortcut, speak, then release or
press again.
The **Transcriptions** page offers the same recorder as one contextual
**Start dictation** action: it appears in the empty state before the first
transcript and moves to the page header once history exists. Desktop starts
wake the recorder window before dispatch, so a hidden WebView cannot silently
miss the request. The in-app action resolves only after the recorder
acknowledges delivery; otherwise the page reports the failed start.
Whisper Tiny is the recommended default on macOS, Windows, and Linux. It
auto-detects more than 90 languages. Parakeet TDT v3 remains available for its
25 supported European languages, but it is not selected automatically.
+87 -3
View File
@@ -1006,12 +1006,96 @@ pub fn get_effective_dictation_shortcut(
}
#[tauri::command]
pub fn request_dictation_capture(app: tauri::AppHandle, action: String) -> Result<(), String> {
pub async fn request_dictation_capture(
app: tauri::AppHandle,
action: String,
) -> Result<(), String> {
if action != "start" && action != "stop" && action != "toggle" {
return Err("capture action must be start, stop, or toggle".into());
}
crate::dispatch_dictation_capture(&app, &action);
Ok(())
let delivery_id = crate::request_dictation_capture_delivery(&app, &action)
.ok_or_else(|| "capture request could not be queued".to_string())?;
let wait_app = app.clone();
let acknowledged = tauri::async_runtime::spawn_blocking(move || {
wait_for_capture_delivery(
|| {
let flags = wait_app.state::<AppFlags>();
let capture = flags
.capture
.lock()
.map_err(|_| "Dictation capture state lock poisoned".to_string())?;
Ok(capture.delivery_pending(delivery_id))
},
CAPTURE_DELIVERY_TIMEOUT,
)
})
.await
.map_err(|error| format!("capture acknowledgement worker failed: {error}"))??;
if acknowledged {
return Ok(());
}
let flags = app.state::<AppFlags>();
let cancelled = flags
.capture
.lock()
.map_err(|_| "Dictation capture state lock poisoned".to_string())?
.cancel_delivery(delivery_id);
if let Some(event) = cancelled.filter(|event| event.name == "tray-dictate") {
flags.output.finish_session(event.payload.session_id);
}
Err("capture window did not acknowledge the request".into())
}
const CAPTURE_DELIVERY_TIMEOUT: Duration = Duration::from_secs(2);
const CAPTURE_DELIVERY_POLL: Duration = Duration::from_millis(20);
fn wait_for_capture_delivery<F>(mut pending: F, timeout: Duration) -> Result<bool, String>
where
F: FnMut() -> Result<bool, String>,
{
let deadline = Instant::now() + timeout;
loop {
if !pending()? {
return Ok(true);
}
if Instant::now() >= deadline {
return Ok(false);
}
std::thread::sleep(CAPTURE_DELIVERY_POLL);
}
}
#[cfg(test)]
mod capture_request_tests {
use super::wait_for_capture_delivery;
use std::time::Duration;
#[test]
fn listener_acknowledgement_completes_the_request() {
let mut polls = 0;
let acknowledged = wait_for_capture_delivery(
|| {
polls += 1;
Ok(polls < 2)
},
Duration::from_millis(50),
)
.expect("poll succeeds");
assert!(acknowledged);
}
#[test]
fn missing_listener_acknowledgement_times_out() {
let acknowledged = wait_for_capture_delivery(
|| Ok(true),
Duration::from_millis(0),
)
.expect("poll succeeds");
assert!(!acknowledged);
}
}
/// Distance from the bottom edge of the work area, in logical pixels — clear of
+91 -17
View File
@@ -109,6 +109,11 @@ pub struct CaptureDispatchState {
active_registration: Option<u64>,
}
struct CaptureEnqueue {
delivery_id: u64,
event: Option<CaptureEvent>,
}
impl Default for CaptureDispatchState {
fn default() -> Self {
Self {
@@ -147,13 +152,21 @@ impl CaptureDispatchState {
.collect()
}
pub(crate) fn enqueue(&mut self, mut event: CaptureEvent) -> Option<CaptureEvent> {
fn enqueue(&mut self, mut event: CaptureEvent) -> CaptureEnqueue {
self.delivery_counter = self.delivery_counter.wrapping_add(1).max(1);
event.payload.delivery_id = self.delivery_counter;
self.pending.push_back(event.clone());
let registration_id = self.active_registration.filter(|_| self.ready)?;
event.payload.registration_id = registration_id;
Some(event)
let ready_event = self
.active_registration
.filter(|_| self.ready)
.map(|registration_id| {
event.payload.registration_id = registration_id;
event
});
CaptureEnqueue {
delivery_id: self.delivery_counter,
event: ready_event,
}
}
pub(crate) fn acknowledge(&mut self, registration_id: u64, delivery_id: u64) {
@@ -169,6 +182,20 @@ impl CaptureDispatchState {
}
}
pub(crate) fn delivery_pending(&self, delivery_id: u64) -> bool {
self.pending
.iter()
.any(|event| event.payload.delivery_id == delivery_id)
}
pub(crate) fn cancel_delivery(&mut self, delivery_id: u64) -> Option<CaptureEvent> {
let index = self
.pending
.iter()
.position(|event| event.payload.delivery_id == delivery_id)?;
self.pending.remove(index)
}
pub(crate) fn end_registration(&mut self, registration_id: u64) {
if self.active_registration == Some(registration_id) {
self.active_registration = None;
@@ -205,10 +232,21 @@ fn dictation_capture_event(action: &str, dictating: bool) -> &'static str {
}
pub fn dispatch_dictation_capture(app: &tauri::AppHandle, action: &str) {
dispatch_dictation_capture_from(app, action, CaptureOrigin::Shortcut);
let _ = dispatch_dictation_capture_from(app, action, CaptureOrigin::Shortcut);
}
fn dispatch_dictation_capture_from(app: &tauri::AppHandle, action: &str, origin: CaptureOrigin) {
pub(crate) fn request_dictation_capture_delivery(
app: &tauri::AppHandle,
action: &str,
) -> Option<u64> {
dispatch_dictation_capture_from(app, action, CaptureOrigin::Shortcut)
}
fn dispatch_dictation_capture_from(
app: &tauri::AppHandle,
action: &str,
origin: CaptureOrigin,
) -> Option<u64> {
let flags = app.state::<AppFlags>();
let event = dictation_capture_event(action, flags.dictating.load(Ordering::SeqCst));
let session_id = if event == "tray-dictate" {
@@ -217,8 +255,21 @@ fn dispatch_dictation_capture_from(app: &tauri::AppHandle, action: &str, origin:
session_id
} else {
log::warn!("Dictation capture '{action}' ignored — no active output session");
return;
return None;
};
// The recorder lives in the widget WebView. WebKit can suspend that
// document while its window is hidden, so an event cannot be relied on to
// wake the very listener that must receive it. Preserve the output target
// first, then show the non-activating pill before enqueueing/emitting the
// start event. The widget's idle reconcile hides it again if capture is
// disabled or startup exits early.
if event == "tray-dictate" {
if let Err(error) = commands::show_dictation_pill(app.clone()) {
log::warn!("Dictation capture '{action}' could not wake the capture window: {error}");
}
}
let capture_event = CaptureEvent {
name: event,
payload: DictationCapturePayload {
@@ -229,9 +280,11 @@ fn dispatch_dictation_capture_from(app: &tauri::AppHandle, action: &str, origin:
};
let Ok(mut capture) = flags.capture.lock() else {
log::warn!("Dictation capture state lock poisoned");
return;
return None;
};
if let Some(capture_event) = capture.enqueue(capture_event) {
let enqueued = capture.enqueue(capture_event);
let delivery_id = enqueued.delivery_id;
if let Some(capture_event) = enqueued.event {
drop(capture);
// A press that reaches Rust but produces no recording is otherwise
// indistinguishable from one the compositor never delivered, so say
@@ -246,6 +299,7 @@ fn dispatch_dictation_capture_from(app: &tauri::AppHandle, action: &str, origin:
"Dictation capture '{action}' queued — the capture window has not registered yet"
);
}
Some(delivery_id)
}
#[cfg(test)]
@@ -297,8 +351,24 @@ mod dictation_capture_tests {
state.acknowledge(stale, delivery_id);
assert_eq!(state.pending.len(), 1);
assert!(state.delivery_pending(delivery_id));
state.acknowledge(current, delivery_id);
assert!(state.pending.is_empty());
assert!(!state.delivery_pending(delivery_id));
}
#[test]
fn timed_out_delivery_can_be_cancelled_without_touching_others() {
let mut state = CaptureDispatchState::default();
state.enqueue(capture_event("tray-dictate"));
state.enqueue(capture_event("tray-dictate-stop"));
let first_id = state.pending[0].payload.delivery_id;
let second_id = state.pending[1].payload.delivery_id;
let cancelled = state.cancel_delivery(first_id).expect("delivery exists");
assert_eq!(cancelled.payload.session_id, 7);
assert!(!state.delivery_pending(first_id));
assert!(state.delivery_pending(second_id));
}
#[test]
@@ -879,12 +949,8 @@ pub fn run() {
match event.state {
ShortcutState::Pressed => {
log::info!("Global shortcut pressed: dictation start");
// The widget window stays hidden until the
// capture itself reaches a state worth
// showing — the widget calls
// `show_dictation_pill` then, so a press
// that bails early never strands an empty
// capsule on the desktop.
// Dispatch preserves the focused target,
// wakes the recorder WebView, then emits.
dispatch_dictation_capture(app_handle, "start");
}
ShortcutState::Released => {
@@ -1034,9 +1100,17 @@ pub fn run() {
// current by the frontend's existing
// `set_tray_recording` call on every start and stop.
if app.state::<AppFlags>().dictating.load(Ordering::SeqCst) {
dispatch_dictation_capture_from(app, "stop", CaptureOrigin::Tray);
let _ = dispatch_dictation_capture_from(
app,
"stop",
CaptureOrigin::Tray,
);
} else {
dispatch_dictation_capture_from(app, "start", CaptureOrigin::Tray);
let _ = dispatch_dictation_capture_from(
app,
"start",
CaptureOrigin::Tray,
);
}
}
"settings" => {
+12 -5
View File
@@ -180,9 +180,11 @@ export default function TranscriptionsPage() {
</span>
</div>
<div className="txn-header__right flex items-center gap-[6px]">
<Button size="sm" variant="primary" onClick={startCapture}>
<Mic size={13} /> {t('transcriptions.capture')}
</Button>
{transcriptions.length > 0 && (
<Button size="sm" variant="primary" leading={<Mic size={13} />} onClick={startCapture}>
{t('transcriptions.capture')}
</Button>
)}
<div className="txn-search relative flex items-center">
<Search
size={13}
@@ -235,8 +237,13 @@ export default function TranscriptionsPage() {
{normalizedSearch ? t('transcriptions.empty_search_desc') : emptyDescription}
</p>
{!normalizedSearch && (
<Button size="sm" variant="primary" onClick={startCapture}>
<Mic size={13} /> {t('transcriptions.capture')}
<Button
size="sm"
variant="primary"
leading={<Mic size={13} />}
onClick={startCapture}
>
{t('transcriptions.capture')}
</Button>
)}
</div>
+15 -3
View File
@@ -34,14 +34,14 @@ describe('Transcriptions capture entry point', () => {
render(<TranscriptionsPage />);
expect(screen.getByText(/Super\+Shift\+V/)).toBeInTheDocument();
fireEvent.click(screen.getAllByRole('button', { name: 'Start dictation' }).at(-1));
fireEvent.click(screen.getByRole('button', { name: 'Start dictation' }));
await waitFor(() => expect(requestDictationCapture).toHaveBeenCalledWith('start'));
});
it('reports a capture-controller failure', async () => {
requestDictationCapture.mockRejectedValueOnce(new Error('event channel unavailable'));
render(<TranscriptionsPage />);
fireEvent.click(screen.getAllByRole('button', { name: 'Start dictation' }).at(-1));
fireEvent.click(screen.getByRole('button', { name: 'Start dictation' }));
await waitFor(() =>
expect(toast.error).toHaveBeenCalledWith(
@@ -56,10 +56,22 @@ describe('Transcriptions capture entry point', () => {
target: { value: ' ' },
});
expect(screen.getAllByRole('button', { name: 'Start dictation' })).toHaveLength(2);
const button = screen.getByRole('button', { name: 'Start dictation' });
expect(button.querySelector(':scope > svg')).toBeInTheDocument();
expect(button.querySelector(':scope > span')).toHaveTextContent('Start dictation');
expect(screen.getByText('No transcriptions yet')).toBeInTheDocument();
});
it('moves the single capture action to the header once history exists', () => {
addTranscription({ text: 'Existing transcript.', language: 'en' });
render(<TranscriptionsPage />);
const button = screen.getByRole('button', { name: 'Start dictation' });
expect(button.querySelector(':scope > svg')).toBeInTheDocument();
expect(button.querySelector(':scope > span')).toHaveTextContent('Start dictation');
expect(screen.queryByText('No transcriptions yet')).not.toBeInTheDocument();
});
it('shows a successful transcript emitted by the shared recorder', async () => {
render(<TranscriptionsPage />);
act(() => {
+46 -23
View File
@@ -1,9 +1,9 @@
"""The dictation widget window must stay hidden, and must stamp its identity.
"""The dictation widget must stay safe while hidden and wake before capture.
The widget window hosts the recorder (`getUserMedia` + `MediaRecorder` + the
transcription WebSocket all live in `CaptureWidget.jsx`), so it has to exist
but it is never shown (owner decision, 2026-08-07): dictation gives no
on-screen pill.
transcription WebSocket all live in `CaptureWidget.jsx`), so it has to exist
while idle. Some WebView engines suspend that hidden document, however, so a
start request must wake it before emitting the event it needs to record.
Two things keep that safe, and both are easy to undo by accident:
@@ -14,8 +14,9 @@ Two things keep that safe, and both are easy to undo by accident:
300x64, with an opaque background and no CaptureWidget to hide it again:
the dark rectangle that could only be cleared by killing the app.
2. Nothing calls `.show()` on it. A re-added show would put that rectangle
back on screen for anyone whose window lost the identity race.
2. Capture dispatch wakes it only through the non-activating pill command,
after preserving the output target and before emitting the start event.
The widget owns hiding itself again when it is idle.
The frontend half is pinned by
`frontend/src/test/DictationNoPillWindow.test.jsx`.
@@ -27,6 +28,9 @@ from pathlib import Path
import pytest
_LIB_RS = Path(__file__).resolve().parents[1] / "frontend" / "src-tauri" / "src" / "lib.rs"
_COMMANDS_RS = (
Path(__file__).resolve().parents[1] / "frontend" / "src-tauri" / "src" / "commands.rs"
)
@pytest.fixture
@@ -34,6 +38,11 @@ def lib_rs() -> str:
return _LIB_RS.read_text(encoding="utf-8")
@pytest.fixture
def commands_rs() -> str:
return _COMMANDS_RS.read_text(encoding="utf-8")
def test_widget_window_stamps_its_identity_before_page_scripts(lib_rs: str) -> None:
assert "initialization_script" in lib_rs, (
"The widget window no longer injects an initialization_script. Window "
@@ -47,24 +56,38 @@ def test_widget_window_stamps_its_identity_before_page_scripts(lib_rs: str) -> N
)
def test_nothing_shows_the_widget_window(lib_rs: str) -> None:
"""No `.show()` may be reachable from a widget window handle.
Scoped to blocks that bind the widget handle, so an unrelated
`main_win.show()` elsewhere in the file doesn't trip this.
"""
offenders = []
for match in re.finditer(r'get_webview_window\("widget"\)', lib_rs):
# The handle's usable scope: to the end of the enclosing block. Take a
# generous window and look for a show on it — cheap and hard to fool.
block = lib_rs[match.start() : match.start() + 1200]
for show in re.finditer(r"\b(\w+)\.show\(\)|show_pill_noactivate\(", block):
offenders.append(show.group(0))
assert not offenders, (
f"Something shows the dictation widget window again: {offenders}. "
"It is a hidden recorder host — showing it is what put an empty "
"rectangle on the user's desktop."
def test_capture_dispatch_wakes_widget_before_emitting_start(lib_rs: str) -> None:
"""A hidden WebView cannot receive the event that tells it to show itself."""
dispatch = re.search(
r"fn dispatch_dictation_capture_from\(.*?\n\}", lib_rs, re.S
)
assert dispatch, "Could not locate dictation capture dispatch."
body = dispatch.group(0)
begin = body.find("begin_session")
wake_guard = body.find('if event == "tray-dictate"', begin)
wake = body.find("commands::show_dictation_pill")
emit = body.find("app.emit")
assert -1 not in (begin, wake_guard, wake, emit), (
"Capture dispatch must preserve the output target, wake the hidden "
"widget, then emit the start event."
)
assert begin < wake_guard < wake < emit, (
"Wake the hidden recorder after preserving the output target and "
"before emitting only a start; otherwise macOS can silently drop the "
"request or a stop can reopen the pill."
)
def test_in_page_capture_waits_for_listener_acknowledgement(commands_rs: str) -> None:
request = re.search(
r"pub async fn request_dictation_capture\(.*?\n\}", commands_rs, re.S
)
assert request, "The in-page capture command must remain asynchronous."
body = request.group(0)
assert "request_dictation_capture_delivery" in body
assert "wait_for_capture_delivery" in body
assert "cancel_delivery" in body
assert "capture window did not acknowledge the request" in body
def test_no_computed_window_target_can_resolve_to_the_widget(lib_rs: str) -> None: