mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
fix(mobile): explain dictation setup before asking for the microphone (#35248)
A mic tap opened capture, and with it the OS permission prompt, before anything asked the host whether it could transcribe; the setup sheet only appeared after the host refused dictation.start. The session screen now gates the start on the setup read it already does, opens the setup sheet when setup is missing, and hands the next tap back to the host and re-reads when the sheet closes. A host that never had a model selected now offers the mic. The sheet's switch uses MobileSwitch. Fixes #35203
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { ActivityIndicator, Pressable, Switch, Text, View } from 'react-native'
|
||||
import { ActivityIndicator, Pressable, Text, View } from 'react-native'
|
||||
import { Check, Download } from 'lucide-react-native'
|
||||
import { BottomDrawer } from './BottomDrawer'
|
||||
import { MobileSwitch } from './MobileSwitch'
|
||||
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
|
||||
import { themedStyleSheet } from '../theme/themed-styles'
|
||||
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
|
||||
@@ -145,7 +146,11 @@ export function MobileDictationSetupSheet({ visible, client, onClose, onReady }:
|
||||
<>
|
||||
<View style={styles.enableRow}>
|
||||
<Text style={styles.enableLabel}>Dictation enabled</Text>
|
||||
<Switch value={setup.enabled} onValueChange={(v) => void handleToggleEnabled(v)} />
|
||||
<MobileSwitch
|
||||
accessibilityLabel="Dictation enabled"
|
||||
value={setup.enabled === true}
|
||||
onValueChange={(v) => void handleToggleEnabled(v)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{setup.models.map((model) => {
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
deleteDictationModel,
|
||||
downloadDictationModel,
|
||||
fetchDictationSetup,
|
||||
isDictationReady,
|
||||
dictationNeedsSetup,
|
||||
isDictationSetupRequiredError,
|
||||
dictationFailureMessage,
|
||||
isModelInFlight,
|
||||
@@ -228,38 +228,20 @@ describe('state helpers', () => {
|
||||
expect(isModelInFlight(model({ status: 'ready' }))).toBe(false)
|
||||
})
|
||||
|
||||
it('isDictationReady requires enabled + selected + ready', () => {
|
||||
expect(
|
||||
isDictationReady({
|
||||
enabled: true,
|
||||
selectedModelId: 'm1',
|
||||
dictationMode: 'toggle',
|
||||
models: [model({ status: 'ready' })]
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
isDictationReady({
|
||||
enabled: false,
|
||||
selectedModelId: 'm1',
|
||||
dictationMode: 'toggle',
|
||||
models: [model({ status: 'ready' })]
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
isDictationReady({
|
||||
enabled: true,
|
||||
selectedModelId: 'm1',
|
||||
dictationMode: 'toggle',
|
||||
models: [model({ status: 'not-downloaded' })]
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
isDictationReady({
|
||||
enabled: true,
|
||||
selectedModelId: '',
|
||||
dictationMode: 'toggle',
|
||||
models: []
|
||||
})
|
||||
).toBe(false)
|
||||
it('dictationNeedsSetup mirrors the host start refusals', () => {
|
||||
const setup = (enabled: boolean, selectedModelId: string, models: MobileSpeechModel[]) => ({
|
||||
enabled,
|
||||
selectedModelId,
|
||||
dictationMode: 'toggle' as const,
|
||||
models
|
||||
})
|
||||
expect(dictationNeedsSetup(setup(true, 'm1', [model({ status: 'ready' })]))).toBe(false)
|
||||
expect(dictationNeedsSetup(setup(false, 'm1', [model({ status: 'ready' })]))).toBe(true)
|
||||
expect(dictationNeedsSetup(setup(true, 'm1', [model({ status: 'not-downloaded' })]))).toBe(true)
|
||||
expect(dictationNeedsSetup(setup(true, '', []))).toBe(true)
|
||||
// A user model is absent from the host catalog; only the host can judge it.
|
||||
expect(dictationNeedsSetup(setup(true, 'custom', [model({ status: 'ready' })]))).toBe(false)
|
||||
// Older hosts may omit enabled and the selection.
|
||||
expect(dictationNeedsSetup({ dictationMode: 'toggle', models: [] })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -118,12 +118,18 @@ export function isModelInFlight(model: MobileSpeechModel): boolean {
|
||||
return model.status === 'downloading' || model.status === 'extracting'
|
||||
}
|
||||
|
||||
export function isDictationReady(setup: MobileSpeechSetup): boolean {
|
||||
if (!setup.enabled || !setup.selectedModelId) {
|
||||
return false
|
||||
// Hide the mic only when dictation was turned off with a model chosen; with none chosen a tap explains setup.
|
||||
export function dictationMicHidden(setup: MobileSpeechSetup): boolean {
|
||||
return setup.enabled === false && setup.selectedModelId !== ''
|
||||
}
|
||||
|
||||
// Mirror the host's start refusals on what it reported; absent fields or an uncatalogued (user) model stay the host's call.
|
||||
export function dictationNeedsSetup(setup: MobileSpeechSetup): boolean {
|
||||
if (setup.enabled === false || setup.selectedModelId === '') {
|
||||
return true
|
||||
}
|
||||
const selected = setup.models.find((m) => m.id === setup.selectedModelId)
|
||||
return selected?.status === 'ready'
|
||||
const status = setup.models.find((m) => m.id === setup.selectedModelId)?.status
|
||||
return status !== undefined && status !== 'ready'
|
||||
}
|
||||
|
||||
// Keep copy short and host-neutral: the paired computer may be a headless server.
|
||||
|
||||
@@ -58,7 +58,7 @@ export function MobileSessionSheets({ controller }: { controller: MobileSessionC
|
||||
setDeleteKeyTarget,
|
||||
terminalModes,
|
||||
showDictationSetup,
|
||||
setShowDictationSetup,
|
||||
closeDictationSetup,
|
||||
browserScreencastSupported,
|
||||
quickCommandsSupported,
|
||||
quickCommandAgentPromptMaxLength,
|
||||
@@ -369,8 +369,8 @@ export function MobileSessionSheets({ controller }: { controller: MobileSessionC
|
||||
<MobileDictationSetupSheet
|
||||
visible={showDictationSetup}
|
||||
client={client}
|
||||
onClose={() => setShowDictationSetup(false)}
|
||||
onReady={() => setShowDictationSetup(false)}
|
||||
onClose={closeDictationSetup}
|
||||
onReady={closeDictationSetup}
|
||||
/>
|
||||
<ActionSheetModal
|
||||
visible={deleteKeyTarget != null}
|
||||
|
||||
@@ -331,6 +331,18 @@ describe('the mic follows the host dictation mode', () => {
|
||||
expect(env.captureEnabled).toBe(false)
|
||||
})
|
||||
|
||||
it('offers the mic on a host that never had a speech model selected', async () => {
|
||||
env.connected = true
|
||||
env.deferReply = true
|
||||
env.slowReply = Promise.resolve({
|
||||
id: 'first-run',
|
||||
ok: true,
|
||||
result: { enabled: false, selectedModelId: '', dictationMode: 'toggle', models: [] }
|
||||
})
|
||||
await render()
|
||||
expect(micBinding()).toBe('toggle')
|
||||
})
|
||||
|
||||
it('keeps legacy controls when the host omits the enabled field', async () => {
|
||||
env.connected = true
|
||||
env.deferReply = true
|
||||
|
||||
@@ -9,6 +9,7 @@ const seen = vi.hoisted(() => ({
|
||||
toasts: new Array<string>(),
|
||||
errorHaptics: 0,
|
||||
setupSheetOpens: 0,
|
||||
micPermissionRequests: 0,
|
||||
clipboard: new Array<string>(),
|
||||
inputs: new Array<string>(),
|
||||
audioListeners: new Map<string, (event: { data: string }) => void>()
|
||||
@@ -24,7 +25,10 @@ vi.mock('@orca/expo-two-way-audio', () => ({
|
||||
return { remove: () => seen.audioListeners.delete(name) }
|
||||
},
|
||||
initialize: () => Promise.resolve(true),
|
||||
requestMicrophonePermissionsAsync: () => Promise.resolve({ granted: true }),
|
||||
requestMicrophonePermissionsAsync: () => {
|
||||
seen.micPermissionRequests += 1
|
||||
return Promise.resolve({ granted: true })
|
||||
},
|
||||
tearDown: () => {},
|
||||
toggleRecording: () => true
|
||||
}))
|
||||
@@ -192,6 +196,7 @@ beforeEach(() => {
|
||||
seen.toasts.length = 0
|
||||
seen.errorHaptics = 0
|
||||
seen.setupSheetOpens = 0
|
||||
seen.micPermissionRequests = 0
|
||||
seen.clipboard.length = 0
|
||||
seen.inputs.length = 0
|
||||
seen.audioListeners.clear()
|
||||
@@ -215,6 +220,35 @@ describe('a mic tap the desktop refuses because dictation is not set up', () =>
|
||||
}
|
||||
})
|
||||
|
||||
describe('a mic tap after the host reported dictation is not set up', () => {
|
||||
it('opens setup without asking for the microphone, then lets the host judge the tap after the sheet closes', async () => {
|
||||
const rpc = createFakeRpcClient()
|
||||
const mounted = mount(rpc, { liveInput: false, sendLive: async () => true })
|
||||
await act(async () => {
|
||||
const read = mounted.current().refreshDictationMode()
|
||||
for (const request of rpc.requests.splice(0)) {
|
||||
request.resolve({
|
||||
id: 'desktop',
|
||||
ok: true,
|
||||
result: { enabled: false, selectedModelId: '', dictationMode: 'toggle', models: [] }
|
||||
})
|
||||
}
|
||||
await read
|
||||
})
|
||||
act(() => mounted.current().handleDictationToggle())
|
||||
expect(seen.setupSheetOpens).toBe(1)
|
||||
expect(seen.micPermissionRequests).toBe(0)
|
||||
expect(rpc.requests).toEqual([])
|
||||
|
||||
// Closing the sheet hands the next tap to the host until a fresh read lands.
|
||||
act(() => mounted.current().closeDictationSetup())
|
||||
act(() => mounted.current().handleDictationToggle())
|
||||
await settle(rpc, '')
|
||||
expect(seen.setupSheetOpens).toBe(1)
|
||||
expect(seen.micPermissionRequests).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('a mic tap the desktop refuses for a reason setup cannot fix', () => {
|
||||
it('toasts the host refusal in plain words with the error haptic', async () => {
|
||||
await tapAndRefuseStart('dictation_already_active')
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
} from '../terminal/terminal-live-dictation-routing'
|
||||
import {
|
||||
dictationFailureMessage,
|
||||
dictationMicHidden,
|
||||
dictationNeedsSetup,
|
||||
fetchDictationSetup,
|
||||
isDictationSetupRequiredError
|
||||
} from '../dictation/mobile-dictation-setup'
|
||||
@@ -179,7 +181,15 @@ export function useMobileSessionNativeChatDictation(
|
||||
}
|
||||
})
|
||||
|
||||
// Only a host setup read sets this; while false, the host's start refusal decides (older hosts, first tap before the read).
|
||||
const dictationNeedsSetupRef = useRef(false)
|
||||
|
||||
const startDictation = useCallback(() => {
|
||||
if (dictationNeedsSetupRef.current) {
|
||||
// Explain setup before the OS microphone prompt: mic access cannot help until the host can transcribe.
|
||||
setShowDictationSetup(true)
|
||||
return
|
||||
}
|
||||
chatDictationDestination.current = captureMobileNativeChatTextDestination()
|
||||
const routeContext = activeHandle
|
||||
? { handle: activeHandle, liveInputEnabled: liveInputTerminalHandles.has(activeHandle) }
|
||||
@@ -191,7 +201,13 @@ export function useMobileSessionNativeChatDictation(
|
||||
}
|
||||
reportDictationFailure(err)
|
||||
})
|
||||
}, [activeHandle, dictation, liveInputTerminalHandles, reportDictationFailure])
|
||||
}, [
|
||||
activeHandle,
|
||||
dictation,
|
||||
liveInputTerminalHandles,
|
||||
reportDictationFailure,
|
||||
setShowDictationSetup
|
||||
])
|
||||
|
||||
const cancelDictation = useCallback(() => {
|
||||
dictationRouteContextRef.current = null
|
||||
@@ -233,13 +249,21 @@ export function useMobileSessionNativeChatDictation(
|
||||
const setup = await fetchDictationSetup(client)
|
||||
if (read === dictationSettingsReadRef.current) {
|
||||
setDictationMode(setup.dictationMode)
|
||||
setDictationEnabled(setup.enabled !== false)
|
||||
setDictationEnabled(!dictationMicHidden(setup))
|
||||
dictationNeedsSetupRef.current = dictationNeedsSetup(setup)
|
||||
}
|
||||
} catch {
|
||||
// Keep the last settings when an older host cannot report them.
|
||||
}
|
||||
}, [client, connState, setDictationEnabled, setDictationMode])
|
||||
|
||||
// The sheet may have changed host setup: let the host judge taps until the fresh read lands.
|
||||
const closeDictationSetup = useCallback(() => {
|
||||
setShowDictationSetup(false)
|
||||
dictationNeedsSetupRef.current = false
|
||||
void refreshDictationMode()
|
||||
}, [refreshDictationMode, setShowDictationSetup])
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
void refreshDictationMode()
|
||||
@@ -280,6 +304,7 @@ export function useMobileSessionNativeChatDictation(
|
||||
handleDictationToggle,
|
||||
handleDictationPressIn,
|
||||
handleDictationPressOut,
|
||||
closeDictationSetup,
|
||||
refreshDictationMode
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user