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
This commit is contained in:
Kelvin Amoaba
2026-10-28 23:02:08 +00:00
committed by GitHub
parent b378dd183a
commit 9dd4dc5d17
20 changed files with 108 additions and 17 deletions
@@ -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 (
<View style={styles.hidden} pointerEvents="none" accessibilityLiveRegion="polite">
<Text>{mobileDictationStatusTitle(dictation) ?? ''}</Text>
</View>
)
}
const styles = StyleSheet.create({
hidden: {
position: 'absolute',
width: 1,
height: 1,
overflow: 'hidden',
opacity: 0
}
})
@@ -80,6 +80,7 @@ function overlayElement(tick: Tick): ReturnType<typeof createElement> {
},
onMicPress: vi.fn(),
micActive: false,
micStarting: false,
micProcessing: false,
dictationMode: 'toggle',
onMicPressIn: vi.fn(),
@@ -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}
@@ -255,6 +255,7 @@ function Harness({
},
onMicPress: undefined,
micActive: false,
micStarting: false,
micProcessing: false,
dictationMode: undefined,
onMicPressIn: () => {},
@@ -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}
@@ -49,6 +49,7 @@ describe('queued phone composer action boundary', () => {
onError,
onMicPress: undefined,
micActive: false,
micStarting: false,
micProcessing: false,
dictationMode: undefined,
onMicPressIn: vi.fn(),
@@ -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}
@@ -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}
@@ -85,6 +85,7 @@ export function MobileSessionCommandDock({
<KeyboardIcon size={16} color={colors.textSecondary} strokeWidth={2} />
<MobileTerminalLiveInputStatus
dictation={dictation}
dictationMode={dictationMode}
isAttaching={isAttaching}
liveInputText={liveInputCapture}
/>
@@ -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({
<MobileSessionActiveContent controller={controller} />
{/* Why: translate instead of resize so keyboard toggles don't trigger a server-side PTY viewport change. */}
<MobileSessionCommandDock controller={controller} keysInRail={keysInRail} />
<MobileDictationStatusAnnouncement dictation={dictation} />
</View>
{canDockPanel && activePanel !== null && (
<SessionDockColumn
@@ -85,6 +85,8 @@ export function MobileTerminalInputActions({
}
: keepHeldPressThroughLongPress
}
accessibilityRole="button"
aria-busy={dictation.isStarting || dictation.isProcessing}
accessibilityLabel={
dictation.isRecording
? 'Stop voice dictation'
@@ -2,34 +2,30 @@ import { Text, View } from 'react-native'
import { colors, typography } from '../theme/mobile-theme'
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
import { themedStyleSheet } from '../theme/themed-styles'
type DictationStatus = {
readonly isStarting: boolean
readonly isRecording: boolean
readonly isProcessing: boolean
}
import {
mobileDictationStatusTitle,
type MobileDictationStatusFlags
} from './MobileDictationStatusAnnouncement'
type MobileTerminalLiveInputStatusProps = {
readonly dictation: DictationStatus
readonly dictation: MobileDictationStatusFlags
readonly dictationMode: string | undefined
readonly isAttaching: boolean
readonly liveInputText: string
}
export function MobileTerminalLiveInputStatus({
dictation,
dictationMode,
isAttaching,
liveInputText
}: MobileTerminalLiveInputStatusProps) {
useMobileColorScheme()
const title = dictation.isRecording
? 'Listening'
: dictation.isProcessing
? 'Processing'
: dictation.isStarting
? 'Starting mic'
: 'Live input'
const title = mobileDictationStatusTitle(dictation) ?? 'Live input'
const detail = dictation.isRecording
? 'Tap mic to stop'
? dictationMode === 'hold'
? 'Release mic to stop'
: 'Tap mic to stop'
: dictation.isProcessing
? 'Transcribing on desktop'
: dictation.isStarting
@@ -156,6 +156,7 @@ function Harness(props: HarnessProps) {
onMicPressOut: vi.fn(),
onMicPressIn: vi.fn(),
micActive: false,
micStarting: false,
micProcessing: false,
dictationMode: 'toggle',
inputLockReason: connState === 'connected' ? null : 'disconnected',
@@ -237,6 +237,7 @@ async function mountPhone(): Promise<Phone> {
images: OVERLAY_IMAGES,
onMicPress: () => {},
micActive: false,
micStarting: false,
micProcessing: false,
dictationMode: 'toggle',
onMicPressIn: () => {},
@@ -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: () => {},
@@ -96,6 +96,7 @@ describe('phone inline editor presentation', () => {
onError: error,
onMicPress: undefined,
micActive: false,
micStarting: false,
micProcessing: false,
dictationMode: undefined,
onMicPressIn: () => {},
@@ -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
@@ -108,6 +108,7 @@ export function MobileNativeChatComposer({
isAttaching = false,
onMicPress,
micActive = false,
micStarting,
micProcessing,
dictationMode,
onMicPressIn,
@@ -332,6 +333,7 @@ export function MobileNativeChatComposer({
<MobileNativeChatComposerMic
onMicPress={onMicPress}
micActive={micActive}
micStarting={micStarting}
micProcessing={micProcessing}
dictationMode={dictationMode}
onMicPressIn={onMicPressIn}
@@ -38,4 +38,22 @@ describe('MobileNativeChatComposerMic', () => {
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)
})
})
@@ -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 (
<Pressable
accessibilityRole="button"
aria-busy={micStarting || micProcessing}
accessibilityLabel={
micActive ? 'Stop dictation' : micProcessing ? 'Transcribing dictation' : 'Dictate'
micStarting
? 'Starting dictation'
: micActive
? 'Stop dictation'
: micProcessing
? 'Transcribing dictation'
: 'Dictate'
}
style={({ pressed }) => [styles.ghostButton, pressed && styles.pressed]}
// Hold mode is walkie-talkie (press-in/out); toggle mode taps.