Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 31 additions & 6 deletions apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -184,6 +194,10 @@ export interface ThreadComposerProps {
readonly canSteerActiveTurn: boolean;
readonly editorRef?: RefObject<ComposerEditorHandle | null>;
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<void>;
readonly onPickDraftFiles: () => Promise<void>;
readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>;
Expand Down Expand Up @@ -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(
Expand All @@ -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({
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
}
: {
Expand Down
32 changes: 30 additions & 2 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -421,8 +422,23 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
const lastScrolledSubmittedMessageIdRef = useRef<MessageId | null>(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<string | null>(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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
setComposerFocused(focused);
handleOwnedInputFocusChange(focused);
},
Expand All @@ -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);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]);

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -1021,6 +1046,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
}, []);

const collapseComposer = useCallback(() => {
setDictationReviewThreadKey(null);
composerEditorRef.current?.blur();
}, []);

Expand Down Expand Up @@ -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}
Expand Down
Loading