@oxy.so/bloom 4.12.0 → 4.13.1

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.
Files changed (43) hide show
  1. package/docs/ai-chat.mdx +31 -5
  2. package/lib/commonjs/ai-chat/AiChatContainer.js +405 -116
  3. package/lib/commonjs/ai-chat/AiChatContainer.js.map +1 -1
  4. package/lib/commonjs/ai-chat/AiChatShell.js +211 -176
  5. package/lib/commonjs/ai-chat/AiChatShell.js.map +1 -1
  6. package/lib/commonjs/ai-chat/context.js +10 -1
  7. package/lib/commonjs/ai-chat/context.js.map +1 -1
  8. package/lib/commonjs/ai-chat/shared.js +41 -2
  9. package/lib/commonjs/ai-chat/shared.js.map +1 -1
  10. package/lib/module/ai-chat/AiChatContainer.js +409 -120
  11. package/lib/module/ai-chat/AiChatContainer.js.map +1 -1
  12. package/lib/module/ai-chat/AiChatShell.js +213 -178
  13. package/lib/module/ai-chat/AiChatShell.js.map +1 -1
  14. package/lib/module/ai-chat/context.js +9 -0
  15. package/lib/module/ai-chat/context.js.map +1 -1
  16. package/lib/module/ai-chat/shared.js +40 -1
  17. package/lib/module/ai-chat/shared.js.map +1 -1
  18. package/lib/typescript/commonjs/ai-chat/AiChatContainer.d.ts +8 -3
  19. package/lib/typescript/commonjs/ai-chat/AiChatContainer.d.ts.map +1 -1
  20. package/lib/typescript/commonjs/ai-chat/AiChatShell.d.ts +1 -1
  21. package/lib/typescript/commonjs/ai-chat/AiChatShell.d.ts.map +1 -1
  22. package/lib/typescript/commonjs/ai-chat/context.d.ts +14 -0
  23. package/lib/typescript/commonjs/ai-chat/context.d.ts.map +1 -1
  24. package/lib/typescript/commonjs/ai-chat/shared.d.ts +24 -2
  25. package/lib/typescript/commonjs/ai-chat/shared.d.ts.map +1 -1
  26. package/lib/typescript/commonjs/ai-chat/types.d.ts +19 -0
  27. package/lib/typescript/commonjs/ai-chat/types.d.ts.map +1 -1
  28. package/lib/typescript/module/ai-chat/AiChatContainer.d.ts +8 -3
  29. package/lib/typescript/module/ai-chat/AiChatContainer.d.ts.map +1 -1
  30. package/lib/typescript/module/ai-chat/AiChatShell.d.ts +1 -1
  31. package/lib/typescript/module/ai-chat/AiChatShell.d.ts.map +1 -1
  32. package/lib/typescript/module/ai-chat/context.d.ts +14 -0
  33. package/lib/typescript/module/ai-chat/context.d.ts.map +1 -1
  34. package/lib/typescript/module/ai-chat/shared.d.ts +24 -2
  35. package/lib/typescript/module/ai-chat/shared.d.ts.map +1 -1
  36. package/lib/typescript/module/ai-chat/types.d.ts +19 -0
  37. package/lib/typescript/module/ai-chat/types.d.ts.map +1 -1
  38. package/package.json +1 -1
  39. package/src/ai-chat/AiChatContainer.tsx +369 -88
  40. package/src/ai-chat/AiChatShell.tsx +39 -13
  41. package/src/ai-chat/context.ts +15 -0
  42. package/src/ai-chat/shared.ts +45 -2
  43. package/src/ai-chat/types.ts +19 -0
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
 
3
- import React, { forwardRef, useCallback, useContext, useImperativeHandle, useMemo, useRef, useState } from 'react';
4
- import { ScrollView, StyleSheet, View } from 'react-native';
3
+ import React, { forwardRef, useCallback, useContext, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
4
+ import { ScrollView, View } from 'react-native';
5
5
  import Animated, { useAnimatedStyle, useSharedValue } from 'react-native-reanimated';
6
6
  import { AgentThinking } from "../agent-thinking/index.js";
7
7
  import { Breadcrumb, BreadcrumbItem } from "../breadcrumb/index.js";
@@ -12,8 +12,9 @@ import { EdgeScrim, SCRIM_TAIL_RATIO } from "../page-header/EdgeScrim.js";
12
12
  import { parseRgba } from "../theme/color-utils.js";
13
13
  import { useIsomorphicLayoutEffect } from "../theme/use-isomorphic-layout-effect.js";
14
14
  import { GlyphAction } from "./AiChatControls.js";
15
- import { AiChatFloatingChromeContext } from "./context.js";
16
- import { CONTAINER_RADIUS, dataHook, IS_WEB, useAiChatPalette, useAiChatWebCss } from "./shared.js";
15
+ import { AiChatDocumentGutterContext, AiChatFloatingChromeContext, useAiChatShell } from "./context.js";
16
+ import { CONTAINER_RADIUS, dataHook, DOCUMENT_LAYER, IS_WEB, SHELL_GUTTER, useAiChatPalette, useAiChatWebCss } from "./shared.js";
17
+ import { WEB_POSITION_STICKY, webViewportHeightMinus } from "../styles/web-view-style.js";
17
18
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
18
19
  const DEFAULT_LABELS = {
19
20
  breadcrumb: 'Chat location',
@@ -23,12 +24,8 @@ const DEFAULT_LABELS = {
23
24
 
24
25
  /** The thread's own top padding: the gap between the header and the first turn. */
25
26
  const THREAD_PAD_TOP = 16;
26
- /**
27
- * `floatingChrome`: the footer's fade strip, px — the footer's top padding, so
28
- * the last turn at rest ends where the fade begins, and the distance over which
29
- * the bottom fade ramps in as the transcript slides under it.
30
- */
31
- const FOOTER_FADE = 24;
27
+ /** The footer's top padding: the gap between the last turn at rest and the composer. */
28
+ const FOOTER_GAP = 12;
32
29
  /** Below this many px of overlap an edge counts as clear (sub-pixel scroll noise). */
33
30
  const EDGE_THRESHOLD = 1;
34
31
  const HIDDEN_FROM_A11Y = {
@@ -62,14 +59,19 @@ const HIDDEN_FROM_A11Y = {
62
59
  * an edge scrim of the surface (opaque at the top, gone
63
60
  * `SCRIM_TAIL_RATIO` below the block) fades in over the first 16px
64
61
  * of transcript scrolled under it
65
- * footer absolute at the bottom, pt 24 instead of 12: the 24px strip is a
66
- * scrim ramp and the rest is solid surface, faded in over the first
67
- * 24px of transcript below the fold
62
+ * footer absolute at the bottom, with no band of its own: the transcript
63
+ * passes behind the composer and stays in sight around it
68
64
  *
69
65
  * Both blocks are `pointerEvents="box-none"`, so the transcript under their
70
66
  * empty space still scrolls and takes clicks. The measured heights reach an
71
67
  * `AiChatThread` child through context (its content padding) and any other
72
68
  * child through `useAiChatChromeInsets()`.
69
+ *
70
+ * Under a shell with `scroll="document"` the chrome always floats, since the
71
+ * document is what moves: the header and the footer are `sticky` in the flow,
72
+ * pinned to the screen's top and bottom gutter, the `background` slot and the
73
+ * card's frame are layers the size of the screen, and the card grows with the
74
+ * conversation. At rest nothing overlaps, so the insets are zero.
73
75
  */
74
76
  export function AiChatContainer({
75
77
  project,
@@ -97,6 +99,8 @@ export function AiChatContainer({
97
99
  ...DEFAULT_LABELS,
98
100
  ...labels
99
101
  }), [labels]);
102
+ const documentScroll = useAiChatShell()?.documentScroll ?? false;
103
+ const gutterColor = useContext(AiChatDocumentGutterContext);
100
104
  const hasCrumbRow = title != null || project != null || onShare || onMore || actions != null;
101
105
  const crumbRow = hasCrumbRow ? /*#__PURE__*/_jsxs(View, {
102
106
  pointerEvents: "box-none",
@@ -179,6 +183,35 @@ export function AiChatContainer({
179
183
  borderRadius: CONTAINER_RADIUS,
180
184
  backgroundColor: surface ? palette.secondary : 'transparent'
181
185
  }, style];
186
+ if (documentScroll) {
187
+ return /*#__PURE__*/_jsx(FloatingChrome, {
188
+ documentScroll: true,
189
+ testID: testID,
190
+ rootStyle: [{
191
+ // Grows with the conversation, one screen at the least; nothing
192
+ // clips it, so the header and composer inside can stick.
193
+ minHeight: webViewportHeightMinus(SHELL_GUTTER * 2),
194
+ minWidth: 0,
195
+ flex: 1,
196
+ flexDirection: 'column',
197
+ borderRadius: CONTAINER_RADIUS,
198
+ backgroundColor: surface ? palette.secondary : 'transparent'
199
+ }, style],
200
+ backgroundLayer: background ? /*#__PURE__*/_jsx(View, {
201
+ pointerEvents: "none",
202
+ style: [DOCUMENT_LAYER, {
203
+ overflow: 'hidden'
204
+ }],
205
+ children: background
206
+ }) : null,
207
+ header: header,
208
+ crumbRow: crumbRow,
209
+ footerBody: footerBody,
210
+ fadeColor: surface ? palette.secondary : null,
211
+ gutterColor: gutterColor,
212
+ children: children
213
+ });
214
+ }
182
215
  if (floatingChrome) {
183
216
  return /*#__PURE__*/_jsx(FloatingChrome, {
184
217
  testID: testID,
@@ -208,17 +241,24 @@ export function AiChatContainer({
208
241
  })]
209
242
  });
210
243
  }
244
+ const NO_INSETS = {
245
+ top: 0,
246
+ bottom: 0
247
+ };
248
+
211
249
  /**
212
- * `AiChatContainer floatingChrome`: the same parts, the chrome floated over the
213
- * transcript. Everything it measures lives here, so the stacked path above
214
- * carries none of the state.
250
+ * `AiChatContainer floatingChrome` (and every container under a document-
251
+ * scrolled shell): the same parts, the chrome floated over the transcript.
252
+ * Everything it measures lives here, so the stacked path above carries none of
253
+ * the state.
215
254
  *
216
- * The edge opacities are shared values written from the thread's scroll
217
- * reports, so a scroll frame re-renders nothing; the booleans only mount and
218
- * unmount the scrims, and change once per crossing. Same model as the
255
+ * The header's edge opacity is a shared value written from the thread's scroll
256
+ * reports, so a scroll frame re-renders nothing; the boolean only mounts and
257
+ * unmounts the scrim, and changes once per crossing. Same model as the
219
258
  * sidebar's `SidebarScrollArea`, whose scrim this reuses.
220
259
  */
221
260
  function FloatingChrome({
261
+ documentScroll = false,
222
262
  testID,
223
263
  rootStyle,
224
264
  backgroundLayer,
@@ -226,32 +266,30 @@ function FloatingChrome({
226
266
  crumbRow,
227
267
  footerBody,
228
268
  fadeColor,
269
+ gutterColor = null,
229
270
  children
230
271
  }) {
231
- const [insets, setInsets] = useState({
232
- top: 0,
233
- bottom: 0
234
- });
235
- const [edges, setEdges] = useState({
236
- top: false,
237
- bottom: false
238
- });
272
+ const [insets, setInsets] = useState(NO_INSETS);
273
+ const [headerHeight, setHeaderHeight] = useState(0);
274
+ const [scrolled, setScrolled] = useState(false);
239
275
  const topFade = useSharedValue(0);
240
- const bottomFade = useSharedValue(0);
241
276
  const onHeaderLayout = useCallback(event => {
242
277
  const top = Math.round(event.nativeEvent.layout.height);
243
- setInsets(previous => previous.top === top ? previous : {
278
+ setHeaderHeight(top);
279
+ // In the flow the header covers nothing at rest: no inset to publish.
280
+ if (!documentScroll) setInsets(previous => previous.top === top ? previous : {
244
281
  ...previous,
245
282
  top
246
283
  });
247
- }, []);
284
+ }, [documentScroll]);
248
285
  const onFooterLayout = useCallback(event => {
286
+ if (documentScroll) return;
249
287
  const bottom = Math.round(event.nativeEvent.layout.height);
250
288
  setInsets(previous => previous.bottom === bottom ? previous : {
251
289
  ...previous,
252
290
  bottom
253
291
  });
254
- }, []);
292
+ }, [documentScroll]);
255
293
  const reportScroll = useCallback(({
256
294
  offset,
257
295
  viewport,
@@ -260,18 +298,12 @@ function FloatingChrome({
260
298
  const range = Math.max(0, content - viewport);
261
299
  const y = Math.max(0, Math.min(offset, range));
262
300
  const overflow = viewport > 0 && range > EDGE_THRESHOLD;
263
- // The first turn rests THREAD_PAD_TOP below the header and the last one
264
- // right on the footer's fade strip, so the overlap starts at the first px
265
- // scrolled and each ramp spans the gap it is closing.
266
- const top = overflow && y > EDGE_THRESHOLD;
267
- const bottom = overflow && range - y > EDGE_THRESHOLD;
301
+ // The first turn rests THREAD_PAD_TOP below the header, so the overlap
302
+ // starts at the first px scrolled and the ramp spans the gap it closes.
268
303
  topFade.value = overflow ? Math.min(1, y / THREAD_PAD_TOP) : 0;
269
- bottomFade.value = overflow ? Math.min(1, (range - y) / FOOTER_FADE) : 0;
270
- setEdges(previous => previous.top === top && previous.bottom === bottom ? previous : {
271
- top,
272
- bottom
273
- });
274
- }, [topFade, bottomFade]);
304
+ const next = overflow && y > EDGE_THRESHOLD;
305
+ setScrolled(previous => previous === next ? previous : next);
306
+ }, [topFade]);
275
307
  const chrome = useMemo(() => ({
276
308
  insets,
277
309
  reportScroll
@@ -284,92 +316,125 @@ function FloatingChrome({
284
316
  const topStyle = useAnimatedStyle(() => ({
285
317
  opacity: topFade.value * alpha
286
318
  }), [topFade, alpha]);
287
- const bottomStyle = useAnimatedStyle(() => ({
288
- opacity: bottomFade.value * alpha
289
- }), [bottomFade, alpha]);
290
319
  const prefix = testID ?? 'ai-chat';
291
- return /*#__PURE__*/_jsxs(View, {
292
- testID: testID,
293
- style: [rootStyle, {
294
- position: 'relative'
295
- }],
296
- children: [backgroundLayer, /*#__PURE__*/_jsx(AiChatFloatingChromeContext.Provider, {
297
- value: chrome,
298
- children: children
299
- }), /*#__PURE__*/_jsxs(View, {
300
- testID: `${prefix}-chrome-top`,
301
- pointerEvents: "box-none",
302
- onLayout: onHeaderLayout,
303
- style: {
320
+
321
+ // Absolute over a card of fixed height; sticky in the flow of a card that
322
+ // grows with the document, pinned to the screen's gutter.
323
+ const headerPosition = documentScroll ? {
324
+ position: WEB_POSITION_STICKY,
325
+ top: SHELL_GUTTER,
326
+ zIndex: 2
327
+ } : {
328
+ position: 'absolute',
329
+ top: 0,
330
+ left: 0,
331
+ right: 0
332
+ };
333
+ const footerPosition = documentScroll ? {
334
+ position: WEB_POSITION_STICKY,
335
+ bottom: SHELL_GUTTER,
336
+ zIndex: 2
337
+ } : {
338
+ position: 'absolute',
339
+ left: 0,
340
+ right: 0,
341
+ bottom: 0
342
+ };
343
+ const top = /*#__PURE__*/_jsxs(View, {
344
+ testID: `${prefix}-chrome-top`,
345
+ pointerEvents: "box-none",
346
+ onLayout: onHeaderLayout,
347
+ style: headerPosition,
348
+ children: [solid && scrolled && headerHeight > 0 ? /*#__PURE__*/_jsx(Animated.View, {
349
+ testID: `${prefix}-fade-top`,
350
+ pointerEvents: "none",
351
+ ...HIDDEN_FROM_A11Y,
352
+ style: [{
304
353
  position: 'absolute',
305
354
  top: 0,
306
355
  left: 0,
307
- right: 0
308
- },
309
- children: [solid && edges.top && insets.top > 0 ? /*#__PURE__*/_jsx(Animated.View, {
310
- testID: `${prefix}-fade-top`,
311
- pointerEvents: "none",
312
- ...HIDDEN_FROM_A11Y,
313
- style: [{
314
- position: 'absolute',
315
- top: 0,
316
- left: 0,
317
- right: 0,
318
- // Past the block's own bottom: a ramp that ends where the layout
319
- // does is a line across the transcript again.
320
- height: Math.round(insets.top * (1 + SCRIM_TAIL_RATIO))
321
- }, topStyle],
322
- children: /*#__PURE__*/_jsx(EdgeScrim, {
323
- color: solid
324
- })
325
- }) : null, header, crumbRow]
326
- }), /*#__PURE__*/_jsxs(View, {
327
- testID: `${prefix}-chrome-bottom`,
356
+ right: 0,
357
+ // Past the block's own bottom: a ramp that ends where the layout
358
+ // does is a line across the transcript again.
359
+ height: Math.round(headerHeight * (1 + SCRIM_TAIL_RATIO)),
360
+ // The card's own corners, since nothing clips a document card.
361
+ borderTopLeftRadius: CONTAINER_RADIUS,
362
+ borderTopRightRadius: CONTAINER_RADIUS,
363
+ overflow: 'hidden'
364
+ }, topStyle],
365
+ children: /*#__PURE__*/_jsx(EdgeScrim, {
366
+ color: solid
367
+ })
368
+ }) : null, header, crumbRow]
369
+ });
370
+ const bottom = /*#__PURE__*/_jsx(View, {
371
+ testID: `${prefix}-chrome-bottom`,
372
+ pointerEvents: "box-none",
373
+ onLayout: onFooterLayout,
374
+ style: footerPosition,
375
+ children: /*#__PURE__*/_jsx(View, {
328
376
  pointerEvents: "box-none",
329
- onLayout: onFooterLayout,
330
377
  style: {
331
- position: 'absolute',
332
- left: 0,
333
- right: 0,
334
- bottom: 0
378
+ width: '100%',
379
+ flexDirection: 'column',
380
+ gap: 10,
381
+ paddingLeft: 10,
382
+ paddingRight: 10,
383
+ paddingTop: FOOTER_GAP,
384
+ paddingBottom: 10
335
385
  },
336
- children: [solid && edges.bottom ? /*#__PURE__*/_jsxs(Animated.View, {
337
- testID: `${prefix}-fade-bottom`,
386
+ children: footerBody
387
+ })
388
+ });
389
+
390
+ // The conversation is ONE layer under the chrome and the frame. A turn's own
391
+ // parts may carry a z-index (a search log, a progress card, a date header);
392
+ // without a stacking context of its own, the highest of them would climb
393
+ // over the frame and show in the gutter, or over the header.
394
+ const thread = /*#__PURE__*/_jsx(View, {
395
+ style: {
396
+ flexGrow: 1,
397
+ flexShrink: 1,
398
+ minWidth: 0,
399
+ minHeight: 0,
400
+ flexDirection: 'column',
401
+ zIndex: 0
402
+ },
403
+ children: /*#__PURE__*/_jsx(AiChatFloatingChromeContext.Provider, {
404
+ value: chrome,
405
+ children: children
406
+ })
407
+ });
408
+ if (documentScroll) {
409
+ return /*#__PURE__*/_jsxs(View, {
410
+ testID: testID,
411
+ style: rootStyle,
412
+ children: [backgroundLayer, gutterColor ?
413
+ /*#__PURE__*/
414
+ // The card's frame, pinned to the screen: a ring of the gutter's
415
+ // colour painted over whatever of the conversation scrolls past the
416
+ // card's visible edge, so the card keeps its rounded corners and its
417
+ // gutter at the top and the bottom of the screen. Clipped to the
418
+ // gutter sideways (the family sheet), so it never reaches the columns
419
+ // beside the card.
420
+ _jsx(View, {
421
+ testID: `${prefix}-frame`,
338
422
  pointerEvents: "none",
339
423
  ...HIDDEN_FROM_A11Y,
340
- style: [StyleSheet.absoluteFill, {
341
- flexDirection: 'column'
342
- }, bottomStyle],
343
- children: [/*#__PURE__*/_jsx(View, {
344
- style: {
345
- height: FOOTER_FADE,
346
- transform: [{
347
- rotate: '180deg'
348
- }]
349
- },
350
- children: /*#__PURE__*/_jsx(EdgeScrim, {
351
- color: solid
352
- })
353
- }), /*#__PURE__*/_jsx(View, {
354
- style: {
355
- flex: 1,
356
- backgroundColor: solid
357
- }
358
- })]
359
- }) : null, /*#__PURE__*/_jsx(View, {
360
- pointerEvents: "box-none",
361
- style: {
362
- width: '100%',
363
- flexDirection: 'column',
364
- gap: 10,
365
- paddingLeft: 10,
366
- paddingRight: 10,
367
- paddingTop: FOOTER_FADE,
368
- paddingBottom: 10
369
- },
370
- children: footerBody
371
- })]
372
- })]
424
+ ...dataHook('bloomAiChatFrame'),
425
+ style: [DOCUMENT_LAYER, {
426
+ zIndex: 1,
427
+ boxShadow: `0 0 0 ${SHELL_GUTTER}px ${gutterColor}`
428
+ }]
429
+ }) : null, top, thread, bottom]
430
+ });
431
+ }
432
+ return /*#__PURE__*/_jsxs(View, {
433
+ testID: testID,
434
+ style: [rootStyle, {
435
+ position: 'relative'
436
+ }],
437
+ children: [backgroundLayer, thread, top, bottom]
373
438
  });
374
439
  }
375
440
 
@@ -402,7 +467,19 @@ function FloatingChrome({
402
467
  * motion they pass under it. It also reports its position so the container
403
468
  * knows when to fade each edge. Anywhere else nothing of this exists.
404
469
  */
405
- export const AiChatThread = /*#__PURE__*/forwardRef(function AiChatThread({
470
+ export const AiChatThread = /*#__PURE__*/forwardRef(function AiChatThread(props, ref) {
471
+ const documentScroll = useAiChatShell()?.documentScroll ?? false;
472
+ return documentScroll ? /*#__PURE__*/_jsx(DocumentThread, {
473
+ ref: ref,
474
+ ...props
475
+ }) : /*#__PURE__*/_jsx(ScrollThread, {
476
+ ref: ref,
477
+ ...props
478
+ });
479
+ });
480
+
481
+ /** The thread inside a card of fixed height: a `ScrollView` of its own. */
482
+ const ScrollThread = /*#__PURE__*/forwardRef(function ScrollThread({
406
483
  children,
407
484
  autoFollow = true,
408
485
  followAnimated = true,
@@ -598,4 +675,216 @@ export const AiChatThread = /*#__PURE__*/forwardRef(function AiChatThread({
598
675
  children: children
599
676
  });
600
677
  });
678
+
679
+ /** The window's scroll position and extent, px. */
680
+ function readWindow() {
681
+ const page = document.scrollingElement ?? document.documentElement;
682
+ return {
683
+ y: window.scrollY,
684
+ viewport: window.innerHeight,
685
+ end: page.scrollHeight
686
+ };
687
+ }
688
+ function scrollWindowTo(top, animated) {
689
+ window.scrollTo({
690
+ top,
691
+ behavior: animated ? 'smooth' : 'auto'
692
+ });
693
+ }
694
+
695
+ /**
696
+ * The thread under a document-scrolled shell (web): no scroller of its own —
697
+ * the turns grow the page and the window scrolls them. Same props, same handle,
698
+ * same behaviour: it follows the newest turn, fires `onStartReached` near the
699
+ * top, holds the reader still while a page lands above, and reports its
700
+ * position to the container.
701
+ *
702
+ * Offsets keep the thread's own frame: `0` is the thread's top, so a row's
703
+ * `onLayout` y is still a `scrollToOffset` target, and `contentSize` runs to the
704
+ * END OF THE PAGE, so `contentSize − offset − viewport` is still the distance
705
+ * left to scroll. The browser's scroll anchoring is off inside the thread (the
706
+ * family sheet): it would move the page for growth the thread already accounts
707
+ * for, and only in some browsers.
708
+ */
709
+ const DocumentThread = /*#__PURE__*/forwardRef(function DocumentThread({
710
+ children,
711
+ autoFollow = true,
712
+ followAnimated = true,
713
+ followThreshold,
714
+ onStartReached,
715
+ onStartReachedThreshold = 300,
716
+ maintainStartPosition = false,
717
+ onScroll,
718
+ style,
719
+ testID
720
+ }, ref) {
721
+ const chrome = useContext(AiChatFloatingChromeContext);
722
+ const nodeRef = useRef(null);
723
+ const lastHeight = useRef(0);
724
+ /** An `onStartReached` is out and its page has not landed yet. */
725
+ const pageOut = useRef(false);
726
+ /** The reader has left the top zone, so the next approach may fire again. */
727
+ const startArmed = useRef(true);
728
+
729
+ /** The thread's top, px from the top of the page. */
730
+ const threadTop = useCallback(() => {
731
+ const node = nodeRef.current;
732
+ return node ? node.getBoundingClientRect().top + window.scrollY : 0;
733
+ }, []);
734
+ useImperativeHandle(ref, () => ({
735
+ scrollToEnd: options => scrollWindowTo(readWindow().end, options?.animated ?? true),
736
+ scrollToOffset: ({
737
+ offset: y,
738
+ animated = false
739
+ }) => scrollWindowTo(threadTop() + y, animated),
740
+ getScrollView: () => null
741
+ }), [threadTop]);
742
+
743
+ // The latest handlers, read by one window listener that is never rebound.
744
+ const handlers = useRef({
745
+ chrome,
746
+ onScroll,
747
+ onStartReached,
748
+ onStartReachedThreshold
749
+ });
750
+ useIsomorphicLayoutEffect(() => {
751
+ handlers.current = {
752
+ chrome,
753
+ onScroll,
754
+ onStartReached,
755
+ onStartReachedThreshold
756
+ };
757
+ });
758
+ const report = useCallback(() => {
759
+ const {
760
+ chrome: frame,
761
+ onScroll: listener,
762
+ onStartReached: startReached,
763
+ onStartReachedThreshold: zone
764
+ } = handlers.current;
765
+ const {
766
+ y,
767
+ viewport,
768
+ end
769
+ } = readWindow();
770
+ frame?.reportScroll({
771
+ offset: y,
772
+ viewport,
773
+ content: end
774
+ });
775
+ const top = threadTop();
776
+ const offset = y - top;
777
+ if (startReached) {
778
+ if (offset <= zone) {
779
+ if (startArmed.current) {
780
+ startArmed.current = false;
781
+ pageOut.current = true;
782
+ startReached();
783
+ }
784
+ } else {
785
+ startArmed.current = true;
786
+ }
787
+ }
788
+ listener?.({
789
+ nativeEvent: {
790
+ contentOffset: {
791
+ x: 0,
792
+ y: offset
793
+ },
794
+ layoutMeasurement: {
795
+ width: window.innerWidth,
796
+ height: viewport
797
+ },
798
+ contentSize: {
799
+ width: window.innerWidth,
800
+ height: end - top
801
+ },
802
+ contentInset: {
803
+ top: 0,
804
+ left: 0,
805
+ bottom: 0,
806
+ right: 0
807
+ },
808
+ zoomScale: 1
809
+ }
810
+ });
811
+ }, [threadTop]);
812
+ useEffect(() => {
813
+ let frame = 0;
814
+ const onWindowChange = () => {
815
+ if (frame !== 0) return;
816
+ frame = requestAnimationFrame(() => {
817
+ frame = 0;
818
+ report();
819
+ });
820
+ };
821
+ window.addEventListener('scroll', onWindowChange, {
822
+ passive: true
823
+ });
824
+ window.addEventListener('resize', onWindowChange);
825
+ report();
826
+ return () => {
827
+ cancelAnimationFrame(frame);
828
+ window.removeEventListener('scroll', onWindowChange);
829
+ window.removeEventListener('resize', onWindowChange);
830
+ };
831
+ }, [report]);
832
+ const onLayout = useCallback(event => {
833
+ const height = event.nativeEvent.layout.height;
834
+ const previous = lastHeight.current;
835
+ lastHeight.current = height;
836
+ report();
837
+ // The page is shared with whatever was on it before — another route
838
+ // left it scrolled wherever it was — so a following thread opens at its
839
+ // newest turn rather than at that offset.
840
+ if (previous === 0) {
841
+ if (autoFollow) scrollWindowTo(readWindow().end, false);
842
+ return;
843
+ }
844
+ if (height <= previous + 0.5) return;
845
+ const growth = height - previous;
846
+
847
+ // A page landing above the reader: keep the turn they are on where it is.
848
+ if (maintainStartPosition && pageOut.current) {
849
+ pageOut.current = false;
850
+ window.scrollBy({
851
+ top: growth,
852
+ behavior: 'auto'
853
+ });
854
+ return;
855
+ }
856
+ pageOut.current = false;
857
+ if (!autoFollow) return;
858
+ if (followThreshold !== undefined) {
859
+ // Measured against the page BEFORE the growth: was the reader near the
860
+ // end when this arrived?
861
+ const {
862
+ y,
863
+ viewport,
864
+ end
865
+ } = readWindow();
866
+ if (end - growth - y - viewport > followThreshold) return;
867
+ }
868
+ scrollWindowTo(readWindow().end, followAnimated);
869
+ }, [autoFollow, followAnimated, followThreshold, maintainStartPosition, report]);
870
+ return /*#__PURE__*/_jsx(View, {
871
+ ref: nodeRef,
872
+ testID: testID,
873
+ ...dataHook('bloomAiChatThread'),
874
+ onLayout: onLayout,
875
+ style: [{
876
+ // Fills the card between the header and the composer, and anchors a
877
+ // short exchange just above the composer, as the scroller does.
878
+ width: '100%',
879
+ flexGrow: 1,
880
+ flexDirection: 'column',
881
+ justifyContent: 'flex-end',
882
+ gap: 12,
883
+ paddingLeft: 16,
884
+ paddingRight: 16,
885
+ paddingTop: THREAD_PAD_TOP
886
+ }, style],
887
+ children: children
888
+ });
889
+ });
601
890
  //# sourceMappingURL=AiChatContainer.js.map