Skip to content
30 changes: 24 additions & 6 deletions apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,12 @@ export const COMPOSER_COLLAPSED_CHROME = 60;

export const COMPOSER_EXPANDED_CHROME = 156;

const EXPANDED_EDITOR_MIN_HEIGHT = 72;
const EXPANDED_EDITOR_MAX_HEIGHT = 160;

export const COMPOSER_DICTATION_REVIEW_CHROME =
COMPOSER_EXPANDED_CHROME + EXPANDED_EDITOR_MAX_HEIGHT - EXPANDED_EDITOR_MIN_HEIGHT;

export interface ThreadComposerProps {
readonly canOperateThread: boolean;
readonly placeholder: string;
Expand Down Expand Up @@ -156,6 +162,9 @@ export interface ThreadComposerProps {
readonly canSteerActiveTurn: boolean;
readonly editorRef?: RefObject<ComposerEditorHandle | null>;
readonly onChangeDraftMessage: (value: string) => void;
readonly onDraftCleared: () => void;
readonly isReviewingDictation: boolean;
readonly onDictationComplete: () => void;
readonly onPickDraftMedia: () => Promise<void>;
readonly onPickDraftFiles: () => Promise<void>;
readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>;
Expand Down Expand Up @@ -457,15 +466,19 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps
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(
voiceInput.state,
voiceInput.elapsedSeconds,
);
const isVoiceInputPresented = voicePresentation.statusLabel !== null;
const isExpanded = isFocused || settingsSheetPresentation.keepsComposerExpanded;
const isExpanded =
isFocused || props.isReviewingDictation || settingsSheetPresentation.keepsComposerExpanded;
const showsCompactDictation = isVoiceInputPresented && !isExpanded;
const isToolbarVisible = isExpanded || isVoiceInputPresented;
const attachmentBlockReason = composerAttachmentUploadBlockReason({
Expand All @@ -489,7 +502,7 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps
sendBlockedReason === null &&
!modelUnavailable;

useEffect(() => {
useLayoutEffect(() => {
onExpandedChange?.(isExpanded);
}, [isExpanded, onExpandedChange]);

Expand Down Expand Up @@ -538,7 +551,10 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps
const handleSend = async (followUp?: ActiveTurnComposerAction) => {
if (voiceInput.blocksSubmission || pendingPastedTextAttachmentCountRef.current > 0) return;
if (usageLimitsOffered && isUsageLimitsCommand(draftMessage) && draftAttachments.length === 0) {
if (openUsageLimits()) onChangeDraftMessage("");
if (openUsageLimits()) {
onChangeDraftMessage("");
props.onDraftCleared();
}
return;
}
const threadKey = scopedThreadKey(props.environmentId, props.selectedThread.id);
Expand Down Expand Up @@ -902,8 +918,10 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps
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
45 changes: 43 additions & 2 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,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 @@ -411,7 +412,25 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai
const lastScrolledSubmittedMessageIdRef = useRef<MessageId | null>(null);
const [composerExpanded, setComposerExpanded] = useState(false);
const [composerFocused, setComposerFocused] = useState(false);
const composerFocusedThreadKeyRef = useRef<string | null>(null);
const [dictationReviewThreadKey, setDictationReviewThreadKey] = useState<string | null>(null);
const isReviewingDictation = dictationReviewThreadKey === selectedThreadKey && !composerFocused;
const dismissDictationReview = () =>
setDictationReviewThreadKey((current) => (current === selectedThreadKey ? null : current));
const handleDictationComplete = () => {
// The voice session retains its startup callback, so only expand its selected thread.
if (selectedThreadKeyRef.current !== selectedThreadKey) return;
if (composerFocusedThreadKeyRef.current !== selectedThreadKey) {
setDictationReviewThreadKey(selectedThreadKey);
}
};
const handleComposerFocusChange = (focused: boolean) => {
if (focused) {
composerFocusedThreadKeyRef.current = selectedThreadKey;
dismissDictationReview();
} else if (composerFocusedThreadKeyRef.current === selectedThreadKey) {
composerFocusedThreadKeyRef.current = null;
}
setComposerFocused(focused);
handleOwnedInputFocusChange(focused);
};
Expand All @@ -424,7 +443,9 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai
// the hide animation, so the composer would ride down flush to the screen edge and then snap up
// into 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 @@ -536,7 +557,11 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai
) ||
(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;
const [collapsedUserInputRequestId, setCollapsedUserInputRequestId] =
useState<RuntimeRequestId | null>(null);
Expand Down Expand Up @@ -779,8 +804,11 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai

useLayoutEffect(() => {
selectedThreadKeyRef.current = selectedThreadKey;
composerEditorRef.current?.blur();
// A replaced or unmounted native editor may not emit a blur event.
composerFocusedThreadKeyRef.current = null;
setComposerFocused(false);
setDictationReviewThreadKey(null);
}, [selectedThreadKey, showContent]);

const visitThread = useAtomCommand(threadEnvironment.visit, { reportFailure: false });
Expand Down Expand Up @@ -900,10 +928,18 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai

const handleSendMessage = async (followUp?: ActiveTurnComposerAction) => {
const targetThreadKey = selectedThreadKey;
const targetDraftKey = props.composerDraftKey ?? targetThreadKey;
const hasUserMessage = selectedThreadFeed.some(
(entry) => entry.type === "message" && entry.message.role === "user",
);
const messageId = await props.onSendMessage(followUp);
const remainingDraft = getComposerDraftSnapshot(targetDraftKey);
if (
messageId !== null ||
(remainingDraft.text.length === 0 && remainingDraft.attachments.length === 0)
) {
dismissDictationReview();
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
if (messageId === null || selectedThreadKeyRef.current !== targetThreadKey) {
return messageId;
}
Expand Down Expand Up @@ -943,6 +979,7 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai
};

const collapseComposer = () => {
dismissDictationReview();
composerEditorRef.current?.blur();
};

Expand Down Expand Up @@ -1159,6 +1196,7 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai
saving={props.isSavingQueuedEdit}
onCancel={() => {
voiceInputSession.cancel(props.composerDraftKey);
dismissDictationReview();
props.onCancelQueuedRunEdit();
}}
/>
Expand Down Expand Up @@ -1337,6 +1375,9 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai
}
bottomInset={composerBottomInset}
onChangeDraftMessage={props.onChangeDraftMessage}
onDraftCleared={dismissDictationReview}
isReviewingDictation={isReviewingDictation}
onDictationComplete={handleDictationComplete}
onPickDraftMedia={props.onPickDraftMedia}
onPickDraftFiles={props.onPickDraftFiles}
onNativePasteImages={props.onNativePasteImages}
Expand Down
Loading