dsh-smooth-stream 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -26,10 +26,9 @@
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
- import { PROCESSED_CLASS } from './auto-collapse-controller.ts'
33
32
 
34
33
  /**
35
34
  * Programmatic follow marker retained for hosts that recognize external
@@ -45,9 +44,13 @@ export const FOLLOW_SPRING_SUBSTEPS = 4
45
44
  export const FOLLOW_SPRING_MAX_STEP_MS = 32
46
45
 
47
46
  /** Minimum visible room for one ordinary line-wrap impulse. */
48
- export const FOLLOW_RESERVE_MIN_PX = 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
49
51
 
50
52
  /** Reveal-speed range produced by the pressure-buffer typewriter. */
53
+ /** Retained for consumers that tune the old pressure threshold. */
51
54
  export const FOLLOW_RESERVE_MIN_CPS = 90
52
55
  export const FOLLOW_RESERVE_MAX_CPS = 600
53
56
 
@@ -85,11 +88,19 @@ export const FOLLOW_HOST_RELEASE_PX = FOLLOW_SLACK_PX + 1
85
88
  export const FOLLOW_PAINT_GUARD_PX = 1
86
89
 
87
90
  /**
88
- * Maximum predictive paint room before status/composer chrome. One rendered
89
- * line is normally 24-28px; 48px plus the host's existing status gap covers
90
- * 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.
91
102
  */
92
- export const FOLLOW_STATUS_RUNWAY_PX = 48
103
+ export const FOLLOW_RUNWAY_RETIRE_MS = 160
93
104
 
94
105
  /** How long a gesture keeps `isUserInteracting` so the next scroll can unpin. */
95
106
  export const FOLLOW_GESTURE_MS = 800
@@ -97,16 +108,48 @@ export const FOLLOW_GESTURE_MS = 800
97
108
  /** Sub-pixel settle threshold; clearing below this cannot produce a visible rebound. */
98
109
  export const FOLLOW_SETTLE_EPSILON_PX = 0.25
99
110
 
111
+ /** Retained visual-motion budget for compatibility with diagnostics/tests. */
112
+ export const FOLLOW_CATCHUP_MAX_STEP_PX = 8
113
+
114
+ /**
115
+ * Max painted-shift change per frame, in px. A line-wrap adds one line height
116
+ * (24-28px) to the floor in a single layout pass; letting the shift follow it
117
+ * instantly paints that whole step as a hard jump of the newest line. Capping
118
+ * the shift's per-frame change spreads the step across a few frames so the
119
+ * line glides in (slow start) instead of snapping.
120
+ */
121
+ /**
122
+ * Per-frame bound on the DECAY side of the painted shift only (runway
123
+ * retirement and settle). The growth side is wrap compensation and must stay
124
+ * unlimited — see the clamp site in `applyVisual`.
125
+ */
126
+ export const FOLLOW_PAINT_SHIFT_MAX_STEP_PX = 8
127
+
128
+ /** Runway size emitted by bundles before the 72px predictive runway. */
129
+ const LEGACY_RUNWAY_PX = 48
130
+
100
131
  /** Lowest reveal rate retained while the spring is short on paint room. */
101
132
  export const FOLLOW_REVEAL_MIN_SCALE = 0.55
102
133
 
103
134
  /** Safe-lag occupancy band over which reveal pressure is progressively reduced. */
104
- export const FOLLOW_BACKPRESSURE_START_RATIO = 0.25
135
+ export const FOLLOW_BACKPRESSURE_START_RATIO = 0.1
105
136
  export const FOLLOW_BACKPRESSURE_FULL_RATIO = 0.75
106
137
 
107
138
  /** Slow release prevents the reveal rate from oscillating around each wrap. */
108
139
  export const FOLLOW_BACKPRESSURE_RELEASE_MS = 240
109
140
 
141
+ /** Effective-scroll acceleration budget, in px/ms². */
142
+ export const FOLLOW_TRAJECTORY_ACCELERATION = 0.00022
143
+
144
+ /** Leave one line of phase range inside the visible runway. */
145
+ export const FOLLOW_TRAJECTORY_PHASE_PX = 32
146
+
147
+ /** Keep adaptive runway retirement from exposing an oversized chrome gap. */
148
+ export const FOLLOW_TRAJECTORY_MIN_LAG_PX = 20
149
+
150
+ /** Phase-centering response; slow enough to preserve continuous velocity. */
151
+ export const FOLLOW_TRAJECTORY_CENTERING_MS = 120
152
+
110
153
  const GESTURE_EVENTS = [
111
154
  'wheel',
112
155
  'touchstart',
@@ -124,14 +167,152 @@ export function computeFollowReserve(
124
167
  ): number {
125
168
  const available = Math.max(0, runwayPx)
126
169
  if (available <= 0) return 0
127
- if (speedCps <= FOLLOW_RESERVE_MIN_CPS) return 0
170
+ if (speedCps <= FOLLOW_RESERVE_IDLE_CPS) return 0
128
171
  const normalized = Math.min(1, Math.max(0, (
129
- speedCps - FOLLOW_RESERVE_MIN_CPS
130
- ) / (FOLLOW_RESERVE_MAX_CPS - FOLLOW_RESERVE_MIN_CPS)))
172
+ speedCps - FOLLOW_RESERVE_IDLE_CPS
173
+ ) / (FOLLOW_RESERVE_MAX_CPS - FOLLOW_RESERVE_IDLE_CPS)))
131
174
  const minimum = Math.min(available, FOLLOW_RESERVE_MIN_PX)
132
175
  return minimum + normalized * (available - minimum)
133
176
  }
134
177
 
178
+ export interface FollowTrajectoryInput {
179
+ readonly positionPx: number
180
+ readonly velocityPxPerMs: number
181
+ readonly targetPx: number
182
+ readonly targetVelocityPxPerMs: number
183
+ readonly minLagPx: number
184
+ readonly maxLagPx: number
185
+ /** Real scroll floor used to turn logical position into a painted shift. */
186
+ readonly paintFloorPx?: number
187
+ }
188
+
189
+ export interface FollowRevealPhaseOptions {
190
+ readonly seedCharsPerLine?: number
191
+ readonly seedLineHeightPx?: number
192
+ }
193
+
194
+ export interface FollowRevealPhase {
195
+ readonly targetPx: number
196
+ readonly phase: number
197
+ readonly charsPerLine: number
198
+ readonly lineHeightPx: number
199
+ }
200
+
201
+ /**
202
+ * Character-domain feed-forward for the stepped layout floor. Callers only
203
+ * provide committed reveal progress and the measured floor; wrap capacity and
204
+ * line height stay local to this module and adapt when a real wrap lands.
205
+ */
206
+ export class FollowRevealPhaseTracker {
207
+ private charsPerLine: number
208
+ private lineHeightPx: number
209
+ private floorPx: number | null = null
210
+ private wrapRevealCount = 0
211
+ private lastRevealCount = 0
212
+
213
+ constructor({
214
+ seedCharsPerLine = 50,
215
+ seedLineHeightPx = 26,
216
+ }: FollowRevealPhaseOptions = {}) {
217
+ this.charsPerLine = Math.max(1, seedCharsPerLine)
218
+ this.lineHeightPx = Math.max(1, seedLineHeightPx)
219
+ }
220
+
221
+ advance(floorPx: number, revealedChars: number): FollowRevealPhase {
222
+ const nextFloor = Math.max(0, floorPx)
223
+ const nextRevealCount = Math.max(0, revealedChars)
224
+ if (this.floorPx === null || nextRevealCount < this.lastRevealCount) {
225
+ this.floorPx = nextFloor
226
+ this.wrapRevealCount = nextRevealCount
227
+ this.lastRevealCount = nextRevealCount
228
+ return this.snapshot(nextFloor, 0)
229
+ }
230
+
231
+ const floorDelta = nextFloor - this.floorPx
232
+ const lineStepThreshold = Math.max(4, this.lineHeightPx * 0.5)
233
+ if (floorDelta >= lineStepThreshold) {
234
+ const wrappedLines = Math.max(1, Math.round(floorDelta / this.lineHeightPx))
235
+ const sampledLineHeight = floorDelta / wrappedLines
236
+ const revealedSinceWrap = nextRevealCount - this.wrapRevealCount
237
+ if (revealedSinceWrap >= this.charsPerLine * 0.5) {
238
+ const sampledCharsPerLine = revealedSinceWrap / wrappedLines
239
+ const alpha = 0.25
240
+ this.charsPerLine += (sampledCharsPerLine - this.charsPerLine) * alpha
241
+ this.lineHeightPx += (sampledLineHeight - this.lineHeightPx) * alpha
242
+ this.wrapRevealCount = nextRevealCount
243
+ } else {
244
+ // A flow sibling can grow without the text arm committing a glyph.
245
+ // Treat that as an unrelated layout event so its height is not folded
246
+ // into the next text-wrap capacity sample.
247
+ this.wrapRevealCount = nextRevealCount
248
+ }
249
+ } else if (floorDelta <= -lineStepThreshold) {
250
+ this.wrapRevealCount = nextRevealCount
251
+ }
252
+
253
+ this.floorPx = nextFloor
254
+ this.lastRevealCount = nextRevealCount
255
+ const phase = Math.min(1, Math.max(
256
+ 0,
257
+ (nextRevealCount - this.wrapRevealCount) / this.charsPerLine,
258
+ ))
259
+ return this.snapshot(nextFloor, phase)
260
+ }
261
+
262
+ private snapshot(floorPx: number, phase: number): FollowRevealPhase {
263
+ return {
264
+ targetPx: floorPx + this.lineHeightPx * phase,
265
+ phase,
266
+ charsPerLine: this.charsPerLine,
267
+ lineHeightPx: this.lineHeightPx,
268
+ }
269
+ }
270
+ }
271
+
272
+ /** Advance a continuous effective scroll position behind a stepped floor. */
273
+ export function computeFollowTrajectoryStep(
274
+ dtMs: number,
275
+ input: FollowTrajectoryInput,
276
+ ): { positionPx: number, shiftPx: number, velocityPxPerMs: number } {
277
+ if (dtMs <= 0) {
278
+ return {
279
+ positionPx: input.positionPx,
280
+ shiftPx: Math.max(0, (input.paintFloorPx ?? input.targetPx) - input.positionPx),
281
+ velocityPxPerMs: input.velocityPxPerMs,
282
+ }
283
+ }
284
+ const elapsedMs = Math.min(FOLLOW_MAX_FRAME_MS, dtMs)
285
+ const currentLagPx = input.targetPx - input.positionPx
286
+ const maxLagPx = Math.max(0, input.maxLagPx)
287
+ const minLagPx = Math.min(Math.max(0, input.minLagPx), maxLagPx)
288
+ const centerLagPx = (minLagPx + maxLagPx) / 2
289
+ const desiredVelocity = Math.max(
290
+ 0,
291
+ input.targetVelocityPxPerMs
292
+ + (currentLagPx - centerLagPx) / FOLLOW_TRAJECTORY_CENTERING_MS,
293
+ )
294
+ const maxVelocityChange = FOLLOW_TRAJECTORY_ACCELERATION * elapsedMs
295
+ const velocityPxPerMs = desiredVelocity >= input.velocityPxPerMs
296
+ ? Math.min(desiredVelocity, input.velocityPxPerMs + maxVelocityChange)
297
+ : Math.max(desiredVelocity, input.velocityPxPerMs - maxVelocityChange)
298
+ const minPosition = input.targetPx - maxLagPx
299
+ const maxPosition = input.targetPx - minLagPx
300
+ // The frame budget follows controlled velocity rather than a refresh-rate
301
+ // dependent pixel constant. `elapsedMs` is already capped at 32ms, so a
302
+ // stalled browser cannot replay an unbounded jump when it catches up.
303
+ const frameAdvancePx = velocityPxPerMs * elapsedMs
304
+ const boundedPositionPx = Math.min(
305
+ maxPosition,
306
+ Math.max(minPosition, input.positionPx + frameAdvancePx),
307
+ )
308
+ const positionPx = Math.max(input.positionPx, boundedPositionPx)
309
+ return {
310
+ positionPx,
311
+ shiftPx: Math.max(0, (input.paintFloorPx ?? input.targetPx) - positionPx),
312
+ velocityPxPerMs,
313
+ }
314
+ }
315
+
135
316
  /**
136
317
  * Reveal-rate multiplier needed to retain one-wrap headroom for the spring.
137
318
  * Throttling starts only after a quarter of the safe transform is occupied;
@@ -174,12 +355,7 @@ export interface FollowGlideStep {
174
355
  readonly velocityPxPerSec: number
175
356
  }
176
357
 
177
- /**
178
- * Semi-implicit spring integration with four substeps per <=32ms slice.
179
- * @param dtMs - Frame delta in ms.
180
- * @param input - Current visible lag and carried physics velocity.
181
- * @returns The position advance, its fraction, and next velocity.
182
- */
358
+ /** Semi-implicit spring integration with four substeps per <=32ms slice. */
183
359
  export function computeFollowStep(
184
360
  dtMs: number,
185
361
  input: FollowGlideInput,
@@ -193,21 +369,17 @@ export function computeFollowStep(
193
369
  const elapsedMs = Math.min(FOLLOW_MAX_FRAME_MS, dtMs)
194
370
  const slices = Math.max(1, Math.ceil(elapsedMs / FOLLOW_SPRING_MAX_STEP_MS))
195
371
  const subDt = elapsedMs / 1000 / slices / FOLLOW_SPRING_SUBSTEPS
196
-
197
372
  for (let slice = 0; slice < slices; slice += 1) {
198
373
  for (let substep = 0; substep < FOLLOW_SPRING_SUBSTEPS; substep += 1) {
199
374
  const acceleration = (
200
- tuning.springStiffness * lag - tuning.springDamping * velocity
201
- ) / tuning.springMass
375
+ tuning.springStiffness * lag - tuning.springDamping * velocity
376
+ ) / tuning.springMass
202
377
  velocity = Math.max(0, velocity + acceleration * subDt)
203
378
  const advance = velocity * subDt
204
- if (advance >= lag) {
205
- return { advancePx: input.lag, lerpStep: 1, velocityPxPerSec: 0 }
206
- }
379
+ if (advance >= lag) return { advancePx: input.lag, lerpStep: 1, velocityPxPerSec: 0 }
207
380
  lag -= advance
208
381
  }
209
382
  }
210
-
211
383
  const advancePx = input.lag - lag
212
384
  return { advancePx, lerpStep: advancePx / input.lag, velocityPxPerSec: velocity }
213
385
  }
@@ -217,16 +389,33 @@ function resizeProxyOf(port: HTMLElement): HTMLElement | null {
217
389
  return port.querySelector('[data-chat-transcript]') ?? port.querySelector('[data-chat-flow]')
218
390
  }
219
391
 
220
- /** Outermost message surfaces; nested tool rows ride their parent. */
221
- /** @internal Test seam: the equal-lag transform set for one scrollport. */
392
+ /**
393
+ * Outermost message surfaces; nested tool rows ride their parent.
394
+ *
395
+ * Another plugin may insert its own element as a flow sibling of the Chat rows
396
+ * (meow-memory's fold bar is one; so is this plugin's own fold summary row).
397
+ * Such a row carries no `data-chat-anchor-key`, so selecting only anchored
398
+ * rows would shift the conversation while leaving the foreign row at its
399
+ * natural offset, letting the shifted rows paint over it. Every direct flow
400
+ * child therefore rides the same transform, keeping the visual order of the
401
+ * column intact.
402
+ */
222
403
  export function shiftSurfacesOf(port: HTMLElement): HTMLElement[] {
223
404
  const transcript = port.querySelector<HTMLElement>('[data-chat-transcript]')
224
405
  if (transcript !== null) return [transcript]
225
- // Fold summary rows are plugin-injected flow children without an anchor
226
- // key; they must ride the same lag transform as native rows, otherwise
227
- // they visibly detach from their neighbours on every follow frame.
228
- return [...port.querySelectorAll<HTMLElement>(`[data-chat-anchor-key], .${PROCESSED_CLASS}`)]
406
+ const anchored = [...port.querySelectorAll<HTMLElement>('[data-chat-anchor-key]')]
229
407
  .filter(row => row.parentElement?.closest('[data-chat-anchor-key]') === null)
408
+ const flow = port.querySelector<HTMLElement>('[data-chat-flow]')
409
+ if (flow === null) return anchored
410
+ const status = turnStatusOf(port)
411
+ const anchoredSet = new Set(anchored)
412
+ // One document-order pass: an anchored row, or a foreign child that contains
413
+ // no anchored row of its own (a wrapper around real rows would double-shift
414
+ // the rows inside it).
415
+ return [...flow.children].filter((child): child is HTMLElement =>
416
+ child instanceof HTMLElement
417
+ && child !== status
418
+ && (anchoredSet.has(child) || child.querySelector('[data-chat-anchor-key]') === null))
230
419
  }
231
420
 
232
421
  function currentShiftOf(element: HTMLElement): number {
@@ -235,15 +424,23 @@ function currentShiftOf(element: HTMLElement): number {
235
424
  )
236
425
  }
237
426
 
238
- 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 {
239
438
  if (Math.abs(px) > 0.01) {
240
439
  if (
241
440
  Math.abs(currentShiftOf(element) - px) <= 0.01
242
441
  && element.style.willChange === 'transform'
243
442
  && element.style.clipPath === ''
244
- ) {
245
- return
246
- }
443
+ ) return
247
444
  element.style.transform = `translate3d(0, ${px}px, 0)`
248
445
  element.style.willChange = 'transform'
249
446
  } else {
@@ -251,16 +448,109 @@ function setShift(element: HTMLElement, px: number): void {
251
448
  element.style.transform = ''
252
449
  element.style.willChange = ''
253
450
  }
254
- // Remove paint state left by v0.3.2 and earlier experimental builds.
255
451
  element.style.clipPath = ''
256
452
  }
257
453
 
454
+ function setShift(element: HTMLElement, px: number): void {
455
+ // Guard: while an open fixed tooltip lives on the surface, hold it
456
+ // untransformed (see the comment above). When the shift is zero the tooltip
457
+ // cannot detach, so skip the subtree scan entirely and keep the fast path.
458
+ if (Math.abs(px) > 0.01 && element.querySelector('[role="tooltip"]') !== null) {
459
+ setDirectShift(element, 0)
460
+ return
461
+ }
462
+ setDirectShift(element, px)
463
+ }
464
+
258
465
  function turnStatusOf(port: HTMLElement): HTMLElement | null {
259
466
  return port.querySelector<HTMLElement>(
260
467
  '[data-chat-turn-status], [data-chat-flow] > [role="status"]',
261
468
  )
262
469
  }
263
470
 
471
+ /**
472
+ * Bottom-anchored hosts (ChatView packs the flow with `justify-content:
473
+ * flex-end`) translate every pre-overflow growth into an instant upward step
474
+ * of the whole column — a line lands and everything on screen hops up by one
475
+ * line-height. No scroll-domain engine can smooth that, because
476
+ * `scrollHeight` does not move while the host slack absorbs the growth.
477
+ *
478
+ * Owning `min-height` on the flow removes the slack instead: the column
479
+ * always fills the scrollport, content grows downward where it is visible,
480
+ * and real overflow — which the spring does model — begins with the very
481
+ * first wrapped line. The style is owned in a page-realm registry so a
482
+ * re-injected bundle adopts it rather than fighting it.
483
+ *
484
+ * The fill targets the VISIBLE client box, not the raw `clientHeight`.
485
+ * Imposing `min-height: clientHeight` inflates `scrollHeight` by whatever
486
+ * the scroller holds beyond the flow (its own padding-bottom, adjacent
487
+ * chrome); with short content the engine then sees a phantom floor equal to
488
+ * that padding and scrolls the conversation up — top edge off-screen while
489
+ * blank padding sits at the bottom. The overshoot is measured once per port
490
+ * and subtracted, so short content reaches `scrollHeight == clientHeight`
491
+ * and the floor is exactly zero.
492
+ */
493
+ interface FollowFlowFill {
494
+ readonly element: HTMLElement
495
+ readonly original: string
496
+ /** Scroller height held by padding/chrome above the flow, px. */
497
+ readonly overshootPx: number
498
+ }
499
+ const FOLLOW_FLOW_FILL_SYMBOL = Symbol.for('dsh-smooth-stream.follow-flow-fill')
500
+ const followFlowFillHost = globalThis as typeof globalThis & {
501
+ [key: symbol]: WeakMap<HTMLElement, FollowFlowFill> | undefined
502
+ }
503
+ const followFlowFills = followFlowFillHost[FOLLOW_FLOW_FILL_SYMBOL]
504
+ ?? new WeakMap<HTMLElement, FollowFlowFill>()
505
+ followFlowFillHost[FOLLOW_FLOW_FILL_SYMBOL] = followFlowFills
506
+ const FOLLOW_FLOW_FILL_USERS_SYMBOL = Symbol.for('dsh-smooth-stream.follow-flow-fill-users')
507
+ const followFlowFillUsersHost = globalThis as typeof globalThis & {
508
+ [key: symbol]: WeakMap<HTMLElement, number> | undefined
509
+ }
510
+ const followFlowFillUsers = followFlowFillUsersHost[FOLLOW_FLOW_FILL_USERS_SYMBOL]
511
+ ?? new WeakMap<HTMLElement, number>()
512
+ followFlowFillUsersHost[FOLLOW_FLOW_FILL_USERS_SYMBOL] = followFlowFillUsers
513
+
514
+ function flowElementOf(port: HTMLElement): HTMLElement | null {
515
+ return port.querySelector<HTMLElement>('[data-chat-transcript]')
516
+ ?? port.querySelector<HTMLElement>('[data-chat-flow]')
517
+ }
518
+
519
+ function ensureFlowFillsPort(port: HTMLElement): void {
520
+ const element = flowElementOf(port)
521
+ const owned = followFlowFills.get(port)
522
+ if (element === null) {
523
+ if (owned !== undefined) restoreFlowFill(port)
524
+ return
525
+ }
526
+ const client = Math.max(0, port.clientHeight)
527
+ let overshoot = owned?.overshootPx
528
+ if (overshoot === undefined) {
529
+ // Measure once per port: impose the full fill, read the scrollHeight
530
+ // excess (scroller padding / chrome), then restore the natural state.
531
+ const pendingOriginal = element.style.minHeight
532
+ element.style.minHeight = `${client}px`
533
+ overshoot = Math.max(0, port.scrollHeight - client)
534
+ element.style.minHeight = pendingOriginal
535
+ if (owned !== undefined) restoreFlowFill(port)
536
+ }
537
+ const target = `${Math.max(0, client - overshoot)}px`
538
+ if (owned !== undefined) {
539
+ if (owned.element === element && owned.overshootPx === overshoot && element.style.minHeight === target) return
540
+ restoreFlowFill(port)
541
+ }
542
+ const original = element.style.minHeight
543
+ element.style.minHeight = target
544
+ followFlowFills.set(port, { element, original, overshootPx: overshoot })
545
+ }
546
+
547
+ function restoreFlowFill(port: HTMLElement): void {
548
+ const owned = followFlowFills.get(port)
549
+ if (owned === undefined) return
550
+ owned.element.style.minHeight = owned.original
551
+ followFlowFills.delete(port)
552
+ }
553
+
264
554
  /** Height committed by one newly mounted Chat row, including its flex gap. */
265
555
  function entranceExtentOf(root: HTMLElement): number {
266
556
  const row = root.closest<HTMLElement>('[data-chat-flow-key]') ?? root
@@ -283,6 +573,7 @@ interface FollowRunway {
283
573
  readonly original: string
284
574
  readonly property: 'marginBottom' | 'marginTop'
285
575
  readonly requestedPx: number
576
+ readonly normalizedLegacy?: boolean
286
577
  }
287
578
 
288
579
  /**
@@ -318,6 +609,15 @@ interface FollowPaintLimit {
318
609
  export const FOLLOW_PAINT_LIMIT_TTL_MS = 250
319
610
 
320
611
  const followPaintLimits = new WeakMap<HTMLElement, FollowPaintLimit>()
612
+ const followHadChrome = new WeakSet<HTMLElement>()
613
+ /** Last painted shift per port, to spread a wrap's one-line step over frames. */
614
+ const followLastShiftPx = new WeakMap<HTMLElement, number>()
615
+ /** Last runway offset seen per port, to rebase the extent when the margin size changes. */
616
+ const followRunwayOffsetHistory = new WeakMap<HTMLElement, number>()
617
+ /** Last observed scroll floor per port, for the slack→overflow runway re-measure. */
618
+ const followFloorHistory = new WeakMap<HTMLElement, number>()
619
+ /** One-shot flag: the transition frame must paint the full runway as baseline. */
620
+ const followSlackTransition = new WeakSet<HTMLElement>()
321
621
 
322
622
  function invalidatePaintLimit(port: HTMLElement): void {
323
623
  followPaintLimits.delete(port)
@@ -335,6 +635,47 @@ interface FollowMotionState {
335
635
  /** Logical position and velocity survive a React owner handoff and finish. */
336
636
  const followMotionStates = new WeakMap<HTMLElement, FollowMotionState>()
337
637
 
638
+ /**
639
+ * Reader-release record that must survive an ownership handoff. A follower
640
+ * closing at stream end (finish/drain arm swap, row lifecycle flip) destroys
641
+ * the closure holding `following = false`, and the arm taking over would
642
+ * otherwise read the held viewport as "at bottom" and hard-snap to the
643
+ * floor. Presence in this map means the reader's unpin away from the floor
644
+ * is still in effect; it is cleared when a follower re-pins at the floor.
645
+ */
646
+ interface FollowReaderHold {
647
+ readonly atMs: number
648
+ }
649
+ const followReaderHolds = new WeakMap<HTMLElement, FollowReaderHold>()
650
+
651
+ /**
652
+ * Commit-time correction channel. A reveal commit that lands after this
653
+ * frame's ResizeObserver delivery would otherwise paint one intermediate
654
+ * frame — content grown, scrollTop/transform not yet compensated — before
655
+ * the next tick fixes it. Reveal arms call {@link notifyFollowCommit} right
656
+ * after their commit; the leading follower re-runs its geometry in the same
657
+ * task, so the intermediate state never reaches a paint.
658
+ */
659
+ const followCommitListeners = new WeakMap<HTMLElement, Set<() => void>>()
660
+
661
+ export function notifyFollowCommit(fromInsidePort: HTMLElement | null): void {
662
+ if (fromInsidePort === null) return
663
+ const port = fromInsidePort.closest<HTMLElement>('[data-conversation-scroll]')
664
+ const listeners = port === null ? undefined : followCommitListeners.get(port)
665
+ if (listeners === undefined) return
666
+ for (const listener of [...listeners]) listener()
667
+ }
668
+
669
+ function subscribeFollowCommit(port: HTMLElement, fn: () => void): () => void {
670
+ let listeners = followCommitListeners.get(port)
671
+ if (listeners === undefined) {
672
+ listeners = new Set()
673
+ followCommitListeners.set(port, listeners)
674
+ }
675
+ listeners.add(fn)
676
+ return () => { listeners!.delete(fn) }
677
+ }
678
+
338
679
  function restoreRunway(port: HTMLElement): void {
339
680
  const runway = followRunways.get(port)
340
681
  if (runway === undefined) return
@@ -347,12 +688,11 @@ function isLegacyRunway(value: string): boolean {
347
688
  if (value === '') return false
348
689
  const terms = [...value.matchAll(/([\d.]+)px/g)]
349
690
  if (terms.length === 0 || value.replaceAll(/calc|px|[\d.+()\s]/g, '') !== '') return false
350
- return terms.every(([, raw]) => {
351
- const px = Number(raw)
352
- return Number.isFinite(px)
353
- && px >= FOLLOW_STATUS_RUNWAY_PX
354
- && Math.abs(px % FOLLOW_STATUS_RUNWAY_PX) <= Number.EPSILON
355
- })
691
+ const values = terms.map(([, raw]) => Number(raw))
692
+ if (values.some(px => !Number.isFinite(px))) return false
693
+ return [LEGACY_RUNWAY_PX, FOLLOW_STATUS_RUNWAY_PX].some(unit => values.every(px => (
694
+ px >= unit && Math.abs(px % unit) <= Number.EPSILON
695
+ )))
356
696
  }
357
697
 
358
698
  /** Remove unowned runway residue written by v0.3.3 and earlier bundles. */
@@ -361,8 +701,8 @@ function migrateLegacyRunway(
361
701
  surfaces: readonly HTMLElement[],
362
702
  status: HTMLElement | null,
363
703
  composer: HTMLElement | null,
364
- ): void {
365
- if (followRunways.has(port)) return
704
+ ): boolean {
705
+ if (followRunways.has(port)) return false
366
706
  let migrated = false
367
707
  if (status !== null && isLegacyRunway(status.style.marginTop)) {
368
708
  // Harness TurnStatus has no inline margin; exact 48px multiples here are
@@ -383,6 +723,7 @@ function migrateLegacyRunway(
383
723
  migrated = true
384
724
  }
385
725
  if (migrated) invalidatePaintLimit(port)
726
+ return migrated
386
727
  }
387
728
 
388
729
  function ensureRunway(
@@ -392,13 +733,32 @@ function ensureRunway(
392
733
  ): void {
393
734
  const status = turnStatusOf(port)
394
735
  const composer = port.querySelector<HTMLElement>('[data-composer-seat]')
395
- 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)
396
752
  // A runway is useful only after the natural conversation already has a
397
753
  // scroll floor for its equal message transform to ride. Before that point
398
754
  // applyVisual keeps every surface in normal flow, so adding status margin
399
755
  // would expose the whole runway as empty space below a short/early Think.
400
756
  const naturalHeight = Math.max(0, port.scrollHeight - runwayOffsetOf(port))
401
- 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) {
402
762
  restoreRunway(port)
403
763
  return
404
764
  }
@@ -413,15 +773,23 @@ function ensureRunway(
413
773
  const current = followRunways.get(port)
414
774
  if (current?.element === element
415
775
  && current.property === target.property
416
- && current.requestedPx === runwayPx) return
776
+ && current.requestedPx === requestedRunwayPx) return
777
+
417
778
  restoreRunway(port)
418
779
  const beforeHeight = port.scrollHeight
419
780
  const original = element.style[target.property]
420
781
  element.style[target.property] = original === ''
421
- ? `${runwayPx}px`
422
- : `calc(${original} + ${runwayPx}px)`
782
+ ? `${requestedRunwayPx}px`
783
+ : `calc(${original} + ${requestedRunwayPx}px)`
423
784
  const offset = Math.max(0, port.scrollHeight - beforeHeight)
424
- 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
+ })
425
793
  invalidatePaintLimit(port)
426
794
  }
427
795
 
@@ -438,6 +806,7 @@ function safeShiftLimit(
438
806
  if (last === undefined) return 0
439
807
  const status = turnStatusOf(port)
440
808
  const composer = port.querySelector<HTMLElement>('[data-composer-seat]')
809
+ if (status !== null || composer !== null) followHadChrome.add(port)
441
810
  const cached = followPaintLimits.get(port)
442
811
  // Content growth alone cannot move the limit (measured at the floor, the
443
812
  // flow bottom rides the scrollport bottom), so the cache survives glyph
@@ -463,7 +832,7 @@ function safeShiftLimit(
463
832
  // mounted but has not measured yet, permit only the runway zero-point
464
833
  // until ResizeObserver provides a real ceiling.
465
834
  return status === null && composer === null
466
- ? Number.POSITIVE_INFINITY
835
+ ? followHadChrome.has(port) ? 0 : Number.POSITIVE_INFINITY
467
836
  : runwayOffsetOf(port)
468
837
  }
469
838
  const ceilingTop = ceiling.rect.top - currentShiftOf(ceiling.element)
@@ -522,17 +891,62 @@ function applyVisual(
522
891
  reservePx: number,
523
892
  velocityPxPerSec = 0,
524
893
  runwayPx = FOLLOW_STATUS_RUNWAY_PX,
894
+ shiftCeilingPx = Number.POSITIVE_INFINITY,
895
+ promoteAtRest = false,
896
+ trajectoryShiftPx?: number,
525
897
  ): number {
526
898
  const surfaces = shiftSurfacesOf(port)
527
- ensureRunway(port, surfaces, runwayPx)
528
- const contentHeight = Math.max(0, port.scrollHeight)
529
- 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
530
943
  const targetHeight = Math.max(0, contentHeight - runwayOffset)
531
944
  const floor = Math.max(0, contentHeight - port.clientHeight)
532
945
  const extent = Math.min(targetHeight, Math.max(0, animatedH))
533
946
  if (port.style.overflowAnchor !== 'none') port.style.overflowAnchor = 'none'
534
947
  if (port.style.scrollBehavior !== 'auto') port.style.scrollBehavior = 'auto'
535
948
  if (floor <= 0) {
949
+ followRunwayOffsetHistory.set(port, 0)
536
950
  setFollowScrollTop(port, 0)
537
951
  followMotionStates.set(port, {
538
952
  capacityPx: Number.POSITIVE_INFINITY,
@@ -543,21 +957,60 @@ function applyVisual(
543
957
  velocityPxPerSec: 0,
544
958
  })
545
959
  for (const surface of surfaces) setShift(surface, 0)
960
+ followLastShiftPx.set(port, 0)
546
961
  const status = turnStatusOf(port)
547
962
  if (status !== null) setShift(status, 0)
548
963
  return targetHeight
549
964
  }
550
- // Measure paint room at the real floor. This write and the final physical
551
- // position land in the same animation frame, so only the latter is painted.
552
- setFollowScrollTop(port, floor)
553
- 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)
554
977
  const visibleReserve = Math.min(runwayOffset, Math.max(0, reservePx))
555
978
  const baselineShift = runwayOffset - visibleReserve
556
979
  const requestedLag = Math.max(0, targetHeight - extent)
557
- const shift = Math.min(baselineShift + requestedLag, Math.max(0, limit))
558
- 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)
559
1011
  const capacityPx = Math.max(0, limit - baselineShift)
560
1012
  const effectiveExtent = targetHeight - effectiveLag
1013
+ setFollowScrollTop(port, floor)
561
1014
  followMotionStates.set(port, {
562
1015
  capacityPx,
563
1016
  constrained: requestedLag > effectiveLag + FOLLOW_SETTLE_EPSILON_PX,
@@ -585,6 +1038,7 @@ function clearVisual(port: HTMLElement): void {
585
1038
  clearMotion(port)
586
1039
  restoreRunway(port)
587
1040
  followMotionStates.delete(port)
1041
+ followLastShiftPx.delete(port)
588
1042
  invalidatePaintLimit(port)
589
1043
  }
590
1044
 
@@ -596,9 +1050,16 @@ function holdCompositorAtRest(element: HTMLElement): void {
596
1050
  }
597
1051
 
598
1052
  /** Remove equal offsets, land on the floor, then retire the compositor quietly. */
599
- function finishAtNaturalFloor(port: HTMLElement): void {
1053
+ function finishAtNaturalFloor(port: HTMLElement, retainCompositor = true): void {
600
1054
  const surfaces = shiftSurfacesOf(port)
601
1055
  const status = turnStatusOf(port)
1056
+ if (!retainCompositor) {
1057
+ restoreRunway(port)
1058
+ settleAtFloor(port)
1059
+ clearMotion(port)
1060
+ followMotionStates.delete(port)
1061
+ return
1062
+ }
602
1063
  const promoted = [...surfaces, ...(status === null ? [] : [status])]
603
1064
  .filter(element => element.style.transform !== '' || element.style.willChange === 'transform')
604
1065
  const promotedSet = new Set(promoted)
@@ -619,7 +1080,6 @@ function finishAtNaturalFloor(port: HTMLElement): void {
619
1080
  if (promoted.length === 0) return
620
1081
  requestAnimationFrame(() => {
621
1082
  requestAnimationFrame(() => {
622
- if (port.hasAttribute(FOLLOW_OWNED_ATTR)) return
623
1083
  for (const element of promoted) {
624
1084
  if (Math.abs(currentShiftOf(element)) <= 0.01) setShift(element, 0)
625
1085
  }
@@ -630,6 +1090,7 @@ function finishAtNaturalFloor(port: HTMLElement): void {
630
1090
  function settleAtFloor(port: HTMLElement): void {
631
1091
  const floor = Math.max(0, port.scrollHeight - port.clientHeight)
632
1092
  setFollowScrollTop(port, floor)
1093
+ followReaderHolds.delete(port)
633
1094
  }
634
1095
 
635
1096
  interface FollowLeader {
@@ -653,6 +1114,7 @@ let followGeneration = 0
653
1114
  * @param onEntranceSettled - Releases a one-shot entrance owner after catch-up.
654
1115
  * @param predictiveRef - Optional live visibility gate for predictive runway.
655
1116
  * @param entranceExtentRef - Optional measured growth delta for a generic row.
1117
+ * @param revealedCharsRef - Committed code-point count for feed-forward phase.
656
1118
  */
657
1119
  export function useConversationFollow(
658
1120
  rootRef: RefObject<HTMLElement | null>,
@@ -664,18 +1126,18 @@ export function useConversationFollow(
664
1126
  onEntranceSettled?: () => void,
665
1127
  predictiveRef?: { current: boolean },
666
1128
  entranceExtentRef?: { current: number | null },
1129
+ revealedCharsRef?: { current: number },
667
1130
  ): void {
668
1131
  const activeRef = useRef(active)
669
1132
  const entranceRef = useRef(entrance)
670
1133
  const onEntranceSettledRef = useRef(onEntranceSettled)
671
1134
  entranceRef.current = entrance
672
1135
  onEntranceSettledRef.current = onEntranceSettled
673
- useEffect(() => {
674
- activeRef.current = active
675
- }, [active])
1136
+ activeRef.current = active
676
1137
 
677
1138
  useLayoutEffect(() => {
678
1139
  if (!active) return
1140
+ const startedAsEntrance = entrance
679
1141
  const owner = {}
680
1142
  const generation = ++followGeneration
681
1143
  let rafId = 0
@@ -692,6 +1154,18 @@ export function useConversationFollow(
692
1154
  let interactTimer: ReturnType<typeof setTimeout> | null = null
693
1155
  let port: HTMLElement | null = null
694
1156
  let resize: ResizeObserver | null = null
1157
+ let observedTail: HTMLElement | null = null
1158
+ let statusWasPresent: boolean | null = null
1159
+ let trajectoryPositionPx: number | null = null
1160
+ let trajectoryVelocityPxPerMs = 0
1161
+ let trajectoryTargetVelocityPxPerMs = 0
1162
+ let trajectoryFloorPx: number | null = null
1163
+ let trajectoryGrowthAtMs: number | null = null
1164
+ let trajectoryAccumulatedGrowthPx = 0
1165
+ let trajectoryAccumulatedGrowthMs = 0
1166
+ let trajectoryGrowthSamples = 0
1167
+ let trajectoryWasActive = false
1168
+ const revealPhase = new FollowRevealPhaseTracker()
695
1169
  let holding: HTMLElement | null = null
696
1170
  let entrancePending = entranceRef.current
697
1171
 
@@ -726,10 +1200,12 @@ export function useConversationFollow(
726
1200
  const reportFollow = (next: HTMLElement, isActive: boolean): void => {
727
1201
  const state = followMotionStates.get(next)
728
1202
  debugRuntime.reportFollow(next, {
729
- 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,
730
1206
  velocityPxPerSec: state?.velocityPxPerSec ?? 0,
731
1207
  reservePx: state?.reservePx ?? 0,
732
- capacityPx: state?.capacityPx ?? 0,
1208
+ capacityPx: state ? state.capacityPx : -1,
733
1209
  revealScale: revealScaleRef?.current ?? 1,
734
1210
  following,
735
1211
  constrained: state?.constrained ?? false,
@@ -764,7 +1240,8 @@ export function useConversationFollow(
764
1240
 
765
1241
  const handBackVisual = (next: HTMLElement): void => {
766
1242
  const shift = currentShiftOf(shiftSurfacesOf(next).at(-1) ?? next)
767
- const 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)
768
1245
  // The predictive runway only has meaning while this follower owns the
769
1246
  // floor. Remove it before choosing the reader's landing point; keeping
770
1247
  // it through the release paints a transient natural gap + 48px blank
@@ -807,21 +1284,60 @@ export function useConversationFollow(
807
1284
 
808
1285
  const restoreBeforePaint = (): void => {
809
1286
  if (!following || port === null || !isLeader(port)) return
810
- 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.
811
1292
  const tuning = debugRuntime.activeTuning()
812
- 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
+ }
813
1319
  updateRevealScale(port, 0, true)
814
1320
  reportFollow(port, activeRef.current)
1321
+ // Host ChatView ResizeObservers may run after this observer and hard-snap
1322
+ // to its floor. Re-apply the owned floating top in the same microtask,
1323
+ // before the browser's next paint, while scroll-event capture prevents a
1324
+ // programmatic write from changing the reader's at-bottom state.
815
1325
  }
816
1326
 
1327
+ // Same correction, run synchronously with a reveal commit (see
1328
+ // notifyFollowCommit above). Subscribed per-port in bindPort.
1329
+ let unsubscribeCommit: (() => void) | null = null
1330
+
817
1331
  const bindPort = (next: HTMLElement): void => {
818
1332
  if (port === next) return
819
1333
  if (port !== null) {
820
1334
  for (const name of GESTURE_EVENTS) port.removeEventListener(name, markGesture)
821
1335
  resize?.disconnect()
822
1336
  }
1337
+ unsubscribeCommit?.()
823
1338
  port = next
824
1339
  invalidatePaintLimit(port)
1340
+ unsubscribeCommit = subscribeFollowCommit(port, () => { restoreBeforePaint() })
825
1341
  for (const name of GESTURE_EVENTS) {
826
1342
  port.addEventListener(name, markGesture, { passive: true })
827
1343
  }
@@ -833,6 +1349,21 @@ export function useConversationFollow(
833
1349
  }
834
1350
  }
835
1351
 
1352
+ /**
1353
+ * Keep the observer on the flow's TAIL surface. A flow locked to the
1354
+ * viewport by min-height does not resize when content grows inside it —
1355
+ * only the last message row does, and missing that resize means missing
1356
+ * the pre-paint correction for that frame's wrap.
1357
+ */
1358
+ const observeTailSurface = (): void => {
1359
+ if (resize === null || port === null) return
1360
+ const tail = shiftSurfacesOf(port).at(-1) ?? null
1361
+ if (tail === observedTail) return
1362
+ if (observedTail !== null) resize.unobserve(observedTail)
1363
+ observedTail = tail
1364
+ if (tail !== null) resize.observe(tail)
1365
+ }
1366
+
836
1367
  const frame = (now: number) => {
837
1368
  rafId = requestAnimationFrame(frame)
838
1369
  // Spring time is clamped so one paint after a stall cannot teleport the
@@ -847,6 +1378,7 @@ export function useConversationFollow(
847
1378
  const nextPort = root.closest<HTMLElement>('[data-conversation-scroll]')
848
1379
  if (nextPort === null) return
849
1380
  bindPort(nextPort)
1381
+ observeTailSurface()
850
1382
  // A hidden/unmeasured port has no meaningful floor yet. Keep this owner
851
1383
  // unprimed and let the already-scheduled RAF initialize it after layout.
852
1384
  if (nextPort.clientHeight <= 0) return
@@ -866,18 +1398,44 @@ export function useConversationFollow(
866
1398
  // A new Agent row is already part of scrollHeight on its first
867
1399
  // frame. Start at the pre-insert extent so that initial Context and
868
1400
  // Tool chrome enters through the same spring as later height growth.
1401
+ // The predictive runway starts pre-opened at the current reveal
1402
+ // pressure: it rides canceled by the equal transform, and waiting
1403
+ // out the response ramp would leave the first wraps unpinned.
869
1404
  const entranceExtent = entrancePending
870
1405
  ? entranceExtentRef?.current ?? entranceExtentOf(root)
871
1406
  : 0
872
- animatedH = Math.max(0, nextPort.scrollHeight - entranceExtent)
873
- 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
874
1425
  velocityPxPerSec = 0
875
1426
  // The committed row/growth delta has already moved the new floor.
876
- // Decide ownership from the reader's position before that delta;
877
- // otherwise any atomic result taller than FOLLOW_SLACK_PX looks
878
- // indistinguishable from an intentional reader pull-up.
879
- const lagBeforeEntrance = Math.max(0, reportedLag - entranceExtent)
880
- 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)
881
1439
  } else {
882
1440
  animatedH = Math.min(nextPort.scrollHeight, inherited.extent)
883
1441
  reservePx = inherited.reservePx
@@ -887,7 +1445,38 @@ export function useConversationFollow(
887
1445
  if (following) {
888
1446
  hold(nextPort)
889
1447
  if (isLeader(nextPort)) {
890
- animatedH = applyVisual(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
+ }
891
1480
  updateRevealScale(nextPort, elapsedMs)
892
1481
  reportFollow(nextPort, activeRef.current)
893
1482
  const runwayOffset = runwayOffsetOf(nextPort)
@@ -910,6 +1499,7 @@ export function useConversationFollow(
910
1499
  if (!following && (!interacting || returnedToFloor) && reportedLag <= repinSlack) {
911
1500
  following = true
912
1501
  readerReleased = false
1502
+ followReaderHolds.delete(nextPort)
913
1503
  animatedH = extent
914
1504
  reservePx = 0
915
1505
  velocityPxPerSec = 0
@@ -921,6 +1511,7 @@ export function useConversationFollow(
921
1511
  following = false
922
1512
  readerGestureIntent = false
923
1513
  readerReleased = true
1514
+ followReaderHolds.set(nextPort, { atMs: performance.now() })
924
1515
  handBackVisual(nextPort)
925
1516
  animatedH = nextPort.scrollHeight
926
1517
  reservePx = 0
@@ -943,32 +1534,152 @@ export function useConversationFollow(
943
1534
  // Runway and an equal transform cancel visually. It is the zero point,
944
1535
  // not residual motion: decaying below it would scroll past the final
945
1536
  // resting position and rebound when runway is removed.
946
- const runwayOffset = runwayOffsetOf(nextPort)
947
- const contentHeight = nextPort.scrollHeight
948
- 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.
949
1544
  const predictGrowth = predictiveRef?.current ?? predictive
950
- 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
951
1564
  ? 0
952
- : computeFollowReserve(speedCpsRef.current, tuning.runwayPx)
1565
+ : statusJustRemoved
1566
+ ? tuning.runwayPx
1567
+ : heldReserveTarget
953
1568
  const reserveStep = 1 - Math.exp(-elapsedMs / tuning.reserveResponseMs)
954
- reservePx += (reserveTarget - reservePx) * reserveStep
955
- const step = computeFollowStep(dt, {
956
- lag,
957
- speedEma: speedCpsRef.current,
958
- velocityPxPerSec,
959
- }, tuning)
960
- if (lag <= 0.1) {
961
- animatedH = contentHeight - runwayOffset
962
- velocityPxPerSec = 0
963
- } else {
964
- const minimumLag = predictGrowth ? 0 : Math.max(0, reservePx)
965
- animatedH = Math.min(
966
- contentHeight - runwayOffset - minimumLag,
967
- 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,
968
1584
  )
969
- 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)
970
1682
  }
971
- animatedH = applyVisual(nextPort, animatedH, reservePx, velocityPxPerSec, tuning.runwayPx)
972
1683
  updateRevealScale(nextPort, elapsedMs)
973
1684
  reportFollow(nextPort, true)
974
1685
  const remainingEntranceLag = Math.max(
@@ -985,6 +1696,7 @@ export function useConversationFollow(
985
1696
  frame(performance.now())
986
1697
  return () => {
987
1698
  cancelAnimationFrame(rafId)
1699
+ unsubscribeCommit?.()
988
1700
  if (interactTimer !== null) clearTimeout(interactTimer)
989
1701
  resize?.disconnect()
990
1702
  if (port !== null) {
@@ -997,6 +1709,13 @@ export function useConversationFollow(
997
1709
  if (!isLeader(host)) return
998
1710
  const preserveReader = interacting && (readerGestureIntent || readerScrolledUp(host))
999
1711
  if (!following || !primed) {
1712
+ if (!following && primed) {
1713
+ // A follower that already released the reader must carry that fact
1714
+ // across this closure's death: the draining arm mounting after this
1715
+ // cleanup would otherwise read the held viewport as its own
1716
+ // at-bottom state and hard-snap it to the floor.
1717
+ followReaderHolds.set(host, { atMs: performance.now() })
1718
+ }
1000
1719
  clearVisual(host)
1001
1720
  followLeaders.delete(host)
1002
1721
  releaseRevealScale()
@@ -1005,6 +1724,7 @@ export function useConversationFollow(
1005
1724
  }
1006
1725
  if (preserveReader) {
1007
1726
  handBackVisual(host)
1727
+ followReaderHolds.set(host, { atMs: performance.now() })
1008
1728
  clearVisual(host)
1009
1729
  followLeaders.delete(host)
1010
1730
  releaseRevealScale()
@@ -1015,21 +1735,56 @@ export function useConversationFollow(
1015
1735
  // Completion can land the final Tool/command height in this same
1016
1736
  // commit. Preserve the logical extent and drain it after unmount instead
1017
1737
  // of clearing the compositor state before the first settled paint.
1738
+ // NOTE: do NOT clamp animatedH down by the held reserve here — the
1739
+ // reserve is canceled space (base = margin − reservation stays flat),
1740
+ // so treating it as real lag paints a whole runway-height step at the
1741
+ // exact moment leadership hands to the draining arm. The settle loop
1742
+ // caps animatedH against the shrinking extent directly.
1743
+ const lagBeforeCompletionPaint = Math.max(
1744
+ 0,
1745
+ host.scrollHeight - animatedH - runwayOffsetOf(host),
1746
+ )
1747
+ if (!activeRef.current && lagBeforeCompletionPaint <= FOLLOW_SLACK_PX) {
1748
+ finishAtNaturalFloor(host, !startedAsEntrance)
1749
+ followLeaders.delete(host)
1750
+ releaseRevealScale()
1751
+ debugRuntime.reportFollow(host, null)
1752
+ return
1753
+ }
1754
+ const completionShift = currentShiftOf(shiftSurfacesOf(host).at(-1) ?? host)
1755
+ const completionShiftCeiling = completionShift > FOLLOW_SETTLE_EPSILON_PX
1756
+ ? completionShift
1757
+ : Number.POSITIVE_INFINITY
1758
+ // The completion handoff keeps at least one real runway open so the
1759
+ // final height has reserved paint room to drain through: with zero
1760
+ // margin the whole final height lands as shift in ONE paint (offset
1761
+ // space collapses to the paint limit), the exact teleport this drain
1762
+ // exists to prevent. The draining arm ramps its reserve from here.
1018
1763
  const completionTuning = debugRuntime.activeTuning()
1019
- ensureRunway(host, shiftSurfacesOf(host), completionTuning.runwayPx)
1764
+ const previousCompletionRunway = runwayOffsetOf(host)
1765
+ ensureRunway(host, shiftSurfacesOf(host), Math.max(reservePx, completionTuning.runwayPx))
1020
1766
  const completionRunway = runwayOffsetOf(host)
1021
- const completionMinimumLag = Math.max(0, reservePx)
1022
- 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,
1023
1774
  animatedH,
1024
- host.scrollHeight - completionRunway - completionMinimumLag,
1775
+ reservePx,
1776
+ velocityPxPerSec,
1777
+ completionRunway,
1778
+ completionShiftCeiling,
1025
1779
  )
1026
- settleAtFloor(host)
1027
- animatedH = applyVisual(host, animatedH, reservePx, velocityPxPerSec, completionTuning.runwayPx)
1028
1780
  reportFollow(host, false)
1029
1781
  const runwayOffset = runwayOffsetOf(host)
1030
1782
  const remainingLag = Math.max(0, host.scrollHeight - animatedH - runwayOffset)
1031
- if (remainingLag <= FOLLOW_SETTLE_EPSILON_PX && reservePx <= FOLLOW_SETTLE_EPSILON_PX) {
1032
- finishAtNaturalFloor(host)
1783
+ if (
1784
+ remainingLag <= FOLLOW_SETTLE_EPSILON_PX
1785
+ || (!activeRef.current && remainingLag <= FOLLOW_SLACK_PX)
1786
+ ) {
1787
+ finishAtNaturalFloor(host, !startedAsEntrance)
1033
1788
  followLeaders.delete(host)
1034
1789
  releaseRevealScale()
1035
1790
  debugRuntime.reportFollow(host, null)
@@ -1047,6 +1802,8 @@ export function useConversationFollow(
1047
1802
  }
1048
1803
  }
1049
1804
  let settleLast = performance.now()
1805
+ let settleMarginPx = Math.max(runwayOffsetOf(host), reservePx)
1806
+ let settleMarginRate = settleMarginPx / FOLLOW_RUNWAY_RETIRE_MS
1050
1807
  const settleFrame = (now: number): void => {
1051
1808
  if (!isLeader(host)) {
1052
1809
  stopSettleListeners()
@@ -1065,15 +1822,24 @@ export function useConversationFollow(
1065
1822
  const dt = Math.min(FOLLOW_MAX_FRAME_MS, Math.max(0, now - settleLast))
1066
1823
  const tuning = debugRuntime.activeTuning()
1067
1824
  settleLast = now
1825
+ // Retire the runway in LOCKSTEP with its canceling reserve: the
1826
+ // margin shrinks by exactly the amount the reservation shrinks, so
1827
+ // their baseline difference — and therefore every painted pixel —
1828
+ // stays put while the reserved space closes. Decaying only the
1829
+ // reservation would slide the whole column by the runway height.
1830
+ if (settleMarginPx > 0 && settleMarginRate > 0) {
1831
+ settleMarginPx = Math.max(0, settleMarginPx - settleMarginRate * dt)
1832
+ ensureRunway(host, shiftSurfacesOf(host), settleMarginPx)
1833
+ reservePx = Math.min(reservePx, settleMarginPx)
1834
+ }
1068
1835
  const runwayOffset = runwayOffsetOf(host)
1069
1836
  const lag = Math.max(0, host.scrollHeight - animatedH - runwayOffset)
1070
- const reserveStep = 1 - Math.exp(-dt / tuning.reserveResponseMs)
1071
- reservePx += (0 - reservePx) * reserveStep
1072
- if (lag <= FOLLOW_SETTLE_EPSILON_PX && reservePx <= FOLLOW_SETTLE_EPSILON_PX) {
1073
- animatedH = host.scrollHeight - runwayOffset
1837
+ if (lag <= FOLLOW_SETTLE_EPSILON_PX && settleMarginPx <= FOLLOW_SETTLE_EPSILON_PX) {
1838
+ animatedH = host.scrollHeight
1074
1839
  reservePx = 0
1075
1840
  velocityPxPerSec = 0
1076
- finishAtNaturalFloor(host)
1841
+ followRunways.delete(host)
1842
+ finishAtNaturalFloor(host, !startedAsEntrance)
1077
1843
  followLeaders.delete(host)
1078
1844
  releaseRevealScale()
1079
1845
  debugRuntime.reportFollow(host, null)
@@ -1085,21 +1851,38 @@ export function useConversationFollow(
1085
1851
  speedEma: speedCpsRef.current,
1086
1852
  velocityPxPerSec,
1087
1853
  }, tuning)
1088
- // The temporary runway is visually neutral only while an equal lag
1089
- // remains in the transform. Do not let the spring outrun the runway's
1090
- // closing reserve or cleanup would reveal an overshoot and rebound.
1091
- const minimumLag = Math.max(0, reservePx)
1092
1854
  animatedH = Math.min(
1093
- host.scrollHeight - runwayOffset - minimumLag,
1855
+ host.scrollHeight - runwayOffset,
1094
1856
  animatedH + step.advancePx,
1095
1857
  )
1096
1858
  velocityPxPerSec = step.velocityPxPerSec
1097
1859
  settleAtFloor(host)
1098
- animatedH = applyVisual(host, animatedH, reservePx, velocityPxPerSec, tuning.runwayPx)
1860
+ animatedH = applyVisual(host, animatedH, reservePx, velocityPxPerSec, Math.max(settleMarginPx, runwayOffset))
1099
1861
  reportFollow(host, false)
1100
1862
  requestAnimationFrame(settleFrame)
1101
1863
  }
1102
1864
  requestAnimationFrame(settleFrame)
1103
1865
  }
1104
1866
  }, [active, rootRef, speedCpsRef, revealScaleRef, predictive, predictiveRef])
1867
+
1868
+ useLayoutEffect(() => {
1869
+ const host = rootRef.current?.closest<HTMLElement>('[data-conversation-scroll]') ?? null
1870
+ if (host !== null) {
1871
+ followFlowFillUsers.set(host, (followFlowFillUsers.get(host) ?? 0) + 1)
1872
+ }
1873
+ return () => {
1874
+ if (host === null) return
1875
+ const remaining = Math.max(0, (followFlowFillUsers.get(host) ?? 1) - 1)
1876
+ if (remaining > 0) {
1877
+ followFlowFillUsers.set(host, remaining)
1878
+ return
1879
+ }
1880
+ followFlowFillUsers.delete(host)
1881
+ requestAnimationFrame(() => {
1882
+ requestAnimationFrame(() => {
1883
+ if (!followLeaders.has(host) && !followFlowFillUsers.has(host)) restoreFlowFill(host)
1884
+ })
1885
+ })
1886
+ }
1887
+ }, [rootRef])
1105
1888
  }