@assistant-ui/react 0.15.25 → 0.15.27

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 (159) hide show
  1. package/dist/hooks/useToolCallElapsed.d.ts.map +1 -1
  2. package/dist/hooks/useToolCallElapsed.js +3 -2
  3. package/dist/hooks/useToolCallElapsed.js.map +1 -1
  4. package/dist/index.d.ts +31 -5
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/mcp-apps/McpAppRenderer.d.ts.map +1 -1
  7. package/dist/mcp-apps/McpAppRenderer.js +3 -2
  8. package/dist/mcp-apps/McpAppRenderer.js.map +1 -1
  9. package/dist/mcp-apps/app-frame.d.ts.map +1 -1
  10. package/dist/mcp-apps/app-frame.js +58 -48
  11. package/dist/mcp-apps/app-frame.js.map +1 -1
  12. package/dist/mcp-apps/bridge.d.ts.map +1 -1
  13. package/dist/mcp-apps/bridge.js +28 -3
  14. package/dist/mcp-apps/bridge.js.map +1 -1
  15. package/dist/mcp-apps/csp.d.ts +4 -0
  16. package/dist/mcp-apps/csp.d.ts.map +1 -0
  17. package/dist/mcp-apps/csp.js +93 -0
  18. package/dist/mcp-apps/csp.js.map +1 -0
  19. package/dist/mcp-apps/index.d.ts +1 -1
  20. package/dist/mcp-apps/index.d.ts.map +1 -1
  21. package/dist/mcp-apps/types.d.ts +19 -1
  22. package/dist/mcp-apps/types.d.ts.map +1 -1
  23. package/dist/mcp-apps/types.js.map +1 -1
  24. package/dist/primitives/assistantModal/AssistantModalRoot.d.ts +1 -0
  25. package/dist/primitives/assistantModal/AssistantModalRoot.d.ts.map +1 -1
  26. package/dist/primitives/assistantModal/AssistantModalRoot.js.map +1 -1
  27. package/dist/primitives/attachment.d.ts +3 -1
  28. package/dist/primitives/attachment.d.ts.map +1 -1
  29. package/dist/primitives/attachment.js.map +1 -1
  30. package/dist/primitives/composer/ComposerAttachmentDropzone.d.ts.map +1 -1
  31. package/dist/primitives/composer/ComposerAttachmentDropzone.js +14 -12
  32. package/dist/primitives/composer/ComposerAttachmentDropzone.js.map +1 -1
  33. package/dist/primitives/composer/ComposerCancel.d.ts +5 -1
  34. package/dist/primitives/composer/ComposerCancel.d.ts.map +1 -1
  35. package/dist/primitives/composer/ComposerCancel.js +14 -1
  36. package/dist/primitives/composer/ComposerCancel.js.map +1 -1
  37. package/dist/primitives/composer/ComposerInput.d.ts +14 -0
  38. package/dist/primitives/composer/ComposerInput.d.ts.map +1 -1
  39. package/dist/primitives/composer/ComposerInput.js +4 -3
  40. package/dist/primitives/composer/ComposerInput.js.map +1 -1
  41. package/dist/primitives/composer/ComposerRoot.d.ts.map +1 -1
  42. package/dist/primitives/composer/ComposerRoot.js +24 -19
  43. package/dist/primitives/composer/ComposerRoot.js.map +1 -1
  44. package/dist/primitives/composer/composerInputSelector.d.ts +3 -0
  45. package/dist/primitives/composer/composerInputSelector.d.ts.map +1 -0
  46. package/dist/primitives/composer/composerInputSelector.js +7 -0
  47. package/dist/primitives/composer/composerInputSelector.js.map +1 -0
  48. package/dist/primitives/composer/useComposerCancelWithFocus.d.ts +2 -0
  49. package/dist/primitives/composer/useComposerCancelWithFocus.d.ts.map +1 -0
  50. package/dist/primitives/composer/useComposerCancelWithFocus.js +42 -0
  51. package/dist/primitives/composer/useComposerCancelWithFocus.js.map +1 -0
  52. package/dist/primitives/composer.d.ts +35 -14
  53. package/dist/primitives/composer.d.ts.map +1 -1
  54. package/dist/primitives/composer.js.map +1 -1
  55. package/dist/primitives/message/MessagePartsGrouped.d.ts +8 -1
  56. package/dist/primitives/message/MessagePartsGrouped.d.ts.map +1 -1
  57. package/dist/primitives/message/MessagePartsGrouped.js +58 -278
  58. package/dist/primitives/message/MessagePartsGrouped.js.map +1 -1
  59. package/dist/primitives/selectionToolbar/SelectionToolbarRoot.d.ts.map +1 -1
  60. package/dist/primitives/selectionToolbar/SelectionToolbarRoot.js +5 -2
  61. package/dist/primitives/selectionToolbar/SelectionToolbarRoot.js.map +1 -1
  62. package/dist/primitives/thread/ThreadViewport.d.ts.map +1 -1
  63. package/dist/primitives/thread/ThreadViewport.js +4 -3
  64. package/dist/primitives/thread/ThreadViewport.js.map +1 -1
  65. package/dist/primitives/thread/topAnchor/useTopAnchorReserve.js +2 -2
  66. package/dist/primitives/thread/topAnchor/useTopAnchorReserve.js.map +1 -1
  67. package/dist/primitives/thread/useThreadViewportAutoScroll.d.ts.map +1 -1
  68. package/dist/primitives/thread/useThreadViewportAutoScroll.js +90 -21
  69. package/dist/primitives/thread/useThreadViewportAutoScroll.js.map +1 -1
  70. package/dist/sandbox-host/SandboxHost.d.ts.map +1 -1
  71. package/dist/sandbox-host/SandboxHost.js +3 -2
  72. package/dist/sandbox-host/SandboxHost.js.map +1 -1
  73. package/dist/unstable/useComposerInput.d.ts +9 -4
  74. package/dist/unstable/useComposerInput.d.ts.map +1 -1
  75. package/dist/unstable/useComposerInput.js +6 -4
  76. package/dist/unstable/useComposerInput.js.map +1 -1
  77. package/dist/unstable/useComposerInputHistory.d.ts +3 -2
  78. package/dist/unstable/useComposerInputHistory.d.ts.map +1 -1
  79. package/dist/unstable/useComposerInputHistory.js +2 -2
  80. package/dist/unstable/useComposerInputHistory.js.map +1 -1
  81. package/dist/unstable/useLiveCompletionAdapter.d.ts +3 -2
  82. package/dist/unstable/useLiveCompletionAdapter.d.ts.map +1 -1
  83. package/dist/unstable/useLiveCompletionAdapter.js +8 -7
  84. package/dist/unstable/useLiveCompletionAdapter.js.map +1 -1
  85. package/dist/unstable/useMentionAdapter.d.ts +12 -3
  86. package/dist/unstable/useMentionAdapter.d.ts.map +1 -1
  87. package/dist/unstable/useMentionAdapter.js +2 -2
  88. package/dist/unstable/useMentionAdapter.js.map +1 -1
  89. package/dist/unstable/useMessageStallDetection.d.ts +4 -2
  90. package/dist/unstable/useMessageStallDetection.d.ts.map +1 -1
  91. package/dist/unstable/useMessageStallDetection.js +2 -2
  92. package/dist/unstable/useMessageStallDetection.js.map +1 -1
  93. package/dist/unstable/useSlashCommandAdapter.d.ts +5 -2
  94. package/dist/unstable/useSlashCommandAdapter.d.ts.map +1 -1
  95. package/dist/unstable/useSlashCommandAdapter.js +5 -4
  96. package/dist/unstable/useSlashCommandAdapter.js.map +1 -1
  97. package/dist/unstable/webmcp/useWebMcpProvider.d.ts +4 -0
  98. package/dist/unstable/webmcp/useWebMcpProvider.d.ts.map +1 -1
  99. package/dist/unstable/webmcp/useWebMcpProvider.js +2 -0
  100. package/dist/unstable/webmcp/useWebMcpProvider.js.map +1 -1
  101. package/dist/utils/getSelectionMessageId.d.ts.map +1 -1
  102. package/dist/utils/getSelectionMessageId.js +31 -8
  103. package/dist/utils/getSelectionMessageId.js.map +1 -1
  104. package/dist/utils/hooks/useOnResizeContent.d.ts.map +1 -1
  105. package/dist/utils/hooks/useOnResizeContent.js +2 -22
  106. package/dist/utils/hooks/useOnResizeContent.js.map +1 -1
  107. package/dist/utils/useIsomorphicLayoutEffect.d.ts +3 -0
  108. package/dist/utils/useIsomorphicLayoutEffect.d.ts.map +1 -0
  109. package/dist/utils/useIsomorphicLayoutEffect.js +6 -0
  110. package/dist/utils/useIsomorphicLayoutEffect.js.map +1 -0
  111. package/package.json +10 -10
  112. package/src/hooks/useToolCallElapsed.test.tsx +4 -2
  113. package/src/hooks/useToolCallElapsed.ts +3 -2
  114. package/src/index.ts +20 -2
  115. package/src/mcp-apps/McpAppRenderer.test.tsx +7 -2
  116. package/src/mcp-apps/McpAppRenderer.tsx +3 -9
  117. package/src/mcp-apps/app-frame.test.tsx +102 -6
  118. package/src/mcp-apps/app-frame.tsx +7 -10
  119. package/src/mcp-apps/bridge.test.ts +154 -0
  120. package/src/mcp-apps/bridge.ts +51 -9
  121. package/src/mcp-apps/csp.test.ts +112 -0
  122. package/src/mcp-apps/csp.ts +124 -0
  123. package/src/mcp-apps/index.ts +1 -0
  124. package/src/mcp-apps/types.ts +16 -1
  125. package/src/model-context/frame/AssistantFrame.test.ts +45 -0
  126. package/src/model-context/frame/SPEC_AssistantFrame.md +1 -0
  127. package/src/primitives/assistantModal/AssistantModalRoot.tsx +1 -0
  128. package/src/primitives/attachment.ts +4 -1
  129. package/src/primitives/composer/ComposerAttachmentDropzone.test.tsx +32 -1
  130. package/src/primitives/composer/ComposerAttachmentDropzone.tsx +14 -12
  131. package/src/primitives/composer/ComposerCancel.focus.test.tsx +307 -0
  132. package/src/primitives/composer/ComposerCancel.ts +8 -1
  133. package/src/primitives/composer/ComposerDisabledThread.test.tsx +121 -0
  134. package/src/primitives/composer/ComposerInput.tsx +19 -1
  135. package/src/primitives/composer/ComposerRoot.tsx +6 -5
  136. package/src/primitives/composer/composerInputSelector.ts +4 -0
  137. package/src/primitives/composer/useComposerCancelWithFocus.ts +57 -0
  138. package/src/primitives/composer.ts +56 -21
  139. package/src/primitives/message/MessagePartsGrouped.test.tsx +106 -2
  140. package/src/primitives/message/MessagePartsGrouped.tsx +36 -134
  141. package/src/primitives/selectionToolbar/SelectionToolbarRoot.test.tsx +20 -12
  142. package/src/primitives/selectionToolbar/SelectionToolbarRoot.tsx +6 -2
  143. package/src/primitives/thread/ThreadViewport.tsx +3 -3
  144. package/src/primitives/thread/topAnchor/useTopAnchorReserve.ts +2 -2
  145. package/src/primitives/thread/useThreadViewportAutoScroll.test.tsx +666 -63
  146. package/src/primitives/thread/useThreadViewportAutoScroll.ts +201 -73
  147. package/src/sandbox-host/SandboxHost.tsx +3 -8
  148. package/src/unstable/useComposerInput.ts +9 -4
  149. package/src/unstable/useComposerInputHistory.ts +3 -2
  150. package/src/unstable/useLiveCompletionAdapter.ts +9 -14
  151. package/src/unstable/useMentionAdapter.ts +12 -3
  152. package/src/unstable/useMessageStallDetection.ts +4 -2
  153. package/src/unstable/useSlashCommandAdapter.ts +8 -4
  154. package/src/unstable/webmcp/useWebMcpProvider.ts +4 -0
  155. package/src/utils/getSelectionMessageId.test.ts +231 -0
  156. package/src/utils/getSelectionMessageId.ts +45 -7
  157. package/src/utils/hooks/useOnResizeContent.ts +2 -29
  158. package/src/utils/useIsomorphicLayoutEffect.test.tsx +21 -0
  159. package/src/utils/useIsomorphicLayoutEffect.ts +9 -0
@@ -20,7 +20,10 @@ import {
20
20
  import { useEffect, useState, type FC, type PropsWithChildren } from "react";
21
21
  import { useAuiState } from "@assistant-ui/store";
22
22
  import { AssistantRuntimeProvider } from "../../context";
23
- import { useThreadViewport } from "../../context/react/ThreadViewportContext";
23
+ import {
24
+ useThreadViewport,
25
+ useThreadViewportStore,
26
+ } from "../../context/react/ThreadViewportContext";
24
27
  import * as MessagePrimitive from "../message";
25
28
  import { ThreadPrimitiveMessages } from "./ThreadMessages";
26
29
  import { ThreadPrimitiveRoot } from "./ThreadRoot";
@@ -53,7 +56,7 @@ const getMaxScrollTop = (element: Element) =>
53
56
  let forceShortViewportMeasurement = false;
54
57
  let viewportMeasurementOffset = 0;
55
58
  const messageHeights = new Map<string, number>();
56
- const resizeObserverCallbacks = new Set<ResizeObserverCallback>();
59
+ const resizeObservers = new Set<TestResizeObserver>();
57
60
 
58
61
  const messageRows = () => [
59
62
  ...document.querySelectorAll<HTMLElement>('[data-testid="thread-message"]'),
@@ -66,22 +69,37 @@ const rowsHeight = (rows: readonly Element[]) =>
66
69
  rows.reduce((height, row) => height + rowHeight(row), 0);
67
70
 
68
71
  class TestResizeObserver {
69
- private callback: ResizeObserverCallback;
72
+ readonly targets = new Set<Element>();
73
+ readonly callback: ResizeObserverCallback;
70
74
 
71
75
  constructor(callback: ResizeObserverCallback) {
72
76
  this.callback = callback;
73
- resizeObserverCallbacks.add(callback);
77
+ resizeObservers.add(this);
74
78
  }
75
79
 
76
- observe() {}
80
+ observe(target: Element) {
81
+ this.targets.add(target);
82
+ }
83
+ unobserve(target: Element) {
84
+ this.targets.delete(target);
85
+ }
77
86
  disconnect() {
78
- resizeObserverCallbacks.delete(this.callback);
87
+ resizeObservers.delete(this);
79
88
  }
80
89
  }
81
90
 
82
91
  const notifyResizeObservers = () => {
83
- for (const callback of resizeObserverCallbacks) {
84
- callback([], {} as ResizeObserver);
92
+ for (const observer of resizeObservers) {
93
+ observer.callback([], {} as ResizeObserver);
94
+ }
95
+ };
96
+
97
+ /** Resizes only `target`, as a child growing without a DOM mutation does. */
98
+ const notifyResizeOf = (target: Element) => {
99
+ for (const observer of resizeObservers) {
100
+ if (observer.targets.has(target)) {
101
+ observer.callback([], {} as ResizeObserver);
102
+ }
85
103
  }
86
104
  };
87
105
 
@@ -176,7 +194,7 @@ afterEach(() => {
176
194
  forceShortViewportMeasurement = false;
177
195
  viewportMeasurementOffset = 0;
178
196
  messageHeights.clear();
179
- resizeObserverCallbacks.clear();
197
+ resizeObservers.clear();
180
198
  cleanup();
181
199
  });
182
200
 
@@ -201,6 +219,16 @@ const AtBottom: FC = () => {
201
219
  return <output data-testid="is-at-bottom">{String(isAtBottom)}</output>;
202
220
  };
203
221
 
222
+ const RequestSmoothScrollToBottom: FC = () => {
223
+ const threadViewportStore = useThreadViewportStore();
224
+
225
+ useEffect(() => {
226
+ threadViewportStore.getState().scrollToBottom({ behavior: "smooth" });
227
+ }, [threadViewportStore]);
228
+
229
+ return null;
230
+ };
231
+
204
232
  const Thread = ({
205
233
  autoScroll,
206
234
  scrollToBottomOnInitialize,
@@ -298,6 +326,50 @@ const DelayedThread = ({
298
326
  };
299
327
 
300
328
  describe("useThreadViewportAutoScroll", () => {
329
+ it("keeps viewport listeners and observers across rerenders", () => {
330
+ const view = render(
331
+ <SyncRuntimeProvider>
332
+ <Thread autoScroll={false} scrollToBottomOnInitialize={false} />
333
+ </SyncRuntimeProvider>,
334
+ );
335
+ const observe = vi.spyOn(TestResizeObserver.prototype, "observe");
336
+ const disconnect = vi.spyOn(TestResizeObserver.prototype, "disconnect");
337
+ const observeMutations = vi.spyOn(MutationObserver.prototype, "observe");
338
+ const disconnectMutations = vi.spyOn(
339
+ MutationObserver.prototype,
340
+ "disconnect",
341
+ );
342
+ const viewport = getViewport();
343
+ const addListenerSpy = vi.spyOn(viewport, "addEventListener");
344
+ const removeListenerSpy = vi.spyOn(viewport, "removeEventListener");
345
+ try {
346
+ view.rerender(
347
+ <SyncRuntimeProvider>
348
+ <Thread autoScroll={false} scrollToBottomOnInitialize={false} />
349
+ </SyncRuntimeProvider>,
350
+ );
351
+
352
+ expect(observe).not.toHaveBeenCalled();
353
+ expect(disconnect).not.toHaveBeenCalled();
354
+ expect(observeMutations).not.toHaveBeenCalled();
355
+ expect(disconnectMutations).not.toHaveBeenCalled();
356
+ expect(addListenerSpy).not.toHaveBeenCalled();
357
+ expect(removeListenerSpy).not.toHaveBeenCalled();
358
+
359
+ view.unmount();
360
+ expect(disconnect).toHaveBeenCalled();
361
+ expect(disconnectMutations).toHaveBeenCalled();
362
+ expect(removeListenerSpy).toHaveBeenCalled();
363
+ } finally {
364
+ observe.mockRestore();
365
+ disconnect.mockRestore();
366
+ observeMutations.mockRestore();
367
+ disconnectMutations.mockRestore();
368
+ addListenerSpy.mockRestore();
369
+ removeListenerSpy.mockRestore();
370
+ }
371
+ });
372
+
301
373
  it("preserves smooth scrolling from a control outside the viewport", async () => {
302
374
  render(
303
375
  <SyncRuntimeProvider>
@@ -327,6 +399,144 @@ describe("useThreadViewportAutoScroll", () => {
327
399
  }
328
400
  });
329
401
 
402
+ it("updates isAtBottom when a wheel interrupts a smooth scroll short of the bottom", async () => {
403
+ const view = render(
404
+ <SyncRuntimeProvider>
405
+ <Thread autoScroll={false} scrollToBottomOnInitialize={false} />
406
+ <ThreadPrimitiveScrollToBottom behavior="smooth">
407
+ Scroll to bottom
408
+ </ThreadPrimitiveScrollToBottom>
409
+ </SyncRuntimeProvider>,
410
+ );
411
+
412
+ const viewport = getViewport();
413
+ await waitFor(() => {
414
+ expect(screen.getAllByTestId("thread-message")).toHaveLength(
415
+ messages.length,
416
+ );
417
+ });
418
+
419
+ act(() => {
420
+ viewport.scrollTop = getMaxScrollTop(viewport);
421
+ viewport.dispatchEvent(new Event("scroll"));
422
+ });
423
+ expect(screen.getByTestId("is-at-bottom").textContent).toBe("true");
424
+ viewportMeasurementOffset += 200;
425
+
426
+ const scrollToSpy = vi
427
+ .spyOn(viewport, "scrollTo")
428
+ .mockImplementation(() => {});
429
+ try {
430
+ view.rerender(
431
+ <SyncRuntimeProvider>
432
+ <Thread autoScroll={false} scrollToBottomOnInitialize={false} />
433
+ <ThreadPrimitiveScrollToBottom behavior="smooth">
434
+ Scroll to bottom
435
+ </ThreadPrimitiveScrollToBottom>
436
+ <RequestSmoothScrollToBottom />
437
+ </SyncRuntimeProvider>,
438
+ );
439
+
440
+ await waitFor(() => {
441
+ expect(scrollToSpy).toHaveBeenCalledWith({
442
+ top: viewport.scrollHeight,
443
+ behavior: "smooth",
444
+ });
445
+ });
446
+
447
+ act(() => {
448
+ viewport.scrollTop += 100;
449
+ viewport.dispatchEvent(new Event("scroll"));
450
+ });
451
+ expect(screen.getByTestId("is-at-bottom").textContent).toBe("true");
452
+ expect(
453
+ (
454
+ screen.getByRole("button", {
455
+ name: "Scroll to bottom",
456
+ }) as HTMLButtonElement
457
+ ).disabled,
458
+ ).toBe(true);
459
+
460
+ act(() => {
461
+ viewport.dispatchEvent(new WheelEvent("wheel"));
462
+ });
463
+
464
+ expect(screen.getByTestId("is-at-bottom").textContent).toBe("false");
465
+ expect(
466
+ (
467
+ screen.getByRole("button", {
468
+ name: "Scroll to bottom",
469
+ }) as HTMLButtonElement
470
+ ).disabled,
471
+ ).toBe(false);
472
+ } finally {
473
+ scrollToSpy.mockRestore();
474
+ }
475
+ });
476
+
477
+ it("keeps a coalesced user scroll-up after a programmatic bottom scroll", async () => {
478
+ render(
479
+ <SyncRuntimeProvider>
480
+ <Thread autoScroll={false} scrollToBottomOnInitialize={false} />
481
+ <ThreadPrimitiveScrollToBottom behavior="instant">
482
+ Scroll to bottom
483
+ </ThreadPrimitiveScrollToBottom>
484
+ </SyncRuntimeProvider>,
485
+ );
486
+
487
+ const viewport = getViewport();
488
+ await waitFor(() => {
489
+ expect(screen.getAllByTestId("thread-message")).toHaveLength(
490
+ messages.length,
491
+ );
492
+ });
493
+
494
+ act(() => {
495
+ viewport.scrollTop = 0;
496
+ viewport.dispatchEvent(new Event("scroll"));
497
+ });
498
+ await waitFor(() => {
499
+ expect(
500
+ screen
501
+ .getByRole("button", { name: "Scroll to bottom" })
502
+ .hasAttribute("disabled"),
503
+ ).toBe(false);
504
+ });
505
+
506
+ const scrollToDescriptor = Object.getOwnPropertyDescriptor(
507
+ viewport,
508
+ "scrollTo",
509
+ );
510
+ Object.defineProperty(viewport, "scrollTo", {
511
+ configurable: true,
512
+ value: ({ top = 0 }: ScrollToOptions) => {
513
+ viewport.scrollTop = Math.min(Number(top), getMaxScrollTop(viewport));
514
+ },
515
+ });
516
+ try {
517
+ fireEvent.click(screen.getByRole("button", { name: "Scroll to bottom" }));
518
+
519
+ act(() => {
520
+ viewport.scrollTop -= 80;
521
+ viewport.dispatchEvent(new Event("scroll"));
522
+ });
523
+
524
+ const scrollTopAfterLeave = viewport.scrollTop;
525
+ viewportMeasurementOffset += 200;
526
+ act(notifyResizeObservers);
527
+
528
+ expect(viewport.scrollTop).toBe(scrollTopAfterLeave);
529
+ expect(viewport.scrollTop).toBeLessThan(getMaxScrollTop(viewport));
530
+ expect(screen.getByTestId("is-at-bottom").textContent).toBe("false");
531
+ } finally {
532
+ if (scrollToDescriptor) {
533
+ Object.defineProperty(viewport, "scrollTo", scrollToDescriptor);
534
+ } else {
535
+ Reflect.deleteProperty(viewport, "scrollTo");
536
+ }
537
+ }
538
+ });
539
+
330
540
  it("scrolls sync initialMessages to the bottom when the viewport mounts after initialization", async () => {
331
541
  render(
332
542
  <SyncRuntimeProvider>
@@ -436,6 +646,47 @@ describe("useThreadViewportAutoScroll", () => {
436
646
  expect(screen.getByTestId("is-at-bottom").textContent).toBe("true");
437
647
  });
438
648
 
649
+ it("follows a message that grows without a DOM mutation", async () => {
650
+ render(
651
+ <SyncRuntimeProvider>
652
+ <BottomAnchorThread />
653
+ </SyncRuntimeProvider>,
654
+ );
655
+
656
+ const viewport = getViewport();
657
+ await waitFor(() => {
658
+ expect(viewport.scrollTop).toBe(getMaxScrollTop(viewport));
659
+ });
660
+
661
+ const lastRow = messageRows().at(-1)!;
662
+ messageHeights.set(lastRow.getAttribute("data-message-id")!, 280);
663
+ act(() => notifyResizeOf(lastRow));
664
+
665
+ expect(viewport.scrollTop).toBe(getMaxScrollTop(viewport));
666
+ expect(screen.getByTestId("is-at-bottom").textContent).toBe("true");
667
+ });
668
+
669
+ it("reports leaving the bottom when a message grows without a DOM mutation", async () => {
670
+ render(
671
+ <SyncRuntimeProvider>
672
+ <Thread />
673
+ </SyncRuntimeProvider>,
674
+ );
675
+
676
+ const viewport = getViewport();
677
+ await waitFor(() => {
678
+ expect(viewport.scrollTop).toBe(getMaxScrollTop(viewport));
679
+ expect(screen.getByTestId("is-at-bottom").textContent).toBe("true");
680
+ });
681
+
682
+ const lastRow = messageRows().at(-1)!;
683
+ messageHeights.set(lastRow.getAttribute("data-message-id")!, 280);
684
+ act(() => notifyResizeOf(lastRow));
685
+
686
+ expect(viewport.scrollTop).toBeLessThan(getMaxScrollTop(viewport));
687
+ expect(screen.getByTestId("is-at-bottom").textContent).toBe("false");
688
+ });
689
+
439
690
  it("keeps following after a pointerdown that does not scroll the viewport", async () => {
440
691
  render(
441
692
  <SyncRuntimeProvider>
@@ -458,8 +709,355 @@ describe("useThreadViewportAutoScroll", () => {
458
709
  expect(screen.getByTestId("is-at-bottom").textContent).toBe("true");
459
710
  });
460
711
 
712
+ describe("disclosures in a message", () => {
713
+ const renderPinned = async () => {
714
+ render(
715
+ <SyncRuntimeProvider>
716
+ <BottomAnchorThread />
717
+ </SyncRuntimeProvider>,
718
+ );
719
+ const viewport = getViewport();
720
+ await waitFor(() => {
721
+ expect(viewport.scrollTop).toBe(getMaxScrollTop(viewport));
722
+ });
723
+ return viewport;
724
+ };
725
+ const growContent = () => {
726
+ viewportMeasurementOffset += 200;
727
+ act(notifyResizeObservers);
728
+ };
729
+ const lastMessage = () => screen.getAllByTestId("thread-message").at(-1)!;
730
+ const addTrigger = (
731
+ attributes: Record<string, string>,
732
+ parent: Element = lastMessage(),
733
+ ) => {
734
+ const trigger = document.createElement("button");
735
+ for (const [name, value] of Object.entries(attributes))
736
+ trigger.setAttribute(name, value);
737
+ parent.append(trigger);
738
+ return trigger;
739
+ };
740
+ const click = (target: Element) => {
741
+ act(() => {
742
+ target.dispatchEvent(new MouseEvent("click", { bubbles: true }));
743
+ });
744
+ };
745
+
746
+ it("stops following after a click expands a collapsed trigger", async () => {
747
+ const viewport = await renderPinned();
748
+ const scrollTop = viewport.scrollTop;
749
+
750
+ click(addTrigger({ "aria-expanded": "false" }));
751
+ growContent();
752
+
753
+ expect(viewport.scrollTop).toBe(scrollTop);
754
+ expect(screen.getByTestId("is-at-bottom").textContent).toBe("false");
755
+ });
756
+
757
+ it("stops following after opening a closed details summary", async () => {
758
+ const viewport = await renderPinned();
759
+ const scrollTop = viewport.scrollTop;
760
+ const details = document.createElement("details");
761
+ const summary = document.createElement("summary");
762
+ details.append(summary);
763
+ lastMessage().append(details);
764
+
765
+ click(summary);
766
+ growContent();
767
+
768
+ expect(viewport.scrollTop).toBe(scrollTop);
769
+ });
770
+
771
+ it("keeps the pause through a resize while the thread still fits", async () => {
772
+ forceShortViewportMeasurement = true;
773
+ const viewport = await renderPinned();
774
+
775
+ click(addTrigger({ "aria-expanded": "false" }));
776
+ act(notifyResizeObservers);
777
+ forceShortViewportMeasurement = false;
778
+ growContent();
779
+
780
+ expect(viewport.scrollTop).toBe(0);
781
+ expect(getMaxScrollTop(viewport)).toBeGreaterThan(0);
782
+ });
783
+
784
+ it("follows again after the reader scrolls back to the bottom", async () => {
785
+ const viewport = await renderPinned();
786
+
787
+ click(addTrigger({ "aria-expanded": "false" }));
788
+ growContent();
789
+ act(() => {
790
+ viewport.dispatchEvent(new WheelEvent("wheel"));
791
+ viewport.scrollTop = getMaxScrollTop(viewport);
792
+ viewport.dispatchEvent(new Event("scroll"));
793
+ });
794
+ growContent();
795
+
796
+ expect(viewport.scrollTop).toBe(getMaxScrollTop(viewport));
797
+ });
798
+
799
+ it.each([
800
+ { label: "an expanded trigger", attributes: { "aria-expanded": "true" } },
801
+ {
802
+ label: "a dialog trigger",
803
+ attributes: { "aria-expanded": "false", "aria-haspopup": "dialog" },
804
+ },
805
+ {
806
+ label: "a menu trigger",
807
+ attributes: { "aria-expanded": "false", "aria-haspopup": "menu" },
808
+ },
809
+ {
810
+ label: "a combobox",
811
+ attributes: { "aria-expanded": "false", role: "combobox" },
812
+ },
813
+ { label: "a plain control", attributes: {} },
814
+ ])("keeps following after clicking $label", async ({ attributes }) => {
815
+ const viewport = await renderPinned();
816
+
817
+ click(addTrigger(attributes));
818
+ growContent();
819
+
820
+ expect(viewport.scrollTop).toBe(getMaxScrollTop(viewport));
821
+ });
822
+
823
+ it("keeps following after a press on a collapsed trigger that never clicks", async () => {
824
+ const viewport = await renderPinned();
825
+
826
+ act(() => {
827
+ addTrigger({ "aria-expanded": "false" }).dispatchEvent(
828
+ new Event("pointerdown", { bubbles: true }),
829
+ );
830
+ });
831
+ growContent();
832
+
833
+ expect(viewport.scrollTop).toBe(getMaxScrollTop(viewport));
834
+ });
835
+
836
+ it("keeps following after expanding a trigger outside the messages", async () => {
837
+ const viewport = await renderPinned();
838
+
839
+ click(addTrigger({ "aria-expanded": "false" }, viewport));
840
+ growContent();
841
+
842
+ expect(viewport.scrollTop).toBe(getMaxScrollTop(viewport));
843
+ });
844
+ it.each([
845
+ {
846
+ label: "flips its state natively",
847
+ listen: (trigger: HTMLElement) =>
848
+ trigger.addEventListener("click", () =>
849
+ trigger.setAttribute("aria-expanded", "true"),
850
+ ),
851
+ },
852
+ {
853
+ label: "stops the event",
854
+ listen: (trigger: HTMLElement) =>
855
+ trigger.addEventListener("click", (event) => event.stopPropagation()),
856
+ },
857
+ ])("stops following when the trigger $label", async ({ listen }) => {
858
+ const viewport = await renderPinned();
859
+ const scrollTop = viewport.scrollTop;
860
+ const trigger = addTrigger({ "aria-expanded": "false" });
861
+ listen(trigger);
862
+
863
+ click(trigger);
864
+ growContent();
865
+
866
+ expect(viewport.scrollTop).toBe(scrollTop);
867
+ });
868
+
869
+ it("keeps the pause when a scroll gesture stops short of the bottom and the thread then fits", async () => {
870
+ const viewport = await renderPinned();
871
+
872
+ click(addTrigger({ "aria-expanded": "false" }));
873
+ growContent();
874
+ act(() => {
875
+ viewport.dispatchEvent(new WheelEvent("wheel"));
876
+ viewport.scrollTop = getMaxScrollTop(viewport) - 50;
877
+ viewport.dispatchEvent(new Event("scroll"));
878
+ });
879
+ forceShortViewportMeasurement = true;
880
+ act(notifyResizeObservers);
881
+ forceShortViewportMeasurement = false;
882
+ const scrollTop = viewport.scrollTop;
883
+ growContent();
884
+
885
+ expect(viewport.scrollTop).toBe(scrollTop);
886
+ });
887
+
888
+ it("treats aria-haspopup=false as a disclosure", async () => {
889
+ const viewport = await renderPinned();
890
+ const scrollTop = viewport.scrollTop;
891
+
892
+ click(addTrigger({ "aria-expanded": "false", "aria-haspopup": "false" }));
893
+ growContent();
894
+
895
+ expect(viewport.scrollTop).toBe(scrollTop);
896
+ });
897
+
898
+ it("follows again after the reader pages back to the bottom with Space", async () => {
899
+ const viewport = await renderPinned();
900
+
901
+ click(addTrigger({ "aria-expanded": "false" }));
902
+ growContent();
903
+ act(() => {
904
+ viewport.dispatchEvent(
905
+ new KeyboardEvent("keydown", { key: " ", bubbles: true }),
906
+ );
907
+ viewport.scrollTop = getMaxScrollTop(viewport);
908
+ viewport.dispatchEvent(new Event("scroll"));
909
+ });
910
+ growContent();
911
+
912
+ expect(viewport.scrollTop).toBe(getMaxScrollTop(viewport));
913
+ });
914
+
915
+ it("keeps following after a non-activating key on a collapsed trigger", async () => {
916
+ const viewport = await renderPinned();
917
+
918
+ act(() => {
919
+ addTrigger({ "aria-expanded": "false" }).dispatchEvent(
920
+ new KeyboardEvent("keydown", { key: "Tab", bubbles: true }),
921
+ );
922
+ });
923
+ growContent();
924
+
925
+ expect(viewport.scrollTop).toBe(getMaxScrollTop(viewport));
926
+ });
927
+
928
+ const renderWithoutRunStartScroll = async () => {
929
+ let runtime: ReturnType<typeof useLocalRuntime> | null = null;
930
+ const Harness: FC = () => {
931
+ runtime = useLocalRuntime(adapter, { initialMessages: messages });
932
+ return (
933
+ <AssistantRuntimeProvider runtime={runtime}>
934
+ <ThreadPrimitiveRoot>
935
+ <ThreadPrimitiveViewport
936
+ data-testid="viewport"
937
+ scrollToBottomOnRunStart={false}
938
+ >
939
+ <ThreadPrimitiveMessages components={{ Message }} />
940
+ <AtBottom />
941
+ </ThreadPrimitiveViewport>
942
+ </ThreadPrimitiveRoot>
943
+ </AssistantRuntimeProvider>
944
+ );
945
+ };
946
+ render(<Harness />);
947
+ const viewport = getViewport();
948
+ await waitFor(() => {
949
+ expect(viewport.scrollTop).toBe(getMaxScrollTop(viewport));
950
+ });
951
+ const startRun = () =>
952
+ act(async () => {
953
+ runtime!.thread.append({
954
+ role: "user",
955
+ content: [{ type: "text", text: "next" }],
956
+ });
957
+ });
958
+ return { viewport, startRun };
959
+ };
960
+
961
+ it("clears the pause when a run starts, even when run start does not scroll", async () => {
962
+ const { viewport, startRun } = await renderWithoutRunStartScroll();
963
+
964
+ click(addTrigger({ "aria-expanded": "false" }));
965
+ await startRun();
966
+ growContent();
967
+
968
+ expect(viewport.scrollTop).toBe(getMaxScrollTop(viewport));
969
+ });
970
+
971
+ it("follows again after a run starts even when the expansion moved the reader off the bottom", async () => {
972
+ const { viewport, startRun } = await renderWithoutRunStartScroll();
973
+
974
+ click(addTrigger({ "aria-expanded": "false" }));
975
+ growContent();
976
+ expect(viewport.scrollTop).toBeLessThan(getMaxScrollTop(viewport));
977
+ await startRun();
978
+ growContent();
979
+
980
+ expect(viewport.scrollTop).toBe(getMaxScrollTop(viewport));
981
+ });
982
+
983
+ it("keeps the reader's place after a run starts when they scrolled up during the pause", async () => {
984
+ const { viewport, startRun } = await renderWithoutRunStartScroll();
985
+
986
+ click(addTrigger({ "aria-expanded": "false" }));
987
+ act(() => {
988
+ viewport.dispatchEvent(new WheelEvent("wheel"));
989
+ viewport.scrollTop = getMaxScrollTop(viewport) - 50;
990
+ viewport.dispatchEvent(new Event("scroll"));
991
+ });
992
+ const scrollTop = viewport.scrollTop;
993
+ await startRun();
994
+ growContent();
995
+
996
+ expect(viewport.scrollTop).toBe(scrollTop);
997
+ });
998
+
999
+ it("clears the pause on a thread switch, even when the switch does not scroll", async () => {
1000
+ const SwitchingThread = () => {
1001
+ const [thread, setThread] = useState({ id: "a", messages });
1002
+ const runtime = useExternalStoreRuntime<ThreadMessageLike>({
1003
+ messages: thread.messages,
1004
+ convertMessage: (message) => message,
1005
+ onNew: async () => {},
1006
+ adapters: {
1007
+ threadList: {
1008
+ threadId: thread.id,
1009
+ threads: [
1010
+ { id: "a", status: "regular" },
1011
+ { id: "b", status: "regular" },
1012
+ ],
1013
+ },
1014
+ },
1015
+ });
1016
+ return (
1017
+ <AssistantRuntimeProvider runtime={runtime}>
1018
+ <Thread autoScroll scrollToBottomOnThreadSwitch={false} />
1019
+ <button
1020
+ type="button"
1021
+ data-testid="switch"
1022
+ onClick={() =>
1023
+ setThread({
1024
+ id: "b",
1025
+ messages: messages.map((message, index) => ({
1026
+ ...message,
1027
+ content: [{ type: "text", text: `Reply ${index + 1}` }],
1028
+ })),
1029
+ })
1030
+ }
1031
+ />
1032
+ </AssistantRuntimeProvider>
1033
+ );
1034
+ };
1035
+ render(<SwitchingThread />);
1036
+ const viewport = getViewport();
1037
+ await waitFor(() =>
1038
+ expect(screen.getAllByTestId("thread-message")).toHaveLength(
1039
+ messages.length,
1040
+ ),
1041
+ );
1042
+ act(() => {
1043
+ viewport.scrollTop = getMaxScrollTop(viewport);
1044
+ fireEvent.scroll(viewport);
1045
+ });
1046
+
1047
+ click(addTrigger({ "aria-expanded": "false" }));
1048
+ await act(async () => {
1049
+ fireEvent.click(screen.getByTestId("switch"));
1050
+ });
1051
+ growContent();
1052
+
1053
+ expect(viewport.scrollTop).toBe(getMaxScrollTop(viewport));
1054
+ });
1055
+ });
1056
+
461
1057
  it.each([
462
1058
  { label: "pointerdown", make: () => new Event("pointerdown") },
1059
+ { label: "wheel", make: () => new WheelEvent("wheel") },
1060
+ { label: "touchstart", make: () => new Event("touchstart") },
463
1061
  {
464
1062
  label: "Enter keydown",
465
1063
  make: () => new KeyboardEvent("keydown", { key: "Enter" }),
@@ -468,6 +1066,12 @@ describe("useThreadViewportAutoScroll", () => {
468
1066
  label: "Space keydown",
469
1067
  make: () => new KeyboardEvent("keydown", { key: " " }),
470
1068
  },
1069
+ ...["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End"].map(
1070
+ (key) => ({
1071
+ label: `${key} keydown`,
1072
+ make: () => new KeyboardEvent("keydown", { key }),
1073
+ }),
1074
+ ),
471
1075
  ])(
472
1076
  "drops pending bottom-scroll intent after a $label in a thread that cannot scroll",
473
1077
  async ({ make }) => {
@@ -503,6 +1107,8 @@ describe("useThreadViewportAutoScroll", () => {
503
1107
 
504
1108
  it.each([
505
1109
  { label: "pointerdown", make: () => new Event("pointerdown") },
1110
+ { label: "wheel", make: () => new WheelEvent("wheel") },
1111
+ { label: "touchstart", make: () => new Event("touchstart") },
506
1112
  {
507
1113
  label: "Enter keydown",
508
1114
  make: () => new KeyboardEvent("keydown", { key: "Enter" }),
@@ -511,6 +1117,10 @@ describe("useThreadViewportAutoScroll", () => {
511
1117
  label: "Space keydown",
512
1118
  make: () => new KeyboardEvent("keydown", { key: " " }),
513
1119
  },
1120
+ {
1121
+ label: "ArrowDown keydown",
1122
+ make: () => new KeyboardEvent("keydown", { key: "ArrowDown" }),
1123
+ },
514
1124
  ])(
515
1125
  "cancels the frame a pending bottom scroll queued when a $label arrives first",
516
1126
  async ({ make }) => {
@@ -566,73 +1176,66 @@ describe("useThreadViewportAutoScroll", () => {
566
1176
  },
567
1177
  );
568
1178
 
569
- it.each([
570
- "Shift",
571
- "Control",
572
- "Meta",
573
- "Tab",
574
- "ArrowDown",
575
- "PageDown",
576
- "Escape",
577
- "a",
578
- ])("keeps pending bottom-scroll intent through a %s press", async (key) => {
579
- forceShortViewportMeasurement = true;
580
-
581
- render(
582
- <AsyncRuntimeProvider>
583
- <Thread />
584
- </AsyncRuntimeProvider>,
585
- );
1179
+ it.each(["Shift", "Control", "Meta", "Tab", "Escape", "a"])(
1180
+ "keeps pending bottom-scroll intent through a %s press",
1181
+ async (key) => {
1182
+ forceShortViewportMeasurement = true;
586
1183
 
587
- await waitFor(() => {
588
- expect(screen.getAllByTestId("thread-message")).toHaveLength(
589
- messages.length,
1184
+ render(
1185
+ <AsyncRuntimeProvider>
1186
+ <Thread />
1187
+ </AsyncRuntimeProvider>,
590
1188
  );
591
- });
592
- await new Promise((resolve) => setTimeout(resolve, 0));
593
1189
 
594
- // neither an activation key nor consumed by a text field
595
- act(() => {
596
- getViewport().dispatchEvent(new KeyboardEvent("keydown", { key }));
597
- });
1190
+ await waitFor(() => {
1191
+ expect(screen.getAllByTestId("thread-message")).toHaveLength(
1192
+ messages.length,
1193
+ );
1194
+ });
1195
+ await new Promise((resolve) => setTimeout(resolve, 0));
598
1196
 
599
- forceShortViewportMeasurement = false;
600
- act(notifyResizeObservers);
1197
+ // neither an activation key nor consumed by a text field
1198
+ act(() => {
1199
+ getViewport().dispatchEvent(new KeyboardEvent("keydown", { key }));
1200
+ });
601
1201
 
602
- expect(getViewport().scrollTop).toBe(getMaxScrollTop(getViewport()));
603
- });
1202
+ forceShortViewportMeasurement = false;
1203
+ act(notifyResizeObservers);
604
1204
 
605
- it("keeps pending bottom-scroll intent while the user types in the composer", async () => {
606
- forceShortViewportMeasurement = true;
1205
+ expect(getViewport().scrollTop).toBe(getMaxScrollTop(getViewport()));
1206
+ },
1207
+ );
607
1208
 
608
- render(
609
- <AsyncRuntimeProvider>
610
- <Thread />
611
- </AsyncRuntimeProvider>,
612
- );
1209
+ it.each([" ", "ArrowDown"])(
1210
+ "keeps pending bottom-scroll intent through a %s press in the composer",
1211
+ async (key) => {
1212
+ forceShortViewportMeasurement = true;
613
1213
 
614
- await waitFor(() => {
615
- expect(screen.getAllByTestId("thread-message")).toHaveLength(
616
- messages.length,
1214
+ render(
1215
+ <AsyncRuntimeProvider>
1216
+ <Thread />
1217
+ </AsyncRuntimeProvider>,
617
1218
  );
618
- });
619
- await new Promise((resolve) => setTimeout(resolve, 0));
620
1219
 
621
- act(() => {
622
- screen
623
- .getByTestId("composer")
624
- // Space is an activation key, so this reaches the text-entry check
625
- // instead of short-circuiting on the allowlist
626
- .dispatchEvent(
627
- new KeyboardEvent("keydown", { key: " ", bubbles: true }),
1220
+ await waitFor(() => {
1221
+ expect(screen.getAllByTestId("thread-message")).toHaveLength(
1222
+ messages.length,
628
1223
  );
629
- });
1224
+ });
1225
+ await new Promise((resolve) => setTimeout(resolve, 0));
630
1226
 
631
- forceShortViewportMeasurement = false;
632
- act(notifyResizeObservers);
1227
+ act(() => {
1228
+ screen
1229
+ .getByTestId("composer")
1230
+ .dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true }));
1231
+ });
633
1232
 
634
- expect(getViewport().scrollTop).toBe(getMaxScrollTop(getViewport()));
635
- });
1233
+ forceShortViewportMeasurement = false;
1234
+ act(notifyResizeObservers);
1235
+
1236
+ expect(getViewport().scrollTop).toBe(getMaxScrollTop(getViewport()));
1237
+ },
1238
+ );
636
1239
 
637
1240
  it.each([
638
1241
  { label: "a checkbox", testid: "checkbox" },