dsh-smooth-stream 0.3.2 → 0.3.4

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
- }
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
103
185
 
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
- }
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,417 @@ 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
+
178
272
  /**
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.
273
+ * The plugin can be reinjected without replacing the conversation DOM. Keep
274
+ * runway ownership in the page realm so a fresh bundle adopts the existing
275
+ * margin instead of treating it as host layout and adding another 48px.
185
276
  */
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))
277
+ const FOLLOW_RUNWAYS_SYMBOL = Symbol.for('dsh-smooth-stream.follow-runways')
278
+ const followRunwayRegistry = globalThis as typeof globalThis & {
279
+ [key: symbol]: WeakMap<HTMLElement, FollowRunway> | undefined
280
+ }
281
+ const followRunways = followRunwayRegistry[FOLLOW_RUNWAYS_SYMBOL]
282
+ ?? new WeakMap<HTMLElement, FollowRunway>()
283
+ followRunwayRegistry[FOLLOW_RUNWAYS_SYMBOL] = followRunways
284
+
285
+ interface FollowPaintLimit {
286
+ readonly clientHeight: number
287
+ readonly limit: number
288
+ readonly measuredAtMs: number
289
+ readonly composer: HTMLElement | null
290
+ readonly status: HTMLElement | null
291
+ readonly surface: HTMLElement | undefined
292
+ }
293
+
294
+ /**
295
+ * A rect read can force a layout flush. At the floor the flow bottom sits on
296
+ * the scrollport bottom, so while content streams the paint limit (chrome top
297
+ * minus flow bottom) is constant; it only truly changes when the viewport or
298
+ * conversation chrome changes. A measured limit therefore stays trusted for
299
+ * this long even as contentHeight grows, so ordinary glyph frames never pay
300
+ * a forced layout.
301
+ */
302
+ export const FOLLOW_PAINT_LIMIT_TTL_MS = 250
303
+
304
+ const followPaintLimits = new WeakMap<HTMLElement, FollowPaintLimit>()
305
+
306
+ function invalidatePaintLimit(port: HTMLElement): void {
307
+ followPaintLimits.delete(port)
308
+ }
309
+
310
+ interface FollowMotionState {
311
+ readonly capacityPx: number
312
+ readonly constrained: boolean
313
+ readonly extent: number
314
+ readonly lagPx: number
315
+ readonly reservePx: number
316
+ readonly velocityPxPerSec: number
317
+ }
318
+
319
+ /** Logical position and velocity survive a React owner handoff and finish. */
320
+ const followMotionStates = new WeakMap<HTMLElement, FollowMotionState>()
321
+
322
+ function restoreRunway(port: HTMLElement): void {
323
+ const runway = followRunways.get(port)
324
+ if (runway === undefined) return
325
+ runway.element.style[runway.property] = runway.original
326
+ followRunways.delete(port)
327
+ invalidatePaintLimit(port)
328
+ }
329
+
330
+ function isLegacyRunway(value: string): boolean {
331
+ if (value === '') return false
332
+ const terms = [...value.matchAll(/([\d.]+)px/g)]
333
+ if (terms.length === 0 || value.replaceAll(/calc|px|[\d.+()\s]/g, '') !== '') return false
334
+ return terms.every(([, raw]) => {
335
+ const px = Number(raw)
336
+ return Number.isFinite(px)
337
+ && px >= FOLLOW_STATUS_RUNWAY_PX
338
+ && Math.abs(px % FOLLOW_STATUS_RUNWAY_PX) <= Number.EPSILON
339
+ })
340
+ }
341
+
342
+ /** Remove unowned runway residue written by v0.3.3 and earlier bundles. */
343
+ function migrateLegacyRunway(
344
+ port: HTMLElement,
345
+ surfaces: readonly HTMLElement[],
346
+ status: HTMLElement | null,
347
+ composer: HTMLElement | null,
348
+ ): void {
349
+ if (followRunways.has(port)) return
350
+ let migrated = false
351
+ if (status !== null && isLegacyRunway(status.style.marginTop)) {
352
+ // Harness TurnStatus has no inline margin; exact 48px multiples here are
353
+ // values emitted by the old runway writer, including reload accumulation.
354
+ status.style.marginTop = ''
355
+ migrated = true
356
+ }
357
+ const last = surfaces.at(-1)
358
+ if (
359
+ status === null
360
+ && composer !== null
361
+ && last !== undefined
362
+ && isLegacyRunway(last.style.marginBottom)
363
+ ) {
364
+ // Without TurnStatus the old writer used the current final message as its
365
+ // completion runway. Limit migration to that same target topology.
366
+ last.style.marginBottom = ''
367
+ migrated = true
368
+ }
369
+ if (migrated) invalidatePaintLimit(port)
370
+ }
371
+
372
+ function ensureRunway(port: HTMLElement, surfaces: readonly HTMLElement[]): void {
373
+ const status = turnStatusOf(port)
374
+ const composer = port.querySelector<HTMLElement>('[data-composer-seat]')
375
+ migrateLegacyRunway(port, surfaces, status, composer)
376
+ // A runway is useful only after the natural conversation already has a
377
+ // scroll floor for its equal message transform to ride. Before that point
378
+ // applyVisual keeps every surface in normal flow, so adding status margin
379
+ // would expose the whole runway as empty space below a short/early Think.
380
+ const naturalHeight = Math.max(0, port.scrollHeight - runwayOffsetOf(port))
381
+ if (port.clientHeight <= 0 || naturalHeight <= port.clientHeight) {
382
+ restoreRunway(port)
206
383
  return
207
384
  }
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
- }
385
+ const target = status === null
386
+ ? { element: composer === null ? undefined : surfaces.at(-1), property: 'marginBottom' as const }
387
+ : { element: status, property: 'marginTop' as const }
388
+ if (target.element === undefined) {
389
+ restoreRunway(port)
223
390
  return
224
391
  }
225
- port.scrollTop = Math.min(floor, Math.max(0, extent))
226
- port.setAttribute(FOLLOW_OWNED_ATTR, String(port.scrollTop))
392
+ const element = target.element
393
+ const current = followRunways.get(port)
394
+ if (current?.element === element && current.property === target.property) return
395
+ restoreRunway(port)
396
+ const beforeHeight = port.scrollHeight
397
+ const original = element.style[target.property]
398
+ element.style[target.property] = original === ''
399
+ ? `${FOLLOW_STATUS_RUNWAY_PX}px`
400
+ : `calc(${original} + ${FOLLOW_STATUS_RUNWAY_PX}px)`
401
+ const offset = Math.max(0, port.scrollHeight - beforeHeight)
402
+ followRunways.set(port, { element, offset, property: target.property, original })
403
+ invalidatePaintLimit(port)
404
+ }
405
+
406
+ function runwayOffsetOf(port: HTMLElement): number {
407
+ return followRunways.get(port)?.offset ?? 0
408
+ }
409
+
410
+ /** Available paint room below the last message before fixed conversation chrome. */
411
+ function safeShiftLimit(
412
+ port: HTMLElement,
413
+ surfaces: readonly HTMLElement[],
414
+ ): number {
415
+ const last = surfaces.at(-1)
416
+ if (last === undefined) return 0
417
+ const status = turnStatusOf(port)
418
+ const composer = port.querySelector<HTMLElement>('[data-composer-seat]')
419
+ const cached = followPaintLimits.get(port)
420
+ // Content growth alone cannot move the limit (measured at the floor, the
421
+ // flow bottom rides the scrollport bottom), so the cache survives glyph
422
+ // frames and only chrome/viewport changes or the TTL force a re-measure.
423
+ if (
424
+ cached !== undefined
425
+ && performance.now() - cached.measuredAtMs <= FOLLOW_PAINT_LIMIT_TTL_MS
426
+ && cached.clientHeight === port.clientHeight
427
+ && cached.surface === last
428
+ && cached.status === status
429
+ && cached.composer === composer
430
+ ) return cached.limit
431
+ const ceiling = [status, composer]
432
+ .filter((element): element is HTMLElement => element !== null)
433
+ .map(element => ({ element, rect: element.getBoundingClientRect() }))
434
+ // A detached or still-unmeasured sticky seat returns an all-zero rect.
435
+ // It cannot constrain paint yet; a real seat at viewport top remains
436
+ // valid because its bottom is still below its top.
437
+ .filter(({ rect }) => Number.isFinite(rect.top) && Number.isFinite(rect.bottom) && rect.bottom > rect.top)
438
+ .sort((first, second) => first.rect.top - second.rect.top)[0]
439
+ if (ceiling === undefined) {
440
+ // No conversation chrome means there is nothing to overlap. If chrome is
441
+ // mounted but has not measured yet, permit only the runway zero-point
442
+ // until ResizeObserver provides a real ceiling.
443
+ return status === null && composer === null
444
+ ? Number.POSITIVE_INFINITY
445
+ : runwayOffsetOf(port)
446
+ }
447
+ const ceilingTop = ceiling.rect.top - currentShiftOf(ceiling.element)
448
+ const naturalBottom = last.getBoundingClientRect().bottom - currentShiftOf(last)
449
+ const limit = Math.max(0, ceilingTop - naturalBottom - FOLLOW_PAINT_GUARD_PX)
450
+ followPaintLimits.set(port, {
451
+ clientHeight: port.clientHeight,
452
+ limit,
453
+ measuredAtMs: performance.now(),
454
+ composer,
455
+ status,
456
+ surface: last,
457
+ })
458
+ return limit
459
+ }
460
+
461
+ function setFollowScrollTop(port: HTMLElement, nextTop: number): void {
462
+ if (Math.abs(port.scrollTop - nextTop) > 0.01) port.scrollTop = nextTop
463
+ followScrollLedgers.set(port, port.scrollTop)
464
+ const ownedTop = String(port.scrollTop)
465
+ if (port.getAttribute(FOLLOW_OWNED_ATTR) !== ownedTop) {
466
+ port.setAttribute(FOLLOW_OWNED_ATTR, ownedTop)
467
+ }
468
+ }
469
+
470
+ /**
471
+ * Last scrollTop this engine wrote or accepted, per port. Reader intent is a
472
+ * real upward delta from this ledger; a key press or touch while pinned
473
+ * (typing in the composer) must not release the pin, because a released pin
474
+ * can never re-acquire while content streams away from the reader position.
475
+ */
476
+ const followScrollLedgers = new WeakMap<HTMLElement, number>()
477
+ const followActivityAt = new WeakMap<HTMLElement, number>()
478
+
479
+ /** Whether this port was owned recently enough to identify a closing tail row. */
480
+ export function hasRecentConversationFollow(port: HTMLElement, windowMs = 250): boolean {
481
+ const last = followActivityAt.get(port)
482
+ return last !== undefined && performance.now() - last <= windowMs
483
+ }
484
+
485
+ function readerScrolledUp(port: HTMLElement): boolean {
486
+ return port.scrollTop < (followScrollLedgers.get(port) ?? 0) - FOLLOW_UNPIN_GESTURE_PX
487
+ }
488
+
489
+ /**
490
+ * Paint a bounded visual lag and return the effective logical extent.
491
+ *
492
+ * This is the final geometry invariant, not merely an animation preference:
493
+ * any lag beyond the real gap to status/composer chrome is caught up in the
494
+ * same frame. Carrying that excess in `scrollTop` would move the transcript
495
+ * toward fixed chrome and also make the host expose jump-to-bottom.
496
+ */
497
+ function applyVisual(
498
+ port: HTMLElement,
499
+ animatedH: number,
500
+ reservePx: number,
501
+ velocityPxPerSec = 0,
502
+ ): number {
503
+ const surfaces = shiftSurfacesOf(port)
504
+ ensureRunway(port, surfaces)
505
+ const contentHeight = Math.max(0, port.scrollHeight)
506
+ const runwayOffset = runwayOffsetOf(port)
507
+ const targetHeight = Math.max(0, contentHeight - runwayOffset)
508
+ const floor = Math.max(0, contentHeight - port.clientHeight)
509
+ const extent = Math.min(targetHeight, Math.max(0, animatedH))
510
+ if (port.style.overflowAnchor !== 'none') port.style.overflowAnchor = 'none'
511
+ if (port.style.scrollBehavior !== 'auto') port.style.scrollBehavior = 'auto'
512
+ if (floor <= 0) {
513
+ setFollowScrollTop(port, 0)
514
+ followMotionStates.set(port, {
515
+ capacityPx: Number.POSITIVE_INFINITY,
516
+ constrained: false,
517
+ extent: targetHeight,
518
+ lagPx: 0,
519
+ reservePx: 0,
520
+ velocityPxPerSec: 0,
521
+ })
522
+ for (const surface of surfaces) setShift(surface, 0)
523
+ const status = turnStatusOf(port)
524
+ if (status !== null) setShift(status, 0)
525
+ return targetHeight
526
+ }
527
+ // Measure paint room at the real floor. This write and the final physical
528
+ // position land in the same animation frame, so only the latter is painted.
529
+ setFollowScrollTop(port, floor)
530
+ const limit = floor > 0 ? safeShiftLimit(port, surfaces) : 0
531
+ const visibleReserve = Math.min(runwayOffset, Math.max(0, reservePx))
532
+ const baselineShift = runwayOffset - visibleReserve
533
+ const requestedLag = Math.max(0, targetHeight - extent)
534
+ const shift = Math.min(baselineShift + requestedLag, Math.max(0, limit))
535
+ const effectiveLag = Math.max(0, shift - baselineShift)
536
+ const capacityPx = Math.max(0, limit - baselineShift)
537
+ const effectiveExtent = targetHeight - effectiveLag
538
+ followMotionStates.set(port, {
539
+ capacityPx,
540
+ constrained: requestedLag > effectiveLag + FOLLOW_SETTLE_EPSILON_PX,
541
+ extent: effectiveExtent,
542
+ lagPx: effectiveLag,
543
+ reservePx: visibleReserve,
544
+ velocityPxPerSec,
545
+ })
546
+ for (const surface of surfaces) setShift(surface, shift)
547
+ const status = turnStatusOf(port)
548
+ if (status !== null) setShift(status, 0)
549
+ return effectiveExtent
550
+ }
551
+
552
+ function clearMotion(port: HTMLElement): void {
553
+ port.removeAttribute(FOLLOW_OWNED_ATTR)
554
+ port.style.overflowAnchor = ''
555
+ port.style.scrollBehavior = ''
556
+ for (const surface of shiftSurfacesOf(port)) setShift(surface, 0)
557
+ const status = turnStatusOf(port)
558
+ if (status !== null) setShift(status, 0)
227
559
  }
228
560
 
229
561
  function clearVisual(port: HTMLElement): void {
562
+ clearMotion(port)
563
+ restoreRunway(port)
564
+ followMotionStates.delete(port)
565
+ invalidatePaintLimit(port)
566
+ }
567
+
568
+ /** Keep an already-promoted surface at zero until one stable final paint lands. */
569
+ function holdCompositorAtRest(element: HTMLElement): void {
570
+ element.style.transform = 'translate3d(0, 0px, 0)'
571
+ element.style.willChange = 'transform'
572
+ element.style.clipPath = ''
573
+ }
574
+
575
+ /** Remove equal offsets, land on the floor, then retire the compositor quietly. */
576
+ function finishAtNaturalFloor(port: HTMLElement): void {
577
+ const surfaces = shiftSurfacesOf(port)
578
+ const status = turnStatusOf(port)
579
+ const promoted = [...surfaces, ...(status === null ? [] : [status])]
580
+ .filter(element => element.style.transform !== '' || element.style.willChange === 'transform')
581
+ const promotedSet = new Set(promoted)
582
+ restoreRunway(port)
583
+ settleAtFloor(port)
230
584
  port.removeAttribute(FOLLOW_OWNED_ATTR)
231
585
  port.style.overflowAnchor = ''
232
586
  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
- }
587
+ for (const surface of surfaces) {
588
+ if (promotedSet.has(surface)) holdCompositorAtRest(surface)
589
+ else setShift(surface, 0)
246
590
  }
247
- const chrome = statusChromeOf(port)
248
- if (chrome !== null) {
249
- chrome.style.transform = ''
250
- chrome.style.willChange = ''
251
- chrome.style.clipPath = ''
591
+ if (status !== null) {
592
+ if (promotedSet.has(status)) holdCompositorAtRest(status)
593
+ else setShift(status, 0)
252
594
  }
595
+ followMotionStates.delete(port)
596
+ if (promoted.length === 0) return
597
+ requestAnimationFrame(() => {
598
+ requestAnimationFrame(() => {
599
+ if (port.hasAttribute(FOLLOW_OWNED_ATTR)) return
600
+ for (const element of promoted) {
601
+ if (Math.abs(currentShiftOf(element)) <= 0.01) setShift(element, 0)
602
+ }
603
+ })
604
+ })
253
605
  }
254
606
 
255
607
  function settleAtFloor(port: HTMLElement): void {
256
608
  const floor = Math.max(0, port.scrollHeight - port.clientHeight)
257
- port.scrollTop = floor
258
- port.setAttribute(FOLLOW_OWNED_ATTR, String(port.scrollTop))
609
+ setFollowScrollTop(port, floor)
259
610
  }
260
611
 
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)
612
+ interface FollowLeader {
613
+ readonly generation: number
614
+ readonly owner: object
266
615
  }
267
616
 
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
- }
617
+ /** Only the newest active follower may write one port's shared visual state. */
618
+ const followLeaders = new WeakMap<HTMLElement, FollowLeader>()
619
+ let followGeneration = 0
277
620
 
278
621
  /**
279
622
  * Own the conversation scrollport's bottom-follow while `active` is true.
@@ -281,78 +624,144 @@ function releaseFollow(port: HTMLElement): number {
281
624
  * @param rootRef - An element inside the conversation scrollport.
282
625
  * @param active - True while the reply is still revealing.
283
626
  * @param speedCpsRef - Live reveal-rate EMA from the smoother.
627
+ * @param revealScaleRef - Optional backpressure control for text reveal.
628
+ * @param predictive - Whether to reserve paint room ahead of growth.
629
+ * @param entrance - Whether the first committed row height should glide in.
630
+ * @param onEntranceSettled - Releases a one-shot entrance owner after catch-up.
631
+ * @param predictiveRef - Optional live visibility gate for predictive runway.
632
+ * @param entranceExtentRef - Optional measured growth delta for a generic row.
284
633
  */
285
634
  export function useConversationFollow(
286
635
  rootRef: RefObject<HTMLElement | null>,
287
636
  active: boolean,
288
637
  speedCpsRef: { current: number },
638
+ revealScaleRef?: { current: number },
639
+ predictive = true,
640
+ entrance = false,
641
+ onEntranceSettled?: () => void,
642
+ predictiveRef?: { current: boolean },
643
+ entranceExtentRef?: { current: number | null },
289
644
  ): void {
290
645
  const activeRef = useRef(active)
646
+ const entranceRef = useRef(entrance)
647
+ const onEntranceSettledRef = useRef(onEntranceSettled)
648
+ entranceRef.current = entrance
649
+ onEntranceSettledRef.current = onEntranceSettled
291
650
  useEffect(() => {
292
651
  activeRef.current = active
293
652
  }, [active])
294
653
 
295
- useEffect(() => {
654
+ useLayoutEffect(() => {
296
655
  if (!active) return
656
+ const owner = {}
657
+ const generation = ++followGeneration
297
658
  let rafId = 0
298
659
  let last = performance.now()
299
660
  let following = true
300
661
  let primed = false
301
662
  let animatedH = 0
663
+ let reservePx = 0
664
+ let velocityPxPerSec = 0
302
665
  let interacting = false
666
+ let readerGestureIntent = false
667
+ let readerReleased = false
668
+ let touchStartY: number | null = null
303
669
  let interactTimer: ReturnType<typeof setTimeout> | null = null
304
670
  let port: HTMLElement | null = null
305
671
  let resize: ResizeObserver | null = null
306
672
  let holding: HTMLElement | null = null
307
- let awayPx = 0
673
+ let entrancePending = entranceRef.current
674
+
675
+ const finishEntrance = (): void => {
676
+ if (!entrancePending) return
677
+ entrancePending = false
678
+ onEntranceSettledRef.current?.()
679
+ }
680
+
681
+ const updateRevealScale = (next: HTMLElement, elapsedMs: number, urgent = false): void => {
682
+ if (revealScaleRef === undefined) return
683
+ const state = followMotionStates.get(next)
684
+ const target = state === undefined
685
+ ? 1
686
+ : computeFollowRevealScale(state.lagPx, state.capacityPx, state.constrained)
687
+ const current = Math.min(1, Math.max(FOLLOW_REVEAL_MIN_SCALE, revealScaleRef.current))
688
+ if (target < current || urgent) {
689
+ // Slowing affects only future glyph commits, so it can react at once
690
+ // without producing a visual discontinuity in the current frame.
691
+ revealScaleRef.current = Math.min(current, target)
692
+ return
693
+ }
694
+ const releaseStep = 1 - Math.exp(-Math.max(0, elapsedMs) / FOLLOW_BACKPRESSURE_RELEASE_MS)
695
+ revealScaleRef.current = current + (target - current) * releaseStep
696
+ }
697
+
698
+ const releaseRevealScale = (): void => {
699
+ if (revealScaleRef !== undefined) revealScaleRef.current = 1
700
+ }
701
+
702
+ const isLeader = (next: HTMLElement): boolean => followLeaders.get(next)?.owner === owner
308
703
 
309
704
  const hold = (next: HTMLElement): void => {
310
- if (holding === next) return
311
- if (holding !== null) releaseFollow(holding)
312
- acquireFollow(next)
705
+ followActivityAt.set(next, performance.now())
706
+ if (holding === next && isLeader(next)) return
313
707
  holding = next
708
+ const leader = followLeaders.get(next)
709
+ if (leader === undefined || generation > leader.generation) {
710
+ followLeaders.set(next, { generation, owner })
711
+ }
314
712
  }
315
713
 
316
714
  const drop = (next: HTMLElement): void => {
317
- if (holding === next) {
318
- releaseFollow(next)
319
- holding = null
715
+ if (holding === next) holding = null
716
+ if (isLeader(next)) {
717
+ clearMotion(next)
718
+ followMotionStates.delete(next)
719
+ releaseRevealScale()
320
720
  }
321
- clearVisual(next)
322
721
  }
323
722
 
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
723
  const handBackVisual = (next: HTMLElement): void => {
334
- if (!hasShiftSurface(next)) return
724
+ const shift = currentShiftOf(shiftSurfacesOf(next).at(-1) ?? next)
725
+ const visualTop = Math.max(0, next.scrollTop - Math.max(0, shift))
335
726
  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))
727
+ // The host keeps its own bottom-follow bit while the reader remains in
728
+ // its 25px slack band. Land one pixel beyond that band so even a light
729
+ // wheel/trackpad/touch intent releases both owners on the same frame.
730
+ next.scrollTop = Math.min(visualTop, Math.max(0, floor - FOLLOW_HOST_RELEASE_PX))
731
+ followScrollLedgers.set(next, next.scrollTop)
339
732
  }
340
733
 
341
734
  const markGesture = (event: Event): void => {
342
735
  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
736
+ if (event.type === 'wheel') {
737
+ const deltaY = (event as WheelEvent).deltaY
738
+ if (Number.isFinite(deltaY) && deltaY < 0) readerGestureIntent = true
739
+ } else if (event.type === 'touchstart') {
740
+ const touch = (event as TouchEvent).touches[0]
741
+ touchStartY = touch?.clientY ?? null
742
+ } else if (event.type === 'touchmove') {
743
+ const touch = (event as TouchEvent).touches[0]
744
+ if (touch !== undefined) {
745
+ if (touchStartY === null) touchStartY = touch.clientY
746
+ // A downward finger drag moves the transcript toward older content.
747
+ if (touch.clientY - touchStartY > 1) readerGestureIntent = true
748
+ }
749
+ } else if (event.type === 'touchend' || event.type === 'touchcancel') {
750
+ touchStartY = null
751
+ }
345
752
  if (interactTimer !== null) clearTimeout(interactTimer)
346
753
  interactTimer = setTimeout(() => {
347
754
  interacting = false
755
+ readerGestureIntent = false
348
756
  interactTimer = null
349
- awayPx = 0
350
757
  }, FOLLOW_GESTURE_MS)
351
758
  }
352
759
 
353
760
  const restoreBeforePaint = (): void => {
354
- if (!following || port === null) return
355
- applyVisual(port, animatedH)
761
+ if (!following || port === null || !isLeader(port)) return
762
+ invalidatePaintLimit(port)
763
+ animatedH = applyVisual(port, animatedH, reservePx, velocityPxPerSec)
764
+ updateRevealScale(port, 0, true)
356
765
  }
357
766
 
358
767
  const bindPort = (next: HTMLElement): void => {
@@ -362,6 +771,7 @@ export function useConversationFollow(
362
771
  resize?.disconnect()
363
772
  }
364
773
  port = next
774
+ invalidatePaintLimit(port)
365
775
  for (const name of GESTURE_EVENTS) {
366
776
  port.addEventListener(name, markGesture, { passive: true })
367
777
  }
@@ -375,63 +785,147 @@ export function useConversationFollow(
375
785
 
376
786
  const frame = (now: number) => {
377
787
  rafId = requestAnimationFrame(frame)
378
- const dt = Math.min(50, now - last)
788
+ // Spring time is clamped so one paint after a stall cannot teleport the
789
+ // transcript. Runway response uses real elapsed time, otherwise long
790
+ // frames would open paint room more slowly precisely when it is needed.
791
+ const elapsedMs = Math.max(0.001, now - last)
792
+ const dt = Math.min(FOLLOW_MAX_FRAME_MS, elapsedMs)
379
793
  last = now
380
794
  const root = rootRef.current
381
795
  if (root === null) return
382
796
  const nextPort = root.closest<HTMLElement>('[data-conversation-scroll]')
383
797
  if (nextPort === null) return
384
798
  bindPort(nextPort)
799
+ // A hidden/unmeasured port has no meaningful floor yet. Keep this owner
800
+ // unprimed and let the already-scheduled RAF initialize it after layout.
801
+ if (nextPort.clientHeight <= 0) return
385
802
 
386
803
  const floor = Math.max(0, nextPort.scrollHeight - nextPort.clientHeight)
387
804
  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))
805
+ const extent = Math.min(
806
+ nextPort.scrollHeight,
807
+ Math.max(0, nextPort.scrollHeight - reportedLag),
808
+ )
392
809
 
393
810
  if (!primed) {
394
- animatedH = extent
395
- following = reportedLag <= FOLLOW_SLACK_PX
811
+ const inherited = nextPort.hasAttribute(FOLLOW_OWNED_ATTR)
812
+ ? followMotionStates.get(nextPort)
813
+ : undefined
814
+ if (inherited === undefined) {
815
+ // A new Agent row is already part of scrollHeight on its first
816
+ // frame. Start at the pre-insert extent so that initial Context and
817
+ // Tool chrome enters through the same spring as later height growth.
818
+ const entranceExtent = entrancePending
819
+ ? entranceExtentRef?.current ?? entranceExtentOf(root)
820
+ : 0
821
+ animatedH = Math.max(0, nextPort.scrollHeight - entranceExtent)
822
+ reservePx = 0
823
+ velocityPxPerSec = 0
824
+ // The committed row/growth delta has already moved the new floor.
825
+ // Decide ownership from the reader's position before that delta;
826
+ // otherwise any atomic result taller than FOLLOW_SLACK_PX looks
827
+ // indistinguishable from an intentional reader pull-up.
828
+ const lagBeforeEntrance = Math.max(0, reportedLag - entranceExtent)
829
+ following = lagBeforeEntrance <= FOLLOW_SLACK_PX
830
+ } else {
831
+ animatedH = Math.min(nextPort.scrollHeight, inherited.extent)
832
+ reservePx = inherited.reservePx
833
+ velocityPxPerSec = inherited.velocityPxPerSec
834
+ following = true
835
+ }
396
836
  if (following) {
397
837
  hold(nextPort)
398
- applyVisual(nextPort, animatedH)
838
+ if (isLeader(nextPort)) {
839
+ animatedH = applyVisual(nextPort, animatedH, reservePx, velocityPxPerSec)
840
+ updateRevealScale(nextPort, elapsedMs)
841
+ const runwayOffset = runwayOffsetOf(nextPort)
842
+ const entranceLag = Math.max(0, nextPort.scrollHeight - animatedH - runwayOffset)
843
+ if (entranceLag <= FOLLOW_SETTLE_EPSILON_PX) finishEntrance()
844
+ } else {
845
+ finishEntrance()
846
+ }
847
+ } else {
848
+ finishEntrance()
399
849
  }
400
850
  primed = true
401
851
  return
402
852
  }
403
853
 
404
- if (!following && !interacting && reportedLag <= FOLLOW_SLACK_PX) {
854
+ const repinSlack = readerReleased ? FOLLOW_REPIN_PX : FOLLOW_SLACK_PX
855
+ if (!following && !interacting && reportedLag <= repinSlack) {
405
856
  following = true
857
+ readerReleased = false
406
858
  animatedH = extent
859
+ reservePx = 0
860
+ velocityPxPerSec = 0
861
+ followScrollLedgers.set(nextPort, nextPort.scrollTop)
407
862
  hold(nextPort)
408
- } else if (following && interacting && awayPx >= FOLLOW_UNPIN_GESTURE_PX) {
863
+ } else if (following && interacting && (readerGestureIntent || readerScrolledUp(nextPort))) {
864
+ // Directional wheel/touch intent is authoritative even when automatic
865
+ // follow has already overwritten the small physical scroll delta.
409
866
  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.
867
+ readerGestureIntent = false
868
+ readerReleased = true
413
869
  handBackVisual(nextPort)
414
870
  animatedH = nextPort.scrollHeight
871
+ reservePx = 0
872
+ velocityPxPerSec = 0
415
873
  drop(nextPort)
874
+ finishEntrance()
416
875
  }
417
876
 
418
- if (!activeRef.current || !following) return
877
+ if (!activeRef.current || !following) {
878
+ followScrollLedgers.set(nextPort, nextPort.scrollTop)
879
+ return
880
+ }
419
881
  hold(nextPort)
882
+ if (!isLeader(nextPort)) {
883
+ finishEntrance()
884
+ return
885
+ }
420
886
 
421
- const lag = nextPort.scrollHeight - animatedH
887
+ // Runway and an equal transform cancel visually. It is the zero point,
888
+ // not residual motion: decaying below it would scroll past the final
889
+ // resting position and rebound when runway is removed.
890
+ const runwayOffset = runwayOffsetOf(nextPort)
891
+ const contentHeight = nextPort.scrollHeight
892
+ const lag = Math.max(0, contentHeight - animatedH - runwayOffset)
893
+ const predictGrowth = predictiveRef?.current ?? predictive
894
+ const reserveTarget = !predictGrowth
895
+ ? 0
896
+ : computeFollowReserve(speedCpsRef.current, runwayOffset)
897
+ const reserveStep = 1 - Math.exp(-elapsedMs / FOLLOW_RESERVE_RESPONSE_MS)
898
+ reservePx += (reserveTarget - reservePx) * reserveStep
422
899
  const step = computeFollowStep(dt, {
423
900
  lag,
424
901
  speedEma: speedCpsRef.current,
902
+ velocityPxPerSec,
425
903
  })
426
904
  if (lag <= 0.1) {
427
- animatedH = nextPort.scrollHeight
905
+ animatedH = contentHeight - runwayOffset
906
+ velocityPxPerSec = 0
428
907
  } else {
429
- animatedH = Math.min(nextPort.scrollHeight, animatedH + step.advancePx)
908
+ const minimumLag = predictGrowth ? 0 : Math.max(0, reservePx)
909
+ animatedH = Math.min(
910
+ contentHeight - runwayOffset - minimumLag,
911
+ animatedH + step.advancePx,
912
+ )
913
+ velocityPxPerSec = step.velocityPxPerSec
430
914
  }
431
- applyVisual(nextPort, animatedH)
915
+ animatedH = applyVisual(nextPort, animatedH, reservePx, velocityPxPerSec)
916
+ updateRevealScale(nextPort, elapsedMs)
917
+ const remainingEntranceLag = Math.max(
918
+ 0,
919
+ nextPort.scrollHeight - animatedH - runwayOffsetOf(nextPort),
920
+ )
921
+ if (remainingEntranceLag <= FOLLOW_SETTLE_EPSILON_PX) finishEntrance()
432
922
  }
433
923
 
434
- rafId = requestAnimationFrame(frame)
924
+ // Prime ownership and the final committed height in this layout phase.
925
+ // A producer-complete text arm can mount and drain before the next RAF;
926
+ // deferring this first pass would let it unmount unprimed after replacing
927
+ // the previous owner, leaving a large final append at the old scrollTop.
928
+ frame(performance.now())
435
929
  return () => {
436
930
  cancelAnimationFrame(rafId)
437
931
  if (interactTimer !== null) clearTimeout(interactTimer)
@@ -442,15 +936,104 @@ export function useConversationFollow(
442
936
  const root = rootRef.current
443
937
  const host = root?.closest<HTMLElement>('[data-conversation-scroll]') ?? port
444
938
  if (host === null) return
445
- const remaining = holding === host ? releaseFollow(host) : (followOwners.get(host) ?? 0)
446
939
  holding = null
447
- if (remaining === 0) {
448
- if (following) {
449
- if (interacting && awayPx >= FOLLOW_UNPIN_GESTURE_PX) handBackVisual(host)
450
- else settleAtFloor(host)
451
- }
940
+ if (!isLeader(host)) return
941
+ const preserveReader = interacting && (readerGestureIntent || readerScrolledUp(host))
942
+ if (!following || !primed) {
943
+ clearVisual(host)
944
+ followLeaders.delete(host)
945
+ releaseRevealScale()
946
+ return
947
+ }
948
+ if (preserveReader) {
949
+ handBackVisual(host)
452
950
  clearVisual(host)
951
+ followLeaders.delete(host)
952
+ releaseRevealScale()
953
+ return
954
+ }
955
+
956
+ // Completion can land the final Tool/command height in this same
957
+ // commit. Preserve the logical extent and drain it after unmount instead
958
+ // of clearing the compositor state before the first settled paint.
959
+ ensureRunway(host, shiftSurfacesOf(host))
960
+ const completionRunway = runwayOffsetOf(host)
961
+ const completionMinimumLag = Math.max(0, reservePx)
962
+ animatedH = Math.min(
963
+ animatedH,
964
+ host.scrollHeight - completionRunway - completionMinimumLag,
965
+ )
966
+ settleAtFloor(host)
967
+ animatedH = applyVisual(host, animatedH, reservePx, velocityPxPerSec)
968
+ const runwayOffset = runwayOffsetOf(host)
969
+ const remainingLag = Math.max(0, host.scrollHeight - animatedH - runwayOffset)
970
+ if (remainingLag <= FOLLOW_SETTLE_EPSILON_PX && reservePx <= FOLLOW_SETTLE_EPSILON_PX) {
971
+ finishAtNaturalFloor(host)
972
+ followLeaders.delete(host)
973
+ releaseRevealScale()
974
+ return
975
+ }
976
+
977
+ for (const name of GESTURE_EVENTS) {
978
+ host.addEventListener(name, markGesture, { passive: true })
979
+ }
980
+ const stopSettleListeners = (): void => {
981
+ for (const name of GESTURE_EVENTS) host.removeEventListener(name, markGesture)
982
+ if (interactTimer !== null) {
983
+ clearTimeout(interactTimer)
984
+ interactTimer = null
985
+ }
986
+ }
987
+ let settleLast = performance.now()
988
+ const settleFrame = (now: number): void => {
989
+ if (!isLeader(host)) {
990
+ stopSettleListeners()
991
+ return
992
+ }
993
+ if (interacting && (readerGestureIntent || readerScrolledUp(host))) {
994
+ readerGestureIntent = false
995
+ handBackVisual(host)
996
+ clearVisual(host)
997
+ followLeaders.delete(host)
998
+ releaseRevealScale()
999
+ stopSettleListeners()
1000
+ return
1001
+ }
1002
+ const dt = Math.min(FOLLOW_MAX_FRAME_MS, Math.max(0, now - settleLast))
1003
+ settleLast = now
1004
+ const runwayOffset = runwayOffsetOf(host)
1005
+ const lag = Math.max(0, host.scrollHeight - animatedH - runwayOffset)
1006
+ const reserveStep = 1 - Math.exp(-dt / FOLLOW_RESERVE_RESPONSE_MS)
1007
+ reservePx += (0 - reservePx) * reserveStep
1008
+ if (lag <= FOLLOW_SETTLE_EPSILON_PX && reservePx <= FOLLOW_SETTLE_EPSILON_PX) {
1009
+ animatedH = host.scrollHeight - runwayOffset
1010
+ reservePx = 0
1011
+ velocityPxPerSec = 0
1012
+ finishAtNaturalFloor(host)
1013
+ followLeaders.delete(host)
1014
+ releaseRevealScale()
1015
+ stopSettleListeners()
1016
+ return
1017
+ }
1018
+ const step = computeFollowStep(dt, {
1019
+ lag,
1020
+ speedEma: speedCpsRef.current,
1021
+ velocityPxPerSec,
1022
+ })
1023
+ // The temporary runway is visually neutral only while an equal lag
1024
+ // remains in the transform. Do not let the spring outrun the runway's
1025
+ // closing reserve or cleanup would reveal an overshoot and rebound.
1026
+ const minimumLag = Math.max(0, reservePx)
1027
+ animatedH = Math.min(
1028
+ host.scrollHeight - runwayOffset - minimumLag,
1029
+ animatedH + step.advancePx,
1030
+ )
1031
+ velocityPxPerSec = step.velocityPxPerSec
1032
+ settleAtFloor(host)
1033
+ animatedH = applyVisual(host, animatedH, reservePx, velocityPxPerSec)
1034
+ requestAnimationFrame(settleFrame)
453
1035
  }
1036
+ requestAnimationFrame(settleFrame)
454
1037
  }
455
- }, [active, rootRef, speedCpsRef])
1038
+ }, [active, rootRef, speedCpsRef, revealScaleRef, predictive, predictiveRef])
456
1039
  }