@assistant-ui/react 0.15.23 → 0.15.25

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 (212) hide show
  1. package/README.md +44 -23
  2. package/dist/assistant-transport.js.map +1 -1
  3. package/dist/cloud-renderer/CloudRendererHost.js.map +1 -1
  4. package/dist/context/providers/MessageProvider.js.map +1 -1
  5. package/dist/context/providers/ThreadViewportProvider.js.map +1 -1
  6. package/dist/hooks/useToolCallElapsed.d.ts +3 -1
  7. package/dist/hooks/useToolCallElapsed.d.ts.map +1 -1
  8. package/dist/hooks/useToolCallElapsed.js +7 -8
  9. package/dist/hooks/useToolCallElapsed.js.map +1 -1
  10. package/dist/index.d.ts +10 -4
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +3 -3
  13. package/dist/legacy-runtime/AssistantRuntimeProvider.js.map +1 -1
  14. package/dist/mcp-apps/app-frame.js.map +1 -1
  15. package/dist/model-context/frame/useAssistantFrameHost.js.map +1 -1
  16. package/dist/model-context/makeAssistantVisible.d.ts +1 -0
  17. package/dist/model-context/makeAssistantVisible.d.ts.map +1 -1
  18. package/dist/model-context/makeAssistantVisible.js +31 -12
  19. package/dist/model-context/makeAssistantVisible.js.map +1 -1
  20. package/dist/primitives/actionBar/ActionBarCopy.js.map +1 -1
  21. package/dist/primitives/actionBar/ActionBarEdit.js.map +1 -1
  22. package/dist/primitives/actionBar/ActionBarExportMarkdown.js.map +1 -1
  23. package/dist/primitives/actionBar/ActionBarFeedbackNegative.js.map +1 -1
  24. package/dist/primitives/actionBar/ActionBarFeedbackPositive.js.map +1 -1
  25. package/dist/primitives/actionBar/ActionBarReload.js.map +1 -1
  26. package/dist/primitives/actionBar/ActionBarRoot.js.map +1 -1
  27. package/dist/primitives/actionBar/ActionBarSpeak.js.map +1 -1
  28. package/dist/primitives/actionBar/ActionBarStopSpeaking.js.map +1 -1
  29. package/dist/primitives/actionBar/useActionBarFloatStatus.js.map +1 -1
  30. package/dist/primitives/actionBarMore/ActionBarMoreContent.js.map +1 -1
  31. package/dist/primitives/actionBarMore/ActionBarMoreItem.js.map +1 -1
  32. package/dist/primitives/actionBarMore/ActionBarMoreRoot.js.map +1 -1
  33. package/dist/primitives/actionBarMore/ActionBarMoreSeparator.js.map +1 -1
  34. package/dist/primitives/actionBarMore/ActionBarMoreTrigger.js.map +1 -1
  35. package/dist/primitives/actionBarMore/scope.js.map +1 -1
  36. package/dist/primitives/assistantModal/AssistantModalAnchor.js.map +1 -1
  37. package/dist/primitives/assistantModal/AssistantModalContent.js.map +1 -1
  38. package/dist/primitives/assistantModal/AssistantModalRoot.js.map +1 -1
  39. package/dist/primitives/assistantModal/AssistantModalTrigger.js.map +1 -1
  40. package/dist/primitives/assistantModal/popoverRenderPrimitives.js.map +1 -1
  41. package/dist/primitives/assistantModal/scope.js.map +1 -1
  42. package/dist/primitives/attachment/AttachmentName.js.map +1 -1
  43. package/dist/primitives/attachment/AttachmentRemove.js.map +1 -1
  44. package/dist/primitives/attachment/AttachmentRoot.js.map +1 -1
  45. package/dist/primitives/attachment/AttachmentThumb.js.map +1 -1
  46. package/dist/primitives/branchPicker/BranchPickerCount.js.map +1 -1
  47. package/dist/primitives/branchPicker/BranchPickerNext.js.map +1 -1
  48. package/dist/primitives/branchPicker/BranchPickerNumber.js.map +1 -1
  49. package/dist/primitives/branchPicker/BranchPickerPrevious.js.map +1 -1
  50. package/dist/primitives/branchPicker/BranchPickerRoot.js.map +1 -1
  51. package/dist/primitives/chainOfThought/ChainOfThoughtAccordionTrigger.js.map +1 -1
  52. package/dist/primitives/chainOfThought/ChainOfThoughtRoot.js.map +1 -1
  53. package/dist/primitives/composer/ComposerAddAttachment.js.map +1 -1
  54. package/dist/primitives/composer/ComposerCancel.js.map +1 -1
  55. package/dist/primitives/composer/ComposerDictate.js.map +1 -1
  56. package/dist/primitives/composer/ComposerDictationTranscript.js.map +1 -1
  57. package/dist/primitives/composer/ComposerInput.d.ts +1 -1
  58. package/dist/primitives/composer/ComposerInput.d.ts.map +1 -1
  59. package/dist/primitives/composer/ComposerInput.js +4 -3
  60. package/dist/primitives/composer/ComposerInput.js.map +1 -1
  61. package/dist/primitives/composer/ComposerInputPluginContext.js.map +1 -1
  62. package/dist/primitives/composer/ComposerQuote.js.map +1 -1
  63. package/dist/primitives/composer/ComposerRoot.js.map +1 -1
  64. package/dist/primitives/composer/ComposerSend.js.map +1 -1
  65. package/dist/primitives/composer/ComposerStopDictation.js.map +1 -1
  66. package/dist/primitives/composer/trigger/TriggerPopover.d.ts +11 -11
  67. package/dist/primitives/composer/trigger/TriggerPopover.d.ts.map +1 -1
  68. package/dist/primitives/composer/trigger/TriggerPopover.js +62 -60
  69. package/dist/primitives/composer/trigger/TriggerPopover.js.map +1 -1
  70. package/dist/primitives/composer/trigger/TriggerPopoverAction.d.ts +8 -8
  71. package/dist/primitives/composer/trigger/TriggerPopoverAction.d.ts.map +1 -1
  72. package/dist/primitives/composer/trigger/TriggerPopoverAction.js +6 -6
  73. package/dist/primitives/composer/trigger/TriggerPopoverAction.js.map +1 -1
  74. package/dist/primitives/composer/trigger/TriggerPopoverBack.js.map +1 -1
  75. package/dist/primitives/composer/trigger/TriggerPopoverCategories.d.ts +3 -3
  76. package/dist/primitives/composer/trigger/TriggerPopoverCategories.d.ts.map +1 -1
  77. package/dist/primitives/composer/trigger/TriggerPopoverCategories.js.map +1 -1
  78. package/dist/primitives/composer/trigger/TriggerPopoverDirective.d.ts +9 -9
  79. package/dist/primitives/composer/trigger/TriggerPopoverDirective.d.ts.map +1 -1
  80. package/dist/primitives/composer/trigger/TriggerPopoverDirective.js +7 -7
  81. package/dist/primitives/composer/trigger/TriggerPopoverDirective.js.map +1 -1
  82. package/dist/primitives/composer/trigger/TriggerPopoverItems.d.ts +5 -5
  83. package/dist/primitives/composer/trigger/TriggerPopoverItems.d.ts.map +1 -1
  84. package/dist/primitives/composer/trigger/TriggerPopoverItems.js.map +1 -1
  85. package/dist/primitives/composer/trigger/TriggerPopoverResource.js.map +1 -1
  86. package/dist/primitives/composer/trigger/TriggerPopoverRootContext.d.ts +8 -8
  87. package/dist/primitives/composer/trigger/TriggerPopoverRootContext.js +8 -8
  88. package/dist/primitives/composer/trigger/TriggerPopoverRootContext.js.map +1 -1
  89. package/dist/primitives/composer/trigger/index.d.ts +1 -1
  90. package/dist/primitives/composer/trigger/index.js.map +1 -1
  91. package/dist/primitives/composer/trigger/triggerDetectionResource.js.map +1 -1
  92. package/dist/primitives/composer/trigger/triggerKeyboardResource.js.map +1 -1
  93. package/dist/primitives/composer/trigger/triggerNavigationResource.js.map +1 -1
  94. package/dist/primitives/composer/trigger/triggerSelectionResource.js.map +1 -1
  95. package/dist/primitives/composer/useComposerInputState.js.map +1 -1
  96. package/dist/primitives/composer.d.ts +20 -1
  97. package/dist/primitives/composer.d.ts.map +1 -1
  98. package/dist/primitives/composer.js +8 -1
  99. package/dist/primitives/composer.js.map +1 -1
  100. package/dist/primitives/dropdownMenuRenderPrimitives.js.map +1 -1
  101. package/dist/primitives/error/ErrorMessage.js.map +1 -1
  102. package/dist/primitives/error/ErrorRoot.js.map +1 -1
  103. package/dist/primitives/message/MessageIf.js.map +1 -1
  104. package/dist/primitives/message/MessageParts.js.map +1 -1
  105. package/dist/primitives/message/MessagePartsGrouped.d.ts.map +1 -1
  106. package/dist/primitives/message/MessagePartsGrouped.js +42 -17
  107. package/dist/primitives/message/MessagePartsGrouped.js.map +1 -1
  108. package/dist/primitives/message/MessageRoot.js.map +1 -1
  109. package/dist/primitives/messagePart/MessagePartImage.js.map +1 -1
  110. package/dist/primitives/queueItem/QueueItemRemove.js.map +1 -1
  111. package/dist/primitives/queueItem/QueueItemSteer.js.map +1 -1
  112. package/dist/primitives/queueItem/QueueItemText.js.map +1 -1
  113. package/dist/primitives/reasoning/useScrollLock.js.map +1 -1
  114. package/dist/primitives/selectionToolbar/SelectionToolbarQuote.js.map +1 -1
  115. package/dist/primitives/selectionToolbar/SelectionToolbarRoot.js.map +1 -1
  116. package/dist/primitives/suggestion/SuggestionDescription.js.map +1 -1
  117. package/dist/primitives/suggestion/SuggestionTitle.js.map +1 -1
  118. package/dist/primitives/suggestion/SuggestionTrigger.js.map +1 -1
  119. package/dist/primitives/thread/ThreadIf.js.map +1 -1
  120. package/dist/primitives/thread/ThreadLoadEarlier.d.ts +19 -0
  121. package/dist/primitives/thread/ThreadLoadEarlier.d.ts.map +1 -0
  122. package/dist/primitives/thread/ThreadLoadEarlier.js +36 -0
  123. package/dist/primitives/thread/ThreadLoadEarlier.js.map +1 -0
  124. package/dist/primitives/thread/ThreadMessages.d.ts +1 -1
  125. package/dist/primitives/thread/ThreadMessages.d.ts.map +1 -1
  126. package/dist/primitives/thread/ThreadMessages.js +2 -2
  127. package/dist/primitives/thread/ThreadRoot.d.ts.map +1 -1
  128. package/dist/primitives/thread/ThreadRoot.js +2 -0
  129. package/dist/primitives/thread/ThreadRoot.js.map +1 -1
  130. package/dist/primitives/thread/ThreadScrollToBottom.js.map +1 -1
  131. package/dist/primitives/thread/ThreadSuggestion.js.map +1 -1
  132. package/dist/primitives/thread/ThreadViewport.js.map +1 -1
  133. package/dist/primitives/thread/ThreadViewportFooter.js.map +1 -1
  134. package/dist/primitives/thread/topAnchor/useTopAnchorReserve.js.map +1 -1
  135. package/dist/primitives/thread/useThreadViewportAutoScroll.d.ts.map +1 -1
  136. package/dist/primitives/thread/useThreadViewportAutoScroll.js +111 -2
  137. package/dist/primitives/thread/useThreadViewportAutoScroll.js.map +1 -1
  138. package/dist/primitives/thread.d.ts +2 -0
  139. package/dist/primitives/thread.d.ts.map +1 -1
  140. package/dist/primitives/thread.js +5 -2
  141. package/dist/primitives/thread.js.map +1 -1
  142. package/dist/primitives/threadList/ThreadListLoadMore.js.map +1 -1
  143. package/dist/primitives/threadList/ThreadListNew.js.map +1 -1
  144. package/dist/primitives/threadList/ThreadListRoot.js.map +1 -1
  145. package/dist/primitives/threadListItem/ThreadListItemArchive.js.map +1 -1
  146. package/dist/primitives/threadListItem/ThreadListItemDelete.js.map +1 -1
  147. package/dist/primitives/threadListItem/ThreadListItemRoot.js.map +1 -1
  148. package/dist/primitives/threadListItem/ThreadListItemTrigger.js.map +1 -1
  149. package/dist/primitives/threadListItem/ThreadListItemUnarchive.js.map +1 -1
  150. package/dist/primitives/threadListItemMore/ThreadListItemMoreContent.js.map +1 -1
  151. package/dist/primitives/threadListItemMore/ThreadListItemMoreItem.js.map +1 -1
  152. package/dist/primitives/threadListItemMore/ThreadListItemMoreRoot.js.map +1 -1
  153. package/dist/primitives/threadListItemMore/ThreadListItemMoreSeparator.js.map +1 -1
  154. package/dist/primitives/threadListItemMore/ThreadListItemMoreTrigger.js.map +1 -1
  155. package/dist/primitives/threadListItemMore/scope.js.map +1 -1
  156. package/dist/sandbox-host/SandboxHost.d.ts.map +1 -1
  157. package/dist/sandbox-host/SandboxHost.js +6 -1
  158. package/dist/sandbox-host/SandboxHost.js.map +1 -1
  159. package/dist/unstable/useMessageStallDetection.d.ts.map +1 -1
  160. package/dist/unstable/useMessageStallDetection.js +10 -6
  161. package/dist/unstable/useMessageStallDetection.js.map +1 -1
  162. package/dist/unstable/useModelContextSnapshot.js.map +1 -1
  163. package/dist/unstable/webmcp/WebMcpRegistrationResource.js.map +1 -1
  164. package/dist/unstable/webmcp/useWebMcpProvider.js.map +1 -1
  165. package/dist/utils/Primitive.js.map +1 -1
  166. package/dist/utils/createActionButton.js.map +1 -1
  167. package/dist/utils/hooks/useManagedRef.js.map +1 -1
  168. package/dist/utils/hooks/useMediaQuery.js.map +1 -1
  169. package/dist/utils/hooks/useOnResizeContent.js.map +1 -1
  170. package/dist/utils/hooks/useOnScrollToBottom.js.map +1 -1
  171. package/dist/utils/hooks/useSizeHandle.js.map +1 -1
  172. package/dist/utils/isCompositionKey.d.ts +2 -0
  173. package/dist/utils/isCompositionKey.d.ts.map +1 -0
  174. package/dist/utils/isCompositionKey.js +6 -0
  175. package/dist/utils/isCompositionKey.js.map +1 -0
  176. package/dist/utils/smooth/SmoothContext.js.map +1 -1
  177. package/dist/utils/smooth/useSmooth.js +6 -6
  178. package/dist/utils/smooth/useSmooth.js.map +1 -1
  179. package/package.json +12 -11
  180. package/src/hooks/useToolCallElapsed.test.tsx +35 -0
  181. package/src/hooks/useToolCallElapsed.ts +9 -7
  182. package/src/index.ts +28 -3
  183. package/src/model-context/makeAssistantVisible.test.tsx +315 -5
  184. package/src/model-context/makeAssistantVisible.tsx +43 -7
  185. package/src/primitives/composer/ComposerInput.test.tsx +33 -1
  186. package/src/primitives/composer/ComposerInput.tsx +4 -3
  187. package/src/primitives/composer/trigger/TriggerPopover.tsx +11 -11
  188. package/src/primitives/composer/trigger/TriggerPopoverAction.tsx +10 -13
  189. package/src/primitives/composer/trigger/TriggerPopoverCategories.tsx +2 -2
  190. package/src/primitives/composer/trigger/TriggerPopoverDirective.tsx +11 -14
  191. package/src/primitives/composer/trigger/TriggerPopoverExports.test.ts +61 -0
  192. package/src/primitives/composer/trigger/TriggerPopoverItems.tsx +3 -3
  193. package/src/primitives/composer/trigger/TriggerPopoverRootContext.tsx +8 -8
  194. package/src/primitives/composer.ts +21 -1
  195. package/src/primitives/message/MessagePartsGrouped.test.tsx +271 -1
  196. package/src/primitives/message/MessagePartsGrouped.tsx +14 -6
  197. package/src/primitives/message/MessageRoot.test.tsx +125 -2
  198. package/src/primitives/thread/ThreadLoadEarlier.tsx +35 -0
  199. package/src/primitives/thread/ThreadMessages.tsx +1 -0
  200. package/src/primitives/thread/ThreadRoot.test.tsx +33 -0
  201. package/src/primitives/thread/ThreadRoot.tsx +3 -0
  202. package/src/primitives/thread/useThreadViewportAutoScroll.test.tsx +256 -5
  203. package/src/primitives/thread/useThreadViewportAutoScroll.ts +147 -1
  204. package/src/primitives/thread.ts +2 -0
  205. package/src/sandbox-host/SandboxHost.test.tsx +34 -0
  206. package/src/sandbox-host/SandboxHost.tsx +8 -4
  207. package/src/tests/toolCallTiming.test.tsx +19 -2
  208. package/src/unstable/useMessageStallDetection.test.tsx +40 -0
  209. package/src/unstable/useMessageStallDetection.ts +10 -6
  210. package/src/utils/isCompositionKey.ts +5 -0
  211. package/src/utils/smooth/useSmooth.test.tsx +16 -5
  212. package/src/utils/smooth/useSmooth.ts +7 -7
@@ -28,11 +28,13 @@ import { ThreadPrimitiveScrollToBottom } from "./ThreadScrollToBottom";
28
28
  import { ThreadPrimitiveViewport } from "./ThreadViewport";
29
29
  import {
30
30
  ExportedMessageRepository,
31
+ useExternalStoreRuntime,
31
32
  useLocalRuntime,
32
33
  type ChatModelAdapter,
33
34
  type ThreadHistoryAdapter,
34
35
  type ThreadMessageLike,
35
36
  } from "../../index";
37
+ import { ThreadPrimitiveLoadEarlier } from "./ThreadLoadEarlier";
36
38
 
37
39
  const adapter: ChatModelAdapter = {
38
40
  async *run() {},
@@ -50,8 +52,19 @@ const getMaxScrollTop = (element: Element) =>
50
52
 
51
53
  let forceShortViewportMeasurement = false;
52
54
  let viewportMeasurementOffset = 0;
55
+ const messageHeights = new Map<string, number>();
53
56
  const resizeObserverCallbacks = new Set<ResizeObserverCallback>();
54
57
 
58
+ const messageRows = () => [
59
+ ...document.querySelectorAll<HTMLElement>('[data-testid="thread-message"]'),
60
+ ];
61
+
62
+ const rowHeight = (row: Element) =>
63
+ messageHeights.get(row.getAttribute("data-message-id") ?? "") ?? 80;
64
+
65
+ const rowsHeight = (rows: readonly Element[]) =>
66
+ rows.reduce((height, row) => height + rowHeight(row), 0);
67
+
55
68
  class TestResizeObserver {
56
69
  private callback: ResizeObserverCallback;
57
70
 
@@ -121,11 +134,32 @@ beforeAll(() => {
121
134
  get() {
122
135
  if (this.getAttribute("data-testid") !== "viewport") return 0;
123
136
  if (forceShortViewportMeasurement) return this.clientHeight;
124
- return (
125
- document.querySelectorAll('[data-testid="thread-message"]').length *
126
- 80 +
127
- viewportMeasurementOffset
128
- );
137
+ return rowsHeight(messageRows()) + viewportMeasurementOffset;
138
+ },
139
+ });
140
+
141
+ Object.defineProperty(HTMLElement.prototype, "getBoundingClientRect", {
142
+ configurable: true,
143
+ value(this: HTMLElement) {
144
+ const rows = messageRows();
145
+ const index = rows.indexOf(this);
146
+ const viewport = this.closest<HTMLElement>('[data-testid="viewport"]');
147
+ const top =
148
+ index === -1
149
+ ? 0
150
+ : rowsHeight(rows.slice(0, index)) - (viewport?.scrollTop ?? 0);
151
+ const height = index === -1 ? 0 : rowHeight(this);
152
+ return {
153
+ x: 0,
154
+ y: top,
155
+ top,
156
+ bottom: top + height,
157
+ left: 0,
158
+ right: 0,
159
+ width: 0,
160
+ height,
161
+ toJSON: () => ({}),
162
+ } satisfies DOMRect;
129
163
  },
130
164
  });
131
165
 
@@ -141,6 +175,7 @@ beforeAll(() => {
141
175
  afterEach(() => {
142
176
  forceShortViewportMeasurement = false;
143
177
  viewportMeasurementOffset = 0;
178
+ messageHeights.clear();
144
179
  resizeObserverCallbacks.clear();
145
180
  cleanup();
146
181
  });
@@ -169,9 +204,11 @@ const AtBottom: FC = () => {
169
204
  const Thread = ({
170
205
  autoScroll,
171
206
  scrollToBottomOnInitialize,
207
+ scrollToBottomOnThreadSwitch,
172
208
  }: {
173
209
  autoScroll?: boolean | undefined;
174
210
  scrollToBottomOnInitialize?: boolean | undefined;
211
+ scrollToBottomOnThreadSwitch?: boolean | undefined;
175
212
  }) => (
176
213
  <ThreadPrimitiveRoot>
177
214
  <ThreadPrimitiveViewport
@@ -179,6 +216,7 @@ const Thread = ({
179
216
  data-testid="viewport"
180
217
  turnAnchor="top"
181
218
  scrollToBottomOnInitialize={scrollToBottomOnInitialize}
219
+ scrollToBottomOnThreadSwitch={scrollToBottomOnThreadSwitch}
182
220
  >
183
221
  <ThreadPrimitiveMessages components={{ Message }} />
184
222
  <AtBottom />
@@ -908,4 +946,217 @@ describe("useThreadViewportAutoScroll", () => {
908
946
  act(notifyResizeObservers);
909
947
  expect(screen.getByTestId("is-at-bottom").textContent).toBe("false");
910
948
  });
949
+
950
+ describe("earlier messages", () => {
951
+ const pagedMessages: ThreadMessageLike[] = messages.map(
952
+ (message, index) => ({
953
+ ...message,
954
+ id: `message-${index}`,
955
+ }),
956
+ );
957
+
958
+ type PagedThreadProps = {
959
+ follow?: boolean;
960
+ running?: boolean;
961
+ /** Runs in the same update that prepends the page. */
962
+ onPrepend?: () => void;
963
+ };
964
+
965
+ const PagedThread = ({
966
+ follow = false,
967
+ running = false,
968
+ onPrepend,
969
+ }: PagedThreadProps) => {
970
+ const [loaded, setLoaded] = useState(pagedMessages.slice(4));
971
+ const runtime = useExternalStoreRuntime<ThreadMessageLike>({
972
+ messages: loaded,
973
+ convertMessage: (message) => message,
974
+ onNew: async () => {},
975
+ isRunning: running,
976
+ hasEarlier: loaded.length < pagedMessages.length,
977
+ onLoadEarlier: async () => {
978
+ onPrepend?.();
979
+ setLoaded(pagedMessages);
980
+ },
981
+ });
982
+
983
+ return (
984
+ <AssistantRuntimeProvider runtime={runtime}>
985
+ {follow ? <BottomAnchorThread /> : <Thread />}
986
+ <ThreadPrimitiveLoadEarlier data-testid="load-earlier" />
987
+ </AssistantRuntimeProvider>
988
+ );
989
+ };
990
+
991
+ const renderAt = async (
992
+ scrollTop: number,
993
+ { follow = false, onPrepend }: PagedThreadProps = {},
994
+ ) => {
995
+ render(<PagedThread follow={follow} {...(onPrepend && { onPrepend })} />);
996
+ await waitFor(() =>
997
+ expect(screen.getAllByTestId("thread-message")).toHaveLength(4),
998
+ );
999
+ act(() => {
1000
+ getViewport().scrollTop = scrollTop;
1001
+ fireEvent.scroll(getViewport());
1002
+ });
1003
+ };
1004
+
1005
+ const loadEarlier = async () => {
1006
+ await act(async () => {
1007
+ fireEvent.click(screen.getByTestId("load-earlier"));
1008
+ });
1009
+ await waitFor(() =>
1010
+ expect(screen.getAllByTestId("thread-message")).toHaveLength(8),
1011
+ );
1012
+ act(notifyResizeObservers);
1013
+ };
1014
+
1015
+ it("keeps the rows a reader scrolled to in place when an earlier page is prepended", async () => {
1016
+ await renderAt(0);
1017
+
1018
+ await loadEarlier();
1019
+
1020
+ expect(getViewport().scrollTop).toBe(4 * 80);
1021
+ expect(
1022
+ (screen.getByTestId("load-earlier") as HTMLButtonElement).disabled,
1023
+ ).toBe(true);
1024
+ });
1025
+
1026
+ it.each([false, true])(
1027
+ "keeps a reader at the bottom at the bottom (auto-follow %s)",
1028
+ async (follow) => {
1029
+ await renderAt(4 * 80 - 100, { follow });
1030
+
1031
+ await loadEarlier();
1032
+
1033
+ expect(getViewport().scrollTop).toBe(8 * 80 - 100);
1034
+ },
1035
+ );
1036
+
1037
+ it("does not count growth below the reader in the same update as part of the page", async () => {
1038
+ await renderAt(100, {
1039
+ onPrepend: () => {
1040
+ viewportMeasurementOffset = 40;
1041
+ },
1042
+ });
1043
+
1044
+ await loadEarlier();
1045
+
1046
+ expect(getViewport().scrollTop).toBe(4 * 80 + 100);
1047
+ });
1048
+
1049
+ it("holds the rows in place while the page above settles, until the reader's next gesture", async () => {
1050
+ await renderAt(0);
1051
+ await loadEarlier();
1052
+ expect(getViewport().scrollTop).toBe(4 * 80);
1053
+
1054
+ messageHeights.set("message-3", 120);
1055
+ act(notifyResizeObservers);
1056
+ expect(getViewport().scrollTop).toBe(4 * 80 + 40);
1057
+
1058
+ fireEvent.wheel(getViewport());
1059
+ messageHeights.set("message-2", 120);
1060
+ act(notifyResizeObservers);
1061
+ expect(getViewport().scrollTop).toBe(4 * 80 + 40);
1062
+ });
1063
+
1064
+ it("lets go of the held rows when a run starts, so the new turn can take the viewport", async () => {
1065
+ const { rerender } = render(<PagedThread />);
1066
+ await waitFor(() =>
1067
+ expect(screen.getAllByTestId("thread-message")).toHaveLength(4),
1068
+ );
1069
+ act(() => {
1070
+ getViewport().scrollTop = 0;
1071
+ fireEvent.scroll(getViewport());
1072
+ });
1073
+ await loadEarlier();
1074
+
1075
+ await act(async () => {
1076
+ rerender(<PagedThread running />);
1077
+ });
1078
+ messageHeights.set("message-3", 120);
1079
+ act(notifyResizeObservers);
1080
+
1081
+ expect(getViewport().scrollTop).toBe(4 * 80);
1082
+ });
1083
+
1084
+ it("lets go of the held rows once something else scrolls the viewport", async () => {
1085
+ await renderAt(0);
1086
+ await loadEarlier();
1087
+
1088
+ act(() => {
1089
+ getViewport().scrollTo({ top: 100 });
1090
+ });
1091
+ messageHeights.set("message-3", 120);
1092
+ act(notifyResizeObservers);
1093
+
1094
+ expect(getViewport().scrollTop).toBe(100);
1095
+ });
1096
+
1097
+ it("holds the row the reader sees, not the old first message, while content between them settles", async () => {
1098
+ await renderAt(100);
1099
+ await loadEarlier();
1100
+ expect(getViewport().scrollTop).toBe(4 * 80 + 100);
1101
+
1102
+ messageHeights.set("message-4", 120);
1103
+ act(notifyResizeObservers);
1104
+ expect(getViewport().scrollTop).toBe(4 * 80 + 100 + 40);
1105
+ });
1106
+
1107
+ it("does not treat a thread switch as a page, even when the new thread repeats the old first message", async () => {
1108
+ const SwitchingThread = () => {
1109
+ const [thread, setThread] = useState({
1110
+ id: "a",
1111
+ messages: pagedMessages.slice(4),
1112
+ });
1113
+ const runtime = useExternalStoreRuntime<ThreadMessageLike>({
1114
+ messages: thread.messages,
1115
+ convertMessage: (message) => message,
1116
+ onNew: async () => {},
1117
+ adapters: {
1118
+ threadList: {
1119
+ threadId: thread.id,
1120
+ threads: [
1121
+ { id: "a", status: "regular" },
1122
+ { id: "b", status: "regular" },
1123
+ ],
1124
+ },
1125
+ },
1126
+ });
1127
+
1128
+ return (
1129
+ <AssistantRuntimeProvider runtime={runtime}>
1130
+ <Thread scrollToBottomOnThreadSwitch={false} />
1131
+ <button
1132
+ type="button"
1133
+ data-testid="switch"
1134
+ onClick={() =>
1135
+ setThread({ id: "b", messages: pagedMessages.slice(3) })
1136
+ }
1137
+ />
1138
+ </AssistantRuntimeProvider>
1139
+ );
1140
+ };
1141
+
1142
+ render(<SwitchingThread />);
1143
+ await waitFor(() =>
1144
+ expect(screen.getAllByTestId("thread-message")).toHaveLength(4),
1145
+ );
1146
+ act(() => {
1147
+ getViewport().scrollTop = 100;
1148
+ fireEvent.scroll(getViewport());
1149
+ });
1150
+
1151
+ await act(async () => {
1152
+ fireEvent.click(screen.getByTestId("switch"));
1153
+ });
1154
+ await waitFor(() =>
1155
+ expect(screen.getAllByTestId("thread-message")).toHaveLength(5),
1156
+ );
1157
+ act(notifyResizeObservers);
1158
+
1159
+ expect(getViewport().scrollTop).toBe(100);
1160
+ });
1161
+ });
911
1162
  });
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { useComposedRefs } from "radix-ui/internal";
4
4
  import { useCallback, useLayoutEffect, useRef, type RefCallback } from "react";
5
- import { useAuiEvent, useAuiState } from "@assistant-ui/store";
5
+ import { useAui, useAuiEvent, useAuiState } from "@assistant-ui/store";
6
6
  import {
7
7
  isUserScrollUp,
8
8
  isViewportAtBottom,
@@ -33,6 +33,69 @@ const TEXT_ENTRY_SELECTOR = [
33
33
  ":not([type='range']):not([type='file']):not([type='color'])",
34
34
  ].join(", ");
35
35
 
36
+ const MESSAGE_SELECTOR = "[data-message-id]";
37
+
38
+ type MessageOffset = { readonly id: string; readonly offset: number };
39
+
40
+ const offsetInViewport = (element: Element, viewport: Element) =>
41
+ element.getBoundingClientRect().top - viewport.getBoundingClientRect().top;
42
+
43
+ const measureFirstMessage = (viewport: HTMLElement): MessageOffset | null => {
44
+ const element = viewport.querySelector<HTMLElement>(MESSAGE_SELECTOR);
45
+ const id = element?.dataset["messageId"];
46
+ return element && id
47
+ ? { id, offset: offsetInViewport(element, viewport) }
48
+ : null;
49
+ };
50
+
51
+ /** The first message that starts in view, else the one spanning its top edge. */
52
+ const measureReaderMessage = (viewport: HTMLElement): MessageOffset | null => {
53
+ const { top, bottom } = viewport.getBoundingClientRect();
54
+ let spanning: MessageOffset | null = null;
55
+ for (const element of viewport.querySelectorAll<HTMLElement>(
56
+ MESSAGE_SELECTOR,
57
+ )) {
58
+ const rect = element.getBoundingClientRect();
59
+ if (rect.bottom <= top) continue;
60
+ if (rect.top >= bottom) break;
61
+ const id = element.dataset["messageId"];
62
+ if (!id) continue;
63
+ if (rect.top >= top) return { id, offset: rect.top - top };
64
+ spanning ??= { id, offset: rect.top - top };
65
+ }
66
+ return spanning;
67
+ };
68
+
69
+ const findMessage = (viewport: HTMLElement, id: string) => {
70
+ for (const element of viewport.querySelectorAll<HTMLElement>(
71
+ MESSAGE_SELECTOR,
72
+ )) {
73
+ if (element.dataset["messageId"] === id) return element;
74
+ }
75
+ return null;
76
+ };
77
+
78
+ /**
79
+ * Scrolls so the message sits at `anchor.offset` again, recording the
80
+ * position it scrolls to in `heldScrollTop` before the scroll event can
81
+ * report it; false when the message is gone.
82
+ */
83
+ const keepMessageAt = (
84
+ viewport: HTMLElement,
85
+ anchor: MessageOffset,
86
+ heldScrollTop: { current: number },
87
+ ) => {
88
+ const element = findMessage(viewport, anchor.id);
89
+ if (!element) return false;
90
+ const delta = offsetInViewport(element, viewport) - anchor.offset;
91
+ heldScrollTop.current = viewport.scrollTop + delta;
92
+ if (Math.abs(delta) >= 1) {
93
+ viewport.scrollTo({ top: heldScrollTop.current, behavior: "instant" });
94
+ }
95
+ heldScrollTop.current = viewport.scrollTop;
96
+ return true;
97
+ };
98
+
36
99
  export namespace useThreadViewportAutoScroll {
37
100
  export type Options = {
38
101
  /**
@@ -72,8 +135,11 @@ export const useThreadViewportAutoScroll = <TElement extends HTMLElement>({
72
135
  scrollToBottomOnInitialize = true,
73
136
  scrollToBottomOnThreadSwitch = true,
74
137
  }: useThreadViewportAutoScroll.Options): RefCallback<TElement> => {
138
+ const aui = useAui();
75
139
  const divRef = useRef<TElement>(null);
76
140
  const hasMessages = useAuiState((s) => s.thread.messages.length > 0);
141
+ const firstMessageId = useAuiState((s) => s.thread.messages[0]?.id);
142
+ const threadId = useAuiState((s) => s.threadListItem.id);
77
143
  const isRunning = useAuiState((s) => s.thread.isRunning);
78
144
  const initializeScrollRequestedRef = useRef(false);
79
145
  const scheduledFrameRef = useRef<number | null>(null);
@@ -87,6 +153,13 @@ export const useThreadViewportAutoScroll = <TElement extends HTMLElement>({
87
153
  const lastScrollHeight = useRef<number>(0);
88
154
  const lastObservedScrollHeight = useRef<number>(0);
89
155
  const lastObservedClientHeight = useRef<number>(0);
156
+ const firstMessageRef = useRef<MessageOffset | null>(null);
157
+ // The row the reader sees when earlier messages land, held until a gesture
158
+ // or a scroll this hook did not make, so content above it that settles
159
+ // afterwards (lazy layout, late media) does not move it on engines without
160
+ // native scroll anchoring.
161
+ const prependAnchorRef = useRef<MessageOffset | null>(null);
162
+ const heldScrollTopRef = useRef(0);
90
163
 
91
164
  // Pending bottom-scroll intent. Planted by initialize/run-start/switch/button
92
165
  // triggers, cleared when handleScroll confirms we reached bottom, or when the
@@ -110,6 +183,7 @@ export const useThreadViewportAutoScroll = <TElement extends HTMLElement>({
110
183
 
111
184
  followBottomRef.current = true;
112
185
  scrollingToBottomBehaviorRef.current = behavior;
186
+ prependAnchorRef.current = null;
113
187
  div.scrollTo({ top: div.scrollHeight, behavior });
114
188
  }, []);
115
189
 
@@ -146,6 +220,13 @@ export const useThreadViewportAutoScroll = <TElement extends HTMLElement>({
146
220
  const div = divRef.current;
147
221
  if (!div) return;
148
222
 
223
+ if (
224
+ prependAnchorRef.current &&
225
+ Math.abs(div.scrollTop - heldScrollTopRef.current) >= 1
226
+ ) {
227
+ prependAnchorRef.current = null;
228
+ }
229
+
149
230
  const isAtBottom = threadViewportStore.getState().isAtBottom;
150
231
  const newIsAtBottom = isViewportAtBottom(div);
151
232
 
@@ -188,6 +269,7 @@ export const useThreadViewportAutoScroll = <TElement extends HTMLElement>({
188
269
 
189
270
  lastScrollTop.current = div.scrollTop;
190
271
  lastScrollHeight.current = div.scrollHeight;
272
+ firstMessageRef.current = measureFirstMessage(div);
191
273
  };
192
274
 
193
275
  const resizeRef = useOnResizeContent(() => {
@@ -216,6 +298,11 @@ export const useThreadViewportAutoScroll = <TElement extends HTMLElement>({
216
298
  followBottomRef.current
217
299
  ) {
218
300
  scrollToBottom("instant");
301
+ } else if (
302
+ prependAnchorRef.current &&
303
+ !keepMessageAt(div, prependAnchorRef.current, heldScrollTopRef)
304
+ ) {
305
+ prependAnchorRef.current = null;
219
306
  }
220
307
 
221
308
  handleScroll();
@@ -242,16 +329,74 @@ export const useThreadViewportAutoScroll = <TElement extends HTMLElement>({
242
329
  if (target?.closest?.(TEXT_ENTRY_SELECTOR)) return;
243
330
  cancelPendingScrollToBottom();
244
331
  };
332
+ const releasePrependAnchor = () => {
333
+ prependAnchorRef.current = null;
334
+ };
335
+ const gestures = ["pointerdown", "wheel", "touchstart", "keydown"] as const;
245
336
  el.addEventListener("scroll", handleScroll);
246
337
  el.addEventListener("pointerdown", cancelPendingScrollToBottom);
247
338
  el.addEventListener("keydown", cancelOnKeyDown);
339
+ for (const gesture of gestures) {
340
+ el.addEventListener(gesture, releasePrependAnchor, { passive: true });
341
+ }
248
342
  return () => {
249
343
  el.removeEventListener("scroll", handleScroll);
250
344
  el.removeEventListener("pointerdown", cancelPendingScrollToBottom);
251
345
  el.removeEventListener("keydown", cancelOnKeyDown);
346
+ for (const gesture of gestures) {
347
+ el.removeEventListener(gesture, releasePrependAnchor);
348
+ }
252
349
  };
253
350
  });
254
351
 
352
+ // Earlier messages prepended above the reader put the old first message back
353
+ // where the reader saw it, so growth below it in the same commit (a
354
+ // streaming reply) is not mistaken for the page; where the browser already
355
+ // anchored the scroll, this is a no-op.
356
+ const previousRef = useRef({ threadId, firstMessageId });
357
+ useLayoutEffect(() => {
358
+ const previous = previousRef.current;
359
+ previousRef.current = { threadId, firstMessageId };
360
+ if (previous.threadId !== threadId) {
361
+ prependAnchorRef.current = null;
362
+ return;
363
+ }
364
+ const div = divRef.current;
365
+ const previousFirstMessageId = previous.firstMessageId;
366
+ if (
367
+ !div ||
368
+ previousFirstMessageId === undefined ||
369
+ previousFirstMessageId === firstMessageId ||
370
+ (autoScroll && followBottomRef.current) ||
371
+ scrollingToBottomBehaviorRef.current !== null
372
+ )
373
+ return;
374
+ const prepended = aui.thread
375
+ .getState()
376
+ .messages.some(
377
+ (message, index) => index > 0 && message.id === previousFirstMessageId,
378
+ );
379
+ if (!prepended) return;
380
+
381
+ const anchor = firstMessageRef.current;
382
+ if (
383
+ anchor?.id === previousFirstMessageId &&
384
+ keepMessageAt(div, anchor, heldScrollTopRef)
385
+ ) {
386
+ prependAnchorRef.current = measureReaderMessage(div) ?? anchor;
387
+ } else {
388
+ // Message roots that render no `data-message-id` leave only the total
389
+ // growth to go on, which counts any growth below the reader too.
390
+ const top =
391
+ div.scrollHeight - (lastScrollHeight.current - lastScrollTop.current);
392
+ if (Math.abs(div.scrollTop - top) >= 1) {
393
+ div.scrollTo({ top, behavior: "instant" });
394
+ }
395
+ }
396
+ lastScrollTop.current = div.scrollTop;
397
+ lastScrollHeight.current = div.scrollHeight;
398
+ }, [aui, autoScroll, firstMessageId, threadId]);
399
+
255
400
  useLayoutEffect(() => {
256
401
  if (!scrollToBottomOnInitialize) return;
257
402
  if (!hasMessages) {
@@ -272,6 +417,7 @@ export const useThreadViewportAutoScroll = <TElement extends HTMLElement>({
272
417
  });
273
418
 
274
419
  useAuiEvent("thread.runStart", () => {
420
+ prependAnchorRef.current = null;
275
421
  if (!scrollToBottomOnRunStart) return;
276
422
  if (threadViewportStore.getState().turnAnchor === "top") return;
277
423
  scheduleScrollToBottom("auto");
@@ -7,7 +7,9 @@ export { ThreadPrimitiveViewportFooter as ViewportFooter } from "./thread/Thread
7
7
  export { ThreadPrimitiveMessages as Messages } from "./thread/ThreadMessages";
8
8
  export { ThreadPrimitiveMessageByIndex as MessageByIndex } from "./thread/ThreadMessages";
9
9
  export { ThreadPrimitiveUnstable_MessageById as Unstable_MessageById } from "./thread/ThreadMessages";
10
+ export { ThreadPrimitiveRow as Row } from "./thread/ThreadMessages";
10
11
  export { ThreadPrimitiveScrollToBottom as ScrollToBottom } from "./thread/ThreadScrollToBottom";
12
+ export { ThreadPrimitiveLoadEarlier as LoadEarlier } from "./thread/ThreadLoadEarlier";
11
13
  export { ThreadPrimitiveSuggestion as Suggestion } from "./thread/ThreadSuggestion";
12
14
  export {
13
15
  ThreadPrimitiveSuggestions as Suggestions,
@@ -416,6 +416,40 @@ describe("SandboxHost", () => {
416
416
  expect(onError).not.toHaveBeenCalled();
417
417
  });
418
418
 
419
+ it("cancels pending renders on replacement and unmount", async () => {
420
+ renderHtmlMock.mockImplementation(() => new Promise(() => {}));
421
+
422
+ await act(async () => {
423
+ root.render(
424
+ <SandboxHost
425
+ content={{ html: "first" }}
426
+ contentKey="first"
427
+ createBridge={() => ({ onMessage: vi.fn(), dispose: vi.fn() })}
428
+ />,
429
+ );
430
+ });
431
+ const firstSignal = renderHtmlMock.mock.calls[0]![2].signal as AbortSignal;
432
+
433
+ await act(async () => {
434
+ root.render(
435
+ <SandboxHost
436
+ content={{ html: "second" }}
437
+ contentKey="second"
438
+ createBridge={() => ({ onMessage: vi.fn(), dispose: vi.fn() })}
439
+ />,
440
+ );
441
+ });
442
+ const secondSignal = renderHtmlMock.mock.calls[1]![2].signal as AbortSignal;
443
+
444
+ expect(firstSignal.aborted).toBe(true);
445
+ expect(secondSignal.aborted).toBe(false);
446
+
447
+ await act(async () => {
448
+ root.unmount();
449
+ });
450
+ expect(secondSignal.aborted).toBe(true);
451
+ });
452
+
419
453
  it("contains failures thrown by onError", async () => {
420
454
  const renderError = new Error("render failed");
421
455
  const callbackError = new Error("error callback failed");
@@ -107,6 +107,7 @@ export function SandboxHost({
107
107
  let frame: RenderedFrame | null = null;
108
108
  let bridge: SandboxBridge | null = null;
109
109
  let onMessage: ((event: MessageEvent) => void) | null = null;
110
+ const renderController = new AbortController();
110
111
 
111
112
  const { content: liveContent, sandbox: sb } = liveRef.current;
112
113
 
@@ -129,10 +130,12 @@ export function SandboxHost({
129
130
  ...(sb?.salt !== undefined && { salt: sb.salt }),
130
131
  });
131
132
 
132
- const renderOpts =
133
- sb?.unsafeDocumentWrite !== undefined
134
- ? { unsafeDocumentWrite: sb.unsafeDocumentWrite }
135
- : undefined;
133
+ const renderOpts = {
134
+ signal: renderController.signal,
135
+ ...(sb?.unsafeDocumentWrite !== undefined && {
136
+ unsafeDocumentWrite: sb.unsafeDocumentWrite,
137
+ }),
138
+ };
136
139
 
137
140
  scf
138
141
  .renderHtml(liveContent.html, container, renderOpts)
@@ -216,6 +219,7 @@ export function SandboxHost({
216
219
  const bridgeToDispose = bridge;
217
220
  bridge = null;
218
221
  if (bridgeToDispose) runCleanup(() => bridgeToDispose.dispose());
222
+ runCleanup(() => renderController.abort());
219
223
  const frameToDispose = frame;
220
224
  frame = null;
221
225
  if (frameToDispose) runCleanup(() => frameToDispose.dispose());
@@ -47,8 +47,15 @@ const ElapsedProbe: FC = () => {
47
47
  };
48
48
 
49
49
  const StallProbe: FC = () => {
50
- const { stalled } = unstable_useMessageStallDetection({ thresholdMs: 2000 });
51
- return <span data-testid="stalled">{String(stalled)}</span>;
50
+ const { stalled, stalledForMs } = unstable_useMessageStallDetection({
51
+ thresholdMs: 2000,
52
+ });
53
+ return (
54
+ <>
55
+ <span data-testid="stalled">{String(stalled)}</span>
56
+ <span data-testid="stalled-for">{stalledForMs}</span>
57
+ </>
58
+ );
52
59
  };
53
60
 
54
61
  const RuntimeProvider: FC<
@@ -224,6 +231,16 @@ describe("unstable_useMessageStallDetection", () => {
224
231
  });
225
232
 
226
233
  expect(screen.getByTestId("stalled").textContent).toBe("true");
234
+ const stalledFor = () =>
235
+ Number(screen.getByTestId("stalled-for").textContent);
236
+ const atStall = stalledFor();
237
+ expect(atStall).toBeGreaterThanOrEqual(2000);
238
+
239
+ await act(async () => {
240
+ await vi.advanceTimersByTimeAsync(1000);
241
+ });
242
+
243
+ expect(stalledFor()).toBeGreaterThan(atStall);
227
244
  });
228
245
 
229
246
  it("never stalls on settled messages", async () => {