@assistant-ui/react 0.15.17 → 0.15.19

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 (169) hide show
  1. package/README.md +8 -2
  2. package/dist/context/providers/ThreadViewportProvider.js +2 -2
  3. package/dist/context/providers/ThreadViewportProvider.js.map +1 -1
  4. package/dist/hooks/useToolCallElapsed.js.map +1 -1
  5. package/dist/index.d.ts +7 -4
  6. package/dist/index.js +5 -3
  7. package/dist/mcp-apps/McpAppRenderer.d.ts +30 -8
  8. package/dist/mcp-apps/McpAppRenderer.d.ts.map +1 -1
  9. package/dist/mcp-apps/McpAppRenderer.js +91 -47
  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 +57 -33
  13. package/dist/mcp-apps/app-frame.js.map +1 -1
  14. package/dist/mcp-apps/bridge.js +9 -4
  15. package/dist/mcp-apps/bridge.js.map +1 -1
  16. package/dist/mcp-apps/index.d.ts +2 -2
  17. package/dist/primitives/assistantModal/AssistantModalRoot.d.ts.map +1 -1
  18. package/dist/primitives/assistantModal/AssistantModalRoot.js +47 -79
  19. package/dist/primitives/assistantModal/AssistantModalRoot.js.map +1 -1
  20. package/dist/primitives/composer/ComposerAttachmentDropzone.d.ts.map +1 -1
  21. package/dist/primitives/composer/ComposerAttachmentDropzone.js +61 -138
  22. package/dist/primitives/composer/ComposerAttachmentDropzone.js.map +1 -1
  23. package/dist/primitives/composer/ComposerInput.js +3 -8
  24. package/dist/primitives/composer/ComposerInput.js.map +1 -1
  25. package/dist/primitives/composer/ComposerInputPluginContext.d.ts +1 -2
  26. package/dist/primitives/composer/ComposerInputPluginContext.d.ts.map +1 -1
  27. package/dist/primitives/composer/ComposerInputPluginContext.js +1 -6
  28. package/dist/primitives/composer/ComposerInputPluginContext.js.map +1 -1
  29. package/dist/primitives/composer/trigger/TriggerPopoverAction.js +46 -15
  30. package/dist/primitives/composer/trigger/TriggerPopoverAction.js.map +1 -1
  31. package/dist/primitives/composer/trigger/TriggerPopoverDirective.js +40 -10
  32. package/dist/primitives/composer/trigger/TriggerPopoverDirective.js.map +1 -1
  33. package/dist/primitives/composer/trigger/matchesTriggerItemQuery.d.ts +6 -0
  34. package/dist/primitives/composer/trigger/matchesTriggerItemQuery.d.ts.map +1 -0
  35. package/dist/primitives/composer/trigger/matchesTriggerItemQuery.js +9 -0
  36. package/dist/primitives/composer/trigger/matchesTriggerItemQuery.js.map +1 -0
  37. package/dist/primitives/composer/trigger/triggerKeyboardResource.d.ts.map +1 -1
  38. package/dist/primitives/composer/trigger/triggerKeyboardResource.js +53 -61
  39. package/dist/primitives/composer/trigger/triggerKeyboardResource.js.map +1 -1
  40. package/dist/primitives/composer/trigger/triggerNavigationResource.d.ts.map +1 -1
  41. package/dist/primitives/composer/trigger/triggerNavigationResource.js +129 -141
  42. package/dist/primitives/composer/trigger/triggerNavigationResource.js.map +1 -1
  43. package/dist/primitives/message/MessageParts.js +2 -14
  44. package/dist/primitives/message/MessageParts.js.map +1 -1
  45. package/dist/primitives/message/MessagePartsGrouped.d.ts +3 -3
  46. package/dist/primitives/message/MessagePartsGrouped.js +2 -2
  47. package/dist/primitives/message/MessagePartsGrouped.js.map +1 -1
  48. package/dist/primitives/messagePart/MessagePartText.d.ts +2 -0
  49. package/dist/primitives/messagePart/MessagePartText.d.ts.map +1 -1
  50. package/dist/primitives/messagePart/MessagePartText.js +13 -36
  51. package/dist/primitives/messagePart/MessagePartText.js.map +1 -1
  52. package/dist/primitives/reasoning/useScrollLock.js +3 -1
  53. package/dist/primitives/reasoning/useScrollLock.js.map +1 -1
  54. package/dist/primitives/thread/useThreadViewportAutoScroll.d.ts.map +1 -1
  55. package/dist/primitives/thread/useThreadViewportAutoScroll.js +3 -3
  56. package/dist/primitives/thread/useThreadViewportAutoScroll.js.map +1 -1
  57. package/dist/sandbox-host/SandboxHost.d.ts.map +1 -1
  58. package/dist/sandbox-host/SandboxHost.js +36 -9
  59. package/dist/sandbox-host/SandboxHost.js.map +1 -1
  60. package/dist/unstable/useLiveCompletionAdapter.js +24 -2
  61. package/dist/unstable/useLiveCompletionAdapter.js.map +1 -1
  62. package/dist/unstable/useMentionAdapter.d.ts +9 -3
  63. package/dist/unstable/useMentionAdapter.d.ts.map +1 -1
  64. package/dist/unstable/useMentionAdapter.js +50 -43
  65. package/dist/unstable/useMentionAdapter.js.map +1 -1
  66. package/dist/unstable/useMessageStallDetection.d.ts +4 -5
  67. package/dist/unstable/useMessageStallDetection.d.ts.map +1 -1
  68. package/dist/unstable/useMessageStallDetection.js +16 -15
  69. package/dist/unstable/useMessageStallDetection.js.map +1 -1
  70. package/dist/unstable/useModelContextSnapshot.d.ts +34 -0
  71. package/dist/unstable/useModelContextSnapshot.d.ts.map +1 -0
  72. package/dist/unstable/useModelContextSnapshot.js +61 -0
  73. package/dist/unstable/useModelContextSnapshot.js.map +1 -0
  74. package/dist/unstable/useSlashCommandAdapter.d.ts.map +1 -1
  75. package/dist/unstable/useSlashCommandAdapter.js +2 -8
  76. package/dist/unstable/useSlashCommandAdapter.js.map +1 -1
  77. package/dist/unstable/webmcp/WebMcpRegistrationResource.d.ts +14 -0
  78. package/dist/unstable/webmcp/WebMcpRegistrationResource.d.ts.map +1 -0
  79. package/dist/unstable/webmcp/WebMcpRegistrationResource.js +85 -0
  80. package/dist/unstable/webmcp/WebMcpRegistrationResource.js.map +1 -0
  81. package/dist/unstable/webmcp/convertTools.d.ts +21 -0
  82. package/dist/unstable/webmcp/convertTools.d.ts.map +1 -0
  83. package/dist/unstable/webmcp/convertTools.js +161 -0
  84. package/dist/unstable/webmcp/convertTools.js.map +1 -0
  85. package/dist/unstable/webmcp/useWebMcpProvider.d.ts +21 -0
  86. package/dist/unstable/webmcp/useWebMcpProvider.d.ts.map +1 -0
  87. package/dist/unstable/webmcp/useWebMcpProvider.js +106 -0
  88. package/dist/unstable/webmcp/useWebMcpProvider.js.map +1 -0
  89. package/dist/unstable/webmcp/webmcp-host.d.ts +39 -0
  90. package/dist/unstable/webmcp/webmcp-host.d.ts.map +1 -0
  91. package/dist/unstable/webmcp/webmcp-host.js +29 -0
  92. package/dist/unstable/webmcp/webmcp-host.js.map +1 -0
  93. package/dist/utils/Primitive.d.ts +10 -2
  94. package/dist/utils/Primitive.d.ts.map +1 -1
  95. package/dist/utils/Primitive.js +25 -6
  96. package/dist/utils/Primitive.js.map +1 -1
  97. package/dist/utils/getSelectionMessageId.d.ts.map +1 -1
  98. package/dist/utils/getSelectionMessageId.js +17 -3
  99. package/dist/utils/getSelectionMessageId.js.map +1 -1
  100. package/package.json +13 -13
  101. package/src/context/providers/ThreadViewportProvider.tsx +2 -2
  102. package/src/hooks/useToolCallElapsed.ts +3 -0
  103. package/src/index.ts +14 -1
  104. package/src/mcp-apps/McpAppRenderer.test.tsx +253 -5
  105. package/src/mcp-apps/McpAppRenderer.tsx +147 -52
  106. package/src/mcp-apps/app-frame.test.tsx +437 -1
  107. package/src/mcp-apps/app-frame.tsx +76 -21
  108. package/src/mcp-apps/bridge.test.ts +98 -0
  109. package/src/mcp-apps/bridge.ts +14 -3
  110. package/src/mcp-apps/index.ts +5 -1
  111. package/src/primitives/assistantModal/AssistantModalRoot.test.tsx +123 -0
  112. package/src/primitives/assistantModal/AssistantModalRoot.tsx +11 -31
  113. package/src/primitives/assistantModal/AssistantModalTrigger.test.tsx +1 -16
  114. package/src/primitives/composer/ComposerAttachmentDropzone.test.tsx +43 -0
  115. package/src/primitives/composer/ComposerAttachmentDropzone.tsx +2 -10
  116. package/src/primitives/composer/ComposerInput.test.tsx +22 -0
  117. package/src/primitives/composer/ComposerInput.tsx +5 -9
  118. package/src/primitives/composer/ComposerInputPluginContext.tsx +0 -10
  119. package/src/primitives/composer/trigger/TriggerPopoverAction.tsx +4 -2
  120. package/src/primitives/composer/trigger/TriggerPopoverBehavior.test.tsx +109 -0
  121. package/src/primitives/composer/trigger/TriggerPopoverDirective.tsx +4 -2
  122. package/src/primitives/composer/trigger/matchesTriggerItemQuery.test.ts +52 -0
  123. package/src/primitives/composer/trigger/matchesTriggerItemQuery.ts +13 -0
  124. package/src/primitives/composer/trigger/triggerKeyboardResource.ts +13 -7
  125. package/src/primitives/composer/trigger/triggerNavigationResource.ts +8 -13
  126. package/src/primitives/message/MessageParts.tsx +1 -21
  127. package/src/primitives/message/MessagePartsGrouped.test.tsx +93 -0
  128. package/src/primitives/message/MessagePartsGrouped.tsx +6 -6
  129. package/src/primitives/messagePart/MessagePartText.test.tsx +69 -0
  130. package/src/primitives/messagePart/MessagePartText.tsx +22 -9
  131. package/src/primitives/reasoning/useScrollLock.test.tsx +129 -0
  132. package/src/primitives/reasoning/useScrollLock.ts +15 -1
  133. package/src/primitives/thread/useThreadViewportAutoScroll.test.tsx +38 -1
  134. package/src/primitives/thread/useThreadViewportAutoScroll.ts +7 -6
  135. package/src/sandbox-host/SandboxHost.test.tsx +160 -2
  136. package/src/sandbox-host/SandboxHost.tsx +50 -10
  137. package/src/tests/MessageParts.rendererOptions.test.tsx +117 -0
  138. package/src/tests/toolCallTiming.test.tsx +55 -1
  139. package/src/unstable/useComposerInputHistory.test.tsx +2 -1
  140. package/src/unstable/useLiveCompletionAdapter.test.tsx +202 -1
  141. package/src/unstable/useLiveCompletionAdapter.ts +38 -8
  142. package/src/unstable/useMentionAdapter.test.tsx +383 -0
  143. package/src/unstable/useMentionAdapter.ts +94 -57
  144. package/src/unstable/useMessageStallDetection.ts +26 -19
  145. package/src/unstable/useModelContextSnapshot.test.tsx +132 -0
  146. package/src/unstable/useModelContextSnapshot.ts +67 -0
  147. package/src/unstable/useSlashCommandAdapter.ts +2 -9
  148. package/src/unstable/webmcp/WebMcpRegistrationResource.ts +92 -0
  149. package/src/unstable/webmcp/__tests__/webmcp.fake.tsx +168 -0
  150. package/src/unstable/webmcp/convertTools.test.ts +553 -0
  151. package/src/unstable/webmcp/convertTools.ts +260 -0
  152. package/src/unstable/webmcp/useWebMcpProvider.test.tsx +466 -0
  153. package/src/unstable/webmcp/useWebMcpProvider.ts +148 -0
  154. package/src/unstable/webmcp/webmcp-host.test.ts +142 -0
  155. package/src/unstable/webmcp/webmcp-host.ts +83 -0
  156. package/src/utils/Primitive.test.tsx +46 -1
  157. package/src/utils/Primitive.tsx +37 -7
  158. package/src/utils/getSelectionMessageId.test.ts +176 -1
  159. package/src/utils/getSelectionMessageId.ts +21 -3
  160. package/dist/utils/invokeCallbackSafely.d.ts +0 -5
  161. package/dist/utils/invokeCallbackSafely.d.ts.map +0 -1
  162. package/dist/utils/invokeCallbackSafely.js +0 -15
  163. package/dist/utils/invokeCallbackSafely.js.map +0 -1
  164. package/dist/utils/useToolArgsFieldStatus.d.ts +0 -19
  165. package/dist/utils/useToolArgsFieldStatus.d.ts.map +0 -1
  166. package/dist/utils/useToolArgsFieldStatus.js +0 -23
  167. package/dist/utils/useToolArgsFieldStatus.js.map +0 -1
  168. package/src/utils/invokeCallbackSafely.ts +0 -11
  169. package/src/utils/useToolArgsFieldStatus.ts +0 -18
@@ -0,0 +1,129 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { renderHook } from "@testing-library/react";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { useScrollLock } from "./useScrollLock";
6
+
7
+ afterEach(() => {
8
+ vi.useRealTimers();
9
+ vi.unstubAllGlobals();
10
+ document.documentElement.removeAttribute("style");
11
+ document.body.removeAttribute("style");
12
+ document.body.replaceChildren();
13
+ });
14
+
15
+ /** jsdom reports zero for every box, so the widths a real browser would measure
16
+ * are stubbed on the element under test. */
17
+ function stubWidths(
18
+ element: HTMLElement,
19
+ { offsetWidth, clientWidth }: { offsetWidth: number; clientWidth: number },
20
+ ) {
21
+ Object.defineProperty(element, "offsetWidth", {
22
+ configurable: true,
23
+ value: offsetWidth,
24
+ });
25
+ Object.defineProperty(element, "clientWidth", {
26
+ configurable: true,
27
+ value: clientWidth,
28
+ });
29
+ }
30
+
31
+ function lockWithin(container: HTMLElement) {
32
+ const animated = document.createElement("div");
33
+ container.appendChild(animated);
34
+ const ref = { current: animated };
35
+ const { result } = renderHook(() => useScrollLock(ref, 200));
36
+ return result.current;
37
+ }
38
+
39
+ describe("useScrollLock", () => {
40
+ it("compensates for the scrollbar it hides on an element scroller", () => {
41
+ const scroller = document.createElement("div");
42
+ scroller.style.overflowY = "auto";
43
+ scroller.style.borderWidth = "0px";
44
+ scroller.style.paddingRight = "4px";
45
+ document.body.appendChild(scroller);
46
+ stubWidths(scroller, { offsetWidth: 306, clientWidth: 300 });
47
+
48
+ lockWithin(scroller)();
49
+
50
+ expect(scroller.style.scrollbarWidth).toBe("none");
51
+ expect(scroller.style.paddingRight).toBe("10px");
52
+ });
53
+
54
+ // The root element's offsetWidth already excludes the viewport scrollbar, so
55
+ // the element formula reports zero for it; without this the page's centered
56
+ // content shifts sideways for the length of the animation.
57
+ it("compensates when the scroller is the root element", () => {
58
+ const root = document.documentElement;
59
+ root.style.overflowY = "auto";
60
+ stubWidths(root, { offsetWidth: 1599, clientWidth: 1599 });
61
+ vi.stubGlobal("innerWidth", 1605);
62
+
63
+ lockWithin(document.body)();
64
+
65
+ expect(root.style.scrollbarWidth).toBe("none");
66
+ expect(root.style.paddingRight).toBe("6px");
67
+ });
68
+
69
+ // With `html { overflow: hidden }` the body scrolls on its own, so the
70
+ // viewport measure reports nothing and only the element formula sees the bar.
71
+ it("compensates when the body is the scroller rather than the viewport", () => {
72
+ const root = document.documentElement;
73
+ stubWidths(root, { offsetWidth: 1600, clientWidth: 1600 });
74
+ vi.stubGlobal("innerWidth", 1600);
75
+ const body = document.body;
76
+ body.style.overflowY = "auto";
77
+ body.style.borderWidth = "0px";
78
+ stubWidths(body, { offsetWidth: 1606, clientWidth: 1600 });
79
+
80
+ lockWithin(body)();
81
+
82
+ expect(body.style.paddingRight).toBe("6px");
83
+ });
84
+
85
+ // Both can scroll at once, and the padding lands on the body, so it is the
86
+ // body's own gutter that has to be replaced rather than the viewport's.
87
+ it("pads a scrolling body by its own gutter, not the viewport's", () => {
88
+ const root = document.documentElement;
89
+ stubWidths(root, { offsetWidth: 1585, clientWidth: 1585 });
90
+ vi.stubGlobal("innerWidth", 1600);
91
+ const body = document.body;
92
+ body.style.overflowY = "auto";
93
+ body.style.borderWidth = "0px";
94
+ stubWidths(body, { offsetWidth: 1591, clientWidth: 1585 });
95
+
96
+ lockWithin(body)();
97
+
98
+ expect(body.style.paddingRight).toBe("6px");
99
+ });
100
+
101
+ it("restores the padding it added once the animation is over", () => {
102
+ vi.useFakeTimers();
103
+ const scroller = document.createElement("div");
104
+ scroller.style.overflowY = "auto";
105
+ scroller.style.borderWidth = "0px";
106
+ document.body.appendChild(scroller);
107
+ stubWidths(scroller, { offsetWidth: 306, clientWidth: 300 });
108
+
109
+ lockWithin(scroller)();
110
+ expect(scroller.style.paddingRight).toBe("6px");
111
+
112
+ vi.advanceTimersByTime(200);
113
+
114
+ expect(scroller.style.paddingRight).toBe("");
115
+ expect(scroller.style.scrollbarWidth).toBe("");
116
+ });
117
+
118
+ it("leaves the padding alone when no scrollbar takes space", () => {
119
+ const scroller = document.createElement("div");
120
+ scroller.style.overflowY = "auto";
121
+ scroller.style.borderWidth = "0px";
122
+ document.body.appendChild(scroller);
123
+ stubWidths(scroller, { offsetWidth: 300, clientWidth: 300 });
124
+
125
+ lockWithin(scroller)();
126
+
127
+ expect(scroller.style.paddingRight).toBe("");
128
+ });
129
+ });
@@ -71,11 +71,25 @@ export const useScrollLock = <T extends HTMLElement = HTMLElement>(
71
71
  const paddingSide =
72
72
  computed.direction === "rtl" ? "paddingLeft" : "paddingRight";
73
73
  const previousPadding = scrollContainer.style[paddingSide];
74
- const scrollbarSize =
74
+ const elementScrollbarSize =
75
75
  scrollContainer.offsetWidth -
76
76
  scrollContainer.clientWidth -
77
77
  parseFloat(computed.borderLeftWidth) -
78
78
  parseFloat(computed.borderRightWidth);
79
+ // A root element's offsetWidth already excludes the viewport scrollbar, so
80
+ // the element formula reports zero once the scroll propagates to the
81
+ // viewport, and only then does the viewport measure apply. A body that
82
+ // scrolls in its own right is measured by the element formula like any
83
+ // other scroller, whether or not the viewport is scrolling too.
84
+ const ownerDocument = scrollContainer.ownerDocument;
85
+ const isRootScroller =
86
+ scrollContainer === ownerDocument.documentElement ||
87
+ scrollContainer === ownerDocument.body;
88
+ const scrollbarSize =
89
+ isRootScroller && elementScrollbarSize <= 0
90
+ ? (ownerDocument.defaultView?.innerWidth ?? 0) -
91
+ ownerDocument.documentElement.clientWidth
92
+ : elementScrollbarSize;
79
93
 
80
94
  scrollContainer.style.scrollbarWidth = "none";
81
95
  if (scrollbarSize > 0) {
@@ -1,6 +1,13 @@
1
1
  // @vitest-environment jsdom
2
2
 
3
- import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
3
+ import {
4
+ act,
5
+ cleanup,
6
+ fireEvent,
7
+ render,
8
+ screen,
9
+ waitFor,
10
+ } from "@testing-library/react";
4
11
  import {
5
12
  afterAll,
6
13
  afterEach,
@@ -17,6 +24,7 @@ import { useThreadViewport } from "../../context/react/ThreadViewportContext";
17
24
  import * as MessagePrimitive from "../message";
18
25
  import { ThreadPrimitiveMessages } from "./ThreadMessages";
19
26
  import { ThreadPrimitiveRoot } from "./ThreadRoot";
27
+ import { ThreadPrimitiveScrollToBottom } from "./ThreadScrollToBottom";
20
28
  import { ThreadPrimitiveViewport } from "./ThreadViewport";
21
29
  import {
22
30
  ExportedMessageRepository,
@@ -242,6 +250,35 @@ const DelayedThread = ({
242
250
  };
243
251
 
244
252
  describe("useThreadViewportAutoScroll", () => {
253
+ it("preserves smooth scrolling from a control outside the viewport", async () => {
254
+ render(
255
+ <SyncRuntimeProvider>
256
+ <Thread autoScroll={false} scrollToBottomOnInitialize={false} />
257
+ <ThreadPrimitiveScrollToBottom behavior="smooth">
258
+ Scroll to bottom
259
+ </ThreadPrimitiveScrollToBottom>
260
+ </SyncRuntimeProvider>,
261
+ );
262
+
263
+ const viewport = getViewport();
264
+ act(() => {
265
+ viewport.dispatchEvent(new Event("scroll"));
266
+ });
267
+ const button = screen.getByRole("button", { name: "Scroll to bottom" });
268
+ await waitFor(() => expect(button.hasAttribute("disabled")).toBe(false));
269
+
270
+ const scrollToSpy = vi.spyOn(viewport, "scrollTo");
271
+ try {
272
+ fireEvent.click(button);
273
+ expect(scrollToSpy).toHaveBeenCalledWith({
274
+ top: viewport.scrollHeight,
275
+ behavior: "smooth",
276
+ });
277
+ } finally {
278
+ scrollToSpy.mockRestore();
279
+ }
280
+ });
281
+
245
282
  it("scrolls sync initialMessages to the bottom when the viewport mounts after initialization", async () => {
246
283
  render(
247
284
  <SyncRuntimeProvider>
@@ -3,7 +3,11 @@
3
3
  import { useComposedRefs } from "radix-ui/internal";
4
4
  import { useCallback, useLayoutEffect, useRef, type RefCallback } from "react";
5
5
  import { useAuiEvent, useAuiState } from "@assistant-ui/store";
6
- import { isUserScrollUp } from "@assistant-ui/store/client";
6
+ import {
7
+ isUserScrollUp,
8
+ isViewportAtBottom,
9
+ viewportOverflows,
10
+ } from "@assistant-ui/store/client";
7
11
  import { useOnResizeContent } from "../../utils/hooks/useOnResizeContent";
8
12
  import { useOnScrollToBottom } from "../../utils/hooks/useOnScrollToBottom";
9
13
  import { useManagedRef } from "../../utils/hooks/useManagedRef";
@@ -114,9 +118,7 @@ export const useThreadViewportAutoScroll = <TElement extends HTMLElement>({
114
118
  if (!div) return;
115
119
 
116
120
  const isAtBottom = threadViewportStore.getState().isAtBottom;
117
- const newIsAtBottom =
118
- Math.abs(div.scrollHeight - div.scrollTop - div.clientHeight) <= 1 ||
119
- div.scrollHeight <= div.clientHeight;
121
+ const newIsAtBottom = isViewportAtBottom(div);
120
122
 
121
123
  const isInFlightDownwardScroll =
122
124
  !newIsAtBottom && lastScrollTop.current < div.scrollTop;
@@ -135,8 +137,7 @@ export const useThreadViewportAutoScroll = <TElement extends HTMLElement>({
135
137
  if (newIsAtBottom) {
136
138
  // newIsAtBottom is ambiguous when the viewport doesn't overflow —
137
139
  // keep intent alive until content can actually scroll
138
- const viewportOverflows = div.scrollHeight > div.clientHeight + 1;
139
- if (viewportOverflows) {
140
+ if (viewportOverflows(div)) {
140
141
  scrollingToBottomBehaviorRef.current = null;
141
142
  }
142
143
  if (autoScroll) followBottomRef.current = true;
@@ -5,7 +5,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
5
 
6
6
  const { renderHtmlMock } = vi.hoisted(() => ({ renderHtmlMock: vi.fn() }));
7
7
 
8
- vi.mock("safe-content-frame", () => ({
8
+ vi.mock("safe-content-frame", async (importOriginal) => ({
9
+ ...(await importOriginal<typeof import("safe-content-frame")>()),
9
10
  SafeContentFrame: class {
10
11
  renderHtml = renderHtmlMock;
11
12
  },
@@ -34,7 +35,7 @@ function fakeRendered() {
34
35
  origin: "https://fake.scf.test",
35
36
  sendMessage: vi.fn(),
36
37
  dispose: vi.fn(),
37
- fullyLoadedPromiseWithTimeout: vi.fn(),
38
+ fullyLoadedPromiseWithTimeout: vi.fn(() => new Promise<void>(() => {})),
38
39
  };
39
40
  }
40
41
 
@@ -140,6 +141,123 @@ describe("SandboxHost", () => {
140
141
  expect(onMessage).toHaveBeenCalledTimes(1);
141
142
  });
142
143
 
144
+ it("reports a frame that never finishes loading through onError", async () => {
145
+ const rendered = fakeRendered();
146
+ rendered.fullyLoadedPromiseWithTimeout.mockImplementation(() =>
147
+ Promise.reject(
148
+ Object.assign(new Error("Failed to load shim: https://fake.scf.test"), {
149
+ code: "shim-unavailable",
150
+ }),
151
+ ),
152
+ );
153
+ renderHtmlMock.mockResolvedValue(rendered);
154
+ const onError = vi.fn();
155
+
156
+ await act(async () => {
157
+ root.render(
158
+ <SandboxHost
159
+ content={{ html: "" }}
160
+ contentKey="k"
161
+ createBridge={() => ({ onMessage: vi.fn(), dispose: vi.fn() })}
162
+ onError={onError}
163
+ />,
164
+ );
165
+ });
166
+ await flush();
167
+
168
+ expect(onError).toHaveBeenCalledTimes(1);
169
+ expect(onError.mock.calls[0]![0]).toBeInstanceOf(Error);
170
+ expect(onError.mock.calls[0]![0]).toMatchObject({
171
+ code: "shim-unavailable",
172
+ message: "Failed to load shim: https://fake.scf.test",
173
+ });
174
+ expect(rendered.dispose).not.toHaveBeenCalled();
175
+ });
176
+
177
+ it("stays silent when the shim started and the render is merely slow", async () => {
178
+ const rendered = fakeRendered();
179
+ rendered.fullyLoadedPromiseWithTimeout.mockImplementation(() =>
180
+ Promise.reject(
181
+ Object.assign(new Error("Timeout"), { code: "render-timeout" }),
182
+ ),
183
+ );
184
+ renderHtmlMock.mockResolvedValue(rendered);
185
+ const onError = vi.fn();
186
+
187
+ await act(async () => {
188
+ root.render(
189
+ <SandboxHost
190
+ content={{ html: "" }}
191
+ contentKey="k"
192
+ createBridge={() => ({ onMessage: vi.fn(), dispose: vi.fn() })}
193
+ onError={onError}
194
+ />,
195
+ );
196
+ });
197
+ await flush();
198
+
199
+ expect(rendered.fullyLoadedPromiseWithTimeout).toHaveBeenCalled();
200
+ expect(onError).not.toHaveBeenCalled();
201
+ expect(rendered.dispose).not.toHaveBeenCalled();
202
+ });
203
+
204
+ it("reports a load failure that carries no shim code", async () => {
205
+ const rendered = fakeRendered();
206
+ rendered.fullyLoadedPromiseWithTimeout.mockImplementation(() =>
207
+ Promise.reject(new Error("Failed to load iframe")),
208
+ );
209
+ renderHtmlMock.mockResolvedValue(rendered);
210
+ const onError = vi.fn();
211
+
212
+ await act(async () => {
213
+ root.render(
214
+ <SandboxHost
215
+ content={{ html: "" }}
216
+ contentKey="k"
217
+ createBridge={() => ({ onMessage: vi.fn(), dispose: vi.fn() })}
218
+ onError={onError}
219
+ />,
220
+ );
221
+ });
222
+ await flush();
223
+
224
+ expect(onError).toHaveBeenCalledTimes(1);
225
+ expect(onError.mock.calls[0]![0].message).toBe("Failed to load iframe");
226
+ });
227
+
228
+ it("does not report a load failure after unmount", async () => {
229
+ const rendered = fakeRendered();
230
+ let rejectLoad: (error: Error) => void;
231
+ rendered.fullyLoadedPromiseWithTimeout.mockReturnValue(
232
+ new Promise<void>((_, reject) => {
233
+ rejectLoad = reject;
234
+ }),
235
+ );
236
+ renderHtmlMock.mockResolvedValue(rendered);
237
+ const onError = vi.fn();
238
+
239
+ await act(async () => {
240
+ root.render(
241
+ <SandboxHost
242
+ content={{ html: "" }}
243
+ contentKey="k"
244
+ createBridge={() => ({ onMessage: vi.fn(), dispose: vi.fn() })}
245
+ onError={onError}
246
+ />,
247
+ );
248
+ });
249
+ await flush();
250
+
251
+ await act(async () => {
252
+ root.unmount();
253
+ });
254
+ rejectLoad!(new Error("Timeout"));
255
+ await flush();
256
+
257
+ expect(rendered.fullyLoadedPromiseWithTimeout).toHaveBeenCalled();
258
+ expect(onError).not.toHaveBeenCalled();
259
+ });
260
+
143
261
  it("clamps the bridge-reported height to maxHeight and ignores invalid values", async () => {
144
262
  const rendered = fakeRendered();
145
263
  renderHtmlMock.mockResolvedValue(rendered);
@@ -209,6 +327,46 @@ describe("SandboxHost", () => {
209
327
  expect(onMessage).not.toHaveBeenCalled();
210
328
  });
211
329
 
330
+ it("disposes the frame when bridge cleanup throws", async () => {
331
+ const rendered = fakeRendered();
332
+ renderHtmlMock.mockResolvedValue(rendered);
333
+ const cleanupError = new Error("bridge cleanup failed");
334
+ const onMessage = vi.fn();
335
+ const bridge: SandboxBridge = {
336
+ onMessage,
337
+ dispose: vi.fn(() => {
338
+ throw cleanupError;
339
+ }),
340
+ };
341
+
342
+ await act(async () => {
343
+ root.render(
344
+ <SandboxHost
345
+ content={{ html: "" }}
346
+ contentKey="k"
347
+ createBridge={() => bridge}
348
+ />,
349
+ );
350
+ });
351
+ await flush();
352
+
353
+ expect(() => {
354
+ act(() => root.unmount());
355
+ }).toThrow(cleanupError);
356
+
357
+ expect(bridge.dispose).toHaveBeenCalledOnce();
358
+ expect(rendered.dispose).toHaveBeenCalledOnce();
359
+
360
+ window.dispatchEvent(
361
+ new MessageEvent("message", {
362
+ data: validData,
363
+ origin: rendered.origin,
364
+ source: rendered.iframe.contentWindow,
365
+ }),
366
+ );
367
+ expect(onMessage).not.toHaveBeenCalled();
368
+ });
369
+
212
370
  it("calls onError when rendering rejects", async () => {
213
371
  renderHtmlMock.mockRejectedValue(new Error("boom"));
214
372
  const onError = vi.fn();
@@ -8,14 +8,16 @@ import {
8
8
  useState,
9
9
  } from "react";
10
10
  import {
11
+ isShimLoadError,
11
12
  type RenderedFrame,
12
13
  SafeContentFrame,
13
14
  type SandboxOption,
14
15
  } from "safe-content-frame";
15
- import { invokeCallbackSafely } from "../utils/invokeCallbackSafely";
16
+ import { invokeUserCallback } from "@assistant-ui/core/internal";
16
17
 
17
18
  const DEFAULT_PRODUCT = "assistant-ui-sandbox";
18
19
  const DEFAULT_MAX_HEIGHT = 800;
20
+ const LOAD_TIMEOUT_MS = 10_000;
19
21
 
20
22
  export type SandboxHostConfig = {
21
23
  sandbox?: SandboxOption[];
@@ -108,6 +110,16 @@ export function SandboxHost({
108
110
 
109
111
  const { content: liveContent, sandbox: sb } = liveRef.current;
110
112
 
113
+ const reportError = (err: unknown) => {
114
+ const error = err instanceof Error ? err : new Error(String(err));
115
+ invokeUserCallback(
116
+ "assistant-ui",
117
+ "SandboxHost onError",
118
+ liveRef.current.onError?.bind(liveRef.current),
119
+ error,
120
+ );
121
+ };
122
+
111
123
  const scf = new SafeContentFrame(sb?.product ?? DEFAULT_PRODUCT, {
112
124
  ...(sb?.sandbox !== undefined && { sandbox: sb.sandbox }),
113
125
  ...(sb?.useShadowDom !== undefined && { useShadowDom: sb.useShadowDom }),
@@ -159,29 +171,57 @@ export function SandboxHost({
159
171
  bridge?.onMessage(event);
160
172
  };
161
173
  window.addEventListener("message", onMessage);
174
+
175
+ // renderHtml resolves at iframe load, which a shim that was never
176
+ // served also reaches, so a completed render is only known once the
177
+ // frame says so. render-timeout is excluded because the shim is
178
+ // running and the guest may still paint, and reporting it would leave
179
+ // a host that renders an error state showing one over a frame that
180
+ // recovers.
181
+ rendered.fullyLoadedPromiseWithTimeout(LOAD_TIMEOUT_MS).catch((err) => {
182
+ if (cancelled) return;
183
+ if (isShimLoadError(err) && err.code === "render-timeout") return;
184
+ reportError(err);
185
+ });
162
186
  })
163
187
  .catch((err) => {
164
188
  if (cancelled) return;
165
189
  frame?.dispose();
166
190
  frame = null;
167
- const error = err instanceof Error ? err : new Error(String(err));
168
- invokeCallbackSafely(
169
- () => liveRef.current.onError?.(error),
170
- "SandboxHost onError",
171
- );
191
+ reportError(err);
172
192
  });
173
193
 
174
194
  return () => {
175
195
  cancelled = true;
196
+ let cleanupFailed = false;
197
+ let cleanupError: unknown;
198
+ const runCleanup = (cleanup: () => void) => {
199
+ try {
200
+ cleanup();
201
+ } catch (error) {
202
+ if (cleanupFailed) {
203
+ console.error(error);
204
+ } else {
205
+ cleanupFailed = true;
206
+ cleanupError = error;
207
+ }
208
+ }
209
+ };
210
+
176
211
  if (onMessage) {
177
- window.removeEventListener("message", onMessage);
212
+ const listener = onMessage;
178
213
  onMessage = null;
214
+ runCleanup(() => window.removeEventListener("message", listener));
179
215
  }
180
- bridge?.dispose();
216
+ const bridgeToDispose = bridge;
181
217
  bridge = null;
182
- frame?.dispose();
218
+ if (bridgeToDispose) runCleanup(() => bridgeToDispose.dispose());
219
+ const frameToDispose = frame;
183
220
  frame = null;
184
- setContentHeight(undefined);
221
+ if (frameToDispose) runCleanup(() => frameToDispose.dispose());
222
+ runCleanup(() => setContentHeight(undefined));
223
+
224
+ if (cleanupFailed) throw cleanupError;
185
225
  };
186
226
  // oxlint-disable-next-line react/exhaustive-deps -- re-init only on contentKey change; live values flow through liveRef
187
227
  }, [contentKey]);
@@ -0,0 +1,117 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { act } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
6
+ import {
7
+ AssistantRuntimeProvider,
8
+ MessageByIndexProvider,
9
+ useExternalStoreRuntime,
10
+ } from "@assistant-ui/core/react";
11
+ import type { ThreadMessageLike } from "@assistant-ui/core";
12
+ import { MessagePrimitiveParts } from "../primitives/message/MessageParts";
13
+
14
+ (globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
15
+
16
+ const messages: ThreadMessageLike[] = [
17
+ {
18
+ role: "assistant",
19
+ content: [
20
+ { type: "text", text: "Answer" },
21
+ { type: "reasoning", text: "Thinking" },
22
+ {
23
+ type: "generative-ui",
24
+ spec: {
25
+ root: [
26
+ { component: "Card", props: { title: "Result" } },
27
+ { component: "Unknown" },
28
+ ],
29
+ },
30
+ },
31
+ { type: "data", name: "chart", data: 42 },
32
+ { type: "data", name: "other", data: 7 },
33
+ ],
34
+ },
35
+ ];
36
+
37
+ const components = {
38
+ generativeUI: {
39
+ components: {
40
+ Card: ({ title }: { title: string }) => <span>{title}</span>,
41
+ },
42
+ Fallback: ({ component }: { component: string }) => (
43
+ <span>Unavailable: {component}</span>
44
+ ),
45
+ },
46
+ data: {
47
+ by_name: {
48
+ chart: ({ data }: { data: unknown }) => (
49
+ <span>Chart: {String(data)}</span>
50
+ ),
51
+ },
52
+ Fallback: ({ data }: { data: unknown }) => (
53
+ <span>Data: {String(data)}</span>
54
+ ),
55
+ },
56
+ };
57
+
58
+ const App = (props: MessagePrimitiveParts.Props) => {
59
+ const runtime = useExternalStoreRuntime({
60
+ messages,
61
+ convertMessage: (message) => message,
62
+ onNew: async () => {
63
+ throw new Error("This thread is read-only");
64
+ },
65
+ });
66
+ return (
67
+ <AssistantRuntimeProvider runtime={runtime}>
68
+ <MessageByIndexProvider index={0}>
69
+ <MessagePrimitiveParts {...props} />
70
+ </MessageByIndexProvider>
71
+ </AssistantRuntimeProvider>
72
+ );
73
+ };
74
+
75
+ const ChainOfThought = () => <span>Thought group</span>;
76
+
77
+ describe("MessagePrimitiveParts", () => {
78
+ let container: HTMLDivElement;
79
+ let root: Root;
80
+
81
+ beforeEach(() => {
82
+ container = document.createElement("div");
83
+ document.body.appendChild(container);
84
+ root = createRoot(container);
85
+ });
86
+
87
+ afterEach(async () => {
88
+ await act(async () => root.unmount());
89
+ container.remove();
90
+ });
91
+
92
+ it("renders generative UI and its fallback beside web text", async () => {
93
+ await act(async () => root.render(<App components={components} />));
94
+ expect(container.textContent).toBe(
95
+ "AnswerResultUnavailable: UnknownChart: 42Data: 7",
96
+ );
97
+ });
98
+
99
+ it("keeps data and generative UI when chain-of-thought grouping changes", async () => {
100
+ await act(async () => root.render(<App components={components} />));
101
+ expect(container.textContent).toBe(
102
+ "AnswerResultUnavailable: UnknownChart: 42Data: 7",
103
+ );
104
+
105
+ await act(async () =>
106
+ root.render(<App components={{ ...components, ChainOfThought }} />),
107
+ );
108
+ expect(container.textContent).toBe(
109
+ "AnswerThought groupResultUnavailable: UnknownChart: 42Data: 7",
110
+ );
111
+
112
+ await act(async () => root.render(<App components={components} />));
113
+ expect(container.textContent).toBe(
114
+ "AnswerResultUnavailable: UnknownChart: 42Data: 7",
115
+ );
116
+ });
117
+ });