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}`, {