diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index d30ee35cbb4..4cb6d7916bf 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -600,9 +600,6 @@ const ONYXKEYS = { /** Company cards custom names */ NVP_EXPENSIFY_COMPANY_CARDS_CUSTOM_NAMES: 'nvp_expensify_ccCustomNames', - /** Whether to kick off the "Concierge is thinking" indicator when AgentZeroStatusGate mounts */ - CONCIERGE_THINKING_KICKOFF: 'conciergeThinkingKickoff', - /** The user's Concierge reportID */ CONCIERGE_REPORT_ID: 'conciergeReportID', @@ -1523,7 +1520,6 @@ type OnyxValuesMapping = { [ONYXKEYS.LAST_ROUTE]: string; [ONYXKEYS.IS_USING_IMPORTED_STATE]: boolean; [ONYXKEYS.NVP_EXPENSIFY_COMPANY_CARDS_CUSTOM_NAMES]: Record; - [ONYXKEYS.CONCIERGE_THINKING_KICKOFF]: boolean; [ONYXKEYS.CONCIERGE_REPORT_ID]: string; [ONYXKEYS.SELF_DM_REPORT_ID]: string; [ONYXKEYS.SHARE_UNKNOWN_USER_DETAILS]: Participant; diff --git a/src/components/Search/SearchRouter/useAskConcierge.tsx b/src/components/Search/SearchRouter/useAskConcierge.tsx index e844dac780a..7e0931d7a8e 100644 --- a/src/components/Search/SearchRouter/useAskConcierge.tsx +++ b/src/components/Search/SearchRouter/useAskConcierge.tsx @@ -4,7 +4,7 @@ import useOnyx from '@hooks/useOnyx'; import useOpenConciergeAnywhere from '@hooks/useOpenConciergeAnywhere'; import useSidePanelReportID from '@hooks/useSidePanelReportID'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; -import {addComment, setConciergeThinkingKickoff} from '@userActions/Report'; +import {addComment} from '@userActions/Report'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -26,7 +26,6 @@ function useAskConcierge() { if (!targetReport || !targetReportID) { return; } - setConciergeThinkingKickoff(); addComment({ report: targetReport, notifyReportID: targetReportID, diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index 0d160972372..fad817a7cfd 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -7542,14 +7542,6 @@ function setOptimisticTransactionThread(reportID?: string, parentReportID?: stri }); } -function setConciergeThinkingKickoff() { - Onyx.set(ONYXKEYS.CONCIERGE_THINKING_KICKOFF, true); -} - -function clearConciergeThinkingKickoff() { - Onyx.set(ONYXKEYS.CONCIERGE_THINKING_KICKOFF, null); -} - export type {Video, GuidedSetupData, TaskForParameters, IntroSelected, OpenReportActionParams, ParticipantInfo}; export { @@ -7668,6 +7660,4 @@ export { prepareOnyxDataForCleanUpOptimisticParticipants, getGuidedSetupDataForOpenReport, getReportChannelName, - setConciergeThinkingKickoff, - clearConciergeThinkingKickoff, }; diff --git a/src/pages/inbox/AgentZeroStatusContext.tsx b/src/pages/inbox/AgentZeroStatusContext.tsx index a65d2432dba..cc38b1d06db 100644 --- a/src/pages/inbox/AgentZeroStatusContext.tsx +++ b/src/pages/inbox/AgentZeroStatusContext.tsx @@ -1,10 +1,9 @@ import {getReportChatType} from '@selectors/Report'; import agentZeroProcessingIndicatorSelector from '@selectors/ReportNameValuePairs'; import React, {createContext, useContext, useEffect, useRef, useState} from 'react'; -import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; -import {clearConciergeThinkingKickoff, getReportChannelName} from '@libs/actions/Report'; +import {getReportChannelName} from '@libs/actions/Report'; import Log from '@libs/Log'; import Pusher from '@libs/Pusher'; import CONST from '@src/CONST'; @@ -17,7 +16,7 @@ type ReasoningEntry = { }; type AgentZeroStatusState = { - /** Whether AgentZero is actively working — true when the server sent a processing label or we're optimistically waiting */ + /** Whether AgentZero is actively working — true when the server has sent a processing label */ isProcessing: boolean; /** Chronological list of reasoning steps streamed via Pusher during the current processing request */ @@ -27,23 +26,13 @@ type AgentZeroStatusState = { 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. @@ -77,59 +66,33 @@ function AgentZeroStatusProvider({reportID, children}: React.PropsWithChildren<{ const MIN_DISPLAY_TIME = 300; // ms // Debounce delay for server label updates const DEBOUNCE_DELAY = 150; // ms -const OPTIMISTIC_TIMEOUT = 120000; // 2 minutes /** - * Inner gate — all Pusher, reasoning, label, and processing state. - * Only mounted when reportID matches the Concierge report. + * Inner gate — all Pusher, reasoning, and label state. + * Only mounted for AgentZero chats (Concierge DMs or policy #admins rooms). * 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...") + // Backend only writes this when AgentZero is actually handling the chat — the client no longer + // sets an optimistic label on send, so if AZ short-circuits (chat job exists, human responded + // within R2LR_TIME) nothing renders. 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(); - // Auto-kickoff "thinking" indicator when opened from search (where kickoffWaitingIndicator isn't accessible) - const [shouldKickoff] = useOnyx(ONYXKEYS.CONCIERGE_THINKING_KICKOFF); - useEffect(() => { - if (!shouldKickoff) { - return; - } - clearConciergeThinkingKickoff(); - // eslint-disable-next-line react-hooks/set-state-in-effect -- one-shot kickoff from search; Onyx flag is cleared immediately so it cannot cascade - setOptimisticStartTime(Date.now()); - }, [shouldKickoff]); - // 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) { @@ -190,12 +153,7 @@ function AgentZeroStatusGate({reportID, children}: React.PropsWithChildren<{repo // 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'); - } + const targetLabel = serverLabel ?? ''; if (displayedLabelRef.current === targetLabel) { return; @@ -213,7 +171,6 @@ function AgentZeroStatusGate({reportID, children}: React.PropsWithChildren<{repo // Immediate update when enough time has passed or when clearing the label if (remainingMinTime === 0 || targetLabel === '') { displayedLabelRef.current = targetLabel; - setDisplayedLabel(targetLabel); lastUpdateTimeRef.current = now; } else { @@ -233,27 +190,10 @@ function AgentZeroStatusGate({reportID, children}: React.PropsWithChildren<{repo } clearTimeout(updateTimerRef.current); }; - }, [serverLabel, optimisticStartTime, translate]); - - // Pusher updates carrying the server label can be silently dropped, leaving the optimistic indicator stuck forever. - useEffect(() => { - if (!optimisticStartTime) { - return; - } - const elapsed = Date.now() - optimisticStartTime; - const remaining = Math.max(0, OPTIMISTIC_TIMEOUT - elapsed); - const timer = setTimeout(() => { - setOptimisticStartTime(null); - }, remaining); - return () => clearTimeout(timer); - }, [optimisticStartTime]); - - const kickoffWaitingIndicator = () => { - setOptimisticStartTime(Date.now()); - }; + }, [serverLabel]); - // True when AgentZero is actively working — either the server sent a label or we're optimistically waiting - const isProcessing = !isOffline && (!!serverLabel || !!optimisticStartTime); + // True when AgentZero is actively working — the server has sent a label + const isProcessing = !isOffline && !!serverLabel; const stateValue: AgentZeroStatusState = { isProcessing, @@ -261,24 +201,12 @@ function AgentZeroStatusGate({reportID, children}: React.PropsWithChildren<{repo statusLabel: displayedLabel, }; - const actionsValue: AgentZeroStatusActions = { - kickoffWaitingIndicator, - }; - - return ( - - {children} - - ); + return {children}; } function useAgentZeroStatus(): AgentZeroStatusState { return useContext(AgentZeroStatusStateContext); } -function useAgentZeroStatusActions(): AgentZeroStatusActions { - return useContext(AgentZeroStatusActionsContext); -} - -export {AgentZeroStatusProvider, useAgentZeroStatus, useAgentZeroStatusActions}; -export type {AgentZeroStatusState, AgentZeroStatusActions, ReasoningEntry}; +export {AgentZeroStatusProvider, useAgentZeroStatus}; +export type {AgentZeroStatusState, ReasoningEntry}; diff --git a/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts b/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts index b59ce22502f..a37eb34a64a 100644 --- a/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts +++ b/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts @@ -20,7 +20,6 @@ import {addDomainToShortMention} from '@libs/ParsingUtils'; import {getFilteredReportActionsForReportView, getOneTransactionThreadReportID, isSentMoneyReportAction} from '@libs/ReportActionsUtils'; import {startSpan} from '@libs/telemetry/activeSpans'; import {generateAccountID} from '@libs/UserUtils'; -import {useAgentZeroStatusActions} from '@pages/inbox/AgentZeroStatusContext'; import {ActionListContext} from '@pages/inbox/ReportScreenContext'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -29,7 +28,6 @@ import {useComposerMeta} from './ComposerContext'; function useComposerSubmit(reportID: string): (comment: string) => void { const {isOffline} = useNetwork(); - const {kickoffWaitingIndicator} = useAgentZeroStatusActions(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const personalDetails = usePersonalDetails(); const {availableLoginsList} = useShortMentionsList(); @@ -61,7 +59,6 @@ function useComposerSubmit(reportID: string): (comment: string) => void { return (newComment: string) => { const newCommentTrimmed = newComment.trim(); - kickoffWaitingIndicator(); if (attachmentFileRef.current) { addAttachmentWithComment({ diff --git a/tests/unit/AgentZeroStatusContextTest.ts b/tests/unit/AgentZeroStatusContextTest.ts index a7e5cd33fb7..6c8c13935d8 100644 --- a/tests/unit/AgentZeroStatusContextTest.ts +++ b/tests/unit/AgentZeroStatusContextTest.ts @@ -1,26 +1,11 @@ -import {act, renderHook, waitFor} from '@testing-library/react-native'; +import {act, renderHook} from '@testing-library/react-native'; import React from 'react'; import Onyx from 'react-native-onyx'; import Pusher from '@libs/Pusher'; -import {AgentZeroStatusProvider, useAgentZeroStatus, useAgentZeroStatusActions} from '@pages/inbox/AgentZeroStatusContext'; +import {AgentZeroStatusProvider, useAgentZeroStatus} from '@pages/inbox/AgentZeroStatusContext'; import ONYXKEYS from '@src/ONYXKEYS'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; -const mockTranslate = jest.fn((key: string) => { - if (key === 'common.thinking') { - return 'Thinking...'; - } - return key; -}); - -jest.mock('@hooks/useLocalize', () => ({ - // eslint-disable-next-line @typescript-eslint/naming-convention - __esModule: true, - default: () => ({ - translate: mockTranslate, - }), -})); - jest.mock('@libs/Pusher'); const mockPusher = Pusher as jest.Mocked; @@ -74,14 +59,13 @@ describe('AgentZeroStatusContext', () => { await Onyx.merge(ONYXKEYS.CONCIERGE_REPORT_ID, '999'); // When we render the hook - const {result} = renderHook(() => ({...useAgentZeroStatus(), ...useAgentZeroStatusActions()}), {wrapper}); + const {result} = renderHook(() => useAgentZeroStatus(), {wrapper}); // Then it should return default state with no processing indicator await waitForBatchedUpdates(); expect(result.current.isProcessing).toBe(false); 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(); @@ -127,161 +111,6 @@ describe('AgentZeroStatusContext', () => { }); }); - 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 {result} = renderHook(() => ({...useAgentZeroStatus(), ...useAgentZeroStatusActions()}), {wrapper}); - await waitForBatchedUpdates(); - - // When the user triggers the waiting indicator (e.g., sending a message) - act(() => { - result.current.kickoffWaitingIndicator(); - }); - - // Then it should show optimistic processing state with waiting label - await waitForBatchedUpdates(); - expect(result.current.isProcessing).toBe(true); - expect(result.current.statusLabel).toBe('Thinking...'); - }); - - 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 serverLabel = 'Concierge is looking up categories...'; - - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`, { - agentZeroProcessingRequestIndicator: serverLabel, - }); - - const {result} = renderHook(() => ({...useAgentZeroStatus(), ...useAgentZeroStatusActions()}), {wrapper}); - await waitForBatchedUpdates(); - - const initialLabel = result.current.statusLabel; - - // When kickoff is called while already processing - act(() => { - result.current.kickoffWaitingIndicator(); - }); - - // Then the server label should remain unchanged - await waitForBatchedUpdates(); - expect(result.current.statusLabel).toBe(initialLabel); - expect(result.current.statusLabel).toBe(serverLabel); - }); - - it('should not trigger waiting state in non-Concierge chat', async () => { - // Given a regular chat (not Concierge) - await Onyx.merge(ONYXKEYS.CONCIERGE_REPORT_ID, '999'); - - const {result} = renderHook(() => ({...useAgentZeroStatus(), ...useAgentZeroStatusActions()}), {wrapper}); - await waitForBatchedUpdates(); - - // When kickoff is called - act(() => { - result.current.kickoffWaitingIndicator(); - }); - - // Then it should not show processing state - await waitForBatchedUpdates(); - expect(result.current.isProcessing).toBe(false); - expect(result.current.statusLabel).toBe(''); - }); - - it('should clear optimistic waiting state after 2-minute timeout when server never responds', async () => { - jest.useFakeTimers(); - - const {result} = renderHook(() => ({...useAgentZeroStatus(), ...useAgentZeroStatusActions()}), {wrapper}); - await waitForBatchedUpdates(); - - act(() => { - result.current.kickoffWaitingIndicator(); - }); - await waitForBatchedUpdates(); - expect(result.current.isProcessing).toBe(true); - expect(result.current.statusLabel).toBe('Thinking...'); - - act(() => { - jest.advanceTimersByTime(120000); - }); - await waitForBatchedUpdates(); - - expect(result.current.isProcessing).toBe(false); - expect(result.current.statusLabel).toBe(''); - }); - - it('should not clear optimistic state before the 2-minute timeout', async () => { - jest.useFakeTimers(); - - const {result} = renderHook(() => ({...useAgentZeroStatus(), ...useAgentZeroStatusActions()}), {wrapper}); - await waitForBatchedUpdates(); - - act(() => { - result.current.kickoffWaitingIndicator(); - }); - await waitForBatchedUpdates(); - - act(() => { - jest.advanceTimersByTime(60000); - }); - - expect(result.current.isProcessing).toBe(true); - expect(result.current.statusLabel).toBe('Thinking...'); - }); - - it('should cancel timeout when server label arrives before 2 minutes', async () => { - jest.useFakeTimers(); - - const {result} = renderHook(() => ({...useAgentZeroStatus(), ...useAgentZeroStatusActions()}), {wrapper}); - await waitForBatchedUpdates(); - - act(() => { - result.current.kickoffWaitingIndicator(); - }); - await waitForBatchedUpdates(); - expect(result.current.isProcessing).toBe(true); - - const serverLabel = 'Concierge is looking up categories...'; - // Don't await — Onyx.merge hangs under fake timers because its internal batching setTimeout never fires. - // waitForBatchedUpdates() handles this by calling jest.runOnlyPendingTimers(). - Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`, { - agentZeroProcessingRequestIndicator: serverLabel, - }); - await waitForBatchedUpdates(); - - act(() => { - jest.advanceTimersByTime(120000); - }); - await waitForBatchedUpdates(); - - expect(result.current.isProcessing).toBe(true); - expect(result.current.statusLabel).toBe(serverLabel); - }); - - it('should clear optimistic waiting state when server label arrives', async () => { - // Given a Concierge chat with optimistic waiting state - const {result} = renderHook(() => ({...useAgentZeroStatus(), ...useAgentZeroStatusActions()}), {wrapper}); - await waitForBatchedUpdates(); - - act(() => { - result.current.kickoffWaitingIndicator(); - }); - await waitForBatchedUpdates(); - - expect(result.current.statusLabel).toBe('Thinking...'); - - // When a server label arrives - const serverLabel = 'Concierge is looking up categories...'; - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`, { - agentZeroProcessingRequestIndicator: serverLabel, - }); - - // Then it should replace the waiting label with the server label - await waitForBatchedUpdates(); - await waitFor(() => { - expect(result.current.statusLabel).toBe(serverLabel); - }); - }); - }); - describe('reasoning via Pusher', () => { it('should update reasoning history when Pusher event arrives', async () => { // Given a Concierge chat @@ -424,40 +253,5 @@ describe('AgentZeroStatusContext', () => { expect(result.current.statusLabel).toBe(''); 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 {result} = renderHook(() => ({...useAgentZeroStatus(), ...useAgentZeroStatusActions()}), {wrapper}); - await waitForBatchedUpdates(); - - // User sends message → optimistic waiting state - act(() => { - result.current.kickoffWaitingIndicator(); - }); - await waitForBatchedUpdates(); - expect(result.current.isProcessing).toBe(true); - expect(result.current.statusLabel).toBe('Thinking...'); - - // Backend starts processing → server label arrives - const serverLabel = 'Processing your request...'; - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`, { - agentZeroProcessingRequestIndicator: serverLabel, - }); - await waitForBatchedUpdates(); - - await waitFor(() => { - expect(result.current.statusLabel).toBe(serverLabel); - }); - - // When the final response arrives → backend clears indicator - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`, { - agentZeroProcessingRequestIndicator: '', - }); - - // Then all processing state should be cleared - await waitForBatchedUpdates(); - expect(result.current.isProcessing).toBe(false); - expect(result.current.statusLabel).toBe(''); - }); }); });