Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/ONYXKEYS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -685,6 +685,7 @@ const ONYXKEYS = {
REPORT_DRAFT_COMMENT: 'reportDraftComment_',
REPORT_IS_COMPOSER_FULL_SIZE: 'reportIsComposerFullSize_',
REPORT_USER_IS_TYPING: 'reportUserIsTyping_',
PENDING_CONCIERGE_RESPONSE: 'pendingConciergeResponse_',
REPORT_USER_IS_LEAVING_ROOM: 'reportUserIsLeavingRoom_',
REPORT_VIOLATIONS: 'reportViolations_',
SECURITY_GROUP: 'securityGroup_',
Expand Down Expand Up @@ -1164,6 +1165,7 @@ type OnyxCollectionValuesMapping = {
[ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT]: string;
[ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE]: boolean;
[ONYXKEYS.COLLECTION.REPORT_USER_IS_TYPING]: OnyxTypes.ReportUserIsTyping;
[ONYXKEYS.COLLECTION.PENDING_CONCIERGE_RESPONSE]: OnyxTypes.PendingConciergeResponse;
[ONYXKEYS.COLLECTION.REPORT_USER_IS_LEAVING_ROOM]: boolean;
[ONYXKEYS.COLLECTION.REPORT_VIOLATIONS]: OnyxTypes.ReportViolations;
[ONYXKEYS.COLLECTION.SECURITY_GROUP]: OnyxTypes.SecurityGroup;
Expand Down
42 changes: 42 additions & 0 deletions src/hooks/usePendingConciergeResponse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import {useEffect} from 'react';
import {applyPendingConciergeAction, discardPendingConciergeAction} from '@libs/actions/Report/SuggestedFollowup';
import ONYXKEYS from '@src/ONYXKEYS';
import useOnyx from './useOnyx';

/** If displayAfter is more than this far in the past, the response is stale (e.g. app was killed and restarted) */
const STALE_THRESHOLD_MS = 10_000;

/**
* Processes pending concierge responses stored in Onyx for a given report.
* When a pending response exists, schedules the action to be moved to REPORT_ACTIONS
* after the remaining delay, with automatic cleanup on unmount via useEffect.
*/
function usePendingConciergeResponse(reportID: string) {
const [pendingResponse] = useOnyx(`${ONYXKEYS.COLLECTION.PENDING_CONCIERGE_RESPONSE}${reportID}`, {canBeMissing: true});

useEffect(() => {
if (!pendingResponse) {
return;
}

const remaining = pendingResponse.displayAfter - Date.now();

// If the pending response is stale (e.g. app was killed/restarted), discard it
// instead of displaying a phantom message that was never confirmed by the server.
if (remaining < -STALE_THRESHOLD_MS) {
discardPendingConciergeAction(reportID);
Comment on lines +26 to +27

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid discarding queued response after routine navigation

The stale guard drops pending responses whenever displayAfter is more than 10s old, but this path is also hit during normal navigation (not just app restarts): useEffect clears the timer on unmount, so if a user leaves the report and comes back after ~10s, the queued Concierge reply is discarded instead of shown. This regresses the delayed-response flow for active sessions and can make a selected follow-up appear to have no Concierge answer.

Useful? React with 👍 / 👎.

@mkhutornyi mkhutornyi Feb 15, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if a user leaves the report and comes back after ~10s, the queued Concierge reply is discarded instead of shown. This regresses the delayed-response flow for active sessions and can make a selected follow-up appear to have no Concierge answer.

Is this expected?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The optimistic follow up is later replaced by actual response that comes from Concierge on BE. I think that if the user comes back after 10 s it's ok to just wait for the actual one. What do you think @marcochavezf ?

return;
}

const timer = setTimeout(
() => {
applyPendingConciergeAction(reportID, pendingResponse.reportAction);
},
Math.max(0, remaining),
);

return () => clearTimeout(timer);
}, [pendingResponse, reportID]);
}

export default usePendingConciergeResponse;
81 changes: 66 additions & 15 deletions src/libs/actions/Report/SuggestedFollowup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import type {Timezone} from '@src/types/onyx/PersonalDetails';
import {addComment, buildOptimisticResolvedFollowups} from '.';

/** Delay before showing pre-generated Concierge response (in milliseconds) */
const CONCIERGE_RESPONSE_DELAY_MS = 1500;
const CONCIERGE_RESPONSE_DELAY_MS = 4000;

/**
* Resolves a suggested followup by posting the selected question as a comment
Expand Down Expand Up @@ -89,22 +89,73 @@ function resolveSuggestedFollowup(
addOptimisticConciergeActionWithDelay(reportID, optimisticConciergeAction);
}

/**
* Queues an optimistic concierge response for delayed display.
* Writes action to Onyx — the usePendingConciergeResponse hook
* handles the actual delay and moves the action to REPORT_ACTIONS
* when the time arrives, with proper lifecycle cleanup.
*/
function addOptimisticConciergeActionWithDelay(reportID: string, optimisticConciergeAction: OptimisticReportAction) {
// Show "Concierge is typing..." indicator
Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_USER_IS_TYPING}${reportID}`, {
[CONST.ACCOUNT_ID.CONCIERGE]: true,
});
Onyx.update([
// Store the pending response for the scheduler to process
{
onyxMethod: Onyx.METHOD.SET,
key: `${ONYXKEYS.COLLECTION.PENDING_CONCIERGE_RESPONSE}${reportID}`,
value: {
Comment on lines +102 to +104

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Queue pending concierge responses instead of overwriting

Storing each delayed Concierge reply at a single key (pendingConciergeResponse_<reportID>) means a second pre-generated followup selected within the same 4s window replaces the first pending action. Because the hook re-subscribes on key changes, the first timer is cleared and only the most recent response is ever applied, so earlier optimistic Concierge replies can be silently dropped in reports where multiple followups are resolved quickly.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There should be only one follow up for the report in that 4s window.

Comment on lines +102 to +104

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve all pending Concierge replies per report

addOptimisticConciergeActionWithDelay() writes the delayed response with Onyx.METHOD.SET to a single PENDING_CONCIERGE_RESPONSE_<reportID> entry, so a second suggested-followup selected in the same report before the first 4s delay expires will overwrite the first pending action. In that case the hook can only apply the latest item, and the earlier optimistic Concierge reply is dropped instead of being shown, which is a user-visible regression when multiple unresolved followups are tapped in quick succession.

Useful? React with 👍 / 👎.

reportAction: optimisticConciergeAction.reportAction,
displayAfter: Date.now() + CONCIERGE_RESPONSE_DELAY_MS,
},
},
// Show "Concierge is typing..." indicator
{
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT_USER_IS_TYPING}${reportID}`,
value: {[CONST.ACCOUNT_ID.CONCIERGE]: true},
},
]);
}

setTimeout(() => {
// Clear the typing indicator
Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_USER_IS_TYPING}${reportID}`, {
[CONST.ACCOUNT_ID.CONCIERGE]: false,
});
/**
* Discards a stale pending concierge response and clears the typing indicator.
* Called when the response has been pending too long (e.g. app was killed and restarted).
*/
function discardPendingConciergeAction(reportID: string) {
Onyx.update([
{
onyxMethod: Onyx.METHOD.SET,
key: `${ONYXKEYS.COLLECTION.PENDING_CONCIERGE_RESPONSE}${reportID}`,
value: null,
},
{
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT_USER_IS_TYPING}${reportID}`,
value: {[CONST.ACCOUNT_ID.CONCIERGE]: false},
},
]);
}

Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, {
[optimisticConciergeAction.reportAction.reportActionID]: optimisticConciergeAction.reportAction,
});
}, CONCIERGE_RESPONSE_DELAY_MS);
/**
* Applies a pending concierge response by moving it to REPORT_ACTIONS
* and clearing the pending state and typing indicator.
*/
function applyPendingConciergeAction(reportID: string, reportAction: ReportAction) {
Onyx.update([
{
onyxMethod: Onyx.METHOD.SET,
key: `${ONYXKEYS.COLLECTION.PENDING_CONCIERGE_RESPONSE}${reportID}`,
value: null,
},
{
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT_USER_IS_TYPING}${reportID}`,
value: {[CONST.ACCOUNT_ID.CONCIERGE]: false},
},
{
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`,
value: {[reportAction.reportActionID]: reportAction},
},
]);
}

export {resolveSuggestedFollowup, CONCIERGE_RESPONSE_DELAY_MS};
export {resolveSuggestedFollowup, discardPendingConciergeAction, applyPendingConciergeAction, CONCIERGE_RESPONSE_DELAY_MS};
2 changes: 2 additions & 0 deletions src/pages/inbox/report/ReportActionsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import useCopySelectionHelper from '@hooks/useCopySelectionHelper';
import useLoadReportActions from '@hooks/useLoadReportActions';
import useNetwork from '@hooks/useNetwork';
import useOnyx from '@hooks/useOnyx';
import usePendingConciergeResponse from '@hooks/usePendingConciergeResponse';
import usePrevious from '@hooks/usePrevious';
import useReportIsArchived from '@hooks/useReportIsArchived';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
Expand Down Expand Up @@ -81,6 +82,7 @@ function ReportActionsView({
isReportTransactionThread,
}: ReportActionsViewProps) {
useCopySelectionHelper();
usePendingConciergeResponse(report.reportID);
const route = useRoute<PlatformStackRouteProp<ReportsSplitNavigatorParamList, typeof SCREENS.REPORT>>();
const isReportArchived = useReportIsArchived(report?.reportID);
const canPerformWriteAction = useMemo(() => canUserPerformWriteAction(report, isReportArchived), [report, isReportArchived]);
Expand Down
12 changes: 12 additions & 0 deletions src/types/onyx/PendingConciergeResponse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type ReportAction from './ReportAction';

/** Pending concierge response queued for delayed display in a report */
type PendingConciergeResponse = {
/** The optimistic report action to add after the delay */
reportAction: ReportAction;

/** Timestamp (ms) after which the response should be displayed */
displayAfter: number;
};

export default PendingConciergeResponse;
2 changes: 2 additions & 0 deletions src/types/onyx/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ import type OnyxInputOrEntry from './OnyxInputOrEntry';
import type {AnyOnyxUpdatesFromServer, OnyxUpdateEvent, OnyxUpdatesFromServer} from './OnyxUpdatesFromServer';
import type {DecisionName, OriginalMessageIOU} from './OriginalMessage';
import type Pages from './Pages';
import type PendingConciergeResponse from './PendingConciergeResponse';
import type {PendingContactAction} from './PendingContactAction';
import type PersonalBankAccount from './PersonalBankAccount';
import type {PersonalDetailsList, PersonalDetailsMetadata} from './PersonalDetails';
Expand Down Expand Up @@ -231,6 +232,7 @@ export type {
OnyxUpdatesFromServer,
AnyOnyxUpdatesFromServer,
Pages,
PendingConciergeResponse,
PersonalBankAccount,
PersonalDetails,
PersonalDetailsList,
Expand Down
21 changes: 9 additions & 12 deletions tests/actions/ReportTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4190,26 +4190,23 @@ describe('actions/Report', () => {
await waitForBatchedUpdates();

// Verify the followup-list was marked as selected
let reportActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}` as const);
const reportActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}` as const);
const updatedHtml = (reportActions?.[REPORT_ACTION_ID]?.message as Message[])?.at(0)?.html;
expect(updatedHtml).toContain('<followup-list selected>');

// Verify addComment was called (which triggers ADD_COMMENT API call)
// With pre-generated response, the API call should include the optimistic Concierge response params
TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.ADD_COMMENT, 1);

// Wait for the delayed Concierge response (1500ms delay in SuggestedFollowup.ts)
await new Promise((resolve) => {
setTimeout(resolve, CONCIERGE_RESPONSE_DELAY_MS + 100);
});
await waitForBatchedUpdates();
// Verify the pending concierge response was written to Onyx (the hook will process it)
const pendingResponse = await getOnyxValue(`${ONYXKEYS.COLLECTION.PENDING_CONCIERGE_RESPONSE}${REPORT_ID}` as const);
expect(pendingResponse).not.toBeNull();
expect(pendingResponse?.reportAction.actorAccountID).toBe(CONST.ACCOUNT_ID.CONCIERGE);
expect(pendingResponse?.displayAfter).toBeGreaterThan(Date.now() - CONCIERGE_RESPONSE_DELAY_MS);

// Verify an optimistic Concierge report action was created
reportActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}` as const);
const allReportActions = Object.values(reportActions ?? {});
const conciergeActions = allReportActions.filter((action) => action?.actorAccountID === CONST.ACCOUNT_ID.CONCIERGE);
// Should have 2 Concierge actions: the original one and the optimistic response
expect(conciergeActions.length).toBe(2);
// Verify the typing indicator was set
const typingStatus = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_USER_IS_TYPING}${REPORT_ID}` as const);
expect(typingStatus?.[CONST.ACCOUNT_ID.CONCIERGE]).toBe(true);
});
});

Expand Down
Loading
Loading