diff --git a/mobile/src/components/MobileDictationSetupSheet.tsx b/mobile/src/components/MobileDictationSetupSheet.tsx
index 89085c68589..3de1b356516 100644
--- a/mobile/src/components/MobileDictationSetupSheet.tsx
+++ b/mobile/src/components/MobileDictationSetupSheet.tsx
@@ -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 }:
<>
Dictation enabled
- void handleToggleEnabled(v)} />
+ void handleToggleEnabled(v)}
+ />
{setup.models.map((model) => {
diff --git a/mobile/src/dictation/mobile-dictation-setup.test.ts b/mobile/src/dictation/mobile-dictation-setup.test.ts
index 921d783d471..750130c8ed0 100644
--- a/mobile/src/dictation/mobile-dictation-setup.test.ts
+++ b/mobile/src/dictation/mobile-dictation-setup.test.ts
@@ -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)
})
})
diff --git a/mobile/src/dictation/mobile-dictation-setup.ts b/mobile/src/dictation/mobile-dictation-setup.ts
index 9796d3bb7d9..316b19d8581 100644
--- a/mobile/src/dictation/mobile-dictation-setup.ts
+++ b/mobile/src/dictation/mobile-dictation-setup.ts
@@ -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.
diff --git a/mobile/src/session/MobileSessionSheets.tsx b/mobile/src/session/MobileSessionSheets.tsx
index 3d3de0104a0..75f8c6b3ce7 100644
--- a/mobile/src/session/MobileSessionSheets.tsx
+++ b/mobile/src/session/MobileSessionSheets.tsx
@@ -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
setShowDictationSetup(false)}
- onReady={() => setShowDictationSetup(false)}
+ onClose={closeDictationSetup}
+ onReady={closeDictationSetup}
/>
{
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
diff --git a/mobile/src/session/mobile-dictation-session-outcomes.test.tsx b/mobile/src/session/mobile-dictation-session-outcomes.test.tsx
index 7a6819f4c86..9fcd6163972 100644
--- a/mobile/src/session/mobile-dictation-session-outcomes.test.tsx
+++ b/mobile/src/session/mobile-dictation-session-outcomes.test.tsx
@@ -9,6 +9,7 @@ const seen = vi.hoisted(() => ({
toasts: new Array(),
errorHaptics: 0,
setupSheetOpens: 0,
+ micPermissionRequests: 0,
clipboard: new Array(),
inputs: new Array(),
audioListeners: new Map 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')
diff --git a/mobile/src/session/use-mobile-session-native-chat-dictation.ts b/mobile/src/session/use-mobile-session-native-chat-dictation.ts
index c87c21d836a..48e53a7c450 100644
--- a/mobile/src/session/use-mobile-session-native-chat-dictation.ts
+++ b/mobile/src/session/use-mobile-session-native-chat-dictation.ts
@@ -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
}
}