@assistant-ui/react-native 0.1.41 → 0.1.43

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 (170) hide show
  1. package/dist/index.d.ts +5 -4
  2. package/dist/index.js +4 -3
  3. package/dist/primitives/actionBar/ActionBarCopy.d.ts +3 -3
  4. package/dist/primitives/actionBar/ActionBarCopy.d.ts.map +1 -1
  5. package/dist/primitives/actionBar/ActionBarCopy.js +23 -19
  6. package/dist/primitives/actionBar/ActionBarCopy.js.map +1 -1
  7. package/dist/primitives/actionBar/ActionBarEdit.d.ts +7 -5
  8. package/dist/primitives/actionBar/ActionBarEdit.d.ts.map +1 -1
  9. package/dist/primitives/actionBar/ActionBarEdit.js +21 -11
  10. package/dist/primitives/actionBar/ActionBarEdit.js.map +1 -1
  11. package/dist/primitives/actionBar/ActionBarFeedbackNegative.d.ts +3 -3
  12. package/dist/primitives/actionBar/ActionBarFeedbackNegative.d.ts.map +1 -1
  13. package/dist/primitives/actionBar/ActionBarFeedbackNegative.js +26 -16
  14. package/dist/primitives/actionBar/ActionBarFeedbackNegative.js.map +1 -1
  15. package/dist/primitives/actionBar/ActionBarFeedbackPositive.d.ts +3 -3
  16. package/dist/primitives/actionBar/ActionBarFeedbackPositive.d.ts.map +1 -1
  17. package/dist/primitives/actionBar/ActionBarFeedbackPositive.js +26 -16
  18. package/dist/primitives/actionBar/ActionBarFeedbackPositive.js.map +1 -1
  19. package/dist/primitives/actionBar/ActionBarReload.d.ts +7 -5
  20. package/dist/primitives/actionBar/ActionBarReload.d.ts.map +1 -1
  21. package/dist/primitives/actionBar/ActionBarReload.js +21 -11
  22. package/dist/primitives/actionBar/ActionBarReload.js.map +1 -1
  23. package/dist/primitives/actionBar/ActionBarSpeak.d.ts +14 -0
  24. package/dist/primitives/actionBar/ActionBarSpeak.d.ts.map +1 -0
  25. package/dist/primitives/actionBar/ActionBarSpeak.js +61 -0
  26. package/dist/primitives/actionBar/ActionBarSpeak.js.map +1 -0
  27. package/dist/primitives/actionBar/ActionBarStopSpeaking.d.ts +14 -0
  28. package/dist/primitives/actionBar/ActionBarStopSpeaking.d.ts.map +1 -0
  29. package/dist/primitives/actionBar/ActionBarStopSpeaking.js +61 -0
  30. package/dist/primitives/actionBar/ActionBarStopSpeaking.js.map +1 -0
  31. package/dist/primitives/actionBar.d.ts +4 -2
  32. package/dist/primitives/actionBar.js +6 -2
  33. package/dist/primitives/actionBar.js.map +1 -1
  34. package/dist/primitives/attachment/AttachmentName.d.ts +2 -3
  35. package/dist/primitives/attachment/AttachmentName.d.ts.map +1 -1
  36. package/dist/primitives/attachment/AttachmentRemove.d.ts +2 -3
  37. package/dist/primitives/attachment/AttachmentRemove.d.ts.map +1 -1
  38. package/dist/primitives/attachment/AttachmentRoot.d.ts +2 -3
  39. package/dist/primitives/attachment/AttachmentRoot.d.ts.map +1 -1
  40. package/dist/primitives/attachment/AttachmentThumb.d.ts +2 -3
  41. package/dist/primitives/attachment/AttachmentThumb.d.ts.map +1 -1
  42. package/dist/primitives/branchPicker/BranchPickerCount.d.ts +2 -3
  43. package/dist/primitives/branchPicker/BranchPickerCount.d.ts.map +1 -1
  44. package/dist/primitives/branchPicker/BranchPickerNext.d.ts +7 -5
  45. package/dist/primitives/branchPicker/BranchPickerNext.d.ts.map +1 -1
  46. package/dist/primitives/branchPicker/BranchPickerNext.js +21 -11
  47. package/dist/primitives/branchPicker/BranchPickerNext.js.map +1 -1
  48. package/dist/primitives/branchPicker/BranchPickerNumber.d.ts +2 -3
  49. package/dist/primitives/branchPicker/BranchPickerNumber.d.ts.map +1 -1
  50. package/dist/primitives/branchPicker/BranchPickerPrevious.d.ts +7 -5
  51. package/dist/primitives/branchPicker/BranchPickerPrevious.d.ts.map +1 -1
  52. package/dist/primitives/branchPicker/BranchPickerPrevious.js +21 -11
  53. package/dist/primitives/branchPicker/BranchPickerPrevious.js.map +1 -1
  54. package/dist/primitives/chainOfThought/ChainOfThoughtAccordionTrigger.d.ts +2 -3
  55. package/dist/primitives/chainOfThought/ChainOfThoughtAccordionTrigger.d.ts.map +1 -1
  56. package/dist/primitives/chainOfThought/ChainOfThoughtRoot.d.ts +2 -3
  57. package/dist/primitives/chainOfThought/ChainOfThoughtRoot.d.ts.map +1 -1
  58. package/dist/primitives/composer/ComposerAddAttachment.d.ts +4 -7
  59. package/dist/primitives/composer/ComposerAddAttachment.d.ts.map +1 -1
  60. package/dist/primitives/composer/ComposerAddAttachment.js +2 -4
  61. package/dist/primitives/composer/ComposerAddAttachment.js.map +1 -1
  62. package/dist/primitives/composer/ComposerAttachments.d.ts +4 -5
  63. package/dist/primitives/composer/ComposerAttachments.d.ts.map +1 -1
  64. package/dist/primitives/composer/ComposerCancel.d.ts +2 -3
  65. package/dist/primitives/composer/ComposerCancel.d.ts.map +1 -1
  66. package/dist/primitives/composer/ComposerInput.d.ts +2 -3
  67. package/dist/primitives/composer/ComposerInput.d.ts.map +1 -1
  68. package/dist/primitives/composer/ComposerQuote.d.ts +2 -3
  69. package/dist/primitives/composer/ComposerQuote.d.ts.map +1 -1
  70. package/dist/primitives/composer/ComposerQuoteDismiss.d.ts +2 -3
  71. package/dist/primitives/composer/ComposerQuoteDismiss.d.ts.map +1 -1
  72. package/dist/primitives/composer/ComposerQuoteText.d.ts +2 -3
  73. package/dist/primitives/composer/ComposerQuoteText.d.ts.map +1 -1
  74. package/dist/primitives/composer/ComposerRoot.d.ts +2 -3
  75. package/dist/primitives/composer/ComposerRoot.d.ts.map +1 -1
  76. package/dist/primitives/composer/ComposerSend.d.ts +2 -3
  77. package/dist/primitives/composer/ComposerSend.d.ts.map +1 -1
  78. package/dist/primitives/error/ErrorMessage.d.ts +2 -3
  79. package/dist/primitives/error/ErrorMessage.d.ts.map +1 -1
  80. package/dist/primitives/error/ErrorRoot.d.ts +2 -3
  81. package/dist/primitives/error/ErrorRoot.d.ts.map +1 -1
  82. package/dist/primitives/message/MessageAttachments.d.ts +4 -5
  83. package/dist/primitives/message/MessageAttachments.d.ts.map +1 -1
  84. package/dist/primitives/message/MessageContent.d.ts +2 -3
  85. package/dist/primitives/message/MessageContent.d.ts.map +1 -1
  86. package/dist/primitives/message/MessageIf.d.ts +2 -3
  87. package/dist/primitives/message/MessageIf.d.ts.map +1 -1
  88. package/dist/primitives/message/MessageParts.d.ts +3 -3
  89. package/dist/primitives/message/MessageParts.d.ts.map +1 -1
  90. package/dist/primitives/message/MessageRoot.d.ts +2 -3
  91. package/dist/primitives/message/MessageRoot.d.ts.map +1 -1
  92. package/dist/primitives/messagePart/MessagePartImage.d.ts +11 -0
  93. package/dist/primitives/messagePart/MessagePartImage.d.ts.map +1 -0
  94. package/dist/primitives/messagePart/MessagePartImage.js +39 -0
  95. package/dist/primitives/messagePart/MessagePartImage.js.map +1 -0
  96. package/dist/primitives/messagePart/MessagePartInProgress.d.ts +2 -0
  97. package/dist/primitives/messagePart/MessagePartInProgress.js +2 -0
  98. package/dist/primitives/messagePart/MessagePartText.d.ts +11 -0
  99. package/dist/primitives/messagePart/MessagePartText.d.ts.map +1 -0
  100. package/dist/primitives/messagePart/MessagePartText.js +32 -0
  101. package/dist/primitives/messagePart/MessagePartText.js.map +1 -0
  102. package/dist/primitives/messagePart.d.ts +10 -0
  103. package/dist/primitives/messagePart.d.ts.map +1 -0
  104. package/dist/primitives/messagePart.js +16 -0
  105. package/dist/primitives/messagePart.js.map +1 -0
  106. package/dist/primitives/queueItem/QueueItemRemove.d.ts +2 -3
  107. package/dist/primitives/queueItem/QueueItemRemove.d.ts.map +1 -1
  108. package/dist/primitives/queueItem/QueueItemSteer.d.ts +2 -3
  109. package/dist/primitives/queueItem/QueueItemSteer.d.ts.map +1 -1
  110. package/dist/primitives/queueItem/QueueItemText.d.ts +2 -3
  111. package/dist/primitives/queueItem/QueueItemText.d.ts.map +1 -1
  112. package/dist/primitives/suggestion/SuggestionDescription.d.ts +2 -3
  113. package/dist/primitives/suggestion/SuggestionDescription.d.ts.map +1 -1
  114. package/dist/primitives/suggestion/SuggestionTitle.d.ts +2 -3
  115. package/dist/primitives/suggestion/SuggestionTitle.d.ts.map +1 -1
  116. package/dist/primitives/suggestion/SuggestionTrigger.d.ts +2 -3
  117. package/dist/primitives/suggestion/SuggestionTrigger.d.ts.map +1 -1
  118. package/dist/primitives/thread/ThreadEmpty.d.ts +2 -3
  119. package/dist/primitives/thread/ThreadEmpty.d.ts.map +1 -1
  120. package/dist/primitives/thread/ThreadIf.d.ts +2 -3
  121. package/dist/primitives/thread/ThreadIf.d.ts.map +1 -1
  122. package/dist/primitives/thread/ThreadMessages.d.ts +9 -5
  123. package/dist/primitives/thread/ThreadMessages.d.ts.map +1 -1
  124. package/dist/primitives/thread/ThreadMessages.js +244 -171
  125. package/dist/primitives/thread/ThreadMessages.js.map +1 -1
  126. package/dist/primitives/thread/ThreadRoot.d.ts +2 -3
  127. package/dist/primitives/thread/ThreadRoot.d.ts.map +1 -1
  128. package/dist/primitives/thread/ThreadSuggestion.d.ts +2 -3
  129. package/dist/primitives/thread/ThreadSuggestion.d.ts.map +1 -1
  130. package/dist/primitives/threadList/ThreadListItems.d.ts +2 -3
  131. package/dist/primitives/threadList/ThreadListItems.d.ts.map +1 -1
  132. package/dist/primitives/threadList/ThreadListNew.d.ts +2 -3
  133. package/dist/primitives/threadList/ThreadListNew.d.ts.map +1 -1
  134. package/dist/primitives/threadList/ThreadListRoot.d.ts +2 -3
  135. package/dist/primitives/threadList/ThreadListRoot.d.ts.map +1 -1
  136. package/dist/primitives/threadListItem/ThreadListItemArchive.d.ts +2 -3
  137. package/dist/primitives/threadListItem/ThreadListItemArchive.d.ts.map +1 -1
  138. package/dist/primitives/threadListItem/ThreadListItemDelete.d.ts +2 -3
  139. package/dist/primitives/threadListItem/ThreadListItemDelete.d.ts.map +1 -1
  140. package/dist/primitives/threadListItem/ThreadListItemRoot.d.ts +2 -3
  141. package/dist/primitives/threadListItem/ThreadListItemRoot.d.ts.map +1 -1
  142. package/dist/primitives/threadListItem/ThreadListItemTrigger.d.ts +2 -3
  143. package/dist/primitives/threadListItem/ThreadListItemTrigger.d.ts.map +1 -1
  144. package/dist/primitives/threadListItem/ThreadListItemUnarchive.d.ts +2 -3
  145. package/dist/primitives/threadListItem/ThreadListItemUnarchive.d.ts.map +1 -1
  146. package/package.json +13 -11
  147. package/src/index.ts +30 -0
  148. package/src/primitives/accessibilityRole.test.tsx +20 -0
  149. package/src/primitives/actionBar/ActionBar.test.tsx +299 -0
  150. package/src/primitives/actionBar/ActionBarCopy.tsx +8 -3
  151. package/src/primitives/actionBar/ActionBarEdit.tsx +16 -4
  152. package/src/primitives/actionBar/ActionBarFeedbackNegative.tsx +14 -3
  153. package/src/primitives/actionBar/ActionBarFeedbackPositive.tsx +14 -3
  154. package/src/primitives/actionBar/ActionBarReload.tsx +16 -4
  155. package/src/primitives/actionBar/ActionBarSpeak.tsx +42 -0
  156. package/src/primitives/actionBar/ActionBarStopSpeaking.tsx +42 -0
  157. package/src/primitives/actionBar.ts +8 -0
  158. package/src/primitives/branchPicker/BranchPickerNext.test.tsx +52 -3
  159. package/src/primitives/branchPicker/BranchPickerNext.tsx +16 -4
  160. package/src/primitives/branchPicker/BranchPickerPrevious.test.tsx +52 -6
  161. package/src/primitives/branchPicker/BranchPickerPrevious.tsx +16 -4
  162. package/src/primitives/composer/ComposerAddAttachment.tsx +3 -8
  163. package/src/primitives/messagePart/MessagePartImage.test.tsx +91 -0
  164. package/src/primitives/messagePart/MessagePartImage.tsx +33 -0
  165. package/src/primitives/messagePart/MessagePartInProgress.ts +1 -0
  166. package/src/primitives/messagePart/MessagePartText.test.tsx +80 -0
  167. package/src/primitives/messagePart/MessagePartText.tsx +36 -0
  168. package/src/primitives/messagePart.ts +10 -0
  169. package/src/primitives/thread/ThreadMessages.test.tsx +335 -54
  170. package/src/primitives/thread/ThreadMessages.tsx +104 -26
@@ -1,7 +1,7 @@
1
1
  import { act, createRef } from "react";
2
2
  import { createRoot, type Root } from "react-dom/client";
3
3
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
- import type { FlatList } from "react-native";
4
+ import type { FlatList, FlatListProps } from "react-native";
5
5
  import type { ThreadMessage } from "@assistant-ui/core";
6
6
  import { ThreadMessages, ThreadMessagesFlatList } from "./ThreadMessages";
7
7
 
@@ -15,7 +15,7 @@ const h = vi.hoisted(() => ({
15
15
  itemState: { role: "user" } as { role: string },
16
16
  events: {} as Record<string, Set<() => void>>,
17
17
  flatListProps: null as Record<string, unknown> | null,
18
- scrollToEnd: vi.fn(),
18
+ scrollToOffset: vi.fn(),
19
19
  }));
20
20
 
21
21
  vi.mock("react-native", async (importOriginal) => {
@@ -29,7 +29,7 @@ vi.mock("react-native", async (importOriginal) => {
29
29
  h.flatListProps = props;
30
30
 
31
31
  React.useImperativeHandle(ref, () => ({
32
- scrollToEnd: h.scrollToEnd,
32
+ scrollToOffset: h.scrollToOffset,
33
33
  }));
34
34
 
35
35
  const data = (props.data as unknown[]) ?? [];
@@ -121,7 +121,7 @@ describe("ThreadMessages", () => {
121
121
  h.itemState = { role: "user" };
122
122
  h.events = {};
123
123
  h.flatListProps = null;
124
- h.scrollToEnd.mockReset();
124
+ h.scrollToOffset.mockReset();
125
125
 
126
126
  container = document.createElement("div");
127
127
  document.body.appendChild(container);
@@ -154,6 +154,10 @@ describe("ThreadMessages", () => {
154
154
  onContentSizeChange?: (width: number, height: number) => void;
155
155
  onLayout?: (event: unknown) => void;
156
156
  onScroll?: (event: unknown) => void;
157
+ onStartReached?: NonNullable<
158
+ FlatListProps<ThreadMessage>["onStartReached"]
159
+ >;
160
+ onStartReachedThreshold?: number;
157
161
  scrollEventThrottle?: number;
158
162
  } | null;
159
163
  if (!props) throw new Error("FlatList was not rendered");
@@ -338,6 +342,25 @@ describe("ThreadMessages", () => {
338
342
  expect(ref.current).not.toBeNull();
339
343
  });
340
344
 
345
+ it("anchors the visible message while content is inserted above it", async () => {
346
+ await mountFlatList({ children: () => null });
347
+
348
+ expect(h.flatListProps?.maintainVisibleContentPosition).toEqual({
349
+ minIndexForVisible: 0,
350
+ });
351
+ });
352
+
353
+ it("lets the app override the visible content anchor", async () => {
354
+ await mountFlatList({
355
+ children: () => null,
356
+ maintainVisibleContentPosition: { minIndexForVisible: 2 },
357
+ });
358
+
359
+ expect(h.flatListProps?.maintainVisibleContentPosition).toEqual({
360
+ minIndexForVisible: 2,
361
+ });
362
+ });
363
+
341
364
  it("keeps deprecated Messages off the scroll-tracking path", async () => {
342
365
  h.state.thread.messages = [{ id: "1", role: "user" }];
343
366
  await mount({ components: messageComponents });
@@ -370,7 +393,81 @@ describe("ThreadMessages", () => {
370
393
  await emit("thread.runStart");
371
394
  await emit("threads.selectionChanged");
372
395
 
373
- expect(h.scrollToEnd).not.toHaveBeenCalled();
396
+ expect(h.scrollToOffset).not.toHaveBeenCalled();
397
+ });
398
+
399
+ describe("MessagesFlatList history", () => {
400
+ it("wires loadMore only while history can load more", async () => {
401
+ const loadMore = vi.fn();
402
+
403
+ await mountFlatList({
404
+ components: messageComponents,
405
+ history: { hasMore: true, isLoadingMore: false, loadMore },
406
+ });
407
+ getFlatListProps().onStartReached?.({ distanceFromStart: 0 });
408
+ expect(loadMore).toHaveBeenCalledOnce();
409
+
410
+ await mountFlatList({
411
+ components: messageComponents,
412
+ history: { hasMore: false, isLoadingMore: false, loadMore },
413
+ });
414
+ expect(getFlatListProps().onStartReached).toBeUndefined();
415
+
416
+ await mountFlatList({
417
+ components: messageComponents,
418
+ history: { hasMore: true, isLoadingMore: true, loadMore },
419
+ });
420
+ expect(getFlatListProps().onStartReached).toBeUndefined();
421
+ });
422
+
423
+ it("defaults the history threshold and preserves a caller override", async () => {
424
+ const history = {
425
+ hasMore: true,
426
+ isLoadingMore: false,
427
+ loadMore: vi.fn(),
428
+ };
429
+
430
+ await mountFlatList({ components: messageComponents, history });
431
+ expect(getFlatListProps().onStartReachedThreshold).toBe(1);
432
+
433
+ await mountFlatList({
434
+ components: messageComponents,
435
+ history,
436
+ onStartReachedThreshold: 0.5,
437
+ });
438
+ expect(getFlatListProps().onStartReachedThreshold).toBe(0.5);
439
+ });
440
+
441
+ it("runs a caller onStartReached before loading more history", async () => {
442
+ const calls: string[] = [];
443
+ const info = { distanceFromStart: 42 };
444
+ const onStartReached = vi.fn(() => calls.push("onStartReached"));
445
+
446
+ await mountFlatList({
447
+ components: messageComponents,
448
+ history: {
449
+ hasMore: true,
450
+ isLoadingMore: false,
451
+ loadMore: () => calls.push("loadMore"),
452
+ },
453
+ onStartReached,
454
+ });
455
+
456
+ getFlatListProps().onStartReached?.(info);
457
+
458
+ expect(onStartReached).toHaveBeenCalledWith(info);
459
+ expect(calls).toEqual(["onStartReached", "loadMore"]);
460
+ });
461
+
462
+ it("leaves start-reached props untouched without history", async () => {
463
+ const onStartReached = vi.fn();
464
+
465
+ await mountFlatList({ components: messageComponents, onStartReached });
466
+
467
+ const props = getFlatListProps();
468
+ expect(props.onStartReached).toBe(onStartReached);
469
+ expect(props).not.toHaveProperty("onStartReachedThreshold");
470
+ });
374
471
  });
375
472
 
376
473
  describe("MessagesFlatList auto-scroll", () => {
@@ -379,24 +476,30 @@ describe("ThreadMessages", () => {
379
476
 
380
477
  await mountFlatList({ components: messageComponents });
381
478
 
382
- expect(h.scrollToEnd).toHaveBeenCalledWith({ animated: false });
479
+ expect(h.scrollToOffset).toHaveBeenCalledWith({
480
+ animated: false,
481
+ offset: 0,
482
+ });
383
483
  });
384
484
 
385
485
  it("scrolls to the bottom when a run starts", async () => {
386
486
  h.state.thread.messages = [{ id: "1", role: "user" }];
387
487
  await mountFlatList({ components: messageComponents });
388
- h.scrollToEnd.mockClear();
488
+ h.scrollToOffset.mockClear();
389
489
 
390
490
  await emit("thread.runStart");
391
491
 
392
- expect(h.scrollToEnd).toHaveBeenCalledWith({ animated: true });
492
+ expect(h.scrollToOffset).toHaveBeenCalledWith({
493
+ animated: true,
494
+ offset: 0,
495
+ });
393
496
  });
394
497
 
395
498
  it("scrolls when content grows while already at the bottom", async () => {
396
499
  h.state.thread.messages = [{ id: "1", role: "user" }];
397
500
  await mountFlatList({ components: messageComponents });
398
501
  const props = getFlatListProps();
399
- h.scrollToEnd.mockClear();
502
+ h.scrollToOffset.mockClear();
400
503
 
401
504
  await act(async () => {
402
505
  props.onLayout?.({
@@ -412,7 +515,10 @@ describe("ThreadMessages", () => {
412
515
  props.onContentSizeChange?.(0, 140);
413
516
  });
414
517
 
415
- expect(h.scrollToEnd).toHaveBeenCalledWith({ animated: false });
518
+ expect(h.scrollToOffset).toHaveBeenCalledWith({
519
+ animated: false,
520
+ offset: 40,
521
+ });
416
522
  });
417
523
 
418
524
  it("does not treat the first content-size event as automatic content growth", async () => {
@@ -427,7 +533,7 @@ describe("ThreadMessages", () => {
427
533
  props.onContentSizeChange?.(0, 140);
428
534
  });
429
535
 
430
- expect(h.scrollToEnd).not.toHaveBeenCalled();
536
+ expect(h.scrollToOffset).not.toHaveBeenCalled();
431
537
  });
432
538
 
433
539
  it("lands the initialize scroll on the first content-size event", async () => {
@@ -435,35 +541,151 @@ describe("ThreadMessages", () => {
435
541
  await mountFlatList({ components: messageComponents });
436
542
  const props = getFlatListProps();
437
543
 
438
- expect(h.scrollToEnd).toHaveBeenCalledTimes(1);
544
+ expect(h.scrollToOffset).toHaveBeenCalledTimes(1);
439
545
 
440
546
  await act(async () => {
547
+ props.onLayout?.({
548
+ nativeEvent: { layout: { height: 100 } },
549
+ });
441
550
  props.onContentSizeChange?.(0, 140);
442
551
  });
443
552
 
444
- expect(h.scrollToEnd).toHaveBeenCalledTimes(2);
553
+ expect(h.scrollToOffset).toHaveBeenCalledTimes(2);
554
+ expect(h.scrollToOffset).toHaveBeenLastCalledWith({
555
+ animated: false,
556
+ offset: 40,
557
+ });
445
558
 
446
559
  await act(async () => {
447
560
  props.onContentSizeChange?.(0, 140);
448
561
  });
449
562
 
450
- expect(h.scrollToEnd).toHaveBeenCalledTimes(2);
563
+ expect(h.scrollToOffset).toHaveBeenCalledTimes(2);
564
+ });
565
+
566
+ it("waits for the first layout before consuming a measured initialize scroll", async () => {
567
+ h.state.thread.messages = [{ id: "1", role: "user" }];
568
+ await mountFlatList({ components: messageComponents });
569
+ const props = getFlatListProps();
570
+
571
+ await act(async () => {
572
+ props.onContentSizeChange?.(0, 140);
573
+ });
574
+ expect(h.scrollToOffset).toHaveBeenCalledTimes(1);
575
+
576
+ await act(async () => {
577
+ props.onLayout?.({
578
+ nativeEvent: { layout: { height: 100 } },
579
+ });
580
+ });
581
+
582
+ expect(h.scrollToOffset).toHaveBeenCalledTimes(2);
583
+ expect(h.scrollToOffset).toHaveBeenLastCalledWith({
584
+ animated: false,
585
+ offset: 40,
586
+ });
587
+ });
588
+
589
+ it("keeps the initialize scroll pending through a zero content measurement", async () => {
590
+ h.state.thread.messages = [{ id: "1", role: "user" }];
591
+ await mountFlatList({ components: messageComponents });
592
+ const props = getFlatListProps();
593
+
594
+ await act(async () => {
595
+ props.onLayout?.({
596
+ nativeEvent: { layout: { height: 100 } },
597
+ });
598
+ props.onContentSizeChange?.(0, 0);
599
+ });
600
+ expect(h.scrollToOffset).toHaveBeenCalledTimes(1);
601
+
602
+ await act(async () => {
603
+ props.onContentSizeChange?.(0, 300);
604
+ });
605
+
606
+ expect(h.scrollToOffset).toHaveBeenCalledTimes(2);
607
+ expect(h.scrollToOffset).toHaveBeenLastCalledWith({
608
+ animated: false,
609
+ offset: 200,
610
+ });
611
+ });
612
+
613
+ it("uses horizontal measurements for horizontal lists", async () => {
614
+ h.state.thread.messages = [{ id: "1", role: "user" }];
615
+ await mountFlatList({ components: messageComponents, horizontal: true });
616
+ const props = getFlatListProps();
617
+
618
+ await act(async () => {
619
+ props.onLayout?.({
620
+ nativeEvent: { layout: { width: 100 } },
621
+ });
622
+ props.onContentSizeChange?.(140, 0);
623
+ });
624
+
625
+ expect(h.scrollToOffset).toHaveBeenLastCalledWith({
626
+ animated: false,
627
+ offset: 40,
628
+ });
451
629
  });
452
630
 
453
631
  it("lands the thread-switch scroll on the next content-size event", async () => {
454
632
  h.state.thread.messages = [{ id: "1", role: "user" }];
455
633
  await mountFlatList({ components: messageComponents });
456
634
  const props = getFlatListProps();
457
- h.scrollToEnd.mockClear();
635
+ h.scrollToOffset.mockClear();
458
636
 
459
637
  await emit("threads.selectionChanged");
460
- expect(h.scrollToEnd).toHaveBeenCalledTimes(1);
638
+ expect(h.scrollToOffset).toHaveBeenCalledTimes(1);
639
+
640
+ await act(async () => {
641
+ props.onContentSizeChange?.(0, 300);
642
+ });
643
+ expect(h.scrollToOffset).toHaveBeenCalledTimes(1);
644
+
645
+ await act(async () => {
646
+ props.onLayout?.({
647
+ nativeEvent: { layout: { height: 100 } },
648
+ });
649
+ });
650
+
651
+ expect(h.scrollToOffset).toHaveBeenCalledTimes(2);
652
+ expect(h.scrollToOffset).toHaveBeenLastCalledWith({
653
+ animated: false,
654
+ offset: 200,
655
+ });
656
+ });
657
+
658
+ it("does not consume a thread-switch scroll with the previous thread metrics", async () => {
659
+ h.state.thread.messages = [{ id: "1", role: "user" }];
660
+ await mountFlatList({ components: messageComponents });
661
+ const props = getFlatListProps();
662
+
663
+ await act(async () => {
664
+ props.onLayout?.({
665
+ nativeEvent: { layout: { height: 100 } },
666
+ });
667
+ props.onScroll?.({
668
+ nativeEvent: {
669
+ contentOffset: { y: 200 },
670
+ contentSize: { height: 300, width: 0 },
671
+ layoutMeasurement: { height: 100, width: 0 },
672
+ },
673
+ });
674
+ });
675
+ h.scrollToOffset.mockClear();
461
676
 
677
+ await emit("threads.selectionChanged");
462
678
  await act(async () => {
463
- props.onContentSizeChange?.(0, 80);
679
+ props.onLayout?.({
680
+ nativeEvent: { layout: { height: 80 } },
681
+ });
682
+ props.onContentSizeChange?.(0, 260);
464
683
  });
465
684
 
466
- expect(h.scrollToEnd).toHaveBeenCalledTimes(2);
685
+ expect(h.scrollToOffset).toHaveBeenLastCalledWith({
686
+ animated: false,
687
+ offset: 180,
688
+ });
467
689
  });
468
690
 
469
691
  it("keeps following through consecutive growth events without scroll echoes", async () => {
@@ -483,7 +705,7 @@ describe("ThreadMessages", () => {
483
705
  },
484
706
  });
485
707
  });
486
- h.scrollToEnd.mockClear();
708
+ h.scrollToOffset.mockClear();
487
709
 
488
710
  await act(async () => {
489
711
  props.onContentSizeChange?.(0, 140);
@@ -492,14 +714,14 @@ describe("ThreadMessages", () => {
492
714
  props.onContentSizeChange?.(0, 180);
493
715
  });
494
716
 
495
- expect(h.scrollToEnd).toHaveBeenCalledTimes(2);
717
+ expect(h.scrollToOffset).toHaveBeenCalledTimes(2);
496
718
  });
497
719
 
498
720
  it("does not scroll when content grows after the user scrolled away", async () => {
499
721
  h.state.thread.messages = [{ id: "1", role: "user" }];
500
722
  await mountFlatList({ components: messageComponents });
501
723
  const props = getFlatListProps();
502
- h.scrollToEnd.mockClear();
724
+ h.scrollToOffset.mockClear();
503
725
 
504
726
  await act(async () => {
505
727
  props.onLayout?.({
@@ -522,7 +744,7 @@ describe("ThreadMessages", () => {
522
744
  props.onContentSizeChange?.(0, 340);
523
745
  });
524
746
 
525
- expect(h.scrollToEnd).not.toHaveBeenCalled();
747
+ expect(h.scrollToOffset).not.toHaveBeenCalled();
526
748
  });
527
749
 
528
750
  it("lands the run-start scroll once the appended message resizes content", async () => {
@@ -549,10 +771,10 @@ describe("ThreadMessages", () => {
549
771
  },
550
772
  });
551
773
  });
552
- h.scrollToEnd.mockClear();
774
+ h.scrollToOffset.mockClear();
553
775
 
554
776
  await emit("thread.runStart");
555
- expect(h.scrollToEnd).toHaveBeenCalledTimes(1);
777
+ expect(h.scrollToOffset).toHaveBeenCalledTimes(1);
556
778
 
557
779
  await act(async () => {
558
780
  props.onScroll?.({
@@ -565,18 +787,39 @@ describe("ThreadMessages", () => {
565
787
  props.onContentSizeChange?.(0, 360);
566
788
  });
567
789
 
568
- expect(h.scrollToEnd).toHaveBeenCalledTimes(2);
569
- expect(h.scrollToEnd).toHaveBeenLastCalledWith({ animated: true });
790
+ expect(h.scrollToOffset).toHaveBeenCalledTimes(2);
791
+ expect(h.scrollToOffset).toHaveBeenLastCalledWith({
792
+ animated: true,
793
+ offset: 260,
794
+ });
570
795
  });
571
796
 
572
- it("scrolls to the bottom when switching threads", async () => {
797
+ it("uses the measured bottom when a thread switch does not resize content", async () => {
573
798
  h.state.thread.messages = [{ id: "1", role: "user" }];
574
799
  await mountFlatList({ components: messageComponents });
575
- h.scrollToEnd.mockClear();
800
+ const props = getFlatListProps();
801
+
802
+ await act(async () => {
803
+ props.onLayout?.({
804
+ nativeEvent: { layout: { height: 100 } },
805
+ });
806
+ props.onScroll?.({
807
+ nativeEvent: {
808
+ contentOffset: { y: 120 },
809
+ contentSize: { height: 300, width: 0 },
810
+ layoutMeasurement: { height: 100, width: 0 },
811
+ },
812
+ });
813
+ });
814
+ h.scrollToOffset.mockClear();
576
815
 
577
816
  await emit("threads.selectionChanged");
578
817
 
579
- expect(h.scrollToEnd).toHaveBeenCalledWith({ animated: false });
818
+ expect(h.scrollToOffset).toHaveBeenCalledTimes(1);
819
+ expect(h.scrollToOffset).toHaveBeenCalledWith({
820
+ animated: false,
821
+ offset: 200,
822
+ });
580
823
  });
581
824
 
582
825
  it("does not rearm initialize scroll when thread-switch scroll is disabled", async () => {
@@ -585,7 +828,7 @@ describe("ThreadMessages", () => {
585
828
  components: messageComponents,
586
829
  scrollToBottomOnThreadSwitch: false,
587
830
  });
588
- h.scrollToEnd.mockClear();
831
+ h.scrollToOffset.mockClear();
589
832
 
590
833
  await emit("threads.selectionChanged");
591
834
 
@@ -598,7 +841,7 @@ describe("ThreadMessages", () => {
598
841
  scrollToBottomOnThreadSwitch: false,
599
842
  });
600
843
 
601
- expect(h.scrollToEnd).not.toHaveBeenCalled();
844
+ expect(h.scrollToOffset).not.toHaveBeenCalled();
602
845
  });
603
846
 
604
847
  it("honors opt-outs for automatic content growth and run-start scrolls", async () => {
@@ -610,7 +853,7 @@ describe("ThreadMessages", () => {
610
853
  scrollToBottomOnRunStart: false,
611
854
  });
612
855
  const props = getFlatListProps();
613
- h.scrollToEnd.mockClear();
856
+ h.scrollToOffset.mockClear();
614
857
 
615
858
  await act(async () => {
616
859
  props.onLayout?.({
@@ -627,7 +870,7 @@ describe("ThreadMessages", () => {
627
870
  });
628
871
  await emit("thread.runStart");
629
872
 
630
- expect(h.scrollToEnd).not.toHaveBeenCalled();
873
+ expect(h.scrollToOffset).not.toHaveBeenCalled();
631
874
  });
632
875
 
633
876
  it("sets a useful default scroll throttle", async () => {
@@ -655,10 +898,32 @@ describe("ThreadMessages", () => {
655
898
  });
656
899
  props.onContentSizeChange?.(0, 300);
657
900
  });
658
- h.scrollToEnd.mockClear();
901
+ h.scrollToOffset.mockClear();
659
902
  return props;
660
903
  };
661
904
 
905
+ it("stays pinned when an anchor adjustment moves the offset with the content above it", async () => {
906
+ const props = await mountPinned();
907
+
908
+ await act(async () => {
909
+ props.onScroll?.({
910
+ nativeEvent: {
911
+ contentOffset: { y: 180 },
912
+ contentSize: { height: 280, width: 0 },
913
+ layoutMeasurement: { height: 100, width: 0 },
914
+ },
915
+ });
916
+ });
917
+ await act(async () => {
918
+ props.onContentSizeChange?.(0, 320);
919
+ });
920
+
921
+ expect(h.scrollToOffset).toHaveBeenCalledWith({
922
+ animated: false,
923
+ offset: 220,
924
+ });
925
+ });
926
+
662
927
  it("stays pinned when the viewport shrinks while at the bottom", async () => {
663
928
  const props = await mountPinned();
664
929
 
@@ -667,12 +932,15 @@ describe("ThreadMessages", () => {
667
932
  nativeEvent: { layout: { height: 60 } },
668
933
  });
669
934
  });
670
- h.scrollToEnd.mockClear();
935
+ h.scrollToOffset.mockClear();
671
936
  await act(async () => {
672
937
  props.onContentSizeChange?.(0, 340);
673
938
  });
674
939
 
675
- expect(h.scrollToEnd).toHaveBeenCalledWith({ animated: false });
940
+ expect(h.scrollToOffset).toHaveBeenCalledWith({
941
+ animated: false,
942
+ offset: 280,
943
+ });
676
944
  });
677
945
 
678
946
  it("commands a bottom scroll when the viewport shrinks while pinned", async () => {
@@ -684,22 +952,29 @@ describe("ThreadMessages", () => {
684
952
  });
685
953
  });
686
954
 
687
- expect(h.scrollToEnd).toHaveBeenCalledWith({ animated: false });
955
+ expect(h.scrollToOffset).toHaveBeenCalledWith({
956
+ animated: false,
957
+ offset: 240,
958
+ });
688
959
  });
689
960
 
690
961
  it("preserves a pending animated scroll on a pinned viewport change", async () => {
691
962
  const props = await mountPinned();
692
963
 
693
964
  await emit("thread.runStart");
694
- h.scrollToEnd.mockClear();
965
+ h.scrollToOffset.mockClear();
695
966
  await act(async () => {
696
967
  props.onLayout?.({
697
968
  nativeEvent: { layout: { height: 60 } },
698
969
  });
970
+ props.onContentSizeChange?.(0, 360);
699
971
  });
700
972
 
701
- expect(h.scrollToEnd).toHaveBeenCalledTimes(1);
702
- expect(h.scrollToEnd).toHaveBeenCalledWith({ animated: true });
973
+ expect(h.scrollToOffset).toHaveBeenCalledTimes(2);
974
+ expect(h.scrollToOffset).toHaveBeenLastCalledWith({
975
+ animated: true,
976
+ offset: 300,
977
+ });
703
978
  });
704
979
 
705
980
  it("ignores a layout event with an unchanged viewport height", async () => {
@@ -711,13 +986,13 @@ describe("ThreadMessages", () => {
711
986
  });
712
987
  });
713
988
 
714
- expect(h.scrollToEnd).not.toHaveBeenCalled();
989
+ expect(h.scrollToOffset).not.toHaveBeenCalled();
715
990
  });
716
991
 
717
992
  it("does not command a scroll on the first layout measurement", async () => {
718
993
  await mountFlatList({ components: messageComponents });
719
994
  getFlatListProps();
720
- h.scrollToEnd.mockClear();
995
+ h.scrollToOffset.mockClear();
721
996
 
722
997
  await act(async () => {
723
998
  getFlatListProps().onLayout?.({
@@ -725,7 +1000,7 @@ describe("ThreadMessages", () => {
725
1000
  });
726
1001
  });
727
1002
 
728
- expect(h.scrollToEnd).not.toHaveBeenCalled();
1003
+ expect(h.scrollToOffset).not.toHaveBeenCalled();
729
1004
  });
730
1005
 
731
1006
  it("does not command a scroll on viewport change when autoScroll is off", async () => {
@@ -744,7 +1019,7 @@ describe("ThreadMessages", () => {
744
1019
  });
745
1020
  props.onContentSizeChange?.(0, 300);
746
1021
  });
747
- h.scrollToEnd.mockClear();
1022
+ h.scrollToOffset.mockClear();
748
1023
 
749
1024
  await act(async () => {
750
1025
  props.onLayout?.({
@@ -752,7 +1027,7 @@ describe("ThreadMessages", () => {
752
1027
  });
753
1028
  });
754
1029
 
755
- expect(h.scrollToEnd).not.toHaveBeenCalled();
1030
+ expect(h.scrollToOffset).not.toHaveBeenCalled();
756
1031
  });
757
1032
 
758
1033
  it("stays unpinned when the viewport shrinks after scrolling away", async () => {
@@ -776,7 +1051,7 @@ describe("ThreadMessages", () => {
776
1051
  props.onContentSizeChange?.(0, 340);
777
1052
  });
778
1053
 
779
- expect(h.scrollToEnd).not.toHaveBeenCalled();
1054
+ expect(h.scrollToOffset).not.toHaveBeenCalled();
780
1055
  });
781
1056
 
782
1057
  it("keeps the pin through a downward scroll echo after a commanded scroll", async () => {
@@ -795,7 +1070,7 @@ describe("ThreadMessages", () => {
795
1070
  await act(async () => {
796
1071
  props.onContentSizeChange?.(0, 320);
797
1072
  });
798
- h.scrollToEnd.mockClear();
1073
+ h.scrollToOffset.mockClear();
799
1074
  await act(async () => {
800
1075
  props.onScroll?.({
801
1076
  nativeEvent: {
@@ -809,14 +1084,17 @@ describe("ThreadMessages", () => {
809
1084
  props.onContentSizeChange?.(0, 360);
810
1085
  });
811
1086
 
812
- expect(h.scrollToEnd).toHaveBeenCalledWith({ animated: false });
1087
+ expect(h.scrollToOffset).toHaveBeenCalledWith({
1088
+ animated: false,
1089
+ offset: 260,
1090
+ });
813
1091
  });
814
1092
 
815
1093
  it("unpins and cancels a pending scroll on an upward gesture echo", async () => {
816
1094
  const props = await mountPinned();
817
1095
 
818
1096
  await emit("thread.runStart");
819
- h.scrollToEnd.mockClear();
1097
+ h.scrollToOffset.mockClear();
820
1098
  await act(async () => {
821
1099
  props.onScroll?.({
822
1100
  nativeEvent: {
@@ -830,17 +1108,20 @@ describe("ThreadMessages", () => {
830
1108
  props.onContentSizeChange?.(0, 360);
831
1109
  });
832
1110
 
833
- expect(h.scrollToEnd).not.toHaveBeenCalled();
1111
+ expect(h.scrollToOffset).not.toHaveBeenCalled();
834
1112
  });
835
1113
 
836
1114
  it("scrolls on content growth while pinned and stays put while unpinned", async () => {
837
1115
  const props = await mountPinned();
838
- h.scrollToEnd.mockClear();
1116
+ h.scrollToOffset.mockClear();
839
1117
 
840
1118
  await act(async () => {
841
1119
  props.onContentSizeChange?.(0, 340);
842
1120
  });
843
- expect(h.scrollToEnd).toHaveBeenCalledWith({ animated: false });
1121
+ expect(h.scrollToOffset).toHaveBeenCalledWith({
1122
+ animated: false,
1123
+ offset: 240,
1124
+ });
844
1125
 
845
1126
  await act(async () => {
846
1127
  props.onScroll?.({
@@ -851,12 +1132,12 @@ describe("ThreadMessages", () => {
851
1132
  },
852
1133
  });
853
1134
  });
854
- h.scrollToEnd.mockClear();
1135
+ h.scrollToOffset.mockClear();
855
1136
  await act(async () => {
856
1137
  props.onContentSizeChange?.(0, 380);
857
1138
  });
858
1139
 
859
- expect(h.scrollToEnd).not.toHaveBeenCalled();
1140
+ expect(h.scrollToOffset).not.toHaveBeenCalled();
860
1141
  });
861
1142
  });
862
1143
  });