blob: e6c55bbd6c631f51543c7a58d1ea03bb96d59796 [file]
/**
* PR-UI-RENDER-1 — Smooth streaming hook.
*
* The renderer used to bind streaming text directly to `<Markdown>`,
* so each `text_delta` event triggered an immediate re-render. The
* visible result was "jumpy" output: chunks landed in clumps,
* sometimes the whole sentence appeared at once, and the user never
* saw the calm typewriter cadence other modern chat clients produce.
*
* `useSmoothStreamContent` decouples the displayed text from the
* upstream `rawText`. It:
*
* - Tracks the EMA of arrival characters-per-second (graphemes
* per second, more precisely). New deltas feed the EMA; a slow
* network does not slow the typewriter floor.
* - Advances `displayedCount` once per RAF frame at the smoothed
* CPS, clamped to `[minCps, maxCps]`.
* - **Grapheme-aware slicing** via `Intl.Segmenter` so emoji
* ZWJ-sequences (family, skin-tone, flag) and CRLF stay whole.
* Falls back to codepoint slicing (`Array.from`) when the
* environment lacks `Intl.Segmenter`. Never slices on raw code
* unit indices, which would cut surrogate pairs.
* - **Continuous backlog catch-up**: a large live burst raises the
* frame speed enough to drain within a bounded budget, but never
* changes the displayed position outside RAF advancement.
* - **Complete flush budget**: when `streaming` flips to false,
* raise the catch-up speed so the remaining tail drains within
* `completeFlushBudgetMs` instead of snapping to the final text.
* - **Reduced-motion bypass**: callers pass `snap: true` (typically
* derived from `prefers-reduced-motion: reduce` or the
* e2e-fixture fixture attribute) to skip all smoothing.
* - **Single RAF owner**: one `useEffect` schedules one RAF; its
* cleanup cancels any in-flight handle before the next effect
* re-runs. No way to have two RAFs writing state concurrently
* (review blocker #4 from @kenji).
*
* The pure helpers (`segmentGraphemes`, `computeFrameAdvance`,
* `resolveLiveBacklogCps`, `updateEma`, `resolveInitialDisplayedCount`)
* are exported so they can be unit-tested without a DOM. The React
* hook is a thin shell over them.
*/
import { useEffect, useMemo, useRef, useState } from 'react';
import { redactSecrets } from './redact.js';
const DEFAULT_MIN_CPS = 30;
const DEFAULT_MAX_CPS = 400;
const DEFAULT_LIVE_CATCH_UP_BUDGET_MS = 2_000;
const DEFAULT_COMPLETE_FLUSH_BUDGET_MS = 600;
const EMA_ALPHA = 0.3;
/**
* Seed CPS used before any arrival has been observed. Picked to feel
* roughly like a fast typist so the first few hundred ms of a fresh
* stream don't crawl while the EMA warms up.
*/
const SEED_CPS = 80;
export interface SmoothStreamOptions {
/**
* `true` while the upstream is still emitting deltas. `false` means
* the stream has officially completed (or aborted / errored — the
* caller decides what counts as "done"). Required — the hook will
* not try to infer stream-end from rawText growth, because a network
* stall and a true complete look identical from the rawText side.
* (@kenji review blocker #1.)
*/
streaming: boolean;
/**
* Force an immediate full-text display, bypassing all smoothing.
* Callers should set this when:
* - `prefers-reduced-motion: reduce` matches,
* - the e2e-fixture fixture attribute is set,
* - the content is being hydrated from history (the stream
* already finished — there is nothing to "smoothly" replay).
*/
snap?: boolean;
/** Target time for draining a live burst without snapping. Default 2000ms. */
liveCatchUpBudgetMs?: number;
/**
* Floor on emit speed in graphemes-per-second. A slow upstream
* still feels alive at this rate. Default 30.
*/
minCps?: number;
/**
* Ceiling on emit speed. Prevents a burst arrival from making the
* typewriter look like an instant snap. Default 400.
*/
maxCps?: number;
/**
* After `streaming` flips to `false`, the hook allows at most this
* many ms to finish flushing the remaining backlog. The hook may exceed
* `maxCps` during this drain so completion stays smooth without waiting
* indefinitely. Default 600.
*/
completeFlushBudgetMs?: number;
}
export interface SmoothStreamResult {
/** Text the caller should render right now. Always a prefix of rawText. */
displayed: string;
/** True while the smoother is still catching up to rawText. */
catchingUp: boolean;
}
/**
* Split `text` into an array of grapheme clusters. Prefers
* `Intl.Segmenter` (handles emoji ZWJ sequences, skin-tone modifiers,
* flags, regional indicators, combining marks). Falls back to
* `Array.from(text)` (codepoint-aware, surrogate-pair-safe, but NOT
* grapheme-aware) when `Intl.Segmenter` is unavailable.
*
* Never indexes on raw UTF-16 code units (which would cut emoji in
* half). This is the load-bearing rule of the entire smoother.
*/
export function segmentGraphemes(text: string): string[] {
if (text === '') return [];
if (typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function') {
try {
const seg = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
const out: string[] = [];
for (const s of seg.segment(text)) {
out.push(s.segment);
}
return out;
} catch {
// fall through to Array.from fallback
}
}
return Array.from(text);
}
export interface FrameAdvanceInputs {
rawGraphemeCount: number;
displayedGraphemeCount: number;
emaCps: number;
dtMs: number;
minCps: number;
maxCps: number;
}
/**
* Pure: how many graphemes should this RAF tick advance? Clamps the
* EMA into [minCps, maxCps], converts to graphemes for `dtMs`, and
* never overshoots the available backlog. Always advances at least 1
* when there is work to do — INCLUDING when `dtMs <= 0`.
*
* The `dtMs <= 0` case is load-bearing, not theoretical: `dtMs` is
* "rAF callback timestamp minus wall-clock time at effect re-arm", and
* Chromium's rAF timestamps are vsync-aligned, so under main-thread
* pressure (an IPC delta burst re-rendering the whole chat) the
* timestamp lags the wall clock and `dtMs` clamps to 0 for every tick
* in the recovery window. Returning 0 here used to leave
* `displayedCount` unchanged, which meant the RAF effect (keyed on
* `displayedCount`/`rawLength`) never re-armed — the typewriter died
* with a full backlog and the bubble sat BLANK until stream end
* snapped the whole answer in at once. Advancing 1 grapheme keeps the
* single-owner RAF chain alive at the frame-rate floor until the
* timestamps normalize.
*/
export function computeFrameAdvance(inputs: FrameAdvanceInputs): number {
const { rawGraphemeCount, displayedGraphemeCount, emaCps, dtMs, minCps, maxCps } = inputs;
const backlog = rawGraphemeCount - displayedGraphemeCount;
if (backlog <= 0) return 0;
const cps = Math.min(Math.max(emaCps, minCps), maxCps);
const advance = Math.max(1, Math.floor((cps * dtMs) / 1000));
return Math.min(advance, backlog);
}
function clampedEmaCps(inputs: { emaCps: number; minCps: number; maxCps: number }): number {
return Math.min(Math.max(inputs.emaCps, inputs.minCps), inputs.maxCps);
}
export function resolveLiveBacklogCps(inputs: {
backlog: number;
elapsedMs: number;
budgetMs: number;
emaCps: number;
minCps: number;
maxCps: number;
}): number {
const normalCps = clampedEmaCps(inputs);
if (inputs.backlog <= 0) return normalCps;
const remainingMs = Math.max(1, inputs.budgetMs - inputs.elapsedMs);
return Math.max(normalCps, Math.ceil((inputs.backlog * 1000) / remainingMs));
}
export interface CompletionCpsInputs {
rawGraphemeCount: number;
displayedGraphemeCount: number;
elapsedMs: number;
budgetMs: number;
emaCps: number;
minCps: number;
maxCps: number;
}
/**
* Pure: while a stream is completing, choose the frame speed needed to drain
* the remaining tail inside the completion budget. This keeps the completion
* handoff fast without a visible end-of-budget snap.
*/
export function resolveCompletionCps(inputs: CompletionCpsInputs): number {
const backlog = inputs.rawGraphemeCount - inputs.displayedGraphemeCount;
const normalCps = clampedEmaCps(inputs);
if (backlog <= 0) return normalCps;
const remainingMs = Math.max(1, inputs.budgetMs - inputs.elapsedMs);
return Math.max(normalCps, Math.ceil((backlog * 1000) / remainingMs));
}
export interface EmaUpdateInputs {
prevEma: number;
alpha: number;
observedCps: number;
}
/**
* Pure: EMA = alpha * observed + (1 - alpha) * prev. Ignores
* non-finite / non-positive observations (so a stalled arrival with
* dt → 0 doesn't poison the average with Infinity).
*/
export function updateEma(inputs: EmaUpdateInputs): number {
const { prevEma, alpha, observedCps } = inputs;
if (!Number.isFinite(observedCps) || observedCps <= 0) return prevEma;
return alpha * observedCps + (1 - alpha) * prevEma;
}
export interface InitialDisplayInputs {
rawGraphemeCount: number;
streaming: boolean;
snap: boolean;
}
/**
* Pure: how much of `rawText` should be displayed on first mount?
*
* - `snap: true` → full text immediately (reduced-motion / fixture).
* - `streaming: false` with non-empty raw → history hydration.
* The stream already finished; replay would be a fake animation
* over already-settled content. Snap. (@kenji review blocker #3.)
* - Otherwise → 0 (start from nothing, let typewriter catch up).
*/
export function resolveInitialDisplayedCount(inputs: InitialDisplayInputs): number {
if (inputs.snap) return inputs.rawGraphemeCount;
if (!inputs.streaming) return inputs.rawGraphemeCount;
return 0;
}
/**
* React hook. See module doc for the design contract.
*/
export function useSmoothStreamContent(
rawText: string,
options: SmoothStreamOptions,
): SmoothStreamResult {
const minCps = options.minCps ?? DEFAULT_MIN_CPS;
const maxCps = options.maxCps ?? DEFAULT_MAX_CPS;
const liveCatchUpBudget = options.liveCatchUpBudgetMs ?? DEFAULT_LIVE_CATCH_UP_BUDGET_MS;
const completeBudget = options.completeFlushBudgetMs ?? DEFAULT_COMPLETE_FLUSH_BUDGET_MS;
const snap = !!options.snap;
// Segment once per rawText change. For typical streaming sizes
// (~few KB) Intl.Segmenter is microseconds-fast, so re-segmenting
// the full string on every delta is fine and avoids the subtle
// boundary bugs of incremental segmentation (ZWJ sequences can
// extend across appends).
const rawGraphemes = useMemo(() => segmentGraphemes(rawText), [rawText]);
const rawLength = rawGraphemes.length;
const [displayedCount, setDisplayedCount] = useState(() =>
resolveInitialDisplayedCount({ rawGraphemeCount: rawLength, streaming: options.streaming, snap }),
);
// Mutable refs read by the RAF tick.
const refs = useRef({
emaCps: SEED_CPS,
lastObservedRawLength: rawLength,
lastArrivalAt: nowMs(),
liveCatchUpStartedAt: 0,
completeStartedAt: 0,
initialized: false,
});
// Update EMA on arrival (rawLength grew).
useEffect(() => {
const s = refs.current;
if (!s.initialized) {
s.lastObservedRawLength = rawLength;
s.lastArrivalAt = nowMs();
s.initialized = true;
return;
}
if (rawLength > s.lastObservedRawLength) {
const now = nowMs();
const dtMs = now - s.lastArrivalAt;
if (dtMs > 0) {
const observedCps = ((rawLength - s.lastObservedRawLength) * 1000) / dtMs;
s.emaCps = updateEma({ prevEma: s.emaCps, alpha: EMA_ALPHA, observedCps });
}
s.lastArrivalAt = now;
s.lastObservedRawLength = rawLength;
// New deltas after a "complete" flag — caller flipped streaming
// back on (e.g., a retry). Clear the complete budget so we
// typewriter the new chunk instead of insta-snapping.
s.completeStartedAt = 0;
} else if (rawLength < s.lastObservedRawLength) {
// Raw shrunk: session switch / source reset. Reset the EMA
// and the typewriter cursor. The displayedCount reset is
// handled in the snap/initial effect below.
s.lastObservedRawLength = rawLength;
s.emaCps = SEED_CPS;
s.liveCatchUpStartedAt = 0;
s.completeStartedAt = 0;
setDisplayedCount(
resolveInitialDisplayedCount({ rawGraphemeCount: rawLength, streaming: options.streaming, snap }),
);
}
}, [rawLength, options.streaming, snap]);
// Explicit snap option (e.g., reduced-motion flipped mid-stream).
// Also runs on mount when snap starts true, mirroring the initial
// displayedCount computation.
useEffect(() => {
if (snap) setDisplayedCount(rawLength);
}, [snap, rawLength]);
// Single RAF owner. Re-runs on any input change; its cleanup
// cancels the prior handle before the next effect schedules a new
// one. Two concurrent RAF writers are impossible by construction.
useEffect(() => {
if (snap) return;
if (displayedCount >= rawLength) {
refs.current.liveCatchUpStartedAt = 0;
// Caught up. If we're still streaming, the next arrival will
// trigger the EMA effect; this effect will re-run and schedule
// the next frame. If streaming finished, we're truly done.
return;
}
if (typeof requestAnimationFrame !== 'function') {
// SSR / non-browser env (node:test without jsdom): snap.
setDisplayedCount(rawLength);
return;
}
let cancelled = false;
let frameStartedAt = nowMs();
const tick = (now: number) => {
if (cancelled) return;
// Stream-end bounded flush. Once streaming flips false and we
// still have backlog, raise the speed enough to drain inside
// completeBudget instead of snapping the tail all at once.
const s = refs.current;
let frameCps: number;
if (options.streaming) {
if (s.liveCatchUpStartedAt === 0 || s.completeStartedAt !== 0) {
s.liveCatchUpStartedAt = now;
s.completeStartedAt = 0;
}
frameCps = resolveLiveBacklogCps({
backlog: rawLength - displayedCount,
elapsedMs: now - s.liveCatchUpStartedAt,
budgetMs: liveCatchUpBudget,
emaCps: s.emaCps,
minCps,
maxCps,
});
} else {
s.liveCatchUpStartedAt = 0;
if (s.completeStartedAt === 0) s.completeStartedAt = now;
frameCps = resolveCompletionCps({
rawGraphemeCount: rawLength,
displayedGraphemeCount: displayedCount,
elapsedMs: now - s.completeStartedAt,
budgetMs: completeBudget,
emaCps: refs.current.emaCps,
minCps,
maxCps,
});
}
const dtMs = Math.max(0, now - frameStartedAt);
const advance = computeFrameAdvance({
rawGraphemeCount: rawLength,
displayedGraphemeCount: displayedCount,
emaCps: frameCps,
dtMs,
minCps,
maxCps: frameCps,
});
if (advance > 0) {
setDisplayedCount((cur) => Math.min(cur + advance, rawLength));
}
// Do NOT re-arm here. Let the effect re-run on the new
// displayedCount and schedule the next frame; that keeps the
// single-owner invariant.
};
const handle = requestAnimationFrame(tick);
return () => {
cancelled = true;
cancelAnimationFrame(handle);
};
}, [rawLength, displayedCount, options.streaming, snap, minCps, maxCps, liveCatchUpBudget, completeBudget]);
const displayed = useMemo(() => {
if (displayedCount >= rawLength) return rawText;
if (displayedCount <= 0) return '';
return rawGraphemes.slice(0, displayedCount).join('');
}, [rawText, rawGraphemes, displayedCount, rawLength]);
return {
displayed,
catchingUp: displayedCount < rawLength,
};
}
function nowMs(): number {
if (typeof performance !== 'undefined' && typeof performance.now === 'function') {
return performance.now();
}
return Date.now();
}
/**
* PR-UI-C1 review fixup (@kenji msg fbb8f119) — pure trust-boundary
* gate that callers MUST apply before feeding text to
* `useSmoothStreamContent`.
*
* The smoother typewriters by rendering successive PREFIXES of its
* input string. If the raw input contains a partially-emitted
* secret (e.g. mid-delta `Authorization: Bearer sk-secret123`), the
* smoother would briefly paint each prefix to the DOM — even though
* the FULL string would later be masked by the downstream Markdown
* redactor. The prefix `Authorization: Bearer s` doesn't match any
* secret pattern by itself, so it would leak to the screen for a
* frame or two.
*
* Solution: callers run the raw text through `prepareSmoothStreamText`
* BEFORE handing it to `useSmoothStreamContent`. The function
* applies `redactSecrets` on the full input, so the smoother only
* ever sees already-masked text — every prefix of which is
* guaranteed secret-free. The downstream `<Markdown>` (or `<pre>`)
* stays in place as defense in depth.
*
* `redactSecrets` is idempotent on already-masked text, so it's
* safe to apply this helper even when the upstream path already
* redacted (e.g. ReasoningPanel, where C0's `applyThinkingDelta`
* already ran `redactSecrets` on each delta).
*
* The function is pure and exported so callers can also use it in
* tests / non-React contexts to verify the trust boundary.
*/
export function prepareSmoothStreamText(raw: string): string {
if (typeof raw !== 'string') return '';
return redactSecrets(raw);
}