@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
@@ -1,8 +1,8 @@
1
1
  "use client";
2
2
 
3
3
  import { useComposedRefs } from "radix-ui/internal";
4
- import { useCallback, useLayoutEffect, useRef, type RefCallback } from "react";
5
- import { useAuiEvent, useAuiState } from "@assistant-ui/store";
4
+ import { useCallback, useRef, type RefCallback } from "react";
5
+ import { useAui, useAuiEvent, useAuiState } from "@assistant-ui/store";
6
6
  import {
7
7
  isUserScrollUp,
8
8
  isViewportAtBottom,
@@ -13,12 +13,21 @@ import { useOnScrollToBottom } from "../../utils/hooks/useOnScrollToBottom";
13
13
  import { useManagedRef } from "../../utils/hooks/useManagedRef";
14
14
  import { writableStore } from "../../context/ReadonlyStore";
15
15
  import { useThreadViewportStore } from "../../context/react/ThreadViewportContext";
16
-
17
- // Enter and Space activate a focused control, which is how a collapsible tool
18
- // call expands without a pointer event ever firing. No other key changes thread
19
- // content: the keys that scroll the viewport reach handleScroll, which already
20
- // clears the intent when the user scrolls up.
21
- const ACTIVATION_KEYS = new Set(["Enter", " "]);
16
+ import { useIsomorphicLayoutEffect } from "../../utils/useIsomorphicLayoutEffect";
17
+
18
+ const SCROLL_KEYS = new Set([
19
+ " ",
20
+ "ArrowUp",
21
+ "ArrowDown",
22
+ "PageUp",
23
+ "PageDown",
24
+ "Home",
25
+ "End",
26
+ ]);
27
+
28
+ // Enter and Space activate focused controls, while navigation keys can
29
+ // interrupt scrolling without producing a scroll event.
30
+ const INTENT_CANCEL_KEYS = new Set(["Enter", ...SCROLL_KEYS]);
22
31
 
23
32
  // A control that consumes the activation key itself instead of acting on thread
24
33
  // content. `contenteditable="false"` marks a non-editable island inside an
@@ -33,6 +42,85 @@ const TEXT_ENTRY_SELECTOR = [
33
42
  ":not([type='range']):not([type='file']):not([type='color'])",
34
43
  ].join(", ");
35
44
 
45
+ const MESSAGE_SELECTOR = "[data-message-id]";
46
+
47
+ const DISCLOSURE_SELECTOR = "[aria-expanded], summary";
48
+
49
+ // A popup trigger (menu, dialog, listbox) also carries aria-expanded, but what
50
+ // it opens is portaled away from the thread, so only disclosures count.
51
+ const isOpeningDisclosure = (target: Element): boolean => {
52
+ const control = target.closest(DISCLOSURE_SELECTOR);
53
+ if (!control) return false;
54
+ if (control.tagName === "SUMMARY")
55
+ return control.parentElement?.hasAttribute("open") === false;
56
+ return (
57
+ control.getAttribute("aria-expanded") === "false" &&
58
+ (control.getAttribute("aria-haspopup") ?? "false") === "false" &&
59
+ control.getAttribute("role") !== "combobox"
60
+ );
61
+ };
62
+
63
+ type MessageOffset = { readonly id: string; readonly offset: number };
64
+
65
+ const offsetInViewport = (element: Element, viewport: Element) =>
66
+ element.getBoundingClientRect().top - viewport.getBoundingClientRect().top;
67
+
68
+ const measureFirstMessage = (viewport: HTMLElement): MessageOffset | null => {
69
+ const element = viewport.querySelector<HTMLElement>(MESSAGE_SELECTOR);
70
+ const id = element?.dataset["messageId"];
71
+ return element && id
72
+ ? { id, offset: offsetInViewport(element, viewport) }
73
+ : null;
74
+ };
75
+
76
+ /** The first message that starts in view, else the one spanning its top edge. */
77
+ const measureReaderMessage = (viewport: HTMLElement): MessageOffset | null => {
78
+ const { top, bottom } = viewport.getBoundingClientRect();
79
+ let spanning: MessageOffset | null = null;
80
+ for (const element of viewport.querySelectorAll<HTMLElement>(
81
+ MESSAGE_SELECTOR,
82
+ )) {
83
+ const rect = element.getBoundingClientRect();
84
+ if (rect.bottom <= top) continue;
85
+ if (rect.top >= bottom) break;
86
+ const id = element.dataset["messageId"];
87
+ if (!id) continue;
88
+ if (rect.top >= top) return { id, offset: rect.top - top };
89
+ spanning ??= { id, offset: rect.top - top };
90
+ }
91
+ return spanning;
92
+ };
93
+
94
+ const findMessage = (viewport: HTMLElement, id: string) => {
95
+ for (const element of viewport.querySelectorAll<HTMLElement>(
96
+ MESSAGE_SELECTOR,
97
+ )) {
98
+ if (element.dataset["messageId"] === id) return element;
99
+ }
100
+ return null;
101
+ };
102
+
103
+ /**
104
+ * Scrolls so the message sits at `anchor.offset` again, recording the
105
+ * position it scrolls to in `heldScrollTop` before the scroll event can
106
+ * report it; false when the message is gone.
107
+ */
108
+ const keepMessageAt = (
109
+ viewport: HTMLElement,
110
+ anchor: MessageOffset,
111
+ heldScrollTop: { current: number },
112
+ ) => {
113
+ const element = findMessage(viewport, anchor.id);
114
+ if (!element) return false;
115
+ const delta = offsetInViewport(element, viewport) - anchor.offset;
116
+ heldScrollTop.current = viewport.scrollTop + delta;
117
+ if (Math.abs(delta) >= 1) {
118
+ viewport.scrollTo({ top: heldScrollTop.current, behavior: "instant" });
119
+ }
120
+ heldScrollTop.current = viewport.scrollTop;
121
+ return true;
122
+ };
123
+
36
124
  export namespace useThreadViewportAutoScroll {
37
125
  export type Options = {
38
126
  /**
@@ -72,8 +160,11 @@ export const useThreadViewportAutoScroll = <TElement extends HTMLElement>({
72
160
  scrollToBottomOnInitialize = true,
73
161
  scrollToBottomOnThreadSwitch = true,
74
162
  }: useThreadViewportAutoScroll.Options): RefCallback<TElement> => {
163
+ const aui = useAui();
75
164
  const divRef = useRef<TElement>(null);
76
165
  const hasMessages = useAuiState((s) => s.thread.messages.length > 0);
166
+ const firstMessageId = useAuiState((s) => s.thread.messages[0]?.id);
167
+ const threadId = useAuiState((s) => s.threadListItem.id);
77
168
  const isRunning = useAuiState((s) => s.thread.isRunning);
78
169
  const initializeScrollRequestedRef = useRef(false);
79
170
  const scheduledFrameRef = useRef<number | null>(null);
@@ -87,15 +178,28 @@ export const useThreadViewportAutoScroll = <TElement extends HTMLElement>({
87
178
  const lastScrollHeight = useRef<number>(0);
88
179
  const lastObservedScrollHeight = useRef<number>(0);
89
180
  const lastObservedClientHeight = useRef<number>(0);
181
+ const firstMessageRef = useRef<MessageOffset | null>(null);
182
+ // The row the reader sees when earlier messages land, held until a gesture
183
+ // or a scroll this hook did not make, so content above it that settles
184
+ // afterwards (lazy layout, late media) does not move it on engines without
185
+ // native scroll anchoring.
186
+ const prependAnchorRef = useRef<MessageOffset | null>(null);
187
+ const heldScrollTopRef = useRef(0);
90
188
 
91
189
  // Pending bottom-scroll intent. Planted by initialize/run-start/switch/button
92
190
  // triggers, cleared when handleScroll confirms we reached bottom, or when the
93
191
  // user actively scrolls up while content size is stable.
94
192
  const scrollingToBottomBehaviorRef = useRef<ScrollBehavior | null>(null);
95
193
  const followBottomRef = useRef(autoScroll);
194
+ // Set by expanding a disclosure; suspends follow without clearing its intent
195
+ // until a scroll gesture reaches an overflowing bottom (content that fits
196
+ // reads as at the bottom), a run starts, the thread changes, or the reader
197
+ // asks for the bottom again.
198
+ const followPausedRef = useRef(false);
199
+ const scrolledSincePauseRef = useRef(false);
96
200
  const previousAutoScrollRef = useRef(autoScroll);
97
201
 
98
- useLayoutEffect(() => {
202
+ useIsomorphicLayoutEffect(() => {
99
203
  const previousAutoScroll = previousAutoScrollRef.current;
100
204
  previousAutoScrollRef.current = autoScroll;
101
205
  if (previousAutoScroll || !autoScroll) return;
@@ -109,8 +213,12 @@ export const useThreadViewportAutoScroll = <TElement extends HTMLElement>({
109
213
  if (!div) return;
110
214
 
111
215
  followBottomRef.current = true;
216
+ followPausedRef.current = false;
112
217
  scrollingToBottomBehaviorRef.current = behavior;
218
+ prependAnchorRef.current = null;
113
219
  div.scrollTo({ top: div.scrollHeight, behavior });
220
+ lastScrollTop.current = div.scrollTop;
221
+ lastScrollHeight.current = div.scrollHeight;
114
222
  }, []);
115
223
 
116
224
  const cancelScheduledFrame = useCallback(() => {
@@ -131,7 +239,10 @@ export const useThreadViewportAutoScroll = <TElement extends HTMLElement>({
131
239
  [cancelScheduledFrame, scrollToBottom],
132
240
  );
133
241
 
134
- useLayoutEffect(() => () => cancelScheduledFrame(), [cancelScheduledFrame]);
242
+ useIsomorphicLayoutEffect(
243
+ () => () => cancelScheduledFrame(),
244
+ [cancelScheduledFrame],
245
+ );
135
246
 
136
247
  const hasActiveTopAnchor = useCallback(() => {
137
248
  const state = threadViewportStore.getState();
@@ -142,15 +253,24 @@ export const useThreadViewportAutoScroll = <TElement extends HTMLElement>({
142
253
  );
143
254
  }, [threadViewportStore]);
144
255
 
145
- const handleScroll = () => {
256
+ const onScroll = () => {
146
257
  const div = divRef.current;
147
258
  if (!div) return;
148
259
 
260
+ if (
261
+ prependAnchorRef.current &&
262
+ Math.abs(div.scrollTop - heldScrollTopRef.current) >= 1
263
+ ) {
264
+ prependAnchorRef.current = null;
265
+ }
266
+
149
267
  const isAtBottom = threadViewportStore.getState().isAtBottom;
150
268
  const newIsAtBottom = isViewportAtBottom(div);
151
269
 
152
270
  const isInFlightDownwardScroll =
153
- !newIsAtBottom && lastScrollTop.current < div.scrollTop;
271
+ scrollingToBottomBehaviorRef.current !== null &&
272
+ !newIsAtBottom &&
273
+ lastScrollTop.current < div.scrollTop;
154
274
  if (isInFlightDownwardScroll) {
155
275
  // no-op: a smooth scroll-to-bottom fires many midpoint scroll events
156
276
  // before landing, don't flicker isAtBottom or clear intent mid-animation
@@ -168,6 +288,7 @@ export const useThreadViewportAutoScroll = <TElement extends HTMLElement>({
168
288
  // keep intent alive until content can actually scroll
169
289
  if (viewportOverflows(div)) {
170
290
  scrollingToBottomBehaviorRef.current = null;
291
+ if (scrolledSincePauseRef.current) followPausedRef.current = false;
171
292
  }
172
293
  if (autoScroll) followBottomRef.current = true;
173
294
  } else if (userScrolledUp) {
@@ -188,9 +309,15 @@ export const useThreadViewportAutoScroll = <TElement extends HTMLElement>({
188
309
 
189
310
  lastScrollTop.current = div.scrollTop;
190
311
  lastScrollHeight.current = div.scrollHeight;
312
+ firstMessageRef.current = measureFirstMessage(div);
191
313
  };
314
+ const onScrollRef = useRef(onScroll);
315
+ useIsomorphicLayoutEffect(() => {
316
+ onScrollRef.current = onScroll;
317
+ });
318
+ const handleScroll = useCallback(() => onScrollRef.current(), []);
192
319
 
193
- const resizeRef = useOnResizeContent(() => {
320
+ const onResize = () => {
194
321
  const div = divRef.current;
195
322
  if (!div) return;
196
323
 
@@ -204,55 +331,199 @@ export const useThreadViewportAutoScroll = <TElement extends HTMLElement>({
204
331
  lastObservedScrollHeight.current = scrollHeight;
205
332
  lastObservedClientHeight.current = clientHeight;
206
333
 
207
- const scrollBehavior = scrollingToBottomBehaviorRef.current;
208
- if (scrollBehavior && hasActiveTopAnchor()) {
209
- // Let the top-anchor reserve own scrolling while a run starts to avoid a bottom-scroll race.
210
- scrollingToBottomBehaviorRef.current = null;
211
- } else if (scrollBehavior) {
212
- scrollToBottom(scrollBehavior);
213
- } else if (
214
- autoScroll &&
215
- !(isRunning && hasActiveTopAnchor()) &&
216
- followBottomRef.current
217
- ) {
218
- scrollToBottom("instant");
334
+ // A scheduled frame owns the pending behavior; resize callbacks must not apply it early.
335
+ if (scheduledFrameRef.current === null) {
336
+ const scrollBehavior = scrollingToBottomBehaviorRef.current;
337
+ if (scrollBehavior && hasActiveTopAnchor()) {
338
+ // Let the top-anchor reserve own scrolling while a run starts to avoid a bottom-scroll race.
339
+ scrollingToBottomBehaviorRef.current = null;
340
+ } else if (scrollBehavior) {
341
+ scrollToBottom(scrollBehavior);
342
+ } else if (
343
+ autoScroll &&
344
+ !(isRunning && hasActiveTopAnchor()) &&
345
+ followBottomRef.current &&
346
+ !followPausedRef.current
347
+ ) {
348
+ scrollToBottom("instant");
349
+ } else if (
350
+ prependAnchorRef.current &&
351
+ !keepMessageAt(div, prependAnchorRef.current, heldScrollTopRef)
352
+ ) {
353
+ prependAnchorRef.current = null;
354
+ }
219
355
  }
220
356
 
221
357
  handleScroll();
358
+ };
359
+ const onResizeRef = useRef(onResize);
360
+ useIsomorphicLayoutEffect(() => {
361
+ onResizeRef.current = onResize;
222
362
  });
363
+ const resizeRef = useOnResizeContent(() => onResizeRef.current());
364
+
365
+ const scrollRef = useManagedRef<HTMLElement>(
366
+ useCallback(
367
+ (el) => {
368
+ // A user gesture invalidates pending bottom-scroll intent; otherwise an
369
+ // intent kept alive by a non-overflowing thread (see handleScroll) hijacks
370
+ // the next content growth, e.g. expanding a collapsible tool call. Keyboard
371
+ // activation reaches that same content without ever emitting a pointer
372
+ // event, so it has to cancel the intent too.
373
+ const cancelPendingScrollToBottom = () => {
374
+ // With nothing pending, the last scroll event already published isAtBottom.
375
+ if (
376
+ scrollingToBottomBehaviorRef.current === null &&
377
+ scheduledFrameRef.current === null
378
+ )
379
+ return;
380
+ // A scheduled frame re-plants the intent when it runs, so clearing the
381
+ // ref alone leaves the gesture undone.
382
+ cancelScheduledFrame();
383
+ scrollingToBottomBehaviorRef.current = null;
384
+ handleScroll();
385
+ };
386
+ // The composer renders inside the viewport, so its keystrokes bubble here;
387
+ // cancellation keys only represent a gesture on thread content when they
388
+ // originate outside text entry.
389
+ const cancelOnKeyDown = (event: KeyboardEvent) => {
390
+ if (!INTENT_CANCEL_KEYS.has(event.key)) return;
391
+ const target = event.target as Element | null;
392
+ if (target?.closest?.(TEXT_ENTRY_SELECTOR)) return;
393
+ cancelPendingScrollToBottom();
394
+ };
395
+ // Expanding a disclosure inside a message, such as a reasoning block or a
396
+ // tool call card, means the reader is inspecting it, so bottom follow
397
+ // pauses until the reader scrolls or asks for the bottom again. Every
398
+ // other interaction, including copying or selecting text, keeps
399
+ // following. Activation is read from click, which keyboard activation also
400
+ // dispatches, in the capture phase so the state is read before any handler
401
+ // on the disclosure flips it or stops the event.
402
+ const pauseFollowOnExpand = (event: MouseEvent) => {
403
+ const target = event.target as Element | null;
404
+ if (
405
+ !target?.closest?.(MESSAGE_SELECTOR) ||
406
+ target.closest(TEXT_ENTRY_SELECTOR) ||
407
+ !isOpeningDisclosure(target)
408
+ )
409
+ return;
410
+ followPausedRef.current = true;
411
+ scrolledSincePauseRef.current = false;
412
+ cancelPendingScrollToBottom();
413
+ };
414
+ const noteScrollGesture = (event: Event) => {
415
+ if (event instanceof KeyboardEvent) {
416
+ if (!SCROLL_KEYS.has(event.key)) return;
417
+ if (
418
+ (event.target as Element | null)?.closest?.(TEXT_ENTRY_SELECTOR)
419
+ )
420
+ return;
421
+ } else if (event.type === "pointerdown" && event.target !== el) {
422
+ return;
423
+ }
424
+ scrolledSincePauseRef.current = true;
425
+ };
426
+ const releasePrependAnchor = () => {
427
+ prependAnchorRef.current = null;
428
+ };
429
+ const gestures = [
430
+ "pointerdown",
431
+ "wheel",
432
+ "touchstart",
433
+ "keydown",
434
+ ] as const;
435
+ const resumeGestures = [
436
+ "pointerdown",
437
+ "wheel",
438
+ "touchmove",
439
+ "keydown",
440
+ ] as const;
441
+ el.addEventListener("scroll", handleScroll);
442
+ el.addEventListener("pointerdown", cancelPendingScrollToBottom);
443
+ el.addEventListener("wheel", cancelPendingScrollToBottom, {
444
+ passive: true,
445
+ });
446
+ el.addEventListener("touchstart", cancelPendingScrollToBottom, {
447
+ passive: true,
448
+ });
449
+ el.addEventListener("keydown", cancelOnKeyDown);
450
+ el.addEventListener("click", pauseFollowOnExpand, { capture: true });
451
+ for (const gesture of resumeGestures) {
452
+ el.addEventListener(gesture, noteScrollGesture, { passive: true });
453
+ }
454
+ for (const gesture of gestures) {
455
+ el.addEventListener(gesture, releasePrependAnchor, { passive: true });
456
+ }
457
+ return () => {
458
+ el.removeEventListener("scroll", handleScroll);
459
+ el.removeEventListener("pointerdown", cancelPendingScrollToBottom);
460
+ el.removeEventListener("wheel", cancelPendingScrollToBottom);
461
+ el.removeEventListener("touchstart", cancelPendingScrollToBottom);
462
+ el.removeEventListener("keydown", cancelOnKeyDown);
463
+ el.removeEventListener("click", pauseFollowOnExpand, {
464
+ capture: true,
465
+ });
466
+ for (const gesture of resumeGestures) {
467
+ el.removeEventListener(gesture, noteScrollGesture);
468
+ }
469
+ for (const gesture of gestures) {
470
+ el.removeEventListener(gesture, releasePrependAnchor);
471
+ }
472
+ };
473
+ },
474
+ [cancelScheduledFrame, handleScroll],
475
+ ),
476
+ );
223
477
 
224
- const scrollRef = useManagedRef<HTMLElement>((el) => {
225
- // A user gesture invalidates pending bottom-scroll intent; otherwise an
226
- // intent kept alive by a non-overflowing thread (see handleScroll) hijacks
227
- // the next content growth, e.g. expanding a collapsible tool call. Keyboard
228
- // activation reaches that same content without ever emitting a pointer
229
- // event, so it has to cancel the intent too.
230
- const cancelPendingScrollToBottom = () => {
231
- // A scheduled frame re-plants the intent when it runs, so clearing the
232
- // ref alone leaves the gesture undone.
233
- cancelScheduledFrame();
234
- scrollingToBottomBehaviorRef.current = null;
235
- };
236
- // The composer renders inside the viewport, so its keystrokes bubble here;
237
- // only an activation key aimed at something other than a text field is a
238
- // gesture on thread content.
239
- const cancelOnKeyDown = (event: KeyboardEvent) => {
240
- if (!ACTIVATION_KEYS.has(event.key)) return;
241
- const target = event.target as Element | null;
242
- if (target?.closest?.(TEXT_ENTRY_SELECTOR)) return;
243
- cancelPendingScrollToBottom();
244
- };
245
- el.addEventListener("scroll", handleScroll);
246
- el.addEventListener("pointerdown", cancelPendingScrollToBottom);
247
- el.addEventListener("keydown", cancelOnKeyDown);
248
- return () => {
249
- el.removeEventListener("scroll", handleScroll);
250
- el.removeEventListener("pointerdown", cancelPendingScrollToBottom);
251
- el.removeEventListener("keydown", cancelOnKeyDown);
252
- };
253
- });
478
+ // Earlier messages prepended above the reader put the old first message back
479
+ // where the reader saw it, so growth below it in the same commit (a
480
+ // streaming reply) is not mistaken for the page; where the browser already
481
+ // anchored the scroll, this is a no-op.
482
+ const previousRef = useRef({ threadId, firstMessageId });
483
+ useIsomorphicLayoutEffect(() => {
484
+ const previous = previousRef.current;
485
+ previousRef.current = { threadId, firstMessageId };
486
+ if (previous.threadId !== threadId) {
487
+ prependAnchorRef.current = null;
488
+ return;
489
+ }
490
+ const div = divRef.current;
491
+ const previousFirstMessageId = previous.firstMessageId;
492
+ if (
493
+ !div ||
494
+ previousFirstMessageId === undefined ||
495
+ previousFirstMessageId === firstMessageId ||
496
+ (autoScroll && followBottomRef.current && !followPausedRef.current) ||
497
+ scrollingToBottomBehaviorRef.current !== null
498
+ )
499
+ return;
500
+ const prepended = aui.thread
501
+ .getState()
502
+ .messages.some(
503
+ (message, index) => index > 0 && message.id === previousFirstMessageId,
504
+ );
505
+ if (!prepended) return;
506
+
507
+ const anchor = firstMessageRef.current;
508
+ if (
509
+ anchor?.id === previousFirstMessageId &&
510
+ keepMessageAt(div, anchor, heldScrollTopRef)
511
+ ) {
512
+ prependAnchorRef.current = measureReaderMessage(div) ?? anchor;
513
+ } else {
514
+ // Message roots that render no `data-message-id` leave only the total
515
+ // growth to go on, which counts any growth below the reader too.
516
+ const top =
517
+ div.scrollHeight - (lastScrollHeight.current - lastScrollTop.current);
518
+ if (Math.abs(div.scrollTop - top) >= 1) {
519
+ div.scrollTo({ top, behavior: "instant" });
520
+ }
521
+ }
522
+ lastScrollTop.current = div.scrollTop;
523
+ lastScrollHeight.current = div.scrollHeight;
524
+ }, [aui, autoScroll, firstMessageId, threadId]);
254
525
 
255
- useLayoutEffect(() => {
526
+ useIsomorphicLayoutEffect(() => {
256
527
  if (!scrollToBottomOnInitialize) return;
257
528
  if (!hasMessages) {
258
529
  initializeScrollRequestedRef.current = false;
@@ -272,12 +543,15 @@ export const useThreadViewportAutoScroll = <TElement extends HTMLElement>({
272
543
  });
273
544
 
274
545
  useAuiEvent("thread.runStart", () => {
546
+ prependAnchorRef.current = null;
547
+ followPausedRef.current = false;
275
548
  if (!scrollToBottomOnRunStart) return;
276
549
  if (threadViewportStore.getState().turnAnchor === "top") return;
277
550
  scheduleScrollToBottom("auto");
278
551
  });
279
552
 
280
553
  useAuiEvent("threads.selectionChanged", () => {
554
+ followPausedRef.current = false;
281
555
  if (!scrollToBottomOnThreadSwitch) return;
282
556
  scheduleScrollToBottom("instant");
283
557
  });
@@ -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");
@@ -1,12 +1,7 @@
1
1
  "use client";
2
2
 
3
- import {
4
- type CSSProperties,
5
- useEffect,
6
- useLayoutEffect,
7
- useRef,
8
- useState,
9
- } from "react";
3
+ import { type CSSProperties, useEffect, useRef, useState } from "react";
4
+ import { useIsomorphicLayoutEffect } from "../utils/useIsomorphicLayoutEffect";
10
5
  import {
11
6
  isShimLoadError,
12
7
  type RenderedFrame,
@@ -95,7 +90,7 @@ export function SandboxHost({
95
90
  createBridge,
96
91
  onError,
97
92
  });
98
- useLayoutEffect(() => {
93
+ useIsomorphicLayoutEffect(() => {
99
94
  liveRef.current = { content, sandbox, createBridge, onError };
100
95
  }, [content, sandbox, createBridge, onError]);
101
96
 
@@ -107,6 +102,7 @@ export function SandboxHost({
107
102
  let frame: RenderedFrame | null = null;
108
103
  let bridge: SandboxBridge | null = null;
109
104
  let onMessage: ((event: MessageEvent) => void) | null = null;
105
+ const renderController = new AbortController();
110
106
 
111
107
  const { content: liveContent, sandbox: sb } = liveRef.current;
112
108
 
@@ -129,10 +125,12 @@ export function SandboxHost({
129
125
  ...(sb?.salt !== undefined && { salt: sb.salt }),
130
126
  });
131
127
 
132
- const renderOpts =
133
- sb?.unsafeDocumentWrite !== undefined
134
- ? { unsafeDocumentWrite: sb.unsafeDocumentWrite }
135
- : undefined;
128
+ const renderOpts = {
129
+ signal: renderController.signal,
130
+ ...(sb?.unsafeDocumentWrite !== undefined && {
131
+ unsafeDocumentWrite: sb.unsafeDocumentWrite,
132
+ }),
133
+ };
136
134
 
137
135
  scf
138
136
  .renderHtml(liveContent.html, container, renderOpts)
@@ -216,6 +214,7 @@ export function SandboxHost({
216
214
  const bridgeToDispose = bridge;
217
215
  bridge = null;
218
216
  if (bridgeToDispose) runCleanup(() => bridgeToDispose.dispose());
217
+ runCleanup(() => renderController.abort());
219
218
  const frameToDispose = frame;
220
219
  frame = null;
221
220
  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 () => {