@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
@@ -2,14 +2,26 @@
2
2
 
3
3
  import { act, render } from "@testing-library/react";
4
4
  import { createContext, useContext } from "react";
5
+ import type { ReactNode } from "react";
6
+ import { hydrateRoot } from "react-dom/client";
7
+ import { renderToString } from "react-dom/server";
5
8
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
- import type { ThreadMessageLike } from "@assistant-ui/core";
9
+ import type {
10
+ ChatModelAdapter,
11
+ ThreadMessageLike,
12
+ ThreadMessage,
13
+ } from "@assistant-ui/core";
7
14
  import {
8
15
  AssistantRuntimeProvider,
16
+ ThreadListItemRuntimeProvider,
9
17
  useExternalStoreRuntime,
18
+ useLocalRuntime,
10
19
  } from "@assistant-ui/core/react";
11
20
  import { useAuiState } from "@assistant-ui/store";
12
- import { ThreadPrimitiveMessageByIndex } from "../thread/ThreadMessages";
21
+ import {
22
+ ThreadPrimitiveMessageByIndex,
23
+ ThreadPrimitiveMessages,
24
+ } from "../thread/ThreadMessages";
13
25
  import { ThreadPrimitiveRoot } from "../thread/ThreadRoot";
14
26
  import { ThreadPrimitiveViewport } from "../thread/ThreadViewport";
15
27
  import { MessagePrimitiveRoot } from "./MessageRoot";
@@ -22,6 +34,14 @@ const messages: ThreadMessageLike[] = [
22
34
  },
23
35
  ];
24
36
 
37
+ const initialMessages: readonly ThreadMessageLike[] = [
38
+ { role: "user", content: "Hydrated message" },
39
+ ];
40
+
41
+ const chatModel: ChatModelAdapter = {
42
+ run: async () => ({ content: [] }),
43
+ };
44
+
25
45
  class TestResizeObserver {
26
46
  observe() {}
27
47
  unobserve() {}
@@ -62,6 +82,64 @@ const Example = ({ visible = true }: { visible?: boolean }) => {
62
82
  );
63
83
  };
64
84
 
85
+ const HydrationMessage = () => (
86
+ <MessagePrimitiveRoot data-testid="hydration-message" />
87
+ );
88
+
89
+ const LocalThread = ({
90
+ onRuntime,
91
+ turnAnchor,
92
+ }: {
93
+ onRuntime: (runtime: ReturnType<typeof useLocalRuntime>) => void;
94
+ turnAnchor: "bottom" | "top";
95
+ }) => {
96
+ const runtime = useLocalRuntime(chatModel, { initialMessages });
97
+ onRuntime(runtime);
98
+
99
+ return (
100
+ <AssistantRuntimeProvider runtime={runtime}>
101
+ <ThreadPrimitiveRoot>
102
+ <ThreadPrimitiveViewport
103
+ scrollToBottomOnInitialize={false}
104
+ turnAnchor={turnAnchor}
105
+ >
106
+ <ThreadPrimitiveMessages components={{ Message: HydrationMessage }} />
107
+ </ThreadPrimitiveViewport>
108
+ </ThreadPrimitiveRoot>
109
+ </AssistantRuntimeProvider>
110
+ );
111
+ };
112
+
113
+ const NestedLocalRuntime = ({
114
+ onRuntime,
115
+ turnAnchor,
116
+ }: {
117
+ onRuntime: (runtime: ReturnType<typeof useLocalRuntime>) => void;
118
+ turnAnchor: "bottom" | "top";
119
+ }) => {
120
+ const host = useExternalStoreRuntime<ThreadMessage>({
121
+ messages: [],
122
+ onNew: async () => {},
123
+ });
124
+
125
+ return (
126
+ <AssistantRuntimeProvider runtime={host}>
127
+ <ThreadListItemRuntimeProvider runtime={host.threads.mainItem}>
128
+ <LocalThread onRuntime={onRuntime} turnAnchor={turnAnchor} />
129
+ </ThreadListItemRuntimeProvider>
130
+ </AssistantRuntimeProvider>
131
+ );
132
+ };
133
+
134
+ const renderOnServer = (node: ReactNode) => {
135
+ vi.stubGlobal("document", undefined);
136
+ try {
137
+ return renderToString(node);
138
+ } finally {
139
+ vi.unstubAllGlobals();
140
+ }
141
+ };
142
+
65
143
  beforeEach(() => {
66
144
  vi.stubGlobal("ResizeObserver", TestResizeObserver);
67
145
  });
@@ -82,6 +160,51 @@ const mockHoveredElement = () => {
82
160
  };
83
161
 
84
162
  describe("MessagePrimitiveRoot", () => {
163
+ it.each(["bottom", "top"] as const)(
164
+ "hydrates nested local message ids with %s anchoring",
165
+ async (turnAnchor) => {
166
+ let serverRuntime: ReturnType<typeof useLocalRuntime> | undefined;
167
+ const html = renderOnServer(
168
+ <NestedLocalRuntime
169
+ onRuntime={(runtime) => (serverRuntime = runtime)}
170
+ turnAnchor={turnAnchor}
171
+ />,
172
+ );
173
+ const serverMessageId = serverRuntime!.thread.getState().messages[0]!.id;
174
+ const container = document.createElement("div");
175
+ container.innerHTML = html;
176
+ vi.stubGlobal("ResizeObserver", TestResizeObserver);
177
+
178
+ let clientRuntime: ReturnType<typeof useLocalRuntime> | undefined;
179
+ const errors = vi.spyOn(console, "error").mockImplementation(() => {});
180
+ let root: ReturnType<typeof hydrateRoot> | undefined;
181
+ await act(async () => {
182
+ root = hydrateRoot(
183
+ container,
184
+ <NestedLocalRuntime
185
+ onRuntime={(runtime) => (clientRuntime = runtime)}
186
+ turnAnchor={turnAnchor}
187
+ />,
188
+ );
189
+ });
190
+
191
+ const clientMessageId = clientRuntime!.thread.getState().messages[0]!.id;
192
+ const domMessageId = container
193
+ .querySelector("[data-testid='hydration-message']")!
194
+ .getAttribute("data-message-id");
195
+
196
+ expect(clientMessageId).toBe(serverMessageId);
197
+ expect(domMessageId).toBe(clientMessageId);
198
+ expect(
199
+ errors.mock.calls.some((args) =>
200
+ args.some((value) => String(value).includes("data-message-id")),
201
+ ),
202
+ ).toBe(false);
203
+
204
+ await act(async () => root?.unmount());
205
+ },
206
+ );
207
+
85
208
  it("synchronizes hover state while mounted", async () => {
86
209
  mockHoveredElement();
87
210
 
@@ -1,6 +1,6 @@
1
1
  /** @vitest-environment jsdom */
2
2
  import type { MouseEvent, ReactNode } from "react";
3
- import { fireEvent, render } from "@testing-library/react";
3
+ import { act, fireEvent, render } from "@testing-library/react";
4
4
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
5
  import type * as StoreModule from "@assistant-ui/store";
6
6
  import { ThreadPrimitiveRoot } from "../thread/ThreadRoot";
@@ -96,14 +96,6 @@ describe("SelectionToolbarPrimitiveRoot onMouseDown composition", () => {
96
96
  });
97
97
 
98
98
  describe("SelectionToolbarPrimitiveRoot selection changes", () => {
99
- it("opens from a selectionchange event without a mouse or key event", () => {
100
- render(<SelectionToolbarPrimitiveRoot data-testid="toolbar" />);
101
-
102
- fireEvent(document, new Event("selectionchange"));
103
-
104
- expect(document.querySelector('[data-testid="toolbar"]')).not.toBeNull();
105
- });
106
-
107
99
  it("closes an open toolbar when the selection collapses", () => {
108
100
  render(<SelectionToolbarPrimitiveRoot data-testid="toolbar" />);
109
101
  fireEvent(document, new Event("selectionchange"));
@@ -282,17 +274,33 @@ describe("SelectionToolbarPrimitiveRoot frame cleanup", () => {
282
274
  expect(frames).toHaveLength(2);
283
275
  });
284
276
 
285
- it("recovers when the window blurs during a drag", () => {
277
+ it("shows the toolbar when the window blurs during a drag", () => {
286
278
  const { frames } = deferFrames();
287
- render(<SelectionToolbarPrimitiveRoot />);
279
+ render(<SelectionToolbarPrimitiveRoot data-testid="toolbar" />);
288
280
 
289
281
  fireEvent.mouseDown(document);
290
282
  fireEvent(document, new Event("selectionchange"));
291
283
  expect(frames).toHaveLength(0);
292
284
 
293
285
  fireEvent.blur(window);
294
- fireEvent(document, new Event("selectionchange"));
295
286
  expect(frames).toHaveLength(1);
287
+
288
+ act(() => frames[0]?.(0));
289
+
290
+ expect(document.querySelector('[data-testid="toolbar"]')).not.toBeNull();
291
+ });
292
+
293
+ it("does not reopen a toolbar hidden by scroll on an unrelated window blur", () => {
294
+ render(<SelectionToolbarPrimitiveRoot data-testid="toolbar" />);
295
+
296
+ fireEvent(document, new Event("selectionchange"));
297
+ expect(document.querySelector('[data-testid="toolbar"]')).not.toBeNull();
298
+
299
+ fireEvent.scroll(document);
300
+ expect(document.querySelector('[data-testid="toolbar"]')).toBeNull();
301
+
302
+ fireEvent.blur(window);
303
+ expect(document.querySelector('[data-testid="toolbar"]')).toBeNull();
296
304
  });
297
305
 
298
306
  it("recovers when a context menu consumes the mouseup of a press", () => {
@@ -134,12 +134,16 @@ export const SelectionToolbarPrimitiveRoot = forwardRef<
134
134
  isMouseDragging = false;
135
135
  };
136
136
 
137
+ const handleWindowBlur = () => {
138
+ if (isMouseDragging) handleMouseUp();
139
+ };
140
+
137
141
  document.addEventListener("mousedown", handleMouseDown, true);
138
142
  document.addEventListener("mouseup", handleMouseUp, true);
139
143
  document.addEventListener("dragend", handleMouseUp, true);
140
144
  // A context menu can consume the mouseup of the press that opened it.
141
145
  document.addEventListener("contextmenu", handleMouseCancel, true);
142
- window.addEventListener("blur", handleMouseCancel);
146
+ window.addEventListener("blur", handleWindowBlur);
143
147
  document.addEventListener("selectionchange", handleSelectionChange);
144
148
  document.addEventListener("scroll", handleScroll, true);
145
149
 
@@ -149,7 +153,7 @@ export const SelectionToolbarPrimitiveRoot = forwardRef<
149
153
  document.removeEventListener("mouseup", handleMouseUp, true);
150
154
  document.removeEventListener("dragend", handleMouseUp, true);
151
155
  document.removeEventListener("contextmenu", handleMouseCancel, true);
152
- window.removeEventListener("blur", handleMouseCancel);
156
+ window.removeEventListener("blur", handleWindowBlur);
153
157
  document.removeEventListener("selectionchange", handleSelectionChange);
154
158
  document.removeEventListener("scroll", handleScroll, true);
155
159
  };
@@ -0,0 +1,35 @@
1
+ "use client";
2
+
3
+ import { useCallback } from "react";
4
+ import { useAui, useAuiState } from "@assistant-ui/store";
5
+ import {
6
+ type ActionButtonElement,
7
+ type ActionButtonProps,
8
+ createActionButton,
9
+ } from "../../utils/createActionButton";
10
+
11
+ const useThreadLoadEarlier = () => {
12
+ const aui = useAui();
13
+ const hasEarlier = useAuiState((s) => s.thread.hasEarlier);
14
+ const loadEarlier = useCallback(() => {
15
+ void aui.thread.loadEarlier();
16
+ }, [aui]);
17
+ if (!hasEarlier) return null;
18
+ return loadEarlier;
19
+ };
20
+
21
+ export namespace ThreadPrimitiveLoadEarlier {
22
+ export type Element = ActionButtonElement;
23
+ export type Props = ActionButtonProps<typeof useThreadLoadEarlier>;
24
+ }
25
+
26
+ /**
27
+ * A button that loads the page before the first loaded message. Disabled
28
+ * while the runtime reports no earlier messages. A press while a page is
29
+ * loading joins that load, so the button stays enabled and keeps keyboard
30
+ * focus until the page lands; read `thread.isLoadingEarlier` to show progress.
31
+ */
32
+ export const ThreadPrimitiveLoadEarlier = createActionButton(
33
+ "ThreadPrimitive.LoadEarlier",
34
+ useThreadLoadEarlier,
35
+ );
@@ -5,4 +5,5 @@ export {
5
5
  ThreadPrimitiveMessagesImpl,
6
6
  ThreadPrimitiveMessageByIndex,
7
7
  ThreadPrimitiveUnstable_MessageById,
8
+ ThreadPrimitiveRow,
8
9
  } from "@assistant-ui/core/react";
@@ -7,7 +7,6 @@ import {
7
7
  forwardRef,
8
8
  type ComponentPropsWithoutRef,
9
9
  useCallback,
10
- useLayoutEffect,
11
10
  useMemo,
12
11
  } from "react";
13
12
  import { useAuiState } from "@assistant-ui/store";
@@ -20,6 +19,7 @@ import {
20
19
  useThreadViewportStore,
21
20
  } from "../../context/react/ThreadViewportContext";
22
21
  import { useTopAnchorReserve } from "./topAnchor/useTopAnchorReserve";
22
+ import { useIsomorphicLayoutEffect } from "../../utils/useIsomorphicLayoutEffect";
23
23
  import {
24
24
  getActiveTopAnchorAnchorId,
25
25
  getActiveTopAnchorTargetId,
@@ -124,13 +124,13 @@ const useTopAnchorTurn = (enabled: boolean) => {
124
124
  isTopAnchorTurnValid(topAnchorTurn, s.thread.messages),
125
125
  );
126
126
 
127
- useLayoutEffect(() => {
127
+ useIsomorphicLayoutEffect(() => {
128
128
  if (!topAnchorTurn || topAnchorTurnIsValid) return;
129
129
 
130
130
  threadViewportStore.getState().setTopAnchorTurn(null);
131
131
  }, [threadViewportStore, topAnchorTurn, topAnchorTurnIsValid]);
132
132
 
133
- useLayoutEffect(() => {
133
+ useIsomorphicLayoutEffect(() => {
134
134
  if (!activeTurn) return;
135
135
 
136
136
  const state = threadViewportStore.getState();
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { useLayoutEffect } from "react";
3
+ import { useIsomorphicLayoutEffect } from "../../../utils/useIsomorphicLayoutEffect";
4
4
  import { useThreadViewportStore } from "../../../context/react/ThreadViewportContext";
5
5
  import { mountTopAnchorReserve } from "./mountTopAnchorReserve";
6
6
 
@@ -12,7 +12,7 @@ import { mountTopAnchorReserve } from "./mountTopAnchorReserve";
12
12
  export const useTopAnchorReserve = (enabled: boolean) => {
13
13
  const threadViewportStore = useThreadViewportStore();
14
14
 
15
- useLayoutEffect(() => {
15
+ useIsomorphicLayoutEffect(() => {
16
16
  if (!enabled) return;
17
17
  return mountTopAnchorReserve(threadViewportStore);
18
18
  }, [enabled, threadViewportStore]);