diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx index 5f78aea26c5db..0c564c8065cdc 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx @@ -16,7 +16,6 @@ import useNetwork from '@hooks/useNetwork'; import useNewTransactions from '@hooks/useNewTransactions'; import useOnyx from '@hooks/useOnyx'; import usePaginatedReportActions from '@hooks/usePaginatedReportActions'; -import useParentReportAction from '@hooks/useParentReportAction'; import useReportTransactionsCollection from '@hooks/useReportTransactionsCollection'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -25,15 +24,15 @@ import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import Log from '@libs/Log'; import {getAllNonDeletedTransactions, shouldDisplayReportTableView, shouldWaitForTransactions as shouldWaitForTransactionsUtil} from '@libs/MoneyRequestReportUtils'; import navigationRef from '@libs/Navigation/navigationRef'; -import {getFilteredReportActionsForReportView, getOneTransactionThreadReportID, isMoneyRequestAction, isSentMoneyReportAction} from '@libs/ReportActionsUtils'; -import {canEditReportAction, getReportOfflinePendingActionAndErrors, isReportTransactionThread} from '@libs/ReportUtils'; +import {getFilteredReportActionsForReportView, getOneTransactionThreadReportID} from '@libs/ReportActionsUtils'; +import {getReportOfflinePendingActionAndErrors, isReportTransactionThread} from '@libs/ReportUtils'; import {buildCannedSearchQuery} from '@libs/SearchQueryUtils'; import {cancelSpan} from '@libs/telemetry/activeSpans'; import markOpenReportEnd from '@libs/telemetry/markOpenReportEnd'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; import Navigation from '@navigation/Navigation'; import ReportActionsView from '@pages/inbox/report/ReportActionsView'; -import ReportFooter from '@pages/inbox/report/ReportFooter'; +import ReportFooter from '@pages/inbox/ReportFooter'; import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -146,13 +145,8 @@ function MoneyRequestReportView({report, policy, reportMetadata, shouldDisplayRe }, [transactions, isOffline]); const reportTransactionIDs = visibleTransactions.map((transaction) => transaction.transactionID); const transactionThreadReportID = getOneTransactionThreadReportID(report, chatReport, reportActions ?? [], isOffline, reportTransactionIDs); - const isSentMoneyReport = useMemo(() => reportActions.some((action) => isSentMoneyReportAction(action)), [reportActions]); - const newTransactions = useNewTransactions(reportMetadata?.hasOnceLoadedReportActions, transactions); - const parentReportAction = useParentReportAction(report); - - const lastReportAction = [...reportActions, parentReportAction].find((action) => canEditReportAction(action) && !isMoneyRequestAction(action)); const isLoadingInitialReportActions = reportMetadata?.isLoadingInitialReportActions; const dismissReportCreationError = useCallback(() => { goBackFromSearchMoneyRequest(); @@ -246,14 +240,7 @@ function MoneyRequestReportView({report, policy, reportMetadata, shouldDisplayRe - {shouldDisplayReportFooter ? ( - - ) : null} + {shouldDisplayReportFooter ? : null} ); } @@ -307,25 +294,13 @@ function MoneyRequestReportView({report, policy, reportMetadata, shouldDisplayRe /> ) : ( )} {shouldDisplayReportFooter ? ( <> - + ) : null} diff --git a/src/hooks/useAgentZeroStatusIndicator.ts b/src/hooks/useAgentZeroStatusIndicator.ts deleted file mode 100644 index a99893e64c599..0000000000000 --- a/src/hooks/useAgentZeroStatusIndicator.ts +++ /dev/null @@ -1,164 +0,0 @@ -import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; -import {subscribeToReportReasoningEvents, unsubscribeFromReportReasoningChannel} from '@libs/actions/Report'; -import ConciergeReasoningStore from '@libs/ConciergeReasoningStore'; -import type {ReasoningEntry} from '@libs/ConciergeReasoningStore'; -import ONYXKEYS from '@src/ONYXKEYS'; -import useLocalize from './useLocalize'; -import useNetwork from './useNetwork'; -import useOnyx from './useOnyx'; - -type AgentZeroStatusState = { - isProcessing: boolean; - reasoningHistory: ReasoningEntry[]; - statusLabel: string; - kickoffWaitingIndicator: () => void; -}; - -/** - * Hook to manage AgentZero status indicator for chats where AgentZero responds. - * This includes both Concierge DM chats and policy #admins rooms (where Concierge handles onboarding). - * @param reportID - The report ID to monitor - * @param isAgentZeroChat - Whether the chat is an AgentZero-enabled chat (Concierge DM or #admins room) - */ -function useAgentZeroStatusIndicator(reportID: string, isAgentZeroChat: boolean): AgentZeroStatusState { - const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`); - const serverLabel = reportNameValuePairs?.agentZeroProcessingRequestIndicator?.trim() ?? ''; - - const [optimisticStartTime, setOptimisticStartTime] = useState(null); - const [displayedLabel, setDisplayedLabel] = useState(''); - const [reasoningHistory, setReasoningHistory] = useState([]); - const {translate} = useLocalize(); - const prevServerLabelRef = useRef(serverLabel); - const updateTimerRef = useRef(null); - const lastUpdateTimeRef = useRef(0); - const {isOffline} = useNetwork(); - - // Minimum time to display a label before allowing change (prevents rapid flicker) - const MIN_DISPLAY_TIME = 300; // ms - // Debounce delay for server label updates - const DEBOUNCE_DELAY = 150; // ms - - useEffect(() => { - setReasoningHistory(ConciergeReasoningStore.getReasoningHistory(reportID)); - }, [reportID]); - - useEffect(() => { - const unsubscribe = ConciergeReasoningStore.subscribe((updatedReportID, entries) => { - if (updatedReportID !== reportID) { - return; - } - setReasoningHistory(entries); - }); - - return unsubscribe; - }, [reportID]); - - useEffect(() => { - if (!isAgentZeroChat) { - return; - } - - subscribeToReportReasoningEvents(reportID); - - // Cleanup: unsubscribeFromReportReasoningChannel handles Pusher unsubscribing, - // clearing reasoning history from ConciergeReasoningStore, and subscription tracking - return () => { - unsubscribeFromReportReasoningChannel(reportID); - }; - }, [isAgentZeroChat, reportID]); - - useEffect(() => { - const hadServerLabel = !!prevServerLabelRef.current; - const hasServerLabel = !!serverLabel; - - // Helper function to update label with timing control - const updateLabel = (newLabel: string) => { - const now = Date.now(); - const timeSinceLastUpdate = now - lastUpdateTimeRef.current; - const remainingMinTime = Math.max(0, MIN_DISPLAY_TIME - timeSinceLastUpdate); - - // Clear any pending update - if (updateTimerRef.current) { - clearTimeout(updateTimerRef.current); - updateTimerRef.current = null; - } - - // If enough time has passed or it's a critical update (clearing), update immediately - if (remainingMinTime === 0 || newLabel === '') { - if (displayedLabel !== newLabel) { - setDisplayedLabel(newLabel); - lastUpdateTimeRef.current = now; - } - } else { - // Schedule update after debounce + remaining min display time - const delay = DEBOUNCE_DELAY + remainingMinTime; - updateTimerRef.current = setTimeout(() => { - if (displayedLabel !== newLabel) { - setDisplayedLabel(newLabel); - lastUpdateTimeRef.current = Date.now(); - } - updateTimerRef.current = null; - }, delay); - } - }; - - // When server label arrives, transition smoothly without flicker - if (hasServerLabel) { - updateLabel(serverLabel); - if (optimisticStartTime) { - setOptimisticStartTime(null); - } - } - // When optimistic state is active but no server label, show "Concierge is thinking..." - else if (optimisticStartTime) { - const thinkingLabel = translate('common.thinking'); - updateLabel(thinkingLabel); - } - // Clear everything when processing ends - else if (hadServerLabel && !hasServerLabel) { - updateLabel(''); - if (reasoningHistory.length > 0) { - ConciergeReasoningStore.clearReasoning(reportID); - } - } - - prevServerLabelRef.current = serverLabel; - - // Cleanup timer on unmount - return () => { - if (!updateTimerRef.current) { - return; - } - clearTimeout(updateTimerRef.current); - }; - }, [serverLabel, reasoningHistory.length, reportID, optimisticStartTime, translate, displayedLabel]); - - useEffect(() => { - if (isOffline) { - return; - } - setOptimisticStartTime(null); - }, [isOffline]); - - const kickoffWaitingIndicator = useCallback(() => { - if (!isAgentZeroChat) { - return; - } - setOptimisticStartTime(Date.now()); - }, [isAgentZeroChat]); - - const isProcessing = isAgentZeroChat && !isOffline && (!!serverLabel || !!optimisticStartTime); - - return useMemo( - () => ({ - isProcessing, - reasoningHistory, - statusLabel: displayedLabel, - kickoffWaitingIndicator, - }), - [isProcessing, reasoningHistory, displayedLabel, kickoffWaitingIndicator], - ); -} - -export default useAgentZeroStatusIndicator; -export type {AgentZeroStatusState}; diff --git a/src/hooks/useConciergeSidePanelReportActions.ts b/src/hooks/useConciergeSidePanelReportActions.ts index 891896c29dea9..22cdd5c6cfbf2 100644 --- a/src/hooks/useConciergeSidePanelReportActions.ts +++ b/src/hooks/useConciergeSidePanelReportActions.ts @@ -1,11 +1,12 @@ import {useCallback, useMemo, useState} from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; import DateUtils from '@libs/DateUtils'; import {isCreatedAction} from '@libs/ReportActionsUtils'; import {buildConciergeGreetingReportAction} from '@libs/ReportUtils'; import type * as OnyxTypes from '@src/types/onyx'; type UseConciergeSidePanelReportActionsParams = { - report: OnyxTypes.Report; + report: OnyxEntry; reportActions: OnyxTypes.ReportAction[]; visibleReportActions: OnyxTypes.ReportAction[]; isConciergeSidePanel: boolean; @@ -76,8 +77,8 @@ function useConciergeSidePanelReportActions({ if (!showConciergeGreeting) { return undefined; } - return buildConciergeGreetingReportAction(report.reportID, greetingText, report.lastReadTime ?? DateUtils.getDBTime()); - }, [showConciergeGreeting, report.reportID, report.lastReadTime, greetingText]); + return buildConciergeGreetingReportAction(report?.reportID ?? '', greetingText, report?.lastReadTime ?? DateUtils.getDBTime()); + }, [showConciergeGreeting, report?.reportID, report?.lastReadTime, greetingText]); const firstUserMessageCreated = useMemo(() => { if (showConciergeSidePanelWelcome || !isConciergeSidePanel || !hasUserSentMessage || !sessionStartTime) { diff --git a/src/hooks/useShouldSuppressConciergeIndicators.ts b/src/hooks/useShouldSuppressConciergeIndicators.ts new file mode 100644 index 0000000000000..af8703d2a440a --- /dev/null +++ b/src/hooks/useShouldSuppressConciergeIndicators.ts @@ -0,0 +1,36 @@ +import type {OnyxEntry} from 'react-native-onyx'; +import {isCreatedAction} from '@libs/ReportActionsUtils'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {ReportActions} from '@src/types/onyx/ReportAction'; +import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; +import useIsInSidePanel from './useIsInSidePanel'; +import useOnyx from './useOnyx'; +import useSidePanelState from './useSidePanelState'; + +/** + * Returns true when thinking/typing indicators should be hidden in the side-panel + * welcome state — specifically for Concierge DMs before the user sends their first message. + */ +function useShouldSuppressConciergeIndicators(reportID: string | undefined): boolean { + const isInSidePanel = useIsInSidePanel(); + const {sessionStartTime} = useSidePanelState(); + const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); + const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); + + const isConciergeChat = reportID === conciergeReportID; + + const hasUserSentMessageSelector = (actions: OnyxEntry) => { + if (!actions || !sessionStartTime) { + return false; + } + return Object.values(actions).some((action) => !isCreatedAction(action) && action.actorAccountID === currentUserAccountID && action.created >= sessionStartTime); + }; + // eslint-disable-next-line rulesdir/no-inline-useOnyx-selector -- React Compiler handles memoization + const [hasUserSentMessage] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { + selector: hasUserSentMessageSelector, + }); + + return isConciergeChat && isInSidePanel && !hasUserSentMessage; +} + +export default useShouldSuppressConciergeIndicators; diff --git a/src/libs/ConciergeReasoningStore.ts b/src/libs/ConciergeReasoningStore.ts deleted file mode 100644 index 1e945033dd022..0000000000000 --- a/src/libs/ConciergeReasoningStore.ts +++ /dev/null @@ -1,122 +0,0 @@ -/** - * Ephemeral in-memory store for managing Concierge reasoning summaries per report. - * This data is transient UI feedback and is NOT persisted to Onyx. - */ - -type ReasoningEntry = { - reasoning: string; - loopCount: number; - timestamp: number; -}; - -type ReasoningData = { - reasoning: string; - agentZeroRequestID: string; - loopCount: number; -}; - -type ReportState = { - agentZeroRequestID: string; - entries: ReasoningEntry[]; -}; - -type Listener = (reportID: string, state: ReasoningEntry[]) => void; - -// In-memory store -const store = new Map(); -const listeners = new Set(); - -/** - * Notify all subscribers of state changes - */ -function notifyListeners(reportID: string, entries: ReasoningEntry[]) { - for (const listener of listeners) { - listener(reportID, entries); - } -} - -/** - * Add a reasoning entry to a report's history. - * If the agentZeroRequestID differs from the current state, resets all entries (new request). - * Skips duplicates (same loopCount + same reasoning text). - */ -function addReasoning(reportID: string, data: ReasoningData) { - // Ignore empty reasoning strings - if (!data.reasoning.trim()) { - return; - } - - const currentState = store.get(reportID); - - // If agentZeroRequestID differs, reset all entries (new request) - if (currentState && currentState.agentZeroRequestID !== data.agentZeroRequestID) { - store.set(reportID, { - agentZeroRequestID: data.agentZeroRequestID, - entries: [], - }); - } - - // Get or create state - const state = store.get(reportID) ?? { - agentZeroRequestID: data.agentZeroRequestID, - entries: [], - }; - - // Skip duplicates (same loopCount + same reasoning text) - const isDuplicate = state.entries.some((entry) => entry.loopCount === data.loopCount && entry.reasoning === data.reasoning); - - if (!isDuplicate) { - const timestamp = Date.now(); - const newEntries = [ - ...state.entries, - { - reasoning: data.reasoning, - loopCount: data.loopCount, - timestamp, - }, - ]; - const newState = { - agentZeroRequestID: state.agentZeroRequestID, - entries: newEntries, - }; - store.set(reportID, newState); - notifyListeners(reportID, newEntries); - } -} - -/** - * Remove all reasoning entries for a report. - * Called when the final Concierge message arrives or when unsubscribing. - */ -function clearReasoning(reportID: string) { - store.delete(reportID); - notifyListeners(reportID, []); -} - -/** - * Get the reasoning history for a report - */ -function getReasoningHistory(reportID: string): ReasoningEntry[] { - return store.get(reportID)?.entries ?? []; -} - -/** - * Subscribe to state changes. - * Listener receives (reportID, state) on every change. - * Returns an unsubscribe function. - */ -function subscribe(listener: Listener): () => void { - listeners.add(listener); - return () => { - listeners.delete(listener); - }; -} - -export default { - addReasoning, - clearReasoning, - getReasoningHistory, - subscribe, -}; - -export type {ReasoningEntry, ReasoningData}; diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index 01662a0acc1eb..d1cac1c34c31e 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -63,7 +63,6 @@ import {READ_COMMANDS, SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs import * as ApiUtils from '@libs/ApiUtils'; import * as Browser from '@libs/Browser'; import * as CollectionUtils from '@libs/CollectionUtils'; -import ConciergeReasoningStore from '@libs/ConciergeReasoningStore'; import type {CustomRNImageManipulatorResult} from '@libs/cropOrRotateImage/types'; import DateUtils from '@libs/DateUtils'; import * as EmojiUtils from '@libs/EmojiUtils'; @@ -405,9 +404,6 @@ Onyx.connect({ const typingWatchTimers: Record = {}; -// Track subscriptions to conciergeReasoning Pusher events to avoid duplicates -const reasoningSubscriptions = new Set(); - let reportIDDeeplinkedFromOldDot: string | undefined; Linking.getInitialURL().then((url) => { reportIDDeeplinkedFromOldDot = processReportIDDeeplink(url ?? ''); @@ -585,50 +581,6 @@ function unsubscribeFromLeavingRoomReportChannel(reportID: string | undefined) { Pusher.unsubscribe(pusherChannelName, Pusher.TYPE.USER_IS_LEAVING_ROOM); } -/** - * Subscribe to conciergeReasoning Pusher events for a report. - * Tracks subscriptions to avoid duplicates and updates ConciergeReasoningStore with reasoning data. - */ -function subscribeToReportReasoningEvents(reportID: string) { - if (!reportID || reasoningSubscriptions.has(reportID)) { - return; - } - - const pusherChannelName = getReportChannelName(reportID); - - // Add to subscriptions immediately to prevent duplicate subscriptions - reasoningSubscriptions.add(reportID); - - Pusher.subscribe(pusherChannelName, Pusher.TYPE.CONCIERGE_REASONING, (data: Record) => { - const eventData = data as {reasoning: string; agentZeroRequestID: string; loopCount: number}; - - ConciergeReasoningStore.addReasoning(reportID, { - reasoning: eventData.reasoning, - agentZeroRequestID: eventData.agentZeroRequestID, - loopCount: eventData.loopCount, - }); - }).catch((error: ReportError) => { - Log.hmmm('[Report] Failed to subscribe to Pusher concierge reasoning events', {errorType: error.type, pusherChannelName, reportID}); - // Remove from subscriptions if subscription failed - reasoningSubscriptions.delete(reportID); - }); -} - -/** - * Unsubscribe from conciergeReasoning Pusher events for a report. - * Clears reasoning state and removes from subscription tracking. - */ -function unsubscribeFromReportReasoningChannel(reportID: string) { - if (!reportID || !reasoningSubscriptions.has(reportID)) { - return; - } - - const pusherChannelName = getReportChannelName(reportID); - Pusher.unsubscribe(pusherChannelName, Pusher.TYPE.CONCIERGE_REASONING); - ConciergeReasoningStore.clearReasoning(reportID); - reasoningSubscriptions.delete(reportID); -} - // New action subscriber array for report pages let newActionSubscribers: ActionSubscriber[] = []; @@ -7341,14 +7293,12 @@ export { startNewChat, subscribeToNewActionEvent, subscribeToReportLeavingEvents, - subscribeToReportReasoningEvents, subscribeToReportTypingEvents, toggleEmojiReaction, togglePinnedState, toggleSubscribeToChildReport, unsubscribeFromLeavingRoomReportChannel, unsubscribeFromReportChannel, - unsubscribeFromReportReasoningChannel, updateDescription, updateGroupChatAvatar, updatePolicyRoomAvatar, @@ -7379,4 +7329,5 @@ export { setOptimisticTransactionThread, prepareOnyxDataForCleanUpOptimisticParticipants, getGuidedSetupDataForOpenReport, + getReportChannelName, }; diff --git a/src/pages/home/report/ConciergeThinkingMessage.tsx b/src/pages/home/report/ConciergeThinkingMessage.tsx index 80d2ab9ef9882..5a77d0477cc1b 100644 --- a/src/pages/home/report/ConciergeThinkingMessage.tsx +++ b/src/pages/home/report/ConciergeThinkingMessage.tsx @@ -11,13 +11,15 @@ import Text from '@components/Text'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import useShouldSuppressConciergeIndicators from '@hooks/useShouldSuppressConciergeIndicators'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import type {ReasoningEntry} from '@libs/ConciergeReasoningStore'; import DateUtils from '@libs/DateUtils'; import Parser from '@libs/Parser'; import {getDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; +import type {ReasoningEntry} from '@pages/inbox/AgentZeroStatusContext'; +import {useAgentZeroStatus} from '@pages/inbox/AgentZeroStatusContext'; import ReportActionItemMessageHeaderSender from '@pages/inbox/report/ReportActionItemMessageHeaderSender'; import variables from '@styles/variables'; import CONST from '@src/CONST'; @@ -30,15 +32,37 @@ type ConciergeThinkingMessageProps = { /** The report action if available */ action?: OnyxEntry; +}; - /** Reasoning history to display */ - reasoningHistory?: ReasoningEntry[]; +function ConciergeThinkingMessage({report, action}: ConciergeThinkingMessageProps) { + const {isProcessing, reasoningHistory, statusLabel} = useAgentZeroStatus(); + const shouldSuppress = useShouldSuppressConciergeIndicators(report?.reportID); - /** Status label text */ - statusLabel?: string; -}; + if (!isProcessing || shouldSuppress) { + return null; + } + + return ( + + ); +} -function ConciergeThinkingMessage({report, action, reasoningHistory, statusLabel}: ConciergeThinkingMessageProps) { +function ConciergeThinkingMessageContent({ + report, + action, + reasoningHistory, + statusLabel, +}: { + report: OnyxEntry; + action?: OnyxEntry; + reasoningHistory: ReasoningEntry[]; + statusLabel: string; +}) { const styles = useThemeStyles(); const theme = useTheme(); const StyleUtils = useStyleUtils(); diff --git a/src/pages/inbox/AgentZeroStatusContext.tsx b/src/pages/inbox/AgentZeroStatusContext.tsx new file mode 100644 index 0000000000000..8667e4cb20d4b --- /dev/null +++ b/src/pages/inbox/AgentZeroStatusContext.tsx @@ -0,0 +1,265 @@ +import {agentZeroProcessingIndicatorSelector} from '@selectors/ReportNameValuePairs'; +import React, {createContext, useContext, useEffect, useRef, useState} from 'react'; +import type {ValueOf} from 'type-fest'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import useOnyx from '@hooks/useOnyx'; +import {getReportChannelName} from '@libs/actions/Report'; +import Log from '@libs/Log'; +import Pusher from '@libs/Pusher'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +type ReasoningEntry = { + reasoning: string; + loopCount: number; + timestamp: number; +}; + +type AgentZeroStatusState = { + /** Whether AgentZero is actively working — true when the server sent a processing label or we're optimistically waiting */ + isProcessing: boolean; + + /** Chronological list of reasoning steps streamed via Pusher during the current processing request */ + reasoningHistory: ReasoningEntry[]; + + /** Debounced label shown in the thinking bubble (e.g. "Looking up categories...") */ + statusLabel: string; +}; + +type AgentZeroStatusActions = { + /** Sets optimistic "thinking" state immediately after the user sends a message, before the server responds */ + kickoffWaitingIndicator: () => void; +}; + +const defaultState: AgentZeroStatusState = { + isProcessing: false, + reasoningHistory: [], + statusLabel: '', +}; + +const defaultActions: AgentZeroStatusActions = { + kickoffWaitingIndicator: () => {}, +}; + +const AgentZeroStatusStateContext = createContext(defaultState); +const AgentZeroStatusActionsContext = createContext(defaultActions); + +/** + * Cheap outer guard — only subscribes to the scalar CONCIERGE_REPORT_ID. + * For non-AgentZero reports (the common case), returns children directly + * without mounting any Pusher subscriptions or heavy state logic. + * + * AgentZero chats include Concierge DMs and policy #admins rooms. + */ +function AgentZeroStatusProvider({reportID, chatType, children}: React.PropsWithChildren<{reportID: string | undefined; chatType: ValueOf | undefined}>) { + const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); + const isConciergeChat = reportID === conciergeReportID; + const isAdmin = chatType === CONST.REPORT.CHAT_TYPE.POLICY_ADMINS; + const isAgentZeroChat = isConciergeChat || isAdmin; + + if (!reportID || !isAgentZeroChat) { + return children; + } + + return {children}; +} + +/** + * Inner gate — all Pusher, reasoning, label, and processing state. + * Only mounted when reportID matches the Concierge report. + */ +function AgentZeroStatusGate({reportID, children}: React.PropsWithChildren<{reportID: string}>) { + // Server-driven processing label from report name-value pairs (e.g. "Looking up categories...") + const [serverLabel] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`, {selector: agentZeroProcessingIndicatorSelector}); + + // Timestamp set when the user sends a message, before the server label arrives — shows "Concierge is thinking..." + const [optimisticStartTime, setOptimisticStartTime] = useState(null); + // Debounced label shown to the user — smooths rapid server label changes + const [displayedLabel, setDisplayedLabel] = useState(''); + // Chronological list of reasoning steps streamed via Pusher during a single processing request + const [reasoningHistory, setReasoningHistory] = useState([]); + const {translate} = useLocalize(); + // Tracks the previous server label to detect transitions (appeared → cleared) + const prevServerLabelRef = useRef(serverLabel); + // Timer for debounced label updates — ensures a minimum display time before switching + const updateTimerRef = useRef(null); + // Timestamp of the last label update — used to enforce MIN_DISPLAY_TIME + const lastUpdateTimeRef = useRef(0); + const {isOffline} = useNetwork(); + + // Tracks the current agentZeroRequestID so the Pusher callback can detect new requests + const agentZeroRequestIDRef = useRef(''); + + // Minimum time to display a label before allowing change (prevents rapid flicker) + const MIN_DISPLAY_TIME = 300; // ms + // Debounce delay for server label updates + const DEBOUNCE_DELAY = 150; // ms + + /** Appends a reasoning entry from Pusher. Resets history when a new request ID is detected; skips duplicates. */ + const addReasoning = (data: {reasoning: string; agentZeroRequestID: string; loopCount: number}) => { + if (!data.reasoning.trim()) { + return; + } + + const isNewRequest = agentZeroRequestIDRef.current !== data.agentZeroRequestID; + if (isNewRequest) { + agentZeroRequestIDRef.current = data.agentZeroRequestID; + } + + const entry: ReasoningEntry = { + reasoning: data.reasoning, + loopCount: data.loopCount, + timestamp: Date.now(), + }; + + if (isNewRequest) { + setReasoningHistory([entry]); + return; + } + + setReasoningHistory((prev) => { + const isDuplicate = prev.some((e) => e.loopCount === data.loopCount && e.reasoning === data.reasoning); + if (isDuplicate) { + return prev; + } + return [...prev, entry]; + }); + }; + + // Reset all transient state when the viewed report changes + useEffect(() => { + setOptimisticStartTime(null); + setReasoningHistory([]); + agentZeroRequestIDRef.current = ''; + }, [reportID]); + + // Subscribe to Pusher reasoning events for this report's channel + useEffect(() => { + const channelName = getReportChannelName(reportID); + + const listener = Pusher.subscribe(channelName, Pusher.TYPE.CONCIERGE_REASONING, (data: Record) => { + const eventData = data as {reasoning: string; agentZeroRequestID: string; loopCount: number}; + addReasoning(eventData); + }); + listener.catch((error: unknown) => { + Log.hmmm('[AgentZeroStatusGate] Failed to subscribe to Pusher concierge reasoning events', {reportID, error}); + }); + + return () => { + listener.unsubscribe(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- addReasoning is stable (uses only refs + functional updater) + }, [reportID]); + + // Synchronize the displayed label with the server label, applying debounce and minimum display time + useEffect(() => { + const hadServerLabel = !!prevServerLabelRef.current; + const hasServerLabel = !!serverLabel; + + // Helper function to update label with timing control + const updateLabel = (newLabel: string) => { + const now = Date.now(); + const timeSinceLastUpdate = now - lastUpdateTimeRef.current; + const remainingMinTime = Math.max(0, MIN_DISPLAY_TIME - timeSinceLastUpdate); + + // Clear any pending update + if (updateTimerRef.current) { + clearTimeout(updateTimerRef.current); + updateTimerRef.current = null; + } + + // If enough time has passed or it's a critical update (clearing), update immediately + if (remainingMinTime === 0 || newLabel === '') { + if (displayedLabel !== newLabel) { + setDisplayedLabel(newLabel); + lastUpdateTimeRef.current = now; + } + } else { + // Schedule update after debounce + remaining min display time + const delay = DEBOUNCE_DELAY + remainingMinTime; + updateTimerRef.current = setTimeout(() => { + if (displayedLabel !== newLabel) { + setDisplayedLabel(newLabel); + lastUpdateTimeRef.current = Date.now(); + } + updateTimerRef.current = null; + }, delay); + } + }; + + // When server label arrives, transition smoothly without flicker + if (hasServerLabel) { + updateLabel(serverLabel); + if (optimisticStartTime) { + setOptimisticStartTime(null); + } + } + // When optimistic state is active but no server label, show "Concierge is thinking..." + else if (optimisticStartTime) { + const thinkingLabel = translate('common.thinking'); + updateLabel(thinkingLabel); + } + // Clear everything when processing ends + else if (hadServerLabel && !hasServerLabel) { + updateLabel(''); + if (reasoningHistory.length > 0) { + setReasoningHistory([]); + } + } + + prevServerLabelRef.current = serverLabel; + + // Cleanup timer on unmount + return () => { + if (!updateTimerRef.current) { + return; + } + clearTimeout(updateTimerRef.current); + }; + }, [serverLabel, reasoningHistory.length, reportID, optimisticStartTime, translate, displayedLabel]); + + // Clear optimistic state when the network comes back online + useEffect(() => { + if (isOffline) { + return; + } + setOptimisticStartTime(null); + }, [isOffline, reportID]); + + const kickoffWaitingIndicator = () => { + setOptimisticStartTime(Date.now()); + }; + + // True when AgentZero is actively working — either the server sent a label or we're optimistically waiting + const isProcessing = !isOffline && (!!serverLabel || !!optimisticStartTime); + + // eslint-disable-next-line react/jsx-no-constructed-context-values -- React Compiler handles memoization + const stateValue: AgentZeroStatusState = { + isProcessing, + reasoningHistory, + statusLabel: displayedLabel, + }; + + // eslint-disable-next-line react/jsx-no-constructed-context-values -- React Compiler handles memoization + const actionsValue: AgentZeroStatusActions = { + kickoffWaitingIndicator, + }; + + return ( + + {children} + + ); +} + +function useAgentZeroStatus(): AgentZeroStatusState { + return useContext(AgentZeroStatusStateContext); +} + +function useAgentZeroStatusActions(): AgentZeroStatusActions { + return useContext(AgentZeroStatusActionsContext); +} + +export {AgentZeroStatusProvider, useAgentZeroStatus, useAgentZeroStatusActions}; +export type {AgentZeroStatusState, AgentZeroStatusActions, ReasoningEntry}; diff --git a/src/pages/inbox/ReportFooter.tsx b/src/pages/inbox/ReportFooter.tsx new file mode 100644 index 0000000000000..3f118562313a5 --- /dev/null +++ b/src/pages/inbox/ReportFooter.tsx @@ -0,0 +1,118 @@ +import {isBlockedFromChatSelector} from '@selectors/BlockedFromChat'; +import React from 'react'; +import {Keyboard, View} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; +import AnonymousReportFooter from '@components/AnonymousReportFooter'; +import ArchivedReportFooter from '@components/ArchivedReportFooter'; +import Banner from '@components/Banner'; +import BlockedReportFooter from '@components/BlockedReportFooter'; +import OfflineIndicator from '@components/OfflineIndicator'; +import SwipeableView from '@components/SwipeableView'; +import useIsAnonymousUser from '@hooks/useIsAnonymousUser'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import useOnyx from '@hooks/useOnyx'; +import useReportIsArchived from '@hooks/useReportIsArchived'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useThemeStyles from '@hooks/useThemeStyles'; +import { + canUserPerformWriteAction, + canWriteInReport as canWriteInReportUtil, + isAdminsOnlyPostingRoom as isAdminsOnlyPostingRoomUtil, + isArchivedNonExpenseReport, + isPublicRoom, + isSystemChat as isSystemChatUtil, +} from '@libs/ReportUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type * as OnyxTypes from '@src/types/onyx'; +import ReportActionCompose from './report/ReportActionCompose/ReportActionCompose'; +import SystemChatReportFooterMessage from './report/SystemChatReportFooterMessage'; + +const policyRoleSelector = (policy: OnyxEntry) => policy?.role; +const isLoadingInitialReportActionsSelector = (reportMetadata: OnyxEntry) => reportMetadata?.isLoadingInitialReportActions; + +type ReportFooterProps = { + /** The ID of the report */ + reportID: string; +}; + +function ReportFooter({reportID}: ReportFooterProps) { + const styles = useThemeStyles(); + const {isOffline} = useNetwork(); + const {translate} = useLocalize(); + // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth + const {isSmallScreenWidth, shouldUseNarrowLayout} = useResponsiveLayout(); + const expensifyIcons = useMemoizedLazyExpensifyIcons(['Lightbulb']); + + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const [shouldShowComposeInput = false] = useOnyx(ONYXKEYS.SHOULD_SHOW_COMPOSE_INPUT); + const isAnonymousUser = useIsAnonymousUser(); + const [isBlockedFromChat] = useOnyx(ONYXKEYS.NVP_BLOCKED_FROM_CHAT, { + selector: isBlockedFromChatSelector, + }); + const [isComposerFullSize = false] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${reportID}`); + const [policyRole] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`, { + selector: policyRoleSelector, + }); + const [isLoadingInitialReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`, { + selector: isLoadingInitialReportActionsSelector, + }); + const isReportArchived = useReportIsArchived(reportID); + + if (!report) { + return null; + } + + const isUserPolicyAdmin = policyRole === CONST.POLICY.ROLE.ADMIN; + const chatFooterStyles = {...styles.chatFooter, minHeight: !isOffline ? CONST.CHAT_FOOTER_MIN_HEIGHT : 0}; + const isArchivedRoom = isArchivedNonExpenseReport(report, isReportArchived); + + // If a user just signed in and is viewing a public report, optimistically show the composer while loading the report, since they will have write access when the response comes back. + const shouldShowComposerOptimistically = !isAnonymousUser && isPublicRoom(report) && !!isLoadingInitialReportActions; + const canPerformWriteAction = canUserPerformWriteAction(report, isReportArchived) ?? shouldShowComposerOptimistically; + const shouldHideComposer = !canPerformWriteAction || isBlockedFromChat; + const canWriteInReport = canWriteInReportUtil(report); + const isSystemChat = isSystemChatUtil(report); + const isAdminsOnlyPostingRoom = isAdminsOnlyPostingRoomUtil(report); + + return ( + <> + {!!shouldHideComposer && ( + + {isAnonymousUser && !isArchivedRoom && } + {isArchivedRoom && } + {!isArchivedRoom && !!isBlockedFromChat && } + {!isAnonymousUser && !canWriteInReport && isSystemChat && } + {isAdminsOnlyPostingRoom && !isUserPolicyAdmin && !isArchivedRoom && !isAnonymousUser && !isBlockedFromChat && ( + + )} + {!shouldUseNarrowLayout && ( + {shouldHideComposer && } + )} + + )} + {!shouldHideComposer && (!!shouldShowComposeInput || !isSmallScreenWidth) && ( + + + + + + )} + + ); +} + +export default ReportFooter; diff --git a/src/pages/inbox/ReportScreen.tsx b/src/pages/inbox/ReportScreen.tsx index ad8dca6031844..f1c3b86e61746 100644 --- a/src/pages/inbox/ReportScreen.tsx +++ b/src/pages/inbox/ReportScreen.tsx @@ -20,7 +20,6 @@ import ScrollView from '@components/ScrollView'; import useShowWideRHPVersion from '@components/WideRHPContextProvider/useShowWideRHPVersion'; import WideRHPOverlayWrapper from '@components/WideRHPOverlayWrapper'; import useActionListContextValue from '@hooks/useActionListContextValue'; -import useAgentZeroStatusIndicator from '@hooks/useAgentZeroStatusIndicator'; import useAppFocusEvent from '@hooks/useAppFocusEvent'; import useArchivedReportsIdSet from '@hooks/useArchivedReportsIdSet'; import {useCurrentReportIDState} from '@hooks/useCurrentReportID'; @@ -40,7 +39,6 @@ import useReportIsArchived from '@hooks/useReportIsArchived'; import useReportTransactionsCollection from '@hooks/useReportTransactionsCollection'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSidePanelActions from '@hooks/useSidePanelActions'; -import useSidePanelState from '@hooks/useSidePanelState'; import useSubmitToDestinationVisible from '@hooks/useSubmitToDestinationVisible'; import useThemeStyles from '@hooks/useThemeStyles'; import useViewportOffsetTop from '@hooks/useViewportOffsetTop'; @@ -52,21 +50,17 @@ import Navigation, {navigationRef} from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import clearReportNotifications from '@libs/Notification/clearReportNotifications'; import { - getCombinedReportActions, getFilteredReportActionsForReportView, getIOUActionForReportID, getOneTransactionThreadReportID, isCreatedAction, isDeletedParentAction, - isMoneyRequestAction, isReportActionVisible, - isSentMoneyReportAction, isTransactionThread, isWhisperAction, } from '@libs/ReportActionsUtils'; import {getReportName} from '@libs/ReportNameUtils'; import { - canEditReportAction, canUserPerformWriteAction, findLastAccessedReport, getReportOfflinePendingActionAndErrors, @@ -74,7 +68,6 @@ import { isAdminRoom, isAnnounceRoom, isChatThread, - isConciergeChatReport, isGroupChat, isHiddenForCurrentUser, isInvoiceReport, @@ -108,14 +101,15 @@ import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import {reportByIDsSelector} from '@src/selectors/Attributes'; import type * as OnyxTypes from '@src/types/onyx'; -import {getEmptyObject, isEmptyObject} from '@src/types/utils/EmptyObject'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; import AccountManagerBanner from './AccountManagerBanner'; +import {AgentZeroStatusProvider} from './AgentZeroStatusContext'; import DeleteTransactionNavigateBackHandler from './DeleteTransactionNavigateBackHandler'; import HeaderView from './HeaderView'; import useReportWasDeleted from './hooks/useReportWasDeleted'; import ReactionListWrapper from './ReactionListWrapper'; import ReportActionsView from './report/ReportActionsView'; -import ReportFooter from './report/ReportFooter'; +import ReportFooter from './ReportFooter'; import {ActionListContext} from './ReportScreenContext'; type ReportScreenNavigationProps = @@ -302,16 +296,6 @@ function ReportScreen({route, navigation}: ReportScreenProps) { const reportActions = useMemo(() => getFilteredReportActionsForReportView(unfilteredReportActions), [unfilteredReportActions]); const [childReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${linkedAction?.childReportID}`); - const isConciergeSidePanel = useMemo(() => isInSidePanel && isConciergeChatReport(report, conciergeReportID), [isInSidePanel, report, conciergeReportID]); - - const {sessionStartTime} = useSidePanelState(); - - const hasUserSentMessage = useMemo(() => { - if (!isConciergeSidePanel || !sessionStartTime) { - return false; - } - return reportActions.some((action) => !isCreatedAction(action) && action.actorAccountID === currentUserAccountID && action.created >= sessionStartTime); - }, [isConciergeSidePanel, reportActions, currentUserAccountID, sessionStartTime]); const viewportOffsetTop = useViewportOffsetTop(); const {reportPendingAction, reportErrors} = getReportOfflinePendingActionAndErrors(report); @@ -345,10 +329,6 @@ function ReportScreen({route, navigation}: ReportScreenProps) { const transactionThreadReportID = getOneTransactionThreadReportID(report, chatReport, reportActions ?? [], isOffline, reportTransactionIDs); const [transactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`); - const [transactionThreadReportActions = getEmptyObject()] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`); - const combinedReportActions = getCombinedReportActions(reportActions, transactionThreadReportID ?? null, Object.values(transactionThreadReportActions)); - const isSentMoneyReport = useMemo(() => reportActions.some((action) => isSentMoneyReportAction(action)), [reportActions]); - const lastReportAction = [...combinedReportActions, parentReportAction].find((action) => canEditReportAction(action) && !isMoneyRequestAction(action)); const isTopMostReportId = currentReportIDValue === reportIDFromRoute; const didSubscribeToReportLeavingEvents = useRef(false); const isTransactionThreadView = isReportTransactionThread(report); @@ -359,15 +339,6 @@ function ReportScreen({route, navigation}: ReportScreenProps) { const newTransactions = useNewTransactions(reportMetadata?.hasOnceLoadedReportActions, reportTransactions); - const isConciergeChat = isConciergeChatReport(report); - const shouldShowConciergeIndicator = isConciergeChat || isAdminRoom(report); - const { - isProcessing: isConciergeProcessing, - reasoningHistory: conciergeReasoningHistory, - statusLabel: conciergeStatusLabel, - kickoffWaitingIndicator, - } = useAgentZeroStatusIndicator(String(report?.reportID ?? CONST.DEFAULT_NUMBER_ID), shouldShowConciergeIndicator); - const {closeSidePanel} = useSidePanelActions(); useEffect(() => { @@ -1054,56 +1025,33 @@ function ReportScreen({route, navigation}: ReportScreenProps) { )} - - {(!report || shouldWaitForTransactions) && } - {!!report && !shouldDisplayMoneyRequestActionsList && !shouldWaitForTransactions ? ( - - ) : null} - {!!report && shouldDisplayMoneyRequestActionsList && !shouldWaitForTransactions ? ( - - ) : null} - {isCurrentReportLoadedFromOnyx ? ( - - ) : null} - + + {(!report || shouldWaitForTransactions) && } + {!!report && !shouldDisplayMoneyRequestActionsList && !shouldWaitForTransactions ? : null} + {!!report && shouldDisplayMoneyRequestActionsList && !shouldWaitForTransactions ? ( + + ) : null} + {isCurrentReportLoadedFromOnyx ? : null} + + diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index 42080a2063908..907f6210d75e5 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -1,3 +1,5 @@ +import {useRoute} from '@react-navigation/native'; +import {Str} from 'expensify-common'; import lodashDebounce from 'lodash/debounce'; import noop from 'lodash/noop'; import React, {memo, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; @@ -27,30 +29,50 @@ import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; +import usePaginatedReportActions from '@hooks/usePaginatedReportActions'; +import useParentReportAction from '@hooks/useParentReportAction'; import usePreferredPolicy from '@hooks/usePreferredPolicy'; import useReportIsArchived from '@hooks/useReportIsArchived'; +import useReportTransactionsCollection from '@hooks/useReportTransactionsCollection'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useShortMentionsList from '@hooks/useShortMentionsList'; +import useShouldSuppressConciergeIndicators from '@hooks/useShouldSuppressConciergeIndicators'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import {addComment} from '@libs/actions/Report'; +import {createTaskAndNavigate, setNewOptimisticAssignee} from '@libs/actions/Task'; import canFocusInputOnScreenFocus from '@libs/canFocusInputOnScreenFocus'; import ComposerFocusManager from '@libs/ComposerFocusManager'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import DomUtils from '@libs/DomUtils'; import FS from '@libs/Fullstory'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; +import {isEmailPublicDomain} from '@libs/LoginUtils'; +import {getAllNonDeletedTransactions} from '@libs/MoneyRequestReportUtils'; +import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {ReportsSplitNavigatorParamList} from '@libs/Navigation/types'; import {rand64} from '@libs/NumberUtils'; -import {getLinkedTransactionID, getReportAction, isMoneyRequestAction} from '@libs/ReportActionsUtils'; +import {addDomainToShortMention} from '@libs/ParsingUtils'; +import { + getCombinedReportActions, + getFilteredReportActionsForReportView, + getLinkedTransactionID, + getOneTransactionThreadReportID, + getReportAction, + isMoneyRequestAction, + isSentMoneyReportAction, +} from '@libs/ReportActionsUtils'; import { canEditFieldOfMoneyRequest, + canEditReportAction, canShowReportRecipientLocalTime, canUserPerformWriteAction as canUserPerformWriteActionReportUtils, chatIncludesChronos, chatIncludesConcierge, getParentReport, + getReportOfflinePendingActionAndErrors, getReportRecipientAccountIDs, - isAdminRoom, isChatRoom, - isConciergeChatReport, isGroupChat, isInvoiceReport, isReportApproved, @@ -60,7 +82,9 @@ import { } from '@libs/ReportUtils'; import {startSpan} from '@libs/telemetry/activeSpans'; import {getTransactionID, hasReceipt as hasReceiptTransactionUtils} from '@libs/TransactionUtils'; +import {generateAccountID} from '@libs/UserUtils'; import willBlurTextInputOnTapOutsideFunc from '@libs/willBlurTextInputOnTapOutside'; +import {useAgentZeroStatusActions} from '@pages/inbox/AgentZeroStatusContext'; import ParticipantLocalTime from '@pages/inbox/report/ParticipantLocalTime'; import ReportTypingIndicator from '@pages/inbox/report/ReportTypingIndicator'; import {ActionListContext} from '@pages/inbox/ReportScreenContext'; @@ -69,13 +93,13 @@ import {addAttachmentWithComment, setIsComposerFullSize} from '@userActions/Repo import {isBlockedFromConcierge as isBlockedFromConciergeUserAction} from '@userActions/User'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import type SCREENS from '@src/SCREENS'; import type * as OnyxTypes from '@src/types/onyx'; -import type * as OnyxCommon from '@src/types/onyx/OnyxCommon'; import type {FileObject} from '@src/types/utils/Attachment'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import AttachmentPickerWithMenuItems from './AttachmentPickerWithMenuItems'; import ComposerWithSuggestions from './ComposerWithSuggestions'; -import type {ComposerRef, ComposerWithSuggestionsProps} from './ComposerWithSuggestions/ComposerWithSuggestions'; +import type {ComposerRef} from './ComposerWithSuggestions/ComposerWithSuggestions'; import SendButton from './SendButton'; import useAttachmentUploadValidation from './useAttachmentUploadValidation'; @@ -89,37 +113,19 @@ type SuggestionsRef = { getIsSuggestionsMenuVisible: () => boolean; }; -type ReportActionComposeProps = Pick & { - /** A method to call when the form is submitted */ - onSubmit: (newComment: string, reportActionID?: string) => void; - - /** The report currently being looked at */ - report: OnyxEntry; - - /** The ID of the transaction thread report if there is a single transaction */ - transactionThreadReportID?: string; - - /** Report transactions */ - reportTransactions?: OnyxEntry; - - /** The type of action that's pending */ - pendingAction?: OnyxCommon.PendingAction; - - /** A method to call when the input is focus */ - onComposerFocus?: () => void; - - /** A method to call when the input is blur */ - onComposerBlur?: () => void; - - /** Whether the main composer was hidden */ - didHideComposerInput?: boolean; - - /** Whether to hide concierge status indicators (agent zero / typing) in the side panel */ - shouldHideStatusIndicators?: boolean; - /** Function to trigger optimistic waiting indicator for Concierge */ - kickoffWaitingIndicator?: () => void; +type ReportActionComposeProps = { + /** The ID of the report this composer is for */ + reportID: string; }; +function AgentZeroAwareTypingIndicator({reportID}: {reportID: string}) { + const shouldSuppress = useShouldSuppressConciergeIndicators(reportID); + if (shouldSuppress) { + return null; + } + return ; +} + // We want consistent auto focus behavior on input between native and mWeb so we have some auto focus management code that will // prevent auto focus on existing chat for mobile device const shouldFocusInputOnScreenFocus = canFocusInputOnScreenFocus(); @@ -129,21 +135,7 @@ const willBlurTextInputOnTapOutside = willBlurTextInputOnTapOutsideFunc(); // eslint-disable-next-line import/no-mutable-exports let onSubmitAction = noop; -function ReportActionCompose({ - isComposerFullSize = false, - onSubmit, - pendingAction, - report, - reportID, - lastReportAction, - onComposerFocus, - onComposerBlur, - didHideComposerInput, - reportTransactions, - transactionThreadReportID, - shouldHideStatusIndicators = false, - kickoffWaitingIndicator, -}: ReportActionComposeProps) { +function ReportActionCompose({reportID}: ReportActionComposeProps) { const styles = useThemeStyles(); const theme = useTheme(); const {translate} = useLocalize(); @@ -151,13 +143,44 @@ function ReportActionCompose({ const {isSmallScreenWidth, isMediumScreenWidth, shouldUseNarrowLayout} = useResponsiveLayout(); const {isOffline} = useNetwork(); const isInSidePanel = useIsInSidePanel(); + const {kickoffWaitingIndicator} = useAgentZeroStatusActions(); const actionButtonRef = useRef(null); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const personalDetails = usePersonalDetails(); const [blockedFromConcierge] = useOnyx(ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE); const [currentDate] = useOnyx(ONYXKEYS.CURRENT_DATE); const [shouldShowComposeInput = true] = useOnyx(ONYXKEYS.SHOULD_SHOW_COMPOSE_INPUT); + const [shouldShowComposeInputForLatch = false] = useOnyx(ONYXKEYS.SHOULD_SHOW_COMPOSE_INPUT); + const [quickAction] = useOnyx(ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE); + const {availableLoginsList} = useShortMentionsList(); + const currentUserEmail = currentUserPersonalDetails.email ?? ''; const {isRestrictedToPreferredPolicy} = usePreferredPolicy(); + + const route = useRoute>(); + const reportActionIDFromRoute = route?.params?.reportActionID; + + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const [isComposerFullSize = false] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${reportID}`); + + const {reportActions: unfilteredReportActions} = usePaginatedReportActions(reportID, reportActionIDFromRoute); + const filteredReportActions = getFilteredReportActionsForReportView(unfilteredReportActions); + + const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.chatReportID}`); + const allReportTransactions = useReportTransactionsCollection(reportID); + const reportTransactions = getAllNonDeletedTransactions(allReportTransactions, filteredReportActions, isOffline, true); + const visibleTransactions = reportTransactions?.filter((transaction) => isOffline || transaction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); + const reportTransactionIDs = visibleTransactions?.map((t) => t.transactionID); + const isSentMoneyReport = filteredReportActions.some((action) => isSentMoneyReportAction(action)); + const transactionThreadReportID = isSentMoneyReport ? undefined : getOneTransactionThreadReportID(report, chatReport, filteredReportActions, isOffline, reportTransactionIDs); + + const parentReportActionForEdit = useParentReportAction(report); + const [transactionThreadReportActionsRaw] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`); + const transactionThreadReportActions = transactionThreadReportActionsRaw ? Object.values(transactionThreadReportActionsRaw) : []; + const combinedReportActions = getCombinedReportActions(filteredReportActions, transactionThreadReportID ?? null, transactionThreadReportActions); + const lastReportAction = [...combinedReportActions, parentReportActionForEdit].find((action) => canEditReportAction(action) && !isMoneyRequestAction(action)); + + const {reportPendingAction: pendingAction} = getReportOfflinePendingActionAndErrors(report); + const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`); const [initialModalState] = useOnyx(ONYXKEYS.MODAL); const [newParentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`); @@ -190,6 +213,7 @@ function ReportActionCompose({ */ const [isMenuVisible, setMenuVisibility] = useState(false); const [isAttachmentPreviewActive, setIsAttachmentPreviewActive] = useState(false); + const [didHideComposerInput, setDidHideComposerInput] = useState(!shouldShowComposeInputForLatch); /** * Updates the composer when the comment length is exceeded @@ -220,9 +244,6 @@ function ReportActionCompose({ const userBlockedFromConcierge = useMemo(() => isBlockedFromConciergeUserAction(blockedFromConcierge), [blockedFromConcierge]); const isBlockedFromConcierge = useMemo(() => includesConcierge && userBlockedFromConcierge, [includesConcierge, userBlockedFromConcierge]); const isReportArchived = useReportIsArchived(report?.reportID); - const isConciergeChat = useMemo(() => isConciergeChatReport(report), [report]); - const shouldShowConciergeIndicator = isConciergeChat || isAdminRoom(report); - const isTransactionThreadView = useMemo(() => isReportTransactionThread(report), [report]); const isExpensesReport = useMemo(() => reportTransactions && reportTransactions.length > 1, [reportTransactions]); @@ -336,9 +357,7 @@ function ReportActionCompose({ (newComment: string) => { const newCommentTrimmed = newComment.trim(); - if (shouldShowConciergeIndicator && kickoffWaitingIndicator) { - kickoffWaitingIndicator(); - } + kickoffWaitingIndicator(); if (attachmentFileRef.current) { addAttachmentWithComment({ @@ -354,6 +373,50 @@ function ReportActionCompose({ }); attachmentFileRef.current = null; } else { + const taskMatch = newCommentTrimmed.match(CONST.REGEX.TASK_TITLE_WITH_OPTIONAL_SHORT_MENTION); + if (taskMatch) { + let taskTitle = taskMatch[3] ? taskMatch[3].trim().replaceAll('\n', ' ') : undefined; + if (taskTitle) { + const mention = taskMatch[1] ? taskMatch[1].trim() : ''; + const currentUserPrivateDomain = isEmailPublicDomain(currentUserEmail) ? '' : Str.extractEmailDomain(currentUserEmail); + const mentionWithDomain = addDomainToShortMention(mention, availableLoginsList, currentUserPrivateDomain) ?? mention; + const isValidMention = Str.isValidEmail(mentionWithDomain); + + let assignee: OnyxEntry; + let assigneeChatReport; + if (mentionWithDomain) { + if (isValidMention) { + assignee = Object.values(personalDetails ?? {}).find((value) => value?.login === mentionWithDomain) ?? undefined; + if (!Object.keys(assignee ?? {}).length) { + const optimisticDataForNewAssignee = setNewOptimisticAssignee(currentUserPersonalDetails.accountID, { + accountID: generateAccountID(mentionWithDomain), + login: mentionWithDomain, + }); + assignee = optimisticDataForNewAssignee.assignee; + assigneeChatReport = optimisticDataForNewAssignee.assigneeReport; + } + } else { + taskTitle = `@${mentionWithDomain} ${taskTitle}`; + } + } + createTaskAndNavigate({ + parentReport: report, + title: taskTitle, + description: '', + assigneeEmail: assignee?.login ?? '', + currentUserAccountID: currentUserPersonalDetails.accountID, + currentUserEmail, + assigneeAccountID: assignee?.accountID, + assigneeChatReport, + policyID: report?.policyID, + isCreatedUsingMarkdown: true, + quickAction, + ancestors, + }); + return; + } + } + // Pre-generate the reportActionID so we can correlate the Sentry send-message span with the exact message const optimisticReportActionID = rand64(); @@ -369,20 +432,33 @@ function ReportActionCompose({ }, }); } - onSubmit(newCommentTrimmed, optimisticReportActionID); + addComment({ + report: transactionThreadReport ?? report, + notifyReportID: reportID, + ancestors, + text: newCommentTrimmed, + timezoneParam: currentUserPersonalDetails.timezone ?? CONST.DEFAULT_TIME_ZONE, + currentUserAccountID: currentUserPersonalDetails.accountID, + shouldPlaySound: true, + isInSidePanel, + reportActionID: optimisticReportActionID, + }); } }, [ - shouldShowConciergeIndicator, kickoffWaitingIndicator, transactionThreadReport, report, reportID, ancestors, currentUserPersonalDetails.accountID, + currentUserPersonalDetails.timezone, personalDetail.timezone, isInSidePanel, - onSubmit, + currentUserEmail, + availableLoginsList, + personalDetails, + quickAction, scrollOffsetRef, ], ); @@ -392,25 +468,20 @@ function ReportActionCompose({ isKeyboardVisibleWhenShowingModalRef.current = true; }, []); - const onBlur = useCallback( - (event: BlurEvent) => { - const webEvent = event as unknown as FocusEvent; - setIsFocused(false); - onComposerBlur?.(); - if (suggestionsRef.current) { - suggestionsRef.current.resetSuggestions(); - } - if (webEvent.relatedTarget && webEvent.relatedTarget === actionButtonRef.current) { - isKeyboardVisibleWhenShowingModalRef.current = true; - } - }, - [onComposerBlur], - ); + const onBlur = useCallback((event: BlurEvent) => { + const webEvent = event as unknown as FocusEvent; + setIsFocused(false); + if (suggestionsRef.current) { + suggestionsRef.current.resetSuggestions(); + } + if (webEvent.relatedTarget && webEvent.relatedTarget === actionButtonRef.current) { + isKeyboardVisibleWhenShowingModalRef.current = true; + } + }, []); const onFocus = useCallback(() => { setIsFocused(true); - onComposerFocus?.(); - }, [onComposerFocus]); + }, []); useEffect(() => { if (hasExceededMaxTaskTitleLength) { @@ -422,6 +493,15 @@ function ReportActionCompose({ } }, [hasExceededMaxTaskTitleLength, hasExceededMaxCommentLength]); + useEffect(() => { + if (didHideComposerInput || shouldShowComposeInputForLatch) { + return; + } + // This is an intentional one-way latch: once the composer input has been hidden, it stays hidden. + // eslint-disable-next-line react-hooks/set-state-in-effect + setDidHideComposerInput(true); + }, [shouldShowComposeInputForLatch, didHideComposerInput]); + // We are returning a callback here as we want to invoke the method on unmount only useEffect( () => () => { @@ -543,6 +623,10 @@ function ReportActionCompose({ setIsAttachmentPreviewActive, }); + if (!report) { + return null; + } + const fsClass = FS.getChatFSClass(report); return ( @@ -677,7 +761,7 @@ function ReportActionCompose({ ]} > {!shouldUseNarrowLayout && } - {!shouldHideStatusIndicators && } + {!!exceededMaxLength && ( void; - /** If concierge is currently processing a request */ - isConciergeProcessing?: boolean; - - /** Concierge reasoning history for display */ - conciergeReasoningHistory?: ReasoningEntry[]; - - /** Concierge status label */ - conciergeStatusLabel?: string; }; // In the component we are subscribing to the arrival of new actions. @@ -187,9 +178,6 @@ function ReportActionsList({ showHiddenHistory, hasPreviousMessages, onShowPreviousMessages, - isConciergeProcessing = false, - conciergeReasoningHistory, - conciergeStatusLabel, }: ReportActionsListProps) { const prevHasCreatedActionAdded = usePrevious(hasCreatedActionAdded); const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); @@ -838,20 +826,14 @@ function ReportActionsList({ return ( <> - {isConciergeProcessing && ( - - )} + ); - }, [canShowHeader, isConciergeProcessing, report, conciergeReasoningHistory, conciergeStatusLabel, retryLoadNewerChatsError]); + }, [canShowHeader, report, retryLoadNewerChatsError]); const shouldShowSkeleton = isOffline && !sortedVisibleReportActions.some((action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED); diff --git a/src/pages/inbox/report/ReportActionsView.tsx b/src/pages/inbox/report/ReportActionsView.tsx index 605caa66ef4b2..e2da7128a4879 100755 --- a/src/pages/inbox/report/ReportActionsView.tsx +++ b/src/pages/inbox/report/ReportActionsView.tsx @@ -2,23 +2,26 @@ import {useIsFocused, useRoute} from '@react-navigation/native'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {InteractionManager} from 'react-native'; import type {LayoutChangeEvent} from 'react-native'; -import type {OnyxEntry} from 'react-native-onyx'; import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; import useConciergeSidePanelReportActions from '@hooks/useConciergeSidePanelReportActions'; import useCopySelectionHelper from '@hooks/useCopySelectionHelper'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useIsInSidePanel from '@hooks/useIsInSidePanel'; import useLoadReportActions from '@hooks/useLoadReportActions'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; +import usePaginatedReportActions from '@hooks/usePaginatedReportActions'; +import useParentReportAction from '@hooks/useParentReportAction'; import usePendingConciergeResponse from '@hooks/usePendingConciergeResponse'; import usePrevious from '@hooks/usePrevious'; import useReportIsArchived from '@hooks/useReportIsArchived'; +import useReportTransactionsCollection from '@hooks/useReportTransactionsCollection'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useSidePanelState from '@hooks/useSidePanelState'; import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport'; import {getReportPreviewAction} from '@libs/actions/IOU'; import {updateLoadingInitialReportAction} from '@libs/actions/Report'; -import type {ReasoningEntry} from '@libs/ConciergeReasoningStore'; import DateUtils from '@libs/DateUtils'; import getIsReportFullyVisible from '@libs/getIsReportFullyVisible'; import {getAllNonDeletedTransactions} from '@libs/MoneyRequestReportUtils'; @@ -27,7 +30,9 @@ import type {ReportsSplitNavigatorParamList} from '@libs/Navigation/types'; import {generateNewRandomInt, rand64} from '@libs/NumberUtils'; import { getCombinedReportActions, + getFilteredReportActionsForReportView, getMostRecentIOURequestActionID, + getOneTransactionThreadReportID, getOriginalMessage, getSortedReportActionsForDisplay, isCreatedAction, @@ -36,7 +41,15 @@ import { isMoneyRequestAction, isReportActionVisible, } from '@libs/ReportActionsUtils'; -import {buildOptimisticCreatedReportAction, buildOptimisticIOUReportAction, canUserPerformWriteAction, isInvoiceReport, isMoneyRequestReport} from '@libs/ReportUtils'; +import { + buildOptimisticCreatedReportAction, + buildOptimisticIOUReportAction, + canUserPerformWriteAction, + isConciergeChatReport, + isInvoiceReport, + isMoneyRequestReport, + isReportTransactionThread as isReportTransactionThreadUtil, +} from '@libs/ReportUtils'; import markOpenReportEnd from '@libs/telemetry/markOpenReportEnd'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -47,86 +60,62 @@ import ReportActionsList from './ReportActionsList'; import UserTypingEventListener from './UserTypingEventListener'; type ReportActionsViewProps = { - /** The report currently being looked at */ - report: OnyxTypes.Report; - - /** Array of report actions for this report */ - reportActions?: OnyxTypes.ReportAction[]; - - /** The report's parentReportAction */ - parentReportAction: OnyxEntry; - - /** The report metadata loading states */ - isLoadingInitialReportActions?: boolean; - - /** Whether report actions have been successfully loaded at least once */ - hasOnceLoadedReportActions?: boolean; + /** The ID of the report to display actions for */ + reportID: string; - /** The reportID of the transaction thread report associated with this current report, if any */ - // eslint-disable-next-line react/no-unused-prop-types - transactionThreadReportID?: string | null; + /** Callback executed on layout */ + onLayout?: (event: LayoutChangeEvent) => void; +}; - /** If the report has newer actions to load */ - hasNewerActions: boolean; +let listOldID = Math.round(Math.random() * 100); - /** If the report has older actions to load */ - hasOlderActions: boolean; +function ReportActionsView({reportID, onLayout}: ReportActionsViewProps) { + useCopySelectionHelper(); + const {translate} = useLocalize(); + usePendingConciergeResponse(reportID); + const route = useRoute>(); + const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); + const {isOffline} = useNetwork(); - /** If the report is a transaction thread report */ - isReportTransactionThread?: boolean; + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); - /** Whether this is the concierge chat report displayed in the side panel */ - isConciergeSidePanel?: boolean; + const reportActionID = route?.params?.reportActionID; + const {reportActions: unfilteredReportActions, hasNewerActions, hasOlderActions} = usePaginatedReportActions(reportID, reportActionID); + const allReportActions = getFilteredReportActionsForReportView(unfilteredReportActions); - /** Whether the current user has sent a message in this side panel session */ - hasUserSentMessage?: boolean; + const parentReportAction = useParentReportAction(report); - /** DB-time string marking when the current side panel session started */ - sessionStartTime?: string | null; + const [reportMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`); + const isLoadingInitialReportActions = reportMetadata?.isLoadingInitialReportActions; + const hasOnceLoadedReportActions = reportMetadata?.hasOnceLoadedReportActions; - /** Whether Concierge is currently processing */ - isConciergeProcessing?: boolean; + const isInSidePanel = useIsInSidePanel(); + const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); + const isConciergeSidePanel = isInSidePanel && isConciergeChatReport(report, conciergeReportID); - /** Concierge reasoning history */ - conciergeReasoningHistory?: ReasoningEntry[]; + const {sessionStartTime} = useSidePanelState(); - /** Concierge status label */ - conciergeStatusLabel?: string; + const hasUserSentMessage = useMemo(() => { + if (!isConciergeSidePanel || !sessionStartTime) { + return false; + } + return allReportActions.some((action) => !isCreatedAction(action) && action.actorAccountID === currentUserAccountID && action.created >= sessionStartTime); + }, [isConciergeSidePanel, allReportActions, currentUserAccountID, sessionStartTime]); - /** Callback executed on layout */ - onLayout?: (event: LayoutChangeEvent) => void; -}; + const isReportTransactionThread = isReportTransactionThreadUtil(report); -let listOldID = Math.round(Math.random() * 100); + const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.chatReportID}`); + const allReportTransactions = useReportTransactionsCollection(reportID); + const reportTransactionsForThreadID = getAllNonDeletedTransactions(allReportTransactions, allReportActions ?? [], isOffline, true); + const visibleTransactionsForThreadID = reportTransactionsForThreadID?.filter((transaction) => isOffline || transaction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); + const reportTransactionIDsForThread = visibleTransactionsForThreadID?.map((t) => t.transactionID); + const transactionThreadReportID = getOneTransactionThreadReportID(report, chatReport, allReportActions ?? [], isOffline, reportTransactionIDsForThread); -function ReportActionsView({ - report, - parentReportAction, - reportActions: allReportActions, - isLoadingInitialReportActions, - hasOnceLoadedReportActions, - transactionThreadReportID, - hasNewerActions, - hasOlderActions, - isReportTransactionThread, - isConciergeSidePanel = false, - hasUserSentMessage = false, - sessionStartTime = null, - isConciergeProcessing, - conciergeReasoningHistory, - conciergeStatusLabel, - onLayout, -}: ReportActionsViewProps) { - useCopySelectionHelper(); - const {translate} = useLocalize(); - usePendingConciergeResponse(report.reportID); - const route = useRoute>(); - const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); - const isReportArchived = useReportIsArchived(report?.reportID); - const canPerformWriteAction = useMemo(() => canUserPerformWriteAction(report, isReportArchived), [report, isReportArchived]); + const isReportArchived = useReportIsArchived(reportID); + const canPerformWriteAction = canUserPerformWriteAction(report, isReportArchived); const getTransactionThreadReportActions = useCallback( - (reportActions: OnyxEntry): OnyxTypes.ReportAction[] => { + (reportActions: OnyxTypes.ReportActions | undefined): OnyxTypes.ReportAction[] => { return getSortedReportActionsForDisplay(reportActions, canPerformWriteAction, true, undefined, transactionThreadReportID ?? undefined); }, [canPerformWriteAction, transactionThreadReportID], @@ -143,23 +132,17 @@ function ReportActionsView({ const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP); const [visibleReportActionsData] = useOnyx(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS); const prevTransactionThreadReport = usePrevious(transactionThreadReport); - const reportActionID = route?.params?.reportActionID; const prevReportActionID = usePrevious(reportActionID); - const reportPreviewAction = useMemo(() => getReportPreviewAction(report.chatReportID, report.reportID), [report.chatReportID, report.reportID]); + const reportPreviewAction = getReportPreviewAction(report?.chatReportID, report?.reportID); const didLayout = useRef(false); - const {isOffline} = useNetwork(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const isFocused = useIsFocused(); const [isNavigatingToLinkedMessage, setNavigatingToLinkedMessage] = useState(false); const prevShouldUseNarrowLayoutRef = useRef(shouldUseNarrowLayout); - const reportID = report.reportID; - const isReportFullyVisible = useMemo((): boolean => getIsReportFullyVisible(isFocused), [isFocused]); + const isReportFullyVisible = getIsReportFullyVisible(isFocused); const {transactions: reportTransactions} = useTransactionsAndViolationsForReport(reportID); - const reportTransactionIDs = useMemo( - () => getAllNonDeletedTransactions(reportTransactions, allReportActions ?? []).map((transaction) => transaction.transactionID), - [reportTransactions, allReportActions], - ); + const reportTransactionIDs = getAllNonDeletedTransactions(reportTransactions, allReportActions ?? []).map((transaction) => transaction.transactionID); const lastAction = allReportActions?.at(-1); const isInitiallyLoadingTransactionThread = isReportTransactionThread && (!!isLoadingInitialReportActions || (allReportActions ?? [])?.length <= 1); @@ -171,8 +154,8 @@ function ReportActionsView({ if (!reportActionID || !isOffline) { return; } - updateLoadingInitialReportAction(report.reportID); - }, [isOffline, report.reportID, reportActionID]); + updateLoadingInitialReportAction(report?.reportID ?? reportID); + }, [isOffline, report?.reportID, reportID, reportActionID]); // Change the list ID only for comment linking to get the positioning right const listID = useMemo(() => { @@ -217,7 +200,7 @@ function ReportActionsView({ ); }); - if (report.total && moneyRequestActions.length < (reportPreviewAction?.childMoneyRequestCount ?? 0) && isEmptyObject(transactionThreadReport)) { + if (report?.total && moneyRequestActions.length < (reportPreviewAction?.childMoneyRequestCount ?? 0) && isEmptyObject(transactionThreadReport)) { const optimisticIOUAction = buildOptimisticIOUReportAction({ type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, amount: 0, @@ -225,7 +208,7 @@ function ReportActionsView({ comment: '', participants: [], transactionID: rand64(), - iouReportID: report.reportID, + iouReportID: report?.reportID, created: DateUtils.subtractMillisecondsFromDateTime(actions.at(-1)?.created ?? '', 1), }) as OnyxTypes.ReportAction; moneyRequestActions.push(optimisticIOUAction); @@ -250,10 +233,7 @@ function ReportActionsView({ ); const parentReportActionForTransactionThread = useMemo( - () => - isEmptyObject(transactionThreadReportActions) - ? undefined - : (allReportActions?.find((action) => action.reportActionID === transactionThreadReport?.parentReportActionID) as OnyxEntry), + () => (isEmptyObject(transactionThreadReportActions) ? undefined : allReportActions?.find((action) => action.reportActionID === transactionThreadReport?.parentReportActionID)), [allReportActions, transactionThreadReportActions, transactionThreadReport?.parentReportActionID], ); @@ -281,12 +261,12 @@ function ReportActionsView({ [reportActions, isOffline, canPerformWriteAction, reportTransactionIDs, visibleReportActionsData, reportID], ); - const newestReportAction = useMemo(() => reportActions?.at(0), [reportActions]); - const mostRecentIOUReportActionID = useMemo(() => getMostRecentIOURequestActionID(reportActions), [reportActions]); + const newestReportAction = reportActions?.at(0); + const mostRecentIOUReportActionID = getMostRecentIOURequestActionID(reportActions); const lastActionCreated = visibleReportActions.at(0)?.created; const isNewestAction = (actionCreated: string | undefined, lastVisibleActionCreated: string | undefined) => actionCreated && lastVisibleActionCreated ? actionCreated >= lastVisibleActionCreated : actionCreated === lastVisibleActionCreated; - const hasNewestReportAction = isNewestAction(lastActionCreated, report.lastVisibleActionCreated) || isNewestAction(lastActionCreated, transactionThreadReport?.lastVisibleActionCreated); + const hasNewestReportAction = isNewestAction(lastActionCreated, report?.lastVisibleActionCreated) || isNewestAction(lastActionCreated, transactionThreadReport?.lastVisibleActionCreated); const isSingleExpenseReport = reportPreviewAction?.childMoneyRequestCount === 1; const isMissingTransactionThreadReportID = !transactionThreadReport?.reportID; @@ -299,9 +279,7 @@ function ReportActionsView({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [shouldUseNarrowLayout, reportActions, isReportFullyVisible]); - const allReportActionIDs = useMemo(() => { - return allReportActions?.map((action) => action.reportActionID) ?? []; - }, [allReportActions]); + const allReportActionIDs = allReportActions?.map((action) => action.reportActionID) ?? []; const {loadOlderChats, loadNewerChats} = useLoadReportActions({ reportID, @@ -336,19 +314,18 @@ function ReportActionsView({ /** * Runs when the FlatList finishes laying out */ - const recordTimeToMeasureItemLayout = useCallback( - (event: LayoutChangeEvent) => { - onLayout?.(event); - if (didLayout.current) { - return; - } + const recordTimeToMeasureItemLayout = (event: LayoutChangeEvent) => { + onLayout?.(event); + if (didLayout.current) { + return; + } - didLayout.current = true; + didLayout.current = true; + if (report) { markOpenReportEnd(report, {warm: true}); - }, - [report, onLayout], - ); + } + }; // Check if the first report action in the list is the one we're currently linked to const isTheFirstReportActionIsLinked = newestReportAction?.reportActionID === reportActionID; @@ -393,12 +370,16 @@ function ReportActionsView({ const shouldShowSkeleton = shouldShowSkeletonForConciergePanel || shouldShowSkeletonForInitialLoad || shouldShowSkeletonForAppLoad; useEffect(() => { - if (!shouldShowSkeleton) { + if (!shouldShowSkeleton || !report) { return; } markOpenReportEnd(report, {warm: false}); }, [report, shouldShowSkeleton]); + if (!report) { + return ; + } + if (shouldShowSkeleton) { return ; } @@ -430,9 +411,6 @@ function ReportActionsView({ showHiddenHistory={!showFullHistory} hasPreviousMessages={hasPreviousMessages} onShowPreviousMessages={handleShowPreviousMessages} - isConciergeProcessing={isConciergeProcessing && !showConciergeSidePanelWelcome} - conciergeReasoningHistory={conciergeReasoningHistory} - conciergeStatusLabel={conciergeStatusLabel} /> diff --git a/src/pages/inbox/report/ReportFooter.tsx b/src/pages/inbox/report/ReportFooter.tsx deleted file mode 100644 index ee338f26f616e..0000000000000 --- a/src/pages/inbox/report/ReportFooter.tsx +++ /dev/null @@ -1,262 +0,0 @@ -import {isBlockedFromChatSelector} from '@selectors/BlockedFromChat'; -import {Str} from 'expensify-common'; -import React, {useEffect, useState} from 'react'; -import {Keyboard, View} from 'react-native'; -import type {OnyxEntry} from 'react-native-onyx'; -import AnonymousReportFooter from '@components/AnonymousReportFooter'; -import ArchivedReportFooter from '@components/ArchivedReportFooter'; -import Banner from '@components/Banner'; -import BlockedReportFooter from '@components/BlockedReportFooter'; -import OfflineIndicator from '@components/OfflineIndicator'; -import {usePersonalDetails} from '@components/OnyxListItemProvider'; -import SwipeableView from '@components/SwipeableView'; -import useAncestors from '@hooks/useAncestors'; -import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; -import useIsAnonymousUser from '@hooks/useIsAnonymousUser'; -import useIsInSidePanel from '@hooks/useIsInSidePanel'; -import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; -import useLocalize from '@hooks/useLocalize'; -import useNetwork from '@hooks/useNetwork'; -import useOnyx from '@hooks/useOnyx'; -import useReportIsArchived from '@hooks/useReportIsArchived'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useShortMentionsList from '@hooks/useShortMentionsList'; -import useThemeStyles from '@hooks/useThemeStyles'; -import {addComment} from '@libs/actions/Report'; -import {createTaskAndNavigate, setNewOptimisticAssignee} from '@libs/actions/Task'; -import {isEmailPublicDomain} from '@libs/LoginUtils'; -import {addDomainToShortMention} from '@libs/ParsingUtils'; -import { - canUserPerformWriteAction, - canWriteInReport as canWriteInReportUtil, - getReportOfflinePendingActionAndErrors, - isAdminsOnlyPostingRoom as isAdminsOnlyPostingRoomUtil, - isArchivedNonExpenseReport, - isPublicRoom, - isSystemChat as isSystemChatUtil, -} from '@libs/ReportUtils'; -import {generateAccountID} from '@libs/UserUtils'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import type * as OnyxTypes from '@src/types/onyx'; -import ReportActionCompose from './ReportActionCompose/ReportActionCompose'; -import SystemChatReportFooterMessage from './SystemChatReportFooterMessage'; - -const policyRoleSelector = (policy: OnyxEntry) => policy?.role; -const isLoadingInitialReportActionsSelector = (reportMetadata: OnyxEntry) => reportMetadata?.isLoadingInitialReportActions; - -type ReportFooterProps = { - /** Report object for the current report */ - report?: OnyxTypes.Report; - - /** Report transactions */ - reportTransactions?: OnyxEntry; - - /** The ID of the transaction thread report if there is a single transaction */ - transactionThreadReportID?: string; - - /** The last report action */ - lastReportAction?: OnyxEntry; - - /** A method to call when the input is focus */ - onComposerFocus?: () => void; - - /** A method to call when the input is blur */ - onComposerBlur?: () => void; - - /** Whether to hide concierge status indicators (agent zero / typing) in the side panel */ - shouldHideStatusIndicators?: boolean; - /** Function to trigger optimistic waiting indicator for Concierge */ - kickoffWaitingIndicator?: () => void; -}; - -function ReportFooter({ - lastReportAction, - report = {reportID: '-1'}, - onComposerBlur, - onComposerFocus, - reportTransactions, - transactionThreadReportID, - shouldHideStatusIndicators, - kickoffWaitingIndicator, -}: ReportFooterProps) { - const styles = useThemeStyles(); - const {isOffline} = useNetwork(); - const {translate} = useLocalize(); - const isInSidePanel = useIsInSidePanel(); - // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth - const {isSmallScreenWidth, shouldUseNarrowLayout} = useResponsiveLayout(); - const personalDetail = useCurrentUserPersonalDetails(); - const expensifyIcons = useMemoizedLazyExpensifyIcons(['Lightbulb']); - - const [shouldShowComposeInput = false] = useOnyx(ONYXKEYS.SHOULD_SHOW_COMPOSE_INPUT); - const [quickAction] = useOnyx(ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE); - const isAnonymousUser = useIsAnonymousUser(); - const [isBlockedFromChat] = useOnyx(ONYXKEYS.NVP_BLOCKED_FROM_CHAT, { - selector: isBlockedFromChatSelector, - }); - const [isComposerFullSize = false] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${report.reportID}`); - const [policyRole] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`, { - selector: policyRoleSelector, - }); - const [isLoadingInitialReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${report.reportID}`, { - selector: isLoadingInitialReportActionsSelector, - }); - - const {reportPendingAction} = getReportOfflinePendingActionAndErrors(report); - const isUserPolicyAdmin = policyRole === CONST.POLICY.ROLE.ADMIN; - const chatFooterStyles = {...styles.chatFooter, minHeight: !isOffline ? CONST.CHAT_FOOTER_MIN_HEIGHT : 0}; - const isReportArchived = useReportIsArchived(report?.reportID); - const ancestors = useAncestors(report); - const isArchivedRoom = isArchivedNonExpenseReport(report, isReportArchived); - - // If a user just signed in and is viewing a public report, optimistically show the composer while loading the report, since they will have write access when the response comes back. - const shouldShowComposerOptimistically = !isAnonymousUser && isPublicRoom(report) && !!isLoadingInitialReportActions; - const canPerformWriteAction = canUserPerformWriteAction(report, isReportArchived) ?? shouldShowComposerOptimistically; - const shouldHideComposer = !canPerformWriteAction || isBlockedFromChat; - const canWriteInReport = canWriteInReportUtil(report); - const isSystemChat = isSystemChatUtil(report); - const isAdminsOnlyPostingRoom = isAdminsOnlyPostingRoomUtil(report); - - const allPersonalDetails = usePersonalDetails(); - const {availableLoginsList} = useShortMentionsList(); - const currentUserEmail = personalDetail.email ?? ''; - - const handleCreateTask = (text: string): boolean => { - const match = text.match(CONST.REGEX.TASK_TITLE_WITH_OPTIONAL_SHORT_MENTION); - if (!match) { - return false; - } - let title = match[3] ? match[3].trim().replaceAll('\n', ' ') : undefined; - if (!title) { - return false; - } - - const mention = match[1] ? match[1].trim() : ''; - const currentUserPrivateDomain = isEmailPublicDomain(currentUserEmail ?? '') ? '' : Str.extractEmailDomain(currentUserEmail ?? ''); - const mentionWithDomain = addDomainToShortMention(mention, availableLoginsList, currentUserPrivateDomain) ?? mention; - const isValidMention = Str.isValidEmail(mentionWithDomain); - - let assignee: OnyxEntry; - let assigneeChatReport; - if (mentionWithDomain) { - if (isValidMention) { - assignee = Object.values(allPersonalDetails ?? {}).find((value) => value?.login === mentionWithDomain) ?? undefined; - if (!Object.keys(assignee ?? {}).length) { - const optimisticDataForNewAssignee = setNewOptimisticAssignee(personalDetail.accountID, { - accountID: generateAccountID(mentionWithDomain), - login: mentionWithDomain, - }); - assignee = optimisticDataForNewAssignee.assignee; - assigneeChatReport = optimisticDataForNewAssignee.assigneeReport; - } - } else { - // If the mention is not valid, include it on the title. - // The mention could be invalid if it's a short mention and failed to be converted to a full mention. - title = `@${mentionWithDomain} ${title}`; - } - } - createTaskAndNavigate({ - parentReport: report, - title, - description: '', - assigneeEmail: assignee?.login ?? '', - currentUserAccountID: personalDetail.accountID, - currentUserEmail, - assigneeAccountID: assignee?.accountID, - assigneeChatReport, - policyID: report.policyID, - isCreatedUsingMarkdown: true, - quickAction, - ancestors, - }); - return true; - }; - - const [targetReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID ?? report.reportID}`); - const targetReportAncestors = useAncestors(targetReport); - - const onSubmitComment = (text: string, reportActionID?: string) => { - const isTaskCreated = handleCreateTask(text); - if (isTaskCreated) { - return; - } - // If we are adding an action on an expense report that only has a single transaction thread child report, we need to add the action to the transaction thread instead. - // This is because we need it to be associated with the transaction thread and not the expense report in order for conversational corrections to work as expected. - addComment({ - report: targetReport, - notifyReportID: report.reportID, - ancestors: targetReportAncestors, - text, - timezoneParam: personalDetail.timezone ?? CONST.DEFAULT_TIME_ZONE, - currentUserAccountID: personalDetail.accountID, - shouldPlaySound: true, - isInSidePanel, - reportActionID, - }); - }; - - const [didHideComposerInput, setDidHideComposerInput] = useState(!shouldShowComposeInput); - - useEffect(() => { - if (didHideComposerInput || shouldShowComposeInput) { - return; - } - // This is an intentional one-way latch: once the composer input has been hidden, it stays hidden. - // eslint-disable-next-line react-hooks/set-state-in-effect - setDidHideComposerInput(true); - }, [shouldShowComposeInput, didHideComposerInput]); - - return ( - <> - {!!shouldHideComposer && ( - - {isAnonymousUser && !isArchivedRoom && } - {isArchivedRoom && } - {!isArchivedRoom && !!isBlockedFromChat && } - {!isAnonymousUser && !canWriteInReport && isSystemChat && } - {isAdminsOnlyPostingRoom && !isUserPolicyAdmin && !isArchivedRoom && !isAnonymousUser && !isBlockedFromChat && ( - - )} - {!shouldUseNarrowLayout && ( - {shouldHideComposer && } - )} - - )} - {!shouldHideComposer && (!!shouldShowComposeInput || !isSmallScreenWidth) && ( - - - - - - )} - - ); -} - -export default ReportFooter; diff --git a/src/selectors/ReportNameValuePairs.ts b/src/selectors/ReportNameValuePairs.ts index 155a95e7341b8..78aa3a9d27196 100644 --- a/src/selectors/ReportNameValuePairs.ts +++ b/src/selectors/ReportNameValuePairs.ts @@ -6,5 +6,9 @@ import type {ReportNameValuePairs} from '@src/types/onyx'; */ const privateIsArchivedSelector = (reportNameValuePairs: OnyxEntry): string | undefined => reportNameValuePairs?.private_isArchived; -// eslint-disable-next-line import/prefer-default-export -export {privateIsArchivedSelector}; +/** + * Selector that extracts and trims the agentZeroProcessingRequestIndicator value + */ +const agentZeroProcessingIndicatorSelector = (reportNameValuePairs: OnyxEntry): string => reportNameValuePairs?.agentZeroProcessingRequestIndicator?.trim() ?? ''; + +export {privateIsArchivedSelector, agentZeroProcessingIndicatorSelector}; diff --git a/tests/perf-test/ReportActionCompose.perf-test.tsx b/tests/perf-test/ReportActionCompose.perf-test.tsx index 4aa022dfa7495..a370780057a2f 100644 --- a/tests/perf-test/ReportActionCompose.perf-test.tsx +++ b/tests/perf-test/ReportActionCompose.perf-test.tsx @@ -11,7 +11,6 @@ import ComposeProviders from '@src/components/ComposeProviders'; import {LocaleContextProvider} from '@src/components/LocaleContextProvider'; import {KeyboardStateProvider} from '@src/components/withKeyboardState'; import ONYXKEYS from '@src/ONYXKEYS'; -import * as LHNTestUtils from '../utils/LHNTestUtils'; import {translateLocal} from '../utils/TestHelper'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; @@ -82,12 +81,7 @@ beforeEach(() => { function ReportActionComposeWrapper() { return ( - jest.fn()} - reportID="1" - report={LHNTestUtils.getFakeReport()} - isComposerFullSize - /> + ); } diff --git a/tests/ui/ReportActionComposeTest.tsx b/tests/ui/ReportActionComposeTest.tsx index a9eecd42f6495..e2f66f89baf86 100644 --- a/tests/ui/ReportActionComposeTest.tsx +++ b/tests/ui/ReportActionComposeTest.tsx @@ -27,6 +27,12 @@ jest.mock('@hooks/useLocalize', () => })), ); +jest.mock('@hooks/usePaginatedReportActions', () => jest.fn(() => ({reportActions: [], hasNewerActions: false, hasOlderActions: false}))); +jest.mock('@hooks/useParentReportAction', () => jest.fn(() => null)); +jest.mock('@hooks/useReportTransactionsCollection', () => jest.fn(() => ({}))); +jest.mock('@hooks/useShortMentionsList', () => jest.fn(() => ({availableLoginsList: []}))); +jest.mock('@hooks/useSidePanelState', () => jest.fn(() => ({sessionStartTime: null}))); + jest.mock('@react-navigation/native', () => ({ ...((): typeof NativeNavigation => { return jest.requireActual('@react-navigation/native'); @@ -43,10 +49,7 @@ TestHelper.setupGlobalFetchMock(); const defaultReport = LHNTestUtils.getFakeReport(); const defaultProps: ReportActionComposeProps = { - onSubmit: jest.fn(), - isComposerFullSize: false, reportID: defaultReport.reportID, - report: defaultReport, }; const renderReportActionCompose = (props?: Partial) => { @@ -77,6 +80,12 @@ describe('ReportActionCompose Integration Tests', () => { }); }); + beforeEach(async () => { + await act(async () => { + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${defaultReport.reportID}`, defaultReport); + }); + }); + afterEach(async () => { await act(async () => { await Onyx.clear(); diff --git a/tests/ui/ReportActionsViewTest.tsx b/tests/ui/ReportActionsViewTest.tsx index 33df561d796b8..059b0b8e5ab03 100644 --- a/tests/ui/ReportActionsViewTest.tsx +++ b/tests/ui/ReportActionsViewTest.tsx @@ -2,11 +2,15 @@ import type * as ReactNavigation from '@react-navigation/native'; import {render, screen} from '@testing-library/react-native'; import React from 'react'; import Onyx from 'react-native-onyx'; -import type {OnyxEntry} from 'react-native-onyx'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useIsInSidePanel from '@hooks/useIsInSidePanel'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; +import usePaginatedReportActions from '@hooks/usePaginatedReportActions'; +import useParentReportAction from '@hooks/useParentReportAction'; +import useReportTransactionsCollection from '@hooks/useReportTransactionsCollection'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useSidePanelState from '@hooks/useSidePanelState'; import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport'; import DateUtils from '@libs/DateUtils'; // eslint-disable-next-line no-restricted-syntax -- disabled because we need ReportActionsUtils to mock @@ -36,11 +40,44 @@ jest.mock('@hooks/useNetwork', () => jest.fn()); jest.mock('@hooks/useOnyx', () => jest.fn()); jest.mock('@hooks/useResponsiveLayout', () => jest.fn()); jest.mock('@hooks/useTransactionsAndViolationsForReport', () => jest.fn()); +jest.mock('@hooks/usePaginatedReportActions', () => jest.fn()); +jest.mock('@hooks/useParentReportAction', () => jest.fn()); +jest.mock('@hooks/useIsInSidePanel', () => jest.fn()); +jest.mock('@hooks/useSidePanelState', () => jest.fn()); +jest.mock('@hooks/useReportTransactionsCollection', () => jest.fn()); const mockUseNetwork = useNetwork as jest.MockedFunction; const mockUseOnyx = useOnyx as jest.MockedFunction; const mockUseResponsiveLayout = useResponsiveLayout as jest.MockedFunction; const mockUseTransactionsAndViolationsForReport = useTransactionsAndViolationsForReport as jest.MockedFunction; +const mockUsePaginatedReportActions = usePaginatedReportActions as jest.MockedFunction; +const mockUseParentReportAction = useParentReportAction as jest.MockedFunction; +const mockUseIsInSidePanel = useIsInSidePanel as jest.MockedFunction; +const mockUseSidePanelState = useSidePanelState as jest.MockedFunction; +const mockUseReportTransactionsCollection = useReportTransactionsCollection as jest.MockedFunction; + +const defaultPaginatedReportActionsResult: ReturnType = { + reportActions: [], + linkedAction: undefined, + oldestUnreadReportAction: undefined, + sortedAllReportActions: undefined, + hasNewerActions: false, + hasOlderActions: false, + report: undefined, +}; + +const defaultSidePanelState: ReturnType = { + sessionStartTime: null, + isSidePanelTransitionEnded: false, + isSidePanelHiddenOrLargeScreen: true, + shouldHideSidePanel: true, + shouldHideSidePanelBackdrop: true, + shouldHideHelpButton: false, + shouldHideToolTip: false, + sidePanelOffset: {current: null} as React.RefObject, + sidePanelTranslateX: {current: null} as React.RefObject, +}; + jest.mock('@hooks/useCopySelectionHelper', () => jest.fn()); jest.mock('@hooks/useCurrentUserPersonalDetails', () => jest.fn()); jest.mock('@hooks/useLoadReportActions', () => @@ -108,34 +145,9 @@ const mockReportActions: OnyxTypes.ReportAction[] = [ }, ]; -const renderReportActionsView = ( - props: Partial<{ - report: OnyxTypes.Report; - reportActions?: OnyxTypes.ReportAction[]; - parentReportAction: OnyxEntry; - isLoadingInitialReportActions?: boolean; - hasOnceLoadedReportActions?: boolean; - transactionThreadReportID?: string | null; - hasNewerActions: boolean; - hasOlderActions: boolean; - isConciergeSidePanel?: boolean; - hasUserSentMessage?: boolean; - sessionStartTime?: string | null; - }> = {}, -) => { - const defaultProps = { - report: mockReport, - reportActions: mockReportActions, - parentReportAction: null as unknown as OnyxEntry, - isLoadingInitialReportActions: false, - hasOnceLoadedReportActions: true, - hasNewerActions: false, - hasOlderActions: false, - ...props, - }; - - // eslint-disable-next-line react/jsx-props-no-spreading - return render(); +const renderReportActionsView = (props: {reportID?: string} = {}) => { + const reportID = props.reportID ?? mockReport.reportID; + return render(); }; describe('ReportActionsView', () => { @@ -172,6 +184,16 @@ describe('ReportActionsView', () => { violations: {}, }); + mockUsePaginatedReportActions.mockReturnValue({ + ...defaultPaginatedReportActionsResult, + reportActions: mockReportActions, + }); + + mockUseParentReportAction.mockReturnValue(undefined as ReturnType); + mockUseIsInSidePanel.mockReturnValue(false); + mockUseSidePanelState.mockReturnValue(defaultSidePanelState); + mockUseReportTransactionsCollection.mockReturnValue({}); + mockUseOnyx.mockImplementation((key: string) => { if (key === ONYXKEYS.IS_LOADING_APP) { return [false, {status: 'loaded'}]; @@ -179,9 +201,15 @@ describe('ReportActionsView', () => { if (key === ONYXKEYS.ARE_TRANSLATIONS_LOADING) { return [false, {status: 'loaded'}]; } + if (key.includes('reportMetadata')) { + return [{isLoadingInitialReportActions: false, hasOnceLoadedReportActions: true}, {status: 'loaded'}]; + } if (key.includes('reportActions')) { return [[], {status: 'loaded'}]; } + if (key === `${ONYXKEYS.COLLECTION.REPORT}${mockReport.reportID}`) { + return [mockReport, {status: 'loaded'}]; + } if (key.includes('report')) { return [undefined, {status: 'loaded'}]; } @@ -207,9 +235,15 @@ describe('ReportActionsView', () => { if (key === ONYXKEYS.ARE_TRANSLATIONS_LOADING) { return [false, {status: 'loaded'}]; } + if (key.includes('reportMetadata')) { + return [{isLoadingInitialReportActions: false, hasOnceLoadedReportActions: true}, {status: 'loaded'}]; + } if (key.includes('reportActions')) { return [[], {status: 'loaded'}]; } + if (key === `${ONYXKEYS.COLLECTION.REPORT}${mockReport.reportID}`) { + return [mockReport, {status: 'loaded'}]; + } if (key.includes('report')) { return [undefined, {status: 'loaded'}]; } @@ -217,10 +251,12 @@ describe('ReportActionsView', () => { }); // Empty report actions to trigger isMissingReportActions condition - renderReportActionsView({ - reportActions: [], + mockUsePaginatedReportActions.mockReturnValue({ + ...defaultPaginatedReportActionsResult, }); + renderReportActionsView(); + expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy(); }); @@ -236,9 +272,15 @@ describe('ReportActionsView', () => { if (key === ONYXKEYS.ARE_TRANSLATIONS_LOADING) { return [false, {status: 'loaded'}]; } + if (key.includes('reportMetadata')) { + return [{isLoadingInitialReportActions: false, hasOnceLoadedReportActions: true}, {status: 'loaded'}]; + } if (key.includes('reportActions')) { return [[], {status: 'loaded'}]; } + if (key === `${ONYXKEYS.COLLECTION.REPORT}${mockReport.reportID}`) { + return [mockReport, {status: 'loaded'}]; + } if (key.includes('report')) { return [undefined, {status: 'loaded'}]; } @@ -262,9 +304,15 @@ describe('ReportActionsView', () => { if (key === ONYXKEYS.ARE_TRANSLATIONS_LOADING) { return [false, {status: 'loaded'}]; } + if (key.includes('reportMetadata')) { + return [{isLoadingInitialReportActions: false, hasOnceLoadedReportActions: true}, {status: 'loaded'}]; + } if (key.includes('reportActions')) { return [[], {status: 'loaded'}]; } + if (key === `${ONYXKEYS.COLLECTION.REPORT}${mockReport.reportID}`) { + return [mockReport, {status: 'loaded'}]; + } if (key.includes('report')) { return [undefined, {status: 'loaded'}]; } @@ -288,9 +336,15 @@ describe('ReportActionsView', () => { if (key === ONYXKEYS.ARE_TRANSLATIONS_LOADING) { return [false, {status: 'loaded'}]; } + if (key.includes('reportMetadata')) { + return [{isLoadingInitialReportActions: false, hasOnceLoadedReportActions: true}, {status: 'loaded'}]; + } if (key.includes('reportActions')) { return [[], {status: 'loaded'}]; } + if (key === `${ONYXKEYS.COLLECTION.REPORT}${mockReport.reportID}`) { + return [mockReport, {status: 'loaded'}]; + } if (key.includes('report')) { return [undefined, {status: 'loaded'}]; } @@ -348,9 +402,15 @@ describe('ReportActionsView', () => { if (key === ONYXKEYS.ARE_TRANSLATIONS_LOADING) { return [false, {status: 'loaded'}]; } + if (key.includes('reportMetadata')) { + return [{isLoadingInitialReportActions: false, hasOnceLoadedReportActions: true}, {status: 'loaded'}]; + } if (key.includes('reportActions')) { return [[], {status: 'loaded'}]; } + if (key === `${ONYXKEYS.COLLECTION.REPORT}${CONCIERGE_REPORT_ID}`) { + return [{...mockReport, reportID: CONCIERGE_REPORT_ID}, {status: 'loaded'}]; + } if (key.includes('report')) { return [undefined, {status: 'loaded'}]; } @@ -361,13 +421,14 @@ describe('ReportActionsView', () => { it('should show only greeting and created action when opened in side panel with no user messages', () => { setupConciergeMocks(); - renderReportActionsView({ - report: {...mockReport, reportID: CONCIERGE_REPORT_ID}, + mockUsePaginatedReportActions.mockReturnValue({ + ...defaultPaginatedReportActionsResult, reportActions: oldReportActions, - isConciergeSidePanel: true, - hasUserSentMessage: false, - sessionStartTime: DateUtils.getDBTime(), }); + mockUseIsInSidePanel.mockReturnValue(true); + mockUseSidePanelState.mockReturnValue({...defaultSidePanelState, sessionStartTime: DateUtils.getDBTime()}); + + renderReportActionsView({reportID: CONCIERGE_REPORT_ID}); expect(mockReportActionsList).toHaveBeenCalled(); const passedActions = (mockReportActionsList.mock.calls.at(0) as [{sortedVisibleReportActions: OnyxTypes.ReportAction[]}]).at(0)?.sortedVisibleReportActions; @@ -378,11 +439,13 @@ describe('ReportActionsView', () => { it('should not show welcome state when not in side panel', () => { setupConciergeMocks(); - renderReportActionsView({ - report: {...mockReport, reportID: CONCIERGE_REPORT_ID}, + mockUsePaginatedReportActions.mockReturnValue({ + ...defaultPaginatedReportActionsResult, reportActions: oldReportActions, - isConciergeSidePanel: false, }); + mockUseIsInSidePanel.mockReturnValue(false); + + renderReportActionsView({reportID: CONCIERGE_REPORT_ID}); expect(mockReportActionItemCreated).not.toHaveBeenCalled(); }); @@ -390,11 +453,13 @@ describe('ReportActionsView', () => { it('should not show welcome state for non-concierge reports in side panel', () => { setupConciergeMocks(); - renderReportActionsView({ - report: {...mockReport, reportID: 'non-concierge-999'}, + mockUsePaginatedReportActions.mockReturnValue({ + ...defaultPaginatedReportActionsResult, reportActions: oldReportActions, - isConciergeSidePanel: false, }); + mockUseIsInSidePanel.mockReturnValue(false); + + renderReportActionsView({reportID: 'non-concierge-999'}); expect(mockReportActionItemCreated).not.toHaveBeenCalled(); }); @@ -420,13 +485,14 @@ describe('ReportActionsView', () => { }, ]; - renderReportActionsView({ - report: {...mockReport, reportID: CONCIERGE_REPORT_ID}, + mockUsePaginatedReportActions.mockReturnValue({ + ...defaultPaginatedReportActionsResult, reportActions: actionsWithNewMessage, - isConciergeSidePanel: true, - hasUserSentMessage: true, - sessionStartTime: sessionStart, }); + mockUseIsInSidePanel.mockReturnValue(true); + mockUseSidePanelState.mockReturnValue({...defaultSidePanelState, sessionStartTime: sessionStart}); + + renderReportActionsView({reportID: CONCIERGE_REPORT_ID}); // Welcome should not be shown since user has sent a message expect(mockReportActionItemCreated).not.toHaveBeenCalled(); diff --git a/tests/unit/useAgentZeroStatusIndicatorTest.ts b/tests/unit/AgentZeroStatusContextTest.ts similarity index 50% rename from tests/unit/useAgentZeroStatusIndicatorTest.ts rename to tests/unit/AgentZeroStatusContextTest.ts index 13bb7f8a1825c..0f6f64db4c9e0 100644 --- a/tests/unit/useAgentZeroStatusIndicatorTest.ts +++ b/tests/unit/AgentZeroStatusContextTest.ts @@ -1,9 +1,8 @@ import {act, renderHook, waitFor} from '@testing-library/react-native'; +import React from 'react'; import Onyx from 'react-native-onyx'; -import useAgentZeroStatusIndicator from '@hooks/useAgentZeroStatusIndicator'; -import {subscribeToReportReasoningEvents, unsubscribeFromReportReasoningChannel} from '@libs/actions/Report'; -import ConciergeReasoningStore from '@libs/ConciergeReasoningStore'; -import type {ReasoningEntry} from '@libs/ConciergeReasoningStore'; +import Pusher from '@libs/Pusher'; +import {AgentZeroStatusProvider, useAgentZeroStatus, useAgentZeroStatusActions} from '@pages/inbox/AgentZeroStatusContext'; import ONYXKEYS from '@src/ONYXKEYS'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; @@ -22,27 +21,46 @@ jest.mock('@hooks/useLocalize', () => ({ }), })); -jest.mock('@libs/actions/Report'); -jest.mock('@libs/ConciergeReasoningStore'); +jest.mock('@libs/Pusher'); -const mockSubscribeToReportReasoningEvents = subscribeToReportReasoningEvents as jest.MockedFunction; -const mockUnsubscribeFromReportReasoningChannel = unsubscribeFromReportReasoningChannel as jest.MockedFunction; -const mockConciergeReasoningStore = ConciergeReasoningStore as jest.Mocked; +const mockPusher = Pusher as jest.Mocked; -describe('useAgentZeroStatusIndicator', () => { - const reportID = '123'; +type PusherCallback = (data: Record) => void; +/** Captures the reasoning callback passed to Pusher.subscribe for CONCIERGE_REASONING */ +function capturePusherCallback(): PusherCallback { + const call = mockPusher.subscribe.mock.calls.find((c) => c.at(1) === Pusher.TYPE.CONCIERGE_REASONING); + const callback = call?.at(2) as PusherCallback | undefined; + if (!callback) { + throw new Error('Pusher.subscribe was not called for CONCIERGE_REASONING'); + } + return callback; +} + +/** Simulates a Pusher reasoning event */ +function simulateReasoning(data: {reasoning: string; agentZeroRequestID: string; loopCount: number}) { + const callback = capturePusherCallback(); + callback(data as unknown as Record); +} + +const reportID = '123'; + +function wrapper({children}: {children: React.ReactNode}) { + return React.createElement(AgentZeroStatusProvider, {reportID, chatType: undefined}, children); +} + +describe('AgentZeroStatusContext', () => { beforeAll(() => Onyx.init({keys: ONYXKEYS})); - beforeEach(() => { + beforeEach(async () => { jest.clearAllMocks(); - Onyx.clear(); + await Onyx.clear(); - // Setup default mocks - mockConciergeReasoningStore.subscribe = jest.fn().mockReturnValue(() => {}); - mockConciergeReasoningStore.getReasoningHistory = jest.fn().mockReturnValue([]); - mockConciergeReasoningStore.addReasoning = jest.fn(); - mockConciergeReasoningStore.clearReasoning = jest.fn(); + mockPusher.subscribe = jest.fn().mockImplementation(() => Object.assign(Promise.resolve(), {unsubscribe: jest.fn()})); + mockPusher.unsubscribe = jest.fn(); + + // Mark this report as Concierge by default + await Onyx.merge(ONYXKEYS.CONCIERGE_REPORT_ID, reportID); }); afterEach(() => { @@ -50,12 +68,12 @@ describe('useAgentZeroStatusIndicator', () => { }); describe('basic functionality', () => { - it('should return default state when not a Concierge chat', async () => { + it('should short-circuit for non-Concierge chat — default state, no Pusher subscription', async () => { // Given a regular chat (not Concierge) - const isConciergeChat = false; + await Onyx.merge(ONYXKEYS.CONCIERGE_REPORT_ID, '999'); // When we render the hook - const {result} = renderHook(() => useAgentZeroStatusIndicator(reportID, isConciergeChat)); + const {result} = renderHook(() => ({...useAgentZeroStatus(), ...useAgentZeroStatusActions()}), {wrapper}); // Then it should return default state with no processing indicator await waitForBatchedUpdates(); @@ -63,11 +81,13 @@ describe('useAgentZeroStatusIndicator', () => { expect(result.current.statusLabel).toBe(''); expect(result.current.reasoningHistory).toEqual([]); expect(result.current.kickoffWaitingIndicator).toBeInstanceOf(Function); + + // And no Pusher subscription should have been created + expect(mockPusher.subscribe).not.toHaveBeenCalled(); }); it('should return processing state when server label is present in Concierge chat', async () => { // Given a Concierge chat with a server status label - const isConciergeChat = true; const serverLabel = 'Concierge is looking up categories...'; await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`, { @@ -75,7 +95,7 @@ describe('useAgentZeroStatusIndicator', () => { }); // When we render the hook - const {result} = renderHook(() => useAgentZeroStatusIndicator(reportID, isConciergeChat)); + const {result} = renderHook(() => useAgentZeroStatus(), {wrapper}); // Then it should show processing state with the server label await waitForBatchedUpdates(); @@ -85,14 +105,13 @@ describe('useAgentZeroStatusIndicator', () => { it('should return empty status when server label is cleared', async () => { // Given a Concierge chat with an initial server label - const isConciergeChat = true; const serverLabel = 'Concierge is looking up categories...'; await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`, { agentZeroProcessingRequestIndicator: serverLabel, }); - const {result} = renderHook(() => useAgentZeroStatusIndicator(reportID, isConciergeChat)); + const {result} = renderHook(() => useAgentZeroStatus(), {wrapper}); await waitForBatchedUpdates(); // When the server label is cleared (processing complete) @@ -110,9 +129,7 @@ describe('useAgentZeroStatusIndicator', () => { describe('kickoffWaitingIndicator', () => { it('should trigger optimistic waiting state when called in Concierge chat without server label', async () => { // Given a Concierge chat with no server label (user about to send a message) - const isConciergeChat = true; - - const {result} = renderHook(() => useAgentZeroStatusIndicator(reportID, isConciergeChat)); + const {result} = renderHook(() => ({...useAgentZeroStatus(), ...useAgentZeroStatusActions()}), {wrapper}); await waitForBatchedUpdates(); // When the user triggers the waiting indicator (e.g., sending a message) @@ -128,14 +145,13 @@ describe('useAgentZeroStatusIndicator', () => { it('should not trigger waiting state if server label already exists', async () => { // Given a Concierge chat that's already processing with a server label - const isConciergeChat = true; const serverLabel = 'Concierge is looking up categories...'; await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`, { agentZeroProcessingRequestIndicator: serverLabel, }); - const {result} = renderHook(() => useAgentZeroStatusIndicator(reportID, isConciergeChat)); + const {result} = renderHook(() => ({...useAgentZeroStatus(), ...useAgentZeroStatusActions()}), {wrapper}); await waitForBatchedUpdates(); const initialLabel = result.current.statusLabel; @@ -153,9 +169,9 @@ describe('useAgentZeroStatusIndicator', () => { it('should not trigger waiting state in non-Concierge chat', async () => { // Given a regular chat (not Concierge) - const isConciergeChat = false; + await Onyx.merge(ONYXKEYS.CONCIERGE_REPORT_ID, '999'); - const {result} = renderHook(() => useAgentZeroStatusIndicator(reportID, isConciergeChat)); + const {result} = renderHook(() => ({...useAgentZeroStatus(), ...useAgentZeroStatusActions()}), {wrapper}); await waitForBatchedUpdates(); // When kickoff is called @@ -171,9 +187,7 @@ describe('useAgentZeroStatusIndicator', () => { it('should clear optimistic waiting state when server label arrives', async () => { // Given a Concierge chat with optimistic waiting state - const isConciergeChat = true; - - const {result} = renderHook(() => useAgentZeroStatusIndicator(reportID, isConciergeChat)); + const {result} = renderHook(() => ({...useAgentZeroStatus(), ...useAgentZeroStatusActions()}), {wrapper}); await waitForBatchedUpdates(); act(() => { @@ -197,231 +211,152 @@ describe('useAgentZeroStatusIndicator', () => { }); }); - describe('ConciergeReasoningStore integration', () => { - it('should subscribe to ConciergeReasoningStore on mount', async () => { + describe('reasoning via Pusher', () => { + it('should update reasoning history when Pusher event arrives', async () => { // Given a Concierge chat - const isConciergeChat = true; - - // When we render the hook - renderHook(() => useAgentZeroStatusIndicator(reportID, isConciergeChat)); + const {result} = renderHook(() => useAgentZeroStatus(), {wrapper}); await waitForBatchedUpdates(); - // Then it should subscribe to the reasoning store - expect(mockConciergeReasoningStore.subscribe).toHaveBeenCalledTimes(1); - expect(mockConciergeReasoningStore.getReasoningHistory).toHaveBeenCalledWith(reportID); - }); - - it('should update reasoning history when store notifies of changes', async () => { - // Given a Concierge chat with reasoning history - const isConciergeChat = true; - const mockReasoningHistory: ReasoningEntry[] = [ - {reasoning: 'First reasoning', loopCount: 1, timestamp: Date.now()}, - {reasoning: 'Second reasoning', loopCount: 2, timestamp: Date.now()}, - ]; - - let subscriberCallback: ((reportID: string, entries: ReasoningEntry[]) => void) | null = null; - mockConciergeReasoningStore.subscribe = jest.fn().mockImplementation((callback: (reportID: string, entries: ReasoningEntry[]) => void) => { - subscriberCallback = callback; - return () => {}; + // When reasoning data arrives via Pusher + act(() => { + simulateReasoning({reasoning: 'First reasoning', agentZeroRequestID: 'req-1', loopCount: 1}); }); + await waitForBatchedUpdates(); - mockConciergeReasoningStore.getReasoningHistory = jest.fn().mockReturnValue([]); + // Then reasoning history should contain the entry + expect(result.current.reasoningHistory).toHaveLength(1); + expect(result.current.reasoningHistory.at(0)?.reasoning).toBe('First reasoning'); + }); - const {result} = renderHook(() => useAgentZeroStatusIndicator(reportID, isConciergeChat)); + it('should deduplicate reasoning entries with same loopCount and text', async () => { + // Given a Concierge chat with existing reasoning + const {result} = renderHook(() => useAgentZeroStatus(), {wrapper}); await waitForBatchedUpdates(); - // When the store notifies of new reasoning entries act(() => { - subscriberCallback?.(reportID, mockReasoningHistory); + simulateReasoning({reasoning: 'Analyzing...', agentZeroRequestID: 'req-1', loopCount: 1}); }); - - // Then the hook should update with the new reasoning history await waitForBatchedUpdates(); - expect(result.current.reasoningHistory).toEqual(mockReasoningHistory); - }); - it('should not update reasoning history for different report IDs', async () => { - // Given a Concierge chat - const isConciergeChat = true; - const otherReportID = '456'; - const mockReasoningHistory: ReasoningEntry[] = [{reasoning: 'Other report reasoning', loopCount: 1, timestamp: Date.now()}]; - - let subscriberCallback: ((reportID: string, entries: ReasoningEntry[]) => void) | null = null; - mockConciergeReasoningStore.subscribe = jest.fn().mockImplementation((callback: (reportID: string, entries: ReasoningEntry[]) => void) => { - subscriberCallback = callback; - return () => {}; + // When a duplicate arrives + act(() => { + simulateReasoning({reasoning: 'Analyzing...', agentZeroRequestID: 'req-1', loopCount: 1}); }); - - const {result} = renderHook(() => useAgentZeroStatusIndicator(reportID, isConciergeChat)); await waitForBatchedUpdates(); - const initialHistory = result.current.reasoningHistory; + // Then it should not be added + expect(result.current.reasoningHistory).toHaveLength(1); + }); + + it('should reset entries when agentZeroRequestID changes', async () => { + // Given a Concierge chat with reasoning from a previous request + const {result} = renderHook(() => useAgentZeroStatus(), {wrapper}); + await waitForBatchedUpdates(); - // When the store notifies of changes for a different report act(() => { - subscriberCallback?.(otherReportID, mockReasoningHistory); + simulateReasoning({reasoning: 'Old reasoning', agentZeroRequestID: 'req-1', loopCount: 1}); + simulateReasoning({reasoning: 'More old reasoning', agentZeroRequestID: 'req-1', loopCount: 2}); }); - - // Then the current hook should not update its reasoning history await waitForBatchedUpdates(); - expect(result.current.reasoningHistory).toEqual(initialHistory); - expect(result.current.reasoningHistory).not.toEqual(mockReasoningHistory); - }); - }); - - describe('Pusher integration', () => { - it('should subscribe to Pusher reasoning events for Concierge chat on mount', async () => { - // Given a Concierge chat - const isConciergeChat = true; + expect(result.current.reasoningHistory).toHaveLength(2); - // When we render the hook - renderHook(() => useAgentZeroStatusIndicator(reportID, isConciergeChat)); + // When a new request starts + act(() => { + simulateReasoning({reasoning: 'New reasoning', agentZeroRequestID: 'req-2', loopCount: 1}); + }); await waitForBatchedUpdates(); - // Then it should subscribe to reasoning events - expect(mockSubscribeToReportReasoningEvents).toHaveBeenCalledTimes(1); - expect(mockSubscribeToReportReasoningEvents).toHaveBeenCalledWith(reportID); + // Then entries should be reset to just the new one + expect(result.current.reasoningHistory).toHaveLength(1); + expect(result.current.reasoningHistory.at(0)?.reasoning).toBe('New reasoning'); }); - it('should not subscribe to Pusher for non-Concierge chat', async () => { - // Given a regular chat (not Concierge) - const isConciergeChat = false; - - // When we render the hook - renderHook(() => useAgentZeroStatusIndicator(reportID, isConciergeChat)); + it('should ignore empty reasoning strings', async () => { + // Given a Concierge chat + const {result} = renderHook(() => useAgentZeroStatus(), {wrapper}); await waitForBatchedUpdates(); - // Then it should not subscribe - expect(mockSubscribeToReportReasoningEvents).not.toHaveBeenCalled(); - }); - - it('should unsubscribe from Pusher on unmount', async () => { - // Given a Concierge chat that's already subscribed - const isConciergeChat = true; - - const {unmount} = renderHook(() => useAgentZeroStatusIndicator(reportID, isConciergeChat)); + // When empty reasoning arrives + act(() => { + simulateReasoning({reasoning: ' ', agentZeroRequestID: 'req-1', loopCount: 1}); + }); await waitForBatchedUpdates(); - // When the component unmounts - unmount(); - - // Then it should unsubscribe from reasoning events - await waitForBatchedUpdates(); - expect(mockUnsubscribeFromReportReasoningChannel).toHaveBeenCalledTimes(1); - expect(mockUnsubscribeFromReportReasoningChannel).toHaveBeenCalledWith(reportID); + // Then it should be ignored + expect(result.current.reasoningHistory).toHaveLength(0); }); }); - describe('report switching', () => { - it('should update subscriptions when report ID changes', async () => { - // Given a Concierge chat - const isConciergeChat = true; - const newReportID = '456'; - const {rerender} = renderHook(({reportID: rID, isConciergeChat: isCC}) => useAgentZeroStatusIndicator(rID, isCC), { - initialProps: {reportID, isConciergeChat}, - }); + describe('Pusher lifecycle', () => { + it('should subscribe to Pusher for Concierge chat on mount', async () => { + renderHook(() => useAgentZeroStatus(), {wrapper}); await waitForBatchedUpdates(); - // Clear the initial subscription calls - jest.clearAllMocks(); - - // When the report ID changes - rerender({reportID: newReportID, isConciergeChat}); - - // Then it should unsubscribe from the old report and subscribe to the new report - await waitForBatchedUpdates(); - expect(mockUnsubscribeFromReportReasoningChannel).toHaveBeenCalledWith(reportID); - expect(mockSubscribeToReportReasoningEvents).toHaveBeenCalledWith(newReportID); + expect(mockPusher.subscribe).toHaveBeenCalledWith(expect.stringContaining(reportID), Pusher.TYPE.CONCIERGE_REASONING, expect.any(Function)); }); - it('should unsubscribe when switching from Concierge to non-Concierge chat', async () => { - // Given a Concierge chat with active subscriptions - const {rerender} = renderHook(({reportID: rID, isConciergeChat: isCC}) => useAgentZeroStatusIndicator(rID, isCC), { - initialProps: {reportID, isConciergeChat: true}, - }); - await waitForBatchedUpdates(); - - // Clear the initial subscription calls - jest.clearAllMocks(); - - // When switching to a non-Concierge chat - rerender({reportID, isConciergeChat: false}); + it('should not subscribe to Pusher for non-Concierge chat', async () => { + await Onyx.merge(ONYXKEYS.CONCIERGE_REPORT_ID, '999'); - // Then it should unsubscribe + renderHook(() => useAgentZeroStatus(), {wrapper}); await waitForBatchedUpdates(); - expect(mockUnsubscribeFromReportReasoningChannel).toHaveBeenCalledTimes(1); + + expect(mockPusher.subscribe).not.toHaveBeenCalledWith(expect.anything(), Pusher.TYPE.CONCIERGE_REASONING, expect.anything()); }); - it('should subscribe when switching from non-Concierge to Concierge chat', async () => { - // Given a non-Concierge chat - const {rerender} = renderHook(({reportID: rID, isConciergeChat: isCC}) => useAgentZeroStatusIndicator(rID, isCC), { - initialProps: {reportID, isConciergeChat: false}, - }); + it('should unsubscribe from Pusher on unmount', async () => { + // Track the per-callback unsubscribe handle + const handleUnsubscribe = jest.fn(); + mockPusher.subscribe = jest.fn().mockImplementation(() => Object.assign(Promise.resolve(), {unsubscribe: handleUnsubscribe})); + + const {unmount} = renderHook(() => useAgentZeroStatus(), {wrapper}); await waitForBatchedUpdates(); - // When switching to a Concierge chat - rerender({reportID, isConciergeChat: true}); + unmount(); - // Then it should subscribe to reasoning events await waitForBatchedUpdates(); - expect(mockSubscribeToReportReasoningEvents).toHaveBeenCalledTimes(1); - expect(mockSubscribeToReportReasoningEvents).toHaveBeenCalledWith(reportID); + expect(handleUnsubscribe).toHaveBeenCalled(); }); }); describe('final response handling', () => { - it('should clear optimistic state and reasoning history when final response arrives', async () => { - // Given a Concierge chat with reasoning history in the store - const isConciergeChat = true; - const mockReasoningHistory: ReasoningEntry[] = [ - {reasoning: 'Analyzing request', loopCount: 1, timestamp: Date.now()}, - {reasoning: 'Fetching data', loopCount: 2, timestamp: Date.now()}, - ]; - - let subscriberCallback: ((reportID: string, entries: ReasoningEntry[]) => void) | null = null; - mockConciergeReasoningStore.subscribe = jest.fn().mockImplementation((callback: (reportID: string, entries: ReasoningEntry[]) => void) => { - subscriberCallback = callback; - return () => {}; - }); - mockConciergeReasoningStore.getReasoningHistory = jest.fn().mockReturnValue([]); - - // Set initial server label (processing) + it('should clear reasoning history when processing indicator is cleared', async () => { + // Given a Concierge chat with active reasoning const serverLabel = 'Concierge is looking up categories...'; await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`, { agentZeroProcessingRequestIndicator: serverLabel, }); - const {result} = renderHook(() => useAgentZeroStatusIndicator(reportID, isConciergeChat)); + const {result} = renderHook(() => useAgentZeroStatus(), {wrapper}); await waitForBatchedUpdates(); // Simulate reasoning history arriving act(() => { - subscriberCallback?.(reportID, mockReasoningHistory); + simulateReasoning({reasoning: 'Analyzing request', agentZeroRequestID: 'req-1', loopCount: 1}); + simulateReasoning({reasoning: 'Fetching data', agentZeroRequestID: 'req-1', loopCount: 2}); }); await waitForBatchedUpdates(); // Verify processing state is active with reasoning history expect(result.current.isProcessing).toBe(true); expect(result.current.statusLabel).toBe(serverLabel); - expect(result.current.reasoningHistory).toEqual(mockReasoningHistory); + expect(result.current.reasoningHistory).toHaveLength(2); // When the final Concierge response arrives, the backend clears the processing indicator await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`, { agentZeroProcessingRequestIndicator: '', }); - // Then step 7: processing indicator clears → reasoning state clears + // Then processing indicator and reasoning history should clear await waitForBatchedUpdates(); expect(result.current.isProcessing).toBe(false); expect(result.current.statusLabel).toBe(''); - expect(mockConciergeReasoningStore.clearReasoning).toHaveBeenCalledWith(reportID); + expect(result.current.reasoningHistory).toEqual([]); }); it('should clear optimistic state when server completes after kickoff', async () => { // Given a Concierge chat where user triggered optimistic waiting - const isConciergeChat = true; - - const {result} = renderHook(() => useAgentZeroStatusIndicator(reportID, isConciergeChat)); + const {result} = renderHook(() => ({...useAgentZeroStatus(), ...useAgentZeroStatusActions()}), {wrapper}); await waitForBatchedUpdates(); // User sends message → optimistic waiting state diff --git a/tests/unit/ConciergeReasoningStoreTest.ts b/tests/unit/ConciergeReasoningStoreTest.ts deleted file mode 100644 index 65cc3360f0920..0000000000000 --- a/tests/unit/ConciergeReasoningStoreTest.ts +++ /dev/null @@ -1,501 +0,0 @@ -import ConciergeReasoningStore from '@libs/ConciergeReasoningStore'; -import type {ReasoningData, ReasoningEntry} from '@libs/ConciergeReasoningStore'; - -describe('ConciergeReasoningStore', () => { - const reportID1 = '123'; - const reportID2 = '456'; - const agentZeroRequestID1 = 'request-1'; - const agentZeroRequestID2 = 'request-2'; - - beforeEach(() => { - // Clear all reasoning data before each test - ConciergeReasoningStore.clearReasoning(reportID1); - ConciergeReasoningStore.clearReasoning(reportID2); - }); - - describe('addReasoning', () => { - it('should add a reasoning entry to a report', () => { - const data: ReasoningData = { - reasoning: 'Looking at your policy settings', - agentZeroRequestID: agentZeroRequestID1, - loopCount: 1, - }; - - ConciergeReasoningStore.addReasoning(reportID1, data); - - const history = ConciergeReasoningStore.getReasoningHistory(reportID1); - expect(history).toHaveLength(1); - expect(history.at(0)?.reasoning).toBe('Looking at your policy settings'); - expect(history.at(0)?.loopCount).toBe(1); - expect(history.at(0)?.timestamp).toBeDefined(); - }); - - it('should add multiple reasoning entries in order', () => { - const data1: ReasoningData = { - reasoning: 'First reasoning', - agentZeroRequestID: agentZeroRequestID1, - loopCount: 1, - }; - const data2: ReasoningData = { - reasoning: 'Second reasoning', - agentZeroRequestID: agentZeroRequestID1, - loopCount: 2, - }; - const data3: ReasoningData = { - reasoning: 'Third reasoning', - agentZeroRequestID: agentZeroRequestID1, - loopCount: 3, - }; - - ConciergeReasoningStore.addReasoning(reportID1, data1); - ConciergeReasoningStore.addReasoning(reportID1, data2); - ConciergeReasoningStore.addReasoning(reportID1, data3); - - const history = ConciergeReasoningStore.getReasoningHistory(reportID1); - expect(history).toHaveLength(3); - expect(history.at(0)?.reasoning).toBe('First reasoning'); - expect(history.at(1)?.reasoning).toBe('Second reasoning'); - expect(history.at(2)?.reasoning).toBe('Third reasoning'); - }); - - it('should ignore empty reasoning strings', () => { - const data: ReasoningData = { - reasoning: '', - agentZeroRequestID: agentZeroRequestID1, - loopCount: 1, - }; - - ConciergeReasoningStore.addReasoning(reportID1, data); - - const history = ConciergeReasoningStore.getReasoningHistory(reportID1); - expect(history).toHaveLength(0); - }); - - it('should ignore whitespace-only reasoning strings', () => { - const data: ReasoningData = { - reasoning: ' ', - agentZeroRequestID: agentZeroRequestID1, - loopCount: 1, - }; - - ConciergeReasoningStore.addReasoning(reportID1, data); - - const history = ConciergeReasoningStore.getReasoningHistory(reportID1); - expect(history).toHaveLength(0); - }); - - it('should reset state when agentZeroRequestID changes', () => { - // Add entries for first request - const data1: ReasoningData = { - reasoning: 'First request reasoning', - agentZeroRequestID: agentZeroRequestID1, - loopCount: 1, - }; - const data2: ReasoningData = { - reasoning: 'First request reasoning 2', - agentZeroRequestID: agentZeroRequestID1, - loopCount: 2, - }; - - ConciergeReasoningStore.addReasoning(reportID1, data1); - ConciergeReasoningStore.addReasoning(reportID1, data2); - - let history = ConciergeReasoningStore.getReasoningHistory(reportID1); - expect(history).toHaveLength(2); - - // Add entry with new agentZeroRequestID - should reset - const data3: ReasoningData = { - reasoning: 'Second request reasoning', - agentZeroRequestID: agentZeroRequestID2, - loopCount: 1, - }; - - ConciergeReasoningStore.addReasoning(reportID1, data3); - - history = ConciergeReasoningStore.getReasoningHistory(reportID1); - expect(history).toHaveLength(1); - expect(history.at(0)?.reasoning).toBe('Second request reasoning'); - expect(history.at(0)?.loopCount).toBe(1); - }); - - it('should skip duplicate entries with same loopCount and reasoning', () => { - const data: ReasoningData = { - reasoning: 'Duplicate reasoning', - agentZeroRequestID: agentZeroRequestID1, - loopCount: 1, - }; - - ConciergeReasoningStore.addReasoning(reportID1, data); - ConciergeReasoningStore.addReasoning(reportID1, data); - ConciergeReasoningStore.addReasoning(reportID1, data); - - const history = ConciergeReasoningStore.getReasoningHistory(reportID1); - expect(history).toHaveLength(1); - }); - - it('should add entries with same loopCount but different reasoning', () => { - const data1: ReasoningData = { - reasoning: 'First reasoning', - agentZeroRequestID: agentZeroRequestID1, - loopCount: 1, - }; - const data2: ReasoningData = { - reasoning: 'Second reasoning', - agentZeroRequestID: agentZeroRequestID1, - loopCount: 1, - }; - - ConciergeReasoningStore.addReasoning(reportID1, data1); - ConciergeReasoningStore.addReasoning(reportID1, data2); - - const history = ConciergeReasoningStore.getReasoningHistory(reportID1); - expect(history).toHaveLength(2); - expect(history.at(0)?.reasoning).toBe('First reasoning'); - expect(history.at(1)?.reasoning).toBe('Second reasoning'); - }); - - it('should add entries with same reasoning but different loopCount', () => { - const data1: ReasoningData = { - reasoning: 'Same reasoning', - agentZeroRequestID: agentZeroRequestID1, - loopCount: 1, - }; - const data2: ReasoningData = { - reasoning: 'Same reasoning', - agentZeroRequestID: agentZeroRequestID1, - loopCount: 2, - }; - - ConciergeReasoningStore.addReasoning(reportID1, data1); - ConciergeReasoningStore.addReasoning(reportID1, data2); - - const history = ConciergeReasoningStore.getReasoningHistory(reportID1); - expect(history).toHaveLength(2); - expect(history.at(0)?.loopCount).toBe(1); - expect(history.at(1)?.loopCount).toBe(2); - }); - }); - - describe('getReasoningHistory', () => { - it('should return empty array for report with no reasoning', () => { - const history = ConciergeReasoningStore.getReasoningHistory(reportID1); - expect(history).toEqual([]); - }); - - it('should return reasoning history for specific report', () => { - const data1: ReasoningData = { - reasoning: 'Report 1 reasoning', - agentZeroRequestID: agentZeroRequestID1, - loopCount: 1, - }; - const data2: ReasoningData = { - reasoning: 'Report 2 reasoning', - agentZeroRequestID: agentZeroRequestID1, - loopCount: 1, - }; - - ConciergeReasoningStore.addReasoning(reportID1, data1); - ConciergeReasoningStore.addReasoning(reportID2, data2); - - const history1 = ConciergeReasoningStore.getReasoningHistory(reportID1); - const history2 = ConciergeReasoningStore.getReasoningHistory(reportID2); - - expect(history1).toHaveLength(1); - expect(history1.at(0)?.reasoning).toBe('Report 1 reasoning'); - expect(history2).toHaveLength(1); - expect(history2.at(0)?.reasoning).toBe('Report 2 reasoning'); - }); - }); - - describe('clearReasoning', () => { - it('should remove all reasoning entries for a report', () => { - const data1: ReasoningData = { - reasoning: 'First reasoning', - agentZeroRequestID: agentZeroRequestID1, - loopCount: 1, - }; - const data2: ReasoningData = { - reasoning: 'Second reasoning', - agentZeroRequestID: agentZeroRequestID1, - loopCount: 2, - }; - - ConciergeReasoningStore.addReasoning(reportID1, data1); - ConciergeReasoningStore.addReasoning(reportID1, data2); - - let history = ConciergeReasoningStore.getReasoningHistory(reportID1); - expect(history).toHaveLength(2); - - ConciergeReasoningStore.clearReasoning(reportID1); - - history = ConciergeReasoningStore.getReasoningHistory(reportID1); - expect(history).toEqual([]); - }); - - it('should only clear reasoning for the specified report', () => { - const data1: ReasoningData = { - reasoning: 'Report 1 reasoning', - agentZeroRequestID: agentZeroRequestID1, - loopCount: 1, - }; - const data2: ReasoningData = { - reasoning: 'Report 2 reasoning', - agentZeroRequestID: agentZeroRequestID1, - loopCount: 1, - }; - - ConciergeReasoningStore.addReasoning(reportID1, data1); - ConciergeReasoningStore.addReasoning(reportID2, data2); - - ConciergeReasoningStore.clearReasoning(reportID1); - - const history1 = ConciergeReasoningStore.getReasoningHistory(reportID1); - const history2 = ConciergeReasoningStore.getReasoningHistory(reportID2); - - expect(history1).toEqual([]); - expect(history2).toHaveLength(1); - expect(history2.at(0)?.reasoning).toBe('Report 2 reasoning'); - }); - - it('should handle clearing a report that has no reasoning', () => { - // Should not throw an error - expect(() => { - ConciergeReasoningStore.clearReasoning(reportID1); - }).not.toThrow(); - - const history = ConciergeReasoningStore.getReasoningHistory(reportID1); - expect(history).toEqual([]); - }); - }); - - describe('subscribe', () => { - it('should notify listener when reasoning is added', () => { - const listener = jest.fn(); - const unsubscribe = ConciergeReasoningStore.subscribe(listener); - - const data: ReasoningData = { - reasoning: 'Test reasoning', - agentZeroRequestID: agentZeroRequestID1, - loopCount: 1, - }; - - ConciergeReasoningStore.addReasoning(reportID1, data); - - expect(listener).toHaveBeenCalledTimes(1); - expect(listener).toHaveBeenCalledWith(reportID1, expect.any(Array)); - - const callArgs = listener.mock.calls.at(0) as [string, ReasoningEntry[]]; - const [, entries] = callArgs; - expect(entries).toHaveLength(1); - expect(entries.at(0)?.reasoning).toBe('Test reasoning'); - - unsubscribe(); - }); - - it('should notify listener when reasoning is cleared', () => { - const listener = jest.fn(); - const unsubscribe = ConciergeReasoningStore.subscribe(listener); - - const data: ReasoningData = { - reasoning: 'Test reasoning', - agentZeroRequestID: agentZeroRequestID1, - loopCount: 1, - }; - - ConciergeReasoningStore.addReasoning(reportID1, data); - listener.mockClear(); - - ConciergeReasoningStore.clearReasoning(reportID1); - - expect(listener).toHaveBeenCalledTimes(1); - expect(listener).toHaveBeenCalledWith(reportID1, []); - - unsubscribe(); - }); - - it('should notify multiple listeners', () => { - const listener1 = jest.fn(); - const listener2 = jest.fn(); - const unsubscribe1 = ConciergeReasoningStore.subscribe(listener1); - const unsubscribe2 = ConciergeReasoningStore.subscribe(listener2); - - const data: ReasoningData = { - reasoning: 'Test reasoning', - agentZeroRequestID: agentZeroRequestID1, - loopCount: 1, - }; - - ConciergeReasoningStore.addReasoning(reportID1, data); - - expect(listener1).toHaveBeenCalledTimes(1); - expect(listener2).toHaveBeenCalledTimes(1); - - unsubscribe1(); - unsubscribe2(); - }); - - it('should not notify listener after unsubscribe', () => { - const listener = jest.fn(); - const unsubscribe = ConciergeReasoningStore.subscribe(listener); - - const data: ReasoningData = { - reasoning: 'Test reasoning', - agentZeroRequestID: agentZeroRequestID1, - loopCount: 1, - }; - - ConciergeReasoningStore.addReasoning(reportID1, data); - expect(listener).toHaveBeenCalledTimes(1); - - listener.mockClear(); - unsubscribe(); - - const data2: ReasoningData = { - reasoning: 'Another reasoning', - agentZeroRequestID: agentZeroRequestID1, - loopCount: 2, - }; - - ConciergeReasoningStore.addReasoning(reportID1, data2); - expect(listener).not.toHaveBeenCalled(); - }); - - it('should not notify listener for duplicate entries', () => { - const listener = jest.fn(); - const unsubscribe = ConciergeReasoningStore.subscribe(listener); - - const data: ReasoningData = { - reasoning: 'Duplicate reasoning', - agentZeroRequestID: agentZeroRequestID1, - loopCount: 1, - }; - - ConciergeReasoningStore.addReasoning(reportID1, data); - expect(listener).toHaveBeenCalledTimes(1); - - listener.mockClear(); - ConciergeReasoningStore.addReasoning(reportID1, data); - expect(listener).not.toHaveBeenCalled(); - - unsubscribe(); - }); - - it('should not notify listener for empty reasoning', () => { - const listener = jest.fn(); - const unsubscribe = ConciergeReasoningStore.subscribe(listener); - - const data: ReasoningData = { - reasoning: '', - agentZeroRequestID: agentZeroRequestID1, - loopCount: 1, - }; - - ConciergeReasoningStore.addReasoning(reportID1, data); - expect(listener).not.toHaveBeenCalled(); - - unsubscribe(); - }); - - it('should notify listener when state is reset for new agentZeroRequestID', () => { - const listener = jest.fn(); - const unsubscribe = ConciergeReasoningStore.subscribe(listener); - - // Add first entry - const data1: ReasoningData = { - reasoning: 'First request', - agentZeroRequestID: agentZeroRequestID1, - loopCount: 1, - }; - ConciergeReasoningStore.addReasoning(reportID1, data1); - expect(listener).toHaveBeenCalledTimes(1); - - listener.mockClear(); - - // Add entry with new request ID - const data2: ReasoningData = { - reasoning: 'Second request', - agentZeroRequestID: agentZeroRequestID2, - loopCount: 1, - }; - ConciergeReasoningStore.addReasoning(reportID1, data2); - - expect(listener).toHaveBeenCalledTimes(1); - const callArgs = listener.mock.calls.at(0) as [string, ReasoningEntry[]]; - const [, entries] = callArgs; - expect(entries).toHaveLength(1); - expect(entries.at(0)?.reasoning).toBe('Second request'); - - unsubscribe(); - }); - - it('should handle multiple unsubscribes safely', () => { - const listener = jest.fn(); - const unsubscribe = ConciergeReasoningStore.subscribe(listener); - - // Should not throw an error when called multiple times - expect(() => { - unsubscribe(); - unsubscribe(); - unsubscribe(); - }).not.toThrow(); - }); - }); - - describe('isolation between reports', () => { - it('should keep reasoning separate for different reports', () => { - const data1: ReasoningData = { - reasoning: 'Report 1 - Loop 1', - agentZeroRequestID: agentZeroRequestID1, - loopCount: 1, - }; - const data2: ReasoningData = { - reasoning: 'Report 1 - Loop 2', - agentZeroRequestID: agentZeroRequestID1, - loopCount: 2, - }; - const data3: ReasoningData = { - reasoning: 'Report 2 - Loop 1', - agentZeroRequestID: agentZeroRequestID1, - loopCount: 1, - }; - - ConciergeReasoningStore.addReasoning(reportID1, data1); - ConciergeReasoningStore.addReasoning(reportID1, data2); - ConciergeReasoningStore.addReasoning(reportID2, data3); - - const history1 = ConciergeReasoningStore.getReasoningHistory(reportID1); - const history2 = ConciergeReasoningStore.getReasoningHistory(reportID2); - - expect(history1).toHaveLength(2); - expect(history2).toHaveLength(1); - expect(history1.at(0)?.reasoning).toBe('Report 1 - Loop 1'); - expect(history1.at(1)?.reasoning).toBe('Report 1 - Loop 2'); - expect(history2.at(0)?.reasoning).toBe('Report 2 - Loop 1'); - }); - - it('should notify listeners with correct reportID', () => { - const listener = jest.fn(); - const unsubscribe = ConciergeReasoningStore.subscribe(listener); - - const data1: ReasoningData = { - reasoning: 'Report 1 reasoning', - agentZeroRequestID: agentZeroRequestID1, - loopCount: 1, - }; - const data2: ReasoningData = { - reasoning: 'Report 2 reasoning', - agentZeroRequestID: agentZeroRequestID1, - loopCount: 1, - }; - - ConciergeReasoningStore.addReasoning(reportID1, data1); - ConciergeReasoningStore.addReasoning(reportID2, data2); - - expect(listener).toHaveBeenCalledTimes(2); - expect(listener).toHaveBeenNthCalledWith(1, reportID1, expect.any(Array)); - expect(listener).toHaveBeenNthCalledWith(2, reportID2, expect.any(Array)); - - unsubscribe(); - }); - }); -}); diff --git a/tests/unit/ReportNameValuePairsSelectorTest.ts b/tests/unit/ReportNameValuePairsSelectorTest.ts new file mode 100644 index 0000000000000..b3a8200745a33 --- /dev/null +++ b/tests/unit/ReportNameValuePairsSelectorTest.ts @@ -0,0 +1,30 @@ +import {agentZeroProcessingIndicatorSelector} from '@selectors/ReportNameValuePairs'; +import type {ReportNameValuePairs} from '@src/types/onyx'; + +describe('ReportNameValuePairs selectors', () => { + describe('agentZeroProcessingIndicatorSelector', () => { + it('should return trimmed indicator when present', () => { + const rnvp = {agentZeroProcessingRequestIndicator: ' Concierge is looking up categories... '} as ReportNameValuePairs; + expect(agentZeroProcessingIndicatorSelector(rnvp)).toBe('Concierge is looking up categories...'); + }); + + it('should return empty string when indicator is undefined', () => { + const rnvp = {} as ReportNameValuePairs; + expect(agentZeroProcessingIndicatorSelector(rnvp)).toBe(''); + }); + + it('should return empty string when rnvp is undefined', () => { + expect(agentZeroProcessingIndicatorSelector(undefined)).toBe(''); + }); + + it('should return empty string when indicator is whitespace-only', () => { + const rnvp = {agentZeroProcessingRequestIndicator: ' '} as ReportNameValuePairs; + expect(agentZeroProcessingIndicatorSelector(rnvp)).toBe(''); + }); + + it('should return the indicator as-is when no trimming needed', () => { + const rnvp = {agentZeroProcessingRequestIndicator: 'Processing your request...'} as ReportNameValuePairs; + expect(agentZeroProcessingIndicatorSelector(rnvp)).toBe('Processing your request...'); + }); + }); +}); diff --git a/tests/unit/ReportReasoningSubscriptionTest.ts b/tests/unit/ReportReasoningSubscriptionTest.ts deleted file mode 100644 index 95b4cf24e5144..0000000000000 --- a/tests/unit/ReportReasoningSubscriptionTest.ts +++ /dev/null @@ -1,237 +0,0 @@ -import Onyx from 'react-native-onyx'; -import {subscribeToReportReasoningEvents, unsubscribeFromReportReasoningChannel} from '@libs/actions/Report'; -import ConciergeReasoningStore from '@libs/ConciergeReasoningStore'; -import Pusher from '@libs/Pusher'; -import ONYXKEYS from '@src/ONYXKEYS'; -import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; - -jest.mock('@libs/Pusher'); -jest.mock('@libs/ConciergeReasoningStore'); - -const mockPusher = Pusher as jest.Mocked; -const mockConciergeReasoningStore = ConciergeReasoningStore as jest.Mocked; - -describe('Report Reasoning Subscription', () => { - const reportID1 = '123'; - const reportID2 = '456'; - const agentZeroRequestID = 'request-abc'; - - beforeAll(() => Onyx.init({keys: ONYXKEYS})); - - beforeEach(() => { - jest.clearAllMocks(); - Onyx.clear(); - - // Unsubscribe from any previous subscriptions to ensure clean state - unsubscribeFromReportReasoningChannel(reportID1); - unsubscribeFromReportReasoningChannel(reportID2); - - // Clear mocks again after cleanup - jest.clearAllMocks(); - - // Setup default mocks - mockPusher.subscribe = jest.fn().mockResolvedValue(undefined); - mockPusher.unsubscribe = jest.fn(); - mockConciergeReasoningStore.addReasoning = jest.fn(); - mockConciergeReasoningStore.clearReasoning = jest.fn(); - }); - - afterEach(() => { - jest.clearAllTimers(); - // Clean up subscriptions after each test - unsubscribeFromReportReasoningChannel(reportID1); - unsubscribeFromReportReasoningChannel(reportID2); - }); - - describe('subscribeToReportReasoningEvents', () => { - it('should subscribe to Pusher concierge reasoning events', async () => { - // When subscribing to reasoning events for a report - subscribeToReportReasoningEvents(reportID1); - await waitForBatchedUpdates(); - - // Then it should subscribe to Pusher - expect(mockPusher.subscribe).toHaveBeenCalledTimes(1); - expect(mockPusher.subscribe).toHaveBeenCalledWith(expect.stringContaining(reportID1), Pusher.TYPE.CONCIERGE_REASONING, expect.any(Function)); - }); - - it('should not subscribe twice to the same report', async () => { - // When subscribing to the same report twice - subscribeToReportReasoningEvents(reportID1); - subscribeToReportReasoningEvents(reportID1); - await waitForBatchedUpdates(); - - // Then it should only subscribe once - expect(mockPusher.subscribe).toHaveBeenCalledTimes(1); - }); - - it('should allow subscriptions to different reports', async () => { - // When subscribing to different reports - subscribeToReportReasoningEvents(reportID1); - subscribeToReportReasoningEvents(reportID2); - await waitForBatchedUpdates(); - - // Then it should subscribe to both - expect(mockPusher.subscribe).toHaveBeenCalledTimes(2); - }); - - it('should handle empty reportID gracefully', async () => { - // When subscribing with empty reportID - subscribeToReportReasoningEvents(''); - await waitForBatchedUpdates(); - - // Then it should not subscribe - expect(mockPusher.subscribe).not.toHaveBeenCalled(); - }); - - it('should handle incoming Pusher events and update store', async () => { - // Given a subscription with a callback - type PusherCallback = (data: unknown) => void; - let pusherCallback: PusherCallback | null = null; - mockPusher.subscribe = jest.fn().mockImplementation((_channel: string, _eventName: string, callback: PusherCallback) => { - pusherCallback = callback; - return Promise.resolve(); - }); - - subscribeToReportReasoningEvents(reportID1); - await waitForBatchedUpdates(); - - // When a Pusher reasoning event arrives - const reasoningEvent = { - reasoning: 'Checking your expense policy', - agentZeroRequestID, - loopCount: 1, - }; - - if (pusherCallback) { - (pusherCallback as PusherCallback)(reasoningEvent); - } - await waitForBatchedUpdates(); - - // Then it should add the reasoning to the store - expect(mockConciergeReasoningStore.addReasoning).toHaveBeenCalledWith(reportID1, { - reasoning: reasoningEvent.reasoning, - agentZeroRequestID: reasoningEvent.agentZeroRequestID, - loopCount: reasoningEvent.loopCount, - }); - }); - - it('should handle Pusher subscription errors gracefully', async () => { - // Given a Pusher subscription that fails - const subscriptionError = new Error('Pusher connection failed'); - mockPusher.subscribe = jest.fn().mockRejectedValue(subscriptionError); - - // When subscribing to reasoning events - subscribeToReportReasoningEvents(reportID1); - await waitForBatchedUpdates(); - - // Then it should not throw and should have attempted to subscribe - expect(mockPusher.subscribe).toHaveBeenCalledTimes(1); - }); - }); - - describe('unsubscribeFromReportReasoningChannel', () => { - it('should unsubscribe from Pusher and clear reasoning state', async () => { - // Given an active subscription - subscribeToReportReasoningEvents(reportID1); - await waitForBatchedUpdates(); - - // When unsubscribing - unsubscribeFromReportReasoningChannel(reportID1); - await waitForBatchedUpdates(); - - // Then it should unsubscribe from Pusher and clear reasoning - expect(mockPusher.unsubscribe).toHaveBeenCalledTimes(1); - expect(mockPusher.unsubscribe).toHaveBeenCalledWith(expect.stringContaining(reportID1), Pusher.TYPE.CONCIERGE_REASONING); - expect(mockConciergeReasoningStore.clearReasoning).toHaveBeenCalledWith(reportID1); - }); - - it('should not unsubscribe if not previously subscribed', async () => { - // When unsubscribing without a prior subscription - unsubscribeFromReportReasoningChannel(reportID1); - await waitForBatchedUpdates(); - - // Then it should not attempt to unsubscribe - expect(mockPusher.unsubscribe).not.toHaveBeenCalled(); - expect(mockConciergeReasoningStore.clearReasoning).not.toHaveBeenCalled(); - }); - - it('should allow resubscription after unsubscribing', async () => { - // Given a subscription that was unsubscribed - subscribeToReportReasoningEvents(reportID1); - await waitForBatchedUpdates(); - unsubscribeFromReportReasoningChannel(reportID1); - await waitForBatchedUpdates(); - - jest.clearAllMocks(); - - // When subscribing again - subscribeToReportReasoningEvents(reportID1); - await waitForBatchedUpdates(); - - // Then it should subscribe again - expect(mockPusher.subscribe).toHaveBeenCalledTimes(1); - }); - - it('should handle empty reportID gracefully', async () => { - // When unsubscribing with empty reportID - unsubscribeFromReportReasoningChannel(''); - await waitForBatchedUpdates(); - - // Then it should not attempt to unsubscribe - expect(mockPusher.unsubscribe).not.toHaveBeenCalled(); - expect(mockConciergeReasoningStore.clearReasoning).not.toHaveBeenCalled(); - }); - - it('should only affect the specific report when unsubscribing', async () => { - // Given subscriptions to multiple reports - subscribeToReportReasoningEvents(reportID1); - subscribeToReportReasoningEvents(reportID2); - await waitForBatchedUpdates(); - - jest.clearAllMocks(); - - // When unsubscribing from one report - unsubscribeFromReportReasoningChannel(reportID1); - await waitForBatchedUpdates(); - - // Then it should only unsubscribe from that report - expect(mockPusher.unsubscribe).toHaveBeenCalledTimes(1); - expect(mockPusher.unsubscribe).toHaveBeenCalledWith(expect.stringContaining(reportID1), Pusher.TYPE.CONCIERGE_REASONING); - expect(mockConciergeReasoningStore.clearReasoning).toHaveBeenCalledWith(reportID1); - - // And the other subscription should still be active - jest.clearAllMocks(); - subscribeToReportReasoningEvents(reportID2); - expect(mockPusher.subscribe).not.toHaveBeenCalled(); // Already subscribed - }); - }); - - describe('subscription tracking', () => { - it('should track subscriptions correctly across multiple operations', async () => { - // Subscribe to first report - subscribeToReportReasoningEvents(reportID1); - await waitForBatchedUpdates(); - expect(mockPusher.subscribe).toHaveBeenCalledTimes(1); - - // Try to subscribe again - should be ignored - subscribeToReportReasoningEvents(reportID1); - await waitForBatchedUpdates(); - expect(mockPusher.subscribe).toHaveBeenCalledTimes(1); - - // Subscribe to second report - subscribeToReportReasoningEvents(reportID2); - await waitForBatchedUpdates(); - expect(mockPusher.subscribe).toHaveBeenCalledTimes(2); - - // Unsubscribe from first report - unsubscribeFromReportReasoningChannel(reportID1); - await waitForBatchedUpdates(); - expect(mockPusher.unsubscribe).toHaveBeenCalledTimes(1); - - // Resubscribe to first report - should work now - subscribeToReportReasoningEvents(reportID1); - await waitForBatchedUpdates(); - expect(mockPusher.subscribe).toHaveBeenCalledTimes(3); - }); - }); -});