diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index f8751d970dc7..7b7bad5ee951 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -31,6 +31,7 @@ import { StackActions, useFocusEffect, useNavigation } from "@react-navigation/n import type { ReactNode } from "react"; import { useEffect, useLayoutEffect, useRef, useState, type RefObject } from "react"; import { Keyboard, Platform, View, type ViewStyle } from "react-native"; +import { KeyboardController, KeyboardEvents } from "react-native-keyboard-controller"; import { composerImageGallery } from "../../lib/composerImageGallery"; import { filePreviewGallery } from "../../lib/filePreviewGallery"; import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; @@ -140,9 +141,19 @@ function collapsedEditorInset(text: ScaledTextRole): number { return Math.max(0, Math.round(COLLAPSED_EDITOR_DEFAULT_INSET - shift)); } -export function useCompactComposerEditor(text: ScaledTextRole, value: string) { +export function useCompactComposerEditor( + text: ScaledTextRole, + value: string, + opensOnFocus: boolean, + yieldsModelLabel: boolean, +) { const [contentSize, setContentSize] = useState({ width: 0, height: 0 }); - const [compactWidth, setCompactWidth] = useState(0); + const [compactSlot, setCompactSlot] = useState({ width: 0, labelYielded: false }); + const [labelYielded, setLabelYielded] = useState(false); + const [measuredRowWidth, setMeasuredRowWidth] = useState(0); + if (labelYielded && value.length === 0) setLabelYielded(false); + const compactWidth = + !yieldsModelLabel || compactSlot.labelYielded === labelYielded ? compactSlot.width : 0; const contentHeight = contentSize.height; const rowWidth = useSharedValue(0); const editorX = useSharedValue(0); @@ -157,9 +168,16 @@ export function useCompactComposerEditor(text: ScaledTextRole, value: string) { // A line that fits the expanded editor may still overflow its compact slot. // Compare its measured width too so widening cannot trigger a collapse loop. const isExpanded = + opensOnFocus || + (value.length > 0 && + (contentHeight > text.lineHeight * 1.5 || + (compactWidth > 0 && contentSize.width > compactWidth))); + const nearsModelLabel = + yieldsModelLabel && value.length > 0 && - (contentHeight > text.lineHeight * 1.5 || - (compactWidth > 0 && contentSize.width > compactWidth)); + compactWidth > 0 && + contentSize.width > compactWidth - text.fontSize; + if (nearsModelLabel && !labelYielded) setLabelYielded(true); const compactRowHeight = Math.max(44, minimum); const animatedHeight = useSharedValue(height); const surfaceHeight = useSharedValue(compactRowHeight); @@ -202,10 +220,16 @@ export function useCompactComposerEditor(text: ScaledTextRole, value: string) { })); return { isExpanded, + labelYielded: labelYielded && value.length > 0, containerStyle, editorStyle, onRowLayout: (event: { nativeEvent: { layout: { width: number } } }) => { - rowWidth.set(event.nativeEvent.layout.width); + const width = event.nativeEvent.layout.width; + rowWidth.set(width); + if (width !== measuredRowWidth) { + setMeasuredRowWidth(width); + setCompactSlot({ width: 0, labelYielded }); + } }, onEditorLayout: (event: { nativeEvent: { layout: { x: number; width: number } } }) => { // The expanded toolbar gives this slot to the model control. Keep the @@ -213,7 +237,7 @@ export function useCompactComposerEditor(text: ScaledTextRole, value: string) { if (!isExpanded) { editorX.set(event.nativeEvent.layout.x); editorWidth.set(event.nativeEvent.layout.width); - setCompactWidth(Math.max(0, event.nativeEvent.layout.width - 8)); + setCompactSlot({ width: Math.max(0, event.nativeEvent.layout.width - 8), labelYielded }); } }, editorProps: { @@ -439,6 +463,19 @@ export function ComposerSurface(props: { ); } +function useKeyboardShowing() { + const [showing, setShowing] = useState(KeyboardController.isVisible); + useEffect(() => { + const show = KeyboardEvents.addListener("keyboardWillShow", () => setShowing(true)); + const hide = KeyboardEvents.addListener("keyboardWillHide", () => setShowing(false)); + return () => { + show.remove(); + hide.remove(); + }; + }, []); + return showing; +} + /** Space below the composer pill, inside the bottom inset it is given. */ export const THREAD_COMPOSER_BOTTOM_GUTTER = 6; @@ -452,6 +489,7 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps const fallbackInputRef = useRef(null); const inputRef = props.editorRef ?? fallbackInputRef; const [isFocused, setIsFocused] = useState(false); + const keyboardVisible = useKeyboardShowing(); const pendingPastedTextAttachmentCountRef = useRef(0); const pendingNativeImagePasteCountRef = useRef(0); const [pendingPastedTextAttachmentCount, setPendingPastedTextAttachmentCount] = useState(0); @@ -600,8 +638,17 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps const { setComposerEditing } = useAdaptiveWorkspaceLayout(); const hasSidebarTabBar = useSidebarWorkspaceTabs() === "picker"; const [compactRowWidth, setCompactRowWidth] = useState(0); - const compactAttachmentLimit = compactRowWidth >= 500 ? 3 : 1; - const compactEditor = useCompactComposerEditor(bodyText, draftMessage); + const isWideRow = compactRowWidth >= 500; + const compactAttachmentLimit = isWideRow ? 3 : 1; + const opensCard = + (isFocused && (Platform.OS !== "android" || keyboardVisible)) || + settingsSheetPresentation.keepsComposerExpanded; + const compactEditor = useCompactComposerEditor( + bodyText, + draftMessage, + opensCard && compactRowWidth > 0 && !isWideRow, + isWideRow, + ); const showsCompactDictation = isVoiceInputPresented; const isToolbarVisible = isVoiceInputPresented; const hasEditingAttachments = @@ -859,7 +906,10 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps reportedSelection={props.reportedModelSelection} prompt={draftMessage} surfaceColor={composerPanel} - iconOnly={!compactEditor.isExpanded} + iconOnly={ + !compactEditor.isExpanded && + (!isWideRow || compactEditor.labelYielded || stripAttachments.length > 0) + } onPress={openSettings} />