dsh-smooth-stream 0.3.4 → 0.4.0

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.
@@ -27,6 +27,9 @@
27
27
  */
28
28
 
29
29
  import { useEffect, useLayoutEffect, useRef, type RefObject } from 'react'
30
+ import { DEFAULT_STREAM_DEBUG_TUNING, type StreamDebugTuning } from '../settings.ts'
31
+ import { debugRuntime } from './debugRuntime.ts'
32
+ import { PROCESSED_CLASS } from './auto-collapse-controller.ts'
30
33
 
31
34
  /**
32
35
  * Programmatic follow marker retained for hosts that recognize external
@@ -115,7 +118,10 @@ const GESTURE_EVENTS = [
115
118
  ] as const
116
119
 
117
120
  /** Visible runway needed for the current reveal pressure. */
118
- export function computeFollowReserve(speedCps: number, runwayPx = FOLLOW_STATUS_RUNWAY_PX): number {
121
+ export function computeFollowReserve(
122
+ speedCps: number,
123
+ runwayPx = FOLLOW_STATUS_RUNWAY_PX,
124
+ ): number {
119
125
  const available = Math.max(0, runwayPx)
120
126
  if (available <= 0) return 0
121
127
  if (speedCps <= FOLLOW_RESERVE_MIN_CPS) return 0
@@ -136,17 +142,18 @@ export function computeFollowRevealScale(
136
142
  lagPx: number,
137
143
  capacityPx: number,
138
144
  constrained = false,
145
+ tuning: StreamDebugTuning = DEFAULT_STREAM_DEBUG_TUNING,
139
146
  ): number {
140
- if (constrained) return FOLLOW_REVEAL_MIN_SCALE
147
+ if (constrained) return tuning.backpressureMinScale
141
148
  if (!Number.isFinite(capacityPx)) return 1
142
- if (capacityPx <= 0) return lagPx > 0 ? FOLLOW_REVEAL_MIN_SCALE : 1
149
+ if (capacityPx <= 0) return lagPx > 0 ? tuning.backpressureMinScale : 1
143
150
  const ratio = Math.min(1, Math.max(0, lagPx / capacityPx))
144
- if (ratio >= FOLLOW_BACKPRESSURE_FULL_RATIO) return FOLLOW_REVEAL_MIN_SCALE
151
+ if (ratio >= FOLLOW_BACKPRESSURE_FULL_RATIO) return tuning.backpressureMinScale
145
152
  const progress = Math.min(1, Math.max(0, (
146
153
  ratio - FOLLOW_BACKPRESSURE_START_RATIO
147
154
  ) / (FOLLOW_BACKPRESSURE_FULL_RATIO - FOLLOW_BACKPRESSURE_START_RATIO)))
148
155
  const eased = progress * progress * (3 - 2 * progress)
149
- return 1 - (1 - FOLLOW_REVEAL_MIN_SCALE) * eased
156
+ return 1 - (1 - tuning.backpressureMinScale) * eased
150
157
  }
151
158
 
152
159
  export interface FollowGlideInput {
@@ -173,7 +180,11 @@ export interface FollowGlideStep {
173
180
  * @param input - Current visible lag and carried physics velocity.
174
181
  * @returns The position advance, its fraction, and next velocity.
175
182
  */
176
- export function computeFollowStep(dtMs: number, input: FollowGlideInput): FollowGlideStep {
183
+ export function computeFollowStep(
184
+ dtMs: number,
185
+ input: FollowGlideInput,
186
+ tuning: StreamDebugTuning = DEFAULT_STREAM_DEBUG_TUNING,
187
+ ): FollowGlideStep {
177
188
  if (input.lag <= 0.1 || dtMs <= 0) {
178
189
  return { advancePx: 0, lerpStep: 0, velocityPxPerSec: 0 }
179
190
  }
@@ -186,8 +197,8 @@ export function computeFollowStep(dtMs: number, input: FollowGlideInput): Follow
186
197
  for (let slice = 0; slice < slices; slice += 1) {
187
198
  for (let substep = 0; substep < FOLLOW_SPRING_SUBSTEPS; substep += 1) {
188
199
  const acceleration = (
189
- FOLLOW_SPRING_STIFFNESS * lag - FOLLOW_SPRING_DAMPING * velocity
190
- ) / FOLLOW_SPRING_MASS
200
+ tuning.springStiffness * lag - tuning.springDamping * velocity
201
+ ) / tuning.springMass
191
202
  velocity = Math.max(0, velocity + acceleration * subDt)
192
203
  const advance = velocity * subDt
193
204
  if (advance >= lag) {
@@ -207,10 +218,14 @@ function resizeProxyOf(port: HTMLElement): HTMLElement | null {
207
218
  }
208
219
 
209
220
  /** Outermost message surfaces; nested tool rows ride their parent. */
210
- function shiftSurfacesOf(port: HTMLElement): HTMLElement[] {
221
+ /** @internal Test seam: the equal-lag transform set for one scrollport. */
222
+ export function shiftSurfacesOf(port: HTMLElement): HTMLElement[] {
211
223
  const transcript = port.querySelector<HTMLElement>('[data-chat-transcript]')
212
224
  if (transcript !== null) return [transcript]
213
- return [...port.querySelectorAll<HTMLElement>('[data-chat-anchor-key]')]
225
+ // Fold summary rows are plugin-injected flow children without an anchor
226
+ // key; they must ride the same lag transform as native rows, otherwise
227
+ // they visibly detach from their neighbours on every follow frame.
228
+ return [...port.querySelectorAll<HTMLElement>(`[data-chat-anchor-key], .${PROCESSED_CLASS}`)]
214
229
  .filter(row => row.parentElement?.closest('[data-chat-anchor-key]') === null)
215
230
  }
216
231
 
@@ -267,6 +282,7 @@ interface FollowRunway {
267
282
  readonly offset: number
268
283
  readonly original: string
269
284
  readonly property: 'marginBottom' | 'marginTop'
285
+ readonly requestedPx: number
270
286
  }
271
287
 
272
288
  /**
@@ -369,7 +385,11 @@ function migrateLegacyRunway(
369
385
  if (migrated) invalidatePaintLimit(port)
370
386
  }
371
387
 
372
- function ensureRunway(port: HTMLElement, surfaces: readonly HTMLElement[]): void {
388
+ function ensureRunway(
389
+ port: HTMLElement,
390
+ surfaces: readonly HTMLElement[],
391
+ runwayPx = FOLLOW_STATUS_RUNWAY_PX,
392
+ ): void {
373
393
  const status = turnStatusOf(port)
374
394
  const composer = port.querySelector<HTMLElement>('[data-composer-seat]')
375
395
  migrateLegacyRunway(port, surfaces, status, composer)
@@ -378,7 +398,7 @@ function ensureRunway(port: HTMLElement, surfaces: readonly HTMLElement[]): void
378
398
  // applyVisual keeps every surface in normal flow, so adding status margin
379
399
  // would expose the whole runway as empty space below a short/early Think.
380
400
  const naturalHeight = Math.max(0, port.scrollHeight - runwayOffsetOf(port))
381
- if (port.clientHeight <= 0 || naturalHeight <= port.clientHeight) {
401
+ if (runwayPx <= 0 || port.clientHeight <= 0 || naturalHeight <= port.clientHeight) {
382
402
  restoreRunway(port)
383
403
  return
384
404
  }
@@ -391,15 +411,17 @@ function ensureRunway(port: HTMLElement, surfaces: readonly HTMLElement[]): void
391
411
  }
392
412
  const element = target.element
393
413
  const current = followRunways.get(port)
394
- if (current?.element === element && current.property === target.property) return
414
+ if (current?.element === element
415
+ && current.property === target.property
416
+ && current.requestedPx === runwayPx) return
395
417
  restoreRunway(port)
396
418
  const beforeHeight = port.scrollHeight
397
419
  const original = element.style[target.property]
398
420
  element.style[target.property] = original === ''
399
- ? `${FOLLOW_STATUS_RUNWAY_PX}px`
400
- : `calc(${original} + ${FOLLOW_STATUS_RUNWAY_PX}px)`
421
+ ? `${runwayPx}px`
422
+ : `calc(${original} + ${runwayPx}px)`
401
423
  const offset = Math.max(0, port.scrollHeight - beforeHeight)
402
- followRunways.set(port, { element, offset, property: target.property, original })
424
+ followRunways.set(port, { element, offset, property: target.property, original, requestedPx: runwayPx })
403
425
  invalidatePaintLimit(port)
404
426
  }
405
427
 
@@ -499,9 +521,10 @@ function applyVisual(
499
521
  animatedH: number,
500
522
  reservePx: number,
501
523
  velocityPxPerSec = 0,
524
+ runwayPx = FOLLOW_STATUS_RUNWAY_PX,
502
525
  ): number {
503
526
  const surfaces = shiftSurfacesOf(port)
504
- ensureRunway(port, surfaces)
527
+ ensureRunway(port, surfaces, runwayPx)
505
528
  const contentHeight = Math.max(0, port.scrollHeight)
506
529
  const runwayOffset = runwayOffsetOf(port)
507
530
  const targetHeight = Math.max(0, contentHeight - runwayOffset)
@@ -680,11 +703,12 @@ export function useConversationFollow(
680
703
 
681
704
  const updateRevealScale = (next: HTMLElement, elapsedMs: number, urgent = false): void => {
682
705
  if (revealScaleRef === undefined) return
706
+ const tuning = debugRuntime.activeTuning()
683
707
  const state = followMotionStates.get(next)
684
708
  const target = state === undefined
685
709
  ? 1
686
- : computeFollowRevealScale(state.lagPx, state.capacityPx, state.constrained)
687
- const current = Math.min(1, Math.max(FOLLOW_REVEAL_MIN_SCALE, revealScaleRef.current))
710
+ : computeFollowRevealScale(state.lagPx, state.capacityPx, state.constrained, tuning)
711
+ const current = Math.min(1, Math.max(tuning.backpressureMinScale, revealScaleRef.current))
688
712
  if (target < current || urgent) {
689
713
  // Slowing affects only future glyph commits, so it can react at once
690
714
  // without producing a visual discontinuity in the current frame.
@@ -699,6 +723,23 @@ export function useConversationFollow(
699
723
  if (revealScaleRef !== undefined) revealScaleRef.current = 1
700
724
  }
701
725
 
726
+ const reportFollow = (next: HTMLElement, isActive: boolean): void => {
727
+ const state = followMotionStates.get(next)
728
+ debugRuntime.reportFollow(next, {
729
+ lagPx: state?.lagPx ?? Math.max(0, next.scrollHeight - next.clientHeight - next.scrollTop),
730
+ velocityPxPerSec: state?.velocityPxPerSec ?? 0,
731
+ reservePx: state?.reservePx ?? 0,
732
+ capacityPx: state?.capacityPx ?? 0,
733
+ revealScale: revealScaleRef?.current ?? 1,
734
+ following,
735
+ constrained: state?.constrained ?? false,
736
+ scrollTop: next.scrollTop,
737
+ scrollHeight: next.scrollHeight,
738
+ clientHeight: next.clientHeight,
739
+ active: isActive,
740
+ })
741
+ }
742
+
702
743
  const isLeader = (next: HTMLElement): boolean => followLeaders.get(next)?.owner === owner
703
744
 
704
745
  const hold = (next: HTMLElement): void => {
@@ -717,12 +758,19 @@ export function useConversationFollow(
717
758
  clearMotion(next)
718
759
  followMotionStates.delete(next)
719
760
  releaseRevealScale()
761
+ debugRuntime.reportFollow(next, null)
720
762
  }
721
763
  }
722
764
 
723
765
  const handBackVisual = (next: HTMLElement): void => {
724
766
  const shift = currentShiftOf(shiftSurfacesOf(next).at(-1) ?? next)
725
767
  const visualTop = Math.max(0, next.scrollTop - Math.max(0, shift))
768
+ // The predictive runway only has meaning while this follower owns the
769
+ // floor. Remove it before choosing the reader's landing point; keeping
770
+ // it through the release paints a transient natural gap + 48px blank
771
+ // block until the next follow frame restores an equal transform.
772
+ clearMotion(next)
773
+ restoreRunway(next)
726
774
  const floor = Math.max(0, next.scrollHeight - next.clientHeight)
727
775
  // The host keeps its own bottom-follow bit while the reader remains in
728
776
  // its 25px slack band. Land one pixel beyond that band so even a light
@@ -760,8 +808,10 @@ export function useConversationFollow(
760
808
  const restoreBeforePaint = (): void => {
761
809
  if (!following || port === null || !isLeader(port)) return
762
810
  invalidatePaintLimit(port)
763
- animatedH = applyVisual(port, animatedH, reservePx, velocityPxPerSec)
811
+ const tuning = debugRuntime.activeTuning()
812
+ animatedH = applyVisual(port, animatedH, reservePx, velocityPxPerSec, tuning.runwayPx)
764
813
  updateRevealScale(port, 0, true)
814
+ reportFollow(port, activeRef.current)
765
815
  }
766
816
 
767
817
  const bindPort = (next: HTMLElement): void => {
@@ -790,6 +840,7 @@ export function useConversationFollow(
790
840
  // frames would open paint room more slowly precisely when it is needed.
791
841
  const elapsedMs = Math.max(0.001, now - last)
792
842
  const dt = Math.min(FOLLOW_MAX_FRAME_MS, elapsedMs)
843
+ const tuning = debugRuntime.activeTuning()
793
844
  last = now
794
845
  const root = rootRef.current
795
846
  if (root === null) return
@@ -836,8 +887,9 @@ export function useConversationFollow(
836
887
  if (following) {
837
888
  hold(nextPort)
838
889
  if (isLeader(nextPort)) {
839
- animatedH = applyVisual(nextPort, animatedH, reservePx, velocityPxPerSec)
890
+ animatedH = applyVisual(nextPort, animatedH, reservePx, velocityPxPerSec, tuning.runwayPx)
840
891
  updateRevealScale(nextPort, elapsedMs)
892
+ reportFollow(nextPort, activeRef.current)
841
893
  const runwayOffset = runwayOffsetOf(nextPort)
842
894
  const entranceLag = Math.max(0, nextPort.scrollHeight - animatedH - runwayOffset)
843
895
  if (entranceLag <= FOLLOW_SETTLE_EPSILON_PX) finishEntrance()
@@ -852,7 +904,10 @@ export function useConversationFollow(
852
904
  }
853
905
 
854
906
  const repinSlack = readerReleased ? FOLLOW_REPIN_PX : FOLLOW_SLACK_PX
855
- if (!following && !interacting && reportedLag <= repinSlack) {
907
+ const returnedToFloor = readerReleased
908
+ && !readerGestureIntent
909
+ && reportedLag <= FOLLOW_REPIN_PX
910
+ if (!following && (!interacting || returnedToFloor) && reportedLag <= repinSlack) {
856
911
  following = true
857
912
  readerReleased = false
858
913
  animatedH = extent
@@ -876,6 +931,7 @@ export function useConversationFollow(
876
931
 
877
932
  if (!activeRef.current || !following) {
878
933
  followScrollLedgers.set(nextPort, nextPort.scrollTop)
934
+ reportFollow(nextPort, activeRef.current)
879
935
  return
880
936
  }
881
937
  hold(nextPort)
@@ -893,14 +949,14 @@ export function useConversationFollow(
893
949
  const predictGrowth = predictiveRef?.current ?? predictive
894
950
  const reserveTarget = !predictGrowth
895
951
  ? 0
896
- : computeFollowReserve(speedCpsRef.current, runwayOffset)
897
- const reserveStep = 1 - Math.exp(-elapsedMs / FOLLOW_RESERVE_RESPONSE_MS)
952
+ : computeFollowReserve(speedCpsRef.current, tuning.runwayPx)
953
+ const reserveStep = 1 - Math.exp(-elapsedMs / tuning.reserveResponseMs)
898
954
  reservePx += (reserveTarget - reservePx) * reserveStep
899
955
  const step = computeFollowStep(dt, {
900
956
  lag,
901
957
  speedEma: speedCpsRef.current,
902
958
  velocityPxPerSec,
903
- })
959
+ }, tuning)
904
960
  if (lag <= 0.1) {
905
961
  animatedH = contentHeight - runwayOffset
906
962
  velocityPxPerSec = 0
@@ -912,8 +968,9 @@ export function useConversationFollow(
912
968
  )
913
969
  velocityPxPerSec = step.velocityPxPerSec
914
970
  }
915
- animatedH = applyVisual(nextPort, animatedH, reservePx, velocityPxPerSec)
971
+ animatedH = applyVisual(nextPort, animatedH, reservePx, velocityPxPerSec, tuning.runwayPx)
916
972
  updateRevealScale(nextPort, elapsedMs)
973
+ reportFollow(nextPort, true)
917
974
  const remainingEntranceLag = Math.max(
918
975
  0,
919
976
  nextPort.scrollHeight - animatedH - runwayOffsetOf(nextPort),
@@ -943,6 +1000,7 @@ export function useConversationFollow(
943
1000
  clearVisual(host)
944
1001
  followLeaders.delete(host)
945
1002
  releaseRevealScale()
1003
+ debugRuntime.reportFollow(host, null)
946
1004
  return
947
1005
  }
948
1006
  if (preserveReader) {
@@ -950,13 +1008,15 @@ export function useConversationFollow(
950
1008
  clearVisual(host)
951
1009
  followLeaders.delete(host)
952
1010
  releaseRevealScale()
1011
+ debugRuntime.reportFollow(host, null)
953
1012
  return
954
1013
  }
955
1014
 
956
1015
  // Completion can land the final Tool/command height in this same
957
1016
  // commit. Preserve the logical extent and drain it after unmount instead
958
1017
  // of clearing the compositor state before the first settled paint.
959
- ensureRunway(host, shiftSurfacesOf(host))
1018
+ const completionTuning = debugRuntime.activeTuning()
1019
+ ensureRunway(host, shiftSurfacesOf(host), completionTuning.runwayPx)
960
1020
  const completionRunway = runwayOffsetOf(host)
961
1021
  const completionMinimumLag = Math.max(0, reservePx)
962
1022
  animatedH = Math.min(
@@ -964,13 +1024,15 @@ export function useConversationFollow(
964
1024
  host.scrollHeight - completionRunway - completionMinimumLag,
965
1025
  )
966
1026
  settleAtFloor(host)
967
- animatedH = applyVisual(host, animatedH, reservePx, velocityPxPerSec)
1027
+ animatedH = applyVisual(host, animatedH, reservePx, velocityPxPerSec, completionTuning.runwayPx)
1028
+ reportFollow(host, false)
968
1029
  const runwayOffset = runwayOffsetOf(host)
969
1030
  const remainingLag = Math.max(0, host.scrollHeight - animatedH - runwayOffset)
970
1031
  if (remainingLag <= FOLLOW_SETTLE_EPSILON_PX && reservePx <= FOLLOW_SETTLE_EPSILON_PX) {
971
1032
  finishAtNaturalFloor(host)
972
1033
  followLeaders.delete(host)
973
1034
  releaseRevealScale()
1035
+ debugRuntime.reportFollow(host, null)
974
1036
  return
975
1037
  }
976
1038
 
@@ -996,14 +1058,16 @@ export function useConversationFollow(
996
1058
  clearVisual(host)
997
1059
  followLeaders.delete(host)
998
1060
  releaseRevealScale()
1061
+ debugRuntime.reportFollow(host, null)
999
1062
  stopSettleListeners()
1000
1063
  return
1001
1064
  }
1002
1065
  const dt = Math.min(FOLLOW_MAX_FRAME_MS, Math.max(0, now - settleLast))
1066
+ const tuning = debugRuntime.activeTuning()
1003
1067
  settleLast = now
1004
1068
  const runwayOffset = runwayOffsetOf(host)
1005
1069
  const lag = Math.max(0, host.scrollHeight - animatedH - runwayOffset)
1006
- const reserveStep = 1 - Math.exp(-dt / FOLLOW_RESERVE_RESPONSE_MS)
1070
+ const reserveStep = 1 - Math.exp(-dt / tuning.reserveResponseMs)
1007
1071
  reservePx += (0 - reservePx) * reserveStep
1008
1072
  if (lag <= FOLLOW_SETTLE_EPSILON_PX && reservePx <= FOLLOW_SETTLE_EPSILON_PX) {
1009
1073
  animatedH = host.scrollHeight - runwayOffset
@@ -1012,6 +1076,7 @@ export function useConversationFollow(
1012
1076
  finishAtNaturalFloor(host)
1013
1077
  followLeaders.delete(host)
1014
1078
  releaseRevealScale()
1079
+ debugRuntime.reportFollow(host, null)
1015
1080
  stopSettleListeners()
1016
1081
  return
1017
1082
  }
@@ -1019,7 +1084,7 @@ export function useConversationFollow(
1019
1084
  lag,
1020
1085
  speedEma: speedCpsRef.current,
1021
1086
  velocityPxPerSec,
1022
- })
1087
+ }, tuning)
1023
1088
  // The temporary runway is visually neutral only while an equal lag
1024
1089
  // remains in the transform. Do not let the spring outrun the runway's
1025
1090
  // closing reserve or cleanup would reveal an overshoot and rebound.
@@ -1030,7 +1095,8 @@ export function useConversationFollow(
1030
1095
  )
1031
1096
  velocityPxPerSec = step.velocityPxPerSec
1032
1097
  settleAtFloor(host)
1033
- animatedH = applyVisual(host, animatedH, reservePx, velocityPxPerSec)
1098
+ animatedH = applyVisual(host, animatedH, reservePx, velocityPxPerSec, tuning.runwayPx)
1099
+ reportFollow(host, false)
1034
1100
  requestAnimationFrame(settleFrame)
1035
1101
  }
1036
1102
  requestAnimationFrame(settleFrame)
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import { useCallback, useEffect, useRef } from 'react'
12
+ import { debugRuntime } from './debugRuntime.ts'
12
13
 
13
14
  const FPS_THRESHOLD = 30
14
15
  const FPS_ALPHA = 0.12
@@ -33,6 +34,7 @@ export function useFpsGuard(active: boolean): {
33
34
  useEffect(() => {
34
35
  if (!active) return
35
36
  let rafId = 0
37
+ let lastDebugReport = 0
36
38
  const frame = (now: number) => {
37
39
  rafId = requestAnimationFrame(frame)
38
40
  const fps = fpsRef.current
@@ -51,11 +53,16 @@ export function useFpsGuard(active: boolean): {
51
53
  fps.healthyRun += 1
52
54
  if (fps.healthyRun >= RECOVER_FRAMES) fps.degraded = false
53
55
  }
56
+ if (now - lastDebugReport >= 100) {
57
+ debugRuntime.reportFps(currentFps, fps.emaMs, fps.degraded)
58
+ lastDebugReport = now
59
+ }
54
60
  }
55
61
  rafId = requestAnimationFrame(frame)
56
62
  return () => {
57
63
  cancelAnimationFrame(rafId)
58
64
  fpsRef.current = { emaMs: 0, lastMs: 0, healthyRun: 0, degraded: false }
65
+ debugRuntime.clearFps()
59
66
  }
60
67
  }, [active])
61
68
 
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { useLayoutEffect, type RefObject } from 'react'
12
12
  import { computeAdaptiveQueueStep } from './useSmoothStreamContent.ts'
13
+ import { debugRuntime } from './debugRuntime.ts'
13
14
 
14
15
  interface TextRevealRecord {
15
16
  chars: readonly string[]
@@ -17,6 +18,9 @@ interface TextRevealRecord {
17
18
  shown: number
18
19
  }
19
20
 
21
+ /** Last presented text per root, retained across follow lifecycle flips. */
22
+ const ledgerByRoot = new WeakMap<HTMLElement, Map<Text, TextRevealRecord>>()
23
+
20
24
  const SKIP_TEXT_SELECTOR = [
21
25
  '[aria-hidden="true"]',
22
26
  '[aria-live]',
@@ -55,11 +59,58 @@ export function useProgressiveDomText(
55
59
  onSettled?: (() => void) | undefined,
56
60
  ): void {
57
61
  useLayoutEffect(() => {
58
- if (!enabled) return
59
62
  const root = rootRef.current
60
63
  if (root === null || typeof document === 'undefined') return
61
64
 
62
- const records = new Map<Text, TextRevealRecord>()
65
+ let records = ledgerByRoot.get(root)
66
+ if (!enabled && records === undefined) return
67
+ if (records === undefined) {
68
+ records = new Map<Text, TextRevealRecord>()
69
+ ledgerByRoot.set(root, records)
70
+ }
71
+
72
+ const forEachText = (from: Node, callback: (text: Text) => void): void => {
73
+ if (from.nodeType === Node.TEXT_NODE) {
74
+ callback(from as Text)
75
+ return
76
+ }
77
+ const walker = document.createTreeWalker(from, NodeFilter.SHOW_TEXT)
78
+ let current = walker.nextNode()
79
+ while (current !== null) {
80
+ callback(current as Text)
81
+ current = walker.nextNode()
82
+ }
83
+ }
84
+
85
+ const settle = (text: Text): void => {
86
+ if (!revealable(text, root)) return
87
+ const chars = [...text.data]
88
+ records.set(text, { chars, full: text.data, shown: chars.length })
89
+ }
90
+
91
+ const snapshotVisible = (): void => {
92
+ const current = new Set<Text>()
93
+ forEachText(root, (text) => {
94
+ if (!revealable(text, root)) return
95
+ current.add(text)
96
+ settle(text)
97
+ })
98
+ for (const text of records.keys()) {
99
+ if (!current.has(text)) records.delete(text)
100
+ }
101
+ }
102
+
103
+ if (!enabled) {
104
+ snapshotVisible()
105
+ const observer = typeof MutationObserver === 'undefined'
106
+ ? null
107
+ : new MutationObserver(snapshotVisible)
108
+ observer?.observe(root, { childList: true, characterData: true, subtree: true })
109
+ return () => {
110
+ observer?.disconnect()
111
+ }
112
+ }
113
+
63
114
  const pending = new Set<Text>()
64
115
  const internalWrites = new WeakMap<Text, string>()
65
116
  let rafId = 0
@@ -67,11 +118,13 @@ export function useProgressiveDomText(
67
118
  let debt = 0
68
119
  let stopped = false
69
120
  let announcedSettled = false
121
+ const streamId = `dom-${Math.random().toString(36).slice(2)}`
70
122
 
71
123
  const announceSettled = (): void => {
72
124
  lastFrame = null
73
125
  debt = 0
74
126
  speedCpsRef.current = 35
127
+ debugRuntime.reportStream(streamId, null)
75
128
  if (announcedSettled) return
76
129
  announcedSettled = true
77
130
  onSettled?.()
@@ -101,18 +154,22 @@ export function useProgressiveDomText(
101
154
  }
102
155
 
103
156
  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
- }
157
+ forEachText(from, (text) => {
158
+ if (!revealable(text, root)) return
159
+ if (reveal) {
160
+ enqueue(text, text.data, records.get(text))
161
+ return
162
+ }
163
+ settle(text)
164
+ })
165
+ }
166
+
167
+ const forget = (from: Node): void => {
168
+ forEachText(from, (text) => {
169
+ if (root.contains(text)) return
170
+ records.delete(text)
171
+ pending.delete(text)
172
+ })
116
173
  }
117
174
 
118
175
  const scheduleFrame = (): void => {
@@ -140,7 +197,7 @@ export function useProgressiveDomText(
140
197
  const record = records.get(node)
141
198
  if (record !== undefined) backlog += record.chars.length - record.shown
142
199
  }
143
- const step = computeAdaptiveQueueStep(backlog, elapsed, debt)
200
+ const step = computeAdaptiveQueueStep(backlog, elapsed, debt, 1, debugRuntime.activeTuning())
144
201
  debt = step.debt
145
202
  speedCpsRef.current = step.speedCps
146
203
  let remaining = step.revealChars
@@ -149,6 +206,7 @@ export function useProgressiveDomText(
149
206
  const record = records.get(node)
150
207
  if (record === undefined || !node.isConnected) {
151
208
  pending.delete(node)
209
+ records.delete(node)
152
210
  continue
153
211
  }
154
212
  const amount = Math.min(remaining, record.chars.length - record.shown)
@@ -159,11 +217,31 @@ export function useProgressiveDomText(
159
217
  remaining -= amount
160
218
  if (shown >= next.chars.length) pending.delete(node)
161
219
  }
220
+ let targetChars = 0
221
+ let displayedChars = 0
222
+ let nextBacklog = 0
223
+ for (const [node, record] of records) {
224
+ if (!node.isConnected) {
225
+ records.delete(node)
226
+ pending.delete(node)
227
+ continue
228
+ }
229
+ targetChars += record.chars.length
230
+ displayedChars += record.shown
231
+ if (pending.has(node)) nextBacklog += record.chars.length - record.shown
232
+ }
233
+ debugRuntime.reportStream(streamId, {
234
+ backlog: nextBacklog,
235
+ speedCps: step.speedCps,
236
+ targetChars,
237
+ displayedChars,
238
+ active: pending.size > 0,
239
+ })
162
240
  if (pending.size === 0) announceSettled()
163
241
  else scheduleFrame()
164
242
  }
165
243
 
166
- if (revealInitial) visit(root, true)
244
+ visit(root, revealInitial)
167
245
 
168
246
  const observer = typeof MutationObserver === 'undefined'
169
247
  ? null
@@ -179,6 +257,7 @@ export function useProgressiveDomText(
179
257
  enqueue(node, node.data, records.get(node))
180
258
  continue
181
259
  }
260
+ for (const removed of mutation.removedNodes) forget(removed)
182
261
  for (const added of mutation.addedNodes) visit(added, true)
183
262
  }
184
263
  if (pending.size === 0) announceSettled()
@@ -197,10 +276,13 @@ export function useProgressiveDomText(
197
276
  // React may have committed a terminal replacement in the same mutation
198
277
  // phase that disables this hook. Never overwrite that newer renderer
199
278
  // value with the previous controlled source during layout cleanup.
200
- if (node.isConnected && node.data === controlled) write(node, record.full)
279
+ if (node.isConnected && node.data === controlled) {
280
+ write(node, record.full)
281
+ }
201
282
  }
202
283
  pending.clear()
203
284
  speedCpsRef.current = 35
285
+ debugRuntime.reportStream(streamId, null)
204
286
  }
205
287
  }, [enabled, onSettled, revealInitial, rootRef, speedCpsRef])
206
288
  }
@@ -14,6 +14,8 @@
14
14
  */
15
15
 
16
16
  import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
17
+ import { DEFAULT_STREAM_DEBUG_TUNING, type StreamDebugTuning } from '../settings.ts'
18
+ import { debugRuntime } from './debugRuntime.ts'
17
19
 
18
20
  export type StreamSmoothingPreset = 'realtime' | 'balanced' | 'silky'
19
21
 
@@ -117,13 +119,14 @@ export function computeAdaptiveQueueStep(
117
119
  dtMs: number,
118
120
  debt: number,
119
121
  revealScale = 1,
122
+ tuning: StreamDebugTuning = DEFAULT_STREAM_DEBUG_TUNING,
120
123
  ): AdaptiveQueueStep {
121
124
  if (backlog <= 0 || dtMs <= 0) return { revealChars: 0, debt: 0, speedCps: 0 }
122
125
  const speedCps = Math.min(
123
- QUEUE_MAX_SPEED_CPS,
124
- QUEUE_BASE_SPEED_CPS + Math.pow(backlog, QUEUE_ACCEL_EXPONENT) * QUEUE_PRESSURE_FACTOR,
126
+ tuning.maxRevealCps,
127
+ QUEUE_BASE_SPEED_CPS + Math.pow(backlog, QUEUE_ACCEL_EXPONENT) * tuning.queuePressure,
125
128
  )
126
- const effectiveScale = clamp(revealScale, 0.05, 1)
129
+ const effectiveScale = clamp(revealScale * tuning.revealScale, 0.05, 2)
127
130
  const accumulated = Math.max(0, debt) + speedCps * effectiveScale * (dtMs / 1000)
128
131
  const revealChars = Math.min(backlog, Math.floor(accumulated))
129
132
  return { revealChars, debt: revealChars >= backlog ? 0 : accumulated - revealChars, speedCps }
@@ -313,6 +316,7 @@ export function useSmoothStreamContent(
313
316
  revealScaleOutRef.current = revealScaleRef
314
317
  const inputCompleteRef = useRef(inputComplete)
315
318
  inputCompleteRef.current = inputComplete
319
+ const streamIdRef = useRef(`stream-${Math.random().toString(36).slice(2)}`)
316
320
 
317
321
  useEffect(() => {
318
322
  holdBackRef.current = shouldHoldBack
@@ -374,6 +378,7 @@ export function useSmoothStreamContent(
374
378
  settleCpsRef.current = null
375
379
  const speedOut = speedOutRef.current
376
380
  if (speedOut !== undefined) speedOut.current = seedCps
381
+ debugRuntime.reportStream(streamIdRef.current, null)
377
382
  stopFrameLoop()
378
383
  return
379
384
  }
@@ -390,6 +395,7 @@ export function useSmoothStreamContent(
390
395
 
391
396
  const idleMs = now - lastInputTsRef.current
392
397
  const producerComplete = inputCompleteRef.current
398
+ const debugTuning = debugRuntime.activeTuning()
393
399
  const inputActive = !producerComplete && idleMs <= config.activeInputWindowMs
394
400
  const settling = producerComplete || (!inputActive && idleMs >= config.settleAfterMs)
395
401
  if (!producerComplete) settleCpsRef.current = null
@@ -422,7 +428,7 @@ export function useSmoothStreamContent(
422
428
  },
423
429
  dtSeconds,
424
430
  )
425
- revealChars = Math.min(step.revealChars, backlog)
431
+ revealChars = Math.min(Math.round(step.revealChars * debugTuning.revealScale), backlog)
426
432
  revealSpeedCps = frameIntervalMs > 0 ? (revealChars * 1000) / frameIntervalMs : 0
427
433
  } else {
428
434
  const step = computeAdaptiveQueueStep(
@@ -430,12 +436,21 @@ export function useSmoothStreamContent(
430
436
  frameIntervalMs,
431
437
  queueDebtRef.current,
432
438
  revealScaleOutRef.current?.current ?? 1,
439
+ debugTuning,
433
440
  )
434
441
  revealChars = step.revealChars
435
442
  revealSpeedCps = step.speedCps
436
443
  nextQueueDebt = step.debt
437
444
  }
438
445
 
446
+ debugRuntime.reportStream(streamIdRef.current, {
447
+ backlog,
448
+ speedCps: revealSpeedCps,
449
+ targetChars: targetCount,
450
+ displayedChars: displayedCount,
451
+ active: !producerComplete,
452
+ })
453
+
439
454
  // Performance guard: while degraded and the reply is offscreen, skip
440
455
  // the DOM commit — the backlog keeps accumulating and flushes when the
441
456
  // guard clears or the reply scrolls into view.
@@ -527,6 +542,7 @@ export function useSmoothStreamContent(
527
542
  useEffect(() => {
528
543
  return () => {
529
544
  stopFrameLoop()
545
+ debugRuntime.reportStream(streamIdRef.current, null)
530
546
  }
531
547
  }, [stopFrameLoop])
532
548