From 0001dfe9afd2d6d034fd887d7f5dc94c5fdca1a5 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Thu, 3 Sep 2026 13:23:28 -0700 Subject: [PATCH] [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 Claude-Session: https://claude.ai/code/session_01C9zwUaHucUgrdxvK8FvjYT --- .../AgentChat/bubbles/smoothTextLag.test.ts | 30 +++++++++++++++ .../bubbles/streamingIntegrity.test.ts | 2 +- .../pages/AgentChat/bubbles/useSmoothText.ts | 37 ++++++++++++++++--- 3 files changed, 63 insertions(+), 6 deletions(-) create mode 100644 frontend/src/app/pages/AgentChat/bubbles/smoothTextLag.test.ts diff --git a/frontend/src/app/pages/AgentChat/bubbles/smoothTextLag.test.ts b/frontend/src/app/pages/AgentChat/bubbles/smoothTextLag.test.ts new file mode 100644 index 00000000..359e3523 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/bubbles/smoothTextLag.test.ts @@ -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]}`); +}); diff --git a/frontend/src/app/pages/AgentChat/bubbles/streamingIntegrity.test.ts b/frontend/src/app/pages/AgentChat/bubbles/streamingIntegrity.test.ts index de4a4318..17d93c1a 100644 --- a/frontend/src/app/pages/AgentChat/bubbles/streamingIntegrity.test.ts +++ b/frontend/src/app/pages/AgentChat/bubbles/streamingIntegrity.test.ts @@ -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`); } }); diff --git a/frontend/src/app/pages/AgentChat/bubbles/useSmoothText.ts b/frontend/src/app/pages/AgentChat/bubbles/useSmoothText.ts index a9d03664..0d2b2408 100644 --- a/frontend/src/app/pages/AgentChat/bubbles/useSmoothText.ts +++ b/frontend/src/app/pages/AgentChat/bubbles/useSmoothText.ts @@ -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(0); // last frame timestamp const committedRef = useRef(committedLen); const lastCommitAtRef = useRef(0); + const lastArrivalRef = useRef(0); + const gapEmaRef = useRef(null); const rafRef = useRef(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);