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.