Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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={<ConciergeThinkingMessage reportID={reportIDFromRoute} />}
Comment thread
chiragsalian marked this conversation as resolved.
/>
)}
</View>
Expand Down
43 changes: 24 additions & 19 deletions src/components/MoneyRequestReportView/MoneyRequestReportView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -276,25 +277,29 @@ function MoneyRequestReportView({report, reportLoadingState, shouldDisplayReport
</ScrollView>
</Animated.View>
)}
<View style={[styles.overflowHidden, styles.justifyContentEnd, styles.flex1]}>
{shouldDisplayMoneyRequestActionsList ? (
<MoneyRequestReportActionsList onLayout={onLayout} />
) : (
<>
<ReportActionsList
reportID={report.reportID}
onLayout={onLayout}
/>
<UserTypingEventListener report={report} />
</>
)}
{shouldDisplayReportFooter ? (
<>
<ReportFooter />
<PortalHost name="suggestions" />
</>
) : null}
</View>
{/* Concierge can be mentioned here, so both feed branches need the AgentZero
status context that drives the thinking indicator. */}
<AgentZeroStatusProvider reportID={report.reportID}>
<View style={[styles.overflowHidden, styles.justifyContentEnd, styles.flex1]}>
{shouldDisplayMoneyRequestActionsList ? (
<MoneyRequestReportActionsList onLayout={onLayout} />
) : (
<>
<ReportActionsList
reportID={report.reportID}
onLayout={onLayout}
/>
<UserTypingEventListener report={report} />
</>
)}
{shouldDisplayReportFooter ? (
<>
<ReportFooter />
<PortalHost name="suggestions" />
</>
) : null}
</View>
</AgentZeroStatusProvider>
</View>
</OfflineWithFeedback>
</View>
Expand Down
60 changes: 42 additions & 18 deletions src/pages/inbox/AgentZeroStatusContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,12 +59,23 @@ const AgentZeroStatusStateContext = createContext<AgentZeroStatusState>(defaultS
const AgentZeroStatusActionsContext = createContext<AgentZeroStatusActions>(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});
Expand All @@ -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;
Comment thread
chiragsalian marked this conversation as resolved.

// 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 (
<AgentZeroStatusGate
key={reportID}
reportID={reportID}
isActive={!!reportID && isAgentZeroChat}
serverAgentIDs={serverAgentIDs}
includeConcierge={isConciergeChat || isAdmin}
customAgentDMAccountID={customAgentDMAccountID}
>
Expand All @@ -98,35 +115,42 @@ 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;

// The composer calls this before the server's processing-indicator NVP lands. Concierge and
// 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);
Expand All @@ -140,10 +164,10 @@ function AgentZeroStatusGate({
kickoffWaitingIndicator();
}, [shouldKickoff, includeConcierge, kickoffWaitingIndicator]);

const candidateIDs = new Set<number>(serverAgentIDs ?? []);
if (includeConcierge) {
const candidateIDs = new Set<number>(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) {
Expand Down
104 changes: 103 additions & 1 deletion tests/unit/AgentZeroStatusContextTest.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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<number, string>) {
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}));

Expand Down Expand Up @@ -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}`, {
Expand Down
Loading