From b2af3a925f23b4639116fb0f8213dd44d7dceebf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marco=20Ch=C3=A1vez?= Date: Mon, 27 Apr 2026 13:09:11 +0900 Subject: [PATCH 1/8] Trickle pregenerated Concierge response over follow-up generation window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the single 4s setTimeout in usePendingConciergeResponse with a decelerating ease-out reveal driven into Issa's ConciergeDraftContext reducer (PR #87110) so the synthetic report-action graft and reconciliation behave identically to server-driven streaming. The pregenerated HTML originates client-side from , so the trickle is purely view-local — REPORT_ACTIONS is written exactly once at trickle completion via applyPendingConciergeAction, preserving LHN previews / push / search snapshots until the bubble is fully formed. When the canonical reportComment lands mid-trickle the loop accelerates the remaining reveal to ~1.5s instead of snapping the synthetic bubble closed. Single-chunk payloads keep the original binary reveal so 1–2 sentence answers don't feel artificially stretched. Tokenizer splits at top-level child boundaries to keep inline atomics (mentions, emoji, anchors, code) whole. Fixes https://github.com/Expensify/Expensify/issues/626938 Stacked on https://github.com/Expensify/App/pull/87110 --- src/hooks/usePendingConciergeResponse.ts | 166 ++++++++++++++++-- src/libs/ReportActionFollowupUtils/index.ts | 4 +- .../tokenizeForReveal.ts | 26 +++ src/pages/inbox/ConciergeDraftContext.tsx | 18 +- .../tokenizeForRevealTest.ts | 54 ++++++ 5 files changed, 248 insertions(+), 20 deletions(-) create mode 100644 src/libs/ReportActionFollowupUtils/tokenizeForReveal.ts create mode 100644 tests/unit/libs/ReportActionFollowupUtils/tokenizeForRevealTest.ts diff --git a/src/hooks/usePendingConciergeResponse.ts b/src/hooks/usePendingConciergeResponse.ts index 2fc5b1e2147c..d29b9fa255b3 100644 --- a/src/hooks/usePendingConciergeResponse.ts +++ b/src/hooks/usePendingConciergeResponse.ts @@ -1,42 +1,172 @@ -import {useEffect} from 'react'; +import {useCallback, useEffect, useMemo, useRef} from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; import {applyPendingConciergeAction, discardPendingConciergeAction} from '@libs/actions/Report/SuggestedFollowup'; +import Log from '@libs/Log'; +import {rand64} from '@libs/NumberUtils'; +import type {ConciergeDraftEvent} from '@libs/Pusher/types'; +import tokenizeForReveal from '@libs/ReportActionFollowupUtils/tokenizeForReveal'; +import {getReportActionHtml} from '@libs/ReportActionsUtils'; +import {useConciergeDraftActions} from '@pages/inbox/ConciergeDraftContext'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {ReportAction, ReportActions} from '@src/types/onyx'; 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) */ +/** 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; +/** Default trickle duration. Roughly matches the p50 follow-up generation window so the reveal lands close to when followups arrive. */ +const DEFAULT_STREAM_DURATION_MS = 30_000; +/** Trickle tick cadence. ~75 updates over the 30s default — enough granularity for a smooth ease-out, sparse enough to keep render churn low. */ +const TICK_INTERVAL_MS = 400; +/** Hard cap on running trickle. If the loop is still alive past this, force completion to avoid pinning a synthetic bubble forever. */ +const TRICKLE_HARD_CAP_MS = 60_000; +/** Once the real reportComment lands in REPORT_ACTIONS, finish the remaining reveal within this window. */ +const ACCELERATED_REMAINING_MS = 1_500; + +function easeOut(t: number): number { + const clamped = Math.max(0, Math.min(1, t)); + return 1 - (1 - clamped) ** 2; +} /** - * 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. + * Manages the lifecycle of a pending pregenerated Concierge response. + * + * For payloads with multiple top-level chunks the hook trickles the body in + * progressively over `DEFAULT_STREAM_DURATION_MS` (decelerating ease-out), + * dispatching `conciergeDraftStarted/updated/completed` events into Issa's + * existing `ConciergeDraftContext` reducer (PR #87110) so the synthetic + * report action splice and reconciliation behave identically to server-driven + * streaming. Canonical Onyx state (`REPORT_ACTIONS`) is only written at the + * end via `applyPendingConciergeAction`, preserving LHN previews / push + * notifications / search snapshots until the trickle finishes. + * + * Single-chunk payloads fall back to the original binary reveal at + * `displayAfter` so short replies (1–2 sentence answers) don't get stretched + * artificially. + * + * Reconciliation: if the real `reportComment` arrives mid-trickle, the loop + * accelerates the remaining reveal so the synthetic bubble lands within + * `ACCELERATED_REMAINING_MS` instead of snapping closed. */ function usePendingConciergeResponse(reportID: string | undefined) { const [pendingResponse] = useOnyx(`${ONYXKEYS.COLLECTION.PENDING_CONCIERGE_RESPONSE}${reportID}`); + const reportActionID = pendingResponse?.reportAction?.reportActionID; + const fullHtml = pendingResponse?.reportAction ? getReportActionHtml(pendingResponse.reportAction) : ''; + const persistedActionSelector = useCallback( + (actions: OnyxEntry): ReportAction | undefined => (reportActionID && actions ? actions[reportActionID] : undefined), + [reportActionID], + ); + const [persistedAction] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, {selector: persistedActionSelector}); + const {dispatchLocalDraftEvent} = useConciergeDraftActions(); + + const tokens = useMemo(() => tokenizeForReveal(fullHtml), [fullHtml]); + const accelerateRef = useRef<((nowMs: number) => void) | null>(null); + // Reconciliation: when the canonical reportComment lands in REPORT_ACTIONS + // mid-trickle, fire the running loop's accelerator so the remaining reveal + // finishes in ~1.5s instead of snapping the synthetic bubble closed. useEffect(() => { - if (!pendingResponse) { + if (!persistedAction || !accelerateRef.current) { return; } + accelerateRef.current(Date.now()); + }, [persistedAction]); - const remaining = pendingResponse.displayAfter - Date.now(); + useEffect(() => { + if (!pendingResponse || !reportID || !reportActionID) { + return; + } + const {reportAction, displayAfter} = pendingResponse; + const remainingDelay = 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) { + if (remainingDelay < -STALE_THRESHOLD_MS) { discardPendingConciergeAction(reportID); return; } - const timer = setTimeout( - () => { - applyPendingConciergeAction(reportID, pendingResponse.reportAction); - }, - Math.max(0, remaining), - ); + // Single-chunk payloads keep the original binary reveal — trickling a + // 1–2 sentence answer over 30s would feel broken. + const shouldTrickle = tokens.length >= 3 && !!fullHtml; + if (!shouldTrickle) { + const timer = setTimeout(() => applyPendingConciergeAction(reportID, reportAction), Math.max(0, remainingDelay)); + return () => clearTimeout(timer); + } + + const session = rand64(); + let sequence = 0; + let intervalID: ReturnType | null = null; + let trickleStart = 0; + let effectiveDuration = DEFAULT_STREAM_DURATION_MS; + let lastStage = 0; + let cancelled = false; + + const dispatch = (status: ConciergeDraftEvent['status'], finalRenderedHTML: string) => { + if (cancelled) { + return; + } + sequence += 1; + dispatchLocalDraftEvent({ + reportID, + reportActionID, + streamSessionID: session, + sequence, + status, + created: reportAction.created, + finalRenderedHTML, + }); + }; + + const completeAndApply = () => { + if (intervalID) { + clearInterval(intervalID); + intervalID = null; + } + dispatch('completed', tokens.at(-1) ?? fullHtml); + applyPendingConciergeAction(reportID, reportAction); + }; + + accelerateRef.current = (nowMs: number) => { + if (!intervalID || trickleStart === 0) { + return; + } + const elapsed = nowMs - trickleStart; + // Compressing effectiveDuration is what makes progress hit 1 within + // ACCELERATED_REMAINING_MS — the next tick observes progress >= 1 + // and runs completeAndApply via the normal path. + effectiveDuration = elapsed + ACCELERATED_REMAINING_MS; + Log.info('[ConciergeTrickle] accelerated due to persisted action arrival', false, {reportActionID, elapsed}); + }; + + const startTrickle = () => { + if (cancelled) { + return; + } + trickleStart = Date.now(); + dispatch('started', tokens.at(1) ?? ''); + lastStage = 1; + intervalID = setInterval(() => { + const elapsed = Date.now() - trickleStart; + const progress = easeOut(elapsed / effectiveDuration); + const stage = Math.min(tokens.length - 1, Math.max(0, Math.ceil(progress * (tokens.length - 1)))); + if (stage > lastStage) { + lastStage = stage; + dispatch('updated', tokens.at(stage) ?? ''); + } + if (progress >= 1 || elapsed >= TRICKLE_HARD_CAP_MS) { + completeAndApply(); + } + }, TICK_INTERVAL_MS); + }; - return () => clearTimeout(timer); - }, [pendingResponse, reportID]); + const startTimer = setTimeout(startTrickle, Math.max(0, remainingDelay)); + return () => { + cancelled = true; + clearTimeout(startTimer); + if (intervalID) { + clearInterval(intervalID); + } + accelerateRef.current = null; + }; + }, [pendingResponse, reportID, reportActionID, fullHtml, tokens, dispatchLocalDraftEvent]); } export default usePendingConciergeResponse; diff --git a/src/libs/ReportActionFollowupUtils/index.ts b/src/libs/ReportActionFollowupUtils/index.ts index dd591ea2a79c..c0091c2f451c 100644 --- a/src/libs/ReportActionFollowupUtils/index.ts +++ b/src/libs/ReportActionFollowupUtils/index.ts @@ -3,6 +3,7 @@ import {DomUtils, parseDocument} from 'htmlparser2'; import {getReportActionMessage, isActionOfType} from '@libs/ReportActionsUtils'; import CONST from '@src/CONST'; import type {OnyxInputOrEntry, ReportAction} from '@src/types/onyx'; +import tokenizeForReveal from './tokenizeForReveal'; type Followup = { text: string; @@ -58,5 +59,6 @@ function parseFollowupsFromHtml(html: string): Followup[] | null { return {text, response}; }); } -export {containsActionableFollowUps, parseFollowupsFromHtml}; + +export {containsActionableFollowUps, parseFollowupsFromHtml, tokenizeForReveal}; export type {Followup}; diff --git a/src/libs/ReportActionFollowupUtils/tokenizeForReveal.ts b/src/libs/ReportActionFollowupUtils/tokenizeForReveal.ts new file mode 100644 index 000000000000..766e62ea1225 --- /dev/null +++ b/src/libs/ReportActionFollowupUtils/tokenizeForReveal.ts @@ -0,0 +1,26 @@ +import render from 'dom-serializer'; +import {parseDocument} from 'htmlparser2'; + +/** + * Pre-computes a list of progressive HTML stages for a pregenerated Concierge + * response, so usePendingConciergeResponse can trickle the body in over the + * follow-up generation window without re-parsing on every tick. + * + * Splits at top-level child boundaries so atomic inline elements (mentions, + * emoji, anchors, code) never render half-parsed. Stage 0 is empty; the final + * stage equals the rendered children. The hook walks indices 0..N-1 over the + * stream duration. + */ +function tokenizeForReveal(html: string): string[] { + if (!html) { + return ['']; + } + const doc = parseDocument(html); + const stages: string[] = ['']; + for (let n = 1; n <= doc.children.length; n++) { + stages.push(render(doc.children.slice(0, n))); + } + return stages; +} + +export default tokenizeForReveal; diff --git a/src/pages/inbox/ConciergeDraftContext.tsx b/src/pages/inbox/ConciergeDraftContext.tsx index 977ca7928486..7745d634c69b 100644 --- a/src/pages/inbox/ConciergeDraftContext.tsx +++ b/src/pages/inbox/ConciergeDraftContext.tsx @@ -18,6 +18,13 @@ type ConciergeDraftState = { type ConciergeDraftActions = { clearDraft: () => void; + /** + * Apply a draft event from a non-Pusher source (e.g. the local pacer in + * usePendingConciergeResponse for pregenerated replies whose body is + * already on the client). Uses the same reducer as the Pusher path so the + * reportActionID-based reconciliation continues to work unchanged. + */ + dispatchLocalDraftEvent: (event: ConciergeDraftEvent) => void; }; const defaultState: ConciergeDraftState = { @@ -27,6 +34,7 @@ const defaultState: ConciergeDraftState = { const defaultActions: ConciergeDraftActions = { clearDraft: () => {}, + dispatchLocalDraftEvent: () => {}, }; const ConciergeDraftStateContext = createContext(defaultState); @@ -60,6 +68,13 @@ function ConciergeDraftGate({reportID, children}: React.PropsWithChildren<{repor setDraft(null); }, []); + const dispatchLocalDraftEvent = useCallback( + (event: ConciergeDraftEvent) => { + setDraft((currentDraft) => applyConciergeDraftEvent(currentDraft, event, reportID)); + }, + [reportID], + ); + useEffect(() => { const channelName = getReportChannelName(reportID); const handleResubscribe = () => { @@ -109,8 +124,9 @@ function ConciergeDraftGate({reportID, children}: React.PropsWithChildren<{repor const actionsValue = useMemo( () => ({ clearDraft, + dispatchLocalDraftEvent, }), - [clearDraft], + [clearDraft, dispatchLocalDraftEvent], ); return ( diff --git a/tests/unit/libs/ReportActionFollowupUtils/tokenizeForRevealTest.ts b/tests/unit/libs/ReportActionFollowupUtils/tokenizeForRevealTest.ts new file mode 100644 index 000000000000..ddb7d51c0045 --- /dev/null +++ b/tests/unit/libs/ReportActionFollowupUtils/tokenizeForRevealTest.ts @@ -0,0 +1,54 @@ +import tokenizeForReveal from '@libs/ReportActionFollowupUtils/tokenizeForReveal'; + +describe('tokenizeForReveal', () => { + it('returns just the empty stage for an empty input', () => { + expect(tokenizeForReveal('')).toEqual(['']); + }); + + it('returns one content stage for single top-level child (binary reveal fallback)', () => { + const html = '

Just one paragraph.

'; + const stages = tokenizeForReveal(html); + + // [empty, full]. The hook treats this as a binary reveal because length < 3. + expect(stages).toHaveLength(2); + expect(stages.at(0)).toBe(''); + expect(stages.at(-1)).toBe(html); + }); + + it('produces progressive prefixes split at top-level boundaries', () => { + const html = '

First.

Second.

  • Third.
'; + const stages = tokenizeForReveal(html); + + expect(stages).toEqual(['', '

First.

', '

First.

Second.

', html]); + }); + + it('keeps inline atomics whole (mention, emoji, anchor, code stay intact in their parent)', () => { + // Each top-level

is an atomic unit so its inline children never render half-parsed. + // htmlparser2's parseDocument lowercases attribute names — that matches the rest of the + // codebase (see parseFollowupsFromHtml) so we accept it as the canonical shape. + const html = '

Hi @daniel

Status: ✅

'; + const stages = tokenizeForReveal(html); + + expect(stages.at(1)).toContain('mention-user'); + expect(stages.at(1)).toContain('@daniel'); + // No stage shows a partially-rendered mention or emoji. + for (const stage of stages) { + const openMentions = (stage.match(//g) ?? []).length; + expect(openMentions).toBe(closeMentions); + + const openEmojis = (stage.match(//g) ?? []).length; + const closeEmojis = (stage.match(/<\/emoji>/g) ?? []).length; + expect(openEmojis).toBe(closeEmojis); + } + }); + + it('preserves the typical Concierge response shape (paragraphs + bullet list)', () => { + const html = "

To set up QuickBooks, here's what to do:

  1. Go to Settings.
  2. Click Connections.
  3. Pick QBO.

Let me know if you hit a snag.

"; + const stages = tokenizeForReveal(html); + + // 3 top-level children => 4 stages (empty + 3 progressive). + expect(stages).toHaveLength(4); + expect(stages.at(-1)).toBe(html); + }); +}); From 3f97494a767c46f68f955cda3294a9c5e0b6f735 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marco=20Ch=C3=A1vez?= Date: Mon, 27 Apr 2026 14:33:37 +0900 Subject: [PATCH 2/8] Add lifecycle telemetry to Concierge trickle (start + complete) Two structured Log.info events bracket each trickle so VictoriaLogs can answer Phase 2 tuning questions and detect regressions: - [ConciergeTrickle] start: {reportActionID, tokenCount, durationMs} - [ConciergeTrickle] complete: {reportActionID, reason, tokenCount, durationMs, totalElapsedMs, arrivedAtProgress, arrivedAtElapsedMs} reason in {natural, accelerated, stale_cap} attributes the completion path; arrivedAtProgress + arrivedAtElapsedMs (defined when accelerated) record where in the trickle the canonical reportComment landed - the single most useful signal for tuning the duration heuristic. Replaces the standalone "[ConciergeTrickle] accelerated" log: that data is now carried by the complete event's reason='accelerated' branch + arrivedAtProgress field. Also enables: stale-cap firing-rate alerts (backend-health signal), client/server cross-correlation by reportActionID against PAZR logs, mobile JS-timer-throttle detection via arrivedAtProgress skew, and A/B-test readiness without per-experiment instrumentation. Lifecycle pairing (start + complete) makes orphaned trickles detectable as starts without matching completes. --- src/hooks/usePendingConciergeResponse.ts | 29 +++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/src/hooks/usePendingConciergeResponse.ts b/src/hooks/usePendingConciergeResponse.ts index d29b9fa255b3..bd087ebcd9a7 100644 --- a/src/hooks/usePendingConciergeResponse.ts +++ b/src/hooks/usePendingConciergeResponse.ts @@ -98,6 +98,11 @@ function usePendingConciergeResponse(reportID: string | undefined) { let effectiveDuration = DEFAULT_STREAM_DURATION_MS; let lastStage = 0; let cancelled = false; + // Telemetry: track whether/when acceleration fired so the [complete] log + // can attribute the completion reason and the arrival point of the canonical + // reportComment. Drives Phase 2 duration tuning + regression detection. + let arrivedAtProgress: number | undefined; + let arrivedAtElapsedMs: number | undefined; const dispatch = (status: ConciergeDraftEvent['status'], finalRenderedHTML: string) => { if (cancelled) { @@ -120,6 +125,22 @@ function usePendingConciergeResponse(reportID: string | undefined) { clearInterval(intervalID); intervalID = null; } + const totalElapsedMs = trickleStart === 0 ? 0 : Date.now() - trickleStart; + let reason: 'natural' | 'accelerated' | 'stale_cap' = 'natural'; + if (arrivedAtProgress !== undefined) { + reason = 'accelerated'; + } else if (totalElapsedMs >= TRICKLE_HARD_CAP_MS) { + reason = 'stale_cap'; + } + Log.info('[ConciergeTrickle] complete', false, { + reportActionID, + reason, + tokenCount: tokens.length, + durationMs: effectiveDuration, + totalElapsedMs, + arrivedAtProgress, + arrivedAtElapsedMs, + }); dispatch('completed', tokens.at(-1) ?? fullHtml); applyPendingConciergeAction(reportID, reportAction); }; @@ -132,8 +153,9 @@ function usePendingConciergeResponse(reportID: string | undefined) { // Compressing effectiveDuration is what makes progress hit 1 within // ACCELERATED_REMAINING_MS — the next tick observes progress >= 1 // and runs completeAndApply via the normal path. + arrivedAtProgress = easeOut(elapsed / effectiveDuration); + arrivedAtElapsedMs = elapsed; effectiveDuration = elapsed + ACCELERATED_REMAINING_MS; - Log.info('[ConciergeTrickle] accelerated due to persisted action arrival', false, {reportActionID, elapsed}); }; const startTrickle = () => { @@ -141,6 +163,11 @@ function usePendingConciergeResponse(reportID: string | undefined) { return; } trickleStart = Date.now(); + Log.info('[ConciergeTrickle] start', false, { + reportActionID, + tokenCount: tokens.length, + durationMs: effectiveDuration, + }); dispatch('started', tokens.at(1) ?? ''); lastStage = 1; intervalID = setInterval(() => { From 44c36bf861486ddb98feeb1e771611292dadd512 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marco=20Ch=C3=A1vez?= Date: Mon, 27 Apr 2026 18:09:16 +0900 Subject: [PATCH 3/8] Word-level tokenizer: ChatGPT-style reveal + bold/em recursion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the top-level-element tokenizer with a DOM-walking word-level one. Text nodes split per word/whitespace run; formatting wrappers (, , , , , etc.) recurse so bold appears as words become bold rather than as a single jump. Atomic visual primitives (, , , , , ) stay whole — no half-rendered mention or partial emoji codepoint. Implementation: collect anchors in document order (one per word run or atomic-tag subtree), then for each anchor render a partial node forest where the path branch is shallow-cloned with truncated children and sibling references stay unchanged so dom-serializer renders them as-is. shouldTrickle threshold bumps from 3 to 20 anchors so single-sentence replies (~10-18 anchors) still get the binary 4s reveal — multi- paragraph replies clear 20 easily and get the smooth trickle. Tests cover: empty, plain word reveal, bold-recursion (wrapper opens around partial words), mention atomicity (no partial mentions), emoji / anchor / code atomicity, multi-paragraph monotonic growth, realistic Xero-shape >= 20 anchors. --- src/hooks/usePendingConciergeResponse.ts | 9 +- .../tokenizeForReveal.ts | 132 ++++++++++++++++-- .../tokenizeForRevealTest.ts | 101 ++++++++++---- 3 files changed, 205 insertions(+), 37 deletions(-) diff --git a/src/hooks/usePendingConciergeResponse.ts b/src/hooks/usePendingConciergeResponse.ts index bd087ebcd9a7..47a07e8a94f8 100644 --- a/src/hooks/usePendingConciergeResponse.ts +++ b/src/hooks/usePendingConciergeResponse.ts @@ -83,9 +83,12 @@ function usePendingConciergeResponse(reportID: string | undefined) { return; } - // Single-chunk payloads keep the original binary reveal — trickling a - // 1–2 sentence answer over 30s would feel broken. - const shouldTrickle = tokens.length >= 3 && !!fullHtml; + // Threshold matches word-level tokenization — a typical 1–2 sentence + // reply produces ~10–18 anchors and feels stretched if trickled over + // 30s, so binary reveal is preserved for those. Multi-paragraph + // replies (intro + list + closing) clear 20 anchors easily and get + // the smooth ChatGPT-style stream. + const shouldTrickle = tokens.length >= 20 && !!fullHtml; if (!shouldTrickle) { const timer = setTimeout(() => applyPendingConciergeAction(reportID, reportAction), Math.max(0, remainingDelay)); return () => clearTimeout(timer); diff --git a/src/libs/ReportActionFollowupUtils/tokenizeForReveal.ts b/src/libs/ReportActionFollowupUtils/tokenizeForReveal.ts index 766e62ea1225..6dff8f637688 100644 --- a/src/libs/ReportActionFollowupUtils/tokenizeForReveal.ts +++ b/src/libs/ReportActionFollowupUtils/tokenizeForReveal.ts @@ -1,24 +1,140 @@ import render from 'dom-serializer'; +import {ElementType} from 'domelementtype'; +import type {AnyNode, Document, Element as DomElement} from 'domhandler'; import {parseDocument} from 'htmlparser2'; +/** + * Tags rendered atomically — splitting their content mid-element looks broken + * (e.g. half a mention, a partial emoji codepoint, a link with no closing tag). + * Mentions/emoji/img are visual primitives, is a structural target with a + * URL, is a literal text run that shouldn't reflow as words trickle in. + */ +const ATOMIC_TAGS = new Set(['mention-user', 'mention-report', 'emoji', 'a', 'code', 'img']); + +/** + * One reveal point. `path` is the index path from the doc root to the anchor + * node; `textEndIdx` (when present) is the character offset to truncate that + * node's text data to. + */ +type Anchor = { + path: number[]; + textEndIdx?: number; +}; + +/** + * Walk the DOM in document order, emitting one anchor per "word or whitespace + * run" inside text nodes and one anchor for each whole atomic-tag subtree. + * Block / formatting elements (

,

    , , , ...) recurse so + * their inner words become anchors with the wrapper preserved on render — + * that's what makes bold appear as words become bold rather than as a single + * jump. + */ +function collectAnchors(doc: Document): Anchor[] { + const anchors: Anchor[] = []; + const visit = (node: AnyNode, path: number[]): void => { + if (node.type === ElementType.Text) { + const text = node.data; + if (text.length === 0) { + return; + } + // Word-or-whitespace runs — each run advances the visible content + // by one perceptual unit. Splitting on \s alone would coalesce + // multiple words around a single space; this keeps the reveal + // granular without breaking on character boundaries. + const re = /\S+|\s+/g; + let match = re.exec(text); + while (match !== null) { + anchors.push({path: path.slice(), textEndIdx: match.index + match[0].length}); + match = re.exec(text); + } + return; + } + if (node.type !== ElementType.Tag) { + return; + } + const elem = node; + const tagName = elem.name.toLowerCase(); + if (ATOMIC_TAGS.has(tagName)) { + anchors.push({path: path.slice()}); + return; + } + if (!elem.children || elem.children.length === 0) { + anchors.push({path: path.slice()}); + return; + } + for (let i = 0; i < elem.children.length; i++) { + const child = elem.children.at(i); + if (child) { + visit(child, [...path, i]); + } + } + }; + for (let i = 0; i < doc.children.length; i++) { + const child = doc.children.at(i); + if (child) { + visit(child, [i]); + } + } + return anchors; +} + +/** + * Build a partial node forest containing every node up to (and including) + * `anchor`, with any text-node leaf truncated to the anchor's offset. + * + * Reconstructs only the path elements: siblings before the path branch are + * cloned by reference (rendered fully), the path branch is shallow-cloned + * with its `children` array replaced. Sibling references are unchanged so + * dom-serializer renders them via their existing structure — no parent/next + * pointer surgery needed. + */ +function buildClippedNodes(doc: Document, anchor: Anchor): AnyNode[] { + const clip = (siblings: readonly AnyNode[], depth: number): AnyNode[] => { + const idx = anchor.path.at(depth) ?? 0; + const isLeaf = depth === anchor.path.length - 1; + const before = siblings.slice(0, idx); + const stopNode = siblings.at(idx); + if (!stopNode) { + return [...before]; + } + if (isLeaf) { + if (stopNode.type === ElementType.Text && anchor.textEndIdx !== undefined) { + const truncated = {...stopNode, data: stopNode.data.slice(0, anchor.textEndIdx)} as unknown as AnyNode; + return [...before, truncated]; + } + return [...before, stopNode]; + } + const elem = stopNode as DomElement; + const innerChildren = clip(elem.children as AnyNode[], depth + 1); + const partialElem = {...elem, children: innerChildren} as unknown as AnyNode; + return [...before, partialElem]; + }; + return clip(doc.children as AnyNode[], 0); +} + /** * Pre-computes a list of progressive HTML stages for a pregenerated Concierge - * response, so usePendingConciergeResponse can trickle the body in over the - * follow-up generation window without re-parsing on every tick. + * response — usePendingConciergeResponse trickles them in over the follow-up + * generation window so the chat keeps moving while the server works. + * + * Word-level granularity within text nodes (ChatGPT-style streaming feel), + * recursing into formatting wrappers like / so bold appears as + * words become bold rather than in one jump. Atomic visual primitives + * (mentions, emoji, links, code, images) stay whole — no half-rendered + * mention or partial emoji codepoint. * - * Splits at top-level child boundaries so atomic inline elements (mentions, - * emoji, anchors, code) never render half-parsed. Stage 0 is empty; the final - * stage equals the rendered children. The hook walks indices 0..N-1 over the - * stream duration. + * Stage 0 is empty; the final stage equals the rendered children. The hook + * walks indices 0..N-1 across the trickle duration via an ease-out curve. */ function tokenizeForReveal(html: string): string[] { if (!html) { return ['']; } const doc = parseDocument(html); + const anchors = collectAnchors(doc); const stages: string[] = ['']; - for (let n = 1; n <= doc.children.length; n++) { - stages.push(render(doc.children.slice(0, n))); + for (const anchor of anchors) { + stages.push(render(buildClippedNodes(doc, anchor))); } return stages; } diff --git a/tests/unit/libs/ReportActionFollowupUtils/tokenizeForRevealTest.ts b/tests/unit/libs/ReportActionFollowupUtils/tokenizeForRevealTest.ts index ddb7d51c0045..45bb3fe14257 100644 --- a/tests/unit/libs/ReportActionFollowupUtils/tokenizeForRevealTest.ts +++ b/tests/unit/libs/ReportActionFollowupUtils/tokenizeForRevealTest.ts @@ -5,50 +5,99 @@ describe('tokenizeForReveal', () => { expect(tokenizeForReveal('')).toEqual(['']); }); - it('returns one content stage for single top-level child (binary reveal fallback)', () => { - const html = '

    Just one paragraph.

    '; + it('reveals plain text word-by-word inside a single block', () => { + const html = '

    The quick brown fox.

    '; const stages = tokenizeForReveal(html); - // [empty, full]. The hook treats this as a binary reveal because length < 3. - expect(stages).toHaveLength(2); expect(stages.at(0)).toBe(''); - expect(stages.at(-1)).toBe(html); + expect(stages.at(-1)).toBe('

    The quick brown fox.

    '); + // Word-level reveal yields one stage per word + whitespace run. + expect(stages.length).toBeGreaterThanOrEqual(5); }); - it('produces progressive prefixes split at top-level boundaries', () => { - const html = '

    First.

    Second.

    • Third.
    '; + it('preserves the wrapper while revealing words inside it (formatting applies as text grows)', () => { + const html = '

    Status: important warning for you.

    '; const stages = tokenizeForReveal(html); - expect(stages).toEqual(['', '

    First.

    ', '

    First.

    Second.

    ', html]); + // At some intermediate stage we should see the bold wrapper opened + // around a partial word — proves we're recursing into the formatting + // element rather than treating it as atomic. + const hasOpenStrong = stages.some((s) => /[^<]*<\/strong>/.test(s) && !s.includes('important warning')); + expect(hasOpenStrong).toBe(true); + + // Every stage must keep the tag balanced. + for (const stage of stages) { + const opens = (stage.match(//g) ?? []).length; + const closes = (stage.match(/<\/strong>/g) ?? []).length; + expect(opens).toBe(closes); + } + + expect(stages.at(-1)).toBe('

    Status: important warning for you.

    '); }); - it('keeps inline atomics whole (mention, emoji, anchor, code stay intact in their parent)', () => { - // Each top-level

    is an atomic unit so its inline children never render half-parsed. - // htmlparser2's parseDocument lowercases attribute names — that matches the rest of the - // codebase (see parseFollowupsFromHtml) so we accept it as the canonical shape. - const html = '

    Hi @daniel

    Status: ✅

    '; + it('keeps mention-user atomic (never half-rendered)', () => { + const html = '

    Hi @daniel welcome.

    '; const stages = tokenizeForReveal(html); - expect(stages.at(1)).toContain('mention-user'); - expect(stages.at(1)).toContain('@daniel'); - // No stage shows a partially-rendered mention or emoji. for (const stage of stages) { - const openMentions = (stage.match(//g) ?? []).length; - expect(openMentions).toBe(closeMentions); + const opens = (stage.match(//g) ?? []).length; + expect(opens).toBe(closes); + } + + // Partial text inside the mention-user wrapper would indicate broken atomicity. + expect(stages.some((s) => /]*>@dani(?!e)/.test(s))).toBe(false); + }); + + it('keeps emoji atomic', () => { + const html = '

    Status: ✅ all good.

    '; + const stages = tokenizeForReveal(html); + + for (const stage of stages) { + const opens = (stage.match(//g) ?? []).length; + const closes = (stage.match(/<\/emoji>/g) ?? []).length; + expect(opens).toBe(closes); + } + }); + + it('keeps anchor atomic so the URL and text appear together', () => { + const html = '
    '; + const stages = tokenizeForReveal(html); - const openEmojis = (stage.match(//g) ?? []).length; - const closeEmojis = (stage.match(/<\/emoji>/g) ?? []).length; - expect(openEmojis).toBe(closeEmojis); + const anchorContents = stages.flatMap((s) => Array.from(s.matchAll(/]*>([^<]*)<\/a>/g)).map((m) => m[1])); + for (const content of anchorContents) { + expect(content).toBe('our docs'); } }); - it('preserves the typical Concierge response shape (paragraphs + bullet list)', () => { - const html = "

    To set up QuickBooks, here's what to do:

    1. Go to Settings.
    2. Click Connections.
    3. Pick QBO.

    Let me know if you hit a snag.

    "; + it('keeps code atomic', () => { + const html = '

    Run npm install to start.

    '; + const stages = tokenizeForReveal(html); + + const codeContents = stages.flatMap((s) => Array.from(s.matchAll(/([^<]*)<\/code>/g)).map((m) => m[1])); + for (const content of codeContents) { + expect(content).toBe('npm install'); + } + }); + + it('reveals each top-level child progressively (multi-paragraph + list shape)', () => { + const html = '

    Intro paragraph here.

    1. First step.
    2. Second step.

    Closing line.

    '; const stages = tokenizeForReveal(html); - // 3 top-level children => 4 stages (empty + 3 progressive). - expect(stages).toHaveLength(4); expect(stages.at(-1)).toBe(html); + + // Stages must monotonically grow: textual content length never decreases. + const lengths = stages.map((s) => s.replaceAll(/<[^>]+>/g, '').length); + for (let i = 1; i < lengths.length; i++) { + expect(lengths.at(i) ?? 0).toBeGreaterThanOrEqual(lengths.at(i - 1) ?? 0); + } + }); + + it('produces enough anchors that a typical multi-paragraph response trickles smoothly (>=20 stages)', () => { + // Realistic shape — Xero-style 5-step instructions. + const html = + '

    Here is how to connect Xero as your accounting integration:

    1. In the left-hand menu, go to Settings.
    2. Click More features, then click Accounting.
    3. Click Set up next to Xero.
    4. Log in to Xero as an administrator.
    5. Confirm the connection.

    This imports your charts of accounts and tracking categories.

    '; + const stages = tokenizeForReveal(html); + expect(stages.length).toBeGreaterThanOrEqual(20); }); }); From 7cf58ac54b78592159e9285bdb0799ccdedf9fd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marco=20Ch=C3=A1vez?= Date: Mon, 27 Apr 2026 22:08:05 +0900 Subject: [PATCH 4/8] Concierge trickle: drop tick cadence 400ms -> 150ms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 400ms ticks left perceptible gaps between word reveals at the ~ChatGPT/Claude streaming pace. 150ms keeps the trailing edge always moving — the gap between dispatches drops below the threshold where the eye perceives discrete jumps. Re-render cost goes 4x but RNW's draft-bubble re-render is one list item, not the whole list. Same total trickle duration (DEFAULT_STREAM_DURATION_MS = 30s); ease-out curve unchanged. Only the loop sampling rate changes. --- src/hooks/usePendingConciergeResponse.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/hooks/usePendingConciergeResponse.ts b/src/hooks/usePendingConciergeResponse.ts index 47a07e8a94f8..d22252daa575 100644 --- a/src/hooks/usePendingConciergeResponse.ts +++ b/src/hooks/usePendingConciergeResponse.ts @@ -15,8 +15,10 @@ import useOnyx from './useOnyx'; const STALE_THRESHOLD_MS = 10_000; /** Default trickle duration. Roughly matches the p50 follow-up generation window so the reveal lands close to when followups arrive. */ const DEFAULT_STREAM_DURATION_MS = 30_000; -/** Trickle tick cadence. ~75 updates over the 30s default — enough granularity for a smooth ease-out, sparse enough to keep render churn low. */ -const TICK_INTERVAL_MS = 400; +/** Trickle tick cadence. 150ms keeps each visible chunk small enough to feel + * continuous (closer to the ChatGPT/Claude streaming feel) without spamming + * dispatches faster than RNW can comfortably re-render the synthetic bubble. */ +const TICK_INTERVAL_MS = 150; /** Hard cap on running trickle. If the loop is still alive past this, force completion to avoid pinning a synthetic bubble forever. */ const TRICKLE_HARD_CAP_MS = 60_000; /** Once the real reportComment lands in REPORT_ACTIONS, finish the remaining reveal within this window. */ From 8bdbb1698208cabd2f45c84345dc4828c7afe4eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marco=20Ch=C3=A1vez?= Date: Tue, 28 Apr 2026 10:59:50 +0900 Subject: [PATCH 5/8] Tighten comments per Expensidev style guide Drop PR/companion-PR references, author attribution, and forward-looking "Phase 2"/"Drives" prose in trickle-related comments. Compress 4+ line blocks down to 1-3 lines without losing the non-obvious WHY. --- src/hooks/usePendingConciergeResponse.ts | 36 ++++--------- .../tokenizeForReveal.ts | 50 ++++++------------- src/pages/inbox/ConciergeDraftContext.tsx | 7 ++- 3 files changed, 29 insertions(+), 64 deletions(-) diff --git a/src/hooks/usePendingConciergeResponse.ts b/src/hooks/usePendingConciergeResponse.ts index d22252daa575..7790935115b3 100644 --- a/src/hooks/usePendingConciergeResponse.ts +++ b/src/hooks/usePendingConciergeResponse.ts @@ -30,24 +30,11 @@ function easeOut(t: number): number { } /** - * Manages the lifecycle of a pending pregenerated Concierge response. - * - * For payloads with multiple top-level chunks the hook trickles the body in - * progressively over `DEFAULT_STREAM_DURATION_MS` (decelerating ease-out), - * dispatching `conciergeDraftStarted/updated/completed` events into Issa's - * existing `ConciergeDraftContext` reducer (PR #87110) so the synthetic - * report action splice and reconciliation behave identically to server-driven - * streaming. Canonical Onyx state (`REPORT_ACTIONS`) is only written at the - * end via `applyPendingConciergeAction`, preserving LHN previews / push - * notifications / search snapshots until the trickle finishes. - * - * Single-chunk payloads fall back to the original binary reveal at - * `displayAfter` so short replies (1–2 sentence answers) don't get stretched - * artificially. - * - * Reconciliation: if the real `reportComment` arrives mid-trickle, the loop - * accelerates the remaining reveal so the synthetic bubble lands within - * `ACCELERATED_REMAINING_MS` instead of snapping closed. + * Multi-chunk payloads trickle into the `ConciergeDraftContext` reducer over + * `DEFAULT_STREAM_DURATION_MS` so the synthetic report action behaves + * identically to server-driven streaming. `REPORT_ACTIONS` is written only + * at completion, preserving LHN previews / push / search snapshots. + * Single-chunk payloads keep the binary reveal at `displayAfter`. */ function usePendingConciergeResponse(reportID: string | undefined) { const [pendingResponse] = useOnyx(`${ONYXKEYS.COLLECTION.PENDING_CONCIERGE_RESPONSE}${reportID}`); @@ -85,11 +72,9 @@ function usePendingConciergeResponse(reportID: string | undefined) { return; } - // Threshold matches word-level tokenization — a typical 1–2 sentence - // reply produces ~10–18 anchors and feels stretched if trickled over - // 30s, so binary reveal is preserved for those. Multi-paragraph - // replies (intro + list + closing) clear 20 anchors easily and get - // the smooth ChatGPT-style stream. + // Short replies (~10–18 anchors) feel stretched if trickled over 30s, + // so they keep the binary reveal; multi-paragraph replies clear the + // threshold and get the smooth trickle. const shouldTrickle = tokens.length >= 20 && !!fullHtml; if (!shouldTrickle) { const timer = setTimeout(() => applyPendingConciergeAction(reportID, reportAction), Math.max(0, remainingDelay)); @@ -103,9 +88,8 @@ function usePendingConciergeResponse(reportID: string | undefined) { let effectiveDuration = DEFAULT_STREAM_DURATION_MS; let lastStage = 0; let cancelled = false; - // Telemetry: track whether/when acceleration fired so the [complete] log - // can attribute the completion reason and the arrival point of the canonical - // reportComment. Drives Phase 2 duration tuning + regression detection. + // Track whether/when acceleration fired so the [complete] log can + // attribute the completion reason and arrival point of `reportComment`. let arrivedAtProgress: number | undefined; let arrivedAtElapsedMs: number | undefined; diff --git a/src/libs/ReportActionFollowupUtils/tokenizeForReveal.ts b/src/libs/ReportActionFollowupUtils/tokenizeForReveal.ts index 6dff8f637688..10a2fa2b1b39 100644 --- a/src/libs/ReportActionFollowupUtils/tokenizeForReveal.ts +++ b/src/libs/ReportActionFollowupUtils/tokenizeForReveal.ts @@ -4,10 +4,8 @@ import type {AnyNode, Document, Element as DomElement} from 'domhandler'; import {parseDocument} from 'htmlparser2'; /** - * Tags rendered atomically — splitting their content mid-element looks broken - * (e.g. half a mention, a partial emoji codepoint, a link with no closing tag). - * Mentions/emoji/img are visual primitives,
    is a structural target with a - * URL, is a literal text run that shouldn't reflow as words trickle in. + * Tags whose content can't be split mid-element without looking broken + * (half mention, partial emoji codepoint, anchor without its URL). */ const ATOMIC_TAGS = new Set(['mention-user', 'mention-report', 'emoji', 'a', 'code', 'img']); @@ -22,12 +20,9 @@ type Anchor = { }; /** - * Walk the DOM in document order, emitting one anchor per "word or whitespace - * run" inside text nodes and one anchor for each whole atomic-tag subtree. - * Block / formatting elements (

    ,

      , , , ...) recurse so - * their inner words become anchors with the wrapper preserved on render — - * that's what makes bold appear as words become bold rather than as a single - * jump. + * Emits one anchor per word/whitespace run inside text nodes and one per + * atomic-tag subtree. Formatting elements (, , ...) recurse so + * bold appears as words become bold, rather than in one jump. */ function collectAnchors(doc: Document): Anchor[] { const anchors: Anchor[] = []; @@ -37,10 +32,9 @@ function collectAnchors(doc: Document): Anchor[] { if (text.length === 0) { return; } - // Word-or-whitespace runs — each run advances the visible content - // by one perceptual unit. Splitting on \s alone would coalesce - // multiple words around a single space; this keeps the reveal - // granular without breaking on character boundaries. + // Splitting on \s alone would coalesce adjacent words around a + // single space; matching word-or-whitespace runs keeps each word + // a separate reveal step. const re = /\S+|\s+/g; let match = re.exec(text); while (match !== null) { @@ -79,14 +73,10 @@ function collectAnchors(doc: Document): Anchor[] { } /** - * Build a partial node forest containing every node up to (and including) - * `anchor`, with any text-node leaf truncated to the anchor's offset. - * - * Reconstructs only the path elements: siblings before the path branch are - * cloned by reference (rendered fully), the path branch is shallow-cloned - * with its `children` array replaced. Sibling references are unchanged so - * dom-serializer renders them via their existing structure — no parent/next - * pointer surgery needed. + * Builds a partial node forest up to and including `anchor`, truncating the + * leaf text node at the anchor offset. Only the path branch is shallow-cloned + * with a new children array; sibling references stay untouched so + * dom-serializer renders them as-is. */ function buildClippedNodes(doc: Document, anchor: Anchor): AnyNode[] { const clip = (siblings: readonly AnyNode[], depth: number): AnyNode[] => { @@ -113,18 +103,10 @@ function buildClippedNodes(doc: Document, anchor: Anchor): AnyNode[] { } /** - * Pre-computes a list of progressive HTML stages for a pregenerated Concierge - * response — usePendingConciergeResponse trickles them in over the follow-up - * generation window so the chat keeps moving while the server works. - * - * Word-level granularity within text nodes (ChatGPT-style streaming feel), - * recursing into formatting wrappers like / so bold appears as - * words become bold rather than in one jump. Atomic visual primitives - * (mentions, emoji, links, code, images) stay whole — no half-rendered - * mention or partial emoji codepoint. - * - * Stage 0 is empty; the final stage equals the rendered children. The hook - * walks indices 0..N-1 across the trickle duration via an ease-out curve. + * Pre-computes progressive HTML stages with word-level granularity. Formatting + * wrappers (, , ...) recurse so bold reveals progressively; + * atomic primitives (mentions, emoji, links, code, images) stay whole. + * Stage 0 is empty; the final stage equals the input. */ function tokenizeForReveal(html: string): string[] { if (!html) { diff --git a/src/pages/inbox/ConciergeDraftContext.tsx b/src/pages/inbox/ConciergeDraftContext.tsx index 7745d634c69b..7bad6309721c 100644 --- a/src/pages/inbox/ConciergeDraftContext.tsx +++ b/src/pages/inbox/ConciergeDraftContext.tsx @@ -19,10 +19,9 @@ type ConciergeDraftState = { type ConciergeDraftActions = { clearDraft: () => void; /** - * Apply a draft event from a non-Pusher source (e.g. the local pacer in - * usePendingConciergeResponse for pregenerated replies whose body is - * already on the client). Uses the same reducer as the Pusher path so the - * reportActionID-based reconciliation continues to work unchanged. + * Apply a draft event from a non-Pusher source (e.g. a local pacer for + * pregenerated replies). Uses the same reducer as the Pusher path so + * `reportActionID`-based reconciliation works unchanged. */ dispatchLocalDraftEvent: (event: ConciergeDraftEvent) => void; }; From a4cd9b9de86301ca57961b8e07a95b820e21179d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marco=20Ch=C3=A1vez?= Date: Tue, 28 Apr 2026 13:41:46 +0900 Subject: [PATCH 6/8] Concierge trickle: char-by-char tokenization (ChatGPT cadence) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Word-level reveal at 150ms ticks still showed perceptible word-jumps near the trailing edge. Char-level brings each visible advance below the threshold where the eye perceives discrete steps — the trickle feels continuous even when the cadence loop is the same 150ms. shouldTrickle threshold bumped from 20 (word-anchors) to 100 (chars) so 1-2 sentence replies still get the binary 4s reveal. Atomic tags (, , , , ) still emit one anchor for the whole element — no half-rendered mentions or partial emoji codepoints. Formatting wrappers still recurse so bold characters appear with the wrapper preserved. Re-render budget on RNW: a typical Xero-shape response (~280 visible chars) over 30s = ~9 dispatches/second. Bubble is one list item, not the whole list, so this stays well within the comfortable range. --- src/hooks/usePendingConciergeResponse.ts | 8 ++++---- .../ReportActionFollowupUtils/tokenizeForReveal.ts | 14 ++++++-------- .../tokenizeForRevealTest.ts | 13 ++++++++----- 3 files changed, 18 insertions(+), 17 deletions(-) diff --git a/src/hooks/usePendingConciergeResponse.ts b/src/hooks/usePendingConciergeResponse.ts index 7790935115b3..b7cd62f03599 100644 --- a/src/hooks/usePendingConciergeResponse.ts +++ b/src/hooks/usePendingConciergeResponse.ts @@ -72,10 +72,10 @@ function usePendingConciergeResponse(reportID: string | undefined) { return; } - // Short replies (~10–18 anchors) feel stretched if trickled over 30s, - // so they keep the binary reveal; multi-paragraph replies clear the - // threshold and get the smooth trickle. - const shouldTrickle = tokens.length >= 20 && !!fullHtml; + // Anchors are character-level. Short replies (~50–100 chars) keep the + // binary reveal; longer ones (paragraphs / lists) cross the threshold + // and get the smooth trickle. + const shouldTrickle = tokens.length >= 100 && !!fullHtml; if (!shouldTrickle) { const timer = setTimeout(() => applyPendingConciergeAction(reportID, reportAction), Math.max(0, remainingDelay)); return () => clearTimeout(timer); diff --git a/src/libs/ReportActionFollowupUtils/tokenizeForReveal.ts b/src/libs/ReportActionFollowupUtils/tokenizeForReveal.ts index 10a2fa2b1b39..3ca4367a60f1 100644 --- a/src/libs/ReportActionFollowupUtils/tokenizeForReveal.ts +++ b/src/libs/ReportActionFollowupUtils/tokenizeForReveal.ts @@ -32,14 +32,12 @@ function collectAnchors(doc: Document): Anchor[] { if (text.length === 0) { return; } - // Splitting on \s alone would coalesce adjacent words around a - // single space; matching word-or-whitespace runs keeps each word - // a separate reveal step. - const re = /\S+|\s+/g; - let match = re.exec(text); - while (match !== null) { - anchors.push({path: path.slice(), textEndIdx: match.index + match[0].length}); - match = re.exec(text); + // One anchor per character for ChatGPT-style streaming feel. + // Atomic tags below still emit a single anchor for the whole tag, + // so mentions/emoji/anchors/code never render half-rendered even + // at this granularity. + for (let i = 1; i <= text.length; i += 1) { + anchors.push({path: path.slice(), textEndIdx: i}); } return; } diff --git a/tests/unit/libs/ReportActionFollowupUtils/tokenizeForRevealTest.ts b/tests/unit/libs/ReportActionFollowupUtils/tokenizeForRevealTest.ts index 45bb3fe14257..ed6a37ce6ebe 100644 --- a/tests/unit/libs/ReportActionFollowupUtils/tokenizeForRevealTest.ts +++ b/tests/unit/libs/ReportActionFollowupUtils/tokenizeForRevealTest.ts @@ -5,14 +5,15 @@ describe('tokenizeForReveal', () => { expect(tokenizeForReveal('')).toEqual(['']); }); - it('reveals plain text word-by-word inside a single block', () => { + it('reveals plain text character-by-character inside a single block', () => { const html = '

      The quick brown fox.

      '; const stages = tokenizeForReveal(html); expect(stages.at(0)).toBe(''); expect(stages.at(-1)).toBe('

      The quick brown fox.

      '); - // Word-level reveal yields one stage per word + whitespace run. - expect(stages.length).toBeGreaterThanOrEqual(5); + // Char-level reveal yields one stage per character of the text content + // ("The quick brown fox." = 20 chars), plus stage 0 (empty). + expect(stages.length).toBe(21); }); it('preserves the wrapper while revealing words inside it (formatting applies as text grows)', () => { @@ -93,11 +94,13 @@ describe('tokenizeForReveal', () => { } }); - it('produces enough anchors that a typical multi-paragraph response trickles smoothly (>=20 stages)', () => { + it('produces enough anchors that a typical multi-paragraph response trickles smoothly', () => { // Realistic shape — Xero-style 5-step instructions. const html = '

      Here is how to connect Xero as your accounting integration:

      1. In the left-hand menu, go to Settings.
      2. Click More features, then click Accounting.
      3. Click Set up next to Xero.
      4. Log in to Xero as an administrator.
      5. Confirm the connection.

      This imports your charts of accounts and tracking categories.

      '; const stages = tokenizeForReveal(html); - expect(stages.length).toBeGreaterThanOrEqual(20); + // Char-level: total visible-text chars ~280, so we expect comfortably + // more than the shouldTrickle threshold of 100 anchors. + expect(stages.length).toBeGreaterThanOrEqual(200); }); }); From 581007060e268b7cef83443db12a34b71af3d2c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marco=20Ch=C3=A1vez?= Date: Tue, 28 Apr 2026 13:57:53 +0900 Subject: [PATCH 7/8] Concierge trickle: faster cadence (20s duration, 80ms ticks) --- src/hooks/usePendingConciergeResponse.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/hooks/usePendingConciergeResponse.ts b/src/hooks/usePendingConciergeResponse.ts index b7cd62f03599..123e771421e0 100644 --- a/src/hooks/usePendingConciergeResponse.ts +++ b/src/hooks/usePendingConciergeResponse.ts @@ -13,12 +13,10 @@ 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; -/** Default trickle duration. Roughly matches the p50 follow-up generation window so the reveal lands close to when followups arrive. */ -const DEFAULT_STREAM_DURATION_MS = 30_000; -/** Trickle tick cadence. 150ms keeps each visible chunk small enough to feel - * continuous (closer to the ChatGPT/Claude streaming feel) without spamming - * dispatches faster than RNW can comfortably re-render the synthetic bubble. */ -const TICK_INTERVAL_MS = 150; +/** Default trickle duration. Targets ~14 chars/sec average reveal across a typical multi-paragraph response, so the trickle visibly streams without dragging the user past the moment they want to read. */ +const DEFAULT_STREAM_DURATION_MS = 20_000; +/** 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). */ +const TICK_INTERVAL_MS = 80; /** Hard cap on running trickle. If the loop is still alive past this, force completion to avoid pinning a synthetic bubble forever. */ const TRICKLE_HARD_CAP_MS = 60_000; /** Once the real reportComment lands in REPORT_ACTIONS, finish the remaining reveal within this window. */ From 8ee8a0bec01b87d103d135e0dc0b5a946040b449 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marco=20Ch=C3=A1vez?= Date: Tue, 28 Apr 2026 14:18:17 +0900 Subject: [PATCH 8/8] Concierge trickle: drop duration to 15s --- src/hooks/usePendingConciergeResponse.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/hooks/usePendingConciergeResponse.ts b/src/hooks/usePendingConciergeResponse.ts index 123e771421e0..ccb66fcee7c5 100644 --- a/src/hooks/usePendingConciergeResponse.ts +++ b/src/hooks/usePendingConciergeResponse.ts @@ -13,8 +13,8 @@ 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; -/** Default trickle duration. Targets ~14 chars/sec average reveal across a typical multi-paragraph response, so the trickle visibly streams without dragging the user past the moment they want to read. */ -const DEFAULT_STREAM_DURATION_MS = 20_000; +/** 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. */ +const DEFAULT_STREAM_DURATION_MS = 15_000; /** 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). */ const TICK_INTERVAL_MS = 80; /** Hard cap on running trickle. If the loop is still alive past this, force completion to avoid pinning a synthetic bubble forever. */

    See our docs for more.