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.
- package/README.en.md +12 -10
- package/README.md +12 -10
- package/lib/client.js +1110 -349
- package/lib/client.js.map +1 -1
- package/package.json +3 -1
- package/src/client/FollowHost.tsx +49 -4
- package/src/client/TypewriterAssistantNodeView.module.css +1 -0
- package/src/client/TypewriterAssistantNodeView.tsx +148 -18
- package/src/client/TypewriterToolNodeView.tsx +183 -8
- package/src/client/index.ts +25 -8
- package/src/client/teleprompterGlide.ts +753 -233
- package/src/client/useProgressiveDomText.ts +206 -0
- package/src/client/useSmoothStreamContent.ts +139 -73
- package/src/config.ts +3 -6
|
@@ -1,127 +1,204 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Conversation-port follow while an assistant reply streams.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
* -
|
|
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
|
-
* -
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* -
|
|
18
|
-
*
|
|
19
|
-
* -
|
|
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
|
|
23
|
-
*
|
|
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
|
-
*
|
|
28
|
-
*
|
|
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
|
-
*
|
|
35
|
-
*
|
|
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
|
-
/**
|
|
41
|
-
export const
|
|
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
|
-
/**
|
|
44
|
-
export const
|
|
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
|
-
/**
|
|
48
|
-
export const
|
|
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
|
-
/**
|
|
51
|
-
export const
|
|
51
|
+
/** Time constant for opening/closing predictive paint room. */
|
|
52
|
+
export const FOLLOW_RESERVE_RESPONSE_MS = 180
|
|
52
53
|
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
*
|
|
171
|
+
* Semi-implicit spring integration with four substeps per <=32ms slice.
|
|
91
172
|
* @param dtMs - Frame delta in ms.
|
|
92
|
-
* @param input - Current lag
|
|
93
|
-
* @returns The
|
|
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)
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
133
|
-
function
|
|
134
|
-
|
|
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(
|
|
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
|
|
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
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
port
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
226
|
-
|
|
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
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
|
258
|
-
port.setAttribute(FOLLOW_OWNED_ATTR, String(port.scrollTop))
|
|
546
|
+
setFollowScrollTop(port, floor)
|
|
259
547
|
}
|
|
260
548
|
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
311
|
-
if (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
|
-
|
|
319
|
-
|
|
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
|
-
|
|
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
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
|
344
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
742
|
+
const extent = Math.min(
|
|
743
|
+
nextPort.scrollHeight,
|
|
744
|
+
Math.max(0, nextPort.scrollHeight - reportedLag),
|
|
745
|
+
)
|
|
392
746
|
|
|
393
747
|
if (!primed) {
|
|
394
|
-
|
|
395
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 &&
|
|
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
|
-
|
|
411
|
-
|
|
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)
|
|
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
|
-
|
|
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 =
|
|
842
|
+
animatedH = contentHeight - runwayOffset
|
|
843
|
+
velocityPxPerSec = 0
|
|
428
844
|
} else {
|
|
429
|
-
|
|
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
|
-
|
|
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 (
|
|
448
|
-
|
|
449
|
-
|
|
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
|
}
|