Prevent voice dictation tip after settings toggle (#4519)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-06-02 18:40:04 -07:00 committed by GitHub
parent 92d8946209
commit b7348e6c6b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 365 additions and 32 deletions

View File

@ -131,6 +131,7 @@
"electron-builder": "^26.8.1",
"electron-vite": "^5.0.0",
"emoji-picker-react": "^4.19.1",
"happy-dom": "^20.9.0",
"html-to-image": "^1.11.13",
"husky": "^9.1.7",
"katex": "^0.16.45",

View File

@ -215,6 +215,9 @@ importers:
emoji-picker-react:
specifier: ^4.19.1
version: 4.19.1(react@19.2.5)
happy-dom:
specifier: ^20.9.0
version: 20.9.0
html-to-image:
specifier: ^1.11.13
version: 1.11.13
@ -328,7 +331,7 @@ importers:
version: 7.3.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)
vitest:
specifier: ^4.1.5
version: 4.1.5(@types/node@25.6.0)(msw@2.14.3(@types/node@25.6.0)(typescript@5.9.3))(vite@7.3.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))
version: 4.1.5(@types/node@25.6.0)(happy-dom@20.9.0)(msw@2.14.3(@types/node@25.6.0)(typescript@5.9.3))(vite@7.3.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))
zustand:
specifier: ^5.0.13
version: 5.0.13(@types/react@19.2.14)(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5))
@ -2954,6 +2957,9 @@ packages:
'@types/verror@1.10.11':
resolution: {integrity: sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==}
'@types/whatwg-mimetype@3.0.2':
resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==}
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
@ -3894,6 +3900,10 @@ packages:
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
engines: {node: '>=0.12'}
entities@7.0.1:
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
engines: {node: '>=0.12'}
env-paths@2.2.1:
resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
engines: {node: '>=6'}
@ -4251,6 +4261,10 @@ packages:
hachure-fill@0.5.2:
resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==}
happy-dom@20.9.0:
resolution: {integrity: sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ==}
engines: {node: '>=20.0.0'}
has-flag@4.0.0:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
@ -6274,6 +6288,10 @@ packages:
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
engines: {node: '>= 8'}
whatwg-mimetype@3.0.0:
resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==}
engines: {node: '>=12'}
which-module@2.0.1:
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
@ -8824,6 +8842,8 @@ snapshots:
'@types/verror@1.10.11':
optional: true
'@types/whatwg-mimetype@3.0.2': {}
'@types/ws@8.18.1':
dependencies:
'@types/node': 25.6.0
@ -9835,6 +9855,8 @@ snapshots:
entities@6.0.1: {}
entities@7.0.1: {}
env-paths@2.2.1: {}
env-paths@3.0.0: {}
@ -10289,6 +10311,18 @@ snapshots:
hachure-fill@0.5.2: {}
happy-dom@20.9.0:
dependencies:
'@types/node': 25.6.0
'@types/whatwg-mimetype': 3.0.2
'@types/ws': 8.18.1
entities: 7.0.1
whatwg-mimetype: 3.0.0
ws: 8.20.0
transitivePeerDependencies:
- bufferutil
- utf-8-validate
has-flag@4.0.0: {}
has-property-descriptors@1.0.2:
@ -12677,7 +12711,7 @@ snapshots:
lightningcss: 1.32.0
yaml: 2.8.4
vitest@4.1.5(@types/node@25.6.0)(msw@2.14.3(@types/node@25.6.0)(typescript@5.9.3))(vite@7.3.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)):
vitest@4.1.5(@types/node@25.6.0)(happy-dom@20.9.0)(msw@2.14.3(@types/node@25.6.0)(typescript@5.9.3))(vite@7.3.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)):
dependencies:
'@vitest/expect': 4.1.5
'@vitest/mocker': 4.1.5(msw@2.14.3(@types/node@25.6.0)(typescript@5.9.3))(vite@7.3.2(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))
@ -12701,6 +12735,7 @@ snapshots:
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 25.6.0
happy-dom: 20.9.0
transitivePeerDependencies:
- msw
@ -12710,6 +12745,8 @@ snapshots:
web-streams-polyfill@3.3.3: {}
whatwg-mimetype@3.0.0: {}
which-module@2.0.1: {}
which@2.0.2:

View File

@ -137,6 +137,22 @@ describe('feature tip startup gate', () => {
).toEqual({ kind: 'skip' })
})
it('does not open the voice tip after Settings marked it seen and dictation is disabled', () => {
expect(
getFeatureTipsAppOpenDecision({
activeModal: 'none',
cliInstalled: true,
featureTipsSeenIds: ['voice-dictation'],
featureInteractions: {},
onboarding: existingUserOnboarding,
persistedUIReady: true,
promptedThisSession: false,
settings: makeSettings(false),
suppressedByOnboardingThisSession: false
})
).toEqual({ kind: 'skip' })
})
it('does not open the CLI tip after the CLI is installed', () => {
expect(
getFeatureTipsAppOpenDecision({

View File

@ -0,0 +1,225 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { DeveloperPermissionRequestResult } from '../../../../shared/developer-permissions-types'
import type { GlobalSettings } from '../../../../shared/types'
import { getDefaultVoiceSettings } from '../../../../shared/constants'
import { handleVoiceDictationToggle, VoicePane } from './VoicePane'
const { useAppStoreMock, useShortcutLabelMock } = vi.hoisted(() => ({
useAppStoreMock: vi.fn(),
useShortcutLabelMock: vi.fn()
}))
vi.mock('@/store', () => ({ useAppStore: useAppStoreMock }))
vi.mock('@/hooks/useShortcutLabel', () => ({
useShortcutLabel: useShortcutLabelMock
}))
vi.mock('sonner', () => ({
toast: {
error: vi.fn(),
message: vi.fn(),
success: vi.fn()
}
}))
const deniedMicrophoneResult: DeveloperPermissionRequestResult = {
id: 'microphone',
status: 'denied',
openedSystemSettings: false
}
function makeSettings(voiceEnabled: boolean): GlobalSettings {
return {
voice: {
...getDefaultVoiceSettings(),
enabled: voiceEnabled
}
} as GlobalSettings
}
function installWindowApi(
requestMicrophonePermission: () => Promise<DeveloperPermissionRequestResult>
) {
Object.assign(window, {
api: {
developerPermissions: {
request: vi.fn(requestMicrophonePermission)
},
speech: {
getCatalog: vi.fn(async () => []),
onDownloadProgress: vi.fn(() => () => {}),
downloadModel: vi.fn()
}
}
})
}
async function renderVoicePane(args: {
voiceEnabled: boolean
markFeatureTipsSeen: (ids: string[]) => void
updateSettings: (updates: Partial<GlobalSettings>) => void
requestMicrophonePermission?: () => Promise<DeveloperPermissionRequestResult>
recordFeatureInteraction?: (id: string) => void
}): Promise<{ button: HTMLButtonElement; root: Root; container: HTMLDivElement }> {
const refreshModelStates = vi.fn()
useAppStoreMock.mockImplementation((selector: (state: Record<string, unknown>) => unknown) =>
selector({
modelStates: [],
refreshModelStates,
markFeatureTipsSeen: args.markFeatureTipsSeen,
recordFeatureInteraction: args.recordFeatureInteraction ?? vi.fn()
})
)
useShortcutLabelMock.mockReturnValue('Ctrl+Shift+Y')
installWindowApi(args.requestMicrophonePermission ?? vi.fn(async () => deniedMicrophoneResult))
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
await act(async () => {
root.render(
<VoicePane settings={makeSettings(args.voiceEnabled)} updateSettings={args.updateSettings} />
)
})
const button = container.querySelector<HTMLButtonElement>('button[role="switch"]')
if (!button) {
throw new Error('Voice Dictation switch was not rendered')
}
return { button, root, container }
}
async function clickSwitch(button: HTMLButtonElement): Promise<void> {
await act(async () => {
button.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
await act(async () => {
await Promise.resolve()
})
}
describe('VoicePane dictation switch', () => {
afterEach(() => {
vi.unstubAllGlobals()
document.body.innerHTML = ''
})
beforeEach(() => {
useAppStoreMock.mockReset()
useShortcutLabelMock.mockReset()
})
it('clicking the switch marks the voice tip seen before disabling voice settings', async () => {
const calls: string[] = []
const requestMicrophonePermission = vi.fn()
const updateVoiceSettings = vi.fn((updates: { enabled?: boolean }) => {
calls.push(`settings:${String(updates.enabled)}`)
})
await handleVoiceDictationToggle({
voiceEnabled: true,
markFeatureTipsSeen: (ids) => calls.push(`seen:${ids.join(',')}`),
updateVoiceSettings,
requestMicrophonePermission
})
expect(calls).toEqual(['seen:voice-dictation', 'settings:false'])
expect(updateVoiceSettings).toHaveBeenCalledWith({ enabled: false })
expect(requestMicrophonePermission).not.toHaveBeenCalled()
})
it('clicking the switch marks the voice tip seen before the disable settings update', async () => {
const calls: string[] = []
const updateSettings = vi.fn((updates: Partial<GlobalSettings>) => {
calls.push(`settings:${String(updates.voice?.enabled)}`)
})
const { button, root } = await renderVoicePane({
voiceEnabled: true,
markFeatureTipsSeen: (ids) => calls.push(`seen:${ids.join(',')}`),
updateSettings,
requestMicrophonePermission: vi.fn(async () => deniedMicrophoneResult)
})
await clickSwitch(button)
root.unmount()
expect(calls).toEqual(['seen:voice-dictation', 'settings:false'])
expect(updateSettings).toHaveBeenCalledWith(
expect.objectContaining({
voice: expect.objectContaining({ enabled: false })
})
)
expect(window.api.developerPermissions.request).not.toHaveBeenCalled()
})
it('clicking the switch marks the voice tip seen before requesting microphone permission', async () => {
const calls: string[] = []
const updateSettings = vi.fn((updates: Partial<GlobalSettings>) => {
calls.push(`settings:${String(updates.voice?.enabled)}`)
})
const { button, root } = await renderVoicePane({
voiceEnabled: false,
markFeatureTipsSeen: (ids) => calls.push(`seen:${ids.join(',')}`),
updateSettings,
requestMicrophonePermission: async () => {
calls.push('permission-request')
return deniedMicrophoneResult
}
})
await clickSwitch(button)
root.unmount()
expect(calls).toEqual(['seen:voice-dictation', 'permission-request'])
expect(updateSettings).not.toHaveBeenCalled()
})
it('marks the voice tip seen before requesting microphone permission when enabling is denied', async () => {
const calls: string[] = []
const updateVoiceSettings = vi.fn((updates: { enabled?: boolean }) => {
calls.push(`settings:${String(updates.enabled)}`)
})
await handleVoiceDictationToggle({
voiceEnabled: false,
markFeatureTipsSeen: (ids) => calls.push(`seen:${ids.join(',')}`),
updateVoiceSettings,
requestMicrophonePermission: async () => {
calls.push('permission-request')
return deniedMicrophoneResult
},
setPermissionPending: (pending) => calls.push(`pending:${String(pending)}`),
notifyPermissionRequired: () => calls.push('permission-required')
})
expect(calls).toEqual([
'seen:voice-dictation',
'pending:true',
'permission-request',
'permission-required',
'pending:false'
])
expect(updateVoiceSettings).not.toHaveBeenCalled()
})
it('does not record voice feature interaction from the settings switch', async () => {
const recordFeatureInteraction = vi.fn()
const { button, root } = await renderVoicePane({
voiceEnabled: true,
markFeatureTipsSeen: vi.fn(),
updateSettings: vi.fn(),
recordFeatureInteraction
})
await clickSwitch(button)
root.unmount()
expect(recordFeatureInteraction).not.toHaveBeenCalled()
})
})

View File

@ -1,7 +1,13 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { GlobalSettings } from '../../../../shared/types'
import { getDefaultVoiceSettings } from '../../../../shared/constants'
import type { SpeechModelManifest, SpeechModelState } from '../../../../shared/speech-types'
import type { DeveloperPermissionRequestResult } from '../../../../shared/developer-permissions-types'
import type { FeatureTipId } from '../../../../shared/feature-tips'
import type {
SpeechModelManifest,
SpeechModelState,
VoiceSettings
} from '../../../../shared/speech-types'
import { Button } from '../ui/button'
import { Label } from '../ui/label'
import { Separator } from '../ui/separator'
@ -21,6 +27,65 @@ type VoicePaneProps = {
updateSettings: (updates: Partial<GlobalSettings>) => void
}
type VoiceDictationToggleOptions = {
voiceEnabled: boolean
markFeatureTipsSeen: (ids: FeatureTipId[]) => void
updateVoiceSettings: (updates: Partial<VoiceSettings>) => void
requestMicrophonePermission: () => Promise<DeveloperPermissionRequestResult>
setPermissionPending?: (pending: boolean) => void
isMounted?: () => boolean
notifyPermissionGranted?: () => void
notifyPermissionOpenedSystemSettings?: () => void
notifyPermissionRequired?: () => void
notifyPermissionRequestFailed?: () => void
}
export async function handleVoiceDictationToggle({
voiceEnabled,
markFeatureTipsSeen,
updateVoiceSettings,
requestMicrophonePermission,
setPermissionPending,
isMounted,
notifyPermissionGranted,
notifyPermissionOpenedSystemSettings,
notifyPermissionRequired,
notifyPermissionRequestFailed
}: VoiceDictationToggleOptions): Promise<void> {
// Why: changing the Voice Dictation switch proves the user discovered the
// feature; disabling it later should not make the discovery modal eligible.
markFeatureTipsSeen(['voice-dictation'])
if (voiceEnabled) {
updateVoiceSettings({ enabled: false })
return
}
setPermissionPending?.(true)
try {
// Why: enabling dictation is the point where users expect the macOS
// microphone prompt, not after their first attempted recording fails.
const result = await requestMicrophonePermission()
if (result.status === 'granted' || result.status === 'unsupported') {
updateVoiceSettings({ enabled: true })
}
if (result.status === 'granted') {
notifyPermissionGranted?.()
} else if (result.openedSystemSettings) {
notifyPermissionOpenedSystemSettings?.()
} else if (result.status !== 'unsupported') {
notifyPermissionRequired?.()
}
} catch {
notifyPermissionRequestFailed?.()
} finally {
if (isMounted?.() ?? true) {
setPermissionPending?.(false)
}
}
}
export function VoicePane({ settings, updateSettings }: VoicePaneProps): React.JSX.Element {
// Why: voice was made optional on GlobalSettings to keep older test fixtures
// and pre-voice profiles type-compatible. Persistence merges defaults at
@ -29,6 +94,7 @@ export function VoicePane({ settings, updateSettings }: VoicePaneProps): React.J
const voiceSettings = settings.voice ?? getDefaultVoiceSettings()
const modelStates = useAppStore((s) => s.modelStates)
const refreshModelStates = useAppStore((s) => s.refreshModelStates)
const markFeatureTipsSeen = useAppStore((s) => s.markFeatureTipsSeen)
const shortcutLabel = useShortcutLabel('voice.dictation')
const [catalog, setCatalog] = useState<SpeechModelManifest[]>([])
const [permissionPending, setPermissionPending] = useState(false)
@ -63,7 +129,7 @@ export function VoicePane({ settings, updateSettings }: VoicePaneProps): React.J
return cleanup
}, [refreshModelStates])
const updateVoiceSettings = (updates: Partial<GlobalSettings['voice']>): void => {
const updateVoiceSettings = (updates: Partial<VoiceSettings>): void => {
updateSettings({
voice: {
...voiceSettings,
@ -73,36 +139,24 @@ export function VoicePane({ settings, updateSettings }: VoicePaneProps): React.J
}
const toggleVoiceDictation = async (): Promise<void> => {
if (voiceSettings.enabled) {
updateVoiceSettings({ enabled: false })
return
}
setPermissionPending(true)
try {
// Why: enabling dictation is the point where users expect the macOS
// microphone prompt, not after their first attempted recording fails.
const result = await window.api.developerPermissions.request({ id: 'microphone' })
if (result.status === 'granted' || result.status === 'unsupported') {
updateVoiceSettings({ enabled: true })
}
if (result.status === 'granted') {
toast.success('Microphone permission granted')
} else if (result.openedSystemSettings) {
await handleVoiceDictationToggle({
voiceEnabled: voiceSettings.enabled,
markFeatureTipsSeen,
updateVoiceSettings,
requestMicrophonePermission: () =>
window.api.developerPermissions.request({ id: 'microphone' }),
setPermissionPending,
isMounted: () => mountedRef.current,
notifyPermissionGranted: () => toast.success('Microphone permission granted'),
notifyPermissionOpenedSystemSettings: () =>
toast.message(
'Opened macOS Privacy & Security. Enable dictation again after granting access.'
)
} else if (result.status !== 'unsupported') {
toast.message('Microphone permission is required before enabling voice dictation.')
}
} catch {
toast.error('Could not request microphone permission. Voice dictation was not enabled.')
} finally {
if (mountedRef.current) {
setPermissionPending(false)
}
}
),
notifyPermissionRequired: () =>
toast.message('Microphone permission is required before enabling voice dictation.'),
notifyPermissionRequestFailed: () =>
toast.error('Could not request microphone permission. Voice dictation was not enabled.')
})
}
const getModelState = (id: string): SpeechModelState | undefined =>