fix(captures): allow dictation without paste permission

This commit is contained in:
Jamie Pine
2026-04-25 10:23:25 -07:00
parent 2e5b8d2d67
commit 166250856f
3 changed files with 17 additions and 15 deletions
@@ -567,7 +567,7 @@ export function CapturesTab() {
{t('captures.actions.configure')} {t('captures.actions.configure')}
</Link> </Link>
</Button> </Button>
{readiness.allReady && ( {readiness.canRecord && (
<Button <Button
variant="outline" variant="outline"
onClick={handleUploadClick} onClick={handleUploadClick}
@@ -583,11 +583,11 @@ export function CapturesTab() {
)} )}
</> </>
)} )}
{/* Hide Dictate when readiness fails so the user can't kick off {/* Hide Dictate when recording readiness fails so the user can't kick off
a capture that has nowhere to land. Stop stays visible if a a capture that has nowhere to land. Stop stays visible if a
recording is somehow already in flight (e.g. a model was recording is somehow already in flight (e.g. a model was
uninstalled mid-record) so the user can always cancel. */} uninstalled mid-record) so the user can always cancel. */}
{(readiness.allReady || session.isRecording) && ( {(readiness.canRecord || session.isRecording) && (
<Button <Button
onClick={session.toggleRecording} onClick={session.toggleRecording}
disabled={session.isUploading && !session.isRecording} disabled={session.isUploading && !session.isRecording}
@@ -842,7 +842,7 @@ export function CapturesTab() {
<Captions className="h-10 w-10 mx-auto opacity-40" /> <Captions className="h-10 w-10 mx-auto opacity-40" />
<p className="text-sm">{t('captures.empty.pickOne')}</p> <p className="text-sm">{t('captures.empty.pickOne')}</p>
</div> </div>
) : hotkeyEnabled && !readiness.allReady ? ( ) : hotkeyEnabled && !readiness.canRecord ? (
<DictationReadinessChecklist readiness={readiness} /> <DictationReadinessChecklist readiness={readiness} />
) : hotkeyEnabled && (pushToTalkKeys.length || toggleToTalkKeys.length) ? ( ) : hotkeyEnabled && (pushToTalkKeys.length || toggleToTalkKeys.length) ? (
<div className="max-w-sm mx-auto text-center space-y-5"> <div className="max-w-sm mx-auto text-center space-y-5">
+6 -6
View File
@@ -6,18 +6,18 @@ import { usePlatform } from '@/platform/PlatformContext';
/** /**
* Spawn (or quiet) the global hotkey monitor based on the saved * Spawn (or quiet) the global hotkey monitor based on the saved
* `capture_settings.hotkey_enabled` flag AND the dictation readiness gates, * `capture_settings.hotkey_enabled` flag and the recording readiness gates,
* and keep its bindings in sync with the user's chord choices. * and keep its bindings in sync with the user's chord choices.
* *
* Boot sequence: * Boot sequence:
* - hotkey_enabled = false OR any readiness gate missing → call * - hotkey_enabled = false OR a recording gate is missing → call
* `disable_hotkey` (no-op if monitor was never spawned). Crucially, we do * `disable_hotkey` (no-op if monitor was never spawned). Crucially, we do
* *not* call `enable_hotkey` in this state, so the macOS Input Monitoring * *not* call `enable_hotkey` in this state, so the macOS Input Monitoring
* TCC prompt is never triggered for users who haven't opted in, AND the * TCC prompt is never triggered for users who haven't opted in, AND the
* chord physically can't fire when models aren't downloaded — preventing * chord physically can't fire when models aren't downloaded — preventing
* the "stuck pill" failure mode where dictation triggers but has nowhere * the "stuck pill" failure mode where dictation triggers but has nowhere
* to land. * to land.
* - hotkey_enabled = true AND all gates green → call `enable_hotkey` with * - hotkey_enabled = true AND recording gates green → call `enable_hotkey` with
* the saved chords. This creates the CGEventTap and triggers the TCC * the saved chords. This creates the CGEventTap and triggers the TCC
* prompt on first opt-in. Re-runs whenever a gate flips green (e.g. the * prompt on first opt-in. Re-runs whenever a gate flips green (e.g. the
* user finishes downloading Whisper in another tab) so the chord * user finishes downloading Whisper in another tab) so the chord
@@ -28,7 +28,7 @@ import { usePlatform } from '@/platform/PlatformContext';
export function useChordSync() { export function useChordSync() {
const platform = usePlatform(); const platform = usePlatform();
const { settings } = useCaptureSettings(); const { settings } = useCaptureSettings();
const { allReady } = useDictationReadiness(); const { canRecord } = useDictationReadiness();
const enabled = settings?.hotkey_enabled; const enabled = settings?.hotkey_enabled;
const pushKeys = settings?.chord_push_to_talk_keys; const pushKeys = settings?.chord_push_to_talk_keys;
const toggleKeys = settings?.chord_toggle_to_talk_keys; const toggleKeys = settings?.chord_toggle_to_talk_keys;
@@ -36,7 +36,7 @@ export function useChordSync() {
useEffect(() => { useEffect(() => {
if (!platform.metadata.isTauri) return; if (!platform.metadata.isTauri) return;
if (enabled === undefined || !pushKeys || !toggleKeys) return; if (enabled === undefined || !pushKeys || !toggleKeys) return;
const shouldArm = enabled && allReady; const shouldArm = enabled && canRecord;
const command = shouldArm ? 'enable_hotkey' : 'disable_hotkey'; const command = shouldArm ? 'enable_hotkey' : 'disable_hotkey';
const args = shouldArm ? { pushToTalk: pushKeys, toggleToTalk: toggleKeys } : {}; const args = shouldArm ? { pushToTalk: pushKeys, toggleToTalk: toggleKeys } : {};
invoke(command, args).catch((err) => { invoke(command, args).catch((err) => {
@@ -45,7 +45,7 @@ export function useChordSync() {
}, [ }, [
platform.metadata.isTauri, platform.metadata.isTauri,
enabled, enabled,
allReady, canRecord,
// Stringify so a referentially-new array with the same content // Stringify so a referentially-new array with the same content
// doesn't fire a redundant invoke on every settings refetch. // doesn't fire a redundant invoke on every settings refetch.
pushKeys?.join(','), pushKeys?.join(','),
+7 -5
View File
@@ -11,6 +11,7 @@ export type ReadinessGate = 'stt' | 'llm' | 'input_monitoring' | 'accessibility'
export interface DictationReadiness { export interface DictationReadiness {
isLoading: boolean; isLoading: boolean;
canRecord: boolean;
allReady: boolean; allReady: boolean;
/** Subset of gates that are NOT yet satisfied — what the checklist renders. */ /** Subset of gates that are NOT yet satisfied — what the checklist renders. */
missing: ReadinessGate[]; missing: ReadinessGate[];
@@ -26,12 +27,11 @@ export interface DictationReadiness {
} }
/** /**
* Single source of truth for "can the user trigger dictation right now?" * Single source of truth for dictation readiness.
* *
* Combines four gates into one struct so the chord-sync hook can refuse to * ``canRecord`` covers the gates that must be green before the chord can
* arm the global hotkey unless every gate is green — the "stuck pill" we * start recording. ``allReady`` also includes Accessibility, which only gates
* used to get on missing models is solved by never letting the chord fire * synthetic paste — dictation still records and lands in Captures without it.
* in the first place.
* *
* Gates: * Gates:
* - stt / llm: backend ``/capture/readiness`` (polled, since downloads * - stt / llm: backend ``/capture/readiness`` (polled, since downloads
@@ -87,9 +87,11 @@ export function useDictationReadiness(): DictationReadiness {
if (!llmReady) missing.push('llm'); if (!llmReady) missing.push('llm');
if (!inputMonitoring) missing.push('input_monitoring'); if (!inputMonitoring) missing.push('input_monitoring');
if (!accessibility) missing.push('accessibility'); if (!accessibility) missing.push('accessibility');
const canRecord = sttReady && llmReady && inputMonitoring;
return { return {
isLoading, isLoading,
canRecord,
allReady: missing.length === 0, allReady: missing.length === 0,
missing, missing,
stt: data?.stt, stt: data?.stt,