dsh-smooth-stream 0.3.4 → 0.4.1
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.
- package/README.en.md +14 -4
- package/README.md +14 -4
- package/lib/client.js +2130 -142
- package/lib/client.js.map +1 -1
- package/lib/index.js +123 -6
- package/package.json +9 -9
- package/src/client/DebugPanel.module.css +370 -0
- package/src/client/DebugPanel.tsx +248 -0
- package/src/client/SmoothStreamCard.module.css +8 -0
- package/src/client/SmoothStreamCard.tsx +42 -3
- package/src/client/auto-collapse-controller.ts +977 -0
- package/src/client/debugRuntime.ts +355 -0
- package/src/client/index.ts +121 -30
- package/src/client/locales.ts +122 -0
- package/src/client/smooth-stream-card-controller.ts +97 -14
- package/src/client/smooth-stream-settings-api.ts +53 -3
- package/src/client/teleprompterGlide.ts +117 -32
- package/src/client/useFpsGuard.ts +7 -0
- package/src/client/useProgressiveDomText.ts +99 -17
- package/src/client/useSmoothStreamContent.ts +20 -4
- package/src/plugin.ts +137 -7
- package/src/settings-api.ts +12 -0
- package/src/settings.ts +55 -0
|
@@ -27,6 +27,8 @@
|
|
|
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'
|
|
30
32
|
|
|
31
33
|
/**
|
|
32
34
|
* Programmatic follow marker retained for hosts that recognize external
|
|
@@ -115,7 +117,10 @@ const GESTURE_EVENTS = [
|
|
|
115
117
|
] as const
|
|
116
118
|
|
|
117
119
|
/** Visible runway needed for the current reveal pressure. */
|
|
118
|
-
export function computeFollowReserve(
|
|
120
|
+
export function computeFollowReserve(
|
|
121
|
+
speedCps: number,
|
|
122
|
+
runwayPx = FOLLOW_STATUS_RUNWAY_PX,
|
|
123
|
+
): number {
|
|
119
124
|
const available = Math.max(0, runwayPx)
|
|
120
125
|
if (available <= 0) return 0
|
|
121
126
|
if (speedCps <= FOLLOW_RESERVE_MIN_CPS) return 0
|
|
@@ -136,17 +141,18 @@ export function computeFollowRevealScale(
|
|
|
136
141
|
lagPx: number,
|
|
137
142
|
capacityPx: number,
|
|
138
143
|
constrained = false,
|
|
144
|
+
tuning: StreamDebugTuning = DEFAULT_STREAM_DEBUG_TUNING,
|
|
139
145
|
): number {
|
|
140
|
-
if (constrained) return
|
|
146
|
+
if (constrained) return tuning.backpressureMinScale
|
|
141
147
|
if (!Number.isFinite(capacityPx)) return 1
|
|
142
|
-
if (capacityPx <= 0) return lagPx > 0 ?
|
|
148
|
+
if (capacityPx <= 0) return lagPx > 0 ? tuning.backpressureMinScale : 1
|
|
143
149
|
const ratio = Math.min(1, Math.max(0, lagPx / capacityPx))
|
|
144
|
-
if (ratio >= FOLLOW_BACKPRESSURE_FULL_RATIO) return
|
|
150
|
+
if (ratio >= FOLLOW_BACKPRESSURE_FULL_RATIO) return tuning.backpressureMinScale
|
|
145
151
|
const progress = Math.min(1, Math.max(0, (
|
|
146
152
|
ratio - FOLLOW_BACKPRESSURE_START_RATIO
|
|
147
153
|
) / (FOLLOW_BACKPRESSURE_FULL_RATIO - FOLLOW_BACKPRESSURE_START_RATIO)))
|
|
148
154
|
const eased = progress * progress * (3 - 2 * progress)
|
|
149
|
-
return 1 - (1 -
|
|
155
|
+
return 1 - (1 - tuning.backpressureMinScale) * eased
|
|
150
156
|
}
|
|
151
157
|
|
|
152
158
|
export interface FollowGlideInput {
|
|
@@ -173,7 +179,11 @@ export interface FollowGlideStep {
|
|
|
173
179
|
* @param input - Current visible lag and carried physics velocity.
|
|
174
180
|
* @returns The position advance, its fraction, and next velocity.
|
|
175
181
|
*/
|
|
176
|
-
export function computeFollowStep(
|
|
182
|
+
export function computeFollowStep(
|
|
183
|
+
dtMs: number,
|
|
184
|
+
input: FollowGlideInput,
|
|
185
|
+
tuning: StreamDebugTuning = DEFAULT_STREAM_DEBUG_TUNING,
|
|
186
|
+
): FollowGlideStep {
|
|
177
187
|
if (input.lag <= 0.1 || dtMs <= 0) {
|
|
178
188
|
return { advancePx: 0, lerpStep: 0, velocityPxPerSec: 0 }
|
|
179
189
|
}
|
|
@@ -186,8 +196,8 @@ export function computeFollowStep(dtMs: number, input: FollowGlideInput): Follow
|
|
|
186
196
|
for (let slice = 0; slice < slices; slice += 1) {
|
|
187
197
|
for (let substep = 0; substep < FOLLOW_SPRING_SUBSTEPS; substep += 1) {
|
|
188
198
|
const acceleration = (
|
|
189
|
-
|
|
190
|
-
|
|
199
|
+
tuning.springStiffness * lag - tuning.springDamping * velocity
|
|
200
|
+
) / tuning.springMass
|
|
191
201
|
velocity = Math.max(0, velocity + acceleration * subDt)
|
|
192
202
|
const advance = velocity * subDt
|
|
193
203
|
if (advance >= lag) {
|
|
@@ -206,12 +216,36 @@ function resizeProxyOf(port: HTMLElement): HTMLElement | null {
|
|
|
206
216
|
return port.querySelector('[data-chat-transcript]') ?? port.querySelector('[data-chat-flow]')
|
|
207
217
|
}
|
|
208
218
|
|
|
209
|
-
/**
|
|
210
|
-
|
|
219
|
+
/**
|
|
220
|
+
* Outermost message surfaces; nested tool rows ride their parent.
|
|
221
|
+
*
|
|
222
|
+
* Another plugin may insert its own element as a flow sibling of the Chat rows
|
|
223
|
+
* (meow-memory's fold bar is one; so is this plugin's own fold summary row).
|
|
224
|
+
* Such a row carries no `data-chat-anchor-key`, so selecting only anchored
|
|
225
|
+
* rows would shift the conversation while leaving the foreign row at its
|
|
226
|
+
* natural offset, letting the shifted rows paint over it. Every direct flow
|
|
227
|
+
* child therefore rides the same transform, keeping the visual order of the
|
|
228
|
+
* column intact.
|
|
229
|
+
*/
|
|
230
|
+
export function shiftSurfacesOf(port: HTMLElement): HTMLElement[] {
|
|
211
231
|
const transcript = port.querySelector<HTMLElement>('[data-chat-transcript]')
|
|
212
232
|
if (transcript !== null) return [transcript]
|
|
213
|
-
|
|
233
|
+
const anchored = [...port.querySelectorAll<HTMLElement>('[data-chat-anchor-key]')]
|
|
214
234
|
.filter(row => row.parentElement?.closest('[data-chat-anchor-key]') === null)
|
|
235
|
+
const flow = port.querySelector<HTMLElement>('[data-chat-flow]')
|
|
236
|
+
if (flow === null) return anchored
|
|
237
|
+
const status = turnStatusOf(port)
|
|
238
|
+
const anchoredSet = new Set(anchored)
|
|
239
|
+
// One document-order pass: an anchored row, or a foreign child that contains
|
|
240
|
+
// no anchored row of its own (a wrapper around real rows would double-shift
|
|
241
|
+
// the rows inside it).
|
|
242
|
+
const __dbg = [...flow.children].filter((child): child is HTMLElement =>
|
|
243
|
+
child instanceof HTMLElement
|
|
244
|
+
&& child !== status
|
|
245
|
+
&& (anchoredSet.has(child) || child.querySelector('[data-chat-anchor-key]') === null))
|
|
246
|
+
console.log('[engine] reached end of shiftSurfacesOf, n=', __dbg.length)
|
|
247
|
+
;(globalThis as Record<string, unknown>).__dshssProbe = __dbg.map(e => `${e.tagName}:${e.getAttribute('data-chat-anchor-key') ?? e.className}`)
|
|
248
|
+
return __dbg
|
|
215
249
|
}
|
|
216
250
|
|
|
217
251
|
function currentShiftOf(element: HTMLElement): number {
|
|
@@ -267,6 +301,7 @@ interface FollowRunway {
|
|
|
267
301
|
readonly offset: number
|
|
268
302
|
readonly original: string
|
|
269
303
|
readonly property: 'marginBottom' | 'marginTop'
|
|
304
|
+
readonly requestedPx: number
|
|
270
305
|
}
|
|
271
306
|
|
|
272
307
|
/**
|
|
@@ -369,7 +404,11 @@ function migrateLegacyRunway(
|
|
|
369
404
|
if (migrated) invalidatePaintLimit(port)
|
|
370
405
|
}
|
|
371
406
|
|
|
372
|
-
function ensureRunway(
|
|
407
|
+
function ensureRunway(
|
|
408
|
+
port: HTMLElement,
|
|
409
|
+
surfaces: readonly HTMLElement[],
|
|
410
|
+
runwayPx = FOLLOW_STATUS_RUNWAY_PX,
|
|
411
|
+
): void {
|
|
373
412
|
const status = turnStatusOf(port)
|
|
374
413
|
const composer = port.querySelector<HTMLElement>('[data-composer-seat]')
|
|
375
414
|
migrateLegacyRunway(port, surfaces, status, composer)
|
|
@@ -378,7 +417,7 @@ function ensureRunway(port: HTMLElement, surfaces: readonly HTMLElement[]): void
|
|
|
378
417
|
// applyVisual keeps every surface in normal flow, so adding status margin
|
|
379
418
|
// would expose the whole runway as empty space below a short/early Think.
|
|
380
419
|
const naturalHeight = Math.max(0, port.scrollHeight - runwayOffsetOf(port))
|
|
381
|
-
if (port.clientHeight <= 0 || naturalHeight <= port.clientHeight) {
|
|
420
|
+
if (runwayPx <= 0 || port.clientHeight <= 0 || naturalHeight <= port.clientHeight) {
|
|
382
421
|
restoreRunway(port)
|
|
383
422
|
return
|
|
384
423
|
}
|
|
@@ -391,15 +430,17 @@ function ensureRunway(port: HTMLElement, surfaces: readonly HTMLElement[]): void
|
|
|
391
430
|
}
|
|
392
431
|
const element = target.element
|
|
393
432
|
const current = followRunways.get(port)
|
|
394
|
-
if (current?.element === element
|
|
433
|
+
if (current?.element === element
|
|
434
|
+
&& current.property === target.property
|
|
435
|
+
&& current.requestedPx === runwayPx) return
|
|
395
436
|
restoreRunway(port)
|
|
396
437
|
const beforeHeight = port.scrollHeight
|
|
397
438
|
const original = element.style[target.property]
|
|
398
439
|
element.style[target.property] = original === ''
|
|
399
|
-
? `${
|
|
400
|
-
: `calc(${original} + ${
|
|
440
|
+
? `${runwayPx}px`
|
|
441
|
+
: `calc(${original} + ${runwayPx}px)`
|
|
401
442
|
const offset = Math.max(0, port.scrollHeight - beforeHeight)
|
|
402
|
-
followRunways.set(port, { element, offset, property: target.property, original })
|
|
443
|
+
followRunways.set(port, { element, offset, property: target.property, original, requestedPx: runwayPx })
|
|
403
444
|
invalidatePaintLimit(port)
|
|
404
445
|
}
|
|
405
446
|
|
|
@@ -499,9 +540,10 @@ function applyVisual(
|
|
|
499
540
|
animatedH: number,
|
|
500
541
|
reservePx: number,
|
|
501
542
|
velocityPxPerSec = 0,
|
|
543
|
+
runwayPx = FOLLOW_STATUS_RUNWAY_PX,
|
|
502
544
|
): number {
|
|
503
545
|
const surfaces = shiftSurfacesOf(port)
|
|
504
|
-
ensureRunway(port, surfaces)
|
|
546
|
+
ensureRunway(port, surfaces, runwayPx)
|
|
505
547
|
const contentHeight = Math.max(0, port.scrollHeight)
|
|
506
548
|
const runwayOffset = runwayOffsetOf(port)
|
|
507
549
|
const targetHeight = Math.max(0, contentHeight - runwayOffset)
|
|
@@ -680,11 +722,12 @@ export function useConversationFollow(
|
|
|
680
722
|
|
|
681
723
|
const updateRevealScale = (next: HTMLElement, elapsedMs: number, urgent = false): void => {
|
|
682
724
|
if (revealScaleRef === undefined) return
|
|
725
|
+
const tuning = debugRuntime.activeTuning()
|
|
683
726
|
const state = followMotionStates.get(next)
|
|
684
727
|
const target = state === undefined
|
|
685
728
|
? 1
|
|
686
|
-
: computeFollowRevealScale(state.lagPx, state.capacityPx, state.constrained)
|
|
687
|
-
const current = Math.min(1, Math.max(
|
|
729
|
+
: computeFollowRevealScale(state.lagPx, state.capacityPx, state.constrained, tuning)
|
|
730
|
+
const current = Math.min(1, Math.max(tuning.backpressureMinScale, revealScaleRef.current))
|
|
688
731
|
if (target < current || urgent) {
|
|
689
732
|
// Slowing affects only future glyph commits, so it can react at once
|
|
690
733
|
// without producing a visual discontinuity in the current frame.
|
|
@@ -699,6 +742,23 @@ export function useConversationFollow(
|
|
|
699
742
|
if (revealScaleRef !== undefined) revealScaleRef.current = 1
|
|
700
743
|
}
|
|
701
744
|
|
|
745
|
+
const reportFollow = (next: HTMLElement, isActive: boolean): void => {
|
|
746
|
+
const state = followMotionStates.get(next)
|
|
747
|
+
debugRuntime.reportFollow(next, {
|
|
748
|
+
lagPx: state?.lagPx ?? Math.max(0, next.scrollHeight - next.clientHeight - next.scrollTop),
|
|
749
|
+
velocityPxPerSec: state?.velocityPxPerSec ?? 0,
|
|
750
|
+
reservePx: state?.reservePx ?? 0,
|
|
751
|
+
capacityPx: state?.capacityPx ?? 0,
|
|
752
|
+
revealScale: revealScaleRef?.current ?? 1,
|
|
753
|
+
following,
|
|
754
|
+
constrained: state?.constrained ?? false,
|
|
755
|
+
scrollTop: next.scrollTop,
|
|
756
|
+
scrollHeight: next.scrollHeight,
|
|
757
|
+
clientHeight: next.clientHeight,
|
|
758
|
+
active: isActive,
|
|
759
|
+
})
|
|
760
|
+
}
|
|
761
|
+
|
|
702
762
|
const isLeader = (next: HTMLElement): boolean => followLeaders.get(next)?.owner === owner
|
|
703
763
|
|
|
704
764
|
const hold = (next: HTMLElement): void => {
|
|
@@ -717,12 +777,19 @@ export function useConversationFollow(
|
|
|
717
777
|
clearMotion(next)
|
|
718
778
|
followMotionStates.delete(next)
|
|
719
779
|
releaseRevealScale()
|
|
780
|
+
debugRuntime.reportFollow(next, null)
|
|
720
781
|
}
|
|
721
782
|
}
|
|
722
783
|
|
|
723
784
|
const handBackVisual = (next: HTMLElement): void => {
|
|
724
785
|
const shift = currentShiftOf(shiftSurfacesOf(next).at(-1) ?? next)
|
|
725
786
|
const visualTop = Math.max(0, next.scrollTop - Math.max(0, shift))
|
|
787
|
+
// The predictive runway only has meaning while this follower owns the
|
|
788
|
+
// floor. Remove it before choosing the reader's landing point; keeping
|
|
789
|
+
// it through the release paints a transient natural gap + 48px blank
|
|
790
|
+
// block until the next follow frame restores an equal transform.
|
|
791
|
+
clearMotion(next)
|
|
792
|
+
restoreRunway(next)
|
|
726
793
|
const floor = Math.max(0, next.scrollHeight - next.clientHeight)
|
|
727
794
|
// The host keeps its own bottom-follow bit while the reader remains in
|
|
728
795
|
// its 25px slack band. Land one pixel beyond that band so even a light
|
|
@@ -760,8 +827,10 @@ export function useConversationFollow(
|
|
|
760
827
|
const restoreBeforePaint = (): void => {
|
|
761
828
|
if (!following || port === null || !isLeader(port)) return
|
|
762
829
|
invalidatePaintLimit(port)
|
|
763
|
-
|
|
830
|
+
const tuning = debugRuntime.activeTuning()
|
|
831
|
+
animatedH = applyVisual(port, animatedH, reservePx, velocityPxPerSec, tuning.runwayPx)
|
|
764
832
|
updateRevealScale(port, 0, true)
|
|
833
|
+
reportFollow(port, activeRef.current)
|
|
765
834
|
}
|
|
766
835
|
|
|
767
836
|
const bindPort = (next: HTMLElement): void => {
|
|
@@ -790,6 +859,7 @@ export function useConversationFollow(
|
|
|
790
859
|
// frames would open paint room more slowly precisely when it is needed.
|
|
791
860
|
const elapsedMs = Math.max(0.001, now - last)
|
|
792
861
|
const dt = Math.min(FOLLOW_MAX_FRAME_MS, elapsedMs)
|
|
862
|
+
const tuning = debugRuntime.activeTuning()
|
|
793
863
|
last = now
|
|
794
864
|
const root = rootRef.current
|
|
795
865
|
if (root === null) return
|
|
@@ -836,8 +906,9 @@ export function useConversationFollow(
|
|
|
836
906
|
if (following) {
|
|
837
907
|
hold(nextPort)
|
|
838
908
|
if (isLeader(nextPort)) {
|
|
839
|
-
animatedH = applyVisual(nextPort, animatedH, reservePx, velocityPxPerSec)
|
|
909
|
+
animatedH = applyVisual(nextPort, animatedH, reservePx, velocityPxPerSec, tuning.runwayPx)
|
|
840
910
|
updateRevealScale(nextPort, elapsedMs)
|
|
911
|
+
reportFollow(nextPort, activeRef.current)
|
|
841
912
|
const runwayOffset = runwayOffsetOf(nextPort)
|
|
842
913
|
const entranceLag = Math.max(0, nextPort.scrollHeight - animatedH - runwayOffset)
|
|
843
914
|
if (entranceLag <= FOLLOW_SETTLE_EPSILON_PX) finishEntrance()
|
|
@@ -852,7 +923,10 @@ export function useConversationFollow(
|
|
|
852
923
|
}
|
|
853
924
|
|
|
854
925
|
const repinSlack = readerReleased ? FOLLOW_REPIN_PX : FOLLOW_SLACK_PX
|
|
855
|
-
|
|
926
|
+
const returnedToFloor = readerReleased
|
|
927
|
+
&& !readerGestureIntent
|
|
928
|
+
&& reportedLag <= FOLLOW_REPIN_PX
|
|
929
|
+
if (!following && (!interacting || returnedToFloor) && reportedLag <= repinSlack) {
|
|
856
930
|
following = true
|
|
857
931
|
readerReleased = false
|
|
858
932
|
animatedH = extent
|
|
@@ -876,6 +950,7 @@ export function useConversationFollow(
|
|
|
876
950
|
|
|
877
951
|
if (!activeRef.current || !following) {
|
|
878
952
|
followScrollLedgers.set(nextPort, nextPort.scrollTop)
|
|
953
|
+
reportFollow(nextPort, activeRef.current)
|
|
879
954
|
return
|
|
880
955
|
}
|
|
881
956
|
hold(nextPort)
|
|
@@ -893,14 +968,14 @@ export function useConversationFollow(
|
|
|
893
968
|
const predictGrowth = predictiveRef?.current ?? predictive
|
|
894
969
|
const reserveTarget = !predictGrowth
|
|
895
970
|
? 0
|
|
896
|
-
: computeFollowReserve(speedCpsRef.current,
|
|
897
|
-
const reserveStep = 1 - Math.exp(-elapsedMs /
|
|
971
|
+
: computeFollowReserve(speedCpsRef.current, tuning.runwayPx)
|
|
972
|
+
const reserveStep = 1 - Math.exp(-elapsedMs / tuning.reserveResponseMs)
|
|
898
973
|
reservePx += (reserveTarget - reservePx) * reserveStep
|
|
899
974
|
const step = computeFollowStep(dt, {
|
|
900
975
|
lag,
|
|
901
976
|
speedEma: speedCpsRef.current,
|
|
902
977
|
velocityPxPerSec,
|
|
903
|
-
})
|
|
978
|
+
}, tuning)
|
|
904
979
|
if (lag <= 0.1) {
|
|
905
980
|
animatedH = contentHeight - runwayOffset
|
|
906
981
|
velocityPxPerSec = 0
|
|
@@ -912,8 +987,9 @@ export function useConversationFollow(
|
|
|
912
987
|
)
|
|
913
988
|
velocityPxPerSec = step.velocityPxPerSec
|
|
914
989
|
}
|
|
915
|
-
animatedH = applyVisual(nextPort, animatedH, reservePx, velocityPxPerSec)
|
|
990
|
+
animatedH = applyVisual(nextPort, animatedH, reservePx, velocityPxPerSec, tuning.runwayPx)
|
|
916
991
|
updateRevealScale(nextPort, elapsedMs)
|
|
992
|
+
reportFollow(nextPort, true)
|
|
917
993
|
const remainingEntranceLag = Math.max(
|
|
918
994
|
0,
|
|
919
995
|
nextPort.scrollHeight - animatedH - runwayOffsetOf(nextPort),
|
|
@@ -943,6 +1019,7 @@ export function useConversationFollow(
|
|
|
943
1019
|
clearVisual(host)
|
|
944
1020
|
followLeaders.delete(host)
|
|
945
1021
|
releaseRevealScale()
|
|
1022
|
+
debugRuntime.reportFollow(host, null)
|
|
946
1023
|
return
|
|
947
1024
|
}
|
|
948
1025
|
if (preserveReader) {
|
|
@@ -950,13 +1027,15 @@ export function useConversationFollow(
|
|
|
950
1027
|
clearVisual(host)
|
|
951
1028
|
followLeaders.delete(host)
|
|
952
1029
|
releaseRevealScale()
|
|
1030
|
+
debugRuntime.reportFollow(host, null)
|
|
953
1031
|
return
|
|
954
1032
|
}
|
|
955
1033
|
|
|
956
1034
|
// Completion can land the final Tool/command height in this same
|
|
957
1035
|
// commit. Preserve the logical extent and drain it after unmount instead
|
|
958
1036
|
// of clearing the compositor state before the first settled paint.
|
|
959
|
-
|
|
1037
|
+
const completionTuning = debugRuntime.activeTuning()
|
|
1038
|
+
ensureRunway(host, shiftSurfacesOf(host), completionTuning.runwayPx)
|
|
960
1039
|
const completionRunway = runwayOffsetOf(host)
|
|
961
1040
|
const completionMinimumLag = Math.max(0, reservePx)
|
|
962
1041
|
animatedH = Math.min(
|
|
@@ -964,13 +1043,15 @@ export function useConversationFollow(
|
|
|
964
1043
|
host.scrollHeight - completionRunway - completionMinimumLag,
|
|
965
1044
|
)
|
|
966
1045
|
settleAtFloor(host)
|
|
967
|
-
animatedH = applyVisual(host, animatedH, reservePx, velocityPxPerSec)
|
|
1046
|
+
animatedH = applyVisual(host, animatedH, reservePx, velocityPxPerSec, completionTuning.runwayPx)
|
|
1047
|
+
reportFollow(host, false)
|
|
968
1048
|
const runwayOffset = runwayOffsetOf(host)
|
|
969
1049
|
const remainingLag = Math.max(0, host.scrollHeight - animatedH - runwayOffset)
|
|
970
1050
|
if (remainingLag <= FOLLOW_SETTLE_EPSILON_PX && reservePx <= FOLLOW_SETTLE_EPSILON_PX) {
|
|
971
1051
|
finishAtNaturalFloor(host)
|
|
972
1052
|
followLeaders.delete(host)
|
|
973
1053
|
releaseRevealScale()
|
|
1054
|
+
debugRuntime.reportFollow(host, null)
|
|
974
1055
|
return
|
|
975
1056
|
}
|
|
976
1057
|
|
|
@@ -996,14 +1077,16 @@ export function useConversationFollow(
|
|
|
996
1077
|
clearVisual(host)
|
|
997
1078
|
followLeaders.delete(host)
|
|
998
1079
|
releaseRevealScale()
|
|
1080
|
+
debugRuntime.reportFollow(host, null)
|
|
999
1081
|
stopSettleListeners()
|
|
1000
1082
|
return
|
|
1001
1083
|
}
|
|
1002
1084
|
const dt = Math.min(FOLLOW_MAX_FRAME_MS, Math.max(0, now - settleLast))
|
|
1085
|
+
const tuning = debugRuntime.activeTuning()
|
|
1003
1086
|
settleLast = now
|
|
1004
1087
|
const runwayOffset = runwayOffsetOf(host)
|
|
1005
1088
|
const lag = Math.max(0, host.scrollHeight - animatedH - runwayOffset)
|
|
1006
|
-
const reserveStep = 1 - Math.exp(-dt /
|
|
1089
|
+
const reserveStep = 1 - Math.exp(-dt / tuning.reserveResponseMs)
|
|
1007
1090
|
reservePx += (0 - reservePx) * reserveStep
|
|
1008
1091
|
if (lag <= FOLLOW_SETTLE_EPSILON_PX && reservePx <= FOLLOW_SETTLE_EPSILON_PX) {
|
|
1009
1092
|
animatedH = host.scrollHeight - runwayOffset
|
|
@@ -1012,6 +1095,7 @@ export function useConversationFollow(
|
|
|
1012
1095
|
finishAtNaturalFloor(host)
|
|
1013
1096
|
followLeaders.delete(host)
|
|
1014
1097
|
releaseRevealScale()
|
|
1098
|
+
debugRuntime.reportFollow(host, null)
|
|
1015
1099
|
stopSettleListeners()
|
|
1016
1100
|
return
|
|
1017
1101
|
}
|
|
@@ -1019,7 +1103,7 @@ export function useConversationFollow(
|
|
|
1019
1103
|
lag,
|
|
1020
1104
|
speedEma: speedCpsRef.current,
|
|
1021
1105
|
velocityPxPerSec,
|
|
1022
|
-
})
|
|
1106
|
+
}, tuning)
|
|
1023
1107
|
// The temporary runway is visually neutral only while an equal lag
|
|
1024
1108
|
// remains in the transform. Do not let the spring outrun the runway's
|
|
1025
1109
|
// closing reserve or cleanup would reveal an overshoot and rebound.
|
|
@@ -1030,7 +1114,8 @@ export function useConversationFollow(
|
|
|
1030
1114
|
)
|
|
1031
1115
|
velocityPxPerSec = step.velocityPxPerSec
|
|
1032
1116
|
settleAtFloor(host)
|
|
1033
|
-
animatedH = applyVisual(host, animatedH, reservePx, velocityPxPerSec)
|
|
1117
|
+
animatedH = applyVisual(host, animatedH, reservePx, velocityPxPerSec, tuning.runwayPx)
|
|
1118
|
+
reportFollow(host, false)
|
|
1034
1119
|
requestAnimationFrame(settleFrame)
|
|
1035
1120
|
}
|
|
1036
1121
|
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
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
if (reveal)
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
}
|