mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user