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.
@@ -26,7 +26,7 @@
26
26
  * reader release re-acquires only after returning to the real floor.
27
27
  */
28
28
 
29
- import { useEffect, useLayoutEffect, useRef, type RefObject } from 'react'
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 = 16
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. One rendered
88
- * line is normally 24-28px; 48px plus the host's existing status gap covers
89
- * the original spring's measured ~63px worst-case trail at 600cps.
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 FOLLOW_STATUS_RUNWAY_PX = 48
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.25
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 <= FOLLOW_RESERVE_MIN_CPS) return 0
170
+ if (speedCps <= FOLLOW_RESERVE_IDLE_CPS) return 0
127
171
  const normalized = Math.min(1, Math.max(0, (
128
- speedCps - FOLLOW_RESERVE_MIN_CPS
129
- ) / (FOLLOW_RESERVE_MAX_CPS - FOLLOW_RESERVE_MIN_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
- tuning.springStiffness * lag - tuning.springDamping * velocity
200
- ) / tuning.springMass
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
- const __dbg = [...flow.children].filter((child): child is HTMLElement =>
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
- function setShift(element: HTMLElement, px: number): void {
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
- return terms.every(([, raw]) => {
370
- const px = Number(raw)
371
- return Number.isFinite(px)
372
- && px >= FOLLOW_STATUS_RUNWAY_PX
373
- && Math.abs(px % FOLLOW_STATUS_RUNWAY_PX) <= Number.EPSILON
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
- ): void {
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
- migrateLegacyRunway(port, surfaces, status, composer)
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
- if (runwayPx <= 0 || port.clientHeight <= 0 || naturalHeight <= port.clientHeight) {
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 === runwayPx) return
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
- ? `${runwayPx}px`
441
- : `calc(${original} + ${runwayPx}px)`
782
+ ? `${requestedRunwayPx}px`
783
+ : `calc(${original} + ${requestedRunwayPx}px)`
442
784
  const offset = Math.max(0, port.scrollHeight - beforeHeight)
443
- followRunways.set(port, { element, offset, property: target.property, original, requestedPx: runwayPx })
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
- ensureRunway(port, surfaces, runwayPx)
547
- const contentHeight = Math.max(0, port.scrollHeight)
548
- const runwayOffset = runwayOffsetOf(port)
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
- // Measure paint room at the real floor. This write and the final physical
570
- // position land in the same animation frame, so only the latter is painted.
571
- setFollowScrollTop(port, floor)
572
- const limit = floor > 0 ? safeShiftLimit(port, surfaces) : 0
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
- const shift = Math.min(baselineShift + requestedLag, Math.max(0, limit))
577
- const effectiveLag = Math.max(0, shift - baselineShift)
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
- useEffect(() => {
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
- lagPx: state?.lagPx ?? Math.max(0, next.scrollHeight - next.clientHeight - next.scrollTop),
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?.capacityPx ?? 0,
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 visualTop = Math.max(0, next.scrollTop - Math.max(0, shift))
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
- invalidatePaintLimit(port)
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
- animatedH = applyVisual(port, animatedH, reservePx, velocityPxPerSec, tuning.runwayPx)
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
- animatedH = Math.max(0, nextPort.scrollHeight - entranceExtent)
892
- reservePx = 0
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 the reader's position before that delta;
896
- // otherwise any atomic result taller than FOLLOW_SLACK_PX looks
897
- // indistinguishable from an intentional reader pull-up.
898
- const lagBeforeEntrance = Math.max(0, reportedLag - entranceExtent)
899
- following = lagBeforeEntrance <= FOLLOW_SLACK_PX
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(nextPort, animatedH, reservePx, velocityPxPerSec, tuning.runwayPx)
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
- const runwayOffset = runwayOffsetOf(nextPort)
966
- const contentHeight = nextPort.scrollHeight
967
- const lag = Math.max(0, contentHeight - animatedH - runwayOffset)
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 reserveTarget = !predictGrowth
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
- : computeFollowReserve(speedCpsRef.current, tuning.runwayPx)
1565
+ : statusJustRemoved
1566
+ ? tuning.runwayPx
1567
+ : heldReserveTarget
972
1568
  const reserveStep = 1 - Math.exp(-elapsedMs / tuning.reserveResponseMs)
973
- reservePx += (reserveTarget - reservePx) * reserveStep
974
- const step = computeFollowStep(dt, {
975
- lag,
976
- speedEma: speedCpsRef.current,
977
- velocityPxPerSec,
978
- }, tuning)
979
- if (lag <= 0.1) {
980
- animatedH = contentHeight - runwayOffset
981
- velocityPxPerSec = 0
982
- } else {
983
- const minimumLag = predictGrowth ? 0 : Math.max(0, reservePx)
984
- animatedH = Math.min(
985
- contentHeight - runwayOffset - minimumLag,
986
- animatedH + step.advancePx,
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
- velocityPxPerSec = step.velocityPxPerSec
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
- ensureRunway(host, shiftSurfacesOf(host), completionTuning.runwayPx)
1764
+ const previousCompletionRunway = runwayOffsetOf(host)
1765
+ ensureRunway(host, shiftSurfacesOf(host), Math.max(reservePx, completionTuning.runwayPx))
1039
1766
  const completionRunway = runwayOffsetOf(host)
1040
- const completionMinimumLag = Math.max(0, reservePx)
1041
- animatedH = Math.min(
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
- host.scrollHeight - completionRunway - completionMinimumLag,
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 (remainingLag <= FOLLOW_SETTLE_EPSILON_PX && reservePx <= FOLLOW_SETTLE_EPSILON_PX) {
1051
- finishAtNaturalFloor(host)
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
- const reserveStep = 1 - Math.exp(-dt / tuning.reserveResponseMs)
1090
- reservePx += (0 - reservePx) * reserveStep
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
- finishAtNaturalFloor(host)
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 - minimumLag,
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, tuning.runwayPx)
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
  }