@oxy.so/bloom 4.12.0 → 4.13.0

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 +390 -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 +394 -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 +361 -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,110 @@ 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
+ const thread = /*#__PURE__*/_jsx(AiChatFloatingChromeContext.Provider, {
390
+ value: chrome,
391
+ children: children
392
+ });
393
+ if (documentScroll) {
394
+ return /*#__PURE__*/_jsxs(View, {
395
+ testID: testID,
396
+ style: rootStyle,
397
+ children: [backgroundLayer, gutterColor ?
398
+ /*#__PURE__*/
399
+ // The card's frame, pinned to the screen: a ring of the gutter's
400
+ // colour painted over whatever of the conversation scrolls past the
401
+ // card's visible edge, so the card keeps its rounded corners and its
402
+ // gutter at the top and the bottom of the screen. Clipped to the
403
+ // gutter sideways (the family sheet), so it never reaches the columns
404
+ // beside the card.
405
+ _jsx(View, {
406
+ testID: `${prefix}-frame`,
338
407
  pointerEvents: "none",
339
408
  ...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
- })]
409
+ ...dataHook('bloomAiChatFrame'),
410
+ style: [DOCUMENT_LAYER, {
411
+ zIndex: 1,
412
+ boxShadow: `0 0 0 ${SHELL_GUTTER}px ${gutterColor}`
413
+ }]
414
+ }) : null, top, thread, bottom]
415
+ });
416
+ }
417
+ return /*#__PURE__*/_jsxs(View, {
418
+ testID: testID,
419
+ style: [rootStyle, {
420
+ position: 'relative'
421
+ }],
422
+ children: [backgroundLayer, thread, top, bottom]
373
423
  });
374
424
  }
375
425
 
@@ -402,7 +452,19 @@ function FloatingChrome({
402
452
  * motion they pass under it. It also reports its position so the container
403
453
  * knows when to fade each edge. Anywhere else nothing of this exists.
404
454
  */
405
- export const AiChatThread = /*#__PURE__*/forwardRef(function AiChatThread({
455
+ export const AiChatThread = /*#__PURE__*/forwardRef(function AiChatThread(props, ref) {
456
+ const documentScroll = useAiChatShell()?.documentScroll ?? false;
457
+ return documentScroll ? /*#__PURE__*/_jsx(DocumentThread, {
458
+ ref: ref,
459
+ ...props
460
+ }) : /*#__PURE__*/_jsx(ScrollThread, {
461
+ ref: ref,
462
+ ...props
463
+ });
464
+ });
465
+
466
+ /** The thread inside a card of fixed height: a `ScrollView` of its own. */
467
+ const ScrollThread = /*#__PURE__*/forwardRef(function ScrollThread({
406
468
  children,
407
469
  autoFollow = true,
408
470
  followAnimated = true,
@@ -598,4 +660,216 @@ export const AiChatThread = /*#__PURE__*/forwardRef(function AiChatThread({
598
660
  children: children
599
661
  });
600
662
  });
663
+
664
+ /** The window's scroll position and extent, px. */
665
+ function readWindow() {
666
+ const page = document.scrollingElement ?? document.documentElement;
667
+ return {
668
+ y: window.scrollY,
669
+ viewport: window.innerHeight,
670
+ end: page.scrollHeight
671
+ };
672
+ }
673
+ function scrollWindowTo(top, animated) {
674
+ window.scrollTo({
675
+ top,
676
+ behavior: animated ? 'smooth' : 'auto'
677
+ });
678
+ }
679
+
680
+ /**
681
+ * The thread under a document-scrolled shell (web): no scroller of its own —
682
+ * the turns grow the page and the window scrolls them. Same props, same handle,
683
+ * same behaviour: it follows the newest turn, fires `onStartReached` near the
684
+ * top, holds the reader still while a page lands above, and reports its
685
+ * position to the container.
686
+ *
687
+ * Offsets keep the thread's own frame: `0` is the thread's top, so a row's
688
+ * `onLayout` y is still a `scrollToOffset` target, and `contentSize` runs to the
689
+ * END OF THE PAGE, so `contentSize − offset − viewport` is still the distance
690
+ * left to scroll. The browser's scroll anchoring is off inside the thread (the
691
+ * family sheet): it would move the page for growth the thread already accounts
692
+ * for, and only in some browsers.
693
+ */
694
+ const DocumentThread = /*#__PURE__*/forwardRef(function DocumentThread({
695
+ children,
696
+ autoFollow = true,
697
+ followAnimated = true,
698
+ followThreshold,
699
+ onStartReached,
700
+ onStartReachedThreshold = 300,
701
+ maintainStartPosition = false,
702
+ onScroll,
703
+ style,
704
+ testID
705
+ }, ref) {
706
+ const chrome = useContext(AiChatFloatingChromeContext);
707
+ const nodeRef = useRef(null);
708
+ const lastHeight = useRef(0);
709
+ /** An `onStartReached` is out and its page has not landed yet. */
710
+ const pageOut = useRef(false);
711
+ /** The reader has left the top zone, so the next approach may fire again. */
712
+ const startArmed = useRef(true);
713
+
714
+ /** The thread's top, px from the top of the page. */
715
+ const threadTop = useCallback(() => {
716
+ const node = nodeRef.current;
717
+ return node ? node.getBoundingClientRect().top + window.scrollY : 0;
718
+ }, []);
719
+ useImperativeHandle(ref, () => ({
720
+ scrollToEnd: options => scrollWindowTo(readWindow().end, options?.animated ?? true),
721
+ scrollToOffset: ({
722
+ offset: y,
723
+ animated = false
724
+ }) => scrollWindowTo(threadTop() + y, animated),
725
+ getScrollView: () => null
726
+ }), [threadTop]);
727
+
728
+ // The latest handlers, read by one window listener that is never rebound.
729
+ const handlers = useRef({
730
+ chrome,
731
+ onScroll,
732
+ onStartReached,
733
+ onStartReachedThreshold
734
+ });
735
+ useIsomorphicLayoutEffect(() => {
736
+ handlers.current = {
737
+ chrome,
738
+ onScroll,
739
+ onStartReached,
740
+ onStartReachedThreshold
741
+ };
742
+ });
743
+ const report = useCallback(() => {
744
+ const {
745
+ chrome: frame,
746
+ onScroll: listener,
747
+ onStartReached: startReached,
748
+ onStartReachedThreshold: zone
749
+ } = handlers.current;
750
+ const {
751
+ y,
752
+ viewport,
753
+ end
754
+ } = readWindow();
755
+ frame?.reportScroll({
756
+ offset: y,
757
+ viewport,
758
+ content: end
759
+ });
760
+ const top = threadTop();
761
+ const offset = y - top;
762
+ if (startReached) {
763
+ if (offset <= zone) {
764
+ if (startArmed.current) {
765
+ startArmed.current = false;
766
+ pageOut.current = true;
767
+ startReached();
768
+ }
769
+ } else {
770
+ startArmed.current = true;
771
+ }
772
+ }
773
+ listener?.({
774
+ nativeEvent: {
775
+ contentOffset: {
776
+ x: 0,
777
+ y: offset
778
+ },
779
+ layoutMeasurement: {
780
+ width: window.innerWidth,
781
+ height: viewport
782
+ },
783
+ contentSize: {
784
+ width: window.innerWidth,
785
+ height: end - top
786
+ },
787
+ contentInset: {
788
+ top: 0,
789
+ left: 0,
790
+ bottom: 0,
791
+ right: 0
792
+ },
793
+ zoomScale: 1
794
+ }
795
+ });
796
+ }, [threadTop]);
797
+ useEffect(() => {
798
+ let frame = 0;
799
+ const onWindowChange = () => {
800
+ if (frame !== 0) return;
801
+ frame = requestAnimationFrame(() => {
802
+ frame = 0;
803
+ report();
804
+ });
805
+ };
806
+ window.addEventListener('scroll', onWindowChange, {
807
+ passive: true
808
+ });
809
+ window.addEventListener('resize', onWindowChange);
810
+ report();
811
+ return () => {
812
+ cancelAnimationFrame(frame);
813
+ window.removeEventListener('scroll', onWindowChange);
814
+ window.removeEventListener('resize', onWindowChange);
815
+ };
816
+ }, [report]);
817
+ const onLayout = useCallback(event => {
818
+ const height = event.nativeEvent.layout.height;
819
+ const previous = lastHeight.current;
820
+ lastHeight.current = height;
821
+ report();
822
+ // The page is shared with whatever was on it before — another route
823
+ // left it scrolled wherever it was — so a following thread opens at its
824
+ // newest turn rather than at that offset.
825
+ if (previous === 0) {
826
+ if (autoFollow) scrollWindowTo(readWindow().end, false);
827
+ return;
828
+ }
829
+ if (height <= previous + 0.5) return;
830
+ const growth = height - previous;
831
+
832
+ // A page landing above the reader: keep the turn they are on where it is.
833
+ if (maintainStartPosition && pageOut.current) {
834
+ pageOut.current = false;
835
+ window.scrollBy({
836
+ top: growth,
837
+ behavior: 'auto'
838
+ });
839
+ return;
840
+ }
841
+ pageOut.current = false;
842
+ if (!autoFollow) return;
843
+ if (followThreshold !== undefined) {
844
+ // Measured against the page BEFORE the growth: was the reader near the
845
+ // end when this arrived?
846
+ const {
847
+ y,
848
+ viewport,
849
+ end
850
+ } = readWindow();
851
+ if (end - growth - y - viewport > followThreshold) return;
852
+ }
853
+ scrollWindowTo(readWindow().end, followAnimated);
854
+ }, [autoFollow, followAnimated, followThreshold, maintainStartPosition, report]);
855
+ return /*#__PURE__*/_jsx(View, {
856
+ ref: nodeRef,
857
+ testID: testID,
858
+ ...dataHook('bloomAiChatThread'),
859
+ onLayout: onLayout,
860
+ style: [{
861
+ // Fills the card between the header and the composer, and anchors a
862
+ // short exchange just above the composer, as the scroller does.
863
+ width: '100%',
864
+ flexGrow: 1,
865
+ flexDirection: 'column',
866
+ justifyContent: 'flex-end',
867
+ gap: 12,
868
+ paddingLeft: 16,
869
+ paddingRight: 16,
870
+ paddingTop: THREAD_PAD_TOP
871
+ }, style],
872
+ children: children
873
+ });
874
+ });
601
875
  //# sourceMappingURL=AiChatContainer.js.map