diff --git a/mobile/src/session/mobile-dictation-refused-start-routing.test.tsx b/mobile/src/session/mobile-dictation-refused-start-routing.test.tsx new file mode 100644 index 00000000000..b073eda047d --- /dev/null +++ b/mobile/src/session/mobile-dictation-refused-start-routing.test.tsx @@ -0,0 +1,186 @@ +/** + * A desktop that refuses `speech.dictation.start`, reaching the composer the user actually tapped. + * + * The sibling suites mount their own copy of the composer's handler; this one mounts the real + * `useMobileSessionNativeChatDictation` over the real `useMobileDictation`, because the defect was + * in the wiring between them rather than in either half. + */ +import { createElement, useRef } from 'react' +import { act, create } from 'react-test-renderer' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createFakeRpcClient, type FakeRpcClient } from '../mobile-web-shell/bridge-host-test-fakes' + +const seen = vi.hoisted(() => ({ + toasts: new Array(), + errorHaptics: 0 +})) + +vi.mock('react-native', () => ({ + AppState: { currentState: 'active', addEventListener: () => ({ remove: () => {} }) }, + Platform: { OS: 'ios' } +})) +vi.mock('@orca/expo-two-way-audio', () => ({ + addExpoTwoWayAudioEventListener: () => ({ remove: () => {} }), + initialize: () => Promise.resolve(true), + requestMicrophonePermissionsAsync: () => Promise.resolve({ granted: true }), + tearDown: () => {}, + toggleRecording: () => true +})) +vi.mock('expo-keep-awake', () => ({ + activateKeepAwakeAsync: () => Promise.resolve(), + deactivateKeepAwake: () => Promise.resolve() +})) +vi.mock('expo-router', () => ({ useFocusEffect: () => {} })) +vi.mock('../platform/haptics', () => ({ + triggerError: () => { + seen.errorHaptics += 1 + } +})) + +// The native-chat surface this hook composes with. None of it is on the mic's path, and reaching +// the real modules would pull the Expo runtime in behind them. +vi.mock('./use-mobile-native-chat-send-error', () => ({ + useMobileNativeChatSendError: () => ({ + message: null, + show: () => {}, + clear: () => {}, + bannerMountedRef: { current: false } + }) +})) +vi.mock('./use-mobile-native-chat-readability', () => ({ + useMobileNativeChatReadability: () => false +})) +vi.mock('./use-mobile-native-chat-input-lease', () => ({ + useMobileNativeChatInputLease: () => ({ + ready: true, + readyRef: { current: true }, + lockReason: null, + markReady: () => {}, + clear: () => {} + }) +})) +vi.mock('./use-mobile-native-chat-controller', () => ({ + useMobileNativeChatController: () => ({ + toggleTabChatView: () => {}, + showNativeChat: false, + showNativeChatRef: { current: false }, + setChatComposerText: () => {} + }) +})) +vi.mock('./use-mobile-send-completion-generation', () => ({ + useMobileSendCompletionGeneration: () => () => 0 +})) + +import { useMobileSessionNativeChatDictation } from './use-mobile-session-native-chat-dictation' + +type Mounted = { + readonly tap: () => void + readonly setupSheetOpens: number[] +} + +function mount(client: FakeRpcClient): Mounted { + const setupSheetOpens: number[] = [] + const held: { start: (() => void) | null } = { start: null } + + function Probe(): null { + const dictationRouteContextRef = useRef(null) + const activeHandleRef = useRef('t1') + const deviceTokenRef = useRef('dev') + const diffCommentsRef = useRef([]) + const scope = { + hostId: 'h1', + worktreeId: 'w1', + client, + connState: 'connected', + agentSessionPromptCancelSupported: true, + setInput: () => {}, + liveInputTerminalHandles: new Set(), + activeHandle: 't1', + activeSessionTabId: 'tab1', + activeSessionTab: { id: 'tab1', type: 'terminal', terminal: 't1' }, + diffComments: [], + diffCommentsRef, + setShowDictationSetup: (next: boolean) => { + if (next) { + setupSheetOpens.push(setupSheetOpens.length + 1) + } + }, + setDictationMode: () => {}, + deviceTokenRef, + dictationRouteContextRef, + activeHandleRef, + flushPendingLiveInputBeforeExternalSend: async () => true, + canSend: true, + liveInputEnabled: false, + showToast: (message: string) => { + seen.toasts.push(message) + }, + resetLiveInputFocus: () => {} + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the scope carries every member this hook destructures; the rest of the session model is unreachable from the mic path. + const model = useMobileSessionNativeChatDictation(scope as never, async () => true) + held.start = model.startDictation + return null + } + + act(() => { + create(createElement(Probe)) + }) + return { + tap: () => held.start?.(), + setupSheetOpens + } +} + +/** Answers every forwarded request, refusing `speech.dictation.start` with the desktop's own code. */ +async function tapAndRefuseStart( + mounted: Mounted, + rpc: FakeRpcClient, + code: string +): Promise { + await act(async () => { + mounted.tap() + for (let round = 0; round < 8; round += 1) { + for (const request of rpc.requests.splice(0)) { + request.resolve( + request.method === 'speech.dictation.start' + ? { id: 'desktop', ok: false, error: { code: 'refused', message: code } } + : { id: 'desktop', ok: true, result: {} } + ) + } + await Promise.resolve() + await Promise.resolve() + } + }) +} + +beforeEach(() => { + seen.toasts.length = 0 + seen.errorHaptics = 0 +}) + +describe('a mic tap the desktop refuses because dictation is not set up', () => { + // The two codes a desktop with no usable model answers with: `voice_dictation_disabled` from a + // profile that never enabled voice, `voice_model_not_ready:` from one whose model dir is empty. + for (const code of ['voice_dictation_disabled', 'voice_model_not_ready:not-downloaded']) { + it(`opens the dictation setup sheet for ${code}`, async () => { + const rpc = createFakeRpcClient() + const mounted = mount(rpc) + await tapAndRefuseStart(mounted, rpc, code) + expect(mounted.setupSheetOpens).toHaveLength(1) + // And the desktop's internal code is never shown as product copy. + expect(seen.toasts).toEqual([]) + }) + } +}) + +describe('a mic tap the desktop refuses for a reason setup cannot fix', () => { + it('still toasts the host message with the error haptic', async () => { + const rpc = createFakeRpcClient() + const mounted = mount(rpc) + await tapAndRefuseStart(mounted, rpc, 'dictation_already_active') + expect(mounted.setupSheetOpens).toEqual([]) + expect(seen.toasts).toEqual(['dictation_already_active']) + expect(seen.errorHaptics).toBe(1) + }) +}) diff --git a/mobile/src/session/mobile-session-route-parity.test.ts b/mobile/src/session/mobile-session-route-parity.test.ts index 9b43103bbb0..13e9ca443ba 100644 --- a/mobile/src/session/mobile-session-route-parity.test.ts +++ b/mobile/src/session/mobile-session-route-parity.test.ts @@ -69,10 +69,13 @@ const HOST_COMPONENT_NAMES = new Set([ // the attachment probe. The screen's other four clipboard sites (the terminal's paste, the sheets, // the quick-command row, the diff-review send) sit outside the walk from `MobileSessionRouteScreen` // and so do not move this pin. The copy-path sheet also gained the failure toast the other two had. -const HEAD_MAIN_HOOK_SHA256 = '6d309ebdf13ecf21e4b42fb29de9db586a3c9835ead43015a5261b67bf18b8f6' -const HEAD_HOOK_BINDING_SHA256 = '9041e8a74efdacc6099933bac11fb624aff46c99648746cf5504bf320ec431c5' +// Refreshed for `reportDictationFailure`, the whole of the +1: the composer's two dictation +// failure handlers were one policy written twice, and only `onError`'s copy knew about the setup +// sheet, so a refused start showed the desktop's own `voice_dictation_disabled` as a toast. +const HEAD_MAIN_HOOK_SHA256 = 'c7f4f35f447f1fd409dc9f1e617346d857ab6a6c6f5cf8420d1e6ee30984425d' +const HEAD_HOOK_BINDING_SHA256 = '01ed7fd19e95b90443edaece28d5be583cafa4f9bb6bee734f6409c4df095fc8' const HEAD_CALLBACK_IDENTITY_SHA256 = - 'ed45268b61372abcfeb29e9ce91822f1fb5214542b78356c7cef869824a09d37' + 'dc189c0b5e5a6e060393382fa2d92d8754d6d14068cb4728d6a79a50599e401c' // Pins that no callback body in the route changed unnoticed. Body text, not behaviour: the sends // and repo reads inside them now name their `RpcOperation` instead of the raw `sendRequest` port. // Refreshed in step 6 for the gesture flush, whose `terminal.send` became `terminalInputSend` and @@ -90,7 +93,9 @@ const HEAD_CALLBACK_IDENTITY_SHA256 = // frame cap inside the shell's page, and the Markdown copy action gained the failure branch that // answers a refused write. Re-recorded against the merged tree, since neither side's hash covers // the other's body. The hook and string counts are C7.2's and stand. -const HEAD_CALLBACK_BODY_SHA256 = '5845c3b85217a3af9d3d2bfafe564a2b29a1b2c6776b5c2c9ec5afbf365a5157' +// Refreshed once more for the two dictation failure handlers, which now both call +// `reportDictationFailure` instead of each choosing between the setup sheet and a toast. +const HEAD_CALLBACK_BODY_SHA256 = '7449a84d321ce698bc5a2ab6bf204b2047dcfaaebf15f05ec924ba95cec9121a' // Refreshed for the startup effect: both `worktree.activate` sends became `worktreeActivate`, and // the sleeping-agent check reads that operation's verdict instead of the reply envelope. Refreshed // again when the reporter took the reply and interpreted it itself, retiring the hand-built @@ -525,10 +530,10 @@ describe('mobile session route extraction parity', () => { const contentBindings = CONTENT_COMPONENT_NAMES.flatMap( (name) => readHookFacts(name, definitions).bindings ) - expect(main.hooks).toHaveLength(275) + expect(main.hooks).toHaveLength(276) expect(hash(main.hooks)).toBe(HEAD_MAIN_HOOK_SHA256) expect(hash(main.bindings)).toBe(HEAD_HOOK_BINDING_SHA256) - expect(main.callbacks).toHaveLength(77) + expect(main.callbacks).toHaveLength(78) expect(hash(main.callbacks)).toBe(HEAD_CALLBACK_IDENTITY_SHA256) expect(hash(main.callbackBodies)).toBe(HEAD_CALLBACK_BODY_SHA256) expect(main.effects).toHaveLength(24) 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 535b4034ec8..8ea9de2b9f0 100644 --- a/mobile/src/session/use-mobile-session-native-chat-dictation.ts +++ b/mobile/src/session/use-mobile-session-native-chat-dictation.ts @@ -91,6 +91,26 @@ export function useMobileSessionNativeChatDictation( surfaceKey: JSON.stringify([routeKey, activeHandle, showNativeChat, liveInputEnabled]) }) + /** + * One policy for every dictation failure, whichever entry point sees it: `onError` for a + * dictation already underway, `start`'s rejection for the tap that never got one. Written twice, + * only the first knew about the setup sheet, so a desktop refusing the start with + * `voice_dictation_disabled` showed the user that code as a toast. + */ + const reportDictationFailure = useCallback( + (err: unknown) => { + const message = err instanceof Error ? err.message : String(err) + // Dictation not set up on desktop → open the setup sheet instead of a dead-end toast. + if (isDictationSetupRequiredError(message)) { + setShowDictationSetup(true) + return + } + triggerError() + showToast(message) + }, + [setShowDictationSetup, showToast] + ) + const dictation = useMobileDictation({ client, enabled: canSend, @@ -132,13 +152,7 @@ export function useMobileSessionNativeChatDictation( }, onError: (err) => { dictationRouteContextRef.current = null - // Dictation not set up on desktop → open the setup sheet instead of a dead-end toast. - if (isDictationSetupRequiredError(err.message)) { - setShowDictationSetup(true) - return - } - triggerError() - showToast(err.message) + reportDictationFailure(err) } }) @@ -151,10 +165,9 @@ export function useMobileSessionNativeChatDictation( if (dictationRouteContextRef.current === routeContext) { dictationRouteContextRef.current = null } - triggerError() - showToast(err instanceof Error ? err.message : String(err)) + reportDictationFailure(err) }) - }, [activeHandle, dictation, liveInputTerminalHandles, triggerError, showToast]) + }, [activeHandle, dictation, liveInputTerminalHandles, reportDictationFailure]) const cancelDictation = useCallback(() => { dictationRouteContextRef.current = null