dsh-smooth-stream 0.3.2 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Progressive text reveal for opaque Agent renderers.
3
+ *
4
+ * Slot renderers own arbitrary React trees, so the generic integration cannot
5
+ * clone or classify their business components. This hook leaves that tree and
6
+ * all of its event handlers in place, and only paces visible Text node data
7
+ * while the row belongs to the live Agent turn. No clip, mask, overlay, or
8
+ * duplicate accessibility tree is introduced.
9
+ */
10
+
11
+ import { useLayoutEffect, type RefObject } from 'react'
12
+ import { computeAdaptiveQueueStep } from './useSmoothStreamContent.ts'
13
+
14
+ interface TextRevealRecord {
15
+ chars: readonly string[]
16
+ full: string
17
+ shown: number
18
+ }
19
+
20
+ const SKIP_TEXT_SELECTOR = [
21
+ '[aria-hidden="true"]',
22
+ '[aria-live]',
23
+ '[contenteditable="true"]',
24
+ 'script',
25
+ 'style',
26
+ 'textarea',
27
+ ].join(',')
28
+
29
+ function revealable(node: Text, root: HTMLElement): boolean {
30
+ if (node.data.trim() === '') return false
31
+ const parent = node.parentElement
32
+ return parent !== null
33
+ && root.contains(parent)
34
+ && parent.closest(SKIP_TEXT_SELECTOR) === null
35
+ }
36
+
37
+ function commonPrefix(left: readonly string[], right: readonly string[]): number {
38
+ const limit = Math.min(left.length, right.length)
39
+ let index = 0
40
+ while (index < limit && left[index] === right[index]) index += 1
41
+ return index
42
+ }
43
+
44
+ /**
45
+ * Pace text inside a renderer whose React component is intentionally opaque.
46
+ * Initial content is revealed only for a genuinely new row; later mutations
47
+ * stay paced until `enabled` becomes false, at which point the full renderer
48
+ * content is restored synchronously before paint.
49
+ */
50
+ export function useProgressiveDomText(
51
+ rootRef: RefObject<HTMLElement | null>,
52
+ enabled: boolean,
53
+ revealInitial: boolean,
54
+ speedCpsRef: { current: number },
55
+ onSettled?: (() => void) | undefined,
56
+ ): void {
57
+ useLayoutEffect(() => {
58
+ if (!enabled) return
59
+ const root = rootRef.current
60
+ if (root === null || typeof document === 'undefined') return
61
+
62
+ const records = new Map<Text, TextRevealRecord>()
63
+ const pending = new Set<Text>()
64
+ const internalWrites = new WeakMap<Text, string>()
65
+ let rafId = 0
66
+ let lastFrame: number | null = null
67
+ let debt = 0
68
+ let stopped = false
69
+ let announcedSettled = false
70
+
71
+ const announceSettled = (): void => {
72
+ lastFrame = null
73
+ debt = 0
74
+ speedCpsRef.current = 35
75
+ if (announcedSettled) return
76
+ announcedSettled = true
77
+ onSettled?.()
78
+ }
79
+
80
+ const write = (node: Text, value: string): void => {
81
+ if (node.data === value) return
82
+ internalWrites.set(node, value)
83
+ node.data = value
84
+ }
85
+
86
+ const enqueue = (node: Text, full: string, preserve: TextRevealRecord | undefined): void => {
87
+ if (!revealable(node, root)) return
88
+ const chars = [...full]
89
+ const preserved = preserve === undefined
90
+ ? 0
91
+ : Math.min(preserve.shown, commonPrefix(preserve.chars, chars))
92
+ const record = { chars, full, shown: preserved }
93
+ records.set(node, record)
94
+ if (preserved < chars.length) {
95
+ pending.add(node)
96
+ announcedSettled = false
97
+ } else {
98
+ pending.delete(node)
99
+ }
100
+ write(node, chars.slice(0, preserved).join(''))
101
+ }
102
+
103
+ const visit = (from: Node, reveal: boolean): void => {
104
+ if (from.nodeType === Node.TEXT_NODE) {
105
+ const text = from as Text
106
+ if (reveal) enqueue(text, text.data, records.get(text))
107
+ return
108
+ }
109
+ const walker = document.createTreeWalker(from, NodeFilter.SHOW_TEXT)
110
+ let current = walker.nextNode()
111
+ while (current !== null) {
112
+ const text = current as Text
113
+ if (reveal) enqueue(text, text.data, records.get(text))
114
+ current = walker.nextNode()
115
+ }
116
+ }
117
+
118
+ const scheduleFrame = (): void => {
119
+ if (stopped || pending.size === 0 || rafId !== 0) return
120
+ rafId = requestAnimationFrame(frame)
121
+ }
122
+
123
+ const frame = (now: number): void => {
124
+ rafId = 0
125
+ if (stopped) return
126
+ if (pending.size === 0) {
127
+ announceSettled()
128
+ return
129
+ }
130
+ announcedSettled = false
131
+ if (lastFrame === null) {
132
+ lastFrame = now
133
+ scheduleFrame()
134
+ return
135
+ }
136
+ const elapsed = Math.max(0, now - lastFrame)
137
+ lastFrame = now
138
+ let backlog = 0
139
+ for (const node of pending) {
140
+ const record = records.get(node)
141
+ if (record !== undefined) backlog += record.chars.length - record.shown
142
+ }
143
+ const step = computeAdaptiveQueueStep(backlog, elapsed, debt)
144
+ debt = step.debt
145
+ speedCpsRef.current = step.speedCps
146
+ let remaining = step.revealChars
147
+ for (const node of [...pending]) {
148
+ if (remaining <= 0) break
149
+ const record = records.get(node)
150
+ if (record === undefined || !node.isConnected) {
151
+ pending.delete(node)
152
+ continue
153
+ }
154
+ const amount = Math.min(remaining, record.chars.length - record.shown)
155
+ const shown = record.shown + amount
156
+ const next = { ...record, shown }
157
+ records.set(node, next)
158
+ write(node, next.chars.slice(0, shown).join(''))
159
+ remaining -= amount
160
+ if (shown >= next.chars.length) pending.delete(node)
161
+ }
162
+ if (pending.size === 0) announceSettled()
163
+ else scheduleFrame()
164
+ }
165
+
166
+ if (revealInitial) visit(root, true)
167
+
168
+ const observer = typeof MutationObserver === 'undefined'
169
+ ? null
170
+ : new MutationObserver((mutations) => {
171
+ for (const mutation of mutations) {
172
+ if (mutation.type === 'characterData') {
173
+ const node = mutation.target as Text
174
+ const expected = internalWrites.get(node)
175
+ if (expected === node.data) {
176
+ internalWrites.delete(node)
177
+ continue
178
+ }
179
+ enqueue(node, node.data, records.get(node))
180
+ continue
181
+ }
182
+ for (const added of mutation.addedNodes) visit(added, true)
183
+ }
184
+ if (pending.size === 0) announceSettled()
185
+ else scheduleFrame()
186
+ })
187
+ observer?.observe(root, { childList: true, characterData: true, subtree: true })
188
+ if (pending.size === 0) announceSettled()
189
+ else scheduleFrame()
190
+
191
+ return () => {
192
+ stopped = true
193
+ cancelAnimationFrame(rafId)
194
+ observer?.disconnect()
195
+ for (const [node, record] of records) {
196
+ const controlled = record.chars.slice(0, record.shown).join('')
197
+ // React may have committed a terminal replacement in the same mutation
198
+ // phase that disables this hook. Never overwrite that newer renderer
199
+ // value with the previous controlled source during layout cleanup.
200
+ if (node.isConnected && node.data === controlled) write(node, record.full)
201
+ }
202
+ pending.clear()
203
+ speedCpsRef.current = 35
204
+ }
205
+ }, [enabled, onSettled, revealInitial, rootRef, speedCpsRef])
206
+ }
@@ -13,7 +13,7 @@
13
13
  * competes with visible frames when the frame rate is degraded.
14
14
  */
15
15
 
16
- import { useCallback, useEffect, useRef, useState } from 'react'
16
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
17
17
 
18
18
  export type StreamSmoothingPreset = 'realtime' | 'balanced' | 'silky'
19
19
 
@@ -26,7 +26,6 @@ export interface StreamSmoothingPresetConfig {
26
26
  readonly maxActiveCps: number
27
27
  readonly maxCps: number
28
28
  readonly maxFlushCps: number
29
- readonly minCommitIntervalMs: number
30
29
  readonly minCps: number
31
30
  readonly settleAfterMs: number
32
31
  readonly settleDrainMaxMs: number
@@ -44,7 +43,6 @@ export const PRESET_CONFIG: Record<StreamSmoothingPreset, StreamSmoothingPresetC
44
43
  maxActiveCps: 360,
45
44
  maxCps: 240,
46
45
  maxFlushCps: 480,
47
- minCommitIntervalMs: 16,
48
46
  minCps: 24,
49
47
  settleAfterMs: 280,
50
48
  settleDrainMaxMs: 420,
@@ -60,7 +58,6 @@ export const PRESET_CONFIG: Record<StreamSmoothingPreset, StreamSmoothingPresetC
60
58
  maxActiveCps: 480,
61
59
  maxCps: 320,
62
60
  maxFlushCps: 640,
63
- minCommitIntervalMs: 16,
64
61
  minCps: 32,
65
62
  settleAfterMs: 200,
66
63
  settleDrainMaxMs: 280,
@@ -76,7 +73,6 @@ export const PRESET_CONFIG: Record<StreamSmoothingPreset, StreamSmoothingPresetC
76
73
  maxActiveCps: 280,
77
74
  maxCps: 180,
78
75
  maxFlushCps: 400,
79
- minCommitIntervalMs: 16,
80
76
  minCps: 20,
81
77
  settleAfterMs: 360,
82
78
  settleDrainMaxMs: 520,
@@ -85,10 +81,11 @@ export const PRESET_CONFIG: Record<StreamSmoothingPreset, StreamSmoothingPresetC
85
81
  },
86
82
  }
87
83
 
88
- const MAX_COMMIT_INTERVAL_MS = 16
89
- /** Demo queue divisor: each frame reveals about `backlog / 8` characters. */
90
- export const QUEUE_REVEAL_DIVISOR = 8
91
- const QUEUE_FRAME_MS = 16.67
84
+ /** Pressure curve from the reference stream renderer. */
85
+ export const QUEUE_BASE_SPEED_CPS = 90
86
+ export const QUEUE_ACCEL_EXPONENT = 1.25
87
+ export const QUEUE_PRESSURE_FACTOR = 0.85
88
+ export const QUEUE_MAX_SPEED_CPS = 600
92
89
  /** Hard cap on how far display may trail a live stream, in characters. */
93
90
  export const LIVE_LAG_CHAR_CEILING = 32
94
91
  const CATCHUP_SECONDS = 0.15
@@ -98,16 +95,38 @@ export const clamp = (value: number, min: number, max: number): number => {
98
95
  }
99
96
 
100
97
  /**
101
- * Demo per-frame reveal: drain `backlog / 8` characters, at least 1, scaled
102
- * by frame time. A small queue types one glyph per frame; a burst raises the
103
- * step so the display does not fall behind.
98
+ * Reference pressure-buffer reveal for a frame that starts without debt.
104
99
  * @param backlog - Unrevealed characters.
105
100
  * @param dtMs - Frame delta in ms.
106
101
  * @returns Characters to reveal this frame.
107
102
  */
108
103
  export function computeQueueReveal(backlog: number, dtMs: number): number {
109
104
  if (backlog <= 0 || dtMs <= 0) return 0
110
- return Math.min(backlog, Math.max(1, Math.ceil((backlog / QUEUE_REVEAL_DIVISOR) * (dtMs / QUEUE_FRAME_MS))))
105
+ return computeAdaptiveQueueStep(backlog, dtMs, 0).revealChars
106
+ }
107
+
108
+ export interface AdaptiveQueueStep {
109
+ readonly revealChars: number
110
+ readonly debt: number
111
+ readonly speedCps: number
112
+ }
113
+
114
+ /** Float-debt queue integration from `ultimate_stream_physics_scroller.html`. */
115
+ export function computeAdaptiveQueueStep(
116
+ backlog: number,
117
+ dtMs: number,
118
+ debt: number,
119
+ revealScale = 1,
120
+ ): AdaptiveQueueStep {
121
+ if (backlog <= 0 || dtMs <= 0) return { revealChars: 0, debt: 0, speedCps: 0 }
122
+ const speedCps = Math.min(
123
+ QUEUE_MAX_SPEED_CPS,
124
+ QUEUE_BASE_SPEED_CPS + Math.pow(backlog, QUEUE_ACCEL_EXPONENT) * QUEUE_PRESSURE_FACTOR,
125
+ )
126
+ const effectiveScale = clamp(revealScale, 0.05, 1)
127
+ const accumulated = Math.max(0, debt) + speedCps * effectiveScale * (dtMs / 1000)
128
+ const revealChars = Math.min(backlog, Math.floor(accumulated))
129
+ return { revealChars, debt: revealChars >= backlog ? 0 : accumulated - revealChars, speedCps }
111
130
  }
112
131
 
113
132
  /** Counts user-perceived characters (code points), not UTF-16 units. */
@@ -148,6 +167,20 @@ export function computeSettleDrain(config: StreamSmoothingPresetConfig, input: S
148
167
  return clamp(Math.max(settleCps, overflowCps), config.flushCps, config.maxFlushCps)
149
168
  }
150
169
 
170
+ /** Fixed velocity that closes a producer-complete queue within its deadline. */
171
+ export function computeCompletionDrain(
172
+ config: StreamSmoothingPresetConfig,
173
+ backlog: number,
174
+ ): number {
175
+ if (backlog <= 0) return 0
176
+ const drainTargetMs = clamp(backlog * 8, config.settleDrainMinMs, config.settleDrainMaxMs)
177
+ const deadlineCps = (backlog * 1000) / drainTargetMs
178
+ return Math.max(
179
+ deadlineCps,
180
+ computeSettleDrain(config, { backlog, inputActive: false, settling: true }),
181
+ )
182
+ }
183
+
151
184
  /**
152
185
  * Drain rate multiplier once the input ends: leftover backlog reveals at
153
186
  * this multiple of the steady rate, so the end never drags.
@@ -206,14 +239,16 @@ export function computeRevealStep(config: StreamSmoothingPresetConfig, input: Re
206
239
 
207
240
  export interface UseSmoothStreamContentOptions {
208
241
  enabled?: boolean
242
+ /** The producer has completed; drain queued text without live backpressure. */
243
+ inputComplete?: boolean
209
244
  preset?: StreamSmoothingPreset
210
245
  /** Performance guard veto: while true, reveal commits are held back. */
211
246
  shouldHoldBack?: (() => boolean) | undefined
212
247
  /**
213
248
  * Fixed reveal rate in chars/s. When set, the reveal runs at this steady
214
249
  * pace while the input streams (instead of tracking the arrival rate) and
215
- * drains the leftover backlog at {@link SETTLE_DRAIN_MULTIPLIER} once the
216
- * input ends.
250
+ * drains an inferred idle backlog at {@link SETTLE_DRAIN_MULTIPLIER}.
251
+ * Explicit producer completion still uses the bounded completion drain.
217
252
  */
218
253
  steadyCps?: number | undefined
219
254
  /**
@@ -223,6 +258,8 @@ export interface UseSmoothStreamContentOptions {
223
258
  defaultCps?: number | undefined
224
259
  /** Written each commit with the live arrival-rate EMA for the follow lerp. */
225
260
  speedCpsRef?: { current: number } | undefined
261
+ /** Live multiplier from the follow spring when safe visual lag is filling. */
262
+ revealScaleRef?: { current: number } | undefined
226
263
  }
227
264
 
228
265
  /**
@@ -234,42 +271,53 @@ export interface UseSmoothStreamContentOptions {
234
271
  */
235
272
  export function useSmoothStreamContent(
236
273
  content: string,
237
- { enabled = true, preset = 'balanced', shouldHoldBack, steadyCps, defaultCps, speedCpsRef }: UseSmoothStreamContentOptions = {},
274
+ {
275
+ enabled = true,
276
+ inputComplete = false,
277
+ preset = 'balanced',
278
+ shouldHoldBack,
279
+ steadyCps,
280
+ defaultCps,
281
+ speedCpsRef,
282
+ revealScaleRef,
283
+ }: UseSmoothStreamContentOptions = {},
238
284
  ): string {
239
285
  const config = PRESET_CONFIG[preset]
240
286
  const seedCps = defaultCps ?? config.defaultCps
241
- const [displayedContent, setDisplayedContent] = useState(content)
242
-
243
- const displayedContentRef = useRef(content)
244
- const displayedCountRef = useRef(countChars(content))
245
- const targetContentRef = useRef(content)
246
- const targetCharsRef = useRef([...content])
247
- const targetCountRef = useRef(countChars(content))
287
+ // A fast provider can fill the first Host render with a large batch. Start
288
+ // an enabled stream behind that batch so it still enters the reveal queue;
289
+ // disabled (settled/history) content must remain immediate.
290
+ const initialContent = enabled ? '' : content
291
+ const [displayedContent, setDisplayedContent] = useState(initialContent)
292
+
293
+ const displayedContentRef = useRef(initialContent)
294
+ const displayedCountRef = useRef(countChars(initialContent))
295
+ const targetContentRef = useRef(initialContent)
296
+ const targetCharsRef = useRef([...initialContent])
297
+ const targetCountRef = useRef(countChars(initialContent))
248
298
 
249
299
  const emaCpsRef = useRef(seedCps)
250
300
  const lastInputTsRef = useRef(0)
251
- const lastInputCountRef = useRef(countChars(content))
301
+ const lastInputCountRef = useRef(countChars(initialContent))
252
302
  const chunkSizeEmaRef = useRef(1)
253
303
  const arrivalCpsEmaRef = useRef(seedCps)
254
304
 
255
305
  const rafRef = useRef<number | null>(null)
256
306
  const lastFrameTsRef = useRef<number | null>(null)
257
- const wakeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
307
+ const queueDebtRef = useRef(0)
308
+ const settleCpsRef = useRef<number | null>(null)
258
309
  const holdBackRef = useRef(shouldHoldBack)
259
310
  const speedOutRef = useRef(speedCpsRef)
260
311
  speedOutRef.current = speedCpsRef
312
+ const revealScaleOutRef = useRef(revealScaleRef)
313
+ revealScaleOutRef.current = revealScaleRef
314
+ const inputCompleteRef = useRef(inputComplete)
315
+ inputCompleteRef.current = inputComplete
261
316
 
262
317
  useEffect(() => {
263
318
  holdBackRef.current = shouldHoldBack
264
319
  }, [shouldHoldBack])
265
320
 
266
- const clearWakeTimer = useCallback(() => {
267
- if (wakeTimerRef.current !== null) {
268
- clearTimeout(wakeTimerRef.current)
269
- wakeTimerRef.current = null
270
- }
271
- }, [])
272
-
273
321
  const stopFrameLoop = useCallback(() => {
274
322
  if (rafRef.current !== null) {
275
323
  cancelAnimationFrame(rafRef.current)
@@ -278,30 +326,11 @@ export function useSmoothStreamContent(
278
326
  lastFrameTsRef.current = null
279
327
  }, [])
280
328
 
281
- const stopScheduling = useCallback(() => {
282
- stopFrameLoop()
283
- clearWakeTimer()
284
- }, [clearWakeTimer, stopFrameLoop])
285
-
286
329
  const startFrameLoopRef = useRef<() => void>(() => {})
287
330
 
288
- const scheduleFrameWake = useCallback(
289
- (delayMs: number) => {
290
- clearWakeTimer()
291
- wakeTimerRef.current = setTimeout(
292
- () => {
293
- wakeTimerRef.current = null
294
- startFrameLoopRef.current()
295
- },
296
- Math.max(1, Math.ceil(delayMs)),
297
- )
298
- },
299
- [clearWakeTimer],
300
- )
301
-
302
331
  const syncImmediate = useCallback(
303
332
  (nextContent: string) => {
304
- stopScheduling()
333
+ stopFrameLoop()
305
334
  const chars = [...nextContent]
306
335
  const now = performance.now()
307
336
  targetContentRef.current = nextContent
@@ -309,6 +338,10 @@ export function useSmoothStreamContent(
309
338
  targetCountRef.current = chars.length
310
339
  displayedContentRef.current = nextContent
311
340
  displayedCountRef.current = chars.length
341
+ queueDebtRef.current = 0
342
+ settleCpsRef.current = null
343
+ const speedOut = speedOutRef.current
344
+ if (speedOut !== undefined) speedOut.current = seedCps
312
345
  setDisplayedContent(nextContent)
313
346
  emaCpsRef.current = seedCps
314
347
  chunkSizeEmaRef.current = 1
@@ -316,46 +349,66 @@ export function useSmoothStreamContent(
316
349
  lastInputTsRef.current = now
317
350
  lastInputCountRef.current = chars.length
318
351
  },
319
- [seedCps, stopScheduling],
352
+ [seedCps, stopFrameLoop],
320
353
  )
321
354
 
355
+ // Producer completion is authoritative. Publish the accumulated source in
356
+ // the same commit instead of leaving a visible typewriter tail after the
357
+ // Agent has already stopped; the conversation follower still glides any
358
+ // resulting layout height before paint.
359
+ useLayoutEffect(() => {
360
+ if (!enabled || !inputComplete || displayedContentRef.current === content) return
361
+ syncImmediate(content)
362
+ }, [content, enabled, inputComplete, syncImmediate])
363
+
322
364
  const startFrameLoop = useCallback(() => {
323
- clearWakeTimer()
324
365
  if (rafRef.current !== null) return
325
366
 
326
- const tick = () => {
367
+ const tick = (now: number) => {
327
368
  const targetCount = targetCountRef.current
328
369
  const displayedCount = displayedCountRef.current
329
370
  const backlog = targetCount - displayedCount
330
371
 
331
372
  if (backlog <= 0) {
373
+ queueDebtRef.current = 0
374
+ settleCpsRef.current = null
375
+ const speedOut = speedOutRef.current
376
+ if (speedOut !== undefined) speedOut.current = seedCps
332
377
  stopFrameLoop()
333
378
  return
334
379
  }
335
380
 
336
- const now = performance.now()
337
381
  if (lastFrameTsRef.current === null) {
338
382
  lastFrameTsRef.current = now
339
383
  rafRef.current = requestAnimationFrame(tick)
340
384
  return
341
385
  }
342
386
 
343
- const commitIntervalMs = Math.min(MAX_COMMIT_INTERVAL_MS, config.minCommitIntervalMs)
344
387
  const frameIntervalMs = Math.max(0, now - lastFrameTsRef.current)
345
- if (frameIntervalMs < commitIntervalMs) {
346
- rafRef.current = requestAnimationFrame(tick)
347
- return
348
- }
349
-
350
388
  const dtSeconds = Math.max(0.001, Math.min(frameIntervalMs / 1000, 0.12))
351
389
  lastFrameTsRef.current = now
352
390
 
353
391
  const idleMs = now - lastInputTsRef.current
354
- const inputActive = idleMs <= config.activeInputWindowMs
355
- const settling = !inputActive && idleMs >= config.settleAfterMs
392
+ const producerComplete = inputCompleteRef.current
393
+ const inputActive = !producerComplete && idleMs <= config.activeInputWindowMs
394
+ const settling = producerComplete || (!inputActive && idleMs >= config.settleAfterMs)
395
+ if (!producerComplete) settleCpsRef.current = null
356
396
 
357
397
  let revealChars: number
358
- if (steadyCps !== undefined) {
398
+ let revealSpeedCps: number
399
+ let nextQueueDebt = 0
400
+ if (producerComplete) {
401
+ // Backpressure protects live layout. Once input has ended, retaining
402
+ // that scale only makes a completed response keep typing onscreen.
403
+ // Keep one drain velocity for the whole completion tail. Recomputing
404
+ // it from the shrinking backlog creates an exponential slow tail.
405
+ const settleCps = settleCpsRef.current ?? computeCompletionDrain(config, backlog)
406
+ settleCpsRef.current = settleCps
407
+ const accumulated = Math.max(0, queueDebtRef.current) + settleCps * dtSeconds
408
+ revealChars = Math.min(backlog, Math.floor(accumulated))
409
+ revealSpeedCps = settleCps
410
+ nextQueueDebt = revealChars >= backlog ? 0 : accumulated - revealChars
411
+ } else if (steadyCps !== undefined) {
359
412
  const step = computeRevealStep(
360
413
  config,
361
414
  {
@@ -370,9 +423,17 @@ export function useSmoothStreamContent(
370
423
  dtSeconds,
371
424
  )
372
425
  revealChars = Math.min(step.revealChars, backlog)
426
+ revealSpeedCps = frameIntervalMs > 0 ? (revealChars * 1000) / frameIntervalMs : 0
373
427
  } else {
374
- // Same rAF queue drain as the silky markdown demo.
375
- revealChars = computeQueueReveal(backlog, frameIntervalMs)
428
+ const step = computeAdaptiveQueueStep(
429
+ backlog,
430
+ frameIntervalMs,
431
+ queueDebtRef.current,
432
+ revealScaleOutRef.current?.current ?? 1,
433
+ )
434
+ revealChars = step.revealChars
435
+ revealSpeedCps = step.speedCps
436
+ nextQueueDebt = step.debt
376
437
  }
377
438
 
378
439
  // Performance guard: while degraded and the reply is offscreen, skip
@@ -383,10 +444,14 @@ export function useSmoothStreamContent(
383
444
  return
384
445
  }
385
446
 
447
+ queueDebtRef.current = nextQueueDebt
448
+
386
449
  const speedOut = speedOutRef.current
387
- if (speedOut !== undefined && frameIntervalMs > 0) {
388
- const instantCps = (revealChars * 1000) / frameIntervalMs
389
- speedOut.current = speedOut.current * 0.92 + instantCps * 0.08
450
+ if (speedOut !== undefined) speedOut.current = revealSpeedCps
451
+
452
+ if (revealChars <= 0) {
453
+ rafRef.current = requestAnimationFrame(tick)
454
+ return
390
455
  }
391
456
 
392
457
  const nextCount = displayedCount + revealChars
@@ -406,7 +471,7 @@ export function useSmoothStreamContent(
406
471
  }
407
472
 
408
473
  rafRef.current = requestAnimationFrame(tick)
409
- }, [config, scheduleFrameWake, stopFrameLoop, clearWakeTimer, steadyCps])
474
+ }, [config, stopFrameLoop, steadyCps])
410
475
 
411
476
  useEffect(() => {
412
477
  startFrameLoopRef.current = startFrameLoop
@@ -436,6 +501,7 @@ export function useSmoothStreamContent(
436
501
  targetContentRef.current = content
437
502
  targetCharsRef.current.push(...appendedChars)
438
503
  targetCountRef.current += appendedCount
504
+ settleCpsRef.current = null
439
505
 
440
506
  const hadSample = lastInputTsRef.current > 0
441
507
  const deltaChars = targetCountRef.current - lastInputCountRef.current
@@ -460,9 +526,9 @@ export function useSmoothStreamContent(
460
526
 
461
527
  useEffect(() => {
462
528
  return () => {
463
- stopScheduling()
529
+ stopFrameLoop()
464
530
  }
465
- }, [stopScheduling])
531
+ }, [stopFrameLoop])
466
532
 
467
533
  return displayedContent
468
534
  }
package/src/config.ts CHANGED
@@ -7,12 +7,12 @@
7
7
 
8
8
  import type { StreamSmoothingPreset } from './client/useSmoothStreamContent.ts'
9
9
 
10
- /** Streaming render direction of the assistant node view. */
10
+ /** Legacy render-mode value retained so existing overlays remain valid. */
11
11
  export type StreamMode = 'typewriter' | 'teleprompter'
12
12
 
13
13
  /** Plugin configuration validated by the Host schema and bridged to the browser half. */
14
14
  export interface StreamConfig {
15
- /** Render direction: horizontal per-character reveal or uniform upward glide. */
15
+ /** Compatibility field; both values use the current adaptive reveal engine. */
16
16
  readonly mode: StreamMode
17
17
  /** Smoothing preset for the reveal cadence. */
18
18
  readonly preset: StreamSmoothingPreset
@@ -26,10 +26,7 @@ export interface StreamConfig {
26
26
  * smooth-damp, not a cruise speed.
27
27
  */
28
28
  readonly scrollSpeedPxPerSec: number
29
- /**
30
- * Follow velocity ceiling in CSS pixels per second, so a huge first lag
31
- * does not teleport. Ordinary wraps stay well below this.
32
- */
29
+ /** Unused at runtime; retained so existing overlays continue to load. */
33
30
  readonly maxScrollSpeedPxPerSec: number
34
31
  }
35
32