Skip to content

Commit b35556f

Browse files
authored
Merge pull request #89146 from Expensify/marcochavezf/626938-trickle-extracted
[Payment due @Pujan92] Trickle Concierge suggested response
2 parents 2eb3e63 + daf6a07 commit b35556f

14 files changed

Lines changed: 1130 additions & 49 deletions

File tree

cspell.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,7 @@
191191
"Dishoom",
192192
"displaystatus",
193193
"DocuSign",
194+
"domelementtype",
194195
"domhandler",
195196
"domparser",
196197
"dont",
Lines changed: 206 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,229 @@
1-
import {useEffect} from 'react';
1+
import {useEffect, useRef} from 'react';
2+
import type {OnyxEntry} from 'react-native-onyx';
23
import {applyPendingConciergeAction, discardPendingConciergeAction} from '@libs/actions/Report/SuggestedFollowup';
4+
import Log from '@libs/Log';
5+
import {rand64} from '@libs/NumberUtils';
6+
import type {ConciergeDraftEvent} from '@libs/Pusher/types';
7+
import tokenizeForReveal from '@libs/ReportActionFollowupUtils/tokenizeForReveal';
8+
import {getReportActionHtml} from '@libs/ReportActionsUtils';
9+
import {useConciergeDraftActions} from '@pages/inbox/ConciergeDraftContext';
310
import ONYXKEYS from '@src/ONYXKEYS';
11+
import type {ReportAction, ReportActions} from '@src/types/onyx';
412
import useOnyx from './useOnyx';
513

6-
/** If displayAfter is more than this far in the past, the response is stale (e.g. app was killed and restarted) */
7-
const STALE_THRESHOLD_MS = 10_000;
14+
/** Default trickle duration. Targets ~19 chars/sec start (~7/sec end after ease-out) across a typical multi-paragraph response — visibly streaming without dragging the user past the moment they want to read. */
15+
const DEFAULT_STREAM_DURATION_MS = 15_000;
16+
/** Trickle tick cadence. 80ms targets ~1 char per tick at char-level granularity — fast enough that the reveal feels continuous, slow enough that the synthetic-bubble re-render budget stays comfortable on RNW (~12 dispatches/sec). */
17+
const TICK_INTERVAL_MS = 80;
18+
/** Hard cap on a running trickle and staleness gate on revisit. Past this many ms after `displayAfter`, the canonical reportComment is expected to be in REPORT_ACTIONS already, so we discard the optimistic rather than resume a doomed reveal. */
19+
const TRICKLE_HARD_CAP_MS = 60_000;
20+
/** Once the real reportComment lands in REPORT_ACTIONS, finish the remaining reveal within this window. */
21+
const ACCELERATED_REMAINING_MS = 1_500;
22+
/** Minimum char-level anchors before we opt into the trickle reveal. Replies under this fall back to the binary reveal at `displayAfter`. */
23+
const MIN_TRICKLE_TOKEN_COUNT = 100;
24+
25+
function easeOut(t: number): number {
26+
const clamped = Math.max(0, Math.min(1, t));
27+
return 1 - (1 - clamped) ** 2;
28+
}
829

930
/**
10-
* Processes pending concierge responses stored in Onyx for a given report.
11-
* When a pending response exists, schedules the action to be moved to REPORT_ACTIONS
12-
* after the remaining delay, with automatic cleanup on unmount via useEffect.
31+
* Long Concierge replies trickle into `ConciergeDraftContext`; short ones keep
32+
* the binary reveal at `displayAfter`. `REPORT_ACTIONS` is written at completion.
1333
*/
1434
function usePendingConciergeResponse(reportID: string | undefined) {
1535
const [pendingResponse] = useOnyx(`${ONYXKEYS.COLLECTION.PENDING_CONCIERGE_RESPONSE}${reportID}`);
36+
const reportActionID = pendingResponse?.reportAction?.reportActionID;
37+
const fullHtml = pendingResponse?.reportAction ? getReportActionHtml(pendingResponse.reportAction) : '';
38+
// React Compiler auto-memoizes the selector closure and the tokenize result;
39+
// explicit useCallback/useMemo would just shadow the compiler's analysis.
40+
const persistedActionSelector = (actions: OnyxEntry<ReportActions>): ReportAction | undefined => (reportActionID && actions ? actions[reportActionID] : undefined);
41+
const [persistedAction] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, {selector: persistedActionSelector});
42+
const {dispatchLocalDraftEvent} = useConciergeDraftActions();
43+
44+
const tokens = tokenizeForReveal(fullHtml);
45+
const accelerateRef = useRef<((nowMs: number) => void) | null>(null);
46+
47+
// Captured into a ref so the trickle effect can re-run only on the IDs that
48+
// identify a distinct Concierge reply. Composer typing, unrelated Onyx emits,
49+
// and ConciergeDraftActions context refreshes all produce reference churn for
50+
// pendingResponse/tokens/fullHtml — without this snapshot, those non-content
51+
// updates would cancel the running interval and restart the reveal. The
52+
// useEffect keeps ref writes in the commit phase (React-Compiler-safe).
53+
const trickleInputsRef = useRef({pendingResponse, fullHtml, tokens, dispatchLocalDraftEvent, persistedAction});
54+
useEffect(() => {
55+
trickleInputsRef.current = {pendingResponse, fullHtml, tokens, dispatchLocalDraftEvent, persistedAction};
56+
});
1657

58+
// Reconciliation: when the canonical reportComment lands in REPORT_ACTIONS
59+
// mid-trickle, fire the running loop's accelerator so the remaining reveal
60+
// finishes in ~1.5s instead of snapping the synthetic bubble closed.
1761
useEffect(() => {
18-
if (!pendingResponse) {
62+
if (!persistedAction || !accelerateRef.current) {
1963
return;
2064
}
65+
accelerateRef.current(Date.now());
66+
}, [persistedAction]);
2167

22-
const remaining = pendingResponse.displayAfter - Date.now();
68+
useEffect(() => {
69+
if (!reportID || !reportActionID) {
70+
return;
71+
}
72+
// Snapshot inputs at effect start. The trickle commits to the content it had
73+
// when it began; subsequent updates that share this same reportActionID don't
74+
// disturb the in-progress reveal. A genuinely new Concierge reply produces a
75+
// new reportActionID and re-enters this effect via the deps below.
76+
const {pendingResponse: snapshot, fullHtml: snapshotHtml, tokens: snapshotTokens} = trickleInputsRef.current;
77+
if (!snapshot) {
78+
return;
79+
}
80+
const {reportAction, displayAfter} = snapshot;
81+
const remainingDelay = displayAfter - Date.now();
2382

24-
// If the pending response is stale (e.g. app was killed/restarted), discard it
25-
// instead of displaying a phantom message that was never confirmed by the server.
26-
if (remaining < -STALE_THRESHOLD_MS) {
83+
// Past the hard cap from displayAfter, the server-side canonical reply
84+
// is expected to be in REPORT_ACTIONS already. Skip the trickle.
85+
if (remainingDelay < -TRICKLE_HARD_CAP_MS) {
2786
discardPendingConciergeAction(reportID);
2887
return;
2988
}
3089

31-
const timer = setTimeout(
32-
() => {
33-
applyPendingConciergeAction(reportID, pendingResponse.reportAction);
34-
},
35-
Math.max(0, remaining),
36-
);
90+
// Anchors are character-level. Short replies (~50–100 chars) keep the
91+
// binary reveal; longer ones (paragraphs / lists) cross the threshold
92+
// and get the smooth trickle.
93+
const shouldTrickle = snapshotTokens.length >= MIN_TRICKLE_TOKEN_COUNT && !!snapshotHtml;
94+
if (!shouldTrickle) {
95+
const timer = setTimeout(() => applyPendingConciergeAction(reportID, reportAction), Math.max(0, remainingDelay));
96+
return () => clearTimeout(timer);
97+
}
98+
99+
const session = rand64();
100+
let sequence = 0;
101+
let intervalID: ReturnType<typeof setInterval> | null = null;
102+
let trickleStart = 0;
103+
let effectiveDuration = DEFAULT_STREAM_DURATION_MS;
104+
let lastStage = 0;
105+
let cancelled = false;
106+
// Snapshot of trickle progress at the moment the canonical reportComment
107+
// arrives. Presence (`arrival !== undefined`) doubles as the
108+
// "acceleration fired" check that selects the completion reason below.
109+
let arrival: {progress: number; elapsedMs: number} | undefined;
110+
111+
const dispatch = (status: ConciergeDraftEvent['status'], finalRenderedHTML: string) => {
112+
if (cancelled) {
113+
return;
114+
}
115+
sequence += 1;
116+
// Read dispatch fn from the ref so a context-provider refresh doesn't pin
117+
// the trickle to a stale handler. The ref always points at the latest.
118+
trickleInputsRef.current.dispatchLocalDraftEvent({
119+
reportID,
120+
reportActionID,
121+
streamSessionID: session,
122+
sequence,
123+
status,
124+
created: reportAction.created,
125+
finalRenderedHTML,
126+
});
127+
};
128+
129+
const completeAndApply = () => {
130+
if (intervalID) {
131+
clearInterval(intervalID);
132+
intervalID = null;
133+
}
134+
const totalElapsedMs = trickleStart === 0 ? 0 : Date.now() - trickleStart;
135+
let reason: 'natural' | 'accelerated' | 'stale_cap' = 'natural';
136+
if (arrival) {
137+
reason = 'accelerated';
138+
} else if (totalElapsedMs >= TRICKLE_HARD_CAP_MS) {
139+
reason = 'stale_cap';
140+
}
141+
Log.info('[ConciergeTrickle] complete', false, {
142+
reportActionID,
143+
reason,
144+
tokenCount: snapshotTokens.length,
145+
durationMs: effectiveDuration,
146+
totalElapsedMs,
147+
arrivedAtProgress: arrival?.progress,
148+
arrivedAtElapsedMs: arrival?.elapsedMs,
149+
});
150+
dispatch('completed', snapshotTokens.at(-1) ?? snapshotHtml);
151+
// Don't reapply our older optimistic when the canonical is already there —
152+
// it would clobber server-added markup (follow-up buttons, deep-link
153+
// Pressables). `arrival` covers the accelerator path; the live ref read
154+
// catches arrivals during the pre-trickle setTimeout where the accelerator
155+
// no-ops on null intervalID.
156+
if (arrival || trickleInputsRef.current.persistedAction) {
157+
discardPendingConciergeAction(reportID);
158+
} else {
159+
applyPendingConciergeAction(reportID, reportAction);
160+
}
161+
};
162+
163+
accelerateRef.current = (nowMs: number) => {
164+
if (!intervalID || trickleStart === 0) {
165+
return;
166+
}
167+
const elapsed = nowMs - trickleStart;
168+
// Compressing effectiveDuration is what makes progress hit 1 within
169+
// ACCELERATED_REMAINING_MS — the next tick observes progress >= 1
170+
// and runs completeAndApply via the normal path.
171+
arrival = {progress: easeOut(elapsed / effectiveDuration), elapsedMs: elapsed};
172+
effectiveDuration = elapsed + ACCELERATED_REMAINING_MS;
173+
};
174+
175+
const startTrickle = () => {
176+
if (cancelled) {
177+
return;
178+
}
179+
// Anchor to displayAfter so revisit resumes at the wall-clock-correct
180+
// stage instead of restarting the reveal from char 0.
181+
trickleStart = displayAfter;
182+
const lastIndex = snapshotTokens.length - 1;
183+
const elapsedAtStart = Date.now() - trickleStart;
184+
const initialProgress = easeOut(elapsedAtStart / effectiveDuration);
185+
// Floor at 1 so a fresh trickle (elapsed ≈ 0) still reveals the leading chunk on the first dispatch.
186+
const initialStage = Math.max(1, Math.min(lastIndex, Math.ceil(initialProgress * lastIndex)));
187+
Log.info('[ConciergeTrickle] start', false, {
188+
reportActionID,
189+
tokenCount: snapshotTokens.length,
190+
durationMs: effectiveDuration,
191+
initialStage,
192+
elapsedAtStart,
193+
});
194+
dispatch('started', snapshotTokens.at(initialStage) ?? '');
195+
lastStage = initialStage;
196+
// If revisited past the duration / cap, finish without scheduling ticks.
197+
if (initialProgress >= 1 || elapsedAtStart >= TRICKLE_HARD_CAP_MS) {
198+
completeAndApply();
199+
return;
200+
}
201+
intervalID = setInterval(() => {
202+
const elapsed = Date.now() - trickleStart;
203+
const progress = easeOut(elapsed / effectiveDuration);
204+
// progress ∈ [0,1] (easeOut clamps) and lastIndex ≥ 99 (shouldTrickle gate),
205+
// so `progress * lastIndex` is always non-negative — only the upper bound needs clamping.
206+
const stage = Math.min(lastIndex, Math.ceil(progress * lastIndex));
207+
if (stage > lastStage) {
208+
lastStage = stage;
209+
dispatch('updated', snapshotTokens.at(stage) ?? '');
210+
}
211+
if (progress >= 1 || elapsed >= TRICKLE_HARD_CAP_MS) {
212+
completeAndApply();
213+
}
214+
}, TICK_INTERVAL_MS);
215+
};
37216

38-
return () => clearTimeout(timer);
39-
}, [pendingResponse, reportID]);
217+
const startTimer = setTimeout(startTrickle, Math.max(0, remainingDelay));
218+
return () => {
219+
cancelled = true;
220+
clearTimeout(startTimer);
221+
if (intervalID) {
222+
clearInterval(intervalID);
223+
}
224+
accelerateRef.current = null;
225+
};
226+
}, [reportID, reportActionID]);
40227
}
41228

42229
export default usePendingConciergeResponse;

src/libs/Pusher/EventType.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ export default {
99
USER_IS_TYPING: 'client-userIsTyping',
1010
MULTIPLE_EVENTS: 'multipleEvents',
1111
CONCIERGE_REASONING: 'conciergeReasoning',
12+
CONCIERGE_DRAFT_STARTED: 'conciergeDraftStarted',
13+
CONCIERGE_DRAFT_UPDATED: 'conciergeDraftUpdated',
14+
CONCIERGE_DRAFT_COMPLETED: 'conciergeDraftCompleted',
15+
CONCIERGE_DRAFT_FAILED: 'conciergeDraftFailed',
16+
CONCIERGE_DRAFT_CLEARED: 'conciergeDraftCleared',
1217

1318
// An event that the server sends back to the client in response to a "ping" API command
1419
PONG: 'pong',

src/libs/Pusher/types.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,30 @@ type ConciergeReasoningEvent = {
3939
loopCount: number;
4040
};
4141

42+
type ConciergeDraftEvent = {
43+
reportID: string;
44+
reportActionID: string;
45+
streamSessionID: string;
46+
sequence: number;
47+
status: 'started' | 'updated' | 'completed' | 'failed' | 'cleared';
48+
created: string;
49+
bodyMarkdown?: string;
50+
finalRenderedHTML?: string;
51+
startedAt?: string;
52+
terminalReason?: string;
53+
updatedAt?: string;
54+
};
55+
4256
type PusherEventMap = {
4357
[TYPE.USER_IS_TYPING]: UserIsTypingEvent;
4458
[TYPE.USER_IS_LEAVING_ROOM]: UserIsLeavingRoomEvent;
4559
[TYPE.PONG]: PingPongEvent;
4660
[TYPE.CONCIERGE_REASONING]: ConciergeReasoningEvent;
61+
[TYPE.CONCIERGE_DRAFT_STARTED]: ConciergeDraftEvent;
62+
[TYPE.CONCIERGE_DRAFT_UPDATED]: ConciergeDraftEvent;
63+
[TYPE.CONCIERGE_DRAFT_COMPLETED]: ConciergeDraftEvent;
64+
[TYPE.CONCIERGE_DRAFT_FAILED]: ConciergeDraftEvent;
65+
[TYPE.CONCIERGE_DRAFT_CLEARED]: ConciergeDraftEvent;
4766
};
4867

4968
type EventData<EventName extends string> = {chunk?: string; id?: string; index?: number; final?: boolean} & (EventName extends keyof PusherEventMap
@@ -103,6 +122,7 @@ export type {
103122
UserIsLeavingRoomEvent,
104123
PingPongEvent,
105124
ConciergeReasoningEvent,
125+
ConciergeDraftEvent,
106126
EventData,
107127
EventCallbackError,
108128
ChunkedDataEvents,

src/libs/ReportActionFollowupUtils/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {DomUtils, parseDocument} from 'htmlparser2';
33
import {getReportActionMessage, isActionOfType} from '@libs/ReportActionsUtils';
44
import CONST from '@src/CONST';
55
import type {OnyxInputOrEntry, ReportAction} from '@src/types/onyx';
6+
import tokenizeForReveal from './tokenizeForReveal';
67

78
type Followup = {
89
text: string;
@@ -58,5 +59,6 @@ function parseFollowupsFromHtml(html: string): Followup[] | null {
5859
return {text, response};
5960
});
6061
}
61-
export {containsActionableFollowUps, parseFollowupsFromHtml};
62+
63+
export {containsActionableFollowUps, parseFollowupsFromHtml, tokenizeForReveal};
6264
export type {Followup};

0 commit comments

Comments
 (0)