diff --git a/src/pages/settings/Agents/DynamicAddAgentPage.tsx b/src/pages/settings/Agents/DynamicAddAgentPage.tsx index 7254ad98bba4..e185049f7436 100644 --- a/src/pages/settings/Agents/DynamicAddAgentPage.tsx +++ b/src/pages/settings/Agents/DynamicAddAgentPage.tsx @@ -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'); @@ -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 ? ( )} { 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); + } }} > { + isPromptFocusedRef.current = false; + }} /> {`${translate('addAgentPage.copilotNote')} ${translate('workspace.rules.agentRules.disclaimer')}`} diff --git a/src/pages/settings/Agents/Fields/DynamicEditPromptPage.tsx b/src/pages/settings/Agents/Fields/DynamicEditPromptPage.tsx index b9f5506a9d2b..0ac450616c7d 100644 --- a/src/pages/settings/Agents/Fields/DynamicEditPromptPage.tsx +++ b/src/pages/settings/Agents/Fields/DynamicEditPromptPage.tsx @@ -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(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): FormInputErrors => { @@ -116,13 +121,18 @@ function DynamicEditPromptPage({route}: DynamicEditPromptPageProps) { > { 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); + } }} > { + isPromptFocusedRef.current = false; + }} /> {translate('workspace.rules.agentRules.disclaimer')} diff --git a/tests/unit/pages/settings/EditPromptPageTest.tsx b/tests/unit/pages/settings/EditPromptPageTest.tsx index 4797947201c6..b127d6f166a0 100644 --- a/tests/unit/pages/settings/EditPromptPageTest.tsx +++ b/tests/unit/pages/settings/EditPromptPageTest.tsx @@ -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; onBlur?: () => void}]>(); + +jest.mock('@pages/settings/Agents/scrollToMultilineInput', () => jest.fn()); + +jest.mock('@hooks/useIsInLandscapeMode', () => jest.fn(() => false)); + jest.mock('@userActions/Agent', () => ({ updateAgentPrompt: jest.fn(), })); @@ -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('react'); + function MockInputWrapper({inputID, defaultValue, ...props}: {inputID: string; defaultValue?: string; onFocus?: () => void; onBlur?: () => void}) { + mockPromptProps(props); + useEffect(() => { + mockPromptMount(); + }, []); return `${inputID}::${defaultValue ?? ''}`; } return MockInputWrapper; @@ -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 = ( + + ); + 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( + , + ); + 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(