dsh-smooth-stream 0.4.1 → 0.4.3
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 +91 -61
- package/README.md +90 -61
- package/lib/client.js +1633 -1119
- package/lib/client.js.map +1 -1
- package/lib/index.js +3 -3
- 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 +134 -39
- 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 +878 -114
- package/src/client/useSmoothStreamContent.ts +71 -15
- package/src/plugin.ts +10 -2
- package/src/settings.ts +1 -1
|
@@ -26,7 +26,7 @@
|
|
|
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
32
|
|
|
@@ -44,9 +44,13 @@ export const FOLLOW_SPRING_SUBSTEPS = 4
|
|
|
44
44
|
export const FOLLOW_SPRING_MAX_STEP_MS = 32
|
|
45
45
|
|
|
46
46
|
/** Minimum visible room for one ordinary line-wrap impulse. */
|
|
47
|
-
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
|
|
48
51
|
|
|
49
52
|
/** Reveal-speed range produced by the pressure-buffer typewriter. */
|
|
53
|
+
/** Retained for consumers that tune the old pressure threshold. */
|
|
50
54
|
export const FOLLOW_RESERVE_MIN_CPS = 90
|
|
51
55
|
export const FOLLOW_RESERVE_MAX_CPS = 600
|
|
52
56
|
|
|
@@ -84,11 +88,19 @@ export const FOLLOW_HOST_RELEASE_PX = FOLLOW_SLACK_PX + 1
|
|
|
84
88
|
export const FOLLOW_PAINT_GUARD_PX = 1
|
|
85
89
|
|
|
86
90
|
/**
|
|
87
|
-
* Maximum predictive paint room before status/composer chrome.
|
|
88
|
-
*
|
|
89
|
-
*
|
|
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.
|
|
90
102
|
*/
|
|
91
|
-
export const
|
|
103
|
+
export const FOLLOW_RUNWAY_RETIRE_MS = 160
|
|
92
104
|
|
|
93
105
|
/** How long a gesture keeps `isUserInteracting` so the next scroll can unpin. */
|
|
94
106
|
export const FOLLOW_GESTURE_MS = 800
|
|
@@ -96,16 +108,48 @@ export const FOLLOW_GESTURE_MS = 800
|
|
|
96
108
|
/** Sub-pixel settle threshold; clearing below this cannot produce a visible rebound. */
|
|
97
109
|
export const FOLLOW_SETTLE_EPSILON_PX = 0.25
|
|
98
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
|
+
|
|
99
131
|
/** Lowest reveal rate retained while the spring is short on paint room. */
|
|
100
132
|
export const FOLLOW_REVEAL_MIN_SCALE = 0.55
|
|
101
133
|
|
|
102
134
|
/** Safe-lag occupancy band over which reveal pressure is progressively reduced. */
|
|
103
|
-
export const FOLLOW_BACKPRESSURE_START_RATIO = 0.
|
|
135
|
+
export const FOLLOW_BACKPRESSURE_START_RATIO = 0.1
|
|
104
136
|
export const FOLLOW_BACKPRESSURE_FULL_RATIO = 0.75
|
|
105
137
|
|
|
106
138
|
/** Slow release prevents the reveal rate from oscillating around each wrap. */
|
|
107
139
|
export const FOLLOW_BACKPRESSURE_RELEASE_MS = 240
|
|
108
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
|
+
|
|
109
153
|
const GESTURE_EVENTS = [
|
|
110
154
|
'wheel',
|
|
111
155
|
'touchstart',
|
|
@@ -123,14 +167,152 @@ export function computeFollowReserve(
|
|
|
123
167
|
): number {
|
|
124
168
|
const available = Math.max(0, runwayPx)
|
|
125
169
|
if (available <= 0) return 0
|
|
126
|
-
if (speedCps <=
|
|
170
|
+
if (speedCps <= FOLLOW_RESERVE_IDLE_CPS) return 0
|
|
127
171
|
const normalized = Math.min(1, Math.max(0, (
|
|
128
|
-
speedCps -
|
|
129
|
-
) / (FOLLOW_RESERVE_MAX_CPS -
|
|
172
|
+
speedCps - FOLLOW_RESERVE_IDLE_CPS
|
|
173
|
+
) / (FOLLOW_RESERVE_MAX_CPS - FOLLOW_RESERVE_IDLE_CPS)))
|
|
130
174
|
const minimum = Math.min(available, FOLLOW_RESERVE_MIN_PX)
|
|
131
175
|
return minimum + normalized * (available - minimum)
|
|
132
176
|
}
|
|
133
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
|
+
|
|
134
316
|
/**
|
|
135
317
|
* Reveal-rate multiplier needed to retain one-wrap headroom for the spring.
|
|
136
318
|
* Throttling starts only after a quarter of the safe transform is occupied;
|
|
@@ -173,12 +355,7 @@ export interface FollowGlideStep {
|
|
|
173
355
|
readonly velocityPxPerSec: number
|
|
174
356
|
}
|
|
175
357
|
|
|
176
|
-
/**
|
|
177
|
-
* Semi-implicit spring integration with four substeps per <=32ms slice.
|
|
178
|
-
* @param dtMs - Frame delta in ms.
|
|
179
|
-
* @param input - Current visible lag and carried physics velocity.
|
|
180
|
-
* @returns The position advance, its fraction, and next velocity.
|
|
181
|
-
*/
|
|
358
|
+
/** Semi-implicit spring integration with four substeps per <=32ms slice. */
|
|
182
359
|
export function computeFollowStep(
|
|
183
360
|
dtMs: number,
|
|
184
361
|
input: FollowGlideInput,
|
|
@@ -192,21 +369,17 @@ export function computeFollowStep(
|
|
|
192
369
|
const elapsedMs = Math.min(FOLLOW_MAX_FRAME_MS, dtMs)
|
|
193
370
|
const slices = Math.max(1, Math.ceil(elapsedMs / FOLLOW_SPRING_MAX_STEP_MS))
|
|
194
371
|
const subDt = elapsedMs / 1000 / slices / FOLLOW_SPRING_SUBSTEPS
|
|
195
|
-
|
|
196
372
|
for (let slice = 0; slice < slices; slice += 1) {
|
|
197
373
|
for (let substep = 0; substep < FOLLOW_SPRING_SUBSTEPS; substep += 1) {
|
|
198
374
|
const acceleration = (
|
|
199
|
-
|
|
200
|
-
|
|
375
|
+
tuning.springStiffness * lag - tuning.springDamping * velocity
|
|
376
|
+
) / tuning.springMass
|
|
201
377
|
velocity = Math.max(0, velocity + acceleration * subDt)
|
|
202
378
|
const advance = velocity * subDt
|
|
203
|
-
if (advance >= lag) {
|
|
204
|
-
return { advancePx: input.lag, lerpStep: 1, velocityPxPerSec: 0 }
|
|
205
|
-
}
|
|
379
|
+
if (advance >= lag) return { advancePx: input.lag, lerpStep: 1, velocityPxPerSec: 0 }
|
|
206
380
|
lag -= advance
|
|
207
381
|
}
|
|
208
382
|
}
|
|
209
|
-
|
|
210
383
|
const advancePx = input.lag - lag
|
|
211
384
|
return { advancePx, lerpStep: advancePx / input.lag, velocityPxPerSec: velocity }
|
|
212
385
|
}
|
|
@@ -239,13 +412,10 @@ export function shiftSurfacesOf(port: HTMLElement): HTMLElement[] {
|
|
|
239
412
|
// One document-order pass: an anchored row, or a foreign child that contains
|
|
240
413
|
// no anchored row of its own (a wrapper around real rows would double-shift
|
|
241
414
|
// the rows inside it).
|
|
242
|
-
|
|
415
|
+
return [...flow.children].filter((child): child is HTMLElement =>
|
|
243
416
|
child instanceof HTMLElement
|
|
244
417
|
&& child !== status
|
|
245
418
|
&& (anchoredSet.has(child) || child.querySelector('[data-chat-anchor-key]') === null))
|
|
246
|
-
console.log('[engine] reached end of shiftSurfacesOf, n=', __dbg.length)
|
|
247
|
-
;(globalThis as Record<string, unknown>).__dshssProbe = __dbg.map(e => `${e.tagName}:${e.getAttribute('data-chat-anchor-key') ?? e.className}`)
|
|
248
|
-
return __dbg
|
|
249
419
|
}
|
|
250
420
|
|
|
251
421
|
function currentShiftOf(element: HTMLElement): number {
|
|
@@ -254,15 +424,23 @@ function currentShiftOf(element: HTMLElement): number {
|
|
|
254
424
|
)
|
|
255
425
|
}
|
|
256
426
|
|
|
257
|
-
|
|
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 {
|
|
258
438
|
if (Math.abs(px) > 0.01) {
|
|
259
439
|
if (
|
|
260
440
|
Math.abs(currentShiftOf(element) - px) <= 0.01
|
|
261
441
|
&& element.style.willChange === 'transform'
|
|
262
442
|
&& element.style.clipPath === ''
|
|
263
|
-
)
|
|
264
|
-
return
|
|
265
|
-
}
|
|
443
|
+
) return
|
|
266
444
|
element.style.transform = `translate3d(0, ${px}px, 0)`
|
|
267
445
|
element.style.willChange = 'transform'
|
|
268
446
|
} else {
|
|
@@ -270,16 +448,109 @@ function setShift(element: HTMLElement, px: number): void {
|
|
|
270
448
|
element.style.transform = ''
|
|
271
449
|
element.style.willChange = ''
|
|
272
450
|
}
|
|
273
|
-
// Remove paint state left by v0.3.2 and earlier experimental builds.
|
|
274
451
|
element.style.clipPath = ''
|
|
275
452
|
}
|
|
276
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
|
+
|
|
277
465
|
function turnStatusOf(port: HTMLElement): HTMLElement | null {
|
|
278
466
|
return port.querySelector<HTMLElement>(
|
|
279
467
|
'[data-chat-turn-status], [data-chat-flow] > [role="status"]',
|
|
280
468
|
)
|
|
281
469
|
}
|
|
282
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
|
+
|
|
283
554
|
/** Height committed by one newly mounted Chat row, including its flex gap. */
|
|
284
555
|
function entranceExtentOf(root: HTMLElement): number {
|
|
285
556
|
const row = root.closest<HTMLElement>('[data-chat-flow-key]') ?? root
|
|
@@ -302,6 +573,7 @@ interface FollowRunway {
|
|
|
302
573
|
readonly original: string
|
|
303
574
|
readonly property: 'marginBottom' | 'marginTop'
|
|
304
575
|
readonly requestedPx: number
|
|
576
|
+
readonly normalizedLegacy?: boolean
|
|
305
577
|
}
|
|
306
578
|
|
|
307
579
|
/**
|
|
@@ -337,6 +609,15 @@ interface FollowPaintLimit {
|
|
|
337
609
|
export const FOLLOW_PAINT_LIMIT_TTL_MS = 250
|
|
338
610
|
|
|
339
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>()
|
|
340
621
|
|
|
341
622
|
function invalidatePaintLimit(port: HTMLElement): void {
|
|
342
623
|
followPaintLimits.delete(port)
|
|
@@ -354,6 +635,47 @@ interface FollowMotionState {
|
|
|
354
635
|
/** Logical position and velocity survive a React owner handoff and finish. */
|
|
355
636
|
const followMotionStates = new WeakMap<HTMLElement, FollowMotionState>()
|
|
356
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
|
+
|
|
357
679
|
function restoreRunway(port: HTMLElement): void {
|
|
358
680
|
const runway = followRunways.get(port)
|
|
359
681
|
if (runway === undefined) return
|
|
@@ -366,12 +688,11 @@ function isLegacyRunway(value: string): boolean {
|
|
|
366
688
|
if (value === '') return false
|
|
367
689
|
const terms = [...value.matchAll(/([\d.]+)px/g)]
|
|
368
690
|
if (terms.length === 0 || value.replaceAll(/calc|px|[\d.+()\s]/g, '') !== '') return false
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
})
|
|
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
|
+
)))
|
|
375
696
|
}
|
|
376
697
|
|
|
377
698
|
/** Remove unowned runway residue written by v0.3.3 and earlier bundles. */
|
|
@@ -380,8 +701,8 @@ function migrateLegacyRunway(
|
|
|
380
701
|
surfaces: readonly HTMLElement[],
|
|
381
702
|
status: HTMLElement | null,
|
|
382
703
|
composer: HTMLElement | null,
|
|
383
|
-
):
|
|
384
|
-
if (followRunways.has(port)) return
|
|
704
|
+
): boolean {
|
|
705
|
+
if (followRunways.has(port)) return false
|
|
385
706
|
let migrated = false
|
|
386
707
|
if (status !== null && isLegacyRunway(status.style.marginTop)) {
|
|
387
708
|
// Harness TurnStatus has no inline margin; exact 48px multiples here are
|
|
@@ -402,6 +723,7 @@ function migrateLegacyRunway(
|
|
|
402
723
|
migrated = true
|
|
403
724
|
}
|
|
404
725
|
if (migrated) invalidatePaintLimit(port)
|
|
726
|
+
return migrated
|
|
405
727
|
}
|
|
406
728
|
|
|
407
729
|
function ensureRunway(
|
|
@@ -411,13 +733,32 @@ function ensureRunway(
|
|
|
411
733
|
): void {
|
|
412
734
|
const status = turnStatusOf(port)
|
|
413
735
|
const composer = port.querySelector<HTMLElement>('[data-composer-seat]')
|
|
414
|
-
|
|
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)
|
|
415
752
|
// A runway is useful only after the natural conversation already has a
|
|
416
753
|
// scroll floor for its equal message transform to ride. Before that point
|
|
417
754
|
// applyVisual keeps every surface in normal flow, so adding status margin
|
|
418
755
|
// would expose the whole runway as empty space below a short/early Think.
|
|
419
756
|
const naturalHeight = Math.max(0, port.scrollHeight - runwayOffsetOf(port))
|
|
420
|
-
|
|
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) {
|
|
421
762
|
restoreRunway(port)
|
|
422
763
|
return
|
|
423
764
|
}
|
|
@@ -432,15 +773,23 @@ function ensureRunway(
|
|
|
432
773
|
const current = followRunways.get(port)
|
|
433
774
|
if (current?.element === element
|
|
434
775
|
&& current.property === target.property
|
|
435
|
-
&& current.requestedPx ===
|
|
776
|
+
&& current.requestedPx === requestedRunwayPx) return
|
|
777
|
+
|
|
436
778
|
restoreRunway(port)
|
|
437
779
|
const beforeHeight = port.scrollHeight
|
|
438
780
|
const original = element.style[target.property]
|
|
439
781
|
element.style[target.property] = original === ''
|
|
440
|
-
? `${
|
|
441
|
-
: `calc(${original} + ${
|
|
782
|
+
? `${requestedRunwayPx}px`
|
|
783
|
+
: `calc(${original} + ${requestedRunwayPx}px)`
|
|
442
784
|
const offset = Math.max(0, port.scrollHeight - beforeHeight)
|
|
443
|
-
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
|
+
})
|
|
444
793
|
invalidatePaintLimit(port)
|
|
445
794
|
}
|
|
446
795
|
|
|
@@ -457,6 +806,7 @@ function safeShiftLimit(
|
|
|
457
806
|
if (last === undefined) return 0
|
|
458
807
|
const status = turnStatusOf(port)
|
|
459
808
|
const composer = port.querySelector<HTMLElement>('[data-composer-seat]')
|
|
809
|
+
if (status !== null || composer !== null) followHadChrome.add(port)
|
|
460
810
|
const cached = followPaintLimits.get(port)
|
|
461
811
|
// Content growth alone cannot move the limit (measured at the floor, the
|
|
462
812
|
// flow bottom rides the scrollport bottom), so the cache survives glyph
|
|
@@ -482,7 +832,7 @@ function safeShiftLimit(
|
|
|
482
832
|
// mounted but has not measured yet, permit only the runway zero-point
|
|
483
833
|
// until ResizeObserver provides a real ceiling.
|
|
484
834
|
return status === null && composer === null
|
|
485
|
-
? Number.POSITIVE_INFINITY
|
|
835
|
+
? followHadChrome.has(port) ? 0 : Number.POSITIVE_INFINITY
|
|
486
836
|
: runwayOffsetOf(port)
|
|
487
837
|
}
|
|
488
838
|
const ceilingTop = ceiling.rect.top - currentShiftOf(ceiling.element)
|
|
@@ -541,17 +891,62 @@ function applyVisual(
|
|
|
541
891
|
reservePx: number,
|
|
542
892
|
velocityPxPerSec = 0,
|
|
543
893
|
runwayPx = FOLLOW_STATUS_RUNWAY_PX,
|
|
894
|
+
shiftCeilingPx = Number.POSITIVE_INFINITY,
|
|
895
|
+
promoteAtRest = false,
|
|
896
|
+
trajectoryShiftPx?: number,
|
|
544
897
|
): number {
|
|
545
898
|
const surfaces = shiftSurfacesOf(port)
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
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
|
|
549
943
|
const targetHeight = Math.max(0, contentHeight - runwayOffset)
|
|
550
944
|
const floor = Math.max(0, contentHeight - port.clientHeight)
|
|
551
945
|
const extent = Math.min(targetHeight, Math.max(0, animatedH))
|
|
552
946
|
if (port.style.overflowAnchor !== 'none') port.style.overflowAnchor = 'none'
|
|
553
947
|
if (port.style.scrollBehavior !== 'auto') port.style.scrollBehavior = 'auto'
|
|
554
948
|
if (floor <= 0) {
|
|
949
|
+
followRunwayOffsetHistory.set(port, 0)
|
|
555
950
|
setFollowScrollTop(port, 0)
|
|
556
951
|
followMotionStates.set(port, {
|
|
557
952
|
capacityPx: Number.POSITIVE_INFINITY,
|
|
@@ -562,21 +957,60 @@ function applyVisual(
|
|
|
562
957
|
velocityPxPerSec: 0,
|
|
563
958
|
})
|
|
564
959
|
for (const surface of surfaces) setShift(surface, 0)
|
|
960
|
+
followLastShiftPx.set(port, 0)
|
|
565
961
|
const status = turnStatusOf(port)
|
|
566
962
|
if (status !== null) setShift(status, 0)
|
|
567
963
|
return targetHeight
|
|
568
964
|
}
|
|
569
|
-
//
|
|
570
|
-
//
|
|
571
|
-
|
|
572
|
-
|
|
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)
|
|
573
977
|
const visibleReserve = Math.min(runwayOffset, Math.max(0, reservePx))
|
|
574
978
|
const baselineShift = runwayOffset - visibleReserve
|
|
575
979
|
const requestedLag = Math.max(0, targetHeight - extent)
|
|
576
|
-
|
|
577
|
-
|
|
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)
|
|
578
1011
|
const capacityPx = Math.max(0, limit - baselineShift)
|
|
579
1012
|
const effectiveExtent = targetHeight - effectiveLag
|
|
1013
|
+
setFollowScrollTop(port, floor)
|
|
580
1014
|
followMotionStates.set(port, {
|
|
581
1015
|
capacityPx,
|
|
582
1016
|
constrained: requestedLag > effectiveLag + FOLLOW_SETTLE_EPSILON_PX,
|
|
@@ -604,6 +1038,7 @@ function clearVisual(port: HTMLElement): void {
|
|
|
604
1038
|
clearMotion(port)
|
|
605
1039
|
restoreRunway(port)
|
|
606
1040
|
followMotionStates.delete(port)
|
|
1041
|
+
followLastShiftPx.delete(port)
|
|
607
1042
|
invalidatePaintLimit(port)
|
|
608
1043
|
}
|
|
609
1044
|
|
|
@@ -615,9 +1050,16 @@ function holdCompositorAtRest(element: HTMLElement): void {
|
|
|
615
1050
|
}
|
|
616
1051
|
|
|
617
1052
|
/** Remove equal offsets, land on the floor, then retire the compositor quietly. */
|
|
618
|
-
function finishAtNaturalFloor(port: HTMLElement): void {
|
|
1053
|
+
function finishAtNaturalFloor(port: HTMLElement, retainCompositor = true): void {
|
|
619
1054
|
const surfaces = shiftSurfacesOf(port)
|
|
620
1055
|
const status = turnStatusOf(port)
|
|
1056
|
+
if (!retainCompositor) {
|
|
1057
|
+
restoreRunway(port)
|
|
1058
|
+
settleAtFloor(port)
|
|
1059
|
+
clearMotion(port)
|
|
1060
|
+
followMotionStates.delete(port)
|
|
1061
|
+
return
|
|
1062
|
+
}
|
|
621
1063
|
const promoted = [...surfaces, ...(status === null ? [] : [status])]
|
|
622
1064
|
.filter(element => element.style.transform !== '' || element.style.willChange === 'transform')
|
|
623
1065
|
const promotedSet = new Set(promoted)
|
|
@@ -638,7 +1080,6 @@ function finishAtNaturalFloor(port: HTMLElement): void {
|
|
|
638
1080
|
if (promoted.length === 0) return
|
|
639
1081
|
requestAnimationFrame(() => {
|
|
640
1082
|
requestAnimationFrame(() => {
|
|
641
|
-
if (port.hasAttribute(FOLLOW_OWNED_ATTR)) return
|
|
642
1083
|
for (const element of promoted) {
|
|
643
1084
|
if (Math.abs(currentShiftOf(element)) <= 0.01) setShift(element, 0)
|
|
644
1085
|
}
|
|
@@ -649,6 +1090,7 @@ function finishAtNaturalFloor(port: HTMLElement): void {
|
|
|
649
1090
|
function settleAtFloor(port: HTMLElement): void {
|
|
650
1091
|
const floor = Math.max(0, port.scrollHeight - port.clientHeight)
|
|
651
1092
|
setFollowScrollTop(port, floor)
|
|
1093
|
+
followReaderHolds.delete(port)
|
|
652
1094
|
}
|
|
653
1095
|
|
|
654
1096
|
interface FollowLeader {
|
|
@@ -672,6 +1114,7 @@ let followGeneration = 0
|
|
|
672
1114
|
* @param onEntranceSettled - Releases a one-shot entrance owner after catch-up.
|
|
673
1115
|
* @param predictiveRef - Optional live visibility gate for predictive runway.
|
|
674
1116
|
* @param entranceExtentRef - Optional measured growth delta for a generic row.
|
|
1117
|
+
* @param revealedCharsRef - Committed code-point count for feed-forward phase.
|
|
675
1118
|
*/
|
|
676
1119
|
export function useConversationFollow(
|
|
677
1120
|
rootRef: RefObject<HTMLElement | null>,
|
|
@@ -683,18 +1126,18 @@ export function useConversationFollow(
|
|
|
683
1126
|
onEntranceSettled?: () => void,
|
|
684
1127
|
predictiveRef?: { current: boolean },
|
|
685
1128
|
entranceExtentRef?: { current: number | null },
|
|
1129
|
+
revealedCharsRef?: { current: number },
|
|
686
1130
|
): void {
|
|
687
1131
|
const activeRef = useRef(active)
|
|
688
1132
|
const entranceRef = useRef(entrance)
|
|
689
1133
|
const onEntranceSettledRef = useRef(onEntranceSettled)
|
|
690
1134
|
entranceRef.current = entrance
|
|
691
1135
|
onEntranceSettledRef.current = onEntranceSettled
|
|
692
|
-
|
|
693
|
-
activeRef.current = active
|
|
694
|
-
}, [active])
|
|
1136
|
+
activeRef.current = active
|
|
695
1137
|
|
|
696
1138
|
useLayoutEffect(() => {
|
|
697
1139
|
if (!active) return
|
|
1140
|
+
const startedAsEntrance = entrance
|
|
698
1141
|
const owner = {}
|
|
699
1142
|
const generation = ++followGeneration
|
|
700
1143
|
let rafId = 0
|
|
@@ -711,6 +1154,18 @@ export function useConversationFollow(
|
|
|
711
1154
|
let interactTimer: ReturnType<typeof setTimeout> | null = null
|
|
712
1155
|
let port: HTMLElement | null = null
|
|
713
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()
|
|
714
1169
|
let holding: HTMLElement | null = null
|
|
715
1170
|
let entrancePending = entranceRef.current
|
|
716
1171
|
|
|
@@ -745,10 +1200,12 @@ export function useConversationFollow(
|
|
|
745
1200
|
const reportFollow = (next: HTMLElement, isActive: boolean): void => {
|
|
746
1201
|
const state = followMotionStates.get(next)
|
|
747
1202
|
debugRuntime.reportFollow(next, {
|
|
748
|
-
|
|
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,
|
|
749
1206
|
velocityPxPerSec: state?.velocityPxPerSec ?? 0,
|
|
750
1207
|
reservePx: state?.reservePx ?? 0,
|
|
751
|
-
capacityPx: state
|
|
1208
|
+
capacityPx: state ? state.capacityPx : -1,
|
|
752
1209
|
revealScale: revealScaleRef?.current ?? 1,
|
|
753
1210
|
following,
|
|
754
1211
|
constrained: state?.constrained ?? false,
|
|
@@ -783,7 +1240,8 @@ export function useConversationFollow(
|
|
|
783
1240
|
|
|
784
1241
|
const handBackVisual = (next: HTMLElement): void => {
|
|
785
1242
|
const shift = currentShiftOf(shiftSurfacesOf(next).at(-1) ?? next)
|
|
786
|
-
const
|
|
1243
|
+
const transferableShift = shift > FOLLOW_SETTLE_EPSILON_PX ? shift : 0
|
|
1244
|
+
const visualTop = Math.max(0, next.scrollTop - transferableShift)
|
|
787
1245
|
// The predictive runway only has meaning while this follower owns the
|
|
788
1246
|
// floor. Remove it before choosing the reader's landing point; keeping
|
|
789
1247
|
// it through the release paints a transient natural gap + 48px blank
|
|
@@ -826,21 +1284,60 @@ export function useConversationFollow(
|
|
|
826
1284
|
|
|
827
1285
|
const restoreBeforePaint = (): void => {
|
|
828
1286
|
if (!following || port === null || !isLeader(port)) return
|
|
829
|
-
|
|
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.
|
|
830
1292
|
const tuning = debugRuntime.activeTuning()
|
|
831
|
-
|
|
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
|
+
}
|
|
832
1319
|
updateRevealScale(port, 0, true)
|
|
833
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.
|
|
834
1325
|
}
|
|
835
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
|
+
|
|
836
1331
|
const bindPort = (next: HTMLElement): void => {
|
|
837
1332
|
if (port === next) return
|
|
838
1333
|
if (port !== null) {
|
|
839
1334
|
for (const name of GESTURE_EVENTS) port.removeEventListener(name, markGesture)
|
|
840
1335
|
resize?.disconnect()
|
|
841
1336
|
}
|
|
1337
|
+
unsubscribeCommit?.()
|
|
842
1338
|
port = next
|
|
843
1339
|
invalidatePaintLimit(port)
|
|
1340
|
+
unsubscribeCommit = subscribeFollowCommit(port, () => { restoreBeforePaint() })
|
|
844
1341
|
for (const name of GESTURE_EVENTS) {
|
|
845
1342
|
port.addEventListener(name, markGesture, { passive: true })
|
|
846
1343
|
}
|
|
@@ -852,6 +1349,21 @@ export function useConversationFollow(
|
|
|
852
1349
|
}
|
|
853
1350
|
}
|
|
854
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
|
+
|
|
855
1367
|
const frame = (now: number) => {
|
|
856
1368
|
rafId = requestAnimationFrame(frame)
|
|
857
1369
|
// Spring time is clamped so one paint after a stall cannot teleport the
|
|
@@ -866,6 +1378,7 @@ export function useConversationFollow(
|
|
|
866
1378
|
const nextPort = root.closest<HTMLElement>('[data-conversation-scroll]')
|
|
867
1379
|
if (nextPort === null) return
|
|
868
1380
|
bindPort(nextPort)
|
|
1381
|
+
observeTailSurface()
|
|
869
1382
|
// A hidden/unmeasured port has no meaningful floor yet. Keep this owner
|
|
870
1383
|
// unprimed and let the already-scheduled RAF initialize it after layout.
|
|
871
1384
|
if (nextPort.clientHeight <= 0) return
|
|
@@ -885,18 +1398,44 @@ export function useConversationFollow(
|
|
|
885
1398
|
// A new Agent row is already part of scrollHeight on its first
|
|
886
1399
|
// frame. Start at the pre-insert extent so that initial Context and
|
|
887
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.
|
|
888
1404
|
const entranceExtent = entrancePending
|
|
889
1405
|
? entranceExtentRef?.current ?? entranceExtentOf(root)
|
|
890
1406
|
: 0
|
|
891
|
-
|
|
892
|
-
|
|
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
|
|
893
1425
|
velocityPxPerSec = 0
|
|
894
1426
|
// The committed row/growth delta has already moved the new floor.
|
|
895
|
-
// Decide ownership from
|
|
896
|
-
//
|
|
897
|
-
//
|
|
898
|
-
|
|
899
|
-
|
|
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)
|
|
900
1439
|
} else {
|
|
901
1440
|
animatedH = Math.min(nextPort.scrollHeight, inherited.extent)
|
|
902
1441
|
reservePx = inherited.reservePx
|
|
@@ -906,7 +1445,38 @@ export function useConversationFollow(
|
|
|
906
1445
|
if (following) {
|
|
907
1446
|
hold(nextPort)
|
|
908
1447
|
if (isLeader(nextPort)) {
|
|
909
|
-
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
|
+
}
|
|
910
1480
|
updateRevealScale(nextPort, elapsedMs)
|
|
911
1481
|
reportFollow(nextPort, activeRef.current)
|
|
912
1482
|
const runwayOffset = runwayOffsetOf(nextPort)
|
|
@@ -929,6 +1499,7 @@ export function useConversationFollow(
|
|
|
929
1499
|
if (!following && (!interacting || returnedToFloor) && reportedLag <= repinSlack) {
|
|
930
1500
|
following = true
|
|
931
1501
|
readerReleased = false
|
|
1502
|
+
followReaderHolds.delete(nextPort)
|
|
932
1503
|
animatedH = extent
|
|
933
1504
|
reservePx = 0
|
|
934
1505
|
velocityPxPerSec = 0
|
|
@@ -940,6 +1511,7 @@ export function useConversationFollow(
|
|
|
940
1511
|
following = false
|
|
941
1512
|
readerGestureIntent = false
|
|
942
1513
|
readerReleased = true
|
|
1514
|
+
followReaderHolds.set(nextPort, { atMs: performance.now() })
|
|
943
1515
|
handBackVisual(nextPort)
|
|
944
1516
|
animatedH = nextPort.scrollHeight
|
|
945
1517
|
reservePx = 0
|
|
@@ -962,32 +1534,152 @@ export function useConversationFollow(
|
|
|
962
1534
|
// Runway and an equal transform cancel visually. It is the zero point,
|
|
963
1535
|
// not residual motion: decaying below it would scroll past the final
|
|
964
1536
|
// resting position and rebound when runway is removed.
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
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.
|
|
968
1544
|
const predictGrowth = predictiveRef?.current ?? predictive
|
|
969
|
-
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
|
|
970
1564
|
? 0
|
|
971
|
-
:
|
|
1565
|
+
: statusJustRemoved
|
|
1566
|
+
? tuning.runwayPx
|
|
1567
|
+
: heldReserveTarget
|
|
972
1568
|
const reserveStep = 1 - Math.exp(-elapsedMs / tuning.reserveResponseMs)
|
|
973
|
-
reservePx += (
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
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,
|
|
987
1584
|
)
|
|
988
|
-
|
|
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)
|
|
989
1682
|
}
|
|
990
|
-
animatedH = applyVisual(nextPort, animatedH, reservePx, velocityPxPerSec, tuning.runwayPx)
|
|
991
1683
|
updateRevealScale(nextPort, elapsedMs)
|
|
992
1684
|
reportFollow(nextPort, true)
|
|
993
1685
|
const remainingEntranceLag = Math.max(
|
|
@@ -1004,6 +1696,7 @@ export function useConversationFollow(
|
|
|
1004
1696
|
frame(performance.now())
|
|
1005
1697
|
return () => {
|
|
1006
1698
|
cancelAnimationFrame(rafId)
|
|
1699
|
+
unsubscribeCommit?.()
|
|
1007
1700
|
if (interactTimer !== null) clearTimeout(interactTimer)
|
|
1008
1701
|
resize?.disconnect()
|
|
1009
1702
|
if (port !== null) {
|
|
@@ -1016,6 +1709,13 @@ export function useConversationFollow(
|
|
|
1016
1709
|
if (!isLeader(host)) return
|
|
1017
1710
|
const preserveReader = interacting && (readerGestureIntent || readerScrolledUp(host))
|
|
1018
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
|
+
}
|
|
1019
1719
|
clearVisual(host)
|
|
1020
1720
|
followLeaders.delete(host)
|
|
1021
1721
|
releaseRevealScale()
|
|
@@ -1024,6 +1724,7 @@ export function useConversationFollow(
|
|
|
1024
1724
|
}
|
|
1025
1725
|
if (preserveReader) {
|
|
1026
1726
|
handBackVisual(host)
|
|
1727
|
+
followReaderHolds.set(host, { atMs: performance.now() })
|
|
1027
1728
|
clearVisual(host)
|
|
1028
1729
|
followLeaders.delete(host)
|
|
1029
1730
|
releaseRevealScale()
|
|
@@ -1034,21 +1735,56 @@ export function useConversationFollow(
|
|
|
1034
1735
|
// Completion can land the final Tool/command height in this same
|
|
1035
1736
|
// commit. Preserve the logical extent and drain it after unmount instead
|
|
1036
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.
|
|
1037
1763
|
const completionTuning = debugRuntime.activeTuning()
|
|
1038
|
-
|
|
1764
|
+
const previousCompletionRunway = runwayOffsetOf(host)
|
|
1765
|
+
ensureRunway(host, shiftSurfacesOf(host), Math.max(reservePx, completionTuning.runwayPx))
|
|
1039
1766
|
const completionRunway = runwayOffsetOf(host)
|
|
1040
|
-
|
|
1041
|
-
|
|
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,
|
|
1042
1774
|
animatedH,
|
|
1043
|
-
|
|
1775
|
+
reservePx,
|
|
1776
|
+
velocityPxPerSec,
|
|
1777
|
+
completionRunway,
|
|
1778
|
+
completionShiftCeiling,
|
|
1044
1779
|
)
|
|
1045
|
-
settleAtFloor(host)
|
|
1046
|
-
animatedH = applyVisual(host, animatedH, reservePx, velocityPxPerSec, completionTuning.runwayPx)
|
|
1047
1780
|
reportFollow(host, false)
|
|
1048
1781
|
const runwayOffset = runwayOffsetOf(host)
|
|
1049
1782
|
const remainingLag = Math.max(0, host.scrollHeight - animatedH - runwayOffset)
|
|
1050
|
-
if (
|
|
1051
|
-
|
|
1783
|
+
if (
|
|
1784
|
+
remainingLag <= FOLLOW_SETTLE_EPSILON_PX
|
|
1785
|
+
|| (!activeRef.current && remainingLag <= FOLLOW_SLACK_PX)
|
|
1786
|
+
) {
|
|
1787
|
+
finishAtNaturalFloor(host, !startedAsEntrance)
|
|
1052
1788
|
followLeaders.delete(host)
|
|
1053
1789
|
releaseRevealScale()
|
|
1054
1790
|
debugRuntime.reportFollow(host, null)
|
|
@@ -1066,6 +1802,8 @@ export function useConversationFollow(
|
|
|
1066
1802
|
}
|
|
1067
1803
|
}
|
|
1068
1804
|
let settleLast = performance.now()
|
|
1805
|
+
let settleMarginPx = Math.max(runwayOffsetOf(host), reservePx)
|
|
1806
|
+
let settleMarginRate = settleMarginPx / FOLLOW_RUNWAY_RETIRE_MS
|
|
1069
1807
|
const settleFrame = (now: number): void => {
|
|
1070
1808
|
if (!isLeader(host)) {
|
|
1071
1809
|
stopSettleListeners()
|
|
@@ -1084,15 +1822,24 @@ export function useConversationFollow(
|
|
|
1084
1822
|
const dt = Math.min(FOLLOW_MAX_FRAME_MS, Math.max(0, now - settleLast))
|
|
1085
1823
|
const tuning = debugRuntime.activeTuning()
|
|
1086
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
|
+
}
|
|
1087
1835
|
const runwayOffset = runwayOffsetOf(host)
|
|
1088
1836
|
const lag = Math.max(0, host.scrollHeight - animatedH - runwayOffset)
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
if (lag <= FOLLOW_SETTLE_EPSILON_PX && reservePx <= FOLLOW_SETTLE_EPSILON_PX) {
|
|
1092
|
-
animatedH = host.scrollHeight - runwayOffset
|
|
1837
|
+
if (lag <= FOLLOW_SETTLE_EPSILON_PX && settleMarginPx <= FOLLOW_SETTLE_EPSILON_PX) {
|
|
1838
|
+
animatedH = host.scrollHeight
|
|
1093
1839
|
reservePx = 0
|
|
1094
1840
|
velocityPxPerSec = 0
|
|
1095
|
-
|
|
1841
|
+
followRunways.delete(host)
|
|
1842
|
+
finishAtNaturalFloor(host, !startedAsEntrance)
|
|
1096
1843
|
followLeaders.delete(host)
|
|
1097
1844
|
releaseRevealScale()
|
|
1098
1845
|
debugRuntime.reportFollow(host, null)
|
|
@@ -1104,21 +1851,38 @@ export function useConversationFollow(
|
|
|
1104
1851
|
speedEma: speedCpsRef.current,
|
|
1105
1852
|
velocityPxPerSec,
|
|
1106
1853
|
}, tuning)
|
|
1107
|
-
// The temporary runway is visually neutral only while an equal lag
|
|
1108
|
-
// remains in the transform. Do not let the spring outrun the runway's
|
|
1109
|
-
// closing reserve or cleanup would reveal an overshoot and rebound.
|
|
1110
|
-
const minimumLag = Math.max(0, reservePx)
|
|
1111
1854
|
animatedH = Math.min(
|
|
1112
|
-
host.scrollHeight - runwayOffset
|
|
1855
|
+
host.scrollHeight - runwayOffset,
|
|
1113
1856
|
animatedH + step.advancePx,
|
|
1114
1857
|
)
|
|
1115
1858
|
velocityPxPerSec = step.velocityPxPerSec
|
|
1116
1859
|
settleAtFloor(host)
|
|
1117
|
-
animatedH = applyVisual(host, animatedH, reservePx, velocityPxPerSec,
|
|
1860
|
+
animatedH = applyVisual(host, animatedH, reservePx, velocityPxPerSec, Math.max(settleMarginPx, runwayOffset))
|
|
1118
1861
|
reportFollow(host, false)
|
|
1119
1862
|
requestAnimationFrame(settleFrame)
|
|
1120
1863
|
}
|
|
1121
1864
|
requestAnimationFrame(settleFrame)
|
|
1122
1865
|
}
|
|
1123
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])
|
|
1124
1888
|
}
|