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/useShouldSuppressConciergeIndicators.tsx b/src/hooks/useShouldSuppressConciergeIndicators.tsx new file mode 100644 index 0000000000000..e990d87972421 --- /dev/null +++ b/src/hooks/useShouldSuppressConciergeIndicators.tsx @@ -0,0 +1,35 @@ +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); + }; + 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 69bde37011682..0d6d62f60466c 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'; @@ -403,9 +402,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 ?? ''); @@ -583,50 +579,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[] = []; @@ -7373,14 +7325,12 @@ export { startNewChat, subscribeToNewActionEvent, subscribeToReportLeavingEvents, - subscribeToReportReasoningEvents, subscribeToReportTypingEvents, toggleEmojiReaction, togglePinnedState, toggleSubscribeToChildReport, unsubscribeFromLeavingRoomReportChannel, unsubscribeFromReportChannel, - unsubscribeFromReportReasoningChannel, updateDescription, updateGroupChatAvatar, updatePolicyRoomAvatar, @@ -7411,6 +7361,7 @@ export { setOptimisticTransactionThread, prepareOnyxDataForCleanUpOptimisticParticipants, getGuidedSetupDataForOpenReport, + getReportChannelName, }; export type {ParticipantInfo}; 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..2fe98914b3ea4 --- /dev/null +++ b/src/pages/inbox/AgentZeroStatusContext.tsx @@ -0,0 +1,258 @@ +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} + + ); +} + +// 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 + +/** + * Inner gate — all Pusher, reasoning, label, and processing state. + * Only mounted when reportID matches the Concierge report. + * Remounted via key prop when reportID changes, so all state resets automatically. + */ +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 displayedLabelRef = useRef(''); + const [displayedLabel, setDisplayedLabel] = useState(''); + // Chronological list of reasoning steps streamed via Pusher during a single processing request + const [reasoningHistory, setReasoningHistory] = useState([]); + const {translate} = useLocalize(); + // 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(''); + + // Clear optimistic state once server label arrives — the server has taken over + if (serverLabel && optimisticStartTime) { + setOptimisticStartTime(null); + } + + // Clear optimistic state when coming back online — stale optimism from offline + const [prevIsOffline, setPrevIsOffline] = useState(isOffline); + if (prevIsOffline !== isOffline) { + setPrevIsOffline(isOffline); + if (!isOffline && optimisticStartTime) { + setOptimisticStartTime(null); + } + } + + // Clear reasoning when processing ends (server label transitions from truthy → falsy) + const [prevServerLabel, setPrevServerLabel] = useState(serverLabel); + if (prevServerLabel !== serverLabel) { + setPrevServerLabel(serverLabel); + if (prevServerLabel && !serverLabel && reasoningHistory.length > 0) { + setReasoningHistory([]); + } + } + + /** 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]; + }); + }; + + // 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(); + }; + }, [reportID, addReasoning]); + + // Synchronize the displayed label with debounce and minimum display time. + // displayedLabelRef mirrors state so the effect can check the current value without depending on displayedLabel. + useEffect(() => { + let targetLabel = ''; + if (serverLabel) { + targetLabel = serverLabel; + } else if (optimisticStartTime) { + targetLabel = translate('common.thinking'); + } + + if (displayedLabelRef.current === targetLabel) { + return; + } + + const now = Date.now(); + const timeSinceLastUpdate = now - lastUpdateTimeRef.current; + const remainingMinTime = Math.max(0, MIN_DISPLAY_TIME - timeSinceLastUpdate); + + if (updateTimerRef.current) { + clearTimeout(updateTimerRef.current); + updateTimerRef.current = null; + } + + // Immediate update when enough time has passed or when clearing the label + if (remainingMinTime === 0 || targetLabel === '') { + displayedLabelRef.current = targetLabel; + // eslint-disable-next-line react-hooks/set-state-in-effect -- guarded by displayedLabelRef check above; fires once per serverLabel/optimistic transition + setDisplayedLabel(targetLabel); + lastUpdateTimeRef.current = now; + } else { + // Schedule update after debounce + remaining min display time + const delay = DEBOUNCE_DELAY + remainingMinTime; + updateTimerRef.current = setTimeout(() => { + displayedLabelRef.current = targetLabel; + setDisplayedLabel(targetLabel); + lastUpdateTimeRef.current = Date.now(); + updateTimerRef.current = null; + }, delay); + } + + return () => { + if (!updateTimerRef.current) { + return; + } + clearTimeout(updateTimerRef.current); + }; + }, [serverLabel, optimisticStartTime, translate]); + + 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); + + const stateValue: AgentZeroStatusState = { + isProcessing, + reasoningHistory, + statusLabel: displayedLabel, + }; + + 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/ReportScreen.tsx b/src/pages/inbox/ReportScreen.tsx index aa1cc37119482..a3c46129202ac 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'; @@ -110,6 +109,7 @@ import {reportByIDsSelector} from '@src/selectors/Attributes'; import type * as OnyxTypes from '@src/types/onyx'; import {getEmptyObject, 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'; @@ -360,15 +360,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(() => { @@ -1055,56 +1046,56 @@ 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..199ce987741f6 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -30,6 +30,7 @@ import useOnyx from '@hooks/useOnyx'; import usePreferredPolicy from '@hooks/usePreferredPolicy'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useShouldSuppressConciergeIndicators from '@hooks/useShouldSuppressConciergeIndicators'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import canFocusInputOnScreenFocus from '@libs/canFocusInputOnScreenFocus'; @@ -48,9 +49,7 @@ import { chatIncludesConcierge, getParentReport, getReportRecipientAccountIDs, - isAdminRoom, isChatRoom, - isConciergeChatReport, isGroupChat, isInvoiceReport, isReportApproved, @@ -61,6 +60,7 @@ import { import {startSpan} from '@libs/telemetry/activeSpans'; import {getTransactionID, hasReceipt as hasReceiptTransactionUtils} from '@libs/TransactionUtils'; 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'; @@ -113,13 +113,16 @@ type ReportActionComposeProps = Pick