Skip to content
Merged
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
32 changes: 23 additions & 9 deletions src/pages/settings/Agents/DynamicAddAgentPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,11 +75,12 @@ function DynamicAddAgentPageContent({route, template}: DynamicAddAgentPageConten
const {windowWidth, windowHeight} = useWindowDimensions();
const {isKeyboardActive} = useKeyboardState();
const isInLandscapeMode = isInLandscapeModeUtil(windowWidth, windowHeight);
// On native portrait the prompt grows with its content up to a max height instead of filling the screen, so the open
// On native the prompt grows with its content up to a max height instead of filling the screen, so the open
// keyboard can't squeeze it or hide the line being edited, and the form scrolls to fit it above the keyboard.
const shouldAutoGrowPromptInput = Platform.OS !== 'web' && !isInLandscapeMode;
const shouldAutoGrowPromptInput = Platform.OS !== 'web';
const shouldUseScrollableLayout = shouldAutoGrowPromptInput || isInLandscapeMode;
const shouldShrinkPromptInput = isInLandscapeMode && isKeyboardActive;
const landscapePromptHeight = shouldShrinkPromptInput ? PROMPT_MAX_HEIGHT_ON_KEYBOARD_OPEN_LANDSCAPE_MODE : styles.h42.height;
const {accountID: currentUserAccountID, login: currentUserLogin, displayName} = useCurrentUserPersonalDetails();
const defaultAgentName = template?.name ?? (displayName ? translate('addAgentPage.defaultAgentName', displayName) : undefined);
const defaultPrompt = template?.prompt ?? translate('addAgentPage.defaultPrompt');
Expand Down Expand Up @@ -237,7 +238,11 @@ function DynamicAddAgentPageContent({route, template}: DynamicAddAgentPageConten
};

const promptTopOffsetRef = useRef(0);
const handleInputFocus = () => scrollToMultilineInput(formRef, shouldUseScrollableLayout, promptTopOffsetRef.current);
const isPromptFocusedRef = useRef(false);
const handleInputFocus = () => {
isPromptFocusedRef.current = true;
scrollToMultilineInput(formRef, shouldUseScrollableLayout, promptTopOffsetRef.current);
};

const agentAvatar = avatarSource ? (
<UserAvatar
Expand Down Expand Up @@ -309,13 +314,18 @@ function DynamicAddAgentPageContent({route, template}: DynamicAddAgentPageConten
</View>
)}
<View
style={
shouldShrinkPromptInput
? StyleUtils.getHeight(PROMPT_MAX_HEIGHT_ON_KEYBOARD_OPEN_LANDSCAPE_MODE)
: [isInLandscapeMode && styles.h42, !isInLandscapeMode && !shouldAutoGrowPromptInput && styles.flex1]
}
style={[
!shouldAutoGrowPromptInput &&
(shouldShrinkPromptInput
? StyleUtils.getHeight(PROMPT_MAX_HEIGHT_ON_KEYBOARD_OPEN_LANDSCAPE_MODE)
: [isInLandscapeMode && styles.h42, !isInLandscapeMode && styles.flex1]),
]}
onLayout={(event) => {
promptTopOffsetRef.current = event.nativeEvent.layout.y;
// Rotation preserves focus, so reveal the prompt again after its new layout is measured.
if (isPromptFocusedRef.current) {
scrollToMultilineInput(formRef, shouldUseScrollableLayout, promptTopOffsetRef.current);
}
}}
>
<InputWrapper
Expand All @@ -329,12 +339,16 @@ function DynamicAddAgentPageContent({route, template}: DynamicAddAgentPageConten
onKeyPress={submitFormOnModEnter}
defaultValue={defaultPrompt}
multiline
// Keep native markdown sizing enabled across rotation so the input retains focus while its height limit changes.
autoGrowHeight={shouldAutoGrowPromptInput}
maxAutoGrowHeight={shouldAutoGrowPromptInput ? PROMPT_MAX_AUTO_GROW_HEIGHT : undefined}
maxAutoGrowHeight={isInLandscapeMode ? landscapePromptHeight : PROMPT_MAX_AUTO_GROW_HEIGHT}
containerStyles={shouldAutoGrowPromptInput ? undefined : [styles.h100]}
touchableInputWrapperStyle={shouldAutoGrowPromptInput ? undefined : [styles.flex1]}
inputStyle={[!shouldAutoGrowPromptInput && styles.flex1, styles.textAlignVerticalTop]}
onFocus={handleInputFocus}
onBlur={() => {
isPromptFocusedRef.current = false;
}}
/>
</View>
<Text style={[styles.textLabelSupporting]}>{`${translate('addAgentPage.copilotNote')} ${translate('workspace.rules.agentRules.disclaimer')}`}</Text>
Expand Down
32 changes: 23 additions & 9 deletions src/pages/settings/Agents/Fields/DynamicEditPromptPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,16 +42,21 @@ function DynamicEditPromptPage({route}: DynamicEditPromptPageProps) {
const styles = useThemeStyles();
const {isKeyboardActive} = useKeyboardState();
const isInLandscapeMode = useIsInLandscapeMode();
// On native portrait the prompt grows with its content up to a max height instead of filling the screen, so the open
// On native the prompt grows with its content up to a max height instead of filling the screen, so the open
// keyboard can't squeeze it or hide the line being edited, and the form scrolls to fit it above the keyboard.
const shouldAutoGrowPromptInput = Platform.OS !== 'web' && !isInLandscapeMode;
const shouldAutoGrowPromptInput = Platform.OS !== 'web';
const shouldUseScrollableLayout = shouldAutoGrowPromptInput || isInLandscapeMode;
const shouldShrinkPromptInput = isInLandscapeMode && isKeyboardActive;
const landscapePromptHeight = shouldShrinkPromptInput ? PROMPT_MAX_HEIGHT_ON_KEYBOARD_OPEN_LANDSCAPE_MODE : styles.h42.height;
const accountID = route.params.accountID;
const [agentPrompt] = useOnyx(`${ONYXKEYS.COLLECTION.AGENT}${accountID}`);
const formRef = useRef<FormRef>(null);
const promptTopOffsetRef = useRef(0);
const scrollToInput = () => scrollToMultilineInput(formRef, shouldUseScrollableLayout, promptTopOffsetRef.current);
const isPromptFocusedRef = useRef(false);
const scrollToInput = () => {
isPromptFocusedRef.current = true;
scrollToMultilineInput(formRef, shouldUseScrollableLayout, promptTopOffsetRef.current);
};
const backPath = useDynamicBackPath(DYNAMIC_ROUTES.AGENT_EDIT_PROMPT.path);

const validate = (values: FormOnyxValues<typeof ONYXKEYS.FORMS.EDIT_AGENT_PROMPT_FORM>): FormInputErrors<typeof ONYXKEYS.FORMS.EDIT_AGENT_PROMPT_FORM> => {
Expand Down Expand Up @@ -116,13 +121,18 @@ function DynamicEditPromptPage({route}: DynamicEditPromptPageProps) {
>
<View style={[styles.flex1, styles.flexColumn, styles.gap5]}>
<View
style={
shouldShrinkPromptInput
? StyleUtils.getHeight(PROMPT_MAX_HEIGHT_ON_KEYBOARD_OPEN_LANDSCAPE_MODE)
: [isInLandscapeMode && styles.h42, !isInLandscapeMode && !shouldAutoGrowPromptInput && styles.flex1]
}
style={[
!shouldAutoGrowPromptInput &&
(shouldShrinkPromptInput
? StyleUtils.getHeight(PROMPT_MAX_HEIGHT_ON_KEYBOARD_OPEN_LANDSCAPE_MODE)
: [isInLandscapeMode && styles.h42, !isInLandscapeMode && styles.flex1]),
]}
onLayout={(event) => {
promptTopOffsetRef.current = event.nativeEvent.layout.y;
// Rotation preserves focus, so reveal the prompt again after its new layout is measured.
if (isPromptFocusedRef.current) {
scrollToMultilineInput(formRef, shouldUseScrollableLayout, promptTopOffsetRef.current);
}
}}
>
<InputWrapper
Expand All @@ -135,12 +145,16 @@ function DynamicEditPromptPage({route}: DynamicEditPromptPageProps) {
excludedMarkdownStyles={['mentionReport']}
defaultValue={Str.htmlDecode(agentPrompt?.prompt ?? '')}
multiline
// Keep native markdown sizing enabled across rotation so the input retains focus while its height limit changes.
autoGrowHeight={shouldAutoGrowPromptInput}
maxAutoGrowHeight={shouldAutoGrowPromptInput ? PROMPT_MAX_AUTO_GROW_HEIGHT : undefined}
maxAutoGrowHeight={isInLandscapeMode ? landscapePromptHeight : PROMPT_MAX_AUTO_GROW_HEIGHT}
containerStyles={shouldAutoGrowPromptInput ? undefined : [styles.h100]}
touchableInputWrapperStyle={shouldAutoGrowPromptInput ? undefined : [styles.flex1]}
inputStyle={[!shouldAutoGrowPromptInput && styles.flex1, styles.textAlignVerticalTop]}
onFocus={scrollToInput}
onBlur={() => {
isPromptFocusedRef.current = false;
}}
/>
</View>
<Text style={[styles.textMicroSupporting, styles.textAlignCenter]}>{translate('workspace.rules.agentRules.disclaimer')}</Text>
Expand Down
79 changes: 77 additions & 2 deletions tests/unit/pages/settings/EditPromptPageTest.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,29 @@
import {render} from '@testing-library/react-native';
import {act, fireEvent, render} from '@testing-library/react-native';

import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode';
import useOnyx from '@hooks/useOnyx';

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

import EditPromptPage from '@pages/settings/Agents/Fields/DynamicEditPromptPage';
import scrollToMultilineInput from '@pages/settings/Agents/scrollToMultilineInput';

import ONYXKEYS from '@src/ONYXKEYS';
import type SCREENS from '@src/SCREENS';

import React from 'react';
import {Platform, View} from 'react-native';

import createMock from '../../../utils/createMock';

const mockPromptMount = jest.fn();
const mockPromptProps = jest.fn<void, [{onFocus?: () => void; onBlur?: () => void}]>();

jest.mock('@pages/settings/Agents/scrollToMultilineInput', () => jest.fn());

jest.mock('@hooks/useIsInLandscapeMode', () => jest.fn(() => false));

jest.mock('@userActions/Agent', () => ({
updateAgentPrompt: jest.fn(),
}));
Expand Down Expand Up @@ -76,7 +86,12 @@ jest.mock('@components/Form/FormProvider', () => {
});

jest.mock('@components/Form/InputWrapper', () => {
function MockInputWrapper({inputID, defaultValue}: {inputID: string; defaultValue?: string}) {
const {useEffect} = jest.requireActual<typeof React>('react');
function MockInputWrapper({inputID, defaultValue, ...props}: {inputID: string; defaultValue?: string; onFocus?: () => void; onBlur?: () => void}) {
mockPromptProps(props);
useEffect(() => {
mockPromptMount();
}, []);
return `${inputID}::${defaultValue ?? ''}`;
}
return MockInputWrapper;
Expand All @@ -98,6 +113,66 @@ describe('EditPromptPage', () => {
mockUseOnyx.mockReturnValue([undefined, {status: 'loaded'}]);
});

it('keeps the prompt mounted when rotating on native', () => {
// Given a native prompt editor in portrait, remounting would discard the input's focus and selection.
const platform = jest.replaceProperty(Platform, 'OS', 'ios');
const mockLandscape = jest.mocked(useIsInLandscapeMode);
mockLandscape.mockReturnValue(false);
const page = (
<EditPromptPage
route={mockRoute}
navigation={mockNavigation}
/>
);
const {rerender} = render(page);
expect(mockPromptMount).toHaveBeenCalledTimes(1);

// When the device rotates to landscape and back while the editor is open.
mockLandscape.mockReturnValue(true);
rerender(React.cloneElement(page));
mockLandscape.mockReturnValue(false);
rerender(React.cloneElement(page));

// Then the original input survives both layout changes, preserving native editing state.
expect(mockPromptMount).toHaveBeenCalledTimes(1);
platform.restore();
});

it('reveals the focused prompt after rotation changes its layout', () => {
// Given an iOS prompt that is being edited, focus will survive rotation.
const platform = jest.replaceProperty(Platform, 'OS', 'ios');
const {UNSAFE_getAllByType: getAllByType} = render(
<EditPromptPage
route={mockRoute}
navigation={mockNavigation}
/>,
);
act(() => mockPromptProps.mock.lastCall?.[0].onFocus?.());
jest.mocked(scrollToMultilineInput).mockClear();
const promptContainer = getAllByType(View).find((view) => {
const child: unknown = view.props.children;
return React.isValidElement<{inputID?: string}>(child) && child.props.inputID === 'prompt';
});
if (!promptContainer) {
throw new Error('Prompt container not found');
}

// When rotation moves the still-focused field, the updated offset must be used without another focus event.
fireEvent(promptContainer, 'layout', {nativeEvent: {layout: {x: 0, y: 120, width: 600, height: 76}}});

// Then the field is revealed using the newly measured position.
expect(scrollToMultilineInput).toHaveBeenLastCalledWith(expect.anything(), true, 120);

// When editing ends, later layout changes should preserve the user's scroll position.
act(() => mockPromptProps.mock.lastCall?.[0].onBlur?.());
jest.mocked(scrollToMultilineInput).mockClear();
fireEvent(promptContainer, 'layout', {nativeEvent: {layout: {x: 0, y: 180, width: 300, height: 200}}});

// Then the page does not scroll to an unfocused field.
expect(scrollToMultilineInput).not.toHaveBeenCalled();
platform.restore();
});

it('renders page title', () => {
const {toJSON} = render(
<EditPromptPage
Expand Down
Loading