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.
- package/README.en.md +14 -4
- package/README.md +14 -4
- package/lib/client.js +2111 -140
- 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 +97 -31
- 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,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(
|
|
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
|
|
147
|
+
if (constrained) return tuning.backpressureMinScale
|
|
141
148
|
if (!Number.isFinite(capacityPx)) return 1
|
|
142
|
-
if (capacityPx <= 0) return lagPx > 0 ?
|
|
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
|
|
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 -
|
|
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(
|
|
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
|
-
|
|
190
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
? `${
|
|
400
|
-
: `calc(${original} + ${
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
897
|
-
const reserveStep = 1 - Math.exp(-elapsedMs /
|
|
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
|
-
|
|
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 /
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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
|
-
|
|
124
|
-
QUEUE_BASE_SPEED_CPS + Math.pow(backlog, QUEUE_ACCEL_EXPONENT) *
|
|
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,
|
|
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
|
|