mirror of
https://github.com/stablyai/orca.git
synced 2026-10-03 00:02:19 +00:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user