@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.
- package/docs/ai-chat.mdx +31 -5
- package/docs/outline-nav.mdx +22 -0
- package/lib/commonjs/ai-chat/AiChatContainer.js +390 -116
- package/lib/commonjs/ai-chat/AiChatContainer.js.map +1 -1
- package/lib/commonjs/ai-chat/AiChatShell.js +211 -176
- package/lib/commonjs/ai-chat/AiChatShell.js.map +1 -1
- package/lib/commonjs/ai-chat/context.js +10 -1
- package/lib/commonjs/ai-chat/context.js.map +1 -1
- package/lib/commonjs/ai-chat/shared.js +41 -2
- package/lib/commonjs/ai-chat/shared.js.map +1 -1
- package/lib/commonjs/outline-nav/OutlineNav.js +15 -1
- package/lib/commonjs/outline-nav/OutlineNav.js.map +1 -1
- package/lib/module/ai-chat/AiChatContainer.js +394 -120
- package/lib/module/ai-chat/AiChatContainer.js.map +1 -1
- package/lib/module/ai-chat/AiChatShell.js +213 -178
- package/lib/module/ai-chat/AiChatShell.js.map +1 -1
- package/lib/module/ai-chat/context.js +9 -0
- package/lib/module/ai-chat/context.js.map +1 -1
- package/lib/module/ai-chat/shared.js +40 -1
- package/lib/module/ai-chat/shared.js.map +1 -1
- package/lib/module/outline-nav/OutlineNav.js +15 -1
- package/lib/module/outline-nav/OutlineNav.js.map +1 -1
- package/lib/typescript/commonjs/ai-chat/AiChatContainer.d.ts +8 -3
- package/lib/typescript/commonjs/ai-chat/AiChatContainer.d.ts.map +1 -1
- package/lib/typescript/commonjs/ai-chat/AiChatShell.d.ts +1 -1
- package/lib/typescript/commonjs/ai-chat/AiChatShell.d.ts.map +1 -1
- package/lib/typescript/commonjs/ai-chat/context.d.ts +14 -0
- package/lib/typescript/commonjs/ai-chat/context.d.ts.map +1 -1
- package/lib/typescript/commonjs/ai-chat/shared.d.ts +24 -2
- package/lib/typescript/commonjs/ai-chat/shared.d.ts.map +1 -1
- package/lib/typescript/commonjs/ai-chat/types.d.ts +19 -0
- package/lib/typescript/commonjs/ai-chat/types.d.ts.map +1 -1
- package/lib/typescript/commonjs/outline-nav/OutlineNav.d.ts.map +1 -1
- package/lib/typescript/commonjs/outline-nav/types.d.ts +8 -0
- package/lib/typescript/commonjs/outline-nav/types.d.ts.map +1 -1
- package/lib/typescript/module/ai-chat/AiChatContainer.d.ts +8 -3
- package/lib/typescript/module/ai-chat/AiChatContainer.d.ts.map +1 -1
- package/lib/typescript/module/ai-chat/AiChatShell.d.ts +1 -1
- package/lib/typescript/module/ai-chat/AiChatShell.d.ts.map +1 -1
- package/lib/typescript/module/ai-chat/context.d.ts +14 -0
- package/lib/typescript/module/ai-chat/context.d.ts.map +1 -1
- package/lib/typescript/module/ai-chat/shared.d.ts +24 -2
- package/lib/typescript/module/ai-chat/shared.d.ts.map +1 -1
- package/lib/typescript/module/ai-chat/types.d.ts +19 -0
- package/lib/typescript/module/ai-chat/types.d.ts.map +1 -1
- package/lib/typescript/module/outline-nav/OutlineNav.d.ts.map +1 -1
- package/lib/typescript/module/outline-nav/types.d.ts +8 -0
- package/lib/typescript/module/outline-nav/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/ai-chat/AiChatContainer.tsx +361 -88
- package/src/ai-chat/AiChatShell.tsx +39 -13
- package/src/ai-chat/context.ts +15 -0
- package/src/ai-chat/shared.ts +45 -2
- package/src/ai-chat/types.ts +19 -0
- package/src/outline-nav/OutlineNav.tsx +23 -2
- 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 {
|
|
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
|
-
|
|
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,
|
|
90
|
-
*
|
|
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
|
|
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
|
|
225
|
-
*
|
|
226
|
-
* carries none of
|
|
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
|
|
229
|
-
* reports, so a scroll frame re-renders nothing; the
|
|
230
|
-
*
|
|
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>(
|
|
244
|
-
const [
|
|
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(
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
|
263
|
-
//
|
|
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
|
-
|
|
269
|
-
|
|
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
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
295
|
-
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
{
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
+
});
|