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
7 changes: 5 additions & 2 deletions src/components/CollapsibleHeaderOnKeyboard/index.native.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const RESTORE_DURATION = 300;
// Assumed vertical space for the focused input field — used to reserve space above the keyboard.
const VERTICAL_SPACE_FOR_FOCUSED_INPUT = 120;
const KEYBOARD_OPENING_PROGRESS_THRESHOLDS = [0.5, 0.7, 0.8, 0.85, 0.9, 0.95, 0.99];
const MIN_HEADER_HEIGHT_ON_COLLAPSE = 8;

function isKeyboardOpeningAtGivenProgress(keyboardProgress: number, prevKeyboardProgress: number, requiredProgress: number[]): boolean {
'worklet';
Expand All @@ -32,7 +33,7 @@ function isKeyboardOpeningAtGivenProgress(keyboardProgress: number, prevKeyboard
* Intended for landscape mode on phones where the keyboard + header can leave no room for inputs.
* Uses height animation (not translateY) so the freed space is reclaimed by the layout below.
*/
function CollapsibleHeaderOnKeyboard({children, collapsibleHeaderOffset = 0}: CollapsibleHeaderOnKeyboardProps) {
function CollapsibleHeaderOnKeyboard({children, collapsibleHeaderOffset = 0, alwaysCollapseHeaderOnKeyboard = false}: CollapsibleHeaderOnKeyboardProps) {
const isFocused = useIsFocused();
const prevIsFocused = usePrevious(isFocused);
// JS ref guards against re-measurement when the Reanimated.View fires onLayout with height=0
Expand Down Expand Up @@ -157,7 +158,9 @@ function CollapsibleHeaderOnKeyboard({children, collapsibleHeaderOffset = 0}: Co
// Target header height: give the input exactly the space it needs above the keyboard,
// the header gets what remains. Clamped to [0, naturalHeight].
const keyboardTop = windowHeightValue + keyboardHeight;
const targetHeight = Math.max(0, keyboardTop - VERTICAL_SPACE_FOR_FOCUSED_INPUT - collapsibleHeaderOffsetSV.get());
const targetHeight = alwaysCollapseHeaderOnKeyboard
? MIN_HEADER_HEIGHT_ON_COLLAPSE
: Math.max(MIN_HEADER_HEIGHT_ON_COLLAPSE, keyboardTop - VERTICAL_SPACE_FOR_FOCUSED_INPUT - collapsibleHeaderOffsetSV.get());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MIN_HEADER_HEIGHT_ON_COLLAPSE changes collapse behavior for all existing consumers.

The old floor was Math.max(0, ...), the new one is Math.max(16, ...). This affects every existing consumer of CollapsibleHeaderOnKeyboard that doesn't pass alwaysCollapseHeaderOnKeyboard..

Is it fine?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's fine. It's just that designers didn't like cases where we did not have any padding above the input on header collapse, so I decided to fix all such cases like this, because I was getting new cases with inputs shown without top padding on header collapse on this PR

const naturalHeightValue = naturalHeight.get();

if (targetHeight >= naturalHeightValue) {
Expand Down
6 changes: 6 additions & 0 deletions src/components/CollapsibleHeaderOnKeyboard/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ type CollapsibleHeaderOnKeyboardProps = {
* component, keyboard, and focused input — e.g. a tab bar below the list.
* The collapse target is reduced by this amount so those elements are not counted twice. */
collapsibleHeaderOffset?: number;

/**
* If true, the header will always collapse on keyboard open,
* regardless if there is enough space for the input above the keyboard.
*/
alwaysCollapseHeaderOnKeyboard?: boolean;
};

// eslint-disable-next-line import/prefer-default-export
Expand Down
3 changes: 3 additions & 0 deletions src/components/Form/FormProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,9 @@ type FormProviderProps<TFormID extends OnyxFormKey = OnyxFormKey> = FormProps<TF

/** Reference to the outer element */
ref?: ForwardedRef<FormRef>;

/** Whether to display the submit button and footer in one row in landscape mode */
shouldDisplaySubmitButtonAndFooterInOneRowInLandscapeMode?: boolean;
};

function FormProvider({
Expand Down
5 changes: 5 additions & 0 deletions src/components/Form/FormWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ type FormWrapperProps = ChildrenProps &
shouldPreventDefaultFocusOnPressSubmit?: boolean;

ref?: ForwardedRef<FormWrapperRef>;

/** Whether to display the submit button and footer in one row in landscape mode */
shouldDisplaySubmitButtonAndFooterInOneRowInLandscapeMode?: boolean;
};

function FormWrapper({
Expand Down Expand Up @@ -112,6 +115,7 @@ function FormWrapper({
forwardedFSClass,
sentryLabel = CONST.SENTRY_LABEL.FORM.SUBMIT_BUTTON,
ref,
shouldDisplaySubmitButtonAndFooterInOneRowInLandscapeMode = false,
}: FormWrapperProps) {
const styles = useThemeStyles();
const formRef = useRef<RNScrollView>(null);
Expand Down Expand Up @@ -217,6 +221,7 @@ function FormWrapper({
shouldBlendOpacity={shouldSubmitButtonBlendOpacity}
shouldPreventDefaultFocusOnPress={shouldPreventDefaultFocusOnPressSubmit}
sentryLabel={sentryLabel}
shouldDisplaySubmitButtonAndFooterInOneRowInLandscapeMode={shouldDisplaySubmitButtonAndFooterInOneRowInLandscapeMode}
/>
);

Expand Down
15 changes: 13 additions & 2 deletions src/components/FormAlertWithSubmitButton.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode';
import useThemeStyles from '@hooks/useThemeStyles';

import getPlatform from '@libs/getPlatform';
Expand Down Expand Up @@ -83,6 +84,9 @@ type FormAlertWithSubmitButtonProps = WithSentryLabel & {

/** Prevents the button from triggering blur on mouse down. */
shouldPreventDefaultFocusOnPress?: boolean;

/** Whether to display the submit button and footer in one row in landscape mode */
shouldDisplaySubmitButtonAndFooterInOneRowInLandscapeMode?: boolean;
};

function FormAlertWithSubmitButton({
Expand All @@ -108,10 +112,17 @@ function FormAlertWithSubmitButton({
shouldBlendOpacity = false,
addButtonBottomPadding = true,
shouldPreventDefaultFocusOnPress = false,
shouldDisplaySubmitButtonAndFooterInOneRowInLandscapeMode = false,
sentryLabel,
}: FormAlertWithSubmitButtonProps) {
const styles = useThemeStyles();
const style = [!shouldRenderFooterAboveSubmit && footerContent && addButtonBottomPadding ? styles.mb3 : {}, buttonStyles];
const isInLandscapeMode = useIsInLandscapeMode();
const shouldDisplayButtonAndFooterInOneRow = isInLandscapeMode && shouldDisplaySubmitButtonAndFooterInOneRowInLandscapeMode;
const style = [
!shouldRenderFooterAboveSubmit && footerContent && addButtonBottomPadding && !shouldDisplayButtonAndFooterInOneRow ? styles.mb3 : undefined,
shouldDisplayButtonAndFooterInOneRow ? styles.flex1 : {},
buttonStyles,
];

// Disable pressOnEnter for Android Native to avoid issues with the Samsung keyboard,
// where pressing Enter saves the form instead of adding a new line in multiline input.
Expand All @@ -129,7 +140,7 @@ function FormAlertWithSubmitButton({
errorMessageStyle={errorMessageStyle}
>
{(isOffline: boolean | undefined) => (
<View>
<View style={shouldDisplayButtonAndFooterInOneRow ? [styles.flexRow, styles.gap3] : undefined}>
{shouldRenderFooterAboveSubmit && footerContent}
{isOffline && !enabledWhenOffline ? (
<Button
Expand Down
40 changes: 29 additions & 11 deletions src/pages/settings/Agents/AddAgentPage.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,25 @@
import AvatarButtonWithIcon from '@components/AvatarButtonWithIcon';
import CollapsibleHeaderOnKeyboard from '@components/CollapsibleHeaderOnKeyboard';
import FormProvider from '@components/Form/FormProvider';
import InputWrapper from '@components/Form/InputWrapper';
import type {FormOnyxValues} from '@components/Form/types';
import type {FormOnyxValues, FormRef} from '@components/Form/types';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import ScreenWrapper from '@components/ScreenWrapper';
import TextInput from '@components/TextInput';

import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode';
import useKeyboardState from '@hooks/useKeyboardState';
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
import useLocalize from '@hooks/useLocalize';
import useStyleUtils from '@hooks/useStyleUtils';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';

import {AGENT_AVATARS} from '@libs/Avatars/AgentAvatarCatalog';
import type {AgentAvatarID} from '@libs/Avatars/AgentAvatarCatalog';
import {isMobile} from '@libs/Browser';
import type {CustomRNImageManipulatorResult} from '@libs/cropOrRotateImage/types';
import isInLandscapeModeUtil from '@libs/isInLandscapeMode';
import Navigation from '@libs/Navigation/Navigation';
import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types';
import type {SettingsNavigatorParamList} from '@libs/Navigation/types';
Expand All @@ -35,24 +38,33 @@ import {useFocusEffect} from '@react-navigation/native';
import React, {useCallback, useRef, useState} from 'react';
import {View} from 'react-native';

import PROMPT_MAX_HEIGHT_ON_KEYBOARD_OPEN_LANDSCAPE_MODE from './const';
import {clearPendingAvatar, getPendingAvatar, setInitialPresetID, setNavigationToken, setReturnRoute} from './pendingAgentAvatarStore';
import scrollToMultilineInput from './scrollToMultilineInput';

type AddAgentPageProps = PlatformStackScreenProps<SettingsNavigatorParamList, typeof SCREENS.SETTINGS.AGENTS.ADD>;

function AddAgentPage({route}: AddAgentPageProps) {
const StyleUtils = useStyleUtils();
const policyID = route.params?.policyID;
const {translate} = useLocalize();
const styles = useThemeStyles();
const {windowWidth, windowHeight} = useWindowDimensions();
const shouldUseScrollableLayout = useIsInLandscapeMode() || (isMobile() && windowWidth > windowHeight);
const {isKeyboardActive} = useKeyboardState();
const isInLandscapeMode = isInLandscapeModeUtil(windowWidth, windowHeight);
const shouldUseScrollableLayout = isInLandscapeMode || (isMobile() && windowWidth > windowHeight);
const shouldShrinkPromptInput = shouldUseScrollableLayout && isKeyboardActive;
const {displayName} = useCurrentUserPersonalDetails();
const defaultAgentName = displayName ? translate('addAgentPage.defaultAgentName', displayName) : undefined;
const defaultPrompt = translate('addAgentPage.defaultPrompt');
const expensifyIcons = useMemoizedLazyExpensifyIcons(['Pencil']);
const avatarStyle = [styles.avatarXLarge, styles.alignSelfCenter];
const [selectedPresetID, setSelectedPresetID] = useState<AgentAvatarID | null>(() => AGENT_AVATARS.ordered.at(Math.floor(Math.random() * AGENT_AVATARS.ordered.length))?.id ?? null);
const [uploadedURI, setUploadedURI] = useState<string | null>(null);
const pendingFileRef = useRef<{file: File | CustomRNImageManipulatorResult; uri: string} | null>(null);
const pendingFileRef = useRef<{
file: File | CustomRNImageManipulatorResult;
uri: string;
} | null>(null);

useFocusEffect(
useCallback(() => {
Expand Down Expand Up @@ -108,17 +120,22 @@ function AddAgentPage({route}: AddAgentPageProps) {
Navigation.goBack();
};

const formWrapperRef = useRef<FormRef>(null);
const handleInputFocus = () => scrollToMultilineInput(formWrapperRef, shouldUseScrollableLayout);

return (
<ScreenWrapper
testID={AddAgentPage.displayName}
includeSafeAreaPaddingBottom
offlineIndicatorStyle={styles.mtAuto}
shouldEnableMaxHeight={shouldUseScrollableLayout}
>
<HeaderWithBackButton
title={translate('addAgentPage.title')}
onBackButtonPress={() => Navigation.goBack()}
/>
<CollapsibleHeaderOnKeyboard alwaysCollapseHeaderOnKeyboard>
<HeaderWithBackButton
title={translate('addAgentPage.title')}
onBackButtonPress={() => Navigation.goBack()}
/>
</CollapsibleHeaderOnKeyboard>
<FormProvider
formID={ONYXKEYS.FORMS.ADD_AGENT_FORM}
onSubmit={handleSubmit}
Expand All @@ -129,6 +146,7 @@ function AddAgentPage({route}: AddAgentPageProps) {
submitFlexEnabled={shouldUseScrollableLayout ? undefined : false}
shouldHideFixErrorsAlert
enabledWhenOffline
ref={formWrapperRef}
>
<View style={[styles.flex1, styles.flexColumn, styles.gap5]}>
<View style={[styles.alignItemsCenter]}>
Expand All @@ -153,7 +171,7 @@ function AddAgentPage({route}: AddAgentPageProps) {
spellCheck={false}
defaultValue={defaultAgentName}
/>
<View style={[styles.flex1, shouldUseScrollableLayout && styles.minHeight42]}>
<View style={shouldShrinkPromptInput ? StyleUtils.getHeight(PROMPT_MAX_HEIGHT_ON_KEYBOARD_OPEN_LANDSCAPE_MODE) : [shouldUseScrollableLayout ? styles.h42 : styles.flex1]}>
<InputWrapper
InputComponent={TextInput}
inputID={INPUT_IDS.PROMPT}
Expand All @@ -162,10 +180,10 @@ function AddAgentPage({route}: AddAgentPageProps) {
role={CONST.ROLE.PRESENTATION}
defaultValue={defaultPrompt}
multiline
containerStyles={[styles.flex1]}
containerStyles={[styles.h100]}
touchableInputWrapperStyle={[styles.flex1]}
textInputContainerStyles={[styles.flex1]}
inputStyle={[styles.flex1, styles.textAlignVerticalTop]}
onFocus={handleInputFocus}
/>
</View>
</View>
Expand Down
30 changes: 19 additions & 11 deletions src/pages/settings/Agents/Fields/EditPromptPage.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import CollapsibleHeaderOnKeyboard from '@components/CollapsibleHeaderOnKeyboard';
import FormProvider from '@components/Form/FormProvider';
import InputWrapper from '@components/Form/InputWrapper';
import type {FormInputErrors, FormOnyxValues} from '@components/Form/types';
Expand All @@ -7,15 +8,19 @@ import TextInput from '@components/TextInput';

import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode';
import useKeyboardShortcut from '@hooks/useKeyboardShortcut';
import useKeyboardState from '@hooks/useKeyboardState';
import useLocalize from '@hooks/useLocalize';
import useOnyx from '@hooks/useOnyx';
import useStyleUtils from '@hooks/useStyleUtils';
import useThemeStyles from '@hooks/useThemeStyles';

import {updateAgentPrompt} from '@libs/actions/Agent';
import Navigation from '@libs/Navigation/Navigation';
import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types';
import type {SettingsNavigatorParamList} from '@libs/Navigation/types';

import PROMPT_MAX_HEIGHT_ON_KEYBOARD_OPEN_LANDSCAPE_MODE from '@pages/settings/Agents/const';

import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type SCREENS from '@src/SCREENS';
Expand All @@ -28,9 +33,12 @@ import {Platform, View} from 'react-native';
type EditPromptPageProps = PlatformStackScreenProps<SettingsNavigatorParamList, typeof SCREENS.SETTINGS.AGENTS.EDIT_PROMPT>;

function EditPromptPage({route}: EditPromptPageProps) {
const StyleUtils = useStyleUtils();
const {translate} = useLocalize();
const styles = useThemeStyles();
const shouldUseScrollableLayout = useIsInLandscapeMode();
const {isKeyboardActive} = useKeyboardState();
const isInLandscapeMode = useIsInLandscapeMode();
const shouldShrinkPromptInput = isInLandscapeMode && isKeyboardActive;
const accountID = route.params.accountID;
const [agentPrompt] = useOnyx(`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${accountID}`);

Expand Down Expand Up @@ -71,27 +79,28 @@ function EditPromptPage({route}: EditPromptPageProps) {
testID={EditPromptPage.displayName}
includeSafeAreaPaddingBottom
offlineIndicatorStyle={styles.mtAuto}
shouldEnableMaxHeight={shouldUseScrollableLayout}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldEnableMaxHeight dropped without an equivalent in EditPromptPage

AddAgentPage.tsx keeps shouldEnableMaxHeight={shouldUseScrollableLayout} on its ScreenWrapper, but this prop was removed entirely here rather than wired to the new isInLandscapeMode value. Was that intentional, or should it mirror AddAgentPage's handling for consistency?

@GCyganek GCyganek Jul 2, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

EditPromptPage is a bit different, as I wanted the input to take the rest of the height in landscape mode, whereas in AddAgentPage in landscape mode the input height is a static value, it was intentional to remove it only in EditPromptPage

>
<HeaderWithBackButton
title={translate('editAgentPromptPage.title')}
onBackButtonPress={() => Navigation.goBack()}
/>
<CollapsibleHeaderOnKeyboard alwaysCollapseHeaderOnKeyboard>
<HeaderWithBackButton
title={translate('editAgentPromptPage.title')}
onBackButtonPress={() => Navigation.goBack()}
/>
</CollapsibleHeaderOnKeyboard>
<FormProvider
formID={ONYXKEYS.FORMS.EDIT_AGENT_PROMPT_FORM}
validate={validate}
onSubmit={handleSubmit}
submitButtonText={translate('common.save')}
style={[styles.flex1, styles.ph5]}
shouldUseScrollView={shouldUseScrollableLayout}
submitFlexEnabled={shouldUseScrollableLayout ? undefined : false}
shouldUseScrollView={false}
submitFlexEnabled={false}
enabledWhenOffline
shouldHideFixErrorsAlert
shouldValidateOnChange
shouldValidateOnBlur
keyboardSubmitBehavior={CONST.KEYBOARD_SUBMIT_BEHAVIOR.SUBMIT_ONLY}
>
<View style={[styles.flex1, shouldUseScrollableLayout && styles.minHeight42]}>
<View style={shouldShrinkPromptInput ? StyleUtils.getHeight(PROMPT_MAX_HEIGHT_ON_KEYBOARD_OPEN_LANDSCAPE_MODE) : [styles.flex1]}>
<InputWrapper
InputComponent={TextInput}
inputID={INPUT_IDS.PROMPT}
Expand All @@ -100,9 +109,8 @@ function EditPromptPage({route}: EditPromptPageProps) {
role={CONST.ROLE.PRESENTATION}
defaultValue={Str.htmlDecode(agentPrompt?.prompt ?? '')}
multiline
containerStyles={[styles.flex1]}
containerStyles={[styles.h100]}
Comment thread
GCyganek marked this conversation as resolved.
touchableInputWrapperStyle={[styles.flex1]}
textInputContainerStyles={[styles.flex1]}
inputStyle={[styles.flex1, styles.textAlignVerticalTop]}
/>
</View>
Expand Down
6 changes: 6 additions & 0 deletions src/pages/settings/Agents/const.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import variables from '@styles/variables';

const MAX_PROMPT_LINES_WITH_KEYBOARD = 2;
const PROMPT_MAX_HEIGHT_ON_KEYBOARD_OPEN_LANDSCAPE_MODE = variables.componentSizeLarge + variables.lineHeightXLarge * (MAX_PROMPT_LINES_WITH_KEYBOARD - 1);

export default PROMPT_MAX_HEIGHT_ON_KEYBOARD_OPEN_LANDSCAPE_MODE;
12 changes: 12 additions & 0 deletions src/pages/settings/Agents/scrollToMultilineInput/index.ios.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type {FormRef} from '@components/Form/types';

import type {RefObject} from 'react';

function scrollToMultilineInput(formWrapperRef: RefObject<FormRef | null>, shouldScrollToMultilineInput: boolean) {
if (!shouldScrollToMultilineInput || !formWrapperRef.current) {
return;
}
formWrapperRef.current.scrollToEnd();
}

export default scrollToMultilineInput;
9 changes: 9 additions & 0 deletions src/pages/settings/Agents/scrollToMultilineInput/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type {FormRef} from '@components/Form/types';

import type {RefObject} from 'react';

// This function is only needed on iOS where there is no auto scroll to the multiline text input on focus.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function scrollToMultilineInput(formWrapperRef: RefObject<FormRef | null>, shouldScrollToMultilineInput: boolean) {}

export default scrollToMultilineInput;
Loading
Loading