@assistant-ui/react 0.15.25 → 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 (144) hide show
  1. package/dist/hooks/useToolCallElapsed.d.ts.map +1 -1
  2. package/dist/hooks/useToolCallElapsed.js +3 -2
  3. package/dist/hooks/useToolCallElapsed.js.map +1 -1
  4. package/dist/index.d.ts +30 -4
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/mcp-apps/McpAppRenderer.d.ts.map +1 -1
  7. package/dist/mcp-apps/McpAppRenderer.js +3 -2
  8. package/dist/mcp-apps/McpAppRenderer.js.map +1 -1
  9. package/dist/mcp-apps/app-frame.d.ts.map +1 -1
  10. package/dist/mcp-apps/app-frame.js +56 -46
  11. package/dist/mcp-apps/app-frame.js.map +1 -1
  12. package/dist/mcp-apps/csp.d.ts +4 -0
  13. package/dist/mcp-apps/csp.d.ts.map +1 -0
  14. package/dist/mcp-apps/csp.js +93 -0
  15. package/dist/mcp-apps/csp.js.map +1 -0
  16. package/dist/mcp-apps/types.d.ts +1 -0
  17. package/dist/mcp-apps/types.d.ts.map +1 -1
  18. package/dist/mcp-apps/types.js.map +1 -1
  19. package/dist/primitives/assistantModal/AssistantModalRoot.d.ts +1 -0
  20. package/dist/primitives/assistantModal/AssistantModalRoot.d.ts.map +1 -1
  21. package/dist/primitives/assistantModal/AssistantModalRoot.js.map +1 -1
  22. package/dist/primitives/attachment.d.ts +3 -1
  23. package/dist/primitives/attachment.d.ts.map +1 -1
  24. package/dist/primitives/attachment.js.map +1 -1
  25. package/dist/primitives/composer/ComposerCancel.d.ts +5 -1
  26. package/dist/primitives/composer/ComposerCancel.d.ts.map +1 -1
  27. package/dist/primitives/composer/ComposerCancel.js +14 -1
  28. package/dist/primitives/composer/ComposerCancel.js.map +1 -1
  29. package/dist/primitives/composer/ComposerInput.d.ts +14 -0
  30. package/dist/primitives/composer/ComposerInput.d.ts.map +1 -1
  31. package/dist/primitives/composer/ComposerInput.js +4 -3
  32. package/dist/primitives/composer/ComposerInput.js.map +1 -1
  33. package/dist/primitives/composer/ComposerRoot.d.ts.map +1 -1
  34. package/dist/primitives/composer/ComposerRoot.js +24 -19
  35. package/dist/primitives/composer/ComposerRoot.js.map +1 -1
  36. package/dist/primitives/composer/composerInputSelector.d.ts +3 -0
  37. package/dist/primitives/composer/composerInputSelector.d.ts.map +1 -0
  38. package/dist/primitives/composer/composerInputSelector.js +7 -0
  39. package/dist/primitives/composer/composerInputSelector.js.map +1 -0
  40. package/dist/primitives/composer/useComposerCancelWithFocus.d.ts +2 -0
  41. package/dist/primitives/composer/useComposerCancelWithFocus.d.ts.map +1 -0
  42. package/dist/primitives/composer/useComposerCancelWithFocus.js +42 -0
  43. package/dist/primitives/composer/useComposerCancelWithFocus.js.map +1 -0
  44. package/dist/primitives/composer.d.ts +35 -14
  45. package/dist/primitives/composer.d.ts.map +1 -1
  46. package/dist/primitives/composer.js.map +1 -1
  47. package/dist/primitives/message/MessagePartsGrouped.d.ts +8 -1
  48. package/dist/primitives/message/MessagePartsGrouped.d.ts.map +1 -1
  49. package/dist/primitives/message/MessagePartsGrouped.js +58 -278
  50. package/dist/primitives/message/MessagePartsGrouped.js.map +1 -1
  51. package/dist/primitives/selectionToolbar/SelectionToolbarRoot.d.ts.map +1 -1
  52. package/dist/primitives/selectionToolbar/SelectionToolbarRoot.js +5 -2
  53. package/dist/primitives/selectionToolbar/SelectionToolbarRoot.js.map +1 -1
  54. package/dist/primitives/thread/ThreadViewport.d.ts.map +1 -1
  55. package/dist/primitives/thread/ThreadViewport.js +4 -3
  56. package/dist/primitives/thread/ThreadViewport.js.map +1 -1
  57. package/dist/primitives/thread/topAnchor/useTopAnchorReserve.js +2 -2
  58. package/dist/primitives/thread/topAnchor/useTopAnchorReserve.js.map +1 -1
  59. package/dist/primitives/thread/useThreadViewportAutoScroll.d.ts.map +1 -1
  60. package/dist/primitives/thread/useThreadViewportAutoScroll.js +90 -21
  61. package/dist/primitives/thread/useThreadViewportAutoScroll.js.map +1 -1
  62. package/dist/sandbox-host/SandboxHost.d.ts.map +1 -1
  63. package/dist/sandbox-host/SandboxHost.js +3 -2
  64. package/dist/sandbox-host/SandboxHost.js.map +1 -1
  65. package/dist/unstable/useComposerInput.d.ts +7 -4
  66. package/dist/unstable/useComposerInput.d.ts.map +1 -1
  67. package/dist/unstable/useComposerInput.js +4 -4
  68. package/dist/unstable/useComposerInput.js.map +1 -1
  69. package/dist/unstable/useComposerInputHistory.d.ts +3 -2
  70. package/dist/unstable/useComposerInputHistory.d.ts.map +1 -1
  71. package/dist/unstable/useComposerInputHistory.js +2 -2
  72. package/dist/unstable/useComposerInputHistory.js.map +1 -1
  73. package/dist/unstable/useLiveCompletionAdapter.d.ts +3 -2
  74. package/dist/unstable/useLiveCompletionAdapter.d.ts.map +1 -1
  75. package/dist/unstable/useLiveCompletionAdapter.js +8 -7
  76. package/dist/unstable/useLiveCompletionAdapter.js.map +1 -1
  77. package/dist/unstable/useMentionAdapter.d.ts +12 -3
  78. package/dist/unstable/useMentionAdapter.d.ts.map +1 -1
  79. package/dist/unstable/useMentionAdapter.js +2 -2
  80. package/dist/unstable/useMentionAdapter.js.map +1 -1
  81. package/dist/unstable/useMessageStallDetection.d.ts +4 -2
  82. package/dist/unstable/useMessageStallDetection.d.ts.map +1 -1
  83. package/dist/unstable/useMessageStallDetection.js +2 -2
  84. package/dist/unstable/useMessageStallDetection.js.map +1 -1
  85. package/dist/unstable/useSlashCommandAdapter.d.ts +5 -2
  86. package/dist/unstable/useSlashCommandAdapter.d.ts.map +1 -1
  87. package/dist/unstable/useSlashCommandAdapter.js +5 -4
  88. package/dist/unstable/useSlashCommandAdapter.js.map +1 -1
  89. package/dist/unstable/webmcp/useWebMcpProvider.d.ts +4 -0
  90. package/dist/unstable/webmcp/useWebMcpProvider.d.ts.map +1 -1
  91. package/dist/unstable/webmcp/useWebMcpProvider.js +2 -0
  92. package/dist/unstable/webmcp/useWebMcpProvider.js.map +1 -1
  93. package/dist/utils/getSelectionMessageId.d.ts.map +1 -1
  94. package/dist/utils/getSelectionMessageId.js +31 -8
  95. package/dist/utils/getSelectionMessageId.js.map +1 -1
  96. package/dist/utils/hooks/useOnResizeContent.d.ts.map +1 -1
  97. package/dist/utils/hooks/useOnResizeContent.js +2 -22
  98. package/dist/utils/hooks/useOnResizeContent.js.map +1 -1
  99. package/dist/utils/useIsomorphicLayoutEffect.d.ts +3 -0
  100. package/dist/utils/useIsomorphicLayoutEffect.d.ts.map +1 -0
  101. package/dist/utils/useIsomorphicLayoutEffect.js +6 -0
  102. package/dist/utils/useIsomorphicLayoutEffect.js.map +1 -0
  103. package/package.json +10 -10
  104. package/src/hooks/useToolCallElapsed.test.tsx +4 -2
  105. package/src/hooks/useToolCallElapsed.ts +3 -2
  106. package/src/index.ts +19 -2
  107. package/src/mcp-apps/McpAppRenderer.tsx +3 -9
  108. package/src/mcp-apps/app-frame.test.tsx +38 -0
  109. package/src/mcp-apps/app-frame.tsx +5 -8
  110. package/src/mcp-apps/csp.test.ts +112 -0
  111. package/src/mcp-apps/csp.ts +124 -0
  112. package/src/mcp-apps/types.ts +1 -0
  113. package/src/model-context/frame/AssistantFrame.test.ts +45 -0
  114. package/src/model-context/frame/SPEC_AssistantFrame.md +1 -0
  115. package/src/primitives/assistantModal/AssistantModalRoot.tsx +1 -0
  116. package/src/primitives/attachment.ts +4 -1
  117. package/src/primitives/composer/ComposerCancel.focus.test.tsx +307 -0
  118. package/src/primitives/composer/ComposerCancel.ts +8 -1
  119. package/src/primitives/composer/ComposerInput.tsx +19 -1
  120. package/src/primitives/composer/ComposerRoot.tsx +6 -5
  121. package/src/primitives/composer/composerInputSelector.ts +4 -0
  122. package/src/primitives/composer/useComposerCancelWithFocus.ts +57 -0
  123. package/src/primitives/composer.ts +56 -21
  124. package/src/primitives/message/MessagePartsGrouped.test.tsx +106 -2
  125. package/src/primitives/message/MessagePartsGrouped.tsx +36 -134
  126. package/src/primitives/selectionToolbar/SelectionToolbarRoot.test.tsx +20 -12
  127. package/src/primitives/selectionToolbar/SelectionToolbarRoot.tsx +6 -2
  128. package/src/primitives/thread/ThreadViewport.tsx +3 -3
  129. package/src/primitives/thread/topAnchor/useTopAnchorReserve.ts +2 -2
  130. package/src/primitives/thread/useThreadViewportAutoScroll.test.tsx +666 -63
  131. package/src/primitives/thread/useThreadViewportAutoScroll.ts +201 -73
  132. package/src/sandbox-host/SandboxHost.tsx +3 -8
  133. package/src/unstable/useComposerInput.ts +7 -4
  134. package/src/unstable/useComposerInputHistory.ts +3 -2
  135. package/src/unstable/useLiveCompletionAdapter.ts +9 -14
  136. package/src/unstable/useMentionAdapter.ts +12 -3
  137. package/src/unstable/useMessageStallDetection.ts +4 -2
  138. package/src/unstable/useSlashCommandAdapter.ts +8 -4
  139. package/src/unstable/webmcp/useWebMcpProvider.ts +4 -0
  140. package/src/utils/getSelectionMessageId.test.ts +231 -0
  141. package/src/utils/getSelectionMessageId.ts +45 -7
  142. package/src/utils/hooks/useOnResizeContent.ts +2 -29
  143. package/src/utils/useIsomorphicLayoutEffect.test.tsx +21 -0
  144. package/src/utils/useIsomorphicLayoutEffect.ts +9 -0
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { useComposedRefs } from "radix-ui/internal";
4
- import { useCallback, useLayoutEffect, useRef, type RefCallback } from "react";
4
+ import { useCallback, useRef, type RefCallback } from "react";
5
5
  import { useAui, useAuiEvent, useAuiState } from "@assistant-ui/store";
6
6
  import {
7
7
  isUserScrollUp,
@@ -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
@@ -35,6 +44,22 @@ const TEXT_ENTRY_SELECTOR = [
35
44
 
36
45
  const MESSAGE_SELECTOR = "[data-message-id]";
37
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
+
38
63
  type MessageOffset = { readonly id: string; readonly offset: number };
39
64
 
40
65
  const offsetInViewport = (element: Element, viewport: Element) =>
@@ -166,9 +191,15 @@ export const useThreadViewportAutoScroll = <TElement extends HTMLElement>({
166
191
  // user actively scrolls up while content size is stable.
167
192
  const scrollingToBottomBehaviorRef = useRef<ScrollBehavior | null>(null);
168
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);
169
200
  const previousAutoScrollRef = useRef(autoScroll);
170
201
 
171
- useLayoutEffect(() => {
202
+ useIsomorphicLayoutEffect(() => {
172
203
  const previousAutoScroll = previousAutoScrollRef.current;
173
204
  previousAutoScrollRef.current = autoScroll;
174
205
  if (previousAutoScroll || !autoScroll) return;
@@ -182,9 +213,12 @@ export const useThreadViewportAutoScroll = <TElement extends HTMLElement>({
182
213
  if (!div) return;
183
214
 
184
215
  followBottomRef.current = true;
216
+ followPausedRef.current = false;
185
217
  scrollingToBottomBehaviorRef.current = behavior;
186
218
  prependAnchorRef.current = null;
187
219
  div.scrollTo({ top: div.scrollHeight, behavior });
220
+ lastScrollTop.current = div.scrollTop;
221
+ lastScrollHeight.current = div.scrollHeight;
188
222
  }, []);
189
223
 
190
224
  const cancelScheduledFrame = useCallback(() => {
@@ -205,7 +239,10 @@ export const useThreadViewportAutoScroll = <TElement extends HTMLElement>({
205
239
  [cancelScheduledFrame, scrollToBottom],
206
240
  );
207
241
 
208
- useLayoutEffect(() => () => cancelScheduledFrame(), [cancelScheduledFrame]);
242
+ useIsomorphicLayoutEffect(
243
+ () => () => cancelScheduledFrame(),
244
+ [cancelScheduledFrame],
245
+ );
209
246
 
210
247
  const hasActiveTopAnchor = useCallback(() => {
211
248
  const state = threadViewportStore.getState();
@@ -216,7 +253,7 @@ export const useThreadViewportAutoScroll = <TElement extends HTMLElement>({
216
253
  );
217
254
  }, [threadViewportStore]);
218
255
 
219
- const handleScroll = () => {
256
+ const onScroll = () => {
220
257
  const div = divRef.current;
221
258
  if (!div) return;
222
259
 
@@ -231,7 +268,9 @@ export const useThreadViewportAutoScroll = <TElement extends HTMLElement>({
231
268
  const newIsAtBottom = isViewportAtBottom(div);
232
269
 
233
270
  const isInFlightDownwardScroll =
234
- !newIsAtBottom && lastScrollTop.current < div.scrollTop;
271
+ scrollingToBottomBehaviorRef.current !== null &&
272
+ !newIsAtBottom &&
273
+ lastScrollTop.current < div.scrollTop;
235
274
  if (isInFlightDownwardScroll) {
236
275
  // no-op: a smooth scroll-to-bottom fires many midpoint scroll events
237
276
  // before landing, don't flicker isAtBottom or clear intent mid-animation
@@ -249,6 +288,7 @@ export const useThreadViewportAutoScroll = <TElement extends HTMLElement>({
249
288
  // keep intent alive until content can actually scroll
250
289
  if (viewportOverflows(div)) {
251
290
  scrollingToBottomBehaviorRef.current = null;
291
+ if (scrolledSincePauseRef.current) followPausedRef.current = false;
252
292
  }
253
293
  if (autoScroll) followBottomRef.current = true;
254
294
  } else if (userScrolledUp) {
@@ -271,8 +311,13 @@ export const useThreadViewportAutoScroll = <TElement extends HTMLElement>({
271
311
  lastScrollHeight.current = div.scrollHeight;
272
312
  firstMessageRef.current = measureFirstMessage(div);
273
313
  };
314
+ const onScrollRef = useRef(onScroll);
315
+ useIsomorphicLayoutEffect(() => {
316
+ onScrollRef.current = onScroll;
317
+ });
318
+ const handleScroll = useCallback(() => onScrollRef.current(), []);
274
319
 
275
- const resizeRef = useOnResizeContent(() => {
320
+ const onResize = () => {
276
321
  const div = divRef.current;
277
322
  if (!div) return;
278
323
 
@@ -286,75 +331,156 @@ export const useThreadViewportAutoScroll = <TElement extends HTMLElement>({
286
331
  lastObservedScrollHeight.current = scrollHeight;
287
332
  lastObservedClientHeight.current = clientHeight;
288
333
 
289
- const scrollBehavior = scrollingToBottomBehaviorRef.current;
290
- if (scrollBehavior && hasActiveTopAnchor()) {
291
- // Let the top-anchor reserve own scrolling while a run starts to avoid a bottom-scroll race.
292
- scrollingToBottomBehaviorRef.current = null;
293
- } else if (scrollBehavior) {
294
- scrollToBottom(scrollBehavior);
295
- } else if (
296
- autoScroll &&
297
- !(isRunning && hasActiveTopAnchor()) &&
298
- followBottomRef.current
299
- ) {
300
- scrollToBottom("instant");
301
- } else if (
302
- prependAnchorRef.current &&
303
- !keepMessageAt(div, prependAnchorRef.current, heldScrollTopRef)
304
- ) {
305
- prependAnchorRef.current = null;
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
+ }
306
355
  }
307
356
 
308
357
  handleScroll();
358
+ };
359
+ const onResizeRef = useRef(onResize);
360
+ useIsomorphicLayoutEffect(() => {
361
+ onResizeRef.current = onResize;
309
362
  });
310
-
311
- const scrollRef = useManagedRef<HTMLElement>((el) => {
312
- // A user gesture invalidates pending bottom-scroll intent; otherwise an
313
- // intent kept alive by a non-overflowing thread (see handleScroll) hijacks
314
- // the next content growth, e.g. expanding a collapsible tool call. Keyboard
315
- // activation reaches that same content without ever emitting a pointer
316
- // event, so it has to cancel the intent too.
317
- const cancelPendingScrollToBottom = () => {
318
- // A scheduled frame re-plants the intent when it runs, so clearing the
319
- // ref alone leaves the gesture undone.
320
- cancelScheduledFrame();
321
- scrollingToBottomBehaviorRef.current = null;
322
- };
323
- // The composer renders inside the viewport, so its keystrokes bubble here;
324
- // only an activation key aimed at something other than a text field is a
325
- // gesture on thread content.
326
- const cancelOnKeyDown = (event: KeyboardEvent) => {
327
- if (!ACTIVATION_KEYS.has(event.key)) return;
328
- const target = event.target as Element | null;
329
- if (target?.closest?.(TEXT_ENTRY_SELECTOR)) return;
330
- cancelPendingScrollToBottom();
331
- };
332
- const releasePrependAnchor = () => {
333
- prependAnchorRef.current = null;
334
- };
335
- const gestures = ["pointerdown", "wheel", "touchstart", "keydown"] as const;
336
- el.addEventListener("scroll", handleScroll);
337
- el.addEventListener("pointerdown", cancelPendingScrollToBottom);
338
- el.addEventListener("keydown", cancelOnKeyDown);
339
- for (const gesture of gestures) {
340
- el.addEventListener(gesture, releasePrependAnchor, { passive: true });
341
- }
342
- return () => {
343
- el.removeEventListener("scroll", handleScroll);
344
- el.removeEventListener("pointerdown", cancelPendingScrollToBottom);
345
- el.removeEventListener("keydown", cancelOnKeyDown);
346
- for (const gesture of gestures) {
347
- el.removeEventListener(gesture, releasePrependAnchor);
348
- }
349
- };
350
- });
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
+ );
351
477
 
352
478
  // Earlier messages prepended above the reader put the old first message back
353
479
  // where the reader saw it, so growth below it in the same commit (a
354
480
  // streaming reply) is not mistaken for the page; where the browser already
355
481
  // anchored the scroll, this is a no-op.
356
482
  const previousRef = useRef({ threadId, firstMessageId });
357
- useLayoutEffect(() => {
483
+ useIsomorphicLayoutEffect(() => {
358
484
  const previous = previousRef.current;
359
485
  previousRef.current = { threadId, firstMessageId };
360
486
  if (previous.threadId !== threadId) {
@@ -367,7 +493,7 @@ export const useThreadViewportAutoScroll = <TElement extends HTMLElement>({
367
493
  !div ||
368
494
  previousFirstMessageId === undefined ||
369
495
  previousFirstMessageId === firstMessageId ||
370
- (autoScroll && followBottomRef.current) ||
496
+ (autoScroll && followBottomRef.current && !followPausedRef.current) ||
371
497
  scrollingToBottomBehaviorRef.current !== null
372
498
  )
373
499
  return;
@@ -397,7 +523,7 @@ export const useThreadViewportAutoScroll = <TElement extends HTMLElement>({
397
523
  lastScrollHeight.current = div.scrollHeight;
398
524
  }, [aui, autoScroll, firstMessageId, threadId]);
399
525
 
400
- useLayoutEffect(() => {
526
+ useIsomorphicLayoutEffect(() => {
401
527
  if (!scrollToBottomOnInitialize) return;
402
528
  if (!hasMessages) {
403
529
  initializeScrollRequestedRef.current = false;
@@ -418,12 +544,14 @@ export const useThreadViewportAutoScroll = <TElement extends HTMLElement>({
418
544
 
419
545
  useAuiEvent("thread.runStart", () => {
420
546
  prependAnchorRef.current = null;
547
+ followPausedRef.current = false;
421
548
  if (!scrollToBottomOnRunStart) return;
422
549
  if (threadViewportStore.getState().turnAnchor === "top") return;
423
550
  scheduleScrollToBottom("auto");
424
551
  });
425
552
 
426
553
  useAuiEvent("threads.selectionChanged", () => {
554
+ followPausedRef.current = false;
427
555
  if (!scrollToBottomOnThreadSwitch) return;
428
556
  scheduleScrollToBottom("instant");
429
557
  });
@@ -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
 
@@ -12,6 +12,7 @@ import {
12
12
  useTriggerPopoverAriaProps,
13
13
  } from "../primitives/composer/useComposerInputState";
14
14
 
15
+ /** @deprecated Experimental since 2026-06-23. Not scheduled for removal; the API may change in any release. */
15
16
  export type Unstable_UseComposerInputOptions = {
16
17
  /**
17
18
  * Disables the input in addition to the composer's own disabled sources
@@ -21,6 +22,7 @@ export type Unstable_UseComposerInputOptions = {
21
22
  disabled?: boolean | undefined;
22
23
  };
23
24
 
25
+ /** @deprecated Experimental since 2026-06-23. Not scheduled for removal; the API may change in any release. */
24
26
  export type Unstable_ComposerInput = {
25
27
  /** Current composer text, or `""` when the composer is not editing. */
26
28
  value: string;
@@ -49,8 +51,6 @@ export type Unstable_ComposerInput = {
49
51
  };
50
52
 
51
53
  /**
52
- * @deprecated Under active development and might change without notice.
53
- *
54
54
  * Headless bridge to the composer's text value and send action, for building a
55
55
  * custom composer input without `ComposerPrimitive.Input`. It is a thin bridge,
56
56
  * not a second input: it does not own keyboard behavior, autosize, IME or
@@ -58,6 +58,8 @@ export type Unstable_ComposerInput = {
58
58
  * state. Spread `unstable_useTriggerPopoverAriaProps()` onto your element for
59
59
  * trigger-popover combobox semantics.
60
60
  *
61
+ * @deprecated Experimental since 2026-06-23. Not scheduled for removal; the API may change in any release.
62
+ *
61
63
  * @example
62
64
  * ```tsx
63
65
  * const { value, setText, send, isDisabled, canSend } = unstable_useComposerInput();
@@ -104,17 +106,18 @@ export function unstable_useComposerInput(
104
106
  return { value, setText, send, isDisabled, canSend };
105
107
  }
106
108
 
109
+ /** @deprecated Experimental since 2026-06-23. Not scheduled for removal; the API may change in any release. */
107
110
  export type Unstable_TriggerPopoverAriaProps = TriggerPopoverAriaProps;
108
111
 
109
112
  /**
110
- * @deprecated Under active development and might change without notice.
111
- *
112
113
  * ARIA combobox attributes for the focused element (typically the composer
113
114
  * input) describing the open trigger popover, per the WAI-ARIA editable
114
115
  * combobox pattern. Returns an empty object outside a `TriggerPopoverRoot` or
115
116
  * when no popover is open. Spread these last so they take precedence over any
116
117
  * matching ARIA props you set yourself, mirroring `ComposerPrimitive.Input`.
117
118
  *
119
+ * @deprecated Experimental since 2026-06-23. Not scheduled for removal; the API may change in any release.
120
+ *
118
121
  * @example
119
122
  * ```tsx
120
123
  * const aria = unstable_useTriggerPopoverAriaProps();
@@ -14,6 +14,7 @@ import type { ThreadMessage } from "@assistant-ui/core";
14
14
  import { getThreadMessageText } from "@assistant-ui/core/internal";
15
15
  import { useTriggerPopoverRootContextOptional } from "../primitives/composer/trigger/TriggerPopoverRootContext";
16
16
 
17
+ /** @deprecated Experimental since 2026-06-11. Not scheduled for removal; the API may change in any release. */
17
18
  export type Unstable_ComposerInputHistory = {
18
19
  /** Keydown handler to spread onto `ComposerPrimitive.Input`. */
19
20
  onKeyDown: KeyboardEventHandler<HTMLTextAreaElement>;
@@ -45,8 +46,6 @@ const isOnLastLine = (value: string, caret: number): boolean =>
45
46
  !value.slice(caret).includes("\n");
46
47
 
47
48
  /**
48
- * @deprecated Under active development and might change without notice.
49
- *
50
49
  * Terminal-style input history for the thread composer: ArrowUp on an
51
50
  * empty draft recalls previously sent user messages (newest first),
52
51
  * ArrowDown steps back toward the newest and finally restores the draft
@@ -58,6 +57,8 @@ const isOnLastLine = (value: string, caret: number): boolean =>
58
57
  * to modifier keys, and to consumer handlers that already called
59
58
  * `preventDefault`. It is inert on edit composers.
60
59
  *
60
+ * @deprecated Experimental since 2026-06-11. Not scheduled for removal; the API may change in any release.
61
+ *
61
62
  * @example
62
63
  * ```tsx
63
64
  * const history = unstable_useComposerInputHistory();
@@ -1,18 +1,13 @@
1
1
  "use client";
2
2
 
3
- import {
4
- useCallback,
5
- useEffect,
6
- useLayoutEffect,
7
- useMemo,
8
- useRef,
9
- useState,
10
- } from "react";
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
+ import { useIsomorphicLayoutEffect } from "../utils/useIsomorphicLayoutEffect";
11
5
  import type {
12
6
  Unstable_TriggerAdapter,
13
7
  Unstable_TriggerItem,
14
8
  } from "@assistant-ui/core";
15
9
 
10
+ /** @deprecated Experimental since 2026-06-15. Not scheduled for removal; the API may change in any release. */
16
11
  export type Unstable_UseLiveCompletionAdapterOptions = {
17
12
  /**
18
13
  * Fetches the items for a query from an async source. Called debounced; the
@@ -35,8 +30,6 @@ export type Unstable_UseLiveCompletionAdapterOptions = {
35
30
  const NO_QUERY = "\u0000";
36
31
 
37
32
  /**
38
- * @deprecated Under active development and may change without notice.
39
- *
40
33
  * Bridges an async completion source (a server search, a gateway RPC) into the
41
34
  * synchronous `Unstable_TriggerAdapter` that `ComposerTriggerPopover` consumes.
42
35
  * `search(query)` returns the last fetched items synchronously and schedules a
@@ -47,6 +40,8 @@ const NO_QUERY = "\u0000";
47
40
  * `isLoading` is `true` while a fetch is in flight. Pass it to the popover's
48
41
  * `isLoading` prop to render a loading state.
49
42
  *
43
+ * @deprecated Experimental since 2026-06-15. Not scheduled for removal; the API may change in any release.
44
+ *
50
45
  * @example
51
46
  * ```tsx
52
47
  * const mentions = unstable_useLiveCompletionAdapter({
@@ -75,7 +70,7 @@ export function unstable_useLiveCompletionAdapter(
75
70
 
76
71
  const fetcherRef = useRef(fetcher);
77
72
  // The debounce timer must only observe fetchers from committed renders.
78
- useLayoutEffect(() => {
73
+ useIsomorphicLayoutEffect(() => {
79
74
  fetcherRef.current = fetcher;
80
75
  }, [fetcher]);
81
76
 
@@ -143,7 +138,7 @@ export function unstable_useLiveCompletionAdapter(
143
138
  );
144
139
 
145
140
  const scheduleFetchRef = useRef(scheduleFetch);
146
- useLayoutEffect(() => {
141
+ useIsomorphicLayoutEffect(() => {
147
142
  scheduleFetchRef.current = scheduleFetch;
148
143
  }, [scheduleFetch]);
149
144
 
@@ -156,7 +151,7 @@ export function unstable_useLiveCompletionAdapter(
156
151
  }, [cancelTimer, rearmPendingRetry]);
157
152
 
158
153
  const cacheKeyRef = useRef(cacheKey);
159
- useLayoutEffect(() => {
154
+ useIsomorphicLayoutEffect(() => {
160
155
  if (cacheKeyRef.current === cacheKey) return;
161
156
  cacheKeyRef.current = cacheKey;
162
157
  invalidatePending();
@@ -175,7 +170,7 @@ export function unstable_useLiveCompletionAdapter(
175
170
 
176
171
  // Render-time searches can outlive an abandoned render, so they only arm
177
172
  // request work after this hook commits.
178
- useLayoutEffect(() => {
173
+ useIsomorphicLayoutEffect(() => {
179
174
  inactiveRef.current = false;
180
175
  const deferredQuery = deferredQueryRef.current;
181
176
  deferredQueryRef.current = null;
@@ -17,9 +17,14 @@ import {
17
17
  type ModelContextSnapshotSource,
18
18
  } from "./useModelContextSnapshot";
19
19
 
20
- /** Icon component shape consumed by `ComposerTriggerPopover`'s `iconMap`. */
20
+ /**
21
+ * Icon component shape consumed by `ComposerTriggerPopover`'s `iconMap`.
22
+ *
23
+ * @deprecated Experimental since 2026-04-15. Not scheduled for removal; the API may change in any release.
24
+ */
21
25
  export type Unstable_IconComponent = FC<{ className?: string }>;
22
26
 
27
+ /** @deprecated Experimental since 2026-04-15. Not scheduled for removal; the API may change in any release. */
23
28
  export type Unstable_Mention = {
24
29
  readonly id: string;
25
30
  readonly type: string;
@@ -30,12 +35,14 @@ export type Unstable_Mention = {
30
35
  readonly metadata?: ReadonlyJSONObject | undefined;
31
36
  };
32
37
 
38
+ /** @deprecated Experimental since 2026-03-16. Not scheduled for removal; the API may change in any release. */
33
39
  export type Unstable_MentionCategory = {
34
40
  readonly id: string;
35
41
  readonly label: string;
36
42
  readonly items: readonly Unstable_Mention[];
37
43
  };
38
44
 
45
+ /** @deprecated Experimental since 2026-04-15. Not scheduled for removal; the API may change in any release. */
39
46
  export type Unstable_ModelContextToolsOptions = {
40
47
  /**
41
48
  * Wrap tools in a dedicated category. Selects drill-down mode on its own
@@ -48,6 +55,7 @@ export type Unstable_ModelContextToolsOptions = {
48
55
  readonly icon?: string;
49
56
  };
50
57
 
58
+ /** @deprecated Experimental since 2026-04-15. Not scheduled for removal; the API may change in any release. */
51
59
  export type Unstable_UseMentionAdapterOptions = {
52
60
  /**
53
61
  * Flat mention list. Ignored when `categories` is set, and keeps the
@@ -78,6 +86,7 @@ export type Unstable_UseMentionAdapterOptions = {
78
86
  readonly fallbackIcon?: Unstable_IconComponent;
79
87
  };
80
88
 
89
+ /** @deprecated Experimental since 2026-04-15. Not scheduled for removal; the API may change in any release. */
81
90
  export type Unstable_MentionDirective = {
82
91
  readonly formatter: Unstable_DirectiveFormatter;
83
92
  readonly onInserted?: ((item: Unstable_TriggerItem) => void) | undefined;
@@ -120,12 +129,12 @@ const toolMentionSource: ModelContextSnapshotSource<
120
129
  };
121
130
 
122
131
  /**
123
- * @deprecated Under active development and might change without notice.
124
- *
125
132
  * Creates a spreadable `{ adapter, directive }` bundle for `@` mentions.
126
133
  * Supports tools registered in model context, explicit items, or both —
127
134
  * flat or categorized.
128
135
  *
136
+ * @deprecated Experimental since 2026-04-15. Not scheduled for removal; the API may change in any release.
137
+ *
129
138
  * @example
130
139
  * ```tsx
131
140
  * const mention = unstable_useMentionAdapter();
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react";
4
4
  import { useAuiState } from "@assistant-ui/store";
5
5
  import { useShallowSelector } from "@assistant-ui/store/internal";
6
6
 
7
+ /** @deprecated Experimental since 2026-06-13. Not scheduled for removal; the API may change in any release. */
7
8
  export type Unstable_MessageStallDetectionOptions = {
8
9
  /**
9
10
  * Milliseconds of unchanged message content before the message counts as
@@ -13,6 +14,7 @@ export type Unstable_MessageStallDetectionOptions = {
13
14
  thresholdMs?: number | undefined;
14
15
  };
15
16
 
17
+ /** @deprecated Experimental since 2026-06-13. Not scheduled for removal; the API may change in any release. */
16
18
  export type Unstable_MessageStallDetection = {
17
19
  /** True while the message is running and its content has not changed for at least `thresholdMs`. */
18
20
  stalled: boolean;
@@ -21,8 +23,6 @@ export type Unstable_MessageStallDetection = {
21
23
  };
22
24
 
23
25
  /**
24
- * @deprecated Under active development and might change without notice.
25
- *
26
26
  * Detects mid-run output stalls on the current message: while the message is
27
27
  * running, watches its text, reasoning, and tool-argument values plus tool-result
28
28
  * availability and reports a stall once they stop changing for `thresholdMs`.
@@ -30,6 +30,8 @@ export type Unstable_MessageStallDetection = {
30
30
  * provider stalls, after the first tokens have already streamed.
31
31
  *
32
32
  * Must be used inside a message scope.
33
+ *
34
+ * @deprecated Experimental since 2026-06-13. Not scheduled for removal; the API may change in any release.
33
35
  */
34
36
  export function unstable_useMessageStallDetection(
35
37
  options?: Unstable_MessageStallDetectionOptions,