dsh-smooth-stream 0.3.1 → 0.3.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.
@@ -1,129 +1,204 @@
1
1
  /**
2
2
  * Conversation-port follow while an assistant reply streams.
3
3
  *
4
- * The demo's damped-spring lerp is driven from a float `animatedH` — the
5
- * smoothed content height — never from the rounded `scrollTop` the browser
6
- * reports back. A wrap raises the content height by a line; if the engine
7
- * were allowed to snap `scrollTop` to the new floor, the previous line would
8
- * hop up in one frame. This overlay:
4
+ * A sub-stepped spring physics engine drives a float `animatedH`, rather
5
+ * than restarting native smooth-scroll animations as every glyph lands.
6
+ * Remaining lag rides a small compositor transform while the real scrollport
7
+ * stays at its floor. The transform is bounded by the measured paint gap
8
+ * before conversation chrome. This follower:
9
9
  *
10
- * - owns follow via `data-follow-owned` so ChatView does not snap;
10
+ * - marks programmatic writes via `data-follow-owned` for compatible hosts;
11
11
  * - sets `overflow-anchor: none` so CSS scroll-anchoring does not snap;
12
12
  * - restores `animatedH` in a ResizeObserver (before paint) so a layout
13
13
  * pass cannot flash a snapped frame;
14
- * - while the port has scroll room, writes the interpolated lag as
15
- * `translate3d` on `[data-chat-transcript]` (message rows only), so the
16
- * turn-status chrome stays pinned at the floor;
17
- * - before the port has scroll room (content shorter than the viewport), the
18
- * same lag is a content-height lag written as a negative translate on
19
- * `[data-chat-turn-status]`, so the status label descends smoothly with
20
- * each wrap instead of hopping a line at a time.
14
+ * - expresses safe lag as a compositor transform on message rows;
15
+ * - opens a speed-adaptive layout runway before fast output wraps, preserving
16
+ * the reference spring constants at every reveal speed;
17
+ * - catches up any lag that cannot fit before turn status / composer chrome,
18
+ * so fixed chrome never has to counter-shift and the host stays at-bottom;
19
+ * - never clips or overlays streamed text.
21
20
  *
22
- * Handing the port back to the reader (unpin, or the owner going inactive)
23
- * first writes the effective visual top (`engine - lag`) into `scrollTop`
24
- * before clearing the transform, so the frame stays continuous. That write
25
- * also lands in ChatView's observed-top ledger as reader input beyond
26
- * `FOLLOW_THRESHOLD`, releasing its native snap-follow instead of fighting
27
- * the gesture for the port.
21
+ * A real reader gesture receives the effective visual position before the
22
+ * transform clears. Lifecycle completion instead settles at the floor.
28
23
  *
29
- * Unpin is a real gesture (wheel / touch / pointer / key) that leaves the
30
- * floor; a `scrollTop` delta from our own write must not release the pin.
24
+ * Directional wheel/touch intent unpins immediately; pointer/key input falls
25
+ * back to an upward scroll delta from the engine's own written position. A
26
+ * reader release re-acquires only after returning to the real floor.
31
27
  */
32
28
 
33
- import { useEffect, useRef, type RefObject } from 'react'
29
+ import { useEffect, useLayoutEffect, useRef, type RefObject } from 'react'
34
30
 
35
31
  /**
36
- * Matches ChatView's `FOLLOW_OWNED_ATTR`. The overlay writes the last
37
- * programmatic `scrollTop` here so ChatView yields snap-follow and does not
38
- * treat the write as reader input.
32
+ * Programmatic follow marker retained for hosts that recognize external
33
+ * scroll ownership. Current Harness also sees the write land at the floor.
39
34
  */
40
35
  const FOLLOW_OWNED_ATTR = 'data-follow-owned'
41
36
 
42
- /** Demo `1 - exp(-dt / 18)` time constant, in ms. */
43
- export const FOLLOW_LERP_DT_MS = 18
37
+ /** Physics parameters from `ultimate_stream_physics_scroller.html`. */
38
+ export const FOLLOW_SPRING_STIFFNESS = 130
39
+ export const FOLLOW_SPRING_DAMPING = 24
40
+ export const FOLLOW_SPRING_MASS = 1
41
+ export const FOLLOW_SPRING_SUBSTEPS = 4
42
+ export const FOLLOW_SPRING_MAX_STEP_MS = 32
44
43
 
45
- /** Floor / ceiling of the per-frame lerp fraction before the dt term. */
46
- export const FOLLOW_LERP_MIN = 0.05
47
- export const FOLLOW_LERP_MAX = 0.25
44
+ /** Minimum visible room for one ordinary line-wrap impulse. */
45
+ export const FOLLOW_RESERVE_MIN_PX = 16
48
46
 
49
- /** Lag (px) at which the lag term of the lerp saturates. */
50
- export const FOLLOW_LERP_LAG_REF_PX = 160
47
+ /** Reveal-speed range produced by the pressure-buffer typewriter. */
48
+ export const FOLLOW_RESERVE_MIN_CPS = 90
49
+ export const FOLLOW_RESERVE_MAX_CPS = 600
51
50
 
52
- /** Reveal cps at which the speed factor is 1. */
53
- export const FOLLOW_SPEED_REF_CPS = 35
51
+ /** Time constant for opening/closing predictive paint room. */
52
+ export const FOLLOW_RESERVE_RESPONSE_MS = 180
54
53
 
55
- export const FOLLOW_SPEED_FACTOR_MIN = 0.7
56
- export const FOLLOW_SPEED_FACTOR_MAX = 2.2
54
+ /**
55
+ * The reference engine clamps physical time to one 32ms visual interval.
56
+ * Replaying a 250ms main-thread stall in one paint teleports the transcript;
57
+ * leaving the remaining distance in the spring makes the next frames catch up
58
+ * smoothly instead.
59
+ */
60
+ export const FOLLOW_MAX_FRAME_MS = 32
61
+
62
+ /** Retained as the neutral reveal-speed seed for follow hosts. */
63
+ export const FOLLOW_SPEED_REF_CPS = 35
57
64
 
58
65
  /** Reader-return / still-pinned boundary, matching ChatView + the demo. */
59
66
  export const FOLLOW_SLACK_PX = 25
60
67
 
61
68
  /**
62
- * Upward wheel/touch distance that releases the pin. The engine `scrollTop`
63
- * is held on the floor while following, so a small trackpad tick never
64
- * appears as `reportedLag` and cannot be judged against {@link FOLLOW_SLACK_PX}.
69
+ * Fallback upward scroll distance that releases the pin when the browser does
70
+ * not expose a directional wheel/touch event. Directional intent releases
71
+ * immediately even if the follow write erases the small physical delta.
65
72
  */
66
73
  export const FOLLOW_UNPIN_GESTURE_PX = 8
67
74
 
75
+ /** A reader-released follow only re-acquires at the actual floor. */
76
+ export const FOLLOW_REPIN_PX = 1
77
+
78
+ /** ChatView's <=25px bottom band remains host-pinned; release beyond it. */
79
+ export const FOLLOW_HOST_RELEASE_PX = FOLLOW_SLACK_PX + 1
80
+
81
+ /** Sub-pixel paint guard before status/composer chrome. */
82
+ export const FOLLOW_PAINT_GUARD_PX = 1
83
+
84
+ /**
85
+ * Maximum predictive paint room before status/composer chrome. One rendered
86
+ * line is normally 24-28px; 48px plus the host's existing status gap covers
87
+ * the original spring's measured ~63px worst-case trail at 600cps.
88
+ */
89
+ export const FOLLOW_STATUS_RUNWAY_PX = 48
90
+
68
91
  /** How long a gesture keeps `isUserInteracting` so the next scroll can unpin. */
69
92
  export const FOLLOW_GESTURE_MS = 800
70
93
 
71
- const GESTURE_EVENTS = ['wheel', 'touchmove', 'pointerdown', 'keydown'] as const
94
+ /** Sub-pixel settle threshold; clearing below this cannot produce a visible rebound. */
95
+ export const FOLLOW_SETTLE_EPSILON_PX = 0.25
96
+
97
+ /** Lowest reveal rate retained while the spring is short on paint room. */
98
+ export const FOLLOW_REVEAL_MIN_SCALE = 0.55
99
+
100
+ /** Safe-lag occupancy band over which reveal pressure is progressively reduced. */
101
+ export const FOLLOW_BACKPRESSURE_START_RATIO = 0.25
102
+ export const FOLLOW_BACKPRESSURE_FULL_RATIO = 0.75
103
+
104
+ /** Slow release prevents the reveal rate from oscillating around each wrap. */
105
+ export const FOLLOW_BACKPRESSURE_RELEASE_MS = 240
106
+
107
+ const GESTURE_EVENTS = [
108
+ 'wheel',
109
+ 'touchstart',
110
+ 'touchmove',
111
+ 'touchend',
112
+ 'touchcancel',
113
+ 'pointerdown',
114
+ 'keydown',
115
+ ] as const
116
+
117
+ /** Visible runway needed for the current reveal pressure. */
118
+ export function computeFollowReserve(speedCps: number, runwayPx = FOLLOW_STATUS_RUNWAY_PX): number {
119
+ const available = Math.max(0, runwayPx)
120
+ if (available <= 0) return 0
121
+ if (speedCps <= FOLLOW_RESERVE_MIN_CPS) return 0
122
+ const normalized = Math.min(1, Math.max(0, (
123
+ speedCps - FOLLOW_RESERVE_MIN_CPS
124
+ ) / (FOLLOW_RESERVE_MAX_CPS - FOLLOW_RESERVE_MIN_CPS)))
125
+ const minimum = Math.min(available, FOLLOW_RESERVE_MIN_PX)
126
+ return minimum + normalized * (available - minimum)
127
+ }
128
+
129
+ /**
130
+ * Reveal-rate multiplier needed to retain one-wrap headroom for the spring.
131
+ * Throttling starts only after a quarter of the safe transform is occupied;
132
+ * a constrained paint lands at the minimum immediately so the next reveal
133
+ * commit cannot keep feeding an already-full visual buffer.
134
+ */
135
+ export function computeFollowRevealScale(
136
+ lagPx: number,
137
+ capacityPx: number,
138
+ constrained = false,
139
+ ): number {
140
+ if (constrained) return FOLLOW_REVEAL_MIN_SCALE
141
+ if (!Number.isFinite(capacityPx)) return 1
142
+ if (capacityPx <= 0) return lagPx > 0 ? FOLLOW_REVEAL_MIN_SCALE : 1
143
+ const ratio = Math.min(1, Math.max(0, lagPx / capacityPx))
144
+ if (ratio >= FOLLOW_BACKPRESSURE_FULL_RATIO) return FOLLOW_REVEAL_MIN_SCALE
145
+ const progress = Math.min(1, Math.max(0, (
146
+ ratio - FOLLOW_BACKPRESSURE_START_RATIO
147
+ ) / (FOLLOW_BACKPRESSURE_FULL_RATIO - FOLLOW_BACKPRESSURE_START_RATIO)))
148
+ const eased = progress * progress * (3 - 2 * progress)
149
+ return 1 - (1 - FOLLOW_REVEAL_MIN_SCALE) * eased
150
+ }
72
151
 
73
152
  export interface FollowGlideInput {
74
153
  /** How far the interpolated top trails the floor, in px. */
75
154
  readonly lag: number
76
- /** Observed reveal rate in chars/s; 0 uses {@link FOLLOW_SPEED_REF_CPS}. */
155
+ /** Observed reveal rate, retained for the public follow-step contract. */
77
156
  readonly speedEma: number
157
+ /** Physics velocity carried from the previous frame, in px/s. */
158
+ readonly velocityPxPerSec?: number
78
159
  }
79
160
 
80
161
  export interface FollowGlideStep {
81
- /** Pixels to advance `animatedH` this frame (fractional). */
162
+ /** Pixels to advance the floating content extent this frame. */
82
163
  readonly advancePx: number
83
164
  /** Applied lerp fraction, for tests. */
84
165
  readonly lerpStep: number
85
- }
86
-
87
- function clamp(value: number, min: number, max: number): number {
88
- return Math.min(max, Math.max(min, value))
166
+ /** Physics velocity to carry into the next frame, in px/s. */
167
+ readonly velocityPxPerSec: number
89
168
  }
90
169
 
91
170
  /**
92
- * One spring-lerp frame from the silky markdown demo.
171
+ * Semi-implicit spring integration with four substeps per <=32ms slice.
93
172
  * @param dtMs - Frame delta in ms.
94
- * @param input - Current lag (from the float top, not the rounded engine top) and reveal-speed EMA.
95
- * @returns The fractional advance and the lerp fraction.
173
+ * @param input - Current visible lag and carried physics velocity.
174
+ * @returns The position advance, its fraction, and next velocity.
96
175
  */
97
176
  export function computeFollowStep(dtMs: number, input: FollowGlideInput): FollowGlideStep {
98
- if (input.lag <= 0.1 || dtMs <= 0) return { advancePx: 0, lerpStep: 0 }
99
- const speed = input.speedEma > 0 ? input.speedEma : FOLLOW_SPEED_REF_CPS
100
- const speedFactor = clamp(speed / FOLLOW_SPEED_REF_CPS, FOLLOW_SPEED_FACTOR_MIN, FOLLOW_SPEED_FACTOR_MAX)
101
- const baseLerp = clamp((input.lag / FOLLOW_LERP_LAG_REF_PX) * speedFactor, FOLLOW_LERP_MIN, FOLLOW_LERP_MAX)
102
- const lerpStep = baseLerp * (1 - Math.exp(-dtMs / FOLLOW_LERP_DT_MS))
103
- return { advancePx: input.lag * lerpStep, lerpStep }
104
- }
177
+ if (input.lag <= 0.1 || dtMs <= 0) {
178
+ return { advancePx: 0, lerpStep: 0, velocityPxPerSec: 0 }
179
+ }
180
+ let lag = input.lag
181
+ let velocity = Math.max(0, input.velocityPxPerSec ?? 0)
182
+ const elapsedMs = Math.min(FOLLOW_MAX_FRAME_MS, dtMs)
183
+ const slices = Math.max(1, Math.ceil(elapsedMs / FOLLOW_SPRING_MAX_STEP_MS))
184
+ const subDt = elapsedMs / 1000 / slices / FOLLOW_SPRING_SUBSTEPS
185
+
186
+ for (let slice = 0; slice < slices; slice += 1) {
187
+ for (let substep = 0; substep < FOLLOW_SPRING_SUBSTEPS; substep += 1) {
188
+ const acceleration = (
189
+ FOLLOW_SPRING_STIFFNESS * lag - FOLLOW_SPRING_DAMPING * velocity
190
+ ) / FOLLOW_SPRING_MASS
191
+ velocity = Math.max(0, velocity + acceleration * subDt)
192
+ const advance = velocity * subDt
193
+ if (advance >= lag) {
194
+ return { advancePx: input.lag, lerpStep: 1, velocityPxPerSec: 0 }
195
+ }
196
+ lag -= advance
197
+ }
198
+ }
105
199
 
106
- /** The message-rows box the lag transform rides on, when the host has one. */
107
- function shiftRootOf(port: HTMLElement): HTMLElement | null {
108
- return port.querySelector('[data-chat-transcript]')
109
- }
110
-
111
- /**
112
- * Row wrappers that carry only messages/tools. Hosts without a transcript
113
- * box keep the turn-status chrome as a sibling of the rows inside
114
- * `[data-chat-flow]`, so shifting that whole flow would drag the chrome
115
- * along; shifting the rows individually leaves every non-message sibling
116
- * (turn status, steering bubbles) pinned while the text glides.
117
- *
118
- * Only the outermost rows are shifted: a tool call nests its subcalls as
119
- * descendant `[data-chat-anchor-key]` rows, and writing the lag on each of
120
- * them would double (or further multiply) the shift, tearing the subcalls
121
- * away from their parent every frame. Descendants ride the parent's
122
- * transform instead.
123
- */
124
- function shiftRowsOf(port: HTMLElement): HTMLElement[] {
125
- return [...port.querySelectorAll<HTMLElement>('[data-chat-anchor-key]')]
126
- .filter(row => row.parentElement?.closest('[data-chat-anchor-key]') === null)
200
+ const advancePx = input.lag - lag
201
+ return { advancePx, lerpStep: advancePx / input.lag, velocityPxPerSec: velocity }
127
202
  }
128
203
 
129
204
  /** Element whose resize signals flow growth for the before-paint restore. */
@@ -131,198 +206,499 @@ function resizeProxyOf(port: HTMLElement): HTMLElement | null {
131
206
  return port.querySelector('[data-chat-transcript]') ?? port.querySelector('[data-chat-flow]')
132
207
  }
133
208
 
134
- /** True when the glide expresses its lag as transforms instead of engine top. */
135
- function hasShiftSurface(port: HTMLElement): boolean {
136
- return shiftRootOf(port) !== null || shiftRowsOf(port).length > 0
209
+ /** Outermost message surfaces; nested tool rows ride their parent. */
210
+ function shiftSurfacesOf(port: HTMLElement): HTMLElement[] {
211
+ const transcript = port.querySelector<HTMLElement>('[data-chat-transcript]')
212
+ if (transcript !== null) return [transcript]
213
+ return [...port.querySelectorAll<HTMLElement>('[data-chat-anchor-key]')]
214
+ .filter(row => row.parentElement?.closest('[data-chat-anchor-key]') === null)
215
+ }
216
+
217
+ function currentShiftOf(element: HTMLElement): number {
218
+ return Number(
219
+ /translate3d\(0(?:px)?,\s*(-?[\d.]+)px,\s*0(?:px)?\)/.exec(element.style.transform)?.[1] ?? 0,
220
+ )
137
221
  }
138
222
 
139
- function setShift(element: HTMLElement, px: number, negative = false): void {
140
- const value = negative ? -px : px
141
- if (Math.abs(value) > 0.01) {
142
- element.style.transform = `translate3d(0, ${value}px, 0)`
223
+ function setShift(element: HTMLElement, px: number): void {
224
+ if (Math.abs(px) > 0.01) {
225
+ if (
226
+ Math.abs(currentShiftOf(element) - px) <= 0.01
227
+ && element.style.willChange === 'transform'
228
+ && element.style.clipPath === ''
229
+ ) {
230
+ return
231
+ }
232
+ element.style.transform = `translate3d(0, ${px}px, 0)`
143
233
  element.style.willChange = 'transform'
144
234
  } else {
235
+ if (element.style.transform === '' && element.style.willChange === '' && element.style.clipPath === '') return
145
236
  element.style.transform = ''
146
237
  element.style.willChange = ''
147
238
  }
239
+ // Remove paint state left by v0.3.2 and earlier experimental builds.
240
+ element.style.clipPath = ''
241
+ }
242
+
243
+ function turnStatusOf(port: HTMLElement): HTMLElement | null {
244
+ return port.querySelector<HTMLElement>(
245
+ '[data-chat-turn-status], [data-chat-flow] > [role="status"]',
246
+ )
247
+ }
248
+
249
+ /** Height committed by one newly mounted Chat row, including its flex gap. */
250
+ function entranceExtentOf(root: HTMLElement): number {
251
+ const row = root.closest<HTMLElement>('[data-chat-flow-key]') ?? root
252
+ const rect = row.getBoundingClientRect()
253
+ const height = Math.max(0, rect.height, rect.bottom - rect.top, row.offsetHeight)
254
+ let previous = row.previousElementSibling
255
+ while (previous instanceof HTMLElement) {
256
+ const previousRect = previous.getBoundingClientRect()
257
+ if (previousRect.height > 0 || previousRect.bottom > previousRect.top) {
258
+ return Math.max(height, rect.bottom - previousRect.bottom)
259
+ }
260
+ previous = previous.previousElementSibling
261
+ }
262
+ return height
263
+ }
264
+
265
+ interface FollowRunway {
266
+ readonly element: HTMLElement
267
+ readonly offset: number
268
+ readonly original: string
269
+ readonly property: 'marginBottom' | 'marginTop'
270
+ }
271
+
272
+ const followRunways = new WeakMap<HTMLElement, FollowRunway>()
273
+
274
+ interface FollowPaintLimit {
275
+ readonly clientHeight: number
276
+ readonly limit: number
277
+ readonly measuredAtMs: number
278
+ readonly composer: HTMLElement | null
279
+ readonly status: HTMLElement | null
280
+ readonly surface: HTMLElement | undefined
148
281
  }
149
282
 
150
283
  /**
151
- * The running-turn label. Before the port scrolls (content shorter than the
152
- * viewport) the content-height lag has no `scrollTop` room to ride, so it is
153
- * expressed as a negative translate on this sibling: the label descends
154
- * smoothly with each wrap instead of hopping down a line at a time.
284
+ * A rect read can force a layout flush. At the floor the flow bottom sits on
285
+ * the scrollport bottom, so while content streams the paint limit (chrome top
286
+ * minus flow bottom) is constant; it only truly changes when the viewport or
287
+ * conversation chrome changes. A measured limit therefore stays trusted for
288
+ * this long even as contentHeight grows, so ordinary glyph frames never pay
289
+ * a forced layout.
155
290
  */
156
- function chromeShiftOf(port: HTMLElement): HTMLElement | null {
157
- return port.querySelector<HTMLElement>('[data-chat-turn-status]')
291
+ export const FOLLOW_PAINT_LIMIT_TTL_MS = 250
292
+
293
+ const followPaintLimits = new WeakMap<HTMLElement, FollowPaintLimit>()
294
+
295
+ function invalidatePaintLimit(port: HTMLElement): void {
296
+ followPaintLimits.delete(port)
297
+ }
298
+
299
+ interface FollowMotionState {
300
+ readonly capacityPx: number
301
+ readonly constrained: boolean
302
+ readonly extent: number
303
+ readonly lagPx: number
304
+ readonly reservePx: number
305
+ readonly velocityPxPerSec: number
306
+ }
307
+
308
+ /** Logical position and velocity survive a React owner handoff and finish. */
309
+ const followMotionStates = new WeakMap<HTMLElement, FollowMotionState>()
310
+
311
+ function restoreRunway(port: HTMLElement): void {
312
+ const runway = followRunways.get(port)
313
+ if (runway === undefined) return
314
+ runway.element.style[runway.property] = runway.original
315
+ followRunways.delete(port)
316
+ invalidatePaintLimit(port)
317
+ }
318
+
319
+ function ensureRunway(port: HTMLElement, surfaces: readonly HTMLElement[]): void {
320
+ const status = turnStatusOf(port)
321
+ const composer = port.querySelector<HTMLElement>('[data-composer-seat]')
322
+ const target = status === null
323
+ ? { element: composer === null ? undefined : surfaces.at(-1), property: 'marginBottom' as const }
324
+ : { element: status, property: 'marginTop' as const }
325
+ if (target.element === undefined) {
326
+ restoreRunway(port)
327
+ return
328
+ }
329
+ const element = target.element
330
+ const current = followRunways.get(port)
331
+ if (current?.element === element && current.property === target.property) return
332
+ restoreRunway(port)
333
+ const beforeHeight = port.scrollHeight
334
+ const original = element.style[target.property]
335
+ element.style[target.property] = original === ''
336
+ ? `${FOLLOW_STATUS_RUNWAY_PX}px`
337
+ : `calc(${original} + ${FOLLOW_STATUS_RUNWAY_PX}px)`
338
+ const offset = Math.max(0, port.scrollHeight - beforeHeight)
339
+ followRunways.set(port, { element, offset, property: target.property, original })
340
+ invalidatePaintLimit(port)
341
+ }
342
+
343
+ function runwayOffsetOf(port: HTMLElement): number {
344
+ return followRunways.get(port)?.offset ?? 0
345
+ }
346
+
347
+ /** Available paint room below the last message before fixed conversation chrome. */
348
+ function safeShiftLimit(
349
+ port: HTMLElement,
350
+ surfaces: readonly HTMLElement[],
351
+ ): number {
352
+ const last = surfaces.at(-1)
353
+ if (last === undefined) return 0
354
+ const status = turnStatusOf(port)
355
+ const composer = port.querySelector<HTMLElement>('[data-composer-seat]')
356
+ const cached = followPaintLimits.get(port)
357
+ // Content growth alone cannot move the limit (measured at the floor, the
358
+ // flow bottom rides the scrollport bottom), so the cache survives glyph
359
+ // frames and only chrome/viewport changes or the TTL force a re-measure.
360
+ if (
361
+ cached !== undefined
362
+ && performance.now() - cached.measuredAtMs <= FOLLOW_PAINT_LIMIT_TTL_MS
363
+ && cached.clientHeight === port.clientHeight
364
+ && cached.surface === last
365
+ && cached.status === status
366
+ && cached.composer === composer
367
+ ) return cached.limit
368
+ const ceiling = [status, composer]
369
+ .filter((element): element is HTMLElement => element !== null)
370
+ .map(element => ({ element, rect: element.getBoundingClientRect() }))
371
+ // A detached or still-unmeasured sticky seat returns an all-zero rect.
372
+ // It cannot constrain paint yet; a real seat at viewport top remains
373
+ // valid because its bottom is still below its top.
374
+ .filter(({ rect }) => Number.isFinite(rect.top) && Number.isFinite(rect.bottom) && rect.bottom > rect.top)
375
+ .sort((first, second) => first.rect.top - second.rect.top)[0]
376
+ if (ceiling === undefined) {
377
+ // No conversation chrome means there is nothing to overlap. If chrome is
378
+ // mounted but has not measured yet, permit only the runway zero-point
379
+ // until ResizeObserver provides a real ceiling.
380
+ return status === null && composer === null
381
+ ? Number.POSITIVE_INFINITY
382
+ : runwayOffsetOf(port)
383
+ }
384
+ const ceilingTop = ceiling.rect.top - currentShiftOf(ceiling.element)
385
+ const naturalBottom = last.getBoundingClientRect().bottom - currentShiftOf(last)
386
+ const limit = Math.max(0, ceilingTop - naturalBottom - FOLLOW_PAINT_GUARD_PX)
387
+ followPaintLimits.set(port, {
388
+ clientHeight: port.clientHeight,
389
+ limit,
390
+ measuredAtMs: performance.now(),
391
+ composer,
392
+ status,
393
+ surface: last,
394
+ })
395
+ return limit
396
+ }
397
+
398
+ function setFollowScrollTop(port: HTMLElement, nextTop: number): void {
399
+ if (Math.abs(port.scrollTop - nextTop) > 0.01) port.scrollTop = nextTop
400
+ followScrollLedgers.set(port, port.scrollTop)
401
+ const ownedTop = String(port.scrollTop)
402
+ if (port.getAttribute(FOLLOW_OWNED_ATTR) !== ownedTop) {
403
+ port.setAttribute(FOLLOW_OWNED_ATTR, ownedTop)
404
+ }
158
405
  }
159
406
 
160
407
  /**
161
- * Render the single smoothed extent `animatedH`. Before the port scrolls
162
- * (content shorter than the viewport) `animatedH` is clamped to the content
163
- * height and the content-height lag rides a negative translate on the
164
- * turn-status label; once the port has scroll room the engine is pinned at
165
- * the floor and the lag rides `[data-chat-transcript]` (or each message row
166
- * when the host has no transcript box) so the chrome stays put. Without any
167
- * shift surface the engine itself carries the interpolated top.
408
+ * Last scrollTop this engine wrote or accepted, per port. Reader intent is a
409
+ * real upward delta from this ledger; a key press or touch while pinned
410
+ * (typing in the composer) must not release the pin, because a released pin
411
+ * can never re-acquire while content streams away from the reader position.
168
412
  */
169
- function applyVisual(port: HTMLElement, animatedH: number): void {
413
+ const followScrollLedgers = new WeakMap<HTMLElement, number>()
414
+ const followActivityAt = new WeakMap<HTMLElement, number>()
415
+
416
+ /** Whether this port was owned recently enough to identify a closing tail row. */
417
+ export function hasRecentConversationFollow(port: HTMLElement, windowMs = 250): boolean {
418
+ const last = followActivityAt.get(port)
419
+ return last !== undefined && performance.now() - last <= windowMs
420
+ }
421
+
422
+ function readerScrolledUp(port: HTMLElement): boolean {
423
+ return port.scrollTop < (followScrollLedgers.get(port) ?? 0) - FOLLOW_UNPIN_GESTURE_PX
424
+ }
425
+
426
+ /**
427
+ * Paint a bounded visual lag and return the effective logical extent.
428
+ *
429
+ * This is the final geometry invariant, not merely an animation preference:
430
+ * any lag beyond the real gap to status/composer chrome is caught up in the
431
+ * same frame. Carrying that excess in `scrollTop` would move the transcript
432
+ * toward fixed chrome and also make the host expose jump-to-bottom.
433
+ */
434
+ function applyVisual(
435
+ port: HTMLElement,
436
+ animatedH: number,
437
+ reservePx: number,
438
+ velocityPxPerSec = 0,
439
+ ): number {
440
+ const surfaces = shiftSurfacesOf(port)
441
+ ensureRunway(port, surfaces)
170
442
  const contentHeight = Math.max(0, port.scrollHeight)
443
+ const runwayOffset = runwayOffsetOf(port)
444
+ const targetHeight = Math.max(0, contentHeight - runwayOffset)
171
445
  const floor = Math.max(0, contentHeight - port.clientHeight)
172
- const extent = Math.min(contentHeight, Math.max(0, animatedH))
173
- const lag = contentHeight - extent
174
- port.style.overflowAnchor = 'none'
175
- port.style.scrollBehavior = 'auto'
176
- const shiftRoot = shiftRootOf(port)
177
- if (shiftRoot !== null) {
178
- if (floor <= 0) {
179
- port.scrollTop = 0
180
- port.setAttribute(FOLLOW_OWNED_ATTR, String(port.scrollTop))
181
- const chrome = chromeShiftOf(port)
182
- if (chrome !== null) setShift(chrome, lag, true)
183
- setShift(shiftRoot, 0)
184
- return
185
- }
186
- port.scrollTop = floor
187
- port.setAttribute(FOLLOW_OWNED_ATTR, String(port.scrollTop))
188
- setShift(shiftRoot, lag)
189
- return
190
- }
191
- const rows = shiftRowsOf(port)
192
- if (rows.length > 0) {
193
- if (floor <= 0) {
194
- port.scrollTop = 0
195
- port.setAttribute(FOLLOW_OWNED_ATTR, String(port.scrollTop))
196
- const chrome = chromeShiftOf(port)
197
- if (chrome !== null) setShift(chrome, lag, true)
198
- for (const row of rows) setShift(row, 0)
199
- return
200
- }
201
- port.scrollTop = floor
202
- port.setAttribute(FOLLOW_OWNED_ATTR, String(port.scrollTop))
203
- for (const row of rows) setShift(row, lag)
204
- return
446
+ const extent = Math.min(targetHeight, Math.max(0, animatedH))
447
+ if (port.style.overflowAnchor !== 'none') port.style.overflowAnchor = 'none'
448
+ if (port.style.scrollBehavior !== 'auto') port.style.scrollBehavior = 'auto'
449
+ if (floor <= 0) {
450
+ setFollowScrollTop(port, 0)
451
+ followMotionStates.set(port, {
452
+ capacityPx: Number.POSITIVE_INFINITY,
453
+ constrained: false,
454
+ extent: targetHeight,
455
+ lagPx: 0,
456
+ reservePx: 0,
457
+ velocityPxPerSec: 0,
458
+ })
459
+ for (const surface of surfaces) setShift(surface, 0)
460
+ const status = turnStatusOf(port)
461
+ if (status !== null) setShift(status, 0)
462
+ return targetHeight
205
463
  }
206
- port.scrollTop = Math.min(floor, Math.max(0, extent))
207
- port.setAttribute(FOLLOW_OWNED_ATTR, String(port.scrollTop))
464
+ // Measure paint room at the real floor. This write and the final physical
465
+ // position land in the same animation frame, so only the latter is painted.
466
+ setFollowScrollTop(port, floor)
467
+ const limit = floor > 0 ? safeShiftLimit(port, surfaces) : 0
468
+ const visibleReserve = Math.min(runwayOffset, Math.max(0, reservePx))
469
+ const baselineShift = runwayOffset - visibleReserve
470
+ const requestedLag = Math.max(0, targetHeight - extent)
471
+ const shift = Math.min(baselineShift + requestedLag, Math.max(0, limit))
472
+ const effectiveLag = Math.max(0, shift - baselineShift)
473
+ const capacityPx = Math.max(0, limit - baselineShift)
474
+ const effectiveExtent = targetHeight - effectiveLag
475
+ followMotionStates.set(port, {
476
+ capacityPx,
477
+ constrained: requestedLag > effectiveLag + FOLLOW_SETTLE_EPSILON_PX,
478
+ extent: effectiveExtent,
479
+ lagPx: effectiveLag,
480
+ reservePx: visibleReserve,
481
+ velocityPxPerSec,
482
+ })
483
+ for (const surface of surfaces) setShift(surface, shift)
484
+ const status = turnStatusOf(port)
485
+ if (status !== null) setShift(status, 0)
486
+ return effectiveExtent
487
+ }
488
+
489
+ function clearMotion(port: HTMLElement): void {
490
+ port.removeAttribute(FOLLOW_OWNED_ATTR)
491
+ port.style.overflowAnchor = ''
492
+ port.style.scrollBehavior = ''
493
+ for (const surface of shiftSurfacesOf(port)) setShift(surface, 0)
494
+ const status = turnStatusOf(port)
495
+ if (status !== null) setShift(status, 0)
208
496
  }
209
497
 
210
498
  function clearVisual(port: HTMLElement): void {
499
+ clearMotion(port)
500
+ restoreRunway(port)
501
+ followMotionStates.delete(port)
502
+ invalidatePaintLimit(port)
503
+ }
504
+
505
+ /** Keep an already-promoted surface at zero until one stable final paint lands. */
506
+ function holdCompositorAtRest(element: HTMLElement): void {
507
+ element.style.transform = 'translate3d(0, 0px, 0)'
508
+ element.style.willChange = 'transform'
509
+ element.style.clipPath = ''
510
+ }
511
+
512
+ /** Remove equal offsets, land on the floor, then retire the compositor quietly. */
513
+ function finishAtNaturalFloor(port: HTMLElement): void {
514
+ const surfaces = shiftSurfacesOf(port)
515
+ const status = turnStatusOf(port)
516
+ const promoted = [...surfaces, ...(status === null ? [] : [status])]
517
+ .filter(element => element.style.transform !== '' || element.style.willChange === 'transform')
518
+ const promotedSet = new Set(promoted)
519
+ restoreRunway(port)
520
+ settleAtFloor(port)
211
521
  port.removeAttribute(FOLLOW_OWNED_ATTR)
212
522
  port.style.overflowAnchor = ''
213
523
  port.style.scrollBehavior = ''
214
- const shiftRoot = shiftRootOf(port)
215
- if (shiftRoot !== null) {
216
- shiftRoot.style.transform = ''
217
- shiftRoot.style.willChange = ''
218
- } else {
219
- for (const row of shiftRowsOf(port)) {
220
- row.style.transform = ''
221
- row.style.willChange = ''
222
- }
524
+ for (const surface of surfaces) {
525
+ if (promotedSet.has(surface)) holdCompositorAtRest(surface)
526
+ else setShift(surface, 0)
223
527
  }
224
- const chrome = chromeShiftOf(port)
225
- if (chrome !== null) {
226
- chrome.style.transform = ''
227
- chrome.style.willChange = ''
528
+ if (status !== null) {
529
+ if (promotedSet.has(status)) holdCompositorAtRest(status)
530
+ else setShift(status, 0)
228
531
  }
532
+ followMotionStates.delete(port)
533
+ if (promoted.length === 0) return
534
+ requestAnimationFrame(() => {
535
+ requestAnimationFrame(() => {
536
+ if (port.hasAttribute(FOLLOW_OWNED_ATTR)) return
537
+ for (const element of promoted) {
538
+ if (Math.abs(currentShiftOf(element)) <= 0.01) setShift(element, 0)
539
+ }
540
+ })
541
+ })
229
542
  }
230
543
 
231
- /** Live follow hosts per conversation port so one unmount does not clear another. */
232
- const followOwners = new WeakMap<HTMLElement, number>()
233
-
234
- function acquireFollow(port: HTMLElement): void {
235
- followOwners.set(port, (followOwners.get(port) ?? 0) + 1)
544
+ function settleAtFloor(port: HTMLElement): void {
545
+ const floor = Math.max(0, port.scrollHeight - port.clientHeight)
546
+ setFollowScrollTop(port, floor)
236
547
  }
237
548
 
238
- function releaseFollow(port: HTMLElement): number {
239
- const next = (followOwners.get(port) ?? 1) - 1
240
- if (next <= 0) {
241
- followOwners.delete(port)
242
- return 0
243
- }
244
- followOwners.set(port, next)
245
- return next
549
+ interface FollowLeader {
550
+ readonly generation: number
551
+ readonly owner: object
246
552
  }
247
553
 
554
+ /** Only the newest active follower may write one port's shared visual state. */
555
+ const followLeaders = new WeakMap<HTMLElement, FollowLeader>()
556
+ let followGeneration = 0
557
+
248
558
  /**
249
559
  * Own the conversation scrollport's bottom-follow while `active` is true.
250
560
  *
251
561
  * @param rootRef - An element inside the conversation scrollport.
252
562
  * @param active - True while the reply is still revealing.
253
563
  * @param speedCpsRef - Live reveal-rate EMA from the smoother.
564
+ * @param revealScaleRef - Optional backpressure control for text reveal.
565
+ * @param predictive - Whether to reserve paint room ahead of growth.
566
+ * @param entrance - Whether the first committed row height should glide in.
567
+ * @param onEntranceSettled - Releases a one-shot entrance owner after catch-up.
568
+ * @param predictiveRef - Optional live visibility gate for predictive runway.
569
+ * @param entranceExtentRef - Optional measured growth delta for a generic row.
254
570
  */
255
571
  export function useConversationFollow(
256
572
  rootRef: RefObject<HTMLElement | null>,
257
573
  active: boolean,
258
574
  speedCpsRef: { current: number },
575
+ revealScaleRef?: { current: number },
576
+ predictive = true,
577
+ entrance = false,
578
+ onEntranceSettled?: () => void,
579
+ predictiveRef?: { current: boolean },
580
+ entranceExtentRef?: { current: number | null },
259
581
  ): void {
260
582
  const activeRef = useRef(active)
583
+ const entranceRef = useRef(entrance)
584
+ const onEntranceSettledRef = useRef(onEntranceSettled)
585
+ entranceRef.current = entrance
586
+ onEntranceSettledRef.current = onEntranceSettled
261
587
  useEffect(() => {
262
588
  activeRef.current = active
263
589
  }, [active])
264
590
 
265
- useEffect(() => {
591
+ useLayoutEffect(() => {
266
592
  if (!active) return
593
+ const owner = {}
594
+ const generation = ++followGeneration
267
595
  let rafId = 0
268
596
  let last = performance.now()
269
597
  let following = true
270
598
  let primed = false
271
599
  let animatedH = 0
600
+ let reservePx = 0
601
+ let velocityPxPerSec = 0
272
602
  let interacting = false
603
+ let readerGestureIntent = false
604
+ let readerReleased = false
605
+ let touchStartY: number | null = null
273
606
  let interactTimer: ReturnType<typeof setTimeout> | null = null
274
607
  let port: HTMLElement | null = null
275
608
  let resize: ResizeObserver | null = null
276
609
  let holding: HTMLElement | null = null
277
- let awayPx = 0
610
+ let entrancePending = entranceRef.current
611
+
612
+ const finishEntrance = (): void => {
613
+ if (!entrancePending) return
614
+ entrancePending = false
615
+ onEntranceSettledRef.current?.()
616
+ }
617
+
618
+ const updateRevealScale = (next: HTMLElement, elapsedMs: number, urgent = false): void => {
619
+ if (revealScaleRef === undefined) return
620
+ const state = followMotionStates.get(next)
621
+ const target = state === undefined
622
+ ? 1
623
+ : computeFollowRevealScale(state.lagPx, state.capacityPx, state.constrained)
624
+ const current = Math.min(1, Math.max(FOLLOW_REVEAL_MIN_SCALE, revealScaleRef.current))
625
+ if (target < current || urgent) {
626
+ // Slowing affects only future glyph commits, so it can react at once
627
+ // without producing a visual discontinuity in the current frame.
628
+ revealScaleRef.current = Math.min(current, target)
629
+ return
630
+ }
631
+ const releaseStep = 1 - Math.exp(-Math.max(0, elapsedMs) / FOLLOW_BACKPRESSURE_RELEASE_MS)
632
+ revealScaleRef.current = current + (target - current) * releaseStep
633
+ }
634
+
635
+ const releaseRevealScale = (): void => {
636
+ if (revealScaleRef !== undefined) revealScaleRef.current = 1
637
+ }
638
+
639
+ const isLeader = (next: HTMLElement): boolean => followLeaders.get(next)?.owner === owner
278
640
 
279
641
  const hold = (next: HTMLElement): void => {
280
- if (holding === next) return
281
- if (holding !== null) releaseFollow(holding)
282
- acquireFollow(next)
642
+ followActivityAt.set(next, performance.now())
643
+ if (holding === next && isLeader(next)) return
283
644
  holding = next
645
+ const leader = followLeaders.get(next)
646
+ if (leader === undefined || generation > leader.generation) {
647
+ followLeaders.set(next, { generation, owner })
648
+ }
284
649
  }
285
650
 
286
651
  const drop = (next: HTMLElement): void => {
287
- if (holding === next) {
288
- releaseFollow(next)
289
- holding = null
652
+ if (holding === next) holding = null
653
+ if (isLeader(next)) {
654
+ clearMotion(next)
655
+ followMotionStates.delete(next)
656
+ releaseRevealScale()
290
657
  }
291
- clearVisual(next)
292
658
  }
293
659
 
294
- /**
295
- * Give the reader the visual position the glide was showing before the
296
- * transforms go away. While transforms carry the lag, the engine sits at
297
- * the floor and the effective visual top is `engine - lag`; writing that
298
- * keeps the handover frame-continuous, and the write shows up in
299
- * ChatView's ledger as reader movement, disarming its snap-follow.
300
- * Without a shift surface the engine already holds the interpolated top,
301
- * so there is nothing to compensate.
302
- */
303
660
  const handBackVisual = (next: HTMLElement): void => {
304
- if (!hasShiftSurface(next)) return
661
+ const shift = currentShiftOf(shiftSurfacesOf(next).at(-1) ?? next)
662
+ const visualTop = Math.max(0, next.scrollTop - Math.max(0, shift))
305
663
  const floor = Math.max(0, next.scrollHeight - next.clientHeight)
306
- if (floor <= 0) return
307
- const lag = Math.max(0, next.scrollHeight - animatedH)
308
- next.scrollTop = Math.min(floor, Math.max(0, next.scrollTop - lag))
664
+ // The host keeps its own bottom-follow bit while the reader remains in
665
+ // its 25px slack band. Land one pixel beyond that band so even a light
666
+ // wheel/trackpad/touch intent releases both owners on the same frame.
667
+ next.scrollTop = Math.min(visualTop, Math.max(0, floor - FOLLOW_HOST_RELEASE_PX))
668
+ followScrollLedgers.set(next, next.scrollTop)
309
669
  }
310
670
 
311
671
  const markGesture = (event: Event): void => {
312
672
  interacting = true
313
- if (event instanceof WheelEvent && event.deltaY < 0) awayPx += -event.deltaY
314
- else if (event.type === 'touchmove' || event.type === 'keydown') awayPx += FOLLOW_UNPIN_GESTURE_PX
673
+ if (event.type === 'wheel') {
674
+ const deltaY = (event as WheelEvent).deltaY
675
+ if (Number.isFinite(deltaY) && deltaY < 0) readerGestureIntent = true
676
+ } else if (event.type === 'touchstart') {
677
+ const touch = (event as TouchEvent).touches[0]
678
+ touchStartY = touch?.clientY ?? null
679
+ } else if (event.type === 'touchmove') {
680
+ const touch = (event as TouchEvent).touches[0]
681
+ if (touch !== undefined) {
682
+ if (touchStartY === null) touchStartY = touch.clientY
683
+ // A downward finger drag moves the transcript toward older content.
684
+ if (touch.clientY - touchStartY > 1) readerGestureIntent = true
685
+ }
686
+ } else if (event.type === 'touchend' || event.type === 'touchcancel') {
687
+ touchStartY = null
688
+ }
315
689
  if (interactTimer !== null) clearTimeout(interactTimer)
316
690
  interactTimer = setTimeout(() => {
317
691
  interacting = false
692
+ readerGestureIntent = false
318
693
  interactTimer = null
319
- awayPx = 0
320
694
  }, FOLLOW_GESTURE_MS)
321
695
  }
322
696
 
323
697
  const restoreBeforePaint = (): void => {
324
- if (!following || port === null) return
325
- applyVisual(port, animatedH)
698
+ if (!following || port === null || !isLeader(port)) return
699
+ invalidatePaintLimit(port)
700
+ animatedH = applyVisual(port, animatedH, reservePx, velocityPxPerSec)
701
+ updateRevealScale(port, 0, true)
326
702
  }
327
703
 
328
704
  const bindPort = (next: HTMLElement): void => {
@@ -332,6 +708,7 @@ export function useConversationFollow(
332
708
  resize?.disconnect()
333
709
  }
334
710
  port = next
711
+ invalidatePaintLimit(port)
335
712
  for (const name of GESTURE_EVENTS) {
336
713
  port.addEventListener(name, markGesture, { passive: true })
337
714
  }
@@ -345,63 +722,147 @@ export function useConversationFollow(
345
722
 
346
723
  const frame = (now: number) => {
347
724
  rafId = requestAnimationFrame(frame)
348
- const dt = Math.min(50, now - last)
725
+ // Spring time is clamped so one paint after a stall cannot teleport the
726
+ // transcript. Runway response uses real elapsed time, otherwise long
727
+ // frames would open paint room more slowly precisely when it is needed.
728
+ const elapsedMs = Math.max(0.001, now - last)
729
+ const dt = Math.min(FOLLOW_MAX_FRAME_MS, elapsedMs)
349
730
  last = now
350
731
  const root = rootRef.current
351
732
  if (root === null) return
352
733
  const nextPort = root.closest<HTMLElement>('[data-conversation-scroll]')
353
734
  if (nextPort === null) return
354
735
  bindPort(nextPort)
736
+ // A hidden/unmeasured port has no meaningful floor yet. Keep this owner
737
+ // unprimed and let the already-scheduled RAF initialize it after layout.
738
+ if (nextPort.clientHeight <= 0) return
355
739
 
356
740
  const floor = Math.max(0, nextPort.scrollHeight - nextPort.clientHeight)
357
741
  const reportedLag = floor - nextPort.scrollTop
358
- // Visual extent (content-height equivalent of the reader's scroll top):
359
- // content shorter than the viewport has no scrollTop to derive from, so
360
- // the extent is just the content height.
361
- const extent = Math.min(nextPort.scrollHeight, Math.max(0, nextPort.scrollHeight - reportedLag))
742
+ const extent = Math.min(
743
+ nextPort.scrollHeight,
744
+ Math.max(0, nextPort.scrollHeight - reportedLag),
745
+ )
362
746
 
363
747
  if (!primed) {
364
- animatedH = extent
365
- following = reportedLag <= FOLLOW_SLACK_PX
748
+ const inherited = nextPort.hasAttribute(FOLLOW_OWNED_ATTR)
749
+ ? followMotionStates.get(nextPort)
750
+ : undefined
751
+ if (inherited === undefined) {
752
+ // A new Agent row is already part of scrollHeight on its first
753
+ // frame. Start at the pre-insert extent so that initial Context and
754
+ // Tool chrome enters through the same spring as later height growth.
755
+ const entranceExtent = entrancePending
756
+ ? entranceExtentRef?.current ?? entranceExtentOf(root)
757
+ : 0
758
+ animatedH = Math.max(0, nextPort.scrollHeight - entranceExtent)
759
+ reservePx = 0
760
+ velocityPxPerSec = 0
761
+ // The committed row/growth delta has already moved the new floor.
762
+ // Decide ownership from the reader's position before that delta;
763
+ // otherwise any atomic result taller than FOLLOW_SLACK_PX looks
764
+ // indistinguishable from an intentional reader pull-up.
765
+ const lagBeforeEntrance = Math.max(0, reportedLag - entranceExtent)
766
+ following = lagBeforeEntrance <= FOLLOW_SLACK_PX
767
+ } else {
768
+ animatedH = Math.min(nextPort.scrollHeight, inherited.extent)
769
+ reservePx = inherited.reservePx
770
+ velocityPxPerSec = inherited.velocityPxPerSec
771
+ following = true
772
+ }
366
773
  if (following) {
367
774
  hold(nextPort)
368
- applyVisual(nextPort, animatedH)
775
+ if (isLeader(nextPort)) {
776
+ animatedH = applyVisual(nextPort, animatedH, reservePx, velocityPxPerSec)
777
+ updateRevealScale(nextPort, elapsedMs)
778
+ const runwayOffset = runwayOffsetOf(nextPort)
779
+ const entranceLag = Math.max(0, nextPort.scrollHeight - animatedH - runwayOffset)
780
+ if (entranceLag <= FOLLOW_SETTLE_EPSILON_PX) finishEntrance()
781
+ } else {
782
+ finishEntrance()
783
+ }
784
+ } else {
785
+ finishEntrance()
369
786
  }
370
787
  primed = true
371
788
  return
372
789
  }
373
790
 
374
- if (!following && !interacting && reportedLag <= FOLLOW_SLACK_PX) {
791
+ const repinSlack = readerReleased ? FOLLOW_REPIN_PX : FOLLOW_SLACK_PX
792
+ if (!following && !interacting && reportedLag <= repinSlack) {
375
793
  following = true
794
+ readerReleased = false
376
795
  animatedH = extent
796
+ reservePx = 0
797
+ velocityPxPerSec = 0
798
+ followScrollLedgers.set(nextPort, nextPort.scrollTop)
377
799
  hold(nextPort)
378
- } else if (following && interacting && awayPx >= FOLLOW_UNPIN_GESTURE_PX) {
800
+ } else if (following && interacting && (readerGestureIntent || readerScrolledUp(nextPort))) {
801
+ // Directional wheel/touch intent is authoritative even when automatic
802
+ // follow has already overwritten the small physical scroll delta.
379
803
  following = false
380
- awayPx = 0
381
- // Compensate before the transform clears, or the flow (turn-status
382
- // chrome included) jumps up by the whole lag in one frame.
804
+ readerGestureIntent = false
805
+ readerReleased = true
383
806
  handBackVisual(nextPort)
384
807
  animatedH = nextPort.scrollHeight
808
+ reservePx = 0
809
+ velocityPxPerSec = 0
385
810
  drop(nextPort)
811
+ finishEntrance()
386
812
  }
387
813
 
388
- if (!activeRef.current || !following) return
814
+ if (!activeRef.current || !following) {
815
+ followScrollLedgers.set(nextPort, nextPort.scrollTop)
816
+ return
817
+ }
389
818
  hold(nextPort)
819
+ if (!isLeader(nextPort)) {
820
+ finishEntrance()
821
+ return
822
+ }
390
823
 
391
- const lag = nextPort.scrollHeight - animatedH
824
+ // Runway and an equal transform cancel visually. It is the zero point,
825
+ // not residual motion: decaying below it would scroll past the final
826
+ // resting position and rebound when runway is removed.
827
+ const runwayOffset = runwayOffsetOf(nextPort)
828
+ const contentHeight = nextPort.scrollHeight
829
+ const lag = Math.max(0, contentHeight - animatedH - runwayOffset)
830
+ const predictGrowth = predictiveRef?.current ?? predictive
831
+ const reserveTarget = !predictGrowth
832
+ ? 0
833
+ : computeFollowReserve(speedCpsRef.current, runwayOffset)
834
+ const reserveStep = 1 - Math.exp(-elapsedMs / FOLLOW_RESERVE_RESPONSE_MS)
835
+ reservePx += (reserveTarget - reservePx) * reserveStep
392
836
  const step = computeFollowStep(dt, {
393
837
  lag,
394
838
  speedEma: speedCpsRef.current,
839
+ velocityPxPerSec,
395
840
  })
396
841
  if (lag <= 0.1) {
397
- animatedH = nextPort.scrollHeight
842
+ animatedH = contentHeight - runwayOffset
843
+ velocityPxPerSec = 0
398
844
  } else {
399
- animatedH = Math.min(nextPort.scrollHeight, animatedH + step.advancePx)
845
+ const minimumLag = predictGrowth ? 0 : Math.max(0, reservePx)
846
+ animatedH = Math.min(
847
+ contentHeight - runwayOffset - minimumLag,
848
+ animatedH + step.advancePx,
849
+ )
850
+ velocityPxPerSec = step.velocityPxPerSec
400
851
  }
401
- applyVisual(nextPort, animatedH)
852
+ animatedH = applyVisual(nextPort, animatedH, reservePx, velocityPxPerSec)
853
+ updateRevealScale(nextPort, elapsedMs)
854
+ const remainingEntranceLag = Math.max(
855
+ 0,
856
+ nextPort.scrollHeight - animatedH - runwayOffsetOf(nextPort),
857
+ )
858
+ if (remainingEntranceLag <= FOLLOW_SETTLE_EPSILON_PX) finishEntrance()
402
859
  }
403
860
 
404
- rafId = requestAnimationFrame(frame)
861
+ // Prime ownership and the final committed height in this layout phase.
862
+ // A producer-complete text arm can mount and drain before the next RAF;
863
+ // deferring this first pass would let it unmount unprimed after replacing
864
+ // the previous owner, leaving a large final append at the old scrollTop.
865
+ frame(performance.now())
405
866
  return () => {
406
867
  cancelAnimationFrame(rafId)
407
868
  if (interactTimer !== null) clearTimeout(interactTimer)
@@ -412,15 +873,104 @@ export function useConversationFollow(
412
873
  const root = rootRef.current
413
874
  const host = root?.closest<HTMLElement>('[data-conversation-scroll]') ?? port
414
875
  if (host === null) return
415
- if (following) {
416
- // Same handback as an unpin: land on the effective visual top rather
417
- // than the floor, so the owner change (stream close, unmount) does
418
- // not snap the flow up by the remaining lag.
876
+ holding = null
877
+ if (!isLeader(host)) return
878
+ const preserveReader = interacting && (readerGestureIntent || readerScrolledUp(host))
879
+ if (!following || !primed) {
880
+ clearVisual(host)
881
+ followLeaders.delete(host)
882
+ releaseRevealScale()
883
+ return
884
+ }
885
+ if (preserveReader) {
419
886
  handBackVisual(host)
887
+ clearVisual(host)
888
+ followLeaders.delete(host)
889
+ releaseRevealScale()
890
+ return
420
891
  }
421
- const remaining = holding === host ? releaseFollow(host) : (followOwners.get(host) ?? 0)
422
- holding = null
423
- if (remaining === 0) clearVisual(host)
892
+
893
+ // Completion can land the final Tool/command height in this same
894
+ // commit. Preserve the logical extent and drain it after unmount instead
895
+ // of clearing the compositor state before the first settled paint.
896
+ ensureRunway(host, shiftSurfacesOf(host))
897
+ const completionRunway = runwayOffsetOf(host)
898
+ const completionMinimumLag = Math.max(0, reservePx)
899
+ animatedH = Math.min(
900
+ animatedH,
901
+ host.scrollHeight - completionRunway - completionMinimumLag,
902
+ )
903
+ settleAtFloor(host)
904
+ animatedH = applyVisual(host, animatedH, reservePx, velocityPxPerSec)
905
+ const runwayOffset = runwayOffsetOf(host)
906
+ const remainingLag = Math.max(0, host.scrollHeight - animatedH - runwayOffset)
907
+ if (remainingLag <= FOLLOW_SETTLE_EPSILON_PX && reservePx <= FOLLOW_SETTLE_EPSILON_PX) {
908
+ finishAtNaturalFloor(host)
909
+ followLeaders.delete(host)
910
+ releaseRevealScale()
911
+ return
912
+ }
913
+
914
+ for (const name of GESTURE_EVENTS) {
915
+ host.addEventListener(name, markGesture, { passive: true })
916
+ }
917
+ const stopSettleListeners = (): void => {
918
+ for (const name of GESTURE_EVENTS) host.removeEventListener(name, markGesture)
919
+ if (interactTimer !== null) {
920
+ clearTimeout(interactTimer)
921
+ interactTimer = null
922
+ }
923
+ }
924
+ let settleLast = performance.now()
925
+ const settleFrame = (now: number): void => {
926
+ if (!isLeader(host)) {
927
+ stopSettleListeners()
928
+ return
929
+ }
930
+ if (interacting && (readerGestureIntent || readerScrolledUp(host))) {
931
+ readerGestureIntent = false
932
+ handBackVisual(host)
933
+ clearVisual(host)
934
+ followLeaders.delete(host)
935
+ releaseRevealScale()
936
+ stopSettleListeners()
937
+ return
938
+ }
939
+ const dt = Math.min(FOLLOW_MAX_FRAME_MS, Math.max(0, now - settleLast))
940
+ settleLast = now
941
+ const runwayOffset = runwayOffsetOf(host)
942
+ const lag = Math.max(0, host.scrollHeight - animatedH - runwayOffset)
943
+ const reserveStep = 1 - Math.exp(-dt / FOLLOW_RESERVE_RESPONSE_MS)
944
+ reservePx += (0 - reservePx) * reserveStep
945
+ if (lag <= FOLLOW_SETTLE_EPSILON_PX && reservePx <= FOLLOW_SETTLE_EPSILON_PX) {
946
+ animatedH = host.scrollHeight - runwayOffset
947
+ reservePx = 0
948
+ velocityPxPerSec = 0
949
+ finishAtNaturalFloor(host)
950
+ followLeaders.delete(host)
951
+ releaseRevealScale()
952
+ stopSettleListeners()
953
+ return
954
+ }
955
+ const step = computeFollowStep(dt, {
956
+ lag,
957
+ speedEma: speedCpsRef.current,
958
+ velocityPxPerSec,
959
+ })
960
+ // The temporary runway is visually neutral only while an equal lag
961
+ // remains in the transform. Do not let the spring outrun the runway's
962
+ // closing reserve or cleanup would reveal an overshoot and rebound.
963
+ const minimumLag = Math.max(0, reservePx)
964
+ animatedH = Math.min(
965
+ host.scrollHeight - runwayOffset - minimumLag,
966
+ animatedH + step.advancePx,
967
+ )
968
+ velocityPxPerSec = step.velocityPxPerSec
969
+ settleAtFloor(host)
970
+ animatedH = applyVisual(host, animatedH, reservePx, velocityPxPerSec)
971
+ requestAnimationFrame(settleFrame)
972
+ }
973
+ requestAnimationFrame(settleFrame)
424
974
  }
425
- }, [active, rootRef, speedCpsRef])
975
+ }, [active, rootRef, speedCpsRef, revealScaleRef, predictive, predictiveRef])
426
976
  }