diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index b8c4b6f9a5e5..e7da0d4b79c1 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -47,7 +47,9 @@ import Visibility from '@libs/Visibility'; import isSearchTopmostFullScreenRoute from '@navigation/helpers/isSearchTopmostFullScreenRoute'; +import ConciergeThinkingMessage from '@pages/home/report/ConciergeThinkingMessage'; import {useActionListContext, useActionListRef} from '@pages/inbox/ActionListContext'; +import {useAgentZeroStatus} from '@pages/inbox/AgentZeroStatusContext'; import {useConciergeDraft} from '@pages/inbox/ConciergeDraftContext'; import FloatingMessageCounter from '@pages/inbox/report/FloatingMessageCounter'; import ReportActionIndexContext from '@pages/inbox/report/ReportActionIndexContext'; @@ -506,6 +508,32 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) resetKey: report?.reportID ?? reportIDFromRoute ?? '', }); + // The indicator renders in the list footer, below the row scrollToBottom targets, so only + // scrollToEnd reveals it. This list is not inverted, so nothing sticks to the bottom for us. + const {candidateAgentIDs} = useAgentZeroStatus(); + const isThinkingIndicatorVisible = candidateAgentIDs.length > 0; + // Scroll once per appearance: the label changes many times per run, and re-firing would yank + // the viewport away from a user who has since scrolled up. + const hasScrolledForThinkingIndicatorRef = useRef(false); + useEffect(() => { + if (!isThinkingIndicatorVisible) { + hasScrolledForThinkingIndicatorRef.current = false; + return; + } + if (hasScrolledForThinkingIndicatorRef.current || scrollingVerticalBottomOffset.current >= CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD) { + return; + } + hasScrolledForThinkingIndicatorRef.current = true; + + // Wait for the footer to lay out, otherwise the content hasn't grown yet and there is + // nothing to scroll to. + const timeoutID = setTimeout(() => { + reportScrollManager.scrollToEnd(); + }, DELAY_FOR_SCROLLING_TO_END); + + return () => clearTimeout(timeoutID); + }, [isThinkingIndicatorVisible, reportScrollManager]); + /** * Subscribe to read/unread events and update our unreadMarkerTime */ @@ -782,6 +810,9 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) contentContainerStyle={shouldUseNarrowLayout ? styles.pt4 : styles.pt3} isLoadingInitialActions={!!showReportActionsLoadingState} skeletonReasonAttributes={skeletonReasonAttributes} + /* This list is not inverted, so the footer is the bottom of the message feed — + the same position the indicator occupies in the inverted ReportActionsList. */ + listFooterComponent={} /> )} diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx index d26f6922876f..b4f3f8a40dc8 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx @@ -28,6 +28,7 @@ import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan import Navigation from '@navigation/Navigation'; +import {AgentZeroStatusProvider} from '@pages/inbox/AgentZeroStatusContext'; import ReportActionsList from '@pages/inbox/report/ReportActionsList'; import ReportFooter from '@pages/inbox/report/ReportFooter'; import UserTypingEventListener from '@pages/inbox/report/UserTypingEventListener'; @@ -276,25 +277,29 @@ function MoneyRequestReportView({report, reportLoadingState, shouldDisplayReport )} - - {shouldDisplayMoneyRequestActionsList ? ( - - ) : ( - <> - - - - )} - {shouldDisplayReportFooter ? ( - <> - - - - ) : null} - + {/* Concierge can be mentioned here, so both feed branches need the AgentZero + status context that drives the thinking indicator. */} + + + {shouldDisplayMoneyRequestActionsList ? ( + + ) : ( + <> + + + + )} + {shouldDisplayReportFooter ? ( + <> + + + + ) : null} + + diff --git a/src/pages/inbox/AgentZeroStatusContext.tsx b/src/pages/inbox/AgentZeroStatusContext.tsx index 6ba2c67765fa..8e94f995717a 100644 --- a/src/pages/inbox/AgentZeroStatusContext.tsx +++ b/src/pages/inbox/AgentZeroStatusContext.tsx @@ -59,12 +59,23 @@ const AgentZeroStatusStateContext = createContext(defaultS const AgentZeroStatusActionsContext = createContext(defaultActions); /** - * Cheap outer guard — only subscribes to the scalar CONCIERGE_REPORT_ID and the report's chat - * metadata. For non-AgentZero reports (the common case), returns children directly. + * Cheap outer guard — subscribes to the scalar CONCIERGE_REPORT_ID, the report's chat metadata, + * and this report's processing-indicator NVP, then decides whether the gate below it does any + * work. For non-AgentZero reports (the common case) the gate stays inert: no reasoning + * subscription, no report-actions subscription, and an empty candidate list. * - * AgentZero chats include Concierge DMs, policy #admins rooms, and custom-agent chats (any - * report with a participant whose personalDetails carries `isCustomAgent: true`, stamped - * server-side in `Account::formatNewDotPersonalDetails`). + * A report qualifies either because the server is *already* processing for an agent in it, or + * because it's one of the chat types where an agent responds by default: Concierge DMs, policy + * #admins rooms, and custom-agent chats (any report with a participant whose personalDetails + * carries `isCustomAgent: true`, stamped server-side in `Account::formatNewDotPersonalDetails`). + * + * The NVP arm is what makes this work outside those chat types — expense reports, threads, + * anywhere else Auth decides to run an agent. Auth owns the rule for where agents respond + * (`shouldEmitConciergeStatusUpdates`), so keying off the indicator it writes keeps the client + * from re-deriving that rule and drifting from it. + * + * The chat-type arm still matters: it mounts the gate *before* the first NVP lands, so the + * reasoning Pusher subscription is live from the start of a Concierge run. */ function AgentZeroStatusProvider({reportID, children}: React.PropsWithChildren<{reportID: string | undefined}>) { const [reportMeta] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {selector: reportMetaSelector}); @@ -73,21 +84,27 @@ function AgentZeroStatusProvider({reportID, children}: React.PropsWithChildren<{ const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [currentUserAccountID] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector}); + // Read once here and hand it to the gate rather than subscribing again inside it — the + // selector narrows to a short accountID list, so this only re-renders when the set of + // actively-processing agents changes. + const [serverAgentIDs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`, {selector: agentZeroProcessingAgentIDsSelector}); + const isConciergeChat = reportID === conciergeReportID; const isAdmin = chatType === CONST.REPORT.CHAT_TYPE.POLICY_ADMINS; const isCustomAgentChat = agentParticipantAccountID !== undefined; const otherParticipantCount = currentUserAccountID === undefined ? 0 : (participantAccountIDs ?? []).filter((accountID) => accountID !== currentUserAccountID).length; const customAgentDMAccountID = isCustomAgentChat && isDMReport && otherParticipantCount === 1 ? agentParticipantAccountID : undefined; - const isAgentZeroChat = isConciergeChat || isAdmin || isCustomAgentChat; - - if (!reportID || !isAgentZeroChat) { - return children; - } + const isServerProcessing = (serverAgentIDs ?? []).length > 0; + const isAgentZeroChat = isConciergeChat || isAdmin || isCustomAgentChat || isServerProcessing; + // Mounted for every report, inert ones included, so `children` keep a stable position. A report + // becomes an AgentZero chat mid-session, and wrapping it only then would remount the whole feed. return ( @@ -98,27 +115,34 @@ function AgentZeroStatusProvider({reportID, children}: React.PropsWithChildren<{ function AgentZeroStatusGate({ reportID, + isActive, + serverAgentIDs, includeConcierge, customAgentDMAccountID, children, -}: React.PropsWithChildren<{reportID: string; includeConcierge: boolean; customAgentDMAccountID?: number}>) { +}: React.PropsWithChildren<{reportID: string | undefined; isActive: boolean; serverAgentIDs: number[] | undefined; includeConcierge: boolean; customAgentDMAccountID?: number}>) { const [currentUserAccountID] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector}); - const [serverAgentIDs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`, {selector: agentZeroProcessingAgentIDsSelector}); // When the agent's reply (ADDCOMMENT) lands before the server's indicator-clear NVP update, // the thinking bubble would remain visible briefly. Suppress any agent whose reply is already // the newest action in the report so the bubble hides as soon as the reply renders. - const [newestReportAction] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, {selector: getNewestReportActionSelector}); + // + // Keyed on the report only while an agent responds here: the gate mounts for every report, and + // this selector re-runs on every report-action change. + const [newestReportAction] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${isActive ? reportID : undefined}`, {selector: getNewestReportActionSelector}); // One reasoning Pusher subscription per report (not per agent). The handler in Report // actions routes each event to the right agent's reasoning history by its actorAccountID. // Cleanup clears the report's reasoning history and the Pusher subscription. useEffect(() => { + if (!isActive || !reportID) { + return; + } subscribeToReportReasoningEvents(reportID); return () => { unsubscribeFromReportReasoningChannel(reportID); }; - }, [reportID]); + }, [reportID, isActive]); const optimisticAgentAccountID = includeConcierge ? CONST.ACCOUNT_ID.CONCIERGE : customAgentDMAccountID; @@ -126,7 +150,7 @@ function AgentZeroStatusGate({ // custom-agent DMs both use the same per-agent optimistic store; other custom-agent contexts // remain server-driven so report-activity agents don't appear before Auth decides to run them. const kickoffWaitingIndicator = () => { - if (optimisticAgentAccountID === undefined) { + if (optimisticAgentAccountID === undefined || !reportID) { return; } AgentZeroOptimisticStore.increment(reportID, optimisticAgentAccountID, newestReportAction?.reportActionID ?? null); @@ -140,10 +164,10 @@ function AgentZeroStatusGate({ kickoffWaitingIndicator(); }, [shouldKickoff, includeConcierge, kickoffWaitingIndicator]); - const candidateIDs = new Set(serverAgentIDs ?? []); - if (includeConcierge) { + const candidateIDs = new Set(isActive ? (serverAgentIDs ?? []) : []); + if (isActive && includeConcierge) { candidateIDs.add(CONST.ACCOUNT_ID.CONCIERGE); - } else if (customAgentDMAccountID !== undefined) { + } else if (isActive && customAgentDMAccountID !== undefined) { candidateIDs.add(customAgentDMAccountID); } if (currentUserAccountID !== undefined) { diff --git a/tests/unit/AgentZeroStatusContextTest.ts b/tests/unit/AgentZeroStatusContextTest.ts index 0dcc41532bd7..3aadea43886f 100644 --- a/tests/unit/AgentZeroStatusContextTest.ts +++ b/tests/unit/AgentZeroStatusContextTest.ts @@ -1,4 +1,4 @@ -import {act, renderHook, waitFor} from '@testing-library/react-native'; +import {act, render, renderHook, waitFor} from '@testing-library/react-native'; import useAgentZeroStatusIndicator from '@hooks/useAgentZeroStatusIndicator'; @@ -87,10 +87,39 @@ async function seedCustomAgentReport({isDM, includeSession = true}: {isDM: boole await waitForBatchedUpdates(); } +/** + * Seeds a report that qualifies for the indicator only because the server writes a processing + * indicator for it — no Concierge DM, no #admins room, no custom-agent participant. + */ +async function seedServerDrivenReport(report: {type: string; parentReportID?: string; parentReportActionID?: string}) { + await Onyx.merge(ONYXKEYS.CONCIERGE_REPORT_ID, '999'); + await Onyx.merge(ONYXKEYS.SESSION, {accountID: currentUserAccountID}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, { + reportID, + participants: {[currentUserAccountID]: participant}, + ...report, + }); + await waitForBatchedUpdates(); +} + +async function setProcessingIndicator(indicator: Record) { + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`, {agentZeroProcessingRequestIndicator: indicator}); + await waitForBatchedUpdates(); +} + function wrapper({children}: {children: React.ReactNode}) { return React.createElement(AgentZeroStatusProvider, {reportID}, children); } +/** Counts how many times it mounts, so a test can prove the provider didn't remount its subtree. */ +let childMountCount = 0; +function MountCounter() { + React.useEffect(() => { + childMountCount += 1; + }, []); + return null; +} + describe('AgentZeroStatusContext', () => { beforeAll(() => Onyx.init({keys: ONYXKEYS})); @@ -213,6 +242,79 @@ describe('AgentZeroStatusContext', () => { expect(result.current.candidateAgentIDs).toContain(CUSTOM_AGENT); }); + it('includes the agent the server names on an expense report', async () => { + // Given an expense report — not a Concierge DM, #admins room, or custom-agent chat + await seedServerDrivenReport({type: CONST.REPORT.TYPE.EXPENSE}); + + // When the server names Concierge as actively processing + await setProcessingIndicator({[CONST.ACCOUNT_ID.CONCIERGE]: 'Concierge is looking up categories...'}); + + // Then Concierge gets a bubble and the server's label drives it + const {result} = renderHook(() => ({state: useAgentZeroStatus(), status: useAgentZeroStatusIndicator(reportID, CONST.ACCOUNT_ID.CONCIERGE)}), {wrapper}); + await waitForBatchedUpdates(); + expect(result.current.state.candidateAgentIDs).toContain(CONST.ACCOUNT_ID.CONCIERGE); + expect(result.current.status.isProcessing).toBe(true); + expect(result.current.status.statusLabel).toBe('Concierge is looking up categories...'); + }); + + it('includes the agent the server names on a thread', async () => { + // Given a thread hanging off another report + await seedServerDrivenReport({type: CONST.REPORT.TYPE.CHAT, parentReportID: '456', parentReportActionID: '789'}); + + // When the server names Concierge as actively processing + await setProcessingIndicator({[CONST.ACCOUNT_ID.CONCIERGE]: 'Concierge is thinking...'}); + + // Then Concierge gets a bubble + const {result} = renderHook(() => useAgentZeroStatus(), {wrapper}); + await waitForBatchedUpdates(); + expect(result.current.candidateAgentIDs).toContain(CONST.ACCOUNT_ID.CONCIERGE); + }); + + it('stays inert on an expense report while no agent is processing', async () => { + // Given an expense report the server has not written an indicator for + await seedServerDrivenReport({type: CONST.REPORT.TYPE.EXPENSE}); + + // When we render without any processing indicator + const {result} = renderHook(() => useAgentZeroStatus(), {wrapper}); + await waitForBatchedUpdates(); + + // Then no bubble renders and the gate never mounts, so nothing subscribes to reasoning + expect(result.current.candidateAgentIDs).toEqual([]); + expect(mockSubscribeToReportReasoningEvents).not.toHaveBeenCalled(); + }); + + it('drops the bubble on an expense report once the server clears the indicator', async () => { + // Given an expense report the server is actively processing + await seedServerDrivenReport({type: CONST.REPORT.TYPE.EXPENSE}); + await setProcessingIndicator({[CONST.ACCOUNT_ID.CONCIERGE]: 'Concierge is thinking...'}); + + const {result} = renderHook(() => useAgentZeroStatus(), {wrapper}); + await waitForBatchedUpdates(); + expect(result.current.candidateAgentIDs).toContain(CONST.ACCOUNT_ID.CONCIERGE); + + // When the server clears the label at the end of the run + await setProcessingIndicator({[CONST.ACCOUNT_ID.CONCIERGE]: ''}); + await waitForBatchedUpdates(); + + // Then the bubble goes away — nothing but the NVP was keeping this report gated in + expect(result.current.candidateAgentIDs).toEqual([]); + }); + + it('keeps children mounted when the server starts processing mid-session', async () => { + // Given an expense report with no processing indicator yet + await seedServerDrivenReport({type: CONST.REPORT.TYPE.EXPENSE}); + childMountCount = 0; + render(React.createElement(AgentZeroStatusProvider, {reportID}, React.createElement(MountCounter))); + await waitForBatchedUpdates(); + expect(childMountCount).toBe(1); + + // When the server starts processing for Concierge on this report + await setProcessingIndicator({[CONST.ACCOUNT_ID.CONCIERGE]: 'Concierge is thinking...'}); + + // Then the subtree is not remounted — the report feed keeps its scroll position and list state + expect(childMountCount).toBe(1); + }); + it('never includes the current user, even when the server names their accountID', async () => { await Onyx.merge(ONYXKEYS.SESSION, {accountID: CUSTOM_AGENT}); await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`, {