From 9dd4dc5d17f71d29643114d8282ec01cfe5f44b5 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Wed, 28 Oct 2026 23:02:08 +0000 Subject: [PATCH] fix(mobile): show the dictation mic's starting state, hold-mode copy and screen-reader status (#35441) The chat mic only got isRecording/isProcessing, so it looked idle while starting; it now shows the live look the terminal mic does. The terminal status row says 'Release mic to stop' in hold mode, both mics expose role and busy state, and one live region announces Starting mic / Listening / Processing. Fixes #35422 --- .../MobileDictationStatusAnnouncement.tsx | 40 +++++++++++++++++++ .../session/MobileNativeChatOverlay.test.ts | 1 + .../src/session/MobileNativeChatOverlay.tsx | 5 +++ ...obileNativeChatView.streaming-rows.test.ts | 1 + mobile/src/session/MobileNativeChatView.tsx | 2 + .../MobileQueuedMessageComposer.test.tsx | 1 + .../session/MobileQueuedMessageComposer.tsx | 1 + .../session/MobileSessionActiveContent.tsx | 3 +- .../src/session/MobileSessionCommandDock.tsx | 1 + .../src/session/MobileSessionContentRow.tsx | 3 ++ .../session/MobileTerminalInputActions.tsx | 2 + .../session/MobileTerminalLiveInputStatus.tsx | 26 +++++------- .../__mocks__/mobile-prompt-controller.ts | 1 + ...chat-merged-snapshot-parity-hooks.test.tsx | 1 + .../mobile-queued-edit-composer.test.tsx | 1 + .../mobile-queued-edit-presentation.test.tsx | 1 + .../mobile-queued-message-composer-context.ts | 1 + .../composer/MobileNativeChatComposer.tsx | 2 + .../MobileNativeChatComposerMic.test.ts | 18 +++++++++ .../composer/MobileNativeChatComposerMic.tsx | 14 ++++++- 20 files changed, 108 insertions(+), 17 deletions(-) create mode 100644 mobile/src/session/MobileDictationStatusAnnouncement.tsx diff --git a/mobile/src/session/MobileDictationStatusAnnouncement.tsx b/mobile/src/session/MobileDictationStatusAnnouncement.tsx new file mode 100644 index 00000000000..3acd8f8a6dc --- /dev/null +++ b/mobile/src/session/MobileDictationStatusAnnouncement.tsx @@ -0,0 +1,40 @@ +import { StyleSheet, Text, View } from 'react-native' +import type { UseMobileDictationResult } from '../hooks/mobile-dictation-session-state' + +export type MobileDictationStatusFlags = Pick< + UseMobileDictationResult, + 'isStarting' | 'isRecording' | 'isProcessing' +> + +export function mobileDictationStatusTitle(dictation: MobileDictationStatusFlags): string | null { + return dictation.isRecording + ? 'Listening' + : dictation.isProcessing + ? 'Processing' + : dictation.isStarting + ? 'Starting mic' + : null +} + +// Mic labels change silently; one live region tells screen readers for every mic, like desktop's indicator. +export function MobileDictationStatusAnnouncement({ + dictation +}: { + dictation: MobileDictationStatusFlags +}): React.JSX.Element { + return ( + + {mobileDictationStatusTitle(dictation) ?? ''} + + ) +} + +const styles = StyleSheet.create({ + hidden: { + position: 'absolute', + width: 1, + height: 1, + overflow: 'hidden', + opacity: 0 + } +}) diff --git a/mobile/src/session/MobileNativeChatOverlay.test.ts b/mobile/src/session/MobileNativeChatOverlay.test.ts index 32886193fae..04e014d0200 100644 --- a/mobile/src/session/MobileNativeChatOverlay.test.ts +++ b/mobile/src/session/MobileNativeChatOverlay.test.ts @@ -80,6 +80,7 @@ function overlayElement(tick: Tick): ReturnType { }, onMicPress: vi.fn(), micActive: false, + micStarting: false, micProcessing: false, dictationMode: 'toggle', onMicPressIn: vi.fn(), diff --git a/mobile/src/session/MobileNativeChatOverlay.tsx b/mobile/src/session/MobileNativeChatOverlay.tsx index 4ef2e29087e..81b56f4ed99 100644 --- a/mobile/src/session/MobileNativeChatOverlay.tsx +++ b/mobile/src/session/MobileNativeChatOverlay.tsx @@ -28,6 +28,7 @@ type Props = { structuredComposer?: MobileNativeChatStructuredComposer | null onMicPress: (() => void) | undefined micActive: boolean + micStarting: boolean micProcessing: boolean dictationMode: string | undefined onMicPressIn: () => void @@ -53,6 +54,7 @@ export function MobileNativeChatOverlay({ structuredComposer = null, onMicPress, micActive, + micStarting, micProcessing, dictationMode, onMicPressIn, @@ -135,6 +137,7 @@ export function MobileNativeChatOverlay({ onError: (message: string) => controller.reportQueuedEditError?.(message), onMicPress, micActive, + micStarting, micProcessing, dictationMode, onMicPressIn, @@ -150,6 +153,7 @@ export function MobileNativeChatOverlay({ controller.reportQueuedEditError, onMicPress, micActive, + micStarting, micProcessing, dictationMode, onMicPressIn, @@ -240,6 +244,7 @@ export function MobileNativeChatOverlay({ isAttaching={images.isAttaching} onMicPress={onMicPress} micActive={micActive} + micStarting={micStarting} micProcessing={micProcessing} dictationMode={dictationMode} onMicPressIn={onMicPressIn} diff --git a/mobile/src/session/MobileNativeChatView.streaming-rows.test.ts b/mobile/src/session/MobileNativeChatView.streaming-rows.test.ts index 97cc8e62d11..8db6b6cfb2d 100644 --- a/mobile/src/session/MobileNativeChatView.streaming-rows.test.ts +++ b/mobile/src/session/MobileNativeChatView.streaming-rows.test.ts @@ -255,6 +255,7 @@ function Harness({ }, onMicPress: undefined, micActive: false, + micStarting: false, micProcessing: false, dictationMode: undefined, onMicPressIn: () => {}, diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index 50bf34e765e..39c3942b684 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -181,6 +181,7 @@ export function MobileNativeChatView({ isAttaching, onMicPress, micActive, + micStarting, micProcessing, dictationMode, onMicPressIn, @@ -402,6 +403,7 @@ export function MobileNativeChatView({ isAttaching={isAttaching} onMicPress={onMicPress} micActive={micActive} + micStarting={micStarting} micProcessing={micProcessing} dictationMode={dictationMode} onMicPressIn={onMicPressIn} diff --git a/mobile/src/session/MobileQueuedMessageComposer.test.tsx b/mobile/src/session/MobileQueuedMessageComposer.test.tsx index ea332328f4c..4a22330b654 100644 --- a/mobile/src/session/MobileQueuedMessageComposer.test.tsx +++ b/mobile/src/session/MobileQueuedMessageComposer.test.tsx @@ -49,6 +49,7 @@ describe('queued phone composer action boundary', () => { onError, onMicPress: undefined, micActive: false, + micStarting: false, micProcessing: false, dictationMode: undefined, onMicPressIn: vi.fn(), diff --git a/mobile/src/session/MobileQueuedMessageComposer.tsx b/mobile/src/session/MobileQueuedMessageComposer.tsx index 197097650c3..76d9e2da424 100644 --- a/mobile/src/session/MobileQueuedMessageComposer.tsx +++ b/mobile/src/session/MobileQueuedMessageComposer.tsx @@ -114,6 +114,7 @@ export function MobileQueuedMessageComposer({ isAttaching={images.isAttaching} onMicPress={context?.onMicPress} micActive={context?.micActive} + micStarting={context?.micStarting} micProcessing={context?.micProcessing} dictationMode={context?.dictationMode} onMicPressIn={context?.onMicPressIn} diff --git a/mobile/src/session/MobileSessionActiveContent.tsx b/mobile/src/session/MobileSessionActiveContent.tsx index 7b14097c9f0..27129124a44 100644 --- a/mobile/src/session/MobileSessionActiveContent.tsx +++ b/mobile/src/session/MobileSessionActiveContent.tsx @@ -250,7 +250,8 @@ export function MobileSessionActiveContent({ images={nativeChatImages} structuredComposer={nativeChatStructuredComposer} onMicPress={dictationEnabled ? handleDictationToggle : undefined} - micActive={dictation.isRecording} + micActive={dictation.isStarting || dictation.isRecording} + micStarting={dictation.isStarting} micProcessing={dictation.isProcessing} dictationMode={dictationMode} onMicPressIn={handleDictationPressIn} diff --git a/mobile/src/session/MobileSessionCommandDock.tsx b/mobile/src/session/MobileSessionCommandDock.tsx index a3b0c22b590..ebbbe4600d7 100644 --- a/mobile/src/session/MobileSessionCommandDock.tsx +++ b/mobile/src/session/MobileSessionCommandDock.tsx @@ -85,6 +85,7 @@ export function MobileSessionCommandDock({ diff --git a/mobile/src/session/MobileSessionContentRow.tsx b/mobile/src/session/MobileSessionContentRow.tsx index 2e8b1e0969f..89127e2a4cc 100644 --- a/mobile/src/session/MobileSessionContentRow.tsx +++ b/mobile/src/session/MobileSessionContentRow.tsx @@ -8,6 +8,7 @@ import { styles } from './mobile-session-styles' import type { MobileSessionController } from './use-mobile-session-controller' import { MobileSessionActiveContent } from './MobileSessionActiveContent' import { MobileSessionCommandDock } from './MobileSessionCommandDock' +import { MobileDictationStatusAnnouncement } from './MobileDictationStatusAnnouncement' export function MobileSessionContentRow({ controller, @@ -27,6 +28,7 @@ export function MobileSessionContentRow({ canDockPanel, setCreateWarningState, createWarning, + dictation, handleFileOpenStart, handleOpenedFileDiff, handleSessionContentRowLayout @@ -51,6 +53,7 @@ export function MobileSessionContentRow({ {/* Why: translate instead of resize so keyboard toggles don't trigger a server-side PTY viewport change. */} + {canDockPanel && activePanel !== null && ( { images: OVERLAY_IMAGES, onMicPress: () => {}, micActive: false, + micStarting: false, micProcessing: false, dictationMode: 'toggle', onMicPressIn: () => {}, diff --git a/mobile/src/session/mobile-queued-edit-composer.test.tsx b/mobile/src/session/mobile-queued-edit-composer.test.tsx index c32b6578355..fac108832d6 100644 --- a/mobile/src/session/mobile-queued-edit-composer.test.tsx +++ b/mobile/src/session/mobile-queued-edit-composer.test.tsx @@ -122,6 +122,7 @@ describe('real phone inline composer from More through picker pruning and host b onError: error, onMicPress: undefined, micActive: false, + micStarting: false, micProcessing: false, dictationMode: undefined, onMicPressIn: () => {}, diff --git a/mobile/src/session/mobile-queued-edit-presentation.test.tsx b/mobile/src/session/mobile-queued-edit-presentation.test.tsx index 96120bbfdcf..b24ad9d9a88 100644 --- a/mobile/src/session/mobile-queued-edit-presentation.test.tsx +++ b/mobile/src/session/mobile-queued-edit-presentation.test.tsx @@ -96,6 +96,7 @@ describe('phone inline editor presentation', () => { onError: error, onMicPress: undefined, micActive: false, + micStarting: false, micProcessing: false, dictationMode: undefined, onMicPressIn: () => {}, diff --git a/mobile/src/session/mobile-queued-message-composer-context.ts b/mobile/src/session/mobile-queued-message-composer-context.ts index ac1dc0d99fd..e26ed715fca 100644 --- a/mobile/src/session/mobile-queued-message-composer-context.ts +++ b/mobile/src/session/mobile-queued-message-composer-context.ts @@ -12,6 +12,7 @@ export const MobileQueuedMessageComposerContext = createContext<{ onError: (text: string) => void onMicPress: (() => void) | undefined micActive: boolean + micStarting: boolean micProcessing: boolean dictationMode: string | undefined onMicPressIn: () => void diff --git a/mobile/src/session/native-chat/composer/MobileNativeChatComposer.tsx b/mobile/src/session/native-chat/composer/MobileNativeChatComposer.tsx index bb3f25d9727..f5b93af8493 100644 --- a/mobile/src/session/native-chat/composer/MobileNativeChatComposer.tsx +++ b/mobile/src/session/native-chat/composer/MobileNativeChatComposer.tsx @@ -108,6 +108,7 @@ export function MobileNativeChatComposer({ isAttaching = false, onMicPress, micActive = false, + micStarting, micProcessing, dictationMode, onMicPressIn, @@ -332,6 +333,7 @@ export function MobileNativeChatComposer({ { expect(findAllByHostType(mic, 'ActivityIndicator')).toHaveLength(1) expect(findAllByHostType(mic, 'Mic')).toHaveLength(0) }) + + it('tells screen readers it is busy starting before recording begins', () => { + act(() => { + renderer = create( + createElement(MobileNativeChatComposerMic, { + onMicPress: vi.fn(), + micActive: true, + micStarting: true, + disabled: false + }) + ) + }) + const mic = renderer!.root.find( + (node) => + String(node.type) === 'Pressable' && node.props.accessibilityLabel === 'Starting dictation' + ) + expect(mic.props['aria-busy']).toBe(true) + }) }) diff --git a/mobile/src/session/native-chat/composer/MobileNativeChatComposerMic.tsx b/mobile/src/session/native-chat/composer/MobileNativeChatComposerMic.tsx index c60673feae4..dd545cd5e62 100644 --- a/mobile/src/session/native-chat/composer/MobileNativeChatComposerMic.tsx +++ b/mobile/src/session/native-chat/composer/MobileNativeChatComposerMic.tsx @@ -6,7 +6,10 @@ import { mobileNativeChatComposerStyles as styles } from './mobile-native-chat-c export type MobileNativeChatMicProps = { onMicPress?: () => void + /** Starting or recording: the mic is live and its release must reach the hook. */ micActive?: boolean + /** Still opening the mic; taps are ignored until recording begins. */ + micStarting?: boolean /** Transcribing after stop; taps are ignored until the transcript lands. */ micProcessing?: boolean dictationMode?: string @@ -17,6 +20,7 @@ export type MobileNativeChatMicProps = { export function MobileNativeChatComposerMic({ onMicPress, micActive = false, + micStarting = false, micProcessing = false, dictationMode = 'toggle', onMicPressIn, @@ -25,8 +29,16 @@ export function MobileNativeChatComposerMic({ }: MobileNativeChatMicProps & { disabled: boolean }): React.JSX.Element { return ( [styles.ghostButton, pressed && styles.pressed]} // Hold mode is walkie-talkie (press-in/out); toggle mode taps.