feat(capture): gate global hotkey on dictation readiness checklist

Stops the "stuck pill" failure where pressing the chord with missing
STT/LLM models triggers a recording that has nowhere to land. The
hotkey now stays disarmed until every gate (models downloaded, Input
Monitoring + Accessibility granted) is green; the empty-state checklist
in CapturesTab surfaces each unmet gate with a one-click action and
auto-arms the chord once everything turns green.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
James Pine
2026-04-23 03:09:44 -07:00
co-authored by Claude Opus 4.7
parent 868a40fb7e
commit 85a3e1363f
10 changed files with 480 additions and 14 deletions
@@ -21,6 +21,7 @@ import {
} from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { CapturePill } from '@/components/CapturePill/CapturePill';
import { DictationReadinessChecklist } from '@/components/CapturesTab/DictationReadinessChecklist';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
@@ -44,6 +45,7 @@ import type {
import type { LanguageCode } from '@/lib/constants/languages';
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
import { useCaptureRecordingSession } from '@/lib/hooks/useCaptureRecordingSession';
import { useDictationReadiness } from '@/lib/hooks/useDictationReadiness';
import { useCaptureSettings } from '@/lib/hooks/useSettings';
import { cn } from '@/lib/utils/cn';
import { displayLabelForKey, modifierSideHint } from '@/lib/utils/keyCodes';
@@ -188,6 +190,7 @@ export function CapturesTab() {
const hotkeyEnabled = captureSettings?.hotkey_enabled ?? false;
const pushToTalkKeys = captureSettings?.chord_push_to_talk_keys ?? [];
const toggleToTalkKeys = captureSettings?.chord_toggle_to_talk_keys ?? [];
const readiness = useDictationReadiness();
const session = useCaptureRecordingSession({
onCaptureCreated: (capture) => setSelectedId(capture.id),
@@ -776,6 +779,8 @@ export function CapturesTab() {
<Captions className="h-10 w-10 mx-auto opacity-40" />
<p className="text-sm">Pick a capture to see the transcript.</p>
</div>
) : hotkeyEnabled && !readiness.allReady ? (
<DictationReadinessChecklist readiness={readiness} />
) : hotkeyEnabled && (pushToTalkKeys.length || toggleToTalkKeys.length) ? (
<div className="max-w-sm mx-auto text-center space-y-5">
<div className="space-y-2">
@@ -0,0 +1,223 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
Accessibility,
CheckCircle2,
Circle,
Cpu,
Download,
ExternalLink,
Keyboard,
Loader2,
} from 'lucide-react';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { useToast } from '@/components/ui/use-toast';
import { apiClient } from '@/lib/api/client';
import type { DictationReadiness, ReadinessGate } from '@/lib/hooks/useDictationReadiness';
import { cn } from '@/lib/utils/cn';
interface RowProps {
icon: React.ReactNode;
title: string;
description: string;
ready: boolean;
action?: React.ReactNode;
}
function ChecklistRow({ icon, title, description, ready, action }: RowProps) {
return (
<div
className={cn(
'flex items-start gap-3 rounded-lg border p-3.5 transition-colors',
ready ? 'border-emerald-500/20 bg-emerald-500/5' : 'border-border bg-muted/20',
)}
>
<div className="mt-0.5 shrink-0">
{ready ? (
<CheckCircle2 className="h-5 w-5 text-emerald-500" />
) : (
<Circle className="h-5 w-5 text-muted-foreground/50" />
)}
</div>
<div className="flex-1 min-w-0 space-y-1">
<div className="flex items-center gap-2">
<span className="text-muted-foreground">{icon}</span>
<p className="text-sm font-medium text-foreground">{title}</p>
</div>
<p className="text-xs text-muted-foreground leading-relaxed">{description}</p>
{!ready && action ? <div className="pt-1.5">{action}</div> : null}
</div>
</div>
);
}
/**
* Renders one row per dictation-readiness gate. Each unmet gate gets an
* inline action — Download for missing models, Open Settings for missing
* TCC permissions — so the user can resolve everything without leaving
* Captures.
*
* The chord stays disarmed until every row is green; this is what stops the
* "stuck pill" failure mode of pressing the chord with a missing model.
*/
export function DictationReadinessChecklist({ readiness }: { readiness: DictationReadiness }) {
const queryClient = useQueryClient();
const { toast } = useToast();
const [downloading, setDownloading] = useState<Set<ReadinessGate>>(new Set());
const downloadMutation = useMutation({
mutationFn: async ({ modelName }: { gate: ReadinessGate; modelName: string }) =>
apiClient.triggerModelDownload(modelName),
onSuccess: (_data, vars) => {
// Bump model status + readiness so the checklist row flips green as
// soon as the cache is populated. Keep the gate in `downloading` until
// readiness reports `ready: true` to avoid a flash of "Download" on
// post-completion polls.
queryClient.invalidateQueries({ queryKey: ['modelStatus'] });
queryClient.invalidateQueries({ queryKey: ['capture-readiness'] });
toast({
title: 'Download started',
description: `${vars.gate === 'stt' ? readiness.stt?.display_name : readiness.llm?.display_name} is downloading. The shortcut will arm itself when it finishes.`,
});
},
onError: (err: Error, vars) => {
setDownloading((prev) => {
const next = new Set(prev);
next.delete(vars.gate);
return next;
});
toast({
title: 'Download failed',
description: err.message,
variant: 'destructive',
});
},
});
const startDownload = (gate: ReadinessGate, modelName: string) => {
setDownloading((prev) => new Set(prev).add(gate));
downloadMutation.mutate({ gate, modelName });
};
// Once readiness flips ready=true for a gate, drop it from `downloading`
// so subsequent reopens show the green check, not "Downloading…".
const isDownloading = (gate: ReadinessGate, ready: boolean) => !ready && downloading.has(gate);
const sttSize =
readiness.stt?.size_mb != null ? `${(readiness.stt.size_mb / 1000).toFixed(1)} GB` : null;
const llmSize =
readiness.llm?.size_mb != null ? `${(readiness.llm.size_mb / 1000).toFixed(1)} GB` : null;
return (
<div className="w-full max-w-md mx-auto space-y-2.5">
<div className="text-center mb-5 space-y-1">
<h2 className="text-base font-semibold text-foreground">
A few things before you can dictate
</h2>
<p className="text-xs text-muted-foreground">
The shortcut stays off until everything below is ready.
</p>
</div>
{readiness.stt && (
<ChecklistRow
icon={<Cpu className="h-3.5 w-3.5" />}
title={`${readiness.stt.display_name} (speech-to-text)`}
description={
readiness.stt.ready
? 'Model downloaded.'
: `Needed to transcribe your audio${sttSize ? ` · ${sttSize}` : ''}.`
}
ready={readiness.stt.ready}
action={
<Button
size="sm"
onClick={() => startDownload('stt', readiness.stt!.model_name)}
disabled={isDownloading('stt', readiness.stt.ready)}
className="gap-1.5"
>
{isDownloading('stt', readiness.stt.ready) ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Downloading
</>
) : (
<>
<Download className="h-3.5 w-3.5" />
Download
</>
)}
</Button>
}
/>
)}
{readiness.llm && (
<ChecklistRow
icon={<Cpu className="h-3.5 w-3.5" />}
title={`${readiness.llm.display_name} (refinement)`}
description={
readiness.llm.ready
? 'Model downloaded.'
: `Cleans up the raw transcript before paste${llmSize ? ` · ${llmSize}` : ''}.`
}
ready={readiness.llm.ready}
action={
<Button
size="sm"
onClick={() => startDownload('llm', readiness.llm!.model_name)}
disabled={isDownloading('llm', readiness.llm.ready)}
className="gap-1.5"
>
{isDownloading('llm', readiness.llm.ready) ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Downloading
</>
) : (
<>
<Download className="h-3.5 w-3.5" />
Download
</>
)}
</Button>
}
/>
)}
<ChecklistRow
icon={<Keyboard className="h-3.5 w-3.5" />}
title="Input Monitoring permission"
description={
readiness.inputMonitoring
? 'macOS allows Voicebox to detect your global shortcut.'
: 'macOS needs to allow Voicebox to detect the global shortcut.'
}
ready={readiness.inputMonitoring}
action={
<Button size="sm" onClick={readiness.openInputMonitoringSettings} className="gap-1.5">
<ExternalLink className="h-3.5 w-3.5" />
Open Settings
</Button>
}
/>
<ChecklistRow
icon={<Accessibility className="h-3.5 w-3.5" />}
title="Accessibility permission"
description={
readiness.accessibility
? 'Voicebox can paste transcriptions into other apps.'
: 'Required so transcriptions can paste into the focused app.'
}
ready={readiness.accessibility}
action={
<Button size="sm" onClick={readiness.openAccessibilitySettings} className="gap-1.5">
<ExternalLink className="h-3.5 w-3.5" />
Open Settings
</Button>
}
/>
</div>
);
}
+29 -1
View File
@@ -21,6 +21,8 @@ import {
SelectValue,
} from '@/components/ui/select';
import { Toggle } from '@/components/ui/toggle';
import { useToast } from '@/components/ui/use-toast';
import { useDictationReadiness } from '@/lib/hooks/useDictationReadiness';
import { useCaptureSettings } from '@/lib/hooks/useSettings';
import { useProfiles } from '@/lib/hooks/useProfiles';
import { cn } from '@/lib/utils/cn';
@@ -132,6 +134,8 @@ function HotkeyPillPreview({ enabled }: { enabled: boolean }) {
export function CapturesPage() {
const { settings, update } = useCaptureSettings();
const { data: profiles } = useProfiles();
const { toast } = useToast();
const readiness = useDictationReadiness();
const sttModel = settings?.stt_model ?? 'turbo';
const language = settings?.language ?? 'auto';
const autoRefine = settings?.auto_refine ?? true;
@@ -172,7 +176,31 @@ export function CapturesPage() {
<Toggle
id="hotkeyEnabled"
checked={hotkeyEnabled}
onCheckedChange={(v) => update({ hotkey_enabled: v })}
onCheckedChange={(v) => {
update({ hotkey_enabled: v });
// Surface model-readiness blocks at the toggle. The
// InputMonitoringNotice below already covers TCC, but
// missing models would otherwise be invisible from this
// page — the user toggles on, presses the chord, and
// nothing happens because useChordSync gates on readiness.
if (!v) return;
const missingModels = readiness.missing.filter(
(g) => g === 'stt' || g === 'llm',
);
if (missingModels.length === 0) return;
const names = [
missingModels.includes('stt') ? readiness.stt?.display_name : null,
missingModels.includes('llm') ? readiness.llm?.display_name : null,
]
.filter(Boolean)
.join(' and ');
toast({
title: 'Shortcut on, but not yet armed',
description: `${names} still need${
missingModels.length === 1 ? 's' : ''
} to download. Open the Captures tab to start.`,
});
}}
/>
}
/>
+5
View File
@@ -38,6 +38,7 @@ import type {
CaptureListResponse,
CaptureResponse,
CaptureCreateResponse,
CaptureReadinessResponse,
CaptureRefineRequest,
CaptureRetranscribeRequest,
CaptureSettings,
@@ -486,6 +487,10 @@ class ApiClient {
return this.request<CaptureSettings>('/settings/captures');
}
async getCaptureReadiness(): Promise<CaptureReadinessResponse> {
return this.request<CaptureReadinessResponse>('/capture/readiness');
}
async updateCaptureSettings(patch: CaptureSettingsUpdate): Promise<CaptureSettings> {
return this.request<CaptureSettings>('/settings/captures', {
method: 'PUT',
+20
View File
@@ -219,6 +219,26 @@ export interface CaptureSettings {
export type CaptureSettingsUpdate = Partial<CaptureSettings>;
/**
* One row in the dictation readiness checklist. ``model_name`` is the
* canonical id understood by ``POST /models/download`` so the UI can wire a
* one-click "Download" button without a second lookup.
*/
export interface ModelReadiness {
ready: boolean;
model_name: string;
display_name: string;
size: string;
size_mb?: number | null;
}
/** Backend half of the dictation readiness check. The frontend combines this
* with TCC permission state into the full checklist used by useDictationReadiness. */
export interface CaptureReadinessResponse {
stt: ModelReadiness;
llm: ModelReadiness;
}
export interface GenerationSettings {
max_chunk_chars: number;
crossfade_ms: number;
+20 -13
View File
@@ -1,27 +1,34 @@
import { invoke } from '@tauri-apps/api/core';
import { useEffect } from 'react';
import { useDictationReadiness } from '@/lib/hooks/useDictationReadiness';
import { useCaptureSettings } from '@/lib/hooks/useSettings';
import { usePlatform } from '@/platform/PlatformContext';
/**
* Spawn (or quiet) the global hotkey monitor based on the saved
* `capture_settings.hotkey_enabled` flag, and keep its bindings in sync with
* the user's chord choices.
* `capture_settings.hotkey_enabled` flag AND the dictation readiness gates,
* and keep its bindings in sync with the user's chord choices.
*
* Boot sequence:
* - hotkey_enabled = false → call `disable_hotkey` (no-op if monitor was
* never spawned). Crucially, we do *not* call `enable_hotkey`, so the
* macOS Input Monitoring TCC prompt is never triggered for users who
* haven't opted in.
* - hotkey_enabled = true → call `enable_hotkey` with the saved chords.
* This is the call that creates the CGEventTap and triggers the TCC
* prompt on first opt-in.
* - hotkey_enabled = false OR any readiness gate missing → call
* `disable_hotkey` (no-op if monitor was never spawned). Crucially, we do
* *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
* chord physically can't fire when models aren't downloaded — preventing
* the "stuck pill" failure mode where dictation triggers but has nowhere
* to land.
* - hotkey_enabled = true AND all gates green → call `enable_hotkey` with
* 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
* user finishes downloading Whisper in another tab) so the chord
* auto-arms without making the user toggle off/on.
*
* Call once from the main app shell.
*/
export function useChordSync() {
const platform = usePlatform();
const { settings } = useCaptureSettings();
const { allReady } = useDictationReadiness();
const enabled = settings?.hotkey_enabled;
const pushKeys = settings?.chord_push_to_talk_keys;
const toggleKeys = settings?.chord_toggle_to_talk_keys;
@@ -29,16 +36,16 @@ export function useChordSync() {
useEffect(() => {
if (!platform.metadata.isTauri) return;
if (enabled === undefined || !pushKeys || !toggleKeys) return;
const command = enabled ? 'enable_hotkey' : 'disable_hotkey';
const args = enabled
? { pushToTalk: pushKeys, toggleToTalk: toggleKeys }
: {};
const shouldArm = enabled && allReady;
const command = shouldArm ? 'enable_hotkey' : 'disable_hotkey';
const args = shouldArm ? { pushToTalk: pushKeys, toggleToTalk: toggleKeys } : {};
invoke(command, args).catch((err) => {
console.warn(`[chord-sync] ${command} failed:`, err);
});
}, [
platform.metadata.isTauri,
enabled,
allReady,
// Stringify so a referentially-new array with the same content
// doesn't fire a redundant invoke on every settings refetch.
pushKeys?.join(','),
@@ -0,0 +1,97 @@
import { useQuery } from '@tanstack/react-query';
import { useAccessibilityPermission } from '@/components/AccessibilityGate/AccessibilityGate';
import { useInputMonitoringPermission } from '@/components/InputMonitoringGate/InputMonitoringGate';
import { apiClient } from '@/lib/api/client';
import type { ModelReadiness } from '@/lib/api/types';
import { usePlatform } from '@/platform/PlatformContext';
const READINESS_POLL_INTERVAL_MS = 5_000;
export type ReadinessGate = 'stt' | 'llm' | 'input_monitoring' | 'accessibility';
export interface DictationReadiness {
isLoading: boolean;
allReady: boolean;
/** Subset of gates that are NOT yet satisfied — what the checklist renders. */
missing: ReadinessGate[];
stt: ModelReadiness | undefined;
llm: ModelReadiness | undefined;
inputMonitoring: boolean;
accessibility: boolean;
refetch: () => void;
openInputMonitoringSettings: () => Promise<void>;
openAccessibilitySettings: () => Promise<void>;
recheckInputMonitoring: () => Promise<boolean>;
recheckAccessibility: () => Promise<boolean>;
}
/**
* Single source of truth for "can the user trigger dictation right now?"
*
* Combines four gates into one struct so the chord-sync hook can refuse to
* arm the global hotkey unless every gate is green — the "stuck pill" we
* used to get on missing models is solved by never letting the chord fire
* in the first place.
*
* Gates:
* - stt / llm: backend ``/capture/readiness`` (polled, since downloads
* finish out-of-band — e.g. user kicks off a download in another tab and
* expects the toggle to auto-unlock when it lands)
* - input_monitoring / accessibility: macOS TCC checks via Tauri commands
* (rechecked on window focus by the underlying hooks)
*
* Hotkey-enabled is the user's intent toggle and is intentionally *not*
* a gate here — that's `useChordSync`'s concern.
*/
export function useDictationReadiness(): DictationReadiness {
const platform = usePlatform();
const isTauri = platform.metadata.isTauri;
const {
needsPermission: inputMonNeeds,
recheck: recheckInputMon,
openSettings: openInputMon,
} = useInputMonitoringPermission();
const {
needsPermission: a11yNeeds,
recheck: recheckA11y,
openSettings: openA11y,
} = useAccessibilityPermission();
const { data, isLoading, refetch } = useQuery({
queryKey: ['capture-readiness'],
queryFn: () => apiClient.getCaptureReadiness(),
refetchInterval: READINESS_POLL_INTERVAL_MS,
refetchOnWindowFocus: true,
});
// On the web build there's no TCC layer — treat both as granted so the
// checklist doesn't block users who can't even open System Settings.
const inputMonitoring = isTauri ? !inputMonNeeds : true;
const accessibility = isTauri ? !a11yNeeds : true;
const sttReady = data?.stt.ready ?? false;
const llmReady = data?.llm.ready ?? false;
const missing: ReadinessGate[] = [];
if (!sttReady) missing.push('stt');
if (!llmReady) missing.push('llm');
if (!inputMonitoring) missing.push('input_monitoring');
if (!accessibility) missing.push('accessibility');
return {
isLoading,
allReady: missing.length === 0,
missing,
stt: data?.stt,
llm: data?.llm,
inputMonitoring,
accessibility,
refetch: () => {
refetch();
},
openInputMonitoringSettings: openInputMon,
openAccessibilitySettings: openA11y,
recheckInputMonitoring: recheckInputMon,
recheckAccessibility: recheckA11y,
};
}
+5
View File
@@ -480,6 +480,11 @@ def get_llm_model_configs() -> list[ModelConfig]:
return _get_qwen_llm_configs()
def get_stt_model_configs() -> list[ModelConfig]:
"""Return only STT (Whisper) model configs."""
return _get_whisper_configs()
# Lookup helpers — these replace the if/elif chains in main.py
+28
View File
@@ -427,6 +427,34 @@ class PersonalitySpeakRequest(BaseModel):
)
class ModelReadiness(BaseModel):
"""Per-model entry in the dictation readiness checklist.
``model_name`` is the canonical id used by ``POST /models/download`` so the
frontend can wire a one-click "Download" button without a second lookup.
``size`` is the user's chosen variant (e.g. "turbo", "0.6B"); ``display_name``
is what the checklist row should show ("Whisper Turbo").
"""
ready: bool
model_name: str
display_name: str
size: str
size_mb: Optional[int] = None
class CaptureReadinessResponse(BaseModel):
"""Backend gates that must be green before the global hotkey will fire.
The frontend combines this with its own TCC permission checks (input
monitoring, accessibility) into the full dictation readiness checklist.
Hotkey-enabled is the user's intent toggle and lives outside this struct.
"""
stt: ModelReadiness
llm: ModelReadiness
class HealthResponse(BaseModel):
"""Response model for health check."""
+48
View File
@@ -7,6 +7,8 @@ from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from .. import config, models
from ..backends import get_llm_model_configs, get_stt_model_configs
from ..backends.base import is_model_cached
from ..database import Capture as DBCapture, get_db
from ..services import captures as captures_service
from ..services import settings as settings_service
@@ -152,6 +154,52 @@ async def refine_capture_endpoint(
return capture
@router.get("/capture/readiness", response_model=models.CaptureReadinessResponse)
async def capture_readiness_endpoint(db: Session = Depends(get_db)):
"""Whether the STT and LLM models the user has selected are downloaded.
The frontend gates the global hotkey on this pressing the chord with
a missing model would otherwise produce a stuck "transcribing" pill that
waits forever for a download to finish. Checks on-disk cache, not RAM
load, so the answer survives backend restarts.
"""
saved = settings_service.get_capture_settings(db)
stt_cfg = next(
(c for c in get_stt_model_configs() if c.model_size == saved.stt_model),
None,
)
llm_cfg = next(
(c for c in get_llm_model_configs() if c.model_size == saved.llm_model),
None,
)
if stt_cfg is None or llm_cfg is None:
# Should be impossible — both fields are pattern-validated against
# known sizes — but bail loudly rather than return half a response.
raise HTTPException(
status_code=500,
detail=f"No model config for stt={saved.stt_model} or llm={saved.llm_model}",
)
return models.CaptureReadinessResponse(
stt=models.ModelReadiness(
ready=is_model_cached(stt_cfg.hf_repo_id),
model_name=stt_cfg.model_name,
display_name=stt_cfg.display_name,
size=stt_cfg.model_size,
size_mb=stt_cfg.size_mb or None,
),
llm=models.ModelReadiness(
ready=is_model_cached(llm_cfg.hf_repo_id),
model_name=llm_cfg.model_name,
display_name=llm_cfg.display_name,
size=llm_cfg.model_size,
size_mb=llm_cfg.size_mb or None,
),
)
@router.post("/captures/{capture_id}/retranscribe", response_model=models.CaptureResponse)
async def retranscribe_capture_endpoint(
capture_id: str,