dsh-smooth-stream 0.3.2 → 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,127 +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
- * - clips only the part of the translated surface that crosses the
18
- * turn-status line, preserving the normal gap above that line;
19
- * - leaves `[data-chat-turn-status]` in normal flow at every height, so its
20
- * layout gap cannot be consumed by an interpolated transform.
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
- * A real reader unpin first writes the effective visual top (`engine - lag`)
23
- * into `scrollTop` before clearing the transform, so the frame stays
24
- * continuous. Lifecycle completion is different: without a reader gesture,
25
- * the last owner settles at the floor and preserves bottom-follow.
21
+ * A real reader gesture receives the effective visual position before the
22
+ * transform clears. Lifecycle completion instead settles at the floor.
26
23
  *
27
- * Unpin is a real gesture (wheel / touch / pointer / key) that leaves the
28
- * 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.
29
27
  */
30
28
 
31
- import { useEffect, useRef, type RefObject } from 'react'
29
+ import { useEffect, useLayoutEffect, useRef, type RefObject } from 'react'
32
30
 
33
31
  /**
34
- * Matches ChatView's `FOLLOW_OWNED_ATTR`. The overlay writes the last
35
- * programmatic `scrollTop` here so ChatView yields snap-follow and does not
36
- * 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.
37
34
  */
38
35
  const FOLLOW_OWNED_ATTR = 'data-follow-owned'
39
36
 
40
- /** Demo `1 - exp(-dt / 18)` time constant, in ms. */
41
- 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
42
43
 
43
- /** Floor / ceiling of the per-frame lerp fraction before the dt term. */
44
- export const FOLLOW_LERP_MIN = 0.05
45
- 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
46
46
 
47
- /** Lag (px) at which the lag term of the lerp saturates. */
48
- 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
49
50
 
50
- /** Reveal cps at which the speed factor is 1. */
51
- export const FOLLOW_SPEED_REF_CPS = 35
51
+ /** Time constant for opening/closing predictive paint room. */
52
+ export const FOLLOW_RESERVE_RESPONSE_MS = 180
52
53
 
53
- export const FOLLOW_SPEED_FACTOR_MIN = 0.7
54
- 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
55
64
 
56
65
  /** Reader-return / still-pinned boundary, matching ChatView + the demo. */
57
66
  export const FOLLOW_SLACK_PX = 25
58
67
 
59
68
  /**
60
- * Upward wheel/touch distance that releases the pin. The engine `scrollTop`
61
- * is held on the floor while following, so a small trackpad tick never
62
- * 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.
63
72
  */
64
73
  export const FOLLOW_UNPIN_GESTURE_PX = 8
65
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
+
66
91
  /** How long a gesture keeps `isUserInteracting` so the next scroll can unpin. */
67
92
  export const FOLLOW_GESTURE_MS = 800
68
93
 
69
- 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
+ }
70
151
 
71
152
  export interface FollowGlideInput {
72
153
  /** How far the interpolated top trails the floor, in px. */
73
154
  readonly lag: number
74
- /** Observed reveal rate in chars/s; 0 uses {@link FOLLOW_SPEED_REF_CPS}. */
155
+ /** Observed reveal rate, retained for the public follow-step contract. */
75
156
  readonly speedEma: number
157
+ /** Physics velocity carried from the previous frame, in px/s. */
158
+ readonly velocityPxPerSec?: number
76
159
  }
77
160
 
78
161
  export interface FollowGlideStep {
79
- /** Pixels to advance `animatedH` this frame (fractional). */
162
+ /** Pixels to advance the floating content extent this frame. */
80
163
  readonly advancePx: number
81
164
  /** Applied lerp fraction, for tests. */
82
165
  readonly lerpStep: number
83
- }
84
-
85
- function clamp(value: number, min: number, max: number): number {
86
- return Math.min(max, Math.max(min, value))
166
+ /** Physics velocity to carry into the next frame, in px/s. */
167
+ readonly velocityPxPerSec: number
87
168
  }
88
169
 
89
170
  /**
90
- * One spring-lerp frame from the silky markdown demo.
171
+ * Semi-implicit spring integration with four substeps per <=32ms slice.
91
172
  * @param dtMs - Frame delta in ms.
92
- * @param input - Current lag (from the float top, not the rounded engine top) and reveal-speed EMA.
93
- * @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.
94
175
  */
95
176
  export function computeFollowStep(dtMs: number, input: FollowGlideInput): FollowGlideStep {
96
- if (input.lag <= 0.1 || dtMs <= 0) return { advancePx: 0, lerpStep: 0 }
97
- const speed = input.speedEma > 0 ? input.speedEma : FOLLOW_SPEED_REF_CPS
98
- const speedFactor = clamp(speed / FOLLOW_SPEED_REF_CPS, FOLLOW_SPEED_FACTOR_MIN, FOLLOW_SPEED_FACTOR_MAX)
99
- const baseLerp = clamp((input.lag / FOLLOW_LERP_LAG_REF_PX) * speedFactor, FOLLOW_LERP_MIN, FOLLOW_LERP_MAX)
100
- const lerpStep = baseLerp * (1 - Math.exp(-dtMs / FOLLOW_LERP_DT_MS))
101
- return { advancePx: input.lag * lerpStep, lerpStep }
102
- }
103
-
104
- /** The message-rows box the lag transform rides on, when the host has one. */
105
- function shiftRootOf(port: HTMLElement): HTMLElement | null {
106
- return port.querySelector('[data-chat-transcript]')
107
- }
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
+ }
108
199
 
109
- /**
110
- * Row wrappers that carry only messages/tools. Hosts without a transcript
111
- * box keep the turn-status chrome as a sibling of the rows inside
112
- * `[data-chat-flow]`, so shifting that whole flow would drag the chrome
113
- * along; shifting the rows individually leaves every non-message sibling
114
- * (turn status, steering bubbles) pinned while the text glides.
115
- *
116
- * Only the outermost rows are shifted: a tool call nests its subcalls as
117
- * descendant `[data-chat-anchor-key]` rows, and writing the lag on each of
118
- * them would double (or further multiply) the shift, tearing the subcalls
119
- * away from their parent every frame. Descendants ride the parent's
120
- * transform instead.
121
- */
122
- function shiftRowsOf(port: HTMLElement): HTMLElement[] {
123
- return [...port.querySelectorAll<HTMLElement>('[data-chat-anchor-key]')]
124
- .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 }
125
202
  }
126
203
 
127
204
  /** Element whose resize signals flow growth for the before-paint restore. */
@@ -129,151 +206,354 @@ function resizeProxyOf(port: HTMLElement): HTMLElement | null {
129
206
  return port.querySelector('[data-chat-transcript]') ?? port.querySelector('[data-chat-flow]')
130
207
  }
131
208
 
132
- /** True when the glide expresses its lag as transforms instead of engine top. */
133
- function hasShiftSurface(port: HTMLElement): boolean {
134
- 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)
135
215
  }
136
216
 
137
- /** Cached natural gap from a shifted surface's bottom to the status line. */
138
- const statusGapCache = new WeakMap<HTMLElement, { chrome: HTMLElement; px: number }>()
139
-
140
217
  function currentShiftOf(element: HTMLElement): number {
141
- return Number(/translate3d\(0, ([\d.]+)px, 0\)/.exec(element.style.transform)?.[1] ?? 0)
142
- }
143
-
144
- function statusGapBelow(surface: HTMLElement, chrome: HTMLElement): number {
145
- const cached = statusGapCache.get(surface)
146
- if (cached?.chrome === chrome) return cached.px
147
- // getBoundingClientRect includes our current transform. Remove it to recover
148
- // the layout bottom, then keep the existing gap available to the reveal.
149
- const naturalBottom = surface.getBoundingClientRect().bottom - currentShiftOf(surface)
150
- const px = Math.max(0, chrome.getBoundingClientRect().top - naturalBottom)
151
- statusGapCache.set(surface, { chrome, px })
152
- return px
218
+ return Number(
219
+ /translate3d\(0(?:px)?,\s*(-?[\d.]+)px,\s*0(?:px)?\)/.exec(element.style.transform)?.[1] ?? 0,
220
+ )
153
221
  }
154
222
 
155
- function setShift(element: HTMLElement, px: number, paintAllowance?: number): void {
223
+ function setShift(element: HTMLElement, px: number): void {
156
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
+ }
157
232
  element.style.transform = `translate3d(0, ${px}px, 0)`
158
233
  element.style.willChange = 'transform'
159
- const bottomClip = paintAllowance === undefined ? 0 : Math.max(0, px - paintAllowance)
160
- element.style.clipPath = bottomClip > 0.01 ? `inset(0 0 ${bottomClip}px 0)` : ''
161
234
  } else {
235
+ if (element.style.transform === '' && element.style.willChange === '' && element.style.clipPath === '') return
162
236
  element.style.transform = ''
163
237
  element.style.willChange = ''
164
- element.style.clipPath = ''
165
238
  }
239
+ // Remove paint state left by v0.3.2 and earlier experimental builds.
240
+ element.style.clipPath = ''
166
241
  }
167
242
 
168
- /**
169
- * The running-turn label. It stays in normal flow so the host column's gap
170
- * always separates it from the transcript.
171
- */
172
- function statusChromeOf(port: HTMLElement): HTMLElement | null {
243
+ function turnStatusOf(port: HTMLElement): HTMLElement | null {
173
244
  return port.querySelector<HTMLElement>(
174
245
  '[data-chat-turn-status], [data-chat-flow] > [role="status"]',
175
246
  )
176
247
  }
177
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
281
+ }
282
+
178
283
  /**
179
- * Render the single smoothed extent `animatedH`. Once the port has scroll
180
- * room the engine is pinned at the floor and the lag rides
181
- * `[data-chat-transcript]` (or each message row when the host has no
182
- * transcript box). Before that, message rows and status chrome remain in
183
- * normal flow. Without any shift surface the engine carries the interpolated
184
- * top itself.
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.
185
290
  */
186
- function applyVisual(port: HTMLElement, animatedH: number): void {
187
- const contentHeight = Math.max(0, port.scrollHeight)
188
- const floor = Math.max(0, contentHeight - port.clientHeight)
189
- const extent = Math.min(contentHeight, Math.max(0, animatedH))
190
- const lag = contentHeight - extent
191
- port.style.overflowAnchor = 'none'
192
- port.style.scrollBehavior = 'auto'
193
- const chrome = statusChromeOf(port)
194
- if (chrome !== null) setShift(chrome, 0)
195
- const shiftRoot = shiftRootOf(port)
196
- if (shiftRoot !== null) {
197
- if (floor <= 0) {
198
- port.scrollTop = 0
199
- port.setAttribute(FOLLOW_OWNED_ATTR, String(port.scrollTop))
200
- setShift(shiftRoot, 0)
201
- return
202
- }
203
- port.scrollTop = floor
204
- port.setAttribute(FOLLOW_OWNED_ATTR, String(port.scrollTop))
205
- setShift(shiftRoot, lag, chrome === null ? undefined : statusGapBelow(shiftRoot, chrome))
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)
206
327
  return
207
328
  }
208
- const rows = shiftRowsOf(port)
209
- if (rows.length > 0) {
210
- if (floor <= 0) {
211
- port.scrollTop = 0
212
- port.setAttribute(FOLLOW_OWNED_ATTR, String(port.scrollTop))
213
- for (const row of rows) setShift(row, 0)
214
- return
215
- }
216
- port.scrollTop = floor
217
- port.setAttribute(FOLLOW_OWNED_ATTR, String(port.scrollTop))
218
- const last = rows.length - 1
219
- for (const [index, row] of rows.entries()) {
220
- const allowance = index === last && chrome !== null ? statusGapBelow(row, chrome) : undefined
221
- setShift(row, lag, allowance)
222
- }
223
- return
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)
224
383
  }
225
- port.scrollTop = Math.min(floor, Math.max(0, extent))
226
- port.setAttribute(FOLLOW_OWNED_ATTR, String(port.scrollTop))
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
+ }
405
+ }
406
+
407
+ /**
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.
412
+ */
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)
442
+ const contentHeight = Math.max(0, port.scrollHeight)
443
+ const runwayOffset = runwayOffsetOf(port)
444
+ const targetHeight = Math.max(0, contentHeight - runwayOffset)
445
+ const floor = Math.max(0, contentHeight - port.clientHeight)
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
463
+ }
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)
227
496
  }
228
497
 
229
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)
230
521
  port.removeAttribute(FOLLOW_OWNED_ATTR)
231
522
  port.style.overflowAnchor = ''
232
523
  port.style.scrollBehavior = ''
233
- const shiftRoot = shiftRootOf(port)
234
- if (shiftRoot !== null) {
235
- statusGapCache.delete(shiftRoot)
236
- shiftRoot.style.transform = ''
237
- shiftRoot.style.willChange = ''
238
- shiftRoot.style.clipPath = ''
239
- } else {
240
- for (const row of shiftRowsOf(port)) {
241
- statusGapCache.delete(row)
242
- row.style.transform = ''
243
- row.style.willChange = ''
244
- row.style.clipPath = ''
245
- }
524
+ for (const surface of surfaces) {
525
+ if (promotedSet.has(surface)) holdCompositorAtRest(surface)
526
+ else setShift(surface, 0)
246
527
  }
247
- const chrome = statusChromeOf(port)
248
- if (chrome !== null) {
249
- chrome.style.transform = ''
250
- chrome.style.willChange = ''
251
- chrome.style.clipPath = ''
528
+ if (status !== null) {
529
+ if (promotedSet.has(status)) holdCompositorAtRest(status)
530
+ else setShift(status, 0)
252
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
+ })
253
542
  }
254
543
 
255
544
  function settleAtFloor(port: HTMLElement): void {
256
545
  const floor = Math.max(0, port.scrollHeight - port.clientHeight)
257
- port.scrollTop = floor
258
- port.setAttribute(FOLLOW_OWNED_ATTR, String(port.scrollTop))
546
+ setFollowScrollTop(port, floor)
259
547
  }
260
548
 
261
- /** Live follow hosts per conversation port so one unmount does not clear another. */
262
- const followOwners = new WeakMap<HTMLElement, number>()
263
-
264
- function acquireFollow(port: HTMLElement): void {
265
- followOwners.set(port, (followOwners.get(port) ?? 0) + 1)
549
+ interface FollowLeader {
550
+ readonly generation: number
551
+ readonly owner: object
266
552
  }
267
553
 
268
- function releaseFollow(port: HTMLElement): number {
269
- const next = (followOwners.get(port) ?? 1) - 1
270
- if (next <= 0) {
271
- followOwners.delete(port)
272
- return 0
273
- }
274
- followOwners.set(port, next)
275
- return next
276
- }
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
277
557
 
278
558
  /**
279
559
  * Own the conversation scrollport's bottom-follow while `active` is true.
@@ -281,78 +561,144 @@ function releaseFollow(port: HTMLElement): number {
281
561
  * @param rootRef - An element inside the conversation scrollport.
282
562
  * @param active - True while the reply is still revealing.
283
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.
284
570
  */
285
571
  export function useConversationFollow(
286
572
  rootRef: RefObject<HTMLElement | null>,
287
573
  active: boolean,
288
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 },
289
581
  ): void {
290
582
  const activeRef = useRef(active)
583
+ const entranceRef = useRef(entrance)
584
+ const onEntranceSettledRef = useRef(onEntranceSettled)
585
+ entranceRef.current = entrance
586
+ onEntranceSettledRef.current = onEntranceSettled
291
587
  useEffect(() => {
292
588
  activeRef.current = active
293
589
  }, [active])
294
590
 
295
- useEffect(() => {
591
+ useLayoutEffect(() => {
296
592
  if (!active) return
593
+ const owner = {}
594
+ const generation = ++followGeneration
297
595
  let rafId = 0
298
596
  let last = performance.now()
299
597
  let following = true
300
598
  let primed = false
301
599
  let animatedH = 0
600
+ let reservePx = 0
601
+ let velocityPxPerSec = 0
302
602
  let interacting = false
603
+ let readerGestureIntent = false
604
+ let readerReleased = false
605
+ let touchStartY: number | null = null
303
606
  let interactTimer: ReturnType<typeof setTimeout> | null = null
304
607
  let port: HTMLElement | null = null
305
608
  let resize: ResizeObserver | null = null
306
609
  let holding: HTMLElement | null = null
307
- 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
308
640
 
309
641
  const hold = (next: HTMLElement): void => {
310
- if (holding === next) return
311
- if (holding !== null) releaseFollow(holding)
312
- acquireFollow(next)
642
+ followActivityAt.set(next, performance.now())
643
+ if (holding === next && isLeader(next)) return
313
644
  holding = next
645
+ const leader = followLeaders.get(next)
646
+ if (leader === undefined || generation > leader.generation) {
647
+ followLeaders.set(next, { generation, owner })
648
+ }
314
649
  }
315
650
 
316
651
  const drop = (next: HTMLElement): void => {
317
- if (holding === next) {
318
- releaseFollow(next)
319
- holding = null
652
+ if (holding === next) holding = null
653
+ if (isLeader(next)) {
654
+ clearMotion(next)
655
+ followMotionStates.delete(next)
656
+ releaseRevealScale()
320
657
  }
321
- clearVisual(next)
322
658
  }
323
659
 
324
- /**
325
- * Give the reader the visual position the glide was showing before the
326
- * transforms go away. While transforms carry the lag, the engine sits at
327
- * the floor and the effective visual top is `engine - lag`; writing that
328
- * keeps the handover frame-continuous, and the write shows up in
329
- * ChatView's ledger as reader movement, disarming its snap-follow.
330
- * Without a shift surface the engine already holds the interpolated top,
331
- * so there is nothing to compensate.
332
- */
333
660
  const handBackVisual = (next: HTMLElement): void => {
334
- 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))
335
663
  const floor = Math.max(0, next.scrollHeight - next.clientHeight)
336
- if (floor <= 0) return
337
- const lag = Math.max(0, next.scrollHeight - animatedH)
338
- 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)
339
669
  }
340
670
 
341
671
  const markGesture = (event: Event): void => {
342
672
  interacting = true
343
- if (event instanceof WheelEvent && event.deltaY < 0) awayPx += -event.deltaY
344
- 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
+ }
345
689
  if (interactTimer !== null) clearTimeout(interactTimer)
346
690
  interactTimer = setTimeout(() => {
347
691
  interacting = false
692
+ readerGestureIntent = false
348
693
  interactTimer = null
349
- awayPx = 0
350
694
  }, FOLLOW_GESTURE_MS)
351
695
  }
352
696
 
353
697
  const restoreBeforePaint = (): void => {
354
- if (!following || port === null) return
355
- 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)
356
702
  }
357
703
 
358
704
  const bindPort = (next: HTMLElement): void => {
@@ -362,6 +708,7 @@ export function useConversationFollow(
362
708
  resize?.disconnect()
363
709
  }
364
710
  port = next
711
+ invalidatePaintLimit(port)
365
712
  for (const name of GESTURE_EVENTS) {
366
713
  port.addEventListener(name, markGesture, { passive: true })
367
714
  }
@@ -375,63 +722,147 @@ export function useConversationFollow(
375
722
 
376
723
  const frame = (now: number) => {
377
724
  rafId = requestAnimationFrame(frame)
378
- 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)
379
730
  last = now
380
731
  const root = rootRef.current
381
732
  if (root === null) return
382
733
  const nextPort = root.closest<HTMLElement>('[data-conversation-scroll]')
383
734
  if (nextPort === null) return
384
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
385
739
 
386
740
  const floor = Math.max(0, nextPort.scrollHeight - nextPort.clientHeight)
387
741
  const reportedLag = floor - nextPort.scrollTop
388
- // Visual extent (content-height equivalent of the reader's scroll top):
389
- // content shorter than the viewport has no scrollTop to derive from, so
390
- // the extent is just the content height.
391
- 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
+ )
392
746
 
393
747
  if (!primed) {
394
- animatedH = extent
395
- 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
+ }
396
773
  if (following) {
397
774
  hold(nextPort)
398
- 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()
399
786
  }
400
787
  primed = true
401
788
  return
402
789
  }
403
790
 
404
- if (!following && !interacting && reportedLag <= FOLLOW_SLACK_PX) {
791
+ const repinSlack = readerReleased ? FOLLOW_REPIN_PX : FOLLOW_SLACK_PX
792
+ if (!following && !interacting && reportedLag <= repinSlack) {
405
793
  following = true
794
+ readerReleased = false
406
795
  animatedH = extent
796
+ reservePx = 0
797
+ velocityPxPerSec = 0
798
+ followScrollLedgers.set(nextPort, nextPort.scrollTop)
407
799
  hold(nextPort)
408
- } 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.
409
803
  following = false
410
- awayPx = 0
411
- // Compensate before the transform clears, or the flow (turn-status
412
- // chrome included) jumps up by the whole lag in one frame.
804
+ readerGestureIntent = false
805
+ readerReleased = true
413
806
  handBackVisual(nextPort)
414
807
  animatedH = nextPort.scrollHeight
808
+ reservePx = 0
809
+ velocityPxPerSec = 0
415
810
  drop(nextPort)
811
+ finishEntrance()
416
812
  }
417
813
 
418
- if (!activeRef.current || !following) return
814
+ if (!activeRef.current || !following) {
815
+ followScrollLedgers.set(nextPort, nextPort.scrollTop)
816
+ return
817
+ }
419
818
  hold(nextPort)
819
+ if (!isLeader(nextPort)) {
820
+ finishEntrance()
821
+ return
822
+ }
420
823
 
421
- 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
422
836
  const step = computeFollowStep(dt, {
423
837
  lag,
424
838
  speedEma: speedCpsRef.current,
839
+ velocityPxPerSec,
425
840
  })
426
841
  if (lag <= 0.1) {
427
- animatedH = nextPort.scrollHeight
842
+ animatedH = contentHeight - runwayOffset
843
+ velocityPxPerSec = 0
428
844
  } else {
429
- 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
430
851
  }
431
- 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()
432
859
  }
433
860
 
434
- 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())
435
866
  return () => {
436
867
  cancelAnimationFrame(rafId)
437
868
  if (interactTimer !== null) clearTimeout(interactTimer)
@@ -442,15 +873,104 @@ export function useConversationFollow(
442
873
  const root = rootRef.current
443
874
  const host = root?.closest<HTMLElement>('[data-conversation-scroll]') ?? port
444
875
  if (host === null) return
445
- const remaining = holding === host ? releaseFollow(host) : (followOwners.get(host) ?? 0)
446
876
  holding = null
447
- if (remaining === 0) {
448
- if (following) {
449
- if (interacting && awayPx >= FOLLOW_UNPIN_GESTURE_PX) handBackVisual(host)
450
- else settleAtFloor(host)
451
- }
877
+ if (!isLeader(host)) return
878
+ const preserveReader = interacting && (readerGestureIntent || readerScrolledUp(host))
879
+ if (!following || !primed) {
452
880
  clearVisual(host)
881
+ followLeaders.delete(host)
882
+ releaseRevealScale()
883
+ return
884
+ }
885
+ if (preserveReader) {
886
+ handBackVisual(host)
887
+ clearVisual(host)
888
+ followLeaders.delete(host)
889
+ releaseRevealScale()
890
+ return
891
+ }
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)
453
972
  }
973
+ requestAnimationFrame(settleFrame)
454
974
  }
455
- }, [active, rootRef, speedCpsRef])
975
+ }, [active, rootRef, speedCpsRef, revealScaleRef, predictive, predictiveRef])
456
976
  }