mirror of
https://github.com/stablyai/orca.git
synced 2026-11-05 19:47:27 +00:00
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:
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user