diff --git a/CHANGELOG.md b/CHANGELOG.md
index 72583807..a4b20a13 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@ the frozen-backend fallback mirror it for their toolchains.
## [Unreleased]
**Highlights**
+- A dictation shortcut another app already owns now says so, instead of silently doing nothing (#1858)
- Generating from a one-character input now says the input was too short, instead of quoting a convolution error (#1826)
- First run asks about text size before the install, not after it (#1849)
- Cloning without a reference clip now says so, instead of naming library parameters you cannot set (#1879)
diff --git a/frontend/src-tauri/src/dictation_shortcut.rs b/frontend/src-tauri/src/dictation_shortcut.rs
index 94d18394..5ac9c750 100644
--- a/frontend/src-tauri/src/dictation_shortcut.rs
+++ b/frontend/src-tauri/src/dictation_shortcut.rs
@@ -64,7 +64,20 @@ impl DictationShortcutManager {
Ok(()) => {
manager.publish(&app, accelerator, None, "native");
}
- Err(error) => log::warn!("Failed to register global shortcut: {error}"),
+ Err(error) => {
+ // Publish the failure instead of only logging it. Whichever
+ // app registers a global shortcut first wins, and the default
+ // collides with 1Password Quick Access on macOS — so for a lot
+ // of installs the hotkey the onboarding screen advertises
+ // silently does nothing. With no publish on this path the
+ // frontend kept reporting whatever accelerator was REQUESTED,
+ // with no way to know the OS never granted it (#1858).
+ //
+ // The accelerator is still published so the UI can name the
+ // shortcut that failed; `backend` carries the outcome.
+ log::warn!("Failed to register global shortcut: {error}");
+ manager.publish(&app, accelerator, None, "unregistered");
+ }
}
}
diff --git a/frontend/src/components/DictationDemo.jsx b/frontend/src/components/DictationDemo.jsx
index 0be2cec2..b781a29b 100644
--- a/frontend/src/components/DictationDemo.jsx
+++ b/frontend/src/components/DictationDemo.jsx
@@ -101,9 +101,17 @@ export default function DictationDemo({ embedded = false }) {
useEffect(() => {
if (!desktop) return;
- setHotkeyState((current) =>
- current === 'verified' ? current : shortcut.backend === 'focused' ? 'unknown' : 'registered',
- );
+ // `unregistered` is its own state, not a flavour of `unknown`: the OS
+ // refused the accelerator, usually because another app already holds it
+ // (the default collides with 1Password Quick Access on macOS). Saying
+ // "no hotkey registered" there would read as "we have not checked yet",
+ // when what the user needs to know is that this specific combination is
+ // taken and they should pick another (#1858).
+ setHotkeyState((current) => {
+ if (shortcut.backend === 'unregistered') return 'unregistered';
+ if (current === 'verified') return current;
+ return shortcut.backend === 'focused' ? 'unknown' : 'registered';
+ });
}, [desktop, shortcut.backend]);
// Subscribe to dictation events: the moment the user presses their
@@ -203,6 +211,21 @@ export default function DictationDemo({ embedded = false }) {
{t('demo.dictation_status_ok')}
);
+ case 'unregistered':
+ return (
+
+ {' '}
+ {t('demo.dictation_status_taken', {
+ defaultValue:
+ 'Another app already uses this shortcut — pick a different one in Settings.',
+ })}{' '}
+
+ {shortcut.display}
+
+
+ );
case 'registered':
return (
({
},
apiJson: vi.fn(),
}));
+const { shortcutInfo } = vi.hoisted(() => ({
+ shortcutInfo: { accelerator: 'CmdOrCtrl+Shift+Space', display: '⌘⇧Space', backend: 'native' },
+}));
+vi.mock('../hooks/useEffectiveDictationShortcut', () => ({
+ useEffectiveDictationShortcut: () => ({ info: shortcutInfo }),
+}));
vi.mock('../hooks/useDictationReadiness', () => ({
useDictationReadiness: () => readiness,
}));
@@ -150,3 +156,43 @@ describe('DictationDemo', () => {
expect(screen.queryByTestId('asr-model-chooser')).not.toBeInTheDocument();
});
});
+
+// #1858: whichever app registers a global shortcut first wins, and the default
+// collides with 1Password Quick Access on macOS. Registration failure used to
+// be a Rust-side log line and nothing else — the frontend kept reporting the
+// accelerator that had been REQUESTED, so the onboarding screen advertised a
+// hotkey the OS had refused, with no way for the user to find out.
+describe('DictationDemo — hotkey registration failure', () => {
+ beforeEach(() => {
+ window.__TAURI_INTERNALS__ = {};
+ });
+ afterEach(() => {
+ delete window.__TAURI_INTERNALS__;
+ });
+
+ it('says the shortcut is taken rather than merely unregistered', () => {
+ shortcutInfo.backend = 'unregistered';
+ shortcutInfo.display = '⌘⇧Space';
+ render(withI18n());
+
+ expect(screen.getByText(/Another app already uses this shortcut/i)).toBeInTheDocument();
+ // "No hotkey registered" means "not checked yet" — a different situation.
+ expect(screen.queryByText(/No hotkey registered/i)).not.toBeInTheDocument();
+ });
+
+ it('still names the shortcut that failed', () => {
+ // The user has to know WHICH combination is taken to pick another.
+ shortcutInfo.backend = 'unregistered';
+ shortcutInfo.display = '⌘⇧Space';
+ render(withI18n());
+
+ expect(screen.getByText('⌘⇧Space')).toBeInTheDocument();
+ });
+
+ it('leaves a successfully registered shortcut alone', () => {
+ shortcutInfo.backend = 'native';
+ render(withI18n());
+
+ expect(screen.queryByText(/Another app already uses this shortcut/i)).not.toBeInTheDocument();
+ });
+});