Skip to content

Commit 2792deb

Browse files
committed
Revert "🤖 perf: word-pace text reveal; remove DOM-level streaming animations (#3221)"
This reverts commit bbc1504.
1 parent bbc1504 commit 2792deb

7 files changed

Lines changed: 31 additions & 231 deletions

File tree

‎bun.lock‎

Lines changed: 0 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎src/browser/features/Messages/MarkdownCore.tsx‎

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -144,10 +144,7 @@ export const MarkdownCore = React.memo<MarkdownCoreProps>(
144144
// Use "static" mode for completed content to bypass useTransition() deferral.
145145
// After ORPC migration, async event boundaries let React deprioritize transitions indefinitely.
146146
mode={parseIncompleteMarkdown ? "streaming" : "static"}
147-
// space-y-2: reduce from default space-y-4 (16px) to space-y-2 (8px).
148-
// Streaming smoothness comes from word-paced reveal in
149-
// SmoothTextEngine; no DOM-level animation here.
150-
className="space-y-2"
147+
className="space-y-2" // Reduce from default space-y-4 (16px) to space-y-2 (8px)
151148
controls={{ table: false, code: true, mermaid: true }} // Disable table copy/download, keep code/mermaid controls
152149
>
153150
{normalizedContent}

‎src/browser/features/Messages/TypewriterMarkdown.tsx‎

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -71,15 +71,6 @@ export const TypewriterMarkdown: React.FC<TypewriterMarkdownProps> = ({
7171
// React Compiler memoizes this object; no manual useMemo needed.
7272
const streamingContextValue = { isStreaming };
7373

74-
// Smoothness comes entirely from the engine's word-paced reveal cadence.
75-
// No DOM-level animation, mask, or shimmer here — earlier iterations
76-
// (per-block fade, per-word fade, per-line wrap, animated mask, shimmer
77-
// overlay) all introduced perceptual artifacts (left-to-right shimmer,
78-
// bottom-edge obfuscation, abrupt fades) that the eye registered as
79-
// jitter regardless of curve/duration tuning. Word-paced reveal at the
80-
// engine level (see SmoothTextEngine) matches how production chat UIs
81-
// (ChatGPT, Claude.ai) feel smooth: humans parse text in word units, so
82-
// the right granularity for reveal is the word, not the character.
8374
return (
8475
<StreamingContext.Provider value={streamingContextValue}>
8576
<div className={cn("markdown-content", className)}>

‎src/browser/styles/globals.css‎

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1542,13 +1542,6 @@ code {
15421542
white-space: normal;
15431543
}
15441544

1545-
/* Streaming smoothness is achieved entirely via word-paced reveal in the
1546-
* SmoothTextEngine (see src/browser/utils/streaming/SmoothTextEngine.ts).
1547-
* No DOM-level fade, mask, or shimmer — earlier iterations introduced
1548-
* perceptual artifacts (bottom-edge obfuscation, abrupt fade transitions,
1549-
* left-to-right per-word shimmer) that the eye registered as jitter
1550-
* regardless of curve / duration tuning. */
1551-
15521545
.markdown-content h1,
15531546
.markdown-content h2,
15541547
.markdown-content h3,

‎src/browser/utils/streaming/SmoothTextEngine.test.ts‎

Lines changed: 9 additions & 114 deletions
Original file line numberDiff line numberDiff line change
@@ -6,22 +6,6 @@ function makeText(length: number): string {
66
return "x".repeat(length);
77
}
88

9-
/**
10-
* Realistic whitespace-bearing text for tests that exercise word-paced reveal
11-
* cadence. Uses fixed 5-char "words" + 1 space = 6 chars per atom — short
12-
* enough to fit comfortably under WORD_PACE_MAX_CHARS=12 so the cap doesn't
13-
* dominate behavior.
14-
*/
15-
function makeWords(length: number): string {
16-
const words: string[] = [];
17-
let total = 0;
18-
while (total < length) {
19-
words.push("abcde");
20-
total += 6; // 5 chars + 1 space
21-
}
22-
return words.join(" ").slice(0, length);
23-
}
24-
259
describe("SmoothTextEngine", () => {
2610
it("reveals text steadily and reaches full length", () => {
2711
const engine = new SmoothTextEngine();
@@ -129,17 +113,17 @@ describe("SmoothTextEngine", () => {
129113

130114
it("does not force reveal when budget is below one char", () => {
131115
const engine = new SmoothTextEngine();
132-
// For a 1-char string with no whitespace, the next reveal atom is the
133-
// entire string (cost=1). With ~74 cps adaptive rate at 4ms per tick:
134-
// ~0.30 budget per tick. The engine waits until floor(charBudget) >= 1
135-
// before revealing — frame-rate invariance means partial budget rolls over.
116+
// With a 1-char backlog, adaptive rate is at floor (~24 cps).
117+
// At 4ms per tick: 24 * 0.004 = 0.096 budget per tick.
118+
// The required-char gate is min(MIN_FRAME_CHARS, backlog) = min(2, 1) = 1
119+
// for this 1-char stream, so it reveals once budget reaches 1.0.
136120
engine.update("x", true, false);
137121

138-
// First tick at 4ms should not reveal (budget ~0.30 < 1).
122+
// First tick at 4ms should not reveal (budget ~0.10).
139123
const afterFirstTick = engine.tick(4);
140124
expect(afterFirstTick).toBe(0);
141125

142-
// Several more small ticks should still not reveal (budget < 1).
126+
// Several more small ticks should still not reveal.
143127
engine.tick(4);
144128
engine.tick(4);
145129
expect(engine.visibleLength).toBe(0);
@@ -153,14 +137,12 @@ describe("SmoothTextEngine", () => {
153137

154138
it("targets the live model rate when provided", () => {
155139
// With a model rate of 200 cps the engine should reveal materially faster
156-
// than at the BASE rate of 72 cps for the same backlog. Uses realistic
157-
// word-bearing text so the rate differential maps onto distinct word
158-
// counts revealed in the same wall-time window.
140+
// than at the BASE rate of 72 cps for the same backlog.
159141
const baseEngine = new SmoothTextEngine();
160142
const modelAwareEngine = new SmoothTextEngine();
161143

162-
baseEngine.update(makeWords(50), true, false);
163-
modelAwareEngine.update(makeWords(50), true, false, 200);
144+
baseEngine.update(makeText(50), true, false);
145+
modelAwareEngine.update(makeText(50), true, false, 200);
164146

165147
for (let i = 0; i < 10; i++) {
166148
baseEngine.tick(16);
@@ -170,93 +152,6 @@ describe("SmoothTextEngine", () => {
170152
expect(modelAwareEngine.visibleLength).toBeGreaterThan(baseEngine.visibleLength);
171153
});
172154

173-
it("reveals at most one atom per tick even with huge budget", () => {
174-
// Time-smoothing: even when budget covers many atoms (catch-up burst,
175-
// very high adaptive rate), reveals must be spread across ticks so the
176-
// user sees one word per animation frame. Multi-atom reveals would
177-
// bypass the temporal cadence and read as bursty.
178-
const engine = new SmoothTextEngine();
179-
// 5-char words + space = 6-char atoms. 100 chars = ~17 atoms.
180-
engine.update(makeWords(100), true, false, 1000); // very high model rate
181-
182-
// Even one tick at the dt clamp ceiling shouldn't reveal more than the
183-
// largest possible atom (WORD_PACE_MAX_CHARS=12).
184-
const before = engine.visibleLength;
185-
engine.tick(33);
186-
const revealed = engine.visibleLength - before;
187-
188-
// ≤ 12 chars (one atom max). With 6-char atoms it's exactly 6.
189-
expect(revealed).toBeLessThanOrEqual(STREAM_SMOOTHING.WORD_PACE_MAX_CHARS);
190-
});
191-
192-
it("clamps dt so a long pause doesn't burst on resume", () => {
193-
// RAF gaps (tab visibility, debugger pauses) can produce multi-second
194-
// dt values. Without clamping, budget = adaptiveRate * dt would balloon
195-
// and feed downstream into multi-atom reveals (or in earlier engine
196-
// designs, a 10s pause would dump the entire backlog in one frame).
197-
const engine = new SmoothTextEngine();
198-
engine.update(makeWords(200), true, false, 200);
199-
200-
const before = engine.visibleLength;
201-
engine.tick(10_000); // 10-second "pause"
202-
const revealed = engine.visibleLength - before;
203-
204-
// Same single-atom cap as a normal tick — the clamp ensures budget
205-
// accumulated from a 10s gap is no larger than from a 33ms gap.
206-
expect(revealed).toBeLessThanOrEqual(STREAM_SMOOTHING.WORD_PACE_MAX_CHARS);
207-
});
208-
209-
it("treats Unicode whitespace as word boundaries", () => {
210-
// Non-English content uses NBSP \u00A0, ideographic space \u3000, etc.
211-
// The boundary scanner must recognize them or the entire stream is treated
212-
// as one no-whitespace run capped at WORD_PACE_MAX_CHARS chunks. Each of
213-
// these strings has a single Unicode whitespace separator at index 5.
214-
const cases = [
215-
"Hello\u00a0world", // NBSP
216-
"Hello\u2003world", // em space
217-
"Hello\u2009world", // thin space
218-
"Hello\u3000world", // ideographic space
219-
"Hello\u2028world", // line separator
220-
];
221-
222-
for (const text of cases) {
223-
const engine = new SmoothTextEngine();
224-
engine.update(text, true, false);
225-
// Tick until "Hello<sep>" is revealed (cost = 6) — boundary scan must
226-
// land at index 6, not at the WORD_PACE_MAX_CHARS cap of 12.
227-
let observed = engine.visibleLength;
228-
for (let i = 0; i < 50 && engine.visibleLength < 6; i++) {
229-
engine.tick(16);
230-
observed = engine.visibleLength;
231-
if (observed >= 6 && observed < text.length) break;
232-
}
233-
expect(observed).toBe(6);
234-
}
235-
});
236-
237-
it("reveals only at whitespace boundaries", () => {
238-
// Word-paced reveal: visibleLength must always land just after a
239-
// whitespace character (or at 0 / fullLength). Prevents mid-word reveals
240-
// that the eye registers as character-by-character chop.
241-
const engine = new SmoothTextEngine();
242-
const text = "Hello world. How are you doing today?";
243-
engine.update(text, true, false);
244-
245-
const seenLengths = new Set<number>([engine.visibleLength]);
246-
for (let i = 0; i < 200 && !engine.isCaughtUp; i++) {
247-
engine.tick(16);
248-
seenLengths.add(engine.visibleLength);
249-
}
250-
251-
expect(engine.isCaughtUp).toBe(true);
252-
for (const len of seenLengths) {
253-
if (len === 0 || len === text.length) continue;
254-
// The character immediately before the reveal cursor must be whitespace.
255-
const charBefore = text[len - 1];
256-
expect(/\s/.test(charBefore)).toBe(true);
257-
}
258-
});
259-
260155
it("soft-catches-up large lag without a hard snap", () => {
261156
const engine = new SmoothTextEngine();
262157

‎src/browser/utils/streaming/SmoothTextEngine.ts‎

Lines changed: 15 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,6 @@
11
import { STREAM_SMOOTHING } from "@/constants/streaming";
22
import { clamp } from "@/common/utils/clamp";
33

4-
/**
5-
* Module-level regex (compiled once, reused across ticks) for whitespace-
6-
* boundary detection in word-paced reveal. `\s` covers all Unicode whitespace
7-
* (per ECMA-262): ASCII space/tab/LF/CR/FF/VT, NBSP, line/paragraph
8-
* separators, thin/em/ideographic spaces, etc. — so non-English text paces
9-
* at proper word boundaries.
10-
*/
11-
const WHITESPACE_REGEX = /\s/;
12-
134
/**
145
* Compute target reveal rate (chars/sec) given current backlog and a hint of how
156
* fast the source is producing characters.
@@ -52,32 +43,13 @@ function getAdaptiveRate(backlog: number, liveCharsPerSec: number): number {
5243
* The ingestion clock (incoming full text) is external; this class manages only
5344
* the presentation clock (visible prefix length) using a character budget model.
5445
*
55-
* **Reveal granularity is word-sized AND temporally paced.** Each tick reveals
56-
* AT MOST ONE atom (a word + trailing whitespace, capped at
57-
* {@link STREAM_SMOOTHING.WORD_PACE_MAX_CHARS}). Multi-atom bursts are
58-
* impossible by construction — even when budget is large (catch-up after a
59-
* long RAF gap, high adaptive rate during burst), reveals are spread across
60-
* frames so the user sees one word per animation frame at the maximum tempo.
61-
* Combined with the dt clamp ({@link STREAM_SMOOTHING.MAX_TICK_MS}), this
62-
* caps cadence at ~60 words/sec on a 60Hz display.
63-
*
64-
* Why word-sized AND time-paced:
65-
* - Word-sized: humans parse text in word units. Character-paced reveal
66-
* triggers an extra decoding step the eye registers as choppy.
67-
* - Time-paced: even at word granularity, dumping 3 atoms in one frame
68-
* reads as bursty. One atom per frame is the smoothest possible cadence
69-
* the display can express.
70-
* - Production chat UIs (ChatGPT, Claude.ai) feel smooth precisely because
71-
* they emit at word boundaries at a steady tempo.
72-
*
7346
* The engine is model-aware: callers should pass {@link update}'s
7447
* `liveCharsPerSec` if they know the source's emission rate. Without it the
7548
* engine targets {@link STREAM_SMOOTHING.BASE_CHARS_PER_SEC}, which can lag
7649
* behind fast models and make the user wait through a backlog drain after the
7750
* stream ends.
7851
*/
7952
export class SmoothTextEngine {
80-
private fullText = "";
8153
private fullLength = 0;
8254
private visibleLengthValue = 0;
8355
private charBudget = 0;
@@ -114,10 +86,6 @@ export class SmoothTextEngine {
11486
bypassSmoothing: boolean,
11587
liveCharsPerSec = 0
11688
): void {
117-
// Retain the full text so tick() can locate whitespace boundaries for
118-
// word-paced reveal. The hook (useSmoothStreamingText) already holds it,
119-
// so the extra reference is "free" — JS strings are immutable and shared.
120-
this.fullText = fullText;
12189
this.fullLength = fullText.length;
12290
this.isStreaming = isStreaming;
12391
this.bypassSmoothing = bypassSmoothing;
@@ -137,30 +105,6 @@ export class SmoothTextEngine {
137105
this.enforceMaxVisualLag();
138106
}
139107

140-
/**
141-
* Find the position to advance visibleLength to from `from`. Returns the
142-
* index AFTER the next whitespace character so the whitespace is included
143-
* in the reveal (the next word stays hidden until its own boundary is
144-
* reached). Returns `min(from + WORD_PACE_MAX_CHARS, fullLength)` if no
145-
* whitespace is found within that span — guarantees long URLs / identifiers
146-
* still progress in bounded chunks.
147-
*
148-
* Uses `\s` (matches all Unicode whitespace: ASCII space/tab/newline/CR/FF,
149-
* NBSP \u00A0, line/paragraph separators \u2028/\u2029, thin space \u2009,
150-
* em space \u2003, ideographic space \u3000, etc.) so non-English content
151-
* paces at proper word boundaries. CJK text without internal whitespace
152-
* still falls back to the WORD_PACE_MAX_CHARS chunk cap.
153-
*/
154-
private findNextRevealBoundary(from: number): number {
155-
const cap = Math.min(this.fullLength, from + STREAM_SMOOTHING.WORD_PACE_MAX_CHARS);
156-
for (let i = from; i < cap; i++) {
157-
if (WHITESPACE_REGEX.test(this.fullText[i] ?? "")) {
158-
return i + 1;
159-
}
160-
}
161-
return cap;
162-
}
163-
164108
/**
165109
* Advance the presentation clock by a timestep.
166110
*/
@@ -185,28 +129,23 @@ export class SmoothTextEngine {
185129
const backlog = this.fullLength - this.visibleLengthValue;
186130
const adaptiveRate = getAdaptiveRate(backlog, this.liveCharsPerSec);
187131

188-
// Clamp dt to MAX_TICK_MS. A long RAF gap (tab visibility, slow frames,
189-
// debugger pauses) would otherwise dump huge budget that bursts on resume,
190-
// bypassing the per-tick atom cap. Backlog drains via subsequent ticks,
191-
// which arrive at frame rate once RAF resumes; the hard-snap safety net
192-
// (enforceMaxVisualLag) handles pathological cases beyond MAX_VISUAL_LAG_CHARS.
193-
const clampedDt = Math.min(dtMs, STREAM_SMOOTHING.MAX_TICK_MS);
194-
this.charBudget += adaptiveRate * (clampedDt / 1000);
195-
196-
// Single-atom reveal per tick. Even when budget covers multiple atoms
197-
// (catch-up burst, high adaptive rate), defer to subsequent ticks so the
198-
// user sees one word per animation frame. This is the smoothest possible
199-
// temporal cadence the display can express; multi-atom-per-tick reveals
200-
// would read as bursty even at word granularity.
201-
const nextBoundary = this.findNextRevealBoundary(this.visibleLengthValue);
202-
const cost = nextBoundary - this.visibleLengthValue;
203-
// Math.floor guarantees monotone progress across tick rates — partial
204-
// budget rolls over so a 240Hz display accumulates across several frames.
205-
if (cost > 0 && Math.floor(this.charBudget) >= cost) {
206-
this.visibleLengthValue = nextBoundary;
207-
this.charBudget -= cost;
132+
this.charBudget += adaptiveRate * (dtMs / 1000);
133+
134+
// Budget-gated reveal: require at least MIN_FRAME_CHARS to accrue. This
135+
// makes cadence frame-rate invariant — a 240Hz display accumulates budget
136+
// across several frames before revealing, instead of forcing 1 char/frame
137+
// at any refresh rate. At the tail of a stream the requirement is capped
138+
// by backlog so we always finish revealing the last 1 char.
139+
const wholeCharsReady = Math.floor(this.charBudget);
140+
const requiredChars = Math.min(STREAM_SMOOTHING.MIN_FRAME_CHARS, backlog);
141+
if (wholeCharsReady < requiredChars) {
142+
return this.visibleLengthValue;
208143
}
209144

145+
const reveal = Math.min(wholeCharsReady, STREAM_SMOOTHING.MAX_FRAME_CHARS);
146+
this.visibleLengthValue = Math.min(this.fullLength, this.visibleLengthValue + reveal);
147+
this.charBudget -= reveal;
148+
210149
return this.visibleLengthValue;
211150
}
212151

@@ -222,7 +161,6 @@ export class SmoothTextEngine {
222161
* Reset all engine state, typically when a new stream starts.
223162
*/
224163
reset(): void {
225-
this.fullText = "";
226164
this.fullLength = 0;
227165
this.visibleLengthValue = 0;
228166
this.charBudget = 0;

‎src/constants/streaming.ts‎

Lines changed: 6 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -37,25 +37,12 @@ export const STREAM_SMOOTHING = {
3737
* below this.
3838
*/
3939
MAX_VISUAL_LAG_CHARS: 1024,
40+
/** Max characters revealed in a single animation frame. */
41+
MAX_FRAME_CHARS: 48,
4042
/**
41-
* Maximum characters in a single reveal "atom" when no whitespace boundary
42-
* is found. The engine paces text in word-sized atoms (a run of non-whitespace
43-
* plus its trailing whitespace); for a long no-whitespace run (e.g., a URL or
44-
* minified identifier) we cap the atom at this length so the engine doesn't
45-
* stall waiting for budget to cover an unbounded chunk. ~12 covers nearly all
46-
* English words ("consideration" = 13, "JavaScript" = 10) without dumping
47-
* long URLs in a single 200-char shot.
43+
* Min characters revealed per tick once budget permits. Set to 2 so reveals
44+
* coalesce to ~30 Hz at the base rate instead of ~60 Hz — equal visual
45+
* smoothness to humans, half the markdown-reparse cost.
4846
*/
49-
WORD_PACE_MAX_CHARS: 12,
50-
/**
51-
* Upper bound on the dt fed to the engine in a single tick. Long RAF gaps
52-
* (tab visibility, slow frames, debugger pauses) would otherwise accumulate
53-
* huge budget that bursts on resume — bypassing the per-tick atom cap below.
54-
* Clamping dt to ~2 frames at 60Hz means at the worst case (max model rate
55-
* 420 cps × 33ms = 13.86 chars per tick), budget can cover at most one
56-
* WORD_PACE_MAX_CHARS-sized atom per tick. Combined with the 1-atom-per-tick
57-
* reveal cap, this enforces a strict temporal cadence: each visual reveal
58-
* is its own animation frame.
59-
*/
60-
MAX_TICK_MS: 33,
47+
MIN_FRAME_CHARS: 2,
6148
} as const;

0 commit comments

Comments
 (0)