@adea-ai/ui 0.63.2 → 0.63.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.
@@ -0,0 +1,314 @@
1
+ /*
2
+ * Substantially translated from KiroCrew website/src/hooks/virtualizer/FollowController.ts
3
+ * (plain-scroller decision units), pinned at
4
+ * 283e136c0f902e965a535a7c9548c57c7504fed0.
5
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
6
+ * This product includes software developed at Amazon.com, Inc.
7
+ * (https://www.amazon.com/).
8
+ *
9
+ * Licensed under the Apache License, Version 2.0 (the "License");
10
+ * you may not use this file except in compliance with the License.
11
+ * You may obtain a copy of the License at
12
+ * http://www.apache.org/licenses/LICENSE-2.0
13
+ * Unless required by applicable law or agreed to in writing, software
14
+ * distributed under the License is distributed on an "AS IS" BASIS,
15
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16
+ * See the License for the specific language governing permissions and
17
+ * limitations under the License.
18
+ *
19
+ * Pure plain-scroller decisions retained from FollowController; virtual row
20
+ * measurement, persisted anchor resolution and restore settling are excluded.
21
+ */
22
+ /** Default distance (px) from the bottom within which `isAtBottom` is true. */
23
+ export const DEFAULT_BOTTOM_THRESHOLD = 100
24
+
25
+ /**
26
+ * Tolerance (px) for treating a scroll position as "the same" as a value we
27
+ * wrote programmatically. Covers sub-pixel rounding and 1px momentum overshoot.
28
+ * Must stay small so a deliberate user scroll of even a few px is still seen as
29
+ * a user scroll.
30
+ */
31
+ export const SELF_SCROLL_EPSILON = 2
32
+
33
+ /**
34
+ * "At the bottom" tolerance (px) for deciding whether an auto-pin still has
35
+ * work to do. A flat 0.5 is UNDER one device pixel at fractional device-pixel
36
+ * ratios (0.67 CSS px at 150% zoom, 0.8 at 125%): the scroller's resting
37
+ * maximum scrollTop lands on a fractional value, so `|scrollTop - target|`
38
+ * stays just above 0.5 even when the viewport is visually pinned to the
39
+ * bottom — making the pin re-fire on every ResizeObserver tick. Scaling the
40
+ * epsilon to the device pixel (never below 1 CSS px) absorbs that fractional
41
+ * resting error. `devicePixelRatio` is read defensively so a jsdom / SSR
42
+ * environment that leaves it undefined falls back to 1 (→ 1.5px).
43
+ */
44
+ export function atBottomEpsilon(): number {
45
+ const dpr =
46
+ typeof window !== 'undefined' &&
47
+ typeof window.devicePixelRatio === 'number' &&
48
+ window.devicePixelRatio > 0
49
+ ? window.devicePixelRatio
50
+ : 1
51
+ return Math.max(1, 1 / dpr + 0.5)
52
+ }
53
+
54
+ /** Live scroll geometry snapshot read from the scroller element. */
55
+ export interface ScrollGeom {
56
+ scrollTop: number
57
+ scrollHeight: number
58
+ clientHeight: number
59
+ }
60
+
61
+ /** scrollTop that places the viewport exactly at the bottom (never negative). */
62
+ export function bottomTarget(geom: ScrollGeom): number {
63
+ return Math.max(0, geom.scrollHeight - geom.clientHeight)
64
+ }
65
+
66
+ /** Pixels between the current scroll position and the bottom. */
67
+ export function distanceFromBottom(geom: ScrollGeom): number {
68
+ return geom.scrollHeight - geom.scrollTop - geom.clientHeight
69
+ }
70
+
71
+ /** Whether the scroller is within `threshold` px of the bottom. */
72
+ export function computeAtBottom(geom: ScrollGeom, threshold: number): boolean {
73
+ return distanceFromBottom(geom) <= threshold
74
+ }
75
+
76
+ /**
77
+ * Recognise a `scroll` event caused by our own programmatic write rather than
78
+ * by the user. `lastWriteTop < 0` means "we have not written this session", so
79
+ * any scroll is treated as the user's.
80
+ */
81
+ export function isSelfScroll(
82
+ scrollTop: number,
83
+ lastWriteTop: number,
84
+ epsilon: number = SELF_SCROLL_EPSILON
85
+ ): boolean {
86
+ return lastWriteTop >= 0 && Math.abs(scrollTop - lastWriteTop) <= epsilon
87
+ }
88
+
89
+ /**
90
+ * Distance (px) from the true bottom within which a user scroll RE-ENGAGES
91
+ * follow. Deliberately much tighter than DEFAULT_BOTTOM_THRESHOLD: that 100px
92
+ * band drives the jump-to-bottom pill's visibility, and reusing it for follow
93
+ * meant a deliberate 3-99px scroll-up kept `stick` armed — the next content
94
+ * change then yanked the reader back to the bottom. Re-engaging only when the
95
+ * user has returned essentially to the bottom keeps "scrolled up to read"
96
+ * positions belonging to the user.
97
+ */
98
+ export const FOLLOW_REENGAGE_PX = 16
99
+
100
+ /**
101
+ * Direction-aware `stick` decision for a *user-initiated* scroll (self-scrolls
102
+ * filtered out by the caller via `isSelfScroll`):
103
+ *
104
+ * 1. At the true bottom (within the DPR-aware epsilon) → follow. This also
105
+ * absorbs the layout engine's clamp: a mid-stream content SHRINK drops
106
+ * scrollTop (which reads as an upward move) but lands exactly at the new
107
+ * bottom — releasing there froze streaming follow for the rest of the
108
+ * turn.
109
+ * 2. Any other upward move → release, regardless of distance from the
110
+ * bottom. The scroll position now belongs to the user; only returning to
111
+ * the bottom (3) re-engages.
112
+ * 3. A genuine DOWNWARD move that arrives within FOLLOW_REENGAGE_PX of the
113
+ * bottom → re-engage. A neutral event inside the band does NOT: that is
114
+ * how content collapsing under a still reader re-armed follow.
115
+ * 4. Otherwise (downward/neutral, still away from the bottom) → keep the
116
+ * previous state.
117
+ *
118
+ * `prevScrollTop < 0` means "no prior observation this session". Direction is
119
+ * unknowable then, so the decision is position-only and CONSERVATIVE: follow
120
+ * only within the re-engage band. Keeping a stale `stick` on an unattributable
121
+ * away-from-bottom scroll is how a reader gets yanked.
122
+ */
123
+ export function resolveUserScrollStick(args: {
124
+ stick: boolean
125
+ followOutput: boolean
126
+ scrollTop: number
127
+ prevScrollTop: number
128
+ geom: ScrollGeom
129
+ /** Change in the scroller's own height since the previous scroll event.
130
+ *
131
+ * Positive = the viewport GREW (the composer shrank under a deletion, the
132
+ * keyboard closed). That growth lowers the maximum scrollTop, so the engine
133
+ * clamps any reader parked closer to the bottom than the growth — with no
134
+ * application write anywhere. The clamp then arrives here as an ordinary
135
+ * scroll event sitting at distance ~0, which rule 1 below used to read as
136
+ * "the reader came back to the bottom" and re-arm follow for someone who
137
+ * never touched the scroller. The next turn to start then took them to the
138
+ * end. Rule 1 exists to absorb a CONTENT-shrink clamp mid-stream, and content
139
+ * shrink moves `scrollHeight`, not `clientHeight` — so the two are
140
+ * distinguishable, and this is the delta that tells them apart. */
141
+ viewportGrowth?: number
142
+ }): boolean {
143
+ const { stick, followOutput, scrollTop, prevScrollTop, geom } = args
144
+ if (!followOutput) return false
145
+ const dist = distanceFromBottom(geom)
146
+ // A viewport growth large enough to explain the reader's arrival at the bottom
147
+ // is the engine's clamp, not the reader. Leave `stick` exactly as it was.
148
+ // A native clamp only ever LOWERS scrollTop, so a downward move concurrent with
149
+ // the growth is the user's own and must still re-engage follow. Without the
150
+ // direction term a reader who deliberately scrolls down while the keyboard
151
+ // closes is refused their re-engagement.
152
+ const clampedByViewport =
153
+ (args.viewportGrowth ?? 0) > atBottomEpsilon() && scrollTop <= prevScrollTop + atBottomEpsilon()
154
+ if (dist <= atBottomEpsilon()) return clampedByViewport ? stick : true
155
+ if (prevScrollTop < 0) return dist <= FOLLOW_REENGAGE_PX
156
+ if (scrollTop < prevScrollTop - 0.5) return false
157
+ // Re-engagement requires a genuine DOWNWARD move, not merely a non-upward
158
+ // event that finds the reader inside the band. A neutral event (identical
159
+ // scrollTop -- the tail of an iOS momentum run, or any scroll fired while the
160
+ // reader is at rest) used to satisfy this, so a reader sitting mid-transcript
161
+ // could be re-armed by CONTENT rather than by their own hand: when rows
162
+ // outside the window reprice smaller than their estimates, the remaining
163
+ // content collapses under them and the bottom band arrives at the reader
164
+ // instead of the reader arriving at it. Follow re-engaged, and the next pin
165
+ // took them to the end -- reported as scrolling along and suddenly landing at
166
+ // the bottom. Distance alone cannot tell those apart; the direction of the
167
+ // reader's own move can.
168
+ if (scrollTop > prevScrollTop + 0.5 && dist <= FOLLOW_REENGAGE_PX) return true
169
+ return stick
170
+ }
171
+
172
+ /** Result of an automatic (RO / append) pin evaluation. */
173
+ export interface AutoPinResult {
174
+ /** Whether to write `el.scrollTop = target` now. */
175
+ pin: boolean
176
+ /** Next value for `stick` (released to false if the user scrolled up). */
177
+ stick: boolean
178
+ /** The bottom scrollTop the caller should write when `pin` is true. */
179
+ target: number
180
+ }
181
+
182
+ /**
183
+ * Decide an automatic pin at the moment content changed (RO callback / append
184
+ * layout effect / its follow-up rAF), reading LIVE geometry.
185
+ *
186
+ * - Not sticking → never pin.
187
+ * - Sticking but the user has scrolled up since our last write
188
+ * (`scrollTop < lastWriteTop - epsilon`) → release stick, don't pin.
189
+ * This is the synchronous, race-proof guard.
190
+ * - Otherwise → pin to the bottom (only actually move if not already there).
191
+ *
192
+ * `lastWriteTop < 0` disables the scroll-up guard (used right after a slot
193
+ * switch, before we have written anything this session).
194
+ *
195
+ * `viewportShrink` (px, default 0) is how much the SCROLLER'S OWN BOX has
196
+ * shrunk since that reference was recorded — chrome mounting below the
197
+ * transcript (a queue band, an attachment strip, a tip card), often
198
+ * spring-animated over several frames. Our own shrink inflates
199
+ * `distanceFromBottom` with no user input, so without this allowance the
200
+ * distance guard reads it as "meaningfully away from the bottom". Paired with
201
+ * a content SHRINK in the same commit window — a tail-row remount clamping
202
+ * scrollTop below `lastWriteTop` — that produced a full user-scroll-up
203
+ * signature out of two of our own layout changes: follow released mid
204
+ * animation and the content settled a card-height low. Judging the distance
205
+ * against the box we were last a bottom FOR keeps the guard measuring the
206
+ * user's move rather than our own. Only the shrink's own pixels are forgiven,
207
+ * so a genuine drag inside the same tick still releases.
208
+ */
209
+ export function evaluateAutoPin(args: {
210
+ stick: boolean
211
+ geom: ScrollGeom
212
+ lastWriteTop: number
213
+ epsilon?: number
214
+ viewportShrink?: number
215
+ /** Is a turn actually producing output right now?
216
+ *
217
+ * Follow means "keep me at the end of a LIVE turn". With nothing running there
218
+ * is no output to follow, so a reader sitting above the bottom is not
219
+ * following — and an automatic pin there is a yank with no cause, reported
220
+ * from a phone as the transcript springing back after scrolling up about a
221
+ * hundred pixels with nothing streaming.
222
+ *
223
+ * Defaults to `true` = assume a run is live, which keeps the behaviour of a
224
+ * caller that has no run signal to give (the app-SDK chat surface). The chat
225
+ * transcript passes the real thing. */
226
+ runActive?: boolean
227
+ /** Is an anchor restore currently OWNING the scroll position?
228
+ *
229
+ * A restore places the reader at an absolute offset and then re-lands it as
230
+ * measurements arrive. An automatic pin during that window is a second owner
231
+ * writing the same scroller, and the two fight: captured on a phone as
232
+ * `WRITE autopin 3091->4245` answered by `WRITE settle 4245->3091`, twice in
233
+ * 120ms, 1,154px each way. The settle won those rounds, but only because its
234
+ * budget had not expired yet -- which is why the same switch sometimes landed
235
+ * at the bottom and sometimes did not.
236
+ *
237
+ * Released rather than merely skipped, for the reason the idle branch below
238
+ * gives: skipping leaves follow armed, so the next growth yanks the reader
239
+ * from wherever the restore just put them. */
240
+ restoreGate?: boolean
241
+ /** Has hardware input -- wheel / touch / pointer / a scrolling key -- reached
242
+ * the scroller since we last placed the reader at the bottom?
243
+ *
244
+ * False means the reader has done nothing, so a gap that opened while they
245
+ * rest on our last write was opened by content -- a row settling from its
246
+ * estimate, a code-block stand-in swapping for the highlighted block, the
247
+ * top spacer repricing -- and is a gap WE owe them, not one they chose.
248
+ *
249
+ * The idle rule below cannot tell those apart from distance alone, and it
250
+ * errs toward release, which was invisible wherever the browser's native
251
+ * scroll anchoring quietly carried the reader through the growth. WebKit has
252
+ * no scroll anchoring at all, so on an iPhone every entry into an idle
253
+ * session paid the whole post-pin reprice as a displacement and then had
254
+ * follow released on top of it: the transcript opened a viewport or more
255
+ * above the end with nothing streaming to bring it back.
256
+ *
257
+ * Defaults to `true` = assume the reader may have moved, which is the
258
+ * release-leaning legacy behaviour for a caller that has no input signal. */
259
+ readerMovedSinceWrite?: boolean
260
+ }): AutoPinResult {
261
+ const { stick, geom, lastWriteTop } = args
262
+ const epsilon = args.epsilon ?? SELF_SCROLL_EPSILON
263
+ const viewportShrink = Math.max(0, args.viewportShrink ?? 0)
264
+ const runActive = args.runActive ?? true
265
+ const readerMovedSinceWrite = args.readerMovedSinceWrite ?? true
266
+ const target = bottomTarget(geom)
267
+ if (args.restoreGate) return { pin: false, stick: false, target }
268
+ if (!stick) return { pin: false, stick: false, target }
269
+ // The reader is resting exactly where we last put them and has given no input
270
+ // since, so any gap is content settling under them -- carry them back, live
271
+ // turn or not. Both conditions are load-bearing. Position alone would read our
272
+ // own write as consent for a reader who wheeled up and happened to stop on it;
273
+ // input alone would drag back a programmatic reveal -- a search hit, a pinned
274
+ // prompt, find-in-page -- whose scroll event has not dispatched yet when a
275
+ // height commit lands, since none of those touch the scroller's input
276
+ // listeners. A reveal moves scrollTop off our write; a reprice does not.
277
+ const restingOnOurWrite = lastWriteTop >= 0 && Math.abs(geom.scrollTop - lastWriteTop) <= epsilon
278
+ if (!readerMovedSinceWrite && restingOnOurWrite) {
279
+ return { pin: distanceFromBottom(geom) > atBottomEpsilon(), stick: true, target }
280
+ }
281
+ // Idle: release rather than merely skip the pin. Skipping would leave follow
282
+ // armed, so the next turn to start would yank this reader to the bottom from
283
+ // wherever they had settled — the same defect one event later.
284
+ //
285
+ // But distance alone cannot say WHO opened that gap, and the two causes want
286
+ // opposite answers: a reader who scrolled up should be released, while a
287
+ // reader the CONTENT moved away from should be carried back.
288
+ if (!runActive && distanceFromBottom(geom) > atBottomEpsilon()) {
289
+ // Released. The question this branch cannot answer from geometry -- did the
290
+ // reader open this gap, or did the content -- is answered ABOVE by
291
+ // `readerMovedSinceWrite`: reaching here means input or an unexplained
292
+ // scroll has been seen since our last positioning, so a reader who is now
293
+ // above the bottom while nothing runs is one who left it. Reading our own
294
+ // last write as consent would be an automatic action authorizing itself;
295
+ // the only evidence that the reader never moved is the absence of input,
296
+ // and that is what the branch above requires.
297
+ return { pin: false, stick: false, target }
298
+ }
299
+ // Release only on a genuine user scroll-UP: scrollTop dropped below our last
300
+ // write AND we are now meaningfully away from the bottom. A pure content
301
+ // SHRINK mid-stream (a partial markdown line re-parsing, a code fence opening
302
+ // and reclassifying the block) clamps scrollTop below lastWriteTop too, but
303
+ // leaves us still AT the new bottom (distance ~0). Without the distance guard
304
+ // that shrink looked like a scroll-up and froze streaming follow — once
305
+ // released, nothing re-armed stick for the rest of the response.
306
+ if (
307
+ lastWriteTop >= 0 &&
308
+ geom.scrollTop < lastWriteTop - epsilon &&
309
+ distanceFromBottom(geom) - viewportShrink > epsilon
310
+ ) {
311
+ return { pin: false, stick: false, target }
312
+ }
313
+ return { pin: Math.abs(geom.scrollTop - target) > atBottomEpsilon(), stick: true, target }
314
+ }
@@ -0,0 +1,133 @@
1
+ /*
2
+ * Substantially translated from KiroCrew website/src/app-sdk/useChatScrollFollow.ts,
3
+ * pinned at 283e136c0f902e965a535a7c9548c57c7504fed0.
4
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
5
+ * This product includes software developed at Amazon.com, Inc. (https://www.amazon.com/).
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0
9
+ * Unless required by applicable law or agreed to in writing, software distributed
10
+ * under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
11
+ * CONDITIONS OF ANY KIND, either express or implied. See the License for the
12
+ * specific language governing permissions and limitations under the License.
13
+ * Solid owner effects replace React refs/effects; native geometry and the
14
+ * content/viewport observer contract are retained. No session store is copied.
15
+ */
16
+ import { createEffect, createSignal, onCleanup } from 'solid-js'
17
+ import {
18
+ bottomTarget,
19
+ computeAtBottom,
20
+ evaluateAutoPin,
21
+ isSelfScroll,
22
+ resolveUserScrollStick,
23
+ } from './scroll-follow-core'
24
+
25
+ const geometry = (element: HTMLDivElement) => ({
26
+ scrollTop: element.scrollTop,
27
+ scrollHeight: element.scrollHeight,
28
+ clientHeight: element.clientHeight,
29
+ })
30
+
31
+ /** Internal plain-scroller binding; host-owned restoration can disable follow. */
32
+ export function createScrollFollow(options: {
33
+ enabled: () => boolean
34
+ resetKey: () => string | undefined
35
+ threshold: () => number
36
+ }) {
37
+ const [atBottom, setAtBottom] = createSignal(true)
38
+ let scroller: HTMLDivElement | undefined
39
+ let content: HTMLDivElement | undefined
40
+ let stick = true
41
+ let lastWriteTop = -1
42
+ let lastWriteClientHeight = -1
43
+ let previousTop = -1
44
+ let lastScrollClientHeight = 0
45
+
46
+ const writePin = (element: HTMLDivElement, target: number) => {
47
+ // Instant writes keep the self-scroll reference synchronized. A smooth
48
+ // animation would produce intermediate positions that resemble user input.
49
+ element.scrollTop = target
50
+ lastWriteTop = target
51
+ lastWriteClientHeight = element.clientHeight
52
+ previousTop = target
53
+ }
54
+ const pinAuto = () => {
55
+ if (!scroller || !options.enabled()) return
56
+ const geom = geometry(scroller)
57
+ const result = evaluateAutoPin({
58
+ stick,
59
+ geom,
60
+ lastWriteTop,
61
+ viewportShrink: lastWriteClientHeight >= 0 ? lastWriteClientHeight - geom.clientHeight : 0,
62
+ })
63
+ stick = result.stick
64
+ if (result.pin) writePin(scroller, result.target)
65
+ else if (result.stick) {
66
+ lastWriteTop = result.target
67
+ lastWriteClientHeight = geom.clientHeight
68
+ }
69
+ setAtBottom(computeAtBottom(geometry(scroller), options.threshold()))
70
+ }
71
+ const onScroll = () => {
72
+ if (!scroller || !options.enabled()) return
73
+ const geom = geometry(scroller)
74
+ setAtBottom(computeAtBottom(geom, options.threshold()))
75
+ if (!isSelfScroll(geom.scrollTop, lastWriteTop)) {
76
+ stick = resolveUserScrollStick({
77
+ stick,
78
+ followOutput: true,
79
+ scrollTop: geom.scrollTop,
80
+ prevScrollTop: previousTop,
81
+ geom,
82
+ viewportGrowth: lastScrollClientHeight > 0 ? geom.clientHeight - lastScrollClientHeight : 0,
83
+ })
84
+ if (!stick) {
85
+ lastWriteTop = -1
86
+ lastWriteClientHeight = -1
87
+ }
88
+ }
89
+ previousTop = geom.scrollTop
90
+ // This baseline belongs only to scroll events, not observer callbacks.
91
+ // Advancing it during resize would erase the viewport-clamp evidence.
92
+ lastScrollClientHeight = geom.clientHeight
93
+ }
94
+ const jump = () => {
95
+ if (!scroller || !options.enabled()) return
96
+ stick = true
97
+ writePin(scroller, bottomTarget(geometry(scroller)))
98
+ setAtBottom(true)
99
+ // The jump control disappears at the bottom. Return its keyboard focus to
100
+ // the transcript instead of leaving the reader on a detached button.
101
+ scroller.focus({ preventScroll: true })
102
+ }
103
+
104
+ createEffect(() => {
105
+ options.resetKey()
106
+ const enabled = options.enabled()
107
+ stick = true
108
+ lastWriteTop = -1
109
+ lastWriteClientHeight = -1
110
+ previousTop = -1
111
+ lastScrollClientHeight = 0
112
+ setAtBottom(true)
113
+ if (!enabled || !scroller) return
114
+ writePin(scroller, bottomTarget(geometry(scroller)))
115
+ if (typeof ResizeObserver === 'undefined') return
116
+ const observer = new ResizeObserver(pinAuto)
117
+ observer.observe(scroller)
118
+ if (content) observer.observe(content)
119
+ onCleanup(() => observer.disconnect())
120
+ })
121
+
122
+ return {
123
+ atBottom,
124
+ onScroll,
125
+ jump,
126
+ bindScroller: (element: HTMLDivElement) => {
127
+ scroller = element
128
+ },
129
+ bindContent: (element: HTMLDivElement) => {
130
+ content = element
131
+ },
132
+ }
133
+ }