@assistant-ui/react 0.15.24 → 0.15.26

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 (204) hide show
  1. package/dist/hooks/useToolCallElapsed.d.ts +3 -1
  2. package/dist/hooks/useToolCallElapsed.d.ts.map +1 -1
  3. package/dist/hooks/useToolCallElapsed.js +8 -8
  4. package/dist/hooks/useToolCallElapsed.js.map +1 -1
  5. package/dist/index.d.ts +39 -7
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +3 -3
  8. package/dist/mcp-apps/McpAppRenderer.d.ts.map +1 -1
  9. package/dist/mcp-apps/McpAppRenderer.js +3 -2
  10. package/dist/mcp-apps/McpAppRenderer.js.map +1 -1
  11. package/dist/mcp-apps/app-frame.d.ts.map +1 -1
  12. package/dist/mcp-apps/app-frame.js +56 -46
  13. package/dist/mcp-apps/app-frame.js.map +1 -1
  14. package/dist/mcp-apps/csp.d.ts +4 -0
  15. package/dist/mcp-apps/csp.d.ts.map +1 -0
  16. package/dist/mcp-apps/csp.js +93 -0
  17. package/dist/mcp-apps/csp.js.map +1 -0
  18. package/dist/mcp-apps/types.d.ts +1 -0
  19. package/dist/mcp-apps/types.d.ts.map +1 -1
  20. package/dist/mcp-apps/types.js.map +1 -1
  21. package/dist/model-context/makeAssistantVisible.d.ts +1 -0
  22. package/dist/model-context/makeAssistantVisible.d.ts.map +1 -1
  23. package/dist/model-context/makeAssistantVisible.js +31 -12
  24. package/dist/model-context/makeAssistantVisible.js.map +1 -1
  25. package/dist/primitives/assistantModal/AssistantModalRoot.d.ts +1 -0
  26. package/dist/primitives/assistantModal/AssistantModalRoot.d.ts.map +1 -1
  27. package/dist/primitives/assistantModal/AssistantModalRoot.js.map +1 -1
  28. package/dist/primitives/attachment.d.ts +3 -1
  29. package/dist/primitives/attachment.d.ts.map +1 -1
  30. package/dist/primitives/attachment.js.map +1 -1
  31. package/dist/primitives/composer/ComposerCancel.d.ts +5 -1
  32. package/dist/primitives/composer/ComposerCancel.d.ts.map +1 -1
  33. package/dist/primitives/composer/ComposerCancel.js +14 -1
  34. package/dist/primitives/composer/ComposerCancel.js.map +1 -1
  35. package/dist/primitives/composer/ComposerInput.d.ts +15 -1
  36. package/dist/primitives/composer/ComposerInput.d.ts.map +1 -1
  37. package/dist/primitives/composer/ComposerInput.js +5 -4
  38. package/dist/primitives/composer/ComposerInput.js.map +1 -1
  39. package/dist/primitives/composer/ComposerRoot.d.ts.map +1 -1
  40. package/dist/primitives/composer/ComposerRoot.js +24 -19
  41. package/dist/primitives/composer/ComposerRoot.js.map +1 -1
  42. package/dist/primitives/composer/composerInputSelector.d.ts +3 -0
  43. package/dist/primitives/composer/composerInputSelector.d.ts.map +1 -0
  44. package/dist/primitives/composer/composerInputSelector.js +7 -0
  45. package/dist/primitives/composer/composerInputSelector.js.map +1 -0
  46. package/dist/primitives/composer/trigger/TriggerPopover.d.ts +11 -11
  47. package/dist/primitives/composer/trigger/TriggerPopover.d.ts.map +1 -1
  48. package/dist/primitives/composer/trigger/TriggerPopover.js +62 -60
  49. package/dist/primitives/composer/trigger/TriggerPopover.js.map +1 -1
  50. package/dist/primitives/composer/trigger/TriggerPopoverAction.d.ts +8 -8
  51. package/dist/primitives/composer/trigger/TriggerPopoverAction.d.ts.map +1 -1
  52. package/dist/primitives/composer/trigger/TriggerPopoverAction.js +6 -6
  53. package/dist/primitives/composer/trigger/TriggerPopoverAction.js.map +1 -1
  54. package/dist/primitives/composer/trigger/TriggerPopoverCategories.d.ts +3 -3
  55. package/dist/primitives/composer/trigger/TriggerPopoverCategories.d.ts.map +1 -1
  56. package/dist/primitives/composer/trigger/TriggerPopoverCategories.js.map +1 -1
  57. package/dist/primitives/composer/trigger/TriggerPopoverDirective.d.ts +9 -9
  58. package/dist/primitives/composer/trigger/TriggerPopoverDirective.d.ts.map +1 -1
  59. package/dist/primitives/composer/trigger/TriggerPopoverDirective.js +7 -7
  60. package/dist/primitives/composer/trigger/TriggerPopoverDirective.js.map +1 -1
  61. package/dist/primitives/composer/trigger/TriggerPopoverItems.d.ts +5 -5
  62. package/dist/primitives/composer/trigger/TriggerPopoverItems.d.ts.map +1 -1
  63. package/dist/primitives/composer/trigger/TriggerPopoverItems.js.map +1 -1
  64. package/dist/primitives/composer/trigger/TriggerPopoverRootContext.d.ts +8 -8
  65. package/dist/primitives/composer/trigger/TriggerPopoverRootContext.js +8 -8
  66. package/dist/primitives/composer/trigger/TriggerPopoverRootContext.js.map +1 -1
  67. package/dist/primitives/composer/trigger/index.d.ts +1 -1
  68. package/dist/primitives/composer/useComposerCancelWithFocus.d.ts +2 -0
  69. package/dist/primitives/composer/useComposerCancelWithFocus.d.ts.map +1 -0
  70. package/dist/primitives/composer/useComposerCancelWithFocus.js +42 -0
  71. package/dist/primitives/composer/useComposerCancelWithFocus.js.map +1 -0
  72. package/dist/primitives/composer.d.ts +55 -15
  73. package/dist/primitives/composer.d.ts.map +1 -1
  74. package/dist/primitives/composer.js +8 -1
  75. package/dist/primitives/composer.js.map +1 -1
  76. package/dist/primitives/message/MessagePartsGrouped.d.ts +8 -1
  77. package/dist/primitives/message/MessagePartsGrouped.d.ts.map +1 -1
  78. package/dist/primitives/message/MessagePartsGrouped.js +58 -278
  79. package/dist/primitives/message/MessagePartsGrouped.js.map +1 -1
  80. package/dist/primitives/selectionToolbar/SelectionToolbarRoot.d.ts.map +1 -1
  81. package/dist/primitives/selectionToolbar/SelectionToolbarRoot.js +5 -2
  82. package/dist/primitives/selectionToolbar/SelectionToolbarRoot.js.map +1 -1
  83. package/dist/primitives/thread/ThreadLoadEarlier.d.ts +19 -0
  84. package/dist/primitives/thread/ThreadLoadEarlier.d.ts.map +1 -0
  85. package/dist/primitives/thread/ThreadLoadEarlier.js +36 -0
  86. package/dist/primitives/thread/ThreadLoadEarlier.js.map +1 -0
  87. package/dist/primitives/thread/ThreadMessages.d.ts +1 -1
  88. package/dist/primitives/thread/ThreadMessages.d.ts.map +1 -1
  89. package/dist/primitives/thread/ThreadMessages.js +2 -2
  90. package/dist/primitives/thread/ThreadViewport.d.ts.map +1 -1
  91. package/dist/primitives/thread/ThreadViewport.js +4 -3
  92. package/dist/primitives/thread/ThreadViewport.js.map +1 -1
  93. package/dist/primitives/thread/topAnchor/useTopAnchorReserve.js +2 -2
  94. package/dist/primitives/thread/topAnchor/useTopAnchorReserve.js.map +1 -1
  95. package/dist/primitives/thread/useThreadViewportAutoScroll.d.ts.map +1 -1
  96. package/dist/primitives/thread/useThreadViewportAutoScroll.js +195 -17
  97. package/dist/primitives/thread/useThreadViewportAutoScroll.js.map +1 -1
  98. package/dist/primitives/thread.d.ts +2 -0
  99. package/dist/primitives/thread.d.ts.map +1 -1
  100. package/dist/primitives/thread.js +5 -2
  101. package/dist/primitives/thread.js.map +1 -1
  102. package/dist/sandbox-host/SandboxHost.d.ts.map +1 -1
  103. package/dist/sandbox-host/SandboxHost.js +9 -3
  104. package/dist/sandbox-host/SandboxHost.js.map +1 -1
  105. package/dist/unstable/useComposerInput.d.ts +7 -4
  106. package/dist/unstable/useComposerInput.d.ts.map +1 -1
  107. package/dist/unstable/useComposerInput.js +4 -4
  108. package/dist/unstable/useComposerInput.js.map +1 -1
  109. package/dist/unstable/useComposerInputHistory.d.ts +3 -2
  110. package/dist/unstable/useComposerInputHistory.d.ts.map +1 -1
  111. package/dist/unstable/useComposerInputHistory.js +2 -2
  112. package/dist/unstable/useComposerInputHistory.js.map +1 -1
  113. package/dist/unstable/useLiveCompletionAdapter.d.ts +3 -2
  114. package/dist/unstable/useLiveCompletionAdapter.d.ts.map +1 -1
  115. package/dist/unstable/useLiveCompletionAdapter.js +8 -7
  116. package/dist/unstable/useLiveCompletionAdapter.js.map +1 -1
  117. package/dist/unstable/useMentionAdapter.d.ts +12 -3
  118. package/dist/unstable/useMentionAdapter.d.ts.map +1 -1
  119. package/dist/unstable/useMentionAdapter.js +2 -2
  120. package/dist/unstable/useMentionAdapter.js.map +1 -1
  121. package/dist/unstable/useMessageStallDetection.d.ts +4 -2
  122. package/dist/unstable/useMessageStallDetection.d.ts.map +1 -1
  123. package/dist/unstable/useMessageStallDetection.js +12 -8
  124. package/dist/unstable/useMessageStallDetection.js.map +1 -1
  125. package/dist/unstable/useSlashCommandAdapter.d.ts +5 -2
  126. package/dist/unstable/useSlashCommandAdapter.d.ts.map +1 -1
  127. package/dist/unstable/useSlashCommandAdapter.js +5 -4
  128. package/dist/unstable/useSlashCommandAdapter.js.map +1 -1
  129. package/dist/unstable/webmcp/useWebMcpProvider.d.ts +4 -0
  130. package/dist/unstable/webmcp/useWebMcpProvider.d.ts.map +1 -1
  131. package/dist/unstable/webmcp/useWebMcpProvider.js +2 -0
  132. package/dist/unstable/webmcp/useWebMcpProvider.js.map +1 -1
  133. package/dist/utils/getSelectionMessageId.d.ts.map +1 -1
  134. package/dist/utils/getSelectionMessageId.js +31 -8
  135. package/dist/utils/getSelectionMessageId.js.map +1 -1
  136. package/dist/utils/hooks/useOnResizeContent.d.ts.map +1 -1
  137. package/dist/utils/hooks/useOnResizeContent.js +2 -22
  138. package/dist/utils/hooks/useOnResizeContent.js.map +1 -1
  139. package/dist/utils/smooth/useSmooth.js +6 -6
  140. package/dist/utils/smooth/useSmooth.js.map +1 -1
  141. package/dist/utils/useIsomorphicLayoutEffect.d.ts +3 -0
  142. package/dist/utils/useIsomorphicLayoutEffect.d.ts.map +1 -0
  143. package/dist/utils/useIsomorphicLayoutEffect.js +6 -0
  144. package/dist/utils/useIsomorphicLayoutEffect.js.map +1 -0
  145. package/package.json +12 -11
  146. package/src/hooks/useToolCallElapsed.test.tsx +37 -0
  147. package/src/hooks/useToolCallElapsed.ts +10 -7
  148. package/src/index.ts +47 -5
  149. package/src/mcp-apps/McpAppRenderer.tsx +3 -9
  150. package/src/mcp-apps/app-frame.test.tsx +38 -0
  151. package/src/mcp-apps/app-frame.tsx +5 -8
  152. package/src/mcp-apps/csp.test.ts +112 -0
  153. package/src/mcp-apps/csp.ts +124 -0
  154. package/src/mcp-apps/types.ts +1 -0
  155. package/src/model-context/frame/AssistantFrame.test.ts +45 -0
  156. package/src/model-context/frame/SPEC_AssistantFrame.md +1 -0
  157. package/src/model-context/makeAssistantVisible.test.tsx +315 -5
  158. package/src/model-context/makeAssistantVisible.tsx +43 -7
  159. package/src/primitives/assistantModal/AssistantModalRoot.tsx +1 -0
  160. package/src/primitives/attachment.ts +4 -1
  161. package/src/primitives/composer/ComposerCancel.focus.test.tsx +307 -0
  162. package/src/primitives/composer/ComposerCancel.ts +8 -1
  163. package/src/primitives/composer/ComposerInput.tsx +20 -2
  164. package/src/primitives/composer/ComposerRoot.tsx +6 -5
  165. package/src/primitives/composer/composerInputSelector.ts +4 -0
  166. package/src/primitives/composer/trigger/TriggerPopover.tsx +11 -11
  167. package/src/primitives/composer/trigger/TriggerPopoverAction.tsx +10 -13
  168. package/src/primitives/composer/trigger/TriggerPopoverCategories.tsx +2 -2
  169. package/src/primitives/composer/trigger/TriggerPopoverDirective.tsx +11 -14
  170. package/src/primitives/composer/trigger/TriggerPopoverExports.test.ts +61 -0
  171. package/src/primitives/composer/trigger/TriggerPopoverItems.tsx +3 -3
  172. package/src/primitives/composer/trigger/TriggerPopoverRootContext.tsx +8 -8
  173. package/src/primitives/composer/useComposerCancelWithFocus.ts +57 -0
  174. package/src/primitives/composer.ts +70 -15
  175. package/src/primitives/message/MessagePartsGrouped.test.tsx +106 -2
  176. package/src/primitives/message/MessagePartsGrouped.tsx +36 -134
  177. package/src/primitives/message/MessageRoot.test.tsx +125 -2
  178. package/src/primitives/selectionToolbar/SelectionToolbarRoot.test.tsx +20 -12
  179. package/src/primitives/selectionToolbar/SelectionToolbarRoot.tsx +6 -2
  180. package/src/primitives/thread/ThreadLoadEarlier.tsx +35 -0
  181. package/src/primitives/thread/ThreadMessages.tsx +1 -0
  182. package/src/primitives/thread/ThreadViewport.tsx +3 -3
  183. package/src/primitives/thread/topAnchor/useTopAnchorReserve.ts +2 -2
  184. package/src/primitives/thread/useThreadViewportAutoScroll.test.tsx +922 -68
  185. package/src/primitives/thread/useThreadViewportAutoScroll.ts +330 -56
  186. package/src/primitives/thread.ts +2 -0
  187. package/src/sandbox-host/SandboxHost.test.tsx +34 -0
  188. package/src/sandbox-host/SandboxHost.tsx +11 -12
  189. package/src/tests/toolCallTiming.test.tsx +19 -2
  190. package/src/unstable/useComposerInput.ts +7 -4
  191. package/src/unstable/useComposerInputHistory.ts +3 -2
  192. package/src/unstable/useLiveCompletionAdapter.ts +9 -14
  193. package/src/unstable/useMentionAdapter.ts +12 -3
  194. package/src/unstable/useMessageStallDetection.test.tsx +40 -0
  195. package/src/unstable/useMessageStallDetection.ts +14 -8
  196. package/src/unstable/useSlashCommandAdapter.ts +8 -4
  197. package/src/unstable/webmcp/useWebMcpProvider.ts +4 -0
  198. package/src/utils/getSelectionMessageId.test.ts +231 -0
  199. package/src/utils/getSelectionMessageId.ts +45 -7
  200. package/src/utils/hooks/useOnResizeContent.ts +2 -29
  201. package/src/utils/smooth/useSmooth.test.tsx +16 -5
  202. package/src/utils/smooth/useSmooth.ts +7 -7
  203. package/src/utils/useIsomorphicLayoutEffect.test.tsx +21 -0
  204. 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";
@@ -28,11 +31,13 @@ import { ThreadPrimitiveScrollToBottom } from "./ThreadScrollToBottom";
28
31
  import { ThreadPrimitiveViewport } from "./ThreadViewport";
29
32
  import {
30
33
  ExportedMessageRepository,
34
+ useExternalStoreRuntime,
31
35
  useLocalRuntime,
32
36
  type ChatModelAdapter,
33
37
  type ThreadHistoryAdapter,
34
38
  type ThreadMessageLike,
35
39
  } from "../../index";
40
+ import { ThreadPrimitiveLoadEarlier } from "./ThreadLoadEarlier";
36
41
 
37
42
  const adapter: ChatModelAdapter = {
38
43
  async *run() {},
@@ -50,25 +55,51 @@ const getMaxScrollTop = (element: Element) =>
50
55
 
51
56
  let forceShortViewportMeasurement = false;
52
57
  let viewportMeasurementOffset = 0;
53
- const resizeObserverCallbacks = new Set<ResizeObserverCallback>();
58
+ const messageHeights = new Map<string, number>();
59
+ const resizeObservers = new Set<TestResizeObserver>();
60
+
61
+ const messageRows = () => [
62
+ ...document.querySelectorAll<HTMLElement>('[data-testid="thread-message"]'),
63
+ ];
64
+
65
+ const rowHeight = (row: Element) =>
66
+ messageHeights.get(row.getAttribute("data-message-id") ?? "") ?? 80;
67
+
68
+ const rowsHeight = (rows: readonly Element[]) =>
69
+ rows.reduce((height, row) => height + rowHeight(row), 0);
54
70
 
55
71
  class TestResizeObserver {
56
- private callback: ResizeObserverCallback;
72
+ readonly targets = new Set<Element>();
73
+ readonly callback: ResizeObserverCallback;
57
74
 
58
75
  constructor(callback: ResizeObserverCallback) {
59
76
  this.callback = callback;
60
- resizeObserverCallbacks.add(callback);
77
+ resizeObservers.add(this);
61
78
  }
62
79
 
63
- observe() {}
80
+ observe(target: Element) {
81
+ this.targets.add(target);
82
+ }
83
+ unobserve(target: Element) {
84
+ this.targets.delete(target);
85
+ }
64
86
  disconnect() {
65
- resizeObserverCallbacks.delete(this.callback);
87
+ resizeObservers.delete(this);
66
88
  }
67
89
  }
68
90
 
69
91
  const notifyResizeObservers = () => {
70
- for (const callback of resizeObserverCallbacks) {
71
- 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
+ }
72
103
  }
73
104
  };
74
105
 
@@ -121,11 +152,32 @@ beforeAll(() => {
121
152
  get() {
122
153
  if (this.getAttribute("data-testid") !== "viewport") return 0;
123
154
  if (forceShortViewportMeasurement) return this.clientHeight;
124
- return (
125
- document.querySelectorAll('[data-testid="thread-message"]').length *
126
- 80 +
127
- viewportMeasurementOffset
128
- );
155
+ return rowsHeight(messageRows()) + viewportMeasurementOffset;
156
+ },
157
+ });
158
+
159
+ Object.defineProperty(HTMLElement.prototype, "getBoundingClientRect", {
160
+ configurable: true,
161
+ value(this: HTMLElement) {
162
+ const rows = messageRows();
163
+ const index = rows.indexOf(this);
164
+ const viewport = this.closest<HTMLElement>('[data-testid="viewport"]');
165
+ const top =
166
+ index === -1
167
+ ? 0
168
+ : rowsHeight(rows.slice(0, index)) - (viewport?.scrollTop ?? 0);
169
+ const height = index === -1 ? 0 : rowHeight(this);
170
+ return {
171
+ x: 0,
172
+ y: top,
173
+ top,
174
+ bottom: top + height,
175
+ left: 0,
176
+ right: 0,
177
+ width: 0,
178
+ height,
179
+ toJSON: () => ({}),
180
+ } satisfies DOMRect;
129
181
  },
130
182
  });
131
183
 
@@ -141,7 +193,8 @@ beforeAll(() => {
141
193
  afterEach(() => {
142
194
  forceShortViewportMeasurement = false;
143
195
  viewportMeasurementOffset = 0;
144
- resizeObserverCallbacks.clear();
196
+ messageHeights.clear();
197
+ resizeObservers.clear();
145
198
  cleanup();
146
199
  });
147
200
 
@@ -166,12 +219,24 @@ const AtBottom: FC = () => {
166
219
  return <output data-testid="is-at-bottom">{String(isAtBottom)}</output>;
167
220
  };
168
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
+
169
232
  const Thread = ({
170
233
  autoScroll,
171
234
  scrollToBottomOnInitialize,
235
+ scrollToBottomOnThreadSwitch,
172
236
  }: {
173
237
  autoScroll?: boolean | undefined;
174
238
  scrollToBottomOnInitialize?: boolean | undefined;
239
+ scrollToBottomOnThreadSwitch?: boolean | undefined;
175
240
  }) => (
176
241
  <ThreadPrimitiveRoot>
177
242
  <ThreadPrimitiveViewport
@@ -179,6 +244,7 @@ const Thread = ({
179
244
  data-testid="viewport"
180
245
  turnAnchor="top"
181
246
  scrollToBottomOnInitialize={scrollToBottomOnInitialize}
247
+ scrollToBottomOnThreadSwitch={scrollToBottomOnThreadSwitch}
182
248
  >
183
249
  <ThreadPrimitiveMessages components={{ Message }} />
184
250
  <AtBottom />
@@ -260,6 +326,50 @@ const DelayedThread = ({
260
326
  };
261
327
 
262
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
+
263
373
  it("preserves smooth scrolling from a control outside the viewport", async () => {
264
374
  render(
265
375
  <SyncRuntimeProvider>
@@ -289,6 +399,144 @@ describe("useThreadViewportAutoScroll", () => {
289
399
  }
290
400
  });
291
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
+
292
540
  it("scrolls sync initialMessages to the bottom when the viewport mounts after initialization", async () => {
293
541
  render(
294
542
  <SyncRuntimeProvider>
@@ -398,6 +646,47 @@ describe("useThreadViewportAutoScroll", () => {
398
646
  expect(screen.getByTestId("is-at-bottom").textContent).toBe("true");
399
647
  });
400
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
+
401
690
  it("keeps following after a pointerdown that does not scroll the viewport", async () => {
402
691
  render(
403
692
  <SyncRuntimeProvider>
@@ -420,8 +709,355 @@ describe("useThreadViewportAutoScroll", () => {
420
709
  expect(screen.getByTestId("is-at-bottom").textContent).toBe("true");
421
710
  });
422
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
+
423
1057
  it.each([
424
1058
  { label: "pointerdown", make: () => new Event("pointerdown") },
1059
+ { label: "wheel", make: () => new WheelEvent("wheel") },
1060
+ { label: "touchstart", make: () => new Event("touchstart") },
425
1061
  {
426
1062
  label: "Enter keydown",
427
1063
  make: () => new KeyboardEvent("keydown", { key: "Enter" }),
@@ -430,6 +1066,12 @@ describe("useThreadViewportAutoScroll", () => {
430
1066
  label: "Space keydown",
431
1067
  make: () => new KeyboardEvent("keydown", { key: " " }),
432
1068
  },
1069
+ ...["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End"].map(
1070
+ (key) => ({
1071
+ label: `${key} keydown`,
1072
+ make: () => new KeyboardEvent("keydown", { key }),
1073
+ }),
1074
+ ),
433
1075
  ])(
434
1076
  "drops pending bottom-scroll intent after a $label in a thread that cannot scroll",
435
1077
  async ({ make }) => {
@@ -465,6 +1107,8 @@ describe("useThreadViewportAutoScroll", () => {
465
1107
 
466
1108
  it.each([
467
1109
  { label: "pointerdown", make: () => new Event("pointerdown") },
1110
+ { label: "wheel", make: () => new WheelEvent("wheel") },
1111
+ { label: "touchstart", make: () => new Event("touchstart") },
468
1112
  {
469
1113
  label: "Enter keydown",
470
1114
  make: () => new KeyboardEvent("keydown", { key: "Enter" }),
@@ -473,6 +1117,10 @@ describe("useThreadViewportAutoScroll", () => {
473
1117
  label: "Space keydown",
474
1118
  make: () => new KeyboardEvent("keydown", { key: " " }),
475
1119
  },
1120
+ {
1121
+ label: "ArrowDown keydown",
1122
+ make: () => new KeyboardEvent("keydown", { key: "ArrowDown" }),
1123
+ },
476
1124
  ])(
477
1125
  "cancels the frame a pending bottom scroll queued when a $label arrives first",
478
1126
  async ({ make }) => {
@@ -528,73 +1176,66 @@ describe("useThreadViewportAutoScroll", () => {
528
1176
  },
529
1177
  );
530
1178
 
531
- it.each([
532
- "Shift",
533
- "Control",
534
- "Meta",
535
- "Tab",
536
- "ArrowDown",
537
- "PageDown",
538
- "Escape",
539
- "a",
540
- ])("keeps pending bottom-scroll intent through a %s press", async (key) => {
541
- forceShortViewportMeasurement = true;
542
-
543
- render(
544
- <AsyncRuntimeProvider>
545
- <Thread />
546
- </AsyncRuntimeProvider>,
547
- );
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;
548
1183
 
549
- await waitFor(() => {
550
- expect(screen.getAllByTestId("thread-message")).toHaveLength(
551
- messages.length,
1184
+ render(
1185
+ <AsyncRuntimeProvider>
1186
+ <Thread />
1187
+ </AsyncRuntimeProvider>,
552
1188
  );
553
- });
554
- await new Promise((resolve) => setTimeout(resolve, 0));
555
1189
 
556
- // neither an activation key nor consumed by a text field
557
- act(() => {
558
- getViewport().dispatchEvent(new KeyboardEvent("keydown", { key }));
559
- });
1190
+ await waitFor(() => {
1191
+ expect(screen.getAllByTestId("thread-message")).toHaveLength(
1192
+ messages.length,
1193
+ );
1194
+ });
1195
+ await new Promise((resolve) => setTimeout(resolve, 0));
560
1196
 
561
- forceShortViewportMeasurement = false;
562
- act(notifyResizeObservers);
1197
+ // neither an activation key nor consumed by a text field
1198
+ act(() => {
1199
+ getViewport().dispatchEvent(new KeyboardEvent("keydown", { key }));
1200
+ });
563
1201
 
564
- expect(getViewport().scrollTop).toBe(getMaxScrollTop(getViewport()));
565
- });
1202
+ forceShortViewportMeasurement = false;
1203
+ act(notifyResizeObservers);
566
1204
 
567
- it("keeps pending bottom-scroll intent while the user types in the composer", async () => {
568
- forceShortViewportMeasurement = true;
1205
+ expect(getViewport().scrollTop).toBe(getMaxScrollTop(getViewport()));
1206
+ },
1207
+ );
569
1208
 
570
- render(
571
- <AsyncRuntimeProvider>
572
- <Thread />
573
- </AsyncRuntimeProvider>,
574
- );
1209
+ it.each([" ", "ArrowDown"])(
1210
+ "keeps pending bottom-scroll intent through a %s press in the composer",
1211
+ async (key) => {
1212
+ forceShortViewportMeasurement = true;
575
1213
 
576
- await waitFor(() => {
577
- expect(screen.getAllByTestId("thread-message")).toHaveLength(
578
- messages.length,
1214
+ render(
1215
+ <AsyncRuntimeProvider>
1216
+ <Thread />
1217
+ </AsyncRuntimeProvider>,
579
1218
  );
580
- });
581
- await new Promise((resolve) => setTimeout(resolve, 0));
582
1219
 
583
- act(() => {
584
- screen
585
- .getByTestId("composer")
586
- // Space is an activation key, so this reaches the text-entry check
587
- // instead of short-circuiting on the allowlist
588
- .dispatchEvent(
589
- new KeyboardEvent("keydown", { key: " ", bubbles: true }),
1220
+ await waitFor(() => {
1221
+ expect(screen.getAllByTestId("thread-message")).toHaveLength(
1222
+ messages.length,
590
1223
  );
591
- });
1224
+ });
1225
+ await new Promise((resolve) => setTimeout(resolve, 0));
592
1226
 
593
- forceShortViewportMeasurement = false;
594
- act(notifyResizeObservers);
1227
+ act(() => {
1228
+ screen
1229
+ .getByTestId("composer")
1230
+ .dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true }));
1231
+ });
595
1232
 
596
- expect(getViewport().scrollTop).toBe(getMaxScrollTop(getViewport()));
597
- });
1233
+ forceShortViewportMeasurement = false;
1234
+ act(notifyResizeObservers);
1235
+
1236
+ expect(getViewport().scrollTop).toBe(getMaxScrollTop(getViewport()));
1237
+ },
1238
+ );
598
1239
 
599
1240
  it.each([
600
1241
  { label: "a checkbox", testid: "checkbox" },
@@ -908,4 +1549,217 @@ describe("useThreadViewportAutoScroll", () => {
908
1549
  act(notifyResizeObservers);
909
1550
  expect(screen.getByTestId("is-at-bottom").textContent).toBe("false");
910
1551
  });
1552
+
1553
+ describe("earlier messages", () => {
1554
+ const pagedMessages: ThreadMessageLike[] = messages.map(
1555
+ (message, index) => ({
1556
+ ...message,
1557
+ id: `message-${index}`,
1558
+ }),
1559
+ );
1560
+
1561
+ type PagedThreadProps = {
1562
+ follow?: boolean;
1563
+ running?: boolean;
1564
+ /** Runs in the same update that prepends the page. */
1565
+ onPrepend?: () => void;
1566
+ };
1567
+
1568
+ const PagedThread = ({
1569
+ follow = false,
1570
+ running = false,
1571
+ onPrepend,
1572
+ }: PagedThreadProps) => {
1573
+ const [loaded, setLoaded] = useState(pagedMessages.slice(4));
1574
+ const runtime = useExternalStoreRuntime<ThreadMessageLike>({
1575
+ messages: loaded,
1576
+ convertMessage: (message) => message,
1577
+ onNew: async () => {},
1578
+ isRunning: running,
1579
+ hasEarlier: loaded.length < pagedMessages.length,
1580
+ onLoadEarlier: async () => {
1581
+ onPrepend?.();
1582
+ setLoaded(pagedMessages);
1583
+ },
1584
+ });
1585
+
1586
+ return (
1587
+ <AssistantRuntimeProvider runtime={runtime}>
1588
+ {follow ? <BottomAnchorThread /> : <Thread />}
1589
+ <ThreadPrimitiveLoadEarlier data-testid="load-earlier" />
1590
+ </AssistantRuntimeProvider>
1591
+ );
1592
+ };
1593
+
1594
+ const renderAt = async (
1595
+ scrollTop: number,
1596
+ { follow = false, onPrepend }: PagedThreadProps = {},
1597
+ ) => {
1598
+ render(<PagedThread follow={follow} {...(onPrepend && { onPrepend })} />);
1599
+ await waitFor(() =>
1600
+ expect(screen.getAllByTestId("thread-message")).toHaveLength(4),
1601
+ );
1602
+ act(() => {
1603
+ getViewport().scrollTop = scrollTop;
1604
+ fireEvent.scroll(getViewport());
1605
+ });
1606
+ };
1607
+
1608
+ const loadEarlier = async () => {
1609
+ await act(async () => {
1610
+ fireEvent.click(screen.getByTestId("load-earlier"));
1611
+ });
1612
+ await waitFor(() =>
1613
+ expect(screen.getAllByTestId("thread-message")).toHaveLength(8),
1614
+ );
1615
+ act(notifyResizeObservers);
1616
+ };
1617
+
1618
+ it("keeps the rows a reader scrolled to in place when an earlier page is prepended", async () => {
1619
+ await renderAt(0);
1620
+
1621
+ await loadEarlier();
1622
+
1623
+ expect(getViewport().scrollTop).toBe(4 * 80);
1624
+ expect(
1625
+ (screen.getByTestId("load-earlier") as HTMLButtonElement).disabled,
1626
+ ).toBe(true);
1627
+ });
1628
+
1629
+ it.each([false, true])(
1630
+ "keeps a reader at the bottom at the bottom (auto-follow %s)",
1631
+ async (follow) => {
1632
+ await renderAt(4 * 80 - 100, { follow });
1633
+
1634
+ await loadEarlier();
1635
+
1636
+ expect(getViewport().scrollTop).toBe(8 * 80 - 100);
1637
+ },
1638
+ );
1639
+
1640
+ it("does not count growth below the reader in the same update as part of the page", async () => {
1641
+ await renderAt(100, {
1642
+ onPrepend: () => {
1643
+ viewportMeasurementOffset = 40;
1644
+ },
1645
+ });
1646
+
1647
+ await loadEarlier();
1648
+
1649
+ expect(getViewport().scrollTop).toBe(4 * 80 + 100);
1650
+ });
1651
+
1652
+ it("holds the rows in place while the page above settles, until the reader's next gesture", async () => {
1653
+ await renderAt(0);
1654
+ await loadEarlier();
1655
+ expect(getViewport().scrollTop).toBe(4 * 80);
1656
+
1657
+ messageHeights.set("message-3", 120);
1658
+ act(notifyResizeObservers);
1659
+ expect(getViewport().scrollTop).toBe(4 * 80 + 40);
1660
+
1661
+ fireEvent.wheel(getViewport());
1662
+ messageHeights.set("message-2", 120);
1663
+ act(notifyResizeObservers);
1664
+ expect(getViewport().scrollTop).toBe(4 * 80 + 40);
1665
+ });
1666
+
1667
+ it("lets go of the held rows when a run starts, so the new turn can take the viewport", async () => {
1668
+ const { rerender } = render(<PagedThread />);
1669
+ await waitFor(() =>
1670
+ expect(screen.getAllByTestId("thread-message")).toHaveLength(4),
1671
+ );
1672
+ act(() => {
1673
+ getViewport().scrollTop = 0;
1674
+ fireEvent.scroll(getViewport());
1675
+ });
1676
+ await loadEarlier();
1677
+
1678
+ await act(async () => {
1679
+ rerender(<PagedThread running />);
1680
+ });
1681
+ messageHeights.set("message-3", 120);
1682
+ act(notifyResizeObservers);
1683
+
1684
+ expect(getViewport().scrollTop).toBe(4 * 80);
1685
+ });
1686
+
1687
+ it("lets go of the held rows once something else scrolls the viewport", async () => {
1688
+ await renderAt(0);
1689
+ await loadEarlier();
1690
+
1691
+ act(() => {
1692
+ getViewport().scrollTo({ top: 100 });
1693
+ });
1694
+ messageHeights.set("message-3", 120);
1695
+ act(notifyResizeObservers);
1696
+
1697
+ expect(getViewport().scrollTop).toBe(100);
1698
+ });
1699
+
1700
+ it("holds the row the reader sees, not the old first message, while content between them settles", async () => {
1701
+ await renderAt(100);
1702
+ await loadEarlier();
1703
+ expect(getViewport().scrollTop).toBe(4 * 80 + 100);
1704
+
1705
+ messageHeights.set("message-4", 120);
1706
+ act(notifyResizeObservers);
1707
+ expect(getViewport().scrollTop).toBe(4 * 80 + 100 + 40);
1708
+ });
1709
+
1710
+ it("does not treat a thread switch as a page, even when the new thread repeats the old first message", async () => {
1711
+ const SwitchingThread = () => {
1712
+ const [thread, setThread] = useState({
1713
+ id: "a",
1714
+ messages: pagedMessages.slice(4),
1715
+ });
1716
+ const runtime = useExternalStoreRuntime<ThreadMessageLike>({
1717
+ messages: thread.messages,
1718
+ convertMessage: (message) => message,
1719
+ onNew: async () => {},
1720
+ adapters: {
1721
+ threadList: {
1722
+ threadId: thread.id,
1723
+ threads: [
1724
+ { id: "a", status: "regular" },
1725
+ { id: "b", status: "regular" },
1726
+ ],
1727
+ },
1728
+ },
1729
+ });
1730
+
1731
+ return (
1732
+ <AssistantRuntimeProvider runtime={runtime}>
1733
+ <Thread scrollToBottomOnThreadSwitch={false} />
1734
+ <button
1735
+ type="button"
1736
+ data-testid="switch"
1737
+ onClick={() =>
1738
+ setThread({ id: "b", messages: pagedMessages.slice(3) })
1739
+ }
1740
+ />
1741
+ </AssistantRuntimeProvider>
1742
+ );
1743
+ };
1744
+
1745
+ render(<SwitchingThread />);
1746
+ await waitFor(() =>
1747
+ expect(screen.getAllByTestId("thread-message")).toHaveLength(4),
1748
+ );
1749
+ act(() => {
1750
+ getViewport().scrollTop = 100;
1751
+ fireEvent.scroll(getViewport());
1752
+ });
1753
+
1754
+ await act(async () => {
1755
+ fireEvent.click(screen.getByTestId("switch"));
1756
+ });
1757
+ await waitFor(() =>
1758
+ expect(screen.getAllByTestId("thread-message")).toHaveLength(5),
1759
+ );
1760
+ act(notifyResizeObservers);
1761
+
1762
+ expect(getViewport().scrollTop).toBe(100);
1763
+ });
1764
+ });
911
1765
  });