diff --git a/mobile/src/components/MobileDictationSetupSheet.tsx b/mobile/src/components/MobileDictationSetupSheet.tsx index f590b10082f..23b12b7be1e 100644 --- a/mobile/src/components/MobileDictationSetupSheet.tsx +++ b/mobile/src/components/MobileDictationSetupSheet.tsx @@ -7,6 +7,8 @@ import type { RpcClient } from '../transport/rpc-client' import { triggerError, triggerSuccess } from '../platform/haptics' import { useDictationSetupPoller } from '../dictation/use-dictation-setup-poller' import { + CLOUD_MODEL_NEEDS_KEY, + dictationModelRowAction, downloadDictationModel, fetchDictationSetup, isModelInFlight, @@ -32,8 +34,8 @@ function formatSize(bytes: number | null | undefined): string { return `${Math.round(bytes / 1_000_000)} MB` } -// Lets the user enable dictation and download a speech model on the paired -// desktop, from the phone. Polls while a download is in flight. +// Lets the user enable dictation and download or pick a speech model on the paired +// computer, from the phone. Polls while a download is in flight. export function MobileDictationSetupSheet({ visible, client, onClose, onReady }: Props) { const [setup, setSetup] = useState(null) const [error, setError] = useState(null) @@ -131,7 +133,7 @@ export function MobileDictationSetupSheet({ visible, client, onClose, onReady }: Set up voice dictation - Download a model and enable dictation on your desktop — all from here. + Download or pick a model and enable dictation on the paired computer — all from here. {setup === null ? ( @@ -146,8 +148,8 @@ export function MobileDictationSetupSheet({ visible, client, onClose, onReady }: {setup.models.map((model) => { - const isSelected = model.id === setup.selectedModelId const inFlight = isModelInFlight(model) + const action = dictationModelRowAction(model, setup.selectedModelId) const rowBusy = busy === model.id return ( @@ -167,29 +169,28 @@ export function MobileDictationSetupSheet({ visible, client, onClose, onReady }: : ''} - {model.provider === 'openai' ? ( - - {model.status === 'ready' ? 'API key set' : 'Set up on desktop'} - - ) : model.status === 'ready' ? ( - isSelected ? ( - - - In use - - ) : ( - [ - styles.actionButton, - pressed && styles.actionPressed - ]} - disabled={rowBusy} - onPress={() => void handleUseModel(model)} - > - Use - - ) - ) : inFlight ? ( + {action === 'needs-api-key' ? ( + {CLOUD_MODEL_NEEDS_KEY} + ) : action === 'in-use' ? ( + + + In use + + ) : action === 'use' ? ( + [ + styles.actionButton, + pressed && styles.actionPressed + ]} + disabled={rowBusy} + accessibilityRole="button" + accessibilityLabel={`Use ${model.label}`} + accessibilityState={{ disabled: rowBusy }} + onPress={() => void handleUseModel(model)} + > + Use + + ) : action === 'downloading' ? ( ) : ( void handleDownload(model)} > {rowBusy ? ( @@ -260,7 +264,7 @@ const styles = StyleSheet.create({ fontWeight: '700' }, modelMeta: { color: colors.textMuted, fontSize: typography.metaSize, marginTop: 2 }, - modelStateText: { color: colors.textMuted, fontSize: typography.metaSize }, + modelStateText: { color: colors.textMuted, fontSize: typography.metaSize, flexShrink: 1 }, actionButton: { flexDirection: 'row', alignItems: 'center', diff --git a/mobile/src/components/VoiceModelList.tsx b/mobile/src/components/VoiceModelList.tsx index a776945a075..cc1cf3f3f94 100644 --- a/mobile/src/components/VoiceModelList.tsx +++ b/mobile/src/components/VoiceModelList.tsx @@ -2,6 +2,8 @@ import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-nati import { Check, Download, Trash2 } from 'lucide-react-native' import { colors, radii, spacing, typography } from '../theme/mobile-theme' import { + CLOUD_MODEL_NEEDS_KEY, + dictationModelRowAction, isModelInFlight, type MobileSpeechModel, type MobileSpeechSetup @@ -52,8 +54,7 @@ export function VoiceModelList({ {setup.models.map((model, idx) => { const anyBusy = busyAction !== null - const isSelected = model.id === setup.selectedModelId - const inFlight = isModelInFlight(model) + const action = dictationModelRowAction(model, setup.selectedModelId) const rowBusy = busyAction?.modelId === model.id const selectBusy = rowBusy && busyAction?.type === 'select' const downloadBusy = rowBusy && busyAction?.type === 'download' @@ -71,13 +72,11 @@ export function VoiceModelList({ {modelMeta(model)} - {model.provider === 'openai' ? ( - - {model.status === 'ready' ? 'API key set' : 'Set up on desktop'} - - ) : model.status === 'ready' ? ( + {action === 'needs-api-key' ? ( + {CLOUD_MODEL_NEEDS_KEY} + ) : action === 'in-use' || action === 'use' ? ( - {isSelected ? ( + {action === 'in-use' ? ( In use @@ -89,6 +88,9 @@ export function VoiceModelList({ pressed && styles.actionPressed ]} disabled={anyBusy} + accessibilityRole="button" + accessibilityLabel={`Use ${model.label}`} + accessibilityState={{ disabled: anyBusy }} onPress={() => onUseModel(model)} > {selectBusy ? ( @@ -98,27 +100,32 @@ export function VoiceModelList({ )} )} - [styles.iconButton, pressed && styles.actionPressed]} - disabled={anyBusy} - onPress={() => onDelete(model)} - accessibilityLabel={'Delete ' + model.label} - > - {deleteBusy ? ( - - ) : ( - - )} - + {/* A cloud model has nothing on this computer to delete. */} + {model.provider === 'openai' ? null : ( + [styles.iconButton, pressed && styles.actionPressed]} + disabled={anyBusy} + onPress={() => onDelete(model)} + accessibilityLabel={`Delete ${model.label}`} + > + {deleteBusy ? ( + + ) : ( + + )} + + )} - ) : inFlight ? ( + ) : action === 'downloading' ? ( ) : ( [styles.iconButton, pressed && styles.actionPressed]} disabled={anyBusy} onPress={() => onDownload(model)} - accessibilityLabel={'Download ' + model.label} + accessibilityRole="button" + accessibilityLabel={`Download ${model.label}`} + accessibilityState={{ disabled: anyBusy }} > {downloadBusy ? ( @@ -155,7 +162,7 @@ const styles = StyleSheet.create({ }, recommended: { color: colors.statusGreen, fontSize: 10, fontWeight: '700' }, modelMeta: { color: colors.textMuted, fontSize: typography.metaSize, marginTop: 2 }, - modelStateText: { color: colors.textMuted, fontSize: typography.metaSize }, + modelStateText: { color: colors.textMuted, fontSize: typography.metaSize, flexShrink: 1 }, actionButton: { flexDirection: 'row', alignItems: 'center', diff --git a/mobile/src/components/voice-model-list-cloud-models.test.tsx b/mobile/src/components/voice-model-list-cloud-models.test.tsx new file mode 100644 index 00000000000..e33213ad7b0 --- /dev/null +++ b/mobile/src/components/voice-model-list-cloud-models.test.tsx @@ -0,0 +1,104 @@ +import { createElement, type ReactNode } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' + +vi.mock('react-native', async () => { + const React = await import('react') + const host = + (name: string) => + ({ children, ...props }: { children?: ReactNode }) => + React.createElement(name, props, children) + return { + ActivityIndicator: host('ActivityIndicator'), + Pressable: host('Pressable'), + Text: host('Text'), + View: host('View'), + StyleSheet: { create: (styles: unknown) => styles } + } +}) +vi.mock('lucide-react-native', () => ({ + Check: () => null, + Download: () => null, + Trash2: () => null +})) + +import { VoiceModelList } from './VoiceModelList' +import { + CLOUD_MODEL_NEEDS_KEY, + type MobileSpeechModel, + type MobileSpeechSetup +} from '../dictation/mobile-dictation-setup' + +function cloud(id: string, status: MobileSpeechModel['status']): MobileSpeechModel { + return { id, label: id, provider: 'openai', sizeBytes: null, status, progress: null } +} + +// #31174: a cloud-only host (Windows or a managed SSH server) lists only OpenAI rows. +describe('VoiceModelList cloud models', () => { + let renderer: ReactTestRenderer | null = null + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + function render(setup: MobileSpeechSetup, onUseModel = vi.fn()) { + act(() => { + renderer = create( + createElement(VoiceModelList, { + setup, + disabled: false, + busyAction: null, + onUseModel, + onDownload: vi.fn(), + onDelete: vi.fn() + }) + ) + }) + const root = renderer!.root + const texts = root.findAll((n) => String(n.type) === 'Text').map((n) => n.props.children) + const pressables = root.findAll((n) => String(n.type) === 'Pressable') + return { texts, pressables, onUseModel } + } + + it('offers a ready cloud model to pick, with nothing to delete', () => { + const ready = cloud('openai-gpt-4o-mini-transcribe', 'ready') + const { texts, pressables, onUseModel } = render({ + enabled: true, + dictationMode: 'toggle', + selectedModelId: '', + models: [ready] + }) + expect(texts).toContain('Use') + expect(pressables).toHaveLength(1) + expect(pressables[0].props).toMatchObject({ + accessibilityRole: 'button', + accessibilityLabel: `Use ${ready.label}`, + accessibilityState: { disabled: false } + }) + act(() => pressables[0].props.onPress()) + expect(onUseModel).toHaveBeenCalledWith(ready) + }) + + it('marks the cloud model in use once picked', () => { + const ready = cloud('openai-gpt-4o-mini-transcribe', 'ready') + const { texts, pressables } = render({ + enabled: true, + dictationMode: 'toggle', + selectedModelId: ready.id, + models: [ready] + }) + expect(texts).toContain('In use') + expect(pressables).toHaveLength(0) + }) + + it('says where to add the key when the cloud model is not ready', () => { + const { texts, pressables } = render({ + enabled: true, + dictationMode: 'toggle', + selectedModelId: '', + models: [cloud('openai-gpt-4o-transcribe', 'not-downloaded')] + }) + expect(texts).toContain(CLOUD_MODEL_NEEDS_KEY) + expect(pressables).toHaveLength(0) + }) +}) diff --git a/mobile/src/dictation/mobile-dictation-setup.test.ts b/mobile/src/dictation/mobile-dictation-setup.test.ts index a732593f14f..68f8230aaf4 100644 --- a/mobile/src/dictation/mobile-dictation-setup.test.ts +++ b/mobile/src/dictation/mobile-dictation-setup.test.ts @@ -3,6 +3,7 @@ import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-clien import type { RpcClient } from '../transport/rpc-client' import type { RpcFailure, RpcResponse, RpcSuccess } from '../transport/types' import { + dictationModelRowAction, deleteDictationModel, downloadDictationModel, fetchDictationSetup, @@ -189,6 +190,31 @@ describe('rpc wrappers', () => { }) }) +describe('dictationModelRowAction', () => { + // #31174: a cloud-only host (Windows or a managed SSH server) lists only OpenAI rows. + it('lets a ready cloud model be picked like a downloaded one', () => { + const cloud = model({ + id: 'openai-gpt-4o-mini-transcribe', + provider: 'openai', + status: 'ready' + }) + expect(dictationModelRowAction(cloud, '')).toBe('use') + expect(dictationModelRowAction(cloud, cloud.id)).toBe('in-use') + }) + + it('asks for a key on the host when a cloud model is not ready', () => { + const cloud = model({ provider: 'openai', status: 'not-downloaded' }) + expect(dictationModelRowAction(cloud, '')).toBe('needs-api-key') + }) + + it('keeps on-device rows downloadable until they are ready', () => { + expect(dictationModelRowAction(model({ status: 'not-downloaded' }), '')).toBe('download') + expect(dictationModelRowAction(model({ status: 'error' }), '')).toBe('download') + expect(dictationModelRowAction(model({ status: 'downloading' }), '')).toBe('downloading') + expect(dictationModelRowAction(model({ status: 'ready' }), 'm1')).toBe('in-use') + }) +}) + describe('state helpers', () => { it('isModelInFlight covers downloading + extracting', () => { expect(isModelInFlight(model({ status: 'downloading' }))).toBe(true) diff --git a/mobile/src/dictation/mobile-dictation-setup.ts b/mobile/src/dictation/mobile-dictation-setup.ts index 6f3002bea8c..cb63ab38aee 100644 --- a/mobile/src/dictation/mobile-dictation-setup.ts +++ b/mobile/src/dictation/mobile-dictation-setup.ts @@ -109,3 +109,29 @@ export function isDictationReady(setup: MobileSpeechSetup): boolean { const selected = setup.models.find((m) => m.id === setup.selectedModelId) return selected?.status === 'ready' } + +// Why no "desktop": the paired computer may be a headless server. Short so the name keeps its room. +export const CLOUD_MODEL_NEEDS_KEY = 'Needs an OpenAI key' + +/** What a model row in the setup sheet and Voice settings offers. */ +export type DictationModelRowAction = + | 'in-use' + | 'use' + | 'download' + | 'downloading' + | 'needs-api-key' + +// Why cloud rows are selectable: a host with no on-device recognizer (Windows or a managed SSH +// server) lists only cloud models, so a read-only row left the sheet with nothing to pick. +export function dictationModelRowAction( + model: MobileSpeechModel, + selectedModelId: string | undefined +): DictationModelRowAction { + if (model.status === 'ready') { + return model.id === selectedModelId ? 'in-use' : 'use' + } + if (model.provider === 'openai') { + return 'needs-api-key' + } + return isModelInFlight(model) ? 'downloading' : 'download' +}