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.