Skip to content

fix(dictation): stop the widget window stranding a rectangle, and stop showing a pill - #1411

Merged
debpalash merged 3 commits into
mainfrom
fix/dictation-no-pill-window
Aug 7, 2026
Merged

fix(dictation): stop the widget window stranding a rectangle, and stop showing a pill#1411
debpalash merged 3 commits into
mainfrom
fix/dictation-no-pill-window

Conversation

@debpalash

@debpalash debpalash commented Aug 7, 2026

Copy link
Copy Markdown
Owner

The rectangle

Reported live: a dark, empty, square-cornered rectangle appearing on every dictation trigger and surviving until the app was killed.

It measured exactly 300×64 — the widget window's declared size — and the pill it should have contained is a capsule (border-radius: 100px). So what was on screen was the window painting its own background with no pill inside it.

One cause explains all three properties:

try {
  const { getCurrentWindow } = await import('@tauri-apps/api/window');
  return getCurrentWindow().label === 'widget';
} catch {
  return window.location.search.includes('window=widget');  // Tauri 2 can't set this
}

getCurrentWindow() throws while __TAURI_INTERNALS__ is still being injected. The catch then guesses — and its only guess is a URL query Tauri 2 has no way to produce. A widget window that guesses "main" renders <App/>:

property why
opaque, square-cornered data-window="widget" never set → body keeps the chrome background
empty the pill never mounts
unkillable the idle-hide reconcile lives inside CaptureWidget, which never mounted

The window now stamps window.__OV_WINDOW__ from an initialization_script, evaluated before any page script — there is nothing left for it to race.

#1398 fixed a different route to the same square (a dropped press during re-arm) and left this one open.

The pill

Owner decision: dictation shows nothing. The widget window is never shown.

It still has to exist — getUserMedia, MediaRecorder and the transcription WebSocket all live in CaptureWidget.jsx, so deleting the window deletes dictation. It is now a hidden host.

Two things that would have broken silently, handled:

  • The tray Start/Stop item inferred recording from widget.is_visible(). A permanently hidden window is never visible, so Stop would have been unreachable. It now reads an AppFlags.dictating flag kept current by the frontend's existing set_tray_recording call.
  • Errors lost their only surface. Accessibility ungranted, mic denied, failed transcription — all used to appear in the pill. Left alone, a blocked hotkey would be indistinguishable from a broken one. They now emit to the main window as a toast carrying the button that opens the relevant OS pane.

Verification

  • DictationNoPillWindow.test.jsx2 of 3 cases fail without the fix (verified by reverting it)
  • tests/test_dictation_no_pill_window.py — marker is injected, nothing calls show() on the widget, tray toggle no longer reads visibility
  • cargo test 109 passed · frontend 1711 passed (214 files) · typecheck clean · no new lint errors

format:check reports 3 files — Header.jsx, AppearancePanel.jsx, AppearancePanel.test.jsx — none of which this PR touches; they belong to unrelated in-progress work in the same working tree and are deliberately not included here.

Not verified

The hidden-window recording path has not been exercised live — whether macOS keeps media capture alive in a never-shown WKWebView is the one claim here that testing can't settle. The webview demonstrably stays alive while hidden today (it services the hotkey listener), but capture previously always started after the window was shown. If capture turns out to be throttled, the fallback is a shown-but-1×1 window.

The dictation widget now sets window.__OV_WINDOW__ before page scripts run, remains hidden, and uses AppFlags.dictating for tray state. Dictation errors now reach the main window as actionable accessibility, microphone, and transcription toasts. Please review the Tauri event bridge and hidden-window lifecycle for platform-specific regressions.

…p showing a pill

The reported symptom was a dark, empty, square-cornered rectangle that
appeared on every dictation trigger and stayed until the app was killed.
It measured exactly 300x64 — the widget window's declared size — and the
pill it should have held is a capsule, so what was on screen was the
window painting its own background with nothing in it.

One cause explains all three properties. detectIsWidget() asked
getCurrentWindow().label, which throws while Tauri's internals are still
being injected; the catch then fell back to a URL query Tauri 2 cannot
set, so the window concluded it was "main". From there it rendered <App/>
instead of <CaptureWidget/>: no data-window="widget" (opaque chrome
background), no pill, and — because the idle-hide reconcile lives inside
CaptureWidget — nothing left in the process that could hide it again.

The window now stamps window.__OV_WINDOW__ from an initialization_script,
which is evaluated before any page script and so cannot race a readiness
check. #1398 fixed a different path to the same square and left this one.

Separately, the pill is gone (owner decision): the widget window is never
shown at all. It has to keep existing — getUserMedia, MediaRecorder and
the transcription WebSocket all live in CaptureWidget — but it is now a
hidden host, so dictation records, transcribes and pastes with nothing on
screen.

Two consequences handled:

- The tray Start/Stop item inferred recording from widget.is_visible(),
  which a permanently hidden window makes meaningless — Stop would have
  been unreachable. It now reads an AppFlags.dictating flag kept current
  by the frontend's existing set_tray_recording call.
- States that need the user to act (Accessibility ungranted, mic denied,
  a failed transcription) used to surface in the pill and would otherwise
  have gone silent, making a blocked hotkey indistinguishable from a
  broken one. They are emitted to the main window and shown as a toast,
  with the button that opens the relevant OS pane.

Regression cover: DictationNoPillWindow.test.jsx pins the identity marker
(2 of its 3 cases fail without the fix), and test_dictation_no_pill_window
pins the Rust side — the marker is injected, nothing calls show() on the
widget, and the tray toggle no longer reads visibility.
@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 98955bf2-5569-4692-a87c-a88ac0e563a9

📥 Commits

Reviewing files that changed from the base of the PR and between 3a95bbf and 220f120.

📒 Files selected for processing (1)
  • CHANGELOG.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • CHANGELOG.md

📝 Walkthrough

Walkthrough

The dictation widget remains hidden and identifies itself before page scripts run. Recording state is tracked in AppFlags and used by tray controls. Dictation setup and runtime failures now appear as notifications in the main window.

Changes

Dictation flow

Layer / File(s) Summary
Hidden widget bootstrap
frontend/src-tauri/src/lib.rs, frontend/src/main-app.jsx, frontend/src/test/DictationNoPillWindow.test.jsx, tests/test_dictation_no_pill_window.py, CHANGELOG.md
The widget receives window.__OV_WINDOW__ = "widget" before frontend scripts run. Dictation activation no longer shows or focuses the widget. Tests cover identity detection and hidden-window behavior.
Independent recording state
frontend/src-tauri/src/lib.rs, frontend/src-tauri/src/commands.rs, tests/test_dictation_no_pill_window.py
AppFlags.dictating stores recording state atomically. Tray updates store the requested state before icon handling, and tray toggling uses this state instead of widget visibility.
Dictation failure notices
frontend/src/utils/dictationNotice.jsx, frontend/src/components/CaptureWidget.jsx, frontend/src/App.jsx, CHANGELOG.md
CaptureWidget emits localized setup and error notices. The main application listens for these events and renders error notifications with optional settings actions. The changelog records the notification behavior.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 7 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 75.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Cross-Platform Default Parity ⚠️ Warning The default hidden-WebView dictation path is unverified on macOS; WKWebView capture may diverge from Windows and Linux, which use different WebView paths. Exercise hidden-widget getUserMedia/MediaRecorder on macOS, Windows, and Linux; if macOS throttles capture, use a shown 1×1 fallback or gate hidden mode behind an explicit opt-in.
✅ Passed checks (7 passed)
Check name Status Explanation
Title check ✅ Passed The title uses Conventional Commit format with the fix(dictation) scope and the body includes issue reference #1398.
Description check ✅ Passed The description clearly covers the problem, implementation, testing, known limitation, and issue context, but omits the template's Type and Checklist sections.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
I18n Completeness (21 Locales) ✅ Passed The PR adds capture.a11y_setup and permissions.open_settings; both exist in all 21 locale files, and new user-facing text uses localized values.
Local-First Guarantee ✅ Passed The PR adds only local Tauri event IPC and OS-settings commands; it adds no URLs, dependencies, accounts, keys, telemetry, or network calls, and notice failures are caught.
Backward Compatibility ✅ Passed The feature diff contains only dictation/UI/Rust files; DB schema, Alembic migrations, settings/project storage, engine code, and model/cache management are unchanged, so no migration or re-downloa...

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@CHANGELOG.md`:
- Line 25: Add the applicable issue or pull request reference in the format
(`#NNN`) to the end of the Unreleased changelog entry, preserving the existing
description.

In `@frontend/src-tauri/src/lib.rs`:
- Around line 551-555: Prevent the single-instance callback in
frontend/src-tauri/src/lib.rs:551-555 from showing or focusing the "widget"
window, preserving it as a hidden recorder host in pill mode. Update
tests/test_dictation_no_pill_window.py:50-67 to exercise the dynamic pill-mode
target and fail if any second-launch path shows the widget.

In `@frontend/src/components/CaptureWidget.jsx`:
- Around line 1322-1331: Update the emitDictationNotice call in the useEffect to
include an explicit action field, setting openMicrophoneSettings only when
errorInfo.kind is 'mic' and errorInfo.deniedByOs is true; otherwise omit or
clear the action so busy or unavailable microphone errors do not offer
permission settings.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: ddb0a0c1-e12c-42c4-a879-6988bcabf81f

📥 Commits

Reviewing files that changed from the base of the PR and between 0c82167 and f0e6961.

📒 Files selected for processing (9)
  • CHANGELOG.md
  • frontend/src-tauri/src/commands.rs
  • frontend/src-tauri/src/lib.rs
  • frontend/src/App.jsx
  • frontend/src/components/CaptureWidget.jsx
  • frontend/src/main-app.jsx
  • frontend/src/test/DictationNoPillWindow.test.jsx
  • frontend/src/utils/dictationNotice.jsx
  • tests/test_dictation_no_pill_window.py

Comment thread CHANGELOG.md
- The repository moved to github.com/debpalash/VoiceStudio. Every link in the app, docs and scripts now points there; GitHub redirects the old URLs, and the Docker image paths, the app bundle identifier and your data folder are all deliberately unchanged. (#1394)
- The app is now **VoiceStudio** (previously OmniVoice-Studio). Only the name you see changes — your data folder, settings and the Docker image paths stay put, so upgrading needs nothing from you. On Linux the .deb is now `voicestudio`; remove the old `omnivoice-studio` package once.
- macOS floor raised to 13.3 (Ventura) — the frontend has required Safari 16.4 for some time, so macOS 12 was a promise the stack could not keep (#1268)
- Dictation no longer shows a floating pill. The hotkey records, transcribes and pastes with nothing on screen; the tray icon still marks recording, and anything needing your attention (Accessibility, microphone, a failed transcription) now arrives as a notification in the main window.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add the release reference.

Line 25 does not end with an issue or PR reference, so this Unreleased entry fails the changelog format.
Add the applicable (#NNN) reference.
As per coding guidelines and path instructions, Unreleased entries must end in (#N).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CHANGELOG.md` at line 25, Add the applicable issue or pull request reference
in the format (`#NNN`) to the end of the Unreleased changelog entry, preserving
the existing description.

Sources: Coding guidelines, Path instructions

Comment thread frontend/src-tauri/src/lib.rs
Comment on lines +1322 to +1331
useEffect(() => {
if (state !== 'error' && state !== 'setup') return;
const kind = state === 'setup' ? 'setup' : errorInfo?.kind || 'transcription';
emitDictationNotice({
kind,
// Localize here: this is where the error's context lives, and both
// windows share one i18n instance and language.
label: state === 'setup' ? t('capture.a11y_setup') : errorLabel(t, errorInfo),
});
}, [state, errorInfo, t]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Preserve the microphone remediation condition.

Line 1324 omits errorInfo.deniedByOs, but showDictationNotice opens microphone settings for every kind: 'mic' notice.
A busy or unavailable microphone then gets a permission-settings action.
Send an explicit action field and only expose openMicrophoneSettings when the mic error is OS-denied.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/components/CaptureWidget.jsx` around lines 1322 - 1331, Update
the emitDictationNotice call in the useEffect to include an explicit action
field, setting openMicrophoneSettings only when errorInfo.kind is 'mic' and
errorInfo.deniedByOs is true; otherwise omit or clear the action so busy or
unavailable microphone errors do not offer permission settings.

…idget

Two CodeRabbit findings, both real.

The single-instance handler picked its focus target as
`if pill_mode { "widget" } else { "main" }` and called show() on it. In
pill mode that put the recorder window on screen — the exact empty
rectangle this PR exists to remove, reached by relaunching the app,
which is precisely what a user does when one is stuck on their desktop.
It now always targets the studio window, matching what the tray's "Open
VoiceStudio" item already does.

The guard test missed it because it only looked for the label written
out literally, and this call site computed it into a variable. Added a
second test that pins every mention of the "widget" label against a
reviewed allowlist, so indirection can't slip past again. Verified it
fails when the old ternary is put back.

Also: the notice toast offered "Open Settings" for every mic error, but
only an OS-level denial has a pane worth opening — a busy or absent mic
arrives under the same kind and would have sent the user somewhere
nothing is wrong. The pill's own button carried that condition; the
notice now carries it too.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/test_dictation_no_pill_window.py`:
- Line 104: The allowlist in the test can accept unsafe calls when they appear
on the same line as an allowed window lookup. In the line-filtering logic,
reject lines containing `.show(` or `show_pill_noactivate(` before evaluating
`allowed_exact` or `allowed`; add a regression test that fails before and passes
after this change.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: d5c6664b-0160-4e7f-b1b2-2865be9e2923

📥 Commits

Reviewing files that changed from the base of the PR and between f0e6961 and 3a95bbf.

📒 Files selected for processing (4)
  • frontend/src-tauri/src/lib.rs
  • frontend/src/components/CaptureWidget.jsx
  • frontend/src/utils/dictationNotice.jsx
  • tests/test_dictation_no_pill_window.py
🚧 Files skipped from review as they are similar to previous changes (3)
  • frontend/src/components/CaptureWidget.jsx
  • frontend/src/utils/dictationNotice.jsx
  • frontend/src-tauri/src/lib.rs

stripped = line.strip()
if stripped.startswith("//") or stripped.startswith("///"):
continue # prose about the widget is not a call site
if stripped in allowed_exact or any(ok in line for ok in allowed):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reject unsafe calls before allowlisting the line.

Line 104 skips a line that contains get_webview_window("widget"), so if let Some(win) = ... { win.show(); } would pass this test. Check for .show( and show_pill_noactivate( before the allowlist, or make permitted lookup patterns exact. As per coding guidelines, “Fix the root cause with a fail-before/pass-after regression test and the smallest correct change.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_dictation_no_pill_window.py` at line 104, The allowlist in the
test can accept unsafe calls when they appear on the same line as an allowed
window lookup. In the line-filtering logic, reject lines containing `.show(` or
`show_pill_noactivate(` before evaluating `allowed_exact` or `allowed`; add a
regression test that fails before and passes after this change.

Source: Coding guidelines

@debpalash
debpalash merged commit 821ab3c into main Aug 7, 2026
9 checks passed
@debpalash
debpalash deleted the fix/dictation-no-pill-window branch August 7, 2026 12:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant