@oxy.so/bloom 4.11.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 (56) hide show
  1. package/docs/ai-chat.mdx +31 -5
  2. package/docs/outline-nav.mdx +22 -0
  3. package/lib/commonjs/ai-chat/AiChatContainer.js +390 -116
  4. package/lib/commonjs/ai-chat/AiChatContainer.js.map +1 -1
  5. package/lib/commonjs/ai-chat/AiChatShell.js +211 -176
  6. package/lib/commonjs/ai-chat/AiChatShell.js.map +1 -1
  7. package/lib/commonjs/ai-chat/context.js +10 -1
  8. package/lib/commonjs/ai-chat/context.js.map +1 -1
  9. package/lib/commonjs/ai-chat/shared.js +41 -2
  10. package/lib/commonjs/ai-chat/shared.js.map +1 -1
  11. package/lib/commonjs/outline-nav/OutlineNav.js +15 -1
  12. package/lib/commonjs/outline-nav/OutlineNav.js.map +1 -1
  13. package/lib/module/ai-chat/AiChatContainer.js +394 -120
  14. package/lib/module/ai-chat/AiChatContainer.js.map +1 -1
  15. package/lib/module/ai-chat/AiChatShell.js +213 -178
  16. package/lib/module/ai-chat/AiChatShell.js.map +1 -1
  17. package/lib/module/ai-chat/context.js +9 -0
  18. package/lib/module/ai-chat/context.js.map +1 -1
  19. package/lib/module/ai-chat/shared.js +40 -1
  20. package/lib/module/ai-chat/shared.js.map +1 -1
  21. package/lib/module/outline-nav/OutlineNav.js +15 -1
  22. package/lib/module/outline-nav/OutlineNav.js.map +1 -1
  23. package/lib/typescript/commonjs/ai-chat/AiChatContainer.d.ts +8 -3
  24. package/lib/typescript/commonjs/ai-chat/AiChatContainer.d.ts.map +1 -1
  25. package/lib/typescript/commonjs/ai-chat/AiChatShell.d.ts +1 -1
  26. package/lib/typescript/commonjs/ai-chat/AiChatShell.d.ts.map +1 -1
  27. package/lib/typescript/commonjs/ai-chat/context.d.ts +14 -0
  28. package/lib/typescript/commonjs/ai-chat/context.d.ts.map +1 -1
  29. package/lib/typescript/commonjs/ai-chat/shared.d.ts +24 -2
  30. package/lib/typescript/commonjs/ai-chat/shared.d.ts.map +1 -1
  31. package/lib/typescript/commonjs/ai-chat/types.d.ts +19 -0
  32. package/lib/typescript/commonjs/ai-chat/types.d.ts.map +1 -1
  33. package/lib/typescript/commonjs/outline-nav/OutlineNav.d.ts.map +1 -1
  34. package/lib/typescript/commonjs/outline-nav/types.d.ts +8 -0
  35. package/lib/typescript/commonjs/outline-nav/types.d.ts.map +1 -1
  36. package/lib/typescript/module/ai-chat/AiChatContainer.d.ts +8 -3
  37. package/lib/typescript/module/ai-chat/AiChatContainer.d.ts.map +1 -1
  38. package/lib/typescript/module/ai-chat/AiChatShell.d.ts +1 -1
  39. package/lib/typescript/module/ai-chat/AiChatShell.d.ts.map +1 -1
  40. package/lib/typescript/module/ai-chat/context.d.ts +14 -0
  41. package/lib/typescript/module/ai-chat/context.d.ts.map +1 -1
  42. package/lib/typescript/module/ai-chat/shared.d.ts +24 -2
  43. package/lib/typescript/module/ai-chat/shared.d.ts.map +1 -1
  44. package/lib/typescript/module/ai-chat/types.d.ts +19 -0
  45. package/lib/typescript/module/ai-chat/types.d.ts.map +1 -1
  46. package/lib/typescript/module/outline-nav/OutlineNav.d.ts.map +1 -1
  47. package/lib/typescript/module/outline-nav/types.d.ts +8 -0
  48. package/lib/typescript/module/outline-nav/types.d.ts.map +1 -1
  49. package/package.json +1 -1
  50. package/src/ai-chat/AiChatContainer.tsx +361 -88
  51. package/src/ai-chat/AiChatShell.tsx +39 -13
  52. package/src/ai-chat/context.ts +15 -0
  53. package/src/ai-chat/shared.ts +45 -2
  54. package/src/ai-chat/types.ts +19 -0
  55. package/src/outline-nav/OutlineNav.tsx +23 -2
  56. package/src/outline-nav/types.ts +8 -0
@@ -2,6 +2,7 @@ import React, {
2
2
  forwardRef,
3
3
  useCallback,
4
4
  useContext,
5
+ useEffect,
5
6
  useImperativeHandle,
6
7
  useMemo,
7
8
  useRef,
@@ -30,12 +31,23 @@ import { parseRgba } from '../theme/color-utils';
30
31
  import { useIsomorphicLayoutEffect } from '../theme/use-isomorphic-layout-effect';
31
32
  import { GlyphAction } from './AiChatControls';
32
33
  import {
34
+ AiChatDocumentGutterContext,
33
35
  AiChatFloatingChromeContext,
36
+ useAiChatShell,
34
37
  type AiChatChromeInsets,
35
38
  type AiChatFloatingChrome,
36
39
  type AiChatScrollMetrics,
37
40
  } from './context';
38
- import { CONTAINER_RADIUS, dataHook, IS_WEB, useAiChatPalette, useAiChatWebCss } from './shared';
41
+ import {
42
+ CONTAINER_RADIUS,
43
+ dataHook,
44
+ DOCUMENT_LAYER,
45
+ IS_WEB,
46
+ SHELL_GUTTER,
47
+ useAiChatPalette,
48
+ useAiChatWebCss,
49
+ } from './shared';
50
+ import { WEB_POSITION_STICKY, webViewportHeightMinus } from '../styles/web-view-style';
39
51
  import type { AiChatContainerProps, AiChatThreadHandle, AiChatThreadProps } from './types';
40
52
 
41
53
  const DEFAULT_LABELS = {
@@ -46,12 +58,8 @@ const DEFAULT_LABELS = {
46
58
 
47
59
  /** The thread's own top padding: the gap between the header and the first turn. */
48
60
  const THREAD_PAD_TOP = 16;
49
- /**
50
- * `floatingChrome`: the footer's fade strip, px — the footer's top padding, so
51
- * the last turn at rest ends where the fade begins, and the distance over which
52
- * the bottom fade ramps in as the transcript slides under it.
53
- */
54
- const FOOTER_FADE = 24;
61
+ /** The footer's top padding: the gap between the last turn at rest and the composer. */
62
+ const FOOTER_GAP = 12;
55
63
  /** Below this many px of overlap an edge counts as clear (sub-pixel scroll noise). */
56
64
  const EDGE_THRESHOLD = 1;
57
65
 
@@ -86,14 +94,19 @@ const HIDDEN_FROM_A11Y = {
86
94
  * an edge scrim of the surface (opaque at the top, gone
87
95
  * `SCRIM_TAIL_RATIO` below the block) fades in over the first 16px
88
96
  * of transcript scrolled under it
89
- * footer absolute at the bottom, pt 24 instead of 12: the 24px strip is a
90
- * scrim ramp and the rest is solid surface, faded in over the first
91
- * 24px of transcript below the fold
97
+ * footer absolute at the bottom, with no band of its own: the transcript
98
+ * passes behind the composer and stays in sight around it
92
99
  *
93
100
  * Both blocks are `pointerEvents="box-none"`, so the transcript under their
94
101
  * empty space still scrolls and takes clicks. The measured heights reach an
95
102
  * `AiChatThread` child through context (its content padding) and any other
96
103
  * child through `useAiChatChromeInsets()`.
104
+ *
105
+ * Under a shell with `scroll="document"` the chrome always floats, since the
106
+ * document is what moves: the header and the footer are `sticky` in the flow,
107
+ * pinned to the screen's top and bottom gutter, the `background` slot and the
108
+ * card's frame are layers the size of the screen, and the card grows with the
109
+ * conversation. At rest nothing overlaps, so the insets are zero.
97
110
  */
98
111
  export function AiChatContainer({
99
112
  project,
@@ -118,6 +131,8 @@ export function AiChatContainer({
118
131
  useAiChatWebCss();
119
132
  const palette = useAiChatPalette();
120
133
  const l = useMemo(() => ({ ...DEFAULT_LABELS, ...labels }), [labels]);
134
+ const documentScroll = useAiChatShell()?.documentScroll ?? false;
135
+ const gutterColor = useContext(AiChatDocumentGutterContext);
121
136
 
122
137
  const hasCrumbRow = title != null || project != null || onShare || onMore || actions != null;
123
138
  const crumbRow = hasCrumbRow ? (
@@ -178,6 +193,41 @@ export function AiChatContainer({
178
193
  style,
179
194
  ];
180
195
 
196
+ if (documentScroll) {
197
+ return (
198
+ <FloatingChrome
199
+ documentScroll
200
+ testID={testID}
201
+ rootStyle={[
202
+ {
203
+ // Grows with the conversation, one screen at the least; nothing
204
+ // clips it, so the header and composer inside can stick.
205
+ minHeight: webViewportHeightMinus(SHELL_GUTTER * 2),
206
+ minWidth: 0,
207
+ flex: 1,
208
+ flexDirection: 'column',
209
+ borderRadius: CONTAINER_RADIUS,
210
+ backgroundColor: surface ? palette.secondary : 'transparent',
211
+ },
212
+ style,
213
+ ]}
214
+ backgroundLayer={
215
+ background ? (
216
+ <View pointerEvents="none" style={[DOCUMENT_LAYER, { overflow: 'hidden' }]}>
217
+ {background}
218
+ </View>
219
+ ) : null
220
+ }
221
+ header={header}
222
+ crumbRow={crumbRow}
223
+ footerBody={footerBody}
224
+ fadeColor={surface ? palette.secondary : null}
225
+ gutterColor={gutterColor}>
226
+ {children}
227
+ </FloatingChrome>
228
+ );
229
+ }
230
+
181
231
  if (floatingChrome) {
182
232
  return (
183
233
  <FloatingChrome
@@ -209,28 +259,36 @@ export function AiChatContainer({
209
259
  }
210
260
 
211
261
  interface FloatingChromeProps {
262
+ /** The page scrolls the document: the chrome is sticky in the flow, not absolute. */
263
+ documentScroll?: boolean;
212
264
  testID?: string;
213
265
  rootStyle: StyleProp<ViewStyle>;
214
266
  backgroundLayer: ReactNode;
215
267
  header: ReactNode;
216
268
  crumbRow: ReactNode;
217
269
  footerBody: ReactNode;
218
- /** The opaque surface the edges fade to, or `null` for no fades. */
270
+ /** The opaque surface the header's edge fades to, or `null` for no fade. */
219
271
  fadeColor: string | null;
272
+ /** `documentScroll`: the gutter around the card, which its frame masks in. */
273
+ gutterColor?: string | null;
220
274
  children: ReactNode;
221
275
  }
222
276
 
277
+ const NO_INSETS: AiChatChromeInsets = { top: 0, bottom: 0 };
278
+
223
279
  /**
224
- * `AiChatContainer floatingChrome`: the same parts, the chrome floated over the
225
- * transcript. Everything it measures lives here, so the stacked path above
226
- * carries none of the state.
280
+ * `AiChatContainer floatingChrome` (and every container under a document-
281
+ * scrolled shell): the same parts, the chrome floated over the transcript.
282
+ * Everything it measures lives here, so the stacked path above carries none of
283
+ * the state.
227
284
  *
228
- * The edge opacities are shared values written from the thread's scroll
229
- * reports, so a scroll frame re-renders nothing; the booleans only mount and
230
- * unmount the scrims, and change once per crossing. Same model as the
285
+ * The header's edge opacity is a shared value written from the thread's scroll
286
+ * reports, so a scroll frame re-renders nothing; the boolean only mounts and
287
+ * unmounts the scrim, and changes once per crossing. Same model as the
231
288
  * sidebar's `SidebarScrollArea`, whose scrim this reuses.
232
289
  */
233
290
  function FloatingChrome({
291
+ documentScroll = false,
234
292
  testID,
235
293
  rootStyle,
236
294
  backgroundLayer,
@@ -238,39 +296,44 @@ function FloatingChrome({
238
296
  crumbRow,
239
297
  footerBody,
240
298
  fadeColor,
299
+ gutterColor = null,
241
300
  children,
242
301
  }: FloatingChromeProps) {
243
- const [insets, setInsets] = useState<AiChatChromeInsets>({ top: 0, bottom: 0 });
244
- const [edges, setEdges] = useState({ top: false, bottom: false });
302
+ const [insets, setInsets] = useState<AiChatChromeInsets>(NO_INSETS);
303
+ const [headerHeight, setHeaderHeight] = useState(0);
304
+ const [scrolled, setScrolled] = useState(false);
245
305
  const topFade = useSharedValue(0);
246
- const bottomFade = useSharedValue(0);
247
306
 
248
- const onHeaderLayout = useCallback((event: LayoutChangeEvent) => {
249
- const top = Math.round(event.nativeEvent.layout.height);
250
- setInsets((previous) => (previous.top === top ? previous : { ...previous, top }));
251
- }, []);
252
- const onFooterLayout = useCallback((event: LayoutChangeEvent) => {
253
- const bottom = Math.round(event.nativeEvent.layout.height);
254
- setInsets((previous) => (previous.bottom === bottom ? previous : { ...previous, bottom }));
255
- }, []);
307
+ const onHeaderLayout = useCallback(
308
+ (event: LayoutChangeEvent) => {
309
+ const top = Math.round(event.nativeEvent.layout.height);
310
+ setHeaderHeight(top);
311
+ // In the flow the header covers nothing at rest: no inset to publish.
312
+ if (!documentScroll) setInsets((previous) => (previous.top === top ? previous : { ...previous, top }));
313
+ },
314
+ [documentScroll],
315
+ );
316
+ const onFooterLayout = useCallback(
317
+ (event: LayoutChangeEvent) => {
318
+ if (documentScroll) return;
319
+ const bottom = Math.round(event.nativeEvent.layout.height);
320
+ setInsets((previous) => (previous.bottom === bottom ? previous : { ...previous, bottom }));
321
+ },
322
+ [documentScroll],
323
+ );
256
324
 
257
325
  const reportScroll = useCallback(
258
326
  ({ offset, viewport, content }: AiChatScrollMetrics) => {
259
327
  const range = Math.max(0, content - viewport);
260
328
  const y = Math.max(0, Math.min(offset, range));
261
329
  const overflow = viewport > 0 && range > EDGE_THRESHOLD;
262
- // The first turn rests THREAD_PAD_TOP below the header and the last one
263
- // right on the footer's fade strip, so the overlap starts at the first px
264
- // scrolled and each ramp spans the gap it is closing.
265
- const top = overflow && y > EDGE_THRESHOLD;
266
- const bottom = overflow && range - y > EDGE_THRESHOLD;
330
+ // The first turn rests THREAD_PAD_TOP below the header, so the overlap
331
+ // starts at the first px scrolled and the ramp spans the gap it closes.
267
332
  topFade.value = overflow ? Math.min(1, y / THREAD_PAD_TOP) : 0;
268
- bottomFade.value = overflow ? Math.min(1, (range - y) / FOOTER_FADE) : 0;
269
- setEdges((previous) =>
270
- previous.top === top && previous.bottom === bottom ? previous : { top, bottom },
271
- );
333
+ const next = overflow && y > EDGE_THRESHOLD;
334
+ setScrolled((previous) => (previous === next ? previous : next));
272
335
  },
273
- [topFade, bottomFade],
336
+ [topFade],
274
337
  );
275
338
 
276
339
  const chrome = useMemo<AiChatFloatingChrome>(() => ({ insets, reportScroll }), [insets, reportScroll]);
@@ -280,66 +343,92 @@ function FloatingChrome({
280
343
  const solid = parsed ? `rgb(${parsed.r}, ${parsed.g}, ${parsed.b})` : fadeColor;
281
344
  const alpha = parsed?.a ?? 1;
282
345
  const topStyle = useAnimatedStyle(() => ({ opacity: topFade.value * alpha }), [topFade, alpha]);
283
- const bottomStyle = useAnimatedStyle(() => ({ opacity: bottomFade.value * alpha }), [bottomFade, alpha]);
284
346
  const prefix = testID ?? 'ai-chat';
285
347
 
286
- return (
287
- <View testID={testID} style={[rootStyle, { position: 'relative' }]}>
288
- {backgroundLayer}
289
- <AiChatFloatingChromeContext.Provider value={chrome}>{children}</AiChatFloatingChromeContext.Provider>
348
+ // Absolute over a card of fixed height; sticky in the flow of a card that
349
+ // grows with the document, pinned to the screen's gutter.
350
+ const headerPosition: ViewStyle = documentScroll
351
+ ? { position: WEB_POSITION_STICKY, top: SHELL_GUTTER, zIndex: 2 }
352
+ : { position: 'absolute', top: 0, left: 0, right: 0 };
353
+ const footerPosition: ViewStyle = documentScroll
354
+ ? { position: WEB_POSITION_STICKY, bottom: SHELL_GUTTER, zIndex: 2 }
355
+ : { position: 'absolute', left: 0, right: 0, bottom: 0 };
356
+
357
+ const top = (
358
+ <View testID={`${prefix}-chrome-top`} pointerEvents="box-none" onLayout={onHeaderLayout} style={headerPosition}>
359
+ {solid && scrolled && headerHeight > 0 ? (
360
+ <Animated.View
361
+ testID={`${prefix}-fade-top`}
362
+ pointerEvents="none"
363
+ {...HIDDEN_FROM_A11Y}
364
+ style={[
365
+ {
366
+ position: 'absolute',
367
+ top: 0,
368
+ left: 0,
369
+ right: 0,
370
+ // Past the block's own bottom: a ramp that ends where the layout
371
+ // does is a line across the transcript again.
372
+ height: Math.round(headerHeight * (1 + SCRIM_TAIL_RATIO)),
373
+ // The card's own corners, since nothing clips a document card.
374
+ borderTopLeftRadius: CONTAINER_RADIUS,
375
+ borderTopRightRadius: CONTAINER_RADIUS,
376
+ overflow: 'hidden',
377
+ },
378
+ topStyle,
379
+ ]}>
380
+ <EdgeScrim color={solid} />
381
+ </Animated.View>
382
+ ) : null}
383
+ {header}
384
+ {crumbRow}
385
+ </View>
386
+ );
290
387
 
388
+ const bottom = (
389
+ <View testID={`${prefix}-chrome-bottom`} pointerEvents="box-none" onLayout={onFooterLayout} style={footerPosition}>
291
390
  <View
292
- testID={`${prefix}-chrome-top`}
293
391
  pointerEvents="box-none"
294
- onLayout={onHeaderLayout}
295
- style={{ position: 'absolute', top: 0, left: 0, right: 0 }}>
296
- {solid && edges.top && insets.top > 0 ? (
297
- <Animated.View
298
- testID={`${prefix}-fade-top`}
299
- pointerEvents="none"
300
- {...HIDDEN_FROM_A11Y}
301
- style={[
302
- {
303
- position: 'absolute',
304
- top: 0,
305
- left: 0,
306
- right: 0,
307
- // Past the block's own bottom: a ramp that ends where the layout
308
- // does is a line across the transcript again.
309
- height: Math.round(insets.top * (1 + SCRIM_TAIL_RATIO)),
310
- },
311
- topStyle,
312
- ]}>
313
- <EdgeScrim color={solid} />
314
- </Animated.View>
315
- ) : null}
316
- {header}
317
- {crumbRow}
392
+ style={{ width: '100%', flexDirection: 'column', gap: 10, paddingLeft: 10, paddingRight: 10, paddingTop: FOOTER_GAP, paddingBottom: 10 }}>
393
+ {footerBody}
318
394
  </View>
395
+ </View>
396
+ );
319
397
 
320
- <View
321
- testID={`${prefix}-chrome-bottom`}
322
- pointerEvents="box-none"
323
- onLayout={onFooterLayout}
324
- style={{ position: 'absolute', left: 0, right: 0, bottom: 0 }}>
325
- {solid && edges.bottom ? (
326
- <Animated.View
327
- testID={`${prefix}-fade-bottom`}
398
+ const thread = <AiChatFloatingChromeContext.Provider value={chrome}>{children}</AiChatFloatingChromeContext.Provider>;
399
+
400
+ if (documentScroll) {
401
+ return (
402
+ <View testID={testID} style={rootStyle}>
403
+ {backgroundLayer}
404
+ {gutterColor ? (
405
+ // The card's frame, pinned to the screen: a ring of the gutter's
406
+ // colour painted over whatever of the conversation scrolls past the
407
+ // card's visible edge, so the card keeps its rounded corners and its
408
+ // gutter at the top and the bottom of the screen. Clipped to the
409
+ // gutter sideways (the family sheet), so it never reaches the columns
410
+ // beside the card.
411
+ <View
412
+ testID={`${prefix}-frame`}
328
413
  pointerEvents="none"
329
414
  {...HIDDEN_FROM_A11Y}
330
- style={[StyleSheet.absoluteFill, { flexDirection: 'column' }, bottomStyle]}>
331
- <View style={{ height: FOOTER_FADE, transform: [{ rotate: '180deg' }] }}>
332
- <EdgeScrim color={solid} />
333
- </View>
334
- <View style={{ flex: 1, backgroundColor: solid }} />
335
- </Animated.View>
415
+ {...dataHook('bloomAiChatFrame')}
416
+ style={[DOCUMENT_LAYER, { zIndex: 1, boxShadow: `0 0 0 ${SHELL_GUTTER}px ${gutterColor}` }]}
417
+ />
336
418
  ) : null}
337
- <View
338
- pointerEvents="box-none"
339
- style={{ width: '100%', flexDirection: 'column', gap: 10, paddingLeft: 10, paddingRight: 10, paddingTop: FOOTER_FADE, paddingBottom: 10 }}>
340
- {footerBody}
341
- </View>
419
+ {top}
420
+ {thread}
421
+ {bottom}
342
422
  </View>
423
+ );
424
+ }
425
+
426
+ return (
427
+ <View testID={testID} style={[rootStyle, { position: 'relative' }]}>
428
+ {backgroundLayer}
429
+ {thread}
430
+ {top}
431
+ {bottom}
343
432
  </View>
344
433
  );
345
434
  }
@@ -373,7 +462,13 @@ function FloatingChrome({
373
462
  * motion they pass under it. It also reports its position so the container
374
463
  * knows when to fade each edge. Anywhere else nothing of this exists.
375
464
  */
376
- export const AiChatThread = forwardRef<AiChatThreadHandle, AiChatThreadProps>(function AiChatThread(
465
+ export const AiChatThread = forwardRef<AiChatThreadHandle, AiChatThreadProps>(function AiChatThread(props, ref) {
466
+ const documentScroll = useAiChatShell()?.documentScroll ?? false;
467
+ return documentScroll ? <DocumentThread ref={ref} {...props} /> : <ScrollThread ref={ref} {...props} />;
468
+ });
469
+
470
+ /** The thread inside a card of fixed height: a `ScrollView` of its own. */
471
+ const ScrollThread = forwardRef<AiChatThreadHandle, AiChatThreadProps>(function ScrollThread(
377
472
  {
378
473
  children,
379
474
  autoFollow = true,
@@ -544,3 +639,181 @@ export const AiChatThread = forwardRef<AiChatThreadHandle, AiChatThreadProps>(fu
544
639
  </ScrollView>
545
640
  );
546
641
  });
642
+
643
+ /** The window's scroll position and extent, px. */
644
+ function readWindow() {
645
+ const page = document.scrollingElement ?? document.documentElement;
646
+ return { y: window.scrollY, viewport: window.innerHeight, end: page.scrollHeight };
647
+ }
648
+
649
+ function scrollWindowTo(top: number, animated: boolean) {
650
+ window.scrollTo({ top, behavior: animated ? 'smooth' : 'auto' });
651
+ }
652
+
653
+ /**
654
+ * The thread under a document-scrolled shell (web): no scroller of its own —
655
+ * the turns grow the page and the window scrolls them. Same props, same handle,
656
+ * same behaviour: it follows the newest turn, fires `onStartReached` near the
657
+ * top, holds the reader still while a page lands above, and reports its
658
+ * position to the container.
659
+ *
660
+ * Offsets keep the thread's own frame: `0` is the thread's top, so a row's
661
+ * `onLayout` y is still a `scrollToOffset` target, and `contentSize` runs to the
662
+ * END OF THE PAGE, so `contentSize − offset − viewport` is still the distance
663
+ * left to scroll. The browser's scroll anchoring is off inside the thread (the
664
+ * family sheet): it would move the page for growth the thread already accounts
665
+ * for, and only in some browsers.
666
+ */
667
+ const DocumentThread = forwardRef<AiChatThreadHandle, AiChatThreadProps>(function DocumentThread(
668
+ {
669
+ children,
670
+ autoFollow = true,
671
+ followAnimated = true,
672
+ followThreshold,
673
+ onStartReached,
674
+ onStartReachedThreshold = 300,
675
+ maintainStartPosition = false,
676
+ onScroll,
677
+ style,
678
+ testID,
679
+ },
680
+ ref,
681
+ ) {
682
+ const chrome = useContext(AiChatFloatingChromeContext);
683
+ const nodeRef = useRef<View>(null);
684
+ const lastHeight = useRef(0);
685
+ /** An `onStartReached` is out and its page has not landed yet. */
686
+ const pageOut = useRef(false);
687
+ /** The reader has left the top zone, so the next approach may fire again. */
688
+ const startArmed = useRef(true);
689
+
690
+ /** The thread's top, px from the top of the page. */
691
+ const threadTop = useCallback(() => {
692
+ const node = nodeRef.current as unknown as HTMLElement | null;
693
+ return node ? node.getBoundingClientRect().top + window.scrollY : 0;
694
+ }, []);
695
+
696
+ useImperativeHandle(
697
+ ref,
698
+ (): AiChatThreadHandle => ({
699
+ scrollToEnd: (options) => scrollWindowTo(readWindow().end, options?.animated ?? true),
700
+ scrollToOffset: ({ offset: y, animated = false }) => scrollWindowTo(threadTop() + y, animated),
701
+ getScrollView: () => null,
702
+ }),
703
+ [threadTop],
704
+ );
705
+
706
+ // The latest handlers, read by one window listener that is never rebound.
707
+ const handlers = useRef({ chrome, onScroll, onStartReached, onStartReachedThreshold });
708
+ useIsomorphicLayoutEffect(() => {
709
+ handlers.current = { chrome, onScroll, onStartReached, onStartReachedThreshold };
710
+ });
711
+
712
+ const report = useCallback(() => {
713
+ const { chrome: frame, onScroll: listener, onStartReached: startReached, onStartReachedThreshold: zone } = handlers.current;
714
+ const { y, viewport, end } = readWindow();
715
+ frame?.reportScroll({ offset: y, viewport, content: end });
716
+ const top = threadTop();
717
+ const offset = y - top;
718
+ if (startReached) {
719
+ if (offset <= zone) {
720
+ if (startArmed.current) {
721
+ startArmed.current = false;
722
+ pageOut.current = true;
723
+ startReached();
724
+ }
725
+ } else {
726
+ startArmed.current = true;
727
+ }
728
+ }
729
+ listener?.({
730
+ nativeEvent: {
731
+ contentOffset: { x: 0, y: offset },
732
+ layoutMeasurement: { width: window.innerWidth, height: viewport },
733
+ contentSize: { width: window.innerWidth, height: end - top },
734
+ contentInset: { top: 0, left: 0, bottom: 0, right: 0 },
735
+ zoomScale: 1,
736
+ },
737
+ } as NativeSyntheticEvent<NativeScrollEvent>);
738
+ }, [threadTop]);
739
+
740
+ useEffect(() => {
741
+ let frame = 0;
742
+ const onWindowChange = () => {
743
+ if (frame !== 0) return;
744
+ frame = requestAnimationFrame(() => {
745
+ frame = 0;
746
+ report();
747
+ });
748
+ };
749
+ window.addEventListener('scroll', onWindowChange, { passive: true });
750
+ window.addEventListener('resize', onWindowChange);
751
+ report();
752
+ return () => {
753
+ cancelAnimationFrame(frame);
754
+ window.removeEventListener('scroll', onWindowChange);
755
+ window.removeEventListener('resize', onWindowChange);
756
+ };
757
+ }, [report]);
758
+
759
+ const onLayout = useCallback(
760
+ (event: LayoutChangeEvent) => {
761
+ const height = event.nativeEvent.layout.height;
762
+ const previous = lastHeight.current;
763
+ lastHeight.current = height;
764
+ report();
765
+ // The page is shared with whatever was on it before — another route
766
+ // left it scrolled wherever it was — so a following thread opens at its
767
+ // newest turn rather than at that offset.
768
+ if (previous === 0) {
769
+ if (autoFollow) scrollWindowTo(readWindow().end, false);
770
+ return;
771
+ }
772
+ if (height <= previous + 0.5) return;
773
+ const growth = height - previous;
774
+
775
+ // A page landing above the reader: keep the turn they are on where it is.
776
+ if (maintainStartPosition && pageOut.current) {
777
+ pageOut.current = false;
778
+ window.scrollBy({ top: growth, behavior: 'auto' });
779
+ return;
780
+ }
781
+ pageOut.current = false;
782
+
783
+ if (!autoFollow) return;
784
+ if (followThreshold !== undefined) {
785
+ // Measured against the page BEFORE the growth: was the reader near the
786
+ // end when this arrived?
787
+ const { y, viewport, end } = readWindow();
788
+ if (end - growth - y - viewport > followThreshold) return;
789
+ }
790
+ scrollWindowTo(readWindow().end, followAnimated);
791
+ },
792
+ [autoFollow, followAnimated, followThreshold, maintainStartPosition, report],
793
+ );
794
+
795
+ return (
796
+ <View
797
+ ref={nodeRef}
798
+ testID={testID}
799
+ {...dataHook('bloomAiChatThread')}
800
+ onLayout={onLayout}
801
+ style={[
802
+ {
803
+ // Fills the card between the header and the composer, and anchors a
804
+ // short exchange just above the composer, as the scroller does.
805
+ width: '100%',
806
+ flexGrow: 1,
807
+ flexDirection: 'column',
808
+ justifyContent: 'flex-end',
809
+ gap: 12,
810
+ paddingLeft: 16,
811
+ paddingRight: 16,
812
+ paddingTop: THREAD_PAD_TOP,
813
+ },
814
+ style,
815
+ ]}>
816
+ {children}
817
+ </View>
818
+ );
819
+ });