diff --git a/src/pages/settings/Profile/AgentAIPromptSection.tsx b/src/pages/settings/Profile/AgentAIPromptSection.tsx index dc8a219300a3..f71a4201bfdb 100644 --- a/src/pages/settings/Profile/AgentAIPromptSection.tsx +++ b/src/pages/settings/Profile/AgentAIPromptSection.tsx @@ -5,12 +5,14 @@ import type {RefObject} from 'react'; import type {ScrollView as RNScrollView, TextInputKeyPressEvent} from 'react-native'; import {Keyboard} from 'react-native'; import Button from '@components/Button'; +import ErrorMessageRow from '@components/ErrorMessageRow'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import Section from '@components/Section'; import TextInput from '@components/TextInput'; import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; import {clearAgentPromptUpdateError, openProfilePage, updateAgentPrompt} from '@libs/actions/Agent'; @@ -47,6 +49,7 @@ function scrollInputIntoView(parentScrollViewRef: RefObject function AgentAIPromptSection({accountID, parentScrollViewRef}: AgentAIPromptSectionProps) { const {translate} = useLocalize(); + const {isOffline} = useNetwork(); const styles = useThemeStyles(); const icons = useMemoizedLazyExpensifyIcons(['Checkmark']); const [agentPrompt] = useOnyx(`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${accountID}`); @@ -95,19 +98,23 @@ function AgentAIPromptSection({accountID, parentScrollViewRef}: AgentAIPromptSec // eslint-disable-next-line react-hooks/exhaustive-deps }, [agentPrompt?.promptErrors]); + const triggerSavedConfirmation = useCallback(() => { + setShowSavedConfirmation(true); + if (savedConfirmationTimerRef.current) { + clearTimeout(savedConfirmationTimerRef.current); + } + savedConfirmationTimerRef.current = setTimeout(() => { + setShowSavedConfirmation(false); + savedConfirmationTimerRef.current = null; + }, SAVED_CONFIRMATION_DURATION_MS); + }, []); + useEffect(() => { if (wasSavingRef.current && !isSaving && !hasPromptErrors) { - setShowSavedConfirmation(true); - if (savedConfirmationTimerRef.current) { - clearTimeout(savedConfirmationTimerRef.current); - } - savedConfirmationTimerRef.current = setTimeout(() => { - setShowSavedConfirmation(false); - savedConfirmationTimerRef.current = null; - }, SAVED_CONFIRMATION_DURATION_MS); + triggerSavedConfirmation(); } wasSavingRef.current = isSaving; - }, [isSaving, hasPromptErrors]); + }, [isSaving, hasPromptErrors, triggerSavedConfirmation]); useEffect(() => { return () => { @@ -131,7 +138,7 @@ function AgentAIPromptSection({accountID, parentScrollViewRef}: AgentAIPromptSec }, [parentScrollViewRef]); const handleSave = () => { - if (isSaving) { + if (isSaving && !isOffline) { return; } @@ -147,6 +154,12 @@ function AgentAIPromptSection({accountID, parentScrollViewRef}: AgentAIPromptSec } dismissInput(); updateAgentPrompt(accountID, trimmed, agentPrompt?.prompt ?? ''); + + // Offline: treat the optimistic write as the final state for UX purposes. The request will be + // replayed on reconnect, so showing "Saved" immediately matches the queued-but-done intent. + if (isOffline) { + triggerSavedConfirmation(); + } }; const handleChangeText = (text: string) => { @@ -172,11 +185,7 @@ function AgentAIPromptSection({accountID, parentScrollViewRef}: AgentAIPromptSec childrenStyles={styles.pt5} titleStyles={styles.accountSettingsSectionTitle} > - clearAgentPromptUpdateError(accountID)} - > + @@ -198,11 +207,16 @@ function AgentAIPromptSection({accountID, parentScrollViewRef}: AgentAIPromptSec text={showSavedConfirmation ? translate('profilePage.aiPromptSection.saved') : translate('common.save')} icon={showSavedConfirmation ? icons.Checkmark : undefined} onPress={handleSave} - isLoading={isSaving} - isDisabled={hasHtmlTag || isSaving} + isLoading={isSaving && !isOffline} + isDisabled={hasHtmlTag || (isSaving && !isOffline)} style={[styles.alignSelfStart]} testID="save-prompt-button" /> + clearAgentPromptUpdateError(accountID)} + /> ); } diff --git a/tests/ui/ProfilePageTest.tsx b/tests/ui/ProfilePageTest.tsx index 304d0ffb6d7c..9a9b8bbd6c38 100644 --- a/tests/ui/ProfilePageTest.tsx +++ b/tests/ui/ProfilePageTest.tsx @@ -343,6 +343,32 @@ describe('ProfilePage - agent account', () => { expect(saveButtonProps.accessibilityState?.disabled).toBe(true); }); + it('allows re-saving an edited prompt while offline even when a previous save is still pending', async () => { + const accountID = 123; + const mockUpdateAgentPrompt = jest.mocked(AgentActions.updateAgentPrompt); + await setupUser('agent_123@expensify.ai'); + + await act(async () => { + // Simulate a first offline save that is queued but not yet replayed: pendingAction stays 'update', + // so isSaving is true. The button is intentionally not disabled while offline. + await Onyx.merge(`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${accountID}`, { + prompt: 'First offline edit.', + pendingAction: 'update', + }); + await Onyx.merge(ONYXKEYS.NETWORK, {shouldForceOffline: true}); + }); + await waitForBatchedUpdatesWithAct(); + + renderPageWithNavigation(SCREENS.SETTINGS.PROFILE.ROOT); + await waitForBatchedUpdatesWithAct(); + + fireEvent.changeText(screen.getByTestId('ai-prompt-input'), 'Second offline edit.'); + fireEvent.press(screen.getByTestId('save-prompt-button')); + await waitForBatchedUpdatesWithAct(); + + expect(mockUpdateAgentPrompt).toHaveBeenCalledWith(accountID, 'Second offline edit.', 'First offline edit.'); + }); + it('does not call updateAgentPrompt when saving blank prompt', async () => { const accountID = 123; const mockUpdateAgentPrompt = jest.mocked(AgentActions.updateAgentPrompt);