diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 6873926fd5dd..0e9e6efd8c61 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -140,6 +140,16 @@ export const COMPOSER_COLLAPSED_CHROME = 60; */ export const COMPOSER_EXPANDED_CHROME = 156; +const EXPANDED_EDITOR_MIN_HEIGHT = 72; +const EXPANDED_EDITOR_MAX_HEIGHT = 160; + +/** + * Height of the composer while a dictated draft is held open for review: the expanded + * composer with its editor at full height. + */ +export const COMPOSER_DICTATION_REVIEW_CHROME = + COMPOSER_EXPANDED_CHROME + EXPANDED_EDITOR_MAX_HEIGHT - EXPANDED_EDITOR_MIN_HEIGHT; + export interface ThreadComposerProps { readonly canOperateThread: boolean; readonly draftMessage: string; @@ -184,6 +194,10 @@ export interface ThreadComposerProps { readonly canSteerActiveTurn: boolean; readonly editorRef?: RefObject; readonly onChangeDraftMessage: (value: string) => void; + /** Holds the composer open at full editor height, unfocused, so a dictated draft can be read. */ + readonly isReviewingDictation: boolean; + /** Called when a finished transcript lands in the draft. */ + readonly onDictationComplete: () => void; readonly onPickDraftMedia: () => Promise; readonly onPickDraftFiles: () => Promise; readonly onNativePasteImages: (uris: ReadonlyArray) => Promise; @@ -515,7 +529,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer readDraftMessage: () => getComposerDraftSnapshot(composerDraftKey).text, subscribeToDraftChanges: (onChange) => appAtomRegistry.subscribe(composerDraftsAtom, onChange), selection: composerMenu.selection, - onChangeDraftMessage: (text) => setComposerDraftText(composerDraftKey, text), + onChangeDraftMessage: (text) => { + setComposerDraftText(composerDraftKey, text); + props.onDictationComplete(); + }, onChangeSelection: composerMenu.onSelectionChange, }); const voicePresentation = resolveVoiceComposerPresentation( @@ -524,7 +541,8 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ); const isVoiceInputPresented = voicePresentation.statusLabel !== null; // An open draft stays visible; only a collapsed composer becomes a voice strip. - const isExpanded = isFocused || settingsSheetPresentation.keepsComposerExpanded; + const isExpanded = + isFocused || props.isReviewingDictation || settingsSheetPresentation.keepsComposerExpanded; const showsCompactDictation = isVoiceInputPresented && !isExpanded; const isToolbarVisible = isExpanded || isVoiceInputPresented; const attachmentBlockReason = composerAttachmentUploadBlockReason({ @@ -590,11 +608,16 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer const handleBlur = useCallback(() => { setIsFocused(false); - if (!settingsSheetPresentation.keepsComposerExpanded) { + if (!props.isReviewingDictation && !settingsSheetPresentation.keepsComposerExpanded) { onExpandedChange?.(false); } onEditorFocusChange?.(false); - }, [onEditorFocusChange, onExpandedChange, settingsSheetPresentation.keepsComposerExpanded]); + }, [ + onEditorFocusChange, + onExpandedChange, + props.isReviewingDictation, + settingsSheetPresentation.keepsComposerExpanded, + ]); const handleSend = useCallback( async (followUp?: ActiveTurnComposerAction) => { if (voiceInput.blocksSubmission || pendingPastedTextAttachmentCountRef.current > 0) return; @@ -1009,8 +1032,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer style={ isExpanded ? { - minHeight: 72, - maxHeight: 160, + minHeight: props.isReviewingDictation + ? EXPANDED_EDITOR_MAX_HEIGHT + : EXPANDED_EDITOR_MIN_HEIGHT, + maxHeight: EXPANDED_EDITOR_MAX_HEIGHT, paddingVertical: 4, } : { diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 79990884085d..066a7173117d 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -132,6 +132,7 @@ import { } from "./pendingUserInputLayout"; import { COMPOSER_COLLAPSED_CHROME, + COMPOSER_DICTATION_REVIEW_CHROME, COMPOSER_EXPANDED_CHROME, COMPOSER_LAYOUT_TRANSITION, COMPOSER_TRANSITION_DURATION_MS, @@ -421,8 +422,23 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const lastScrolledSubmittedMessageIdRef = useRef(null); const [composerExpanded, setComposerExpanded] = useState(false); const [composerFocused, setComposerFocused] = useState(false); + const composerFocusedRef = useRef(false); + // A transcript dictated into the collapsed composer opens it for reading with the + // keyboard down. Focusing the editor, tapping the feed, or sending ends the review. + const [dictationReviewThreadKey, setDictationReviewThreadKey] = useState(null); + const isReviewingDictation = + dictationReviewThreadKey === selectedThreadKey && + props.draftMessage.trim().length > 0 && + !composerFocused; + // Dictation keeps the callback it started with, and a focus event may not have rendered + // yet, so the ref carries the latest focus for the moment the transcript lands. + const handleDictationComplete = useCallback(() => { + if (!composerFocusedRef.current) setDictationReviewThreadKey(selectedThreadKey); + }, [selectedThreadKey]); const handleComposerFocusChange = useCallback( (focused: boolean) => { + composerFocusedRef.current = focused; + if (focused) setDictationReviewThreadKey(null); setComposerFocused(focused); handleOwnedInputFocusChange(focused); }, @@ -439,8 +455,11 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread // then snap up into the inset. On iOS blur precedes the hide, so the // focus-keyed inset is already in place while the composer rides down. // Dictation keeps that focus while the composer switches to its compact pill. + // A dictation review is expanded with the keyboard down, so it keeps the inset. const composerBottomInset = ( - Platform.OS === "android" ? isKeyboardVisible : composerExpanded || composerFocused + Platform.OS === "android" + ? isKeyboardVisible + : !isReviewingDictation && (composerExpanded || composerFocused) ) ? 0 : Math.max(insets.bottom, 12); @@ -549,7 +568,11 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread ) || (props.historyControls?.hasMoreHistory === true && props.selectedThread.latestUserMessageAt !== null); - const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME; + const composerChrome = isReviewingDictation + ? COMPOSER_DICTATION_REVIEW_CHROME + : composerExpanded + ? COMPOSER_EXPANDED_CHROME + : COMPOSER_COLLAPSED_CHROME; const composerOverlapHeight = composerChrome + composerBottomInset; // While a user-input request is pending, the questionnaire owns the // composer slot outright: expanded it is the full card, collapsed it is a @@ -843,6 +866,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread useLayoutEffect(() => { selectedThreadKeyRef.current = selectedThreadKey; // A replaced or unmounted native editor may not emit a blur event. + composerFocusedRef.current = false; setComposerFocused(false); }, [selectedThreadKey, showContent]); @@ -979,6 +1003,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread // A sent message makes the snapshot stale; a refused send leaves it in place. clearUsageLimitsFor(targetThreadKey); + setDictationReviewThreadKey(null); setSubmittedMessageId(messageId); setAnchorMessageId( @@ -1021,6 +1046,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread }, []); const collapseComposer = useCallback(() => { + setDictationReviewThreadKey(null); composerEditorRef.current?.blur(); }, []); @@ -1412,6 +1438,8 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread } bottomInset={composerBottomInset} onChangeDraftMessage={props.onChangeDraftMessage} + isReviewingDictation={isReviewingDictation} + onDictationComplete={handleDictationComplete} onPickDraftMedia={props.onPickDraftMedia} onPickDraftFiles={props.onPickDraftFiles} onNativePasteImages={props.onNativePasteImages}