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.