dsh-smooth-stream 0.3.3 → 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 +23 -18
- package/README.md +23 -18
- package/lib/client.js +2343 -175
- package/lib/client.js.map +1 -1
- package/lib/index.js +123 -6
- package/package.json +15 -10
- 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/TypewriterAssistantNodeView.tsx +226 -49
- 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 +160 -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,9 +282,21 @@ 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
|
+
/**
|
|
289
|
+
* The plugin can be reinjected without replacing the conversation DOM. Keep
|
|
290
|
+
* runway ownership in the page realm so a fresh bundle adopts the existing
|
|
291
|
+
* margin instead of treating it as host layout and adding another 48px.
|
|
292
|
+
*/
|
|
293
|
+
const FOLLOW_RUNWAYS_SYMBOL = Symbol.for('dsh-smooth-stream.follow-runways')
|
|
294
|
+
const followRunwayRegistry = globalThis as typeof globalThis & {
|
|
295
|
+
[key: symbol]: WeakMap<HTMLElement, FollowRunway> | undefined
|
|
296
|
+
}
|
|
297
|
+
const followRunways = followRunwayRegistry[FOLLOW_RUNWAYS_SYMBOL]
|
|
298
|
+
?? new WeakMap<HTMLElement, FollowRunway>()
|
|
299
|
+
followRunwayRegistry[FOLLOW_RUNWAYS_SYMBOL] = followRunways
|
|
273
300
|
|
|
274
301
|
interface FollowPaintLimit {
|
|
275
302
|
readonly clientHeight: number
|
|
@@ -316,9 +343,65 @@ function restoreRunway(port: HTMLElement): void {
|
|
|
316
343
|
invalidatePaintLimit(port)
|
|
317
344
|
}
|
|
318
345
|
|
|
319
|
-
function
|
|
346
|
+
function isLegacyRunway(value: string): boolean {
|
|
347
|
+
if (value === '') return false
|
|
348
|
+
const terms = [...value.matchAll(/([\d.]+)px/g)]
|
|
349
|
+
if (terms.length === 0 || value.replaceAll(/calc|px|[\d.+()\s]/g, '') !== '') return false
|
|
350
|
+
return terms.every(([, raw]) => {
|
|
351
|
+
const px = Number(raw)
|
|
352
|
+
return Number.isFinite(px)
|
|
353
|
+
&& px >= FOLLOW_STATUS_RUNWAY_PX
|
|
354
|
+
&& Math.abs(px % FOLLOW_STATUS_RUNWAY_PX) <= Number.EPSILON
|
|
355
|
+
})
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Remove unowned runway residue written by v0.3.3 and earlier bundles. */
|
|
359
|
+
function migrateLegacyRunway(
|
|
360
|
+
port: HTMLElement,
|
|
361
|
+
surfaces: readonly HTMLElement[],
|
|
362
|
+
status: HTMLElement | null,
|
|
363
|
+
composer: HTMLElement | null,
|
|
364
|
+
): void {
|
|
365
|
+
if (followRunways.has(port)) return
|
|
366
|
+
let migrated = false
|
|
367
|
+
if (status !== null && isLegacyRunway(status.style.marginTop)) {
|
|
368
|
+
// Harness TurnStatus has no inline margin; exact 48px multiples here are
|
|
369
|
+
// values emitted by the old runway writer, including reload accumulation.
|
|
370
|
+
status.style.marginTop = ''
|
|
371
|
+
migrated = true
|
|
372
|
+
}
|
|
373
|
+
const last = surfaces.at(-1)
|
|
374
|
+
if (
|
|
375
|
+
status === null
|
|
376
|
+
&& composer !== null
|
|
377
|
+
&& last !== undefined
|
|
378
|
+
&& isLegacyRunway(last.style.marginBottom)
|
|
379
|
+
) {
|
|
380
|
+
// Without TurnStatus the old writer used the current final message as its
|
|
381
|
+
// completion runway. Limit migration to that same target topology.
|
|
382
|
+
last.style.marginBottom = ''
|
|
383
|
+
migrated = true
|
|
384
|
+
}
|
|
385
|
+
if (migrated) invalidatePaintLimit(port)
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function ensureRunway(
|
|
389
|
+
port: HTMLElement,
|
|
390
|
+
surfaces: readonly HTMLElement[],
|
|
391
|
+
runwayPx = FOLLOW_STATUS_RUNWAY_PX,
|
|
392
|
+
): void {
|
|
320
393
|
const status = turnStatusOf(port)
|
|
321
394
|
const composer = port.querySelector<HTMLElement>('[data-composer-seat]')
|
|
395
|
+
migrateLegacyRunway(port, surfaces, status, composer)
|
|
396
|
+
// A runway is useful only after the natural conversation already has a
|
|
397
|
+
// scroll floor for its equal message transform to ride. Before that point
|
|
398
|
+
// applyVisual keeps every surface in normal flow, so adding status margin
|
|
399
|
+
// would expose the whole runway as empty space below a short/early Think.
|
|
400
|
+
const naturalHeight = Math.max(0, port.scrollHeight - runwayOffsetOf(port))
|
|
401
|
+
if (runwayPx <= 0 || port.clientHeight <= 0 || naturalHeight <= port.clientHeight) {
|
|
402
|
+
restoreRunway(port)
|
|
403
|
+
return
|
|
404
|
+
}
|
|
322
405
|
const target = status === null
|
|
323
406
|
? { element: composer === null ? undefined : surfaces.at(-1), property: 'marginBottom' as const }
|
|
324
407
|
: { element: status, property: 'marginTop' as const }
|
|
@@ -328,15 +411,17 @@ function ensureRunway(port: HTMLElement, surfaces: readonly HTMLElement[]): void
|
|
|
328
411
|
}
|
|
329
412
|
const element = target.element
|
|
330
413
|
const current = followRunways.get(port)
|
|
331
|
-
if (current?.element === element
|
|
414
|
+
if (current?.element === element
|
|
415
|
+
&& current.property === target.property
|
|
416
|
+
&& current.requestedPx === runwayPx) return
|
|
332
417
|
restoreRunway(port)
|
|
333
418
|
const beforeHeight = port.scrollHeight
|
|
334
419
|
const original = element.style[target.property]
|
|
335
420
|
element.style[target.property] = original === ''
|
|
336
|
-
? `${
|
|
337
|
-
: `calc(${original} + ${
|
|
421
|
+
? `${runwayPx}px`
|
|
422
|
+
: `calc(${original} + ${runwayPx}px)`
|
|
338
423
|
const offset = Math.max(0, port.scrollHeight - beforeHeight)
|
|
339
|
-
followRunways.set(port, { element, offset, property: target.property, original })
|
|
424
|
+
followRunways.set(port, { element, offset, property: target.property, original, requestedPx: runwayPx })
|
|
340
425
|
invalidatePaintLimit(port)
|
|
341
426
|
}
|
|
342
427
|
|
|
@@ -436,9 +521,10 @@ function applyVisual(
|
|
|
436
521
|
animatedH: number,
|
|
437
522
|
reservePx: number,
|
|
438
523
|
velocityPxPerSec = 0,
|
|
524
|
+
runwayPx = FOLLOW_STATUS_RUNWAY_PX,
|
|
439
525
|
): number {
|
|
440
526
|
const surfaces = shiftSurfacesOf(port)
|
|
441
|
-
ensureRunway(port, surfaces)
|
|
527
|
+
ensureRunway(port, surfaces, runwayPx)
|
|
442
528
|
const contentHeight = Math.max(0, port.scrollHeight)
|
|
443
529
|
const runwayOffset = runwayOffsetOf(port)
|
|
444
530
|
const targetHeight = Math.max(0, contentHeight - runwayOffset)
|
|
@@ -617,11 +703,12 @@ export function useConversationFollow(
|
|
|
617
703
|
|
|
618
704
|
const updateRevealScale = (next: HTMLElement, elapsedMs: number, urgent = false): void => {
|
|
619
705
|
if (revealScaleRef === undefined) return
|
|
706
|
+
const tuning = debugRuntime.activeTuning()
|
|
620
707
|
const state = followMotionStates.get(next)
|
|
621
708
|
const target = state === undefined
|
|
622
709
|
? 1
|
|
623
|
-
: computeFollowRevealScale(state.lagPx, state.capacityPx, state.constrained)
|
|
624
|
-
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))
|
|
625
712
|
if (target < current || urgent) {
|
|
626
713
|
// Slowing affects only future glyph commits, so it can react at once
|
|
627
714
|
// without producing a visual discontinuity in the current frame.
|
|
@@ -636,6 +723,23 @@ export function useConversationFollow(
|
|
|
636
723
|
if (revealScaleRef !== undefined) revealScaleRef.current = 1
|
|
637
724
|
}
|
|
638
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
|
+
|
|
639
743
|
const isLeader = (next: HTMLElement): boolean => followLeaders.get(next)?.owner === owner
|
|
640
744
|
|
|
641
745
|
const hold = (next: HTMLElement): void => {
|
|
@@ -654,12 +758,19 @@ export function useConversationFollow(
|
|
|
654
758
|
clearMotion(next)
|
|
655
759
|
followMotionStates.delete(next)
|
|
656
760
|
releaseRevealScale()
|
|
761
|
+
debugRuntime.reportFollow(next, null)
|
|
657
762
|
}
|
|
658
763
|
}
|
|
659
764
|
|
|
660
765
|
const handBackVisual = (next: HTMLElement): void => {
|
|
661
766
|
const shift = currentShiftOf(shiftSurfacesOf(next).at(-1) ?? next)
|
|
662
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)
|
|
663
774
|
const floor = Math.max(0, next.scrollHeight - next.clientHeight)
|
|
664
775
|
// The host keeps its own bottom-follow bit while the reader remains in
|
|
665
776
|
// its 25px slack band. Land one pixel beyond that band so even a light
|
|
@@ -697,8 +808,10 @@ export function useConversationFollow(
|
|
|
697
808
|
const restoreBeforePaint = (): void => {
|
|
698
809
|
if (!following || port === null || !isLeader(port)) return
|
|
699
810
|
invalidatePaintLimit(port)
|
|
700
|
-
|
|
811
|
+
const tuning = debugRuntime.activeTuning()
|
|
812
|
+
animatedH = applyVisual(port, animatedH, reservePx, velocityPxPerSec, tuning.runwayPx)
|
|
701
813
|
updateRevealScale(port, 0, true)
|
|
814
|
+
reportFollow(port, activeRef.current)
|
|
702
815
|
}
|
|
703
816
|
|
|
704
817
|
const bindPort = (next: HTMLElement): void => {
|
|
@@ -727,6 +840,7 @@ export function useConversationFollow(
|
|
|
727
840
|
// frames would open paint room more slowly precisely when it is needed.
|
|
728
841
|
const elapsedMs = Math.max(0.001, now - last)
|
|
729
842
|
const dt = Math.min(FOLLOW_MAX_FRAME_MS, elapsedMs)
|
|
843
|
+
const tuning = debugRuntime.activeTuning()
|
|
730
844
|
last = now
|
|
731
845
|
const root = rootRef.current
|
|
732
846
|
if (root === null) return
|
|
@@ -773,8 +887,9 @@ export function useConversationFollow(
|
|
|
773
887
|
if (following) {
|
|
774
888
|
hold(nextPort)
|
|
775
889
|
if (isLeader(nextPort)) {
|
|
776
|
-
animatedH = applyVisual(nextPort, animatedH, reservePx, velocityPxPerSec)
|
|
890
|
+
animatedH = applyVisual(nextPort, animatedH, reservePx, velocityPxPerSec, tuning.runwayPx)
|
|
777
891
|
updateRevealScale(nextPort, elapsedMs)
|
|
892
|
+
reportFollow(nextPort, activeRef.current)
|
|
778
893
|
const runwayOffset = runwayOffsetOf(nextPort)
|
|
779
894
|
const entranceLag = Math.max(0, nextPort.scrollHeight - animatedH - runwayOffset)
|
|
780
895
|
if (entranceLag <= FOLLOW_SETTLE_EPSILON_PX) finishEntrance()
|
|
@@ -789,7 +904,10 @@ export function useConversationFollow(
|
|
|
789
904
|
}
|
|
790
905
|
|
|
791
906
|
const repinSlack = readerReleased ? FOLLOW_REPIN_PX : FOLLOW_SLACK_PX
|
|
792
|
-
|
|
907
|
+
const returnedToFloor = readerReleased
|
|
908
|
+
&& !readerGestureIntent
|
|
909
|
+
&& reportedLag <= FOLLOW_REPIN_PX
|
|
910
|
+
if (!following && (!interacting || returnedToFloor) && reportedLag <= repinSlack) {
|
|
793
911
|
following = true
|
|
794
912
|
readerReleased = false
|
|
795
913
|
animatedH = extent
|
|
@@ -813,6 +931,7 @@ export function useConversationFollow(
|
|
|
813
931
|
|
|
814
932
|
if (!activeRef.current || !following) {
|
|
815
933
|
followScrollLedgers.set(nextPort, nextPort.scrollTop)
|
|
934
|
+
reportFollow(nextPort, activeRef.current)
|
|
816
935
|
return
|
|
817
936
|
}
|
|
818
937
|
hold(nextPort)
|
|
@@ -830,14 +949,14 @@ export function useConversationFollow(
|
|
|
830
949
|
const predictGrowth = predictiveRef?.current ?? predictive
|
|
831
950
|
const reserveTarget = !predictGrowth
|
|
832
951
|
? 0
|
|
833
|
-
: computeFollowReserve(speedCpsRef.current,
|
|
834
|
-
const reserveStep = 1 - Math.exp(-elapsedMs /
|
|
952
|
+
: computeFollowReserve(speedCpsRef.current, tuning.runwayPx)
|
|
953
|
+
const reserveStep = 1 - Math.exp(-elapsedMs / tuning.reserveResponseMs)
|
|
835
954
|
reservePx += (reserveTarget - reservePx) * reserveStep
|
|
836
955
|
const step = computeFollowStep(dt, {
|
|
837
956
|
lag,
|
|
838
957
|
speedEma: speedCpsRef.current,
|
|
839
958
|
velocityPxPerSec,
|
|
840
|
-
})
|
|
959
|
+
}, tuning)
|
|
841
960
|
if (lag <= 0.1) {
|
|
842
961
|
animatedH = contentHeight - runwayOffset
|
|
843
962
|
velocityPxPerSec = 0
|
|
@@ -849,8 +968,9 @@ export function useConversationFollow(
|
|
|
849
968
|
)
|
|
850
969
|
velocityPxPerSec = step.velocityPxPerSec
|
|
851
970
|
}
|
|
852
|
-
animatedH = applyVisual(nextPort, animatedH, reservePx, velocityPxPerSec)
|
|
971
|
+
animatedH = applyVisual(nextPort, animatedH, reservePx, velocityPxPerSec, tuning.runwayPx)
|
|
853
972
|
updateRevealScale(nextPort, elapsedMs)
|
|
973
|
+
reportFollow(nextPort, true)
|
|
854
974
|
const remainingEntranceLag = Math.max(
|
|
855
975
|
0,
|
|
856
976
|
nextPort.scrollHeight - animatedH - runwayOffsetOf(nextPort),
|
|
@@ -880,6 +1000,7 @@ export function useConversationFollow(
|
|
|
880
1000
|
clearVisual(host)
|
|
881
1001
|
followLeaders.delete(host)
|
|
882
1002
|
releaseRevealScale()
|
|
1003
|
+
debugRuntime.reportFollow(host, null)
|
|
883
1004
|
return
|
|
884
1005
|
}
|
|
885
1006
|
if (preserveReader) {
|
|
@@ -887,13 +1008,15 @@ export function useConversationFollow(
|
|
|
887
1008
|
clearVisual(host)
|
|
888
1009
|
followLeaders.delete(host)
|
|
889
1010
|
releaseRevealScale()
|
|
1011
|
+
debugRuntime.reportFollow(host, null)
|
|
890
1012
|
return
|
|
891
1013
|
}
|
|
892
1014
|
|
|
893
1015
|
// Completion can land the final Tool/command height in this same
|
|
894
1016
|
// commit. Preserve the logical extent and drain it after unmount instead
|
|
895
1017
|
// of clearing the compositor state before the first settled paint.
|
|
896
|
-
|
|
1018
|
+
const completionTuning = debugRuntime.activeTuning()
|
|
1019
|
+
ensureRunway(host, shiftSurfacesOf(host), completionTuning.runwayPx)
|
|
897
1020
|
const completionRunway = runwayOffsetOf(host)
|
|
898
1021
|
const completionMinimumLag = Math.max(0, reservePx)
|
|
899
1022
|
animatedH = Math.min(
|
|
@@ -901,13 +1024,15 @@ export function useConversationFollow(
|
|
|
901
1024
|
host.scrollHeight - completionRunway - completionMinimumLag,
|
|
902
1025
|
)
|
|
903
1026
|
settleAtFloor(host)
|
|
904
|
-
animatedH = applyVisual(host, animatedH, reservePx, velocityPxPerSec)
|
|
1027
|
+
animatedH = applyVisual(host, animatedH, reservePx, velocityPxPerSec, completionTuning.runwayPx)
|
|
1028
|
+
reportFollow(host, false)
|
|
905
1029
|
const runwayOffset = runwayOffsetOf(host)
|
|
906
1030
|
const remainingLag = Math.max(0, host.scrollHeight - animatedH - runwayOffset)
|
|
907
1031
|
if (remainingLag <= FOLLOW_SETTLE_EPSILON_PX && reservePx <= FOLLOW_SETTLE_EPSILON_PX) {
|
|
908
1032
|
finishAtNaturalFloor(host)
|
|
909
1033
|
followLeaders.delete(host)
|
|
910
1034
|
releaseRevealScale()
|
|
1035
|
+
debugRuntime.reportFollow(host, null)
|
|
911
1036
|
return
|
|
912
1037
|
}
|
|
913
1038
|
|
|
@@ -933,14 +1058,16 @@ export function useConversationFollow(
|
|
|
933
1058
|
clearVisual(host)
|
|
934
1059
|
followLeaders.delete(host)
|
|
935
1060
|
releaseRevealScale()
|
|
1061
|
+
debugRuntime.reportFollow(host, null)
|
|
936
1062
|
stopSettleListeners()
|
|
937
1063
|
return
|
|
938
1064
|
}
|
|
939
1065
|
const dt = Math.min(FOLLOW_MAX_FRAME_MS, Math.max(0, now - settleLast))
|
|
1066
|
+
const tuning = debugRuntime.activeTuning()
|
|
940
1067
|
settleLast = now
|
|
941
1068
|
const runwayOffset = runwayOffsetOf(host)
|
|
942
1069
|
const lag = Math.max(0, host.scrollHeight - animatedH - runwayOffset)
|
|
943
|
-
const reserveStep = 1 - Math.exp(-dt /
|
|
1070
|
+
const reserveStep = 1 - Math.exp(-dt / tuning.reserveResponseMs)
|
|
944
1071
|
reservePx += (0 - reservePx) * reserveStep
|
|
945
1072
|
if (lag <= FOLLOW_SETTLE_EPSILON_PX && reservePx <= FOLLOW_SETTLE_EPSILON_PX) {
|
|
946
1073
|
animatedH = host.scrollHeight - runwayOffset
|
|
@@ -949,6 +1076,7 @@ export function useConversationFollow(
|
|
|
949
1076
|
finishAtNaturalFloor(host)
|
|
950
1077
|
followLeaders.delete(host)
|
|
951
1078
|
releaseRevealScale()
|
|
1079
|
+
debugRuntime.reportFollow(host, null)
|
|
952
1080
|
stopSettleListeners()
|
|
953
1081
|
return
|
|
954
1082
|
}
|
|
@@ -956,7 +1084,7 @@ export function useConversationFollow(
|
|
|
956
1084
|
lag,
|
|
957
1085
|
speedEma: speedCpsRef.current,
|
|
958
1086
|
velocityPxPerSec,
|
|
959
|
-
})
|
|
1087
|
+
}, tuning)
|
|
960
1088
|
// The temporary runway is visually neutral only while an equal lag
|
|
961
1089
|
// remains in the transform. Do not let the spring outrun the runway's
|
|
962
1090
|
// closing reserve or cleanup would reveal an overshoot and rebound.
|
|
@@ -967,7 +1095,8 @@ export function useConversationFollow(
|
|
|
967
1095
|
)
|
|
968
1096
|
velocityPxPerSec = step.velocityPxPerSec
|
|
969
1097
|
settleAtFloor(host)
|
|
970
|
-
animatedH = applyVisual(host, animatedH, reservePx, velocityPxPerSec)
|
|
1098
|
+
animatedH = applyVisual(host, animatedH, reservePx, velocityPxPerSec, tuning.runwayPx)
|
|
1099
|
+
reportFollow(host, false)
|
|
971
1100
|
requestAnimationFrame(settleFrame)
|
|
972
1101
|
}
|
|
973
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
|
}
|