dsh-smooth-stream 0.4.0 → 0.4.2
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 +41 -7
- package/README.md +40 -6
- package/lib/client.js +2923 -2392
- package/lib/client.js.map +1 -1
- package/lib/index.js +1 -1
- package/package.json +17 -13
- package/src/client/AnimatedDisclosure.tsx +14 -1
- package/src/client/FollowHost.tsx +21 -4
- package/src/client/TypewriterAssistantNodeView.module.css +28 -1
- package/src/client/TypewriterAssistantNodeView.tsx +36 -3
- package/src/client/auto-collapse-controller.ts +137 -42
- package/src/client/clientStore.ts +41 -0
- package/src/client/debugRuntime.ts +1 -1
- package/src/client/smooth-stream-card-controller.ts +1 -1
- package/src/client/teleprompterGlide.ts +900 -117
- package/src/client/useSmoothStreamContent.ts +71 -15
- package/src/settings.ts +1 -1
|
@@ -26,10 +26,9 @@
|
|
|
26
26
|
* reader release re-acquires only after returning to the real floor.
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
|
-
import {
|
|
29
|
+
import { useLayoutEffect, useRef, type RefObject } from 'react'
|
|
30
30
|
import { DEFAULT_STREAM_DEBUG_TUNING, type StreamDebugTuning } from '../settings.ts'
|
|
31
31
|
import { debugRuntime } from './debugRuntime.ts'
|
|
32
|
-
import { PROCESSED_CLASS } from './auto-collapse-controller.ts'
|
|
33
32
|
|
|
34
33
|
/**
|
|
35
34
|
* Programmatic follow marker retained for hosts that recognize external
|
|
@@ -45,9 +44,13 @@ export const FOLLOW_SPRING_SUBSTEPS = 4
|
|
|
45
44
|
export const FOLLOW_SPRING_MAX_STEP_MS = 32
|
|
46
45
|
|
|
47
46
|
/** Minimum visible room for one ordinary line-wrap impulse. */
|
|
48
|
-
export const FOLLOW_RESERVE_MIN_PX =
|
|
47
|
+
export const FOLLOW_RESERVE_MIN_PX = 31
|
|
48
|
+
|
|
49
|
+
/** Reveal speeds at or below this are idle; no predictive room is held. */
|
|
50
|
+
export const FOLLOW_RESERVE_IDLE_CPS = 20
|
|
49
51
|
|
|
50
52
|
/** Reveal-speed range produced by the pressure-buffer typewriter. */
|
|
53
|
+
/** Retained for consumers that tune the old pressure threshold. */
|
|
51
54
|
export const FOLLOW_RESERVE_MIN_CPS = 90
|
|
52
55
|
export const FOLLOW_RESERVE_MAX_CPS = 600
|
|
53
56
|
|
|
@@ -85,11 +88,19 @@ export const FOLLOW_HOST_RELEASE_PX = FOLLOW_SLACK_PX + 1
|
|
|
85
88
|
export const FOLLOW_PAINT_GUARD_PX = 1
|
|
86
89
|
|
|
87
90
|
/**
|
|
88
|
-
* Maximum predictive paint room before status/composer chrome.
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
+
* Maximum predictive paint room before status/composer chrome. The
|
|
92
|
+
* feed-forward phase leads the real floor by up to one wrapped line, so the
|
|
93
|
+
* runway must hold steady lag + one line + guard (35 + 26 + 8 ≈ 72) or the
|
|
94
|
+
* leading shift gets clipped against chrome at the wrap cadence.
|
|
95
|
+
*/
|
|
96
|
+
export const FOLLOW_STATUS_RUNWAY_PX = 72
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Duration of the lockstep runway retirement at stream end. The margin and
|
|
100
|
+
* its canceling transform shrink together, so this duration is invisible;
|
|
101
|
+
* it only bounds how long the owned margin lingers.
|
|
91
102
|
*/
|
|
92
|
-
export const
|
|
103
|
+
export const FOLLOW_RUNWAY_RETIRE_MS = 160
|
|
93
104
|
|
|
94
105
|
/** How long a gesture keeps `isUserInteracting` so the next scroll can unpin. */
|
|
95
106
|
export const FOLLOW_GESTURE_MS = 800
|
|
@@ -97,16 +108,48 @@ export const FOLLOW_GESTURE_MS = 800
|
|
|
97
108
|
/** Sub-pixel settle threshold; clearing below this cannot produce a visible rebound. */
|
|
98
109
|
export const FOLLOW_SETTLE_EPSILON_PX = 0.25
|
|
99
110
|
|
|
111
|
+
/** Retained visual-motion budget for compatibility with diagnostics/tests. */
|
|
112
|
+
export const FOLLOW_CATCHUP_MAX_STEP_PX = 8
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Max painted-shift change per frame, in px. A line-wrap adds one line height
|
|
116
|
+
* (24-28px) to the floor in a single layout pass; letting the shift follow it
|
|
117
|
+
* instantly paints that whole step as a hard jump of the newest line. Capping
|
|
118
|
+
* the shift's per-frame change spreads the step across a few frames so the
|
|
119
|
+
* line glides in (slow start) instead of snapping.
|
|
120
|
+
*/
|
|
121
|
+
/**
|
|
122
|
+
* Per-frame bound on the DECAY side of the painted shift only (runway
|
|
123
|
+
* retirement and settle). The growth side is wrap compensation and must stay
|
|
124
|
+
* unlimited — see the clamp site in `applyVisual`.
|
|
125
|
+
*/
|
|
126
|
+
export const FOLLOW_PAINT_SHIFT_MAX_STEP_PX = 8
|
|
127
|
+
|
|
128
|
+
/** Runway size emitted by bundles before the 72px predictive runway. */
|
|
129
|
+
const LEGACY_RUNWAY_PX = 48
|
|
130
|
+
|
|
100
131
|
/** Lowest reveal rate retained while the spring is short on paint room. */
|
|
101
132
|
export const FOLLOW_REVEAL_MIN_SCALE = 0.55
|
|
102
133
|
|
|
103
134
|
/** Safe-lag occupancy band over which reveal pressure is progressively reduced. */
|
|
104
|
-
export const FOLLOW_BACKPRESSURE_START_RATIO = 0.
|
|
135
|
+
export const FOLLOW_BACKPRESSURE_START_RATIO = 0.1
|
|
105
136
|
export const FOLLOW_BACKPRESSURE_FULL_RATIO = 0.75
|
|
106
137
|
|
|
107
138
|
/** Slow release prevents the reveal rate from oscillating around each wrap. */
|
|
108
139
|
export const FOLLOW_BACKPRESSURE_RELEASE_MS = 240
|
|
109
140
|
|
|
141
|
+
/** Effective-scroll acceleration budget, in px/ms². */
|
|
142
|
+
export const FOLLOW_TRAJECTORY_ACCELERATION = 0.00022
|
|
143
|
+
|
|
144
|
+
/** Leave one line of phase range inside the visible runway. */
|
|
145
|
+
export const FOLLOW_TRAJECTORY_PHASE_PX = 32
|
|
146
|
+
|
|
147
|
+
/** Keep adaptive runway retirement from exposing an oversized chrome gap. */
|
|
148
|
+
export const FOLLOW_TRAJECTORY_MIN_LAG_PX = 20
|
|
149
|
+
|
|
150
|
+
/** Phase-centering response; slow enough to preserve continuous velocity. */
|
|
151
|
+
export const FOLLOW_TRAJECTORY_CENTERING_MS = 120
|
|
152
|
+
|
|
110
153
|
const GESTURE_EVENTS = [
|
|
111
154
|
'wheel',
|
|
112
155
|
'touchstart',
|
|
@@ -124,14 +167,152 @@ export function computeFollowReserve(
|
|
|
124
167
|
): number {
|
|
125
168
|
const available = Math.max(0, runwayPx)
|
|
126
169
|
if (available <= 0) return 0
|
|
127
|
-
if (speedCps <=
|
|
170
|
+
if (speedCps <= FOLLOW_RESERVE_IDLE_CPS) return 0
|
|
128
171
|
const normalized = Math.min(1, Math.max(0, (
|
|
129
|
-
speedCps -
|
|
130
|
-
) / (FOLLOW_RESERVE_MAX_CPS -
|
|
172
|
+
speedCps - FOLLOW_RESERVE_IDLE_CPS
|
|
173
|
+
) / (FOLLOW_RESERVE_MAX_CPS - FOLLOW_RESERVE_IDLE_CPS)))
|
|
131
174
|
const minimum = Math.min(available, FOLLOW_RESERVE_MIN_PX)
|
|
132
175
|
return minimum + normalized * (available - minimum)
|
|
133
176
|
}
|
|
134
177
|
|
|
178
|
+
export interface FollowTrajectoryInput {
|
|
179
|
+
readonly positionPx: number
|
|
180
|
+
readonly velocityPxPerMs: number
|
|
181
|
+
readonly targetPx: number
|
|
182
|
+
readonly targetVelocityPxPerMs: number
|
|
183
|
+
readonly minLagPx: number
|
|
184
|
+
readonly maxLagPx: number
|
|
185
|
+
/** Real scroll floor used to turn logical position into a painted shift. */
|
|
186
|
+
readonly paintFloorPx?: number
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export interface FollowRevealPhaseOptions {
|
|
190
|
+
readonly seedCharsPerLine?: number
|
|
191
|
+
readonly seedLineHeightPx?: number
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export interface FollowRevealPhase {
|
|
195
|
+
readonly targetPx: number
|
|
196
|
+
readonly phase: number
|
|
197
|
+
readonly charsPerLine: number
|
|
198
|
+
readonly lineHeightPx: number
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Character-domain feed-forward for the stepped layout floor. Callers only
|
|
203
|
+
* provide committed reveal progress and the measured floor; wrap capacity and
|
|
204
|
+
* line height stay local to this module and adapt when a real wrap lands.
|
|
205
|
+
*/
|
|
206
|
+
export class FollowRevealPhaseTracker {
|
|
207
|
+
private charsPerLine: number
|
|
208
|
+
private lineHeightPx: number
|
|
209
|
+
private floorPx: number | null = null
|
|
210
|
+
private wrapRevealCount = 0
|
|
211
|
+
private lastRevealCount = 0
|
|
212
|
+
|
|
213
|
+
constructor({
|
|
214
|
+
seedCharsPerLine = 50,
|
|
215
|
+
seedLineHeightPx = 26,
|
|
216
|
+
}: FollowRevealPhaseOptions = {}) {
|
|
217
|
+
this.charsPerLine = Math.max(1, seedCharsPerLine)
|
|
218
|
+
this.lineHeightPx = Math.max(1, seedLineHeightPx)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
advance(floorPx: number, revealedChars: number): FollowRevealPhase {
|
|
222
|
+
const nextFloor = Math.max(0, floorPx)
|
|
223
|
+
const nextRevealCount = Math.max(0, revealedChars)
|
|
224
|
+
if (this.floorPx === null || nextRevealCount < this.lastRevealCount) {
|
|
225
|
+
this.floorPx = nextFloor
|
|
226
|
+
this.wrapRevealCount = nextRevealCount
|
|
227
|
+
this.lastRevealCount = nextRevealCount
|
|
228
|
+
return this.snapshot(nextFloor, 0)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const floorDelta = nextFloor - this.floorPx
|
|
232
|
+
const lineStepThreshold = Math.max(4, this.lineHeightPx * 0.5)
|
|
233
|
+
if (floorDelta >= lineStepThreshold) {
|
|
234
|
+
const wrappedLines = Math.max(1, Math.round(floorDelta / this.lineHeightPx))
|
|
235
|
+
const sampledLineHeight = floorDelta / wrappedLines
|
|
236
|
+
const revealedSinceWrap = nextRevealCount - this.wrapRevealCount
|
|
237
|
+
if (revealedSinceWrap >= this.charsPerLine * 0.5) {
|
|
238
|
+
const sampledCharsPerLine = revealedSinceWrap / wrappedLines
|
|
239
|
+
const alpha = 0.25
|
|
240
|
+
this.charsPerLine += (sampledCharsPerLine - this.charsPerLine) * alpha
|
|
241
|
+
this.lineHeightPx += (sampledLineHeight - this.lineHeightPx) * alpha
|
|
242
|
+
this.wrapRevealCount = nextRevealCount
|
|
243
|
+
} else {
|
|
244
|
+
// A flow sibling can grow without the text arm committing a glyph.
|
|
245
|
+
// Treat that as an unrelated layout event so its height is not folded
|
|
246
|
+
// into the next text-wrap capacity sample.
|
|
247
|
+
this.wrapRevealCount = nextRevealCount
|
|
248
|
+
}
|
|
249
|
+
} else if (floorDelta <= -lineStepThreshold) {
|
|
250
|
+
this.wrapRevealCount = nextRevealCount
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
this.floorPx = nextFloor
|
|
254
|
+
this.lastRevealCount = nextRevealCount
|
|
255
|
+
const phase = Math.min(1, Math.max(
|
|
256
|
+
0,
|
|
257
|
+
(nextRevealCount - this.wrapRevealCount) / this.charsPerLine,
|
|
258
|
+
))
|
|
259
|
+
return this.snapshot(nextFloor, phase)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
private snapshot(floorPx: number, phase: number): FollowRevealPhase {
|
|
263
|
+
return {
|
|
264
|
+
targetPx: floorPx + this.lineHeightPx * phase,
|
|
265
|
+
phase,
|
|
266
|
+
charsPerLine: this.charsPerLine,
|
|
267
|
+
lineHeightPx: this.lineHeightPx,
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Advance a continuous effective scroll position behind a stepped floor. */
|
|
273
|
+
export function computeFollowTrajectoryStep(
|
|
274
|
+
dtMs: number,
|
|
275
|
+
input: FollowTrajectoryInput,
|
|
276
|
+
): { positionPx: number, shiftPx: number, velocityPxPerMs: number } {
|
|
277
|
+
if (dtMs <= 0) {
|
|
278
|
+
return {
|
|
279
|
+
positionPx: input.positionPx,
|
|
280
|
+
shiftPx: Math.max(0, (input.paintFloorPx ?? input.targetPx) - input.positionPx),
|
|
281
|
+
velocityPxPerMs: input.velocityPxPerMs,
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
const elapsedMs = Math.min(FOLLOW_MAX_FRAME_MS, dtMs)
|
|
285
|
+
const currentLagPx = input.targetPx - input.positionPx
|
|
286
|
+
const maxLagPx = Math.max(0, input.maxLagPx)
|
|
287
|
+
const minLagPx = Math.min(Math.max(0, input.minLagPx), maxLagPx)
|
|
288
|
+
const centerLagPx = (minLagPx + maxLagPx) / 2
|
|
289
|
+
const desiredVelocity = Math.max(
|
|
290
|
+
0,
|
|
291
|
+
input.targetVelocityPxPerMs
|
|
292
|
+
+ (currentLagPx - centerLagPx) / FOLLOW_TRAJECTORY_CENTERING_MS,
|
|
293
|
+
)
|
|
294
|
+
const maxVelocityChange = FOLLOW_TRAJECTORY_ACCELERATION * elapsedMs
|
|
295
|
+
const velocityPxPerMs = desiredVelocity >= input.velocityPxPerMs
|
|
296
|
+
? Math.min(desiredVelocity, input.velocityPxPerMs + maxVelocityChange)
|
|
297
|
+
: Math.max(desiredVelocity, input.velocityPxPerMs - maxVelocityChange)
|
|
298
|
+
const minPosition = input.targetPx - maxLagPx
|
|
299
|
+
const maxPosition = input.targetPx - minLagPx
|
|
300
|
+
// The frame budget follows controlled velocity rather than a refresh-rate
|
|
301
|
+
// dependent pixel constant. `elapsedMs` is already capped at 32ms, so a
|
|
302
|
+
// stalled browser cannot replay an unbounded jump when it catches up.
|
|
303
|
+
const frameAdvancePx = velocityPxPerMs * elapsedMs
|
|
304
|
+
const boundedPositionPx = Math.min(
|
|
305
|
+
maxPosition,
|
|
306
|
+
Math.max(minPosition, input.positionPx + frameAdvancePx),
|
|
307
|
+
)
|
|
308
|
+
const positionPx = Math.max(input.positionPx, boundedPositionPx)
|
|
309
|
+
return {
|
|
310
|
+
positionPx,
|
|
311
|
+
shiftPx: Math.max(0, (input.paintFloorPx ?? input.targetPx) - positionPx),
|
|
312
|
+
velocityPxPerMs,
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
135
316
|
/**
|
|
136
317
|
* Reveal-rate multiplier needed to retain one-wrap headroom for the spring.
|
|
137
318
|
* Throttling starts only after a quarter of the safe transform is occupied;
|
|
@@ -174,12 +355,7 @@ export interface FollowGlideStep {
|
|
|
174
355
|
readonly velocityPxPerSec: number
|
|
175
356
|
}
|
|
176
357
|
|
|
177
|
-
/**
|
|
178
|
-
* Semi-implicit spring integration with four substeps per <=32ms slice.
|
|
179
|
-
* @param dtMs - Frame delta in ms.
|
|
180
|
-
* @param input - Current visible lag and carried physics velocity.
|
|
181
|
-
* @returns The position advance, its fraction, and next velocity.
|
|
182
|
-
*/
|
|
358
|
+
/** Semi-implicit spring integration with four substeps per <=32ms slice. */
|
|
183
359
|
export function computeFollowStep(
|
|
184
360
|
dtMs: number,
|
|
185
361
|
input: FollowGlideInput,
|
|
@@ -193,21 +369,17 @@ export function computeFollowStep(
|
|
|
193
369
|
const elapsedMs = Math.min(FOLLOW_MAX_FRAME_MS, dtMs)
|
|
194
370
|
const slices = Math.max(1, Math.ceil(elapsedMs / FOLLOW_SPRING_MAX_STEP_MS))
|
|
195
371
|
const subDt = elapsedMs / 1000 / slices / FOLLOW_SPRING_SUBSTEPS
|
|
196
|
-
|
|
197
372
|
for (let slice = 0; slice < slices; slice += 1) {
|
|
198
373
|
for (let substep = 0; substep < FOLLOW_SPRING_SUBSTEPS; substep += 1) {
|
|
199
374
|
const acceleration = (
|
|
200
|
-
|
|
201
|
-
|
|
375
|
+
tuning.springStiffness * lag - tuning.springDamping * velocity
|
|
376
|
+
) / tuning.springMass
|
|
202
377
|
velocity = Math.max(0, velocity + acceleration * subDt)
|
|
203
378
|
const advance = velocity * subDt
|
|
204
|
-
if (advance >= lag) {
|
|
205
|
-
return { advancePx: input.lag, lerpStep: 1, velocityPxPerSec: 0 }
|
|
206
|
-
}
|
|
379
|
+
if (advance >= lag) return { advancePx: input.lag, lerpStep: 1, velocityPxPerSec: 0 }
|
|
207
380
|
lag -= advance
|
|
208
381
|
}
|
|
209
382
|
}
|
|
210
|
-
|
|
211
383
|
const advancePx = input.lag - lag
|
|
212
384
|
return { advancePx, lerpStep: advancePx / input.lag, velocityPxPerSec: velocity }
|
|
213
385
|
}
|
|
@@ -217,16 +389,33 @@ function resizeProxyOf(port: HTMLElement): HTMLElement | null {
|
|
|
217
389
|
return port.querySelector('[data-chat-transcript]') ?? port.querySelector('[data-chat-flow]')
|
|
218
390
|
}
|
|
219
391
|
|
|
220
|
-
/**
|
|
221
|
-
|
|
392
|
+
/**
|
|
393
|
+
* Outermost message surfaces; nested tool rows ride their parent.
|
|
394
|
+
*
|
|
395
|
+
* Another plugin may insert its own element as a flow sibling of the Chat rows
|
|
396
|
+
* (meow-memory's fold bar is one; so is this plugin's own fold summary row).
|
|
397
|
+
* Such a row carries no `data-chat-anchor-key`, so selecting only anchored
|
|
398
|
+
* rows would shift the conversation while leaving the foreign row at its
|
|
399
|
+
* natural offset, letting the shifted rows paint over it. Every direct flow
|
|
400
|
+
* child therefore rides the same transform, keeping the visual order of the
|
|
401
|
+
* column intact.
|
|
402
|
+
*/
|
|
222
403
|
export function shiftSurfacesOf(port: HTMLElement): HTMLElement[] {
|
|
223
404
|
const transcript = port.querySelector<HTMLElement>('[data-chat-transcript]')
|
|
224
405
|
if (transcript !== null) return [transcript]
|
|
225
|
-
|
|
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}`)]
|
|
406
|
+
const anchored = [...port.querySelectorAll<HTMLElement>('[data-chat-anchor-key]')]
|
|
229
407
|
.filter(row => row.parentElement?.closest('[data-chat-anchor-key]') === null)
|
|
408
|
+
const flow = port.querySelector<HTMLElement>('[data-chat-flow]')
|
|
409
|
+
if (flow === null) return anchored
|
|
410
|
+
const status = turnStatusOf(port)
|
|
411
|
+
const anchoredSet = new Set(anchored)
|
|
412
|
+
// One document-order pass: an anchored row, or a foreign child that contains
|
|
413
|
+
// no anchored row of its own (a wrapper around real rows would double-shift
|
|
414
|
+
// the rows inside it).
|
|
415
|
+
return [...flow.children].filter((child): child is HTMLElement =>
|
|
416
|
+
child instanceof HTMLElement
|
|
417
|
+
&& child !== status
|
|
418
|
+
&& (anchoredSet.has(child) || child.querySelector('[data-chat-anchor-key]') === null))
|
|
230
419
|
}
|
|
231
420
|
|
|
232
421
|
function currentShiftOf(element: HTMLElement): number {
|
|
@@ -235,15 +424,23 @@ function currentShiftOf(element: HTMLElement): number {
|
|
|
235
424
|
)
|
|
236
425
|
}
|
|
237
426
|
|
|
238
|
-
|
|
427
|
+
/* An open fixed tooltip (role="tooltip") inside a surface is the only reason a
|
|
428
|
+
* naive translate would misalign content during the follow shift: the applied
|
|
429
|
+
* transform turns the surface into the tooltip's containing block and drags
|
|
430
|
+
* the bubble off its anchor. Rather than re-derive viewport coordinates every
|
|
431
|
+
* frame (a fragile engine that needed four follow-up fixes), hold the affected
|
|
432
|
+
* surface untransformed while a tooltip is open. A tooltip exists in the DOM
|
|
433
|
+
* only during hover, so the transient un-shifted row is invisible to a reader
|
|
434
|
+
* and matches the release 0.4.0 feel; the shift resumes on the next frame once
|
|
435
|
+
* it closes. */
|
|
436
|
+
|
|
437
|
+
function setDirectShift(element: HTMLElement, px: number): void {
|
|
239
438
|
if (Math.abs(px) > 0.01) {
|
|
240
439
|
if (
|
|
241
440
|
Math.abs(currentShiftOf(element) - px) <= 0.01
|
|
242
441
|
&& element.style.willChange === 'transform'
|
|
243
442
|
&& element.style.clipPath === ''
|
|
244
|
-
)
|
|
245
|
-
return
|
|
246
|
-
}
|
|
443
|
+
) return
|
|
247
444
|
element.style.transform = `translate3d(0, ${px}px, 0)`
|
|
248
445
|
element.style.willChange = 'transform'
|
|
249
446
|
} else {
|
|
@@ -251,16 +448,109 @@ function setShift(element: HTMLElement, px: number): void {
|
|
|
251
448
|
element.style.transform = ''
|
|
252
449
|
element.style.willChange = ''
|
|
253
450
|
}
|
|
254
|
-
// Remove paint state left by v0.3.2 and earlier experimental builds.
|
|
255
451
|
element.style.clipPath = ''
|
|
256
452
|
}
|
|
257
453
|
|
|
454
|
+
function setShift(element: HTMLElement, px: number): void {
|
|
455
|
+
// Guard: while an open fixed tooltip lives on the surface, hold it
|
|
456
|
+
// untransformed (see the comment above). When the shift is zero the tooltip
|
|
457
|
+
// cannot detach, so skip the subtree scan entirely and keep the fast path.
|
|
458
|
+
if (Math.abs(px) > 0.01 && element.querySelector('[role="tooltip"]') !== null) {
|
|
459
|
+
setDirectShift(element, 0)
|
|
460
|
+
return
|
|
461
|
+
}
|
|
462
|
+
setDirectShift(element, px)
|
|
463
|
+
}
|
|
464
|
+
|
|
258
465
|
function turnStatusOf(port: HTMLElement): HTMLElement | null {
|
|
259
466
|
return port.querySelector<HTMLElement>(
|
|
260
467
|
'[data-chat-turn-status], [data-chat-flow] > [role="status"]',
|
|
261
468
|
)
|
|
262
469
|
}
|
|
263
470
|
|
|
471
|
+
/**
|
|
472
|
+
* Bottom-anchored hosts (ChatView packs the flow with `justify-content:
|
|
473
|
+
* flex-end`) translate every pre-overflow growth into an instant upward step
|
|
474
|
+
* of the whole column — a line lands and everything on screen hops up by one
|
|
475
|
+
* line-height. No scroll-domain engine can smooth that, because
|
|
476
|
+
* `scrollHeight` does not move while the host slack absorbs the growth.
|
|
477
|
+
*
|
|
478
|
+
* Owning `min-height` on the flow removes the slack instead: the column
|
|
479
|
+
* always fills the scrollport, content grows downward where it is visible,
|
|
480
|
+
* and real overflow — which the spring does model — begins with the very
|
|
481
|
+
* first wrapped line. The style is owned in a page-realm registry so a
|
|
482
|
+
* re-injected bundle adopts it rather than fighting it.
|
|
483
|
+
*
|
|
484
|
+
* The fill targets the VISIBLE client box, not the raw `clientHeight`.
|
|
485
|
+
* Imposing `min-height: clientHeight` inflates `scrollHeight` by whatever
|
|
486
|
+
* the scroller holds beyond the flow (its own padding-bottom, adjacent
|
|
487
|
+
* chrome); with short content the engine then sees a phantom floor equal to
|
|
488
|
+
* that padding and scrolls the conversation up — top edge off-screen while
|
|
489
|
+
* blank padding sits at the bottom. The overshoot is measured once per port
|
|
490
|
+
* and subtracted, so short content reaches `scrollHeight == clientHeight`
|
|
491
|
+
* and the floor is exactly zero.
|
|
492
|
+
*/
|
|
493
|
+
interface FollowFlowFill {
|
|
494
|
+
readonly element: HTMLElement
|
|
495
|
+
readonly original: string
|
|
496
|
+
/** Scroller height held by padding/chrome above the flow, px. */
|
|
497
|
+
readonly overshootPx: number
|
|
498
|
+
}
|
|
499
|
+
const FOLLOW_FLOW_FILL_SYMBOL = Symbol.for('dsh-smooth-stream.follow-flow-fill')
|
|
500
|
+
const followFlowFillHost = globalThis as typeof globalThis & {
|
|
501
|
+
[key: symbol]: WeakMap<HTMLElement, FollowFlowFill> | undefined
|
|
502
|
+
}
|
|
503
|
+
const followFlowFills = followFlowFillHost[FOLLOW_FLOW_FILL_SYMBOL]
|
|
504
|
+
?? new WeakMap<HTMLElement, FollowFlowFill>()
|
|
505
|
+
followFlowFillHost[FOLLOW_FLOW_FILL_SYMBOL] = followFlowFills
|
|
506
|
+
const FOLLOW_FLOW_FILL_USERS_SYMBOL = Symbol.for('dsh-smooth-stream.follow-flow-fill-users')
|
|
507
|
+
const followFlowFillUsersHost = globalThis as typeof globalThis & {
|
|
508
|
+
[key: symbol]: WeakMap<HTMLElement, number> | undefined
|
|
509
|
+
}
|
|
510
|
+
const followFlowFillUsers = followFlowFillUsersHost[FOLLOW_FLOW_FILL_USERS_SYMBOL]
|
|
511
|
+
?? new WeakMap<HTMLElement, number>()
|
|
512
|
+
followFlowFillUsersHost[FOLLOW_FLOW_FILL_USERS_SYMBOL] = followFlowFillUsers
|
|
513
|
+
|
|
514
|
+
function flowElementOf(port: HTMLElement): HTMLElement | null {
|
|
515
|
+
return port.querySelector<HTMLElement>('[data-chat-transcript]')
|
|
516
|
+
?? port.querySelector<HTMLElement>('[data-chat-flow]')
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function ensureFlowFillsPort(port: HTMLElement): void {
|
|
520
|
+
const element = flowElementOf(port)
|
|
521
|
+
const owned = followFlowFills.get(port)
|
|
522
|
+
if (element === null) {
|
|
523
|
+
if (owned !== undefined) restoreFlowFill(port)
|
|
524
|
+
return
|
|
525
|
+
}
|
|
526
|
+
const client = Math.max(0, port.clientHeight)
|
|
527
|
+
let overshoot = owned?.overshootPx
|
|
528
|
+
if (overshoot === undefined) {
|
|
529
|
+
// Measure once per port: impose the full fill, read the scrollHeight
|
|
530
|
+
// excess (scroller padding / chrome), then restore the natural state.
|
|
531
|
+
const pendingOriginal = element.style.minHeight
|
|
532
|
+
element.style.minHeight = `${client}px`
|
|
533
|
+
overshoot = Math.max(0, port.scrollHeight - client)
|
|
534
|
+
element.style.minHeight = pendingOriginal
|
|
535
|
+
if (owned !== undefined) restoreFlowFill(port)
|
|
536
|
+
}
|
|
537
|
+
const target = `${Math.max(0, client - overshoot)}px`
|
|
538
|
+
if (owned !== undefined) {
|
|
539
|
+
if (owned.element === element && owned.overshootPx === overshoot && element.style.minHeight === target) return
|
|
540
|
+
restoreFlowFill(port)
|
|
541
|
+
}
|
|
542
|
+
const original = element.style.minHeight
|
|
543
|
+
element.style.minHeight = target
|
|
544
|
+
followFlowFills.set(port, { element, original, overshootPx: overshoot })
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function restoreFlowFill(port: HTMLElement): void {
|
|
548
|
+
const owned = followFlowFills.get(port)
|
|
549
|
+
if (owned === undefined) return
|
|
550
|
+
owned.element.style.minHeight = owned.original
|
|
551
|
+
followFlowFills.delete(port)
|
|
552
|
+
}
|
|
553
|
+
|
|
264
554
|
/** Height committed by one newly mounted Chat row, including its flex gap. */
|
|
265
555
|
function entranceExtentOf(root: HTMLElement): number {
|
|
266
556
|
const row = root.closest<HTMLElement>('[data-chat-flow-key]') ?? root
|
|
@@ -283,6 +573,7 @@ interface FollowRunway {
|
|
|
283
573
|
readonly original: string
|
|
284
574
|
readonly property: 'marginBottom' | 'marginTop'
|
|
285
575
|
readonly requestedPx: number
|
|
576
|
+
readonly normalizedLegacy?: boolean
|
|
286
577
|
}
|
|
287
578
|
|
|
288
579
|
/**
|
|
@@ -318,6 +609,15 @@ interface FollowPaintLimit {
|
|
|
318
609
|
export const FOLLOW_PAINT_LIMIT_TTL_MS = 250
|
|
319
610
|
|
|
320
611
|
const followPaintLimits = new WeakMap<HTMLElement, FollowPaintLimit>()
|
|
612
|
+
const followHadChrome = new WeakSet<HTMLElement>()
|
|
613
|
+
/** Last painted shift per port, to spread a wrap's one-line step over frames. */
|
|
614
|
+
const followLastShiftPx = new WeakMap<HTMLElement, number>()
|
|
615
|
+
/** Last runway offset seen per port, to rebase the extent when the margin size changes. */
|
|
616
|
+
const followRunwayOffsetHistory = new WeakMap<HTMLElement, number>()
|
|
617
|
+
/** Last observed scroll floor per port, for the slack→overflow runway re-measure. */
|
|
618
|
+
const followFloorHistory = new WeakMap<HTMLElement, number>()
|
|
619
|
+
/** One-shot flag: the transition frame must paint the full runway as baseline. */
|
|
620
|
+
const followSlackTransition = new WeakSet<HTMLElement>()
|
|
321
621
|
|
|
322
622
|
function invalidatePaintLimit(port: HTMLElement): void {
|
|
323
623
|
followPaintLimits.delete(port)
|
|
@@ -335,6 +635,47 @@ interface FollowMotionState {
|
|
|
335
635
|
/** Logical position and velocity survive a React owner handoff and finish. */
|
|
336
636
|
const followMotionStates = new WeakMap<HTMLElement, FollowMotionState>()
|
|
337
637
|
|
|
638
|
+
/**
|
|
639
|
+
* Reader-release record that must survive an ownership handoff. A follower
|
|
640
|
+
* closing at stream end (finish/drain arm swap, row lifecycle flip) destroys
|
|
641
|
+
* the closure holding `following = false`, and the arm taking over would
|
|
642
|
+
* otherwise read the held viewport as "at bottom" and hard-snap to the
|
|
643
|
+
* floor. Presence in this map means the reader's unpin away from the floor
|
|
644
|
+
* is still in effect; it is cleared when a follower re-pins at the floor.
|
|
645
|
+
*/
|
|
646
|
+
interface FollowReaderHold {
|
|
647
|
+
readonly atMs: number
|
|
648
|
+
}
|
|
649
|
+
const followReaderHolds = new WeakMap<HTMLElement, FollowReaderHold>()
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* Commit-time correction channel. A reveal commit that lands after this
|
|
653
|
+
* frame's ResizeObserver delivery would otherwise paint one intermediate
|
|
654
|
+
* frame — content grown, scrollTop/transform not yet compensated — before
|
|
655
|
+
* the next tick fixes it. Reveal arms call {@link notifyFollowCommit} right
|
|
656
|
+
* after their commit; the leading follower re-runs its geometry in the same
|
|
657
|
+
* task, so the intermediate state never reaches a paint.
|
|
658
|
+
*/
|
|
659
|
+
const followCommitListeners = new WeakMap<HTMLElement, Set<() => void>>()
|
|
660
|
+
|
|
661
|
+
export function notifyFollowCommit(fromInsidePort: HTMLElement | null): void {
|
|
662
|
+
if (fromInsidePort === null) return
|
|
663
|
+
const port = fromInsidePort.closest<HTMLElement>('[data-conversation-scroll]')
|
|
664
|
+
const listeners = port === null ? undefined : followCommitListeners.get(port)
|
|
665
|
+
if (listeners === undefined) return
|
|
666
|
+
for (const listener of [...listeners]) listener()
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function subscribeFollowCommit(port: HTMLElement, fn: () => void): () => void {
|
|
670
|
+
let listeners = followCommitListeners.get(port)
|
|
671
|
+
if (listeners === undefined) {
|
|
672
|
+
listeners = new Set()
|
|
673
|
+
followCommitListeners.set(port, listeners)
|
|
674
|
+
}
|
|
675
|
+
listeners.add(fn)
|
|
676
|
+
return () => { listeners!.delete(fn) }
|
|
677
|
+
}
|
|
678
|
+
|
|
338
679
|
function restoreRunway(port: HTMLElement): void {
|
|
339
680
|
const runway = followRunways.get(port)
|
|
340
681
|
if (runway === undefined) return
|
|
@@ -347,12 +688,11 @@ function isLegacyRunway(value: string): boolean {
|
|
|
347
688
|
if (value === '') return false
|
|
348
689
|
const terms = [...value.matchAll(/([\d.]+)px/g)]
|
|
349
690
|
if (terms.length === 0 || value.replaceAll(/calc|px|[\d.+()\s]/g, '') !== '') return false
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
})
|
|
691
|
+
const values = terms.map(([, raw]) => Number(raw))
|
|
692
|
+
if (values.some(px => !Number.isFinite(px))) return false
|
|
693
|
+
return [LEGACY_RUNWAY_PX, FOLLOW_STATUS_RUNWAY_PX].some(unit => values.every(px => (
|
|
694
|
+
px >= unit && Math.abs(px % unit) <= Number.EPSILON
|
|
695
|
+
)))
|
|
356
696
|
}
|
|
357
697
|
|
|
358
698
|
/** Remove unowned runway residue written by v0.3.3 and earlier bundles. */
|
|
@@ -361,8 +701,8 @@ function migrateLegacyRunway(
|
|
|
361
701
|
surfaces: readonly HTMLElement[],
|
|
362
702
|
status: HTMLElement | null,
|
|
363
703
|
composer: HTMLElement | null,
|
|
364
|
-
):
|
|
365
|
-
if (followRunways.has(port)) return
|
|
704
|
+
): boolean {
|
|
705
|
+
if (followRunways.has(port)) return false
|
|
366
706
|
let migrated = false
|
|
367
707
|
if (status !== null && isLegacyRunway(status.style.marginTop)) {
|
|
368
708
|
// Harness TurnStatus has no inline margin; exact 48px multiples here are
|
|
@@ -383,6 +723,7 @@ function migrateLegacyRunway(
|
|
|
383
723
|
migrated = true
|
|
384
724
|
}
|
|
385
725
|
if (migrated) invalidatePaintLimit(port)
|
|
726
|
+
return migrated
|
|
386
727
|
}
|
|
387
728
|
|
|
388
729
|
function ensureRunway(
|
|
@@ -392,13 +733,32 @@ function ensureRunway(
|
|
|
392
733
|
): void {
|
|
393
734
|
const status = turnStatusOf(port)
|
|
394
735
|
const composer = port.querySelector<HTMLElement>('[data-composer-seat]')
|
|
395
|
-
|
|
736
|
+
// Adopt one exact current runway before migration. Larger exact multiples
|
|
737
|
+
// are accumulated residue from older bundles and must be stripped.
|
|
738
|
+
if (status !== null && followRunways.get(port) === undefined) {
|
|
739
|
+
const inlinePx = Number.parseFloat(status.style.marginTop ?? '') || 0
|
|
740
|
+
if (Math.abs(inlinePx - FOLLOW_STATUS_RUNWAY_PX) <= 0.5) {
|
|
741
|
+
followRunways.set(port, {
|
|
742
|
+
element: status,
|
|
743
|
+
offset: inlinePx,
|
|
744
|
+
property: 'marginTop',
|
|
745
|
+
original: '',
|
|
746
|
+
requestedPx: inlinePx,
|
|
747
|
+
})
|
|
748
|
+
invalidatePaintLimit(port)
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
const migratedLegacy = migrateLegacyRunway(port, surfaces, status, composer)
|
|
396
752
|
// A runway is useful only after the natural conversation already has a
|
|
397
753
|
// scroll floor for its equal message transform to ride. Before that point
|
|
398
754
|
// applyVisual keeps every surface in normal flow, so adding status margin
|
|
399
755
|
// would expose the whole runway as empty space below a short/early Think.
|
|
400
756
|
const naturalHeight = Math.max(0, port.scrollHeight - runwayOffsetOf(port))
|
|
401
|
-
|
|
757
|
+
const existing = followRunways.get(port)
|
|
758
|
+
const requestedRunwayPx = migratedLegacy || existing?.normalizedLegacy === true
|
|
759
|
+
? FOLLOW_STATUS_RUNWAY_PX
|
|
760
|
+
: runwayPx
|
|
761
|
+
if (requestedRunwayPx <= 0 || port.clientHeight <= 0 || naturalHeight <= port.clientHeight) {
|
|
402
762
|
restoreRunway(port)
|
|
403
763
|
return
|
|
404
764
|
}
|
|
@@ -413,15 +773,23 @@ function ensureRunway(
|
|
|
413
773
|
const current = followRunways.get(port)
|
|
414
774
|
if (current?.element === element
|
|
415
775
|
&& current.property === target.property
|
|
416
|
-
&& current.requestedPx ===
|
|
776
|
+
&& current.requestedPx === requestedRunwayPx) return
|
|
777
|
+
|
|
417
778
|
restoreRunway(port)
|
|
418
779
|
const beforeHeight = port.scrollHeight
|
|
419
780
|
const original = element.style[target.property]
|
|
420
781
|
element.style[target.property] = original === ''
|
|
421
|
-
? `${
|
|
422
|
-
: `calc(${original} + ${
|
|
782
|
+
? `${requestedRunwayPx}px`
|
|
783
|
+
: `calc(${original} + ${requestedRunwayPx}px)`
|
|
423
784
|
const offset = Math.max(0, port.scrollHeight - beforeHeight)
|
|
424
|
-
followRunways.set(port, {
|
|
785
|
+
followRunways.set(port, {
|
|
786
|
+
element,
|
|
787
|
+
offset,
|
|
788
|
+
property: target.property,
|
|
789
|
+
original,
|
|
790
|
+
requestedPx: requestedRunwayPx,
|
|
791
|
+
normalizedLegacy: migratedLegacy || existing?.normalizedLegacy === true,
|
|
792
|
+
})
|
|
425
793
|
invalidatePaintLimit(port)
|
|
426
794
|
}
|
|
427
795
|
|
|
@@ -438,6 +806,7 @@ function safeShiftLimit(
|
|
|
438
806
|
if (last === undefined) return 0
|
|
439
807
|
const status = turnStatusOf(port)
|
|
440
808
|
const composer = port.querySelector<HTMLElement>('[data-composer-seat]')
|
|
809
|
+
if (status !== null || composer !== null) followHadChrome.add(port)
|
|
441
810
|
const cached = followPaintLimits.get(port)
|
|
442
811
|
// Content growth alone cannot move the limit (measured at the floor, the
|
|
443
812
|
// flow bottom rides the scrollport bottom), so the cache survives glyph
|
|
@@ -463,7 +832,7 @@ function safeShiftLimit(
|
|
|
463
832
|
// mounted but has not measured yet, permit only the runway zero-point
|
|
464
833
|
// until ResizeObserver provides a real ceiling.
|
|
465
834
|
return status === null && composer === null
|
|
466
|
-
? Number.POSITIVE_INFINITY
|
|
835
|
+
? followHadChrome.has(port) ? 0 : Number.POSITIVE_INFINITY
|
|
467
836
|
: runwayOffsetOf(port)
|
|
468
837
|
}
|
|
469
838
|
const ceilingTop = ceiling.rect.top - currentShiftOf(ceiling.element)
|
|
@@ -522,17 +891,62 @@ function applyVisual(
|
|
|
522
891
|
reservePx: number,
|
|
523
892
|
velocityPxPerSec = 0,
|
|
524
893
|
runwayPx = FOLLOW_STATUS_RUNWAY_PX,
|
|
894
|
+
shiftCeilingPx = Number.POSITIVE_INFINITY,
|
|
895
|
+
promoteAtRest = false,
|
|
896
|
+
trajectoryShiftPx?: number,
|
|
525
897
|
): number {
|
|
526
898
|
const surfaces = shiftSurfacesOf(port)
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
899
|
+
ensureFlowFillsPort(port)
|
|
900
|
+
void runwayPx
|
|
901
|
+
// A runway added while the column still fit the viewport was absorbed by
|
|
902
|
+
// the host's bottom slack: its measured offset was zero, but once real
|
|
903
|
+
// overflow begins the same margin costs scroll length. Detect that
|
|
904
|
+
// transition BEFORE any geometry is read and re-add the margin so its
|
|
905
|
+
// stored offset is measured against the true post-overflow layout.
|
|
906
|
+
{
|
|
907
|
+
const preFloor = Math.max(0, port.scrollHeight - port.clientHeight)
|
|
908
|
+
const lastFloorSeen = followFloorHistory.get(port)
|
|
909
|
+
if (lastFloorSeen !== undefined && lastFloorSeen === 0 && preFloor > 0 && followRunways.has(port)) {
|
|
910
|
+
const owned = followRunways.get(port)
|
|
911
|
+
restoreRunway(port)
|
|
912
|
+
ensureRunway(port, surfaces, owned?.requestedPx ?? runwayPx)
|
|
913
|
+
// This frame the margin materializes into scroll length AND lands in
|
|
914
|
+
// the floor in the same write; painting the held reserve as baseline
|
|
915
|
+
// would leave its px uncompensated on screen. Spend the full runway
|
|
916
|
+
// as baseline for exactly this frame.
|
|
917
|
+
followSlackTransition.add(port)
|
|
918
|
+
}
|
|
919
|
+
if (preFloor !== lastFloorSeen) {
|
|
920
|
+
// A changed floor means the natural tail geometry may have changed. This
|
|
921
|
+
// is the infrequent layout-growth boundary; ordinary same-floor reveal
|
|
922
|
+
// commits continue to use the cached chrome clearance.
|
|
923
|
+
invalidatePaintLimit(port)
|
|
924
|
+
followFloorHistory.set(port, preFloor)
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
ensureRunway(port, surfaces, reservePx)
|
|
928
|
+
const contentHeight2 = Math.max(0, port.scrollHeight)
|
|
929
|
+
const runwayOffset2 = runwayOffsetOf(port)
|
|
930
|
+
// Rebase the spring extent onto the current offset domain. targetHeight
|
|
931
|
+
// = contentHeight − offset AND animatedH live in the same space; when the
|
|
932
|
+
// margin grows, targetHeight drops by the offset's growth, so animatedH
|
|
933
|
+
// must drop in lockstep or the whole req (painted shift) is released as
|
|
934
|
+
// an upward snap. Shifting both by the same delta leaves the painted
|
|
935
|
+
// shift untouched.
|
|
936
|
+
const prevOffset2 = followRunwayOffsetHistory.get(port)
|
|
937
|
+
if (prevOffset2 !== undefined && runwayOffset2 !== prevOffset2) {
|
|
938
|
+
animatedH = Math.max(0, animatedH - (runwayOffset2 - prevOffset2))
|
|
939
|
+
}
|
|
940
|
+
followRunwayOffsetHistory.set(port, runwayOffset2)
|
|
941
|
+
const contentHeight = contentHeight2
|
|
942
|
+
const runwayOffset = runwayOffset2
|
|
530
943
|
const targetHeight = Math.max(0, contentHeight - runwayOffset)
|
|
531
944
|
const floor = Math.max(0, contentHeight - port.clientHeight)
|
|
532
945
|
const extent = Math.min(targetHeight, Math.max(0, animatedH))
|
|
533
946
|
if (port.style.overflowAnchor !== 'none') port.style.overflowAnchor = 'none'
|
|
534
947
|
if (port.style.scrollBehavior !== 'auto') port.style.scrollBehavior = 'auto'
|
|
535
948
|
if (floor <= 0) {
|
|
949
|
+
followRunwayOffsetHistory.set(port, 0)
|
|
536
950
|
setFollowScrollTop(port, 0)
|
|
537
951
|
followMotionStates.set(port, {
|
|
538
952
|
capacityPx: Number.POSITIVE_INFINITY,
|
|
@@ -543,21 +957,60 @@ function applyVisual(
|
|
|
543
957
|
velocityPxPerSec: 0,
|
|
544
958
|
})
|
|
545
959
|
for (const surface of surfaces) setShift(surface, 0)
|
|
960
|
+
followLastShiftPx.set(port, 0)
|
|
546
961
|
const status = turnStatusOf(port)
|
|
547
962
|
if (status !== null) setShift(status, 0)
|
|
548
963
|
return targetHeight
|
|
549
964
|
}
|
|
550
|
-
//
|
|
551
|
-
//
|
|
552
|
-
|
|
553
|
-
|
|
965
|
+
// The visible scroll position rides the spring's smooth extent so each newly
|
|
966
|
+
// revealed line is approached at continuous velocity instead of snapping the
|
|
967
|
+
// whole wrap into one frame (the residual "不丝滑" jump). `animatedH` advances
|
|
968
|
+
// by the spring's bounded per-frame step; the newest ≤ `visibleReserve` lines
|
|
969
|
+
// stay hidden below the fold in the runway while `scrollTop` glides up to
|
|
970
|
+
// reveal them. Concretely: `scrollTop = floor − min(requestedLag, reserve)`,
|
|
971
|
+
// so during fast typing scrollTop trails floor by at most one runway (≤ 2
|
|
972
|
+
// lines) advancing smoothly at the spring cadence; when typing stops and lag
|
|
973
|
+
// exhausts, scrollTop converges to the floor. The shift below still holds the
|
|
974
|
+
// reserve so no per-wrap tail jump appears.
|
|
975
|
+
const limit = safeShiftLimit(port, surfaces)
|
|
976
|
+
followSlackTransition.delete(port)
|
|
554
977
|
const visibleReserve = Math.min(runwayOffset, Math.max(0, reservePx))
|
|
555
978
|
const baselineShift = runwayOffset - visibleReserve
|
|
556
979
|
const requestedLag = Math.max(0, targetHeight - extent)
|
|
557
|
-
|
|
558
|
-
|
|
980
|
+
// Steady-state tail pin: while the predictive reserve is held, paint the
|
|
981
|
+
// shifted surfaces at a FIXED offset equal to the reserve, so the newest
|
|
982
|
+
// revealed line rides at a constant viewport position and every wrap's reveal
|
|
983
|
+
// lands below the fold inside that space — instead of translating the whole
|
|
984
|
+
// message by the decaying per-reveal lag (which moved the newest line up/down
|
|
985
|
+
// at reveal cadence = the residual "轻微回弹来回" jitter). The reserve is held
|
|
986
|
+
// constant during streaming; only when it retires (entrance/settle) does the
|
|
987
|
+
// shift glide with it. The spring still closes below the fold, and backpressure
|
|
988
|
+
// reads the untranslated requestedLag so reveal pacing is unchanged.
|
|
989
|
+
const availableShift = Math.min(
|
|
990
|
+
Math.max(0, limit),
|
|
991
|
+
Math.max(0, shiftCeilingPx),
|
|
992
|
+
)
|
|
993
|
+
const motionShift = Math.min(
|
|
994
|
+
trajectoryShiftPx ?? baselineShift + requestedLag,
|
|
995
|
+
availableShift,
|
|
996
|
+
)
|
|
997
|
+
const idlePromotion = promoteAtRest && motionShift <= 0.01 && availableShift > 0 ? 0.1 : 0
|
|
998
|
+
let shift = Math.max(motionShift, idlePromotion)
|
|
999
|
+
const previousShift = followLastShiftPx.get(port)
|
|
1000
|
+
if (previousShift !== undefined && shift < previousShift - FOLLOW_PAINT_SHIFT_MAX_STEP_PX) {
|
|
1001
|
+
// Decay-only rate limit. Growth is a WRAP COMPENSATION: the floor already
|
|
1002
|
+
// jumped one line in the same layout pass and `scrollTop` followed it, so
|
|
1003
|
+
// the matching shift increase cancels that step exactly. Rate-limiting it
|
|
1004
|
+
// paints the uncovered remainder as a visible one-frame jump (the
|
|
1005
|
+
// "换行 18px 跳变"). Only the decay side — runway retirement and settle —
|
|
1006
|
+
// is a real animation and keeps its per-frame bound.
|
|
1007
|
+
shift = previousShift - FOLLOW_PAINT_SHIFT_MAX_STEP_PX
|
|
1008
|
+
}
|
|
1009
|
+
followLastShiftPx.set(port, shift)
|
|
1010
|
+
const effectiveLag = Math.max(0, motionShift - baselineShift)
|
|
559
1011
|
const capacityPx = Math.max(0, limit - baselineShift)
|
|
560
1012
|
const effectiveExtent = targetHeight - effectiveLag
|
|
1013
|
+
setFollowScrollTop(port, floor)
|
|
561
1014
|
followMotionStates.set(port, {
|
|
562
1015
|
capacityPx,
|
|
563
1016
|
constrained: requestedLag > effectiveLag + FOLLOW_SETTLE_EPSILON_PX,
|
|
@@ -585,6 +1038,7 @@ function clearVisual(port: HTMLElement): void {
|
|
|
585
1038
|
clearMotion(port)
|
|
586
1039
|
restoreRunway(port)
|
|
587
1040
|
followMotionStates.delete(port)
|
|
1041
|
+
followLastShiftPx.delete(port)
|
|
588
1042
|
invalidatePaintLimit(port)
|
|
589
1043
|
}
|
|
590
1044
|
|
|
@@ -596,9 +1050,16 @@ function holdCompositorAtRest(element: HTMLElement): void {
|
|
|
596
1050
|
}
|
|
597
1051
|
|
|
598
1052
|
/** Remove equal offsets, land on the floor, then retire the compositor quietly. */
|
|
599
|
-
function finishAtNaturalFloor(port: HTMLElement): void {
|
|
1053
|
+
function finishAtNaturalFloor(port: HTMLElement, retainCompositor = true): void {
|
|
600
1054
|
const surfaces = shiftSurfacesOf(port)
|
|
601
1055
|
const status = turnStatusOf(port)
|
|
1056
|
+
if (!retainCompositor) {
|
|
1057
|
+
restoreRunway(port)
|
|
1058
|
+
settleAtFloor(port)
|
|
1059
|
+
clearMotion(port)
|
|
1060
|
+
followMotionStates.delete(port)
|
|
1061
|
+
return
|
|
1062
|
+
}
|
|
602
1063
|
const promoted = [...surfaces, ...(status === null ? [] : [status])]
|
|
603
1064
|
.filter(element => element.style.transform !== '' || element.style.willChange === 'transform')
|
|
604
1065
|
const promotedSet = new Set(promoted)
|
|
@@ -619,7 +1080,6 @@ function finishAtNaturalFloor(port: HTMLElement): void {
|
|
|
619
1080
|
if (promoted.length === 0) return
|
|
620
1081
|
requestAnimationFrame(() => {
|
|
621
1082
|
requestAnimationFrame(() => {
|
|
622
|
-
if (port.hasAttribute(FOLLOW_OWNED_ATTR)) return
|
|
623
1083
|
for (const element of promoted) {
|
|
624
1084
|
if (Math.abs(currentShiftOf(element)) <= 0.01) setShift(element, 0)
|
|
625
1085
|
}
|
|
@@ -630,6 +1090,7 @@ function finishAtNaturalFloor(port: HTMLElement): void {
|
|
|
630
1090
|
function settleAtFloor(port: HTMLElement): void {
|
|
631
1091
|
const floor = Math.max(0, port.scrollHeight - port.clientHeight)
|
|
632
1092
|
setFollowScrollTop(port, floor)
|
|
1093
|
+
followReaderHolds.delete(port)
|
|
633
1094
|
}
|
|
634
1095
|
|
|
635
1096
|
interface FollowLeader {
|
|
@@ -653,6 +1114,7 @@ let followGeneration = 0
|
|
|
653
1114
|
* @param onEntranceSettled - Releases a one-shot entrance owner after catch-up.
|
|
654
1115
|
* @param predictiveRef - Optional live visibility gate for predictive runway.
|
|
655
1116
|
* @param entranceExtentRef - Optional measured growth delta for a generic row.
|
|
1117
|
+
* @param revealedCharsRef - Committed code-point count for feed-forward phase.
|
|
656
1118
|
*/
|
|
657
1119
|
export function useConversationFollow(
|
|
658
1120
|
rootRef: RefObject<HTMLElement | null>,
|
|
@@ -664,18 +1126,18 @@ export function useConversationFollow(
|
|
|
664
1126
|
onEntranceSettled?: () => void,
|
|
665
1127
|
predictiveRef?: { current: boolean },
|
|
666
1128
|
entranceExtentRef?: { current: number | null },
|
|
1129
|
+
revealedCharsRef?: { current: number },
|
|
667
1130
|
): void {
|
|
668
1131
|
const activeRef = useRef(active)
|
|
669
1132
|
const entranceRef = useRef(entrance)
|
|
670
1133
|
const onEntranceSettledRef = useRef(onEntranceSettled)
|
|
671
1134
|
entranceRef.current = entrance
|
|
672
1135
|
onEntranceSettledRef.current = onEntranceSettled
|
|
673
|
-
|
|
674
|
-
activeRef.current = active
|
|
675
|
-
}, [active])
|
|
1136
|
+
activeRef.current = active
|
|
676
1137
|
|
|
677
1138
|
useLayoutEffect(() => {
|
|
678
1139
|
if (!active) return
|
|
1140
|
+
const startedAsEntrance = entrance
|
|
679
1141
|
const owner = {}
|
|
680
1142
|
const generation = ++followGeneration
|
|
681
1143
|
let rafId = 0
|
|
@@ -692,6 +1154,18 @@ export function useConversationFollow(
|
|
|
692
1154
|
let interactTimer: ReturnType<typeof setTimeout> | null = null
|
|
693
1155
|
let port: HTMLElement | null = null
|
|
694
1156
|
let resize: ResizeObserver | null = null
|
|
1157
|
+
let observedTail: HTMLElement | null = null
|
|
1158
|
+
let statusWasPresent: boolean | null = null
|
|
1159
|
+
let trajectoryPositionPx: number | null = null
|
|
1160
|
+
let trajectoryVelocityPxPerMs = 0
|
|
1161
|
+
let trajectoryTargetVelocityPxPerMs = 0
|
|
1162
|
+
let trajectoryFloorPx: number | null = null
|
|
1163
|
+
let trajectoryGrowthAtMs: number | null = null
|
|
1164
|
+
let trajectoryAccumulatedGrowthPx = 0
|
|
1165
|
+
let trajectoryAccumulatedGrowthMs = 0
|
|
1166
|
+
let trajectoryGrowthSamples = 0
|
|
1167
|
+
let trajectoryWasActive = false
|
|
1168
|
+
const revealPhase = new FollowRevealPhaseTracker()
|
|
695
1169
|
let holding: HTMLElement | null = null
|
|
696
1170
|
let entrancePending = entranceRef.current
|
|
697
1171
|
|
|
@@ -726,10 +1200,12 @@ export function useConversationFollow(
|
|
|
726
1200
|
const reportFollow = (next: HTMLElement, isActive: boolean): void => {
|
|
727
1201
|
const state = followMotionStates.get(next)
|
|
728
1202
|
debugRuntime.reportFollow(next, {
|
|
729
|
-
|
|
1203
|
+
// TEMP audit provenance: lagPx=-1 marks the fallback path (no motion
|
|
1204
|
+
// state owned by this reporter this frame).
|
|
1205
|
+
lagPx: state ? state.lagPx : -1,
|
|
730
1206
|
velocityPxPerSec: state?.velocityPxPerSec ?? 0,
|
|
731
1207
|
reservePx: state?.reservePx ?? 0,
|
|
732
|
-
capacityPx: state
|
|
1208
|
+
capacityPx: state ? state.capacityPx : -1,
|
|
733
1209
|
revealScale: revealScaleRef?.current ?? 1,
|
|
734
1210
|
following,
|
|
735
1211
|
constrained: state?.constrained ?? false,
|
|
@@ -764,7 +1240,8 @@ export function useConversationFollow(
|
|
|
764
1240
|
|
|
765
1241
|
const handBackVisual = (next: HTMLElement): void => {
|
|
766
1242
|
const shift = currentShiftOf(shiftSurfacesOf(next).at(-1) ?? next)
|
|
767
|
-
const
|
|
1243
|
+
const transferableShift = shift > FOLLOW_SETTLE_EPSILON_PX ? shift : 0
|
|
1244
|
+
const visualTop = Math.max(0, next.scrollTop - transferableShift)
|
|
768
1245
|
// The predictive runway only has meaning while this follower owns the
|
|
769
1246
|
// floor. Remove it before choosing the reader's landing point; keeping
|
|
770
1247
|
// it through the release paints a transient natural gap + 48px blank
|
|
@@ -807,21 +1284,60 @@ export function useConversationFollow(
|
|
|
807
1284
|
|
|
808
1285
|
const restoreBeforePaint = (): void => {
|
|
809
1286
|
if (!following || port === null || !isLeader(port)) return
|
|
810
|
-
|
|
1287
|
+
// A reveal commit changes the measured tail, not the fixed chrome. Keep
|
|
1288
|
+
// the paint-limit TTL intact here; ResizeObserver and viewport/chrome
|
|
1289
|
+
// changes invalidate it when the cached geometry is no longer valid.
|
|
1290
|
+
// The same-task correction still runs, but ordinary glyph commits do not
|
|
1291
|
+
// force a fresh chrome rect read.
|
|
811
1292
|
const tuning = debugRuntime.activeTuning()
|
|
812
|
-
|
|
1293
|
+
const predictGrowth = predictiveRef?.current ?? predictive
|
|
1294
|
+
const floor = Math.max(0, port.scrollHeight - port.clientHeight)
|
|
1295
|
+
// A wrap or other layout growth changes the real floor and may change the
|
|
1296
|
+
// natural tail clearance. Re-measure that boundary; same-floor glyph
|
|
1297
|
+
// commits can continue using the cached chrome geometry.
|
|
1298
|
+
if (followFloorHistory.get(port) !== floor) invalidatePaintLimit(port)
|
|
1299
|
+
const isReasoningSurface = rootRef.current?.querySelector('[data-variant="think"]') !== null
|
|
1300
|
+
const trajectoryShift = predictive
|
|
1301
|
+
&& !isReasoningSurface
|
|
1302
|
+
&& runwayOffsetOf(port) > 0
|
|
1303
|
+
&& trajectoryPositionPx !== null
|
|
1304
|
+
? floor - trajectoryPositionPx
|
|
1305
|
+
: undefined
|
|
1306
|
+
animatedH = applyVisual(
|
|
1307
|
+
port,
|
|
1308
|
+
animatedH,
|
|
1309
|
+
reservePx,
|
|
1310
|
+
velocityPxPerSec,
|
|
1311
|
+
tuning.runwayPx,
|
|
1312
|
+
Number.POSITIVE_INFINITY,
|
|
1313
|
+
!predictGrowth,
|
|
1314
|
+
trajectoryShift,
|
|
1315
|
+
)
|
|
1316
|
+
if (trajectoryShift !== undefined) {
|
|
1317
|
+
trajectoryPositionPx = floor - currentShiftOf(shiftSurfacesOf(port).at(-1) ?? port)
|
|
1318
|
+
}
|
|
813
1319
|
updateRevealScale(port, 0, true)
|
|
814
1320
|
reportFollow(port, activeRef.current)
|
|
1321
|
+
// Host ChatView ResizeObservers may run after this observer and hard-snap
|
|
1322
|
+
// to its floor. Re-apply the owned floating top in the same microtask,
|
|
1323
|
+
// before the browser's next paint, while scroll-event capture prevents a
|
|
1324
|
+
// programmatic write from changing the reader's at-bottom state.
|
|
815
1325
|
}
|
|
816
1326
|
|
|
1327
|
+
// Same correction, run synchronously with a reveal commit (see
|
|
1328
|
+
// notifyFollowCommit above). Subscribed per-port in bindPort.
|
|
1329
|
+
let unsubscribeCommit: (() => void) | null = null
|
|
1330
|
+
|
|
817
1331
|
const bindPort = (next: HTMLElement): void => {
|
|
818
1332
|
if (port === next) return
|
|
819
1333
|
if (port !== null) {
|
|
820
1334
|
for (const name of GESTURE_EVENTS) port.removeEventListener(name, markGesture)
|
|
821
1335
|
resize?.disconnect()
|
|
822
1336
|
}
|
|
1337
|
+
unsubscribeCommit?.()
|
|
823
1338
|
port = next
|
|
824
1339
|
invalidatePaintLimit(port)
|
|
1340
|
+
unsubscribeCommit = subscribeFollowCommit(port, () => { restoreBeforePaint() })
|
|
825
1341
|
for (const name of GESTURE_EVENTS) {
|
|
826
1342
|
port.addEventListener(name, markGesture, { passive: true })
|
|
827
1343
|
}
|
|
@@ -833,6 +1349,21 @@ export function useConversationFollow(
|
|
|
833
1349
|
}
|
|
834
1350
|
}
|
|
835
1351
|
|
|
1352
|
+
/**
|
|
1353
|
+
* Keep the observer on the flow's TAIL surface. A flow locked to the
|
|
1354
|
+
* viewport by min-height does not resize when content grows inside it —
|
|
1355
|
+
* only the last message row does, and missing that resize means missing
|
|
1356
|
+
* the pre-paint correction for that frame's wrap.
|
|
1357
|
+
*/
|
|
1358
|
+
const observeTailSurface = (): void => {
|
|
1359
|
+
if (resize === null || port === null) return
|
|
1360
|
+
const tail = shiftSurfacesOf(port).at(-1) ?? null
|
|
1361
|
+
if (tail === observedTail) return
|
|
1362
|
+
if (observedTail !== null) resize.unobserve(observedTail)
|
|
1363
|
+
observedTail = tail
|
|
1364
|
+
if (tail !== null) resize.observe(tail)
|
|
1365
|
+
}
|
|
1366
|
+
|
|
836
1367
|
const frame = (now: number) => {
|
|
837
1368
|
rafId = requestAnimationFrame(frame)
|
|
838
1369
|
// Spring time is clamped so one paint after a stall cannot teleport the
|
|
@@ -847,6 +1378,7 @@ export function useConversationFollow(
|
|
|
847
1378
|
const nextPort = root.closest<HTMLElement>('[data-conversation-scroll]')
|
|
848
1379
|
if (nextPort === null) return
|
|
849
1380
|
bindPort(nextPort)
|
|
1381
|
+
observeTailSurface()
|
|
850
1382
|
// A hidden/unmeasured port has no meaningful floor yet. Keep this owner
|
|
851
1383
|
// unprimed and let the already-scheduled RAF initialize it after layout.
|
|
852
1384
|
if (nextPort.clientHeight <= 0) return
|
|
@@ -866,18 +1398,44 @@ export function useConversationFollow(
|
|
|
866
1398
|
// A new Agent row is already part of scrollHeight on its first
|
|
867
1399
|
// frame. Start at the pre-insert extent so that initial Context and
|
|
868
1400
|
// Tool chrome enters through the same spring as later height growth.
|
|
1401
|
+
// The predictive runway starts pre-opened at the current reveal
|
|
1402
|
+
// pressure: it rides canceled by the equal transform, and waiting
|
|
1403
|
+
// out the response ramp would leave the first wraps unpinned.
|
|
869
1404
|
const entranceExtent = entrancePending
|
|
870
1405
|
? entranceExtentRef?.current ?? entranceExtentOf(root)
|
|
871
1406
|
: 0
|
|
872
|
-
|
|
873
|
-
|
|
1407
|
+
const predictGrowth = predictiveRef?.current ?? predictive
|
|
1408
|
+
// Start the entrance at the pre-insert extent, not the raw reported
|
|
1409
|
+
// lag: holding the reader's small first-frame offset here would keep
|
|
1410
|
+
// the entrance lag above the settle epsilon for ~a second (the
|
|
1411
|
+
// spring's advance at single-digit lag is sub-pixel), leaving the
|
|
1412
|
+
// entrance arm alive past a settled swap and deferring the
|
|
1413
|
+
// completion settle until it finally closes.
|
|
1414
|
+
animatedH = entrancePending
|
|
1415
|
+
? Math.max(0, nextPort.scrollHeight - entranceExtent)
|
|
1416
|
+
: nextPort.scrollHeight
|
|
1417
|
+
// Established before first paint; the matching margin below is
|
|
1418
|
+
// written in the same commit, so this held-and-canceled space
|
|
1419
|
+
// never moves a pixel.
|
|
1420
|
+
const hasStatus = turnStatusOf(nextPort) !== null
|
|
1421
|
+
reservePx = predictGrowth && (hasStatus || speedCpsRef.current > FOLLOW_RESERVE_MIN_CPS)
|
|
1422
|
+
? computeFollowReserve(speedCpsRef.current, tuning.runwayPx)
|
|
1423
|
+
: 0
|
|
1424
|
+
statusWasPresent = hasStatus
|
|
874
1425
|
velocityPxPerSec = 0
|
|
875
1426
|
// The committed row/growth delta has already moved the new floor.
|
|
876
|
-
// Decide ownership from
|
|
877
|
-
//
|
|
878
|
-
//
|
|
879
|
-
|
|
880
|
-
|
|
1427
|
+
// Decide ownership from READER INTENT EVIDENCE, not raw lag: an
|
|
1428
|
+
// upward move recorded past our own ledger is a pull-up; anything
|
|
1429
|
+
// else (content that mounted while the host had not re-pinned yet,
|
|
1430
|
+
// a post-fold clamp, first-frame geometry) must keep following,
|
|
1431
|
+
// otherwise the whole stream falls back to the host's hard snap.
|
|
1432
|
+
void reportedLag
|
|
1433
|
+
// A prior follower's reader-release record outranks ledger
|
|
1434
|
+
// evidence: its closure died with `following = false` after the
|
|
1435
|
+
// release already reset the ledger to the held position, so the
|
|
1436
|
+
// delta-based check alone can no longer see the unpin.
|
|
1437
|
+
following = !readerScrolledUp(nextPort)
|
|
1438
|
+
&& !followReaderHolds.has(nextPort)
|
|
881
1439
|
} else {
|
|
882
1440
|
animatedH = Math.min(nextPort.scrollHeight, inherited.extent)
|
|
883
1441
|
reservePx = inherited.reservePx
|
|
@@ -887,7 +1445,38 @@ export function useConversationFollow(
|
|
|
887
1445
|
if (following) {
|
|
888
1446
|
hold(nextPort)
|
|
889
1447
|
if (isLeader(nextPort)) {
|
|
890
|
-
animatedH = applyVisual(
|
|
1448
|
+
animatedH = applyVisual(
|
|
1449
|
+
nextPort,
|
|
1450
|
+
animatedH,
|
|
1451
|
+
reservePx,
|
|
1452
|
+
velocityPxPerSec,
|
|
1453
|
+
tuning.runwayPx,
|
|
1454
|
+
Number.POSITIVE_INFINITY,
|
|
1455
|
+
!(predictiveRef?.current ?? predictive),
|
|
1456
|
+
)
|
|
1457
|
+
if (
|
|
1458
|
+
predictive
|
|
1459
|
+
&& root.querySelector('[data-variant="think"]') === null
|
|
1460
|
+
&& runwayOffsetOf(nextPort) > 0
|
|
1461
|
+
) {
|
|
1462
|
+
const floor = Math.max(0, nextPort.scrollHeight - nextPort.clientHeight)
|
|
1463
|
+
const requestedMinLagPx = Math.max(
|
|
1464
|
+
FOLLOW_TRAJECTORY_MIN_LAG_PX,
|
|
1465
|
+
runwayOffsetOf(nextPort) - FOLLOW_TRAJECTORY_PHASE_PX,
|
|
1466
|
+
)
|
|
1467
|
+
const paintMaxLagPx = Math.max(0, safeShiftLimit(nextPort, shiftSurfacesOf(nextPort)) - FOLLOW_PAINT_GUARD_PX)
|
|
1468
|
+
const minLagPx = Math.min(requestedMinLagPx, paintMaxLagPx)
|
|
1469
|
+
const currentShift = currentShiftOf(shiftSurfacesOf(nextPort).at(-1) ?? nextPort)
|
|
1470
|
+
trajectoryPositionPx = floor - Math.min(paintMaxLagPx, Math.max(minLagPx, currentShift))
|
|
1471
|
+
trajectoryTargetVelocityPxPerMs = Math.max(0, speedCpsRef.current) * 0.4 / 1000
|
|
1472
|
+
trajectoryVelocityPxPerMs = trajectoryTargetVelocityPxPerMs
|
|
1473
|
+
trajectoryFloorPx = floor
|
|
1474
|
+
trajectoryGrowthAtMs = now
|
|
1475
|
+
trajectoryAccumulatedGrowthPx = 0
|
|
1476
|
+
trajectoryAccumulatedGrowthMs = 0
|
|
1477
|
+
trajectoryGrowthSamples = 0
|
|
1478
|
+
trajectoryWasActive = true
|
|
1479
|
+
}
|
|
891
1480
|
updateRevealScale(nextPort, elapsedMs)
|
|
892
1481
|
reportFollow(nextPort, activeRef.current)
|
|
893
1482
|
const runwayOffset = runwayOffsetOf(nextPort)
|
|
@@ -910,6 +1499,7 @@ export function useConversationFollow(
|
|
|
910
1499
|
if (!following && (!interacting || returnedToFloor) && reportedLag <= repinSlack) {
|
|
911
1500
|
following = true
|
|
912
1501
|
readerReleased = false
|
|
1502
|
+
followReaderHolds.delete(nextPort)
|
|
913
1503
|
animatedH = extent
|
|
914
1504
|
reservePx = 0
|
|
915
1505
|
velocityPxPerSec = 0
|
|
@@ -921,6 +1511,7 @@ export function useConversationFollow(
|
|
|
921
1511
|
following = false
|
|
922
1512
|
readerGestureIntent = false
|
|
923
1513
|
readerReleased = true
|
|
1514
|
+
followReaderHolds.set(nextPort, { atMs: performance.now() })
|
|
924
1515
|
handBackVisual(nextPort)
|
|
925
1516
|
animatedH = nextPort.scrollHeight
|
|
926
1517
|
reservePx = 0
|
|
@@ -943,32 +1534,152 @@ export function useConversationFollow(
|
|
|
943
1534
|
// Runway and an equal transform cancel visually. It is the zero point,
|
|
944
1535
|
// not residual motion: decaying below it would scroll past the final
|
|
945
1536
|
// resting position and rebound when runway is removed.
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
1537
|
+
//
|
|
1538
|
+
// The reservation scales with reveal pressure — idle holds nothing,
|
|
1539
|
+
// because speculative space at rest is a visible defect on handback —
|
|
1540
|
+
// but the runway MARGIN tracks the reservation 1:1 in the same frame,
|
|
1541
|
+
// so their difference (the only part that can move pixels) stays
|
|
1542
|
+
// constant. Growing the reservation without the margin would glide the
|
|
1543
|
+
// whole column; growing both together is invisible.
|
|
949
1544
|
const predictGrowth = predictiveRef?.current ?? predictive
|
|
950
|
-
const
|
|
1545
|
+
const hasStatus = turnStatusOf(nextPort) !== null
|
|
1546
|
+
const statusJustRemoved = predictGrowth && statusWasPresent === true && !hasStatus
|
|
1547
|
+
if (statusJustRemoved) reservePx = tuning.runwayPx
|
|
1548
|
+
statusWasPresent = hasStatus
|
|
1549
|
+
const reserveEnabled = hasStatus
|
|
1550
|
+
|| statusJustRemoved
|
|
1551
|
+
|| reservePx > FOLLOW_SETTLE_EPSILON_PX
|
|
1552
|
+
|| speedCpsRef.current > FOLLOW_RESERVE_MIN_CPS
|
|
1553
|
+
const pressureReserveTarget = predictGrowth && reserveEnabled
|
|
1554
|
+
? computeFollowReserve(speedCpsRef.current, tuning.runwayPx)
|
|
1555
|
+
: 0
|
|
1556
|
+
// Reveal pressure may open more runway, but a burst gap must not retire
|
|
1557
|
+
// it mid-stream: shrinking the owned margin moves the real floor and
|
|
1558
|
+
// creates the exact 1px back-and-forth motion this module prevents.
|
|
1559
|
+
// Prediction shutdown and an explicit lower debug cap still retire it.
|
|
1560
|
+
const heldReserveTarget = tuning.runwayPx < reservePx
|
|
1561
|
+
? tuning.runwayPx
|
|
1562
|
+
: Math.max(reservePx, pressureReserveTarget)
|
|
1563
|
+
const effectiveReserveTarget = !predictGrowth
|
|
951
1564
|
? 0
|
|
952
|
-
:
|
|
1565
|
+
: statusJustRemoved
|
|
1566
|
+
? tuning.runwayPx
|
|
1567
|
+
: heldReserveTarget
|
|
953
1568
|
const reserveStep = 1 - Math.exp(-elapsedMs / tuning.reserveResponseMs)
|
|
954
|
-
reservePx += (
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
1569
|
+
reservePx += (effectiveReserveTarget - reservePx) * reserveStep
|
|
1570
|
+
if (predictGrowth || runwayOffsetOf(nextPort) > 0.5) {
|
|
1571
|
+
ensureRunway(nextPort, shiftSurfacesOf(nextPort), reservePx)
|
|
1572
|
+
}
|
|
1573
|
+
const runwayOffset = runwayOffsetOf(nextPort)
|
|
1574
|
+
const contentHeight = nextPort.scrollHeight
|
|
1575
|
+
const floorNow = Math.max(0, contentHeight - nextPort.clientHeight)
|
|
1576
|
+
const trajectoryActive = predictive
|
|
1577
|
+
&& root.querySelector('[data-variant="think"]') === null
|
|
1578
|
+
&& runwayOffset > 0
|
|
1579
|
+
let trajectoryShift: number | undefined
|
|
1580
|
+
if (trajectoryActive) {
|
|
1581
|
+
const requestedMinLagPx = Math.max(
|
|
1582
|
+
FOLLOW_TRAJECTORY_MIN_LAG_PX,
|
|
1583
|
+
runwayOffset - FOLLOW_TRAJECTORY_PHASE_PX,
|
|
968
1584
|
)
|
|
969
|
-
|
|
1585
|
+
// Phase-band upper bound comes from paint space, not a runway-derived
|
|
1586
|
+
// constant: `minLag + 39` can exceed the real gap to status/composer
|
|
1587
|
+
// chrome and get clipped into a hard catch-up exactly at the wrap
|
|
1588
|
+
// cadence the band exists to absorb (see docs §3.1).
|
|
1589
|
+
const paintLimit = safeShiftLimit(nextPort, shiftSurfacesOf(nextPort))
|
|
1590
|
+
const maxLagPx = Math.max(0, paintLimit - FOLLOW_PAINT_GUARD_PX)
|
|
1591
|
+
const minLagPx = Math.min(requestedMinLagPx, maxLagPx)
|
|
1592
|
+
if (trajectoryPositionPx === null || trajectoryFloorPx === null) {
|
|
1593
|
+
const currentShift = currentShiftOf(shiftSurfacesOf(nextPort).at(-1) ?? nextPort)
|
|
1594
|
+
trajectoryPositionPx = floorNow - Math.min(maxLagPx, Math.max(minLagPx, currentShift))
|
|
1595
|
+
trajectoryTargetVelocityPxPerMs = Math.max(0, speedCpsRef.current) * 0.4 / 1000
|
|
1596
|
+
trajectoryVelocityPxPerMs = trajectoryTargetVelocityPxPerMs
|
|
1597
|
+
trajectoryGrowthAtMs = now
|
|
1598
|
+
} else if (floorNow > trajectoryFloorPx + 0.5) {
|
|
1599
|
+
const intervalMs = Math.max(1, now - (trajectoryGrowthAtMs ?? now))
|
|
1600
|
+
if (trajectoryGrowthSamples > 0) {
|
|
1601
|
+
trajectoryAccumulatedGrowthPx += floorNow - trajectoryFloorPx
|
|
1602
|
+
trajectoryAccumulatedGrowthMs += intervalMs
|
|
1603
|
+
const measuredVelocity = trajectoryAccumulatedGrowthPx
|
|
1604
|
+
/ trajectoryAccumulatedGrowthMs
|
|
1605
|
+
// A single wrap interval is quantized by layout and may also span a
|
|
1606
|
+
// dropped frame. Do not replace the trajectory target with that
|
|
1607
|
+
// staircase sample: low-pass it over a few wraps so the visual
|
|
1608
|
+
// velocity remains continuous when the host is busy or a retry row
|
|
1609
|
+
// changes the layout.
|
|
1610
|
+
const targetBlend = 1 - Math.exp(-intervalMs / 240)
|
|
1611
|
+
trajectoryTargetVelocityPxPerMs += (
|
|
1612
|
+
measuredVelocity - trajectoryTargetVelocityPxPerMs
|
|
1613
|
+
) * targetBlend
|
|
1614
|
+
}
|
|
1615
|
+
trajectoryGrowthSamples += 1
|
|
1616
|
+
trajectoryGrowthAtMs = now
|
|
1617
|
+
} else if (floorNow < trajectoryFloorPx - 0.5) {
|
|
1618
|
+
trajectoryPositionPx = floorNow - minLagPx
|
|
1619
|
+
trajectoryGrowthAtMs = now
|
|
1620
|
+
trajectoryAccumulatedGrowthPx = 0
|
|
1621
|
+
trajectoryAccumulatedGrowthMs = 0
|
|
1622
|
+
trajectoryGrowthSamples = 0
|
|
1623
|
+
}
|
|
1624
|
+
trajectoryFloorPx = floorNow
|
|
1625
|
+
const phaseTarget = revealedCharsRef === undefined
|
|
1626
|
+
? floorNow
|
|
1627
|
+
: revealPhase.advance(floorNow, revealedCharsRef.current).targetPx
|
|
1628
|
+
const trajectoryStep = computeFollowTrajectoryStep(elapsedMs, {
|
|
1629
|
+
positionPx: trajectoryPositionPx,
|
|
1630
|
+
velocityPxPerMs: trajectoryVelocityPxPerMs,
|
|
1631
|
+
targetPx: phaseTarget,
|
|
1632
|
+
targetVelocityPxPerMs: trajectoryTargetVelocityPxPerMs,
|
|
1633
|
+
minLagPx,
|
|
1634
|
+
maxLagPx,
|
|
1635
|
+
paintFloorPx: floorNow,
|
|
1636
|
+
})
|
|
1637
|
+
trajectoryPositionPx = trajectoryStep.positionPx
|
|
1638
|
+
trajectoryVelocityPxPerMs = trajectoryStep.velocityPxPerMs
|
|
1639
|
+
trajectoryShift = trajectoryStep.shiftPx
|
|
1640
|
+
trajectoryWasActive = true
|
|
1641
|
+
const baselineShift = runwayOffset - Math.min(runwayOffset, Math.max(0, reservePx))
|
|
1642
|
+
animatedH = contentHeight - runwayOffset - Math.max(0, trajectoryShift - baselineShift)
|
|
1643
|
+
velocityPxPerSec = trajectoryVelocityPxPerMs * 1000
|
|
1644
|
+
} else {
|
|
1645
|
+
if (trajectoryWasActive) {
|
|
1646
|
+
const currentShift = currentShiftOf(shiftSurfacesOf(nextPort).at(-1) ?? nextPort)
|
|
1647
|
+
animatedH = contentHeight - runwayOffset - Math.max(0, currentShift)
|
|
1648
|
+
velocityPxPerSec = trajectoryVelocityPxPerMs * 1000
|
|
1649
|
+
trajectoryPositionPx = null
|
|
1650
|
+
trajectoryWasActive = false
|
|
1651
|
+
}
|
|
1652
|
+
const lag = Math.max(0, contentHeight - animatedH - runwayOffset)
|
|
1653
|
+
const step = computeFollowStep(dt, {
|
|
1654
|
+
lag,
|
|
1655
|
+
speedEma: speedCpsRef.current,
|
|
1656
|
+
velocityPxPerSec,
|
|
1657
|
+
}, tuning)
|
|
1658
|
+
if (lag <= 0.1) {
|
|
1659
|
+
animatedH = contentHeight - runwayOffset
|
|
1660
|
+
velocityPxPerSec = 0
|
|
1661
|
+
} else {
|
|
1662
|
+
const minimumLag = predictGrowth ? 0 : Math.max(0, reservePx)
|
|
1663
|
+
animatedH = Math.min(
|
|
1664
|
+
contentHeight - runwayOffset - minimumLag,
|
|
1665
|
+
animatedH + step.advancePx,
|
|
1666
|
+
)
|
|
1667
|
+
velocityPxPerSec = step.velocityPxPerSec
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
animatedH = applyVisual(
|
|
1671
|
+
nextPort,
|
|
1672
|
+
animatedH,
|
|
1673
|
+
reservePx,
|
|
1674
|
+
velocityPxPerSec,
|
|
1675
|
+
tuning.runwayPx,
|
|
1676
|
+
Number.POSITIVE_INFINITY,
|
|
1677
|
+
!predictGrowth,
|
|
1678
|
+
trajectoryShift,
|
|
1679
|
+
)
|
|
1680
|
+
if (trajectoryActive) {
|
|
1681
|
+
trajectoryPositionPx = floorNow - currentShiftOf(shiftSurfacesOf(nextPort).at(-1) ?? nextPort)
|
|
970
1682
|
}
|
|
971
|
-
animatedH = applyVisual(nextPort, animatedH, reservePx, velocityPxPerSec, tuning.runwayPx)
|
|
972
1683
|
updateRevealScale(nextPort, elapsedMs)
|
|
973
1684
|
reportFollow(nextPort, true)
|
|
974
1685
|
const remainingEntranceLag = Math.max(
|
|
@@ -985,6 +1696,7 @@ export function useConversationFollow(
|
|
|
985
1696
|
frame(performance.now())
|
|
986
1697
|
return () => {
|
|
987
1698
|
cancelAnimationFrame(rafId)
|
|
1699
|
+
unsubscribeCommit?.()
|
|
988
1700
|
if (interactTimer !== null) clearTimeout(interactTimer)
|
|
989
1701
|
resize?.disconnect()
|
|
990
1702
|
if (port !== null) {
|
|
@@ -997,6 +1709,13 @@ export function useConversationFollow(
|
|
|
997
1709
|
if (!isLeader(host)) return
|
|
998
1710
|
const preserveReader = interacting && (readerGestureIntent || readerScrolledUp(host))
|
|
999
1711
|
if (!following || !primed) {
|
|
1712
|
+
if (!following && primed) {
|
|
1713
|
+
// A follower that already released the reader must carry that fact
|
|
1714
|
+
// across this closure's death: the draining arm mounting after this
|
|
1715
|
+
// cleanup would otherwise read the held viewport as its own
|
|
1716
|
+
// at-bottom state and hard-snap it to the floor.
|
|
1717
|
+
followReaderHolds.set(host, { atMs: performance.now() })
|
|
1718
|
+
}
|
|
1000
1719
|
clearVisual(host)
|
|
1001
1720
|
followLeaders.delete(host)
|
|
1002
1721
|
releaseRevealScale()
|
|
@@ -1005,6 +1724,7 @@ export function useConversationFollow(
|
|
|
1005
1724
|
}
|
|
1006
1725
|
if (preserveReader) {
|
|
1007
1726
|
handBackVisual(host)
|
|
1727
|
+
followReaderHolds.set(host, { atMs: performance.now() })
|
|
1008
1728
|
clearVisual(host)
|
|
1009
1729
|
followLeaders.delete(host)
|
|
1010
1730
|
releaseRevealScale()
|
|
@@ -1015,21 +1735,56 @@ export function useConversationFollow(
|
|
|
1015
1735
|
// Completion can land the final Tool/command height in this same
|
|
1016
1736
|
// commit. Preserve the logical extent and drain it after unmount instead
|
|
1017
1737
|
// of clearing the compositor state before the first settled paint.
|
|
1738
|
+
// NOTE: do NOT clamp animatedH down by the held reserve here — the
|
|
1739
|
+
// reserve is canceled space (base = margin − reservation stays flat),
|
|
1740
|
+
// so treating it as real lag paints a whole runway-height step at the
|
|
1741
|
+
// exact moment leadership hands to the draining arm. The settle loop
|
|
1742
|
+
// caps animatedH against the shrinking extent directly.
|
|
1743
|
+
const lagBeforeCompletionPaint = Math.max(
|
|
1744
|
+
0,
|
|
1745
|
+
host.scrollHeight - animatedH - runwayOffsetOf(host),
|
|
1746
|
+
)
|
|
1747
|
+
if (!activeRef.current && lagBeforeCompletionPaint <= FOLLOW_SLACK_PX) {
|
|
1748
|
+
finishAtNaturalFloor(host, !startedAsEntrance)
|
|
1749
|
+
followLeaders.delete(host)
|
|
1750
|
+
releaseRevealScale()
|
|
1751
|
+
debugRuntime.reportFollow(host, null)
|
|
1752
|
+
return
|
|
1753
|
+
}
|
|
1754
|
+
const completionShift = currentShiftOf(shiftSurfacesOf(host).at(-1) ?? host)
|
|
1755
|
+
const completionShiftCeiling = completionShift > FOLLOW_SETTLE_EPSILON_PX
|
|
1756
|
+
? completionShift
|
|
1757
|
+
: Number.POSITIVE_INFINITY
|
|
1758
|
+
// The completion handoff keeps at least one real runway open so the
|
|
1759
|
+
// final height has reserved paint room to drain through: with zero
|
|
1760
|
+
// margin the whole final height lands as shift in ONE paint (offset
|
|
1761
|
+
// space collapses to the paint limit), the exact teleport this drain
|
|
1762
|
+
// exists to prevent. The draining arm ramps its reserve from here.
|
|
1018
1763
|
const completionTuning = debugRuntime.activeTuning()
|
|
1019
|
-
|
|
1764
|
+
const previousCompletionRunway = runwayOffsetOf(host)
|
|
1765
|
+
ensureRunway(host, shiftSurfacesOf(host), Math.max(reservePx, completionTuning.runwayPx))
|
|
1020
1766
|
const completionRunway = runwayOffsetOf(host)
|
|
1021
|
-
|
|
1022
|
-
|
|
1767
|
+
// Rebase the spring extent onto the new offset domain before the paint:
|
|
1768
|
+
// adding the margin drops targetHeight by the same amount, so animatedH
|
|
1769
|
+
// must drop in lockstep or the margin's px release as an instant shift.
|
|
1770
|
+
animatedH = Math.max(0, animatedH - (completionRunway - previousCompletionRunway))
|
|
1771
|
+
settleAtFloor(host)
|
|
1772
|
+
animatedH = applyVisual(
|
|
1773
|
+
host,
|
|
1023
1774
|
animatedH,
|
|
1024
|
-
|
|
1775
|
+
reservePx,
|
|
1776
|
+
velocityPxPerSec,
|
|
1777
|
+
completionRunway,
|
|
1778
|
+
completionShiftCeiling,
|
|
1025
1779
|
)
|
|
1026
|
-
settleAtFloor(host)
|
|
1027
|
-
animatedH = applyVisual(host, animatedH, reservePx, velocityPxPerSec, completionTuning.runwayPx)
|
|
1028
1780
|
reportFollow(host, false)
|
|
1029
1781
|
const runwayOffset = runwayOffsetOf(host)
|
|
1030
1782
|
const remainingLag = Math.max(0, host.scrollHeight - animatedH - runwayOffset)
|
|
1031
|
-
if (
|
|
1032
|
-
|
|
1783
|
+
if (
|
|
1784
|
+
remainingLag <= FOLLOW_SETTLE_EPSILON_PX
|
|
1785
|
+
|| (!activeRef.current && remainingLag <= FOLLOW_SLACK_PX)
|
|
1786
|
+
) {
|
|
1787
|
+
finishAtNaturalFloor(host, !startedAsEntrance)
|
|
1033
1788
|
followLeaders.delete(host)
|
|
1034
1789
|
releaseRevealScale()
|
|
1035
1790
|
debugRuntime.reportFollow(host, null)
|
|
@@ -1047,6 +1802,8 @@ export function useConversationFollow(
|
|
|
1047
1802
|
}
|
|
1048
1803
|
}
|
|
1049
1804
|
let settleLast = performance.now()
|
|
1805
|
+
let settleMarginPx = Math.max(runwayOffsetOf(host), reservePx)
|
|
1806
|
+
let settleMarginRate = settleMarginPx / FOLLOW_RUNWAY_RETIRE_MS
|
|
1050
1807
|
const settleFrame = (now: number): void => {
|
|
1051
1808
|
if (!isLeader(host)) {
|
|
1052
1809
|
stopSettleListeners()
|
|
@@ -1065,15 +1822,24 @@ export function useConversationFollow(
|
|
|
1065
1822
|
const dt = Math.min(FOLLOW_MAX_FRAME_MS, Math.max(0, now - settleLast))
|
|
1066
1823
|
const tuning = debugRuntime.activeTuning()
|
|
1067
1824
|
settleLast = now
|
|
1825
|
+
// Retire the runway in LOCKSTEP with its canceling reserve: the
|
|
1826
|
+
// margin shrinks by exactly the amount the reservation shrinks, so
|
|
1827
|
+
// their baseline difference — and therefore every painted pixel —
|
|
1828
|
+
// stays put while the reserved space closes. Decaying only the
|
|
1829
|
+
// reservation would slide the whole column by the runway height.
|
|
1830
|
+
if (settleMarginPx > 0 && settleMarginRate > 0) {
|
|
1831
|
+
settleMarginPx = Math.max(0, settleMarginPx - settleMarginRate * dt)
|
|
1832
|
+
ensureRunway(host, shiftSurfacesOf(host), settleMarginPx)
|
|
1833
|
+
reservePx = Math.min(reservePx, settleMarginPx)
|
|
1834
|
+
}
|
|
1068
1835
|
const runwayOffset = runwayOffsetOf(host)
|
|
1069
1836
|
const lag = Math.max(0, host.scrollHeight - animatedH - runwayOffset)
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
if (lag <= FOLLOW_SETTLE_EPSILON_PX && reservePx <= FOLLOW_SETTLE_EPSILON_PX) {
|
|
1073
|
-
animatedH = host.scrollHeight - runwayOffset
|
|
1837
|
+
if (lag <= FOLLOW_SETTLE_EPSILON_PX && settleMarginPx <= FOLLOW_SETTLE_EPSILON_PX) {
|
|
1838
|
+
animatedH = host.scrollHeight
|
|
1074
1839
|
reservePx = 0
|
|
1075
1840
|
velocityPxPerSec = 0
|
|
1076
|
-
|
|
1841
|
+
followRunways.delete(host)
|
|
1842
|
+
finishAtNaturalFloor(host, !startedAsEntrance)
|
|
1077
1843
|
followLeaders.delete(host)
|
|
1078
1844
|
releaseRevealScale()
|
|
1079
1845
|
debugRuntime.reportFollow(host, null)
|
|
@@ -1085,21 +1851,38 @@ export function useConversationFollow(
|
|
|
1085
1851
|
speedEma: speedCpsRef.current,
|
|
1086
1852
|
velocityPxPerSec,
|
|
1087
1853
|
}, tuning)
|
|
1088
|
-
// The temporary runway is visually neutral only while an equal lag
|
|
1089
|
-
// remains in the transform. Do not let the spring outrun the runway's
|
|
1090
|
-
// closing reserve or cleanup would reveal an overshoot and rebound.
|
|
1091
|
-
const minimumLag = Math.max(0, reservePx)
|
|
1092
1854
|
animatedH = Math.min(
|
|
1093
|
-
host.scrollHeight - runwayOffset
|
|
1855
|
+
host.scrollHeight - runwayOffset,
|
|
1094
1856
|
animatedH + step.advancePx,
|
|
1095
1857
|
)
|
|
1096
1858
|
velocityPxPerSec = step.velocityPxPerSec
|
|
1097
1859
|
settleAtFloor(host)
|
|
1098
|
-
animatedH = applyVisual(host, animatedH, reservePx, velocityPxPerSec,
|
|
1860
|
+
animatedH = applyVisual(host, animatedH, reservePx, velocityPxPerSec, Math.max(settleMarginPx, runwayOffset))
|
|
1099
1861
|
reportFollow(host, false)
|
|
1100
1862
|
requestAnimationFrame(settleFrame)
|
|
1101
1863
|
}
|
|
1102
1864
|
requestAnimationFrame(settleFrame)
|
|
1103
1865
|
}
|
|
1104
1866
|
}, [active, rootRef, speedCpsRef, revealScaleRef, predictive, predictiveRef])
|
|
1867
|
+
|
|
1868
|
+
useLayoutEffect(() => {
|
|
1869
|
+
const host = rootRef.current?.closest<HTMLElement>('[data-conversation-scroll]') ?? null
|
|
1870
|
+
if (host !== null) {
|
|
1871
|
+
followFlowFillUsers.set(host, (followFlowFillUsers.get(host) ?? 0) + 1)
|
|
1872
|
+
}
|
|
1873
|
+
return () => {
|
|
1874
|
+
if (host === null) return
|
|
1875
|
+
const remaining = Math.max(0, (followFlowFillUsers.get(host) ?? 1) - 1)
|
|
1876
|
+
if (remaining > 0) {
|
|
1877
|
+
followFlowFillUsers.set(host, remaining)
|
|
1878
|
+
return
|
|
1879
|
+
}
|
|
1880
|
+
followFlowFillUsers.delete(host)
|
|
1881
|
+
requestAnimationFrame(() => {
|
|
1882
|
+
requestAnimationFrame(() => {
|
|
1883
|
+
if (!followLeaders.has(host) && !followFlowFillUsers.has(host)) restoreFlowFill(host)
|
|
1884
|
+
})
|
|
1885
|
+
})
|
|
1886
|
+
}
|
|
1887
|
+
}, [rootRef])
|
|
1105
1888
|
}
|