Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
1dffb24
Fix stuck Concierge thinking indicator when Onyx batches SET+CLEAR up…
marcochavezf Mar 18, 2026
96487ea
Fix CI: move Onyx.connect to lib module and fix spellcheck
marcochavezf Mar 20, 2026
c7604b1
Fix CI: use Connection type for Onyx.connect return value and prefer …
marcochavezf Mar 20, 2026
22b0816
Scope Onyx write counter to track pending requests for multi-message …
marcochavezf Mar 24, 2026
1a681db
Replace NVP version tracker with client-side TTL (lease pattern) for …
marcochavezf Mar 25, 2026
0515c33
Add XMPP to cspell allowed words
marcochavezf Mar 25, 2026
32c1893
Trigger CI re-run for checklist validation
marcochavezf Mar 25, 2026
fb98ac7
Add getNewerActions fallback when safety timer fires or network recon…
marcochavezf Mar 27, 2026
b1ce260
Merge branch 'main' into marcochavezf/612534-fix-stuck-thinking-indic…
marcochavezf Mar 27, 2026
3de2a0b
Replace hard TTL clear with progressive retry for thinking indicator
marcochavezf Mar 27, 2026
f6f8678
Update safety timeout tests for progressive retry behavior
marcochavezf Mar 27, 2026
ac267d7
Fix ESLint: use Set for PROGRESSIVE_RETRY_INTERVALS_MS in tests
marcochavezf Mar 27, 2026
8938ec1
Replace progressive retry with 30s polling for thinking indicator
marcochavezf Mar 28, 2026
2e00661
Keep thinking indicator on reconnect until response arrives
marcochavezf Mar 28, 2026
8d2d8c4
Restore original offline behavior: hide indicator when offline, reapp…
marcochavezf Mar 28, 2026
bb281de
Fix reconnect test: indicator persists through offline/online cycle
marcochavezf Mar 28, 2026
9815ba2
Fix: clear indicator NVP alongside getNewerActions poll
marcochavezf Mar 28, 2026
cd11423
Fix: detect new actions to clear stuck indicator instead of blind NVP…
marcochavezf Mar 28, 2026
5e86d94
Clear indicator NVP on network reconnect to fix stuck state after Pus…
marcochavezf Mar 28, 2026
6a18430
Delegate AgentZeroStatusContext to useAgentZeroStatusIndicator hook (…
marcochavezf Mar 29, 2026
5bd1c44
Clear indicator immediately when Concierge response detected via Onyx
marcochavezf Mar 30, 2026
74ed559
Fix: move Concierge detection useEffect after dependency declarations
marcochavezf Mar 30, 2026
0790eec
Fix ESLint: prefer-early-return + narrow hook dependencies
marcochavezf Mar 30, 2026
ac8ba88
Address all bot review comments on PR #85620
marcochavezf Mar 30, 2026
cc74304
Fix infinite re-render loop in useAgentZeroStatusIndicator tests
marcochavezf Mar 30, 2026
efa98d2
Remove redundant useCallback/useMemo wrappers (React Compiler handles…
marcochavezf Apr 3, 2026
bc884e3
Merge remote-tracking branch 'origin/main' into marcochavezf/612534-f…
marcochavezf Apr 6, 2026
19aebb9
Merge main, resolve conflicts, address review comments
marcochavezf Apr 8, 2026
fecc854
Merge main + fix broken isModalVisible reference from #87077
marcochavezf Apr 8, 2026
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
331 changes: 331 additions & 0 deletions src/hooks/useAgentZeroStatusIndicator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,331 @@
import {useEffect, useRef, useState, useSyncExternalStore} from 'react';
import type {OnyxEntry} from 'react-native-onyx';
import {clearAgentZeroProcessingIndicator, getNewerActions, subscribeToReportReasoningEvents, unsubscribeFromReportReasoningChannel} from '@libs/actions/Report';
import ConciergeReasoningStore from '@libs/ConciergeReasoningStore';
import type {ReasoningEntry} from '@libs/ConciergeReasoningStore';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {ReportActions} from '@src/types/onyx/ReportAction';
import useLocalize from './useLocalize';
import useNetwork from './useNetwork';
import useOnyx from './useOnyx';

type AgentZeroStatusState = {
isProcessing: boolean;
reasoningHistory: ReasoningEntry[];
statusLabel: string;
kickoffWaitingIndicator: () => void;
};

type NewestReportAction = {
reportActionID: string;
actorAccountID?: number;
};

/**
* Polling interval for fetching missed Concierge responses while the thinking indicator is visible.
*
* While the indicator is active, we poll getNewerActions every 30s to recover from
* WebSocket drops or missed Pusher events. If a Concierge reply arrives (via Pusher
* or the poll response), the normal Onyx update clears the indicator automatically.
*
* A hard safety clear at MAX_POLL_DURATION_MS ensures the indicator doesn't stay
* forever if something goes wrong.
*/
const POLL_INTERVAL_MS = 30000;

/**
* Maximum duration to poll before hard-clearing the indicator (safety net).
* After this time, if we're online and no response has arrived, we clear the indicator.
*/
const MAX_POLL_DURATION_MS = 120000;

/**
* Hook to manage AgentZero status indicator for chats where AgentZero responds.
* This includes both Concierge DM chats and policy #admins rooms (where Concierge handles onboarding).
* @param reportID - The report ID to monitor
* @param isAgentZeroChat - Whether the chat is an AgentZero-enabled chat (Concierge DM or #admins room)
*/
/** Selector that extracts the newest report action ID and actor from the report actions collection. */
function selectNewestReportAction(reportActions: OnyxEntry<ReportActions>): NewestReportAction | undefined {
if (!reportActions) {
return undefined;
}
const actionIDs = Object.keys(reportActions);
if (actionIDs.length === 0) {
return undefined;
}
const newestReportActionID = actionIDs.reduce((a, b) => (Number(a) > Number(b) ? a : b));
return {
reportActionID: newestReportActionID,
actorAccountID: reportActions[newestReportActionID]?.actorAccountID,
};
}

function useAgentZeroStatusIndicator(reportID: string, isAgentZeroChat: boolean): AgentZeroStatusState {
const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`);
const serverLabel = reportNameValuePairs?.agentZeroProcessingRequestIndicator?.trim() ?? '';

// Track the newest report action so we can fetch missed actions and detect actual Concierge replies.
const [newestReportAction] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, {selector: selectNewestReportAction});
const newestReportActionRef = useRef<NewestReportAction | undefined>(newestReportAction);
useEffect(() => {
newestReportActionRef.current = newestReportAction;
}, [newestReportAction]);

// Track pending optimistic requests with a counter instead of a single timestamp.
// Each kickoffWaitingIndicator() call increments the counter; each Concierge reply
// decrements it. The indicator stays active until all pending requests are resolved.
const [pendingOptimisticRequests, setPendingOptimisticRequests] = useState(0);
const [displayedLabel, setDisplayedLabel] = useState<string>('');
const {translate} = useLocalize();
const prevServerLabelRef = useRef<string>(serverLabel);
const updateTimerRef = useRef<NodeJS.Timeout | null>(null);
const lastUpdateTimeRef = useRef<number>(0);
const pollIntervalRef = useRef<NodeJS.Timeout | null>(null);
const pollSafetyTimerRef = useRef<NodeJS.Timeout | null>(null);
const isOfflineRef = useRef<boolean>(false);

// Minimum time to display a label before allowing change (prevents rapid flicker)
const MIN_DISPLAY_TIME = 300; // ms
// Debounce delay for server label updates
const DEBOUNCE_DELAY = 150; // ms

/**
* Clear the polling interval and safety timer. Called when the indicator clears normally,
* when a new processing cycle starts, or when the component unmounts.
*/
const clearPolling = () => {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
if (pollSafetyTimerRef.current) {
clearTimeout(pollSafetyTimerRef.current);
pollSafetyTimerRef.current = null;
}
};

/**
* Hard-clear the indicator by resetting local state and clearing the Onyx NVP.
* Called as a safety net after MAX_POLL_DURATION_MS if no response has arrived.
*/
const hardClearIndicator = () => {
// If offline, don't clear — the response may arrive when reconnected
if (isOfflineRef.current) {
return;
}
clearPolling();
setPendingOptimisticRequests(0);
setDisplayedLabel('');
clearAgentZeroProcessingIndicator(reportID);
getNewerActions(reportID, newestReportActionRef.current?.reportActionID);
};

/**
* Start polling for missed actions every POLL_INTERVAL_MS. Every time processing
* becomes active or the server label changes (renewal), the existing polling is
* cleared and restarted.
*
* - Every 30s: call getNewerActions to fetch any missed Concierge responses
* - After MAX_POLL_DURATION_MS: hard-clear the indicator if still showing (safety net)
*
* Polling stops when: indicator clears, component unmounts, or user goes offline.
*/
const startPolling = () => {
clearPolling();

// Poll every 30s for missed actions. Track the newest action ID before polling
// so we can detect if new actions arrived (meaning Concierge responded).
// If new actions arrive but the NVP CLEAR was missed via Pusher, we clear
// the indicator client-side.
const prePollingActionID = newestReportActionRef.current?.reportActionID;
pollIntervalRef.current = setInterval(() => {
if (isOfflineRef.current) {
return;
}
const currentNewestReportAction = newestReportActionRef.current;
const didConciergeReplyAfterPollingStarted =
currentNewestReportAction?.actorAccountID === CONST.ACCOUNT_ID.CONCIERGE && currentNewestReportAction.reportActionID !== prePollingActionID;

if (didConciergeReplyAfterPollingStarted) {
clearAgentZeroProcessingIndicator(reportID);
clearPolling();
setPendingOptimisticRequests(0);
return;
}
getNewerActions(reportID, currentNewestReportAction?.reportActionID);
}, POLL_INTERVAL_MS);

// Safety net: hard-clear after MAX_POLL_DURATION_MS
pollSafetyTimerRef.current = setTimeout(() => {
hardClearIndicator();
}, MAX_POLL_DURATION_MS);
};

// On reconnect, fetch missed actions if the indicator is still active.
// Do not clear locally just because the socket recovered, and do not restart polling here:
// the existing poll cycle keeps the original action baseline needed to detect a missed Concierge reply.
const {isOffline} = useNetwork({
onReconnect: () => {
if (!serverLabel && pendingOptimisticRequests === 0) {
return;
}
// Fetch missed actions AND start polling to detect when the Concierge response arrives.
// getNewerActions is a one-shot fetch; polling ensures we keep checking until
// the response is detected (via actorAccountID === CONCIERGE check in the poll).
getNewerActions(reportID, newestReportActionRef.current?.reportActionID);
startPolling();
},
});

// Subscribe to ConciergeReasoningStore using useSyncExternalStore for
// correct synchronization with React's render cycle.
const subscribeToReasoningStore = (onStoreChange: () => void) => {
const unsubscribe = ConciergeReasoningStore.subscribe((updatedReportID) => {
if (updatedReportID !== reportID) {
return;
}
onStoreChange();
});
return unsubscribe;
};
const getReasoningSnapshot = () => ConciergeReasoningStore.getReasoningHistory(reportID);
const reasoningHistory = useSyncExternalStore(subscribeToReasoningStore, getReasoningSnapshot, getReasoningSnapshot);

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

subscribeToReportReasoningEvents(reportID);

// Cleanup: unsubscribeFromReportReasoningChannel handles Pusher unsubscribing,
// clearing reasoning history from ConciergeReasoningStore, and subscription tracking
return () => {
unsubscribeFromReportReasoningChannel(reportID);
};
}, [isAgentZeroChat, reportID]);

useEffect(() => {
const hadServerLabel = !!prevServerLabelRef.current;
const hasServerLabel = !!serverLabel;

// Helper function to update label with timing control
const updateLabel = (newLabel: string) => {
const now = Date.now();
const timeSinceLastUpdate = now - lastUpdateTimeRef.current;
const remainingMinTime = Math.max(0, MIN_DISPLAY_TIME - timeSinceLastUpdate);

// Clear any pending update
if (updateTimerRef.current) {
clearTimeout(updateTimerRef.current);
updateTimerRef.current = null;
}

// If enough time has passed or it's a critical update (clearing), update immediately
if (remainingMinTime === 0 || newLabel === '') {
if (displayedLabel !== newLabel) {
setDisplayedLabel(newLabel);
lastUpdateTimeRef.current = now;
}
} else {
// Schedule update after debounce + remaining min display time
const delay = DEBOUNCE_DELAY + remainingMinTime;
updateTimerRef.current = setTimeout(() => {
if (displayedLabel !== newLabel) {
setDisplayedLabel(newLabel);
lastUpdateTimeRef.current = Date.now();
}
updateTimerRef.current = null;
}, delay);
}
};

// When server label arrives, transition smoothly without flicker.
// Start/reset polling — the label acts as a lease renewal.
if (hasServerLabel) {
updateLabel(serverLabel);
startPolling();
if (pendingOptimisticRequests > 0) {
setPendingOptimisticRequests(0);
}
}
// When optimistic state is active but no server label, show "Concierge is thinking..."
else if (pendingOptimisticRequests > 0) {
const thinkingLabel = translate('common.thinking');
updateLabel(thinkingLabel);
// Polling was already started in kickoffWaitingIndicator
}
// Clear everything when processing ends — either via the normal transition
// (server label went from non-empty to empty), or when the indicator is idle.
else {
clearPolling();
if (displayedLabel !== '') {
updateLabel('');
}
if (hadServerLabel && reasoningHistory.length > 0) {
ConciergeReasoningStore.clearReasoning(reportID);
}
}

prevServerLabelRef.current = serverLabel;

// Cleanup timer on unmount
return () => {
if (!updateTimerRef.current) {
return;
}
clearTimeout(updateTimerRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- startPolling/clearPolling are plain functions stable via React Compiler
}, [serverLabel, reasoningHistory.length, reportID, pendingOptimisticRequests, translate, displayedLabel]);

useEffect(() => {
isOfflineRef.current = isOffline;
}, [isOffline]);

// Clean up polling on unmount
useEffect(
() => () => {
clearPolling();
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);

const kickoffWaitingIndicator = () => {
if (!isAgentZeroChat) {
return;
}
setPendingOptimisticRequests((prev) => prev + 1);
startPolling();
};

// Immediately clear the indicator when a Concierge response arrives while processing.
// This eliminates the 30s delay waiting for the next poll cycle to detect it.
const newestActorAccountID = newestReportAction?.actorAccountID;
useEffect(() => {
if (newestActorAccountID !== CONST.ACCOUNT_ID.CONCIERGE) {
return;
}
if (!serverLabel && pendingOptimisticRequests === 0) {
return;
}
clearAgentZeroProcessingIndicator(reportID);
clearPolling();
setPendingOptimisticRequests(0);
}, [newestActorAccountID, serverLabel, pendingOptimisticRequests, reportID]);

const isProcessing = isAgentZeroChat && !isOffline && (!!serverLabel || pendingOptimisticRequests > 0);

return {
isProcessing,
reasoningHistory,
statusLabel: displayedLabel,
kickoffWaitingIndicator,
};
}

export default useAgentZeroStatusIndicator;
export type {AgentZeroStatusState};
Loading
Loading