[eric] streaming: the reveal commits every frame and its lag tracks the arrival interval; the CLI hands text over in 90-char lumps every 580 ms and 60 ms commits read as words popping

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C9zwUaHucUgrdxvK8FvjYT
This commit is contained in:
ciregenz
2026-09-03 13:23:28 -07:00
co-authored by Claude Fable 5.1
parent 6253429050
commit 0001dfe9af
3 changed files with 63 additions and 6 deletions
@@ -0,0 +1,30 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { lagForGap, nextGapEma } from './useSmoothText';
// The CLI hands text over in ~90-char lumps every ~580 ms; a fixed 0.35 s lag drained each lump and
// then idled, and a 60 ms commit painted 12 characters at a time, which together read as "chunky".
// The lag now tracks the arrival interval so the reveal always has the next lump in hand, and the
// reveal commits every frame (3 characters a frame, measured free on a 9,000-char reply).
test('a lumpy lane gets a lag longer than its interval; a fine lane keeps the floor', () => {
assert.equal(lagForGap(0.58), 0.725);
assert.equal(lagForGap(0.05), 0.3);
assert.equal(lagForGap(null), 0.3);
assert.equal(lagForGap(5), 0.9);
});
test('the interval estimate seeds on the first gap and eases toward later ones', () => {
const first = nextGapEma(null, 0.6);
assert.equal(first, 0.6);
const second = nextGapEma(first, 0.2);
assert.ok(second < 0.6 && second > 0.2, String(second));
});
test('the reveal commits every frame, not in 60 ms word-sized steps', () => {
const src = fs.readFileSync(path.join(process.cwd(), 'src/app/pages/AgentChat/bubbles/useSmoothText.ts'), 'utf8');
const m = src.match(/const COMMIT_MS = (\d+);/);
assert.ok(m && Number(m[1]) <= 17, `COMMIT_MS is ${m && m[1]}`);
});
@@ -40,7 +40,7 @@ test('the reveal still advances, it just advances on commits', () => {
test('the typed feel is kept rather than dropped', () => {
// hermes renders streamed text with a memoised parse, a caret, and no pacing at all. That is the
// same conclusion one step further; the velocity model is what makes this read like typing.
for (const knob of ['TARGET_LAG_S', 'RATE_SMOOTH_S', 'MAX_CPS']) {
for (const knob of ['LAG_MIN_S', 'lagForGap', 'RATE_SMOOTH_S', 'MAX_CPS']) {
assert.ok(smooth.includes(knob), `${knob} is part of the feel this exists for`);
}
});
@@ -34,13 +34,32 @@ import { useEffect, useRef, useState } from 'react';
* further; keeping the velocity model preserves the typed feel this was built for.
*/
const TARGET_LAG_S = 0.35; // stay this far behind = the buffer that prevents stalls
// The lag is the buffer that prevents stalls, so it has to be at least one arrival interval: the
// CLI hands us text in ~90-char lumps every ~580 ms (measured 2026-09-03 at the SDK boundary, while
// the router underneath streamed every 30-90 ms), and a fixed 0.35 s drained each lump and then sat
// idle for the rest of the gap, which read as burst, pause, burst. Fine-grained lanes keep the floor.
const LAG_MIN_S = 0.3;
const LAG_MAX_S = 0.9;
const LAG_GAP_RATIO = 1.25;
const GAP_EMA = 0.3;
export function lagForGap(gapEmaS: number | null): number {
if (gapEmaS == null || !Number.isFinite(gapEmaS) || gapEmaS <= 0) return LAG_MIN_S;
return Math.min(LAG_MAX_S, Math.max(LAG_MIN_S, gapEmaS * LAG_GAP_RATIO));
}
/** EMA of the interval between text arrivals; the first arrival seeds it. */
export function nextGapEma(prev: number | null, gapS: number): number {
if (prev == null) return gapS;
return prev + (gapS - prev) * GAP_EMA;
}
const RATE_SMOOTH_S = 0.25; // how fast the reveal speed eases toward its target
const MAX_CPS = 1000; // cap so a huge paste/burst still reveals smoothly, not instantly
const MAX_DT_S = 0.05; // clamp elapsed after a frame drop / tab switch so we don't leap
// 60ms ~= 17fps, which still reads as typing. It was 150ms when a per-frame DOM write hid the
// commit cadence; with React the only writer, that cadence IS the reveal, and 150 looked stepped.
const COMMIT_MS = 60;
// Every frame. At 60 ms the eye got 12 characters every 67 ms, which reads as words popping (Eric:
// "chunky"); at 16 ms it gets 3 per frame. Measured 2026-09-03 on a 9,000-char reply: frame time
// p50/p90/p99 17/17/18 ms in both arms, 8 vs 2 frames over 25 ms across 49 s, so a commit per frame is free.
const COMMIT_MS = 16;
export function useSmoothText(
target: string,
@@ -58,6 +77,8 @@ export function useSmoothText(
const lastRef = useRef<number>(0); // last frame timestamp
const committedRef = useRef<number>(committedLen);
const lastCommitAtRef = useRef<number>(0);
const lastArrivalRef = useRef<number>(0);
const gapEmaRef = useRef<number | null>(null);
const rafRef = useRef<number | null>(null);
const tickRef = useRef<((now: number) => void) | null>(null);
@@ -78,7 +99,7 @@ export function useSmoothText(
const dt = dtRaw > MAX_DT_S ? MAX_DT_S : dtRaw;
const backlog = Math.max(0, full - posRef.current);
const desired = backlog / TARGET_LAG_S; // speed that holds the lag steady (0 when caught up)
const desired = backlog / lagForGap(gapEmaRef.current); // speed that holds the lag steady (0 when caught up)
const k = Math.min(1, dt / RATE_SMOOTH_S);
let cps = cpsRef.current + (desired - cpsRef.current) * k; // EMA-smooth the speed itself, both up and down
if (cps > MAX_CPS) cps = MAX_CPS;
@@ -121,6 +142,10 @@ export function useSmoothText(
// persistent-loop comment above warns about); it only restarts one that parked itself at idle.
useEffect(() => {
if (!enabled) return;
// Every growth is an arrival; the interval between them sizes the lag above.
const now = performance.now();
if (lastArrivalRef.current) gapEmaRef.current = nextGapEma(gapEmaRef.current, (now - lastArrivalRef.current) / 1000);
lastArrivalRef.current = now;
if (rafRef.current === null && tickRef.current && posRef.current < target.length) {
lastRef.current = 0;
rafRef.current = requestAnimationFrame(tickRef.current);
@@ -132,6 +157,8 @@ export function useSmoothText(
if (posRef.current > target.length) {
posRef.current = enabled ? 0 : target.length;
cpsRef.current = 0;
lastArrivalRef.current = 0;
gapEmaRef.current = null;
lastRef.current = 0;
committedRef.current = enabled ? 0 : target.length;
setCommittedLen(enabled ? 0 : target.length);