Skip to content

Commit fc1a858

Browse files
authored
Merge pull request Expensify#85620 from Expensify/marcochavezf/612534-fix-stuck-thinking-indicator
[Payment due @situchan] Fix stuck Concierge thinking indicator when client misses Onyx clear update
2 parents dd74f00 + 86984b4 commit fc1a858

8 files changed

Lines changed: 1654 additions & 189 deletions

File tree

src/ONYXKEYS.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -609,6 +609,9 @@ const ONYXKEYS = {
609609
/** Company cards custom names */
610610
NVP_EXPENSIFY_COMPANY_CARDS_CUSTOM_NAMES: 'nvp_expensify_ccCustomNames',
611611

612+
/** Whether to kick off the "Concierge is thinking" indicator when AgentZeroStatusGate mounts */
613+
CONCIERGE_THINKING_KICKOFF: 'conciergeThinkingKickoff',
614+
612615
/** The user's Concierge reportID */
613616
CONCIERGE_REPORT_ID: 'conciergeReportID',
614617

@@ -1541,6 +1544,7 @@ type OnyxValuesMapping = {
15411544
[ONYXKEYS.LAST_ROUTE]: string;
15421545
[ONYXKEYS.IS_USING_IMPORTED_STATE]: boolean;
15431546
[ONYXKEYS.NVP_EXPENSIFY_COMPANY_CARDS_CUSTOM_NAMES]: Record<string, string>;
1547+
[ONYXKEYS.CONCIERGE_THINKING_KICKOFF]: boolean;
15441548
[ONYXKEYS.CONCIERGE_REPORT_ID]: string;
15451549
[ONYXKEYS.SELF_DM_REPORT_ID]: string;
15461550
[ONYXKEYS.SHARE_UNKNOWN_USER_DETAILS]: Participant;

src/components/Search/SearchRouter/useAskConcierge.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import useOnyx from '@hooks/useOnyx';
44
import useOpenConciergeAnywhere from '@hooks/useOpenConciergeAnywhere';
55
import useSidePanelReportID from '@hooks/useSidePanelReportID';
66
import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID';
7-
import {addComment} from '@userActions/Report';
7+
import {addComment, setConciergeThinkingKickoff} from '@userActions/Report';
88
import CONST from '@src/CONST';
99
import ONYXKEYS from '@src/ONYXKEYS';
1010

@@ -29,6 +29,7 @@ function useAskConcierge() {
2929
return;
3030
}
3131
openConciergeAnywhere();
32+
setConciergeThinkingKickoff();
3233
addComment({
3334
report: targetReport,
3435
notifyReportID: targetReportID,

src/hooks/useAgentZeroStatusIndicator.ts

Lines changed: 410 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
/**
2+
* Ephemeral in-memory store for the optimistic "Concierge is thinking…" state per report.
3+
*
4+
* The optimistic counter has to survive ReportScreen remounts (switching chats and coming
5+
* back) — React state scoped to the mounted provider doesn't. This store holds it at the
6+
* module level, keyed by reportID, so the next mount can restore the indicator.
7+
*
8+
* Transient by nature: cleared on reply detection, safety timeout, reconnect, or by the
9+
* caller. Not persisted to Onyx.
10+
*/
11+
12+
/** Upper bound on how long an optimistic entry stays valid without a server label or reply. */
13+
const MAX_AGE_MS = 120000;
14+
15+
type OptimisticEntry = {
16+
/** Number of pending optimistic kickoffs (one per user message). */
17+
count: number;
18+
19+
/**
20+
* Timestamp of the most recent kickoff (ms). Drives the remaining safety window across
21+
* remounts. Bumped on every `increment` — matches the in-memory behavior where each
22+
* kickoff call to `startPolling` resets the 120s safety timer.
23+
*/
24+
startedAt: number;
25+
26+
/**
27+
* Newest reportActionID captured at the moment the indicator transitioned inactive→active.
28+
* Persisted so the Concierge-reply-detection effect still fires after a remount, even if
29+
* the reply arrived while the provider was unmounted. Unlike `startedAt`, this stays fixed
30+
* at the *first* kickoff's baseline — any new Concierge action afterwards is a new reply.
31+
*/
32+
baselineActionID: string | null;
33+
};
34+
35+
type Listener = (reportID: string) => void;
36+
37+
const store = new Map<string, OptimisticEntry>();
38+
const listeners = new Set<Listener>();
39+
40+
function notifyListeners(reportID: string) {
41+
for (const listener of listeners) {
42+
listener(reportID);
43+
}
44+
}
45+
46+
function isFresh(entry: OptimisticEntry): boolean {
47+
return Date.now() - entry.startedAt < MAX_AGE_MS;
48+
}
49+
50+
/**
51+
* Get the current entry for a report, or undefined if none exists or the entry is past
52+
* its MAX_AGE_MS window. Stale entries are left in the map — the next increment/clear
53+
* will evict them. Callers that care about cleanup can call `clear` imperatively.
54+
*/
55+
function getEntry(reportID: string): OptimisticEntry | undefined {
56+
const entry = store.get(reportID);
57+
if (!entry) {
58+
return undefined;
59+
}
60+
return isFresh(entry) ? entry : undefined;
61+
}
62+
63+
/**
64+
* Increment the pending count for a report, or start a fresh entry if none exists / the
65+
* existing one is stale. `baselineActionID` is only recorded on a fresh start — subsequent
66+
* kickoffs within the same window keep the original baseline (we care about replies newer
67+
* than the *first* optimistic message).
68+
*
69+
* `startedAt` is bumped on every call so the safety window always measures from the most
70+
* recent kickoff — this matches the in-memory `startPolling` behavior where each call
71+
* resets the 120s timer.
72+
*/
73+
function increment(reportID: string, baselineActionID: string | null) {
74+
const existing = store.get(reportID);
75+
const now = Date.now();
76+
if (existing && isFresh(existing)) {
77+
store.set(reportID, {
78+
count: existing.count + 1,
79+
startedAt: now,
80+
baselineActionID: existing.baselineActionID,
81+
});
82+
} else {
83+
store.set(reportID, {
84+
count: 1,
85+
startedAt: now,
86+
baselineActionID,
87+
});
88+
}
89+
notifyListeners(reportID);
90+
}
91+
92+
/** Drop all optimistic state for a report. Safe to call when no entry exists. */
93+
function clear(reportID: string) {
94+
if (!store.has(reportID)) {
95+
return;
96+
}
97+
store.delete(reportID);
98+
notifyListeners(reportID);
99+
}
100+
101+
function subscribe(listener: Listener): () => void {
102+
listeners.add(listener);
103+
return () => {
104+
listeners.delete(listener);
105+
};
106+
}
107+
108+
export default {
109+
getEntry,
110+
increment,
111+
clear,
112+
subscribe,
113+
};
114+
115+
export {MAX_AGE_MS};
116+
export type {OptimisticEntry};
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
/**
2+
* Ephemeral in-memory store for managing Concierge reasoning summaries per report.
3+
* This data is transient UI feedback and is NOT persisted to Onyx.
4+
*/
5+
6+
type ReasoningEntry = {
7+
reasoning: string;
8+
loopCount: number;
9+
timestamp: number;
10+
};
11+
12+
type ReasoningData = {
13+
reasoning: string;
14+
agentZeroRequestID: string;
15+
loopCount: number;
16+
};
17+
18+
type ReportState = {
19+
agentZeroRequestID: string;
20+
entries: ReasoningEntry[];
21+
};
22+
23+
type Listener = (reportID: string, state: ReasoningEntry[]) => void;
24+
25+
// In-memory store
26+
const store = new Map<string, ReportState>();
27+
const listeners = new Set<Listener>();
28+
29+
// Stable empty array reference for useSyncExternalStore compatibility.
30+
// getSnapshot must return the same reference when data hasn't changed,
31+
// otherwise React will re-render infinitely.
32+
const EMPTY_ENTRIES: ReasoningEntry[] = [];
33+
34+
/**
35+
* Notify all subscribers of state changes
36+
*/
37+
function notifyListeners(reportID: string, entries: ReasoningEntry[]) {
38+
for (const listener of listeners) {
39+
listener(reportID, entries);
40+
}
41+
}
42+
43+
/**
44+
* Add a reasoning entry to a report's history.
45+
* If the agentZeroRequestID differs from the current state, resets all entries (new request).
46+
* Skips duplicates (same loopCount + same reasoning text).
47+
*/
48+
function addReasoning(reportID: string, data: ReasoningData) {
49+
// Ignore empty reasoning strings
50+
if (!data.reasoning.trim()) {
51+
return;
52+
}
53+
54+
const currentState = store.get(reportID);
55+
56+
// If agentZeroRequestID differs, reset all entries (new request)
57+
if (currentState && currentState.agentZeroRequestID !== data.agentZeroRequestID) {
58+
store.set(reportID, {
59+
agentZeroRequestID: data.agentZeroRequestID,
60+
entries: [],
61+
});
62+
}
63+
64+
// Get or create state
65+
const state = store.get(reportID) ?? {
66+
agentZeroRequestID: data.agentZeroRequestID,
67+
entries: [],
68+
};
69+
70+
// Skip duplicates (same loopCount + same reasoning text)
71+
const isDuplicate = state.entries.some((entry) => entry.loopCount === data.loopCount && entry.reasoning === data.reasoning);
72+
73+
if (!isDuplicate) {
74+
const timestamp = Date.now();
75+
const newEntries = [
76+
...state.entries,
77+
{
78+
reasoning: data.reasoning,
79+
loopCount: data.loopCount,
80+
timestamp,
81+
},
82+
];
83+
const newState = {
84+
agentZeroRequestID: state.agentZeroRequestID,
85+
entries: newEntries,
86+
};
87+
store.set(reportID, newState);
88+
notifyListeners(reportID, newEntries);
89+
}
90+
}
91+
92+
/**
93+
* Remove all reasoning entries for a report.
94+
* Called when the final Concierge message arrives or when unsubscribing.
95+
*/
96+
function clearReasoning(reportID: string) {
97+
store.delete(reportID);
98+
notifyListeners(reportID, []);
99+
}
100+
101+
/**
102+
* Get the reasoning history for a report
103+
*/
104+
function getReasoningHistory(reportID: string): ReasoningEntry[] {
105+
return store.get(reportID)?.entries ?? EMPTY_ENTRIES;
106+
}
107+
108+
/**
109+
* Subscribe to state changes.
110+
* Listener receives (reportID, state) on every change.
111+
* Returns an unsubscribe function.
112+
*/
113+
function subscribe(listener: Listener): () => void {
114+
listeners.add(listener);
115+
return () => {
116+
listeners.delete(listener);
117+
};
118+
}
119+
120+
export default {
121+
addReasoning,
122+
clearReasoning,
123+
getReasoningHistory,
124+
subscribe,
125+
};
126+
127+
export type {ReasoningEntry, ReasoningData};

src/libs/actions/Report/index.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ import {READ_COMMANDS, SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs
6464
import * as ApiUtils from '@libs/ApiUtils';
6565
import * as Browser from '@libs/Browser';
6666
import * as CollectionUtils from '@libs/CollectionUtils';
67+
import ConciergeReasoningStore from '@libs/ConciergeReasoningStore';
6768
import type {CustomRNImageManipulatorResult} from '@libs/cropOrRotateImage/types';
6869
import DateUtils from '@libs/DateUtils';
6970
import * as EmojiUtils from '@libs/EmojiUtils';
@@ -432,6 +433,10 @@ Onyx.connect({
432433

433434
const typingWatchTimers: Record<string, NodeJS.Timeout> = {};
434435

436+
// Track subscriptions to conciergeReasoning Pusher events to avoid duplicates.
437+
// Maps reportID to the PusherSubscription handle for proper per-callback cleanup.
438+
const reasoningSubscriptions = new Map<string, ReturnType<typeof Pusher.subscribe>>();
439+
435440
let reportIDDeeplinkedFromOldDot: string | undefined;
436441
Linking.getInitialURL().then((url) => {
437442
reportIDDeeplinkedFromOldDot = processReportIDDeeplink(url ?? '');
@@ -609,6 +614,64 @@ function unsubscribeFromLeavingRoomReportChannel(reportID: string | undefined) {
609614
Pusher.unsubscribe(pusherChannelName, Pusher.TYPE.USER_IS_LEAVING_ROOM);
610615
}
611616

617+
/**
618+
* Subscribe to conciergeReasoning Pusher events for a report.
619+
* Tracks subscriptions to avoid duplicates and updates ConciergeReasoningStore with reasoning data.
620+
*/
621+
function subscribeToReportReasoningEvents(reportID: string) {
622+
if (!reportID || reasoningSubscriptions.has(reportID)) {
623+
return;
624+
}
625+
626+
const pusherChannelName = getReportChannelName(reportID);
627+
628+
const handle = Pusher.subscribe(pusherChannelName, Pusher.TYPE.CONCIERGE_REASONING, (data: Record<string, unknown>) => {
629+
const eventData = data as {reasoning: string; agentZeroRequestID: string; loopCount: number};
630+
631+
ConciergeReasoningStore.addReasoning(reportID, {
632+
reasoning: eventData.reasoning,
633+
agentZeroRequestID: eventData.agentZeroRequestID,
634+
loopCount: eventData.loopCount,
635+
});
636+
});
637+
638+
// Store the handle immediately to prevent duplicate subscriptions
639+
reasoningSubscriptions.set(reportID, handle);
640+
641+
handle.catch((error: ReportError) => {
642+
Log.hmmm('[Report] Failed to subscribe to Pusher concierge reasoning events', {errorType: error.type, pusherChannelName, reportID});
643+
// Remove from subscriptions if subscription failed
644+
reasoningSubscriptions.delete(reportID);
645+
});
646+
}
647+
648+
/**
649+
* Unsubscribe from conciergeReasoning Pusher events for a report.
650+
* Clears reasoning state and removes from subscription tracking.
651+
*/
652+
function unsubscribeFromReportReasoningChannel(reportID: string) {
653+
const handle = reasoningSubscriptions.get(reportID);
654+
if (!reportID || !handle) {
655+
return;
656+
}
657+
658+
// Use the per-callback handle for precise cleanup instead of the global
659+
// Pusher.unsubscribe which removes ALL callbacks for the event on the channel.
660+
handle.unsubscribe();
661+
ConciergeReasoningStore.clearReasoning(reportID);
662+
reasoningSubscriptions.delete(reportID);
663+
}
664+
665+
/**
666+
* Clear the AgentZero processing indicator for a report.
667+
* Used by the safety timeout (lease pattern) and network reconnect handler
668+
* to auto-clear stale indicators when the CLEAR update was missed.
669+
*/
670+
function clearAgentZeroProcessingIndicator(reportID: string) {
671+
Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`, {agentZeroProcessingRequestIndicator: null});
672+
ConciergeReasoningStore.clearReasoning(reportID);
673+
}
674+
612675
// New action subscriber array for report pages
613676
let newActionSubscribers: ActionSubscriber[] = [];
614677

@@ -7612,6 +7675,14 @@ function setOptimisticTransactionThread(reportID?: string, parentReportID?: stri
76127675
});
76137676
}
76147677

7678+
function setConciergeThinkingKickoff() {
7679+
Onyx.set(ONYXKEYS.CONCIERGE_THINKING_KICKOFF, true);
7680+
}
7681+
7682+
function clearConciergeThinkingKickoff() {
7683+
Onyx.set(ONYXKEYS.CONCIERGE_THINKING_KICKOFF, null);
7684+
}
7685+
76157686
export type {Video, GuidedSetupData, TaskForParameters, IntroSelected, OpenReportActionParams, ParticipantInfo};
76167687

76177688
export {
@@ -7693,6 +7764,11 @@ export {
76937764
startNewChat,
76947765
subscribeToNewActionEvent,
76957766
subscribeToReportLeavingEvents,
7767+
clearAgentZeroProcessingIndicator,
7768+
clearConciergeThinkingKickoff,
7769+
setConciergeThinkingKickoff,
7770+
subscribeToReportReasoningEvents,
7771+
unsubscribeFromReportReasoningChannel,
76967772
subscribeToReportTypingEvents,
76977773
toggleEmojiReaction,
76987774
togglePinnedState,

0 commit comments

Comments
 (0)