fix(mobile): pick a cloud dictation model from the phone (#31221)

* fix(mobile): pick a cloud dictation model from the phone

The dictation setup sheet and Voice settings showed OpenAI models as
read-only text, so a host that lists only cloud models (Windows orca
serve, managed SSH or WSL servers) left the phone with nothing to pick.
A ready cloud row is now selectable through the same speech.dictation.setup
modelId write as a downloaded model; a cloud row without a key says to add
it on the paired computer instead of "Set up on desktop".

Refs #31174
Reported-by: delorean2020 (@delorean2020)

* style(mobile): template literal on the re-indented delete label

* test(mobile): complete the setup fixtures for the tests typecheck ratchet (Codex)

* fix(mobile): short not-ready label that cannot squeeze the model name; button roles and labels
This commit is contained in:
Neil
2026-10-20 14:41:09 -07:00
committed by GitHub
parent 3be5039eed
commit 749aadcbc8
5 changed files with 218 additions and 51 deletions
@@ -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<MobileSpeechSetup | null>(null)
const [error, setError] = useState<string | null>(null)
@@ -131,7 +133,7 @@ export function MobileDictationSetupSheet({ visible, client, onClose, onReady }:
<View>
<Text style={styles.heading}>Set up voice dictation</Text>
<Text style={styles.subtitle}>
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.
</Text>
{setup === null ? (
@@ -146,8 +148,8 @@ export function MobileDictationSetupSheet({ visible, client, onClose, onReady }:
</View>
{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 (
<View key={model.id} style={styles.modelRow}>
@@ -167,29 +169,28 @@ export function MobileDictationSetupSheet({ visible, client, onClose, onReady }:
: ''}
</Text>
</View>
{model.provider === 'openai' ? (
<Text style={styles.modelStateText}>
{model.status === 'ready' ? 'API key set' : 'Set up on desktop'}
</Text>
) : model.status === 'ready' ? (
isSelected ? (
<View style={styles.selectedTag}>
<Check size={14} color={colors.statusGreen} strokeWidth={2.4} />
<Text style={styles.selectedText}>In use</Text>
</View>
) : (
<Pressable
style={({ pressed }) => [
styles.actionButton,
pressed && styles.actionPressed
]}
disabled={rowBusy}
onPress={() => void handleUseModel(model)}
>
<Text style={styles.actionText}>Use</Text>
</Pressable>
)
) : inFlight ? (
{action === 'needs-api-key' ? (
<Text style={styles.modelStateText}>{CLOUD_MODEL_NEEDS_KEY}</Text>
) : action === 'in-use' ? (
<View style={styles.selectedTag}>
<Check size={14} color={colors.statusGreen} strokeWidth={2.4} />
<Text style={styles.selectedText}>In use</Text>
</View>
) : action === 'use' ? (
<Pressable
style={({ pressed }) => [
styles.actionButton,
pressed && styles.actionPressed
]}
disabled={rowBusy}
accessibilityRole="button"
accessibilityLabel={`Use ${model.label}`}
accessibilityState={{ disabled: rowBusy }}
onPress={() => void handleUseModel(model)}
>
<Text style={styles.actionText}>Use</Text>
</Pressable>
) : action === 'downloading' ? (
<ActivityIndicator size="small" color={colors.textSecondary} />
) : (
<Pressable
@@ -198,6 +199,9 @@ export function MobileDictationSetupSheet({ visible, client, onClose, onReady }:
pressed && styles.actionPressed
]}
disabled={rowBusy}
accessibilityRole="button"
accessibilityLabel={`Download ${model.label}`}
accessibilityState={{ disabled: rowBusy }}
onPress={() => 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',
+30 -23
View File
@@ -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({
<View style={disabled ? styles.disabled : undefined} pointerEvents={disabled ? 'none' : 'auto'}>
{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({
</View>
<Text style={styles.modelMeta}>{modelMeta(model)}</Text>
</View>
{model.provider === 'openai' ? (
<Text style={styles.modelStateText}>
{model.status === 'ready' ? 'API key set' : 'Set up on desktop'}
</Text>
) : model.status === 'ready' ? (
{action === 'needs-api-key' ? (
<Text style={styles.modelStateText}>{CLOUD_MODEL_NEEDS_KEY}</Text>
) : action === 'in-use' || action === 'use' ? (
<View style={styles.readyActions}>
{isSelected ? (
{action === 'in-use' ? (
<View style={styles.selectedTag}>
<Check size={14} color={colors.statusGreen} strokeWidth={2.4} />
<Text style={styles.selectedText}>In use</Text>
@@ -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({
)}
</Pressable>
)}
<Pressable
style={({ pressed }) => [styles.iconButton, pressed && styles.actionPressed]}
disabled={anyBusy}
onPress={() => onDelete(model)}
accessibilityLabel={'Delete ' + model.label}
>
{deleteBusy ? (
<ActivityIndicator size="small" color={colors.statusRed} />
) : (
<Trash2 size={18} color={colors.statusRed} strokeWidth={2.2} />
)}
</Pressable>
{/* A cloud model has nothing on this computer to delete. */}
{model.provider === 'openai' ? null : (
<Pressable
style={({ pressed }) => [styles.iconButton, pressed && styles.actionPressed]}
disabled={anyBusy}
onPress={() => onDelete(model)}
accessibilityLabel={`Delete ${model.label}`}
>
{deleteBusy ? (
<ActivityIndicator size="small" color={colors.statusRed} />
) : (
<Trash2 size={18} color={colors.statusRed} strokeWidth={2.2} />
)}
</Pressable>
)}
</View>
) : inFlight ? (
) : action === 'downloading' ? (
<ActivityIndicator size="small" color={colors.textSecondary} />
) : (
<Pressable
style={({ pressed }) => [styles.iconButton, pressed && styles.actionPressed]}
disabled={anyBusy}
onPress={() => onDownload(model)}
accessibilityLabel={'Download ' + model.label}
accessibilityRole="button"
accessibilityLabel={`Download ${model.label}`}
accessibilityState={{ disabled: anyBusy }}
>
{downloadBusy ? (
<ActivityIndicator size="small" color={colors.textSecondary} />
@@ -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',
@@ -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)
})
})
@@ -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)
@@ -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'
}