From 2ec92bade701e52215535fc17c9f4e2b48027026 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:24:06 -0700 Subject: [PATCH] fix(ui): release modal grab after TinyDiarize download before transcript opens (#965) * fix(ui): release modal grab after TinyDiarize download before transcript opens The transcribe path's ModelDownloadDialog is modal. On success it stayed open behind the diarized transcript dialog, holding its grab so every button on the transcript was unclickable until the hidden dialog was found and closed. Destroy it on success there and let the transcript be the completion affordance; the failure path still keeps the dialog up to show the error. * style(tests): black-format new dialog-grab tests * fix(ui): stop download dialog pulse timer on destroy; type test helper * style(ui): type destroy-handler varargs --------- Co-authored-by: Jatin K Malik --- src/vocalinux/ui/settings_dialog.py | 7 +++++ src/vocalinux/ui/tray_indicator.py | 8 +++--- tests/test_tray_indicator.py | 41 +++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 3 deletions(-) diff --git a/src/vocalinux/ui/settings_dialog.py b/src/vocalinux/ui/settings_dialog.py index b1c4978..f2a9e68 100644 --- a/src/vocalinux/ui/settings_dialog.py +++ b/src/vocalinux/ui/settings_dialog.py @@ -2507,10 +2507,17 @@ class ModelDownloadDialog(Gtk.Dialog): self.show_all() self._pulse_timeout = None + # A destroyed dialog must not leave its pulse timer firing: the + # timeout retains the widget and keeps calling back into it. + self.connect("destroy", self._on_destroy) # For Whisper, we can't track progress, so pulse if engine == "whisper": self._start_pulsing() + def _on_destroy(self, *_args: object) -> None: + """Stop the pulse timer when the dialog goes away.""" + self._stop_pulsing() + def _start_pulsing(self) -> None: """Bounce the bar, for work that reports no measurable progress.""" if self._pulse_timeout is None: diff --git a/src/vocalinux/ui/tray_indicator.py b/src/vocalinux/ui/tray_indicator.py index 56148aa..a373e84 100644 --- a/src/vocalinux/ui/tray_indicator.py +++ b/src/vocalinux/ui/tray_indicator.py @@ -1131,9 +1131,11 @@ class TrayIndicator: self.speech_engine.set_download_progress_callback(None) self.speech_engine.end_download() - GLib.idle_add(dialog.set_complete, True, "") - # The dialog shows its own completion; starting the transcription - # as soon as the model lands keeps the flow to a single click. + # The transcript dialog that opens next is the completion + # affordance here: the download dialog is modal, so leaving it + # open behind the transcript keeps its grab and makes the + # transcript's buttons unclickable until it is found and closed. + GLib.idle_add(dialog.destroy) _idle_once(self._start_file_transcription, path) threading.Thread(target=run, daemon=True, name="tdrz-download").start() diff --git a/tests/test_tray_indicator.py b/tests/test_tray_indicator.py index 5104364..866a437 100644 --- a/tests/test_tray_indicator.py +++ b/tests/test_tray_indicator.py @@ -1166,3 +1166,44 @@ class TestAppIndicatorImportFallback(unittest.TestCase): import vocalinux.ui.tray_indicator as tray self.assertIs(tray.AppIndicator3, sentinel) + + +class TestTdrzDownloadDialog(TestTrayIndicator): + """The transcribe-path TinyDiarize download dialog must release its modal + grab once the model lands: the transcript dialog opening behind it would + otherwise be unclickable until the hidden dialog is found and closed.""" + + def _run_download( + self, download_side_effect: Exception | None = None + ) -> tuple[MagicMock, MagicMock]: + from vocalinux.ui.tray_indicator import Gtk + + mock_prompt = MagicMock() + mock_prompt.run.return_value = Gtk.ResponseType.OK + mock_dialog = MagicMock() + mock_dialog.cancelled = False + self.mock_speech_engine.try_begin_download.return_value = True + self.mock_speech_engine.download_whispercpp_model.side_effect = download_side_effect + + with ( + patch("vocalinux.ui.tray_indicator.Gtk.MessageDialog", return_value=mock_prompt), + patch("vocalinux.ui.tray_indicator.ModelDownloadDialog", return_value=mock_dialog), + patch.object(self.tray_indicator, "_start_file_transcription") as mock_start, + ): + self.tray_indicator._offer_tdrz_download("/tmp/clip.wav") + run = self.mock_thread_class.call_args.kwargs["target"] + run() + return mock_dialog, mock_start + + def test_success_destroys_modal_dialog(self) -> None: + mock_dialog, mock_start = self._run_download() + mock_dialog.destroy.assert_called_once() + mock_dialog.set_complete.assert_not_called() + mock_start.assert_called_once_with("/tmp/clip.wav") + + def test_failure_keeps_dialog_for_the_error(self) -> None: + mock_dialog, mock_start = self._run_download(Exception("network down")) + mock_dialog.set_complete.assert_called_once() + self.assertFalse(mock_dialog.set_complete.call_args[0][0]) + mock_dialog.destroy.assert_not_called() + mock_start.assert_not_called()