@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
@@ -3,7 +3,7 @@ import { createRoot, type Root } from "react-dom/client";
3
3
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
4
  import type { FlatList } from "react-native";
5
5
  import type { ThreadMessage } from "@assistant-ui/core";
6
- import { ThreadMessages } from "./ThreadMessages";
6
+ import { ThreadMessages, ThreadMessagesFlatList } from "./ThreadMessages";
7
7
 
8
8
  type Msg = { id: string; role: string };
9
9
 
@@ -13,16 +13,96 @@ const h = vi.hoisted(() => ({
13
13
  message: { role: "user" as string, composer: { isEditing: false } },
14
14
  },
15
15
  itemState: { role: "user" } as { role: string },
16
+ events: {} as Record<string, Set<() => void>>,
17
+ flatListProps: null as Record<string, unknown> | null,
18
+ scrollToEnd: vi.fn(),
16
19
  }));
17
20
 
18
- vi.mock("@assistant-ui/store", () => ({
19
- useAuiState: <T,>(selector: (s: typeof h.state) => T) => selector(h.state),
20
- RenderChildrenWithAccessor: ({
21
- children,
22
- }: {
23
- children: (getItem: () => unknown) => unknown;
24
- }) => children(() => h.itemState),
25
- }));
21
+ vi.mock("react-native", async (importOriginal) => {
22
+ const actual = await importOriginal<typeof import("react-native")>();
23
+ const React = await import("react");
24
+
25
+ const FlatListMock = React.forwardRef(function FlatListMock(
26
+ props: Record<string, unknown>,
27
+ ref,
28
+ ) {
29
+ h.flatListProps = props;
30
+
31
+ React.useImperativeHandle(ref, () => ({
32
+ scrollToEnd: h.scrollToEnd,
33
+ }));
34
+
35
+ const data = (props.data as unknown[]) ?? [];
36
+ const renderItem = props.renderItem as
37
+ | ((value: {
38
+ item: unknown;
39
+ index: number;
40
+ separators: {
41
+ highlight: () => void;
42
+ unhighlight: () => void;
43
+ updateProps: () => void;
44
+ };
45
+ }) => React.ReactNode)
46
+ | undefined;
47
+ const keyExtractor = props.keyExtractor as
48
+ | ((item: unknown, index: number) => string)
49
+ | undefined;
50
+
51
+ return React.createElement(
52
+ "div",
53
+ { "data-testid": "flatlist" },
54
+ data.map((item, index) =>
55
+ React.createElement(
56
+ "div",
57
+ { key: keyExtractor?.(item, index) ?? index },
58
+ renderItem?.({
59
+ item,
60
+ index,
61
+ separators: {
62
+ highlight: vi.fn(),
63
+ unhighlight: vi.fn(),
64
+ updateProps: vi.fn(),
65
+ },
66
+ }),
67
+ ),
68
+ ),
69
+ );
70
+ });
71
+
72
+ return {
73
+ ...actual,
74
+ FlatList: FlatListMock,
75
+ };
76
+ });
77
+
78
+ vi.mock("@assistant-ui/store", async () => {
79
+ const React = await import("react");
80
+
81
+ return {
82
+ useAuiState: <T,>(selector: (s: typeof h.state) => T) => selector(h.state),
83
+ useAuiEvent: (
84
+ selector: string | { scope: string; event: string },
85
+ callback: () => void,
86
+ ) => {
87
+ React.useEffect(() => {
88
+ const event =
89
+ typeof selector === "string"
90
+ ? selector
91
+ : `${selector.scope}.${selector.event}`;
92
+ const callbacks = (h.events[event] ??= new Set());
93
+ callbacks.add(callback);
94
+ return () => {
95
+ callbacks.delete(callback);
96
+ };
97
+ }, [selector, callback]);
98
+ },
99
+ RenderChildrenWithAccessor: ({
100
+ children,
101
+ }: {
102
+ children: (getItem: () => unknown) => unknown;
103
+ }) => children(() => h.itemState),
104
+ };
105
+ });
26
106
 
27
107
  vi.mock("@assistant-ui/core/react", () => ({
28
108
  MessageByIndexProvider: ({ children }: { children: unknown }) => children,
@@ -39,6 +119,9 @@ describe("ThreadMessages", () => {
39
119
  h.state.message.role = "user";
40
120
  h.state.message.composer.isEditing = false;
41
121
  h.itemState = { role: "user" };
122
+ h.events = {};
123
+ h.flatListProps = null;
124
+ h.scrollToEnd.mockReset();
42
125
 
43
126
  container = document.createElement("div");
44
127
  document.body.appendChild(container);
@@ -58,6 +141,37 @@ describe("ThreadMessages", () => {
58
141
  });
59
142
  };
60
143
 
144
+ const mountFlatList = async (
145
+ props: Parameters<typeof ThreadMessagesFlatList>[0],
146
+ ) => {
147
+ await act(async () => {
148
+ root.render(<ThreadMessagesFlatList {...props} />);
149
+ });
150
+ };
151
+
152
+ const getFlatListProps = () => {
153
+ const props = h.flatListProps as {
154
+ onContentSizeChange?: (width: number, height: number) => void;
155
+ onLayout?: (event: unknown) => void;
156
+ onScroll?: (event: unknown) => void;
157
+ scrollEventThrottle?: number;
158
+ } | null;
159
+ if (!props) throw new Error("FlatList was not rendered");
160
+ return props;
161
+ };
162
+
163
+ const emit = async (event: string) => {
164
+ await act(async () => {
165
+ h.events[event]?.forEach((callback) => {
166
+ callback();
167
+ });
168
+ });
169
+ };
170
+
171
+ const messageComponents = {
172
+ Message: () => <span data-testid="c-message">message</span>,
173
+ };
174
+
61
175
  describe("components mode dispatch", () => {
62
176
  const makeComponents = () => ({
63
177
  Message: vi.fn(() => <span data-testid="c-message">message</span>),
@@ -209,4 +323,526 @@ describe("ThreadMessages", () => {
209
323
 
210
324
  expect(ref.current).not.toBeNull();
211
325
  });
326
+
327
+ it("keeps deprecated Messages off the scroll-tracking path", async () => {
328
+ h.state.thread.messages = [{ id: "1", role: "user" }];
329
+ await mount({ components: messageComponents });
330
+ const props = getFlatListProps();
331
+
332
+ expect(props.onScroll).toBeUndefined();
333
+ expect(props.onLayout).toBeUndefined();
334
+ expect(props.onContentSizeChange).toBeUndefined();
335
+ expect(props.scrollEventThrottle).toBeUndefined();
336
+ });
337
+
338
+ it("keeps deprecated Messages from auto-scrolling by default", async () => {
339
+ h.state.thread.messages = [{ id: "1", role: "user" }];
340
+ await mount({ components: messageComponents });
341
+ const props = getFlatListProps();
342
+
343
+ await act(async () => {
344
+ props.onLayout?.({
345
+ nativeEvent: { layout: { height: 100 } },
346
+ });
347
+ props.onScroll?.({
348
+ nativeEvent: {
349
+ contentOffset: { y: 0 },
350
+ contentSize: { height: 100, width: 0 },
351
+ layoutMeasurement: { height: 100, width: 0 },
352
+ },
353
+ });
354
+ props.onContentSizeChange?.(0, 140);
355
+ });
356
+ await emit("thread.runStart");
357
+ await emit("threadListItem.switchedTo");
358
+
359
+ expect(h.scrollToEnd).not.toHaveBeenCalled();
360
+ });
361
+
362
+ describe("MessagesFlatList auto-scroll", () => {
363
+ it("scrolls to the bottom when messages first appear", async () => {
364
+ h.state.thread.messages = [{ id: "1", role: "user" }];
365
+
366
+ await mountFlatList({ components: messageComponents });
367
+
368
+ expect(h.scrollToEnd).toHaveBeenCalledWith({ animated: false });
369
+ });
370
+
371
+ it("scrolls to the bottom when a run starts", async () => {
372
+ h.state.thread.messages = [{ id: "1", role: "user" }];
373
+ await mountFlatList({ components: messageComponents });
374
+ h.scrollToEnd.mockClear();
375
+
376
+ await emit("thread.runStart");
377
+
378
+ expect(h.scrollToEnd).toHaveBeenCalledWith({ animated: true });
379
+ });
380
+
381
+ it("scrolls when content grows while already at the bottom", async () => {
382
+ h.state.thread.messages = [{ id: "1", role: "user" }];
383
+ await mountFlatList({ components: messageComponents });
384
+ const props = getFlatListProps();
385
+ h.scrollToEnd.mockClear();
386
+
387
+ await act(async () => {
388
+ props.onLayout?.({
389
+ nativeEvent: { layout: { height: 100 } },
390
+ });
391
+ props.onScroll?.({
392
+ nativeEvent: {
393
+ contentOffset: { y: 0 },
394
+ contentSize: { height: 100, width: 0 },
395
+ layoutMeasurement: { height: 100, width: 0 },
396
+ },
397
+ });
398
+ props.onContentSizeChange?.(0, 140);
399
+ });
400
+
401
+ expect(h.scrollToEnd).toHaveBeenCalledWith({ animated: false });
402
+ });
403
+
404
+ it("does not treat the first content-size event as automatic content growth", async () => {
405
+ h.state.thread.messages = [{ id: "1", role: "user" }];
406
+ await mountFlatList({
407
+ components: messageComponents,
408
+ scrollToBottomOnInitialize: false,
409
+ });
410
+ const props = getFlatListProps();
411
+
412
+ await act(async () => {
413
+ props.onContentSizeChange?.(0, 140);
414
+ });
415
+
416
+ expect(h.scrollToEnd).not.toHaveBeenCalled();
417
+ });
418
+
419
+ it("lands the initialize scroll on the first content-size event", async () => {
420
+ h.state.thread.messages = [{ id: "1", role: "user" }];
421
+ await mountFlatList({ components: messageComponents });
422
+ const props = getFlatListProps();
423
+
424
+ expect(h.scrollToEnd).toHaveBeenCalledTimes(1);
425
+
426
+ await act(async () => {
427
+ props.onContentSizeChange?.(0, 140);
428
+ });
429
+
430
+ expect(h.scrollToEnd).toHaveBeenCalledTimes(2);
431
+
432
+ await act(async () => {
433
+ props.onContentSizeChange?.(0, 140);
434
+ });
435
+
436
+ expect(h.scrollToEnd).toHaveBeenCalledTimes(2);
437
+ });
438
+
439
+ it("lands the thread-switch scroll on the next content-size event", async () => {
440
+ h.state.thread.messages = [{ id: "1", role: "user" }];
441
+ await mountFlatList({ components: messageComponents });
442
+ const props = getFlatListProps();
443
+ h.scrollToEnd.mockClear();
444
+
445
+ await emit("threadListItem.switchedTo");
446
+ expect(h.scrollToEnd).toHaveBeenCalledTimes(1);
447
+
448
+ await act(async () => {
449
+ props.onContentSizeChange?.(0, 80);
450
+ });
451
+
452
+ expect(h.scrollToEnd).toHaveBeenCalledTimes(2);
453
+ });
454
+
455
+ it("keeps following through consecutive growth events without scroll echoes", async () => {
456
+ h.state.thread.messages = [{ id: "1", role: "user" }];
457
+ await mountFlatList({ components: messageComponents });
458
+ const props = getFlatListProps();
459
+
460
+ await act(async () => {
461
+ props.onLayout?.({
462
+ nativeEvent: { layout: { height: 100 } },
463
+ });
464
+ props.onScroll?.({
465
+ nativeEvent: {
466
+ contentOffset: { y: 0 },
467
+ contentSize: { height: 100, width: 0 },
468
+ layoutMeasurement: { height: 100, width: 0 },
469
+ },
470
+ });
471
+ });
472
+ h.scrollToEnd.mockClear();
473
+
474
+ await act(async () => {
475
+ props.onContentSizeChange?.(0, 140);
476
+ });
477
+ await act(async () => {
478
+ props.onContentSizeChange?.(0, 180);
479
+ });
480
+
481
+ expect(h.scrollToEnd).toHaveBeenCalledTimes(2);
482
+ });
483
+
484
+ it("does not scroll when content grows after the user scrolled away", async () => {
485
+ h.state.thread.messages = [{ id: "1", role: "user" }];
486
+ await mountFlatList({ components: messageComponents });
487
+ const props = getFlatListProps();
488
+ h.scrollToEnd.mockClear();
489
+
490
+ await act(async () => {
491
+ props.onLayout?.({
492
+ nativeEvent: { layout: { height: 100 } },
493
+ });
494
+ props.onScroll?.({
495
+ nativeEvent: {
496
+ contentOffset: { y: 200 },
497
+ contentSize: { height: 300, width: 0 },
498
+ layoutMeasurement: { height: 100, width: 0 },
499
+ },
500
+ });
501
+ props.onScroll?.({
502
+ nativeEvent: {
503
+ contentOffset: { y: 50 },
504
+ contentSize: { height: 300, width: 0 },
505
+ layoutMeasurement: { height: 100, width: 0 },
506
+ },
507
+ });
508
+ props.onContentSizeChange?.(0, 340);
509
+ });
510
+
511
+ expect(h.scrollToEnd).not.toHaveBeenCalled();
512
+ });
513
+
514
+ it("lands the run-start scroll once the appended message resizes content", async () => {
515
+ h.state.thread.messages = [{ id: "1", role: "user" }];
516
+ await mountFlatList({ components: messageComponents });
517
+ const props = getFlatListProps();
518
+
519
+ await act(async () => {
520
+ props.onLayout?.({
521
+ nativeEvent: { layout: { height: 100 } },
522
+ });
523
+ props.onScroll?.({
524
+ nativeEvent: {
525
+ contentOffset: { y: 200 },
526
+ contentSize: { height: 300, width: 0 },
527
+ layoutMeasurement: { height: 100, width: 0 },
528
+ },
529
+ });
530
+ props.onScroll?.({
531
+ nativeEvent: {
532
+ contentOffset: { y: 50 },
533
+ contentSize: { height: 300, width: 0 },
534
+ layoutMeasurement: { height: 100, width: 0 },
535
+ },
536
+ });
537
+ });
538
+ h.scrollToEnd.mockClear();
539
+
540
+ await emit("thread.runStart");
541
+ expect(h.scrollToEnd).toHaveBeenCalledTimes(1);
542
+
543
+ await act(async () => {
544
+ props.onScroll?.({
545
+ nativeEvent: {
546
+ contentOffset: { y: 120 },
547
+ contentSize: { height: 300, width: 0 },
548
+ layoutMeasurement: { height: 100, width: 0 },
549
+ },
550
+ });
551
+ props.onContentSizeChange?.(0, 360);
552
+ });
553
+
554
+ expect(h.scrollToEnd).toHaveBeenCalledTimes(2);
555
+ expect(h.scrollToEnd).toHaveBeenLastCalledWith({ animated: true });
556
+ });
557
+
558
+ it("scrolls to the bottom when switching threads", async () => {
559
+ h.state.thread.messages = [{ id: "1", role: "user" }];
560
+ await mountFlatList({ components: messageComponents });
561
+ h.scrollToEnd.mockClear();
562
+
563
+ await emit("threadListItem.switchedTo");
564
+
565
+ expect(h.scrollToEnd).toHaveBeenCalledWith({ animated: false });
566
+ });
567
+
568
+ it("does not rearm initialize scroll when thread-switch scroll is disabled", async () => {
569
+ h.state.thread.messages = [{ id: "1", role: "user" }];
570
+ await mountFlatList({
571
+ components: messageComponents,
572
+ scrollToBottomOnThreadSwitch: false,
573
+ });
574
+ h.scrollToEnd.mockClear();
575
+
576
+ await emit("threadListItem.switchedTo");
577
+
578
+ h.state.thread.messages = [
579
+ { id: "2", role: "user" },
580
+ { id: "3", role: "assistant" },
581
+ ];
582
+ await mountFlatList({
583
+ components: messageComponents,
584
+ scrollToBottomOnThreadSwitch: false,
585
+ });
586
+
587
+ expect(h.scrollToEnd).not.toHaveBeenCalled();
588
+ });
589
+
590
+ it("honors opt-outs for automatic content growth and run-start scrolls", async () => {
591
+ h.state.thread.messages = [{ id: "1", role: "user" }];
592
+ await mountFlatList({
593
+ autoScroll: false,
594
+ components: messageComponents,
595
+ scrollToBottomOnInitialize: false,
596
+ scrollToBottomOnRunStart: false,
597
+ });
598
+ const props = getFlatListProps();
599
+ h.scrollToEnd.mockClear();
600
+
601
+ await act(async () => {
602
+ props.onLayout?.({
603
+ nativeEvent: { layout: { height: 100 } },
604
+ });
605
+ props.onScroll?.({
606
+ nativeEvent: {
607
+ contentOffset: { y: 0 },
608
+ contentSize: { height: 100, width: 0 },
609
+ layoutMeasurement: { height: 100, width: 0 },
610
+ },
611
+ });
612
+ props.onContentSizeChange?.(0, 140);
613
+ });
614
+ await emit("thread.runStart");
615
+
616
+ expect(h.scrollToEnd).not.toHaveBeenCalled();
617
+ });
618
+
619
+ it("sets a useful default scroll throttle", async () => {
620
+ h.state.thread.messages = [{ id: "1", role: "user" }];
621
+ await mountFlatList({ components: messageComponents });
622
+
623
+ expect(getFlatListProps().scrollEventThrottle).toBe(16);
624
+ });
625
+ });
626
+
627
+ describe("isAtBottom pin state", () => {
628
+ const mountPinned = async () => {
629
+ await mountFlatList({ components: messageComponents });
630
+ const props = getFlatListProps();
631
+ await act(async () => {
632
+ props.onLayout?.({
633
+ nativeEvent: { layout: { height: 100 } },
634
+ });
635
+ props.onScroll?.({
636
+ nativeEvent: {
637
+ contentOffset: { y: 200 },
638
+ contentSize: { height: 300, width: 0 },
639
+ layoutMeasurement: { height: 100, width: 0 },
640
+ },
641
+ });
642
+ props.onContentSizeChange?.(0, 300);
643
+ });
644
+ h.scrollToEnd.mockClear();
645
+ return props;
646
+ };
647
+
648
+ it("stays pinned when the viewport shrinks while at the bottom", async () => {
649
+ const props = await mountPinned();
650
+
651
+ await act(async () => {
652
+ props.onLayout?.({
653
+ nativeEvent: { layout: { height: 60 } },
654
+ });
655
+ });
656
+ h.scrollToEnd.mockClear();
657
+ await act(async () => {
658
+ props.onContentSizeChange?.(0, 340);
659
+ });
660
+
661
+ expect(h.scrollToEnd).toHaveBeenCalledWith({ animated: false });
662
+ });
663
+
664
+ it("commands a bottom scroll when the viewport shrinks while pinned", async () => {
665
+ const props = await mountPinned();
666
+
667
+ await act(async () => {
668
+ props.onLayout?.({
669
+ nativeEvent: { layout: { height: 60 } },
670
+ });
671
+ });
672
+
673
+ expect(h.scrollToEnd).toHaveBeenCalledWith({ animated: false });
674
+ });
675
+
676
+ it("preserves a pending animated scroll on a pinned viewport change", async () => {
677
+ const props = await mountPinned();
678
+
679
+ await emit("thread.runStart");
680
+ h.scrollToEnd.mockClear();
681
+ await act(async () => {
682
+ props.onLayout?.({
683
+ nativeEvent: { layout: { height: 60 } },
684
+ });
685
+ });
686
+
687
+ expect(h.scrollToEnd).toHaveBeenCalledTimes(1);
688
+ expect(h.scrollToEnd).toHaveBeenCalledWith({ animated: true });
689
+ });
690
+
691
+ it("ignores a layout event with an unchanged viewport height", async () => {
692
+ const props = await mountPinned();
693
+
694
+ await act(async () => {
695
+ props.onLayout?.({
696
+ nativeEvent: { layout: { height: 100 } },
697
+ });
698
+ });
699
+
700
+ expect(h.scrollToEnd).not.toHaveBeenCalled();
701
+ });
702
+
703
+ it("does not command a scroll on the first layout measurement", async () => {
704
+ await mountFlatList({ components: messageComponents });
705
+ getFlatListProps();
706
+ h.scrollToEnd.mockClear();
707
+
708
+ await act(async () => {
709
+ getFlatListProps().onLayout?.({
710
+ nativeEvent: { layout: { height: 100 } },
711
+ });
712
+ });
713
+
714
+ expect(h.scrollToEnd).not.toHaveBeenCalled();
715
+ });
716
+
717
+ it("does not command a scroll on viewport change when autoScroll is off", async () => {
718
+ await mountFlatList({ components: messageComponents, autoScroll: false });
719
+ const props = getFlatListProps();
720
+ await act(async () => {
721
+ props.onLayout?.({
722
+ nativeEvent: { layout: { height: 100 } },
723
+ });
724
+ props.onScroll?.({
725
+ nativeEvent: {
726
+ contentOffset: { y: 200 },
727
+ contentSize: { height: 300, width: 0 },
728
+ layoutMeasurement: { height: 100, width: 0 },
729
+ },
730
+ });
731
+ props.onContentSizeChange?.(0, 300);
732
+ });
733
+ h.scrollToEnd.mockClear();
734
+
735
+ await act(async () => {
736
+ props.onLayout?.({
737
+ nativeEvent: { layout: { height: 60 } },
738
+ });
739
+ });
740
+
741
+ expect(h.scrollToEnd).not.toHaveBeenCalled();
742
+ });
743
+
744
+ it("stays unpinned when the viewport shrinks after scrolling away", async () => {
745
+ const props = await mountPinned();
746
+
747
+ await act(async () => {
748
+ props.onScroll?.({
749
+ nativeEvent: {
750
+ contentOffset: { y: 50 },
751
+ contentSize: { height: 300, width: 0 },
752
+ layoutMeasurement: { height: 100, width: 0 },
753
+ },
754
+ });
755
+ });
756
+ await act(async () => {
757
+ props.onLayout?.({
758
+ nativeEvent: { layout: { height: 60 } },
759
+ });
760
+ });
761
+ await act(async () => {
762
+ props.onContentSizeChange?.(0, 340);
763
+ });
764
+
765
+ expect(h.scrollToEnd).not.toHaveBeenCalled();
766
+ });
767
+
768
+ it("keeps the pin through a downward scroll echo after a commanded scroll", async () => {
769
+ const props = await mountPinned();
770
+
771
+ await act(async () => {
772
+ props.onScroll?.({
773
+ nativeEvent: {
774
+ contentOffset: { y: 50 },
775
+ contentSize: { height: 300, width: 0 },
776
+ layoutMeasurement: { height: 100, width: 0 },
777
+ },
778
+ });
779
+ });
780
+ await emit("thread.runStart");
781
+ await act(async () => {
782
+ props.onContentSizeChange?.(0, 320);
783
+ });
784
+ h.scrollToEnd.mockClear();
785
+ await act(async () => {
786
+ props.onScroll?.({
787
+ nativeEvent: {
788
+ contentOffset: { y: 150 },
789
+ contentSize: { height: 320, width: 0 },
790
+ layoutMeasurement: { height: 100, width: 0 },
791
+ },
792
+ });
793
+ });
794
+ await act(async () => {
795
+ props.onContentSizeChange?.(0, 360);
796
+ });
797
+
798
+ expect(h.scrollToEnd).toHaveBeenCalledWith({ animated: false });
799
+ });
800
+
801
+ it("unpins and cancels a pending scroll on an upward gesture echo", async () => {
802
+ const props = await mountPinned();
803
+
804
+ await emit("thread.runStart");
805
+ h.scrollToEnd.mockClear();
806
+ await act(async () => {
807
+ props.onScroll?.({
808
+ nativeEvent: {
809
+ contentOffset: { y: 50 },
810
+ contentSize: { height: 300, width: 0 },
811
+ layoutMeasurement: { height: 100, width: 0 },
812
+ },
813
+ });
814
+ });
815
+ await act(async () => {
816
+ props.onContentSizeChange?.(0, 360);
817
+ });
818
+
819
+ expect(h.scrollToEnd).not.toHaveBeenCalled();
820
+ });
821
+
822
+ it("scrolls on content growth while pinned and stays put while unpinned", async () => {
823
+ const props = await mountPinned();
824
+ h.scrollToEnd.mockClear();
825
+
826
+ await act(async () => {
827
+ props.onContentSizeChange?.(0, 340);
828
+ });
829
+ expect(h.scrollToEnd).toHaveBeenCalledWith({ animated: false });
830
+
831
+ await act(async () => {
832
+ props.onScroll?.({
833
+ nativeEvent: {
834
+ contentOffset: { y: 50 },
835
+ contentSize: { height: 340, width: 0 },
836
+ layoutMeasurement: { height: 100, width: 0 },
837
+ },
838
+ });
839
+ });
840
+ h.scrollToEnd.mockClear();
841
+ await act(async () => {
842
+ props.onContentSizeChange?.(0, 380);
843
+ });
844
+
845
+ expect(h.scrollToEnd).not.toHaveBeenCalled();
846
+ });
847
+ });
212
848
  });