@assistant-ui/react-native 0.1.30 → 0.1.32

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 (31) hide show
  1. package/dist/primitives/actionBar/ActionBarCopy.js.map +1 -1
  2. package/dist/primitives/actionBar/ActionBarFeedbackNegative.js.map +1 -1
  3. package/dist/primitives/actionBar/ActionBarFeedbackPositive.js.map +1 -1
  4. package/dist/primitives/attachment/AttachmentName.js.map +1 -1
  5. package/dist/primitives/attachment/AttachmentRemove.js +1 -0
  6. package/dist/primitives/attachment/AttachmentRemove.js.map +1 -1
  7. package/dist/primitives/attachment/AttachmentThumb.js.map +1 -1
  8. package/dist/primitives/branchPicker/BranchPickerCount.js.map +1 -1
  9. package/dist/primitives/branchPicker/BranchPickerNumber.js.map +1 -1
  10. package/dist/primitives/chainOfThought/ChainOfThoughtAccordionTrigger.js +1 -0
  11. package/dist/primitives/chainOfThought/ChainOfThoughtAccordionTrigger.js.map +1 -1
  12. package/dist/primitives/error/ErrorMessage.d.ts.map +1 -1
  13. package/dist/primitives/error/ErrorMessage.js.map +1 -1
  14. package/dist/primitives/error/ErrorRoot.d.ts.map +1 -1
  15. package/dist/primitives/message/MessageIf.js.map +1 -1
  16. package/dist/primitives/suggestion/SuggestionDescription.js.map +1 -1
  17. package/dist/primitives/suggestion/SuggestionTitle.js.map +1 -1
  18. package/dist/primitives/thread/ThreadIf.js.map +1 -1
  19. package/dist/primitives/thread/ThreadMessages.d.ts +12 -3
  20. package/dist/primitives/thread/ThreadMessages.d.ts.map +1 -1
  21. package/dist/primitives/thread/ThreadMessages.js +385 -33
  22. package/dist/primitives/thread/ThreadMessages.js.map +1 -1
  23. package/dist/primitives/thread.d.ts +3 -3
  24. package/dist/primitives/thread.js +3 -2
  25. package/dist/primitives/thread.js.map +1 -1
  26. package/dist/primitives/threadList/ThreadListItems.js +1 -0
  27. package/dist/primitives/threadList/ThreadListItems.js.map +1 -1
  28. package/package.json +8 -8
  29. package/src/primitives/thread/ThreadMessages.test.tsx +645 -9
  30. package/src/primitives/thread/ThreadMessages.tsx +336 -28
  31. package/src/primitives/thread.ts +2 -0
@@ -1,14 +1,28 @@
1
1
  import {
2
2
  type ComponentType,
3
3
  type FC,
4
+ type ForwardedRef,
4
5
  type ReactNode,
6
+ type RefObject,
5
7
  forwardRef,
6
8
  memo,
7
9
  useCallback,
10
+ useEffect,
11
+ useRef,
8
12
  } from "react";
9
- import { FlatList, type FlatListProps } from "react-native";
13
+ import {
14
+ FlatList,
15
+ type FlatListProps,
16
+ type LayoutChangeEvent,
17
+ type NativeScrollEvent,
18
+ type NativeSyntheticEvent,
19
+ } from "react-native";
10
20
  import type { MessageState, ThreadMessage } from "@assistant-ui/core";
11
- import { RenderChildrenWithAccessor, useAuiState } from "@assistant-ui/store";
21
+ import {
22
+ RenderChildrenWithAccessor,
23
+ useAuiEvent,
24
+ useAuiState,
25
+ } from "@assistant-ui/store";
12
26
  import { MessageByIndexProvider } from "@assistant-ui/core/react";
13
27
 
14
28
  type MessageComponents =
@@ -44,13 +58,22 @@ type MessagesContent =
44
58
  components?: never;
45
59
  };
46
60
 
47
- export type ThreadMessagesProps = Omit<
61
+ export type ThreadMessagesFlatListProps = Omit<
48
62
  FlatListProps<ThreadMessage>,
49
63
  "data" | "renderItem" | "children"
50
64
  > &
51
- MessagesContent;
65
+ MessagesContent & {
66
+ autoScroll?: boolean | undefined;
67
+ scrollToBottomOnRunStart?: boolean | undefined;
68
+ scrollToBottomOnInitialize?: boolean | undefined;
69
+ scrollToBottomOnThreadSwitch?: boolean | undefined;
70
+ };
71
+
72
+ /** @deprecated Use ThreadMessagesFlatListProps instead. */
73
+ export type ThreadMessagesProps = ThreadMessagesFlatListProps;
52
74
 
53
75
  const DEFAULT_SYSTEM_MESSAGE = () => null;
76
+ const AT_BOTTOM_THRESHOLD = 4;
54
77
 
55
78
  const getComponent = (
56
79
  components: MessageComponents,
@@ -155,36 +178,321 @@ const ThreadMessageByChildren = memo(
155
178
  );
156
179
  ThreadMessageByChildren.displayName = "ThreadPrimitive.MessageByChildren";
157
180
 
158
- export const ThreadMessages = forwardRef<
159
- FlatList<ThreadMessage>,
160
- ThreadMessagesProps
161
- >(({ components, children, ...flatListProps }, ref) => {
162
- const messages = useAuiState((s) => s.thread.messages);
181
+ const setForwardedRef = <T,>(ref: ForwardedRef<T>, value: T | null) => {
182
+ if (typeof ref === "function") {
183
+ ref(value);
184
+ } else if (ref) {
185
+ ref.current = value;
186
+ }
187
+ };
163
188
 
164
- const renderItem = useCallback(
165
- ({ index }: { item: ThreadMessage; index: number }) => {
166
- if (children) {
167
- return (
168
- <ThreadMessageByChildren index={index}>
169
- {children}
170
- </ThreadMessageByChildren>
171
- );
189
+ const useComposedFlatListRef = (
190
+ forwardedRef: ForwardedRef<FlatList<ThreadMessage>>,
191
+ ) => {
192
+ const flatListRef = useRef<FlatList<ThreadMessage> | null>(null);
193
+
194
+ const setFlatListRef = useCallback(
195
+ (node: FlatList<ThreadMessage> | null) => {
196
+ flatListRef.current = node;
197
+ setForwardedRef(forwardedRef, node);
198
+ },
199
+ [forwardedRef],
200
+ );
201
+
202
+ return [flatListRef, setFlatListRef] as const;
203
+ };
204
+
205
+ const useThreadMessagesFlatListAutoScroll = ({
206
+ flatListRef,
207
+ hasMessages,
208
+ autoScroll = true,
209
+ scrollToBottomOnRunStart = true,
210
+ scrollToBottomOnInitialize = true,
211
+ scrollToBottomOnThreadSwitch = true,
212
+ }: {
213
+ flatListRef: RefObject<FlatList<ThreadMessage> | null>;
214
+ hasMessages: boolean;
215
+ autoScroll?: boolean | undefined;
216
+ scrollToBottomOnRunStart?: boolean | undefined;
217
+ scrollToBottomOnInitialize?: boolean | undefined;
218
+ scrollToBottomOnThreadSwitch?: boolean | undefined;
219
+ }) => {
220
+ const metricsRef = useRef({
221
+ contentHeight: 0,
222
+ viewportHeight: 0,
223
+ scrollY: 0,
224
+ });
225
+ const isAtBottomRef = useRef(true);
226
+ const lastScrollEventYRef = useRef(0);
227
+ const initializeScrollRequestedRef = useRef(false);
228
+ const pendingScrollToBottomRef = useRef<false | { animated: boolean }>(false);
229
+
230
+ const updateIsAtBottom = useCallback(() => {
231
+ const { contentHeight, scrollY, viewportHeight } = metricsRef.current;
232
+ isAtBottomRef.current =
233
+ contentHeight <= viewportHeight ||
234
+ contentHeight - scrollY - viewportHeight <= AT_BOTTOM_THRESHOLD;
235
+ }, []);
236
+
237
+ // Commanding a scroll records the intended position immediately; the
238
+ // native scroll echo is bridged and throttled, so waiting for it lets a
239
+ // fast stream observe stale metrics and drop out of following.
240
+ const scrollToBottom = useCallback(
241
+ (animated: boolean) => {
242
+ const { contentHeight, viewportHeight } = metricsRef.current;
243
+ metricsRef.current.scrollY = Math.max(0, contentHeight - viewportHeight);
244
+ isAtBottomRef.current = true;
245
+ flatListRef.current?.scrollToEnd({ animated });
246
+ },
247
+ [flatListRef],
248
+ );
249
+
250
+ const handleLayout = useCallback(
251
+ (event: LayoutChangeEvent) => {
252
+ const wasAtBottom = isAtBottomRef.current;
253
+ const previousViewportHeight = metricsRef.current.viewportHeight;
254
+ const viewportHeight = event.nativeEvent.layout.height;
255
+ metricsRef.current.viewportHeight = viewportHeight;
256
+ updateIsAtBottom();
257
+ if (!wasAtBottom) return;
258
+ // Layout changes are never user gestures, so they must not unpin. Past
259
+ // the first measurement, a viewport change while pinned re-commands the
260
+ // bottom position, since no content-size event follows a bare keyboard
261
+ // open or close.
262
+ if (
263
+ autoScroll &&
264
+ previousViewportHeight !== 0 &&
265
+ viewportHeight !== previousViewportHeight
266
+ ) {
267
+ const pending = pendingScrollToBottomRef.current;
268
+ scrollToBottom(pending ? pending.animated : false);
269
+ } else {
270
+ isAtBottomRef.current = true;
271
+ }
272
+ },
273
+ [autoScroll, scrollToBottom, updateIsAtBottom],
274
+ );
275
+
276
+ const handleScroll = useCallback(
277
+ (event: NativeSyntheticEvent<NativeScrollEvent>) => {
278
+ const { contentOffset, contentSize, layoutMeasurement } =
279
+ event.nativeEvent;
280
+ const previousEventY = lastScrollEventYRef.current;
281
+ const wasPinnedToBottom = isAtBottomRef.current;
282
+ lastScrollEventYRef.current = contentOffset.y;
283
+ metricsRef.current = {
284
+ contentHeight: contentSize.height,
285
+ viewportHeight: layoutMeasurement.height,
286
+ scrollY: contentOffset.y,
287
+ };
288
+ updateIsAtBottom();
289
+ const upwardMove = contentOffset.y < previousEventY;
290
+ // Only a deliberate upward move unpins or cancels a pending scroll.
291
+ // Gestures are detected echo-to-echo because a commanded scroll
292
+ // optimistically moves the tracked position ahead of its ascending
293
+ // animation echoes, and those echoes must not unpin mid-flight.
294
+ if (wasPinnedToBottom && !upwardMove) {
295
+ isAtBottomRef.current = true;
296
+ }
297
+ if (!isAtBottomRef.current && upwardMove) {
298
+ pendingScrollToBottomRef.current = false;
299
+ }
300
+ },
301
+ [updateIsAtBottom],
302
+ );
303
+
304
+ const handleContentSizeChange = useCallback(
305
+ (_width: number, height: number) => {
306
+ const metrics = metricsRef.current;
307
+ const previousContentHeight = metrics.contentHeight;
308
+ const wasAtBottom = isAtBottomRef.current;
309
+ metrics.contentHeight = height;
310
+ updateIsAtBottom();
311
+
312
+ // FlatList.scrollToEnd is a no-op before the list has measured, so the
313
+ // initialize and thread-switch scrolls land on the next content-size
314
+ // event, once real metrics exist.
315
+ const pendingScroll = pendingScrollToBottomRef.current;
316
+ if (pendingScroll) {
317
+ pendingScrollToBottomRef.current = false;
318
+ scrollToBottom(pendingScroll.animated);
319
+ return;
172
320
  }
173
- return <ThreadMessageByIndex index={index} components={components!} />;
321
+
322
+ if (!autoScroll) return;
323
+ if (!wasAtBottom) return;
324
+ if (previousContentHeight === 0) return;
325
+ if (height <= previousContentHeight) return;
326
+
327
+ scrollToBottom(false);
174
328
  },
175
- [components, children],
329
+ [autoScroll, scrollToBottom, updateIsAtBottom],
176
330
  );
177
331
 
178
- const keyExtractor = useCallback((item: ThreadMessage) => item.id, []);
332
+ useEffect(() => {
333
+ if (!scrollToBottomOnInitialize) return;
334
+ if (!hasMessages) {
335
+ initializeScrollRequestedRef.current = false;
336
+ return;
337
+ }
338
+ if (initializeScrollRequestedRef.current) return;
339
+
340
+ initializeScrollRequestedRef.current = true;
341
+ pendingScrollToBottomRef.current = { animated: false };
342
+ scrollToBottom(false);
343
+ }, [hasMessages, scrollToBottom, scrollToBottomOnInitialize]);
344
+
345
+ useAuiEvent("thread.runStart", () => {
346
+ if (!scrollToBottomOnRunStart) return;
347
+ pendingScrollToBottomRef.current = { animated: true };
348
+ scrollToBottom(true);
349
+ });
350
+
351
+ useAuiEvent("threadListItem.switchedTo", () => {
352
+ if (!scrollToBottomOnThreadSwitch) return;
353
+ initializeScrollRequestedRef.current = false;
354
+ lastScrollEventYRef.current = 0;
355
+ pendingScrollToBottomRef.current = { animated: false };
356
+ scrollToBottom(false);
357
+ });
358
+
359
+ return {
360
+ handleLayout,
361
+ handleScroll,
362
+ handleContentSizeChange,
363
+ };
364
+ };
179
365
 
180
- return (
181
- <FlatList
366
+ export const ThreadMessagesFlatList = forwardRef<
367
+ FlatList<ThreadMessage>,
368
+ ThreadMessagesFlatListProps
369
+ >(
370
+ (
371
+ {
372
+ autoScroll,
373
+ components,
374
+ children,
375
+ onContentSizeChange,
376
+ onLayout,
377
+ onScroll,
378
+ scrollEventThrottle,
379
+ scrollToBottomOnInitialize,
380
+ scrollToBottomOnRunStart,
381
+ scrollToBottomOnThreadSwitch,
382
+ ...flatListProps
383
+ },
384
+ forwardedRef,
385
+ ) => {
386
+ const messages = useAuiState((s) => s.thread.messages);
387
+ const [flatListRef, setFlatListRef] = useComposedFlatListRef(forwardedRef);
388
+ const {
389
+ handleContentSizeChange: handleAutoScrollContentSizeChange,
390
+ handleLayout: handleAutoScrollLayout,
391
+ handleScroll: handleAutoScrollScroll,
392
+ } = useThreadMessagesFlatListAutoScroll({
393
+ flatListRef,
394
+ hasMessages: messages.length > 0,
395
+ autoScroll,
396
+ scrollToBottomOnInitialize,
397
+ scrollToBottomOnRunStart,
398
+ scrollToBottomOnThreadSwitch,
399
+ });
400
+
401
+ const renderItem = useCallback(
402
+ ({ index }: { item: ThreadMessage; index: number }) => {
403
+ if (children) {
404
+ return (
405
+ <ThreadMessageByChildren index={index}>
406
+ {children}
407
+ </ThreadMessageByChildren>
408
+ );
409
+ }
410
+ return <ThreadMessageByIndex index={index} components={components!} />;
411
+ },
412
+ [components, children],
413
+ );
414
+
415
+ const keyExtractor = useCallback((item: ThreadMessage) => item.id, []);
416
+
417
+ const scrollTracking =
418
+ (autoScroll ?? true) ||
419
+ (scrollToBottomOnInitialize ?? true) ||
420
+ (scrollToBottomOnRunStart ?? true) ||
421
+ (scrollToBottomOnThreadSwitch ?? true);
422
+
423
+ const handleLayout = useCallback(
424
+ (event: LayoutChangeEvent) => {
425
+ handleAutoScrollLayout(event);
426
+ onLayout?.(event);
427
+ },
428
+ [handleAutoScrollLayout, onLayout],
429
+ );
430
+
431
+ const handleScroll = useCallback(
432
+ (event: NativeSyntheticEvent<NativeScrollEvent>) => {
433
+ handleAutoScrollScroll(event);
434
+ onScroll?.(event);
435
+ },
436
+ [handleAutoScrollScroll, onScroll],
437
+ );
438
+
439
+ const handleContentSizeChange = useCallback(
440
+ (width: number, height: number) => {
441
+ handleAutoScrollContentSizeChange(width, height);
442
+ onContentSizeChange?.(width, height);
443
+ },
444
+ [handleAutoScrollContentSizeChange, onContentSizeChange],
445
+ );
446
+
447
+ return (
448
+ <FlatList
449
+ ref={setFlatListRef}
450
+ data={messages as unknown as ThreadMessage[]}
451
+ renderItem={renderItem}
452
+ keyExtractor={keyExtractor}
453
+ {...(scrollTracking
454
+ ? {
455
+ onContentSizeChange: handleContentSizeChange,
456
+ onLayout: handleLayout,
457
+ onScroll: handleScroll,
458
+ scrollEventThrottle: scrollEventThrottle ?? 16,
459
+ }
460
+ : {
461
+ ...(onContentSizeChange && { onContentSizeChange }),
462
+ ...(onLayout && { onLayout }),
463
+ ...(onScroll && { onScroll }),
464
+ ...(scrollEventThrottle !== undefined && { scrollEventThrottle }),
465
+ })}
466
+ {...flatListProps}
467
+ />
468
+ );
469
+ },
470
+ );
471
+ ThreadMessagesFlatList.displayName = "ThreadPrimitive.MessagesFlatList";
472
+
473
+ /** @deprecated Use ThreadPrimitive.MessagesFlatList instead. */
474
+ export const ThreadMessages = forwardRef<
475
+ FlatList<ThreadMessage>,
476
+ ThreadMessagesProps
477
+ >(
478
+ (
479
+ {
480
+ autoScroll = false,
481
+ scrollToBottomOnInitialize = false,
482
+ scrollToBottomOnRunStart = false,
483
+ scrollToBottomOnThreadSwitch = false,
484
+ ...props
485
+ },
486
+ ref,
487
+ ) => (
488
+ <ThreadMessagesFlatList
182
489
  ref={ref}
183
- data={messages as unknown as ThreadMessage[]}
184
- renderItem={renderItem}
185
- keyExtractor={keyExtractor}
186
- {...flatListProps}
490
+ autoScroll={autoScroll}
491
+ scrollToBottomOnInitialize={scrollToBottomOnInitialize}
492
+ scrollToBottomOnRunStart={scrollToBottomOnRunStart}
493
+ scrollToBottomOnThreadSwitch={scrollToBottomOnThreadSwitch}
494
+ {...props}
187
495
  />
188
- );
189
- });
496
+ ),
497
+ );
190
498
  ThreadMessages.displayName = "ThreadPrimitive.Messages";
@@ -3,6 +3,8 @@ export {
3
3
  type ThreadRootProps as RootProps,
4
4
  } from "./thread/ThreadRoot";
5
5
  export {
6
+ ThreadMessagesFlatList as MessagesFlatList,
7
+ type ThreadMessagesFlatListProps as MessagesFlatListProps,
6
8
  ThreadMessages as Messages,
7
9
  type ThreadMessagesProps as MessagesProps,
8
10
  } from "./thread/ThreadMessages";