fix(mobile): a refused dictation start says why (#22256)

The composer had two dictation failure handlers, one policy written twice.
`onError` routed a setup-required refusal to the dictation setup sheet;
`startDictation`'s own catch — the only path a refused `speech.dictation.start`
takes — did not, so a desktop whose voice settings are off or whose model dir is
empty answered the tap with its internal code as a toast.

Both entry points now call one `reportDictationFailure`, so the sheet opens for
`voice_dictation_disabled`, `voice_model_not_selected` and `voice_model_not_ready:*`
whichever path saw them, and every other refusal keeps the toast and haptic it had.

The desktop's error already carries its reason in the message, so no wire change.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo Hong
2026-09-22 08:38:09 -04:00
committed by GitHub
parent eaf71ce99c
commit 0748915ce2
3 changed files with 220 additions and 16 deletions
@@ -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<string>(),
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<string>(),
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<void> {
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)
})
})
@@ -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)
@@ -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