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);
- });
- });
-});